1 Commits
Author SHA1 Message Date
clawbot c149de1d9a Add the ProtonVPN server networks and addresses to the IP lookup (closes #236)
check / check (push) Successful in 1m27s
Adds the VPN lists `protonvpn`, the servers' /24 networks, IPv4 only, and `protonvpn_entry`, the servers' IPv4 and IPv6 addresses. Each is downloaded once a day and shows in `flags.is_vpn` and `vpn_providers` with no other change.

Proton's `/vpn/logicals` answers 401 without a logged-in session, even with a current client's `x-pm-appversion`, so both come from the public ProtonVPN-IPs mirror. No request header is needed.

Judgement call: the mirror's /24 file as well as its exact addresses. Its README says traffic leaves from unpublished addresses in those networks. The cost is that neighbouring addresses are listed too.

Model: opus-5-5
2026-11-10 12:06:41 +00:00
6 changed files with 114 additions and 1 deletions

No files matched your search

+8
View File
@@ -434,6 +434,14 @@ The lists:
- `pia`: VPN servers run by Private Internet Access. From - `pia`: VPN servers run by Private Internet Access. From
[Private Internet Access](https://serverlist.piaservers.net/vpninfo/servers/v6), [Private Internet Access](https://serverlist.piaservers.net/vpninfo/servers/v6),
downloaded once a day. downloaded once a day.
- `protonvpn`: the /24 networks that hold ProtonVPN servers. The whole /24 is
listed because the ProtonVPN-IPs README says traffic leaves from unpublished
addresses in it. From the public
[ProtonVPN-IPs](https://github.com/tn3w/ProtonVPN-IPs) mirror of Proton's
server list, downloaded once a day.
- `protonvpn_entry`: the IPv4 and IPv6 addresses of ProtonVPN servers. From the
same [ProtonVPN-IPs](https://github.com/tn3w/ProtonVPN-IPs) mirror, downloaded
once a day.
- `spamhaus_drop` and `spamhaus_dropv6`: IPv4 and IPv6 networks hijacked or - `spamhaus_drop` and `spamhaus_dropv6`: IPv4 and IPv6 networks hijacked or
leased by spammers or cyber criminals, according to Spamhaus. From leased by spammers or cyber criminals, according to Spamhaus. From
[Spamhaus DROP](https://www.spamhaus.org/blocklists/do-not-route-or-peer/), [Spamhaus DROP](https://www.spamhaus.org/blocklists/do-not-route-or-peer/),
+3
View File
@@ -53,6 +53,9 @@ problem the run finds.
# Completed Steps # Completed Steps
- 2026-11-10: the IP lookup checks the `protonvpn` list of ProtonVPN's /24
networks, from a public mirror of Proton's server list (closes
https://git.eeqj.de/sneak/routewatch/issues/236).
- 2026-11-10: the tests no longer fail when a busy host stalls for several - 2026-11-10: the tests no longer fail when a busy host stalls for several
seconds. Most such waits are now 15 seconds. The test of the stream's idle seconds. Most such waits are now 15 seconds. The test of the stream's idle
timeout runs on a fake clock (closes #240) timeout runs on a fake clock (closes #240)
+5
View File
@@ -51,6 +51,11 @@ const (
// FormatIVPNJSON is IVPN's JSON server list. The hosts of its wireguard // FormatIVPNJSON is IVPN's JSON server list. The hosts of its wireguard
// and openvpn gateways have their addresses in host and v2ray. // and openvpn gateways have their addresses in host and v2ray.
FormatIVPNJSON FormatIVPNJSON
// FormatProtonVPN has one address or network per line, with comment lines
// that start with #. Unlike FormatLines, a file with no addresses or
// networks fails.
FormatProtonVPN
) )
// List is one public IP list. // List is one public IP list.
+56 -1
View File
@@ -70,6 +70,19 @@ const (
"config":{"api":{"ips":["192.0.2.73"]}}}` "config":{"api":{"ips":["192.0.2.73"]}}}`
) )
// protonVPNBody is a small file in the format of the ProtonVPN-IPs list.
const protonVPNBody = "#\n# protonvpn_ip_ranges.txt\n" +
"# https://github.com/tn3w/ProtonVPN-IPs\n#\n" +
"# The /24 block of every advertised IPv4.\n#\n" +
"192.0.2.0/24\n198.51.100.0/24\n"
// protonVPNEntryBody is a small file in the format of the ProtonVPN-IPs list
// of server addresses.
const protonVPNEntryBody = "#\n# protonvpn_entry_ips.txt\n" +
"# https://github.com/tn3w/ProtonVPN-IPs\n#\n" +
"# Entry IPs clients connect to, resolved from ProtonVPN hostnames.\n#\n" +
"192.0.2.80\n2001:db8::80\n"
// errorPage is a file that is no list, as a publisher's error page is. // errorPage is a file that is no list, as a publisher's error page is.
const errorPage = "<!DOCTYPE html>\n<html></html>\n" const errorPage = "<!DOCTYPE html>\n<html></html>\n"
@@ -267,6 +280,20 @@ func TestLookupFindsVPNServers(t *testing.T) {
listed: []string{"192.0.2.70", "192.0.2.71", "192.0.2.72"}, listed: []string{"192.0.2.70", "192.0.2.71", "192.0.2.72"},
unlisted: []string{"192.0.2.73", "2001:db8::70"}, unlisted: []string{"192.0.2.73", "2001:db8::70"},
}, },
{
name: "ProtonVPN networks",
format: FormatProtonVPN,
body: protonVPNBody,
listed: []string{"192.0.2.0", "192.0.2.255", "198.51.100.7"},
unlisted: []string{"192.0.3.1", "2001:db8::90"},
},
{
name: "ProtonVPN server addresses",
format: FormatProtonVPN,
body: protonVPNEntryBody,
listed: []string{"192.0.2.80", "2001:db8::80"},
unlisted: []string{"192.0.2.81", "2001:db8::81"},
},
}) })
} }
@@ -479,6 +506,31 @@ func TestWrongVPNDocumentFailsToParse(t *testing.T) {
} }
} }
// TestWrongProtonVPNDocumentFailsToParse parses documents that are not a
// ProtonVPN-IPs list. Each fails, so none can replace a good copy.
func TestWrongProtonVPNDocumentFailsToParse(t *testing.T) {
t.Parallel()
bodies := map[string]string{
"error page": errorPage,
"JSON": `{"status":"ok"}`,
"comments only": "#\n# protonvpn_ip_ranges.txt\n#\n",
"bad network": "192.0.2.0/24\nexample\n",
"an empty file": "",
}
for name, body := range bodies {
t.Run(name, func(t *testing.T) {
t.Parallel()
addresses, err := parse(strings.NewReader(body), FormatProtonVPN)
if err == nil {
t.Errorf("parse returned %v and no error", addresses.Ranges())
}
})
}
}
// TestLookupMarksVPNLists looks up an address on a VPN list and on another // TestLookupMarksVPNLists looks up an address on a VPN list and on another
// list. Only the VPN list's listing says it is one. // list. Only the VPN list's listing says it is one.
func TestLookupMarksVPNLists(t *testing.T) { func TestLookupMarksVPNLists(t *testing.T) {
@@ -523,7 +575,10 @@ func TestPublicListsMarkTheVPNLists(t *testing.T) {
} }
} }
want := []string{"ivpn", "mullvad", "nordvpn", "pia", "x4bnet", "x4bnet_v6"} want := []string{
"ivpn", "mullvad", "nordvpn", "pia", "protonvpn", "protonvpn_entry",
"x4bnet", "x4bnet_v6",
}
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
t.Errorf("VPN lists %v, want %v", got, want) t.Errorf("VPN lists %v, want %v", got, want)
} }
+24
View File
@@ -146,6 +146,30 @@ func vpnLists() []List {
Format: FormatPIAJSON, Format: FormatPIAJSON,
VPN: true, VPN: true,
}, },
{
// Proton's own server list needs a logged-in session, so this is
// the public ProtonVPN-IPs mirror of it. Proton publishes only
// the addresses clients connect to. The mirror's README says
// traffic leaves from others in the same /24, so this file of
// /24 networks is the one it says to use for detecting exits.
Name: "protonvpn",
Description: "In a /24 network that holds a server of the " +
"ProtonVPN service",
URL: "https://raw.githubusercontent.com/tn3w/ProtonVPN-IPs/master/" +
"protonvpn_ip_ranges.txt",
Interval: vpnListInterval,
Format: FormatProtonVPN,
VPN: true,
},
{
Name: "protonvpn_entry",
Description: "A server of the ProtonVPN service",
URL: "https://raw.githubusercontent.com/tn3w/ProtonVPN-IPs/master/" +
"protonvpn_entry_ips.txt",
Interval: vpnListInterval,
Format: FormatProtonVPN,
VPN: true,
},
{ {
Name: "x4bnet", Name: "x4bnet",
Description: "In an IPv4 network of a VPN service, according to " + Description: "In an IPv4 network of a VPN service, according to " +
+18
View File
@@ -58,6 +58,8 @@ func parse(r io.Reader, format Format) (*netipx.IPSet, error) {
return parsePIA(r) return parsePIA(r)
case FormatIVPNJSON: case FormatIVPNJSON:
return parseIVPN(r) return parseIVPN(r)
case FormatProtonVPN:
return parseProtonVPN(r)
default: default:
return nil, errUnknownFormat return nil, errUnknownFormat
} }
@@ -329,6 +331,22 @@ func parseIVPN(r io.Reader) (*netipx.IPSet, error) {
return addressSet(texts) return addressSet(texts)
} }
// parseProtonVPN returns the addresses or networks in a ProtonVPN-IPs list. A
// file with no addresses or networks fails, because the service always has
// servers.
func parseProtonVPN(r io.Reader) (*netipx.IPSet, error) {
addresses, err := parseLines(r, addAddressOrNetwork)
if err != nil {
return nil, err
}
if len(addresses.Ranges()) == 0 {
return nil, errNoAddresses
}
return addresses, nil
}
// readJSON reads r to its end and decodes it, as one JSON document, into v. // readJSON reads r to its end and decodes it, as one JSON document, into v.
func readJSON(r io.Reader, v any) error { func readJSON(r io.Reader, v any) error {
data, err := io.ReadAll(r) data, err := io.ReadAll(r)