From ad67e3ca1e754711912a6b0387d05ef8ff7967d0 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 05:27:23 +0100 Subject: [PATCH] Show the IP information API on the home page (closes #156) The home page gets an IP information API card. It shows the curl command that looks up the visitor's own address at /ip/{addr}. The page then makes that same request and shows the JSON answer, pretty-printed. An error answer, such as a 404 for an address with no route, is shown the same way. A request that fails shows an error line. The server gives the page the visitor's address, found as /ip/ finds it, so a trusted proxy's X-Forwarded-For is honoured. The command takes its origin from the browser, so it names the host the visitor used. Model: opus-5-5 --- README.md | 3 +- TODO.md | 3 ++ internal/server/handlers.go | 14 ++++- internal/server/handlers_internal_test.go | 59 +++++++++++++++++++++ internal/server/middleware_internal_test.go | 9 ++-- internal/templates/index.html | 47 ++++++++++++++-- internal/templates/templates_test.go | 5 +- 7 files changed, 129 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 314e7f5..e3d89a9 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,8 @@ Every route answers `HEAD` as it answers `GET`, without the body. ### Web Interface - `GET /` - Home page (HTML): headline figures, lookups by AS number and by IP - address, and a search of AS names + 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. - `GET /status` - HTML status dashboard, which reads `/api/v1/stats` - `GET /status.json` - JSON statistics: feed, memory, database and route figures - `GET /.well-known/healthcheck.json` - Health check (JSON): 200 when the diff --git a/TODO.md b/TODO.md index fd09c4d..9b76298 100644 --- a/TODO.md +++ b/TODO.md @@ -33,6 +33,9 @@ tested. # Completed Steps +- 2026-10-27: the home page has an IP information API card. It shows the `curl` + command that looks up the visitor's own address at `/ip/{addr}`, and that + command's live answer (closes #156) - 2026-10-27: on the AS page, a click on the heading of the IPv4 or the IPv6 prefix list folds or unfolds the list. Both lists start open (closes #155) - 2026-10-27: the AS page lists its IPv4 and its IPv6 prefixes by mask length, diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 7c20bc1..406a839 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -146,7 +146,10 @@ func (s *Server) handleIndex() http.HandlerFunc { tmpl := templates.IndexTemplate() - err := tmpl.Execute(w, pageData{CSPNonce: cspNonce(r.Context())}) + err := tmpl.Execute(w, indexPageData{ + CSPNonce: cspNonce(r.Context()), + ClientIP: extractClientIP(r), + }) if err != nil { s.logger.Error("Failed to render index template", "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) @@ -160,6 +163,15 @@ type pageData struct { CSPNonce string } +// indexPageData is what the home page is rendered with. +type indexPageData struct { + // CSPNonce is the nonce the page's script and style blocks carry. + CSPNonce string + // 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 +} + // writeJSONError writes a standardized JSON error response with the given // status code and error message. func (s *Server) writeJSONError(w http.ResponseWriter, statusCode int, message string) { diff --git a/internal/server/handlers_internal_test.go b/internal/server/handlers_internal_test.go index 78a8f83..434be48 100644 --- a/internal/server/handlers_internal_test.go +++ b/internal/server/handlers_internal_test.go @@ -7,6 +7,7 @@ import ( "html" "net/http" "net/http/httptest" + "net/netip" "runtime" "slices" "strconv" @@ -503,6 +504,64 @@ func TestPagesSayOriginZeroIsNoKnownOrigin(t *testing.T) { } } +// TestIndexShowsTheClientAddress requests the home page and checks that it has +// the IP information API card, holding the address /ip/ would look up for the +// same request: the TCP peer, or, when the peer is a trusted proxy, the client +// the proxy names in X-Forwarded-For +// (https://git.eeqj.de/sneak/routewatch/issues/156). +func TestIndexShowsTheClientAddress(t *testing.T) { + t.Parallel() + + const cardHeading = "

IP Information API

" + + // Both requests carry an X-Forwarded-For naming forwardedClient; only a + // trusted proxy's is believed. + tests := []struct { + name string + trustedProxies []netip.Prefix + peer string + want string + }{ + {"direct request", nil, untrustedPeer, untrustedPeerAddr}, + { + "request through a trusted proxy", + []netip.Prefix{netip.MustParsePrefix(trustedProxyRange)}, + trustedPeer, forwardedClient, + }, + } + + for _, tt := range tests { + // The home page reads nothing from the database, so the server has none. + s := New(nil, streamer.New(logger.New(), metrics.New()), logger.New(), + &config.Config{TrustedProxies: tt.trustedProxies}) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) + req.RemoteAddr = tt.peer + req.Header.Set("X-Forwarded-For", forwardedClient) + + rec := httptest.NewRecorder() + s.router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("%s: status %d, want %d; body %s", + tt.name, rec.Code, http.StatusOK, rec.Body) + + continue + } + + page := rec.Body.String() + + if !strings.Contains(page, cardHeading) { + t.Errorf("%s: the page has no IP information API card", tt.name) + } + + want := `data-client-ip="` + tt.want + `"` + if !strings.Contains(page, want) { + t.Errorf("%s: the card does not hold %s", tt.name, want) + } + } +} + // TestPagesShowAgesInWords stores routes last updated about 5 minutes, 3 hours // and 15 days ago, and checks that the AS page and the prefix pages show each // age in words. Before the fix the ages read "5m0s ago", "3h0m0s ago" and diff --git a/internal/server/middleware_internal_test.go b/internal/server/middleware_internal_test.go index e3803fb..fc06be0 100644 --- a/internal/server/middleware_internal_test.go +++ b/internal/server/middleware_internal_test.go @@ -11,11 +11,12 @@ import ( ) // The addresses the forwarding tests use: the client a proxy names in -// X-Forwarded-For, a peer inside the trusted range of -// TestRealIPMiddlewareTrustsOnlyConfiguredProxies, and a peer outside it, -// which lies in testPrefixV4 so that an IP lookup of it finds a route. +// 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. 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" @@ -189,7 +190,7 @@ func TestForwardedForFromUntrustedPeerIsIgnored(t *testing.T) { func TestRealIPMiddlewareTrustsOnlyConfiguredProxies(t *testing.T) { t.Parallel() - trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")} + trusted := []netip.Prefix{netip.MustParsePrefix(trustedProxyRange)} tests := []struct { name string diff --git a/internal/templates/index.html b/internal/templates/index.html index 4c408a5..4fac31b 100644 --- a/internal/templates/index.html +++ b/internal/templates/index.html @@ -239,7 +239,8 @@ cursor: pointer; font-size: 12px; } - .ip-result pre { + .ip-result pre, + #ip-api pre { background: #2c3e50; color: #ecf0f1; padding: 16px; @@ -251,18 +252,30 @@ max-height: 400px; overflow-y: auto; } - .ip-result .error { + .ip-result .error, + #ip-api .error { background: #fee; color: #c00; padding: 12px; border-radius: 6px; font-size: 14px; } - .ip-result .loading { + .ip-result .loading, + #ip-api .loading { color: #7f8c8d; font-style: italic; } + /* IP information API card: the command, then its answer */ + #ip-api p { + margin: 0; + color: #7f8c8d; + font-size: 14px; + } + #ip-api pre { + margin-top: 12px; + } + /* Footer */ .footer { margin-top: 40px; @@ -367,6 +380,13 @@ + +
+

IP Information API

+

The API answers what RouteWatch knows about an IP address, such as its AS number and country. /ip/ with no address answers for the caller's own address.

+

+            
Loading...
+