check / check (push) Successful in 5m8s
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
926 lines
26 KiB
Go
926 lines
26 KiB
Go
package iplist
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The test list's name and description, the address its good version lists,
|
|
// and the address a newer version lists instead.
|
|
const (
|
|
testListName = "test_list"
|
|
testListDescription = "On the test list"
|
|
listedAddr = "192.0.2.1"
|
|
goodListBody = "# A test list\n" + listedAddr + "\n"
|
|
newListedAddr = "192.0.2.2"
|
|
newListBody = "# A test list\n" + newListedAddr + "\n"
|
|
)
|
|
|
|
// A small Onionoo details document: a relay with an exit address and IPv4 and
|
|
// IPv6 relay addresses, one without exit addresses, and one with a list of
|
|
// ports.
|
|
const (
|
|
onionooExitAddr = "192.0.2.10"
|
|
onionooIPv6ExitAddr = "2001:db8::1"
|
|
onionooBody = `{"version":"8.0","relays":[
|
|
{"exit_addresses":["` + onionooExitAddr + `"],
|
|
"or_addresses":["192.0.2.11:443","[` + onionooIPv6ExitAddr + `]:9001"]},
|
|
{"or_addresses":["192.0.2.20:443"]},
|
|
{"or_addresses":["192.0.2.30:80,443"]}
|
|
],"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"]}}}`
|
|
)
|
|
|
|
// 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 = "<!DOCTYPE html>\n<html></html>\n"
|
|
|
|
// testETag is the ETag a publisher sends with a version of the test list.
|
|
const testETag = `"v1"`
|
|
|
|
// maxWait is how long a test waits for the download goroutine.
|
|
const maxWait = 10 * time.Second
|
|
|
|
// publisher stands in for a list's publisher. It answers every request with
|
|
// its status and body, and keeps the headers of each request. A 200 answer
|
|
// carries lastModified and etag, when set, as Last-Modified and ETag. As
|
|
// from a real publisher, a request whose If-None-Match or If-Modified-Since
|
|
// matches them is answered "not modified".
|
|
type publisher struct {
|
|
mu sync.Mutex
|
|
status int
|
|
body string
|
|
lastModified time.Time
|
|
etag string
|
|
headers []http.Header
|
|
}
|
|
|
|
// newPublisher starts a publisher that answers 200 with body, and returns it
|
|
// and its URL.
|
|
func newPublisher(t *testing.T, body string) (*publisher, string) {
|
|
t.Helper()
|
|
|
|
p := &publisher{status: http.StatusOK, body: body}
|
|
srv := httptest.NewServer(p)
|
|
t.Cleanup(srv.Close)
|
|
|
|
return p, srv.URL
|
|
}
|
|
|
|
func (p *publisher) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
p.headers = append(p.headers, r.Header.Clone())
|
|
|
|
if p.status != http.StatusOK {
|
|
w.WriteHeader(p.status)
|
|
_, _ = io.WriteString(w, p.body)
|
|
|
|
return
|
|
}
|
|
|
|
if p.etag != "" {
|
|
w.Header().Set(etagHeader, p.etag)
|
|
}
|
|
|
|
// ServeContent sends Last-Modified unless lastModified is zero, and
|
|
// answers "not modified" when the request's conditions match.
|
|
http.ServeContent(w, r, testListName, p.lastModified,
|
|
strings.NewReader(p.body))
|
|
}
|
|
|
|
// answer makes the publisher answer later requests with status and body.
|
|
func (p *publisher) answer(status int, body string) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
p.status, p.body = status, body
|
|
}
|
|
|
|
// publish makes the publisher answer later requests with a version of the
|
|
// list: body, with lastModified and etag. A zero lastModified or an empty
|
|
// etag is not sent.
|
|
func (p *publisher) publish(body string, lastModified time.Time, etag string) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
p.status, p.body = http.StatusOK, body
|
|
p.lastModified, p.etag = lastModified, etag
|
|
}
|
|
|
|
// requests returns the headers of the requests the publisher has had.
|
|
func (p *publisher) requests() []http.Header {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
return append([]http.Header(nil), p.headers...)
|
|
}
|
|
|
|
// testList returns a list served at url, written in format.
|
|
func testList(url string, format Format) List {
|
|
return List{
|
|
Name: testListName,
|
|
Description: testListDescription,
|
|
URL: url,
|
|
Interval: time.Hour,
|
|
Format: format,
|
|
}
|
|
}
|
|
|
|
func newTestIPLists(dir string, list List) *IPLists {
|
|
return New(Params{
|
|
Dir: dir,
|
|
Lists: []List{list},
|
|
Logger: slog.New(slog.DiscardHandler),
|
|
})
|
|
}
|
|
|
|
// lookup looks addr up in b.
|
|
func lookup(t *testing.T, b *IPLists, addr string) []Listing {
|
|
t.Helper()
|
|
|
|
return b.Lookup(netip.MustParseAddr(addr))
|
|
}
|
|
|
|
// TestLookupFindsListedAddresses downloads a list in each format and looks
|
|
// up addresses on it, inside its networks, and off it, in IPv4 and IPv6.
|
|
// A listed address has one listing that names the list; any other has an
|
|
// empty list of listings.
|
|
func TestLookupFindsListedAddresses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runLookupCases(t, []lookupCase{
|
|
{
|
|
name: "single addresses",
|
|
format: FormatLines,
|
|
body: "# comment\n192.0.2.1\n\n2001:db8::1\n",
|
|
listed: []string{"192.0.2.1", "::ffff:192.0.2.1", "2001:db8::1"},
|
|
unlisted: []string{"192.0.2.2", "2001:db8::2"},
|
|
},
|
|
{
|
|
name: "networks",
|
|
format: FormatLines,
|
|
body: "# comment\n198.51.100.0/24\n2001:db8:1::/48\n",
|
|
listed: []string{"198.51.100.0", "198.51.100.255", "2001:db8:1::5"},
|
|
unlisted: []string{"198.51.101.0", "2001:db8:2::1"},
|
|
},
|
|
{
|
|
name: "Spamhaus networks",
|
|
format: FormatSpamhausJSON,
|
|
body: `{"cidr":"203.0.113.0/25","sblid":"SBL1","rir":"arin"}` + "\n" +
|
|
`{"cidr":"2001:db8:3::/48","sblid":"SBL2","rir":"ripencc"}` + "\n" +
|
|
`{"type":"metadata","timestamp":1793074442,"records":2}` + "\n",
|
|
listed: []string{"203.0.113.1", "2001:db8:3::1"},
|
|
unlisted: []string{"203.0.113.200", "2001:db8:4::1"},
|
|
},
|
|
{
|
|
name: "DShield networks",
|
|
format: FormatDShield,
|
|
body: "# DShield.org Recommended Block List\n" +
|
|
"192.0.2.0\t192.0.2.255\t24\t327\tEXAMPLE\tUS\tNone\n",
|
|
listed: []string{"192.0.2.0", "192.0.2.77"},
|
|
unlisted: []string{"192.0.3.1", "2001:db8::1"},
|
|
},
|
|
{
|
|
name: "Onionoo relays",
|
|
format: FormatOnionooJSON,
|
|
body: onionooBody,
|
|
listed: []string{
|
|
onionooExitAddr, "192.0.2.11", "192.0.2.20", "192.0.2.30",
|
|
onionooIPv6ExitAddr,
|
|
},
|
|
unlisted: []string{"192.0.2.12", "2001:db8::2"},
|
|
},
|
|
})
|
|
}
|
|
|
|
// 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"},
|
|
},
|
|
{
|
|
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"},
|
|
},
|
|
})
|
|
}
|
|
|
|
// 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()
|
|
|
|
_, url := newPublisher(t, tt.body)
|
|
b := newTestIPLists(t.TempDir(), testList(url, tt.format))
|
|
|
|
err := b.download(t.Context(), b.lists[0])
|
|
if err != nil {
|
|
t.Fatalf("download: %v", err)
|
|
}
|
|
|
|
checkLookups(t, b, tt.listed, tt.unlisted)
|
|
})
|
|
}
|
|
}
|
|
|
|
// checkLookups checks that each of listed has one listing that names the test
|
|
// list, and each of unlisted has an empty list of listings.
|
|
func checkLookups(t *testing.T, b *IPLists, listed, unlisted []string) {
|
|
t.Helper()
|
|
|
|
for _, addr := range listed {
|
|
listings := lookup(t, b, addr)
|
|
if len(listings) != 1 || listings[0].Name != testListName ||
|
|
listings[0].Description != testListDescription ||
|
|
listings[0].Updated.IsZero() {
|
|
t.Errorf("%s: listings %+v, want one for %s", addr,
|
|
listings, testListName)
|
|
}
|
|
}
|
|
|
|
for _, addr := range unlisted {
|
|
listings := lookup(t, b, addr)
|
|
if listings == nil || len(listings) != 0 {
|
|
t.Errorf("%s: listings %#v, want an empty list", addr, listings)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFailedDownloadKeepsThePreviousCopy downloads a good version of a list,
|
|
// then one that fails. The good copy stays in memory and on disk, and no
|
|
// part of the failed download is left beside it.
|
|
func TestFailedDownloadKeepsThePreviousCopy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
}{
|
|
{"server error", http.StatusInternalServerError, "unavailable"},
|
|
{"not a list", http.StatusOK, errorPage},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
p, url := newPublisher(t, goodListBody)
|
|
b := newTestIPLists(dir, testList(url, FormatLines))
|
|
|
|
err := b.download(t.Context(), b.lists[0])
|
|
if err != nil {
|
|
t.Fatalf("first download: %v", err)
|
|
}
|
|
|
|
before := lookup(t, b, listedAddr)
|
|
|
|
p.answer(tt.status, tt.body)
|
|
|
|
err = b.download(t.Context(), b.lists[0])
|
|
if err == nil {
|
|
t.Fatal("second download returned no error")
|
|
}
|
|
|
|
after := lookup(t, b, listedAddr)
|
|
if len(after) != 1 || len(before) != 1 ||
|
|
!after[0].Updated.Equal(before[0].Updated) {
|
|
t.Errorf("listings %+v after the failure, want %+v", after, before)
|
|
}
|
|
|
|
checkGoodCopyOnDisk(t, dir)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFileThatIsNotAnOnionooDocumentKeepsThePreviousCopy downloads a good
|
|
// Onionoo document, then files that are not one. Each fails the parse, and
|
|
// the good copy stays.
|
|
func TestFileThatIsNotAnOnionooDocumentKeepsThePreviousCopy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{"not JSON", errorPage},
|
|
{"other JSON", `{"status":"ok","data":{"relays_count":1}}`},
|
|
{"JSON array", `[{"or_addresses":["192.0.2.1:443"]}]`},
|
|
{"no relays", `{"relays":[],"bridges":[]}`},
|
|
{"relays without addresses", `{"relays":[{"nickname":"example"}]}`},
|
|
{"relay address without a port", `{"relays":[{"or_addresses":["192.0.2.1"]}]}`},
|
|
{"exit address that is not one", `{"relays":[{"exit_addresses":["example"]}]}`},
|
|
{"text after the document", `{"relays":[{"or_addresses":["192.0.2.1:1"]}]} x`},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
p, url := newPublisher(t, onionooBody)
|
|
b := newTestIPLists(dir, testList(url, FormatOnionooJSON))
|
|
|
|
err := b.download(t.Context(), b.lists[0])
|
|
if err != nil {
|
|
t.Fatalf("first download: %v", err)
|
|
}
|
|
|
|
p.answer(http.StatusOK, tt.body)
|
|
|
|
err = b.download(t.Context(), b.lists[0])
|
|
if err == nil {
|
|
t.Fatal("second download returned no error")
|
|
}
|
|
|
|
if len(lookup(t, b, onionooExitAddr)) != 1 {
|
|
t.Errorf("%s is no longer listed", onionooExitAddr)
|
|
}
|
|
|
|
onDisk, err := fs.ReadFile(os.DirFS(dir), testListName)
|
|
if err != nil || string(onDisk) != onionooBody {
|
|
t.Errorf("copy on disk %q (%v), want the good version", onDisk, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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", "protonvpn", "protonvpn_entry",
|
|
"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) {
|
|
t.Helper()
|
|
|
|
copies := os.DirFS(dir)
|
|
|
|
onDisk, err := fs.ReadFile(copies, testListName)
|
|
if err != nil || string(onDisk) != goodListBody {
|
|
t.Errorf("copy on disk %q (%v), want %q", onDisk, err, goodListBody)
|
|
}
|
|
|
|
_, err = fs.Stat(copies, testListName+downloadSuffix)
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
t.Errorf("the failed download's file is still there (%v)", err)
|
|
}
|
|
}
|
|
|
|
// commentLines is an endless io.Reader of comment lines. It lets a test send
|
|
// a large list file without holding the file in memory.
|
|
type commentLines struct {
|
|
offset int
|
|
}
|
|
|
|
func (c *commentLines) Read(p []byte) (int, error) {
|
|
const line = "# a comment line that pads a large list file\n"
|
|
|
|
for filled := 0; filled < len(p); {
|
|
copied := copy(p[filled:], line[c.offset:])
|
|
filled += copied
|
|
c.offset = (c.offset + copied) % len(line)
|
|
}
|
|
|
|
return len(p), nil
|
|
}
|
|
|
|
// largeListFile returns a reader of a list file of size bytes: comment lines,
|
|
// then newListedAddr on the last line.
|
|
func largeListFile(size int64) io.Reader {
|
|
lastLine := "\n" + newListedAddr + "\n"
|
|
|
|
return io.MultiReader(
|
|
io.LimitReader(&commentLines{}, size-int64(len(lastLine))),
|
|
strings.NewReader(lastLine),
|
|
)
|
|
}
|
|
|
|
// TestLargeFileUnderTheSizeLimitIsAccepted replaces a list with a 199 MiB
|
|
// file. The address on its last line is listed, so the whole file was read.
|
|
func TestLargeFileUnderTheSizeLimitIsAccepted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const size = 199 << 20
|
|
|
|
b := newTestIPLists(t.TempDir(), testList("", FormatLines))
|
|
|
|
err := b.replaceCopy(b.lists[0], largeListFile(size), validators{})
|
|
if err != nil {
|
|
t.Fatalf("replaceCopy: %v", err)
|
|
}
|
|
|
|
if listings := lookup(t, b, newListedAddr); len(listings) != 1 {
|
|
t.Errorf("listings %+v, want one", listings)
|
|
}
|
|
}
|
|
|
|
// TestFileOverTheSizeLimitKeepsThePreviousCopy downloads a good version of a
|
|
// list, then replaces it with a file one byte over the limit. The attempt
|
|
// fails, and the good copy stays in memory and on disk.
|
|
func TestFileOverTheSizeLimitKeepsThePreviousCopy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
_, url := newPublisher(t, goodListBody)
|
|
b := newTestIPLists(dir, testList(url, FormatLines))
|
|
|
|
err := b.download(t.Context(), b.lists[0])
|
|
if err != nil {
|
|
t.Fatalf("download: %v", err)
|
|
}
|
|
|
|
err = b.replaceCopy(b.lists[0], largeListFile(maxDownloadSize+1), validators{})
|
|
if !errors.Is(err, errTooLarge) {
|
|
t.Fatalf("replaceCopy: %v, want %v", err, errTooLarge)
|
|
}
|
|
|
|
if listings := lookup(t, b, listedAddr); len(listings) != 1 {
|
|
t.Errorf("listings of %s %+v, want one", listedAddr, listings)
|
|
}
|
|
|
|
checkGoodCopyOnDisk(t, dir)
|
|
}
|
|
|
|
// TestRestartLoadsCopiesFromDisk downloads a list, then starts a new
|
|
// IPLists on the same directory, as a restart does. The new one answers
|
|
// from the copy on disk at once, and does not download the list again while
|
|
// the copy is younger than the list's interval.
|
|
func TestRestartLoadsCopiesFromDisk(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
p, url := newPublisher(t, goodListBody)
|
|
list := testList(url, FormatLines)
|
|
|
|
first := newTestIPLists(dir, list)
|
|
|
|
err := first.download(t.Context(), list)
|
|
if err != nil {
|
|
t.Fatalf("download: %v", err)
|
|
}
|
|
|
|
downloaded := lookup(t, first, listedAddr)[0].Updated
|
|
|
|
second := newTestIPLists(dir, list)
|
|
second.Start(t.Context())
|
|
second.Stop(t.Context())
|
|
|
|
// The download takes its time after writing the file, and the restart
|
|
// reads the file's time. A busy host can put seconds between the two.
|
|
const maxUpdatedDifference = 15 * time.Second
|
|
|
|
listings := lookup(t, second, listedAddr)
|
|
if len(listings) != 1 ||
|
|
listings[0].Updated.Sub(downloaded).Abs() > maxUpdatedDifference {
|
|
t.Errorf("after the restart, listings %+v, want one updated at %s",
|
|
listings, downloaded)
|
|
}
|
|
|
|
if got := len(p.requests()); got != 1 {
|
|
t.Errorf("the publisher had %d requests, want 1", got)
|
|
}
|
|
}
|
|
|
|
// TestStartRefreshesAnOldCopy starts on a copy older than its list's
|
|
// interval. The list is downloaded at once, with routewatch's User-Agent and
|
|
// the Last-Modified and ETag the publisher sent with the copy, kept on disk.
|
|
// The publisher answers that the copy is still current, so the copy is kept
|
|
// and marked as updated now.
|
|
func TestStartRefreshesAnOldCopy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
p, url := newPublisher(t, goodListBody)
|
|
list := testList(url, FormatLines)
|
|
|
|
lastModified := time.Now().Add(-3 * list.Interval).UTC().Truncate(time.Second)
|
|
p.publish(goodListBody, lastModified, testETag)
|
|
|
|
err := newTestIPLists(dir, list).download(t.Context(), list)
|
|
if err != nil {
|
|
t.Fatalf("download: %v", err)
|
|
}
|
|
|
|
old := time.Now().Add(-2 * list.Interval).UTC().Truncate(time.Second)
|
|
|
|
err = os.Chtimes(filepath.Join(dir, testListName), old, old)
|
|
if err != nil {
|
|
t.Fatalf("Chtimes: %v", err)
|
|
}
|
|
|
|
b := newTestIPLists(dir, list)
|
|
b.Start(t.Context())
|
|
|
|
deadline := time.Now().Add(maxWait)
|
|
for !lookup(t, b, listedAddr)[0].Updated.After(old) {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("the copy was not marked updated within %s", maxWait)
|
|
}
|
|
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
b.Stop(t.Context())
|
|
|
|
requests := p.requests()
|
|
last := requests[len(requests)-1]
|
|
|
|
want := lastModified.Format(http.TimeFormat)
|
|
if got := last.Get(ifModifiedSinceHeader); got != want {
|
|
t.Errorf("If-Modified-Since %q, want %q", got, want)
|
|
}
|
|
|
|
if got := last.Get(ifNoneMatchHeader); got != testETag {
|
|
t.Errorf("If-None-Match %q, want %q", got, testETag)
|
|
}
|
|
|
|
if got := last.Get(userAgentHeader); got != userAgent {
|
|
t.Errorf("User-Agent %q, want %q", got, userAgent)
|
|
}
|
|
}
|
|
|
|
// TestNewVersionOlderThanTheLastDownloadReplacesTheCopy downloads a list.
|
|
// The publisher then puts out a new version whose Last-Modified is earlier
|
|
// than that download, as when a cache in front of the publisher passes the
|
|
// version on late. The next download sends the Last-Modified of the copy,
|
|
// not the time of the download, so it gets the new version.
|
|
func TestNewVersionOlderThanTheLastDownloadReplacesTheCopy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
p, url := newPublisher(t, goodListBody)
|
|
list := testList(url, FormatLines)
|
|
b := newTestIPLists(t.TempDir(), list)
|
|
|
|
firstVersion := time.Now().Add(-2 * list.Interval).UTC().Truncate(time.Second)
|
|
p.publish(goodListBody, firstVersion, "")
|
|
|
|
err := b.download(t.Context(), list)
|
|
if err != nil {
|
|
t.Fatalf("first download: %v", err)
|
|
}
|
|
|
|
p.publish(newListBody, firstVersion.Add(list.Interval), "")
|
|
|
|
err = b.download(t.Context(), list)
|
|
if err != nil {
|
|
t.Fatalf("second download: %v", err)
|
|
}
|
|
|
|
if listings := lookup(t, b, newListedAddr); len(listings) != 1 {
|
|
t.Errorf("%s: listings %+v, want one from the new version",
|
|
newListedAddr, listings)
|
|
}
|
|
|
|
want := firstVersion.Format(http.TimeFormat)
|
|
if got := p.requests()[1].Get(ifModifiedSinceHeader); got != want {
|
|
t.Errorf("If-Modified-Since %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// TestDownloadWithoutLastModifiedOrETagSendsNoCondition downloads a list
|
|
// twice from a publisher that sends neither Last-Modified nor ETag. Neither
|
|
// request carries If-Modified-Since or If-None-Match, so the second download
|
|
// gets the whole list again.
|
|
func TestDownloadWithoutLastModifiedOrETagSendsNoCondition(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
p, url := newPublisher(t, goodListBody)
|
|
list := testList(url, FormatLines)
|
|
b := newTestIPLists(t.TempDir(), list)
|
|
|
|
err := b.download(t.Context(), list)
|
|
if err != nil {
|
|
t.Fatalf("first download: %v", err)
|
|
}
|
|
|
|
err = b.download(t.Context(), list)
|
|
if err != nil {
|
|
t.Fatalf("second download: %v", err)
|
|
}
|
|
|
|
for i, request := range p.requests() {
|
|
if request.Get(ifModifiedSinceHeader) != "" ||
|
|
request.Get(ifNoneMatchHeader) != "" {
|
|
t.Errorf("request %d has a condition: %v", i, request)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStopCancelsADownloadInProgress stops while the publisher has not
|
|
// answered. Stop cancels the download and returns once the download
|
|
// goroutine has stopped.
|
|
func TestStopCancelsADownloadInProgress(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
arrived := make(chan struct{})
|
|
|
|
var once sync.Once
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(
|
|
func(_ http.ResponseWriter, r *http.Request) {
|
|
once.Do(func() { close(arrived) })
|
|
<-r.Context().Done()
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
b := newTestIPLists(t.TempDir(), testList(srv.URL, FormatLines))
|
|
b.Start(t.Context())
|
|
|
|
select {
|
|
case <-arrived:
|
|
case <-time.After(maxWait):
|
|
t.Fatalf("no download began within %s", maxWait)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), maxWait)
|
|
defer cancel()
|
|
|
|
b.Stop(ctx)
|
|
|
|
select {
|
|
case <-b.done:
|
|
default:
|
|
t.Error("Stop returned before the download goroutine stopped")
|
|
}
|
|
}
|
|
|
|
// TestTorExitsListHoldsIPv6Exits downloads an Onionoo document as the public
|
|
// Tor exit list, which is what an IPv6 address's is_tor answer comes from.
|
|
func TestTorExitsListHoldsIPv6Exits(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
index := slices.IndexFunc(Lists(), func(list List) bool {
|
|
return list.Name == TorExitsList
|
|
})
|
|
if index < 0 {
|
|
t.Fatalf("no list named %s", TorExitsList)
|
|
}
|
|
|
|
list := Lists()[index]
|
|
_, list.URL = newPublisher(t, onionooBody)
|
|
b := newTestIPLists(t.TempDir(), list)
|
|
|
|
err := b.download(t.Context(), list)
|
|
if err != nil {
|
|
t.Fatalf("download: %v", err)
|
|
}
|
|
|
|
listings := lookup(t, b, onionooIPv6ExitAddr)
|
|
if len(listings) != 1 || listings[0].Name != TorExitsList {
|
|
t.Errorf("listings %+v, want one for %s", listings, TorExitsList)
|
|
}
|
|
}
|
|
|
|
// TestPublicListsAreComplete checks that every public list has what a
|
|
// download and a lookup answer need, and a unique name.
|
|
func TestPublicListsAreComplete(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
names := make(map[string]bool)
|
|
|
|
for _, list := range Lists() {
|
|
if list.Name == "" || list.Description == "" || list.URL == "" ||
|
|
list.Interval <= 0 || names[list.Name] {
|
|
t.Errorf("list %+v is incomplete or has a name used before", list)
|
|
}
|
|
|
|
names[list.Name] = true
|
|
}
|
|
}
|