From e2707b5e41e81bfe81268208011246964d9d508a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 10 Nov 2026 11:20:38 +0000 Subject: [PATCH] Add the ProtonVPN server networks and addresses to the IP lookup (closes #236) 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 --- README.md | 8 ++++ TODO.md | 4 ++ internal/iplist/iplist.go | 5 +++ internal/iplist/iplist_internal_test.go | 57 ++++++++++++++++++++++++- internal/iplist/lists.go | 25 +++++++++++ internal/iplist/parse.go | 18 ++++++++ 6 files changed, 116 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 85dad40..47eae62 100644 --- a/README.md +++ b/README.md @@ -431,6 +431,14 @@ The lists: - `pia`: VPN servers run by Private Internet Access. From [Private Internet Access](https://serverlist.piaservers.net/vpninfo/servers/v6), 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 leased by spammers or cyber criminals, according to Spamhaus. From [Spamhaus DROP](https://www.spamhaus.org/blocklists/do-not-route-or-peer/), diff --git a/TODO.md b/TODO.md index 1b5fc02..7cf65ff 100644 --- a/TODO.md +++ b/TODO.md @@ -53,6 +53,10 @@ problem the run finds. # Completed Steps +- 2026-11-10: the IP lookup checks the `protonvpn` list of ProtonVPN's /24 + networks and the `protonvpn_entry` list of its servers' IPv4 and IPv6 + addresses, from a public mirror of Proton's server list (closes + https://git.eeqj.de/sneak/routewatch/issues/236). - 2026-11-10: the IP lookup finds PTR names over DNS-over-HTTPS and caches them for the record's TTL, in memory and in `cache.db` (closes https://git.eeqj.de/sneak/routewatch/issues/238). diff --git a/internal/iplist/iplist.go b/internal/iplist/iplist.go index b2baea7..9795665 100644 --- a/internal/iplist/iplist.go +++ b/internal/iplist/iplist.go @@ -51,6 +51,11 @@ const ( // FormatIVPNJSON is IVPN's JSON server list. The hosts of its wireguard // and openvpn gateways have their addresses in host and v2ray. 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. diff --git a/internal/iplist/iplist_internal_test.go b/internal/iplist/iplist_internal_test.go index 663ea8b..3b7576b 100644 --- a/internal/iplist/iplist_internal_test.go +++ b/internal/iplist/iplist_internal_test.go @@ -70,6 +70,19 @@ const ( "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. const errorPage = "\n\n" @@ -267,6 +280,20 @@ func TestLookupFindsVPNServers(t *testing.T) { listed: []string{"192.0.2.70", "192.0.2.71", "192.0.2.72"}, 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 // list. Only the VPN list's listing says it is one. 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) { t.Errorf("VPN lists %v, want %v", got, want) } diff --git a/internal/iplist/lists.go b/internal/iplist/lists.go index 97e8a18..7990378 100644 --- a/internal/iplist/lists.go +++ b/internal/iplist/lists.go @@ -8,6 +8,9 @@ import ( // TorExitsList is the name of the list of Tor exit nodes. const TorExitsList = "tor_exits" +// protonVPNIPsURL is where the ProtonVPN-IPs mirror publishes its files. +const protonVPNIPsURL = "https://raw.githubusercontent.com/tn3w/ProtonVPN-IPs/master/" + // How often each list is downloaded. No list is downloaded more often than // its publisher updates it or allows. const ( @@ -146,6 +149,28 @@ func vpnLists() []List { Format: FormatPIAJSON, 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: protonVPNIPsURL + "protonvpn_ip_ranges.txt", + Interval: vpnListInterval, + Format: FormatProtonVPN, + VPN: true, + }, + { + Name: "protonvpn_entry", + Description: "A server of the ProtonVPN service", + URL: protonVPNIPsURL + "protonvpn_entry_ips.txt", + Interval: vpnListInterval, + Format: FormatProtonVPN, + VPN: true, + }, { Name: "x4bnet", Description: "In an IPv4 network of a VPN service, according to " + diff --git a/internal/iplist/parse.go b/internal/iplist/parse.go index 373f91e..7209427 100644 --- a/internal/iplist/parse.go +++ b/internal/iplist/parse.go @@ -58,6 +58,8 @@ func parse(r io.Reader, format Format) (*netipx.IPSet, error) { return parsePIA(r) case FormatIVPNJSON: return parseIVPN(r) + case FormatProtonVPN: + return parseProtonVPN(r) default: return nil, errUnknownFormat } @@ -329,6 +331,22 @@ func parseIVPN(r io.Reader) (*netipx.IPSet, error) { 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. func readJSON(r io.Reader, v any) error { data, err := io.ReadAll(r)