3 Commits
Author SHA1 Message Date
sneak e02267a659 Handle an announcement without an origin like a replacement (closes #113)
check / check (push) Waiting to run
An announcement whose AS path is empty or ends in AS 0 now deletes the
peer's route for the prefix, as a withdrawal does. Before, it was neither
stored nor applied: the peer's old route stayed live, and within one
batch it cancelled the peer's earlier withdrawal of the prefix.

Model: opus-5-5
2026-10-20 01:18:41 +00:00
clawbot 2f238236bd Read the drop counter atomically and set the WHOIS fetcher before serving (closes #115)
check / check (push) Canceled after 0s
The read loop added to a handler's drop count atomically while the
statistics endpoint and the metrics log read it as a plain field, a data
race whenever messages were dropped. The count is now an atomic.Uint64,
read with Load. A test reads the handler statistics while the read loop
drops messages for a handler that never finishes one.

Run created the WHOIS fetcher and gave it to the HTTP server after the
server had started, while request handlers could read it. The fetcher is
now set before the server starts.

Model: opus-5-5
2026-10-20 03:18:00 +02:00
clawbot f489321e3a Answer HEAD requests and give 405 a plain message (closes #111)
check / check (push) Canceled after 0s
Every route was registered for GET only, so HEAD answered 405, and on
the JSON routes that 405 said "Internal server error". chi's GetHead
now serves HEAD with the GET handler; /ip and /ip/ register HEAD
themselves, as GetHead does not reach them. The /api/v1 and /ip route
groups answer a path they lack with a JSON 404 "Not found", and the
other methods the router knows (POST, PUT, PATCH, DELETE, OPTIONS,
TRACE, CONNECT) with a JSON 405 "Method not allowed" whose Allow header
names GET and HEAD. JSONValidationMiddleware gives a 4xx sent without a
JSON body the status's standard text instead of "Internal server error".

Model: opus-5-5
2026-10-20 03:06:23 +02:00
10 changed files with 254 additions and 18 deletions
+7 -2
View File
@@ -36,6 +36,8 @@ DEBUG=routewatch ./routewatch
## HTTP Endpoints
Every route answers `HEAD` as it answers `GET`, without the body.
### Web Interface
- `GET /` - Home page (HTML): headline figures, and lookups by AS number and by
@@ -90,8 +92,11 @@ and 4 seconds for the database reads behind `/status.json` and `/api/v1/stats`.
An AS number, prefix or mask length that does not parse answers 400, as does a
mask length out of range for its IP version. An AS the database does not hold,
or a prefix with no live route, answers 404. The HTML pages give these errors as
plain text. A 500 answer says only `Internal server error`, and a host name that
does not resolve only `could not resolve host name`; the detail goes to the log.
plain text. Under `/api/v1` and `/ip/`, a path no route matches answers 404 with
the message `Not found`, and `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`,
`TRACE` or `CONNECT` answers 405 with `Method not allowed`. A 500 answer says
only `Internal server error`, and a host name that does not resolve only
`could not resolve host name`; the detail goes to the log.
Every answer carries `Strict-Transport-Security`, `Content-Security-Policy`,
`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` and
+6 -5
View File
@@ -116,17 +116,18 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
return err
}
// Start ASN WHOIS fetcher for background updates. The HTTP handlers read
// the server's fetcher without a lock, so it is set before they run.
rw.asnFetcher = NewASNFetcher(rw.db, rw.logger.Logger)
rw.asnFetcher.Start(ctx)
rw.server.SetASNFetcher(rw.asnFetcher)
// Start HTTP server
err = rw.server.Start(ctx)
if err != nil {
return err
}
// Start ASN WHOIS fetcher for background updates
rw.asnFetcher = NewASNFetcher(rw.db, rw.logger.Logger)
rw.asnFetcher.Start(ctx)
rw.server.SetASNFetcher(rw.asnFetcher)
// Start database maintenance goroutine
rw.dbMaintainer = NewDBMaintainer(rw.db, rw.logger.Logger)
rw.dbMaintainer.Start(ctx)
+1 -1
View File
@@ -388,7 +388,7 @@ func (h *PrefixHandler) flushBatch() {
// Parse CIDR to get IP version
_, ipVersion, err := parseCIDR(update.prefix)
if err != nil {
h.logger.Error("Failed to parse CIDR for withdrawal",
h.logger.Error("Failed to parse CIDR for route deletion",
"prefix", update.prefix, "error", err)
continue
+9 -3
View File
@@ -142,8 +142,9 @@ func deadlinePassed(ctx context.Context) bool {
//
// If a response is not valid JSON or is empty when JSON is expected, the middleware
// returns a properly formatted JSON error response. If the request's time limit
// has passed, it answers 504 with the error message "Request timeout". For other
// errors, it returns "Internal server error" with status 500 if the original
// has passed, it answers 504 with the error message "Request timeout". A 4xx
// keeps its status, with the status's standard text as the message. For other
// errors, it returns "Internal server error", with status 500 if the original
// status was 200.
//
// Non-JSON responses are passed through unchanged.
@@ -203,8 +204,13 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
w.WriteHeader(statusCode)
errorMsg := internalErrorMessage
if statusCode == http.StatusGatewayTimeout {
switch {
case statusCode == http.StatusGatewayTimeout:
errorMsg = requestTimeoutMessage
case statusCode >= http.StatusBadRequest &&
statusCode < http.StatusInternalServerError:
errorMsg = http.StatusText(statusCode)
}
response := map[string]any{
@@ -58,6 +58,34 @@ func TestResponsesCarrySecurityHeaders(t *testing.T) {
}
}
// TestJSONValidationKeeps4xxWithoutBody wraps a handler that answers 400
// without a body in JSONValidationMiddleware and checks that the JSON error
// body keeps the 400 and gives the status's own text. Before the fix it said
// "Internal server error"
// (https://git.eeqj.de/sneak/routewatch/issues/111).
func TestJSONValidationKeeps4xxWithoutBody(t *testing.T) {
t.Parallel()
handler := JSONValidationMiddleware(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil))
const name = "400 without a body"
if rec.Code != http.StatusBadRequest {
t.Errorf("%s: status %d, want %d; body %s",
name, rec.Code, http.StatusBadRequest, rec.Body)
}
checkJSONErrorCode(t, name, rec, http.StatusBadRequest)
checkJSONErrorMessage(t, name, rec, http.StatusText(http.StatusBadRequest))
}
// 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
+34 -1
View File
@@ -1,18 +1,33 @@
package server
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// The error messages the JSON routes under /api/v1 and /ip answer with when no
// route matches the path, and when none takes the method.
const (
notFoundMessage = "Not found"
methodNotAllowedMessage = "Method not allowed"
// jsonRoutesAllow is the Allow header of their 405 answer: every one of
// them takes GET and HEAD, and no other method.
jsonRoutesAllow = "GET, HEAD"
)
// setupRoutes configures the HTTP routes
func (s *Server) setupRoutes() {
r := chi.NewRouter()
// Middleware
r.Use(DecodedPathMiddleware)
// GetHead serves HEAD with the GET handler. It looks the route up by the
// path DecodedPathMiddleware sets, so it comes after it.
r.Use(middleware.GetHead)
r.Use(SecurityHeadersMiddleware)
r.Use(middleware.RequestID)
r.Use(RealIPMiddleware(s.trustedProxies))
@@ -40,16 +55,34 @@ func (s *Server) setupRoutes() {
r.Get("/prefixlength/{length}", s.handlePrefixLength())
r.Get("/prefixlength6/{length}", s.handlePrefixLength6())
// 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) {
s.writeJSONError(w, http.StatusNotFound, notFoundMessage)
}
jsonMethodNotAllowed := func(w http.ResponseWriter, _ *http.Request) {
// chi's own 405 answer sets Allow; one set in its place must too.
w.Header().Set("Allow", jsonRoutesAllow)
s.writeJSONError(w, http.StatusMethodNotAllowed, methodNotAllowedMessage)
}
// IP info JSON endpoints (replaces old /ip redirect)
r.Route("/ip", func(r chi.Router) {
r.Use(JSONValidationMiddleware)
r.Get("/", s.handleIPInfo()) // Client IP
r.NotFound(jsonNotFound)
r.MethodNotAllowed(jsonMethodNotAllowed)
r.Get("/", s.handleIPInfo()) // Client IP
// GetHead does not reach this route: mounting the group registers
// /ip and /ip/ for every method, so HEAD arrives here as HEAD.
r.Head("/", s.handleIPInfo())
r.Get("/{addr}", s.handleIPInfo()) // Specified IP
})
// API routes
r.Route("/api/v1", func(r chi.Router) {
r.Use(JSONValidationMiddleware)
r.NotFound(jsonNotFound)
r.MethodNotAllowed(jsonMethodNotAllowed)
r.Get("/stats", s.handleStats())
r.Get("/ip/{ip}", s.handleIPLookup())
r.Get("/as/{asn}", s.handleASDetailJSON())
+100
View File
@@ -0,0 +1,100 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestRoutesAnswerHEAD sends HEAD for a page and JSON routes through the
// server's router and checks that each answers as GET does. /ip and /ip/, the
// lookup of the caller's own address, are in the list because chi's GetHead
// does not reach them. Before the fix every route answered HEAD with 405
// (https://git.eeqj.de/sneak/routewatch/issues/111).
func TestRoutesAnswerHEAD(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
for _, path := range []string{"/", statsPath, "/ip", "/ip/"} {
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodHead, path, nil))
if rec.Code != http.StatusOK {
t.Errorf("HEAD %s: status %d, want %d; body %s",
path, rec.Code, http.StatusOK, rec.Body)
}
}
}
// TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors sends a method the JSON
// routes do not take, and a path none of them matches, to each group of JSON
// routes through the server's router. Each must answer with the JSON error
// body, and a 405 must name the methods the routes take in its Allow header.
// Before the fix a 405 said "Internal server error" and a 404 was chi's plain
// text answer (https://git.eeqj.de/sneak/routewatch/issues/111).
func TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
tests := []struct {
method string
path string
wantCode int
wantMsg string
}{
{http.MethodPost, statsPath, http.StatusMethodNotAllowed, methodNotAllowedMessage},
{http.MethodPost, "/ip/", http.StatusMethodNotAllowed, methodNotAllowedMessage},
{http.MethodGet, "/api/v1/nothing", http.StatusNotFound, notFoundMessage},
{http.MethodGet, "/ip/192.0.2.1/nothing", http.StatusNotFound, notFoundMessage},
}
for _, tt := range tests {
name := tt.method + " " + tt.path
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), tt.method, tt.path, nil))
if rec.Code != tt.wantCode {
t.Errorf("%s: status %d, want %d; body %s",
name, rec.Code, tt.wantCode, rec.Body)
}
checkJSONErrorCode(t, name, rec, tt.wantCode)
checkJSONErrorMessage(t, name, rec, tt.wantMsg)
allow := rec.Header().Get("Allow")
if tt.wantCode == http.StatusMethodNotAllowed && allow != jsonRoutesAllow {
t.Errorf("%s: Allow %q, want %q", name, allow, jsonRoutesAllow)
}
}
}
// checkJSONErrorMessage checks that rec holds the JSON error body with the
// given message.
func checkJSONErrorMessage(
t *testing.T, name string, rec *httptest.ResponseRecorder, want string,
) {
t.Helper()
var body struct {
Error struct {
Msg string `json:"msg"`
} `json:"error"`
}
err := json.Unmarshal(rec.Body.Bytes(), &body)
if err != nil {
t.Errorf("%s: decoding the answer %q: %v", name, rec.Body, err)
return
}
if body.Error.Msg != want {
t.Errorf("%s: error message %q, want %q", name, body.Error.Msg, want)
}
}
+2 -1
View File
@@ -139,7 +139,8 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}
// SetASNFetcher sets the ASN WHOIS fetcher for on-demand lookups.
// SetASNFetcher sets the ASN WHOIS fetcher for on-demand lookups. Call it
// before Start: the handlers read the fetcher without a lock.
func (s *Server) SetASNFetcher(fetcher ASNFetcher) {
s.asnFetcher = fetcher
}
+5 -5
View File
@@ -106,12 +106,12 @@ type RawMessageHandler func(line string)
// handlerMetrics tracks performance metrics for a handler
type handlerMetrics struct {
processedCount uint64 // Total messages processed
droppedCount uint64 // Total messages dropped
droppedCount atomic.Uint64 // Total messages dropped; not under mu
totalTime time.Duration // Total processing time (for average calculation)
minTime time.Duration // Minimum processing time
maxTime time.Duration // Maximum processing time
queueHighWaterMark int // Maximum queue length seen
mu sync.Mutex // Protects the metrics
mu sync.Mutex // Protects the other metrics
}
// handlerInfo wraps a handler with its queue and metrics
@@ -371,7 +371,7 @@ func (s *Streamer) GetHandlerStats() []HandlerStats {
QueueCapacity: cap(info.queue),
QueueHighWaterMark: info.metrics.queueHighWaterMark,
ProcessedCount: info.metrics.processedCount,
DroppedCount: info.metrics.droppedCount,
DroppedCount: info.metrics.droppedCount.Load(),
MinProcessTime: info.metrics.minTime,
MaxProcessTime: info.metrics.maxTime,
}
@@ -488,7 +488,7 @@ func (s *Streamer) logMetrics() {
"queue_len", len(info.queue),
"queue_cap", cap(info.queue),
"processed", info.metrics.processedCount,
"dropped", info.metrics.droppedCount,
"dropped", info.metrics.droppedCount.Load(),
"avg_time", avgTime,
"min_time", info.metrics.minTime,
"max_time", info.metrics.maxTime,
@@ -858,7 +858,7 @@ func (s *Streamer) dispatchMessage(
}
if !s.queueMessage(info, msg) {
atomic.AddUint64(&info.metrics.droppedCount, 1)
info.metrics.droppedCount.Add(1)
s.totalDropped.Add(1)
continue
@@ -621,6 +621,68 @@ func TestStreamReadsLongLine(t *testing.T) {
}
}
// blockedHandler wants UPDATE messages, has room on its queue for one, and
// does not return from HandleMessage until release is closed.
type blockedHandler struct {
release chan struct{}
}
func (blockedHandler) WantsMessage(messageType string) bool {
return messageType == ristypes.MessageTypeUpdate
}
func (blockedHandler) QueueCapacity() int { return 1 }
func (h blockedHandler) HandleMessage(*ristypes.RISMessage) { <-h.release }
// TestHandlerStatsReadWhileMessagesAreDropped has the server send a few UPDATE
// lines to a blockedHandler, so the read loop drops all but the one the
// handler holds and the one on its queue, while the test reads the handler's
// statistics. The drop count must reach the number dropped, and the race
// detector must find the count read and written safely.
func TestHandlerStatsReadWhileMessagesAreDropped(t *testing.T) {
t.Parallel()
const (
lines = 5
wantDropped = lines - 2
maxWait = 5 * time.Second
)
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, strings.Repeat(updateLine, lines))
flush(w)
<-r.Context().Done()
}))
defer srv.Close()
s := New(logger.New(), metrics.New())
s.url = srv.URL
handler := blockedHandler{release: make(chan struct{})}
s.RegisterHandler(handler)
err := s.Start(t.Context())
if err != nil {
t.Fatalf("Start returned error: %v", err)
}
defer s.Stop(t.Context())
defer close(handler.release)
// The test polls instead of waiting for a signal from the read loop: a
// signal would order the drops before the reads here, and the race
// detector would have nothing to report.
deadline := time.Now().Add(maxWait)
for s.GetHandlerStats()[0].DroppedCount < wantDropped {
if time.Now().After(deadline) {
t.Fatalf("fewer than %d messages counted as dropped within %s",
wantDropped, maxWait)
}
}
}
// TestPeerCountCountsEveryPeerHeardFrom has the server send an UPDATE from
// stateDownPeer and a KEEPALIVE from another peer, with no OPEN from either,
// and then a message that ends stateDownPeer's session: a NOTIFICATION, or a