diff --git a/client/internal/metrics/infra/README.md b/client/internal/metrics/infra/README.md index 0a69404df..7c23e42bd 100644 --- a/client/internal/metrics/infra/README.md +++ b/client/internal/metrics/infra/README.md @@ -32,13 +32,24 @@ Clients do not talk to InfluxDB directly. An ingest server sits between clients ```text Client ──POST──▶ Ingest Server (:8087) ──▶ InfluxDB (internal) │ + ├─ Checks the X-Peer-ID header format ├─ Validates line protocol ├─ Allowlists measurements, fields, and tags ├─ Rejects out-of-bound values └─ Serves remote config at /config ``` -- **No secret/token-based client auth** — the ingest server holds the InfluxDB token server-side. Clients must send a hashed peer ID via `X-Peer-ID` header. +- **Intentionally unauthenticated** — the endpoint receives obfuscated telemetry from + the peers of both cloud and self-hosted deployments. For a self-hosted peer there is + no shared trust anchor with this server, so there is nothing to authenticate against. +- **`X-Peer-ID` is a correlation tag, not a credential** — it carries the obfuscated + peer identifier so samples from one peer can be grouped. The server only checks that + the header is well-formed (16 hex chars); a malformed value is rejected with + `400 Bad Request`, not `401`. Any well-formed value is accepted by design, and the + header must not be relied on for access control. The header itself is not forwarded + to InfluxDB — the stored `peer_id` tag comes from the request body and is constrained + only by the tag allowlist and the maximum tag value length. +- **The InfluxDB token stays server-side** — clients never hold a write credential. - **InfluxDB is not exposed** — only accessible within the docker network - Source: `ingest/main.go` @@ -61,7 +72,7 @@ Tags: - `version`: NetBird version string - `os`: Operating system (linux, darwin, windows, android, ios, etc.) - `arch`: CPU architecture (amd64, arm64, etc.) -- `peer_id`: anonymised peer identifier (truncated SHA-256 of the WireGuard public key) +- `peer_id`: obfuscated peer identifier (truncated SHA-256 of the WireGuard public key) - `connection_pair_id`: deterministic identifier for the peer pair, identical on both sides **Note:** `SignalingReceived` is set when the first offer or answer arrives from the remote peer (in both initial and reconnection paths). It excludes the potentially unbounded wait for the remote peer to come online. @@ -195,7 +206,7 @@ docker compose up -d ``` This starts: -- **Ingest server** on http://localhost:8087 — accepts client metrics (requires `X-Peer-ID` header, no secret/token auth) +- **Ingest server** on http://localhost:8087 — accepts client metrics (unauthenticated by design; expects a well-formed `X-Peer-ID` correlation tag) - **InfluxDB** — internal only, not exposed to host - **Grafana** on http://localhost:3001 diff --git a/client/internal/metrics/infra/ingest/main.go b/client/internal/metrics/infra/ingest/main.go index 91405b85f..a9fb25178 100644 --- a/client/internal/metrics/infra/ingest/main.go +++ b/client/internal/metrics/infra/ingest/main.go @@ -22,6 +22,11 @@ const ( maxDurationSeconds = 86400.0 // reject any duration field > 24 hours peerIDLength = 16 // truncated SHA-256: 8 bytes = 16 hex chars maxTagValueLength = 64 // reject tag values longer than this + readTimeout = 30 * time.Second // must fit reading a compressed body up to maxBodySize + writeTimeout = 60 * time.Second // must exceed the upstream client timeout below + idleTimeout = 120 * time.Second + readHeaderTimeout = 10 * time.Second + maxHeaderBytes = 1 << 20 // 1 MB ) type measurementSpec struct { @@ -124,8 +129,17 @@ func main() { fmt.Fprint(w, "ok") //nolint:errcheck }) + srv := &http.Server{ + Addr: listenAddr, + ReadTimeout: readTimeout, + ReadHeaderTimeout: readHeaderTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + MaxHeaderBytes: maxHeaderBytes, + } + log.Printf("ingest server listening on %s, forwarding to %s", listenAddr, influxURL) - if err := http.ListenAndServe(listenAddr, nil); err != nil { //nolint:gosec + if err := srv.ListenAndServe(); err != nil { log.Fatal(err) } } @@ -137,8 +151,8 @@ func handleIngest(client *http.Client, influxURL, influxToken string) http.Handl return } - if err := validateAuth(r); err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) + if err := validatePeerIDFormat(r); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -187,8 +201,13 @@ func forwardToInflux(w http.ResponseWriter, r *http.Request, client *http.Client io.Copy(w, resp.Body) //nolint:errcheck } -// validateAuth checks that the X-Peer-ID header contains a valid hashed peer ID. -func validateAuth(r *http.Request) error { +// validatePeerIDFormat checks the shape of the X-Peer-ID header. The header is a +// correlation tag, not a credential: this endpoint is intentionally +// unauthenticated so that peers of self-hosted deployments, for which no shared +// trust anchor exists, can report obfuscated telemetry. The header is not forwarded +// to InfluxDB, so this check does not bound the stored peer_id tag; it only rejects +// a malformed header as a bad request rather than an auth failure. +func validatePeerIDFormat(r *http.Request) error { peerID := r.Header.Get("X-Peer-ID") if peerID == "" { return fmt.Errorf("missing X-Peer-ID header") diff --git a/client/internal/metrics/infra/ingest/main_test.go b/client/internal/metrics/infra/ingest/main_test.go index 96287813e..526f127cb 100644 --- a/client/internal/metrics/infra/ingest/main_test.go +++ b/client/internal/metrics/infra/ingest/main_test.go @@ -94,7 +94,7 @@ func TestValidateLineProtocol_RejectsOnBadLine(t *testing.T) { require.Error(t, err) } -func TestValidateAuth(t *testing.T) { +func TestValidatePeerIDFormat(t *testing.T) { tests := []struct { name string peerID string @@ -113,7 +113,7 @@ func TestValidateAuth(t *testing.T) { if tt.peerID != "" { r.Header.Set("X-Peer-ID", tt.peerID) } - err := validateAuth(r) + err := validatePeerIDFormat(r) if tt.wantErr { require.Error(t, err) } else {