Author SHA1 Message Date
clawbot e2707b5e41 Add the ProtonVPN server networks and addresses to the IP lookup (closes #236)
check / check (push) Successful in 1m23s
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
2026-11-10 13:03:40 +00:00
clawbot 0cbd9cd943 Resolve PTR names over DNS-over-HTTPS and cache them for the record TTL (closes #238)
check / check (push) Successful in 4m39s
`ptr` keeps its field and shape. It now comes from Google, Cloudflare, AdGuard and Quad9 over RFC 8484, tried in turn within 2 seconds, in place of the host's resolver with no cache. Answers stay in memory and in a new `cache.db` for the record's TTL, at most 250,000 addresses. A reserved address is not sent to any resolver and has an empty `ptr`.

Judgement call: each resolver gets an equal share of the time left, so a hung one does not use up the limit.
Judgement call: the negative TTL is the smaller of the SOA minimum and the SOA's own TTL.
Deviation: the cache is SQLite, by sneak's words, not the JSON file the standing rule asks for.

Model: opus-5-5
2026-11-10 13:48:06 +01:00
clawbot 641798bb22 Make the tests pass on a busy host (closes #240)
check / check (push) Successful in 1m51s
Tests that wait on the real clock failed when the host stalled for a few seconds, although the code was right. Waits for something that should happen are now mostly 15 seconds. Tests whose result depends on a short idle timeout or a production request limit run under `synctest.Test`, on a fake clock, with a fake transport where a real connection would stop that clock. Test code only.

Judgement call: the 5-second waits in three `internal/streamer` tests also became 15 seconds.

Model: opus-5-5
2026-11-10 12:48:07 +01:00
clawbot 28ef0275ba Add the public VPN server lists, with is_vpn and vpn_providers (closes #235)
check / check (push) Failing after 4m35s
The IP lookup now checks the public VPN server lists of Mullvad, NordVPN, Private Internet Access and IVPN, and X4BNet's community list, each downloaded once a day. `List` gains `VPN`. Every result gains `flags.is_vpn` and `vpn_providers`, which is empty when no list holds the address.

Judgement call: X4BNet's IPv4 and IPv6 files are two lists, `x4bnet` and `x4bnet_v6`, as Spamhaus's are.
Judgement call: Private Internet Access's signature is neither read nor checked.
Judgement call: a document with no addresses fails the parse.
Rule suppressed: `tagliatelle` on the structs holding the publishers' field names.

Model: opus-5-5
2026-11-10 12:15:32 +01:00
31 changed files with 2424 additions and 255 deletions

No files matched your search

+3 -2
View File
@@ -93,11 +93,12 @@
**/*.dylib **/*.dylib
# Local databases. The image carries a source archive of the whole build # Local databases. The image carries a source archive of the whole build
# context, so these would otherwise ship inside it. The daemon's database # context, so these would otherwise ship inside it. The daemon's databases
# is db.sqlite, with its -wal and -shm files while it runs. # are db.sqlite and cache.db, with their -wal and -shm files while it runs.
**/*.db **/*.db
**/*.db-journal **/*.db-journal
**/*.db-wal **/*.db-wal
**/*.db-shm
**/*.sqlite **/*.sqlite
**/*.sqlite-wal **/*.sqlite-wal
**/*.sqlite-shm **/*.sqlite-shm
+3 -1
View File
@@ -68,10 +68,12 @@ go.work
go.work.sum go.work.sum
# Local databases, temporary files and debug output. The daemon's # Local databases, temporary files and debug output. The daemon's
# database is db.sqlite, with its -wal and -shm files while it runs. # databases are db.sqlite and cache.db, with their -wal and -shm files
# while it runs.
*.db *.db
*.db-journal *.db-journal
*.db-wal *.db-wal
*.db-shm
*.sqlite *.sqlite
*.sqlite-wal *.sqlite-wal
*.sqlite-shm *.sqlite-shm
+70 -6
View File
@@ -281,8 +281,8 @@ answer holds `query`, the text looked up, and `results`, with one entry for each
address. Every entry has the same fields: address. Every entry has the same fields:
- `ip`: the address - `ip`: the address
- `ptr`: the address's PTR names, the host names that DNS gives for it, or an - `ptr`: the address's PTR names, the host names that DNS gives for it (see PTR
empty list when it has none lookups), or an 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 - `is_vpn`: true when the address is on any list of VPN servers (see IP
@@ -334,9 +334,6 @@ address, such as one that resolves to a private address, is reported as
- `{ip}` comes to more than 10 addresses once its names are resolved - `{ip}` comes to more than 10 addresses once its names are resolved
- nothing in `{ip}` is an IP address or a host name with a public address - nothing in `{ip}` is an IP address or a host name with a public address
Each PTR lookup gets 2 seconds. An address whose lookup takes longer is answered
without PTR names.
A successful answer carries `Cache-Control` with a `max-age` of 15 minutes, the A successful answer carries `Cache-Control` with a `max-age` of 15 minutes, the
shortest IP list interval. The answer for the caller's own address (`/ip/`) is shortest IP list interval. The answer for the caller's own address (`/ip/`) is
`private`, and the others are `public`. `private`, and the others are `public`.
@@ -434,6 +431,14 @@ The lists:
- `pia`: VPN servers run by Private Internet Access. From - `pia`: VPN servers run by Private Internet Access. From
[Private Internet Access](https://serverlist.piaservers.net/vpninfo/servers/v6), [Private Internet Access](https://serverlist.piaservers.net/vpninfo/servers/v6),
downloaded once a day. downloaded once a day.
- `protonvpn`: the /24 networks that hold ProtonVPN servers. The whole /24 is
listed because the ProtonVPN-IPs README says traffic leaves from unpublished
addresses in it. From the public
[ProtonVPN-IPs](https://github.com/tn3w/ProtonVPN-IPs) mirror of Proton's
server list, downloaded once a day.
- `protonvpn_entry`: the IPv4 and IPv6 addresses of ProtonVPN servers. From the
same [ProtonVPN-IPs](https://github.com/tn3w/ProtonVPN-IPs) mirror, 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/),
@@ -465,6 +470,45 @@ How RouteWatch keeps the lists:
- When a download fails, or its file does not parse, RouteWatch keeps the - When a download fails, or its file does not parse, RouteWatch keeps the
previous copy and logs the failure. It tries again one interval later. previous copy and logs the failure. It tries again one interval later.
## PTR lookups
An IP lookup finds each address's PTR names over DNS-over-HTTPS. It asks these
public resolvers in order, until one answers:
- Google: `https://dns.google/dns-query`
- Cloudflare: `https://cloudflare-dns.com/dns-query`
- AdGuard: `https://unfiltered.adguard-dns.com/dns-query`
- Quad9: `https://dns.quad9.net/dns-query`
One address's lookup gets 2 seconds across all four. Each resolver gets an equal
share of the time left, so one that does not answer leaves time for the next.
When none answers in time, the address's `ptr` is an empty list.
A reserved address, one whose `is_bogon` flag is true, is not sent to the
resolvers. Its `ptr` is an empty list.
What goes outside: each resolver asked receives the address being looked up. For
`/ip/` and the home page, that address is the visitor's own. A lookup answered
from the cache sends nothing.
How RouteWatch keeps the answers:
- It holds answers in memory. An answer that has not expired is served from
there.
- It writes new answers every 5 seconds to `cache.db` in the state directory
(see Configuration), and loads them from there at startup.
- An answer expires after the TTL of its records, the shortest one when there
are several. RouteWatch keeps it for at least 60 seconds and at most 24 hours.
- When an address has no PTR names, that answer expires after the SOA record's
minimum field, or the record's own TTL when that is shorter. RouteWatch keeps
it for at most 1 hour.
- An answer with no PTR names and no SOA record is not kept. Neither is one
whose SOA record has a TTL or minimum field of 0.
- A failed lookup is not kept, so the next lookup of the address asks again.
- Every 10 minutes it deletes expired answers from memory and from `cache.db`.
- It keeps answers for at most 250,000 addresses, and keeps no answer for a new
address while it holds that many.
## Configuration ## Configuration
RouteWatch reads its settings from environment variables. It takes no RouteWatch reads its settings from environment variables. It takes no
@@ -513,7 +557,13 @@ by the client. For a request from any other address, they use the address the
request came from, whatever headers it carries. `X-Real-IP` and `True-Client-IP` request came from, whatever headers it carries. `X-Real-IP` and `True-Client-IP`
are not read. are not read.
The state directory holds the database, `db.sqlite`, and the IP list copies: The state directory holds:
- `db.sqlite`: the database
- `cache.db`: the answers of PTR lookups
- `iplists`: the IP list copies
It is at:
- macOS: `~/Library/Application Support/berlin.sneak.app.routewatch/` - macOS: `~/Library/Application Support/berlin.sneak.app.routewatch/`
- Linux and the BSDs, as root: `/var/lib/berlin.sneak.app.routewatch/` - Linux and the BSDs, as root: `/var/lib/berlin.sneak.app.routewatch/`
@@ -670,6 +720,8 @@ The parts:
with old or missing details asks the fetcher for it at once, without the wait. with old or missing details asks the fetcher for it at once, without the wait.
- The IP lists part downloads the IP lists and answers which lists hold an - The IP lists part downloads the IP lists and answers which lists hold an
address. address.
- The PTR cache looks up the PTR names of addresses and keeps the answers (see
PTR lookups).
- The database maintainer checkpoints the database every 5 seconds: it folds the - The database maintainer checkpoints the database every 5 seconds: it folds the
write-ahead log, where SQLite first writes changes, into the database file. It write-ahead log, where SQLite first writes changes, into the database file. It
reclaims free space every 10 minutes. Every hour it refreshes the statistics reclaims free space every 10 minutes. Every hour it refreshes the statistics
@@ -779,6 +831,7 @@ On `SIGTERM` or `SIGINT` the daemon stops its parts in this order:
4. the database maintainer 4. the database maintainer
5. the IP list downloads 5. the IP list downloads
6. the HTTP server 6. the HTTP server
7. the PTR cache, which writes what it holds
Last, the daemon checkpoints and closes the database. It gives all this 8 Last, the daemon checkpoints and closes the database. It gives all this 8
seconds and then exits with status 0. A stop that is still running after 9 seconds and then exits with status 0. A stop that is still running after 9
@@ -799,6 +852,8 @@ What a stop can lose:
- When the 8 seconds run out, the streamer and the handlers log what they could - When the 8 seconds run out, the streamer and the handlers log what they could
not hand over or write. The database is then not closed. When it next opens, not hand over or write. The database is then not closed. When it next opens,
SQLite rolls back any write the exit cut off. SQLite rolls back any write the exit cut off.
- When the 8 seconds run out, the PTR answers not yet in `cache.db` are lost.
RouteWatch asks the resolvers for them again the next time they are needed.
### Code layout ### Code layout
@@ -812,11 +867,13 @@ routewatch/
│ └── message-examples.json # Examples of RIS Live messages │ └── message-examples.json # Examples of RIS Live messages
├── internal/ ├── internal/
│ ├── bogon/ # Reserved prefixes and AS numbers │ ├── bogon/ # Reserved prefixes and AS numbers
│ ├── cachedb/ # cache.db, the caches of outside answers
│ ├── config/ # Settings from the environment │ ├── config/ # Settings from the environment
│ ├── database/ # SQLite storage │ ├── database/ # SQLite storage
│ ├── iplist/ # Public IP lists: downloads and lookups │ ├── iplist/ # Public IP lists: downloads and lookups
│ ├── logger/ # Structured logging │ ├── logger/ # Structured logging
│ ├── metrics/ # Feed and route update figures │ ├── metrics/ # Feed and route update figures
│ ├── ptr/ # PTR lookups over DNS-over-HTTPS, and their cache
│ ├── ristypes/ # The RIS Live message types │ ├── ristypes/ # The RIS Live message types
│ ├── routetable/ # The whole routing table in memory │ ├── routetable/ # The whole routing table in memory
│ ├── routewatch/ # The daemon: startup, handlers, WHOIS fetcher │ ├── routewatch/ # The daemon: startup, handlers, WHOIS fetcher
@@ -865,6 +922,13 @@ announcements(id PRIMARY KEY, prefix_id, peer_asn, origin_asn, path,
The indexes are in `internal/database/schema.sql`. The indexes are in `internal/database/schema.sql`.
`cache.db` is a separate file, with one table:
```sql
-- The answers of PTR lookups. names is empty for an address with no PTR names.
hostnames(address PRIMARY KEY, names, expires_at)
```
## Development ## Development
The `make` targets: The `make` targets:
+10
View File
@@ -53,6 +53,16 @@ problem the run finds.
# Completed Steps # Completed Steps
- 2026-11-10: the IP lookup checks the `protonvpn` list of ProtonVPN's /24
networks and the `protonvpn_entry` list of its servers' IPv4 and IPv6
addresses, from a public mirror of Proton's server list (closes
https://git.eeqj.de/sneak/routewatch/issues/236).
- 2026-11-10: the IP lookup finds PTR names over DNS-over-HTTPS and caches them
for the record's TTL, in memory and in `cache.db` (closes
https://git.eeqj.de/sneak/routewatch/issues/238).
- 2026-11-10: the tests no longer fail when a busy host stalls for several
seconds. Most such waits are now 15 seconds. The test of the stream's idle
timeout runs on a fake clock (closes #240)
- 2026-11-10: the IP lookup checks the public VPN server lists of Mullvad, - 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 NordVPN, Private Internet Access and IVPN, and X4BNet's community list. Each
result has `flags.is_vpn` and `vpn_providers` (closes result has `flags.is_vpn` and `vpn_providers` (closes
+1 -1
View File
@@ -12,7 +12,7 @@ import (
// parseTimeLimit is how long a test waits for parseCSV to return before it // parseTimeLimit is how long a test waits for parseCSV to return before it
// fails. // fails.
const parseTimeLimit = 2 * time.Second const parseTimeLimit = 15 * time.Second
// TestParseCSVReadsTodaysLayout parses an as.csv with the country code column // TestParseCSVReadsTodaysLayout parses an as.csv with the country code column
// that the source added after the description. It expects each AS with its // that the source added after the description. It expects each AS with its
+4 -2
View File
@@ -9,10 +9,11 @@ require (
github.com/google/btree v1.1.3 github.com/google/btree v1.1.3
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.29 github.com/mattn/go-sqlite3 v1.14.29
github.com/miekg/dns v1.1.73
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9
go.uber.org/fx v1.24.0 go.uber.org/fx v1.24.0
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb go4.org/netipx v0.0.0-20260823151212-3075585bcbeb
golang.org/x/term v0.33.0 golang.org/x/term v0.45.0
golang.org/x/text v0.43.0 golang.org/x/text v0.43.0
) )
@@ -20,5 +21,6 @@ require (
go.uber.org/dig v1.19.0 // indirect go.uber.org/dig v1.19.0 // indirect
go.uber.org/multierr v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.26.0 // indirect go.uber.org/zap v1.26.0 // indirect
golang.org/x/sys v0.34.0 // indirect golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
) )
+10 -4
View File
@@ -12,6 +12,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ= github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ=
github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg=
@@ -30,10 +32,14 @@ go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs= go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs=
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+11 -2
View File
@@ -1,5 +1,5 @@
// Package bogon tells whether a prefix or an AS number is reserved: set aside // Package bogon tells whether a prefix, an address or an AS number is reserved:
// for a special purpose, so that no network should announce it on the // set aside for a special purpose, so that no network should announce it on the
// internet. A route is a bogon announcement when its prefix is reserved or its // internet. A route is a bogon announcement when its prefix is reserved or its
// origin AS is. // origin AS is.
// //
@@ -42,6 +42,15 @@ func Prefix(prefix netip.Prefix) (string, bool) {
return holder.name + " (" + holder.prefix.String() + ")", true return holder.name + " (" + holder.prefix.String() + ")", true
} }
// Addr returns why addr is reserved, and true, when it lies inside a reserved
// block, as Prefix decides. An IPv4 address written in IPv6 form counts as the
// IPv4 address it holds. It returns "" and false when addr is not reserved.
func Addr(addr netip.Addr) (string, bool) {
addr = addr.Unmap()
return Prefix(netip.PrefixFrom(addr, addr.BitLen()))
}
// ASN returns why asn is reserved, and true, when it is. It returns "" and // ASN returns why asn is reserved, and true, when it is. It returns "" and
// false when asn is not reserved. // false when asn is not reserved.
func ASN(asn int) (string, bool) { func ASN(asn int) (string, bool) {
+38 -2
View File
@@ -8,6 +8,9 @@ import (
"git.eeqj.de/sneak/routewatch/internal/bogon" "git.eeqj.de/sneak/routewatch/internal/bogon"
) )
// privateUseTen is why a prefix or an address inside 10.0.0.0/8 is reserved.
const privateUseTen = "Private-Use (10.0.0.0/8)"
func TestPrefix(t *testing.T) { func TestPrefix(t *testing.T) {
t.Parallel() t.Parallel()
@@ -16,8 +19,8 @@ func TestPrefix(t *testing.T) {
// want is why the prefix is reserved, or "" when it is not. // want is why the prefix is reserved, or "" when it is not.
want string want string
}{ }{
{"10.1.2.0/24", "Private-Use (10.0.0.0/8)"}, {"10.1.2.0/24", privateUseTen},
{"10.0.0.0/8", "Private-Use (10.0.0.0/8)"}, {"10.0.0.0/8", privateUseTen},
{"192.0.2.0/24", "Documentation (TEST-NET-1) (192.0.2.0/24)"}, {"192.0.2.0/24", "Documentation (TEST-NET-1) (192.0.2.0/24)"},
{"2001:db8:1::/48", "Documentation (2001:db8::/32)"}, {"2001:db8:1::/48", "Documentation (2001:db8::/32)"},
{"fd00::/8", "Unique-Local (fc00::/7)"}, {"fd00::/8", "Unique-Local (fc00::/7)"},
@@ -56,6 +59,39 @@ func TestPrefix(t *testing.T) {
} }
} }
func TestAddr(t *testing.T) {
t.Parallel()
tests := []struct {
addr string
// want is why the address is reserved, or "" when it is not.
want string
}{
{"10.0.0.1", privateUseTen},
{"fd00::1", "Unique-Local (fc00::/7)"},
// An IPv4 address written in IPv6 form counts as the IPv4 address.
{"::ffff:10.0.0.1", privateUseTen},
{"::ffff:8.8.8.8", ""},
// An address that may be announced, inside a reserved block.
{"192.0.0.9", ""},
// Addresses anyone may announce.
{"8.8.8.8", ""},
{"2001:4860:4860::8888", ""},
}
for _, tc := range tests {
t.Run(tc.addr, func(t *testing.T) {
t.Parallel()
reason, reserved := bogon.Addr(netip.MustParseAddr(tc.addr))
if reason != tc.want || reserved != (tc.want != "") {
t.Errorf("Addr(%s) = %q, %v; want %q", tc.addr, reason,
reserved, tc.want)
}
})
}
}
func TestASN(t *testing.T) { func TestASN(t *testing.T) {
t.Parallel() t.Parallel()
+51
View File
@@ -0,0 +1,51 @@
// Package cachedb opens cache.db, the SQLite file in the state directory that
// holds RouteWatch's caches of answers from outside services. It is separate
// from the routes database so that a cache write never queues behind a write
// to that database.
package cachedb
import (
"context"
"database/sql"
_ "embed" // for schema.sql
"fmt"
"path/filepath"
_ "github.com/mattn/go-sqlite3" // CGO SQLite driver
)
const (
// fileName is the database's file in the state directory.
fileName = "cache.db"
// busyTimeoutMs is how long a write waits for another write to finish.
busyTimeoutMs = 5000
)
//go:embed schema.sql
var schema string
// Open opens cache.db in stateDir, creating the file and its tables when they
// do not exist. The caller closes the database.
func Open(ctx context.Context, stateDir string) (*sql.DB, error) {
dsn := fmt.Sprintf(
"file:%s?_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=%d"+
"&_txlock=immediate",
filepath.Join(stateDir, fileName),
busyTimeoutMs,
)
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("opening the cache database: %w", err)
}
_, err = db.ExecContext(ctx, schema)
if err != nil {
_ = db.Close()
return nil, fmt.Errorf("creating the cache database's tables: %w", err)
}
return db, nil
}
+64
View File
@@ -0,0 +1,64 @@
package cachedb
import (
"os"
"path/filepath"
"testing"
)
// TestOpenCreatesTheFileInWALMode opens a new database, and then opens it
// again, as a restart does.
func TestOpenCreatesTheFileInWALMode(t *testing.T) {
t.Parallel()
dir := t.TempDir()
for range 2 {
db, err := Open(t.Context(), dir)
if err != nil {
t.Fatalf("Open: %v", err)
}
var mode string
err = db.QueryRowContext(t.Context(), "PRAGMA journal_mode").Scan(&mode)
if err != nil {
t.Fatalf("reading the journal mode: %v", err)
}
if mode != "wal" {
t.Errorf("journal mode %q, want wal", mode)
}
var rows int
err = db.QueryRowContext(t.Context(),
"SELECT COUNT(*) FROM hostnames").Scan(&rows)
if err != nil {
t.Fatalf("reading the hostnames table: %v", err)
}
err = db.Close()
if err != nil {
t.Fatalf("Close: %v", err)
}
}
_, err := os.Stat(filepath.Join(dir, fileName))
if err != nil {
t.Errorf("the file is not in the state directory: %v", err)
}
}
// TestOpenFailsWhenTheDirectoryDoesNotExist checks that a state directory that
// is not there is reported, not ignored.
func TestOpenFailsWhenTheDirectoryDoesNotExist(t *testing.T) {
t.Parallel()
db, err := Open(t.Context(), filepath.Join(t.TempDir(), "missing"))
if err == nil {
_ = db.Close()
t.Fatal("Open succeeded in a directory that does not exist")
}
}
+8
View File
@@ -0,0 +1,8 @@
-- hostnames holds the PTR names of addresses. names has the names separated by
-- spaces, and is empty for an address with no PTR names. expires_at is in
-- seconds since 1970.
CREATE TABLE IF NOT EXISTS hostnames (
address TEXT PRIMARY KEY,
names TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
+25 -5
View File
@@ -72,17 +72,34 @@ const (
// lockTestPrefix is the prefix the batch writes in the checkpoint tests // lockTestPrefix is the prefix the batch writes in the checkpoint tests
// store. // store.
lockTestPrefix = "192.0.2.0/24" lockTestPrefix = "192.0.2.0/24"
// longBusyTimeoutMs is the busy timeout setLongBusyTimeout gives the
// writing connection. A checkpoint that waits for an open read takes this
// long.
longBusyTimeoutMs = 30000
// readerCheckpointLimit is how long a checkpoint, and a batch write // readerCheckpointLimit is how long a checkpoint, and a batch write
// waiting for it, may take while a reader is open: well under the busy // waiting for it, may take while a reader is open: well under
// timeout, which a checkpoint that waits for the reader takes. // longBusyTimeoutMs, with room for a stall on a busy host.
readerCheckpointLimit = time.Second readerCheckpointLimit = 15 * time.Second
// truncateCheckpointLimit is how long a checkpoint of a WAL file over // truncateCheckpointLimit is how long a checkpoint of a WAL file over
// sqliteWALSizeLimitBytes may take to give up while a reader is open: its // sqliteWALSizeLimitBytes may take to give up while a reader is open: its
// wait for the reader plus a second, well under the busy timeout. // wait for the reader plus readerCheckpointLimit.
truncateCheckpointLimit = truncateBusyTimeoutMs*time.Millisecond + truncateCheckpointLimit = truncateBusyTimeoutMs*time.Millisecond +
time.Second readerCheckpointLimit
) )
// setLongBusyTimeout gives the writing connection of db longBusyTimeoutMs, so
// that a checkpoint that waits for an open read takes far longer than a stall
// on a busy host.
func setLongBusyTimeout(ctx context.Context, t *testing.T, db *Database) {
t.Helper()
_, err := db.writer.ExecContext(ctx,
fmt.Sprintf("PRAGMA busy_timeout=%d", longBusyTimeoutMs))
if err != nil {
t.Fatalf("failed to set the busy timeout: %v", err)
}
}
// TestConnectionPoolPragmas holds several pooled connections open at once and // TestConnectionPoolPragmas holds several pooled connections open at once and
// checks each one carries the per-connection settings from the DSN, plus the // checks each one carries the per-connection settings from the DSN, plus the
// process-wide heap limits, and sees the new file with auto_vacuum // process-wide heap limits, and sees the new file with auto_vacuum
@@ -579,6 +596,8 @@ func TestCheckpointDoesNotWaitForReader(t *testing.T) {
ctx := context.Background() ctx := context.Background()
ts := time.Now().UTC() ts := time.Now().UTC()
setLongBusyTimeout(ctx, t, db)
err = db.UpdatePrefixesBatch(map[string]time.Time{"198.51.100.0/24": ts}) err = db.UpdatePrefixesBatch(map[string]time.Time{"198.51.100.0/24": ts})
if err != nil { if err != nil {
t.Fatalf("failed to store the prefix the reader reads: %v", err) t.Fatalf("failed to store the prefix the reader reads: %v", err)
@@ -690,6 +709,7 @@ func TestCheckpointEmptiesWALOnceNoReadIsOpen(t *testing.T) {
} }
fillWALPastLimit(ctx, t, db) fillWALPastLimit(ctx, t, db)
setLongBusyTimeout(ctx, t, db)
started := time.Now() started := time.Now()
+5
View File
@@ -51,6 +51,11 @@ const (
// FormatIVPNJSON is IVPN's JSON server list. The hosts of its wireguard // FormatIVPNJSON is IVPN's JSON server list. The hosts of its wireguard
// and openvpn gateways have their addresses in host and v2ray. // and openvpn gateways have their addresses in host and v2ray.
FormatIVPNJSON FormatIVPNJSON
// FormatProtonVPN has one address or network per line, with comment lines
// that start with #. Unlike FormatLines, a file with no addresses or
// networks fails.
FormatProtonVPN
) )
// List is one public IP list. // List is one public IP list.
+61 -2
View File
@@ -70,6 +70,19 @@ const (
"config":{"api":{"ips":["192.0.2.73"]}}}` "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. // errorPage is a file that is no list, as a publisher's error page is.
const errorPage = "<!DOCTYPE html>\n<html></html>\n" const errorPage = "<!DOCTYPE html>\n<html></html>\n"
@@ -267,6 +280,20 @@ func TestLookupFindsVPNServers(t *testing.T) {
listed: []string{"192.0.2.70", "192.0.2.71", "192.0.2.72"}, listed: []string{"192.0.2.70", "192.0.2.71", "192.0.2.72"},
unlisted: []string{"192.0.2.73", "2001:db8::70"}, 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"},
},
}) })
} }
@@ -479,6 +506,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 // TestLookupMarksVPNLists looks up an address on a VPN list and on another
// list. Only the VPN list's listing says it is one. // list. Only the VPN list's listing says it is one.
func TestLookupMarksVPNLists(t *testing.T) { func TestLookupMarksVPNLists(t *testing.T) {
@@ -523,7 +575,10 @@ func TestPublicListsMarkTheVPNLists(t *testing.T) {
} }
} }
want := []string{"ivpn", "mullvad", "nordvpn", "pia", "x4bnet", "x4bnet_v6"} want := []string{
"ivpn", "mullvad", "nordvpn", "pia", "protonvpn", "protonvpn_entry",
"x4bnet", "x4bnet_v6",
}
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
t.Errorf("VPN lists %v, want %v", got, want) t.Errorf("VPN lists %v, want %v", got, want)
} }
@@ -646,9 +701,13 @@ func TestRestartLoadsCopiesFromDisk(t *testing.T) {
second.Start(t.Context()) second.Start(t.Context())
second.Stop(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) listings := lookup(t, second, listedAddr)
if len(listings) != 1 || if len(listings) != 1 ||
listings[0].Updated.Sub(downloaded).Abs() > time.Second { listings[0].Updated.Sub(downloaded).Abs() > maxUpdatedDifference {
t.Errorf("after the restart, listings %+v, want one updated at %s", t.Errorf("after the restart, listings %+v, want one updated at %s",
listings, downloaded) listings, downloaded)
} }
+25
View File
@@ -8,6 +8,9 @@ import (
// 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"
// protonVPNIPsURL is where the ProtonVPN-IPs mirror publishes its files.
const protonVPNIPsURL = "https://raw.githubusercontent.com/tn3w/ProtonVPN-IPs/master/"
// How often each list is downloaded. No list is downloaded more often than // How often each list is downloaded. No list is downloaded more often than
// its publisher updates it or allows. // its publisher updates it or allows.
const ( const (
@@ -146,6 +149,28 @@ func vpnLists() []List {
Format: FormatPIAJSON, Format: FormatPIAJSON,
VPN: true, 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: protonVPNIPsURL + "protonvpn_ip_ranges.txt",
Interval: vpnListInterval,
Format: FormatProtonVPN,
VPN: true,
},
{
Name: "protonvpn_entry",
Description: "A server of the ProtonVPN service",
URL: protonVPNIPsURL + "protonvpn_entry_ips.txt",
Interval: vpnListInterval,
Format: FormatProtonVPN,
VPN: true,
},
{ {
Name: "x4bnet", Name: "x4bnet",
Description: "In an IPv4 network of a VPN service, according to " + Description: "In an IPv4 network of a VPN service, according to " +
+18
View File
@@ -58,6 +58,8 @@ func parse(r io.Reader, format Format) (*netipx.IPSet, error) {
return parsePIA(r) return parsePIA(r)
case FormatIVPNJSON: case FormatIVPNJSON:
return parseIVPN(r) return parseIVPN(r)
case FormatProtonVPN:
return parseProtonVPN(r)
default: default:
return nil, errUnknownFormat return nil, errUnknownFormat
} }
@@ -329,6 +331,22 @@ func parseIVPN(r io.Reader) (*netipx.IPSet, error) {
return addressSet(texts) return addressSet(texts)
} }
// parseProtonVPN returns the addresses or networks in a ProtonVPN-IPs list. A
// file with no addresses or 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. // readJSON reads r to its end and decodes it, as one JSON document, into v.
func readJSON(r io.Reader, v any) error { func readJSON(r io.Reader, v any) error {
data, err := io.ReadAll(r) data, err := io.ReadAll(r)
+324
View File
@@ -0,0 +1,324 @@
// Package ptr looks up the PTR names of addresses over DNS-over-HTTPS. It
// keeps answers in memory for as long as DNS allows, and in cache.db so that
// the answers survive a restart.
package ptr
import (
"context"
"database/sql"
"fmt"
"log/slog"
"maps"
"net/http"
"net/netip"
"slices"
"strings"
"sync"
"time"
"git.eeqj.de/sneak/routewatch/internal/bogon"
"github.com/miekg/dns"
)
const (
// flushInterval is how often new answers are written to cache.db.
flushInterval = 5 * time.Second
// sweepInterval is how often expired answers are deleted.
sweepInterval = 10 * time.Minute
// nameSeparator separates the names in a row of the hostnames table.
nameSeparator = " "
// maxAddresses limits how many addresses the cache holds, so that a public
// lookup cannot grow memory and cache.db without bound.
maxAddresses = 250_000
)
// Params is what New needs.
type Params struct {
// DB is the open cache.db.
DB *sql.DB
// Resolvers are the URLs of the DNS-over-HTTPS resolvers to ask, in order.
Resolvers []string
Logger *slog.Logger
}
// entry is one address's cached answer.
type entry struct {
// names are the address's PTR names, nil when it has none.
names []string
expires time.Time
}
// Cache answers PTR lookups from memory, and asks the resolvers for an address
// it holds no current answer for. Start loads it from cache.db.
type Cache struct {
db *sql.DB
resolvers []string
timeLimit time.Duration
maxEntries int
logger *slog.Logger
client *http.Client
now func() time.Time
// entries holds the kept answers by address, at most maxEntries of them.
// pending holds those that cache.db does not have yet.
mu sync.RWMutex
entries map[string]entry
pending map[string]entry
// stop ends the goroutine Start runs, and done is closed when it returns.
stop context.CancelFunc
done chan struct{}
}
// New returns an empty Cache. Start loads it.
func New(params Params) *Cache {
return &Cache{
db: params.DB,
resolvers: params.Resolvers,
timeLimit: LookupTimeout,
maxEntries: maxAddresses,
logger: params.Logger.With("component", "ptr"),
client: &http.Client{},
now: time.Now,
entries: make(map[string]entry),
pending: make(map[string]entry),
done: make(chan struct{}),
}
}
// LookupAddr returns the PTR names of addr, fully qualified, or none when it
// has none. A reserved address has none, and is not sent to the resolvers. A
// lookup takes at most LookupTimeout. A cached answer makes no outside request.
// It returns an error when no resolver answered, and nothing is cached then.
func (c *Cache) LookupAddr(ctx context.Context, addr string) ([]string, error) {
parsed, err := netip.ParseAddr(addr)
if err != nil {
return nil, fmt.Errorf("parsing the address: %w", err)
}
_, reserved := bogon.Addr(parsed)
if reserved {
return []string{}, nil
}
address := parsed.Unmap().String()
cached, ok := c.current(address)
if ok {
return slices.Clone(cached.names), nil
}
reverseName, err := dns.ReverseAddr(address)
if err != nil {
return nil, fmt.Errorf("building the reverse name: %w", err)
}
result, err := c.lookup(ctx, reverseName)
if err != nil {
return nil, err
}
if result.ttl > 0 {
c.store(address, entry{names: result.names, expires: c.now().Add(result.ttl)})
}
return slices.Clone(result.names), nil
}
// Start loads the answers that have not expired from cache.db. It then starts
// the goroutine that writes new answers there and deletes expired ones. The
// goroutine runs until Stop: the end of ctx does not stop it.
func (c *Cache) Start(ctx context.Context) error {
err := c.load(ctx)
if err != nil {
return err
}
c.logger.Info("PTR cache loaded", "entries", len(c.entries))
ctx, c.stop = context.WithCancel(context.WithoutCancel(ctx))
go c.run(ctx)
return nil
}
// Stop ends the goroutine, which first writes the answers cache.db does not
// have yet, and waits until it has returned or ctx ends.
func (c *Cache) Stop(ctx context.Context) {
c.stop()
select {
case <-c.done:
c.logger.Info("PTR cache stopped")
case <-ctx.Done():
c.logger.Warn("Stop deadline passed before the PTR cache was written")
}
}
// current returns the answer held for address, unless it has expired.
func (c *Cache) current(address string) (entry, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
found, ok := c.entries[address]
if !ok || !c.now().Before(found.expires) {
return entry{}, false
}
return found, true
}
// store keeps an answer in memory and queues it for cache.db. Once entries
// holds maxEntries addresses it keeps no new address. It still replaces the
// answer of an address already held.
func (c *Cache) store(address string, answer entry) {
c.mu.Lock()
defer c.mu.Unlock()
_, held := c.entries[address]
if !held && len(c.entries) >= c.maxEntries {
return
}
c.entries[address] = answer
c.pending[address] = answer
}
// run writes new answers and deletes expired ones until ctx ends.
func (c *Cache) run(ctx context.Context) {
defer close(c.done)
flushes := time.NewTicker(flushInterval)
defer flushes.Stop()
sweeps := time.NewTicker(sweepInterval)
defer sweeps.Stop()
for {
select {
case <-ctx.Done():
c.flush(context.WithoutCancel(ctx))
return
case <-flushes.C:
c.flush(ctx)
case <-sweeps.C:
c.sweep(ctx)
}
}
}
// load reads the rows that have not expired into memory.
func (c *Cache) load(ctx context.Context) error {
rows, err := c.db.QueryContext(ctx,
"SELECT address, names, expires_at FROM hostnames WHERE expires_at > ?",
c.now().Unix())
if err != nil {
return fmt.Errorf("reading the PTR cache: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
address, names string
expires int64
)
err = rows.Scan(&address, &names, &expires)
if err != nil {
return fmt.Errorf("reading a PTR cache row: %w", err)
}
c.entries[address] = entry{
names: strings.Fields(names),
expires: time.Unix(expires, 0).UTC(),
}
}
err = rows.Err()
if err != nil {
return fmt.Errorf("reading the PTR cache: %w", err)
}
return nil
}
// flush writes the pending answers to cache.db. Those it cannot write stay
// pending, unless a newer answer for the address has arrived meanwhile.
func (c *Cache) flush(ctx context.Context) {
c.mu.Lock()
batch := c.pending
c.pending = make(map[string]entry)
c.mu.Unlock()
if len(batch) == 0 {
return
}
err := c.writeRows(ctx, batch)
if err == nil {
return
}
c.logger.Error("Failed to write the PTR cache", "error", err)
c.mu.Lock()
defer c.mu.Unlock()
for address, answer := range batch {
_, newer := c.pending[address]
if !newer {
c.pending[address] = answer
}
}
}
// writeRows writes batch to cache.db in one transaction.
func (c *Cache) writeRows(ctx context.Context, batch map[string]entry) error {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for address, answer := range batch {
_, err = tx.ExecContext(ctx,
"INSERT OR REPLACE INTO hostnames (address, names, expires_at) "+
"VALUES (?, ?, ?)",
address,
strings.Join(answer.names, nameSeparator),
answer.expires.Unix())
if err != nil {
return err
}
}
return tx.Commit()
}
// sweep deletes the expired answers from memory and cache.db.
func (c *Cache) sweep(ctx context.Context) {
now := c.now()
expired := func(_ string, answer entry) bool {
return !now.Before(answer.expires)
}
c.mu.Lock()
maps.DeleteFunc(c.entries, expired)
maps.DeleteFunc(c.pending, expired)
c.mu.Unlock()
_, err := c.db.ExecContext(ctx,
"DELETE FROM hostnames WHERE expires_at <= ?", now.Unix())
if err != nil {
c.logger.Error("Failed to delete expired PTR cache rows", "error", err)
}
}
+951
View File
@@ -0,0 +1,951 @@
package ptr
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"sync"
"testing"
"testing/synctest"
"time"
"git.eeqj.de/sneak/routewatch/internal/cachedb"
"github.com/miekg/dns"
)
// These addresses are public, because a reserved address is not sent to the
// resolvers. That rules out the documentation ranges.
const (
testAddress = "8.8.4.4"
testReverse = "4.4.8.8.in-addr.arpa."
mappedAddress = "::ffff:" + testAddress
otherAddress = "8.8.8.8"
testV6Address = "2606:4700:4700::1111"
testV6Reverse = "1.1.1.1.0.0.0.0.0.0.0.0.0.0.0.0." +
"0.0.0.0.0.0.7.4.0.0.7.4.6.0.6.2.ip6.arpa."
)
const (
testName = "host.example.com."
otherName = "other.example.com."
// testTimeLimit replaces LookupTimeout in the tests that use a real
// server. A stall on a busy host cannot use it up, and no test waits for
// it.
testTimeLimit = 2 * time.Minute
)
// classlessOwner is the name that holds the PTR records of a CNAME chain.
const classlessOwner = "4.0-25.4.8.8.in-addr.arpa."
// fixedTime returns the time a test's clock starts at.
func fixedTime() time.Time {
return time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
}
// fakeResolver is a DNS-over-HTTPS server. It answers each query with reply's
// result, and counts its requests.
type fakeResolver struct {
server *httptest.Server
mu sync.Mutex
requests int
reply func(http.ResponseWriter, *http.Request, *dns.Msg)
}
// newFakeResolver starts a fake resolver that answers with reply.
func newFakeResolver(
t *testing.T,
reply func(http.ResponseWriter, *http.Request, *dns.Msg),
) *fakeResolver {
t.Helper()
resolver := &fakeResolver{reply: reply}
resolver.server = httptest.NewServer(http.HandlerFunc(resolver.serve))
t.Cleanup(resolver.server.Close)
return resolver
}
// serve checks that the request is RFC 8484 wire format and passes the
// question on to the current reply.
func (f *fakeResolver) serve(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
f.requests++
reply := f.reply
f.mu.Unlock()
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var query dns.Msg
err = query.Unpack(body)
if err != nil || r.Header.Get("Content-Type") != dnsMessageType ||
r.Header.Get("Accept") != dnsMessageType || r.Method != http.MethodPost {
http.Error(w, "not an RFC 8484 request", http.StatusBadRequest)
return
}
reply(w, r, &query)
}
func (f *fakeResolver) setReply(
reply func(http.ResponseWriter, *http.Request, *dns.Msg),
) {
f.mu.Lock()
defer f.mu.Unlock()
f.reply = reply
}
func (f *fakeResolver) requestCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.requests
}
// writeReply packs reply and writes it as the answer to query.
func writeReply(w http.ResponseWriter, query, reply *dns.Msg) {
reply.SetReply(query)
wire, err := reply.Pack()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", dnsMessageType)
_, _ = w.Write(wire)
}
// ptrReply answers with one PTR record for each name, each with the TTL ttl.
func ptrReply(
ttl uint32, names ...string,
) func(http.ResponseWriter, *http.Request, *dns.Msg) {
return func(w http.ResponseWriter, _ *http.Request, query *dns.Msg) {
reply := new(dns.Msg)
for _, name := range names {
reply.Answer = append(reply.Answer, &dns.PTR{
Hdr: dns.RR_Header{
Name: query.Question[0].Name, Rrtype: dns.TypePTR,
Class: dns.ClassINET, Ttl: ttl,
},
Ptr: name,
})
}
writeReply(w, query, reply)
}
}
// rcodeReply answers with the response code and nothing else.
func rcodeReply(rcode int) func(http.ResponseWriter, *http.Request, *dns.Msg) {
return func(w http.ResponseWriter, _ *http.Request, query *dns.Msg) {
reply := new(dns.Msg)
reply.SetRcode(query, rcode)
wire, err := reply.Pack()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write(wire)
}
}
// soaReply answers with the response code and a SOA record with these TTLs.
func soaReply(
rcode int, soaTTL, minimum uint32,
) func(http.ResponseWriter, *http.Request, *dns.Msg) {
return func(w http.ResponseWriter, _ *http.Request, query *dns.Msg) {
reply := new(dns.Msg)
reply.SetRcode(query, rcode)
reply.Ns = []dns.RR{&dns.SOA{
Hdr: dns.RR_Header{
Name: "arpa.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: soaTTL,
},
Ns: "ns.example.com.", Mbox: "hostmaster.example.com.", Minttl: minimum,
}}
wire, err := reply.Pack()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write(wire)
}
}
// hangingReply never answers: it waits until the client gives up.
func hangingReply(_ http.ResponseWriter, r *http.Request, _ *dns.Msg) {
<-r.Context().Done()
}
// statusReply answers with an HTTP status.
func statusReply(status int) func(http.ResponseWriter, *http.Request, *dns.Msg) {
return func(w http.ResponseWriter, _ *http.Request, _ *dns.Msg) {
w.WriteHeader(status)
}
}
// openTestDB opens a cache.db in dir.
func openTestDB(t *testing.T, dir string) *sql.DB {
t.Helper()
db, err := cachedb.Open(t.Context(), dir)
if err != nil {
t.Fatalf("opening the cache database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
// testClock is a clock a test moves by hand.
type testClock struct {
mu sync.Mutex
now time.Time
}
func (c *testClock) time() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *testClock) advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.now = c.now.Add(d)
}
// newTestCache returns a Cache that asks the resolvers in order, reads the
// time from a clock the test holds, has testTimeLimit, and is not started.
func newTestCache(
t *testing.T, db *sql.DB, resolvers ...*fakeResolver,
) (*Cache, *testClock) {
t.Helper()
urls := make([]string, 0, len(resolvers))
for _, resolver := range resolvers {
urls = append(urls, resolver.server.URL)
}
cache := New(Params{DB: db, Resolvers: urls, Logger: slog.Default()})
clock := &testClock{now: fixedTime()}
cache.now = clock.time
cache.timeLimit = testTimeLimit
return cache, clock
}
// handlerTransport hands each request to the fake resolver for its host, with
// no network between them. A test in a synctest bubble needs this: the bubble's
// clock stands still while one of its goroutines waits on a real connection. As
// over a network, a request whose time is up is not sent.
type handlerTransport map[string]*fakeResolver
func (h handlerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
recorder := httptest.NewRecorder()
if r.Context().Err() == nil {
h[r.URL.Host].serve(recorder, r)
}
err := r.Context().Err()
if err != nil {
return nil, err
}
return recorder.Result(), nil
}
// newBubbleCache returns a Cache with the real time limit, for a test in a
// synctest bubble. It has one fake resolver for each reply, in order, reached
// through a handlerTransport.
func newBubbleCache(
replies ...func(http.ResponseWriter, *http.Request, *dns.Msg),
) (*Cache, []*fakeResolver) {
transport := make(handlerTransport, len(replies))
resolvers := make([]*fakeResolver, 0, len(replies))
urls := make([]string, 0, len(replies))
for i, reply := range replies {
host := "resolver" + strconv.Itoa(i) + ".example.com"
resolver := &fakeResolver{reply: reply}
transport[host] = resolver
resolvers = append(resolvers, resolver)
urls = append(urls, "https://"+host+"/dns-query")
}
cache := New(Params{Resolvers: urls, Logger: slog.Default()})
cache.client = &http.Client{Transport: transport}
return cache, resolvers
}
// lookupOK looks up address and fails the test on an error.
func lookupOK(t *testing.T, cache *Cache, address string) []string {
t.Helper()
names, err := cache.LookupAddr(t.Context(), address)
if err != nil {
t.Fatalf("LookupAddr(%s): %v", address, err)
}
return names
}
func countRows(t *testing.T, db *sql.DB) int {
t.Helper()
var rows int
err := db.QueryRowContext(t.Context(),
"SELECT COUNT(*) FROM hostnames").Scan(&rows)
if err != nil {
t.Fatalf("counting rows: %v", err)
}
return rows
}
func TestLookupAsksForTheReverseName(t *testing.T) {
t.Parallel()
tests := []struct {
address string
want string
}{
{testAddress, testReverse},
{mappedAddress, testReverse},
{testV6Address, testV6Reverse},
}
for _, tt := range tests {
var asked string
resolver := newFakeResolver(t,
func(w http.ResponseWriter, r *http.Request, query *dns.Msg) {
asked = query.Question[0].Name
if query.Question[0].Qtype != dns.TypePTR {
t.Errorf("query type %d, want PTR", query.Question[0].Qtype)
}
ptrReply(300, testName)(w, r, query)
})
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
names := lookupOK(t, cache, tt.address)
if !slices.Equal(names, []string{testName}) {
t.Errorf("%s: names %v, want %s", tt.address, names, testName)
}
if asked != tt.want {
t.Errorf("%s: asked for %s, want %s", tt.address, asked, tt.want)
}
}
}
// TestOnlyPublicAddressesAreSentToTheResolvers gives every resolver an answer
// to send, which a reserved address must not get.
func TestOnlyPublicAddressesAreSentToTheResolvers(t *testing.T) {
t.Parallel()
tests := []struct {
name string
address string
want []string
requests int
}{
{"private IPv4", "10.0.0.1", []string{}, 0},
{"documentation IPv4", "192.0.2.1", []string{}, 0},
{"private IPv4 in IPv6 form", "::ffff:10.0.0.1", []string{}, 0},
{"unique-local IPv6", "fd00::1", []string{}, 0},
{"documentation IPv6", "2001:db8::1", []string{}, 0},
{"public IPv4", testAddress, []string{testName}, 1},
{"public IPv4 in IPv6 form", mappedAddress, []string{testName}, 1},
{"public IPv6", testV6Address, []string{testName}, 1},
}
for _, tt := range tests {
resolver := newFakeResolver(t, ptrReply(300, testName))
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
names := lookupOK(t, cache, tt.address)
if names == nil || !slices.Equal(names, tt.want) {
t.Errorf("%s: names %#v, want %#v", tt.name, names, tt.want)
}
if got := resolver.requestCount(); got != tt.requests {
t.Errorf("%s: %d requests, want %d", tt.name, got, tt.requests)
}
}
}
// TestResolversAreTriedInOrderUntilOneAnswers has the first two resolvers fail,
// in different ways, and the third answer. The fourth is not asked.
func TestResolversAreTriedInOrderUntilOneAnswers(t *testing.T) {
t.Parallel()
var (
mu sync.Mutex
order []int
)
asked := func(
index int, reply func(http.ResponseWriter, *http.Request, *dns.Msg),
) func(http.ResponseWriter, *http.Request, *dns.Msg) {
return func(w http.ResponseWriter, r *http.Request, query *dns.Msg) {
mu.Lock()
order = append(order, index)
mu.Unlock()
reply(w, r, query)
}
}
resolvers := []*fakeResolver{
newFakeResolver(t, asked(0, statusReply(http.StatusServiceUnavailable))),
newFakeResolver(t, asked(1, rcodeReply(dns.RcodeServerFailure))),
newFakeResolver(t, asked(2, ptrReply(300, testName))),
newFakeResolver(t, asked(3, ptrReply(300, otherName))),
}
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolvers...)
names := lookupOK(t, cache, testAddress)
if !slices.Equal(names, []string{testName}) {
t.Errorf("names %v, want %s", names, testName)
}
if !slices.Equal(order, []int{0, 1, 2}) {
t.Errorf("resolvers asked %v, want [0 1 2]", order)
}
}
// TestResolverThatNeverAnswersLeavesTimeForTheNext would fail if the first
// resolver could take the whole time limit. The first gets half of it, the
// share of one resolver in two. The test runs in a synctest bubble, whose clock
// is fake, so a stall on the host uses none of the limit.
func TestResolverThatNeverAnswersLeavesTimeForTheNext(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
cache, _ := newBubbleCache(hangingReply, ptrReply(300, testName))
start := time.Now()
names := lookupOK(t, cache, testAddress)
elapsed := time.Since(start)
if !slices.Equal(names, []string{testName}) {
t.Errorf("names %v, want %s", names, testName)
}
if elapsed != time.Second {
t.Errorf("took %s, want 1s", elapsed)
}
})
}
// TestLookupStopsAtTheTimeLimit has no resolver answer. The lookup fails when
// 2 seconds have passed, having asked every resolver. The test runs in a
// synctest bubble, whose clock is fake, so a stall on the host uses none of the
// limit.
func TestLookupStopsAtTheTimeLimit(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
cache, resolvers := newBubbleCache(
hangingReply, hangingReply, hangingReply, hangingReply)
start := time.Now()
_, err := cache.LookupAddr(t.Context(), testAddress)
elapsed := time.Since(start)
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("error %v, want %v", err, context.DeadlineExceeded)
}
if elapsed != 2*time.Second {
t.Errorf("took %s, want 2s", elapsed)
}
for i, resolver := range resolvers {
if resolver.requestCount() != 1 {
t.Errorf("resolver %d asked %d times, want once",
i, resolver.requestCount())
}
}
})
}
// TestAnswerIsServedFromMemoryUntilItExpires makes a second lookup within the
// TTL, which asks nobody, and a third after it, which asks again.
func TestAnswerIsServedFromMemoryUntilItExpires(t *testing.T) {
t.Parallel()
resolver := newFakeResolver(t, ptrReply(300, testName))
cache, clock := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
lookupOK(t, cache, testAddress)
names := lookupOK(t, cache, testAddress)
if !slices.Equal(names, []string{testName}) {
t.Errorf("cached names %v, want %s", names, testName)
}
if got := resolver.requestCount(); got != 1 {
t.Errorf("%d requests after the second lookup, want 1", got)
}
clock.advance(300 * time.Second)
lookupOK(t, cache, testAddress)
if got := resolver.requestCount(); got != 2 {
t.Errorf("%d requests after the TTL passed, want 2", got)
}
}
// TestAnAddressHasOneCacheEntryHoweverItIsWritten looks up an IPv4 address in
// its IPv4-mapped IPv6 form, then in its usual form.
func TestAnAddressHasOneCacheEntryHoweverItIsWritten(t *testing.T) {
t.Parallel()
resolver := newFakeResolver(t, ptrReply(300, testName))
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
lookupOK(t, cache, mappedAddress)
lookupOK(t, cache, testAddress)
if got := resolver.requestCount(); got != 1 {
t.Errorf("%d requests, want 1", got)
}
}
// TestCallerCannotChangeACachedAnswer changes the list each lookup returned, as
// the server does when it removes the trailing dots. The second lookup is the
// first answered from the cache. The third shows that the cached list did not
// change with the list the second returned.
func TestCallerCannotChangeACachedAnswer(t *testing.T) {
t.Parallel()
resolver := newFakeResolver(t, ptrReply(300, testName))
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
for range 3 {
names := lookupOK(t, cache, testAddress)
if !slices.Equal(names, []string{testName}) {
t.Errorf("names %v, want %s", names, testName)
}
names[0] = "changed"
}
}
func TestTTLIsClamped(t *testing.T) {
t.Parallel()
tests := []struct {
name string
ttl uint32
want time.Duration
}{
{"below the minimum", 1, time.Minute},
{"zero", 0, time.Minute},
{"at the minimum", 60, time.Minute},
{"in range", 300, 300 * time.Second},
{"at the maximum", 86400, 24 * time.Hour},
{"above the maximum", 604800, 24 * time.Hour},
}
for _, tt := range tests {
resolver := newFakeResolver(t, ptrReply(tt.ttl, testName))
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
lookupOK(t, cache, testAddress)
got := cache.entries[testAddress].expires.Sub(fixedTime())
if got != tt.want {
t.Errorf("%s: kept for %s, want %s", tt.name, got, tt.want)
}
}
}
// TestSmallestTTLOfTheChainCounts answers with a CNAME record and two PTR
// records, whose TTLs all differ.
func TestSmallestTTLOfTheChainCounts(t *testing.T) {
t.Parallel()
reply := func(w http.ResponseWriter, _ *http.Request, query *dns.Msg) {
owner := query.Question[0].Name
answer := new(dns.Msg)
answer.Answer = []dns.RR{
&dns.CNAME{
Hdr: dns.RR_Header{
Name: owner, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 200,
},
Target: classlessOwner,
},
&dns.PTR{
Hdr: dns.RR_Header{
Name: classlessOwner, Rrtype: dns.TypePTR,
Class: dns.ClassINET, Ttl: 900,
},
Ptr: testName,
},
&dns.PTR{
Hdr: dns.RR_Header{
Name: classlessOwner, Rrtype: dns.TypePTR,
Class: dns.ClassINET, Ttl: 600,
},
Ptr: otherName,
},
}
writeReply(w, query, answer)
}
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), newFakeResolver(t, reply))
names := lookupOK(t, cache, testAddress)
if !slices.Equal(names, []string{testName, otherName}) {
t.Errorf("names %v, want both PTR names", names)
}
got := cache.entries[testAddress].expires.Sub(fixedTime())
if got != 200*time.Second {
t.Errorf("kept for %s, want 200s", got)
}
}
func TestNegativeTTL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rcode int
soaTTL uint32
minimum uint32
want time.Duration
}{
{"name does not exist", dns.RcodeNameError, 3600, 300, 300 * time.Second},
{"no PTR record", dns.RcodeSuccess, 3600, 300, 300 * time.Second},
{"limited to an hour", dns.RcodeNameError, 86400, 7200, time.Hour},
{"SOA's own TTL is smaller", dns.RcodeNameError, 100, 300, 100 * time.Second},
}
for _, tt := range tests {
resolver := newFakeResolver(t, soaReply(tt.rcode, tt.soaTTL, tt.minimum))
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
names := lookupOK(t, cache, testAddress)
if len(names) != 0 {
t.Errorf("%s: names %v, want none", tt.name, names)
}
got := cache.entries[testAddress].expires.Sub(fixedTime())
if got != tt.want {
t.Errorf("%s: kept for %s, want %s", tt.name, got, tt.want)
}
lookupOK(t, cache, testAddress)
if count := resolver.requestCount(); count != 1 {
t.Errorf("%s: %d requests, want 1", tt.name, count)
}
}
}
// TestAnswerWithoutNamesOrSOAIsNotCached has nothing to say how long the
// answer may be kept.
func TestAnswerWithoutNamesOrSOAIsNotCached(t *testing.T) {
t.Parallel()
resolver := newFakeResolver(t, rcodeReply(dns.RcodeNameError))
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
lookupOK(t, cache, testAddress)
lookupOK(t, cache, testAddress)
if got := resolver.requestCount(); got != 2 {
t.Errorf("%d requests, want 2", got)
}
}
// TestFailedQueryIsNotCached has the only resolver fail, and then recover.
func TestFailedQueryIsNotCached(t *testing.T) {
t.Parallel()
resolver := newFakeResolver(t, rcodeReply(dns.RcodeServerFailure))
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
_, err := cache.LookupAddr(t.Context(), testAddress)
if !errors.Is(err, errBadResponse) {
t.Fatalf("error %v, want %v", err, errBadResponse)
}
if len(cache.entries)+len(cache.pending) != 0 {
t.Error("a failed query left an entry in the cache")
}
resolver.setReply(ptrReply(300, testName))
names := lookupOK(t, cache, testAddress)
if !slices.Equal(names, []string{testName}) {
t.Errorf("names %v, want %s", names, testName)
}
}
// TestNamesThatAreNotHostNamesAreDropped answers with a name that holds markup
// beside a good one, as a PTR record can.
func TestNamesThatAreNotHostNamesAreDropped(t *testing.T) {
t.Parallel()
resolver := newFakeResolver(t,
ptrReply(300, "bad<script>.example.com.", testName, "."))
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
names := lookupOK(t, cache, testAddress)
if !slices.Equal(names, []string{testName}) {
t.Errorf("names %v, want only %s", names, testName)
}
}
// TestCacheSurvivesARestart stops a cache that holds a positive and a negative
// answer, and starts a new one on the reopened file. It makes no request.
func TestCacheSurvivesARestart(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := openTestDB(t, dir)
resolver := newFakeResolver(t,
func(w http.ResponseWriter, r *http.Request, query *dns.Msg) {
if query.Question[0].Name == testReverse {
ptrReply(300, testName, otherName)(w, r, query)
return
}
soaReply(dns.RcodeNameError, 3600, 300)(w, r, query)
})
cache, _ := newTestCache(t, db, resolver)
err := cache.Start(t.Context())
if err != nil {
t.Fatalf("Start: %v", err)
}
lookupOK(t, cache, testAddress)
lookupOK(t, cache, otherAddress)
cache.Stop(t.Context())
err = db.Close()
if err != nil {
t.Fatalf("closing the database: %v", err)
}
restarted, _ := newTestCache(t, openTestDB(t, dir), resolver)
err = restarted.Start(t.Context())
if err != nil {
t.Fatalf("Start after the restart: %v", err)
}
defer restarted.Stop(t.Context())
requestsBefore := resolver.requestCount()
names := lookupOK(t, restarted, testAddress)
if !slices.Equal(names, []string{testName, otherName}) {
t.Errorf("names after restart %v", names)
}
if names := lookupOK(t, restarted, otherAddress); len(names) != 0 {
t.Errorf("names of the address with none after restart: %v", names)
}
if got := resolver.requestCount(); got != requestsBefore {
t.Errorf("%d requests after the restart, want none",
got-requestsBefore)
}
}
// TestSweepDeletesExpiredRows also checks that a restart does not load a row
// that has expired since it was written.
func TestSweepDeletesExpiredRows(t *testing.T) {
t.Parallel()
db := openTestDB(t, t.TempDir())
resolver := newFakeResolver(t,
func(w http.ResponseWriter, r *http.Request, query *dns.Msg) {
ttl := uint32(300)
if query.Question[0].Name == testReverse {
ttl = 60
}
ptrReply(ttl, testName)(w, r, query)
})
cache, clock := newTestCache(t, db, resolver)
lookupOK(t, cache, testAddress)
lookupOK(t, cache, otherAddress)
cache.flush(t.Context())
if got := countRows(t, db); got != 2 {
t.Fatalf("%d rows before the sweep, want 2", got)
}
clock.advance(90 * time.Second)
loaded, loadedClock := newTestCache(t, db, resolver)
loadedClock.advance(90 * time.Second)
err := loaded.load(t.Context())
if err != nil {
t.Fatalf("load: %v", err)
}
if len(loaded.entries) != 1 {
t.Errorf("loaded %d entries, want the 1 not expired", len(loaded.entries))
}
cache.sweep(t.Context())
if got := countRows(t, db); got != 1 {
t.Errorf("%d rows after the sweep, want 1", got)
}
if len(cache.entries) != 1 {
t.Errorf("%d entries in memory after the sweep, want 1", len(cache.entries))
}
}
// TestFlushKeepsAnswersItCouldNotWrite makes one write fail by hiding the
// table. The next write, once the table is back, stores the answer.
func TestFlushKeepsAnswersItCouldNotWrite(t *testing.T) {
t.Parallel()
db := openTestDB(t, t.TempDir())
resolver := newFakeResolver(t, ptrReply(300, testName))
cache, _ := newTestCache(t, db, resolver)
lookupOK(t, cache, testAddress)
_, err := db.ExecContext(t.Context(), "ALTER TABLE hostnames RENAME TO hidden")
if err != nil {
t.Fatalf("renaming the table: %v", err)
}
cache.flush(t.Context())
_, err = db.ExecContext(t.Context(), "ALTER TABLE hidden RENAME TO hostnames")
if err != nil {
t.Fatalf("renaming the table back: %v", err)
}
if got := countRows(t, db); got != 0 {
t.Fatalf("%d rows after the failed write, want 0", got)
}
cache.flush(t.Context())
if got := countRows(t, db); got != 1 {
t.Errorf("%d rows after the second write, want 1", got)
}
}
// TestNewAddressIsNotKeptAtTheLimit fills a cache that holds one address. A
// lookup of a second address still returns its answer, and the cache keeps
// nothing for it until the sweep has deleted the first address's expired
// answer.
func TestNewAddressIsNotKeptAtTheLimit(t *testing.T) {
t.Parallel()
db := openTestDB(t, t.TempDir())
resolver := newFakeResolver(t, ptrReply(300, testName))
cache, clock := newTestCache(t, db, resolver)
cache.maxEntries = 1
lookupOK(t, cache, testAddress)
names := lookupOK(t, cache, otherAddress)
if !slices.Equal(names, []string{testName}) {
t.Errorf("names %v at the limit, want %s", names, testName)
}
_, inEntries := cache.entries[otherAddress]
_, inPending := cache.pending[otherAddress]
if inEntries || inPending {
t.Errorf("a new address was kept at the limit: in entries %t, in pending %t",
inEntries, inPending)
}
cache.flush(t.Context())
if got := countRows(t, db); got != 1 {
t.Errorf("%d rows at the limit, want 1", got)
}
clock.advance(300 * time.Second)
cache.sweep(t.Context())
lookupOK(t, cache, otherAddress)
_, inEntries = cache.entries[otherAddress]
if !inEntries {
t.Error("a new address was not kept after the sweep made room")
}
}
// TestHeldAddressIsReplacedAtTheLimit lets the answer of the only address a
// cache holds expire. The new answer replaces it in memory and is queued for
// cache.db.
func TestHeldAddressIsReplacedAtTheLimit(t *testing.T) {
t.Parallel()
resolver := newFakeResolver(t, ptrReply(300, testName))
cache, clock := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
cache.maxEntries = 1
lookupOK(t, cache, testAddress)
clock.advance(300 * time.Second)
resolver.setReply(ptrReply(300, otherName))
lookupOK(t, cache, testAddress)
want := []string{otherName}
if got := cache.entries[testAddress].names; !slices.Equal(got, want) {
t.Errorf("entries holds %v, want %v", got, want)
}
if got := cache.pending[testAddress].names; !slices.Equal(got, want) {
t.Errorf("pending holds %v, want %v", got, want)
}
}
+200
View File
@@ -0,0 +1,200 @@
package ptr
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"math"
"net/http"
"strings"
"time"
"github.com/miekg/dns"
)
const (
// LookupTimeout limits one address's lookup, every resolver included.
LookupTimeout = 2 * time.Second
// minTTL and maxTTL limit how long PTR names are kept.
minTTL = time.Minute
maxTTL = 24 * time.Hour
// maxNegativeTTL limits how long an address with no PTR names is
// remembered as having none.
maxNegativeTTL = time.Hour
// dnsMessageType is the content type of RFC 8484 requests and answers.
dnsMessageType = "application/dns-message"
// maxMessageBytes is the largest possible DNS message.
maxMessageBytes = 65535
)
var (
errBadStatus = errors.New("unexpected HTTP status")
errBadResponse = errors.New("resolver reported an error")
)
// Resolvers returns the URLs of the public DNS-over-HTTPS resolvers, in the
// order they are tried.
func Resolvers() []string {
return []string{
"https://dns.google/dns-query",
"https://cloudflare-dns.com/dns-query",
"https://unfiltered.adguard-dns.com/dns-query",
"https://dns.quad9.net/dns-query",
}
}
// answer is a resolver's answer for one address.
type answer struct {
// names are the address's PTR names, nil when it has none.
names []string
// ttl is how long the answer may be kept. Zero means it is not kept.
ttl time.Duration
}
// lookup asks the resolvers in turn for the PTR names of the reverse name
// until one answers. It fails when none has answered within the time limit.
func (c *Cache) lookup(ctx context.Context, reverseName string) (answer, error) {
ctx, cancel := context.WithTimeout(ctx, c.timeLimit)
defer cancel()
query := new(dns.Msg).SetQuestion(reverseName, dns.TypePTR)
query.Id = 0 // RFC 8484 asks for 0, so that equal queries can be cached.
wire, err := query.Pack()
if err != nil {
return answer{}, fmt.Errorf("packing the query: %w", err)
}
var failures []error
for i, resolver := range c.resolvers {
// Each resolver gets an equal share of the time left, so that one
// that never answers leaves time for the next.
deadline, _ := ctx.Deadline()
share := time.Until(deadline) / time.Duration(len(c.resolvers)-i)
result, err := c.ask(ctx, resolver, wire, share)
if err == nil {
return result, nil
}
failures = append(failures, fmt.Errorf("%s: %w", resolver, err))
}
return answer{}, errors.Join(failures...)
}
// ask sends the query to one resolver and waits at most timeout for its answer.
func (c *Cache) ask(
ctx context.Context, resolver string, query []byte, timeout time.Duration,
) (answer, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
request, err := http.NewRequestWithContext(
ctx, http.MethodPost, resolver, bytes.NewReader(query))
if err != nil {
return answer{}, err
}
request.Header.Set("Content-Type", dnsMessageType)
request.Header.Set("Accept", dnsMessageType)
response, err := c.client.Do(request)
if err != nil {
return answer{}, err
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode != http.StatusOK {
return answer{}, fmt.Errorf("%w: %d", errBadStatus, response.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(response.Body, maxMessageBytes))
if err != nil {
return answer{}, err
}
var reply dns.Msg
err = reply.Unpack(body)
if err != nil {
return answer{}, err
}
return answerFrom(&reply)
}
// answerFrom reads a resolver's reply. A name that does not exist and a name
// with no PTR records are answers. Every other response code is a failure.
func answerFrom(reply *dns.Msg) (answer, error) {
if reply.Rcode != dns.RcodeSuccess && reply.Rcode != dns.RcodeNameError {
return answer{}, fmt.Errorf("%w: %s",
errBadResponse, dns.RcodeToString[reply.Rcode])
}
var names []string
seconds := uint32(math.MaxUint32)
for _, record := range reply.Answer {
// The chain to a PTR record can hold CNAME records, and the answer
// is good only as long as every record in it.
seconds = min(seconds, record.Header().Ttl)
ptr, ok := record.(*dns.PTR)
if ok && isHostName(ptr.Ptr) {
names = append(names, ptr.Ptr)
}
}
if len(names) == 0 {
return answer{ttl: negativeTTL(reply.Ns)}, nil
}
ttl := time.Duration(seconds) * time.Second
return answer{names: names, ttl: min(max(ttl, minTTL), maxTTL)}, nil
}
// negativeTTL returns how long a reply with no PTR names may be kept: the
// smaller of the SOA record's own TTL and its minimum field (RFC 2308), at most
// maxNegativeTTL. Without a SOA record it is zero.
func negativeTTL(authority []dns.RR) time.Duration {
for _, record := range authority {
soa, ok := record.(*dns.SOA)
if ok {
ttl := time.Duration(min(soa.Hdr.Ttl, soa.Minttl)) * time.Second
return min(ttl, maxNegativeTTL)
}
}
return 0
}
// isHostName reports whether name is a host name. A PTR record can hold any
// bytes, and the names go into web pages, so anything else is dropped, as
// net.Resolver.LookupAddr drops it. The root name is not a host name.
func isHostName(name string) bool {
_, ok := dns.IsDomainName(name)
return ok && name != "." && !strings.ContainsFunc(name, notInHostName)
}
// notInHostName reports whether r cannot be part of a host name.
func notInHostName(r rune) bool {
return !strings.ContainsRune(hostNameCharacters, r)
}
// hostNameCharacters are the characters of a host name, in presentation format.
const hostNameCharacters = "abcdefghijklmnopqrstuvwxyz" +
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."
+63 -2
View File
@@ -9,17 +9,20 @@ package routewatch
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"sync" "sync"
"time" "time"
"git.eeqj.de/sneak/routewatch/internal/cachedb"
"git.eeqj.de/sneak/routewatch/internal/config" "git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/database" "git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/iplist" "git.eeqj.de/sneak/routewatch/internal/iplist"
"git.eeqj.de/sneak/routewatch/internal/logger" "git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics" "git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/ptr"
"git.eeqj.de/sneak/routewatch/internal/routetable" "git.eeqj.de/sneak/routewatch/internal/routetable"
"git.eeqj.de/sneak/routewatch/internal/server" "git.eeqj.de/sneak/routewatch/internal/server"
"git.eeqj.de/sneak/routewatch/internal/streamer" "git.eeqj.de/sneak/routewatch/internal/streamer"
@@ -62,6 +65,8 @@ type RouteWatch struct {
asnFetcher *ASNFetcher asnFetcher *ASNFetcher
dbMaintainer *DBMaintainer dbMaintainer *DBMaintainer
ipLists *iplist.IPLists ipLists *iplist.IPLists
cacheDB *sql.DB
ptrCache *ptr.Cache
} }
// ipListsDirName is the directory in the state directory that holds the IP // ipListsDirName is the directory in the state directory that holds the IP
@@ -150,6 +155,11 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
rw.ipLists = ipLists rw.ipLists = ipLists
rw.server.SetIPLists(ipLists) rw.server.SetIPLists(ipLists)
err = rw.startPTRCache(ctx)
if err != nil {
return err
}
// Start HTTP server // Start HTTP server
err = rw.server.Start(ctx) err = rw.server.Start(ctx)
if err != nil { if err != nil {
@@ -168,8 +178,8 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
// Shutdown stops all services in order: the streamer, the handlers, which // Shutdown stops all services in order: the streamer, the handlers, which
// write what they hold, the WHOIS fetcher, the database maintainer, the // write what they hold, the WHOIS fetcher, the database maintainer, the
// IP list downloads and the HTTP server, and last the database, which it // IP list downloads, the HTTP server and the PTR cache, and last the database,
// checkpoints and closes. Each stage waits at most until ctx ends, so // which it checkpoints and closes. Each stage waits at most until ctx ends, so
// Shutdown returns soon after: a handler logs what it could not write by // Shutdown returns soon after: a handler logs what it could not write by
// then, and the HTTP server closes the connections of requests still // then, and the HTTP server closes the connections of requests still
// running. // running.
@@ -229,6 +239,8 @@ func (rw *RouteWatch) Shutdown(ctx context.Context) {
"error", err) "error", err)
} }
rw.stopPTRCache(ctx)
rw.closeDatabase(ctx) rw.closeDatabase(ctx)
// Log final metrics // Log final metrics
@@ -242,6 +254,55 @@ func (rw *RouteWatch) Shutdown(ctx context.Context) {
) )
} }
// startPTRCache opens cache.db, loads the PTR cache from it and gives the
// server the cache. The HTTP handlers read it without a lock, so it is set
// before they run.
func (rw *RouteWatch) startPTRCache(ctx context.Context) error {
cacheDB, err := cachedb.Open(ctx, rw.config.StateDir)
if err != nil {
return err
}
ptrCache := ptr.New(ptr.Params{
DB: cacheDB,
Resolvers: ptr.Resolvers(),
Logger: rw.logger.Logger,
})
err = ptrCache.Start(ctx)
if err != nil {
_ = cacheDB.Close()
return err
}
rw.cacheDB = cacheDB
rw.ptrCache = ptrCache
rw.server.SetPTRResolver(ptrCache)
return nil
}
// stopPTRCache stops the PTR cache, which writes the answers cache.db does not
// have yet, and closes cache.db, unless ctx has ended and a write may still be
// running.
func (rw *RouteWatch) stopPTRCache(ctx context.Context) {
if rw.ptrCache == nil {
return
}
rw.ptrCache.Stop(ctx)
if ctx.Err() != nil {
return
}
err := rw.cacheDB.Close()
if err != nil {
rw.logger.Error("Failed to close the cache database", "error", err)
}
}
// closeDatabase checkpoints and closes the database, unless ctx has ended: a // closeDatabase checkpoints and closes the database, unless ctx has ended: a
// write may then still be running, and both would wait for it. SQLite rolls // write may then still be running, and both would wait for it. SQLite rolls
// back a write cut off by the exit when the database next opens. // back a write cut off by the exit when the database next opens.
@@ -8,6 +8,8 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"os"
"path/filepath"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -783,3 +785,74 @@ func TestShutdownCheckpointsAndClosesTheDatabaseLast(t *testing.T) {
t.Errorf("Shutdown made the calls %q, want %q", store.calls, want) t.Errorf("Shutdown made the calls %q, want %q", store.calls, want)
} }
} }
// TestPTRCacheAnswersTheIPLookup starts the PTR cache and the HTTP server as
// Run does, and looks up a reserved address, which asks no resolver. Without
// the PTR cache the server has no PTR resolver, and every IP lookup answers
// 500.
func TestPTRCacheAnswersTheIPLookup(t *testing.T) {
t.Parallel()
store := newMockStore()
log := logger.New()
port := freePort(t)
cfg := &config.Config{Port: port, StateDir: t.TempDir()}
s := streamer.New(log, metrics.New())
// A started server reads its routing table in the background, so it
// needs one.
rw := New(Dependencies{
DB: store,
Streamer: s,
Server: server.New(store, routetable.New(routetable.Params{}), s, log,
cfg),
Logger: log,
Config: cfg,
})
err := rw.startPTRCache(t.Context())
if err != nil {
t.Fatalf("failed to start the PTR cache: %v", err)
}
defer rw.stopPTRCache(t.Context())
err = rw.server.Start(t.Context())
if err != nil {
t.Fatalf("failed to start the HTTP server: %v", err)
}
defer func() { _ = rw.server.Stop(t.Context()) }()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
"http://"+net.JoinHostPort("127.0.0.1", strconv.Itoa(port))+
"/api/v1/ip/10.0.0.1", nil)
if err != nil {
t.Fatalf("failed to build the request: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("the IP lookup failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read the answer: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, want %d; body %s", resp.StatusCode, http.StatusOK, body)
}
if !strings.Contains(string(body), `"ptr":[]`) {
t.Errorf("answer %s, want an empty ptr", body)
}
_, err = os.Stat(filepath.Join(cfg.StateDir, "cache.db"))
if err != nil {
t.Errorf("cache.db is not in the state directory: %v", err)
}
}
+13 -16
View File
@@ -39,12 +39,9 @@ const (
const ( const (
// maxLookupTargets is the most addresses and host names an IP lookup may // maxLookupTargets is the most addresses and host names an IP lookup may
// list, and the most addresses it may have once the names are resolved: // list, and the most addresses it may have once the names are resolved:
// each address costs two database reads and a PTR lookup. // each address costs two database reads and a PTR lookup. Ten PTR lookups
// that run to ptr.LookupTimeout fit in the request's 30-second limit.
maxLookupTargets = 10 maxLookupTargets = 10
// ptrLookupTimeout is each PTR lookup's own time limit, so that ten
// addresses whose PTR lookups never answer fit in the 30-second limit.
ptrLookupTimeout = 2 * time.Second
) )
// The Cache-Control header of an IP lookup answer, such as // The Cache-Control header of an IP lookup answer, such as
@@ -1032,16 +1029,14 @@ func ipLookupFlags(ip string, listings []iplist.Listing) IPLookupFlags {
} }
} }
// isBogon says whether the address ip is reserved. An IPv4 address written in // isBogon says whether the address ip is reserved, as package bogon tells.
// IPv6 form counts as the IPv4 address it holds.
func isBogon(ip string) bool { func isBogon(ip string) bool {
addr, err := netip.ParseAddr(ip) addr, err := netip.ParseAddr(ip)
if err != nil { if err != nil {
return false return false
} }
addr = addr.Unmap() _, reserved := bogon.Addr(addr)
_, reserved := bogon.Prefix(netip.PrefixFrom(addr, addr.BitLen()))
return reserved return reserved
} }
@@ -1220,15 +1215,17 @@ func publicAddresses(addrs []string) []string {
} }
// lookupPTRNames returns the PTR records of ip without their trailing dots. It // lookupPTRNames returns the PTR records of ip without their trailing dots. It
// returns an empty list, never nil, when there are none, when the lookup fails // returns an empty list, never nil, when there are none and when the lookup
// and when it runs past ptrLookupTimeout, so that an answer's ptr is [] rather // fails, so that an answer's ptr is [] rather than null.
// than null.
func (s *Server) lookupPTRNames(ctx context.Context, ip string) []string { func (s *Server) lookupPTRNames(ctx context.Context, ip string) []string {
ctx, cancel := context.WithTimeout(ctx, ptrLookupTimeout) ptrs, err := s.ptrResolver.LookupAddr(ctx, ip)
defer cancel() if err != nil {
s.logger.Warn("PTR lookup failed", "ip", ip, "error", err)
ptrs, err := s.resolver.LookupAddr(ctx, ip) return []string{}
if err != nil || ptrs == nil { }
if ptrs == nil {
return []string{} return []string{}
} }
+183 -90
View File
@@ -17,6 +17,7 @@ import (
"strings" "strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
"testing/synctest"
"time" "time"
"git.eeqj.de/sneak/routewatch/internal/config" "git.eeqj.de/sneak/routewatch/internal/config"
@@ -24,6 +25,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/iplist" "git.eeqj.de/sneak/routewatch/internal/iplist"
"git.eeqj.de/sneak/routewatch/internal/logger" "git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics" "git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/ptr"
"git.eeqj.de/sneak/routewatch/internal/ristypes" "git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable" "git.eeqj.de/sneak/routewatch/internal/routetable"
"git.eeqj.de/sneak/routewatch/internal/streamer" "git.eeqj.de/sneak/routewatch/internal/streamer"
@@ -173,6 +175,9 @@ type tableStats struct {
// carried 203.0.113.0/24, the two with kept routes withdrew it, so its origin // carried 203.0.113.0/24, the two with kept routes withdrew it, so its origin
// keeps no route. The oldest and newest routes are those of the other two // keeps no route. The oldest and newest routes are those of the other two
// prefixes. // prefixes.
//
// The requests run in a synctest bubble, whose clock is fake, so a stall on
// the host does not use up the handlers' 4-second limit.
func TestStatsHandlersAnswerFromMemory(t *testing.T) { func TestStatsHandlersAnswerFromMemory(t *testing.T) {
t.Parallel() t.Parallel()
@@ -218,7 +223,11 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
for name, handler := range handlers { for name, handler := range handlers {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil))
synctest.Test(t, func(t *testing.T) {
handler(rec,
httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil))
})
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Errorf("%s: status %d, want %d; body %s", t.Errorf("%s: status %d, want %d; body %s",
@@ -253,15 +262,20 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
// TestStatsAnswerCarriesChurnPerMinute checks that the stream object of the // TestStatsAnswerCarriesChurnPerMinute checks that the stream object of the
// /api/v1/stats answer holds route_churn_per_min as a whole number, and no // /api/v1/stats answer holds route_churn_per_min as a whole number, and no
// longer route_churn_per_sec, the moving average it replaces // longer route_churn_per_sec, the moving average it replaces
// (https://git.eeqj.de/sneak/routewatch/issues/160). // (https://git.eeqj.de/sneak/routewatch/issues/160). The request runs in a
// synctest bubble, whose clock is fake, so a stall on the host does not use up
// the handler's 4-second limit.
func TestStatsAnswerCarriesChurnPerMinute(t *testing.T) { func TestStatsAnswerCarriesChurnPerMinute(t *testing.T) {
t.Parallel() t.Parallel()
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
s.handleStats()(rec,
httptest.NewRequestWithContext(t.Context(), http.MethodGet, statsPath, nil)) synctest.Test(t, func(t *testing.T) {
s.handleStats()(rec,
httptest.NewRequestWithContext(t.Context(), http.MethodGet, statsPath, nil))
})
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body) t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
@@ -309,18 +323,26 @@ func (heldBackHandler) HeldBackCount() int { return heldBackMess
// TestStatsAnswerGivesHowManyMessagesAHandlerHoldsBack registers a handler // TestStatsAnswerGivesHowManyMessagesAHandlerHoldsBack registers a handler
// that holds back messages. Its figures in /api/v1/stats, which the status // that holds back messages. Its figures in /api/v1/stats, which the status
// page shows, must give how many. // page shows, must give how many. The request runs in a synctest bubble, whose
// clock is fake, so a stall on the host does not use up the handler's 4-second
// limit.
func TestStatsAnswerGivesHowManyMessagesAHandlerHoldsBack(t *testing.T) { func TestStatsAnswerGivesHowManyMessagesAHandlerHoldsBack(t *testing.T) {
t.Parallel() t.Parallel()
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
s.streamer.RegisterHandler(heldBackHandler{}) s.streamer.RegisterHandler(heldBackHandler{})
var answer []byte
synctest.Test(t, func(t *testing.T) {
answer = get(t, s, statsPath).Body.Bytes()
})
var body struct { var body struct {
Data StatsResponse `json:"data"` Data StatsResponse `json:"data"`
} }
err := json.Unmarshal(get(t, s, statsPath).Body.Bytes(), &body) err := json.Unmarshal(answer, &body)
if err != nil { if err != nil {
t.Fatalf("decoding the answer: %v", err) t.Fatalf("decoding the answer: %v", err)
} }
@@ -364,7 +386,9 @@ type unitStats struct {
// names. They used to be strings with the unit in them, such as "1h18m34s" or // names. They used to be strings with the unit in them, such as "1h18m34s" or
// "1.1 GB" (https://git.eeqj.de/sneak/routewatch/issues/199). The stream has // "1.1 GB" (https://git.eeqj.de/sneak/routewatch/issues/199). The stream has
// never connected, so the uptime is 0. The memory use in /api/v1/stats is the // never connected, so the uptime is 0. The memory use in /api/v1/stats is the
// same number of bytes as its gc.heap_alloc_bytes. // same number of bytes as its gc.heap_alloc_bytes. The requests run in a
// synctest bubble, whose clock is fake, so a stall on the host does not use up
// the handlers' 4-second limit.
func TestStatsAnswersGiveUnitsInFieldNames(t *testing.T) { func TestStatsAnswersGiveUnitsInFieldNames(t *testing.T) {
t.Parallel() t.Parallel()
@@ -374,7 +398,11 @@ func TestStatsAnswersGiveUnitsInFieldNames(t *testing.T) {
s.SetASNFetcher(fixedIntervalASNFetcher{interval: fetchInterval}) s.SetASNFetcher(fixedIntervalASNFetcher{interval: fetchInterval})
for _, path := range []string{statusJSONPath, statsPath} { for _, path := range []string{statusJSONPath, statsPath} {
answer := get(t, s, path).Body.Bytes() var answer []byte
synctest.Test(t, func(t *testing.T) {
answer = get(t, s, path).Body.Bytes()
})
var body struct { var body struct {
Data unitStats `json:"data"` Data unitStats `json:"data"`
@@ -415,82 +443,89 @@ func TestStatsAnswersGiveUnitsInFieldNames(t *testing.T) {
// connection_duration_seconds of /api/v1/stats is null while the stream is not // connection_duration_seconds of /api/v1/stats is null while the stream is not
// connected, and a whole number of seconds once it is. It used to be the // connected, and a whole number of seconds once it is. It used to be the
// string "disconnected", or a Go duration such as "1h18m34s" // string "disconnected", or a Go duration such as "1h18m34s"
// (https://git.eeqj.de/sneak/routewatch/issues/199). // (https://git.eeqj.de/sneak/routewatch/issues/199). The test runs in a
// synctest bubble, whose clock is fake, so a stall on the host does not use up
// the handler's 4-second limit.
func TestStatsAnswerGivesConnectionDurationInSeconds(t *testing.T) { func TestStatsAnswerGivesConnectionDurationInSeconds(t *testing.T) {
t.Parallel() t.Parallel()
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
connectionDuration := func() json.RawMessage { synctest.Test(t, func(t *testing.T) {
answer := get(t, s, statsPath).Body.Bytes() connectionDuration := func() json.RawMessage {
answer := get(t, s, statsPath).Body.Bytes()
var body struct { var body struct {
Data map[string]json.RawMessage `json:"data"` Data map[string]json.RawMessage `json:"data"`
}
err := json.Unmarshal(answer, &body)
if err != nil {
t.Fatalf("decoding the answer: %v; answer %s", err, answer)
}
duration, ok := body.Data["connection_duration_seconds"]
if !ok {
t.Fatalf("no connection_duration_seconds: %s", answer)
}
return duration
} }
err := json.Unmarshal(answer, &body) disconnected := connectionDuration()
if err != nil { if string(disconnected) != "null" {
t.Fatalf("decoding the answer: %v; answer %s", err, answer) t.Errorf("disconnected: connection_duration_seconds = %s, want null",
disconnected)
} }
duration, ok := body.Data["connection_duration_seconds"] s.streamer.GetMetricsTracker().SetConnected(true)
if !ok {
t.Fatalf("no connection_duration_seconds: %s", answer) var seconds int64
connected := connectionDuration()
err := json.Unmarshal(connected, &seconds)
if err != nil || seconds != 0 {
t.Errorf("just connected: connection_duration_seconds = %s, want 0",
connected)
} }
})
return duration
}
disconnected := connectionDuration()
if string(disconnected) != "null" {
t.Errorf("disconnected: connection_duration_seconds = %s, want null",
disconnected)
}
s.streamer.GetMetricsTracker().SetConnected(true)
var seconds int64
connected := connectionDuration()
err := json.Unmarshal(connected, &seconds)
if err != nil || seconds != 0 {
t.Errorf("just connected: connection_duration_seconds = %s, want 0",
connected)
}
} }
// TestStreamDurationsCountSeconds checks that the uptime and the connection // TestStreamDurationsCountSeconds checks that the uptime and the connection
// duration of a stream that connected 90 seconds ago are both 90. A stream // duration of a stream that connected 90 seconds ago are both 90. A stream
// that has never connected has an uptime of 0, and one that is not connected // that has never connected has an uptime of 0, and one that is not connected
// has no connection duration. // has no connection duration. The test runs in a synctest bubble, whose clock
// is fake, so a stall on the host does not add to the 90 seconds.
func TestStreamDurationsCountSeconds(t *testing.T) { func TestStreamDurationsCountSeconds(t *testing.T) {
t.Parallel() t.Parallel()
const connectedFor = 90 * time.Second synctest.Test(t, func(t *testing.T) {
const connectedFor = 90 * time.Second
connectedSince := time.Now().Add(-connectedFor) connectedSince := time.Now().Add(-connectedFor)
want := int64(connectedFor / time.Second) want := int64(connectedFor / time.Second)
if got := uptimeSeconds(connectedSince); got != want { if got := uptimeSeconds(connectedSince); got != want {
t.Errorf("uptimeSeconds = %d, want %d", got, want) t.Errorf("uptimeSeconds = %d, want %d", got, want)
} }
if got := uptimeSeconds(time.Time{}); got != 0 { if got := uptimeSeconds(time.Time{}); got != 0 {
t.Errorf("uptimeSeconds before any connection = %d, want 0", got) t.Errorf("uptimeSeconds before any connection = %d, want 0", got)
} }
switch got := connectionDurationSeconds(true, connectedSince); { switch got := connectionDurationSeconds(true, connectedSince); {
case got == nil: case got == nil:
t.Errorf("connectionDurationSeconds = nil, want %d", want) t.Errorf("connectionDurationSeconds = nil, want %d", want)
case *got != want: case *got != want:
t.Errorf("connectionDurationSeconds = %d, want %d", *got, want) t.Errorf("connectionDurationSeconds = %d, want %d", *got, want)
} }
if got := connectionDurationSeconds(false, connectedSince); got != nil { if got := connectionDurationSeconds(false, connectedSince); got != nil {
t.Errorf("connectionDurationSeconds while not connected = %d, want nil", t.Errorf("connectionDurationSeconds while not connected = %d, want nil",
*got) *got)
} }
})
} }
// newTestTable returns a routing table that holds a route for testPrefixV4, // newTestTable returns a routing table that holds a route for testPrefixV4,
@@ -523,8 +558,11 @@ func newServerWithTable(t *testing.T, table *routetable.Table) *Server {
t.Cleanup(func() { _ = db.Close() }) t.Cleanup(func() { _ = db.Close() })
return New(db, table, streamer.New(logger.New(), metrics.New()), logger.New(), s := New(db, table, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{}) &config.Config{})
useFakeResolver(s, &fakeResolver{})
return s
} }
// TestPrefixDetailHandlersParsePrefix checks that both prefix pages find a // TestPrefixDetailHandlersParsePrefix checks that both prefix pages find a
@@ -1134,7 +1172,9 @@ func (failingDB) GetPrefixFirstSeen(
// with the driver's error text. It also sends a prefix that does not parse. // with the driver's error text. It also sends a prefix that does not parse.
// No answer may carry the driver's or the prefix parser's text. Before the fix // No answer may carry the driver's or the prefix parser's text. Before the fix
// the answers sent it to the client // the answers sent it to the client
// (https://git.eeqj.de/sneak/routewatch/issues/108). // (https://git.eeqj.de/sneak/routewatch/issues/108). The requests run in a
// synctest bubble, whose clock is fake, so a stall on the host does not use up
// the stats handlers' 4-second limit.
func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) { func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1155,8 +1195,11 @@ func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, tt.path, nil)) synctest.Test(t, func(t *testing.T) {
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, tt.path, nil))
})
if rec.Code != tt.wantCode { if rec.Code != tt.wantCode {
t.Errorf("%s: status %d, want %d; body %s", t.Errorf("%s: status %d, want %d; body %s",
@@ -1213,7 +1256,7 @@ func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
s := New(failingIPLookupDB{}, newTestTable(), s := New(failingIPLookupDB{}, newTestTable(),
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{}) streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
s.resolver = &fakeResolver{} useFakeResolver(s, &fakeResolver{})
tests := []struct { tests := []struct {
path string path string
@@ -1249,16 +1292,19 @@ func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
} }
} }
// errNoSuchHost is what fakeResolver answers a lookup it has no answer for // errNoSuchHost is what fakeResolver answers a host name lookup it has no
// with. // addresses for with.
var errNoSuchHost = errors.New("no such host") var errNoSuchHost = errors.New("no such host")
// fakeResolver answers a host name lookup with the addresses hosts lists for // fakeResolver answers a host name lookup with the addresses hosts lists for
// the name, and a PTR lookup with no names or, when ptrNeverAnswers is set, // the name. It answers a PTR lookup with the names ptrs lists for the address,
// not at all: it waits until the lookup's context ends. hostLookups counts the // and with no names and no error, as the real one does, when ptrs lists none.
// host name lookups. // When ptrNeverAnswers is set, a PTR lookup fails after ptr.LookupTimeout, as
// the real one does when no resolver answers. hostLookups counts the host name
// lookups.
type fakeResolver struct { type fakeResolver struct {
hosts map[string][]string hosts map[string][]string
ptrs map[string][]string
ptrNeverAnswers bool ptrNeverAnswers bool
hostLookups atomic.Int64 hostLookups atomic.Int64
@@ -1275,14 +1321,23 @@ func (r *fakeResolver) LookupHost(_ context.Context, host string) ([]string, err
return addrs, nil return addrs, nil
} }
func (r *fakeResolver) LookupAddr(ctx context.Context, _ string) ([]string, error) { func (r *fakeResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
if r.ptrNeverAnswers { if r.ptrNeverAnswers {
ctx, cancel := context.WithTimeout(ctx, ptr.LookupTimeout)
defer cancel()
<-ctx.Done() <-ctx.Done()
return nil, ctx.Err() return nil, ctx.Err()
} }
return nil, errNoSuchHost return r.ptrs[addr], nil
}
// useFakeResolver makes r answer the server's host name and PTR lookups.
func useFakeResolver(s *Server, r *fakeResolver) {
s.resolver = r
s.ptrResolver = r
} }
// countingIPLookupDB embeds database.Store (left nil) and answers the IP // countingIPLookupDB embeds database.Store (left nil) and answers the IP
@@ -1353,7 +1408,7 @@ func TestIPLookupRefusesTooManyAddresses(t *testing.T) {
s := New(db, newTestTable(), streamer.New(logger.New(), metrics.New()), s := New(db, newTestTable(), streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{}) logger.New(), &config.Config{})
resolver := &fakeResolver{hosts: hosts} resolver := &fakeResolver{hosts: hosts}
s.resolver = resolver useFakeResolver(s, resolver)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext( s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
@@ -1388,17 +1443,21 @@ const addrInTestPrefixV4 = "192.0.2.1"
// TestIPLookupKeepsRouteWhenPTRLookupDoesNotAnswer looks up a routed address // TestIPLookupKeepsRouteWhenPTRLookupDoesNotAnswer looks up a routed address
// with a resolver that never answers its PTR lookup. Once the PTR lookup's own // with a resolver that never answers its PTR lookup. Once the PTR lookup's own
// limit passes, the lookup answers 200 with the route. Before the fix the PTR // limit passes, the lookup answers 200 with the route and an empty ptr. Before
// lookup ran until the request's time limit, and the lookup answered 504 // the fix the PTR lookup ran until the request's time limit, and the lookup
// without the route (https://git.eeqj.de/sneak/routewatch/issues/110). // answered 504 without the route
// (https://git.eeqj.de/sneak/routewatch/issues/110).
func TestIPLookupKeepsRouteWhenPTRLookupDoesNotAnswer(t *testing.T) { func TestIPLookupKeepsRouteWhenPTRLookupDoesNotAnswer(t *testing.T) {
t.Parallel() t.Parallel()
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{ptrNeverAnswers: true} useFakeResolver(s, &fakeResolver{ptrNeverAnswers: true})
// The request's time limit passes after the PTR lookup's. // The request's time limit passes well after the PTR lookup's, so that a
ctx, cancel := context.WithTimeout(t.Context(), 2*ptrLookupTimeout) // stall on a busy host does not use up the difference.
const requestTimeLimit = ptr.LookupTimeout + 15*time.Second
ctx, cancel := context.WithTimeout(t.Context(), requestTimeLimit)
defer cancel() defer cancel()
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1422,6 +1481,10 @@ func TestIPLookupKeepsRouteWhenPTRLookupDoesNotAnswer(t *testing.T) {
*body.Data.Results[0].Netblock != testPrefixV4 { *body.Data.Results[0].Netblock != testPrefixV4 {
t.Errorf("answer %s, want one result in %s", rec.Body, testPrefixV4) t.Errorf("answer %s, want one result in %s", rec.Body, testPrefixV4)
} }
if !strings.Contains(rec.Body.String(), `"ptr":[]`) {
t.Errorf("answer %s, want an empty ptr list", rec.Body)
}
} }
// addrInTestPrefixV6 is an address in testPrefixV6, which // addrInTestPrefixV6 is an address in testPrefixV6, which
@@ -1499,7 +1562,7 @@ func TestIPLookupFlags(t *testing.T) {
t.Parallel() t.Parallel()
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{} useFakeResolver(s, &fakeResolver{})
s.SetIPLists(fakeIPLists{}) s.SetIPLists(fakeIPLists{})
tests := []struct { tests := []struct {
@@ -1612,7 +1675,7 @@ func TestIPLookupReportsIPLists(t *testing.T) {
t.Parallel() t.Parallel()
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{} useFakeResolver(s, &fakeResolver{})
s.SetIPLists(fakeIPLists{}) s.SetIPLists(fakeIPLists{})
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1689,7 +1752,7 @@ func TestIPLookupReportsIPListsOfAnAddressWithNoRoute(t *testing.T) {
t.Parallel() t.Parallel()
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{} useFakeResolver(s, &fakeResolver{})
s.SetIPLists(fakeIPLists{}) s.SetIPLists(fakeIPLists{})
// routeFields are the fields of a result that come from the address's // routeFields are the fields of a result that come from the address's
@@ -1767,9 +1830,9 @@ func TestIPLookupResultsHaveTheSameFields(t *testing.T) {
t.Parallel() t.Parallel()
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{hosts: map[string][]string{ useFakeResolver(s, &fakeResolver{hosts: map[string][]string{
twoAddressHostName: {addrInTestPrefixV4, listedUnroutedAddr}, twoAddressHostName: {addrInTestPrefixV4, listedUnroutedAddr},
}} }})
s.SetIPLists(fakeIPLists{}) s.SetIPLists(fakeIPLists{})
// want is the fields of the first result. // want is the fields of the first result.
@@ -1802,7 +1865,7 @@ func TestIPLookupOfAnAddressWithoutPTRNamesOrStoredNetblock(t *testing.T) {
t.Parallel() t.Parallel()
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{} useFakeResolver(s, &fakeResolver{})
path := "/api/v1/ip/" + addrInTestPrefixV4 path := "/api/v1/ip/" + addrInTestPrefixV4
@@ -1824,6 +1887,32 @@ func TestIPLookupOfAnAddressWithoutPTRNamesOrStoredNetblock(t *testing.T) {
} }
} }
// TestIPLookupGivesPTRNamesWithoutTrailingDots looks up an address whose PTR
// name the resolver gives fully qualified. The result's ptr holds the name
// without its trailing dot.
func TestIPLookupGivesPTRNamesWithoutTrailingDots(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
useFakeResolver(s, &fakeResolver{ptrs: map[string][]string{
addrInTestPrefixV4: {"host.example.com."},
}})
path := "/api/v1/ip/" + addrInTestPrefixV4
results := resultFields(t, get(t, s, path).Body.Bytes())
if len(results) != 1 {
t.Fatalf("%s: %d results, want 1", path, len(results))
}
got := string(results[0]["ptr"])
want := `["host.example.com"]`
if got != want {
t.Errorf("%s: ptr is %s, want %s", path, got, want)
}
}
// TestIPLookupAnswersMayBeCached checks the Cache-Control header of the IP // TestIPLookupAnswersMayBeCached checks the Cache-Control header of the IP
// lookup answers. Caches may keep them for the IP lists' shortest interval, // lookup answers. Caches may keep them for the IP lists' shortest interval,
// and only the caller's own browser may keep the answer about the caller's // and only the caller's own browser may keep the answer about the caller's
@@ -1832,7 +1921,7 @@ func TestIPLookupAnswersMayBeCached(t *testing.T) {
t.Parallel() t.Parallel()
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{} useFakeResolver(s, &fakeResolver{})
s.SetIPLists(fakeIPLists{}) s.SetIPLists(fakeIPLists{})
tests := []struct { tests := []struct {
@@ -1883,10 +1972,10 @@ func TestIPLookupHidesPrivateAddresses(t *testing.T) {
t.Parallel() t.Parallel()
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{hosts: map[string][]string{ useFakeResolver(s, &fakeResolver{hosts: map[string][]string{
internalHostName: {internalHostAddr}, internalHostName: {internalHostAddr},
"mixed.test": {internalHostAddr, addrInTestPrefixV4}, "mixed.test": {internalHostAddr, addrInTestPrefixV4},
}} }})
notPublic := internalHostName + ": " + hostNotPublicMessage notPublic := internalHostName + ": " + hostNotPublicMessage
@@ -2367,9 +2456,13 @@ func settledGoroutineCount() int {
return prev return prev
} }
// waitForGoroutines waits until the goroutine count drops to target or below. // waitForGoroutines waits up to 15 seconds for the goroutine count to drop to
// target or below.
func waitForGoroutines(target int) bool { func waitForGoroutines(target int) bool {
for range 100 { const maxWait = 15 * time.Second
deadline := time.Now().Add(maxWait)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() <= target { if runtime.NumGoroutine() <= target {
return true return true
} }
@@ -85,7 +85,7 @@ func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) {
t.Parallel() t.Parallel()
s := newServerWithTable(t, newTwoOriginTable()) s := newServerWithTable(t, newTwoOriginTable())
s.resolver = &fakeResolver{} useFakeResolver(s, &fakeResolver{})
firstSeen := testRouteTime(-1) firstSeen := testRouteTime(-1)
+8 -4
View File
@@ -76,7 +76,9 @@ func asLink(asn int) string {
} }
// TestHomePageCardsShowTheTableFigures fills a routing table and the asns // TestHomePageCardsShowTheTableFigures fills a routing table and the asns
// table, works out the cards, and checks that each card shows its figure. // table, works out the cards, and checks that each card shows its figure. The
// cards are worked out in a synctest bubble, whose clock is fake, so a stall on
// the host does not use up the 4-second limit on their database read.
func TestHomePageCardsShowTheTableFigures(t *testing.T) { func TestHomePageCardsShowTheTableFigures(t *testing.T) {
t.Parallel() t.Parallel()
@@ -95,7 +97,9 @@ func TestHomePageCardsShowTheTableFigures(t *testing.T) {
t.Fatalf("GetOrCreateASNBatch: %v", err) t.Fatalf("GetOrCreateASNBatch: %v", err)
} }
s.refreshHomeCards(t.Context()) synctest.Test(t, func(t *testing.T) {
s.refreshHomeCards(t.Context())
})
page := get(t, s, "/").Body.String() page := get(t, s, "/").Body.String()
@@ -279,7 +283,7 @@ func TestKeepHomeCardsFreshWorksThemOutEveryInterval(t *testing.T) {
const ( const (
interval = 10 * time.Millisecond interval = 10 * time.Millisecond
maxWait = 5 * time.Second maxWait = 15 * time.Second
) )
table := emptyTable() table := emptyTable()
@@ -321,7 +325,7 @@ func TestKeepHomeCardsFreshWorksThemOutEveryInterval(t *testing.T) {
func TestStartedServerWorksOutTheCards(t *testing.T) { func TestStartedServerWorksOutTheCards(t *testing.T) {
t.Parallel() t.Parallel()
const maxWait = 5 * time.Second const maxWait = 15 * time.Second
s := newServerWithTable(t, newCardsTable()) s := newServerWithTable(t, newCardsTable())
+42 -35
View File
@@ -14,6 +14,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"testing" "testing"
"testing/synctest"
"time" "time"
"git.eeqj.de/sneak/routewatch/internal/config" "git.eeqj.de/sneak/routewatch/internal/config"
@@ -114,7 +115,7 @@ func TestJSONAnswersPutNoUnitInAString(t *testing.T) {
`^([0-9]+(\.[0-9]+)? ?(ns|µs|ms|s|m|h|B|kB|MB|GB|TB))+$`) `^([0-9]+(\.[0-9]+)? ?(ns|µs|ms|s|m|h|B|kB|MB|GB|TB))+$`)
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{} useFakeResolver(s, &fakeResolver{})
s.SetASNFetcher(fixedIntervalASNFetcher{interval: time.Second}) s.SetASNFetcher(fixedIntervalASNFetcher{interval: time.Second})
paths := []string{ paths := []string{
@@ -163,6 +164,10 @@ func TestJSONAnswersPutNoUnitInAString(t *testing.T) {
// router and the middleware write. Before the fix a panic in a handler, and a // router and the middleware write. Before the fix a panic in a handler, and a
// method /status.json or the health check does not take, answered with an // method /status.json or the health check does not take, answered with an
// empty body (https://git.eeqj.de/sneak/routewatch/issues/201). // empty body (https://git.eeqj.de/sneak/routewatch/issues/201).
//
// The requests run in a synctest bubble, whose clock is fake, so a stall on
// the host does not use up the 2-second limit of the health check or the
// 4-second limit of the stats handlers.
func TestJSONAnswersCarryMeta(t *testing.T) { func TestJSONAnswersCarryMeta(t *testing.T) {
t.Parallel() t.Parallel()
@@ -174,13 +179,17 @@ func TestJSONAnswersCarryMeta(t *testing.T) {
) )
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{} useFakeResolver(s, &fakeResolver{})
s.streamer.GetMetricsTracker().SetConnected(true) s.streamer.GetMetricsTracker().SetConnected(true)
s.router.Get(writesNothingPath, JSONValidationMiddleware(http.HandlerFunc( s.router.Get(writesNothingPath, JSONValidationMiddleware(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {})).ServeHTTP) func(http.ResponseWriter, *http.Request) {})).ServeHTTP)
s.router.Get(panicsPath, JSONValidationMiddleware(http.HandlerFunc( s.router.Get(panicsPath, JSONValidationMiddleware(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) { panic(errDiskIO) })).ServeHTTP) func(http.ResponseWriter, *http.Request) { panic(errDiskIO) })).ServeHTTP)
failing := New(failingDB{}, newTestTable(),
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
useFakeResolver(failing, &fakeResolver{})
tests := []struct { tests := []struct {
method string method string
path string path string
@@ -216,42 +225,40 @@ func TestJSONAnswersCarryMeta(t *testing.T) {
{http.MethodGet, panicsPath, http.StatusInternalServerError}, {http.MethodGet, panicsPath, http.StatusInternalServerError},
} }
for _, tt := range tests { synctest.Test(t, func(t *testing.T) {
checkAnswerCarriesMeta(t, s, httptest.NewRequestWithContext( for _, tt := range tests {
t.Context(), tt.method, tt.path, nil), tt.wantCode) checkAnswerCarriesMeta(t, s, httptest.NewRequestWithContext(
} t.Context(), tt.method, tt.path, nil), tt.wantCode)
}
// A request whose time limit has already passed answers 504. The handlers // A request whose time limit has already passed answers 504. The
// write the answers of the first five routes, and the JSON check on the // handlers write the answers of the first five routes, and the JSON
// route writes the last. // check on the route writes the last.
pastTimeLimit, cancel := context.WithDeadline(t.Context(), time.Now()) pastTimeLimit, cancel := context.WithDeadline(t.Context(), time.Now())
defer cancel() defer cancel()
for _, path := range []string{ for _, path := range []string{
statusJSONPath, statsPath, "/ip/" + addrInTestPrefixV4, statusJSONPath, statsPath, "/ip/" + addrInTestPrefixV4,
"/api/v1/ip/" + addrInTestPrefixV4, asJSONPath, writesNothingPath, "/api/v1/ip/" + addrInTestPrefixV4, asJSONPath, writesNothingPath,
} { } {
checkAnswerCarriesMeta(t, s, httptest.NewRequestWithContext( checkAnswerCarriesMeta(t, s, httptest.NewRequestWithContext(
pastTimeLimit, http.MethodGet, path, nil), http.StatusGatewayTimeout) pastTimeLimit, http.MethodGet, path, nil), http.StatusGatewayTimeout)
} }
// With a store whose reads fail, the health check answers 503, and the // With a store whose reads fail, the health check answers 503, and
// handlers of the other routes 500. // the handlers of the other routes 500.
failing := New(failingDB{}, newTestTable(),
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
failing.resolver = &fakeResolver{}
checkAnswerCarriesMeta(t, failing, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, healthCheckPath, nil),
http.StatusServiceUnavailable)
for _, path := range []string{
statusJSONPath, statsPath, asJSONPath,
"/api/v1/ip/" + addrInTestPrefixV4, "/ip/" + addrInTestPrefixV4,
} {
checkAnswerCarriesMeta(t, failing, httptest.NewRequestWithContext( checkAnswerCarriesMeta(t, failing, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil), http.StatusInternalServerError) t.Context(), http.MethodGet, healthCheckPath, nil),
} http.StatusServiceUnavailable)
for _, path := range []string{
statusJSONPath, statsPath, asJSONPath,
"/api/v1/ip/" + addrInTestPrefixV4, "/ip/" + addrInTestPrefixV4,
} {
checkAnswerCarriesMeta(t, failing, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil), http.StatusInternalServerError)
}
})
} }
// checkAnswerCarriesMeta serves req through the server's router, and checks // checkAnswerCarriesMeta serves req through the server's router, and checks
@@ -514,7 +521,7 @@ func newServerWithConfig(
s := New(db, newTestTable(), streamer.New(logger.New(), metrics.New()), s := New(db, newTestTable(), streamer.New(logger.New(), metrics.New()),
&logger.Logger{Logger: slog.New(slog.NewJSONHandler(requestLog, nil))}, cfg) &logger.Logger{Logger: slog.New(slog.NewJSONHandler(requestLog, nil))}, cfg)
s.resolver = &fakeResolver{} useFakeResolver(s, &fakeResolver{})
return s return s
} }
+11 -2
View File
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"testing/synctest"
) )
// TestRoutesAnswerHEAD sends HEAD for a page and JSON routes through the // TestRoutesAnswerHEAD sends HEAD for a page and JSON routes through the
@@ -12,15 +13,23 @@ import (
// lookup of the caller's own address, are in the list because chi's GetHead // lookup of the caller's own address, are in the list because chi's GetHead
// does not reach them. Before the fix every route answered HEAD with 405 // does not reach them. Before the fix every route answered HEAD with 405
// (https://git.eeqj.de/sneak/routewatch/issues/111). // (https://git.eeqj.de/sneak/routewatch/issues/111).
//
// The requests run in a synctest bubble, whose clock is fake, so a stall on
// the host does not use up the stats handlers' 4-second limit. The resolver
// is fake because no time limit would cut off a real DNS lookup in a bubble.
func TestRoutesAnswerHEAD(t *testing.T) { func TestRoutesAnswerHEAD(t *testing.T) {
t.Parallel() t.Parallel()
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
for _, path := range []string{"/", statusJSONPath, statsPath, "/ip", "/ip/"} { for _, path := range []string{"/", statusJSONPath, statsPath, "/ip", "/ip/"} {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodHead, path, nil)) synctest.Test(t, func(t *testing.T) {
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodHead, path, nil))
})
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Errorf("HEAD %s: status %d, want %d; body %s", t.Errorf("HEAD %s: status %d, want %d; body %s",
+16 -3
View File
@@ -52,10 +52,14 @@ type IPLists interface {
ShortestInterval() time.Duration ShortestInterval() time.Duration
} }
// dnsResolver looks up the addresses of host names and the names of addresses // dnsResolver looks up the addresses of host names in DNS, as *net.Resolver
// in DNS, as *net.Resolver does. // does.
type dnsResolver interface { type dnsResolver interface {
LookupHost(ctx context.Context, host string) ([]string, error) LookupHost(ctx context.Context, host string) ([]string, error)
}
// PTRResolver looks up the PTR names of addresses, as *ptr.Cache does.
type PTRResolver interface {
LookupAddr(ctx context.Context, addr string) ([]string, error) LookupAddr(ctx context.Context, addr string) ([]string, error)
} }
@@ -75,10 +79,13 @@ type Server struct {
// RealIPMiddleware believes // RealIPMiddleware believes
trustedProxies []netip.Prefix trustedProxies []netip.Prefix
// resolver makes the IP lookup's DNS lookups: net.DefaultResolver, or a // resolver looks up the IP lookup's host names: net.DefaultResolver, or a
// fake in tests // fake in tests
resolver dnsResolver resolver dnsResolver
// ptrResolver looks up the IP lookup's PTR names. SetPTRResolver sets it.
ptrResolver PTRResolver
// cards is the copy of the home page's cards that the pages serve, nil // cards is the copy of the home page's cards that the pages serve, nil
// until the server first works them out // until the server first works them out
cards atomic.Pointer[homeCards] cards atomic.Pointer[homeCards]
@@ -196,6 +203,12 @@ func (s *Server) Stop(ctx context.Context) error {
return nil return nil
} }
// SetPTRResolver sets what looks up the IP lookup's PTR names. Call it before
// Start: the handlers read it without a lock.
func (s *Server) SetPTRResolver(resolver PTRResolver) {
s.ptrResolver = resolver
}
// SetASNFetcher sets the ASN WHOIS fetcher for on-demand lookups. Call it // SetASNFetcher sets the ASN WHOIS fetcher for on-demand lookups. Call it
// before Start: the handlers read the fetcher without a lock. // before Start: the handlers read the fetcher without a lock.
func (s *Server) SetASNFetcher(fetcher ASNFetcher) { func (s *Server) SetASNFetcher(fetcher ASNFetcher) {
+2 -2
View File
@@ -22,10 +22,10 @@ func TestStopClosesTheConnectionOfARequestStillRunning(t *testing.T) {
stopDeadline = 100 * time.Millisecond stopDeadline = 100 * time.Millisecond
// waitTimeout is how long the test waits for the request to reach // waitTimeout is how long the test waits for the request to reach
// the handler, and then for the client to see its connection closed. // the handler, and then for the client to see its connection closed.
waitTimeout = 2 * time.Second waitTimeout = 15 * time.Second
// readHeaderTimeout bounds the test server's read of the request // readHeaderTimeout bounds the test server's read of the request
// header, as every http.Server must. // header, as every http.Server must.
readHeaderTimeout = time.Second readHeaderTimeout = waitTimeout
) )
release := make(chan struct{}) release := make(chan struct{})
+130 -73
View File
@@ -14,6 +14,7 @@ import (
"strings" "strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
"testing/synctest"
"time" "time"
"git.eeqj.de/sneak/routewatch/internal/logger" "git.eeqj.de/sneak/routewatch/internal/logger"
@@ -398,7 +399,7 @@ func TestStopQueuesTheHeldStateMessages(t *testing.T) {
const ( const (
held = 2 held = 2
lines = held + 1 lines = held + 1
maxWait = 5 * time.Second maxWait = 15 * time.Second
) )
srv := httptest.NewServer(http.HandlerFunc( srv := httptest.NewServer(http.HandlerFunc(
@@ -478,15 +479,15 @@ func TestStopQueuesTheHeldStateMessages(t *testing.T) {
// TestStopReturnsByItsDeadline stops a streamer whose handler has a full queue // TestStopReturnsByItsDeadline stops a streamer whose handler has a full queue
// and a STATE message held for it, and a worker that stays busy until the test // and a STATE message held for it, and a worker that stays busy until the test
// ends, as one does whose handler waits for a database write. Stop must return // ends, as one does whose handler waits for a database write. Stop must return
// within its deadline plus a small margin instead of waiting for room on the // within its deadline plus a margin instead of waiting for room on the queue
// queue and for the worker, and log as not handed over the held message and // and for the worker, and log as not handed over the held message and every
// every message on the queue. // message on the queue.
func TestStopReturnsByItsDeadline(t *testing.T) { func TestStopReturnsByItsDeadline(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
deadline = 100 * time.Millisecond deadline = 100 * time.Millisecond
margin = 2 * time.Second margin = 15 * time.Second
) )
var logs bytes.Buffer var logs bytes.Buffer
@@ -577,7 +578,7 @@ func TestStopBeforeMessageReachesQueues(t *testing.T) {
func TestReconnectWhenStreamEnds(t *testing.T) { func TestReconnectWhenStreamEnds(t *testing.T) {
t.Parallel() t.Parallel()
endResponse := func(http.ResponseWriter, *http.Request) {} endResponse := pipeTransport{write: func(context.Context, io.Writer) {}}
checkReconnect(t, endResponse, streamIdleTimeout) checkReconnect(t, endResponse, streamIdleTimeout)
} }
@@ -589,9 +590,8 @@ func TestReconnectWhenStreamGoesIdle(t *testing.T) {
const idleTimeout = 50 * time.Millisecond const idleTimeout = 50 * time.Millisecond
holdResponse := func(w http.ResponseWriter, r *http.Request) { holdResponse := pipeTransport{
flush(w) write: func(ctx context.Context, _ io.Writer) { <-ctx.Done() },
<-r.Context().Done()
} }
checkReconnect(t, holdResponse, idleTimeout) checkReconnect(t, holdResponse, idleTimeout)
} }
@@ -604,16 +604,51 @@ func TestReconnectWhenServerNeverAnswers(t *testing.T) {
const idleTimeout = 50 * time.Millisecond const idleTimeout = 50 * time.Millisecond
neverAnswer := func(_ http.ResponseWriter, r *http.Request) { neverAnswer := roundTripFunc(
<-r.Context().Done() func(req *http.Request) (*http.Response, error) {
} <-req.Context().Done()
return nil, req.Context().Err()
},
)
checkReconnect(t, neverAnswer, idleTimeout) checkReconnect(t, neverAnswer, idleTimeout)
} }
// TestStreamNotDroppedWhileLinesArrive has the server send lines well within // pipeTransport answers every request with a response whose body write fills.
// the idle timeout for several times the timeout, then end the response. Each // The body fails when the request's context ends, as a connection's does.
// line must start the idle timeout over, so the stream lasts until the server // write gets that context, so that it can end when the request does.
// ends it instead of being dropped as idle. type pipeTransport struct {
write func(context.Context, io.Writer)
}
func (p pipeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
body, w := io.Pipe()
context.AfterFunc(req.Context(), func() {
_ = w.CloseWithError(req.Context().Err())
})
go func(ctx context.Context) {
p.write(ctx, w)
_ = w.Close()
}(req.Context())
return &http.Response{StatusCode: http.StatusOK, Body: body}, nil
}
// roundTripFunc makes a function an http.RoundTripper.
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
// TestStreamNotDroppedWhileLinesArrive has a response send lines well within
// the idle timeout for several times the timeout, then end. Each line must
// start the idle timeout over, so the stream lasts until the response ends
// instead of being dropped as idle. It runs in a synctest bubble, whose clock
// is fake, so a stall on the host does not use up the timeout.
func TestStreamNotDroppedWhileLinesArrive(t *testing.T) { func TestStreamNotDroppedWhileLinesArrive(t *testing.T) {
t.Parallel() t.Parallel()
@@ -623,26 +658,35 @@ func TestStreamNotDroppedWhileLinesArrive(t *testing.T) {
sendFor = 5 * idleTimeout sendFor = 5 * idleTimeout
) )
srv := httptest.NewServer(http.HandlerFunc( // The streamer is made outside the bubble. Its metrics start a goroutine
func(w http.ResponseWriter, _ *http.Request) { // that never returns, and a bubble waits for each of its goroutines to
// return.
s := New(logger.New(), metrics.New())
s.idleTimeout = idleTimeout
s.client = &http.Client{Transport: pipeTransport{
write: func(ctx context.Context, w io.Writer) {
deadline := time.Now().Add(sendFor) deadline := time.Now().Add(sendFor)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
_, _ = io.WriteString(w, updateLine) _, _ = io.WriteString(w, updateLine)
flush(w) // The wait ends with the stream. A bubble's clock stops when
time.Sleep(lineInterval) // the test function returns, and a goroutine then still
// asleep panics the test binary.
select {
case <-ctx.Done():
return
case <-time.After(lineInterval):
}
} }
})) },
defer srv.Close() }}
s := New(logger.New(), metrics.New()) synctest.Test(t, func(t *testing.T) {
s.url = srv.URL err := s.stream(context.Background())
s.idleTimeout = idleTimeout if !errors.Is(err, errStreamEnded) {
t.Fatalf("stream returned %v, want %v", err, errStreamEnded)
err := s.stream(context.Background()) }
if !errors.Is(err, errStreamEnded) { })
t.Fatalf("stream returned %v, want %v", err, errStreamEnded)
}
} }
// TestStreamReadsLongLine has the server send one UPDATE line of about // TestStreamReadsLongLine has the server send one UPDATE line of about
@@ -706,7 +750,7 @@ func TestHandlerStatsReadWhileMessagesAreDropped(t *testing.T) {
const ( const (
lines = 5 lines = 5
wantDropped = lines - 2 wantDropped = lines - 2
maxWait = 5 * time.Second maxWait = 15 * time.Second
) )
srv := httptest.NewServer(http.HandlerFunc( srv := httptest.NewServer(http.HandlerFunc(
@@ -820,61 +864,70 @@ func streamLines(t *testing.T, s *Streamer, lines string) {
// line and then stays open. The streamer must come back for a second // line and then stays open. The streamer must come back for a second
// response, must not be marked connected when it does, and must handle the // response, must not be marked connected when it does, and must handle the
// UPDATE in it. // UPDATE in it.
//
// It runs in a synctest bubble, whose clock is fake, so a stall on the host
// does not use up the idle timeout. The fake server gets the requests in the
// order the streamer makes them, because no real connection carries them.
func checkReconnect( func checkReconnect(
t *testing.T, firstResponse http.HandlerFunc, idleTimeout time.Duration, t *testing.T, firstResponse http.RoundTripper, idleTimeout time.Duration,
) { ) {
t.Helper() t.Helper()
const maxWait = 5 * time.Second const maxWait = 15 * time.Second
// The tracker is made outside the bubble. It starts a goroutine that never
// returns, and a bubble waits for each of its goroutines to return.
tracker := metrics.New() tracker := metrics.New()
connectedAtReconnect := make(chan bool, 1)
var requests atomic.Int32 synctest.Test(t, func(t *testing.T) {
connectedAtReconnect := make(chan bool, 1)
laterResponse := pipeTransport{
write: func(ctx context.Context, w io.Writer) {
_, _ = io.WriteString(w, updateLine)
srv := httptest.NewServer(http.HandlerFunc( <-ctx.Done()
func(w http.ResponseWriter, r *http.Request) { },
if requests.Add(1) == 1 { }
firstResponse(w, r)
return var requests atomic.Int32
}
select { s := New(logger.New(), tracker)
case connectedAtReconnect <- tracker.IsConnected(): s.client = &http.Client{Transport: roundTripFunc(
default: func(req *http.Request) (*http.Response, error) {
} if requests.Add(1) == 1 {
return firstResponse.RoundTrip(req)
}
_, _ = io.WriteString(w, updateLine) select {
case connectedAtReconnect <- tracker.IsConnected():
default:
}
flush(w) return laterResponse.RoundTrip(req)
<-r.Context().Done() },
})) )}
defer srv.Close() s.idleTimeout = idleTimeout
s.minBackoff = time.Millisecond
handler := updateHandler{handled: make(chan struct{}, 1)}
s.RegisterHandler(handler)
s := New(logger.New(), tracker) err := s.Start(t.Context())
s.url = srv.URL if err != nil {
s.idleTimeout = idleTimeout t.Fatalf("Start returned error: %v", err)
s.minBackoff = time.Millisecond }
handler := updateHandler{handled: make(chan struct{}, 1)} defer s.Stop(t.Context())
s.RegisterHandler(handler)
err := s.Start(t.Context()) select {
if err != nil { case <-handler.handled:
t.Fatalf("Start returned error: %v", err) case <-time.After(maxWait):
} t.Fatalf("no message from a second response handled within %s", maxWait)
defer s.Stop(t.Context()) }
select { if <-connectedAtReconnect {
case <-handler.handled: t.Error("streamer was still marked connected when it came back " +
case <-time.After(maxWait): "for a second response")
t.Fatalf("no message from a second response handled within %s", maxWait) }
} })
if <-connectedAtReconnect {
t.Error("streamer was still marked connected when it came back " +
"for a second response")
}
} }
// settledGoroutineCount lets transient goroutines finish, then reports the // settledGoroutineCount lets transient goroutines finish, then reports the
@@ -896,9 +949,13 @@ func settledGoroutineCount() int {
return prev return prev
} }
// waitForGoroutines waits until the goroutine count drops to target or below. // waitForGoroutines waits up to 15 seconds for the goroutine count to drop to
// target or below.
func waitForGoroutines(target int) bool { func waitForGoroutines(target int) bool {
for range 100 { const maxWait = 15 * time.Second
deadline := time.Now().Add(maxWait)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() <= target { if runtime.NumGoroutine() <= target {
return true return true
} }