Compare commits
4
Commits
bfa4da3dba
..
prod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f03248095 | ||
|
|
1c137c75a7 | ||
|
|
4c0ef132e7 | ||
|
|
2152395f36 |
@@ -1,20 +0,0 @@
|
||||
---
|
||||
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 }
|
||||
```
|
||||
@@ -70,56 +70,12 @@ one of its peers sends an update.
|
||||
- `GET /` - Home page (HTML): headline figures, lookups by AS number and by IP
|
||||
address, and a search of AS names. A card shows the `curl` command that looks
|
||||
up the visitor's own address at `/ip/{addr}`, and that command's live answer.
|
||||
Other cards show figures of the routing table, with links to the AS and prefix
|
||||
pages:
|
||||
- the newest and the oldest kept route, with its prefix, origin AS and time
|
||||
- the AS last seen in an AS path most recently, and the one last seen
|
||||
longest ago
|
||||
- the AS that originates the most prefixes
|
||||
- how many ASes originate a single prefix, and the most recently updated of
|
||||
them
|
||||
- the number of bogon announcements, linked to `/bogons`
|
||||
- the number of prefixes with more than one origin AS, linked to
|
||||
`/multiple-origins`
|
||||
- well-known networks, such as Apple, Google, Cogent and MIT, each with its
|
||||
number of prefixes and the time of its newest kept route, newest first
|
||||
|
||||
RouteWatch works these figures out once a minute, so they can be a minute
|
||||
old.
|
||||
|
||||
- `GET /bogons` - The bogon announcements (HTML): the routes whose prefix or
|
||||
origin AS is reserved. A prefix is reserved when it lies inside one of these:
|
||||
- a block that the IANA special-purpose address registries mark as not
|
||||
globally reachable
|
||||
- the multicast space, `224.0.0.0/4` and `ff00::/8`, which the IANA IPv4 and
|
||||
IPv6 address space registries list as Multicast
|
||||
|
||||
A reserved AS number is one of these:
|
||||
- an AS number for private use or for documentation
|
||||
- `AS23456`
|
||||
- the last AS number of the 16-bit or the 32-bit range
|
||||
|
||||
Address space and AS numbers that the IANA has not handed out yet are not
|
||||
counted. Each row shows the prefix, the origin AS, the number of peers and
|
||||
why it is a bogon. The page shows at most 1,000 rows, in prefix order, and
|
||||
says how many there are in all.
|
||||
|
||||
- `GET /multiple-origins` - The prefixes announced by more than one origin AS
|
||||
(HTML). Each row shows the origin ASes and how many peers carry the prefix
|
||||
with each. Like `/bogons`, it shows at most 1,000 rows, in prefix order, and
|
||||
says how many there are in all.
|
||||
- `GET /status` - HTML status dashboard, which reads `/api/v1/stats`
|
||||
- `GET /status.json` - JSON statistics: feed, memory, database and route
|
||||
figures. The route figures come from the routing table. `oldest_route` and
|
||||
`newest_route` are the times of the oldest and the newest kept routes.
|
||||
`origins_without_kept_route` counts, across all prefixes, the origin ASes that
|
||||
have no kept route. Other figures include these:
|
||||
- `uptime_seconds`: the number of seconds since the RIS Live feed last
|
||||
connected, or 0 when it has never connected
|
||||
- `memory_usage_bytes`: the size of the Go heap in use, in bytes
|
||||
- `whois_stats.current_interval_seconds`: the WHOIS fetcher's wait between
|
||||
two fetches, in seconds, which can include a fraction of a second
|
||||
|
||||
have no kept route.
|
||||
- `GET /.well-known/healthcheck.json` - Health check (JSON): 200 when the
|
||||
database answers and the RIS Live feed is connected, otherwise 503;
|
||||
`data.status` is `ok` or `error` and `data.checks` says which check failed
|
||||
@@ -142,45 +98,20 @@ one of its peers sends an update.
|
||||
### API v1
|
||||
|
||||
- `GET /api/v1/stats` - The `/status.json` figures plus connection, garbage
|
||||
collector, stream and handler queue statistics. `connection_duration_seconds`
|
||||
is the number of seconds the RIS Live feed has been connected, or null while
|
||||
it is not connected. `stream.route_churn_per_min` is the number of prefix
|
||||
announcements and withdrawals, from all peers, in the last full clock minute.
|
||||
collector, stream and handler queue statistics. `stream.route_churn_per_min`
|
||||
is the number of prefix announcements and withdrawals, from all peers, in the
|
||||
last full clock minute.
|
||||
- `GET /api/v1/ip/{ip}` - Looks up each address. `{ip}` is an IP address, a host
|
||||
name, or a comma-separated list of at most 10 of these. `results` has one
|
||||
entry for each address. Every entry has the same fields:
|
||||
- `ip`: the address
|
||||
- `ptr`: the address's PTR names, or an empty list when it has none
|
||||
- `blocklists`: the public blocklists the address is on (see Blocklists), or
|
||||
an empty list when it is on none
|
||||
|
||||
Every other field is about the address's live route. Each is null when the
|
||||
address has no live route. The fields about the route:
|
||||
- `netblock`: the most specific prefix that holds the address
|
||||
- `mask_length`: the netblock's mask length
|
||||
- `ip_version`: the netblock's IP version, 4 or 6
|
||||
- `num_peers`: the number of peers that carry the netblock
|
||||
- `asn`: the origin AS that the most peers carry the netblock with
|
||||
- `num_origins`: the number of origin ASes that peers carry the netblock
|
||||
with
|
||||
- `origin_asns`: those origin ASes, in AS number order
|
||||
- `first_seen`: when RouteWatch first saw the netblock, or null when the
|
||||
database has not stored the netblock yet
|
||||
- `last_seen`: the time of the netblock's newest kept route, or null when
|
||||
the netblock keeps no route
|
||||
|
||||
The fields about the AS in `asn` come from the database. Each is also null
|
||||
when the database does not have it:
|
||||
- `handle`: the AS's handle
|
||||
- `description`: the AS's description
|
||||
- `as_name`: the AS's name in its WHOIS details
|
||||
- `org_name`: the name of the organisation that holds the AS
|
||||
- `org_id`: the organisation's ID at its registry
|
||||
- `address`: the organisation's postal address
|
||||
- `country_code`: the organisation's country code
|
||||
- `abuse_email`: the address that takes abuse reports for the AS
|
||||
- `rir`: the regional internet registry of the AS
|
||||
entry for each address, with:
|
||||
- `ip` and `ptr`: the address and its PTR names
|
||||
- `netblock` and the fields beside it: the most specific prefix that holds
|
||||
the address, and its origin AS with that AS's WHOIS details. For a prefix
|
||||
with several origin ASes, `asn` is the one that most peers carry.
|
||||
`last_seen` is the time of the prefix's newest kept route.
|
||||
- `blocklists`: the public blocklists the address is on (see Blocklists)
|
||||
|
||||
An address with no live route has a result without the route and AS fields.
|
||||
Of the addresses a host name resolves to, only public ones are looked up. A
|
||||
name with none, such as one that resolves to a private address, is reported
|
||||
as `host name has no public address`, without its addresses. `errors` has
|
||||
@@ -214,16 +145,10 @@ one of its peers sends an update.
|
||||
missing, empty or only spaces answers 400.
|
||||
|
||||
A JSON answer is `{"status": "ok", "data": ...}`, or for an error
|
||||
`{"status": "error", "error": {"msg": ..., "code": ...}}`. The health check is
|
||||
the exception: its 503 answer is also `{"status": "ok", "data": ...}`, with
|
||||
`error` in `data.status`. Every JSON answer also carries an `@meta` object:
|
||||
|
||||
- `time_zone`: always `UTC`
|
||||
- `api_version`: `1`
|
||||
- `execution_time_ms`: the number of milliseconds the request took
|
||||
|
||||
Every value with a unit is a number whose field name ends in the unit, such as
|
||||
`_ms`, `_seconds` or `_bytes`.
|
||||
`{"status": "error", "error": {"msg": ..., "code": ...}}`, and carries an
|
||||
`@meta` object with the time zone, the API version and the time the request
|
||||
took. The health check is the exception: its 503 answer is also
|
||||
`{"status": "ok", "data": ...}`, with `error` in `data.status`.
|
||||
|
||||
A request that runs past its time limit answers 504: the limit is 30 seconds,
|
||||
and 4 seconds for the database reads behind `/status.json` and `/api/v1/stats`.
|
||||
@@ -248,15 +173,10 @@ so each answer's policy names a random nonce made for that answer, and the
|
||||
browser applies or runs only the blocks that carry it. The pages therefore use
|
||||
no `style` attributes, which a nonce cannot cover.
|
||||
|
||||
An origin AS of 0 means the route has no known origin. Its AS path ends in an AS
|
||||
set of several members, any of which may have originated it. The 0 appears in
|
||||
these fields:
|
||||
|
||||
- `origin_asn` in a route
|
||||
- `asn` and `origin_asns` in an IP lookup
|
||||
- the key `0` in a prefix's `origins`
|
||||
|
||||
The pages show such a route as having no known origin.
|
||||
An origin AS of 0 (`origin_asn` in a route, `asn` in an IP lookup, the key `0`
|
||||
in a prefix's `origins`) means the route has no known origin: its AS path ends
|
||||
in an AS set of several members, any of which may have originated it. The pages
|
||||
show such a route as having no known origin.
|
||||
|
||||
## Blocklists
|
||||
|
||||
@@ -328,7 +248,6 @@ routewatch/
|
||||
│ └── streamdumper/ # Debug utility for raw stream output
|
||||
├── internal/
|
||||
│ ├── blocklist/ # Public IP blocklists: downloads and lookups
|
||||
│ ├── bogon/ # Reserved prefixes and AS numbers
|
||||
│ ├── routewatch/ # Core application logic
|
||||
│ ├── server/ # HTTP server and handlers
|
||||
│ ├── database/ # SQLite storage layer
|
||||
@@ -511,29 +430,19 @@ Configuration is handled via environment variables and OS-specific paths:
|
||||
| `XDG_DATA_HOME` | `/var/lib` (in the Docker image) | Base of the state directory; must be an absolute path |
|
||||
| `GOMEMLIMIT` | `2560MiB` (in the Docker image) | Go soft memory limit; see Memory |
|
||||
| `MALLOC_ARENA_MAX` | `2` (in the Docker image) | glibc malloc arena cap, a positive whole number; see Memory |
|
||||
| `TRUSTED_PROXIES` | the RFC 1918 ranges | Addresses and CIDR ranges of trusted proxies; see below |
|
||||
| `TRUSTED_PROXIES` | (empty) | Addresses and CIDR ranges of trusted proxies; see below |
|
||||
|
||||
A variable that is set to an invalid value stops the start with an error and a
|
||||
non-zero exit. An empty variable counts as unset. `TRUSTED_PROXIES` is the one
|
||||
exception: set but empty, it makes the server trust no proxy.
|
||||
non-zero exit. An empty variable counts as unset.
|
||||
|
||||
`TRUSTED_PROXIES` names the reverse proxies whose `X-Forwarded-For` the server
|
||||
believes:
|
||||
|
||||
- When it is not set, the server trusts the private ranges of RFC 1918:
|
||||
- `10.0.0.0/8`
|
||||
- `172.16.0.0/12`
|
||||
- `192.168.0.0/16`
|
||||
- When it lists addresses and CIDR ranges, comma-separated, such as
|
||||
`10.0.0.0/8,192.0.2.1`, the server trusts those instead.
|
||||
- When it is set but empty (`TRUSTED_PROXIES=`), the server trusts no proxy.
|
||||
|
||||
For a request from a trusted proxy, the request log and `/ip/` use the last
|
||||
address in `X-Forwarded-For` that is not a trusted proxy. Each proxy adds the
|
||||
address it received the request from to the end, so the entries before it are
|
||||
whatever the client sent. For a request from any other address they use the
|
||||
address the request came from, whatever forwarding headers it carries.
|
||||
`X-Real-IP` and `True-Client-IP` are not read.
|
||||
`TRUSTED_PROXIES` lists the reverse proxies whose `X-Forwarded-For` the server
|
||||
believes, comma-separated, such as `10.0.0.0/8,192.0.2.1`. For a request from
|
||||
one of them, the request log and `/ip/` use the last address in
|
||||
`X-Forwarded-For` that is not a trusted proxy: each proxy adds the address it
|
||||
received the request from to the end, so the entries before it are whatever the
|
||||
client sent. For a request from any other address they use the address the
|
||||
request came from, whatever forwarding headers it carries. `X-Real-IP` and
|
||||
`True-Client-IP` are not read.
|
||||
|
||||
State directory (the database and the blocklist copies):
|
||||
|
||||
@@ -627,11 +536,12 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app needs:
|
||||
|
||||
- Container port: `8080`.
|
||||
- Volume: one, at container path `/var/lib/berlin.sneak.app.routewatch`.
|
||||
- Environment: nothing. The server trusts a reverse proxy at an RFC 1918 address
|
||||
without `TRUSTED_PROXIES` (see Configuration). Set it only when the proxy
|
||||
connects from any other address. Leave `XDG_DATA_HOME`, `GOMEMLIMIT` and
|
||||
`MALLOC_ARENA_MAX` at the image's values. `DEBUG=routewatch` is optional and
|
||||
adds the `System stats` memory line to the log.
|
||||
- Environment: `TRUSTED_PROXIES`, set to the address the reverse proxy in front
|
||||
of the app connects from, as the container sees it; without it the request log
|
||||
and `/ip/` give the proxy's address for every request. Nothing else is needed:
|
||||
leave `XDG_DATA_HOME`, `GOMEMLIMIT` and `MALLOC_ARENA_MAX` at the image's
|
||||
values. `DEBUG=routewatch` is optional and adds the `System stats` memory line
|
||||
to the log.
|
||||
- Memory Limit: `5g`, the 5 GiB limit from Memory above. upaas sets no swap
|
||||
limit, so on a host with swap Docker allows the same amount of swap again.
|
||||
- Health check: the image's `HEALTHCHECK` requests
|
||||
|
||||
+22
-92
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-10-27
|
||||
last_modified: 2026-10-18
|
||||
---
|
||||
|
||||
This document covers repository structure, tooling, and workflow standards. Code
|
||||
@@ -12,7 +12,6 @@ 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)
|
||||
|
||||
---
|
||||
|
||||
@@ -175,32 +174,27 @@ 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`, 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.
|
||||
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.
|
||||
|
||||
- **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
|
||||
@@ -315,9 +309,6 @@ 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:
|
||||
@@ -372,67 +363,6 @@ 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
|
||||
|
||||
@@ -35,38 +35,6 @@ tested.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-27: the home page's IP lookup shows the whole JSON answer, with its
|
||||
`@meta`, as the IP information API card does. It used to show only the
|
||||
answer's `data`, or only the message of an error answer. A test checks that
|
||||
every JSON route answers with `@meta`, 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
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
// 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 = ®istryBlocks[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"},
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -30,14 +30,6 @@ 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")
|
||||
@@ -167,21 +159,11 @@ func getPort() (int, error) {
|
||||
return int(port), nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func getTrustedProxies() ([]netip.Prefix, error) {
|
||||
value, set := os.LookupEnv("TRUSTED_PROXIES")
|
||||
if !set {
|
||||
return []netip.Prefix{
|
||||
netip.MustParsePrefix(privateRange10),
|
||||
netip.MustParsePrefix(privateRange172),
|
||||
netip.MustParsePrefix(privateRange192),
|
||||
}, nil
|
||||
}
|
||||
|
||||
value := os.Getenv("TRUSTED_PROXIES")
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package config
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"runtime"
|
||||
"slices"
|
||||
"testing"
|
||||
@@ -47,47 +46,25 @@ func TestNewRefusesInvalidPort(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) {
|
||||
func TestNewReadsTrustedProxies(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)
|
||||
}
|
||||
|
||||
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 len(cfg.TrustedProxies) != 0 {
|
||||
t.Errorf("New() without TRUSTED_PROXIES: TrustedProxies = %v, want none",
|
||||
cfg.TrustedProxies)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -100,18 +77,6 @@ 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) {
|
||||
|
||||
@@ -972,34 +972,6 @@ 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 = `
|
||||
@@ -1407,29 +1379,6 @@ 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,62 +1003,6 @@ 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.
|
||||
|
||||
@@ -62,7 +62,6 @@ 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)
|
||||
|
||||
@@ -83,9 +83,6 @@ 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);
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,6 @@ 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
|
||||
@@ -214,12 +211,10 @@ 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, OriginASNs: make([]int, 0, len(origins))}
|
||||
summary := PrefixSummary{Prefix: prefix}
|
||||
mostPeers := 0
|
||||
|
||||
for _, o := range origins {
|
||||
summary.OriginASNs = append(summary.OriginASNs, o.asn)
|
||||
|
||||
peers := int(o.peers.Count())
|
||||
summary.Peers += peers
|
||||
|
||||
|
||||
@@ -46,22 +46,21 @@ 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
|
||||
originASNs []int
|
||||
peers int
|
||||
newest int // in seconds after the start; 0 for none
|
||||
want string
|
||||
originASN int
|
||||
peers int
|
||||
newest int // in seconds after the start; 0 for none
|
||||
}{
|
||||
{"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},
|
||||
{"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},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
@@ -78,10 +77,9 @@ func TestLookup(t *testing.T) {
|
||||
}
|
||||
|
||||
want := routetable.PrefixSummary{
|
||||
Prefix: netip.MustParsePrefix(tc.want),
|
||||
OriginASN: tc.originASN,
|
||||
OriginASNs: tc.originASNs,
|
||||
Peers: tc.peers,
|
||||
Prefix: netip.MustParsePrefix(tc.want),
|
||||
OriginASN: tc.originASN,
|
||||
Peers: tc.peers,
|
||||
}
|
||||
if tc.newest != 0 {
|
||||
want.Newest = timeAt(tc.newest)
|
||||
@@ -168,8 +166,7 @@ func TestSamplePrefixes(t *testing.T) {
|
||||
|
||||
for _, got := range sample {
|
||||
want := routetable.PrefixSummary{
|
||||
Prefix: got.Prefix, OriginASN: asA, OriginASNs: []int{asA},
|
||||
Peers: 1, Newest: timeAt(10),
|
||||
Prefix: got.Prefix, OriginASN: asA, Peers: 1, Newest: timeAt(10),
|
||||
}
|
||||
if seen[got.Prefix] || got.Prefix.Bits() != tc.maskLength ||
|
||||
!reflect.DeepEqual(got, want) {
|
||||
|
||||
@@ -625,7 +625,6 @@ 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,35 +223,6 @@ 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)
|
||||
@@ -634,15 +605,12 @@ 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, routetable.New(routetable.Params{}), s, log,
|
||||
cfg),
|
||||
Logger: log,
|
||||
Config: cfg,
|
||||
Server: server.New(store, nil, s, log, cfg),
|
||||
Logger: log,
|
||||
Config: cfg,
|
||||
})
|
||||
|
||||
err := rw.server.Start(t.Context())
|
||||
|
||||
+78
-103
@@ -23,6 +23,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -159,7 +160,6 @@ 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,9 +181,6 @@ 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
|
||||
@@ -225,23 +222,18 @@ 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. 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.
|
||||
// and the routing table's figures. 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 {
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
Uptime string `json:"uptime"`
|
||||
TotalMessages uint64 `json:"total_messages"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
TotalWireBytes uint64 `json:"total_wire_bytes"`
|
||||
@@ -251,7 +243,7 @@ type Stats struct {
|
||||
Connected bool `json:"connected"`
|
||||
GoVersion string `json:"go_version"`
|
||||
Goroutines int `json:"goroutines"`
|
||||
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
||||
MemoryUsage string `json:"memory_usage"`
|
||||
ASNs int `json:"asns"`
|
||||
Prefixes int `json:"prefixes"`
|
||||
IPv4Prefixes int `json:"ipv4_prefixes"`
|
||||
@@ -366,7 +358,7 @@ func (s *Server) statusStats(
|
||||
runtime.ReadMemStats(&memStats)
|
||||
|
||||
return Stats{
|
||||
UptimeSeconds: uptimeSeconds(streamMetrics.ConnectedSince),
|
||||
Uptime: streamUptime(streamMetrics.ConnectedSince),
|
||||
TotalMessages: streamMetrics.TotalMessages,
|
||||
TotalBytes: streamMetrics.TotalBytes,
|
||||
TotalWireBytes: streamMetrics.TotalWireBytes,
|
||||
@@ -376,7 +368,7 @@ func (s *Server) statusStats(
|
||||
Connected: streamMetrics.Connected,
|
||||
GoVersion: runtime.Version(),
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
MemoryUsageBytes: memStats.Alloc,
|
||||
MemoryUsage: humanize.Bytes(memStats.Alloc),
|
||||
ASNs: dbStats.ASNs,
|
||||
Prefixes: counts.IPv4Prefixes + counts.IPv6Prefixes,
|
||||
IPv4Prefixes: counts.IPv4Prefixes,
|
||||
@@ -443,31 +435,30 @@ 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(),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
if connectedSince.IsZero() {
|
||||
return 0
|
||||
return "0s"
|
||||
}
|
||||
|
||||
return int64(time.Since(connectedSince) / time.Second)
|
||||
return time.Since(connectedSince).Truncate(time.Second).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 {
|
||||
// connectionDuration is how long the stream has been connected, to the
|
||||
// second, or "disconnected".
|
||||
func connectionDuration(connected bool, connectedSince time.Time) string {
|
||||
if !connected || connectedSince.IsZero() {
|
||||
return nil
|
||||
return "disconnected"
|
||||
}
|
||||
|
||||
return new(uptimeSeconds(connectedSince))
|
||||
return time.Since(connectedSince).Truncate(time.Second).String()
|
||||
}
|
||||
|
||||
// whoisStaleThreshold matches the fetcher's threshold for consistency.
|
||||
@@ -517,7 +508,7 @@ type StreamStats struct {
|
||||
//
|
||||
//nolint:tagliatelle // field names are the existing JSON API
|
||||
type StatsResponse struct {
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
Uptime string `json:"uptime"`
|
||||
TotalMessages uint64 `json:"total_messages"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
TotalWireBytes uint64 `json:"total_wire_bytes"`
|
||||
@@ -525,10 +516,11 @@ 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"`
|
||||
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
||||
MemoryUsage string `json:"memory_usage"`
|
||||
GC GCStats `json:"gc"`
|
||||
Stream StreamStats `json:"stream"`
|
||||
ASNs int `json:"asns"`
|
||||
@@ -550,9 +542,6 @@ 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"`
|
||||
}
|
||||
|
||||
@@ -691,18 +680,20 @@ func (s *Server) statsResponse(
|
||||
runtime.ReadMemStats(&memStats)
|
||||
|
||||
return StatsResponse{
|
||||
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,
|
||||
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),
|
||||
ReconnectCount: streamMetrics.ReconnectCount,
|
||||
GoVersion: runtime.Version(),
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
MemoryUsageBytes: memStats.Alloc,
|
||||
MemoryUsage: humanize.Bytes(memStats.Alloc),
|
||||
GC: gcStats(&memStats),
|
||||
Stream: s.streamStats(),
|
||||
ASNs: dbStats.ASNs,
|
||||
@@ -724,9 +715,6 @@ func (s *Server) statsResponse(
|
||||
IPv6PrefixDistribution: prefixDistribution(counts.IPv6PrefixesByLength),
|
||||
WHOISStats: whoisStats,
|
||||
|
||||
ConnectionDurationSeconds: connectionDurationSeconds(
|
||||
streamMetrics.Connected, streamMetrics.ConnectedSince),
|
||||
|
||||
OriginsWithoutKeptRoute: counts.OriginsWithoutKeptRoute,
|
||||
}
|
||||
}
|
||||
@@ -760,14 +748,15 @@ 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. Every result has every
|
||||
// field. The fields of IPInfo are null when the address has no 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.
|
||||
type IPLookupResult struct {
|
||||
IPInfo
|
||||
*IPInfo
|
||||
|
||||
IP string `json:"ip"`
|
||||
// PTR is empty, never nil, when the address has no PTR names.
|
||||
PTR []string `json:"ptr"`
|
||||
IP string `json:"ip"`
|
||||
PTR []string `json:"ptr,omitempty"`
|
||||
|
||||
// Blocklists has one entry for each blocklist the address is on.
|
||||
Blocklists []blocklist.Listing `json:"blocklists"`
|
||||
@@ -825,8 +814,8 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
|
||||
|
||||
switch {
|
||||
case errors.Is(err, ErrNoRoute):
|
||||
// The address still gets a result, with every field of
|
||||
// ipInfo null.
|
||||
// The address still gets a result, without route fields.
|
||||
ipInfo = nil
|
||||
case errors.Is(err, ErrInvalidIP):
|
||||
response.Errors = append(response.Errors, ip+": "+err.Error())
|
||||
|
||||
@@ -853,8 +842,8 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
|
||||
Blocklists: s.blocklistListings(ip),
|
||||
})
|
||||
|
||||
if ipInfo.NeedsWHOISRefresh {
|
||||
refreshASNs[*ipInfo.ASN] = true
|
||||
if ipInfo != nil && ipInfo.NeedsWHOISRefresh {
|
||||
refreshASNs[ipInfo.ASN] = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,72 +854,60 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return IPInfo{}, fmt.Errorf("%w: %s", ErrInvalidIP, ip)
|
||||
return nil, fmt.Errorf("%w: %s", ErrInvalidIP, ip)
|
||||
}
|
||||
|
||||
summary, found := s.table.Lookup(addr)
|
||||
if !found {
|
||||
return IPInfo{}, fmt.Errorf("%w for IP %s", ErrNoRoute, ip)
|
||||
return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip)
|
||||
}
|
||||
|
||||
firstSeen, err := s.db.GetPrefixFirstSeen(ctx, summary.Prefix)
|
||||
if err != nil {
|
||||
return IPInfo{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
asInfo, err := s.db.GetASWHOIS(ctx, summary.OriginASN)
|
||||
if err != nil {
|
||||
return IPInfo{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
NeedsWHOISRefresh: needsWHOISRefresh(asInfo),
|
||||
}
|
||||
|
||||
if !firstSeen.IsZero() {
|
||||
info.FirstSeen = &firstSeen
|
||||
if summary.Newest != nil {
|
||||
info.LastSeen = *summary.Newest
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -1136,17 +1113,15 @@ func publicAddresses(addrs []string) []string {
|
||||
return public
|
||||
}
|
||||
|
||||
// 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.
|
||||
// lookupPTRNames returns the PTR records of ip without their trailing dots, or
|
||||
// nil when the lookup fails or runs past ptrLookupTimeout.
|
||||
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 || ptrs == nil {
|
||||
return []string{}
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove trailing dots from PTR records
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -73,11 +72,9 @@ 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"
|
||||
healthCheckPath = "/.well-known/healthcheck.json"
|
||||
)
|
||||
|
||||
// The names the stats handler tests report the two stats handlers by.
|
||||
@@ -297,167 +294,6 @@ 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 {
|
||||
@@ -929,7 +765,7 @@ func TestRoutesAnswer504WhenDatabaseReadIsCutOff(t *testing.T) {
|
||||
{path: "/ip/" + addrInTestPrefixV4, wantJSONBody: true},
|
||||
{path: statsPath, wantJSONBody: true},
|
||||
{path: "/api/v1/ip/" + addrInTestPrefixV4, wantJSONBody: true},
|
||||
{path: asJSONPath, wantJSONBody: true},
|
||||
{path: "/api/v1/as/64500", wantJSONBody: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -1110,10 +946,10 @@ func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
|
||||
path string
|
||||
wantCode int
|
||||
}{
|
||||
{healthCheckPath, http.StatusServiceUnavailable},
|
||||
{"/.well-known/healthcheck.json", http.StatusServiceUnavailable},
|
||||
{statusJSONPath, http.StatusInternalServerError},
|
||||
{statsPath, http.StatusInternalServerError},
|
||||
{asJSONPath, http.StatusInternalServerError},
|
||||
{"/api/v1/as/64500", http.StatusInternalServerError},
|
||||
{"/api/v1/ip/" + addrInTestPrefixV4, http.StatusInternalServerError},
|
||||
{"/api/v1/prefix/foo/24", http.StatusBadRequest},
|
||||
}
|
||||
@@ -1383,8 +1219,7 @@ 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 == nil ||
|
||||
*body.Data.Results[0].Netblock != testPrefixV4 {
|
||||
if len(body.Data.Results) != 1 || body.Data.Results[0].Netblock != testPrefixV4 {
|
||||
t.Errorf("answer %s, want one result in %s", rec.Body, testPrefixV4)
|
||||
}
|
||||
}
|
||||
@@ -1476,39 +1311,12 @@ 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
|
||||
// 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).
|
||||
// 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.
|
||||
func TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1516,15 +1324,6 @@ 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,
|
||||
@@ -1555,21 +1354,12 @@ func TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
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 result.IPInfo != nil {
|
||||
t.Errorf("%s: result for %s has route fields %+v",
|
||||
target, listedUnroutedAddr, result.IPInfo)
|
||||
}
|
||||
|
||||
result := body.Data.Results[i]
|
||||
if len(result.Blocklists) != 1 ||
|
||||
result.Blocklists[0].Name != testListing().Name {
|
||||
t.Errorf("%s: blocklists %+v, want [%+v]",
|
||||
@@ -1578,76 +1368,6 @@ 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
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -77,10 +75,9 @@ 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, 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.
|
||||
// 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.
|
||||
func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -108,26 +105,6 @@ 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{
|
||||
@@ -138,15 +115,22 @@ func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) {
|
||||
Data IPLookupResponse `json:"data"`
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(body.Data.Results[0].IPInfo, want) {
|
||||
t.Errorf("%s: answer %s, want the fields %s", path, answer, wantJSON)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -53,15 +53,6 @@ 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.
|
||||
@@ -115,10 +106,10 @@ func JSONResponseMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// Add @meta field
|
||||
originalResponse[jsonKeyMeta] = map[string]any{
|
||||
metaKeyTimeZone: "UTC",
|
||||
metaKeyAPIVersion: 1,
|
||||
metaKeyExecutionTimeMs: execTime.Milliseconds(),
|
||||
originalResponse["@meta"] = map[string]any{
|
||||
"time_zone": "UTC",
|
||||
"api_version": 1,
|
||||
"execution_time": fmt.Sprintf("%d ms", execTime.Milliseconds()),
|
||||
}
|
||||
|
||||
// Write the enhanced response
|
||||
|
||||
@@ -1,40 +1,25 @@
|
||||
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. reverseProxyPeer is a reverse proxy at a private address, as Docker
|
||||
// gives one.
|
||||
// route.
|
||||
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
|
||||
@@ -102,190 +87,6 @@ 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
|
||||
// (https://git.eeqj.de/sneak/routewatch/issues/201).
|
||||
func TestJSONAnswersCarryMeta(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// writesNothingPath is a JSON route whose handler writes nothing, so that
|
||||
// the JSON check on the route writes the error answer itself.
|
||||
const writesNothingPath = "/writes-nothing.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)
|
||||
|
||||
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/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.MethodGet, writesNothingPath, 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 three 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, "/ip/" + addrInTestPrefixV4, asJSONPath, writesNothingPath,
|
||||
} {
|
||||
checkAnswerCarriesMeta(t, s, httptest.NewRequestWithContext(
|
||||
pastTimeLimit, http.MethodGet, path, nil), http.StatusGatewayTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -382,141 +183,6 @@ 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,
|
||||
|
||||
+21
-28
@@ -38,37 +38,30 @@ type PrefixDistribution struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
// IPInfo represents comprehensive IP information for the /ip endpoint
|
||||
//
|
||||
//nolint:tagliatelle // field names are the existing JSON API
|
||||
type IPInfo struct {
|
||||
// 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"`
|
||||
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"`
|
||||
// Indicates if WHOIS data needs refresh (not serialized)
|
||||
NeedsWHOISRefresh bool `json:"-"`
|
||||
}
|
||||
|
||||
@@ -55,10 +55,6 @@ 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) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// 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
|
||||
@@ -19,7 +17,6 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/blocklist"
|
||||
@@ -78,10 +75,6 @@ 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
|
||||
@@ -110,8 +103,7 @@ 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 opening the port, not serving. Once listening,
|
||||
// the server works out the home page's cards every minute until ctx ends.
|
||||
// already in use. ctx bounds only opening the port, not serving.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
const (
|
||||
network = "tcp"
|
||||
@@ -148,10 +140,6 @@ func (s *Server) Start(ctx context.Context) error {
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
s.keepHomeCardsFresh(ctx, homeCardsRefreshInterval)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
<!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>
|
||||
+11
-154
@@ -276,52 +276,6 @@
|
||||
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;
|
||||
@@ -427,109 +381,6 @@
|
||||
</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>
|
||||
@@ -714,8 +565,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
// IP lookup: show the whole JSON answer, its @meta and an error answer
|
||||
// included, as the IP information API card does.
|
||||
// IP lookup
|
||||
document.getElementById('ip-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const ip = document.getElementById('ip-input').value.trim();
|
||||
@@ -731,11 +581,18 @@
|
||||
|
||||
fetch('/ip/' + encodeURIComponent(ip))
|
||||
.then(response => response.json())
|
||||
.then(answer => {
|
||||
contentEl.className = '';
|
||||
contentEl.textContent = JSON.stringify(answer, null, 2);
|
||||
.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);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
contentEl.classList.remove('loading');
|
||||
contentEl.className = 'error';
|
||||
contentEl.textContent = 'Error: ' + error.message;
|
||||
});
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
<!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>
|
||||
@@ -401,8 +401,6 @@
|
||||
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'];
|
||||
|
||||
@@ -445,18 +443,7 @@
|
||||
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';
|
||||
@@ -632,13 +619,11 @@
|
||||
noteMessageCount(data.total_messages);
|
||||
|
||||
// Update all metrics
|
||||
document.getElementById('uptime').textContent = formatDuration(data.uptime_seconds);
|
||||
document.getElementById('uptime').textContent = data.uptime;
|
||||
document.getElementById('go_version').textContent = data.go_version;
|
||||
document.getElementById('goroutines').textContent = formatNumber(data.goroutines);
|
||||
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('memory_usage').textContent = data.memory_usage;
|
||||
document.getElementById('connection_duration').textContent = data.connection_duration;
|
||||
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));
|
||||
@@ -687,7 +672,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 = formatDuration(data.whois_stats.current_interval_seconds);
|
||||
document.getElementById('whois_interval').textContent = data.whois_stats.current_interval;
|
||||
}
|
||||
|
||||
// Update handler stats
|
||||
|
||||
@@ -27,12 +27,6 @@ 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
|
||||
@@ -45,11 +39,6 @@ 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 (
|
||||
@@ -127,18 +116,6 @@ 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
|
||||
@@ -172,14 +149,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -12,17 +12,13 @@ import (
|
||||
)
|
||||
|
||||
// renderPage renders a page with the data every page needs, and the visitor's
|
||||
// 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.
|
||||
// address, which the home page also needs.
|
||||
func renderPage(t *testing.T, tmpl *template.Template) string {
|
||||
t.Helper()
|
||||
|
||||
var page bytes.Buffer
|
||||
|
||||
err := tmpl.Execute(&page, struct {
|
||||
CSPNonce, ClientIP string
|
||||
Cards any
|
||||
}{})
|
||||
err := tmpl.Execute(&page, struct{ CSPNonce, ClientIP string }{})
|
||||
if err != nil {
|
||||
t.Fatalf("rendering the page: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user