Skip to content

feat(relay): localhost-only /metrics listener with bind-address validation (#60)#87

Merged
ilmoniemi merged 3 commits into
mainfrom
feature/60
May 13, 2026
Merged

feat(relay): localhost-only /metrics listener with bind-address validation (#60)#87
ilmoniemi merged 3 commits into
mainfrom
feature/60

Conversation

@ilmoniemi
Copy link
Copy Markdown
Contributor

What

Stands up a second http.Server for /metrics, bound to a loopback IP
literal only, and refuses to boot on any non-loopback --metrics-listen
value. Pulled together with the registry and handler from #59 and the
collector from #61.

  • New internal/relay/metrics_listen.go:
    • CheckLoopbackBind(addr) — pure validator. Refuses hostnames
      (DNS-time TOCTOU), non-loopback IPs, port 0, and malformed inputs.
      Loopback IPv4 (127.0.0.0/8) and IPv6 (::1) are accepted.
    • NewMetricsServer(addr, h) — opt-out aware. addr == "" returns
      (nil, nil); otherwise validates and returns an *http.Server with
      timeouts matching the public listener (gosec G114 compliant).
  • cmd/pyrycode-relay/main.go:
    • New --metrics-listen flag, default 127.0.0.1:9090.
    • Constructs metricsReg, wires NewConnectionsMetrics, builds a
      dedicated mux, and launches the listener in a goroutine.
    • Metrics port joins both expected and actual sets for
      CheckListenerPorts (relay: refuse to boot if listener ports exceed expected set #81) in both insecure and autocert modes; the
      listener is skipped entirely when metricsSrv == nil.

Issue

Closes #60.

Testing

  • internal/relay/metrics_listen_test.go (new):
    • TestCheckLoopbackBind_Matrix — every branch of the validator,
      including the hostname-refusal case (localhost:9090).
    • TestNewMetricsServer_Matrix — opt-out, rejection, and timeout
      pinning.
    • TestMetricsServer_EndToEnd_HappyPath — real net.Listen + HTTP
      round-trip on an ephemeral loopback port, asserts 200 + Prometheus
      text Content-Type + non-empty body.
  • make vet, make test (with -race), and make build all clean.

Architecture compliance

Follows docs/specs/architecture/60-metrics-listener-loopback-bind.md:

  • Validator and constructor in one new helper file (~80 LoC); no edits
    to metrics.go (the relay: adopt prometheus/client_golang and introduce metrics registry scaffolding #59 seam is fixed).
  • Three localised edits to main.go: flag declaration, construction
    block, and per-mode goroutine + port-set integration.
  • Hostname refusal documented in the CheckLoopbackBind docstring with
    the DNS-rebinding / /etc/hosts-race rationale so future contributors
    do not "fix" it by adding net.LookupHost.
  • Metrics goroutine treats ListenAndServe return as process-fatal
    (os.Exit(1)), matching the http-01 listener's shape — loud failure
    over silent correction.

🤖 Generated with Claude Code

ilmoniemi added 2 commits May 13, 2026 10:08
…ation (#60)

Adds a second http.Server bound to a loopback IP for /metrics scraping,
separate from the internet-exposed public listener. Operators reach
/metrics via SSH tunnel or sidecar; the listener defaults to
127.0.0.1:9090 and accepts an empty value as the opt-out.

CheckLoopbackBind is the structural defence: bind address must be an
IP literal (hostnames refused to close the DNS-time TOCTOU window) and
that IP must satisfy net.IP.IsLoopback. Non-loopback addresses fail
loudly at boot rather than silently publishing operational state.

The metrics port joins both the expected and actual sets of #81's
listener-port allowlist so the boot check stays consistent across the
opt-out, insecure-mode, and autocert-mode paths.
@ilmoniemi
Copy link
Copy Markdown
Contributor Author

Code Review: #60

Decision: PASS

Findings

  • [NIT] internal/relay/metrics_listen.go:42-48CheckLoopbackBind calls net.SplitHostPort directly to extract host, then calls ListenerPort(addr) which re-runs SplitHostPort internally. Functionally fine (boot-time, runs once), but a single split + reuse of the host/port pair would shed a few lines. Not worth a re-roll.
  • [NIT] internal/relay/metrics_listen_test.go:145 — happy-path test uses the literal 127.0.0.1:9090 to drive NewMetricsServer, then ignores srv.Addr and binds an ephemeral port via net.Listen("tcp", "127.0.0.1:0"). The constructor never binds, so there is no collision risk; the spec doc explicitly authorises this shape. Just flagging that the literal could mislead a future reader into thinking the test binds 9090 — the surrounding doc comment defuses that, so leaving as-is is reasonable.

Summary

Implementation faithfully matches the architect's spec. The load-bearing security choice (refuse hostnames to avoid DNS-time TOCTOU) is documented in the CheckLoopbackBind docstring and asserted by TestCheckLoopbackBind_Matrix (localhost:9090 row). Opt-out path is structural (addr == ""(nil, nil) short-circuits before validation). Port-allowlist integration adds the metrics port to both expected and actual sets in both insecure and autocert modes — #81's asymmetric check stays consistent. Goroutine shape matches the existing http-01 listener pattern (os.Exit(1) on ListenAndServe return — loud-failure-over-silent).

  • go vet, go test -race ./..., and go build ./... all clean locally.
  • Timeouts (10s/60s/60s/120s) explicitly set in NewMetricsServer — gosec G114 mitigated.
  • Validator matrix covers IPv4/IPv6 loopback accept, non-loopback IP reject, hostname reject, port-0/out-of-range/missing reject, and :9090 (empty host) reject. All wrap ErrNonLoopbackBind where appropriate.
  • End-to-end test exercises validator + constructor + real net.Listen + actual HTTP round-trip; race detector covers the goroutine.

)

Adds the metrics-listener feature doc and the per-ticket codebase note;
updates the metrics-registry and connection-count-gauges feature pages
where they pointed at #60 as a pending follow-up.

INDEX.md gains the new feature entry and drops the "Listener still
pending (#60)" trailer on the metrics-registry line now that the wiring
has landed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@ilmoniemi ilmoniemi merged commit e0e21b2 into main May 13, 2026
3 checks passed
@ilmoniemi ilmoniemi deleted the feature/60 branch May 13, 2026 07:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

relay: localhost-only /metrics listener with bind-address validation

1 participant