7 Commits
Author SHA1 Message Date
sneak ebb7bf4d71 Rewrite the README sentence by sentence (closes #157)
check / check (push) Waiting to run
The old text failed the issue's prose rules: a 60-word sentence on the
content security policy, a markdown table of settings, comma-chained
lists of batch sizes and error codes, undefined jargon and an edge case
in the opening. The new text leads with what RouteWatch is, why it
exists and how to run it, then the pages, the API and the blocklists,
then the settings, memory and upaas, and last the design and the
development workflow. Every statement was checked against the code on
next. The PR body lists where the old text disagreed with the code.

Model: fable-5-1
2026-10-27 21:03:44 +00:00
clawbot 6d4f3e3419 Say in README why RouteWatch exists (closes #204)
check / check (push) Canceled after 0s
The Rationale section now gives the owner's reason for the program: it
is an internal API that our other services ask about IP addresses. It
sees which AS routes each address and which announced prefix holds it
from its own routing table. WHOIS details of each AS, kept in its
database, add to that.

What RIS Live does and does not give stays, shortened. Planned geojs
lookups are left out, because the README describes only what is built.

Model: opus-5-5
2026-10-27 21:34:08 +01:00
clawbot 47aed8761e Give every JSON answer @meta, errors included (closes #201)
check / check (push) Canceled after 0s
A panic in a JSON route's handler answered 500 with an empty body,
because the router's recoverer sits outside the middleware that adds
`@meta`. `JSONValidationMiddleware` now wraps the handler in the same
recoverer, and answers the panic with the JSON error body.

`/status.json` and the health check answered chi's empty 405 to any
method they do not take. They now answer with the same JSON 405 as the
routes under `/api/v1` and `/ip`.

The home page's IP lookup card showed only the answer's `data`. It now
shows the whole answer, `@meta` included.

A table test checks `@meta` on every JSON route, in its successes and
its errors.

Model: opus-5-5
2026-10-27 21:20:07 +01:00
clawbot e8d04f1304 Give every API value with a unit as a number (closes #199)
check / check (push) Canceled after 0s
A value with a unit is now a number, and its field name ends in the
unit, as the vendored API_POLICIES.md requires. @meta.execution_time
becomes execution_time_ms. In the stats answers, uptime,
connection_duration, memory_usage and whois_stats.current_interval
become uptime_seconds, connection_duration_seconds, memory_usage_bytes
and whois_stats.current_interval_seconds. connection_duration_seconds
is null while the feed is not connected. The status page formats the
numbers itself. REPO_POLICIES.md is re-vendored from the same
sneak/prompts commit.

Model: opus-5-5
2026-10-27 20:19:01 +01:00
clawbot a671b8a0b0 Give every IP lookup result the same fields (closes #200)
check / check (push) Canceled after 0s
Every result of /ip/, /ip/{addr} and /api/v1/ip/{ip} now has every
field. A field without a value is null instead of left out, so an
address with no live route has all its route and AS fields set to null.
ptr is an empty list when there are no PTR names.

Two new fields, num_origins and origin_asns, give how many origin ASes
peers carry the netblock with, and which. The routing table's prefix
summary now lists them. asn still names the origin that most peers
carry.

first_seen is null, no longer the zero time, when the database has not
stored the netblock yet.

Model: opus-5-5
2026-10-27 19:54:02 +01:00
clawbot fb5e98feab Trust reverse proxies at private addresses by default (closes #198)
check / check (push) Canceled after 0s
With TRUSTED_PROXIES unset, the server now trusts reverse proxies in the
private ranges of RFC 1918. A proxy such as traefik on a Docker network is
then believed. /ip/, the home page's curl card and the request log give the
client's address from X-Forwarded-For instead of the proxy's address.

A list in TRUSTED_PROXIES replaces the default. TRUSTED_PROXIES set but
empty trusts no proxy, so the config package reads it with os.LookupEnv.
A direct client at a public address still cannot choose its address with
a forged header.

Model: opus-5-5
2026-10-27 19:18:34 +01:00
clawbot 281d162e27 Show figures of the routing table on the home page (closes #154)
check / check (push) Canceled after 0s
The home page gets cards for the newest and oldest kept route, the newest
and oldest updated AS, the AS with the most prefixes, the ASes with a
single prefix and fifteen well-known networks. Two more cards count the
bogon announcements and the prefixes with several origin ASes. They link
to the new pages /bogons and /multiple-origins, which list up to 1,000
entries each.

The new package internal/bogon tells reserved prefixes and AS numbers,
from the IANA special-purpose and address space registries. Table.Overview
walks the table and lets the read lock go every 10,000 prefixes. The
server works the cards out once a minute and serves that copy. A new index
on asns(last_seen) finds the newest and oldest updated AS.

Model: opus-5-5
2026-10-27 18:27:01 +01:00
36 changed files with 3954 additions and 703 deletions
+20
View File
@@ -0,0 +1,20 @@
---
title: API Policies
last_modified: 2026-10-27
---
A value in an API response is never a string with a label or unit in it. A value
of a native type is sent as that type, and its unit goes in the field name, for
example `_ms`.
Wrong:
```json
{ "execution_time": "0 ms" }
```
Right:
```json
{ "execution_time_ms": 0 }
```
+624 -485
View File
File diff suppressed because it is too large Load Diff
+92 -22
View File
@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-10-18
last_modified: 2026-10-27
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -12,6 +12,7 @@ style conventions are in separate documents:
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
- [API Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/API_POLICIES.md)
---
@@ -174,27 +175,32 @@ style conventions are in separate documents:
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
`script/docker` are the four, and there is no fifth — `script/check` runs the
two gate phases and `script/fmt-check`, and builds no image of its own. In
every stage of the `Dockerfile`, `ARG CACHEBUST` comes right after the steps
that install system packages and download Go's modules, or right after `FROM`
in a stage with neither, and every step that lints, tests or compiles comes
after it. A step that installs anything else as well, such as a
`script/bootstrap` that also installs node packages, comes after it too. A new
value reruns every `RUN` step after that line; `COPY` steps are compared by
content and need nothing. Docker keeps the layers above the line until the
base image, their own lines or the files they copy change. A stage without the
`ARG` line is served from cache on an unchanged tree, so a repository
re-vendors the scripts and adds the `ARG` lines in one commit. The scripts do
not pass `--no-cache`: it reruns every step too, but it also builds the layers
above the line again and empties every cache mount, so Go's build cache and
golangci-lint's, which the canonical Go `Dockerfile` below mounts so that they
survive between check builds, would start empty every time. A bare
`docker build .` is not evidence that anything ran: a sub-second build
reporting success is a cache hit, not a result. Never invalidate by pruning —
`docker builder prune` and friends destroy a build cache shared with every
other build on the host. When a check is added or changed, prove it works by
planting a defect it must catch and watching the run fail on it, then revert
the defect. A green run alone shows neither that the check ran nor that it
covers what it should.
every stage of the `Dockerfile`, every step that lints, tests or compiles
comes after `ARG CACHEBUST`. `ARG CACHEBUST` comes right after these steps:
- the steps that install system packages
- the steps that download Go's modules
- the step that runs `script/bootstrap` to install a Python `test` phase's
packages from `Pipfile.lock`
A stage with none of these steps declares `ARG CACHEBUST` right after
`FROM`. A step that installs anything else as well, such as a
`script/bootstrap` that also installs node packages, comes after it too. A
new value reruns every `RUN` step after that line; `COPY` steps are compared
by content and need nothing. Docker keeps the layers above the line until
the base image, their own lines or the files they copy change. A stage
without the `ARG` line is served from cache on an unchanged tree, so a
repository re-vendors the scripts and adds the `ARG` lines in one commit.
The scripts do not pass `--no-cache`: it reruns every step too, but it also
builds the layers above the line again and empties every cache mount, so
Go's build cache and golangci-lint's, which the canonical Go `Dockerfile`
below mounts so that they survive between check builds, would start empty
every time. A bare `docker build .` is not evidence that anything ran: a
sub-second build reporting success is a cache hit, not a result. Never
invalidate by pruning — `docker builder prune` and friends destroy a build
cache shared with every other build on the host. When a check is added or
changed, prove it works by planting a defect it must catch and watching the
run fail on it, then revert the defect. A green run alone shows neither that
the check ran nor that it covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by
@@ -309,6 +315,9 @@ style conventions are in separate documents:
a package's findings only when the package's files and those of every
package it imports, `go.mod`, the linters and their settings in
`.golangci.yml`, and the golangci-lint version are all unchanged.
- In a Python `test` phase, the layer of the step that runs
`script/bootstrap` to install the packages from `Pipfile.lock`, as
described for that phase below.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example:
@@ -363,6 +372,67 @@ style conventions are in separate documents:
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
do.
- **A Python `test` phase that installs its packages from `Pipfile.lock` does it
above `ARG CACHEBUST`, so check builds keep them.** The steps are
`COPY script/ script/`, `COPY Pipfile Pipfile.lock ./` and
`RUN script/bootstrap`, with `ARG CACHEBUST` right after them. Docker keeps
what that `RUN` installed until the base image, `script/`, `Pipfile`,
`Pipfile.lock` or a line up to and including the `RUN` changes. The tests run
after `ARG CACHEBUST`, on every check build. In every other stage, a step that
installs Python packages comes after `ARG CACHEBUST`.
- When `script/bootstrap` runs in that step, it installs nothing beyond
system packages, pipenv and its dependencies, and the packages from
`Pipfile.lock`. It installs no Python there: the tests use the base
image's Python, which the image's hash pins. If it installs anything else
there, the step goes after `ARG CACHEBUST`.
- Every external reference must be pinned by hash, so `script/bootstrap`
installs pipenv and its dependencies by hash. It does so with
`pip install --require-hashes -r` and a requirements file in `script/`
that lists each of them with `==` and its `--hash`. pip refuses to install
a dependency the file does not list.
- It then runs `pipenv install --deploy`, with `--dev` when the tests need
the development packages. `--deploy` installs only what `Pipfile.lock`
names, checks every package against the lockfile's hashes, and fails when
the lockfile is out of date with `Pipfile`.
- A kept layer holds nothing a new install would refuse: pip checked pipenv
and its dependencies, and pipenv checked the lockfile's packages, against
their hashes. pipenv creates its virtual environment with the `virtualenv`
package. The packages that `virtualenv` adds come from wheels inside that
package, so the hash pip checked for it covers them. Two things in the
layer are not fixed by a hash. A package with no ready-built wheel for the
image is built from source. pip downloads the build tools without a hash
check, so a kept layer's build of such a package may differ from a new
one. And system packages keep the versions the package manager gave when
the layer was built; a new install may get newer ones.
- As with Go's modules, the packages are kept in a layer, not a cache mount.
A cache mount is shared by every build on the host, and nothing checks its
files again.
In this example, `script/bootstrap` finds `make` and `git` already
installed, installs pipenv and its dependencies by hash, and runs
`pipenv install --dev --deploy`:
```dockerfile
# Test phase. The 90-second timeout covers both pytest runs.
# python:3.x-slim, YYYY-MM-DD
FROM python@sha256:... AS test
# make and git are base tools that script/bootstrap installs when they
# are missing. Installed here, they stay in their own layer when
# script/ changes.
RUN apt-get update \
&& apt-get install -y --no-install-recommends make git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY script/ script/
COPY Pipfile Pipfile.lock ./
RUN script/bootstrap
ARG CACHEBUST
COPY . .
RUN timeout 90 sh -c 'pipenv run pytest || { \
echo "--- Rerunning with -v for details ---"; \
pipenv run pytest -v; exit 1; }'
```
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step,
with `persist-credentials: false`: `script/cibuild` needs no token, and
+38
View File
@@ -35,6 +35,44 @@ tested.
# Completed Steps
- 2026-10-27: README "Rationale" says why RouteWatch exists: to answer our other
services' questions about IP addresses, from its own routing table and the
WHOIS details it keeps. It still says what RIS Live does not give (closes
#204)
- 2026-10-27: every JSON answer carries `@meta`. A panic on a JSON route answers
500 with the JSON error body. `/status.json` and the health check answer 405
with the JSON error body to any method they do not take. The 500 and the 405
used to have an empty body. The home page's IP lookup shows the whole JSON
answer, `@meta` included. It used to show only the answer's `data`, or only
the message of an error answer. A test checks `@meta` on every JSON route, in
its successes and its errors (closes #201)
- 2026-10-27: every value in a JSON answer that has a unit is a number, with the
unit at the end of its field name. `@meta.execution_time` is now
`execution_time_ms`. The stats' `uptime`, `connection_duration` and
`memory_usage` are now `uptime_seconds`, `connection_duration_seconds` and
`memory_usage_bytes`. `whois_stats.current_interval` is now
`current_interval_seconds`. They used to be strings such as "12 ms",
"1h18m34s" or "1.1 GB". `connection_duration_seconds` is null while the feed
is not connected. `API_POLICIES.md` is vendored from `sneak/prompts`, and
`REPO_POLICIES.md` re-vendored (closes #199)
- 2026-10-27: every result of the IP lookup has the same fields. A field without
a value is null. It used to be left out. An address with no live route has
every route and AS field set to null, and `ptr` is an empty list when there
are no PTR names. New fields give the number of origin ASes of the netblock,
in `num_origins`, and which they are, in `origin_asns` (closes #200)
- 2026-10-27: with `TRUSTED_PROXIES` unset, the server trusts reverse proxies at
the private addresses of RFC 1918. `/ip/`, the home page's `curl` card and the
request log then give the client's address from `X-Forwarded-For`. They used
to give the proxy's address. A list in `TRUSTED_PROXIES` replaces the default,
and `TRUSTED_PROXIES` set but empty trusts no proxy (closes #198)
- 2026-10-27: the home page has cards with figures of the routing table: the
newest and oldest kept route, the newest and oldest updated AS, the AS with
the most prefixes, the ASes with a single prefix, and fifteen well-known
networks. Two cards count the bogon announcements and the prefixes with more
than one origin AS, and link to the new pages `/bogons` and
`/multiple-origins` that list them. A new package, `internal/bogon`, tells
which prefixes and AS numbers are reserved. The server works the figures out
once a minute (closes #154)
- 2026-10-27: the per-peer route tables are gone, with the code that wrote,
counted and read them. The prefix handler applies each update to the routing
table in memory and writes the table's changes every second. It also stores
+202
View File
@@ -0,0 +1,202 @@
// Package bogon tells whether a prefix or an AS number is reserved: 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
// origin AS is.
//
// Most reserved prefixes come from the IANA special-purpose address registries
// for IPv4 and IPv6. The two multicast blocks, 224.0.0.0/4 and ff00::/8, come
// from the IANA IPv4 and IPv6 address space registries. The reserved AS
// numbers come from the IANA special-purpose AS numbers registry:
// - the AS numbers for private use
// - the AS numbers for documentation
// - AS 23456, which stands in for a 32-bit AS number
// - the last AS number of the 16-bit and of the 32-bit range
//
// Address space and AS numbers that the IANA has not handed out yet are not
// counted.
package bogon
import (
"net/netip"
"strconv"
)
// Prefix returns why prefix is reserved, and true, when it lies inside a
// reserved block. The most specific registry block that holds prefix decides,
// because some blocks that may be announced lie inside reserved ones. It
// returns "" and false when prefix is not reserved.
func Prefix(prefix netip.Prefix) (string, bool) {
var holder *block
for i, b := range registryBlocks {
inside := b.prefix.Bits() <= prefix.Bits() && b.prefix.Contains(prefix.Addr())
if inside && (holder == nil || b.prefix.Bits() > holder.prefix.Bits()) {
holder = &registryBlocks[i]
}
}
if holder == nil || !holder.reserved {
return "", false
}
return holder.name + " (" + holder.prefix.String() + ")", true
}
// ASN returns why asn is reserved, and true, when it is. It returns "" and
// false when asn is not reserved.
func ASN(asn int) (string, bool) {
for _, r := range reservedASRanges {
if asn < r.first || asn > r.last {
continue
}
if r.first == r.last {
return r.name + " (AS" + strconv.Itoa(asn) + ")", true
}
return r.name + " (AS" + strconv.Itoa(r.first) + "-AS" +
strconv.Itoa(r.last) + ")", true
}
return "", false
}
// The names that the registries give to more than one row.
const (
privateUse = "Private-Use"
documentation = "Documentation"
benchmarking = "Benchmarking"
ietfProtocolAssignments = "IETF Protocol Assignments"
portControlAnycast = "Port Control Protocol Anycast"
natTraversalAnycast = "Traversal Using Relays around NAT Anycast"
nat64Discovery = "NAT64/DNS64 Discovery"
amt = "AMT"
as112DirectDelegation = "Direct Delegation AS112 Service"
ipv4IPv6Translation = "IPv4-IPv6 Translat."
multicast = "Multicast"
)
// block is one row of an IANA address registry.
type block struct {
prefix netip.Prefix
name string
// reserved says that no network should announce the row's addresses on
// the internet.
reserved bool
}
// reservedBlock returns a reserved row: a multicast block, or a special-purpose
// row whose Globally Reachable column is False.
func reservedBlock(prefix, name string) block {
return block{prefix: netip.MustParsePrefix(prefix), name: name, reserved: true}
}
// reachableBlock returns a special-purpose row whose Globally Reachable column
// is True or N/A. Such a row matters only inside a reserved block, whose
// addresses it lets be announced.
func reachableBlock(prefix, name string) block {
return block{prefix: netip.MustParsePrefix(prefix), name: name}
}
// registryBlocks are the rows of the IANA special-purpose address registries,
// without the deprecated ones:
// - https://www.iana.org/assignments/iana-ipv4-special-registry/
// - https://www.iana.org/assignments/iana-ipv6-special-registry/
//
// They also hold the multicast blocks, which the IANA address space registries
// list as Multicast:
// - https://www.iana.org/assignments/ipv4-address-space/
// - https://www.iana.org/assignments/ipv6-address-space/
//
//nolint:gochecknoglobals // a fixed list, parsed once
var registryBlocks = []block{
reservedBlock("0.0.0.0/8", "This network"),
reservedBlock("0.0.0.0/32", "This host on this network"),
reservedBlock("10.0.0.0/8", privateUse),
reservedBlock("100.64.0.0/10", "Shared Address Space"),
reservedBlock("127.0.0.0/8", "Loopback"),
reservedBlock("169.254.0.0/16", "Link Local"),
reservedBlock("172.16.0.0/12", privateUse),
reservedBlock("192.0.0.0/24", ietfProtocolAssignments),
reservedBlock("192.0.0.0/29", "IPv4 Service Continuity Prefix"),
reservedBlock("192.0.0.8/32", "IPv4 dummy address"),
reachableBlock("192.0.0.9/32", portControlAnycast),
reachableBlock("192.0.0.10/32", natTraversalAnycast),
reservedBlock("192.0.0.170/32", nat64Discovery),
reservedBlock("192.0.0.171/32", nat64Discovery),
reservedBlock("192.0.2.0/24", "Documentation (TEST-NET-1)"),
reachableBlock("192.31.196.0/24", "AS112-v4"),
reachableBlock("192.52.193.0/24", amt),
reservedBlock("192.88.99.2/32", "6a44-relay anycast address"),
reservedBlock("192.168.0.0/16", privateUse),
reachableBlock("192.175.48.0/24", as112DirectDelegation),
reservedBlock("198.18.0.0/15", benchmarking),
reservedBlock("198.51.100.0/24", "Documentation (TEST-NET-2)"),
reservedBlock("203.0.113.0/24", "Documentation (TEST-NET-3)"),
reservedBlock("224.0.0.0/4", multicast),
reservedBlock("240.0.0.0/4", "Reserved"),
reservedBlock("255.255.255.255/32", "Limited Broadcast"),
reservedBlock("::1/128", "Loopback Address"),
reservedBlock("::/128", "Unspecified Address"),
reservedBlock("::ffff:0:0/96", "IPv4-mapped Address"),
reachableBlock("64:ff9b::/96", ipv4IPv6Translation),
reservedBlock("64:ff9b:1::/48", ipv4IPv6Translation),
reservedBlock("100::/64", "Discard-Only Address Block"),
reservedBlock("100:0:0:1::/64", "Dummy IPv6 Prefix"),
reservedBlock("2001::/23", ietfProtocolAssignments),
reachableBlock("2001::/32", "TEREDO"),
reachableBlock("2001:1::1/128", portControlAnycast),
reachableBlock("2001:1::2/128", natTraversalAnycast),
reachableBlock("2001:1::3/128", "DNS-SD Service Registration Protocol Anycast"),
reservedBlock("2001:2::/48", benchmarking),
reachableBlock("2001:3::/32", amt),
reachableBlock("2001:4:112::/48", "AS112-v6"),
reachableBlock("2001:20::/28", "ORCHIDv2"),
reachableBlock("2001:30::/28", "Drone Remote ID Protocol Entity Tags (DETs) Prefix"),
reservedBlock("2001:db8::/32", documentation),
reachableBlock("2002::/16", "6to4"),
reachableBlock("2620:4f:8000::/48", as112DirectDelegation),
reservedBlock("3fff::/20", documentation),
reservedBlock("5f00::/16", "Segment Routing (SRv6) SIDs"),
reservedBlock("fc00::/7", "Unique-Local"),
reservedBlock("fe80::/10", "Link-Local Unicast"),
reservedBlock("ff00::/8", multicast),
}
// asRange is a range of reserved AS numbers, first to last.
type asRange struct {
first, last int
name string
}
// The reserved AS numbers of the IANA special-purpose AS numbers registry.
const (
asTrans = 23456
firstDocumentationAS16 = 64496
lastDocumentationAS16 = 64511
firstPrivateUseAS16 = 64512
lastPrivateUseAS16 = 65534
lastAS16 = 65535
firstDocumentationAS32 = 65536
lastDocumentationAS32 = 65551
firstPrivateUseAS32 = 4200000000
lastPrivateUseAS32 = 4294967294
lastAS32 = 4294967295
)
// reservedASRanges are the reserved rows of the IANA special-purpose AS numbers
// registry, https://www.iana.org/assignments/iana-as-numbers-special-registry/.
// AS 0 is left out: RouteWatch uses it for a route with no known origin. AS 112
// is left out because the AS112 project announces routes with it.
//
//nolint:gochecknoglobals // a fixed list
var reservedASRanges = []asRange{
{first: asTrans, last: asTrans, name: "AS_TRANS"},
{first: firstDocumentationAS16, last: lastDocumentationAS16, name: documentation},
{first: firstPrivateUseAS16, last: lastPrivateUseAS16, name: privateUse},
{first: lastAS16, last: lastAS16, name: "Last 16-bit AS number"},
{first: firstDocumentationAS32, last: lastDocumentationAS32, name: documentation},
{first: firstPrivateUseAS32, last: lastPrivateUseAS32, name: privateUse},
{first: lastAS32, last: lastAS32, name: "Last 32-bit AS number"},
}
+95
View File
@@ -0,0 +1,95 @@
package bogon_test
import (
"net/netip"
"strconv"
"testing"
"git.eeqj.de/sneak/routewatch/internal/bogon"
)
func TestPrefix(t *testing.T) {
t.Parallel()
tests := []struct {
prefix string
// want is why the prefix is reserved, or "" when it is not.
want string
}{
{"10.1.2.0/24", "Private-Use (10.0.0.0/8)"},
{"10.0.0.0/8", "Private-Use (10.0.0.0/8)"},
{"192.0.2.0/24", "Documentation (TEST-NET-1) (192.0.2.0/24)"},
{"2001:db8:1::/48", "Documentation (2001:db8::/32)"},
{"fd00::/8", "Unique-Local (fc00::/7)"},
// Multicast space, from the IANA address space registries.
{"224.0.0.0/4", "Multicast (224.0.0.0/4)"},
{"239.255.0.0/16", "Multicast (224.0.0.0/4)"},
{"ff00::/8", "Multicast (ff00::/8)"},
{"ff02::/16", "Multicast (ff00::/8)"},
// The most specific block names the reason.
{"0.0.0.0/32", "This host on this network (0.0.0.0/32)"},
{"2001:2::/48", "Benchmarking (2001:2::/48)"},
// Blocks that may be announced, inside reserved blocks.
{"192.0.0.9/32", ""},
{"2001:4:112::/48", ""},
// Blocks that may be announced, inside no reserved block.
{"192.175.48.0/24", ""},
{"2002::/16", ""},
// A prefix that holds a reserved block is not inside it.
{"192.0.0.0/16", ""},
{"0.0.0.0/0", ""},
// Prefixes anyone may announce.
{"8.8.8.0/24", ""},
{"2001:4860::/32", ""},
}
for _, tc := range tests {
t.Run(tc.prefix, func(t *testing.T) {
t.Parallel()
reason, reserved := bogon.Prefix(netip.MustParsePrefix(tc.prefix))
if reason != tc.want || reserved != (tc.want != "") {
t.Errorf("Prefix(%s) = %q, %v; want %q", tc.prefix, reason,
reserved, tc.want)
}
})
}
}
func TestASN(t *testing.T) {
t.Parallel()
tests := []struct {
asn int
// want is why the AS number is reserved, or "" when it is not.
want string
}{
{23456, "AS_TRANS (AS23456)"},
{64496, "Documentation (AS64496-AS64511)"},
{64512, "Private-Use (AS64512-AS65534)"},
{65534, "Private-Use (AS64512-AS65534)"},
{65535, "Last 16-bit AS number (AS65535)"},
{65551, "Documentation (AS65536-AS65551)"},
{4200000000, "Private-Use (AS4200000000-AS4294967294)"},
{4294967295, "Last 32-bit AS number (AS4294967295)"},
// AS 0 means no known origin, and the AS112 project uses AS 112.
{0, ""},
{112, ""},
// Not handed out yet, which is not counted.
{65552, ""},
{15169, ""},
{64495, ""},
}
for _, tc := range tests {
t.Run(strconv.Itoa(tc.asn), func(t *testing.T) {
t.Parallel()
reason, reserved := bogon.ASN(tc.asn)
if reason != tc.want || reserved != (tc.want != "") {
t.Errorf("ASN(%d) = %q, %v; want %q", tc.asn, reason, reserved,
tc.want)
}
})
}
}
+22 -4
View File
@@ -30,6 +30,14 @@ const (
maxPort = 65535
)
// The private address ranges of RFC 1918. The HTTP server trusts a reverse
// proxy in any of them when TRUSTED_PROXIES is not set.
const (
privateRange10 = "10.0.0.0/8"
privateRange172 = "172.16.0.0/12"
privateRange192 = "192.168.0.0/16"
)
var (
errRelativeXDGDataHome = errors.New("XDG_DATA_HOME must be an absolute path")
errUnsupportedOS = errors.New("unsupported operating system")
@@ -159,11 +167,21 @@ func getPort() (int, error) {
return int(port), nil
}
// getTrustedProxies returns the addresses and CIDR ranges listed in
// TRUSTED_PROXIES, such as "10.0.0.0/8,192.0.2.1", or none when it is not set.
// An address is returned as a range holding only that address.
// getTrustedProxies returns the reverse proxies the HTTP server trusts. When
// TRUSTED_PROXIES is not set, they are the private ranges of RFC 1918. When it
// is set, they are the addresses and CIDR ranges it lists, such as
// "10.0.0.0/8,192.0.2.1", and none when it is empty. An address is returned as
// a range holding only that address.
func getTrustedProxies() ([]netip.Prefix, error) {
value := os.Getenv("TRUSTED_PROXIES")
value, set := os.LookupEnv("TRUSTED_PROXIES")
if !set {
return []netip.Prefix{
netip.MustParsePrefix(privateRange10),
netip.MustParsePrefix(privateRange172),
netip.MustParsePrefix(privateRange192),
}, nil
}
if value == "" {
return nil, nil
}
+40 -5
View File
@@ -2,6 +2,7 @@ package config
import (
"net/netip"
"os"
"runtime"
"slices"
"testing"
@@ -46,25 +47,47 @@ func TestNewRefusesInvalidPort(t *testing.T) {
}
}
func TestNewReadsTrustedProxies(t *testing.T) {
// TestNewTrustsPrivateRangesByDefault checks that with TRUSTED_PROXIES unset
// the trusted proxies are the private ranges of RFC 1918. Before the fix no
// proxy was trusted, so a reverse proxy at a private address was taken for the
// client (https://git.eeqj.de/sneak/routewatch/issues/198).
func TestNewTrustsPrivateRangesByDefault(t *testing.T) {
t.Setenv("PORT", "")
t.Setenv("XDG_DATA_HOME", "")
// t.Setenv puts the variable back when the test ends.
t.Setenv("TRUSTED_PROXIES", "")
err := os.Unsetenv("TRUSTED_PROXIES")
if err != nil {
t.Fatal(err)
}
cfg, err := New()
if err != nil {
t.Fatalf("New() without TRUSTED_PROXIES: %v", err)
}
if len(cfg.TrustedProxies) != 0 {
t.Errorf("New() without TRUSTED_PROXIES: TrustedProxies = %v, want none",
cfg.TrustedProxies)
want := []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.168.0.0/16"),
}
if !slices.Equal(cfg.TrustedProxies, want) {
t.Errorf("New() without TRUSTED_PROXIES: TrustedProxies = %v, want %v",
cfg.TrustedProxies, want)
}
}
// TestNewReadsTrustedProxies checks that a list in TRUSTED_PROXIES replaces
// the default, and that TRUSTED_PROXIES set but empty trusts no proxy.
func TestNewReadsTrustedProxies(t *testing.T) {
t.Setenv("PORT", "")
t.Setenv("XDG_DATA_HOME", "")
t.Setenv("TRUSTED_PROXIES", "10.0.0.0/8, 192.0.2.1,2001:db8::/32")
cfg, err = New()
cfg, err := New()
if err != nil {
t.Fatalf("New() with TRUSTED_PROXIES: %v", err)
}
@@ -77,6 +100,18 @@ func TestNewReadsTrustedProxies(t *testing.T) {
if !slices.Equal(cfg.TrustedProxies, want) {
t.Errorf("TrustedProxies = %v, want %v", cfg.TrustedProxies, want)
}
t.Setenv("TRUSTED_PROXIES", "")
cfg, err = New()
if err != nil {
t.Fatalf("New() with TRUSTED_PROXIES set but empty: %v", err)
}
if len(cfg.TrustedProxies) != 0 {
t.Errorf("New() with TRUSTED_PROXIES set but empty: TrustedProxies = %v, "+
"want none", cfg.TrustedProxies)
}
}
func TestNewRefusesInvalidTrustedProxies(t *testing.T) {
+51
View File
@@ -972,6 +972,34 @@ func (d *Database) GetAS(ctx context.Context, asn int) (*ASN, bool, error) {
return &asnInfo, true, nil
}
// The queries of GetLastSeenASes. The index on last_seen answers each without
// reading the whole table.
const (
newestLastSeenASQuery = `
SELECT asn, handle, description, first_seen, last_seen
FROM asns ORDER BY last_seen DESC LIMIT 1`
oldestLastSeenASQuery = `
SELECT asn, handle, description, first_seen, last_seen
FROM asns ORDER BY last_seen ASC LIMIT 1`
)
// GetLastSeenASes returns two ASes of the asns table, with their handles,
// descriptions and seen times: the one last seen most recently, and the one
// last seen longest ago. Both are nil when the table holds no AS.
func (d *Database) GetLastSeenASes(ctx context.Context) (*ASN, *ASN, error) {
newest, found, err := d.readOneAS(ctx, newestLastSeenASQuery)
if err != nil || !found {
return nil, nil, err
}
oldest, _, err := d.readOneAS(ctx, oldestLastSeenASQuery)
if err != nil {
return nil, nil, err
}
return newest, oldest, nil
}
// asWHOISQuery reads the handle, description and WHOIS details of one AS, and
// when the WHOIS details were fetched.
const asWHOISQuery = `
@@ -1379,6 +1407,29 @@ func (d *Database) Ping(ctx context.Context) error {
return nil
}
// readOneAS returns the AS that query reads, and whether it read one.
func (d *Database) readOneAS(ctx context.Context, query string) (*ASN, bool, error) {
var (
asnInfo ASN
handle, description sql.NullString
)
err := d.db.QueryRowContext(ctx, query).Scan(&asnInfo.ASN, &handle,
&description, &asnInfo.FirstSeen, &asnInfo.LastSeen)
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("failed to query AS by last seen: %w", err)
}
asnInfo.Handle = handle.String
asnInfo.Description = description.String
return &asnInfo, true, nil
}
// truncateWAL runs the TRUNCATE checkpoint of Checkpoint on a WAL file of
// walBytes, with the writing connection's busy timeout lowered to
// truncateBusyTimeoutMs for it, and logs how long it took. The caller holds the
@@ -1003,6 +1003,62 @@ func TestGetASReadsTheRecordOrTheBuiltInList(t *testing.T) {
}
}
// TestGetLastSeenASes stores three ASes, each last seen at another time, and
// expects the one last seen most recently and the one last seen longest ago.
// An empty asns table gives neither.
func TestGetLastSeenASes(t *testing.T) {
t.Parallel()
db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
newest, oldest, err := db.GetLastSeenASes(t.Context())
if err != nil || newest != nil || oldest != nil {
t.Fatalf("GetLastSeenASes() on an empty table = %+v, %+v, %v; want "+
"neither", newest, oldest, err)
}
const (
newestASN = 13335
middleASN = 15169
oldestASN = 3
)
start := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
err = db.GetOrCreateASNBatch(map[int]SeenTimes{
middleASN: {First: start, Last: start.Add(time.Hour)},
newestASN: {First: start, Last: start.Add(2 * time.Hour)},
oldestASN: {First: start, Last: start.Add(time.Minute)},
})
if err != nil {
t.Fatalf("GetOrCreateASNBatch: %v", err)
}
newest, oldest, err = db.GetLastSeenASes(t.Context())
if err != nil {
t.Fatalf("GetLastSeenASes: %v", err)
}
listed, _ := asinfo.Get(newestASN)
if newest == nil || newest.ASN != newestASN ||
!newest.LastSeen.Equal(start.Add(2*time.Hour)) ||
newest.Description != listed.Description {
t.Errorf("newest = %+v, want AS%d (%s) last seen %v", newest, newestASN,
listed.Description, start.Add(2*time.Hour))
}
if oldest == nil || oldest.ASN != oldestASN ||
!oldest.LastSeen.Equal(start.Add(time.Minute)) {
t.Errorf("oldest = %+v, want AS%d last seen %v", oldest, oldestASN,
start.Add(time.Minute))
}
}
// TestGetASWHOISReadsTheStoredDetails stores an AS with WHOIS details and asks
// GetASWHOIS for it and for an AS the asns table does not hold. The first gets
// its stored handle, description and details, and the time they were fetched.
+1
View File
@@ -62,6 +62,7 @@ type Store interface {
// AS and prefix detail operations
GetAS(ctx context.Context, asn int) (*ASN, bool, error)
GetLastSeenASes(ctx context.Context) (*ASN, *ASN, error)
GetASWHOIS(ctx context.Context, asn int) (*ASN, error)
GetPrefixFirstSeen(ctx context.Context, prefix netip.Prefix) (time.Time, error)
GetASPeers(asn int) ([]ASPeer, error)
+3
View File
@@ -83,6 +83,9 @@ CREATE INDEX IF NOT EXISTS idx_peerings_lookup ON peerings(as_a, as_b);
-- Indexes for asns table
CREATE INDEX IF NOT EXISTS idx_asns_asn ON asns(asn);
CREATE INDEX IF NOT EXISTS idx_asns_whois_updated_at ON asns(whois_updated_at);
-- Finds the AS last seen most recently and the one last seen longest ago, for
-- the home page.
CREATE INDEX IF NOT EXISTS idx_asns_last_seen ON asns(last_seen);
-- Indexes for bgp_peers table
CREATE INDEX IF NOT EXISTS idx_bgp_peers_asn ON bgp_peers(peer_asn);
+259
View File
@@ -0,0 +1,259 @@
package routetable
import (
"cmp"
"net/netip"
"slices"
"time"
"git.eeqj.de/sneak/routewatch/internal/bogon"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
)
// walkBatch is how many prefixes the walk over the whole table reads in one
// hold of the read lock. The updates waiting for the lock get it in between.
const walkBatch = 10000
// Overview holds figures about the whole table. Working them out takes a walk
// over every prefix, so a caller asks for them now and then and keeps them.
type Overview struct {
// NewestRoute and OldestRoute are the newest and the oldest kept routes,
// or nil when the table keeps none. Of several with the same time, it is
// any one of them.
NewestRoute *PrefixRoute
OldestRoute *PrefixRoute
// MostPrefixesASN is the AS that originates the most prefixes, and
// MostPrefixes is how many it originates. Of several with as many, it is
// the lowest AS number. AS 0, no known origin, counts neither here nor
// in the single-prefix figures.
MostPrefixesASN int
MostPrefixes int
// SinglePrefixASes counts the ASes that originate one prefix.
SinglePrefixASes int
// NewestSinglePrefix is the newest kept route of those ASes, or nil when
// they keep none.
NewestSinglePrefix *PrefixRoute
// Bogons are the bogon announcements, in prefix order: at most the
// number Overview is asked for. BogonCount counts them all.
Bogons []Bogon
BogonCount int
// MultipleOrigins are the prefixes that peers carry with more than one
// origin AS, in prefix order: at most the number Overview is asked for.
// MultipleOriginCount counts them all.
MultipleOrigins []MultipleOriginPrefix
MultipleOriginCount int
}
// PrefixRoute is the time of a kept route, with its prefix and origin AS.
type PrefixRoute struct {
Prefix netip.Prefix
OriginASN int
Time time.Time
}
// Bogon is a bogon announcement: a prefix and origin AS pair whose prefix or
// origin AS is reserved, as package bogon tells.
type Bogon struct {
Prefix netip.Prefix
OriginASN int
// Peers is how many peers carry the prefix with this origin AS.
Peers int
// Reasons say why the prefix, the origin AS or both are reserved.
Reasons []string
}
// MultipleOriginPrefix is a prefix that peers carry with more than one origin
// AS.
type MultipleOriginPrefix struct {
Prefix netip.Prefix
// Origins are its origin ASes, in AS number order.
Origins []OriginPeers
}
// OriginPeers is one origin AS of a prefix, and how many peers carry the prefix
// with it.
type OriginPeers struct {
ASN int
Peers int
}
// Overview works out the table's overview. It lists at most maxListed bogon
// announcements and at most maxListed prefixes with more than one origin AS:
// the first in prefix order. It walks over every prefix, but holds the read
// lock for walkBatch prefixes at a time.
func (t *Table) Overview(maxListed int) Overview {
var overview Overview
t.walkPrefixes(func(prefix netip.Prefix, origins []*origin) {
overview.noteKeptRoutes(prefix, origins)
overview.noteBogons(prefix, origins)
overview.noteMultipleOrigins(prefix, origins)
})
t.countPrefixesPerOrigin(&overview)
slices.SortFunc(overview.Bogons, func(a, b Bogon) int {
return cmp.Or(a.Prefix.Compare(b.Prefix),
cmp.Compare(a.OriginASN, b.OriginASN))
})
overview.BogonCount = len(overview.Bogons)
overview.Bogons = overview.Bogons[:min(maxListed, overview.BogonCount)]
slices.SortFunc(overview.MultipleOrigins, func(a, b MultipleOriginPrefix) int {
return a.Prefix.Compare(b.Prefix)
})
overview.MultipleOriginCount = len(overview.MultipleOrigins)
overview.MultipleOrigins =
overview.MultipleOrigins[:min(maxListed, overview.MultipleOriginCount)]
return overview
}
// walkPrefixes calls visit with every prefix of the table and its origins. It
// holds the read lock, but lets it go after every walkBatch prefixes, so the
// updates waiting for it get in. A prefix that is added or removed in such a
// gap may or may not be visited, as Go allows for a map that changes during a
// range over it. visit must not keep origins once it returns.
func (t *Table) walkPrefixes(visit func(prefix netip.Prefix, origins []*origin)) {
t.mu.RLock()
defer t.mu.RUnlock()
visited := 0
for _, f := range []*family{&t.ipv4, &t.ipv6} {
for _, prefixes := range f.byLength {
for prefix, origins := range prefixes {
visit(prefix, origins)
visited++
if visited%walkBatch == 0 {
t.mu.RUnlock()
t.mu.RLock()
}
}
}
}
}
// countPrefixesPerOrigin works out the overview's figures of how many prefixes
// each AS originates. It holds the read lock once, for one look at each origin
// AS, of which there are far fewer than prefixes.
func (t *Table) countPrefixesPerOrigin(overview *Overview) {
t.mu.RLock()
defer t.mu.RUnlock()
for asn, prefixes := range t.byOrigin {
if asn == ristypes.NoOrigin {
continue
}
count := len(prefixes)
if count > overview.MostPrefixes ||
(count == overview.MostPrefixes && asn < overview.MostPrefixesASN) {
overview.MostPrefixesASN = asn
overview.MostPrefixes = count
}
if count == 1 {
overview.SinglePrefixASes++
overview.noteSinglePrefix(asn, prefixes)
}
}
}
// noteSinglePrefix makes the newest kept route of asn, whose one prefix is in
// prefixes, the overview's newest single-prefix route when it is newer.
func (overview *Overview) noteSinglePrefix(
asn int, prefixes map[netip.Prefix]*origin,
) {
for prefix, o := range prefixes {
newest := o.newestTime()
if newest == nil {
continue
}
if overview.NewestSinglePrefix == nil ||
newest.After(overview.NewestSinglePrefix.Time) {
overview.NewestSinglePrefix = &PrefixRoute{
Prefix: prefix, OriginASN: asn, Time: *newest,
}
}
}
}
// noteKeptRoutes makes the newest and the oldest kept routes of prefix's
// origins the overview's newest and oldest routes when they are newer or older.
func (overview *Overview) noteKeptRoutes(prefix netip.Prefix, origins []*origin) {
for _, o := range origins {
if len(o.routes) == 0 {
continue
}
newest := o.routes[0].time
if overview.NewestRoute == nil || newest.After(overview.NewestRoute.Time) {
overview.NewestRoute = &PrefixRoute{
Prefix: prefix, OriginASN: o.asn, Time: newest,
}
}
oldest := o.routes[len(o.routes)-1].time
if overview.OldestRoute == nil || oldest.Before(overview.OldestRoute.Time) {
overview.OldestRoute = &PrefixRoute{
Prefix: prefix, OriginASN: o.asn, Time: oldest,
}
}
}
}
// noteBogons adds each origin of prefix to the overview's bogon announcements
// when the prefix or the origin AS is reserved.
func (overview *Overview) noteBogons(prefix netip.Prefix, origins []*origin) {
prefixReason, prefixReserved := bogon.Prefix(prefix)
for _, o := range origins {
asnReason, asnReserved := bogon.ASN(o.asn)
if !prefixReserved && !asnReserved {
continue
}
announcement := Bogon{
Prefix: prefix, OriginASN: o.asn, Peers: int(o.peers.Count()),
}
if prefixReserved {
announcement.Reasons = append(announcement.Reasons, prefixReason)
}
if asnReserved {
announcement.Reasons = append(announcement.Reasons, asnReason)
}
overview.Bogons = append(overview.Bogons, announcement)
}
}
// noteMultipleOrigins adds prefix to the overview's prefixes with more than one
// origin AS when it has more than one.
func (overview *Overview) noteMultipleOrigins(
prefix netip.Prefix, origins []*origin,
) {
if len(origins) <= 1 {
return
}
entry := MultipleOriginPrefix{
Prefix: prefix,
Origins: make([]OriginPeers, 0, len(origins)),
}
for _, o := range origins {
entry.Origins = append(entry.Origins,
OriginPeers{ASN: o.asn, Peers: int(o.peers.Count())})
}
overview.MultipleOrigins = append(overview.MultipleOrigins, entry)
}
@@ -0,0 +1,133 @@
package routetable
import (
"net/netip"
"runtime"
"testing"
"time"
)
// TestWalkPrefixesVisitsEveryPrefix walks a table with more prefixes than two
// of the walk's batches, while another goroutine keeps adding and removing
// other prefixes of the same mask length. The walk lets the lock go between
// batches, so those updates get in while it runs. Every prefix that was in
// the table all along must be visited once.
func TestWalkPrefixesVisitsEveryPrefix(t *testing.T) {
t.Parallel()
const (
asn = 1000
peer = "192.0.2.1"
// prefixCount is more prefixes than two of the walk's batches.
prefixCount = 2*walkBatch + 1
)
routeTime := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
table := New(Params{})
prefixes := make([]netip.Prefix, 0, prefixCount)
addr := netip.MustParseAddr("1.0.0.0")
for range prefixCount {
prefix := netip.PrefixFrom(addr, addr.BitLen())
table.Announce(prefix, asn, Route{Peer: peer, Time: routeTime})
prefixes = append(prefixes, prefix)
addr = addr.Next()
}
stop := make(chan struct{})
updated := make(chan struct{})
go func() {
defer close(updated)
other := netip.MustParseAddr("2.0.0.0")
for {
select {
case <-stop:
return
default:
}
prefix := netip.PrefixFrom(other, other.BitLen())
table.Announce(prefix, asn, Route{Peer: peer, Time: routeTime})
table.Withdraw(prefix, peer)
other = other.Next()
}
}()
visits := make(map[netip.Prefix]int)
table.walkPrefixes(func(prefix netip.Prefix, _ []*origin) {
visits[prefix]++
})
close(stop)
<-updated
for _, prefix := range prefixes {
if visits[prefix] != 1 {
t.Fatalf("the walk visited %s %d times, want once", prefix,
visits[prefix])
}
}
}
// TestWalkPrefixesLetsUpdatesIn starts an update during the walk's first
// batch. The update must have been made by the time the walk visits the first
// prefix of its second batch, because the walk lets the lock go in between.
func TestWalkPrefixesLetsUpdatesIn(t *testing.T) {
t.Parallel()
const (
asn = 1000
peer = "192.0.2.1"
)
routeTime := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
table := New(Params{})
addr := netip.MustParseAddr("1.0.0.0")
for range walkBatch + 1 {
prefix := netip.PrefixFrom(addr, addr.BitLen())
table.Announce(prefix, asn, Route{Peer: peer, Time: routeTime})
addr = addr.Next()
}
updatePrefix := netip.MustParsePrefix("2.0.0.0/8")
updateDone := make(chan struct{})
visited := 0
madeBeforeSecondBatch := false
table.walkPrefixes(func(netip.Prefix, []*origin) {
visited++
switch visited {
case 1:
go func() {
defer close(updateDone)
table.Announce(updatePrefix, asn, Route{Peer: peer, Time: routeTime})
}()
// Wait until the update waits for the lock. TryRLock fails
// once a writer waits.
for table.mu.TryRLock() {
table.mu.RUnlock()
runtime.Gosched()
}
case walkBatch + 1:
// The walk holds the read lock, so the table can be read.
madeBeforeSecondBatch = table.origins(updatePrefix) != nil
}
})
<-updateDone
if !madeBeforeSecondBatch {
t.Fatal("an update started during the walk's first batch was not made " +
"before its second batch")
}
}
+231
View File
@@ -0,0 +1,231 @@
package routetable_test
import (
"net/netip"
"reflect"
"testing"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// Origin ASes that are not reserved, unlike asA, asB and asC, which are
// documentation AS numbers and so make bogon announcements.
const (
asOne = 1000
asTwo = 2000
asThree = 3000
asFour = 4000
asFive = 5000
// noKnownOrigin is the origin AS of a route whose path ends in an AS
// set.
noKnownOrigin = 0
// privateUseAS and asTrans are reserved AS numbers.
privateUseAS = 64512
asTrans = 23456
// maxListed is how many bogon announcements and prefixes with more than
// one origin AS the tests ask Overview for.
maxListed = 10
)
// prefixRoute returns the PrefixRoute of prefix with origin AS asn, second
// seconds after the start.
func prefixRoute(prefix string, asn, second int) *routetable.PrefixRoute {
return &routetable.PrefixRoute{
Prefix: netip.MustParsePrefix(prefix), OriginASN: asn, Time: at(second),
}
}
func TestOverviewOfAnEmptyTable(t *testing.T) {
t.Parallel()
got := routetable.New(routetable.Params{}).Overview(maxListed)
if !reflect.DeepEqual(got, routetable.Overview{}) {
t.Errorf("Overview() = %+v, want no figures", got)
}
}
// TestOverviewNewestAndOldestRoutes checks that the newest and the oldest kept
// routes are found among every origin's kept routes, and that withdrawn routes
// no longer count.
func TestOverviewNewestAndOldestRoutes(t *testing.T) {
t.Parallel()
table := routetable.New(routetable.Params{})
announceAll(table, "1.0.0.0/24", asOne, 20, peerA, peerB)
// The oldest is the older of this origin's two kept routes.
announceAll(table, "2.0.0.0/24", asTwo, 10, peerA, peerB)
announceAll(table, "2001:4860::/32", asThree, 40, peerC)
// Withdrawn routes, newer and older than every other.
announceAll(table, "3.0.0.0/24", asOne, 50, peerA)
announceAll(table, "4.0.0.0/24", asOne, 5, peerB)
table.Withdraw(netip.MustParsePrefix("3.0.0.0/24"), peerA)
table.Withdraw(netip.MustParsePrefix("4.0.0.0/24"), peerB)
got := table.Overview(maxListed)
want := prefixRoute("2001:4860::/32", asThree, 40)
if !reflect.DeepEqual(got.NewestRoute, want) {
t.Errorf("NewestRoute = %+v, want %+v", got.NewestRoute, want)
}
want = prefixRoute("2.0.0.0/24", asTwo, 10)
if !reflect.DeepEqual(got.OldestRoute, want) {
t.Errorf("OldestRoute = %+v, want %+v", got.OldestRoute, want)
}
}
// TestOverviewPrefixesPerOrigin checks the AS that originates the most
// prefixes and the ASes that originate one.
func TestOverviewPrefixesPerOrigin(t *testing.T) {
t.Parallel()
table := routetable.New(routetable.Params{})
// asOne and asTwo originate two prefixes each; the lower AS number counts.
announceAll(table, "1.0.0.0/24", asTwo, 10, peerA)
announceAll(table, "1.0.1.0/24", asTwo, 10, peerA)
announceAll(table, "2.0.0.0/24", asOne, 10, peerA)
announceAll(table, "2.0.1.0/24", asOne, 10, peerB)
// Three ASes originate one prefix. asThree's route is the newest, and
// asFive's origin keeps no route: peers B and C withdrew theirs.
announceAll(table, "3.0.0.0/24", asThree, 30, peerA)
announceAll(table, "4.0.0.0/24", asFour, 20, peerA)
announceAll(table, "5.0.0.0/24", asFive, 40, peerA, peerB, peerC)
table.Withdraw(netip.MustParsePrefix("5.0.0.0/24"), peerB)
table.Withdraw(netip.MustParsePrefix("5.0.0.0/24"), peerC)
got := table.Overview(maxListed)
if got.MostPrefixesASN != asOne || got.MostPrefixes != 2 {
t.Errorf("most prefixes: AS%d with %d, want AS%d with 2",
got.MostPrefixesASN, got.MostPrefixes, asOne)
}
if got.SinglePrefixASes != 3 {
t.Errorf("SinglePrefixASes = %d, want 3", got.SinglePrefixASes)
}
want := prefixRoute("3.0.0.0/24", asThree, 30)
if !reflect.DeepEqual(got.NewestSinglePrefix, want) {
t.Errorf("NewestSinglePrefix = %+v, want %+v", got.NewestSinglePrefix, want)
}
}
// TestOverviewLeavesOutNoKnownOrigin checks that AS 0, which stands for no
// known origin, counts in neither the most nor the single-prefix figures. It
// originates one prefix, with a route newer than asOne's, and asOne would lose
// the tie for the most prefixes to it.
func TestOverviewLeavesOutNoKnownOrigin(t *testing.T) {
t.Parallel()
table := routetable.New(routetable.Params{})
announceAll(table, "1.0.0.0/24", asOne, 10, peerA)
announceAll(table, "2.0.0.0/24", noKnownOrigin, 20, peerA)
got := table.Overview(maxListed)
if got.MostPrefixesASN != asOne || got.SinglePrefixASes != 1 {
t.Errorf("most prefixes AS%d, %d single-prefix ASes; want AS%d, 1",
got.MostPrefixesASN, got.SinglePrefixASes, asOne)
}
want := prefixRoute("1.0.0.0/24", asOne, 10)
if !reflect.DeepEqual(got.NewestSinglePrefix, want) {
t.Errorf("NewestSinglePrefix = %+v, want %+v", got.NewestSinglePrefix, want)
}
}
// TestOverviewBogons checks which routes are bogon announcements and why, in
// prefix order, and that a smaller maxListed lists fewer but counts them all.
func TestOverviewBogons(t *testing.T) {
t.Parallel()
table := routetable.New(routetable.Params{})
// A reserved prefix, carried by two peers.
announceAll(table, "192.168.0.0/16", asOne, 10, peerA, peerB)
// A reserved origin AS.
announceAll(table, "1.0.0.0/24", privateUseAS, 10, peerA)
// Both.
announceAll(table, "10.0.0.0/8", asTrans, 10, peerA)
// Neither: a normal route, and a block that may be announced although it
// lies inside a reserved one.
announceAll(table, "1.0.1.0/24", asOne, 10, peerA)
announceAll(table, "2001:4:112::/48", asTwo, 10, peerA)
want := []routetable.Bogon{
{
Prefix: netip.MustParsePrefix("1.0.0.0/24"), OriginASN: privateUseAS,
Peers: 1, Reasons: []string{"Private-Use (AS64512-AS65534)"},
},
{
Prefix: netip.MustParsePrefix("10.0.0.0/8"), OriginASN: asTrans,
Peers: 1,
Reasons: []string{
"Private-Use (10.0.0.0/8)", "AS_TRANS (AS23456)",
},
},
{
Prefix: netip.MustParsePrefix("192.168.0.0/16"), OriginASN: asOne,
Peers: 2, Reasons: []string{"Private-Use (192.168.0.0/16)"},
},
}
got := table.Overview(maxListed)
if !reflect.DeepEqual(got.Bogons, want) || got.BogonCount != len(want) {
t.Errorf("Overview(%d): %d bogons %+v, want %d %+v", maxListed,
got.BogonCount, got.Bogons, len(want), want)
}
const fewer = 2
got = table.Overview(fewer)
if !reflect.DeepEqual(got.Bogons, want[:fewer]) || got.BogonCount != len(want) {
t.Errorf("Overview(%d): %d bogons %+v, want %d %+v", fewer,
got.BogonCount, got.Bogons, len(want), want[:fewer])
}
}
// TestOverviewMultipleOrigins checks the prefixes with more than one origin
// AS, in prefix order, and that a smaller maxListed lists fewer but counts them
// all.
func TestOverviewMultipleOrigins(t *testing.T) {
t.Parallel()
table := routetable.New(routetable.Params{})
announceAll(table, "2001:4860::/32", asThree, 10, peerA)
announceAll(table, "2001:4860::/32", asTwo, 10, peerB)
announceAll(table, "1.0.0.0/24", asOne, 10, peerA, peerB)
announceAll(table, "1.0.0.0/24", asTwo, 30, peerC)
// One origin only.
announceAll(table, "2.0.0.0/24", asOne, 10, peerA, peerB)
want := []routetable.MultipleOriginPrefix{
{
Prefix: netip.MustParsePrefix("1.0.0.0/24"),
Origins: []routetable.OriginPeers{
{ASN: asOne, Peers: 2}, {ASN: asTwo, Peers: 1},
},
},
{
Prefix: netip.MustParsePrefix("2001:4860::/32"),
Origins: []routetable.OriginPeers{
{ASN: asTwo, Peers: 1}, {ASN: asThree, Peers: 1},
},
},
}
got := table.Overview(maxListed)
if !reflect.DeepEqual(got.MultipleOrigins, want) ||
got.MultipleOriginCount != len(want) {
t.Errorf("Overview(%d): %d prefixes %+v, want %d %+v", maxListed,
got.MultipleOriginCount, got.MultipleOrigins, len(want), want)
}
got = table.Overview(1)
if !reflect.DeepEqual(got.MultipleOrigins, want[:1]) ||
got.MultipleOriginCount != len(want) {
t.Errorf("Overview(1): %d prefixes %+v, want %d %+v",
got.MultipleOriginCount, got.MultipleOrigins, len(want), want[:1])
}
}
+6 -1
View File
@@ -14,6 +14,9 @@ type PrefixSummary struct {
// OriginASN is the origin AS that the most peers carry the prefix with.
// When several have as many peers, it is the lowest AS number.
OriginASN int
// OriginASNs are all the origin ASes that peers carry the prefix with, in
// AS number order.
OriginASNs []int
// Peers is how many peers carry the prefix.
Peers int
// Newest is the time of the newest kept route of the prefix, or nil when
@@ -211,10 +214,12 @@ func (t *Table) addresses(peers *bitset.BitSet) []string {
// summarize returns the summary of prefix, whose origins are in AS number
// order.
func summarize(prefix netip.Prefix, origins []*origin) PrefixSummary {
summary := PrefixSummary{Prefix: prefix}
summary := PrefixSummary{Prefix: prefix, OriginASNs: make([]int, 0, len(origins))}
mostPeers := 0
for _, o := range origins {
summary.OriginASNs = append(summary.OriginASNs, o.asn)
peers := int(o.peers.Count())
summary.Peers += peers
+21 -18
View File
@@ -46,21 +46,22 @@ func TestLookup(t *testing.T) {
tests := []struct {
addr string
// want is the summary's prefix, or empty when none is found.
want string
originASN int
peers int
newest int // in seconds after the start; 0 for none
want string
originASN int
originASNs []int
peers int
newest int // in seconds after the start; 0 for none
}{
{"10.1.2.3", prefixV4, asA, 2, 20},
{"::ffff:10.1.2.3", prefixV4, asA, 2, 20},
{"10.1.3.4", "10.1.0.0/16", asB, 3, 30},
{"10.2.0.1", "10.0.0.0/8", asA, 1, 10},
{"10.9.0.1", "10.9.0.0/16", asA, 1, 0},
{"192.0.2.1", "", 0, 0, 0},
{"2001:db8:1::5", prefixV6, asB, 1, 20},
{"2001:db8:2::1", "2001:db8::/32", asA, 1, 10},
{"3000::1", "3000::/16", asA, 1, 10},
{"2001:db9::1", "", 0, 0, 0},
{"10.1.2.3", prefixV4, asA, []int{asA, asB}, 2, 20},
{"::ffff:10.1.2.3", prefixV4, asA, []int{asA, asB}, 2, 20},
{"10.1.3.4", "10.1.0.0/16", asB, []int{asA, asB}, 3, 30},
{"10.2.0.1", "10.0.0.0/8", asA, []int{asA}, 1, 10},
{"10.9.0.1", "10.9.0.0/16", asA, []int{asA}, 1, 0},
{"192.0.2.1", "", 0, nil, 0, 0},
{"2001:db8:1::5", prefixV6, asB, []int{asB}, 1, 20},
{"2001:db8:2::1", "2001:db8::/32", asA, []int{asA}, 1, 10},
{"3000::1", "3000::/16", asA, []int{asA}, 1, 10},
{"2001:db9::1", "", 0, nil, 0, 0},
}
for _, tc := range tests {
@@ -77,9 +78,10 @@ func TestLookup(t *testing.T) {
}
want := routetable.PrefixSummary{
Prefix: netip.MustParsePrefix(tc.want),
OriginASN: tc.originASN,
Peers: tc.peers,
Prefix: netip.MustParsePrefix(tc.want),
OriginASN: tc.originASN,
OriginASNs: tc.originASNs,
Peers: tc.peers,
}
if tc.newest != 0 {
want.Newest = timeAt(tc.newest)
@@ -166,7 +168,8 @@ func TestSamplePrefixes(t *testing.T) {
for _, got := range sample {
want := routetable.PrefixSummary{
Prefix: got.Prefix, OriginASN: asA, Peers: 1, Newest: timeAt(10),
Prefix: got.Prefix, OriginASN: asA, OriginASNs: []int{asA},
Peers: 1, Newest: timeAt(10),
}
if seen[got.Prefix] || got.Prefix.Bits() != tc.maskLength ||
!reflect.DeepEqual(got, want) {
+1
View File
@@ -625,6 +625,7 @@ func TestConcurrentUse(t *testing.T) {
func(int) { table.OriginatedPrefixes(asA) },
func(int) { table.SamplePrefixes(ipVersion, prefix.Bits(), sampleSize) },
func(int) { table.Counts() },
func(int) { table.Overview(sampleSize) },
}
var wg sync.WaitGroup
@@ -223,6 +223,35 @@ func (m *mockStore) GetAS(_ context.Context, asn int) (*database.ASN, bool, erro
return &database.ASN{ASN: asn}, false, nil
}
// GetLastSeenASes returns copies of the stored ASes last seen most recently
// and longest ago, or neither when none is stored.
func (m *mockStore) GetLastSeenASes(
_ context.Context,
) (*database.ASN, *database.ASN, error) {
m.mu.Lock()
defer m.mu.Unlock()
var newest, oldest *database.ASN
for _, asn := range m.ASNs {
if newest == nil || asn.LastSeen.After(newest.LastSeen) {
newest = asn
}
if oldest == nil || asn.LastSeen.Before(oldest.LastSeen) {
oldest = asn
}
}
if newest == nil {
return nil, nil, nil
}
newestCopy, oldestCopy := *newest, *oldest
return &newestCopy, &oldestCopy, nil
}
// GetASWHOIS returns the stored AS, or only its number when none is stored.
func (m *mockStore) GetASWHOIS(ctx context.Context, asn int) (*database.ASN, error) {
asnInfo, _, err := m.GetAS(ctx, asn)
@@ -605,12 +634,15 @@ func TestShutdownReturnsByItsDeadlineWhileAWriteIsBlocked(t *testing.T) {
cfg := &config.Config{Port: port}
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, nil, s, log, cfg),
Logger: log,
Config: cfg,
Server: server.New(store, routetable.New(routetable.Params{}), s, log,
cfg),
Logger: log,
Config: cfg,
})
err := rw.server.Start(t.Context())
+103 -78
View File
@@ -23,7 +23,6 @@ import (
"git.eeqj.de/sneak/routewatch/internal/streamer"
"git.eeqj.de/sneak/routewatch/internal/templates"
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
"github.com/dustin/go-humanize"
"github.com/go-chi/chi/v5"
)
@@ -160,6 +159,7 @@ func (s *Server) handleIndex() http.HandlerFunc {
err := tmpl.Execute(w, indexPageData{
CSPNonce: cspNonce(r.Context()),
ClientIP: extractClientIP(r),
Cards: s.cards.Load(),
})
if err != nil {
s.logger.Error("Failed to render index template", "error", err)
@@ -181,6 +181,9 @@ type indexPageData struct {
// ClientIP is the visitor's address, which the IP information API card
// looks up: the address /ip/ would look up for the same request.
ClientIP string
// Cards is the copy of the cards that show figures of the routing table,
// or nil before the server has first worked them out.
Cards *homeCards
}
// writeJSONError writes a standardized JSON error response with the given
@@ -222,18 +225,23 @@ type WHOISStatsInfo struct {
NeverFetched int `json:"never_fetched"`
SuccessesLastHour int `json:"successes_last_hour"`
ErrorsLastHour int `json:"errors_last_hour"`
CurrentInterval string `json:"current_interval"`
ConsecutiveFails int `json:"consecutive_fails"`
FreshPercent float64 `json:"fresh_percent"`
// CurrentIntervalSeconds is the wait between two fetches. It can have a
// fractional part, such as 22.5.
CurrentIntervalSeconds float64 `json:"current_interval_seconds"`
}
// Stats is the /status.json answer: uptime, message counts, database stats,
// and the routing table's figures. OldestRoute and NewestRoute are the times
// of the oldest and the newest kept routes.
// and the routing table's figures. UptimeSeconds is the time since the stream
// last connected, or 0 if it has never connected. MemoryUsageBytes is the Go
// heap in use. OldestRoute and NewestRoute are the times of the oldest and the
// newest kept routes.
//
//nolint:tagliatelle // field names are the existing JSON API
type Stats struct {
Uptime string `json:"uptime"`
UptimeSeconds int64 `json:"uptime_seconds"`
TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"`
TotalWireBytes uint64 `json:"total_wire_bytes"`
@@ -243,7 +251,7 @@ type Stats struct {
Connected bool `json:"connected"`
GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"`
MemoryUsage string `json:"memory_usage"`
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
ASNs int `json:"asns"`
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
@@ -358,7 +366,7 @@ func (s *Server) statusStats(
runtime.ReadMemStats(&memStats)
return Stats{
Uptime: streamUptime(streamMetrics.ConnectedSince),
UptimeSeconds: uptimeSeconds(streamMetrics.ConnectedSince),
TotalMessages: streamMetrics.TotalMessages,
TotalBytes: streamMetrics.TotalBytes,
TotalWireBytes: streamMetrics.TotalWireBytes,
@@ -368,7 +376,7 @@ func (s *Server) statusStats(
Connected: streamMetrics.Connected,
GoVersion: runtime.Version(),
Goroutines: runtime.NumGoroutine(),
MemoryUsage: humanize.Bytes(memStats.Alloc),
MemoryUsageBytes: memStats.Alloc,
ASNs: dbStats.ASNs,
Prefixes: counts.IPv4Prefixes + counts.IPv6Prefixes,
IPv4Prefixes: counts.IPv4Prefixes,
@@ -435,30 +443,31 @@ func (s *Server) getWHOISStats(ctx context.Context) *WHOISStatsInfo {
NeverFetched: dbStats.NeverFetched,
SuccessesLastHour: fetcherStats.SuccessesLastHour,
ErrorsLastHour: fetcherStats.ErrorsLastHour,
CurrentInterval: fetcherStats.CurrentInterval.String(),
ConsecutiveFails: fetcherStats.ConsecutiveFails,
FreshPercent: freshPercent,
CurrentIntervalSeconds: fetcherStats.CurrentInterval.Seconds(),
}
}
// streamUptime is how long the stream has been connected, to the second, or
// "0s" if it has never connected.
func streamUptime(connectedSince time.Time) string {
// uptimeSeconds is the number of whole seconds since the stream last
// connected, or 0 if it has never connected.
func uptimeSeconds(connectedSince time.Time) int64 {
if connectedSince.IsZero() {
return "0s"
return 0
}
return time.Since(connectedSince).Truncate(time.Second).String()
return int64(time.Since(connectedSince) / time.Second)
}
// connectionDuration is how long the stream has been connected, to the
// second, or "disconnected".
func connectionDuration(connected bool, connectedSince time.Time) string {
// connectionDurationSeconds is the number of whole seconds the stream has
// been connected, or nil while it is not connected.
func connectionDurationSeconds(connected bool, connectedSince time.Time) *int64 {
if !connected || connectedSince.IsZero() {
return "disconnected"
return nil
}
return time.Since(connectedSince).Truncate(time.Second).String()
return new(uptimeSeconds(connectedSince))
}
// whoisStaleThreshold matches the fetcher's threshold for consistency.
@@ -508,7 +517,7 @@ type StreamStats struct {
//
//nolint:tagliatelle // field names are the existing JSON API
type StatsResponse struct {
Uptime string `json:"uptime"`
UptimeSeconds int64 `json:"uptime_seconds"`
TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"`
TotalWireBytes uint64 `json:"total_wire_bytes"`
@@ -516,11 +525,10 @@ type StatsResponse struct {
MbitsPerSec float64 `json:"mbits_per_sec"`
WireMbitsPerSec float64 `json:"wire_mbits_per_sec"`
Connected bool `json:"connected"`
ConnectionDuration string `json:"connection_duration"`
ReconnectCount uint64 `json:"reconnect_count"`
GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"`
MemoryUsage string `json:"memory_usage"`
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
GC GCStats `json:"gc"`
Stream StreamStats `json:"stream"`
ASNs int `json:"asns"`
@@ -542,6 +550,9 @@ type StatsResponse struct {
IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
// ConnectionDurationSeconds is null while the stream is not connected.
ConnectionDurationSeconds *int64 `json:"connection_duration_seconds"`
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
}
@@ -680,20 +691,18 @@ func (s *Server) statsResponse(
runtime.ReadMemStats(&memStats)
return StatsResponse{
Uptime: streamUptime(streamMetrics.ConnectedSince),
TotalMessages: streamMetrics.TotalMessages,
TotalBytes: streamMetrics.TotalBytes,
TotalWireBytes: streamMetrics.TotalWireBytes,
MessagesPerSec: streamMetrics.MessagesPerSec,
MbitsPerSec: streamMetrics.BitsPerSec / bitsPerMegabit,
WireMbitsPerSec: streamMetrics.WireBitsPerSec / bitsPerMegabit,
Connected: streamMetrics.Connected,
ConnectionDuration: connectionDuration(
streamMetrics.Connected, streamMetrics.ConnectedSince),
UptimeSeconds: uptimeSeconds(streamMetrics.ConnectedSince),
TotalMessages: streamMetrics.TotalMessages,
TotalBytes: streamMetrics.TotalBytes,
TotalWireBytes: streamMetrics.TotalWireBytes,
MessagesPerSec: streamMetrics.MessagesPerSec,
MbitsPerSec: streamMetrics.BitsPerSec / bitsPerMegabit,
WireMbitsPerSec: streamMetrics.WireBitsPerSec / bitsPerMegabit,
Connected: streamMetrics.Connected,
ReconnectCount: streamMetrics.ReconnectCount,
GoVersion: runtime.Version(),
Goroutines: runtime.NumGoroutine(),
MemoryUsage: humanize.Bytes(memStats.Alloc),
MemoryUsageBytes: memStats.Alloc,
GC: gcStats(&memStats),
Stream: s.streamStats(),
ASNs: dbStats.ASNs,
@@ -715,6 +724,9 @@ func (s *Server) statsResponse(
IPv6PrefixDistribution: prefixDistribution(counts.IPv6PrefixesByLength),
WHOISStats: whoisStats,
ConnectionDurationSeconds: connectionDurationSeconds(
streamMetrics.Connected, streamMetrics.ConnectedSince),
OriginsWithoutKeptRoute: counts.OriginsWithoutKeptRoute,
}
}
@@ -748,15 +760,14 @@ type IPLookupResponse struct {
}
// IPLookupResult is the answer for one address: its PTR names, its live route
// and that route's AS, and the blocklists that list it. IPInfo is nil when
// the address has no live route, and the answer then has no route or AS
// fields. IP hides the IPInfo field of the same name, so that an address with
// no route has it too.
// and that route's AS, and the blocklists that list it. Every result has every
// field. The fields of IPInfo are null when the address has no live route.
type IPLookupResult struct {
*IPInfo
IPInfo
IP string `json:"ip"`
PTR []string `json:"ptr,omitempty"`
IP string `json:"ip"`
// PTR is empty, never nil, when the address has no PTR names.
PTR []string `json:"ptr"`
// Blocklists has one entry for each blocklist the address is on.
Blocklists []blocklist.Listing `json:"blocklists"`
@@ -814,8 +825,8 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
switch {
case errors.Is(err, ErrNoRoute):
// The address still gets a result, without route fields.
ipInfo = nil
// The address still gets a result, with every field of
// ipInfo null.
case errors.Is(err, ErrInvalidIP):
response.Errors = append(response.Errors, ip+": "+err.Error())
@@ -842,8 +853,8 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
Blocklists: s.blocklistListings(ip),
})
if ipInfo != nil && ipInfo.NeedsWHOISRefresh {
refreshASNs[ipInfo.ASN] = true
if ipInfo.NeedsWHOISRefresh {
refreshASNs[*ipInfo.ASN] = true
}
}
@@ -854,60 +865,72 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
}
}
// ipInfo returns the IP lookup's answer for ip. The routing table gives the
// most specific prefix that holds the address, the number of peers that carry
// it, the origin AS that most of them carry, and the time of its newest kept
// route. The database gives the prefix's first-seen time and the AS's WHOIS
// details. It returns ErrNoRoute when no prefix holds the address, and
// ErrInvalidIP when ip does not parse.
func (s *Server) ipInfo(ctx context.Context, ip string) (*IPInfo, error) {
// ipInfo returns what the IP lookup knows about the route of ip. The routing
// table gives the most specific prefix that holds the address, the number of
// peers that carry it, its origin ASes and the one that most peers carry, and
// the time of its newest kept route. The database gives the prefix's
// first-seen time and the AS's WHOIS details. It returns ErrNoRoute when no
// prefix holds the address, and ErrInvalidIP when ip does not parse. With an
// error, every field of the IPInfo it returns is nil.
func (s *Server) ipInfo(ctx context.Context, ip string) (IPInfo, error) {
addr, err := netip.ParseAddr(ip)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidIP, ip)
return IPInfo{}, fmt.Errorf("%w: %s", ErrInvalidIP, ip)
}
summary, found := s.table.Lookup(addr)
if !found {
return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip)
return IPInfo{}, fmt.Errorf("%w for IP %s", ErrNoRoute, ip)
}
firstSeen, err := s.db.GetPrefixFirstSeen(ctx, summary.Prefix)
if err != nil {
return nil, err
return IPInfo{}, err
}
asInfo, err := s.db.GetASWHOIS(ctx, summary.OriginASN)
if err != nil {
return nil, err
return IPInfo{}, err
}
info := &IPInfo{
IP: ip,
Netblock: summary.Prefix.String(),
MaskLength: summary.Prefix.Bits(),
IPVersion: ipVersionOf(summary.Prefix),
NumPeers: summary.Peers,
ASN: summary.OriginASN,
ASName: asInfo.ASName,
Handle: asInfo.Handle,
Description: asInfo.Description,
OrgName: asInfo.OrgName,
OrgID: asInfo.OrgID,
Address: asInfo.Address,
CountryCode: asInfo.CountryCode,
AbuseEmail: asInfo.AbuseEmail,
RIR: asInfo.RIR,
FirstSeen: firstSeen,
info := IPInfo{
Netblock: new(summary.Prefix.String()),
MaskLength: new(summary.Prefix.Bits()),
IPVersion: new(ipVersionOf(summary.Prefix)),
NumPeers: new(summary.Peers),
ASN: new(summary.OriginASN),
NumOrigins: new(len(summary.OriginASNs)),
OriginASNs: summary.OriginASNs,
ASName: nilIfEmpty(asInfo.ASName),
Handle: nilIfEmpty(asInfo.Handle),
Description: nilIfEmpty(asInfo.Description),
OrgName: nilIfEmpty(asInfo.OrgName),
OrgID: nilIfEmpty(asInfo.OrgID),
Address: nilIfEmpty(asInfo.Address),
CountryCode: nilIfEmpty(asInfo.CountryCode),
AbuseEmail: nilIfEmpty(asInfo.AbuseEmail),
RIR: nilIfEmpty(asInfo.RIR),
LastSeen: summary.Newest,
NeedsWHOISRefresh: needsWHOISRefresh(asInfo),
}
if summary.Newest != nil {
info.LastSeen = *summary.Newest
if !firstSeen.IsZero() {
info.FirstSeen = &firstSeen
}
return info, nil
}
// nilIfEmpty returns a pointer to s, or nil when s is empty, so that JSON
// gives a detail the database does not hold as null.
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
// needsWHOISRefresh says whether the WHOIS details of asInfo were never
// fetched or are older than whoisStaleThreshold. AS 0, no known origin, has
// none to fetch.
@@ -1113,15 +1136,17 @@ func publicAddresses(addrs []string) []string {
return public
}
// lookupPTRNames returns the PTR records of ip without their trailing dots, or
// nil when the lookup fails or runs past ptrLookupTimeout.
// 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
// and when it runs past ptrLookupTimeout, so that an answer's ptr is [] rather
// than null.
func (s *Server) lookupPTRNames(ctx context.Context, ip string) []string {
ctx, cancel := context.WithTimeout(ctx, ptrLookupTimeout)
defer cancel()
ptrs, err := s.resolver.LookupAddr(ctx, ip)
if err != nil {
return nil
if err != nil || ptrs == nil {
return []string{}
}
// Remove trailing dots from PTR records
+291 -13
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"html"
"maps"
"net"
"net/http"
"net/http/httptest"
@@ -72,8 +73,8 @@ func emptyTable() *routetable.Table {
// Paths several tests request.
const (
asPagePath = "/as/64500"
asJSONPath = "/api/v1/as/64500"
prefixLengthPagePath = "/prefixlength/24"
statusJSONPath = "/status.json"
statsPath = "/api/v1/stats"
)
@@ -294,6 +295,167 @@ func TestStatsAnswerCarriesChurnPerMinute(t *testing.T) {
}
}
// fixedIntervalASNFetcher is a WHOIS fetcher that does nothing and reports
// interval as its wait between fetches.
type fixedIntervalASNFetcher struct {
idleASNFetcher
interval time.Duration
}
func (f fixedIntervalASNFetcher) GetStats() ASNFetcherStats {
return ASNFetcherStats{CurrentInterval: f.interval}
}
// unitStats holds the figures of a stats answer that carry a unit. Each is a
// pointer, so that a figure missing from the answer is nil.
//
//nolint:tagliatelle // field names are the existing JSON API
type unitStats struct {
UptimeSeconds *int64 `json:"uptime_seconds"`
MemoryUsageBytes *uint64 `json:"memory_usage_bytes"`
WHOISStats struct {
CurrentIntervalSeconds *float64 `json:"current_interval_seconds"`
} `json:"whois_stats"`
GC struct {
HeapAllocBytes uint64 `json:"heap_alloc_bytes"`
} `json:"gc"`
}
// TestStatsAnswersGiveUnitsInFieldNames checks that the figures of both stats
// answers that carry a unit are numbers, with the unit at the end of their
// 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
// 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.
func TestStatsAnswersGiveUnitsInFieldNames(t *testing.T) {
t.Parallel()
const fetchInterval = 18200 * time.Millisecond
s := newServerWithTestRoutes(t)
s.SetASNFetcher(fixedIntervalASNFetcher{interval: fetchInterval})
for _, path := range []string{statusJSONPath, statsPath} {
answer := get(t, s, path).Body.Bytes()
var body struct {
Data unitStats `json:"data"`
}
err := json.Unmarshal(answer, &body)
if err != nil {
t.Errorf("%s: figures are not numbers: %v; answer %s", path, err, answer)
continue
}
got := body.Data
if got.UptimeSeconds == nil || *got.UptimeSeconds != 0 {
t.Errorf("%s: uptime_seconds is not 0: %s", path, answer)
}
if got.MemoryUsageBytes == nil || *got.MemoryUsageBytes == 0 {
t.Errorf("%s: memory_usage_bytes is not a number of bytes: %s",
path, answer)
}
interval := got.WHOISStats.CurrentIntervalSeconds
if interval == nil || *interval != fetchInterval.Seconds() {
t.Errorf("%s: whois_stats.current_interval_seconds is not %v: %s",
path, fetchInterval.Seconds(), answer)
}
if path == statsPath && got.MemoryUsageBytes != nil &&
*got.MemoryUsageBytes != got.GC.HeapAllocBytes {
t.Errorf("%s: memory_usage_bytes %d, want gc.heap_alloc_bytes %d",
path, *got.MemoryUsageBytes, got.GC.HeapAllocBytes)
}
}
}
// TestStatsAnswerGivesConnectionDurationInSeconds checks that the
// 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
// string "disconnected", or a Go duration such as "1h18m34s"
// (https://git.eeqj.de/sneak/routewatch/issues/199).
func TestStatsAnswerGivesConnectionDurationInSeconds(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
connectionDuration := func() json.RawMessage {
answer := get(t, s, statsPath).Body.Bytes()
var body struct {
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
}
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
// 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
// has no connection duration.
func TestStreamDurationsCountSeconds(t *testing.T) {
t.Parallel()
const connectedFor = 90 * time.Second
connectedSince := time.Now().Add(-connectedFor)
want := int64(connectedFor / time.Second)
if got := uptimeSeconds(connectedSince); got != want {
t.Errorf("uptimeSeconds = %d, want %d", got, want)
}
if got := uptimeSeconds(time.Time{}); got != 0 {
t.Errorf("uptimeSeconds before any connection = %d, want 0", got)
}
switch got := connectionDurationSeconds(true, connectedSince); {
case got == nil:
t.Errorf("connectionDurationSeconds = nil, want %d", want)
case *got != want:
t.Errorf("connectionDurationSeconds = %d, want %d", *got, want)
}
if got := connectionDurationSeconds(false, connectedSince); got != nil {
t.Errorf("connectionDurationSeconds while not connected = %d, want nil",
*got)
}
}
// newTestTable returns a routing table that holds a route for testPrefixV4,
// with origin AS 64500, and one for testPrefixV6, with origin AS 64501.
func newTestTable() *routetable.Table {
@@ -765,7 +927,7 @@ func TestRoutesAnswer504WhenDatabaseReadIsCutOff(t *testing.T) {
{path: "/ip/" + addrInTestPrefixV4, wantJSONBody: true},
{path: statsPath, wantJSONBody: true},
{path: "/api/v1/ip/" + addrInTestPrefixV4, wantJSONBody: true},
{path: "/api/v1/as/64500", wantJSONBody: true},
{path: asJSONPath, wantJSONBody: true},
}
for _, tt := range tests {
@@ -946,10 +1108,10 @@ func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
path string
wantCode int
}{
{"/.well-known/healthcheck.json", http.StatusServiceUnavailable},
{healthCheckPath, http.StatusServiceUnavailable},
{statusJSONPath, http.StatusInternalServerError},
{statsPath, http.StatusInternalServerError},
{"/api/v1/as/64500", http.StatusInternalServerError},
{asJSONPath, http.StatusInternalServerError},
{"/api/v1/ip/" + addrInTestPrefixV4, http.StatusInternalServerError},
{"/api/v1/prefix/foo/24", http.StatusBadRequest},
}
@@ -1219,7 +1381,8 @@ func TestIPLookupKeepsRouteWhenPTRLookupDoesNotAnswer(t *testing.T) {
t.Fatalf("decoding the answer %q: %v", rec.Body, err)
}
if len(body.Data.Results) != 1 || body.Data.Results[0].Netblock != testPrefixV4 {
if len(body.Data.Results) != 1 || body.Data.Results[0].Netblock == nil ||
*body.Data.Results[0].Netblock != testPrefixV4 {
t.Errorf("answer %s, want one result in %s", rec.Body, testPrefixV4)
}
}
@@ -1311,12 +1474,39 @@ func TestIPLookupReportsBlocklists(t *testing.T) {
}
}
// jsonNull is how JSON writes a field without a value.
const jsonNull = "null"
// jsonEmptyList is how JSON writes an empty list.
const jsonEmptyList = "[]"
// resultFields decodes an IP lookup answer and returns each of its results as
// its JSON fields.
func resultFields(t *testing.T, answer []byte) []map[string]json.RawMessage {
t.Helper()
var body struct {
Data struct {
Results []map[string]json.RawMessage `json:"results"`
} `json:"data"`
}
err := json.Unmarshal(answer, &body)
if err != nil {
t.Fatalf("decoding the answer %s: %v", answer, err)
}
return body.Data.Results
}
// TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute looks up a listed
// address that has no live route, alone and beside a routed address. Each
// answer is 200 and has a result for the address, with its blocklists and no
// route fields. Before the fix the lookup of that address alone answered
// 404, and beside a routed address it was only named in errors, so its
// blocklists were never reported.
// answer is 200 and has a result for the address, with its blocklists and
// every route field set to null. Before the fix the lookup of that address
// alone answered 404, and beside a routed address it was only named in
// errors, so its blocklists were never reported. After that fix the result
// still left out the route fields
// (https://git.eeqj.de/sneak/routewatch/issues/200).
func TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute(t *testing.T) {
t.Parallel()
@@ -1324,6 +1514,15 @@ func TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute(t *testing.T) {
s.resolver = &fakeResolver{}
s.SetBlocklists(fakeBlocklists{})
// routeFields are the fields of a result that come from the address's
// route and that route's origin AS.
routeFields := []string{
"netblock", "mask_length", "ip_version", "num_peers", "asn",
"num_origins", "origin_asns", "as_name", "handle", "description",
"org_name", "org_id", "address", "country_code", "abuse_email", "rir",
"first_seen", "last_seen",
}
for _, target := range []string{
listedUnroutedAddr,
addrInTestPrefixV4 + "," + listedUnroutedAddr,
@@ -1354,12 +1553,21 @@ func TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute(t *testing.T) {
continue
}
result := body.Data.Results[i]
if result.IPInfo != nil {
t.Errorf("%s: result for %s has route fields %+v",
target, listedUnroutedAddr, result.IPInfo)
fields := resultFields(t, rec.Body.Bytes())[i]
for _, name := range routeFields {
value, present := fields[name]
switch {
case !present:
t.Errorf("%s: the result for %s has no %s",
target, listedUnroutedAddr, name)
case string(value) != jsonNull:
t.Errorf("%s: %s of %s is %s, want %s",
target, name, listedUnroutedAddr, value, jsonNull)
}
}
result := body.Data.Results[i]
if len(result.Blocklists) != 1 ||
result.Blocklists[0].Name != testListing().Name {
t.Errorf("%s: blocklists %+v, want [%+v]",
@@ -1368,6 +1576,76 @@ func TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute(t *testing.T) {
}
}
// twoAddressHostName is a host name that resolves to addrInTestPrefixV4 and
// listedUnroutedAddr.
const twoAddressHostName = "two-addresses.test"
// TestIPLookupResultsHaveTheSameFields looks up a host name, addresses and
// the caller's own address, through each IP lookup route. Some of the
// addresses have a route and some do not, and none has PTR names. Every
// result has the same fields. Before the fix a result left out every field
// without a value (https://git.eeqj.de/sneak/routewatch/issues/200).
func TestIPLookupResultsHaveTheSameFields(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{hosts: map[string][]string{
twoAddressHostName: {addrInTestPrefixV4, listedUnroutedAddr},
}}
s.SetBlocklists(fakeBlocklists{})
// want is the fields of the first result.
var want []string
for _, path := range []string{
"/api/v1/ip/" + twoAddressHostName,
"/ip/" + addrInTestPrefixV6 + "," + listedUnroutedAddr,
ownAddressPath,
} {
for _, result := range resultFields(t, get(t, s, path).Body.Bytes()) {
got := slices.Sorted(maps.Keys(result))
if want == nil {
want = got
}
if !slices.Equal(got, want) {
t.Errorf("%s: a result has the fields %v, want %v", path, got, want)
}
}
}
}
// TestIPLookupOfAnAddressWithoutPTRNamesOrStoredNetblock looks up a routed
// address that has no PTR names, and whose netblock the database has not
// stored. Its ptr is an empty list and its first_seen is null. Before the fix
// ptr was left out, and first_seen was 0001-01-01T00:00:00Z
// (https://git.eeqj.de/sneak/routewatch/issues/200).
func TestIPLookupOfAnAddressWithoutPTRNamesOrStoredNetblock(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
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))
}
want := map[string]string{
"ptr": jsonEmptyList,
"first_seen": jsonNull,
}
for name, value := range want {
got := string(results[0][name])
if got != value {
t.Errorf("%s: %s is %s, want %s", path, name, got, value)
}
}
}
// TestIPLookupAnswersMayBeCached checks the Cache-Control header of the IP
// lookup answers. Caches may keep them for the blocklists' shortest interval,
// and only the caller's own browser may keep the answer about the caller's
+33 -17
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"reflect"
"slices"
"strconv"
"strings"
@@ -75,9 +77,10 @@ func get(t *testing.T, s *Server, path string) *httptest.ResponseRecorder {
// TestIPLookupGivesTheOriginMostPeersCarry looks up an address in a prefix
// with two origin ASes, through each IP lookup route. The answer names the
// origin AS that more peers carry, counts every peer, and gives the time of
// the prefix's newest kept route. The prefix's first-seen time and the AS's
// WHOIS details come from the database.
// origin AS that more peers carry, counts every peer, lists both origin ASes,
// and gives the time of the prefix's newest kept route. The prefix's
// first-seen time and the AS's WHOIS details come from the database. A WHOIS
// detail the database does not hold is null.
func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) {
t.Parallel()
@@ -105,6 +108,26 @@ func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) {
t.Fatalf("UpdateASNWHOIS: %v", err)
}
// The details the database does not hold, such as org_name, are null.
want := IPInfo{
Netblock: new(testPrefixV4),
MaskLength: new(netip.MustParsePrefix(testPrefixV4).Bits()),
IPVersion: new(ipVersionV4),
NumPeers: new(4),
ASN: new(mostPeersASN),
NumOrigins: new(2),
OriginASNs: []int{mostPeersASN, fewerPeersASN},
ASName: &whois.ASName,
CountryCode: &whois.CountryCode,
FirstSeen: &firstSeen,
LastSeen: new(testRouteTime(5)),
}
wantJSON, err := json.Marshal(want)
if err != nil {
t.Fatalf("encoding %+v: %v", want, err)
}
// ownAddressPath looks up the request's own address, which httptest
// gives as addrInTestPrefixV4.
for _, path := range []string{
@@ -115,22 +138,15 @@ func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) {
Data IPLookupResponse `json:"data"`
}
err := json.Unmarshal(get(t, s, path).Body.Bytes(), &body)
if err != nil || len(body.Data.Results) != 1 ||
body.Data.Results[0].IPInfo == nil {
t.Fatalf("%s: answer %+v (%v), want one result with a route",
path, body.Data, err)
answer := get(t, s, path).Body.Bytes()
err := json.Unmarshal(answer, &body)
if err != nil || len(body.Data.Results) != 1 {
t.Fatalf("%s: answer %s (%v), want one result", path, answer, err)
}
got := body.Data.Results[0].IPInfo
if got.Netblock != testPrefixV4 || got.ASN != mostPeersASN ||
got.NumPeers != 4 || !got.LastSeen.Equal(testRouteTime(5)) ||
!got.FirstSeen.Equal(firstSeen) || got.ASName != whois.ASName ||
got.CountryCode != whois.CountryCode {
t.Errorf("%s: route %+v, want %s from AS%d, 4 peers, last seen %v, "+
"first seen %v, %s in %s", path, *got, testPrefixV4,
mostPeersASN, testRouteTime(5), firstSeen, whois.ASName,
whois.CountryCode)
if !reflect.DeepEqual(body.Data.Results[0].IPInfo, want) {
t.Errorf("%s: answer %s, want the fields %s", path, answer, wantJSON)
}
}
}
+206
View File
@@ -0,0 +1,206 @@
package server
import (
"context"
"html/template"
"net/http"
"slices"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/routetable"
"git.eeqj.de/sneak/routewatch/internal/templates"
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
)
const (
// homeCardsRefreshInterval is how often the server works out the home
// page's cards again. Some of their figures take a walk over the whole
// routing table, so no request works them out.
homeCardsRefreshInterval = time.Minute
// homeCardsDatabaseTimeout is how long the database read behind the
// cards of the newest and the oldest AS may take.
homeCardsDatabaseTimeout = 4 * time.Second
// maxListedEntries is how many entries the bogon and multiple-origin
// pages list at most.
maxListedEntries = 1000
)
// homeCards is what the home page's cards show. The bogon and multiple-origin
// pages list the entries their cards count. The server works it out every
// homeCardsRefreshInterval and serves this copy in between.
type homeCards struct {
routetable.Overview
// NewestAS and OldestAS are the ASes the asns table last saw most
// recently and longest ago. They are nil when the database did not
// answer or holds no AS.
NewestAS *database.ASN
OldestAS *database.ASN
// WellKnownNetworks are the networks of wellKnownASNs, newest update
// first.
WellKnownNetworks []wellKnownNetwork
}
// wellKnownNetwork is one network on the home page's card of well-known
// networks.
type wellKnownNetwork struct {
ASN int
// Name is its description in the AS list built into RouteWatch.
Name string
// Prefixes is how many prefixes it originates.
Prefixes int
// Newest is the time of its newest kept route, or nil when it keeps none.
Newest *time.Time
}
// listPageData is what the bogon and multiple-origin pages are rendered with.
type listPageData struct {
// CSPNonce is the nonce the page's style block carries.
CSPNonce string
// Cards is the copy of the home page's cards, or nil before the server
// has first worked them out.
Cards *homeCards
}
// The AS numbers of the networks the home page lists as well known.
const (
appleASN = 714
googleASN = 15169
amazonASN = 16509
microsoftASN = 8075
metaASN = 32934
cloudflareASN = 13335
akamaiASN = 20940
netflixASN = 2906
dodNetworkInfoCtrASN = 721
cogentASN = 174
hurricaneElectricASN = 6939
level3ASN = 3356
arelionASN = 1299
nttASN = 2914
mitASN = 3
)
// wellKnownASNs returns the AS numbers of the networks the home page lists as
// well known: large companies, large transit networks, the US Department of
// Defense's Network Information Center and a university.
func wellKnownASNs() []int {
return []int{
appleASN, googleASN, amazonASN, microsoftASN, metaASN, cloudflareASN,
akamaiASN, netflixASN, dodNetworkInfoCtrASN, cogentASN,
hurricaneElectricASN, level3ASN, arelionASN, nttASN, mitASN,
}
}
// keepHomeCardsFresh works out the home page's cards now, and again every
// interval until ctx ends.
func (s *Server) keepHomeCardsFresh(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
s.refreshHomeCards(ctx)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
// refreshHomeCards works out the home page's cards and keeps them for the
// pages to serve. When the database does not answer, the failure is logged
// and the cards of the newest and the oldest AS stay empty.
func (s *Server) refreshHomeCards(ctx context.Context) {
cards := &homeCards{
Overview: s.table.Overview(maxListedEntries),
WellKnownNetworks: s.wellKnownNetworks(),
}
ctx, cancel := context.WithTimeout(ctx, homeCardsDatabaseTimeout)
defer cancel()
newest, oldest, err := s.db.GetLastSeenASes(ctx)
if err != nil {
s.logger.Warn("Failed to read the newest and the oldest AS", "error", err)
} else {
cards.NewestAS, cards.OldestAS = newest, oldest
}
s.cards.Store(cards)
}
// wellKnownNetworks returns the networks of wellKnownASNs, each with how many
// prefixes it originates and the time of its newest kept route. They come
// newest first, and those that keep no route last.
func (s *Server) wellKnownNetworks() []wellKnownNetwork {
asns := wellKnownASNs()
networks := make([]wellKnownNetwork, 0, len(asns))
for _, asn := range asns {
originated := s.table.OriginatedPrefixes(asn)
network := wellKnownNetwork{ASN: asn, Prefixes: len(originated)}
if info, ok := asinfo.Get(asn); ok {
network.Name = info.Description
}
for _, p := range originated {
if p.Newest != nil &&
(network.Newest == nil || p.Newest.After(*network.Newest)) {
network.Newest = p.Newest
}
}
networks = append(networks, network)
}
slices.SortStableFunc(networks, func(a, b wellKnownNetwork) int {
switch {
case a.Newest == nil && b.Newest == nil:
return 0
case a.Newest == nil:
return 1
case b.Newest == nil:
return -1
}
return b.Newest.Compare(*a.Newest)
})
return networks
}
// handleBogons returns a handler that serves the page listing the bogon
// announcements.
func (s *Server) handleBogons() http.HandlerFunc {
return s.listPageHandler(templates.BogonsTemplate())
}
// handleMultipleOrigins returns a handler that serves the page listing the
// prefixes announced by more than one origin AS.
func (s *Server) handleMultipleOrigins() http.HandlerFunc {
return s.listPageHandler(templates.MultipleOriginsTemplate())
}
// listPageHandler returns a handler that renders tmpl, a page that lists
// entries the home page's cards count, from the copy of the cards.
func (s *Server) listPageHandler(tmpl *template.Template) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
err := tmpl.Execute(w, listPageData{
CSPNonce: cspNonce(r.Context()),
Cards: s.cards.Load(),
})
if err != nil {
s.logger.Error("Failed to render a list page", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
+358
View File
@@ -0,0 +1,358 @@
package server
import (
"context"
"net/netip"
"strconv"
"strings"
"testing"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// The ASes of the routing table that newCardsTable fills. Each card test
// finds one of them on its card.
const (
twoPrefixASN = 13335 // originates two prefixes, the most
newestRouteASN = 15169 // the newest route
oldestRouteASN = 3 // the oldest route
secondOrigin = 16509 // a second origin AS of twoPrefixASN's prefix
privateUseASN = 64512 // reserved, so its route is a bogon announcement
)
// Paths of the pages that list what two of the home page's cards count.
const (
bogonsPath = "/bogons"
multipleOriginsPath = "/multiple-origins"
)
// cardsNotReadyText is what the pages say before the cards are first worked
// out.
const cardsNotReadyText = "being worked out"
// newCardsTable returns a routing table in which:
// - twoPrefixASN originates 1.0.0.0/24 and 1.0.4.0/22;
// - secondOrigin originates 1.0.0.0/24 too, which so has two origin ASes;
// - newestRouteASN's 8.8.8.0/24 has the newest route;
// - oldestRouteASN's 18.0.0.0/8 has the oldest;
// - privateUseASN originates 10.0.0.0/8, carried by two peers: a bogon
// announcement for both its prefix and its AS.
//
// The last four ASes originate one prefix each.
func newCardsTable() *routetable.Table {
table := emptyTable()
announce(table, "1.0.0.0/24", twoPrefixASN, testPeer(1), testRouteTime(10))
announce(table, "1.0.4.0/22", twoPrefixASN, testPeer(1), testRouteTime(5))
announce(table, "1.0.0.0/24", secondOrigin, testPeer(3), testRouteTime(12))
announce(table, "8.8.8.0/24", newestRouteASN, testPeer(2), testRouteTime(20))
announce(table, "18.0.0.0/8", oldestRouteASN, testPeer(2), testRouteTime(1))
announce(table, "10.0.0.0/8", privateUseASN, testPeer(1), testRouteTime(15))
announce(table, "10.0.0.0/8", privateUseASN, testPeer(2), testRouteTime(15))
return table
}
// homeCard returns the HTML of the home page's card with heading, up to the
// end of the card.
func homeCard(t *testing.T, page, heading string) string {
t.Helper()
_, card, found := strings.Cut(page, "<h2>"+heading+"</h2>")
if !found {
t.Fatalf("the home page has no card %q", heading)
}
card, _, _ = strings.Cut(card, "</div>")
return card
}
// asLink returns the HTML attribute of a link to the AS page of asn.
func asLink(asn int) string {
return `href="/as/` + strconv.Itoa(asn) + `"`
}
// TestHomePageCardsShowTheTableFigures fills a routing table and the asns
// table, works out the cards, and checks that each card shows its figure.
func TestHomePageCardsShowTheTableFigures(t *testing.T) {
t.Parallel()
const (
newestSeenASN = 6939
oldestSeenASN = 174
)
s := newServerWithTable(t, newCardsTable())
err := s.db.GetOrCreateASNBatch(map[int]database.SeenTimes{
newestSeenASN: {First: testRouteTime(0), Last: testRouteTime(30)},
oldestSeenASN: {First: testRouteTime(-30), Last: testRouteTime(-30)},
})
if err != nil {
t.Fatalf("GetOrCreateASNBatch: %v", err)
}
s.refreshHomeCards(t.Context())
page := get(t, s, "/").Body.String()
tests := []struct {
heading string
want []string
}{
{"Newest Updated Route", []string{
"8.8.8.0/24", asLink(newestRouteASN),
testRouteTime(20).Format(routeTimeFormat),
}},
{"Oldest Updated Route", []string{
"18.0.0.0/8", asLink(oldestRouteASN),
testRouteTime(1).Format(routeTimeFormat),
}},
{"Newest Updated AS", []string{
asLink(newestSeenASN), testRouteTime(30).Format(routeTimeFormat),
}},
{"Oldest Updated AS", []string{
asLink(oldestSeenASN), testRouteTime(-30).Format(routeTimeFormat),
}},
{"AS with the Most Prefixes", []string{
asLink(twoPrefixASN), "Prefixes it originates: 2",
}},
{"ASes with the Fewest Prefixes", []string{
"ASes that originate a single prefix: 4", asLink(newestRouteASN),
"8.8.8.0/24",
}},
{"Bogon Announcements", []string{`href="/bogons">1<`}},
{"Prefixes with Several Origins", []string{`href="/multiple-origins">1<`}},
}
for _, tc := range tests {
card := homeCard(t, page, tc.heading)
for _, want := range tc.want {
if !strings.Contains(card, want) {
t.Errorf("card %q does not hold %s: %s", tc.heading, want, card)
}
}
}
// The well-known networks come newest update first, and those without
// a route after them.
networks := homeCard(t, page, "Well-Known Networks")
order := []int{newestRouteASN, secondOrigin, twoPrefixASN, oldestRouteASN, 714}
last := -1
for _, asn := range order {
i := strings.Index(networks, asLink(asn))
if i <= last {
t.Fatalf("well-known networks: AS%d is not after the ASes before "+
"it in %v: %s", asn, order, networks)
}
last = i
}
if !strings.Contains(networks, "<td>2</td>") {
t.Errorf("well-known networks: no network with 2 prefixes: %s", networks)
}
}
// TestListPagesListTheirEntries checks the rows of the bogon and multiple-origin
// pages for the table of newCardsTable.
func TestListPagesListTheirEntries(t *testing.T) {
t.Parallel()
s := newServerWithTable(t, newCardsTable())
s.refreshHomeCards(t.Context())
pages := map[string][]string{
bogonsPath: {
"Bogon announcements: 1.", "10.0.0.0/8", asLink(privateUseASN),
"<td>2</td>",
"Private-Use (10.0.0.0/8); Private-Use (AS64512-AS65534)",
},
multipleOriginsPath: {
"Prefixes announced by more than one origin AS: 1.", "1.0.0.0/24",
asLink(twoPrefixASN), asLink(secondOrigin), ">1 peer<",
},
}
for path, wants := range pages {
page := get(t, s, path).Body.String()
for _, want := range wants {
if !strings.Contains(page, want) {
t.Errorf("%s does not hold %s", path, want)
}
}
}
}
// TestListPagesListAtMostMaxListedEntries fills a table with more prefixes than
// the list pages list, each announced by two origin ASes in reserved address
// space. Each page lists maxListedEntries of them and gives how many there are
// in all.
func TestListPagesListAtMostMaxListedEntries(t *testing.T) {
t.Parallel()
const prefixCount = maxListedEntries + 1
table := emptyTable()
addr := netip.MustParseAddr("10.0.0.0")
for range prefixCount {
prefix := netip.PrefixFrom(addr, addr.BitLen()).String()
announce(table, prefix, twoPrefixASN, testPeer(1), testRouteTime(0))
announce(table, prefix, secondOrigin, testPeer(2), testRouteTime(0))
addr = addr.Next()
}
s := newServerWithTable(t, table)
s.refreshHomeCards(t.Context())
listed := "The first " + strconv.Itoa(maxListedEntries) + " are listed"
tests := []struct {
path string
total string
}{
{bogonsPath, "Bogon announcements: " + strconv.Itoa(2*prefixCount) + "."},
{multipleOriginsPath, "Prefixes announced by more than one origin AS: " +
strconv.Itoa(prefixCount) + "."},
}
for _, tc := range tests {
page := get(t, s, tc.path).Body.String()
rows := strings.Count(page, `class="prefix-link"`)
if rows != maxListedEntries {
t.Errorf("%s lists %d entries, want %d", tc.path, rows,
maxListedEntries)
}
if !strings.Contains(page, tc.total) || !strings.Contains(page, listed) {
t.Errorf("%s does not say %q and %q", tc.path, tc.total, listed)
}
}
}
// TestPagesServeTheCopyOfTheCards checks that the pages show the cards as they
// were last worked out, not as the routing table is now, and that they say so
// before the cards are first worked out.
func TestPagesServeTheCopyOfTheCards(t *testing.T) {
t.Parallel()
table := emptyTable()
announce(table, "1.0.0.0/24", twoPrefixASN, testPeer(1), testRouteTime(0))
s := newServerWithTable(t, table)
for _, path := range []string{"/", bogonsPath, multipleOriginsPath} {
if !strings.Contains(get(t, s, path).Body.String(), cardsNotReadyText) {
t.Errorf("%s does not say the cards are %s", path, cardsNotReadyText)
}
}
s.refreshHomeCards(t.Context())
announce(table, "8.8.8.0/24", newestRouteASN, testPeer(2), testRouteTime(1))
card := homeCard(t, get(t, s, "/").Body.String(), "Newest Updated Route")
if !strings.Contains(card, "1.0.0.0/24") {
t.Errorf("before the cards are worked out again, the newest route "+
"is not the one they were worked out with: %s", card)
}
s.refreshHomeCards(t.Context())
card = homeCard(t, get(t, s, "/").Body.String(), "Newest Updated Route")
if !strings.Contains(card, "8.8.8.0/24") {
t.Errorf("after the cards are worked out again, the newest route is "+
"not the table's: %s", card)
}
}
// TestKeepHomeCardsFreshWorksThemOutEveryInterval runs keepHomeCardsFresh with
// a short interval. It works the cards out at once, again after the table
// changes, and stops when its context ends.
func TestKeepHomeCardsFreshWorksThemOutEveryInterval(t *testing.T) {
t.Parallel()
const (
interval = 10 * time.Millisecond
maxWait = 5 * time.Second
)
table := emptyTable()
announce(table, "1.0.0.0/24", twoPrefixASN, testPeer(1), testRouteTime(0))
s := newServerWithTable(t, table)
ctx, cancel := context.WithCancel(t.Context())
stopped := make(chan struct{})
go func() {
s.keepHomeCardsFresh(ctx, interval)
close(stopped)
}()
newestRouteASNIs := func(asn int) bool {
cards := s.cards.Load()
return cards != nil && cards.NewestRoute != nil &&
cards.NewestRoute.OriginASN == asn
}
waitUntil(t, maxWait, func() bool { return newestRouteASNIs(twoPrefixASN) })
announce(table, "8.8.8.0/24", newestRouteASN, testPeer(2), testRouteTime(1))
waitUntil(t, maxWait, func() bool { return newestRouteASNIs(newestRouteASN) })
cancel()
select {
case <-stopped:
case <-time.After(maxWait):
t.Fatal("keepHomeCardsFresh did not stop when its context ended")
}
}
// TestStartedServerWorksOutTheCards starts a server and waits until its home
// page shows the cards.
func TestStartedServerWorksOutTheCards(t *testing.T) {
t.Parallel()
const maxWait = 5 * time.Second
s := newServerWithTable(t, newCardsTable())
err := s.Start(t.Context())
if err != nil {
t.Fatalf("Start: %v", err)
}
defer func() { _ = s.Stop(context.Background()) }()
waitUntil(t, maxWait, func() bool {
return !strings.Contains(get(t, s, "/").Body.String(), cardsNotReadyText)
})
card := homeCard(t, get(t, s, "/").Body.String(), "Newest Updated Route")
if !strings.Contains(card, "8.8.8.0/24") {
t.Errorf("the newest route is not the table's: %s", card)
}
}
// waitUntil fails the test unless done reports true within maxWait.
func waitUntil(t *testing.T, maxWait time.Duration, done func() bool) {
t.Helper()
const pollInterval = 5 * time.Millisecond
deadline := time.Now().Add(maxWait)
for !done() {
if time.Now().After(deadline) {
t.Fatalf("the cards did not change within %s", maxWait)
}
time.Sleep(pollInterval)
}
}
+19 -7
View File
@@ -16,6 +16,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// responseWriter wraps http.ResponseWriter to capture the response
@@ -53,6 +54,15 @@ func (rw *responseWriter) Header() http.Header {
return rw.ResponseWriter.Header()
}
// The @meta object JSONResponseMiddleware adds to a JSON answer: its key, and
// the keys of its fields.
const (
jsonKeyMeta = "@meta"
metaKeyTimeZone = "time_zone"
metaKeyAPIVersion = "api_version"
metaKeyExecutionTimeMs = "execution_time_ms"
)
// JSONResponseMiddleware is an HTTP middleware that wraps all JSON responses
// with a @meta field containing execution metadata. The metadata includes the
// time zone (always UTC), API version, and request execution time in milliseconds.
@@ -106,10 +116,10 @@ func JSONResponseMiddleware(next http.Handler) http.Handler {
}
// Add @meta field
originalResponse["@meta"] = map[string]any{
"time_zone": "UTC",
"api_version": 1,
"execution_time": fmt.Sprintf("%d ms", execTime.Milliseconds()),
originalResponse[jsonKeyMeta] = map[string]any{
metaKeyTimeZone: "UTC",
metaKeyAPIVersion: 1,
metaKeyExecutionTimeMs: execTime.Milliseconds(),
}
// Write the enhanced response
@@ -145,7 +155,8 @@ func deadlinePassed(ctx context.Context) bool {
// has passed, it answers 504 with the error message "Request timeout". A 4xx
// keeps its status, with the status's standard text as the message. For other
// errors, it returns "Internal server error", with status 500 if the original
// status was 200.
// status was 200. A panic in the handler answers 500 with "Internal server
// error" too.
//
// Non-JSON responses are passed through unchanged.
func JSONValidationMiddleware(next http.Handler) http.Handler {
@@ -157,8 +168,9 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
statusCode: http.StatusOK,
}
// Serve the request
next.ServeHTTP(rw, r)
// Serve the request. Recoverer logs a panic in the handler and turns it
// into a 500 without a body, which gets the JSON error body below.
middleware.Recoverer(next).ServeHTTP(rw, r)
// Check if it's meant to be a JSON response
contentType := rw.Header().Get("Content-Type")
+364 -1
View File
@@ -1,25 +1,40 @@
package server
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"regexp"
"strings"
"testing"
"time"
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/streamer"
)
// The addresses the forwarding tests use: the client a proxy names in
// X-Forwarded-For, the range of trusted proxies, a peer inside it, and a peer
// outside it, which lies in testPrefixV4 so that an IP lookup of it finds a
// route.
// route. reverseProxyPeer is a reverse proxy at a private address, as Docker
// gives one.
const (
forwardedClient = "203.0.113.9"
trustedProxyRange = "10.0.0.0/8"
trustedPeer = "10.0.0.2:1234"
untrustedPeerAddr = "192.0.2.1"
untrustedPeer = untrustedPeerAddr + ":1234"
reverseProxyPeer = "172.19.0.14:1234"
)
// TestResponsesCarrySecurityHeaders checks that a page, a JSON route and a
@@ -87,6 +102,219 @@ func TestJSONValidationKeeps4xxWithoutBody(t *testing.T) {
checkJSONErrorMessage(t, name, rec, http.StatusText(http.StatusBadRequest))
}
// TestJSONAnswersPutNoUnitInAString sends a request to each JSON route and
// checks each answer. Its @meta.execution_time_ms is a number, and none of its
// strings is a number with a unit, such as "12 ms", "1.1 GB" or "1h18m34s"
// (https://git.eeqj.de/sneak/routewatch/issues/199). The health check answers
// 503, since the stream is not connected, and the last path matches no route.
func TestJSONAnswersPutNoUnitInAString(t *testing.T) {
t.Parallel()
numberWithUnit := regexp.MustCompile(
`^([0-9]+(\.[0-9]+)? ?(ns|µs|ms|s|m|h|B|kB|MB|GB|TB))+$`)
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
s.SetASNFetcher(fixedIntervalASNFetcher{interval: time.Second})
paths := []string{
statusJSONPath,
statsPath,
healthCheckPath,
"/api/v1/ip/" + untrustedPeerAddr,
"/ip/" + untrustedPeerAddr,
asJSONPath,
"/api/v1/prefix/" + testPrefixV4,
"/api/v1/search/as?q=google",
"/api/v1/no-such-route",
}
for _, path := range paths {
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil))
var answer map[string]any
err := json.Unmarshal(rec.Body.Bytes(), &answer)
if err != nil {
t.Errorf("%s: decoding the answer: %v; answer %s", path, err, rec.Body)
continue
}
meta, _ := answer["@meta"].(map[string]any)
if _, ok := meta["execution_time_ms"].(float64); !ok {
t.Errorf("%s: @meta.execution_time_ms is not a number: %s",
path, rec.Body)
}
for _, value := range stringsIn(answer) {
if numberWithUnit.MatchString(value) {
t.Errorf("%s: %q is a number with a unit in a string", path, value)
}
}
}
}
// TestJSONAnswersCarryMeta sends requests to every JSON route through the
// server's router, and checks that each answer carries @meta with its three
// fields. The answers include successes, and errors that the handlers, the
// 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
// empty body (https://git.eeqj.de/sneak/routewatch/issues/201).
func TestJSONAnswersCarryMeta(t *testing.T) {
t.Parallel()
// JSON routes whose handlers write nothing or panic, so that the JSON
// check on the route writes the error answer itself.
const (
writesNothingPath = "/writes-nothing.json"
panicsPath = "/panics.json"
)
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
s.streamer.GetMetricsTracker().SetConnected(true)
s.router.Get(writesNothingPath, JSONValidationMiddleware(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {})).ServeHTTP)
s.router.Get(panicsPath, JSONValidationMiddleware(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) { panic(errDiskIO) })).ServeHTTP)
tests := []struct {
method string
path string
wantCode int
}{
// Answers that succeed
{http.MethodGet, statusJSONPath, http.StatusOK},
{http.MethodGet, healthCheckPath, http.StatusOK},
{http.MethodGet, "/ip", http.StatusOK},
{http.MethodGet, ownAddressPath, http.StatusOK},
{http.MethodGet, "/ip/" + addrInTestPrefixV4, http.StatusOK},
{http.MethodGet, statsPath, http.StatusOK},
{http.MethodGet, "/api/v1/ip/" + addrInTestPrefixV4, http.StatusOK},
{http.MethodGet, asJSONPath, http.StatusOK},
{http.MethodGet, "/api/v1/prefix/" + testPrefixV4, http.StatusOK},
{http.MethodGet, "/api/v1/search/as?q=google", http.StatusOK},
// Errors the handlers write
{http.MethodGet, "/ip/no-such-host.example", http.StatusBadRequest},
{http.MethodGet, "/api/v1/ip/no-such-host.example", http.StatusBadRequest},
{http.MethodGet, "/api/v1/as/foo", http.StatusBadRequest},
{http.MethodGet, "/api/v1/as/0", http.StatusNotFound},
{http.MethodGet, "/api/v1/prefix/192.0.2.0/33", http.StatusBadRequest},
{http.MethodGet, "/api/v1/prefix/198.51.100.0/24", http.StatusNotFound},
{http.MethodGet, "/api/v1/search/as", http.StatusBadRequest},
// Errors the router and the middleware write
{http.MethodGet, "/api/v1/no-such-route", http.StatusNotFound},
{http.MethodGet, "/ip/192.0.2.1/nothing", http.StatusNotFound},
{http.MethodPost, statsPath, http.StatusMethodNotAllowed},
{http.MethodPost, ownAddressPath, http.StatusMethodNotAllowed},
{http.MethodPost, statusJSONPath, http.StatusMethodNotAllowed},
{http.MethodPut, healthCheckPath, http.StatusMethodNotAllowed},
{http.MethodGet, writesNothingPath, http.StatusInternalServerError},
{http.MethodGet, panicsPath, http.StatusInternalServerError},
}
for _, tt := range tests {
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
// write the answers of the first five routes, and the JSON check on the
// route writes the last.
pastTimeLimit, cancel := context.WithDeadline(t.Context(), time.Now())
defer cancel()
for _, path := range []string{
statusJSONPath, statsPath, "/ip/" + addrInTestPrefixV4,
"/api/v1/ip/" + addrInTestPrefixV4, asJSONPath, writesNothingPath,
} {
checkAnswerCarriesMeta(t, s, httptest.NewRequestWithContext(
pastTimeLimit, http.MethodGet, path, nil), http.StatusGatewayTimeout)
}
// With a store whose reads fail, the health check answers 503, and 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(
t.Context(), http.MethodGet, path, nil), http.StatusInternalServerError)
}
}
// checkAnswerCarriesMeta serves req through the server's router, and checks
// that it answers wantCode with @meta and its three fields.
func checkAnswerCarriesMeta(
t *testing.T, s *Server, req *http.Request, wantCode int,
) {
t.Helper()
// The values README "API v1" gives the two fields that never change.
const (
wantTimeZone = "UTC"
wantAPIVersion = 1
)
name := req.Method + " " + req.URL.String()
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
if rec.Code != wantCode {
t.Errorf("%s: status %d, want %d; body %s", name, rec.Code, wantCode, rec.Body)
}
var answer map[string]any
err := json.Unmarshal(rec.Body.Bytes(), &answer)
if err != nil {
t.Errorf("%s: decoding the answer %q: %v", name, rec.Body, err)
return
}
meta, _ := answer[jsonKeyMeta].(map[string]any)
_, timeIsNumber := meta[metaKeyExecutionTimeMs].(float64)
if meta[metaKeyTimeZone] != wantTimeZone ||
meta[metaKeyAPIVersion] != float64(wantAPIVersion) || !timeIsNumber {
t.Errorf("%s: @meta %v, want time_zone %q, api_version %d and a "+
"number in execution_time_ms", name, meta, wantTimeZone, wantAPIVersion)
}
}
// stringsIn returns every string in a decoded JSON value, at any depth.
func stringsIn(value any) []string {
var found []string
switch v := value.(type) {
case string:
found = append(found, v)
case []any:
for _, item := range v {
found = append(found, stringsIn(item)...)
}
case map[string]any:
for _, item := range v {
found = append(found, stringsIn(item)...)
}
}
return found
}
// TestPagesCarryTheCSPNonce checks that every script and style block of each
// page carries the nonce its Content-Security-Policy header names, so that the
// browser runs and applies them, that each answer has a nonce of its own, and
@@ -183,6 +411,141 @@ func TestForwardedForFromUntrustedPeerIsIgnored(t *testing.T) {
}
}
// TestDefaultTrustedProxiesGiveTheForwardedClient builds the server from the
// configuration with TRUSTED_PROXIES unset. A request from a reverse proxy at
// a private address must be looked up by /ip/, shown on the home page's card
// and logged as the client its X-Forwarded-For names. A direct request from a
// public address with a forged X-Forwarded-For keeps its own address. Before
// the fix no proxy was trusted unless TRUSTED_PROXIES named it, so /ip/ gave
// the proxy's address (https://git.eeqj.de/sneak/routewatch/issues/198).
func TestDefaultTrustedProxiesGiveTheForwardedClient(t *testing.T) {
t.Setenv("PORT", "")
t.Setenv("XDG_DATA_HOME", "")
// t.Setenv puts the variable back when the test ends.
t.Setenv("TRUSTED_PROXIES", "")
err := os.Unsetenv("TRUSTED_PROXIES")
if err != nil {
t.Fatal(err)
}
cfg, err := config.New()
if err != nil {
t.Fatalf("config.New: %v", err)
}
var requestLog bytes.Buffer
s := newServerWithConfig(t, cfg, &requestLog)
tests := []struct {
name string
peer string
want string
}{
{"through a reverse proxy", reverseProxyPeer, forwardedClient},
{"direct from a public address", untrustedPeer, untrustedPeerAddr},
}
for _, tt := range tests {
requestLog.Reset()
get := func(path string) *httptest.ResponseRecorder {
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)
req.RemoteAddr = tt.peer
req.Header.Set("X-Forwarded-For", forwardedClient)
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
return rec
}
var answer struct {
Data IPLookupResponse `json:"data"`
}
rec := get("/ip/")
err := json.Unmarshal(rec.Body.Bytes(), &answer)
if err != nil {
t.Fatalf("%s: decoding the answer %q: %v", tt.name, rec.Body, err)
}
if answer.Data.Query != tt.want {
t.Errorf("%s: /ip/ looked up %q, want %q", tt.name, answer.Data.Query, tt.want)
}
page := get("/").Body.String()
if !strings.Contains(page, `data-client-ip="`+tt.want+`"`) {
t.Errorf("%s: the home page's card does not hold %s", tt.name, tt.want)
}
logged := loggedAddresses(requestLog.String())
if len(logged) == 0 {
t.Errorf("%s: the request log names no address", tt.name)
}
for _, addr := range logged {
if addr != tt.want {
t.Errorf("%s: the request log gives %s, want %s", tt.name, addr, tt.want)
}
}
}
}
// newServerWithConfig returns a server built with cfg, on a new database, that
// reads the routes of newTestTable and logs its requests as JSON to
// requestLog.
func newServerWithConfig(
t *testing.T, cfg *config.Config, requestLog io.Writer,
) *Server {
t.Helper()
cfg.StateDir = t.TempDir()
db, err := database.New(cfg, logger.New())
if err != nil {
t.Fatalf("database.New: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
s := New(db, newTestTable(), streamer.New(logger.New(), metrics.New()),
&logger.Logger{Logger: slog.New(slog.NewJSONHandler(requestLog, nil))}, cfg)
s.resolver = &fakeResolver{}
return s
}
// loggedAddresses returns the address each line of the JSON request log
// names, without the port a direct request is logged with.
func loggedAddresses(requestLog string) []string {
var addrs []string
for line := range strings.Lines(requestLog) {
//nolint:tagliatelle // the field name is the key the request log uses
var entry struct {
RemoteAddr string `json:"remote_addr"`
}
err := json.Unmarshal([]byte(line), &entry)
if err != nil || entry.RemoteAddr == "" {
continue
}
addr, _, err := net.SplitHostPort(entry.RemoteAddr)
if err != nil {
addr = entry.RemoteAddr
}
addrs = append(addrs, addr)
}
return addrs
}
// TestRealIPMiddlewareTrustsOnlyConfiguredProxies checks which address
// RealIPMiddleware gives a request: the TCP peer, unless the peer is a trusted
// proxy; then the last X-Forwarded-For entry that is not a trusted proxy,
+28 -21
View File
@@ -38,30 +38,37 @@ type PrefixDistribution struct {
Count int `json:"count"`
}
// IPInfo represents comprehensive IP information for the /ip endpoint
// IPInfo is what the IP lookup knows about the route of an address and that
// route's origin AS. A field is nil, null in JSON, when its value is not known.
// Every field is nil for an address with no route.
//
//nolint:tagliatelle // field names are the existing JSON API
type IPInfo struct {
IP string `json:"ip"`
Netblock string `json:"netblock"`
MaskLength int `json:"mask_length"`
IPVersion int `json:"ip_version"`
NumPeers int `json:"num_peers"`
// AS information
ASN int `json:"asn"`
ASName string `json:"as_name,omitempty"`
Handle string `json:"handle,omitempty"`
Description string `json:"description,omitempty"`
OrgName string `json:"org_name,omitempty"`
OrgID string `json:"org_id,omitempty"`
Address string `json:"address,omitempty"`
CountryCode string `json:"country_code,omitempty"`
AbuseEmail string `json:"abuse_email,omitempty"`
RIR string `json:"rir,omitempty"`
// Timestamps. LastSeen is zero, and left out of JSON, when the prefix
// keeps no route.
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen,omitzero"`
// Netblock is the most specific prefix that holds the address.
Netblock *string `json:"netblock"`
MaskLength *int `json:"mask_length"`
IPVersion *int `json:"ip_version"`
NumPeers *int `json:"num_peers"`
// ASN is the origin AS that the most peers carry the netblock with.
ASN *int `json:"asn"`
// NumOrigins and OriginASNs are how many origin ASes peers carry the
// netblock with, and which, in AS number order.
NumOrigins *int `json:"num_origins"`
OriginASNs []int `json:"origin_asns"`
// The details the database holds about the AS in ASN.
ASName *string `json:"as_name"`
Handle *string `json:"handle"`
Description *string `json:"description"`
OrgName *string `json:"org_name"`
OrgID *string `json:"org_id"`
Address *string `json:"address"`
CountryCode *string `json:"country_code"`
AbuseEmail *string `json:"abuse_email"`
RIR *string `json:"rir"`
// FirstSeen is when RouteWatch first saw the netblock. LastSeen is the
// time of the netblock's newest kept route.
FirstSeen *time.Time `json:"first_seen"`
LastSeen *time.Time `json:"last_seen"`
// Indicates if WHOIS data needs refresh (not serialized)
NeedsWHOISRefresh bool `json:"-"`
}
+30 -7
View File
@@ -8,8 +8,8 @@ import (
"github.com/go-chi/chi/v5/middleware"
)
// The error messages the JSON routes under /api/v1 and /ip answer with when no
// route matches the path, and when none takes the method.
// The error messages the JSON routes answer with when no route matches the
// path, and when the route does not take the method.
const (
notFoundMessage = "Not found"
methodNotAllowedMessage = "Method not allowed"
@@ -19,6 +19,13 @@ const (
jsonRoutesAllow = "GET, HEAD"
)
// The paths of the status figures and the health check, the two JSON routes
// outside /api/v1 and /ip.
const (
statusJSONPath = "/status.json"
healthCheckPath = "/.well-known/healthcheck.json"
)
// setupRoutes configures the HTTP routes
func (s *Server) setupRoutes() {
r := chi.NewRouter()
@@ -32,6 +39,8 @@ func (s *Server) setupRoutes() {
r.Use(middleware.RequestID)
r.Use(RealIPMiddleware(s.trustedProxies))
r.Use(RequestLoggerMiddleware(s.logger.Logger)) // Structured request logging
// Recoverer answers a panic on a page. JSONValidationMiddleware recovers a
// panic on a JSON route itself, so that the answer is JSON with @meta.
r.Use(middleware.Recoverer)
r.Use(JSONResponseMiddleware)
// Timeout must come after JSONResponseMiddleware, which sends its answer
@@ -43,11 +52,6 @@ func (s *Server) setupRoutes() {
// Routes
r.Get("/", s.handleIndex())
r.Get("/status", s.handleStatusHTML())
r.Get("/status.json", JSONValidationMiddleware(s.handleStatusJSON()).ServeHTTP)
r.Get(
"/.well-known/healthcheck.json",
JSONValidationMiddleware(s.handleHealthCheck()).ServeHTTP,
)
// AS and prefix detail pages
r.Get("/as/{asn}", s.handleASDetail())
@@ -55,6 +59,10 @@ func (s *Server) setupRoutes() {
r.Get("/prefixlength/{length}", s.handlePrefixLength())
r.Get("/prefixlength6/{length}", s.handlePrefixLength6())
// The pages that list what two of the home page's cards count
r.Get("/bogons", s.handleBogons())
r.Get("/multiple-origins", s.handleMultipleOrigins())
// The JSON routes answer a path or method they do not take with the JSON
// error body, as they answer everything else.
jsonNotFound := func(w http.ResponseWriter, _ *http.Request) {
@@ -66,6 +74,21 @@ func (s *Server) setupRoutes() {
s.writeJSONError(w, http.StatusMethodNotAllowed, methodNotAllowedMessage)
}
// The status figures and the health check. chi sets one 405 answer for a
// whole router. These two routes are on the pages' router, whose 405 stays
// chi's own. So each method they do not take is routed to the JSON 405
// here.
r.Get(statusJSONPath, JSONValidationMiddleware(s.handleStatusJSON()).ServeHTTP)
r.Get(healthCheckPath, JSONValidationMiddleware(s.handleHealthCheck()).ServeHTTP)
for _, method := range []string{
http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete,
http.MethodOptions, http.MethodTrace, http.MethodConnect,
} {
r.MethodFunc(method, statusJSONPath, jsonMethodNotAllowed)
r.MethodFunc(method, healthCheckPath, jsonMethodNotAllowed)
}
// IP info JSON endpoints (replaces old /ip redirect)
r.Route("/ip", func(r chi.Router) {
r.Use(JSONValidationMiddleware)
+13 -2
View File
@@ -17,7 +17,7 @@ func TestRoutesAnswerHEAD(t *testing.T) {
s := newServerWithTestRoutes(t)
for _, path := range []string{"/", statsPath, "/ip", "/ip/"} {
for _, path := range []string{"/", statusJSONPath, statsPath, "/ip", "/ip/"} {
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodHead, path, nil))
@@ -31,10 +31,13 @@ func TestRoutesAnswerHEAD(t *testing.T) {
// TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors sends a method the JSON
// routes do not take, and a path none of them matches, to each group of JSON
// routes through the server's router. Each must answer with the JSON error
// routes through the server's router. It also sends a method they do not take
// to /status.json and the health check. Each must answer with the JSON error
// body, and a 405 must name the methods the routes take in its Allow header.
// Before the fix a 405 said "Internal server error" and a 404 was chi's plain
// text answer (https://git.eeqj.de/sneak/routewatch/issues/111).
// /status.json and the health check gave chi's empty 405
// (https://git.eeqj.de/sneak/routewatch/issues/201).
func TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors(t *testing.T) {
t.Parallel()
@@ -48,6 +51,14 @@ func TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors(t *testing.T) {
}{
{http.MethodPost, statsPath, http.StatusMethodNotAllowed, methodNotAllowedMessage},
{http.MethodPost, "/ip/", http.StatusMethodNotAllowed, methodNotAllowedMessage},
{
http.MethodDelete, statusJSONPath,
http.StatusMethodNotAllowed, methodNotAllowedMessage,
},
{
http.MethodOptions, healthCheckPath,
http.StatusMethodNotAllowed, methodNotAllowedMessage,
},
{http.MethodGet, "/api/v1/nothing", http.StatusNotFound, notFoundMessage},
{http.MethodGet, "/ip/192.0.2.1/nothing", http.StatusNotFound, notFoundMessage},
}
+13 -1
View File
@@ -1,5 +1,7 @@
// Package server serves RouteWatch's web pages and JSON API:
// - the home page, the status figures and the health check
// - the pages that list the bogon announcements and the prefixes announced
// by more than one origin AS
// - the AS, prefix and prefix length pages, and the JSON answers behind them
// - the IP lookups
// - the AS name search
@@ -17,6 +19,7 @@ import (
"net/http"
"net/netip"
"strconv"
"sync/atomic"
"time"
"git.eeqj.de/sneak/routewatch/internal/blocklist"
@@ -75,6 +78,10 @@ type Server struct {
// resolver makes the IP lookup's DNS lookups: net.DefaultResolver, or a
// fake in tests
resolver dnsResolver
// cards is the copy of the home page's cards that the pages serve, nil
// until the server first works them out
cards atomic.Pointer[homeCards]
}
// New creates a new HTTP server that reads routes from table, the routing
@@ -103,7 +110,8 @@ func New(
// Start listens on the configured port and serves HTTP in the background.
// It returns an error if it cannot listen, for example because the port is
// already in use. ctx bounds only opening the port, not serving.
// already in use. ctx bounds opening the port, not serving. Once listening,
// the server works out the home page's cards every minute until ctx ends.
func (s *Server) Start(ctx context.Context) error {
const (
network = "tcp"
@@ -140,6 +148,10 @@ func (s *Server) Start(ctx context.Context) error {
}
}()
go func() {
s.keepHomeCardsFresh(ctx, homeCardsRefreshInterval)
}()
return nil
}
+170
View File
@@ -0,0 +1,170 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bogon Announcements - RouteWatch</title>
<style nonce="{{.CSPNonce}}">
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
background: #f5f5f5;
}
/* Navbar styles */
.navbar {
background: #2c3e50;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar-brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
}
.navbar-brand a {
color: white;
text-decoration: none;
}
.navbar-brand a:hover {
color: #ecf0f1;
}
.navbar-brand .by {
font-weight: normal;
color: #95a5a6;
font-size: 14px;
}
.navbar-brand .author {
color: #3498db;
font-weight: normal;
}
.navbar-brand .author:hover {
text-decoration: underline;
}
.navbar-links {
display: flex;
gap: 20px;
}
.navbar-links a {
color: #ecf0f1;
text-decoration: none;
font-size: 14px;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.2s;
}
.navbar-links a:hover {
background: rgba(255,255,255,0.1);
}
/* Main content */
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
color: #333;
margin-bottom: 10px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
}
table {
width: 100%;
border-collapse: collapse;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-radius: 8px;
overflow: hidden;
}
th {
background: #f8f9fa;
padding: 12px;
text-align: left;
font-weight: 600;
color: #333;
border-bottom: 2px solid #dee2e6;
}
td {
padding: 12px;
border-bottom: 1px solid #eee;
}
tr:last-child td {
border-bottom: none;
}
tr:hover {
background: #f8f9fa;
}
a {
color: #0066cc;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.prefix-link {
font-family: 'SF Mono', Monaco, 'Cascadia Mono', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
</style>
</head>
<body>
<nav class="navbar">
<div class="navbar-brand">
<a href="/">routewatch</a>
<span class="by">by</span>
<a href="https://sneak.berlin" class="author">@sneak</a>
</div>
<div class="navbar-links">
<a href="/">Home</a>
<a href="/status">Status</a>
</div>
</nav>
<main class="main-content">
<h1>Bogon Announcements</h1>
{{ with .Cards }}
<p class="subtitle">
Bogon announcements: {{ .BogonCount }}.
A bogon announcement is a route that announces a reserved prefix, or that comes from a reserved AS number.
{{ if lt (len .Bogons) .BogonCount }}The first {{ len .Bogons }} are listed, in prefix order.{{ end }}
The list is worked out once a minute.
</p>
<table>
<thead>
<tr>
<th>Prefix</th>
<th>Origin AS</th>
<th>Peers</th>
<th>Why it is a bogon</th>
</tr>
</thead>
<tbody>
{{ range .Bogons }}
<tr>
<td><a href="{{ prefixURL .Prefix.String }}" class="prefix-link">{{ .Prefix }}</a></td>
<td>{{ if .OriginASN }}<a href="/as/{{ .OriginASN }}">AS{{ .OriginASN }}</a>{{ else }}No known origin{{ end }}</td>
<td>{{ .Peers }}</td>
<td>{{ range $i, $reason := .Reasons }}{{ if $i }}; {{ end }}{{ $reason }}{{ end }}</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<p class="subtitle">The list is being worked out. Reload the page in a minute.</p>
{{ end }}
</main>
</body>
</html>
+154 -11
View File
@@ -276,6 +276,52 @@
margin-top: 12px;
}
/* Cards with figures of the routing table, worked out once a minute */
#table-cards p,
#well-known p {
margin: 0 0 8px 0;
font-size: 14px;
}
#table-cards a,
#well-known a {
color: #3498db;
text-decoration: none;
}
#table-cards a:hover,
#well-known a:hover {
text-decoration: underline;
}
#table-cards .card-figure {
font-size: 32px;
font-weight: bold;
}
#table-cards .card-detail,
#table-cards .card-time {
color: #7f8c8d;
font-size: 12px;
}
#table-cards .prefix {
font-family: 'SF Mono', Monaco, 'Cascadia Mono', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
#well-known {
margin-bottom: 40px;
}
#well-known table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
#well-known th,
#well-known td {
text-align: left;
padding: 8px 4px;
border-bottom: 1px solid #eee;
}
#well-known th {
color: #7f8c8d;
font-weight: 600;
}
/* Footer */
.footer {
margin-top: 40px;
@@ -381,6 +427,109 @@
</div>
</div>
{{ with .Cards }}
<div class="search-section" id="table-cards">
<div class="search-card">
<h2>Newest Updated Route</h2>
{{ with .NewestRoute }}
<p><a href="{{ prefixURL .Prefix.String }}" class="prefix">{{ .Prefix }}</a> from {{ if .OriginASN }}<a href="/as/{{ .OriginASN }}">AS{{ .OriginASN }}</a>{{ else }}no known origin{{ end }}</p>
<p class="card-time">{{ .Time.UTC.Format "2006-01-02 15:04:05" }} UTC, {{ timeSince .Time }}</p>
{{ else }}
<p>The routing table keeps no route yet.</p>
{{ end }}
</div>
<div class="search-card">
<h2>Oldest Updated Route</h2>
{{ with .OldestRoute }}
<p><a href="{{ prefixURL .Prefix.String }}" class="prefix">{{ .Prefix }}</a> from {{ if .OriginASN }}<a href="/as/{{ .OriginASN }}">AS{{ .OriginASN }}</a>{{ else }}no known origin{{ end }}</p>
<p class="card-time">{{ .Time.UTC.Format "2006-01-02 15:04:05" }} UTC, {{ timeSince .Time }}</p>
{{ else }}
<p>The routing table keeps no route yet.</p>
{{ end }}
</div>
<div class="search-card">
<h2>Newest Updated AS</h2>
{{ with .NewestAS }}
<p><a href="/as/{{ .ASN }}">AS{{ .ASN }}</a> {{ .Handle }}</p>
<p class="card-detail">{{ .Description }}</p>
<p class="card-time">Last seen {{ .LastSeen.UTC.Format "2006-01-02 15:04:05" }} UTC, {{ timeSince .LastSeen }}</p>
{{ else }}
<p>No AS has been seen yet.</p>
{{ end }}
</div>
<div class="search-card">
<h2>Oldest Updated AS</h2>
{{ with .OldestAS }}
<p><a href="/as/{{ .ASN }}">AS{{ .ASN }}</a> {{ .Handle }}</p>
<p class="card-detail">{{ .Description }}</p>
<p class="card-time">Last seen {{ .LastSeen.UTC.Format "2006-01-02 15:04:05" }} UTC, {{ timeSince .LastSeen }}</p>
{{ else }}
<p>No AS has been seen yet.</p>
{{ end }}
</div>
<div class="search-card">
<h2>AS with the Most Prefixes</h2>
{{ if .MostPrefixes }}
<p><a href="/as/{{ .MostPrefixesASN }}">AS{{ .MostPrefixesASN }}</a></p>
<p>Prefixes it originates: {{ .MostPrefixes }}</p>
{{ else }}
<p>No AS originates a prefix yet.</p>
{{ end }}
</div>
<div class="search-card">
<h2>ASes with the Fewest Prefixes</h2>
<p>ASes that originate a single prefix: {{ .SinglePrefixASes }}</p>
{{ with .NewestSinglePrefix }}
<p>The most recently updated of them: <a href="/as/{{ .OriginASN }}">AS{{ .OriginASN }}</a>, with <a href="{{ prefixURL .Prefix.String }}" class="prefix">{{ .Prefix }}</a></p>
<p class="card-time">{{ .Time.UTC.Format "2006-01-02 15:04:05" }} UTC, {{ timeSince .Time }}</p>
{{ end }}
</div>
<div class="search-card">
<h2>Bogon Announcements</h2>
<p class="card-figure"><a href="/bogons">{{ .BogonCount }}</a></p>
<p>Routes that announce a reserved prefix, or that come from a reserved AS number</p>
</div>
<div class="search-card">
<h2>Prefixes with Several Origins</h2>
<p class="card-figure"><a href="/multiple-origins">{{ .MultipleOriginCount }}</a></p>
<p>Prefixes announced by more than one origin AS</p>
</div>
</div>
<div class="search-card" id="well-known">
<h2>Well-Known Networks</h2>
<table>
<thead>
<tr>
<th>Network</th>
<th>Prefixes</th>
<th>Last Update</th>
</tr>
</thead>
<tbody>
{{ range .WellKnownNetworks }}
<tr>
<td><a href="/as/{{ .ASN }}">AS{{ .ASN }}</a> {{ .Name }}</td>
<td>{{ .Prefixes }}</td>
<td>{{ with .Newest }}{{ timeSince . }}{{ else }}no route{{ end }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ else }}
<div class="search-card" id="well-known">
<p>The figures of the routing table are being worked out. Reload the page in a minute.</p>
</div>
{{ end }}
<div class="search-card" id="ip-api">
<h2>IP Information API</h2>
<p>The API answers what RouteWatch knows about an IP address, such as its AS number and country. <code>/ip/</code> with no address answers for the caller's own address.</p>
@@ -565,7 +714,8 @@
});
});
// IP lookup
// IP lookup: show the whole JSON answer, its @meta and an error answer
// included, as the IP information API card does.
document.getElementById('ip-form').addEventListener('submit', function(e) {
e.preventDefault();
const ip = document.getElementById('ip-input').value.trim();
@@ -581,18 +731,11 @@
fetch('/ip/' + encodeURIComponent(ip))
.then(response => response.json())
.then(response => {
contentEl.classList.remove('loading');
if (response.status === 'error') {
contentEl.className = 'error';
contentEl.textContent = 'Error: ' + response.error.msg;
} else {
contentEl.className = '';
contentEl.textContent = JSON.stringify(response.data, null, 2);
}
.then(answer => {
contentEl.className = '';
contentEl.textContent = JSON.stringify(answer, null, 2);
})
.catch(error => {
contentEl.classList.remove('loading');
contentEl.className = 'error';
contentEl.textContent = 'Error: ' + error.message;
});
+182
View File
@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prefixes with Several Origins - RouteWatch</title>
<style nonce="{{.CSPNonce}}">
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
background: #f5f5f5;
}
/* Navbar styles */
.navbar {
background: #2c3e50;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar-brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
}
.navbar-brand a {
color: white;
text-decoration: none;
}
.navbar-brand a:hover {
color: #ecf0f1;
}
.navbar-brand .by {
font-weight: normal;
color: #95a5a6;
font-size: 14px;
}
.navbar-brand .author {
color: #3498db;
font-weight: normal;
}
.navbar-brand .author:hover {
text-decoration: underline;
}
.navbar-links {
display: flex;
gap: 20px;
}
.navbar-links a {
color: #ecf0f1;
text-decoration: none;
font-size: 14px;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.2s;
}
.navbar-links a:hover {
background: rgba(255,255,255,0.1);
}
/* Main content */
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
color: #333;
margin-bottom: 10px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
}
table {
width: 100%;
border-collapse: collapse;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-radius: 8px;
overflow: hidden;
}
th {
background: #f8f9fa;
padding: 12px;
text-align: left;
font-weight: 600;
color: #333;
border-bottom: 2px solid #dee2e6;
}
td {
padding: 12px;
border-bottom: 1px solid #eee;
vertical-align: top;
}
tr:last-child td {
border-bottom: none;
}
tr:hover {
background: #f8f9fa;
}
a {
color: #0066cc;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.prefix-link {
font-family: 'SF Mono', Monaco, 'Cascadia Mono', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
.origins {
list-style: none;
margin: 0;
padding: 0;
}
.peers {
color: #666;
font-size: 0.9em;
}
</style>
</head>
<body>
<nav class="navbar">
<div class="navbar-brand">
<a href="/">routewatch</a>
<span class="by">by</span>
<a href="https://sneak.berlin" class="author">@sneak</a>
</div>
<div class="navbar-links">
<a href="/">Home</a>
<a href="/status">Status</a>
</div>
</nav>
<main class="main-content">
<h1>Prefixes with Several Origin ASes</h1>
{{ with .Cards }}
<p class="subtitle">
Prefixes announced by more than one origin AS: {{ .MultipleOriginCount }}.
Such a prefix can be a hijack.
{{ if lt (len .MultipleOrigins) .MultipleOriginCount }}The first {{ len .MultipleOrigins }} are listed, in prefix order.{{ end }}
The list is worked out once a minute.
</p>
<table>
<thead>
<tr>
<th>Prefix</th>
<th>Origin ASes</th>
</tr>
</thead>
<tbody>
{{ range .MultipleOrigins }}
<tr>
<td><a href="{{ prefixURL .Prefix.String }}" class="prefix-link">{{ .Prefix }}</a></td>
<td>
<ul class="origins">
{{ range .Origins }}
<li>{{ if .ASN }}<a href="/as/{{ .ASN }}">AS{{ .ASN }}</a>{{ else }}No known origin{{ end }} <span class="peers">{{ .Peers }} {{ if eq .Peers 1 }}peer{{ else }}peers{{ end }}</span></li>
{{ end }}
</ul>
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<p class="subtitle">The list is being worked out. Reload the page in a minute.</p>
{{ end }}
</main>
</body>
</html>
+20 -5
View File
@@ -401,6 +401,8 @@
const REFRESH_PERIOD_MS = 2000;
const STALE_AFTER_REFRESHES = 3;
const MS_PER_SECOND = 1000;
const SECONDS_PER_MINUTE = 60;
const SECONDS_PER_HOUR = 3600;
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const SPINNER_COLOURS = ['#e74c3c', '#e67e22', '#f1c40f', '#27ae60', '#3498db', '#9b59b6'];
@@ -443,7 +445,18 @@
function formatNumber(num) {
return num.toLocaleString();
}
// formatDuration writes a number of seconds in hours, minutes and
// seconds, such as "1h 18m 34s" or "18.2s", with at most one decimal.
function formatDuration(totalSeconds) {
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
const minutes = Math.floor(totalSeconds % SECONDS_PER_HOUR / SECONDS_PER_MINUTE);
const seconds = parseFloat((totalSeconds % SECONDS_PER_MINUTE).toFixed(1));
if (hours > 0) return hours + 'h ' + minutes + 'm ' + seconds + 's';
if (minutes > 0) return minutes + 'm ' + seconds + 's';
return seconds + 's';
}
function formatProcessingTime(ms) {
if (ms < 0.001) {
return (ms * 1000).toFixed(0) + ' µs';
@@ -619,11 +632,13 @@
noteMessageCount(data.total_messages);
// Update all metrics
document.getElementById('uptime').textContent = data.uptime;
document.getElementById('uptime').textContent = formatDuration(data.uptime_seconds);
document.getElementById('go_version').textContent = data.go_version;
document.getElementById('goroutines').textContent = formatNumber(data.goroutines);
document.getElementById('memory_usage').textContent = data.memory_usage;
document.getElementById('connection_duration').textContent = data.connection_duration;
document.getElementById('memory_usage').textContent = formatBytes(data.memory_usage_bytes);
// connection_duration_seconds is null while the stream is not connected.
document.getElementById('connection_duration').textContent =
data.connection_duration_seconds === null ? 'disconnected' : formatDuration(data.connection_duration_seconds);
document.getElementById('reconnect_count').textContent = formatNumber(data.reconnect_count);
document.getElementById('total_messages').textContent = formatNumber(data.total_messages);
document.getElementById('messages_per_sec').textContent = formatNumber(Math.round(data.messages_per_sec));
@@ -672,7 +687,7 @@
const errorsEl = document.getElementById('whois_errors');
errorsEl.textContent = formatNumber(data.whois_stats.errors_last_hour);
errorsEl.className = 'metric-value' + (data.whois_stats.errors_last_hour > 0 ? ' disconnected' : '');
document.getElementById('whois_interval').textContent = data.whois_stats.current_interval;
document.getElementById('whois_interval').textContent = formatDuration(data.whois_stats.current_interval_seconds);
}
// Update handler stats
+34
View File
@@ -27,6 +27,12 @@ var prefixDetailHTML string
//go:embed prefix_length.html
var prefixLengthHTML string
//go:embed bogons.html
var bogonsHTML string
//go:embed multiple_origins.html
var multipleOriginsHTML string
// Templates contains all parsed templates
type Templates struct {
// Index is the template for the home page
@@ -39,6 +45,11 @@ type Templates struct {
PrefixDetail *template.Template
// PrefixLength is the template for displaying prefixes by length
PrefixLength *template.Template
// Bogons is the template of the page that lists the bogon announcements
Bogons *template.Template
// MultipleOrigins is the template of the page that lists the prefixes
// announced by more than one origin AS
MultipleOrigins *template.Template
}
var (
@@ -116,6 +127,18 @@ func initTemplates() {
if err != nil {
panic("failed to parse prefix length template: " + err.Error())
}
defaultTemplates.Bogons, err = template.New("bogons").
Funcs(funcs).Parse(bogonsHTML)
if err != nil {
panic("failed to parse bogons template: " + err.Error())
}
defaultTemplates.MultipleOrigins, err = template.New("multipleOrigins").
Funcs(funcs).Parse(multipleOriginsHTML)
if err != nil {
panic("failed to parse multiple origins template: " + err.Error())
}
}
// Get returns the singleton Templates instance
@@ -149,3 +172,14 @@ func PrefixDetailTemplate() *template.Template {
func PrefixLengthTemplate() *template.Template {
return Get().PrefixLength
}
// BogonsTemplate returns the parsed template of the bogon announcements page
func BogonsTemplate() *template.Template {
return Get().Bogons
}
// MultipleOriginsTemplate returns the parsed template of the page of prefixes
// announced by more than one origin AS
func MultipleOriginsTemplate() *template.Template {
return Get().MultipleOrigins
}
+6 -2
View File
@@ -12,13 +12,17 @@ import (
)
// renderPage renders a page with the data every page needs, and the visitor's
// address, which the home page also needs.
// address and the routing table's cards, which the home page also needs. The
// cards are left out, as before the server first works them out.
func renderPage(t *testing.T, tmpl *template.Template) string {
t.Helper()
var page bytes.Buffer
err := tmpl.Execute(&page, struct{ CSPNonce, ClientIP string }{})
err := tmpl.Execute(&page, struct {
CSPNonce, ClientIP string
Cards any
}{})
if err != nil {
t.Fatalf("rendering the page: %v", err)
}