Author SHA1 Message Date
sneak 5c02f6ba62 Give every IP lookup result the same fields (closes #200)
check / check (push) Canceled after 0s
Every result of /ip/, /ip/{addr} and /api/v1/ip/{ip} now has every
field. A field without a value is null instead of left out, so an
address with no live route has all its route and AS fields set to null.
ptr is an empty list when there are no PTR names.

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

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

Model: opus-5-5
2026-10-27 18:31:09 +00:00
12 changed files with 634 additions and 1280 deletions
-20
View File
@@ -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 }
```
+543 -610
View File
File diff suppressed because it is too large Load Diff
+22 -92
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-10-27 last_modified: 2026-10-18
--- ---
This document covers repository structure, tooling, and workflow standards. Code 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) - [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) - [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) - [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/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
`script/docker` are the four, and there is no fifth — `script/check` runs the `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 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 every stage of the `Dockerfile`, `ARG CACHEBUST` comes right after the steps
comes after `ARG CACHEBUST`. `ARG CACHEBUST` comes right after these steps: that install system packages and download Go's modules, or right after `FROM`
- the steps that install system packages in a stage with neither, and every step that lints, tests or compiles comes
- the steps that download Go's modules after it. A step that installs anything else as well, such as a
- the step that runs `script/bootstrap` to install a Python `test` phase's `script/bootstrap` that also installs node packages, comes after it too. A new
packages from `Pipfile.lock` 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
A stage with none of these steps declares `ARG CACHEBUST` right after base image, their own lines or the files they copy change. A stage without the
`FROM`. A step that installs anything else as well, such as a `ARG` line is served from cache on an unchanged tree, so a repository
`script/bootstrap` that also installs node packages, comes after it too. A re-vendors the scripts and adds the `ARG` lines in one commit. The scripts do
new value reruns every `RUN` step after that line; `COPY` steps are compared not pass `--no-cache`: it reruns every step too, but it also builds the layers
by content and need nothing. Docker keeps the layers above the line until above the line again and empties every cache mount, so Go's build cache and
the base image, their own lines or the files they copy change. A stage golangci-lint's, which the canonical Go `Dockerfile` below mounts so that they
without the `ARG` line is served from cache on an unchanged tree, so a survive between check builds, would start empty every time. A bare
repository re-vendors the scripts and adds the `ARG` lines in one commit. `docker build .` is not evidence that anything ran: a sub-second build
The scripts do not pass `--no-cache`: it reruns every step too, but it also reporting success is a cache hit, not a result. Never invalidate by pruning —
builds the layers above the line again and empties every cache mount, so `docker builder prune` and friends destroy a build cache shared with every
Go's build cache and golangci-lint's, which the canonical Go `Dockerfile` other build on the host. When a check is added or changed, prove it works by
below mounts so that they survive between check builds, would start empty planting a defect it must catch and watching the run fail on it, then revert
every time. A bare `docker build .` is not evidence that anything ran: a the defect. A green run alone shows neither that the check ran nor that it
sub-second build reporting success is a cache hit, not a result. Never covers what it should.
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 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 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 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 package it imports, `go.mod`, the linters and their settings in
`.golangci.yml`, and the golangci-lint version are all unchanged. `.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 - 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 (e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example: 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 `--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
do. 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 - 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, 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 with `persist-credentials: false`: `script/cibuild` needs no token, and
-20
View File
@@ -35,26 +35,6 @@ tested.
# Completed Steps # Completed Steps
- 2026-10-27: README "Rationale" says why RouteWatch exists: to answer our other
services' questions about IP addresses, from its own routing table and the
WHOIS details it keeps. It still says what RIS Live does not give (closes
#204)
- 2026-10-27: every JSON answer carries `@meta`. A panic on a JSON route answers
500 with the JSON error body. `/status.json` and the health check answer 405
with the JSON error body to any method they do not take. The 500 and the 405
used to have an empty body. The home page's IP lookup shows the whole JSON
answer, `@meta` included. It used to show only the answer's `data`, or only
the message of an error answer. A test checks `@meta` on every JSON route, in
its successes and its errors (closes #201)
- 2026-10-27: every value in a JSON answer that has a unit is a number, with the
unit at the end of its field name. `@meta.execution_time` is now
`execution_time_ms`. The stats' `uptime`, `connection_duration` and
`memory_usage` are now `uptime_seconds`, `connection_duration_seconds` and
`memory_usage_bytes`. `whois_stats.current_interval` is now
`current_interval_seconds`. They used to be strings such as "12 ms",
"1h18m34s" or "1.1 GB". `connection_duration_seconds` is null while the feed
is not connected. `API_POLICIES.md` is vendored from `sneak/prompts`, and
`REPO_POLICIES.md` re-vendored (closes #199)
- 2026-10-27: every result of the IP lookup has the same fields. A field without - 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 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 every route and AS field set to null, and `ptr` is an empty list when there
+33 -41
View File
@@ -23,6 +23,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/streamer" "git.eeqj.de/sneak/routewatch/internal/streamer"
"git.eeqj.de/sneak/routewatch/internal/templates" "git.eeqj.de/sneak/routewatch/internal/templates"
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo" asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
"github.com/dustin/go-humanize"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -225,23 +226,18 @@ type WHOISStatsInfo struct {
NeverFetched int `json:"never_fetched"` NeverFetched int `json:"never_fetched"`
SuccessesLastHour int `json:"successes_last_hour"` SuccessesLastHour int `json:"successes_last_hour"`
ErrorsLastHour int `json:"errors_last_hour"` ErrorsLastHour int `json:"errors_last_hour"`
CurrentInterval string `json:"current_interval"`
ConsecutiveFails int `json:"consecutive_fails"` ConsecutiveFails int `json:"consecutive_fails"`
FreshPercent float64 `json:"fresh_percent"` 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, // Stats is the /status.json answer: uptime, message counts, database stats,
// and the routing table's figures. UptimeSeconds is the time since the stream // and the routing table's figures. OldestRoute and NewestRoute are the times
// last connected, or 0 if it has never connected. MemoryUsageBytes is the Go // of the oldest and the newest kept routes.
// heap in use. OldestRoute and NewestRoute are the times of the oldest and the
// newest kept routes.
// //
//nolint:tagliatelle // field names are the existing JSON API //nolint:tagliatelle // field names are the existing JSON API
type Stats struct { type Stats struct {
UptimeSeconds int64 `json:"uptime_seconds"` Uptime string `json:"uptime"`
TotalMessages uint64 `json:"total_messages"` TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"` TotalBytes uint64 `json:"total_bytes"`
TotalWireBytes uint64 `json:"total_wire_bytes"` TotalWireBytes uint64 `json:"total_wire_bytes"`
@@ -251,7 +247,7 @@ type Stats struct {
Connected bool `json:"connected"` Connected bool `json:"connected"`
GoVersion string `json:"go_version"` GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"` Goroutines int `json:"goroutines"`
MemoryUsageBytes uint64 `json:"memory_usage_bytes"` MemoryUsage string `json:"memory_usage"`
ASNs int `json:"asns"` ASNs int `json:"asns"`
Prefixes int `json:"prefixes"` Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"` IPv4Prefixes int `json:"ipv4_prefixes"`
@@ -366,7 +362,7 @@ func (s *Server) statusStats(
runtime.ReadMemStats(&memStats) runtime.ReadMemStats(&memStats)
return Stats{ return Stats{
UptimeSeconds: uptimeSeconds(streamMetrics.ConnectedSince), Uptime: streamUptime(streamMetrics.ConnectedSince),
TotalMessages: streamMetrics.TotalMessages, TotalMessages: streamMetrics.TotalMessages,
TotalBytes: streamMetrics.TotalBytes, TotalBytes: streamMetrics.TotalBytes,
TotalWireBytes: streamMetrics.TotalWireBytes, TotalWireBytes: streamMetrics.TotalWireBytes,
@@ -376,7 +372,7 @@ func (s *Server) statusStats(
Connected: streamMetrics.Connected, Connected: streamMetrics.Connected,
GoVersion: runtime.Version(), GoVersion: runtime.Version(),
Goroutines: runtime.NumGoroutine(), Goroutines: runtime.NumGoroutine(),
MemoryUsageBytes: memStats.Alloc, MemoryUsage: humanize.Bytes(memStats.Alloc),
ASNs: dbStats.ASNs, ASNs: dbStats.ASNs,
Prefixes: counts.IPv4Prefixes + counts.IPv6Prefixes, Prefixes: counts.IPv4Prefixes + counts.IPv6Prefixes,
IPv4Prefixes: counts.IPv4Prefixes, IPv4Prefixes: counts.IPv4Prefixes,
@@ -443,31 +439,30 @@ func (s *Server) getWHOISStats(ctx context.Context) *WHOISStatsInfo {
NeverFetched: dbStats.NeverFetched, NeverFetched: dbStats.NeverFetched,
SuccessesLastHour: fetcherStats.SuccessesLastHour, SuccessesLastHour: fetcherStats.SuccessesLastHour,
ErrorsLastHour: fetcherStats.ErrorsLastHour, ErrorsLastHour: fetcherStats.ErrorsLastHour,
CurrentInterval: fetcherStats.CurrentInterval.String(),
ConsecutiveFails: fetcherStats.ConsecutiveFails, ConsecutiveFails: fetcherStats.ConsecutiveFails,
FreshPercent: freshPercent, FreshPercent: freshPercent,
CurrentIntervalSeconds: fetcherStats.CurrentInterval.Seconds(),
} }
} }
// uptimeSeconds is the number of whole seconds since the stream last // streamUptime is how long the stream has been connected, to the second, or
// connected, or 0 if it has never connected. // "0s" if it has never connected.
func uptimeSeconds(connectedSince time.Time) int64 { func streamUptime(connectedSince time.Time) string {
if connectedSince.IsZero() { 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 // connectionDuration is how long the stream has been connected, to the
// been connected, or nil while it is not connected. // second, or "disconnected".
func connectionDurationSeconds(connected bool, connectedSince time.Time) *int64 { func connectionDuration(connected bool, connectedSince time.Time) string {
if !connected || connectedSince.IsZero() { 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. // whoisStaleThreshold matches the fetcher's threshold for consistency.
@@ -517,7 +512,7 @@ type StreamStats struct {
// //
//nolint:tagliatelle // field names are the existing JSON API //nolint:tagliatelle // field names are the existing JSON API
type StatsResponse struct { type StatsResponse struct {
UptimeSeconds int64 `json:"uptime_seconds"` Uptime string `json:"uptime"`
TotalMessages uint64 `json:"total_messages"` TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"` TotalBytes uint64 `json:"total_bytes"`
TotalWireBytes uint64 `json:"total_wire_bytes"` TotalWireBytes uint64 `json:"total_wire_bytes"`
@@ -525,10 +520,11 @@ type StatsResponse struct {
MbitsPerSec float64 `json:"mbits_per_sec"` MbitsPerSec float64 `json:"mbits_per_sec"`
WireMbitsPerSec float64 `json:"wire_mbits_per_sec"` WireMbitsPerSec float64 `json:"wire_mbits_per_sec"`
Connected bool `json:"connected"` Connected bool `json:"connected"`
ConnectionDuration string `json:"connection_duration"`
ReconnectCount uint64 `json:"reconnect_count"` ReconnectCount uint64 `json:"reconnect_count"`
GoVersion string `json:"go_version"` GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"` Goroutines int `json:"goroutines"`
MemoryUsageBytes uint64 `json:"memory_usage_bytes"` MemoryUsage string `json:"memory_usage"`
GC GCStats `json:"gc"` GC GCStats `json:"gc"`
Stream StreamStats `json:"stream"` Stream StreamStats `json:"stream"`
ASNs int `json:"asns"` ASNs int `json:"asns"`
@@ -550,9 +546,6 @@ type StatsResponse struct {
IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"` IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"` 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"` OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
} }
@@ -691,18 +684,20 @@ func (s *Server) statsResponse(
runtime.ReadMemStats(&memStats) runtime.ReadMemStats(&memStats)
return StatsResponse{ return StatsResponse{
UptimeSeconds: uptimeSeconds(streamMetrics.ConnectedSince), Uptime: streamUptime(streamMetrics.ConnectedSince),
TotalMessages: streamMetrics.TotalMessages, TotalMessages: streamMetrics.TotalMessages,
TotalBytes: streamMetrics.TotalBytes, TotalBytes: streamMetrics.TotalBytes,
TotalWireBytes: streamMetrics.TotalWireBytes, TotalWireBytes: streamMetrics.TotalWireBytes,
MessagesPerSec: streamMetrics.MessagesPerSec, MessagesPerSec: streamMetrics.MessagesPerSec,
MbitsPerSec: streamMetrics.BitsPerSec / bitsPerMegabit, MbitsPerSec: streamMetrics.BitsPerSec / bitsPerMegabit,
WireMbitsPerSec: streamMetrics.WireBitsPerSec / bitsPerMegabit, WireMbitsPerSec: streamMetrics.WireBitsPerSec / bitsPerMegabit,
Connected: streamMetrics.Connected, Connected: streamMetrics.Connected,
ConnectionDuration: connectionDuration(
streamMetrics.Connected, streamMetrics.ConnectedSince),
ReconnectCount: streamMetrics.ReconnectCount, ReconnectCount: streamMetrics.ReconnectCount,
GoVersion: runtime.Version(), GoVersion: runtime.Version(),
Goroutines: runtime.NumGoroutine(), Goroutines: runtime.NumGoroutine(),
MemoryUsageBytes: memStats.Alloc, MemoryUsage: humanize.Bytes(memStats.Alloc),
GC: gcStats(&memStats), GC: gcStats(&memStats),
Stream: s.streamStats(), Stream: s.streamStats(),
ASNs: dbStats.ASNs, ASNs: dbStats.ASNs,
@@ -724,9 +719,6 @@ func (s *Server) statsResponse(
IPv6PrefixDistribution: prefixDistribution(counts.IPv6PrefixesByLength), IPv6PrefixDistribution: prefixDistribution(counts.IPv6PrefixesByLength),
WHOISStats: whoisStats, WHOISStats: whoisStats,
ConnectionDurationSeconds: connectionDurationSeconds(
streamMetrics.Connected, streamMetrics.ConnectedSince),
OriginsWithoutKeptRoute: counts.OriginsWithoutKeptRoute, OriginsWithoutKeptRoute: counts.OriginsWithoutKeptRoute,
} }
} }
+4 -199
View File
@@ -73,8 +73,8 @@ func emptyTable() *routetable.Table {
// Paths several tests request. // Paths several tests request.
const ( const (
asPagePath = "/as/64500" asPagePath = "/as/64500"
asJSONPath = "/api/v1/as/64500"
prefixLengthPagePath = "/prefixlength/24" prefixLengthPagePath = "/prefixlength/24"
statusJSONPath = "/status.json"
statsPath = "/api/v1/stats" statsPath = "/api/v1/stats"
) )
@@ -295,167 +295,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, // newTestTable returns a routing table that holds a route for testPrefixV4,
// with origin AS 64500, and one for testPrefixV6, with origin AS 64501. // with origin AS 64500, and one for testPrefixV6, with origin AS 64501.
func newTestTable() *routetable.Table { func newTestTable() *routetable.Table {
@@ -927,7 +766,7 @@ func TestRoutesAnswer504WhenDatabaseReadIsCutOff(t *testing.T) {
{path: "/ip/" + addrInTestPrefixV4, wantJSONBody: true}, {path: "/ip/" + addrInTestPrefixV4, wantJSONBody: true},
{path: statsPath, wantJSONBody: true}, {path: statsPath, wantJSONBody: true},
{path: "/api/v1/ip/" + addrInTestPrefixV4, wantJSONBody: true}, {path: "/api/v1/ip/" + addrInTestPrefixV4, wantJSONBody: true},
{path: asJSONPath, wantJSONBody: true}, {path: "/api/v1/as/64500", wantJSONBody: true},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -1108,10 +947,10 @@ func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
path string path string
wantCode int wantCode int
}{ }{
{healthCheckPath, http.StatusServiceUnavailable}, {"/.well-known/healthcheck.json", http.StatusServiceUnavailable},
{statusJSONPath, http.StatusInternalServerError}, {statusJSONPath, http.StatusInternalServerError},
{statsPath, http.StatusInternalServerError}, {statsPath, http.StatusInternalServerError},
{asJSONPath, http.StatusInternalServerError}, {"/api/v1/as/64500", http.StatusInternalServerError},
{"/api/v1/ip/" + addrInTestPrefixV4, http.StatusInternalServerError}, {"/api/v1/ip/" + addrInTestPrefixV4, http.StatusInternalServerError},
{"/api/v1/prefix/foo/24", http.StatusBadRequest}, {"/api/v1/prefix/foo/24", http.StatusBadRequest},
} }
@@ -1477,9 +1316,6 @@ func TestIPLookupReportsBlocklists(t *testing.T) {
// jsonNull is how JSON writes a field without a value. // jsonNull is how JSON writes a field without a value.
const jsonNull = "null" 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 // resultFields decodes an IP lookup answer and returns each of its results as
// its JSON fields. // its JSON fields.
func resultFields(t *testing.T, answer []byte) []map[string]json.RawMessage { func resultFields(t *testing.T, answer []byte) []map[string]json.RawMessage {
@@ -1615,37 +1451,6 @@ func TestIPLookupResultsHaveTheSameFields(t *testing.T) {
} }
} }
// 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 // TestIPLookupAnswersMayBeCached checks the Cache-Control header of the IP
// lookup answers. Caches may keep them for the blocklists' shortest interval, // 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 // and only the caller's own browser may keep the answer about the caller's
+7 -19
View File
@@ -16,7 +16,6 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
) )
// responseWriter wraps http.ResponseWriter to capture the response // responseWriter wraps http.ResponseWriter to capture the response
@@ -54,15 +53,6 @@ func (rw *responseWriter) Header() http.Header {
return rw.ResponseWriter.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 // JSONResponseMiddleware is an HTTP middleware that wraps all JSON responses
// with a @meta field containing execution metadata. The metadata includes the // with a @meta field containing execution metadata. The metadata includes the
// time zone (always UTC), API version, and request execution time in milliseconds. // time zone (always UTC), API version, and request execution time in milliseconds.
@@ -116,10 +106,10 @@ func JSONResponseMiddleware(next http.Handler) http.Handler {
} }
// Add @meta field // Add @meta field
originalResponse[jsonKeyMeta] = map[string]any{ originalResponse["@meta"] = map[string]any{
metaKeyTimeZone: "UTC", "time_zone": "UTC",
metaKeyAPIVersion: 1, "api_version": 1,
metaKeyExecutionTimeMs: execTime.Milliseconds(), "execution_time": fmt.Sprintf("%d ms", execTime.Milliseconds()),
} }
// Write the enhanced response // Write the enhanced response
@@ -155,8 +145,7 @@ func deadlinePassed(ctx context.Context) bool {
// has passed, it answers 504 with the error message "Request timeout". A 4xx // has passed, it answers 504 with the error message "Request timeout". A 4xx
// keeps its status, with the status's standard text as the message. For other // keeps its status, with the status's standard text as the message. For other
// errors, it returns "Internal server error", with status 500 if the original // errors, it returns "Internal server error", with status 500 if the original
// status was 200. A panic in the handler answers 500 with "Internal server // status was 200.
// error" too.
// //
// Non-JSON responses are passed through unchanged. // Non-JSON responses are passed through unchanged.
func JSONValidationMiddleware(next http.Handler) http.Handler { func JSONValidationMiddleware(next http.Handler) http.Handler {
@@ -168,9 +157,8 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
statusCode: http.StatusOK, statusCode: http.StatusOK,
} }
// Serve the request. Recoverer logs a panic in the handler and turns it // Serve the request
// into a 500 without a body, which gets the JSON error body below. next.ServeHTTP(rw, r)
middleware.Recoverer(next).ServeHTTP(rw, r)
// Check if it's meant to be a JSON response // Check if it's meant to be a JSON response
contentType := rw.Header().Get("Content-Type") contentType := rw.Header().Get("Content-Type")
-215
View File
@@ -2,7 +2,6 @@ package server
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"io" "io"
"log/slog" "log/slog"
@@ -11,7 +10,6 @@ import (
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"os" "os"
"regexp"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -102,219 +100,6 @@ func TestJSONValidationKeeps4xxWithoutBody(t *testing.T) {
checkJSONErrorMessage(t, name, rec, http.StatusText(http.StatusBadRequest)) checkJSONErrorMessage(t, name, rec, http.StatusText(http.StatusBadRequest))
} }
// TestJSONAnswersPutNoUnitInAString sends a request to each JSON route and
// checks each answer. Its @meta.execution_time_ms is a number, and none of its
// strings is a number with a unit, such as "12 ms", "1.1 GB" or "1h18m34s"
// (https://git.eeqj.de/sneak/routewatch/issues/199). The health check answers
// 503, since the stream is not connected, and the last path matches no route.
func TestJSONAnswersPutNoUnitInAString(t *testing.T) {
t.Parallel()
numberWithUnit := regexp.MustCompile(
`^([0-9]+(\.[0-9]+)? ?(ns|µs|ms|s|m|h|B|kB|MB|GB|TB))+$`)
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
s.SetASNFetcher(fixedIntervalASNFetcher{interval: time.Second})
paths := []string{
statusJSONPath,
statsPath,
healthCheckPath,
"/api/v1/ip/" + untrustedPeerAddr,
"/ip/" + untrustedPeerAddr,
asJSONPath,
"/api/v1/prefix/" + testPrefixV4,
"/api/v1/search/as?q=google",
"/api/v1/no-such-route",
}
for _, path := range paths {
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil))
var answer map[string]any
err := json.Unmarshal(rec.Body.Bytes(), &answer)
if err != nil {
t.Errorf("%s: decoding the answer: %v; answer %s", path, err, rec.Body)
continue
}
meta, _ := answer["@meta"].(map[string]any)
if _, ok := meta["execution_time_ms"].(float64); !ok {
t.Errorf("%s: @meta.execution_time_ms is not a number: %s",
path, rec.Body)
}
for _, value := range stringsIn(answer) {
if numberWithUnit.MatchString(value) {
t.Errorf("%s: %q is a number with a unit in a string", path, value)
}
}
}
}
// TestJSONAnswersCarryMeta sends requests to every JSON route through the
// server's router, and checks that each answer carries @meta with its three
// fields. The answers include successes, and errors that the handlers, the
// router and the middleware write. Before the fix a panic in a handler, and a
// method /status.json or the health check does not take, answered with an
// empty body (https://git.eeqj.de/sneak/routewatch/issues/201).
func TestJSONAnswersCarryMeta(t *testing.T) {
t.Parallel()
// JSON routes whose handlers write nothing or panic, so that the JSON
// check on the route writes the error answer itself.
const (
writesNothingPath = "/writes-nothing.json"
panicsPath = "/panics.json"
)
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
s.streamer.GetMetricsTracker().SetConnected(true)
s.router.Get(writesNothingPath, JSONValidationMiddleware(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {})).ServeHTTP)
s.router.Get(panicsPath, JSONValidationMiddleware(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) { panic(errDiskIO) })).ServeHTTP)
tests := []struct {
method string
path string
wantCode int
}{
// Answers that succeed
{http.MethodGet, statusJSONPath, http.StatusOK},
{http.MethodGet, healthCheckPath, http.StatusOK},
{http.MethodGet, "/ip", http.StatusOK},
{http.MethodGet, ownAddressPath, http.StatusOK},
{http.MethodGet, "/ip/" + addrInTestPrefixV4, http.StatusOK},
{http.MethodGet, statsPath, http.StatusOK},
{http.MethodGet, "/api/v1/ip/" + addrInTestPrefixV4, http.StatusOK},
{http.MethodGet, asJSONPath, http.StatusOK},
{http.MethodGet, "/api/v1/prefix/" + testPrefixV4, http.StatusOK},
{http.MethodGet, "/api/v1/search/as?q=google", http.StatusOK},
// Errors the handlers write
{http.MethodGet, "/ip/no-such-host.example", http.StatusBadRequest},
{http.MethodGet, "/api/v1/ip/no-such-host.example", http.StatusBadRequest},
{http.MethodGet, "/api/v1/as/foo", http.StatusBadRequest},
{http.MethodGet, "/api/v1/as/0", http.StatusNotFound},
{http.MethodGet, "/api/v1/prefix/192.0.2.0/33", http.StatusBadRequest},
{http.MethodGet, "/api/v1/prefix/198.51.100.0/24", http.StatusNotFound},
{http.MethodGet, "/api/v1/search/as", http.StatusBadRequest},
// Errors the router and the middleware write
{http.MethodGet, "/api/v1/no-such-route", http.StatusNotFound},
{http.MethodGet, "/ip/192.0.2.1/nothing", http.StatusNotFound},
{http.MethodPost, statsPath, http.StatusMethodNotAllowed},
{http.MethodPost, ownAddressPath, http.StatusMethodNotAllowed},
{http.MethodPost, statusJSONPath, http.StatusMethodNotAllowed},
{http.MethodPut, healthCheckPath, http.StatusMethodNotAllowed},
{http.MethodGet, writesNothingPath, http.StatusInternalServerError},
{http.MethodGet, panicsPath, http.StatusInternalServerError},
}
for _, tt := range tests {
checkAnswerCarriesMeta(t, s, httptest.NewRequestWithContext(
t.Context(), tt.method, tt.path, nil), tt.wantCode)
}
// A request whose time limit has already passed answers 504. The handlers
// write the answers of the first five routes, and the JSON check on the
// route writes the last.
pastTimeLimit, cancel := context.WithDeadline(t.Context(), time.Now())
defer cancel()
for _, path := range []string{
statusJSONPath, statsPath, "/ip/" + addrInTestPrefixV4,
"/api/v1/ip/" + addrInTestPrefixV4, asJSONPath, writesNothingPath,
} {
checkAnswerCarriesMeta(t, s, httptest.NewRequestWithContext(
pastTimeLimit, http.MethodGet, path, nil), http.StatusGatewayTimeout)
}
// With a store whose reads fail, the health check answers 503, and the
// handlers of the other routes 500.
failing := New(failingDB{}, newTestTable(),
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
failing.resolver = &fakeResolver{}
checkAnswerCarriesMeta(t, failing, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, healthCheckPath, nil),
http.StatusServiceUnavailable)
for _, path := range []string{
statusJSONPath, statsPath, asJSONPath,
"/api/v1/ip/" + addrInTestPrefixV4, "/ip/" + addrInTestPrefixV4,
} {
checkAnswerCarriesMeta(t, failing, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil), http.StatusInternalServerError)
}
}
// checkAnswerCarriesMeta serves req through the server's router, and checks
// that it answers wantCode with @meta and its three fields.
func checkAnswerCarriesMeta(
t *testing.T, s *Server, req *http.Request, wantCode int,
) {
t.Helper()
// The values README "API v1" gives the two fields that never change.
const (
wantTimeZone = "UTC"
wantAPIVersion = 1
)
name := req.Method + " " + req.URL.String()
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
if rec.Code != wantCode {
t.Errorf("%s: status %d, want %d; body %s", name, rec.Code, wantCode, rec.Body)
}
var answer map[string]any
err := json.Unmarshal(rec.Body.Bytes(), &answer)
if err != nil {
t.Errorf("%s: decoding the answer %q: %v", name, rec.Body, err)
return
}
meta, _ := answer[jsonKeyMeta].(map[string]any)
_, timeIsNumber := meta[metaKeyExecutionTimeMs].(float64)
if meta[metaKeyTimeZone] != wantTimeZone ||
meta[metaKeyAPIVersion] != float64(wantAPIVersion) || !timeIsNumber {
t.Errorf("%s: @meta %v, want time_zone %q, api_version %d and a "+
"number in execution_time_ms", name, meta, wantTimeZone, wantAPIVersion)
}
}
// stringsIn returns every string in a decoded JSON value, at any depth.
func stringsIn(value any) []string {
var found []string
switch v := value.(type) {
case string:
found = append(found, v)
case []any:
for _, item := range v {
found = append(found, stringsIn(item)...)
}
case map[string]any:
for _, item := range v {
found = append(found, stringsIn(item)...)
}
}
return found
}
// TestPagesCarryTheCSPNonce checks that every script and style block of each // TestPagesCarryTheCSPNonce checks that every script and style block of each
// page carries the nonce its Content-Security-Policy header names, so that the // 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 // browser runs and applies them, that each answer has a nonce of its own, and
+7 -26
View File
@@ -8,8 +8,8 @@ import (
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
) )
// The error messages the JSON routes answer with when no route matches the // The error messages the JSON routes under /api/v1 and /ip answer with when no
// path, and when the route does not take the method. // route matches the path, and when none takes the method.
const ( const (
notFoundMessage = "Not found" notFoundMessage = "Not found"
methodNotAllowedMessage = "Method not allowed" methodNotAllowedMessage = "Method not allowed"
@@ -19,13 +19,6 @@ const (
jsonRoutesAllow = "GET, HEAD" jsonRoutesAllow = "GET, HEAD"
) )
// The paths of the status figures and the health check, the two JSON routes
// outside /api/v1 and /ip.
const (
statusJSONPath = "/status.json"
healthCheckPath = "/.well-known/healthcheck.json"
)
// setupRoutes configures the HTTP routes // setupRoutes configures the HTTP routes
func (s *Server) setupRoutes() { func (s *Server) setupRoutes() {
r := chi.NewRouter() r := chi.NewRouter()
@@ -39,8 +32,6 @@ func (s *Server) setupRoutes() {
r.Use(middleware.RequestID) r.Use(middleware.RequestID)
r.Use(RealIPMiddleware(s.trustedProxies)) r.Use(RealIPMiddleware(s.trustedProxies))
r.Use(RequestLoggerMiddleware(s.logger.Logger)) // Structured request logging r.Use(RequestLoggerMiddleware(s.logger.Logger)) // Structured request logging
// Recoverer answers a panic on a page. JSONValidationMiddleware recovers a
// panic on a JSON route itself, so that the answer is JSON with @meta.
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(JSONResponseMiddleware) r.Use(JSONResponseMiddleware)
// Timeout must come after JSONResponseMiddleware, which sends its answer // Timeout must come after JSONResponseMiddleware, which sends its answer
@@ -52,6 +43,11 @@ func (s *Server) setupRoutes() {
// Routes // Routes
r.Get("/", s.handleIndex()) r.Get("/", s.handleIndex())
r.Get("/status", s.handleStatusHTML()) r.Get("/status", s.handleStatusHTML())
r.Get("/status.json", JSONValidationMiddleware(s.handleStatusJSON()).ServeHTTP)
r.Get(
"/.well-known/healthcheck.json",
JSONValidationMiddleware(s.handleHealthCheck()).ServeHTTP,
)
// AS and prefix detail pages // AS and prefix detail pages
r.Get("/as/{asn}", s.handleASDetail()) r.Get("/as/{asn}", s.handleASDetail())
@@ -74,21 +70,6 @@ func (s *Server) setupRoutes() {
s.writeJSONError(w, http.StatusMethodNotAllowed, methodNotAllowedMessage) s.writeJSONError(w, http.StatusMethodNotAllowed, methodNotAllowedMessage)
} }
// The status figures and the health check. chi sets one 405 answer for a
// whole router. These two routes are on the pages' router, whose 405 stays
// chi's own. So each method they do not take is routed to the JSON 405
// here.
r.Get(statusJSONPath, JSONValidationMiddleware(s.handleStatusJSON()).ServeHTTP)
r.Get(healthCheckPath, JSONValidationMiddleware(s.handleHealthCheck()).ServeHTTP)
for _, method := range []string{
http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete,
http.MethodOptions, http.MethodTrace, http.MethodConnect,
} {
r.MethodFunc(method, statusJSONPath, jsonMethodNotAllowed)
r.MethodFunc(method, healthCheckPath, jsonMethodNotAllowed)
}
// IP info JSON endpoints (replaces old /ip redirect) // IP info JSON endpoints (replaces old /ip redirect)
r.Route("/ip", func(r chi.Router) { r.Route("/ip", func(r chi.Router) {
r.Use(JSONValidationMiddleware) r.Use(JSONValidationMiddleware)
+2 -13
View File
@@ -17,7 +17,7 @@ func TestRoutesAnswerHEAD(t *testing.T) {
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
for _, path := range []string{"/", statusJSONPath, statsPath, "/ip", "/ip/"} { for _, path := range []string{"/", statsPath, "/ip", "/ip/"} {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext( s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodHead, path, nil)) t.Context(), http.MethodHead, path, nil))
@@ -31,13 +31,10 @@ func TestRoutesAnswerHEAD(t *testing.T) {
// TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors sends a method the JSON // TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors sends a method the JSON
// routes do not take, and a path none of them matches, to each group of JSON // routes do not take, and a path none of them matches, to each group of JSON
// routes through the server's router. It also sends a method they do not take // routes through the server's router. Each must answer with the JSON error
// to /status.json and the health check. Each must answer with the JSON error
// body, and a 405 must name the methods the routes take in its Allow header. // body, and a 405 must name the methods the routes take in its Allow header.
// Before the fix a 405 said "Internal server error" and a 404 was chi's plain // Before the fix a 405 said "Internal server error" and a 404 was chi's plain
// text answer (https://git.eeqj.de/sneak/routewatch/issues/111). // text answer (https://git.eeqj.de/sneak/routewatch/issues/111).
// /status.json and the health check gave chi's empty 405
// (https://git.eeqj.de/sneak/routewatch/issues/201).
func TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors(t *testing.T) { func TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors(t *testing.T) {
t.Parallel() t.Parallel()
@@ -51,14 +48,6 @@ func TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors(t *testing.T) {
}{ }{
{http.MethodPost, statsPath, http.StatusMethodNotAllowed, methodNotAllowedMessage}, {http.MethodPost, statsPath, http.StatusMethodNotAllowed, methodNotAllowedMessage},
{http.MethodPost, "/ip/", http.StatusMethodNotAllowed, methodNotAllowedMessage}, {http.MethodPost, "/ip/", http.StatusMethodNotAllowed, methodNotAllowedMessage},
{
http.MethodDelete, statusJSONPath,
http.StatusMethodNotAllowed, methodNotAllowedMessage,
},
{
http.MethodOptions, healthCheckPath,
http.StatusMethodNotAllowed, methodNotAllowedMessage,
},
{http.MethodGet, "/api/v1/nothing", http.StatusNotFound, notFoundMessage}, {http.MethodGet, "/api/v1/nothing", http.StatusNotFound, notFoundMessage},
{http.MethodGet, "/ip/192.0.2.1/nothing", http.StatusNotFound, notFoundMessage}, {http.MethodGet, "/ip/192.0.2.1/nothing", http.StatusNotFound, notFoundMessage},
} }
+11 -5
View File
@@ -714,8 +714,7 @@
}); });
}); });
// IP lookup: show the whole JSON answer, its @meta and an error answer // IP lookup
// included, as the IP information API card does.
document.getElementById('ip-form').addEventListener('submit', function(e) { document.getElementById('ip-form').addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
const ip = document.getElementById('ip-input').value.trim(); const ip = document.getElementById('ip-input').value.trim();
@@ -731,11 +730,18 @@
fetch('/ip/' + encodeURIComponent(ip)) fetch('/ip/' + encodeURIComponent(ip))
.then(response => response.json()) .then(response => response.json())
.then(answer => { .then(response => {
contentEl.className = ''; contentEl.classList.remove('loading');
contentEl.textContent = JSON.stringify(answer, null, 2); 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 => { .catch(error => {
contentEl.classList.remove('loading');
contentEl.className = 'error'; contentEl.className = 'error';
contentEl.textContent = 'Error: ' + error.message; contentEl.textContent = 'Error: ' + error.message;
}); });
+5 -20
View File
@@ -401,8 +401,6 @@
const REFRESH_PERIOD_MS = 2000; const REFRESH_PERIOD_MS = 2000;
const STALE_AFTER_REFRESHES = 3; const STALE_AFTER_REFRESHES = 3;
const MS_PER_SECOND = 1000; const MS_PER_SECOND = 1000;
const SECONDS_PER_MINUTE = 60;
const SECONDS_PER_HOUR = 3600;
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const SPINNER_COLOURS = ['#e74c3c', '#e67e22', '#f1c40f', '#27ae60', '#3498db', '#9b59b6']; const SPINNER_COLOURS = ['#e74c3c', '#e67e22', '#f1c40f', '#27ae60', '#3498db', '#9b59b6'];
@@ -445,18 +443,7 @@
function formatNumber(num) { function formatNumber(num) {
return num.toLocaleString(); 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) { function formatProcessingTime(ms) {
if (ms < 0.001) { if (ms < 0.001) {
return (ms * 1000).toFixed(0) + ' µs'; return (ms * 1000).toFixed(0) + ' µs';
@@ -632,13 +619,11 @@
noteMessageCount(data.total_messages); noteMessageCount(data.total_messages);
// Update all metrics // 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('go_version').textContent = data.go_version;
document.getElementById('goroutines').textContent = formatNumber(data.goroutines); document.getElementById('goroutines').textContent = formatNumber(data.goroutines);
document.getElementById('memory_usage').textContent = formatBytes(data.memory_usage_bytes); document.getElementById('memory_usage').textContent = data.memory_usage;
// connection_duration_seconds is null while the stream is not connected. document.getElementById('connection_duration').textContent = data.connection_duration;
document.getElementById('connection_duration').textContent =
data.connection_duration_seconds === null ? 'disconnected' : formatDuration(data.connection_duration_seconds);
document.getElementById('reconnect_count').textContent = formatNumber(data.reconnect_count); document.getElementById('reconnect_count').textContent = formatNumber(data.reconnect_count);
document.getElementById('total_messages').textContent = formatNumber(data.total_messages); document.getElementById('total_messages').textContent = formatNumber(data.total_messages);
document.getElementById('messages_per_sec').textContent = formatNumber(Math.round(data.messages_per_sec)); document.getElementById('messages_per_sec').textContent = formatNumber(Math.round(data.messages_per_sec));
@@ -687,7 +672,7 @@
const errorsEl = document.getElementById('whois_errors'); const errorsEl = document.getElementById('whois_errors');
errorsEl.textContent = formatNumber(data.whois_stats.errors_last_hour); errorsEl.textContent = formatNumber(data.whois_stats.errors_last_hour);
errorsEl.className = 'metric-value' + (data.whois_stats.errors_last_hour > 0 ? ' disconnected' : ''); 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 // Update handler stats