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

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

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

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

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

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

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

Model: opus-5-5
2026-10-27 21:20:07 +01:00
7 changed files with 694 additions and 579 deletions
+602 -553
View File
File diff suppressed because it is too large Load Diff
+11 -5
View File
@@ -35,11 +35,17 @@ tested.
# Completed Steps # Completed Steps
- 2026-10-27: the home page's IP lookup shows the whole JSON answer, with its - 2026-10-27: README "Rationale" says why RouteWatch exists: to answer our other
`@meta`, as the IP information API card does. It used to show only the services' questions about IP addresses, from its own routing table and the
answer's `data`, or only the message of an error answer. A test checks that WHOIS details it keeps. It still says what RIS Live does not give (closes
every JSON route answers with `@meta`, in its successes and its errors (closes #204)
#201) - 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 - 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 unit at the end of its field name. `@meta.execution_time` is now
`execution_time_ms`. The stats' `uptime`, `connection_duration` and `execution_time_ms`. The stats' `uptime`, `connection_duration` and
@@ -75,9 +75,7 @@ const (
asPagePath = "/as/64500" asPagePath = "/as/64500"
asJSONPath = "/api/v1/as/64500" asJSONPath = "/api/v1/as/64500"
prefixLengthPagePath = "/prefixlength/24" prefixLengthPagePath = "/prefixlength/24"
statusJSONPath = "/status.json"
statsPath = "/api/v1/stats" statsPath = "/api/v1/stats"
healthCheckPath = "/.well-known/healthcheck.json"
) )
// The names the stats handler tests report the two stats handlers by. // The names the stats handler tests report the two stats handlers by.
+6 -3
View File
@@ -16,6 +16,7 @@ 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
@@ -154,7 +155,8 @@ 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. // status was 200. A panic in the handler answers 500 with "Internal server
// 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 {
@@ -166,8 +168,9 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
statusCode: http.StatusOK, statusCode: http.StatusOK,
} }
// Serve the request // Serve the request. Recoverer logs a panic in the handler and turns it
next.ServeHTTP(rw, r) // into a 500 without a body, which gets the JSON error body below.
middleware.Recoverer(next).ServeHTTP(rw, r)
// Check if it's meant to be a JSON response // Check if it's meant to be a JSON response
contentType := rw.Header().Get("Content-Type") contentType := rw.Header().Get("Content-Type")
+36 -7
View File
@@ -160,20 +160,26 @@ func TestJSONAnswersPutNoUnitInAString(t *testing.T) {
// TestJSONAnswersCarryMeta sends requests to every JSON route through the // TestJSONAnswersCarryMeta sends requests to every JSON route through the
// server's router, and checks that each answer carries @meta with its three // server's router, and checks that each answer carries @meta with its three
// fields. The answers include successes, and errors that the handlers, the // fields. The answers include successes, and errors that the handlers, the
// router and the middleware write // router and the middleware write. Before the fix a panic in a handler, and a
// (https://git.eeqj.de/sneak/routewatch/issues/201). // 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) { func TestJSONAnswersCarryMeta(t *testing.T) {
t.Parallel() t.Parallel()
// writesNothingPath is a JSON route whose handler writes nothing, so that // JSON routes whose handlers write nothing or panic, so that the JSON
// the JSON check on the route writes the error answer itself. // check on the route writes the error answer itself.
const writesNothingPath = "/writes-nothing.json" const (
writesNothingPath = "/writes-nothing.json"
panicsPath = "/panics.json"
)
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{} s.resolver = &fakeResolver{}
s.streamer.GetMetricsTracker().SetConnected(true) s.streamer.GetMetricsTracker().SetConnected(true)
s.router.Get(writesNothingPath, JSONValidationMiddleware(http.HandlerFunc( s.router.Get(writesNothingPath, JSONValidationMiddleware(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {})).ServeHTTP) func(http.ResponseWriter, *http.Request) {})).ServeHTTP)
s.router.Get(panicsPath, JSONValidationMiddleware(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) { panic(errDiskIO) })).ServeHTTP)
tests := []struct { tests := []struct {
method string method string
@@ -193,6 +199,7 @@ func TestJSONAnswersCarryMeta(t *testing.T) {
{http.MethodGet, "/api/v1/search/as?q=google", http.StatusOK}, {http.MethodGet, "/api/v1/search/as?q=google", http.StatusOK},
// Errors the handlers write // Errors the handlers write
{http.MethodGet, "/ip/no-such-host.example", http.StatusBadRequest}, {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/foo", http.StatusBadRequest},
{http.MethodGet, "/api/v1/as/0", http.StatusNotFound}, {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/192.0.2.0/33", http.StatusBadRequest},
@@ -203,7 +210,10 @@ func TestJSONAnswersCarryMeta(t *testing.T) {
{http.MethodGet, "/ip/192.0.2.1/nothing", http.StatusNotFound}, {http.MethodGet, "/ip/192.0.2.1/nothing", http.StatusNotFound},
{http.MethodPost, statsPath, http.StatusMethodNotAllowed}, {http.MethodPost, statsPath, http.StatusMethodNotAllowed},
{http.MethodPost, ownAddressPath, http.StatusMethodNotAllowed}, {http.MethodPost, ownAddressPath, http.StatusMethodNotAllowed},
{http.MethodPost, statusJSONPath, http.StatusMethodNotAllowed},
{http.MethodPut, healthCheckPath, http.StatusMethodNotAllowed},
{http.MethodGet, writesNothingPath, http.StatusInternalServerError}, {http.MethodGet, writesNothingPath, http.StatusInternalServerError},
{http.MethodGet, panicsPath, http.StatusInternalServerError},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -212,17 +222,36 @@ func TestJSONAnswersCarryMeta(t *testing.T) {
} }
// A request whose time limit has already passed answers 504. The handlers // 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 // write the answers of the first five routes, and the JSON check on the
// route writes the last. // route writes the last.
pastTimeLimit, cancel := context.WithDeadline(t.Context(), time.Now()) pastTimeLimit, cancel := context.WithDeadline(t.Context(), time.Now())
defer cancel() defer cancel()
for _, path := range []string{ for _, path := range []string{
statusJSONPath, "/ip/" + addrInTestPrefixV4, asJSONPath, writesNothingPath, statusJSONPath, statsPath, "/ip/" + addrInTestPrefixV4,
"/api/v1/ip/" + addrInTestPrefixV4, asJSONPath, writesNothingPath,
} { } {
checkAnswerCarriesMeta(t, s, httptest.NewRequestWithContext( checkAnswerCarriesMeta(t, s, httptest.NewRequestWithContext(
pastTimeLimit, http.MethodGet, path, nil), http.StatusGatewayTimeout) pastTimeLimit, http.MethodGet, path, nil), http.StatusGatewayTimeout)
} }
// With a store whose reads fail, the health check answers 503, and the
// 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 // checkAnswerCarriesMeta serves req through the server's router, and checks
+26 -7
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 under /api/v1 and /ip answer with when no // The error messages the JSON routes answer with when no route matches the
// route matches the path, and when none takes the method. // path, and when the route does not take the method.
const ( const (
notFoundMessage = "Not found" notFoundMessage = "Not found"
methodNotAllowedMessage = "Method not allowed" methodNotAllowedMessage = "Method not allowed"
@@ -19,6 +19,13 @@ 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()
@@ -32,6 +39,8 @@ 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
@@ -43,11 +52,6 @@ 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())
@@ -70,6 +74,21 @@ 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)
+13 -2
View File
@@ -17,7 +17,7 @@ func TestRoutesAnswerHEAD(t *testing.T) {
s := newServerWithTestRoutes(t) s := newServerWithTestRoutes(t)
for _, path := range []string{"/", statsPath, "/ip", "/ip/"} { for _, path := range []string{"/", statusJSONPath, 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,10 +31,13 @@ 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. Each must answer with the JSON error // routes through the server's router. It also sends a method they do not take
// to /status.json and the health check. Each must answer with the JSON error
// body, and a 405 must name the methods the routes take in its Allow header. // 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()
@@ -48,6 +51,14 @@ 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},
} }