Add the public VPN server lists, with is_vpn and vpn_providers (closes #235)
check / check (push) Successful in 1m36s
check / check (push) Successful in 1m36s
The IP lookup now checks Mullvad, NordVPN, Private Internet Access, IVPN and X4BNet's community list, each downloaded once a day. Each result has `flags.is_vpn` and `vpn_providers`. Judgement call: X4BNet publishes IPv4 and IPv6 as two files, so they are two lists, `x4bnet` and `x4bnet_v6`. Judgement call: Private Internet Access's signature is not read or checked. Rule suppressed: `tagliatelle` on the structs holding the publishers' field names. Model: opus-5-5
This commit is contained in:
1 parent
3204b7ee4b
commit
601bc812b9
8 files changed
+584
-28
No files matched your search
@@ -43,6 +43,36 @@ const (
|
||||
],"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.
|
||||
const testETag = `"v1"`
|
||||
|
||||
@@ -158,13 +188,7 @@ func lookup(t *testing.T, b *IPLists, addr string) []Listing {
|
||||
func TestLookupFindsListedAddresses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
format Format
|
||||
body string
|
||||
listed []string
|
||||
unlisted []string
|
||||
}{
|
||||
runLookupCases(t, []lookupCase{
|
||||
{
|
||||
name: "single addresses",
|
||||
format: FormatLines,
|
||||
@@ -206,9 +230,60 @@ func TestLookupFindsListedAddresses(t *testing.T) {
|
||||
},
|
||||
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.Parallel()
|
||||
|
||||
@@ -260,7 +335,7 @@ func TestFailedDownloadKeepsThePreviousCopy(t *testing.T) {
|
||||
body string
|
||||
}{
|
||||
{"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 {
|
||||
@@ -306,7 +381,7 @@ func TestFileThatIsNotAnOnionooDocumentKeepsThePreviousCopy(t *testing.T) {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"not JSON", "<!DOCTYPE html>\n<html></html>\n"},
|
||||
{"not JSON", errorPage},
|
||||
{"other JSON", `{"status":"ok","data":{"relays_count":1}}`},
|
||||
{"JSON array", `[{"or_addresses":["192.0.2.1:443"]}]`},
|
||||
{"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
|
||||
// and no part of a failed download.
|
||||
func checkGoodCopyOnDisk(t *testing.T, dir string) {
|
||||
|
||||
Reference in new issue
Block a user