Telemetry up-gating hardening (code portion of #32):
- Context field whitelist: telemetry `context` is filtered to a small set of
non-content diagnostic keys (route/retryable/phase/exit_code/duration_ms/
attempt) before persistence. Unknown keys — including potentially identifying
ones (email, full file path, prompt, raw IP) — are dropped, so a client
regression cannot land arbitrary JSON in the store. Empty/unparseable/no-allowed-key
context is dropped to "".
- Per-field size cap: stack_top and context are truncated to 8KiB after
redaction (backstop against unbounded blobs within batch limits).
- Retention: daily master-only task deletes telemetry rows older than
HEICODE_TELEMETRY_RETENTION_DAYS (default 30; <=0 disables).
HEICODE_TELEMETRY_RETENTION_INTERVAL_HOURS (default 24) sets cadence.
model.DeleteTelemetryEventsBefore(cutoff) + controller.StartTelemetryRetentionTask()
wired into main.go under IsMasterNode.
- GET /api/heicode/config telemetry block now surfaces retention_days for
client/admin transparency.
Tests: whitelist drop/keep, size cap, redaction-within-allowed-key. go build/vet
clean; controller telemetry tests pass.
Affects: Manager only (telemetry ingest + retention). No billing/consume-log
change (telemetry still never bills). Privacy-doc disclosure + production
enable-checklist portions of #32 tracked in heicodeDocs sync (#34) / desktop
client API docs (#35).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previous SetTrustedProxies commit (407dbb7) was necessary but
insufficient. In production Manager sits behind Cloudflare in
proxy mode, which:
- strips the inbound X-Forwarded-For header
- sets CF-Connecting-IP with the real client IP
Gin's default ClientIP() only knows about X-Forwarded-For + X-Real-IP
— it does NOT recognize CF-Connecting-IP. So every request showed the
docker bridge peer (10.2.3.4) in audit fields and rate-limit buckets
even after we added private ranges to TrustedProxies.
Setting TrustedPlatform = gin.PlatformCloudflare instructs Gin to
read CF-Connecting-IP as ground truth, bypassing the XFF parser.
When the header is absent (health checks, direct non-CF probes)
Gin falls back through TrustedProxies → XFF → RemoteAddr as before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Manager was created via gin.New() without calling SetTrustedProxies,
which in Gin v1.7+ defaults to trusting NOTHING — c.ClientIP() returned
the docker bridge peer (e.g. 10.2.3.4) instead of the real client IP
populated in X-Forwarded-For by the front reverse proxy.
Symptoms observed in production:
- Devices page showed every user's "Last IP" as 10.2.3.4 / 10.2.3.5
- tokens.device_last_seen_ip audit field useless for security review
- Token IP allowlists effectively bypassed (always saw docker IP)
- Rate-limit buckets keyed on docker IP — all users share a bucket
Fix: SetTrustedProxies with the standard RFC1918 + loopback ranges.
Covers every realistic Manager topology (docker compose, k8s ClusterIP,
reverse proxy on same VM). Cloudflare-direct topologies still need the
CF published ranges added; document that inline rather than auto-fetch
since we currently always front with Caddy/nginx.
UI cosmetic: When device_name is empty (pre-0.3.3 desktop clients
didn't always send it), Devices page now synthesises a label like
"Windows · 4f3a" from platform + last 4 chars of device_id instead
of the generic "Unnamed device", so users can tell their devices
apart at a glance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eliminate sk- bearer from the client wire entirely. V2 requests
authenticate via Ed25519 device signature (over a canonical that
binds method/path/timestamp/nonce/fingerprint/eph-pubkey/plaintext-
body-hash) and encrypt the request body with X25519 ECDH +
ChaCha20-Poly1305-AEAD. Server-issued sk- tokens still exist for
legacy callers during a 30-day deadline window; after the deadline
bare-bearer sk- on /v1/* is rejected.
What's new server-side:
- model/server_key.go + service/server_keys.go: long-lived X25519
keypair persisted in DB. Private half is AES-256-GCM-sealed with a
key derived from CRYPTO_SECRET so a SQL dump alone doesn't leak it.
Generated on first launch by main.go::EnsureServerECDHKey.
- common/crypto.go: SealWithCryptoSecret / UnsealWithCryptoSecret
helpers (AES-GCM); SafeWipe defense-in-depth zero-out.
- controller/server_pubkey.go + GET /api/server-pubkey: public
endpoint clients fetch at startup to obtain the ECDH pubkey.
- middleware/body_decrypt.go: ChaCha20-Poly1305 decrypt of V2 bodies.
AD binds device_id/timestamp/nonce/method/path so tampering any
fails AEAD verify. Replaces c.Request.Body with plaintext for
downstream relay handlers to consume unchanged.
- middleware/device_signature.go: new VerifyV2DeviceSignedRequest()
looks up token by device_id (not bearer) and verifies an extended
canonical that includes the ephemeral pubkey + plaintext body hash.
- middleware/auth.go::TokenAuth: dispatch on Content-Encoding header.
V2 path skips ValidateUserToken entirely. Legacy path adds a 30-day
/v1/* deadline knob.
- model/token.go::FindTokenByDeviceId: V2 lookup helper.
- controller/device.go::PairDevice: stops returning the sk in
responses. Client identifies itself by device_id + signature from
now on, no bearer needed.
- setting/operation_setting/device_binding_setting.go: new
LegacySkV1DeadlineMs knob (0 = disabled until operator sets it).
Backward compatibility: V1 device-signed tokens (those issued by
the earlier PairDevice that DID return a sk-) keep working through
the legacy bearer path; the existing V1 signature middleware still
runs for them. The 30-day deadline is opt-in until ops sets it.
Tests: V1 regression suite passes (middleware + common).
V2-specific tests come in a follow-up commit alongside the client
encryptedFetch wiring; deferring lets us land the server-side
plumbing first without coupling.