Take the Tor exit list from Onionoo, which has the IPv6 exits (closes #234)
check / check (push) Failing after 1m50s

`check.torproject.org` lists IPv4 exits only, so `is_tor` was false for an IPv6 exit. `tor_exits` now comes from Onionoo through a new format that reads `relays[].exit_addresses` and `relays[].or_addresses`. IPv6 addresses come only from `or_addresses`, the address a relay accepts Tor connections on, so an exit leaving from another IPv6 address still has `is_tor` false.

Judgement call: a document with no addresses fails the parse.
Rule suppressed: `tagliatelle` on the struct holding Onionoo's field names.

Model: opus-5-5
This commit was merged in pull request #243.
This commit is contained in:
clawbot committed 2026-11-10 11:47:56 +01:00
1 parent cf50d195ad
commit 3204b7ee4b
6 files changed
+245 -39

No files matched your search

+3 -2
View File
@@ -424,8 +424,9 @@ The lists:
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/),
downloaded once a day. downloaded once a day.
- `tor_exits`: Tor exit nodes, where traffic from the Tor network leaves. From - `tor_exits`: the IPv4 and IPv6 addresses of Tor exit nodes, where traffic from
the [Tor Project](https://check.torproject.org/torbulkexitlist), downloaded the Tor network leaves. From the Tor Project's
[Onionoo](https://metrics.torproject.org/onionoo.html) service, downloaded
every hour. every hour.
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
+3
View File
@@ -53,6 +53,9 @@ problem the run finds.
# Completed Steps # Completed Steps
- 2026-11-10: `tor_exits` comes from Onionoo, so it holds IPv6 exits and
`is_tor` is true for them (closes
https://git.eeqj.de/sneak/routewatch/issues/234).
- 2026-11-10: each result of the IP lookup has a `flags` object with `is_tor` - 2026-11-10: each result of the IP lookup has a `flags` object with `is_tor`
and `is_bogon`. The public IP lists are named `ip_lists` in the answer, and `is_bogon`. The public IP lists are named `ip_lists` in the answer,
`internal/iplist` in the code and `iplists` in the state directory. A list `internal/iplist` in the code and `iplists` in the state directory. A list
+5
View File
@@ -29,6 +29,11 @@ const (
// start with the network's first and last address. Lines that start // start with the network's first and last address. Lines that start
// with # are comments. // with # are comments.
FormatDShield FormatDShield
// FormatOnionooJSON is one Onionoo details document. Its relays have
// addresses in two fields: exit_addresses, which has IPv4 only, and
// or_addresses, which has IPv4 and IPv6, each with a port.
FormatOnionooJSON
) )
// List is one public IP list. // List is one public IP list.
+128 -16
View File
@@ -11,6 +11,7 @@ import (
"net/netip" "net/netip"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -28,6 +29,20 @@ const (
newListBody = "# A test list\n" + newListedAddr + "\n" 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":[]}`
)
// 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"`
@@ -181,6 +196,16 @@ func TestLookupFindsListedAddresses(t *testing.T) {
listed: []string{"192.0.2.0", "192.0.2.77"}, listed: []string{"192.0.2.0", "192.0.2.77"},
unlisted: []string{"192.0.3.1", "2001:db8::1"}, 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"},
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -195,26 +220,34 @@ func TestLookupFindsListedAddresses(t *testing.T) {
t.Fatalf("download: %v", err) t.Fatalf("download: %v", err)
} }
for _, addr := range tt.listed { checkLookups(t, b, tt.listed, tt.unlisted)
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 tt.unlisted {
listings := lookup(t, b, addr)
if listings == nil || len(listings) != 0 {
t.Errorf("%s: listings %#v, want an empty list", addr, listings)
}
}
}) })
} }
} }
// 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, // TestFailedDownloadKeepsThePreviousCopy downloads a good version of a list,
// then one that fails. The good copy stays in memory and on disk, and no // then one that fails. The good copy stays in memory and on disk, and no
// part of the failed download is left beside it. // part of the failed download is left beside it.
@@ -263,6 +296,58 @@ func TestFailedDownloadKeepsThePreviousCopy(t *testing.T) {
} }
} }
// 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", "<!DOCTYPE html>\n<html></html>\n"},
{"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)
}
})
}
}
// 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) {
@@ -555,6 +640,33 @@ func TestStopCancelsADownloadInProgress(t *testing.T) {
} }
} }
// 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 // TestPublicListsAreComplete checks that every public list has what a
// download and a lookup answer need, and a unique name. // download and a lookup answer need, and a unique name.
func TestPublicListsAreComplete(t *testing.T) { func TestPublicListsAreComplete(t *testing.T) {
+7 -5
View File
@@ -28,8 +28,9 @@ const (
// https://www.spamhaus.org/blocklists/do-not-route-or-peer/ // https://www.spamhaus.org/blocklists/do-not-route-or-peer/
spamhausDROPInterval = 24 * time.Hour spamhausDROPInterval = 24 * time.Hour
// Tor publishes a new exit list every hour and sets no download limit: // Onionoo's relay data follows Tor's hourly network consensus, and
// https://collector.torproject.org/recent/exit-lists/ // Onionoo sets no download limit:
// https://metrics.torproject.org/onionoo.html
torExitsInterval = time.Hour torExitsInterval = time.Hour
) )
@@ -88,9 +89,10 @@ func Lists() []List {
{ {
Name: TorExitsList, Name: TorExitsList,
Description: "A Tor exit node, where traffic from the Tor network leaves", Description: "A Tor exit node, where traffic from the Tor network leaves",
URL: "https://check.torproject.org/torbulkexitlist", URL: "https://onionoo.torproject.org/details?" +
Interval: torExitsInterval, "flag=Exit&running=true&fields=exit_addresses,or_addresses",
Format: FormatLines, Interval: torExitsInterval,
Format: FormatOnionooJSON,
}, },
} }
} }
+99 -16
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/netip" "net/netip"
"strings" "strings"
@@ -32,12 +33,32 @@ 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")
) )
// 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
// holds. A line it cannot read fails the whole file, so a file that is not // holds. Anything it cannot read fails the whole file, so a file that is not
// the list, such as an error page, never replaces a good copy. // the list, such as an error page, never replaces a good copy.
func parse(r io.Reader, format Format) (*netipx.IPSet, error) { func parse(r io.Reader, format Format) (*netipx.IPSet, error) {
switch format {
case FormatLines:
return parseLines(r, addAddressOrNetwork)
case FormatSpamhausJSON:
return parseLines(r, addSpamhausNetwork)
case FormatDShield:
return parseLines(r, addDShieldNetwork)
case FormatOnionooJSON:
return parseOnionoo(r)
default:
return nil, errUnknownFormat
}
}
// parseLines reads a list file line by line, skipping blank lines and
// comments, and calls addLine with each other line.
func parseLines(
r io.Reader, addLine func(*netipx.IPSetBuilder, string) error,
) (*netipx.IPSet, error) {
var builder netipx.IPSetBuilder var builder netipx.IPSetBuilder
scanner := bufio.NewScanner(r) scanner := bufio.NewScanner(r)
@@ -48,7 +69,7 @@ func parse(r io.Reader, format Format) (*netipx.IPSet, error) {
continue continue
} }
err := addLine(&builder, line, format) err := addLine(&builder, line)
if err != nil { if err != nil {
return nil, fmt.Errorf("line %d: %w", lineNumber, err) return nil, fmt.Errorf("line %d: %w", lineNumber, err)
} }
@@ -62,20 +83,6 @@ func parse(r io.Reader, format Format) (*netipx.IPSet, error) {
return builder.IPSet() return builder.IPSet()
} }
// addLine adds the address or network on one line of a list file to builder.
func addLine(builder *netipx.IPSetBuilder, line string, format Format) error {
switch format {
case FormatLines:
return addAddressOrNetwork(builder, line)
case FormatSpamhausJSON:
return addSpamhausNetwork(builder, line)
case FormatDShield:
return addDShieldNetwork(builder, line)
default:
return errUnknownFormat
}
}
// addAddressOrNetwork adds an address, such as 192.0.2.1, or a network, such // addAddressOrNetwork adds an address, such as 192.0.2.1, or a network, such
// as 192.0.2.0/24, to builder. // as 192.0.2.0/24, to builder.
func addAddressOrNetwork(builder *netipx.IPSetBuilder, text string) error { func addAddressOrNetwork(builder *netipx.IPSetBuilder, text string) error {
@@ -156,3 +163,79 @@ func addDShieldNetwork(builder *netipx.IPSetBuilder, line string) error {
return nil return nil
} }
// parseOnionoo returns every address in the exit_addresses and or_addresses
// fields of the relays in an Onionoo details document. A document with no
// addresses fails, because the Tor network always has relays.
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
var details struct {
Relays []struct {
ExitAddresses []string `json:"exit_addresses"`
ORAddresses []string `json:"or_addresses"`
} `json:"relays"`
}
err = json.Unmarshal(data, &details)
if err != nil {
return nil, err
}
var builder netipx.IPSetBuilder
for _, relay := range details.Relays {
for _, text := range relay.ExitAddresses {
err = addAddress(&builder, text)
if err != nil {
return nil, err
}
}
for _, text := range relay.ORAddresses {
err = addAddressWithPort(&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.
func addAddress(builder *netipx.IPSetBuilder, text string) error {
addr, err := netip.ParseAddr(text)
if err != nil {
return err
}
builder.Add(addr)
return nil
}
// addAddressWithPort adds the address of text, such as 192.0.2.1:443 or
// [2001:db8::1]:443, to builder. Onionoo allows a list of ports after the
// address, so the part after the address is not read.
func addAddressWithPort(builder *netipx.IPSetBuilder, text string) error {
host, _, err := net.SplitHostPort(text)
if err != nil {
return err
}
return addAddress(builder, host)
}