diff --git a/README.md b/README.md
index 314e7f5..172f989 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 asks
+ `/ip/` about the visitor's own address, with 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..a14a875 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 asks `/ip/` about the visitor's own address, and the live answer
+ to that request (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...
+