From 5713e6e1813629d1b6c4a1a2754fad4d4748b4d1 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 to the IP lookup (closes #236) Adds the VPN list `protonvpn`, downloaded once a day. It 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 the list comes from the public ProtonVPN-IPs mirror. No request header is needed. Judgement call: the mirror's /24 file, not 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 | 5 +++ TODO.md | 3 ++ internal/iplist/iplist.go | 4 +++ internal/iplist/iplist_internal_test.go | 42 ++++++++++++++++++++++++- internal/iplist/lists.go | 15 +++++++++ internal/iplist/parse.go | 17 ++++++++++ 6 files changed, 85 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 650d8f4..10d6218 100644 --- a/README.md +++ b/README.md @@ -434,6 +434,11 @@ 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 ProtonVPN traffic can leave from an address Proton does not + publish. From the public + [ProtonVPN-IPs](https://github.com/tn3w/ProtonVPN-IPs) mirror of Proton's + server list, 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 7e90229..a168465 100644 --- a/TODO.md +++ b/TODO.md @@ -53,6 +53,9 @@ problem the run finds. # 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 IP lookup checks the public VPN server lists of Mullvad, NordVPN, Private Internet Access and IVPN, and X4BNet's community list. Each result has `flags.is_vpn` and `vpn_providers` (closes diff --git a/internal/iplist/iplist.go b/internal/iplist/iplist.go index b2baea7..4525118 100644 --- a/internal/iplist/iplist.go +++ b/internal/iplist/iplist.go @@ -51,6 +51,10 @@ 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 network per line, with comment lines that start + // with #. Unlike FormatLines, a file with no 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 82850ea..c809804 100644 --- a/internal/iplist/iplist_internal_test.go +++ b/internal/iplist/iplist_internal_test.go @@ -70,6 +70,12 @@ 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" + // errorPage is a file that is no list, as a publisher's error page is. const errorPage = "\n\n" @@ -267,6 +273,13 @@ 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"}, + }, }) } @@ -479,6 +492,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 +561,9 @@ func TestPublicListsMarkTheVPNLists(t *testing.T) { } } - want := []string{"ivpn", "mullvad", "nordvpn", "pia", "x4bnet", "x4bnet_v6"} + want := []string{ + "ivpn", "mullvad", "nordvpn", "pia", "protonvpn", "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..8233d5e 100644 --- a/internal/iplist/lists.go +++ b/internal/iplist/lists.go @@ -146,6 +146,21 @@ 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: "https://raw.githubusercontent.com/tn3w/ProtonVPN-IPs/master/" + + "protonvpn_ip_ranges.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..c6720a3 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,21 @@ func parseIVPN(r io.Reader) (*netipx.IPSet, error) { return addressSet(texts) } +// parseProtonVPN returns the networks in a ProtonVPN-IPs list. A file with no +// 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)