Show the IP information API on the home page (closes #156) #183

Merged
clawbot merged 1 commits from issue-156-ip-api-card into next 2026-10-27 05:27:24 +01:00
7 changed files with 129 additions and 11 deletions
+2 -1
View File
@@ -57,7 +57,8 @@ Every route answers `HEAD` as it answers `GET`, without the body.
### Web Interface ### Web Interface
- `GET /` - Home page (HTML): headline figures, lookups by AS number and by IP - `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` - HTML status dashboard, which reads `/api/v1/stats`
- `GET /status.json` - JSON statistics: feed, memory, database and route figures - `GET /status.json` - JSON statistics: feed, memory, database and route figures
- `GET /.well-known/healthcheck.json` - Health check (JSON): 200 when the - `GET /.well-known/healthcheck.json` - Health check (JSON): 200 when the
+3
View File
@@ -33,6 +33,9 @@ tested.
# Completed Steps # 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 - 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) 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, - 2026-10-27: the AS page lists its IPv4 and its IPv6 prefixes by mask length,
+13 -1
View File
@@ -146,7 +146,10 @@ func (s *Server) handleIndex() http.HandlerFunc {
tmpl := templates.IndexTemplate() 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 { if err != nil {
s.logger.Error("Failed to render index template", "error", err) s.logger.Error("Failed to render index template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -160,6 +163,15 @@ type pageData struct {
CSPNonce string 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 // writeJSONError writes a standardized JSON error response with the given
// status code and error message. // status code and error message.
func (s *Server) writeJSONError(w http.ResponseWriter, statusCode int, message string) { func (s *Server) writeJSONError(w http.ResponseWriter, statusCode int, message string) {
+59
View File
@@ -7,6 +7,7 @@ import (
"html" "html"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip"
"runtime" "runtime"
"slices" "slices"
"strconv" "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 = "<h2>IP Information API</h2>"
// 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 // 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 // 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 // age in words. Before the fix the ages read "5m0s ago", "3h0m0s ago" and
+5 -4
View File
@@ -11,11 +11,12 @@ import (
) )
// The addresses the forwarding tests use: the client a proxy names in // The addresses the forwarding tests use: the client a proxy names in
// X-Forwarded-For, a peer inside the trusted range of // X-Forwarded-For, the range of trusted proxies, a peer inside it, and a peer
// TestRealIPMiddlewareTrustsOnlyConfiguredProxies, and a peer outside it, // outside it, which lies in testPrefixV4 so that an IP lookup of it finds a
// which lies in testPrefixV4 so that an IP lookup of it finds a route. // route.
const ( const (
forwardedClient = "203.0.113.9" forwardedClient = "203.0.113.9"
trustedProxyRange = "10.0.0.0/8"
trustedPeer = "10.0.0.2:1234" trustedPeer = "10.0.0.2:1234"
untrustedPeerAddr = "192.0.2.1" untrustedPeerAddr = "192.0.2.1"
untrustedPeer = untrustedPeerAddr + ":1234" untrustedPeer = untrustedPeerAddr + ":1234"
@@ -189,7 +190,7 @@ func TestForwardedForFromUntrustedPeerIsIgnored(t *testing.T) {
func TestRealIPMiddlewareTrustsOnlyConfiguredProxies(t *testing.T) { func TestRealIPMiddlewareTrustsOnlyConfiguredProxies(t *testing.T) {
t.Parallel() t.Parallel()
trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")} trusted := []netip.Prefix{netip.MustParsePrefix(trustedProxyRange)}
tests := []struct { tests := []struct {
name string name string
+44 -3
View File
@@ -239,7 +239,8 @@
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
} }
.ip-result pre { .ip-result pre,
#ip-api pre {
background: #2c3e50; background: #2c3e50;
color: #ecf0f1; color: #ecf0f1;
padding: 16px; padding: 16px;
@@ -251,18 +252,30 @@
max-height: 400px; max-height: 400px;
overflow-y: auto; overflow-y: auto;
} }
.ip-result .error { .ip-result .error,
#ip-api .error {
background: #fee; background: #fee;
color: #c00; color: #c00;
padding: 12px; padding: 12px;
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: 14px;
} }
.ip-result .loading { .ip-result .loading,
#ip-api .loading {
color: #7f8c8d; color: #7f8c8d;
font-style: italic; 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 */
.footer { .footer {
margin-top: 40px; margin-top: 40px;
@@ -367,6 +380,13 @@
</div> </div>
</div> </div>
</div> </div>
<div class="search-card" id="ip-api">
<h2>IP Information API</h2>
<p>The API answers what RouteWatch knows about an IP address, such as its AS number and country. <code>/ip/</code> with no address answers for the caller's own address.</p>
<pre id="ip-api-command" data-client-ip="{{.ClientIP}}"></pre>
<pre id="ip-api-answer" class="loading">Loading...</pre>
</div>
</main> </main>
<footer class="footer"> <footer class="footer">
@@ -584,6 +604,27 @@
document.getElementById('ip-input').value = ''; document.getElementById('ip-input').value = '';
}); });
// IP information API card: show the curl command that asks /ip/ about
// the visitor's own address, then make that same request and show the
// JSON it answers, an error answer such as a 404 included. The answer
// holds outside data, so it is set with textContent, never innerHTML.
const ipAPICommand = document.getElementById('ip-api-command');
const ipAPIAnswer = document.getElementById('ip-api-answer');
const ipAPIURL = window.location.origin + '/ip/' + ipAPICommand.dataset.clientIp;
ipAPICommand.textContent = 'curl ' + ipAPIURL;
fetch(ipAPIURL)
.then(response => response.json())
.then(answer => {
ipAPIAnswer.className = '';
ipAPIAnswer.textContent = JSON.stringify(answer, null, 2);
})
.catch(error => {
ipAPIAnswer.className = 'error';
ipAPIAnswer.textContent = 'Error: ' + error.message;
});
// Initial load and refresh stats every REFRESH_PERIOD_MS. The freshness // Initial load and refresh stats every REFRESH_PERIOD_MS. The freshness
// check runs every second on its own, so a request that never returns // check runs every second on its own, so a request that never returns
// still turns the spinner into "stale". // still turns the spinner into "stale".
+3 -2
View File
@@ -10,13 +10,14 @@ import (
"git.eeqj.de/sneak/routewatch/internal/templates" "git.eeqj.de/sneak/routewatch/internal/templates"
) )
// renderPage renders a page with the data every page needs. // renderPage renders a page with the data every page needs, and the visitor's
// address, which the home page also needs.
func renderPage(t *testing.T, tmpl *template.Template) string { func renderPage(t *testing.T, tmpl *template.Template) string {
t.Helper() t.Helper()
var page bytes.Buffer var page bytes.Buffer
err := tmpl.Execute(&page, struct{ CSPNonce string }{}) err := tmpl.Execute(&page, struct{ CSPNonce, ClientIP string }{})
if err != nil { if err != nil {
t.Fatalf("rendering the page: %v", err) t.Fatalf("rendering the page: %v", err)
} }