Show the IP information API on the home page (closes #156)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
The home page gets an IP information API card. It shows the curl command that asks /ip/ about the visitor's own address. 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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = "<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
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
</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>
|
||||
|
||||
<footer class="footer">
|
||||
@@ -584,6 +604,27 @@
|
||||
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
|
||||
// check runs every second on its own, so a request that never returns
|
||||
// still turns the spinner into "stale".
|
||||
|
||||
@@ -10,13 +10,14 @@ import (
|
||||
"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 {
|
||||
t.Helper()
|
||||
|
||||
var page bytes.Buffer
|
||||
|
||||
err := tmpl.Execute(&page, struct{ CSPNonce string }{})
|
||||
err := tmpl.Execute(&page, struct{ CSPNonce, ClientIP string }{})
|
||||
if err != nil {
|
||||
t.Fatalf("rendering the page: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user