Add the public VPN server lists, with is_vpn and vpn_providers #246

Merged
clawbot merged 1 commits from issue-235-vpn-lists into next 2026-11-10 12:15:32 +01:00
8 changed files with 584 additions and 28 deletions

No files matched your search

+17
View File
@@ -285,8 +285,12 @@ address. Every entry has the same fields:
empty list when it has none empty list when it has none
- `flags`: yes-or-no facts about the address: - `flags`: yes-or-no facts about the address:
- `is_tor`: true when the address is on the `tor_exits` list - `is_tor`: true when the address is on the `tor_exits` list
- `is_vpn`: true when the address is on any list of VPN servers (see IP
lists)
- `is_bogon`: true when the address is reserved, so that no network should - `is_bogon`: true when the address is reserved, so that no network should
announce it on the internet announce it on the internet
- `vpn_providers`: the names of the lists of VPN servers the address is on, or
an empty list when it is on none
- `ip_lists`: the public IP lists the address is on (see IP lists), or an empty - `ip_lists`: the public IP lists the address is on (see IP lists), or an empty
list when it is on none list when it is on none
@@ -420,6 +424,16 @@ The lists:
last 30 days. From last 30 days. From
[FireHOL](https://iplists.firehol.org/?ipset=firehol_level3), downloaded every [FireHOL](https://iplists.firehol.org/?ipset=firehol_level3), downloaded every
3 hours. 3 hours.
- `ivpn`: VPN servers run by IVPN. From
[IVPN](https://api.ivpn.net/v5/servers.json), downloaded once a day.
- `mullvad`: VPN servers run by Mullvad. From
[Mullvad](https://api.mullvad.net/www/relays/all/), downloaded once a day.
- `nordvpn`: VPN servers run by NordVPN. From
[NordVPN](https://api.nordvpn.com/v1/servers?limit=0&fields[servers.station]&fields[servers.ipv6_station]&fields[servers.hostname]),
downloaded once a day.
- `pia`: VPN servers run by Private Internet Access. From
[Private Internet Access](https://serverlist.piaservers.net/vpninfo/servers/v6),
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/),
@@ -428,6 +442,9 @@ The lists:
the Tor network leaves. From the Tor Project's the Tor network leaves. From the Tor Project's
[Onionoo](https://metrics.torproject.org/onionoo.html) service, downloaded [Onionoo](https://metrics.torproject.org/onionoo.html) service, downloaded
every hour. every hour.
- `x4bnet` and `x4bnet_v6`: IPv4 and IPv6 networks of VPN services, according to
a community list that names no service. From
[X4BNet](https://github.com/X4BNet/lists_vpn), downloaded once a day.
No list is downloaded more often than its publisher updates it or allows. Each No list is downloaded more often than its publisher updates it or allows. Each
can be downloaded whole without an account or a key. can be downloaded whole without an account or a key.
+4
View File
@@ -53,6 +53,10 @@ problem the run finds.
# Completed Steps # Completed Steps
- 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
https://git.eeqj.de/sneak/routewatch/issues/235).
- 2026-11-10: `tor_exits` comes from Onionoo, so it holds IPv6 exits and - 2026-11-10: `tor_exits` comes from Onionoo, so it holds IPv6 exits and
`is_tor` is true for them (closes `is_tor` is true for them (closes
https://git.eeqj.de/sneak/routewatch/issues/234). https://git.eeqj.de/sneak/routewatch/issues/234).
+25
View File
@@ -34,6 +34,23 @@ const (
// addresses in two fields: exit_addresses, which has IPv4 only, and // addresses in two fields: exit_addresses, which has IPv4 only, and
// or_addresses, which has IPv4 and IPv6, each with a port. // or_addresses, which has IPv4 and IPv6, each with a port.
FormatOnionooJSON FormatOnionooJSON
// FormatMullvadJSON is Mullvad's JSON list of relays. A relay's addresses
// are in ipv4_addr_in, ipv6_addr_in and ipv4_v2ray.
FormatMullvadJSON
// FormatNordVPNJSON is NordVPN's JSON list of servers. A server's
// addresses are in station and ipv6_station.
FormatNordVPNJSON
// FormatPIAJSON is Private Internet Access's JSON server list, then a
// blank line and a signature. Each region's servers has lists of servers
// by protocol, and each server has its address in ip.
FormatPIAJSON
// FormatIVPNJSON is IVPN's JSON server list. The hosts of its wireguard
// and openvpn gateways have their addresses in host and v2ray.
FormatIVPNJSON
) )
// List is one public IP list. // List is one public IP list.
@@ -53,6 +70,9 @@ type List struct {
// Format is how the list's file is written. // Format is how the list's file is written.
Format Format Format Format
// VPN says that the list holds servers of VPN services.
VPN bool
} }
// Listing says that a list contains an address. // Listing says that a list contains an address.
@@ -63,6 +83,10 @@ type Listing struct {
// Updated is when routewatch last downloaded the list, or last heard from // Updated is when routewatch last downloaded the list, or last heard from
// its publisher that its copy was still current. // its publisher that its copy was still current.
Updated time.Time `json:"updated"` Updated time.Time `json:"updated"`
// VPN says that the list holds servers of VPN services. A lookup answer
// reports it through is_vpn and vpn_providers.
VPN bool `json:"-"`
} }
// listCopy is a list's last good copy in memory. // listCopy is a list's last good copy in memory.
@@ -129,6 +153,7 @@ func (b *IPLists) Lookup(addr netip.Addr) []Listing {
Name: list.Name, Name: list.Name,
Description: list.Description, Description: list.Description,
Updated: c.updated, Updated: c.updated,
VPN: list.VPN,
}) })
} }
} }
+192 -11
View File
@@ -43,6 +43,36 @@ const (
],"bridges":[]}` ],"bridges":[]}`
) )
// Small documents in the VPN services' formats. PIA's and IVPN's have no
// public IPv6 addresses, and IVPN's holds addresses that are not its servers,
// which must not be read.
const (
mullvadBody = `[
{"hostname":"a-wg-001","ipv4_addr_in":"192.0.2.40","ipv6_addr_in":"2001:db8::40"},
{"hostname":"a-br-001","ipv4_addr_in":"192.0.2.41","ipv6_addr_in":null,
"ipv4_v2ray":"192.0.2.42"}
]`
nordVPNBody = `[
{"hostname":"a1.example.com","station":"192.0.2.50","ipv6_station":""},
{"hostname":"a2.example.com","station":"192.0.2.51","ipv6_station":"2001:db8::51"}
]`
piaBody = `{"groups":{"wg":[{"name":"wireguard","ports":[1337]}]},"regions":[
{"id":"a","servers":{
"wg":[{"ip":"192.0.2.60","cn":"a1"}],
"ikev2":[{"ip":"192.0.2.61","cn":"a1"}]}},
{"id":"b","servers":{"meta":[{"ip":"192.0.2.62","cn":"b1","van":true}]}}
]}` + "\n\nc2lnbmF0dXJl\nbGluZSB0d28=\n"
ivpnBody = `{"wireguard":[{"gateway":"a.wg.example.com","hosts":[
{"hostname":"a1","host":"192.0.2.70","v2ray":"192.0.2.71",
"ipv6":{"local_ip":"2001:db8::70/96"}}]}],
"openvpn":[{"gateway":"a.gw.example.com","hosts":[
{"hostname":"a1","host":"192.0.2.72"}]}],
"config":{"api":{"ips":["192.0.2.73"]}}}`
)
// errorPage is a file that is no list, as a publisher's error page is.
const errorPage = "<!DOCTYPE html>\n<html></html>\n"
// testETag is the ETag a publisher sends with a version of the test list. // testETag is the ETag a publisher sends with a version of the test list.
const testETag = `"v1"` const testETag = `"v1"`
@@ -158,13 +188,7 @@ func lookup(t *testing.T, b *IPLists, addr string) []Listing {
func TestLookupFindsListedAddresses(t *testing.T) { func TestLookupFindsListedAddresses(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { runLookupCases(t, []lookupCase{
name string
format Format
body string
listed []string
unlisted []string
}{
{ {
name: "single addresses", name: "single addresses",
format: FormatLines, format: FormatLines,
@@ -206,9 +230,60 @@ func TestLookupFindsListedAddresses(t *testing.T) {
}, },
unlisted: []string{"192.0.2.12", "2001:db8::2"}, unlisted: []string{"192.0.2.12", "2001:db8::2"},
}, },
} })
}
for _, tt := range tests { // TestLookupFindsVPNServers downloads a list in each VPN service's format and
// looks up addresses on it and off it.
func TestLookupFindsVPNServers(t *testing.T) {
t.Parallel()
runLookupCases(t, []lookupCase{
{
name: "Mullvad relays",
format: FormatMullvadJSON,
body: mullvadBody,
listed: []string{"192.0.2.40", "192.0.2.41", "192.0.2.42", "2001:db8::40"},
unlisted: []string{"192.0.2.43", "2001:db8::41"},
},
{
name: "NordVPN servers",
format: FormatNordVPNJSON,
body: nordVPNBody,
listed: []string{"192.0.2.50", "192.0.2.51", "2001:db8::51"},
unlisted: []string{"192.0.2.52", "2001:db8::50"},
},
{
name: "PIA servers, with the signature after the JSON",
format: FormatPIAJSON,
body: piaBody,
listed: []string{"192.0.2.60", "192.0.2.61", "192.0.2.62"},
unlisted: []string{"192.0.2.63", "2001:db8::60"},
},
{
name: "IVPN hosts",
format: FormatIVPNJSON,
body: ivpnBody,
listed: []string{"192.0.2.70", "192.0.2.71", "192.0.2.72"},
unlisted: []string{"192.0.2.73", "2001:db8::70"},
},
})
}
// lookupCase is a list file, the addresses it holds and some it does not.
type lookupCase struct {
name string
format Format
body string
listed []string
unlisted []string
}
// runLookupCases downloads each case's list file and checks its addresses.
func runLookupCases(t *testing.T, cases []lookupCase) {
t.Helper()
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -260,7 +335,7 @@ func TestFailedDownloadKeepsThePreviousCopy(t *testing.T) {
body string body string
}{ }{
{"server error", http.StatusInternalServerError, "unavailable"}, {"server error", http.StatusInternalServerError, "unavailable"},
{"not a list", http.StatusOK, "<!DOCTYPE html>\n<html></html>\n"}, {"not a list", http.StatusOK, errorPage},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -306,7 +381,7 @@ func TestFileThatIsNotAnOnionooDocumentKeepsThePreviousCopy(t *testing.T) {
name string name string
body string body string
}{ }{
{"not JSON", "<!DOCTYPE html>\n<html></html>\n"}, {"not JSON", errorPage},
{"other JSON", `{"status":"ok","data":{"relays_count":1}}`}, {"other JSON", `{"status":"ok","data":{"relays_count":1}}`},
{"JSON array", `[{"or_addresses":["192.0.2.1:443"]}]`}, {"JSON array", `[{"or_addresses":["192.0.2.1:443"]}]`},
{"no relays", `{"relays":[],"bridges":[]}`}, {"no relays", `{"relays":[],"bridges":[]}`},
@@ -348,6 +423,112 @@ func TestFileThatIsNotAnOnionooDocumentKeepsThePreviousCopy(t *testing.T) {
} }
} }
// TestWrongVPNDocumentFailsToParse parses documents that are not a VPN
// service's list. Each fails, so none can replace a good copy.
func TestWrongVPNDocumentFailsToParse(t *testing.T) {
t.Parallel()
formats := []struct {
name string
format Format
good string
empty string
badAddress string
}{
{
"Mullvad", FormatMullvadJSON, mullvadBody,
`[{"hostname":"a"}]`, `[{"ipv4_addr_in":"example"}]`,
},
{
"NordVPN", FormatNordVPNJSON, nordVPNBody,
`[{"hostname":"a","station":"","ipv6_station":""}]`,
`[{"station":"example"}]`,
},
{
"PIA", FormatPIAJSON, piaBody,
`{"regions":[{"id":"a","servers":{}}]}`,
`{"regions":[{"servers":{"wg":[{"ip":"example"}]}}]}`,
},
{
"IVPN", FormatIVPNJSON, ivpnBody,
`{"wireguard":[{"hosts":[]}],"openvpn":[]}`,
`{"wireguard":[{"hosts":[{"host":"example"}]}]}`,
},
}
for _, f := range formats {
bodies := map[string]string{
"not JSON": errorPage,
"other JSON": `{"status":"ok","data":{"count":1}}`,
"no addresses": f.empty,
"bad address": f.badAddress,
"cut off": f.good[:len(f.good)/2],
"an empty file": "",
}
for name, body := range bodies {
t.Run(f.name+" "+name, func(t *testing.T) {
t.Parallel()
addresses, err := parse(strings.NewReader(body), f.format)
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) {
t.Parallel()
_, url := newPublisher(t, goodListBody)
vpnList := testList(url, FormatLines)
vpnList.Name = "test_vpn_list"
vpnList.VPN = true
otherList := testList(url, FormatLines)
b := New(Params{
Dir: t.TempDir(),
Lists: []List{vpnList, otherList},
Logger: slog.New(slog.DiscardHandler),
})
for _, list := range b.lists {
err := b.download(t.Context(), list)
if err != nil {
t.Fatalf("download of %s: %v", list.Name, err)
}
}
listings := lookup(t, b, listedAddr)
if len(listings) != 2 || !listings[0].VPN || listings[1].VPN {
t.Errorf("listings %+v, want the first of two marked as a VPN list",
listings)
}
}
// TestPublicListsMarkTheVPNLists checks which public lists are VPN lists,
// since their names are what vpn_providers reports.
func TestPublicListsMarkTheVPNLists(t *testing.T) {
t.Parallel()
var got []string
for _, list := range Lists() {
if list.VPN {
got = append(got, list.Name)
}
}
want := []string{"ivpn", "mullvad", "nordvpn", "pia", "x4bnet", "x4bnet_v6"}
if !slices.Equal(got, want) {
t.Errorf("VPN lists %v, want %v", got, want)
}
}
// checkGoodCopyOnDisk checks that dir holds the good version of the test list // checkGoodCopyOnDisk checks that dir holds the good version of the test list
// and no part of a failed download. // and no part of a failed download.
func checkGoodCopyOnDisk(t *testing.T, dir string) { func checkGoodCopyOnDisk(t *testing.T, dir string) {
+73 -1
View File
@@ -1,6 +1,9 @@
package iplist package iplist
import "time" import (
"slices"
"time"
)
// TorExitsList is the name of the list of Tor exit nodes. // TorExitsList is the name of the list of Tor exit nodes.
const TorExitsList = "tor_exits" const TorExitsList = "tor_exits"
@@ -32,12 +35,20 @@ const (
// Onionoo sets no download limit: // Onionoo sets no download limit:
// https://metrics.torproject.org/onionoo.html // https://metrics.torproject.org/onionoo.html
torExitsInterval = time.Hour torExitsInterval = time.Hour
// A VPN service's servers change slowly, so once a day is enough.
vpnListInterval = 24 * time.Hour
) )
// Lists returns the public IP lists routewatch downloads. Each can be // Lists returns the public IP lists routewatch downloads. Each can be
// downloaded whole without an account or a key. Spamhaus merged its EDROP // downloaded whole without an account or a key. Spamhaus merged its EDROP
// list into DROP, so DROP covers both. // list into DROP, so DROP covers both.
func Lists() []List { func Lists() []List {
return slices.Concat(nonVPNLists(), vpnLists())
}
// nonVPNLists returns the public lists that are not lists of VPN servers.
func nonVPNLists() []List {
return []List{ return []List{
{ {
Name: "dshield", Name: "dshield",
@@ -96,3 +107,64 @@ func Lists() []List {
}, },
} }
} }
// vpnLists returns the public lists of VPN servers. Their names are what
// vpn_providers reports.
func vpnLists() []List {
return []List{
{
Name: "ivpn",
Description: "A server of the IVPN service",
URL: "https://api.ivpn.net/v5/servers.json",
Interval: vpnListInterval,
Format: FormatIVPNJSON,
VPN: true,
},
{
Name: "mullvad",
Description: "A server of the Mullvad VPN service",
URL: "https://api.mullvad.net/www/relays/all/",
Interval: vpnListInterval,
Format: FormatMullvadJSON,
VPN: true,
},
{
Name: "nordvpn",
Description: "A server of the NordVPN service",
URL: "https://api.nordvpn.com/v1/servers?limit=0" +
"&fields[servers.station]&fields[servers.ipv6_station]" +
"&fields[servers.hostname]",
Interval: vpnListInterval,
Format: FormatNordVPNJSON,
VPN: true,
},
{
Name: "pia",
Description: "A server of the Private Internet Access VPN service",
URL: "https://serverlist.piaservers.net/vpninfo/servers/v6",
Interval: vpnListInterval,
Format: FormatPIAJSON,
VPN: true,
},
{
Name: "x4bnet",
Description: "In an IPv4 network of a VPN service, according to " +
"X4BNet's community list",
URL: "https://raw.githubusercontent.com/X4BNet/lists_vpn/main/" +
"output/vpn/ipv4.txt",
Interval: vpnListInterval,
Format: FormatLines,
VPN: true,
},
{
Name: "x4bnet_v6",
Description: "In an IPv6 network of a VPN service, according to " +
"X4BNet's community list",
URL: "https://raw.githubusercontent.com/X4BNet/lists_vpn/main/" +
"output/vpn/ipv6.txt",
Interval: vpnListInterval,
Format: FormatLines,
VPN: true,
},
}
}
+158 -7
View File
@@ -8,6 +8,7 @@ import (
"io" "io"
"net" "net"
"net/netip" "net/netip"
"slices"
"strings" "strings"
"go4.org/netipx" "go4.org/netipx"
@@ -33,7 +34,7 @@ var (
errUnknownFormat = errors.New("unknown list format") errUnknownFormat = errors.New("unknown list format")
errNoNetwork = errors.New("no network on the line") errNoNetwork = errors.New("no network on the line")
errBadRange = errors.New("first and last address make no range") errBadRange = errors.New("first and last address make no range")
errNoAddresses = errors.New("no relay addresses in the document") errNoAddresses = errors.New("no addresses in the document")
) )
// parse reads a list file written in format and returns the addresses it // parse reads a list file written in format and returns the addresses it
@@ -49,6 +50,14 @@ func parse(r io.Reader, format Format) (*netipx.IPSet, error) {
return parseLines(r, addDShieldNetwork) return parseLines(r, addDShieldNetwork)
case FormatOnionooJSON: case FormatOnionooJSON:
return parseOnionoo(r) return parseOnionoo(r)
case FormatMullvadJSON:
return parseMullvad(r)
case FormatNordVPNJSON:
return parseNordVPN(r)
case FormatPIAJSON:
return parsePIA(r)
case FormatIVPNJSON:
return parseIVPN(r)
default: default:
return nil, errUnknownFormat return nil, errUnknownFormat
} }
@@ -168,11 +177,6 @@ func addDShieldNetwork(builder *netipx.IPSetBuilder, line string) error {
// fields of the relays in an Onionoo details document. A document with no // fields of the relays in an Onionoo details document. A document with no
// addresses fails, because the Tor network always has relays. // addresses fails, because the Tor network always has relays.
func parseOnionoo(r io.Reader) (*netipx.IPSet, error) { func parseOnionoo(r io.Reader) (*netipx.IPSet, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
//nolint:tagliatelle // Onionoo's own field names //nolint:tagliatelle // Onionoo's own field names
var details struct { var details struct {
Relays []struct { Relays []struct {
@@ -181,7 +185,7 @@ func parseOnionoo(r io.Reader) (*netipx.IPSet, error) {
} `json:"relays"` } `json:"relays"`
} }
err = json.Unmarshal(data, &details) err := readJSON(r, &details)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -216,6 +220,153 @@ func parseOnionoo(r io.Reader) (*netipx.IPSet, error) {
return addresses, nil return addresses, nil
} }
// parseMullvad returns the addresses of the relays in a Mullvad document. A
// relay with no IPv6 address has null for it, and only bridges have
// ipv4_v2ray.
func parseMullvad(r io.Reader) (*netipx.IPSet, error) {
//nolint:tagliatelle // Mullvad's own field names
var relays []struct {
IPv4 string `json:"ipv4_addr_in"`
IPv6 string `json:"ipv6_addr_in"`
IPv4V2Ray string `json:"ipv4_v2ray"`
}
err := readJSON(r, &relays)
if err != nil {
return nil, err
}
var texts []string
for _, relay := range relays {
texts = append(texts, relay.IPv4, relay.IPv6, relay.IPv4V2Ray)
}
return addressSet(texts)
}
// parseNordVPN returns the addresses of the servers in a NordVPN document. A
// server with no IPv6 address has an empty ipv6_station.
func parseNordVPN(r io.Reader) (*netipx.IPSet, error) {
//nolint:tagliatelle // NordVPN's own field names
var servers []struct {
Station string `json:"station"`
IPv6Station string `json:"ipv6_station"`
}
err := readJSON(r, &servers)
if err != nil {
return nil, err
}
var texts []string
for _, server := range servers {
texts = append(texts, server.Station, server.IPv6Station)
}
return addressSet(texts)
}
// parsePIA returns the addresses of the servers in a Private Internet Access
// document. json.Decoder stops at the end of the JSON, so the signature after
// it is not read.
func parsePIA(r io.Reader) (*netipx.IPSet, error) {
var list struct {
Regions []struct {
Servers map[string][]struct {
IP string `json:"ip"`
} `json:"servers"`
} `json:"regions"`
}
err := json.NewDecoder(r).Decode(&list)
if err != nil {
return nil, err
}
var texts []string
for _, region := range list.Regions {
for _, servers := range region.Servers {
for _, server := range servers {
texts = append(texts, server.IP)
}
}
}
return addressSet(texts)
}
// parseIVPN returns the addresses of the hosts in an IVPN document. A host's
// ipv6 object holds only an address inside the VPN, so it is not read.
func parseIVPN(r io.Reader) (*netipx.IPSet, error) {
type gateway struct {
Hosts []struct {
Host string `json:"host"`
V2Ray string `json:"v2ray"`
} `json:"hosts"`
}
var list struct {
WireGuard []gateway `json:"wireguard"`
OpenVPN []gateway `json:"openvpn"`
}
err := readJSON(r, &list)
if err != nil {
return nil, err
}
var texts []string
for _, gw := range slices.Concat(list.WireGuard, list.OpenVPN) {
for _, host := range gw.Hosts {
texts = append(texts, host.Host, host.V2Ray)
}
}
return addressSet(texts)
}
// 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)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
// addressSet returns the set of the addresses in texts, skipping empty ones.
// A list with no addresses fails, because every VPN service has servers.
func addressSet(texts []string) (*netipx.IPSet, error) {
var builder netipx.IPSetBuilder
for _, text := range texts {
if text == "" {
continue
}
err := addAddress(&builder, text)
if err != nil {
return nil, err
}
}
addresses, err := builder.IPSet()
if err != nil {
return nil, err
}
if len(addresses.Ranges()) == 0 {
return nil, errNoAddresses
}
return addresses, nil
}
// addAddress adds an address, such as 192.0.2.1, to builder. // addAddress adds an address, such as 192.0.2.1, to builder.
func addAddress(builder *netipx.IPSetBuilder, text string) error { func addAddress(builder *netipx.IPSetBuilder, text string) error {
addr, err := netip.ParseAddr(text) addr, err := netip.ParseAddr(text)
+31 -5
View File
@@ -781,6 +781,10 @@ type IPLookupResult struct {
Flags IPLookupFlags `json:"flags"` Flags IPLookupFlags `json:"flags"`
// VPNProviders names the VPN lists the address is on. It is empty, never
// nil, when there are none.
VPNProviders []string `json:"vpn_providers"`
// IPLists has one entry for each IP list the address is on. // IPLists has one entry for each IP list the address is on.
IPLists []iplist.Listing `json:"ip_lists"` IPLists []iplist.Listing `json:"ip_lists"`
} }
@@ -793,6 +797,9 @@ type IPLookupFlags struct {
// IsTor says that the address is on the Tor exit list. // IsTor says that the address is on the Tor exit list.
IsTor bool `json:"is_tor"` IsTor bool `json:"is_tor"`
// IsVPN says that the address is on a list of VPN servers.
IsVPN bool `json:"is_vpn"`
// IsBogon says that the address is reserved, as package bogon tells. // IsBogon says that the address is reserved, as package bogon tells.
IsBogon bool `json:"is_bogon"` IsBogon bool `json:"is_bogon"`
} }
@@ -977,14 +984,30 @@ func (s *Server) ipLookupResult(ip string, ptrs []string, info IPInfo) IPLookupR
listings := s.ipListListings(ip) listings := s.ipListListings(ip)
return IPLookupResult{ return IPLookupResult{
IP: ip, IP: ip,
PTR: ptrs, PTR: ptrs,
IPInfo: info, IPInfo: info,
Flags: ipLookupFlags(ip, listings), Flags: ipLookupFlags(ip, listings),
IPLists: listings, VPNProviders: vpnProviders(listings),
IPLists: listings,
} }
} }
// vpnProviders returns the names of the VPN lists among listings. It returns
// an empty list, never nil, so that an answer's vpn_providers is [] rather
// than null.
func vpnProviders(listings []iplist.Listing) []string {
names := []string{}
for _, listing := range listings {
if listing.VPN {
names = append(names, listing.Name)
}
}
return names
}
// ipListListings returns the IP lists that contain ip. It returns an empty // ipListListings returns the IP lists that contain ip. It returns an empty
// list, never nil, so that an answer's ip_lists is [] rather than null. // list, never nil, so that an answer's ip_lists is [] rather than null.
func (s *Server) ipListListings(ip string) []iplist.Listing { func (s *Server) ipListListings(ip string) []iplist.Listing {
@@ -1002,6 +1025,9 @@ func ipLookupFlags(ip string, listings []iplist.Listing) IPLookupFlags {
IsTor: slices.ContainsFunc(listings, func(l iplist.Listing) bool { IsTor: slices.ContainsFunc(listings, func(l iplist.Listing) bool {
return l.Name == iplist.TorExitsList return l.Name == iplist.TorExitsList
}), }),
IsVPN: slices.ContainsFunc(listings, func(l iplist.Listing) bool {
return l.VPN
}),
IsBogon: isBogon(ip), IsBogon: isBogon(ip),
} }
} }
+84 -4
View File
@@ -1441,8 +1441,19 @@ const listedUnroutedAddr = "203.0.113.1"
// torExitAddr is the address that fakeIPLists lists on the Tor exit list. // torExitAddr is the address that fakeIPLists lists on the Tor exit list.
const torExitAddr = "203.0.113.2" const torExitAddr = "203.0.113.2"
// fakeIPLists has two lists. The test list holds addrInTestPrefixV4 and // The addresses that fakeIPLists lists on one and on two VPN lists, and the
// listedUnroutedAddr, and the Tor exit list holds torExitAddr. // names of those lists.
const (
oneVPNListAddr = "203.0.113.3"
twoVPNListsAddr = "203.0.113.4"
testVPNList = "test_vpn"
otherTestVPN = "other_test_vpn"
)
// fakeIPLists has four lists. The test list holds addrInTestPrefixV4,
// listedUnroutedAddr and twoVPNListsAddr. The Tor exit list holds torExitAddr.
// The test VPN list holds oneVPNListAddr and twoVPNListsAddr, and the other
// test VPN list holds twoVPNListsAddr.
type fakeIPLists struct{} type fakeIPLists struct{}
func testListing() iplist.Listing { func testListing() iplist.Listing {
@@ -1463,6 +1474,18 @@ func (fakeIPLists) Lookup(addr netip.Addr) []iplist.Listing {
return []iplist.Listing{{Name: iplist.TorExitsList}} return []iplist.Listing{{Name: iplist.TorExitsList}}
} }
if addr == netip.MustParseAddr(oneVPNListAddr) {
return []iplist.Listing{{Name: testVPNList, VPN: true}}
}
if addr == netip.MustParseAddr(twoVPNListsAddr) {
return []iplist.Listing{
{Name: otherTestVPN, VPN: true},
testListing(),
{Name: testVPNList, VPN: true},
}
}
return []iplist.Listing{} return []iplist.Listing{}
} }
@@ -1471,7 +1494,7 @@ func (fakeIPLists) ShortestInterval() time.Duration {
} }
// TestIPLookupFlags looks up addresses with and without a route. Every result // TestIPLookupFlags looks up addresses with and without a route. Every result
// has both flags, under the JSON keys the API documents. // has every flag, under the JSON keys the API documents.
func TestIPLookupFlags(t *testing.T) { func TestIPLookupFlags(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1517,7 +1540,9 @@ func TestIPLookupFlags(t *testing.T) {
t.Fatalf("%s: decoding flags %s: %v", path, results[0]["flags"], err) t.Fatalf("%s: decoding flags %s: %v", path, results[0]["flags"], err)
} }
want := map[string]bool{"is_tor": tt.tor, "is_bogon": tt.bogon} want := map[string]bool{
"is_tor": tt.tor, "is_vpn": false, "is_bogon": tt.bogon,
}
if !maps.Equal(got, want) { if !maps.Equal(got, want) {
t.Errorf("%s: flags %v, want %v", path, got, want) t.Errorf("%s: flags %v, want %v", path, got, want)
} }
@@ -1525,6 +1550,61 @@ func TestIPLookupFlags(t *testing.T) {
} }
} }
// TestIPLookupReportsVPNProviders looks up addresses on no list, on a list
// that is not a VPN list, on one VPN list and on two. is_vpn says whether any
// VPN list holds the address, and vpn_providers names only those lists. An
// address on none has [], not null.
func TestIPLookupReportsVPNProviders(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
s.SetIPLists(fakeIPLists{})
tests := []struct {
name string
addr string
isVPN string
providers string
}{
{"on no list", "1.1.1.1", "false", `[]`},
{"on a list that is not a VPN list", listedUnroutedAddr, "false", `[]`},
{"on one VPN list", oneVPNListAddr, "true", `["` + testVPNList + `"]`},
{
"on two VPN lists", twoVPNListsAddr, "true",
`["` + otherTestVPN + `","` + testVPNList + `"]`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
path := "/api/v1/ip/" + tt.addr
results := resultFields(t, get(t, s, path).Body.Bytes())
if len(results) != 1 {
t.Fatalf("%s: %d results, want 1", path, len(results))
}
var flags map[string]json.RawMessage
err := json.Unmarshal(results[0]["flags"], &flags)
if err != nil {
t.Fatalf("%s: decoding flags %s: %v", path, results[0]["flags"], err)
}
if got := string(flags["is_vpn"]); got != tt.isVPN {
t.Errorf("%s: is_vpn %s, want %s", path, got, tt.isVPN)
}
if got := string(results[0]["vpn_providers"]); got != tt.providers {
t.Errorf("%s: vpn_providers %s, want %s", path, got, tt.providers)
}
})
}
}
// TestIPLookupReportsIPLists looks up two routed addresses, one of them on an // TestIPLookupReportsIPLists looks up two routed addresses, one of them on an
// IP list. The listed address's result names the list, says what it means and // IP list. The listed address's result names the list, says what it means and
// when it was updated. The other's result has an empty list. // when it was updated. The other's result has an empty list.