Show the IP information API on the home page (closes #156) #183
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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".
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user