diff --git a/.claude/agents/ui-design-reviewer.md b/.claude/agents/ui-design-reviewer.md new file mode 100644 index 0000000..3860483 --- /dev/null +++ b/.claude/agents/ui-design-reviewer.md @@ -0,0 +1,53 @@ +--- +name: ui-design-reviewer +description: Use proactively after any frontend UI change to verify the affected screens against frontend/DESIGN.md and the devmock state matrix. Drives headless Brave against the Vite devmock, asserts computed styles/DOM, and returns a prioritized findings list. Never edits repo files. +tools: Read, Grep, Glob, Bash, Write +model: sonnet +--- + +You are proxyforward's UI design reviewer. You verify frontend work; you never fix it. + +## Review criteria (read first, in order) + +1. `frontend/DESIGN.md` — the design charter. +2. `.claude/rules/frontend.md` — conventions: tokens.css-only colors/sizes/durations, + pf-* recipe boundaries, sentinel rendering. +3. The diff or screens you were asked to review (`git diff`, named components). + +## Driving the UI on this machine + +No Chrome/Edge installed; screenshots unavailable — use playwright-core + installed +Brave, headless, with DOM/computed-style assertions: + +- Dev server: `npm --prefix frontend run dev` (reuse an existing one on 5173; if the + port is held by something stale, set `PORT=5199` — vite.config.ts honors PORT with + strictPort — rather than killing whatever is there). +- In the scratchpad: `npm i playwright-core`, then + `chromium.launch({ executablePath: 'C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe', headless: true })`. +- Scenarios: `http://localhost:/?mock=agent|gateway|wizard` composed with axes + `&link=down &mode=attached &fatal=1 &fresh=1 &analytics=off + &geo=off|empty|error|pending &fx=low|high` (semantics: + `docs/agent/architecture.md` "devmock axes" and the `devmock.ts` header). + Theme/motion via `localStorage['pf-theme']` / `['pf-motion']` before load. +- Known false alarms: exactly one favicon.ico 404 console.error on the first page of + a fresh browser (no icon declared; dev-server-only); registered @property colors + compute to rgb() form, not hex. + +## What to check (the state-matrix walk) + +- All four states per data surface: geometry-matched skeleton, real data, written + empty, honest unavailable. +- Sentinels render "—", never fake zeros; status is never color alone. +- New colors/sizes/durations resolve to tokens.css custom properties (assert + computed styles). +- Motion gates on data-motion / prefersReduced(); data changes are instant under + reduced motion. +- Both themes × both roles (agent/gateway); walk every axis the change touches + before passing it. + +## Report + +Findings as CRITICAL/HIGH/MEDIUM/LOW, each with the component/file reference and the +scenario URL that reproduces it. End with what you could NOT verify headlessly +(view-transition visuals, WebView2 rendering quirks) so the human spot-checks in +`wails dev`. Write only under the scratchpad/temp directory; never modify the repo. diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index 9edce23..ad3e886 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -29,7 +29,21 @@ Read `frontend/DESIGN.md` (the charter) before any UI work; pull polish work fro specificity. - Load-bearing couplings — change both sides or nothing: `--sidebar-w` ↔ `Shell` grid, `--nav-item-h` ↔ `Sidebar.tsx ITEM_H`, `--dur-theme` ↔ - `theme.ts THEME_SWEEP_MS`. Root font 13.5 px scales every rem. + `theme.ts THEME_SWEEP_MS`. +- Every scroller rubber-bands at its ends (`rubberband.ts`). A new one must mark the + element to translate with `data-band-content` (default: its first child — wrong for + a well whose children are rows). A well that owns the surface (overlay body, menu, + the log) also takes `overscroll-y-contain`, so it bands itself; an embedded list on + a scrollable page must NOT — it keeps chaining and the *page* bounces, or reaching + its end would freeze the page under the cursor (`GeoRank.tsx`). An optional + edge-light overlay is a *sibling* of the scroller marked `data-band-glow`; + `rubberband.ts` stamps `--band-t`/`data-band` straight onto it, never onto the + scroller — the scroller's subtree is the whole screen and `--band-t` inherits, so + stamping there restyles every descendant each frame (`.pf-band-glow`, Shell/Activity). +- Root font = 13.5px × `--ui-scale` (viewport-stepped in `tokens.css`), so every + rem AND every `--fs-*`/`--sp-*` token scale together across resolutions. New + geometry that must align with JS px math (like `ITEM_H`) must NOT multiply by + `--ui-scale`; everything user-facing should. ## Footguns - `frontend/wailsjs/` is GENERATED — never hand-edit (a PreToolUse hook blocks it); diff --git a/.claude/settings.json b/.claude/settings.json index c581e18..15648f3 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -30,5 +30,9 @@ "enabledPlugins": { "gopls-lsp@claude-plugins-official": true, "typescript-lsp@claude-plugins-official": true + }, + "attribution": { + "commit": "", + "pr": "" } } diff --git a/.claude/skills/ui-change/SKILL.md b/.claude/skills/ui-change/SKILL.md index ccce524..5585e30 100644 --- a/.claude/skills/ui-change/SKILL.md +++ b/.claude/skills/ui-change/SKILL.md @@ -20,4 +20,6 @@ description: Playbook for UI-only changes to proxyforward's React frontend — p escalation trigger. 6. Gate: `npm run build` (tsc is the only checker), walk the relevant mock axes, then spot-check in `wails dev` (WebView2 ≠ your browser: clipboard, native - controls, input icons). + controls, input icons). The axis walk can be delegated: dispatch the + `ui-design-reviewer` agent (`.claude/agents/ui-design-reviewer.md`) to run it + out-of-context and report findings. diff --git a/.claude/skills/wire-protocol/SKILL.md b/.claude/skills/wire-protocol/SKILL.md index 512380a..b466690 100644 --- a/.claude/skills/wire-protocol/SKILL.md +++ b/.claude/skills/wire-protocol/SKILL.md @@ -18,7 +18,8 @@ capability semantic change, or wire-field meaning change is an escalation trigge `MaxFrame` — chunk like `MaxConnStatsPerFrame` does; never raise the cap. 4. Test both mixed-version directions: e2e `harnessOpts.offerCaps: []string{}` simulates a legacy agent (`TestLegacyRegisterFallback`). -5. Implement fully before advertising the capability — `CapTunnelUDP` is the - standing counterexample (advertised, unimplemented, live protocol-bug risk). +5. Implement fully before advertising the capability — offering one the peer can't + honor is a live protocol-bug risk (the `tunnel-udp` capability was exactly this + until it was un-advertised; don't reintroduce the pattern). 6. Protocol and implementation never change in the same commit. 7. Gate: control + e2e suites; a mixed-version manual run if the change is risky. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b810a03..3612403 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -234,9 +234,11 @@ jobs: shell: bash env: SHA: ${{ github.sha }} + # The build-metadata suffix uses the short SHA — the full 40 chars end + # up in the UI (sidebar footer, Settings → About) and overflow them. run: > wails build -platform windows/amd64 -clean -s -trimpath - -ldflags "-X proxyforward/internal/version.Version=0.0.0-dev+$SHA -X proxyforward/internal/version.Commit=$SHA" + -ldflags "-X proxyforward/internal/version.Version=0.0.0-dev+${SHA::7} -X proxyforward/internal/version.Commit=$SHA" # frontend/wailsjs is generated and committed; a PreToolUse hook blocks # hand-edits. This promotes that hook into a merge gate: bindings that have # drifted from the bound Go types fail here. diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index e26537e..159e18d 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -31,6 +31,7 @@ jobs: matrix: include: - { pkg: ./internal/control, target: FuzzReadMsg } + - { pkg: ./internal/link, target: FuzzParsePairingCode } - { pkg: ./internal/mc, target: FuzzReadVarInt } - { pkg: ./internal/mc, target: FuzzParseHandshake } - { pkg: ./internal/mc, target: FuzzReadPacket } diff --git a/CLAUDE.md b/CLAUDE.md index d30ce91..ec49b00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,21 +61,27 @@ Each entry: the rule, why, and the symbol that embodies it today. Numbers live i arms in both `agent.go` and `gateway.go`). Replies that can grow are chunked or clamped under the frame cap (`MaxConnStatsPerFrame`, `ipc.MaxStatusConns`) — never raise the cap itself. -- Never advertise a capability that isn't implemented end-to-end. (Currently violated - by `CapTunnelUDP` — see Reality check. Don't repeat this.) +- Never advertise a capability that isn't implemented end-to-end — the peer acts on + the offer, then fails. (`tunnel-udp` violated this until it was un-advertised.) ### Security (`internal/link/`, `internal/gateway/`) - TLS 1.3 only, both sides (`cert.go GatewayTLSConfig` / `AgentTLSConfig`). Trust = the gateway's self-signed ECDSA P-256 cert pinned by SHA-256 fingerprint carried - out-of-band in the pairing code `pf1://host:port/#sha256:` - (`link/pairing.go`). No CA, ever. Token and fingerprint compare in constant time + out-of-band in the pairing code `pxf://host:port/v1/pair/#sha256:` + (`link/pairing.go`; `pxf` doubles as the OS deep-link scheme, `/v1/pair/` is a + format-version + role marker so a wrong-kind link fails loudly). No CA, ever. Token and fingerprint compare in constant time (`crypto/subtle` — `gateway.go handleControlConn`, `cert.go`). - The pre-auth prologue (accept → TLS → hello) finishes within `preAuthTimeout` or dies; failed auth rate-limits per IP, fail2ban-style — successes never count (`limits.go authLimiter`). Public conns gate globally and per-IP (`limits.go connGate`; defaults in `config.go`). -- Same agentID reconnect **supersedes** (generation counter); a different agentID on - the same token is **rejected** — no flapping (`actor.go admit`). +- One shared gateway token admits **many** agents, told apart by self-asserted + `agentID`: a matching agentID **supersedes** (reconnect), a distinct one is admitted + **alongside**. Supersede is anti-flap dampened so an ID collision degrades to a slow + contest, not a loop (`actor.go admit`, `noteSupersede`). Residual risk that ships + (shared token + self-asserted ID + FCFS ports + no per-agent revocation): a + token-holder can supersede or port-squat any agent, recoverable only by rotating the + shared token — the mitigation (per-agent tokens/revocation) is deferred. - The IPC pipe ACL admits Administrators, SYSTEM, and the interactive user only (`ipc/server_windows.go pipeSecurity`). - Diagnostics bundles redact every secret, host, IP, and identity; peer IPs become @@ -87,18 +93,25 @@ Each entry: the rule, why, and the symbol that embodies it today. Numbers live i ### Liveness & lifecycle - **One liveness owner**: app-level ping in **both** directions (`pingInterval`) with - an idle read deadline (`controlIdleTimeout`); yamux keepalive OFF and its write - timeout deliberately long so the heartbeat, not yamux, declares death - (`transport/yamux.go muxConfig`). -- Nothing outside `internal/transport` imports yamux. Agent and gateway program - against `transport.Session` / `Stream` so the mux can be swapped. + an idle read deadline (`controlIdleTimeout`); the transport's own keepalive is OFF + and its write/idle timeout deliberately long so the heartbeat, not the transport, + declares death (yamux keepalive off + long write timeout in `transport/yamux.go + muxConfig`; QUIC `KeepAlivePeriod=0` + a `MaxIdleTimeout` above the liveness budget + in `transport/quicconfig.go quicConfig`). +- Nothing outside `internal/transport` imports yamux or quic-go. Agent and gateway + program against `transport.Session` / `Stream` so the transport can be swapped + (yamux-over-TCP, per-conn multi-TCP, or QUIC); the gateway chooses the data + plane once per session behind `dataPlane.openFlow` (QUIC rides the shared-session + mux plane), keeping `handleClient` transport-agnostic (`dataplane.go pickDataPlane`). - Reconnect: full-jitter exponential backoff, sequence resets after a stable period; network-change/resume ticks short-circuit it; DNS re-resolves every attempt (`link/backoff.go`, `netnotify/`, `agent.go runSession`). - **Ghost-listener guarantee**: all session/listener lifecycle runs on the gateway's - single actor goroutine; eviction closes each listener and waits for its accept loop - before anything else proceeds — a rebound port is provably free - (`actor.go evictLocked` / `bindLocked`; regression: e2e `TestAgentRestartRebinds`). + single actor goroutine; per-agent eviction closes that agent's listeners and waits + each accept loop before anything else proceeds — a rebound port is provably free, and + evicting one agent leaves the others' listeners and connections untouched (the + per-agent mux is the connection-drain boundary). (`actor.go evict` / `bindLocked`; + regressions: e2e `TestAgentRestartRebinds`, `TestEvictionIsolatesAndDrains`.) - Exactly one process owns ports and config: every engine serves the named pipe `\\.\pipe\proxyforward`; a GUI that finds it attaches as a thin client; pipe conflict is fatal by design (`engine.go Run`, `app/app.go Startup`). @@ -110,8 +123,9 @@ Each entry: the rule, why, and the symbol that embodies it today. Numbers live i intact (`relay_test.go`, e2e `TestFinalBytesThroughTunnel`). Every write refreshes a progress deadline so a parked peer can't leak a goroutine. - No Nagle anywhere: Go's default `TCP_NODELAY` end-to-end, set explicitly on the - agent's two dials (`SetNoDelay` in `agent.go runSession` and `handleDataStream`); - the yamux window is sized so a chunk burst fits in flight. + agent's dials (`SetNoDelay` in `agent.go dialGateway` — control conn and every + per-conn data conn — and `handleDataStream` for the local dial); the yamux + window is sized so a chunk burst fits in flight. - **Enforced floor**: `TestBurstThroughputAndCrossStreamLatency` in `internal/e2e/e2e_test.go` — throughput and worst cross-stream RTT bounds are in the numbers table. Run it before and after any hot-path change (`hot-path` skill). @@ -131,12 +145,15 @@ Each entry: the rule, why, and the symbol that embodies it today. Numbers live i alone (`ui.tsx StatusDot`). - All motion gates on `prefersReduced()` / `data-motion` (`motion.ts`, kill switch at the bottom of `motion.css`); data changes are instant under reduced motion, never - eased (`NumberTicker`, `charts/util.ts useTweenedValues`). + eased (`NumberTicker`, `charts/util.ts useTweenedValues`). That kill switch only + zeroes CSS durations, so *scripted* motion must gate itself in JS — the scroll + rubber band attaches no listener at all under it (`rubberband.ts useRubberBand`). - Chart series tokens `--dl/--ul/--conn/--rtt` are load-bearing names; direction mapping (wire "in/out" → UI "upload/download") happens in exactly one place, the `frontend/src/history.ts` header. Never re-map elsewhere. -- The design charter is `frontend/DESIGN.md` — one identity surface per screen, glass - as a reward, motion communicates network state, color is signal. +- The design charter is `frontend/DESIGN.md` — one identity surface per screen; every + surface is glass but only Signal Glass *answers the pointer* (never give a card the + caustics/streak/wake); motion communicates network state; color is signal. ### Privacy - Player/traffic analytics are local-only (SQLite next to the config); the only @@ -150,11 +167,7 @@ The README and the Settings/Tunnels UI **oversell**. Ground truth at 4a8b0c9: | Feature (advertised in README/UI) | Actual state | |---|---| -| Offline MOTD responder | `mc.ServeOffline` built + fuzzed, **never called**; gateway closes dead-session conns (`gateway.go handleClient`). | -| `per-conn` transport | Config-valid only; agent never reads it, gateway rejects `KindData` (`handleControlConn`). | -| UDP tunnels | No UDP socket code; `validateSpec` rejects `type:"udp"` — yet `CapTunnelUDP` is **advertised** (live protocol-bug risk). | -| Bandwidth cap | `BandwidthLimitMbps` stored, never enforced. | -| Prometheus `/metrics` | `MetricsConfig` stored, no server exists. | +| UDP tunnels | Not implemented: no UDP socket code, `validateSpec` rejects `type:"udp"`. No longer advertised (the `tunnel-udp` capability was removed); config still accepts `type:"udp"` but the gateway rejects it — a latent gap, not an oversell. | | MC status polling (MOTD/players) | Only login sniffing (`mcsniff/`); the health probe is a bare TCP dial (`health.go probeOnce`). | | Tray / minimize-to-tray / autostart | Hidden `tray_spike.go` command only; `MinimizeToTray` / `Autostart` stored, unused. | | Linux / macOS binaries | CI **builds** them (`.github/workflows/ci.yml`) so the `*_other.go` stubs can't rot, but they **cannot run**: `ipc.Serve` returns `ErrUnsupported` off Windows and every engine must serve the pipe (`engine.go Run`), so the window opens and the engine dies. Unpublished artifacts, never release assets. Fixing this means a real unix-socket IPC port. | diff --git a/README.md b/README.md index 5a140f8..aa974b1 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,18 @@ # proxyforward -**Make your Minecraft server public — no port-forwarding required.** +**Make your Minecraft server public — no port-forwarding on the Minecraft machine.** -An ngrok-style reverse tunnel: the Minecraft machine dials *out* to a machine that can -accept inbound connections, and that machine relays player traffic back through the tunnel. +An ngrok-style reverse tunnel: the machine hosting Minecraft dials *out* to a machine that +can accept inbound connections, and that machine relays player traffic back through the +one outbound link. One Windows binary is both halves. [![CI](https://github.com/xeri/proxyforward/actions/workflows/ci.yml/badge.svg)](https://github.com/xeri/proxyforward/actions/workflows/ci.yml) [![Security](https://github.com/xeri/proxyforward/actions/workflows/security.yml/badge.svg)](https://github.com/xeri/proxyforward/actions/workflows/security.yml) [![Go](https://img.shields.io/badge/Go-1.25-00ADD8?logo=go&logoColor=white)](https://go.dev) [![Platform](https://img.shields.io/badge/platform-Windows-0078D4)](#) [![GUI](https://img.shields.io/badge/GUI-Wails%20%2B%20React%2019-61DAFB?logo=react&logoColor=black)](https://wails.io) +[![Transports](https://img.shields.io/badge/transports-QUIC%20%C2%B7%20multi--TCP%20%C2%B7%20mux-6E56CF)](#the-tunnel-in-one-link--three-data-planes) [![TLS](https://img.shields.io/badge/TLS-1.3%20only-2E7D32)](#-security-model) [![License](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE) @@ -21,27 +23,54 @@ accept inbound connections, and that machine relays player traffic back through --- +## Contents + +- [How it works](#how-it-works) +- [Download](#download) · [Quick start](#quick-start-two-machines) +- **Engineering** + - [The tunnel in one link — three data planes](#the-tunnel-in-one-link--three-data-planes) + - [Head-of-line blocking, and how each plane answers it](#head-of-line-blocking-and-how-each-plane-answers-it) + - [Auto: pick the best plane the network allows](#auto-pick-the-best-plane-the-network-allows) + - [One gateway, many agents — identity & enrollment](#one-gateway-many-agents--identity--enrollment) + - [Gateway-authoritative config](#gateway-authoritative-config) + - [Built to stay up — self-healing links](#-built-to-stay-up--self-healing-links) + - [Engineered for the hot path](#-engineered-for-the-hot-path) + - [Security model](#-security-model) +- [Life of a player byte](#life-of-a-player-byte) +- [Features](#features) · [Observability](#-observability--windows-citizenship) · [CLI](#cli) +- [Building & developing](#building) · [Contributing](#contributing) · [License](#license) + ## How it works -``` -Minecraft players Gateway (Server B) Agent (Server A) - │ public IP, can port-forward behind NAT, dials OUT - │ │ │ - └──── TCP :25565 ──────────▶ public listener │ - │ │ - ◀═══ one TLS 1.3 conn, yamux ═════ ┤ - │ (control + 1 stream/player) │ - │ │ - │ └──▶ localhost:25565 - │ your Minecraft server +The **agent** sits next to Minecraft and can't accept inbound connections (it's behind +NAT). The **gateway** sits somewhere with a reachable public IP. The agent makes a single +outbound TLS 1.3 connection to the gateway and keeps it open; the gateway binds a public +port, accepts players, and relays each one back down that link. Players connect to the +gateway as if it *were* the server. + +```mermaid +flowchart LR + P["🎮 Minecraft players"] + + subgraph PUB ["Gateway — public IP, port-forwarded"] + L["Public listener :25565"] + end + + subgraph NAT ["Agent — behind NAT, dials OUT"] + A["Agent"] + end + + MC[("Minecraft server
127.0.0.1:25565")] + + P -->|"TCP :25565"| L + A -.->|"opens one outbound
TLS 1.3 link"| L + L <-->|"control + one flow per player"| A + A -->|"localhost dial"| MC ``` -One `proxyforward.exe` runs in **either role** — a WebView2 GUI (Wails + React), a -`--headless` console mode, or a Windows service. The agent keeps a single outbound -TCP+TLS connection open to the gateway, multiplexed with yamux into a control stream -plus one stream per player. Everything is programmed against a `transport.Session` -interface, so a per-connection mode (sidesteps TCP head-of-line blocking) and a future -QUIC transport drop in without touching agent or gateway code. +One `proxyforward.exe` runs in **either role** — as a WebView2 desktop app (Wails + React +19), a `--headless` console process, or a Windows service. Nothing on the Minecraft +machine's router changes; only the gateway forwards a port. ## Download @@ -68,7 +97,8 @@ gh attestation verify proxyforward--windows-amd64.exe -R xeri/proxyforw 1. **Launch** `proxyforward.exe` and choose **"This faces the internet."** 2. **Enter** your public hostname (a stable DNS/DDNS name is strongly recommended — see [DNS and dynamic IPs](#dns-and-dynamic-ips)) and click **Start gateway**. -3. **Copy** the pairing code it shows: `pf1://host:8474/…#sha256:…` +3. **Copy** the pairing code it shows: + `pxf://host:8474/v1/pair/#sha256:` 4. **Forward** port **25565** (or your chosen public port) on the router to this machine, and allow the inbound firewall rule when prompted (Settings → Windows integration → *Add rule*). @@ -76,6 +106,8 @@ gh attestation verify proxyforward--windows-amd64.exe -R xeri/proxyforw **On the Minecraft machine — the agent:** 1. **Launch** `proxyforward.exe` and choose **"This hosts Minecraft."** + (Or just **click the `pxf://` pairing link** — Windows hands it to the app, which opens + straight into pairing.) 2. **Paste** the pairing code. It validates instantly (`✓ certificate pinned`). 3. **Confirm** the local address (`127.0.0.1:25565`) and public port, then click **Connect**. @@ -83,6 +115,190 @@ The agent's dashboard turns green and players join at `your-host:25565`. Use **Dashboard → Test public reachability** to validate the whole path (DNS → firewall → router → tunnel → server) in one click. +--- + +## The tunnel in one link — three data planes + +Everything above is programmed against a single `transport.Session` interface, so *how* +the player flows ride the wire is a pluggable choice. proxyforward ships **three** data +planes and picks between them automatically. All three share the same TLS-pinned control +plane, the same admission and rate-limiting, and byte-identical hello frames — they differ +only in how a player's bytes travel. + +```mermaid +flowchart TB + subgraph mux ["① yamux mux · one TCP"] + direction LR + m1["control"] --- m0(("shared
TLS conn")) + m2["player A"] --- m0 + m3["player B"] --- m0 + end + subgraph pc ["② per-conn · multi-TCP"] + direction LR + p0["control (mux)"] + p1["player A → own TCP+TLS"] + p2["player B → own TCP+TLS"] + end + subgraph quic ["③ QUIC · one UDP conn"] + direction LR + q1["control"] --- q0(("QUIC
conn")) + q2["player A stream"] --- q0 + q3["player B stream"] --- q0 + end +``` + +| Plane | Wire | Isolation | Cost | Config value | +|---|---|---|---|---| +| **mux** | one TCP+TLS, [yamux](https://github.com/hashicorp/yamux)-multiplexed: control stream + one stream per player | ⚠️ shared TCP — a lost segment stalls *all* streams (transport-level HoL) | 1 conn, 1 handshake, 1 NAT entry | `transport = "mux"` | +| **per-conn** | control on the mux; **each player dials back a fresh TCP+TLS connection** | ✅ full — a lost segment on one player's socket can't touch another's | N+1 conns / handshakes / NAT entries | `transport = "per-conn"` | +| **QUIC** | one UDP [QUIC](https://github.com/quic-go/quic-go) connection; control + every player are independent QUIC streams | ✅ full — QUIC does per-stream loss recovery over one connection | 1 conn, 1 handshake, 1 NAT entry | `transport = "quic"` | + +QUIC gets you per-conn's isolation *and* mux's single-connection economy: one handshake, +one NAT mapping, and if the agent's IP changes mid-session, QUIC connection migration +follows it without a reconnect. It binds a UDP listener on the **same port number** as the +TCP control listener (the TCP and UDP port spaces are independent, so one `host:port` in +the pairing code serves both), reuses every pre-auth guard and the same admission path, and +adds no new control message or capability — it's a parallel wire, negotiated by which +socket the agent dials. + +## Head-of-line blocking, and how each plane answers it + +Head-of-line (HoL) blocking is the reason there's more than one plane. When many players +share **one TCP connection** (the mux plane), TCP guarantees in-order delivery of the +*whole byte stream*. A single lost packet forces the kernel to hold back every later +byte — for **every** player — until that one packet is retransmitted. Player B's chunk +burst waits on Player A's dropped segment. On a clean LAN this never shows; on a lossy WAN +it's the difference between "one player rubber-banding" and "everyone rubber-banding." + +- **per-conn** eliminates it structurally: each player owns a separate TCP connection, so + loss is contained to that connection's own kernel buffers. +- **QUIC** eliminates it at the protocol level: streams are independently flow-controlled + and loss-recovered inside one connection, so a drop on one stream never blocks another. + +This isn't a claim — it's a **CI gate**. `TestBurstThroughputAndCrossStreamLatency` (with +per-transport twins `TestBurstThroughputPerConn` and `TestBurstThroughputQUIC`) pushes a +**64 MiB** burst down one flow through the full agent → gateway → client path and fails if +throughput drops below **20 MiB/s** *or* a concurrent second flow's round-trip exceeds +**500 ms** mid-burst. A regression that reintroduces cross-flow HoL blocking turns the +build red ([`e2e_test.go`](internal/e2e/e2e_test.go)). + +## Auto: pick the best plane the network allows + +The shipped default is `transport = "auto"` — a connect-time fallback ladder, +best-isolation-first. The agent tries each rung; a rung that *connects* is used, a rung +that fails to connect falls through immediately to the next. + +```mermaid +flowchart TD + S([connect]) --> Q{"QUIC
UDP reachable?"} + Q -->|yes| QUIC["🟢 QUIC data plane"] + Q -->|"blocked / cooled"| P{"per-conn
dial-back OK?"} + P -->|yes| PC["🟢 multi-TCP data plane"] + P -->|no| M["🟢 yamux mux data plane"] + + QUIC -.->|reconnect| S + PC -.->|reconnect| S + M -.->|reconnect| S +``` + +The clever part is *cooling*: a rung is only marked "don't retry for a while" once a +**lower** rung succeeds — that's the unambiguous "UDP is blocked here" signal (QUIC +failed, but per-conn worked). If *every* rung fails, the link is simply down and nothing is +cooled, so a transient outage doesn't permanently demote you off QUIC. The cooldown clears +the instant the OS reports a network change, and whichever rung wins is surfaced to the GUI +(`Status.Transport`) so you can see what auto settled on. + +## One gateway, many agents — identity & enrollment + +A single gateway fronts a **fleet** of agents. They're told apart not by a shared secret +but by a per-agent **cryptographic identity**: on first run each agent generates a +long-term **Ed25519** keypair (PKCS#8, mode `0600`, never leaves the machine). Its public +ID — `agt_` — is *derived*, so it's stable across +re-pairs and can't be forged by someone who merely holds a token. + +Two ways an agent authenticates, told apart by the shape of the token in the pairing code: + +- **Enrollment ticket** (`tkt_…`, the modern path): a **single-use** nonce the gateway + mints and embeds in the pairing code. The agent replays it once; the gateway binds that + agent's public key into its **allowlist** under the derived `agt_` ID and the ticket is + spent. Every later connection is authenticated by an Ed25519 signature over a message + **bound to the gateway's pinned cert fingerprint** — so a signature made for one gateway + can't be replayed against another. +- **Shared token** (bare hex, legacy): one token admits many agents; a matching `agentID` + *supersedes* (reconnect), a distinct one is admitted *alongside*. Simpler, but with no + per-agent revocation. + +```mermaid +flowchart LR + subgraph GW ["Gateway"] + AL[["allowlist
(AgentStore)"]] + TK[["outstanding tickets"]] + end + A1["agt_9f3kd…
survival"] -->|Ed25519 sig| GW + A2["agt_x7p2v…
creative"] -->|Ed25519 sig| GW + A3["agt_qm40b…
modded"] -->|Ed25519 sig| GW + AL -.->|"enroll · revoke · rename · scope"| A1 +``` + +Enrolled identities unlock the operations a shared token can't safely offer: + +- **Scope** — restrict which public ports and tunnel IDs an agent may bind (empty = any, + the permissive default). An agent can't squat a port outside its lane. +- **Revoke** — an identity is marked revoked (kept, not deleted) so its next connect gets a + clear *"revoked"* answer instead of a confusing *"unknown identity."* +- **Clone detection** — because a derived, pubkey-bound ID makes two-places-at-once + *impossible* unless the private key was copied, the same `agentID` rapidly contested from + two IPs raises a `clone-suspected` event and a GUI card nudging you to re-enroll. +- **Auto port-reassign** — if a requested public port is already taken, the gateway keeps + the agent online and binds a free, policy-valid alternative, recording a `port-reassigned` + event with a one-click "reclaim it" card, rather than failing the tunnel. + +> **Residual risk that ships honestly:** with a *shared* token the model is +> token + self-asserted ID + first-come-first-served ports and no per-agent revocation — a +> token-holder can supersede or port-squat, recoverable only by rotating the shared token. +> Enrollment tickets + per-identity revocation are the mitigation; prefer them. + +## Gateway-authoritative config + +Enrolled agents negotiate the `gateway-config` capability and the **gateway becomes the +source of truth** for that identity's tunnel set. It stores each identity's desired +configuration with a monotonic generation, hashed by `HashTunnels`. On connect the agent +reports its `configHash`/`configGeneration` in the hello; on any drift the gateway pushes +`push_config{generation, hash, tunnels[]}` and the agent applies it and acks. + +A local edit isn't last-write-wins guesswork — it's a `propose_config` the gateway +**adopts, bumps the generation, and re-pushes** to that agent; a proposal made against a +stale generation is refused and the authoritative set re-pushed. Deterministic, and +recoverable from either end. Shared-token agents (which can't carry a stable identity) have +the capability negotiated away and fall back to the simpler `tunnel-sync` desired-state +reconcile. + +## 🔁 Built to stay up — self-healing links + +The link is designed to survive the messiness of home networks — sleep, Wi‑Fi roams, DHCP +lease changes, gateway restarts — without a human noticing. + +- **Full-jitter exponential backoff** — 1 s → 60 s cap, sequence resets after 60 s of + stable connection. Full jitter means a gateway restart doesn't trigger a + thundering-herd of reconnects across a fleet. +- **Instant reconnect on network change** — Windows `NotifyAddrChange` and a + wall-clock-jump resume-from-sleep detector short-circuit the backoff instead of + waiting out a read deadline. DNS re-resolves on **every** attempt, so dynamic IPs and + DDNS just work; the gateway address is even editable later without re-pairing. +- **Identity, not just auth** — a reconnect by the same ID supersedes the old session + (anti-flap dampened, so an ID collision degrades to a slow contest, not a loop); a + *different* agent gets a clear rejection instead of the two fighting forever. +- **QUIC connection migration** — on the QUIC plane, an agent whose IP changes is followed + passively without tearing down the session at all. +- **Fatal errors stop, they don't hammer** — `bad_token`, `agent_conflict`, and version + mismatches are classified fatal: the agent stops and surfaces the reason in the UI, + rather than retry-hammering the gateway. +- **Ghost-listener guarantee** — all session/listener lifecycle runs on the gateway's + single actor goroutine; evicting one agent closes *its* listeners and drains *its* + connections while every other agent's stay untouched, and a rebound port is provably free + before handoff. Regression-guarded by `TestAgentRestartRebinds` and + `TestEvictionIsolatesAndDrains`. + ## ⚡ Engineered for the hot path The relay is a purpose-built splice, not an `io.Copy` wrapper — every default was @@ -92,46 +308,68 @@ questioned and most were replaced: |---|---|---| | 📦 | **128 KiB pooled buffers** (`sync.Pool`) | `io.Copy`'s 32 KiB default throttles chunk-load bursts on fat pipes — [`relay.go`](internal/relay/relay.go) | | 🪟 | **1 MiB yamux stream windows** (4× default) | a full Minecraft chunk burst fits in flight on one stream without stalling — [`yamux.go`](internal/transport/yamux.go) | -| 💓 | **One liveness owner** | yamux keepalive is *off*; the app-level 5 s ping (which also feeds the dashboard RTT) is the single source of truth, with a 30 s conn-write timeout as backstop | +| 🪟 | **6 MiB QUIC stream / 12 MiB conn receive windows** | the same burst-headroom on the QUIC plane, per stream — [`quicconfig.go`](internal/transport/quicconfig.go) | +| 💓 | **One liveness owner** | yamux/QUIC transport keepalive is *off*; the app-level 5 s ping (which also feeds the dashboard RTT) is the single source of truth, backed by a 15 s idle read deadline | | ⏱️ | **`TCP_NODELAY` end-to-end** | no Nagle-induced latency on either leg, and player data never enters the control path | -| 🤝 | **FIN-preserving half-close** | EOF on one leg becomes `CloseWrite` on the other while the opposite direction keeps draining — a disconnect message written just before close arrives intact instead of becoming a raw reset | +| 🤝 | **FIN-preserving half-close** | EOF on one leg becomes `CloseWrite` on the other while the opposite direction keeps draining — a kick/disconnect message written just before close arrives intact instead of becoming a raw reset | | 🛡️ | **2-minute write-stall deadline** | a peer that stops draining can never park a splice goroutine forever; byte counters are atomic, snapshotted lock-free by the GUI and metrics | -| 🎭 | **Single-goroutine gateway actor** | all session/listener lifecycle mutations are naturally serialized — a re-registered port can never race its own dying listener (the *ghost-listener guarantee*: the port is provably free before handoff) — [`actor.go`](internal/gateway/actor.go) | +| 🎭 | **Single-goroutine gateway actor** | all session/listener lifecycle mutations are naturally serialized — a re-registered port can never race its own dying listener — [`actor.go`](internal/gateway/actor.go) | -And it's **enforced in CI**, not just claimed: the loopback e2e suite pushes a 64 MiB -burst through the full agent → gateway → client path and fails if round-trip throughput -drops below **20 MiB/s** or a concurrent stream's RTT exceeds **500 ms** mid-burst — a -regression guard against head-of-line blocking ([`e2e_test.go`](internal/e2e/e2e_test.go)). +There is **no per-byte or per-packet logging or locking anywhere on the data path**, and +the Go→JS boundary is coalesced into one `tick` event so the UI never touches the splice. ## 🔒 Security model -- **TLS 1.3 only.** The gateway generates a self-signed **ECDSA P-256** certificate on - first run; the pairing code pins its **SHA-256 fingerprint** — no CA, no third party, - nothing to leak. -- **Constant-time comparisons** for both the auth token and the certificate fingerprint +- **TLS 1.3 only, both directions.** The gateway generates a self-signed **ECDSA P-256** + certificate on first run; the pairing code pins its **SHA-256 fingerprint** — no CA, no + third party, nothing to leak. The key exchange is Go's post-quantum hybrid + **X25519MLKEM768**. +- **Per-agent Ed25519 identity** (above): connections are signed proof-of-possession bound + to the gateway's cert fingerprint, not a bearer secret that can be replayed elsewhere. +- **Constant-time comparisons** for the auth token and the certificate fingerprint (`crypto/subtle`). -- **Pre-auth hardening:** the entire unauthenticated prologue (TCP accept → TLS - handshake → hello frame) must finish within **10 s**, and pre-auth frames are capped at - **4 KiB** (vs 64 KiB post-auth) — internet scanners get nothing to chew on. -- **fail2ban-style auth limiter:** 10 failed attempts per minute per IP; successes never - count, so a legitimately flapping agent is never locked out while a token brute-forcer is. -- **Connection gates:** 4096 global / 32 per-IP, plus a public-port allowlist. - -## 🔁 Built to stay up +- **Pre-auth hardening** — the entire unauthenticated prologue (accept → TLS handshake → + hello frame) must finish within **10 s**, and pre-auth frames are capped at **4 KiB** + (vs 64 KiB post-auth), with the length checked *before* allocation — internet scanners + get nothing to chew on. +- **fail2ban-style auth limiter** — 10 failed attempts per minute per IP; successes never + count, so a legitimately flapping agent is never locked out while a brute-forcer is. +- **Connection gates** — 4096 global / 32 per-IP, plus a public-port allowlist. +- **Locked-down IPC** — the GUI attaches to the engine over a named pipe whose ACL admits + only Administrators, SYSTEM, and the interactive user. +- **Redacted diagnostics** — bundles strip every secret, host, IP, and identity; peer IPs + become stable `sha256` pseudonyms, leak-tested in CI. + +## Life of a player byte + +```mermaid +sequenceDiagram + autonumber + participant Pl as Player + participant GW as Gateway + participant Ag as Agent + participant MC as Minecraft + + Pl->>GW: TCP connect :25565 + Note over GW: connGate.admit (global + per-IP) + alt mux / QUIC + GW->>Ag: open a stream over the shared conn + else per-conn + GW->>Ag: open_data{connId} on control + Ag-->>GW: dials back a fresh TCP+TLS data conn + Note over GW: matches connId → exactly-once handoff + end + GW-->>Ag: open_conn{tunnelId, clientAddr, connId} header + Ag->>MC: dial 127.0.0.1:25565 (optional PROXY v2 header) + loop steady state + Pl->>MC: bytes spliced both ways · 128 KiB pooled buffers + GW-->>Ag: ping/pong · conn_stats (per-player kernel RTT) + end + Note over Pl,MC: EOF → CloseWrite (FIN); opposite leg drains +``` -- **Full-jitter exponential backoff** — 1 s → 60 s cap, sequence resets after 60 s of - stable connection. Full jitter means a gateway restart doesn't trigger a - thundering-herd of reconnects. -- **Instant reconnect on network change** — Windows `NotifyAddrChange` and a - wall-clock-jump resume-from-sleep detector short-circuit the backoff instead of - waiting out a read deadline. -- **Identity, not just auth** — the agent carries a persistent random ID. A reconnect by - the same ID supersedes the old session; a *different* agent on the same token gets a - clear rejection instead of the two flapping forever. -- **Fresh DNS on every attempt** — dynamic IPs just work; the gateway address is even - editable later without re-pairing. -- **Health you can see** — 5 s local server probes, plus RFC 3550-style jitter EWMA and - packet-loss tracking derived from the heartbeat, rolled into one dashboard health score. +Counters are per-connection atomics, sampled at 10 Hz into RRD tiers and shipped to the GUI +via a 2 Hz lock-free snapshot — the splice itself never blocks on measurement. ## Features @@ -139,13 +377,13 @@ Per-tunnel options (**Tunnels → edit**, off by default): | Feature | What it does | |---|---| -| **Minecraft-aware** | polls the server for MOTD / player count / version and sniffs usernames for the Connections view | -| **PROXY protocol v2** | prepends a PP2 header when dialing the local server so Paper/Velocity see the real player IP (set `proxy-protocol: true` in `paper-global.yml`). ⚠️ **Mutually exclusive** with BungeeCord/Velocity IP-forwarding on the same server — enabling both causes ghost errors | +| **Minecraft-aware** | sniffs the login handshake for usernames (Connections view) and probes the local server's liveness | +| **PROXY protocol v2** | prepends a PP2 header when dialing the local server so Paper/Velocity see the real player IP (set `proxy-protocol: true` in `paper-global.yml`). ⚠️ **Mutually exclusive** with BungeeCord/Velocity IP-forwarding on the same server | | **Offline MOTD** | a message the gateway serves when the agent or server is down, instead of a dead port. Leave blank for a clean disconnect | -| **Bandwidth cap** | per-tunnel throughput limit to protect the gateway uplink | +| **Bandwidth cap** | per-tunnel throughput limit (combined / per-direction / per-connection scope) enforced on the splice on both sides, to protect the gateway uplink | -Global toggles live in **Settings**: transport mode, Prometheus `/metrics`, abuse limits -(max connections global/per-IP, auth attempts/min), logging. +Global toggles live in **Settings**: transport mode (`auto`/`quic`/`per-conn`/`mux`), +Prometheus `/metrics`, abuse limits, logging. ### DNS and dynamic IPs @@ -170,10 +408,11 @@ server-side can fix that. So: - **RRD-style traffic history** — five resolution tiers from **100 ms** buckets (live graph) up to **1-day** buckets (~3 years retention), with rate OHLC candles per bucket. Persistent tiers are saved atomically, so a crash never corrupts history. +- **Local-only analytics** — sessions, players, geography (GeoLite2), and uptime in a + SQLite DB next to the config. The only outbound calls are Mojang identity/skins + (opt-out). No telemetry ever leaves the machine. - **Logging that respects your disk** — 10 MiB × 3 rotating files plus an in-memory ring the GUI reads live. -- **Locked-down IPC** — the GUI attaches to the engine over a named pipe whose ACL admits - only Administrators, SYSTEM, and the interactive user. - **One UAC prompt, ever** — the firewall rule is added via `netsh advfirewall`, scoped to the program (not a port), with one-click status/repair in the GUI. - **Diagnostics with names attached** — a port conflict reports @@ -201,6 +440,9 @@ client — exactly one process ever owns the ports. wails build # produces the single Windows/amd64 proxyforward.exe ``` +A fresh clone must build the frontend once before any Go command +(`cd frontend && npm ci && npm run build`) — `main.go` embeds `frontend/dist`. + ## Development ``` @@ -218,19 +460,62 @@ cd frontend && npm run dev go test ./... ``` -8 fuzz targets on the internet-facing parsers (control frames, Minecraft -handshake/VarInt/packet, login sniffer, offline responder) and an in-process loopback -e2e suite that is goroutine-leak-checked with `goleak` and enforces the -throughput/latency floor described above. +9 fuzz targets on the internet-facing parsers (control frames, Minecraft +handshake/VarInt/packet, login sniffer, offline responder, pairing code, sniff tap) plus an in-process +loopback e2e suite that is goroutine-leak-checked with `goleak` and enforces the +throughput/latency floor and per-transport twins described [above](#head-of-line-blocking-and-how-each-plane-answers-it). `go test` runs the fuzz **seed corpora**; the targets are actually fuzzed nightly ([`fuzz.yml`](.github/workflows/fuzz.yml)). Every push also runs the race detector, the burst floor, CodeQL, and `govulncheck` — see [`ci.yml`](.github/workflows/ci.yml) and [`security.yml`](.github/workflows/security.yml). -A fresh clone must build the frontend once before any Go command -(`cd frontend && npm ci && npm run build`) — `main.go` embeds `frontend/dist`. +## Contributing + +Contributions are welcome — bug reports, transport experiments, UI polish, and docs alike. + +**Before you start** + +- Read [`CLAUDE.md`](CLAUDE.md) (the operating manual) and, for the subsystem you're + touching, its deep-dive in [`docs/agent/`](docs/agent/). The **Invariants** in + `CLAUDE.md` survive any rewrite — protect them. +- Anything touching the **wire protocol** (`internal/control`), the **hot path** + (`internal/relay`, `internal/transport`), or a **release** is an escalation trigger — + open an issue to discuss the design before writing code. + +**The gates your PR must clear** (all enforced in CI, none negotiable): + +| Gate | What it checks | +|---|---| +| `go test ./...` | unit + loopback e2e + goleak + doc-citation checks | +| **Burst floor** | ≥ 20 MiB/s throughput and ≤ 500 ms cross-flow RTT on a 64 MiB burst — **never lower the floor to go green** | +| `-race` | the race detector (CI is the only place it runs — it needs cgo) | +| `gofmt` · `go vet` · `golangci-lint` | formatting and static analysis; a PostToolUse hook also checks gofmt per-edit | +| `npm run build` | `tsc` + `vite` — the only frontend checker | +| Doc-citation test | every file/symbol/test name cited in docs must exist (`internal/doccheck`) | +| Security | CodeQL, `govulncheck`, gitleaks, dependency-review, `npm audit` | + +**House style** + +- **Commits**: lowercase, terse, scope-prefixed (`gateway: …`); one concern per commit; + **protocol and implementation never change in the same commit**. +- **Go**: package docs state the ownership/concurrency model up top; comments explain + *why* and justify every tuned number (they're the spec). Zero `TODO`/`FIXME` markers — + debt goes in [`docs/agent/polish-backlog.md`](docs/agent/polish-backlog.md). +- **Numbers** live in exactly one place: the "The numbers" table in + [`docs/agent/architecture.md`](docs/agent/architecture.md). Cite constants by name; + don't restate values. +- **UI**: read [`frontend/DESIGN.md`](frontend/DESIGN.md) first. Every data surface needs + all four states — skeleton, real data, empty, and an *honest* unavailable state. +- **Honesty over hype**: don't document or build on features that don't exist end-to-end. + The "Reality check" table in `CLAUDE.md` is the ground truth; when you implement a stub, + delete its row in the same commit. + +New to the code? The `.claude/skills/` playbooks (`hot-path`, `wire-protocol`, +`ui-change`, `backend-capability`, …) walk through each kind of change step by step. ## License [GPL-3.0](LICENSE) + + diff --git a/app/agents.go b/app/agents.go new file mode 100644 index 0000000..5875319 --- /dev/null +++ b/app/agents.go @@ -0,0 +1,97 @@ +package app + +import ( + "fmt" + + "proxyforward/internal/engine" + "proxyforward/internal/gateway" +) + +// Agent management (gateway role). These bindings drive the GUI agent roster, +// enrollment codes, and per-agent revoke/rename/scope. Each runs one op over the +// shared JSON envelope (analyticsCall), so it works both in-process and attached +// to a service daemon; all degrade to an empty/error result off the gateway role. + +// ListAgents returns the gateway's agent roster: every enrolled agent (online or +// not) plus any connected shared-token agent, with identity, scope, and live +// status. Empty on any error (agent role, detached, old daemon). +func (a *App) ListAgents() []gateway.AgentView { + var out []gateway.AgentView + if err := a.analyticsCall(engine.OpListAgents, nil, &out); err != nil || out == nil { + return []gateway.AgentView{} + } + return out +} + +// IssuePairingCode mints a pxf:// pairing code carrying a fresh enrollment ticket. +// reusable=false is the single-use default; ttlSecs=0 never expires; ports/tunnels +// (empty = unrestricted) become the enrolling agent's bind scope. +func (a *App) IssuePairingCode(reusable bool, ttlSecs int64, ports []int, tunnels []string) (string, error) { + req := struct { + Reusable bool `json:"reusable"` + TTLSecs int64 `json:"ttlSecs"` + Ports []int `json:"ports"` + Tunnels []string `json:"tunnels"` + }{reusable, ttlSecs, ports, tunnels} + var resp struct { + Code string `json:"code"` + } + if err := a.analyticsCall(engine.OpIssuePairing, req, &resp); err != nil { + return "", err + } + return resp.Code, nil +} + +// RevokeAgent removes an agent from the allowlist and evicts its live session, so +// the next connect is a clean "access revoked". +func (a *App) RevokeAgent(agentID string) error { + return a.agentMutate(engine.OpRevokeAgent, struct { + AgentID string `json:"agentId"` + }{agentID}) +} + +// RenameAgent sets an agent's freely-editable display nickname. +func (a *App) RenameAgent(agentID, nickname string) error { + return a.agentMutate(engine.OpRenameAgent, struct { + AgentID string `json:"agentId"` + Nickname string `json:"nickname"` + }{agentID, nickname}) +} + +// SetAgentScope replaces an agent's bind scope (empty ports+tunnels = unrestricted). +func (a *App) SetAgentScope(agentID string, ports []int, tunnels []string) error { + return a.agentMutate(engine.OpSetAgentScope, struct { + AgentID string `json:"agentId"` + Ports []int `json:"ports"` + Tunnels []string `json:"tunnels"` + }{agentID, ports, tunnels}) +} + +// agentMutate runs a mutating admin op and turns a not-found result (the agentID +// isn't in the allowlist) into an error the UI can surface. +func (a *App) agentMutate(op string, req any) error { + var resp struct { + OK bool `json:"ok"` + } + if err := a.analyticsCall(op, req, &resp); err != nil { + return err + } + if !resp.OK { + return fmt.Errorf("that agent is no longer in the roster — it may have already been removed") + } + return nil +} + +// GatewayEvents returns the gateway's notable auto-fixes and detected conflicts +// (port reassignment, suspected clone) recorded since the cursor — 0 returns all +// retained — for the GUI event log. Empty on any error. +func (a *App) GatewayEvents(sinceSeq uint64) []gateway.GatewayEvent { + req := struct { + SinceSeq uint64 `json:"sinceSeq"` + }{sinceSeq} + var out []gateway.GatewayEvent + if err := a.analyticsCall(engine.OpGatewayEvents, req, &out); err != nil || out == nil { + return []gateway.GatewayEvent{} + } + return out +} diff --git a/app/analytics.go b/app/analytics.go index bfc5a94..7f6365f 100644 --- a/app/analytics.go +++ b/app/analytics.go @@ -12,6 +12,7 @@ import ( "proxyforward/internal/engine" "proxyforward/internal/geo" "proxyforward/internal/ipc" + "proxyforward/internal/stats" ) // analyticsCall runs one analytics op in whichever mode this GUI is in and @@ -275,6 +276,27 @@ func (a *App) GeoSnapshot(rangeMs int64) []analytics.CountryAgg { return rows } +// AgentBandwidthHistory returns one agent's bandwidth history over the trailing +// windowMs (0 = everything), aggregated to at most maxBuckets buckets — the +// gateway drill-in chart scoped to a selected agent. Empty on any error or an +// unknown/evicted agent. +func (a *App) AgentBandwidthHistory(agentID string, windowMs int64, maxBuckets int) stats.HistoryResult { + empty := stats.HistoryResult{Buckets: []stats.Bucket{}} + req := struct { + AgentID string `json:"agentId"` + WindowMs int64 `json:"windowMs"` + MaxBuckets int `json:"maxBuckets"` + }{AgentID: agentID, WindowMs: windowMs, MaxBuckets: maxBuckets} + var res stats.HistoryResult + if err := a.analyticsCall(engine.OpAgentHistory, req, &res); err != nil { + return empty + } + if res.Buckets == nil { + res.Buckets = []stats.Bucket{} + } + return res +} + // BrowseMMDB opens a file picker for a MaxMind .mmdb database and returns the // chosen path ("" when cancelled). func (a *App) BrowseMMDB(title string) (string, error) { diff --git a/app/app.go b/app/app.go index 5e85120..432f632 100644 --- a/app/app.go +++ b/app/app.go @@ -59,6 +59,10 @@ type App struct { // engineFatal holds the engine's terminal error until the next start, // so every status tick (not just the one that drained done) reports it. engineFatal string + // pendingDeepLink holds a pxf:// pairing link delivered by the OS before the + // frontend was ready to receive the event (a cold start via the protocol + // handler); the frontend drains it once on mount via TakePendingDeepLink. + pendingDeepLink string // historyUnsupported latches after an attached daemon fails a history // request (older version): stop asking instead of eating a timeout per // poll. @@ -124,6 +128,53 @@ func (a *App) Startup(ctx context.Context) { go a.tickLoop(ctx) } +// HandleDeepLink routes an OS-delivered pxf:// pairing link into the UI. Once the +// window is up it emits a "pxf:deeplink" event and surfaces the window so the click +// feels like it opened the app; before startup (a cold protocol launch) it stashes +// the link for the frontend to pull via TakePendingDeepLink when it mounts. +func (a *App) HandleDeepLink(rawURL string) { + if a.ctx == nil { + a.mu.Lock() + a.pendingDeepLink = rawURL + a.mu.Unlock() + return + } + runtime.EventsEmit(a.ctx, "pxf:deeplink", rawURL) + a.surfaceWindow() +} + +// OnSecondInstance handles a second launch of the GUI: the OS forwards that +// process's args here and exits it. A pxf:// deep link routes into pairing; either +// way the existing window is brought to the front. +func (a *App) OnSecondInstance(deepLink string) { + if deepLink != "" { + a.HandleDeepLink(deepLink) + return + } + a.surfaceWindow() +} + +// TakePendingDeepLink returns and clears any pxf:// link captured before the +// frontend was listening (a cold start via the OS protocol handler). Bound to Wails; +// the frontend calls it once on mount. +func (a *App) TakePendingDeepLink() string { + a.mu.Lock() + defer a.mu.Unlock() + url := a.pendingDeepLink + a.pendingDeepLink = "" + return url +} + +// surfaceWindow brings the running GUI window to the foreground (un-minimising if +// needed). A no-op before startup. +func (a *App) surfaceWindow() { + if a.ctx == nil { + return + } + runtime.WindowUnminimise(a.ctx) + runtime.WindowShow(a.ctx) +} + // Shutdown is wired to Wails OnShutdown. func (a *App) Shutdown(ctx context.Context) { a.mu.Lock() @@ -206,6 +257,11 @@ type UIStatus struct { RTTMillis int64 `json:"rttMillis"` AgentConnected bool `json:"agentConnected"` + // Transport is the data plane the live agent session settled on ("quic" | + // "per-conn" | "mux"); "" while down or on the gateway role. Shows what the + // auto ladder connected over. + Transport string `json:"transport"` + // Link quality + health rollup. Both roles run their own heartbeat and // report the same stats; values are -1/"unknown" until the link is up. JitterMillis float64 `json:"jitterMillis"` @@ -221,6 +277,11 @@ type UIStatus struct { LocalLANIPs []string `json:"localLanIps"` PeerLANIPs []string `json:"peerLanIps"` + // Agents is the per-agent link state on a gateway (empty on the agent + // role); the Agents screen and drill-in read it. Tunnels/Connections stay + // flat and carry agentId so the GUI groups them per agent. + Agents []AgentUI `json:"agents"` + Tunnels []TunnelUI `json:"tunnels"` Connections []ConnUI `json:"connections"` TotalBytesIn int64 `json:"totalBytesIn"` @@ -262,16 +323,20 @@ type UIStatus struct { // TunnelUI is one tunnel's live state for the frontend. type TunnelUI struct { - ID string `json:"id"` - Name string `json:"name"` - PublicPort int `json:"publicPort"` - LocalUp bool `json:"localUp"` - LocalKnown bool `json:"localKnown"` + AgentID string `json:"agentId,omitempty"` // owning agent (gateway role) + ID string `json:"id"` + Name string `json:"name"` + PublicPort int `json:"publicPort"` + LocalUp bool `json:"localUp"` + LocalKnown bool `json:"localKnown"` + BandwidthLimitMbps int `json:"bandwidthLimitMbps"` // configured cap (0 = unlimited) + BandwidthLimitScope string `json:"bandwidthLimitScope"` // combined | per-direction | per-connection } // ConnUI is one live connection for the frontend. type ConnUI struct { ID uint64 `json:"id"` + AgentID string `json:"agentId,omitempty"` // owning agent (gateway role) TunnelName string `json:"tunnelName"` ClientAddr string `json:"clientAddr"` StartedAt int64 `json:"startedAt"` // unix millis @@ -284,6 +349,24 @@ type ConnUI struct { RttMs float64 `json:"rttMs"` } +// AgentUI is one connected agent's link state on a gateway, mirroring +// ipc.AgentStatus for the Agents dashboard and drill-in. +type AgentUI struct { + AgentID string `json:"agentId"` + Hostname string `json:"hostname"` + LANIPs []string `json:"lanIps"` + RemoteIP string `json:"remoteIp"` + LinkUpSinceMs int64 `json:"linkUpSinceMs"` + RTTMillis int64 `json:"rttMillis"` + JitterMillis float64 `json:"jitterMillis"` + PacketLossPct float64 `json:"packetLossPct"` + HealthScore string `json:"healthScore"` + LinkBytesIn int64 `json:"linkBytesIn"` + LinkBytesOut int64 `json:"linkBytesOut"` + Tunnels int `json:"tunnels"` + Players int `json:"players"` +} + // applyIPCStatus copies a daemon snapshot into the UI shape. func (st *UIStatus) applyIPCStatus(s ipc.Status) { st.Role = s.Role @@ -297,6 +380,7 @@ func (st *UIStatus) applyIPCStatus(s ipc.Status) { } st.LinkUp = s.LinkUp st.RTTMillis = s.RTTMillis + st.Transport = s.Transport st.JitterMillis = s.JitterMillis st.PacketLossPct = s.PacketLossPct st.HealthScore = s.HealthScore @@ -317,11 +401,25 @@ func (st *UIStatus) applyIPCStatus(s ipc.Status) { st.AllTimeBytesOut = s.AllTimeBytesOut st.CumulativeUptimeMs = s.CumulativeUptimeMs st.LinkSessions = s.LinkSessions + st.Agents = make([]AgentUI, 0, len(s.Agents)) + for _, ag := range s.Agents { + st.Agents = append(st.Agents, AgentUI{ + AgentID: ag.AgentID, Hostname: ag.Hostname, LANIPs: ag.LANIPs, + RemoteIP: ag.RemoteIP, LinkUpSinceMs: ag.LinkUpSinceMs, + RTTMillis: ag.RTTMillis, JitterMillis: ag.JitterMillis, + PacketLossPct: ag.PacketLossPct, HealthScore: ag.HealthScore, + LinkBytesIn: ag.LinkBytesIn, LinkBytesOut: ag.LinkBytesOut, + Tunnels: ag.Tunnels, Players: ag.Players, + }) + } st.Tunnels = make([]TunnelUI, 0, len(s.Tunnels)) for _, t := range s.Tunnels { st.Tunnels = append(st.Tunnels, TunnelUI{ - ID: t.ID, Name: t.Name, PublicPort: t.PublicPort, + AgentID: t.AgentID, + ID: t.ID, Name: t.Name, PublicPort: t.PublicPort, LocalUp: t.LocalUp, LocalKnown: t.LocalKnown, + BandwidthLimitMbps: t.BandwidthLimitMbps, + BandwidthLimitScope: t.BandwidthLimitScope, }) } st.ConnectionsTruncated = s.ConnectionsTruncated @@ -329,7 +427,7 @@ func (st *UIStatus) applyIPCStatus(s ipc.Status) { st.Connections = make([]ConnUI, 0, len(s.Connections)) for _, c := range s.Connections { st.Connections = append(st.Connections, ConnUI{ - ID: c.ID, TunnelName: c.TunnelName, ClientAddr: c.ClientAddr, + ID: c.ID, AgentID: c.AgentID, TunnelName: c.TunnelName, ClientAddr: c.ClientAddr, StartedAt: c.StartedAt, BytesIn: c.BytesIn, BytesOut: c.BytesOut, PlayerName: c.PlayerName, PlayerUUID: c.PlayerUUID, RttMs: c.RttMs, }) @@ -449,8 +547,18 @@ func (a *App) SetupAgent(pairingCode, localAddr string, publicPort int) error { } a.cfg.Agent.GatewayHost = code.Host a.cfg.Agent.GatewayPort = code.Port - a.cfg.Agent.Token = code.Token a.cfg.Agent.CertFingerprint = code.Fingerprint + // The code's token is self-describing: a tkt_ ticket enrolls a per-agent + // identity (the default), a bare-hex token is legacy shared-token auth. Route it + // to exactly one field — the composite validator treats a failed enrollment as + // definitive (no shared-token fallback), so the two must never both be set. + if link.IsEnrollTicket(code.Token) { + a.cfg.Agent.EnrollTicket = code.Token + a.cfg.Agent.Token = "" + } else { + a.cfg.Agent.Token = code.Token + a.cfg.Agent.EnrollTicket = "" + } if localAddr == "" { localAddr = "127.0.0.1:25565" } diff --git a/app/app_test.go b/app/app_test.go index cbab7fd..e3b23ad 100644 --- a/app/app_test.go +++ b/app/app_test.go @@ -29,6 +29,23 @@ func simulateEngineDeath(a *App, err error) { a.mu.Unlock() } +// TestDeepLinkColdStartIntake covers the observable half of the deep-link path: a +// pxf:// link delivered before the window is up (ctx nil — a cold protocol launch) +// is stashed and then drained exactly once, so the frontend opens pairing on mount +// and never re-opens it on a later mount. The warm path emits a Wails event and +// needs a live runtime context, so it is exercised manually, not here. +func TestDeepLinkColdStartIntake(t *testing.T) { + a := newTestApp() + const link = "pxf://gw.example.com:8474/v1/pair/tok#sha256:abc" + a.HandleDeepLink(link) + if got := a.TakePendingDeepLink(); got != link { + t.Fatalf("TakePendingDeepLink = %q, want the stashed link %q", got, link) + } + if got := a.TakePendingDeepLink(); got != "" { + t.Fatalf("second TakePendingDeepLink = %q, want empty (drains once)", got) + } +} + func TestEngineFatalPersistsAcrossTicks(t *testing.T) { a := newTestApp() simulateEngineDeath(a, errors.New("boom")) diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index 60c608c..a6359b6 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -20,7 +20,7 @@ internal/ gateway/ TLS listener, pre-auth, admission; actor.go = ONE goroutine owning sessions+listeners; limits.go; per-conn RTT sampler relay/ Splice (the hot path) + TapConn (read-only sniff hook) - transport/ Session/Stream interfaces + tuned yamux impl (only yamux user) + transport/ Session/Stream interfaces + tuned yamux & QUIC impls (only yamux/quic-go user) control/ Wire protocol: envelope framing, messages, capabilities link/ Pairing codes, self-signed cert + pin, backoff ipc/ Named-pipe JSON-RPC (same framing); status/history/analytics @@ -39,6 +39,7 @@ internal/ frontend/src/ React 19 + Tailwind v4 + hand-rolled SVG charts, no router, no state lib. state.ts (tick) · history/analytics/players.ts (polled data layers + module caches) · devmock.ts (browser dev) + · motion.ts (the gate) + rubberband.ts (scroll rubber band) · styles/ = tokens → base → glass → motion · DESIGN.md charter frontend/wailsjs/ GENERATED bindings — never edit; regen via wails build/dev ``` @@ -52,6 +53,16 @@ Counters: per-conn `conntrack.Entry` (atomics) → 2 Hz status + 10 Hz `stats.Sa → SQLite via the 45 s flush; gateway samples kernel RTT per conn every 5 s and ships it over `conn_stats` so both ends attribute per-player ping. +**Bandwidth cap** (`internal/bwcap`): a tunnel's `BandwidthLimitMbps` (+ scope: combined +| per-direction | per-connection) throttles the splice on **both** sides via +`golang.org/x/time/rate` — one token = one byte, rate = `Mbps × 125_000` (decimal), burst += `relay.BufSize`, so `WaitN(n)` with n ≤ that never trips the burst. Buckets are keyed +per **`(agentID, tunnelID)`**: the gateway hangs the `bwcap.LimiterSet` on the tunnel's +`publicListener` (two agents' same-named tunnels cap independently), the agent keys a +`bwcap.Registry` by tunnel ID. `mbps ≤ 0` is the byte-identical uncapped fast path (nil +`relay.Limiter`, no per-iteration cost, no per-splice context). A throttled `WaitN` +unblocks on the session ctx, cancelled by gateway `evict` / agent session teardown. + **Go↔JS boundary**: methods on `app.App` (promise-returning bindings) + the 2 Hz `tick` event; attached mode proxies the same data over the pipe, analytics on its own pipe conn so slow queries never stall the tick (`app/analytics.go`). The Wails @@ -71,9 +82,14 @@ persisted to config. | Protocol version | 1 | `control.go:19` | | Frame caps | 64 KiB post-auth / 4 KiB pre-auth | `control.go:88-91` | | Pre-auth prologue deadline | 10 s | `gateway.go:43` | +| Per-conn dial-back wait | 12 s (> data-conn pre-auth, so its failure surfaces first) | `perconn.go dataDialTimeout` | | Heartbeat / idle deadline / ctrl write | 5 s / 15 s / 10 s | `agent.go:38-43`, `gateway.go:45-53` | | yamux window / conn-write timeout | 1 MiB / 30 s | `transport/yamux.go` | -| Splice buffer / write-stall deadline | 128 KiB pooled / 2 min | `relay.go:23-28` | +| QUIC keepalive / idle / handshake | off / 30 s / 5 s (dial aborts at 2×) | `transport/quicconfig.go quicConfig` | +| QUIC recv windows / max bidi streams / ALPN | 1→6 MiB stream, 2→12 MiB conn / 65536 / `pf-quic/1` | `transport/quicconfig.go quicConfig` | +| Auto-transport re-probe cooldown | 5 min (cleared on network change) | `agent.go transportReprobeAfter` | +| Splice buffer / write-stall deadline | 128 KiB pooled / 2 min | `relay.go` (`BufSize` / `WriteStallTimeout`) | +| Bandwidth cap unit / burst | `Mbps × 125_000` B/s / `relay.BufSize` | `bwcap.go` | | Backoff | 1 s → 60 s full jitter, reset after 60 s stable | `link/backoff.go` | | Abuse defaults | 4096 global / 32 per-IP conns; 10 auth fails/min/IP | `config.go Default` | | Loss window / ping-loss timeout | 32 heartbeats / 2×interval | `agent.go:50-51`, `linkquality.go` | @@ -91,7 +107,13 @@ persisted to config. | Avatars | sizes 16–128 (default 64), 8×8 master; Mojang spacing 60 s/player, 1 rps burst 3; miss TTL 15 min; evict 4000 files / 64 MiB / 6 h | `app/avatars.go:37-56` | | Pipe | 5 s request / 2 min idle timeouts; ACL BA+SY+IU | `ipc/server_windows.go` | | Cert | ECDSA P-256, 20-year validity (trust = pin, not expiry) | `link/cert.go:86` | -| Perf floor | ≥20 MiB/s, worst cross-stream RTT ≤500 ms (64 MiB loopback burst) | `e2e_test.go:716,719` | +| Key exchange | X25519MLKEM768 (PQ hybrid, Go default; `CurvePreferences` unset) | `link/cert.go`, `link/pq_test.go` | +| Perf floor | ≥20 MiB/s, worst cross-stream RTT ≤500 ms (64 MiB loopback burst); per-transport twins `TestBurstThroughputPerConn` / `TestBurstThroughputQUIC` | `e2e_test.go:716,719` | +| Blur ladder | control 10, Signal Glass 20, card frost 30, chrome 36, island 40, float 48, pop 56 px | `tokens.css` | +| Switch geometry | 40×22 track, 1px rim + 2px seat → 16px knob (7px radius), 18px travel; ×`--ui-scale` | `tokens.css`, `ui.tsx Switch` | +| Control height | 2.25rem + 2px = 1px rim + 0.5rem padding + 1.25rem line, per side | `tokens.css` | +| Halo clearance | 12px dot→label (the halo ring breathes out to 5px) | `tokens.css`, `motion.css` | +| Hero bleed | 0 → (page-pad − 8px), continuous from 640px of container width | `tokens.css`, `Overview.tsx` | ## Control-plane message flow @@ -101,6 +123,19 @@ the control stream → tunnel registration: - Capability `tunnel-sync`: one `sync_tunnels{seq, tunnels[]}` desired-state frame; gateway's actor reconciles (identical specs keep listeners + live conns: `actor.reconcile`), answers `sync_result` (stale seq dropped agent-side). +- Capability `gateway-config` (enrolled agents only — it is keyed to the Ed25519 + identity, so the gateway negotiates it away for a shared-token agent, which then + falls back to `tunnel-sync`): the gateway is authoritative. It stores each identity's + desired set in the `AgentStore` (`DesiredConfig`/`AdoptConfig`) with a monotonic + generation, hashed by `HashTunnels`. The agent reports its `configHash`/ + `configGeneration` in the hello; the gateway reconciles its set onto the fresh + session's listeners and, on drift, pushes `push_config{generation, hash, tunnels[]}` + (agent applies + `config_ack`s: `applyPushedConfig`). First contact is bootstrapped by + the `hello_ok{configSeedNeeded}` flag, which asks the agent for one + `propose_config{tunnels[]}` seed. A local edit is a `propose_config` the gateway + adopts, bumps, and re-pushes — deterministic, not last-write-wins; a proposal on a + stale generation is refused and the authoritative set re-pushed + (`pushConfigOnConnect`/`adoptProposal`). - Legacy: per-tunnel `register_tunnel`/`unregister_tunnel` + `register_ok|register_err`. Steady state: `ping/pong` both directions (RTT/jitter/loss both sides; pong echoes `recvUnixNano` for one-way estimates); agent pushes `health{tunnelId, localUp}` on @@ -108,6 +143,43 @@ probe transitions; gateway pushes `conn_stats{[{c,r}]}` (cap `conn-stats`) mappi kernel RTT onto agent conn entries via `ConnKey`. Data streams: gateway → `OpenStream` + `open_conn{tunnelId, clientAddr, connId}` header, then raw bytes. +Per-conn data plane (cap `per-conn-data`; agent config `transport = "per-conn"`): the +control plane stays on the mux, but instead of `OpenStream` the gateway sends +`open_data{connId}` on the control stream and the agent dials back (`dialBackData`) a +fresh `KindData` TCP+TLS connection carrying that connId. The gateway authenticates it +through the same `Validator` and matches it to the waiting player (`perconn.go` +`pendingConn` — an exactly-once, loser-closes handoff), then writes the same `open_conn` +header and splices. One dedicated connection per player, so a lost segment on one +player's connection cannot head-of-line-block another's (the one defect of yamux-over-one- +TCP). Data conns resume the control conn's TLS session (`dialGateway` shares an LRU +`ClientSessionCache`) and are drained on eviction alongside the mux (`agentSession` +`dataConns`, `closeAll`). The gateway advertises the capability only because it serves +the accept path end-to-end; a mux/legacy agent never offers it and rides `OpenStream`. + +QUIC data plane (agent config `transport = "quic"`; gateway `Gateway.QUICEnabled`, on by +default): a separate wire, not a capability. The gateway binds a UDP QUIC listener on the +**same port number** as the TCP control listener (`startQUIC`; TCP/UDP port spaces are +independent, so the pairing code's one host:port serves both) and accepts sessions on it +(`acceptQUIC`/`handleQUICSession`), reusing the same pre-auth guards, `Validator`, and the +shared `buildAndAdmit`/`serveAdmitted` admission as the TCP path. A QUIC session is a +`transport.Session` (`transport/quic.go`) that rides the existing `muxDataPlane` — control +and every player are independent QUIC streams over one connection, so a lost packet on one +stream can't head-of-line-block another (per-conn's benefit, one connection/handshake/NAT +entry). No new control message or capability, and the hello frames are byte-identical. +Liveness stays the app ping (`quicConfig` sets `KeepAlivePeriod=0`, `MaxIdleTimeout` above +the 15 s budget); passive connection migration follows an agent whose IP changes. Eviction +is simpler than per-conn — the session's `Close` (a `*quic.Conn`) tears down every stream, +so `dataConns` stays empty (`closeAll` nil-guards the absent raw conn). + +Auto transport (agent config `transport = "auto"`, the shipped default): a connect-time +fallback ladder, best-isolation first — QUIC → per-conn → mux (`transportPreference`). +`runSessionAuto` tries each non-cooled rung; a rung that *connects* is served (Run backs +off and the ladder re-evaluates on reconnect), a rung that fails to *connect* falls through +immediately. A failed rung is cooled (`transportReprobeAfter`, cleared on `netnotify` +change) only once a later rung succeeds — the "UDP blocked" tell; if every rung fails the +link is simply down, so nothing is cooled. The rung that connects is reported to the GUI as +`ActiveTransport` (tick `Status.Transport`) so a user can see what auto settled on. + ## Analytics data model (`internal/analytics/schema.go`) - `rrd(tier,t,…)` — persisted image of stats tiers 2–4 (28 OHLC/gauge columns; −1 = @@ -181,9 +253,15 @@ equirectangular paths (`worldgeo.ts`, generated — regenerate, don't edit). `?mock=agent|gateway|wizard` plus composable: `&link=down`, `&mode=attached` (gated bindings reject like the real backend), `&fatal=1`, `&fresh=1`, -`&analytics=off`, `&geo=off|empty|error|pending`, `&fx=low|high`. The traffic model -is deterministic functions of absolute time, so chart/tiles/replay all agree at any -poll cadence. When you add a binding, add its stub here or the mock throws. +`&analytics=off`, `&paired=0` (never paired to a gateway — the sidebar's role +switcher cannot become the agent and must route to setup), `&geo=off|empty|error|pending`, +`&fx=low|high`, `&fleet=multi|old` (gateway only — `multi`: a five-agent fleet with a +good/fair/poor health spread instead of the default single agent, for the Agents roster ++ drill-in; `old`: a pre-roster daemon that sends no agents array → the roster's +honest-unavailable state). The traffic model is deterministic functions of absolute time, so +chart/tiles/replay all agree at any poll cadence. When you add a binding, add its stub +here or the mock throws. Role setup mutates the mock's role, so the switcher flips the +whole app live in the browser with no Go running. ## Windows integration corners diff --git a/docs/agent/commands.md b/docs/agent/commands.md index 5c65297..b9cd201 100644 --- a/docs/agent/commands.md +++ b/docs/agent/commands.md @@ -27,7 +27,7 @@ build/bin/proxyforward.exe --version # verify the stamp `http://localhost:5173/?mock=agent` — scenarios `agent|gateway|wizard`, plus composable axes `&link=down &mode=attached &fatal=1 &fresh=1 &analytics=off -&geo=off|empty|error|pending &fx=low|high`. Axis semantics are documented in +&paired=0 &geo=off|empty|error|pending &fx=low|high &fleet=multi|old`. Axis semantics are documented in `docs/agent/architecture.md` ("devmock axes") and the `devmock.ts` header. This is the UI state-matrix test harness — walk the relevant axes before calling UI work done, then spot-check in `wails dev` (WebView2 ≠ your browser). diff --git a/docs/agent/polish-backlog.md b/docs/agent/polish-backlog.md index 69c06e4..d7df9cf 100644 --- a/docs/agent/polish-backlog.md +++ b/docs/agent/polish-backlog.md @@ -14,9 +14,9 @@ Ordered by user-visible impact. "Fix" describes the smallest on-system change. Minecraft-aware hint's "Poll the server for MOTD, player count and version" claim (`frontend/src/screens/Tunnels.tsx:268-283`) — none are wired (`gateway.go handleClient`, no cap enforcement, no status poller). - - Settings: Transport "Per-connection" option (`Settings.tsx:139-144`), Prometheus - toggle + address (`Settings.tsx:227-232`), "Minimize to tray" and "Start on - login" (`Settings.tsx:122-125`) — all stored, all inert. + - Settings: Prometheus toggle + address, "Minimize to tray" and "Start on login" + — all stored, all inert. (The Transport selector is now fully wired: auto / quic / + per-conn / mux are all real, and the tick reports the active transport.) - Fix: either implement, or visibly mark ("not yet wired") / hide until real. Decide per-feature with the human (scope call — escalation trigger). @@ -46,70 +46,120 @@ Ordered by user-visible impact. "Fix" describes the smallest on-system change. 6. **Pagination row duplicated** — identical "x–y of z · Previous/Next" blocks in `Players.tsx:196-206` and `Analytics.tsx:581-591`. Extract a `Pager` into `ui.tsx`. -7. **Players wall search forks the input recipe** — hand-rolled `h-8` input with its - own focus style (`Players.tsx:147-154`) beside kit `TextInput`. Add a compact/icon - variant to `TextInput` and use it. - -8. **BandwidthChart's range + Line/Candles pickers are hand-rolled button rows** +7. **BandwidthChart's range + Line/Candles pickers are hand-rolled button rows** (`BandwidthChart.tsx:85-121`) while every sibling toggle is `SegmentedControl`. Likely deliberate (mono tabular labels, 9 dense options) — either port `SegmentedControl` to support a dense/mono variant, or record the exception in the component comment so it stops looking like drift. -9. **Direction-column vocabulary varies**: "Received/Sent" (`Traffic.tsx:108-109`), +8. **Direction-column vocabulary varies**: "Received/Sent" (`Traffic.tsx:108-109`), "Total ↓/Total ↑" (`Traffic.tsx:136-137`), bare "↓/↑" (`Analytics.tsx:532-533`, `Players.tsx:374-375`). Pick: glyphs for dense tables, words for wide ones — and apply it once. -10. **Legend ramps differ for the same idea** — peak-hours swatches - `[10,30,55,80,100]%` (`Analytics.tsx:343`) vs map activity swatches - `[16,38,58,76,92]%` (`WorldMap.tsx:203`); the map's fill floor is 14 % + 78 %·t - (`WorldMap.tsx:18`) vs heatmap 7 % + 85 %·t (`Analytics.tsx:326`). Harmonize the - ramp constants (one exported pair in `charts/util.ts`). +9. **Legend ramps differ for the same idea** — peak-hours swatches + `[10,30,55,80,100]%` (`Analytics.tsx:343`) vs map activity swatches + `[16,38,58,76,92]%` (`WorldMap.tsx:203`); the map's fill floor is 14 % + 78 %·t + (`WorldMap.tsx:18`) vs heatmap 7 % + 85 %·t (`Analytics.tsx:326`). Harmonize the + ramp constants (one exported pair in `charts/util.ts`). -11. **Two duration styles side by side** — live sessions use clock format `1:02:03` +10. **Two duration styles side by side** — live sessions use clock format `1:02:03` (`fmtElapsed`, `Traffic.tsx:341`) while dossier/history tables use `1h 2m` (`fmtDuration`, `state.ts:61`). Both are defensible; choose per-context (live-updating → clock, historical → words) and write that rule into `state.ts`. ## Small judgment calls -12. **Overview shows RTT twice** — HealthPanel metric and the "Round trip" StatTile +11. **Overview shows RTT twice** — HealthPanel metric and the "Round trip" StatTile (`Overview.tsx:177,262`). Probably drop the tile in favor of something not already on screen (e.g. peak rate today). -13. **AnalyticsUnavailable reuses the Players icon on the Analytics screen** +12. **AnalyticsUnavailable reuses the Players icon on the Analytics screen** (`Players.tsx:53-60` rendered from `Analytics.tsx:42`). Give it a neutral icon (IconAnalytics/IconActivity) or parameterize. -14. **ConnectionPill ignores truncation** — at >150 conns the pill's source data is +13. **ConnectionPill ignores truncation** — at >150 conns the pill's source data is clamped but nothing in the titlebar hints at it (`ConnectionPill.tsx`). Low priority; only matters on very busy gateways. +14. **Overview HealthPanel metrics truncate at mid widths** — the 3-up + jitter/loss/RTT grid (`Overview.tsx HealthMetric`, `grid-cols-3`) lives inside the + `@5xl:col-span-5` card, so at ~1440px the 26px `--fs-metric` numerals clip to + "6.0 …" / "26 …". Either let these values shrink a step (they are metrics, not the + hero) or drop to a 2-up grid below some container width. Predates the glass pass. + 15. **`frontend/src/hooks.ts:26` carries an `eslint-disable` comment but no ESLint config exists** — either add ESLint (decision) or drop the comment. +16. **The analytics DB is role-blind, and the sidebar can now switch roles** + (`RoleSwitcher.tsx`). `engine.New` opens the same `analytics.db` in the config dir + for both roles (`engine.go:96`) and the schema has no role column on `sessions`, + `peers`, `rollup_*`, or `lifetime` (`internal/analytics/schema.go`). Both roles do + record the same *conceptual* population — the gateway forwards the player's address + to the agent (`agent.go Conns`) — but a machine that ran as a gateway and then as an + agent of a *different* tunnel splices two measurement points into one history with + no way to tell the rows apart. Told, not engineered around: the switch confirm + (`RoleSwitcher.tsx`) says so out loud. Fixing it properly means either a `role` + column + a filter on every query, or per-role data dirs — and the config dir also + holds the cert, the logs, and the setup import/export target, so that is an + `overhaul`-scale change, not a migration. + +17. **Analytics Geography empty state collapses three GeoIP conditions into one** — + the map's fallback branches only on `!geoStatus.cityLoaded` and always renders + "GeoIP not configured" (`Analytics.tsx:238`), so a *failed* city-DB open (a real + `geoStatus.cityError`) and a *pending* reload read identically to *unconfigured*. + Settings' `MmdbBadge` already tells Failed/Pending/Loaded apart from the same + `useGeoStatus` data, and CLAUDE.md's GUI contract cites `Analytics.tsx` as the + example of telling states apart — so this is a real gap against it. Predates the + glass pass. Fix: branch on `geoStatus.cityError` the way `MmdbBadge` does. + +18. **Wizard role-choice cards both wear Signal Glass** — the two `RoleCard`s on the + "choose role" step are each `.pf-signal` (`Wizard.tsx:327`), so two pointer-reactive + surfaces coexist on one screen, which `DESIGN.md` rule 2 and `glass.css`'s + live-activity-only restriction warn against (a role picker isn't live traffic). The + glass pass only dropped this card's inset bevel (§8); the `pf-signal` class predates + it. Fix: move `RoleCard` to `.pf-card` (or a "choice" recipe) reserving `.pf-signal` + for the live-handshake step, or record it as a reviewed exception in a one-line + comment (as #7 does for BandwidthChart's pickers). + +## QUIC transport follow-ons + +23. **Gateway per-session QUIC link bytes are unattributed** — the QUIC listener shares + one UDP socket across every agent session, so `sess.link` (the GUI's per-agent + LinkBytesIn/Out) can't be counted at the socket layer the way `NewCountingConn` wraps + a single yamux conn. Process totals (`g.linkTotals`) are exact; the agent side is exact + (one socket = one session). v1 leaves the gateway per-agent link bytes at 0 (renders + "—"). Proper fix: count at the stream layer inside `transport/quic.go` per accepted + session (would need the session's `*stats.LinkCounters` threaded in). +24. **No QUIC connection-migration test** — passive migration (gateway follows an agent + whose public IP changes) is a claimed benefit but untested; a UDP relay that swaps its + source port mid-transfer would exercise it. Flaky-prone, hence deferred. +25. **QUIC perf headroom on Windows** — `TestBurstThroughputQUIC` clears the floor + comfortably on loopback, but quic-go's GSO/GRO/ECN fast paths are largely Linux; a + Windows perf pass (and, if quic-go ever exposes a pluggable CC, BBR for lossy + residential links) is future work. + ## Backend polish adjacent to UX -16. **Gateway jitter/loss comment drift** — `ipc.go:101-103` says "the gateway +19. **Gateway jitter/loss comment drift** — `ipc.go:101-103` says "the gateway reports -1/unknown" but the gateway has measured its own jitter/loss since the bidirectional heartbeat landed (`gateway.go pingLoop`, `engine.go:391-395`). Fix the comment before it misleads someone. -17. **stats.redacted.json in diagnostics is pre-migration only** — after +20. **stats.redacted.json in diagnostics is pre-migration only** — after `ImportLegacyStats` renames `stats.json` away, bundles carry no stats snapshot (`app/tools.go:90`, `analytics/importjson.go`). Either export a redacted snapshot from SQLite or drop the stale zip entry. ## CI debt -18. **`errcheck` is disabled in `.golangci.yml`** — it reports 50 findings, and most +21. **`errcheck` is disabled in `.golangci.yml`** — it reports 50 findings, and most are deliberate (`windows.SetStdHandle`, deferred `Close()` on read paths, netsh calls whose exit code is the real signal). Turning it on means auditing all 50 and writing explicit `_ =` assignments with a reason. Worth doing as its own commit; it was kept out of the CI suite so the lint gate could be green on day one. -19. **Linux/macOS binaries build but cannot run** — CI compiles them to keep the +22. **Linux/macOS binaries build but cannot run** — CI compiles them to keep the `*_other.go` stubs honest, but `ipc.Serve` returns `ErrUnsupported` off Windows (`internal/ipc/stub_other.go`), so the engine never starts. Shipping them means a real unix-socket IPC port — an `overhaul`-skill change, not a stub fix. diff --git a/docs/superpowers/specs/2026-07-15-bandwidth-cap-design.md b/docs/superpowers/specs/2026-07-15-bandwidth-cap-design.md new file mode 100644 index 0000000..b78871f --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-bandwidth-cap-design.md @@ -0,0 +1,206 @@ +# Per-tunnel bandwidth cap — enforcement + scope selector + +Status: approved 2026-07-15. Layers on `feat/multi-agent-gateway`. + +## Problem + +`config.TunnelOptions.BandwidthLimitMbps` already exists — agent-side TOML, validated +`>= 0`, surfaced in the Tunnels editor — but is **never read**. It is the last +Reality-check stub sitting on the data hot path: advertised, stored, ignored. This design +makes it real. A configured cap actually throttles the tunnel, enforced on **both** the +agent and the gateway, and gains a **scope** dimension so the cap is a `(rate, scope)` +pair rather than a bare number. + +## Decisions (locked) + +- **Scope is selectable**: `combined` (default) | `per-direction` | `per-connection`. +- **Limiter** = `golang.org/x/time/rate` (new light direct dep; stdlib-only transitives). +- **Unit** = decimal megabits/sec: `bytesPerSec = Mbps * 125_000`, one `rate` token = one + byte. This is the single source of truth for limiter construction, the e2e throughput + assertion, and the architecture note. No mebibits, no megabytes. +- **Gateway limiter home** = the limiter set hangs on `publicListener`, reached by + **widening the accept-loop callback to pass `pl`** to `handleClient` (no parallel + Registry on the gateway; lifecycle rides the nested `listeners` map). +- Build all three phases in one pass. + +## Branch reality + +The multi-agent gateway (nested `listeners map[agentID]map[tunnelID]*publicListener`, +per-agent `admit`/`evict`, per-agent `agentSession`) is **not on master** — it lives on +`feat/multi-agent-gateway` (Go committed; the frontend Agents dashboard / drill-in is +still uncommitted in the working tree). This work layers on that branch. Two agents may +serve the **same `tunnelID`**, so bucket identity on the gateway is `(agentID, tunnelID)`, +never a bare `tunnelID`. + +## Scope semantics (what "N Mbps" applies to) + +Scope applies **within one `(agentID, tunnelID)`** — never across agents. + +- **combined** (default; `"" ` normalizes here): one shared bucket for that tunnel — both + directions and all its connections sum to ≤ N Mbps. Both `copyHalf` goroutines call + `WaitN` on the *same* limiter. +- **per-direction**: two shared buckets (inbound = client→server, outbound = + server→client), each ≤ N Mbps, each summed across that tunnel's connections. +- **per-connection**: each connection gets its own fresh *combined* bucket of N Mbps; + writes no shared state, takes no lock. + +## Corrections to the working assumptions (verified in code) + +The design was drafted against a `pl`-field / `SpliceOpts` / session-context model that +does not yet exist in the tree. Five gaps, each with its fix: + +1. `gateway.go handleClient(sess, spec, conn)` receives the spec **unpacked**; + `acceptClients` calls `handle(pl.owner, pl.spec, conn)` — `pl` is never passed. Fix: + widen the callback to `func(*publicListener, net.Conn)`. +2. `agentSession` has **no context**. Fix: add `ctx`/`cancel`, cancelled in `evict`, so a + `copyHalf` blocked in `WaitN` unblocks promptly and per-agent. +3. `relay.Splice(a, b, counters)` has no opts/ctx, and the read-buffer constant `bufSize` + is **unexported**. Fix: add `SpliceOpts` + a `Limiter` interface; export `BufSize`. +4. Agent side has no ctx at the splice — `session` has no `ctx` field and + `handleDataStream(st)` isn't given the serve ctx. Fix: thread the serve ctx onto + `session`. +5. `golang.org/x/time` is not yet a dependency. + +Confirmed sound: `TunnelSpec` decodes without `DisallowUnknownFields` (additive fields are +safe); the multi-agent e2e helpers exist (`addAgent`, `waitPublicPortOf`, `tcpTunnel`, +`TestEvictionIsolatesAndDrains`). + +## Invariants + +- **Uncapped = byte-identical fast path.** `mbps <= 0 ⇒` nil limiters ⇒ `copyHalf` skips + the limiter branch, and `Splice` skips the per-splice `context.WithCancel` when both + limiters are nil — zero added allocations/locks. The burst floor + (`TestBurstThroughputAndCrossStreamLatency`, ≥20 MiB/s, worst cross-stream RTT ≤500 ms) + sets no cap; re-run it best-of-3 before/after — the run is the *measured* proof. +- **Bucket identity is `(agentID, tunnelID)`** on the gateway, `tunID` on the agent + (single-identity). Guarded by the two-agent e2e. +- **No per-iteration allocation on the capped path.** Pooled `BufSize` buffer + direction + limiter resolved once per connection; `WaitN(ctx, n)` once per ≤`BufSize` chunk. Burst + = `BufSize`, and `n ≤ BufSize` always, so `WaitN` never errors on burst. +- **Direction mapping lives in one place per call site.** Gateway `copyHalf` AToB = + client→server = inbound; agent AToB = server→client = outbound. +- **Wire additivity.** New `TunnelSpec` fields are `omitempty`, zero-value-safe (0 mbps / + `""` scope = legacy peer), no capability, no `ProtocolVersion` bump — proven byte- + identical in `hello_compat_test.go`. + +## Phase 1 — config + wire plumbing (no enforcement) + +- `internal/config/config.go` — add `BandwidthLimitScope string` to `TunnelOptions`. + Validate in `(*Config).validateAgent()`: normalize `"" → combined`, **reject** an + unknown non-empty scope at load, skip the check (no error) when `mbps <= 0`. +- `internal/control/control.go` — add `BandwidthLimitMbps int + json:"bandwidthLimitMbps,omitempty"` and `BandwidthLimitScope string + json:"bandwidthLimitScope,omitempty"` to `TunnelSpec`; rewrite the "caps stay on the + agent" doc comment. +- `internal/agent/agent.go` — `specFromTunnel` copies both fields; update its comment. +- `internal/control/hello_compat_test.go` — `TestTunnelSpecWireBackCompat`: zero value + omits both keys; a legacy frame decodes both zero; a set value round-trips. +- Gate: `go test ./internal/control/... ./internal/config/... ./internal/agent/...`. +- Commits: (a) config scope + validation; (b) wire fields + `specFromTunnel` + + `hello_compat` + doc rewrites. + +## Phase 2 — enforcement engine + +**Dep:** `go get golang.org/x/time && go mod tidy`; full suite + `govulncheck`; confirm no +transitive deps. + +**`internal/bwcap` (new):** +- `type Scope string` (`ScopeCombined`/`ScopePerDirection`/`ScopePerConnection`); + `NormalizeScope` (`"" →` combined, unknown → combined; fail-safe). +- `type LimiterSet` (exported, so `publicListener` can hold + `atomic.Pointer[bwcap.LimiterSet]`) carrying `scope`, `rate` (mbps), and + `combined/in/out *rate.Limiter`. +- `BuildSet(mbps, scope)` — combined: one shared limiter; per-direction: two; + per-connection: shared nils; uncapped (`mbps<=0`): nil limiters, `rate=0`. +- `Resolve(set) (inbound, outbound relay.Limiter)` — uncapped→`nil,nil`; combined→same + limiter both ways; per-direction→`in,out`; per-connection→mint a fresh combined limiter + (Resolve is called once per connection). +- `newLimiter(mbps) = rate.NewLimiter(rate.Limit(mbps*125_000), relay.BufSize)`. +- Agent `type Registry` keyed by `tunID`: `Resolve(tunID, mbps, scope)` (get-or-build, + apply rate-only `SetLimit` / scope-change rebuild), `Release(tunID)`. Mutex-guarded, no + `config` import. +- Tests: each scope's sharing shape; nil uncapped; rate-only vs scope change; a real + throughput assertion (drive N ≫ burst, `elapsed ≈ bytes/rate`). + +**`internal/relay/relay.go`:** +- Export `BufSize`. +- `type Limiter interface { WaitN(ctx context.Context, n int) error }` (`*rate.Limiter` + satisfies structurally); `nil` = fast path. +- `type SpliceOpts struct { Ctx context.Context; LimitAToB, LimitBToA Limiter }`; add + `opts SpliceOpts` to `Splice` (zero value = today). Derive the per-splice child ctx only + when a limiter is non-nil; `cancel` on either half's return. +- `copyHalf(dst, src, count, ctx, lim)` — inside `if n > 0`, before the write: + `if lim != nil { if err := lim.WaitN(ctx, n); err != nil { src.Close(); return err } }`. +- `isExpectedCloseErr` — add `context.Canceled` / `context.DeadlineExceeded` as clean; the + `WriteStallTimeout` net-deadline and a `n > burst` error stay loud. +- Gate: `go test ./internal/relay/...` + burst floor best-of-3 + a relay throttle test. + +**Gateway wiring** (`internal/gateway/actor.go`, `gateway.go`): +- `publicListener` gains `limiters atomic.Pointer[bwcap.LimiterSet]`. +- Widen the accept-loop callback `func(*agentSession, control.TunnelSpec, net.Conn) → + func(*publicListener, net.Conn)` across `bindLocked`, `bindTunnel`, `reconcile`, + `acceptClients` (`handle(pl, conn)`). +- `bindLocked` builds and `.Store()`s the set from the spec's bandwidth fields. +- `reconcile`: keep the `pl.spec == spec` short-circuit; add a branch — structural fields + equal but bandwidth differs (`sameListener` = equal on + ID/Name/Type/PublicPort/OfflineMOTD/MinecraftAware) → update the limiter in place + (rate-only `SetLimit`; scope change build + `.Store()`), keep the listener and its live + connections. Never mutate `pl.spec` (read off-actor in `acceptClients`); the `LimiterSet` + carries the live rate/scope so repeated reconciles converge idempotently. A structural + change still rebinds. +- `agentSession` gains `ctx`/`cancel` (child of the gateway root ctx), cancelled in + `evict` alongside `closeAll()`. +- `handleClient(pl, clientConn)` — `sess := pl.owner`, `spec := pl.spec`, + `in, out := bwcap.Resolve(pl.limiters.Load())`; AToB = inbound: + `relay.Splice(client, stream, entry.Counters, relay.SpliceOpts{Ctx: sess.ctx, LimitAToB: + in, LimitBToA: out})`. + +**Agent wiring** (`internal/agent/agent.go`): +- `*Agent` gets a `*bwcap.Registry`; `session` gains a `ctx` field set from `serve(ctx)`. +- `handleDataStream` resolves from `tun.Options` via the Registry; AToB = outbound: + `relay.Splice(tcp, src, entry.Counters, relay.SpliceOpts{Ctx: s.ctx, LimitAToB: outbound, + LimitBToA: inbound})`. `Release(tunID)` on tunnel removal. + +**e2e:** add `bandwidthMbps`/`bandwidthScope` to `harnessOpts`. +- Single-tunnel capped, offered load above the cap, ≥ ~2.5–5 MB at 5 Mbps, throughput + within ±15% of the cap; uncapped still clears the floor. Cover combined + per-direction. +- Two-agent isolation (guards the keying fix): two agents share one `tunnelID`, each capped + N; each sustains ≈N independently (combined ≈ 2N). +- Evict-under-throttle (fold into `TestEvictionIsolatesAndDrains`): a capped agent's + throttled connections drop promptly on evict (session-ctx cancel), the other unaffected. + +**Docs:** delete the CLAUDE.md Reality-check "Bandwidth cap" row; add the relay-section +note to `docs/agent/architecture.md` (burst = read-buffer size; `Mbps × 125_000`; caps are +per-`(agentID, tunnelID)`); resolve the honesty-pass "future bandwidth spec" pointer here. + +- Commits: (a) dep + `internal/bwcap` + tests; (b) relay threading + relay test; + (c) call-site wiring + e2e + Reality-check row deletion + architecture note. + +## Phase 3 — UI: scope selector (additive) + +- `frontend/src/screens/Tunnels.tsx` — a scope `Select` (Combined / Per-direction / + Per-connection) beside "Bandwidth cap (Mbps)", disabled/ignored at 0, with a one-line + hint; the tunnel-summary chip shows the scope when a cap is set. +- Gateway drill-in: the cap/scope chip renders in the read-only Tunnels view scoped by + `agentId`. +- devmock: a capped tunnel in the multi-agent gateway fixture (chip at `?mock=gateway` + drill-in) + a capped example in the agent fixture (`?mock=agent`). +- Regenerate `frontend/wailsjs/go/models.ts` via `wails build` (never hand-edit). +- Gate: `npm run build`; walk the Tunnels editor + gateway drill-in (both themes × + Animations Off); dispatch `ui-design-reviewer`. + +## Import layering (no cycle) + +`relay` imports nothing new. `bwcap` imports `relay` (for `BufSize`) + `x/time/rate`. +`agent`/`gateway` import `bwcap` + `relay`; the gateway also reads `pl.limiters` (its own +field). `x/time/rate` is reachable only from `bwcap`. + +## End-to-end verification (not just tests) + +`wails dev`; pair two agents to one gateway (or use the multi-agent devmock gateway +fixture). Give each agent a low cap (e.g. 5 Mbps) on a same-named tunnel, in each of the +three scopes; push traffic; drill into each agent and confirm its per-agent +`BandwidthChart` plateaus at that agent's cap independently (a brief spike up to one +`BufSize` burst is expected). Evict one agent and confirm its throttled connections drop +promptly while the other keeps serving. Flip a cap to 0 and confirm full-rate returns. All +in light + dark, Animations On + Off. diff --git a/docs/superpowers/specs/2026-07-15-honesty-pass-design.md b/docs/superpowers/specs/2026-07-15-honesty-pass-design.md new file mode 100644 index 0000000..df593f2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-honesty-pass-design.md @@ -0,0 +1,253 @@ +# Design — Honesty pass: wire up (and stop over-advertising) four stubbed features + +- **Date:** 2026-07-15 +- **Status:** proposed (awaiting review) +- **Context:** `docs/agent/polish-backlog.md` item #1 ("Stub controls promise unimplemented + behavior") and the `CLAUDE.md` "Reality check" table. Each change here **deletes a + Reality-check row** — the app stops lying, and where cheap, starts telling the truth for real. + +## Goal + +Make proxyforward's advertised surface honest. Four independent changes, none touching the +data hot path. Two of the four *implement* a stubbed feature (Prometheus, Offline MOTD); one +*removes a false capability advertisement* (UDP); one *corrects/hides UI copy* for things we +are deliberately not building yet (per-conn transport, tray/autostart, MC status polling). + +## Scope + +### In scope (this spec) + +1. **CapTunnelUDP — stop advertising** an unimplemented capability (the one live protocol bug). +2. **UI honesty** — correct the Minecraft-aware hint; hide the per-connection transport option + and the tray/autostart toggles. +3. **Prometheus `/metrics`** — stand up the endpoint that config already stores. +4. **Offline MOTD** — wire the already-built, already-fuzzed `mc.ServeOffline` into the gateway. + +### Out of scope (each its own spec) + +- **Bandwidth cap enforcement** — the only stub touching the data hot path; carries three skill + gates (`hot-path`, `wire-protocol`, `dep-bump`) and a new dependency. Split into its own + burst-gated spec immediately after this one. Its Reality-check row (`CLAUDE.md`, "Bandwidth + cap") and its UI controls (`Tunnels.tsx` bandwidth field) are **left untouched** by this spec. +- **Multi-agent namespacing** (`actor.go` single-session → `map[agentID]`) — the committed next + architectural direction; its own spec. Data-wipe is authorized (no on-disk migration needed). +- **UDP tunnels / per-conn transport / tray / autostart** — real features for later lanes. This + spec only makes their *advertising* honest, it does not build them. + +## Cross-cutting discipline + +- **Reality-check rows deleted in the landing commit.** Per `CLAUDE.md` "when you implement one, + delete its row in the same commit." This spec lands three deletions: Offline MOTD row, UDP row, + Prometheus row. The per-conn, MC-status-polling, and tray/autostart rows **stay** (we hide UI, + we do not implement the backend). The bandwidth row stays (separate spec). +- **`internal/doccheck`** scans `CLAUDE.md`, `docs/agent/`, `.claude/rules/`, `.claude/skills/`. + It does **not** scan `docs/superpowers/`, so this file's citations are informational. But every + symbol removed (e.g. `CapTunnelUDP`) must have its doc mentions updated in the same commit for + accuracy — the repo treats a stale citation as a defect even where doccheck can't catch it. +- **One checker per surface.** Go: `go test ./...`. Frontend: `cd frontend && npm run build`. + +--- + +## Feature 1 — CapTunnelUDP: stop advertising *(wire-protocol · size S)* + +**Problem.** `CapTunnelUDP` is advertised in `SupportedCapabilities`, but no UDP code exists and +`validateSpec` rejects `type:"udp"`. The gateway echoes the capability back in `hello_ok`, the +agent may act on a udp spec, and it then dies — a live protocol lie the `CLAUDE.md` invariant +("Never advertise a capability that isn't implemented end-to-end") explicitly names as violated. + +**Design.** Remove the advertisement. `SupportedCapabilities` (`internal/control/control.go:48`) +is the single source of truth for both the agent offer (`agent.go:283`) and the gateway's +accepted intersection (`gateway.go:445`), so one edit closes both sides. + +**Changes (one protocol-only commit):** +- `internal/control/control.go:48` — `SupportedCapabilities` → `[]string{CapTunnelSync, CapConnStats}`. +- `internal/control/control.go:34-38` — delete the `CapTunnelUDP` const + comment (nothing consumes it; no `CapSet.Has(CapTunnelUDP)` anywhere). +- `internal/control/control.go:183` — fix the `TunnelSpec.Type` comment (drop "udp requires CapTunnelUDP"). +- `internal/config/config.go:38-39` — fix the `TunnelUDP` comment ("Requires the tunnel-udp capability" is now false). +- `internal/control/control_test.go:146` — remove `!s.Has(CapTunnelUDP)` (won't compile once the const is gone); update the stale comment at `:149`; add a positive assertion that the supported set no longer contains `"tunnel-udp"` to lock the fix in. +- Docs: delete the UDP Reality-check row (`CLAUDE.md`, ~line 158); drop the "(Currently violated by `CapTunnelUDP`…)" parenthetical from the invariant bullet (~line 64-65); reword the "standing counterexample" bullet in `.claude/skills/wire-protocol/SKILL.md:21-22` to past tense. + +**Confirmed safe.** No golden-frame impact — `capabilities` is a live-negotiated `omitempty` +field, never present in the byte-identical assertions of `hello_compat_test.go` / +`TestLegacyHelloCompat`. **No `ProtocolVersion` bump** — dropping a capability is +backward-compatible (a legacy peer that offered `tunnel-udp` simply has it negotiated away, the +already-tested sync-only path). `validateSpec` and `validateAgent` are untouched: a `type:"udp"` +agent config still passes agent validation and is still rejected at the gateway — this change +only stops the false "I support udp" signal, it does not add or remove udp support. + +**Escalation.** Wire-protocol change → human sign-off required (this spec's approval). It is +*protocol-only* (no implementation rides with it), satisfying "protocol and implementation never +change in the same commit." + +--- + +## Feature 2 — UI honesty *(frontend · size S)* + +Three edits. The Offline-MOTD, Bandwidth-cap, and Prometheus controls are **left in place** — +two of them become real in this spec, and the bandwidth field becomes real in the next. + +1. **Minecraft-aware hint** (`frontend/src/screens/Tunnels.tsx:270`). The claim "Poll the server + for MOTD, player count and version" is fiction (no status poller exists — only login + sniffing). Replace with a hint describing only the true behavior: + `"Sniff player usernames from the login handshake for the traffic and players views."` + The `MinecraftAware` toggle itself stays (sniffing is real, via `mcsniff/`). + +2. **Per-connection transport option** (`frontend/src/screens/Settings.tsx`, transport `Select` + in the agent-role branch, ~line 170-174). The backend never honors `per-conn`. Remove that + one option from the `Select` (leaving `mux`), and correct the Field hint (~line 170) which + currently sells the removed option, e.g. + `"All player traffic is multiplexed over the single control connection."` + +3. **Tray / autostart toggles** (`frontend/src/screens/Settings.tsx`, "Behavior" `Section`, + ~line 152-157). Both inert. Remove the whole Section (it contains only these two toggles) — + **and** its `SectionRail` entry (`{id: 'behavior', label: 'Behavior'}`, ~line 54), or the + scrollspy will point at a missing `s-behavior` node and mis-measure. The codebase has no + "coming soon" convention; straight removal is idiomatic. `cfg.UI.MinimizeToTray`/`Autostart` + are unread elsewhere in the frontend after this (the `devmock.ts` mock value is harmless). + +**Reality-check rows:** per-conn, MC-status-polling, and tray/autostart rows **stay** (backend +still unimplemented; we corrected the UI only). + +**Gate:** `cd frontend && npm run build` (tsc is the only checker). State-matrix walk is a near +no-op (copy/visibility only); re-verify the Settings scrollspy in both roles after edit 3. + +--- + +## Feature 3 — Prometheus `/metrics` *(engine · size M)* + +**Problem.** `MetricsConfig` (`config.go:108-111`, default `127.0.0.1:9464`) is stored and the +Settings toggle round-trips it, but no HTTP server exists. + +**Design.** +- **Source:** the handler calls `Engine.Status()` (`engine.go:341`) once per scrape — the same + lock-free snapshot the IPC pipe already assembles. **Zero data-path cost**; no new counters, + no per-byte work. Respects the `conntrack.go` lock-free-read invariant. +- **Dependency: hand-roll, do not add `client_golang`.** It's absent from `go.mod`/`go.sum`; the + exposition format is trivial line-oriented text (`# HELP` / `# TYPE` / `name{label="v"} value`, + `Content-Type: text/plain; version=0.0.4`). The repo's minimal-dependency posture + (`dependency-review` CI gate) and a fixed ~13-metric set make a stdlib `net/http` handler the + right call. Escape label values (`\`, `"`, `\n`). +- **Lifecycle:** new `internal/engine/metrics.go` — `func (e *Engine) serveMetrics(ctx)` plus a + pure formatter helper (unit-testable). Spawn under `runCtx` in `Engine.Run` (~line 200-211, + beside `runSampler`/`resolver.Run`), own a `metricsDone` channel drained at `engine.go:219-220` + after `cancel()`. On `runCtx.Done()`, `srv.Shutdown(context.Background())` (or `ln.Close()`) + **before `Run` returns** — mandatory, because `RestartEngine` constructs a new engine and + re-binds the same `PrometheusAddr`; a leaked listener = "address already in use" on restart. +- **Non-fatal.** Unlike the IPC pipe (fatal by design via the 2-slot `errCh`), a metrics bind + failure logs a WARN and returns — proxying is the core job. If `!PrometheusEnabled`, don't + spawn at all (so the disabled-path test can assert the port is free). +- **Exposure/privacy.** Keep the loopback default. Warn on a non-loopback bind + (`net.SplitHostPort` + `net.ParseIP().IsLoopback()/IsUnspecified()`) — soft warning, not a + hard validation error (users may front it with a reverse proxy). **No player-name or peer-IP + labels** (privacy charter) — use `Registry.PlayerCount()` (a count), never `Snapshot()`. +- **Sentinels.** `-1` "unknown" gauges (RTT/jitter/loss) are **omitted entirely**, never + exported as `0`/`NaN` — preserves the honest-unknown invariant. + +**Metric set (all from `Status()`, all prefixed `proxyforward_`):** `build_info{version,role}`, +`bytes_total{direction}`, `alltime_bytes_total{direction}`, `link_bytes_total{direction}`, +`connections`, `players`, `link_up`, `link_rtt_ms`/`link_jitter_ms`/`link_loss_pct` (omit at -1), +`link_sessions_total`, `uptime_ms`, `tunnel_local_up{tunnel_id,name}`. + +**Tests (`internal/engine/metrics_test.go`, modeled on `engine_test.go`):** +1. Content/format — GET `/metrics`, assert 200 + content-type + a well-formed known series + (e.g. `^proxyforward_connections \d+$`); assert **no** name/IP substring leaks. +2. Restart / port-leak — start on a fixed borrowed port, cancel, wait for `Run` to return, start + a second engine on the **same** port and assert it also serves (proves listener release). + `goleak` additionally enforces the goroutine exits. +3. Disabled path — `PrometheusEnabled=false` → port stays free. +4. Config validation — enabled + malformed addr fails `Validate`; enabled + public addr still + validates but emits the WARN (assert via a captured `slog` handler). + +**Doc:** delete the Prometheus Reality-check row (`CLAUDE.md`, ~line 160) in this commit. + +--- + +## Feature 4 — Offline MOTD *(gateway · size M)* + +**Problem.** `mc.ServeOffline` (`internal/mc/status.go:62`) is built and fuzzed but never called; +a player hitting a tunnel whose backend is down gets a dead socket. The seam even has a +"milestone 5 adds the offline MOTD here" comment (`gateway.go:782-785`). + +**Design.** One helper, wired into `handleClient` (`gateway.go:786`), which is already called +with the owning `*agentSession` and this tunnel's `control.TunnelSpec` in scope. + +- **Gate on opt-in.** `spec.OfflineMOTD == ""` ⇒ clean close (today's behavior). Non-empty ⇒ + serve. This guard is load-bearing: at the mc layer an empty MOTD silently becomes "Server + offline", but the *gateway* contract is empty = feature off. Do **not** additionally require + `MinecraftAware` — gate purely on `OfflineMOTD != ""`, matching the config contract. +- **Primary trigger: health map.** The common case is "agent connected, local server crashed." + Read `sess.health.Load(spec.ID)` (the per-tunnel `LocalUp`, fed by `TypeHealth` frames, + `actor.go:59-61` / `gateway.go:696`). Serve offline only when health is **known and down** + (`ok && !up`); on unknown health, attempt the real connection (never false-positive a working + backend). Reading `sess.health` directly is more correct than the `g.TunnelLocalUp` helper, + which reads the *current* session. +- **Also cover the race paths.** Route the existing `mux == nil` early return (`gateway.go:794-797`) + and the `OpenStream` failure (`~798-802`) through the same helper, so a session dying + mid-accept also gets a graceful MOTD instead of a bare close. +- **No stream / no conntrack / no `OpenConn`** on the offline path (it returns before all of + that). Add a serve deadline — the public conn has none today; `mc.ServeOffline` requires the + caller to own deadlines, and `handleClient` already owns Close via `defer clientConn.Close()`. +- **Skip VersionName and player counts.** `TunnelSpec` carries no version; let it default to + `"offline"` (protocol is pinned to `-1` regardless, so the string is cosmetic). Real player + counts would need extending `OfflineInfo` + `StatusResponse` — not worth the surface; `0/0` is + correct for an offline server. + +**Sketch (`internal/gateway/gateway.go`):** import `internal/mc`; add `offlineServeTimeout` +(~10s) near the other deadlines; add `serveOffline(conn, spec)` (guard on empty, set deadline, +call `mc.ServeOffline(conn, mc.OfflineInfo{MOTD: spec.OfflineMOTD})`, debug-log the end) and a +tiny `healthDown(sess, id)` helper; rewire the two early returns to +`if mux == nil || healthDown(sess, spec.ID) { g.serveOffline(clientConn, spec); return }`. + +**`ServeOffline` already handles all three exchanges:** server-list status ping (MOTD in the +list), login/transfer attempt (disconnect with MOTD as the chat reason), and legacy `0xFE` ping. + +**Tests:** +- **Primary e2e** (`internal/e2e`, modeled on `TestHealthPropagates`): extend `harnessOpts` with + an `offlineMOTD` field (thread it into the tunnel `Options` like `mcAware`); point `LocalAddr` + at a dead port so `probeOnce` reports down; wait for `TunnelLocalUp` to read known-and-down; + dial the public port, send a status handshake+request, unmarshal `mc.StatusResponse`, assert + `Description.Text == offlineMOTD` and `Version.Protocol == -1`. Add a login-intent variant + asserting the disconnect-reason JSON equals the MOTD. +- **Optional gateway unit test** (`mux == nil` path via a nil-session `agentSession` over + `net.Pipe`). The mc protocol itself is already unit + fuzz tested, so the e2e alone is + acceptable coverage if the fake session proves fiddly. + +**Doc:** delete the Offline-MOTD Reality-check row (`CLAUDE.md`, ~line 156) in this commit. + +--- + +## Build sequence + +Independent features; order is lightest/riskiest-isolating first. Each is its own commit (or +small commit set) and lands its own doc-row deletion. + +1. **CapTunnelUDP removal** — protocol-only; smallest; kills the live bug. +2. **UI honesty** — frontend only; `npm run build`. +3. **Prometheus** — engine HTTP server; non-wire, non-hot-path. +4. **Offline MOTD** — gateway wiring; non-wire, non-hot-path. + +(Then, separate spec: bandwidth cap.) + +## Testing & gates + +- Full Go gate `go test ./...` after 1/3/4 (unit + e2e + goleak + doccheck). +- `cd frontend && npm run build` after 2. +- No `hot-path` burst gate applies to this spec (nothing touches `relay`/`transport`/`stats` + counting) — that gate belongs to the bandwidth-cap spec. +- `-race` runs in CI only (no local C compiler). + +## Risks & escalation + +- **Feature 1 is a wire-protocol change** → the `wire-protocol` skill flags it as an escalation + trigger. It is protocol-only and backward-compatible; this spec's approval is the documented + human sign-off on the exact frame delta (removing `tunnel-udp` from the advertised set). +- **Feature 3 restart hazard:** a leaked metrics listener breaks `RestartEngine`. Mitigated by + the drain-before-`Run`-returns discipline and the dedicated restart/port-leak test. +- **Feature 4 semantics:** the empty-MOTD guard must be exact, or every backend-down tunnel would + suddenly emit "Server offline" to opted-out operators. Covered by the guard + gated e2e. + +## Open questions + +None outstanding. Resolved during brainstorming: implement all four (vs hide); split bandwidth +cap into its own spec; hand-roll Prometheus (no new dep); Offline MOTD triggers on health-down +with the race paths folded in. diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 69cf449..2460115 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -11,20 +11,35 @@ operating a live network. Every design decision is held to these rules: - Traffic — *Motion*: the bandwidth graph itself, bare. The graph is the artwork. - Analytics — *Time & place*: the world map, huge and bare. - Players — *People*: the wall of faces. + - Agents (gateway) — *Fleet*: the roster of machine health cards. Frost, never + Signal Glass — N equal cards can't each answer the pointer (rule 2). The + drill-in borrows Traffic's bare bandwidth hero, scoped to one agent. - Settings — *Precision*: no identity surface. IDE-quiet. - Activity — *Terminal*: the log well, near-zero decoration. -2. **Glass is a reward, not a default.** The signature material — **Signal - Glass** (`.pf-signal`: soft optical distortion, directional reflection, - chromatic edge, internal glow, pointer-wake caustics) — appears only on - surfaces that represent live network activity. Standard cards - (`.pf-card`) are quiet, near-solid panels: one subtle border, one soft - shadow, nothing else. +2. **Everything is glass. The reward is the glass that answers you.** + Standard cards (`.pf-card`) are **frost**: heavy blur, low transmission, a + milled rim. Controls (`.pf-control`) are thin films. They refract what passes + behind them — and that is all they do. The signature material, **Signal + Glass** (`.pf-signal`), is the only surface that *reacts*: it is clear where + cards are frosted, its rim and surface follow the pointer, caustics drift + across it while someone is there, a reflection streak crosses it, and it + ignites when the agent connects. One per screen, only on surfaces that + represent live network activity. + So the hierarchy is no longer *glass vs. not-glass* — it is **behavior**. + Never give a card the caustics, the streak, the arc, or the pointer-wake: + the moment a second surface answers the pointer, the page has two identity + surfaces and rule 1 is broken. 3. **Motion communicates network state, never decoration.** Conduits flow when packets flow and stop when the link is down. The pipeline ignites when the agent connects. A country pulses when a player joins. Idle UI is still UI. Everything gates on `prefersReduced()`. + The sole exception is **tactility** — motion that answers the user's own + hand: the press, the hover lift, the rubber band at a scroller's end + (`frontend/src/rubberband.ts`). It exists to make the instrument feel + physical, so it must always be a *reply* to input and must never play on + its own. 4. **Color represents signal, not branding.** The role aurora, the Emblem, and the role hues are the product's identity — keep them, concentrated. @@ -38,9 +53,11 @@ operating a live network. Every design decision is held to these rules: typography on whitespace, not in boxes. Vary the primitives — hero, metric row, divider, chart, list, code block — never card-card-card. -6. **Type contrast over type size.** 26px page titles, 36px for the one hero - figure per page, 26px standard metrics, 13.5px body, 11px uppercase - tracked labels. The jump between levels is what creates hierarchy. +6. **Type contrast over type size.** The face is Inter (variable, self-hosted). + 26px page titles, 36px for the one hero figure per page, 26px standard + metrics, 13.5px body, 11px uppercase tracked labels — design-width sizes; + `--ui-scale` (tokens.css) steps the whole scale with the viewport. The jump + between levels is what creates hierarchy. 7. **Users should remember one composition from every page.** One deliberate grid break (the Overview pipeline runs full-bleed), one moment of light, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3887441..a125a4b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@fontsource-variable/inter": "^5.2.8", "react": "^19.1.0", "react-dom": "^19.1.0" }, @@ -55,6 +56,15 @@ "tslib": "^2.4.0" } }, + "node_modules/@fontsource-variable/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/frontend/package.json b/frontend/package.json index 34efcd4..58fb336 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource-variable/inter": "^5.2.8", "react": "^19.1.0", "react-dom": "^19.1.0" }, diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 5dc8997..f8b110e 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -5f643673fb83cb3ca0d159c0faf27b18 \ No newline at end of file +31e9a3fcd940b7614f907114c3583d4a \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index edaa7ed..becc279 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,8 +3,9 @@ import {flushSync} from 'react-dom' import {Shell} from './layout/Shell' import {TitleBar} from './layout/TitleBar' import {Sidebar} from './layout/Sidebar' -import {NAV_MAIN, NAV_SETTINGS, NavId} from './nav' +import {mainNav, navFor, NavId} from './nav' import {Overview} from './screens/Overview' +import {Agents} from './screens/Agents' import {Traffic} from './screens/Traffic' import {Players} from './screens/Players' import {Analytics} from './screens/Analytics' @@ -16,6 +17,7 @@ import {CommandPalette} from './components/CommandPalette' import {Spinner} from './components/ui' import {UIStatus, useTick} from './state' import {prefersReduced} from './motion' +import {resetBands} from './rubberband' const supportsVT = typeof (document as Document & {startViewTransition?: unknown}).startViewTransition === 'function' @@ -73,6 +75,8 @@ export default function App() { // Navigate inside a view transition: content morphs, chrome stays pinned. const go = (id: NavId) => { if (id === nav) return + // A bounce still in flight would be captured into the pf-content snapshot. + resetBands() const doc = document as Document & {startViewTransition?: (cb: () => void) => {finished: Promise}} if (!prefersReduced() && doc.startViewTransition) { document.documentElement.classList.add('pf-vt-nav') @@ -83,10 +87,19 @@ export default function App() { } } - // Ctrl+K opens the palette; Ctrl+1..6 jump straight to a screen. + // A live role switch (RoleSwitcher) can hide the current screen — the agent + // has no Agents rail. Fall back to Overview so the console never renders a + // screen the role can't reach. + useEffect(() => { + const role = status?.role || '' + if (nav !== 'settings' && !mainNav(role).some(n => n.id === nav)) setNav('overview') + }, [status?.role, nav]) + + // Ctrl+K opens the palette; Ctrl+1..n jump straight to a screen (the rail is + // role-dependent, so the map is derived from the live role). const [palette, setPalette] = useState(false) useEffect(() => { - const items = [...NAV_MAIN, NAV_SETTINGS] + const items = navFor(status?.role || '') const h = (e: KeyboardEvent) => { if (!e.ctrlKey || e.altKey || e.metaKey) return if (e.key.toLowerCase() === 'k') { e.preventDefault(); setPalette(o => !o); return } @@ -137,7 +150,11 @@ export default function App() { const s = status return ( } + // onPair reopens setup from the console: the sidebar's role switcher can + // always become the gateway, but becoming the agent needs a pairing code + // this machine may never have had — that route lands in the wizard's own + // pairing flow rather than failing backend validation. + sidebar={ setWizardHold(true)} />} titlebar={ setPalette(true)} />} > {/* The wide adaptive canvas: screens lay out against this container's @@ -146,11 +163,13 @@ export default function App() { not-yet-redesigned screens at their designed width; each redesign deletes its own clamp. */}
{nav === 'overview' && } + {nav === 'agents' && } {nav === 'traffic' && } {nav === 'players' && } {nav === 'analytics' && } diff --git a/frontend/src/commands.tsx b/frontend/src/commands.tsx index 9f82ddd..580a067 100644 --- a/frontend/src/commands.tsx +++ b/frontend/src/commands.tsx @@ -4,7 +4,7 @@ import { IconCopy, IconExternal, IconFolder, IconKey, IconMonitor, IconMoon, IconRefresh, IconSun, } from './components/icons' import {copyText} from './components/ui' -import {NAV_MAIN, NAV_SETTINGS, NavId} from './nav' +import {navFor, NavId} from './nav' import {UIStatus} from './state' import {setThemePref} from './theme' @@ -25,15 +25,22 @@ export type Command = { run: (ctx: CommandCtx) => void | Promise } -export const COMMANDS: Command[] = [ - ...[...NAV_MAIN, NAV_SETTINGS].map((n): Command => ({ +/** navCommands: the Navigate section, built from the role's live rail so the + * gateway's Agents entry (and its shortcut) appear only for the gateway. */ +export function navCommands(role: string): Command[] { + return navFor(role).map((n): Command => ({ id: `nav-${n.id}`, title: `Go to ${n.label}`, icon: , kbd: `Ctrl ${n.shortcut}`, section: 'Navigate', run: ctx => ctx.go(n.id), - })), + })) +} + +// The static actions/appearance commands; the Navigate section is prepended per +// role by navCommands() at render time. +export const COMMANDS: Command[] = [ { id: 'theme-light', title: 'Theme: Light', icon: , section: 'Appearance', run: () => setThemePref('light'), diff --git a/frontend/src/components/BandwidthChart.tsx b/frontend/src/components/BandwidthChart.tsx index 6d0b3d7..b269d1e 100644 --- a/frontend/src/components/BandwidthChart.tsx +++ b/frontend/src/components/BandwidthChart.tsx @@ -3,10 +3,11 @@ import {prefersReduced} from '../motion' import {fmtBytes, fmtRate} from '../state' import { Bucket, ChartMode, HistoryResult, RANGE_KEYS, RANGES, RangeKey, SeriesVisibility, - loadCandlePref, loadRangePref, loadSeriesPref, modeFor, saveCandlePref, saveRangePref, saveSeriesPref, + loadCandlePref, loadRangePref, loadSeriesPref, loadUptimePref, modeFor, + saveCandlePref, saveRangePref, saveSeriesPref, saveUptimePref, useBandwidthHistory, } from '../history' -import {Button, Card, LiveDot} from './ui' +import {Button, Card, LiveDot, Switch} from './ui' export {LiveDot} // moved to ui.tsx; re-exported for existing importers import {IconArrowRight} from './icons' @@ -22,6 +23,9 @@ const H = 260 // the same amount so the plot area — and download/upload — stay the same size. // Wide enough for the spelled-out axis names ("conns" / "RTT ms"). const RIGHT_COL = 54 +// The uptime strip claims one short lane below the time-label row when shown, +// so toggling it never resizes the plot itself. +const UPTIME_LANE = 20 // --------------------------------------------------------------------------- // BandwidthPanel: range selector + mode toggle + legend/series toggles + stats @@ -30,19 +34,31 @@ const RIGHT_COL = 54 // cadence and keeps it in a module-level cache so tab switches never lose // history. // --------------------------------------------------------------------------- -export function BandwidthPanel({historyUnsupported, compact = false, hero = false, onExpand}: { +export function BandwidthPanel({historyUnsupported, compact = false, hero = false, onExpand, useHistory, heading = 'Bandwidth', vtName = 'pf-bw'}: { historyUnsupported?: boolean /** Compact teaser: live-rate headline + a 1h sparkline, no controls, optional jump-off. */ compact?: boolean /** Hero: Traffic's identity surface — bare, no card. The graph is the artwork. */ hero?: boolean onExpand?: () => void + /** History source hook — defaults to the gateway-wide series. The Agents + * drill-in injects a per-agent source so the same instrument (range, candles, + * series, stats) draws one agent's RRD without forking the panel. */ + useHistory?: (range: RangeKey) => HistoryResult | null + /** Card heading override (the per-agent panel keeps "Bandwidth"). */ + heading?: string + /** View-transition name — one per screen. Pass null for the drill-in chart, + * which shares a screen with no morph target. */ + vtName?: string | null }) { const [rangePref, setRange] = useState(loadRangePref) const [candles, setCandles] = useState(loadCandlePref) const [vis, setVis] = useState(loadSeriesPref) + const [uptime, setUptime] = useState(loadUptimePref) const range: RangeKey = compact ? '1h' : rangePref - const data = useBandwidthHistory(range) + // One hook call, one position — the source is chosen once per mount. + const useHist = useHistory ?? useBandwidthHistory + const data = useHist(range) const spec = RANGES[range] const mode = modeFor(range, candles) const buckets = data?.buckets ?? [] @@ -50,6 +66,7 @@ export function BandwidthPanel({historyUnsupported, compact = false, hero = fals const pickRange = (r: RangeKey) => { setRange(r); saveRangePref(r) } const pickCandles = (on: boolean) => { setCandles(on); saveCandlePref(on) } + const pickUptime = (on: boolean) => { setUptime(on); saveUptimePref(on) } const toggle = (k: keyof SeriesVisibility) => { setVis(prev => { const next = {...prev, [k]: !prev[k]}; saveSeriesPref(next); return next }) } @@ -73,6 +90,7 @@ export function BandwidthPanel({historyUnsupported, compact = false, hero = fals } {!compact && ( - + <> + + {/* Coverage toggle: reveal a strip under the time axis marking when + the app was actually recording — the stretches the plot draws as + real data, versus the quiet gaps that read as 0 B/s. */} +
+ + +
+ )} ) @@ -144,8 +175,9 @@ export function BandwidthPanel({historyUnsupported, compact = false, hero = fals // The panel names itself a shared element: navigating Overview ⇄ Traffic // morphs the teaser into the hero (each screen mounts exactly one). The // view-transition group animates the box, so the teaser's quiet card can - // morph into the bare hero. -
+ // morph into the bare hero. The drill-in chart passes vtName=null — it has + // no morph partner and must not claim the name a real transition owns. +
{hero ? (
@@ -159,7 +191,7 @@ export function BandwidthPanel({historyUnsupported, compact = false, hero = fals
) : ( number, +): {b: Bucket; cx: number} | null { + if (!buckets.length) return null + // Slots tile [x(t), x(t+bucketMs)); contiguous buckets abut exactly, so a hit + // here is genuine data (candle-coalesced, unevenly spaced slots included). + for (const b of buckets) { + if (hoverX >= x(b.t) && hoverX < x(b.t + bucketMs)) return {b, cx: x(b.t + bucketMs / 2)} + } + // Empty time: invert x to a timestamp (clamped into the domain), snap to the + // bucket grid, and render it as zero throughput at that slot's center. + const ht = Math.min(t1 - 1, Math.max(t0, t0 + ((hoverX - padL) / plotW) * span)) + const slot = Math.floor(ht / bucketMs) * bucketMs + return {b: zeroBucket(slot), cx: x(slot + bucketMs / 2)} +} + /** useTweenedPlots eases plotted values toward each fresh target over ~220ms * (ease-out cubic), aligning by timestamp so refreshes glide instead of * snapping. Buckets with no prior value (newly appeared) snap in. */ @@ -370,9 +443,12 @@ function useTweenedPlots(target: Plot[]): Plot[] { const SPARK_H = 160 const SPARK_PAD = {t: 14, r: 2, b: 4, l: 2} -function SparkChart({buckets, bucketMs, emptyHint}: { +function SparkChart({buckets, bucketMs, windowMs = 0, emptyHint}: { buckets: Bucket[] bucketMs: number + /** Range window (ms); anchors the time domain so young data grows in from + * the right edge instead of floating mid-plot. 0 = fit the data extent. */ + windowMs?: number emptyHint?: string }) { const svgRef = useRef(null) @@ -384,8 +460,12 @@ function SparkChart({buckets, bucketMs, emptyHint}: { const view = useMemo(() => { if (!buckets.length || !bucketMs) return null - const t0 = buckets[0].t + // Anchor the domain to the full range window: a freshly started app has + // only seconds of history, and fitting the extent floated a short line in + // the middle of the plot. Anchored, the line hugs the right ("now") edge + // and grows leftward until the window fills — the live-monitor read. const t1 = buckets[buckets.length - 1].t + bucketMs + const t0 = windowMs > 0 ? Math.min(buckets[0].t, t1 - windowMs) : buckets[0].t const span = Math.max(1, t1 - t0) const x = (t: number) => SPARK_PAD.l + ((t - t0) / span) * plotW const dn = niceScale(Math.max(1, ...buckets.map(b => b.out * 1000 / bucketMs))) @@ -394,7 +474,7 @@ function SparkChart({buckets, bucketMs, emptyHint}: { const yDn = (v: number) => baseY - (v / dn.max) * plotH const yUp = (v: number) => baseY - (v / up.max) * plotH return {t0, t1, span, x, yDn, yUp, nowMs: Date.now()} - }, [buckets, bucketMs, plotW, baseY]) + }, [buckets, bucketMs, windowMs, plotW, baseY]) const plotsTarget = useMemo( () => (view ? toPlots(buckets, bucketMs, 'line', view.nowMs) : []), @@ -402,18 +482,11 @@ function SparkChart({buckets, bucketMs, emptyHint}: { ) const plots = useTweenedPlots(plotsTarget) - const hover = useMemo(() => { - if (hoverX === null || !view || !buckets.length) return null - let best = 0 - let bestD = Infinity - for (let i = 0; i < buckets.length; i++) { - const c = view.x(buckets[i].t + bucketMs / 2) - const d = Math.abs(c - hoverX) - if (d < bestD) { bestD = d; best = i } - } - const b = buckets[best] - return {b, cx: view.x(b.t + bucketMs / 2)} - }, [hoverX, view, buckets, bucketMs]) + const hover = useMemo( + () => (hoverX === null || !view ? null + : resolveHover(hoverX, buckets, bucketMs, view.t0, view.t1, view.span, SPARK_PAD.l, plotW, view.x)), + [hoverX, view, buckets, bucketMs, plotW], + ) if (!view) { // HTML well (not SVG text) so the long hints wrap; height matches the @@ -429,6 +502,12 @@ function SparkChart({buckets, bucketMs, emptyHint}: { const cx = (t: number) => x(t + bucketMs / 2) const dnLine = plots.map((p, i) => `${i === 0 ? 'M' : 'L'}${cx(p.t).toFixed(1)},${yDn(p.dn).toFixed(1)}`).join('') const upLine = plots.map((p, i) => `${i === 0 ? 'M' : 'L'}${cx(p.t).toFixed(1)},${yUp(p.up).toFixed(1)}`).join('') + // A series that is flat zero across the window is not drawn: both lines + // would stack neon on the baseline and whichever painted last (blue upload) + // won. The baseline rule IS the zero line; the headlines above still say + // "0 B/s" in each series color, so nothing goes unexplained. + const dnIdle = plots.every(p => p.dn <= 0) + const upIdle = plots.every(p => p.up <= 0) const first = plots[0] const lastP = plots[plots.length - 1] const hoverDn = hover ? hover.b.out * 1000 / bucketMs : 0 @@ -464,16 +543,20 @@ function SparkChart({buckets, bucketMs, emptyHint}: { - - - - - - + {!dnIdle && <> + + + + } + {!upIdle && <> + + + + } {/* End dots anchor the headline numerals to their line ends. */} - - + {!dnIdle && } + {!upIdle && } {/* Hover: hairline + dots + one readout line in the top pad lane, keeping to the side away from the cursor. */} @@ -504,14 +587,19 @@ function SparkChart({buckets, bucketMs, emptyHint}: { // (thin line) are recessive overlays, each on its own outboard right axis added // only when enabled, so they never overlap the download/upload scales. // --------------------------------------------------------------------------- -export function BandwidthChart({buckets: rawBuckets, bucketMs: rawBucketMs, mode, vis, emptyHint, height = H}: { +export function BandwidthChart({buckets: rawBuckets, bucketMs: rawBucketMs, windowMs = 0, mode, vis, emptyHint, height = H, showUptime = false}: { buckets: Bucket[] bucketMs: number + /** Range window (ms); anchors the time domain so young data grows in from + * the right edge instead of floating mid-plot. 0 = fit the data extent. */ + windowMs?: number mode: ChartMode vis: SeriesVisibility emptyHint?: string /** viewBox height; the plot area absorbs the change (default 260). */ height?: number + /** Draw the uptime coverage strip in an extra lane below the time axis. */ + showUptime?: boolean }) { const svgRef = useRef(null) const [hoverX, setHoverX] = useState(null) @@ -559,8 +647,16 @@ export function BandwidthChart({buckets: rawBuckets, bucketMs: rawBucketMs, mode const view = useMemo(() => { if (!buckets.length || !bucketMs) return null - const t0 = buckets[0].t + // Line/candle ranges are a live feed: right-anchor to the window so young + // history hugs the "now" edge and grows leftward instead of floating short + // in the middle. Bars are historical totals — fit the data extent instead, + // so a sparse history (say a week of data inside the 30d window) spreads + // across the plot rather than cramming against the right at a tiny scale + // with overlapping date labels. const t1 = buckets[buckets.length - 1].t + bucketMs + const t0 = mode === 'bars' + ? buckets[0].t + : windowMs > 0 ? Math.min(buckets[0].t, t1 - windowMs) : buckets[0].t const span = Math.max(1, t1 - t0) const x = (t: number) => PAD.l + ((t - t0) / span) * plotW @@ -587,7 +683,7 @@ export function BandwidthChart({buckets: rawBuckets, bucketMs: rawBucketMs, mode const timeTicks = ticksFor(t0, t1, bucketMs, buckets).map(tk => ({...tk, x: x(tk.t)})) const nowMs = Date.now() return {t0, t1, span, x, left, right, yL, yR, connScale, rttScale, yConn, yRtt, timeTicks, nowMs} - }, [buckets, bucketMs, mode, PAD.r, showConn, showRtt]) + }, [buckets, bucketMs, windowMs, mode, PAD.r, showConn, showRtt]) const plotsTarget = useMemo( () => (view && mode !== 'bars' ? toPlots(buckets, bucketMs, mode, view.nowMs) : []), @@ -595,18 +691,11 @@ export function BandwidthChart({buckets: rawBuckets, bucketMs: rawBucketMs, mode ) const plots = useTweenedPlots(plotsTarget) - const hover = useMemo(() => { - if (hoverX === null || !view || !buckets.length) return null - let best = 0 - let bestD = Infinity - for (let i = 0; i < buckets.length; i++) { - const cx = view.x(buckets[i].t + bucketMs / 2) - const d = Math.abs(cx - hoverX) - if (d < bestD) { bestD = d; best = i } - } - const b = buckets[best] - return {b, cx: view.x(b.t + bucketMs / 2)} - }, [hoverX, view, buckets, bucketMs]) + const hover = useMemo( + () => (hoverX === null || !view ? null + : resolveHover(hoverX, buckets, bucketMs, view.t0, view.t1, view.span, PAD.l, plotW, view.x)), + [hoverX, view, buckets, bucketMs, PAD.l, plotW], + ) if (!view) { return ( @@ -628,17 +717,29 @@ export function BandwidthChart({buckets: rawBuckets, bucketMs: rawBucketMs, mode const cx = (t: number) => x(t + bucketMs / 2) const dnLine = plots.map((p, i) => `${i === 0 ? 'M' : 'L'}${cx(p.t).toFixed(1)},${yL(p.dn).toFixed(1)}`).join('') const upLinePlots = plots.map((p, i) => `${i === 0 ? 'M' : 'L'}${cx(p.t).toFixed(1)},${yR(p.up).toFixed(1)}`).join('') + // Flat-zero series are not painted (see SparkChart): the baseline rule is + // the zero line, and two glowing strokes stacked on it just read as one + // wrong-colored line. + const dnIdle = plots.length > 0 && plots.every(p => p.dn <= 0) + const upIdle = plots.length > 0 && plots.every(p => p.up <= 0) // Outboard axis label x-positions (each column just right of the previous). const axisX = (idx: number) => plotRight + (mode === 'bars' ? 16 : 68) + idx * RIGHT_COL + 6 const connIdx = 0 const rttIdx = showConn ? 1 : 0 + // Uptime strip: an extra lane below the time labels, so the plot geometry is + // unchanged whether it shows or not. Bar sits low in the lane, aligned to the + // same x-domain as the axis above it. + const svgH = showUptime ? height + UPTIME_LANE : height + const stripY = height + 5 + const stripBarH = 6 + return (
{ const r = svgRef.current!.getBoundingClientRect() @@ -646,6 +747,16 @@ export function BandwidthChart({buckets: rawBuckets, bucketMs: rawBucketMs, mode }} onMouseLeave={() => setHoverX(null)} > + {/* Clip the time-label lane to the plot's x-span so a label centered on + a tick near either edge is cut at the axis gridline — it slides + *under* the axis (paired with the edge fade below) instead of + spilling into the y-axis value columns or the corner axis markers. */} + + + + + + {/* fine grid: horizontal at value ticks, vertical at time ticks — recessive; the data is the artwork */} @@ -676,19 +787,23 @@ export function BandwidthChart({buckets: rawBuckets, bucketMs: rawBucketMs, mode {view.rttScale && view.rttScale.ticks.map((v, i) => v > 0 && ( {v} ))} - {/* time labels — opacity ramps to zero toward the plot edges, so a - tick drifting left (live data slides the axis) fades away instead - of popping, new ticks fade in from the right, and no label ever - reaches the corner axis markers (↓ / ↑ / conns / RTT ms). */} - {timeTicks.map((t, i) => { - const fade = Math.min(1, (t.x - (PAD.l + 16)) / 26, (plotRight - 16 - t.x) / 26) - if (fade <= 0.02) return null - return ( - - {t.label} - - ) - })} + {/* time labels — clipped to the plot x-span (above) and cross-faded + at both edges, so a tick drifting toward an edge (live data slides + the axis) dims and tucks under the axis gridline instead of + popping or overrunning it; a new tick fades in from the right. The + ramp reaches 0 exactly at the gridline (24px window), where the + clip has already hidden the overhang. */} + + {timeTicks.map((t, i) => { + const fade = Math.min(1, (t.x - PAD.l) / 24, (plotRight - t.x) / 24) + if (fade <= 0.02) return null + return ( + + {t.label} + + ) + })} + {/* axis names, in the time-label row's empty corners — spelled out and wearing their exact series color, so the outboard tick columns @@ -719,7 +834,7 @@ export function BandwidthChart({buckets: rawBuckets, bucketMs: rawBucketMs, mode - {showDn && <> + {showDn && !dnIdle && <> {plots.length === 1 && } } - {showUp && <> + {showUp && !upIdle && <> ))} - {showUp && } + {showUp && !upIdle && } )} @@ -794,6 +909,23 @@ export function BandwidthChart({buckets: rawBuckets, bucketMs: rawBucketMs, mode ) })} + {/* uptime coverage strip: neutral fill across the spans the app was + recording, faint track across downtime — the plot's quiet gaps line + up with the empty stretches here. */} + {showUptime && ( + + + {coverageRuns(buckets, bucketMs).map((r, i) => { + const rx0 = Math.max(PAD.l, x(r.a)) + const rx1 = Math.min(plotRight, x(r.b)) + return ( + + ) + })} + + )} + {/* crosshair + readout */} {hover && ( number): number { return -1 } +/** coverageRuns collapses the buckets into the wall-clock spans the app was + * actually recording: adjacent buckets (starts within half a slot of the prior + * slot's end) join one run; a real gap starts a new one. These are the filled + * segments of the uptime strip — everything between them is downtime. */ +function coverageRuns(buckets: Bucket[], bucketMs: number): {a: number; b: number}[] { + const runs: {a: number; b: number}[] = [] + for (const bk of buckets) { + const last = runs[runs.length - 1] + if (last && bk.t - last.b <= bucketMs * 0.5) last.b = bk.t + bucketMs + else runs.push({a: bk.t, b: bk.t + bucketMs}) + } + return runs +} + /** stepSegments builds one path per run of known gauge buckets, drawing a * horizontal segment across each bucket's width (a gauge is constant within a * slot) joined by vertical steps. Unknown (-1) buckets break the line. */ diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index c043ac9..28c6f30 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -1,5 +1,5 @@ import {useEffect, useMemo, useRef, useState} from 'react' -import {Command, CommandCtx, COMMANDS, fuzzyScore} from '../commands' +import {Command, CommandCtx, COMMANDS, fuzzyScore, navCommands} from '../commands' import {IconSearch} from './icons' import {Kbd} from './ui' @@ -21,7 +21,10 @@ export function CommandPalette({ctx, onClose}: {ctx: CommandCtx; onClose: () => window.setTimeout(() => closeRef.current(), 150) } - const available = useMemo(() => COMMANDS.filter(c => !c.when || c.when(ctx)), [ctx]) + // Navigate commands are role-derived (the gateway's Agents entry); prepend + // them so the palette lists exactly the screens the current role can reach. + const all = useMemo(() => [...navCommands(ctx.status.role), ...COMMANDS], [ctx.status.role]) + const available = useMemo(() => all.filter(c => !c.when || c.when(ctx)), [all, ctx]) const results = useMemo(() => { if (!q.trim()) return available return available @@ -107,18 +110,20 @@ export function CommandPalette({ctx, onClose}: {ctx: CommandCtx; onClose: () => /> Esc
-
- {results.length === 0 && ( -
Nothing matches "{q}".
- )} - {grouped - ? grouped.map(([section, cmds]) => ( -
-
{section}
- {cmds.map(renderItem)} -
- )) - : results.map(renderItem)} +
+
+ {results.length === 0 && ( +
Nothing matches "{q}".
+ )} + {grouped + ? grouped.map(([section, cmds]) => ( +
+
{section}
+ {cmds.map(renderItem)} +
+ )) + : results.map(renderItem)} +
↑ ↓ navigate diff --git a/frontend/src/components/ConnectionPill.tsx b/frontend/src/components/ConnectionPill.tsx index fcdce47..8dbafaf 100644 --- a/frontend/src/components/ConnectionPill.tsx +++ b/frontend/src/components/ConnectionPill.tsx @@ -1,14 +1,18 @@ import {useEffect, useState} from 'react' -import {fmtUptime, useTickStale, UIStatus} from '../state' +import {fmtUptime, useTickStale, UIStatus, worstHealth} from '../state' import {RoleWord, Spinner} from './ui' /** * The one-glance link state in the title bar: state + consequence, with RTT, - * loss and uptime once the link is up. Shows "Syncing…" when snapshots stall. + * loss and uptime once the link is up. For a gateway it rolls the whole fleet + * up — "N agents online" with the worst-of-fleet health driving the dot. Shows + * "Syncing…" when snapshots stall. */ export function ConnectionPill({status}: {status: UIStatus}) { const stale = useTickStale(status) const isAgent = status.role === 'agent' + const agents = status.agents ?? [] + const n = agents.length const up = isAgent ? status.linkUp : status.agentConnected // 1 Hz re-render so uptime ticks between the 2 Hz snapshots. @@ -30,32 +34,44 @@ export function ConnectionPill({status}: {status: UIStatus}) { const label = isAgent ? up ? 'Connected' : 'Reconnecting…' - : up - ? <>Agent online - : <>Waiting for agent + : n === 0 + ? <>Waiting for agents + : n === 1 + ? <>Agent online + : <>{n} agents online // Once up, the health rollup drives the dot so jitter/loss degradation shows - // at a glance. Both roles measure their own health. + // at a glance. The agent scores its own link; the gateway takes the fleet's + // worst so one struggling agent isn't hidden behind healthy ones. const health = status.healthScore const tone = !up ? (isAgent ? 'bad' : 'warn') - : health === 'warn' || health === 'bad' ? health - : 'good' + : isAgent + ? (health === 'warn' || health === 'bad' ? health : 'good') + : worstHealth(agents) const color = {good: 'var(--good)', bad: 'var(--bad)', warn: 'var(--warn)'}[tone] - const showLoss = up && status.packetLossPct > 0 + // Per-link RTT/loss/uptime are only unambiguous for one link: the agent's own, + // or a gateway with exactly one agent. With a fleet, the roster owns them. + const showTail = up && (isAgent || n === 1) + const showLoss = showTail && status.packetLossPct > 0 return (
- - {label} - {up && · {status.rttMillis} ms} + {/* The dot keeps --halo-gap from its own label — it breathes a 5px ring + (motion.css .pf-halo) — while the readouts that follow stay on the + pill's tighter rhythm. */} + + + {label} + + {showTail && · {status.rttMillis} ms} {showLoss && ( · {status.packetLossPct.toFixed(status.packetLossPct < 10 ? 1 : 0)}% loss )} - {up && uptime && · up {uptime}} + {showTail && uptime && · up {uptime}}
) } diff --git a/frontend/src/components/Emblem.tsx b/frontend/src/components/Emblem.tsx index 8576b0a..f13fcae 100644 --- a/frontend/src/components/Emblem.tsx +++ b/frontend/src/components/Emblem.tsx @@ -28,10 +28,17 @@ export function Emblem({role, size = 32, glow = false, fixed = false}: { borderRadius: Math.max(4, Math.round(size * 0.28)), color: c, border: `1px solid color-mix(in srgb, ${c} 45%, var(--border))`, - background: `linear-gradient(160deg, color-mix(in srgb, ${c} 26%, transparent), color-mix(in srgb, ${c} 8%, transparent))`, + // Catch-light is a padding-box band, not an `inset 0 1px 0`: an offset + // inset shadow rounds the corner as a crescent that specks where it + // ends (glass.css, the rim primitive). + // backgroundImage, never the `background` shorthand: React warns when a + // style object updates a shorthand alongside a longhand it subsumes + // (backgroundClip), and this mark re-tints on every role swap. + backgroundImage: `linear-gradient(180deg, var(--bevel-top) 0 1px, transparent 1px), linear-gradient(160deg, color-mix(in srgb, ${c} 26%, transparent), color-mix(in srgb, ${c} 8%, transparent))`, + backgroundClip: 'padding-box, border-box', boxShadow: glow - ? `inset 0 1px 0 var(--bevel-top), 0 0 ${Math.round(size * 0.8)}px ${-Math.round(size * 0.25)}px color-mix(in srgb, ${c} 70%, transparent)` - : 'inset 0 1px 0 var(--bevel-top)', + ? `0 0 ${Math.round(size * 0.8)}px ${-Math.round(size * 0.25)}px color-mix(in srgb, ${c} 70%, transparent)` + : undefined, }} > diff --git a/frontend/src/components/GeoRank.tsx b/frontend/src/components/GeoRank.tsx index fadc2b3..82aa2bb 100644 --- a/frontend/src/components/GeoRank.tsx +++ b/frontend/src/components/GeoRank.tsx @@ -34,6 +34,10 @@ export function GeoRank({rows, metric, hoverCc, onHover, onSelect, selectedCc, c {metric === 'latency' ? 'Avg ping' : 'Sessions'}
)} + {/* Deliberately NOT overscroll-contain (rubberband.ts): an embedded list on + a long page must keep chaining, or reaching its end would freeze the + page under the cursor. Overscroll here falls through to the page, and + the page is what bounces. */}
{shown.map(r => { const val = metric === 'latency' ? r.rttAvg : r.sessions diff --git a/frontend/src/components/RoleSwitcher.tsx b/frontend/src/components/RoleSwitcher.tsx new file mode 100644 index 0000000..f9f0e47 --- /dev/null +++ b/frontend/src/components/RoleSwitcher.tsx @@ -0,0 +1,189 @@ +import {useEffect, useRef, useState} from 'react' +import {GetConfig, RestartEngine, SaveSettings, SetupGateway} from '../../wailsjs/go/app/App' +import {config} from '../../wailsjs/go/models' +import {Badge, Button, ErrorBanner, Menu, MenuItem, Modal, RoleWord} from './ui' +import {Emblem} from './Emblem' +import {IconChevronDown} from './icons' +import {UIStatus} from '../state' + +type Role = 'agent' | 'gateway' + +const OTHER: Record = {agent: 'gateway', gateway: 'agent'} + +/** + * RoleSwitcher: the sidebar's mode identity anchor, made live. This machine can + * be the agent or the gateway, and the config holds BOTH sections + * independently (`internal/config/config.go`), so flipping is a one-field + * change — not a reinstall. + * + * The backend already refuses the unsafe direction, loudly: SaveSettings + * validates before it writes, so `Role = agent` without a pairing token is + * rejected and nothing is persisted. Rather than surface that as an error + * string, the menu reads the config first and offers the wizard's pairing flow + * instead. + * + * → gateway is always available: `app.go SetupGateway` stops the engine, sets + * the role, MINTS a token if there isn't one, saves, and starts. The TLS + * keypair is cached in the config dir, so a machine that was a gateway before + * comes back with the same certificate fingerprint — previously issued + * pairing codes keep working. + * → agent needs a pairing code it can only get from a gateway. With one already + * stored it is GetConfig → Role → SaveSettings → RestartEngine, the same pair + * Settings runs on save. Without one, this routes to setup. + * + * Attached to a service, the config belongs to the service — RestartEngine + * refuses in ModeAttached, so the trigger is disabled and says why. + */ +export function RoleSwitcher({status, onPair}: {status: UIStatus; onPair: () => void}) { + const role: Role = status.role === 'agent' ? 'agent' : 'gateway' + const attached = status.mode === 'attached' + const btnRef = useRef(null) + const [open, setOpen] = useState(false) + const [confirm, setConfirm] = useState(null) + const [cfg, setCfg] = useState(null) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState('') + + // Re-read on every role change: after a switch the other side's readiness + // (and the gateway's public host) may be different. + useEffect(() => { + let cancelled = false + GetConfig().then(c => { if (!cancelled) setCfg(c) }).catch(() => {}) + return () => { cancelled = true } + }, [status.role]) + + // "Paired" is exactly what validateAgent demands before it will let the role + // be saved (config.go): a gateway host and a token. + const paired = !!(cfg?.Agent?.Token && cfg?.Agent?.GatewayHost) + + // Hovering a role previews its whole world: [data-role] remaps the accent + // ramp and the registered --accent property (tokens.css) cross-fades every + // var() consumer down the tree. The wizard's role cards do the same thing. + const preview = (r: Role | null) => { + document.documentElement.dataset.role = r ?? (status.role || 'unset') + } + // Never leave the app wearing a previewed role. + useEffect(() => () => preview(null), []) + + const close = () => { setOpen(false); preview(null) } + + const pick = (target: Role) => { + close() + if (target === role) return + if (target === 'agent' && !paired) { onPair(); return } + setErr('') + setConfirm(target) + } + + const doSwitch = async (target: Role) => { + setBusy(true); setErr('') + try { + const c = await GetConfig() + if (target === 'gateway') { + // Mints the token itself if this machine has never been a gateway. + await SetupGateway((c.Gateway?.PublicHost || '').trim()) + } else { + c.Role = 'agent' + await SaveSettings(c) + await RestartEngine() + } + setConfirm(null) + } catch (e) { + setErr(String(e)) + } finally { + setBusy(false) + } + } + + return ( + <> + + + +
+ This machine is +
+ {(['gateway', 'agent'] as Role[]).map(r => ( + } + title={r === 'agent' ? 'Agent' : 'Gateway'} + hint={r === 'agent' + ? (paired ? 'Hosts Minecraft — dials out' : 'Not paired yet — set up a pairing code') + : 'Faces the internet — players connect here'} + onClick={() => pick(r)} + onPointerEnter={() => preview(r)} + onPointerLeave={() => preview(null)} + /> + ))} +
+ + {confirm && ( + { if (!busy) { setConfirm(null); setErr('') } }} + footer={ + <> + + + + } + > +
+

+ This machine stops being the{' '} + {OTHER[confirm]} and becomes the{' '} + {confirm}. The engine restarts, so{' '} + any live player sessions drop. +

+ {confirm === 'gateway' ? ( +

+ Players will connect to this machine and it will listen for an agent. Its + certificate is kept in the config folder, so any pairing code it has already + handed out keeps working. +

+ ) : ( +

+ This machine will dial out to{' '} + {cfg?.Agent?.GatewayHost}{' '} + and stop accepting players directly. Its gateway settings stay in the config, + so you can switch back. +

+ )} +

+ Analytics keeps one history for this machine, and rows carry no role — sessions + recorded as the {OTHER[confirm]} and as the {confirm} will sit side by side in + Traffic and Analytics. +

+ {err && setErr('')} />} +
+
+ )} + + ) +} diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx index 4fd3eb2..e59443b 100644 --- a/frontend/src/components/icons.tsx +++ b/frontend/src/components/icons.tsx @@ -36,6 +36,11 @@ export const IconAnalytics = (p: IconProps) => ( export const IconSettings = (p: IconProps) => ( ) +/** Agents: the gateway's fleet — a stack of two machine tiles, so the roster of + * several agents reads at rail size. */ +export const IconAgents = (p: IconProps) => ( + +) /* --- Window controls ------------------------------------------------------ */ export const IconMinimize = (p: IconProps) => ( diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index a84bfbb..4f2ff7f 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -147,7 +147,7 @@ export function PillGroup({value, onChange, options}: {
+ live @@ -223,10 +227,15 @@ export function Button({children, onClick, variant = 'primary', size = 'md', dis className?: string title?: string }) { + // The catch-light is --btn-lip, a layer of .pf-btn's rim ring — never an + // `inset 0 1px 0` here: this element already wears the ring, and a crisp + // inset bevel on top of it specks at the corners (glass.css, the rim + // primitive). The hot variant just turns the lip up. Blurred glows stay + // box-shadows; they have no crisp end. const styles = { - primary: 'pf-btn-hot bg-[var(--btn-accent-fill)] text-[var(--accent-contrast)] shadow-[inset_0_1px_0_rgba(255,255,255,0.28),0_2px_12px_-2px_color-mix(in_srgb,var(--accent)_45%,transparent)] hover:bg-[var(--btn-accent-fill-hover)] hover:shadow-[inset_0_1px_0_rgba(255,255,255,0.3),0_4px_20px_-2px_color-mix(in_srgb,var(--accent)_60%,transparent)] disabled:opacity-50 disabled:shadow-none', + primary: 'pf-btn-hot [--btn-lip:0.28] bg-[var(--btn-accent-fill)] text-[var(--accent-contrast)] shadow-[0_2px_12px_-2px_color-mix(in_srgb,var(--accent)_45%,transparent)] hover:bg-[var(--btn-accent-fill-hover)] hover:shadow-[0_4px_20px_-2px_color-mix(in_srgb,var(--accent)_60%,transparent)] disabled:opacity-50 disabled:shadow-none', ghost: 'border border-[color-mix(in_srgb,var(--text)_14%,transparent)] bg-transparent text-[var(--text)] hover:bg-[var(--btn-bg)] hover:border-[color-mix(in_srgb,var(--text)_24%,transparent)] disabled:opacity-50', - subtle: 'bg-[var(--btn-bg)] text-[var(--text)] shadow-[inset_0_1px_0_rgba(255,255,255,0.1)] hover:bg-[var(--btn-bg-hover)] disabled:opacity-50', + subtle: 'bg-[var(--btn-bg)] text-[var(--text)] hover:bg-[var(--btn-bg-hover)] disabled:opacity-50', danger: 'border border-[color-mix(in_srgb,var(--bad)_55%,var(--border))] bg-transparent text-[var(--bad)] hover:bg-[var(--bad)] hover:border-[var(--bad)] hover:text-white disabled:opacity-50', }[variant] const sz = size === 'sm' ? 'px-2.5 py-1 text-xs' : 'px-3.5 py-2 text-sm' @@ -263,20 +272,38 @@ export function Field({label, hint, children}: {label: string; hint?: ReactNode; ) } -export function TextInput({value, onChange, placeholder, type = 'text', mono, onEnter, autoFocus}: { +/** TextInput: the one text field. The WRAPPER wears .pf-control (an + * cannot host a pseudo-element, so the rim ring has nowhere to live on the + * input itself) and :has() carries focus outward to it; the input is a + * transparent pane inside. The pressed-in seat stays a blurred inset shadow — + * blurred shadows have no crisp end and cannot speck (glass.css). + * + * `size="sm"` + `icon` is the compact form for list toolbars. Screens must not + * hand-roll their own search box beside this one. */ +export function TextInput({value, onChange, placeholder, type = 'text', mono, onEnter, autoFocus, size = 'md', icon, ariaLabel}: { value: string; onChange: (v: string) => void; placeholder?: string; type?: string; mono?: boolean - onEnter?: () => void; autoFocus?: boolean + onEnter?: () => void; autoFocus?: boolean; size?: 'sm' | 'md'; icon?: ReactNode; ariaLabel?: string }) { const isPassword = type === 'password' const [reveal, setReveal] = useState(false) const effectiveType = isPassword && reveal ? 'text' : type + const sm = size === 'sm' return ( -
+
+ {icon && ( + {icon} + )} onChange(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && onEnter) onEnter() }} - className={`w-full rounded-[var(--r-md)] border border-[var(--border)] bg-[var(--input-bg)] py-2 pl-3 text-sm text-[var(--text)] shadow-[inset_0_2px_4px_-1px_var(--bevel-bot),inset_0_-1px_0_var(--bevel-top)] outline-none transition-all duration-200 placeholder:text-[var(--text-3)] hover:border-[var(--border-strong)] hover:bg-[var(--input-bg-hover)] focus:border-[var(--accent)] focus:bg-[var(--input-bg-hover)] focus:shadow-[inset_0_2px_4px_-1px_var(--bevel-bot),0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent),0_0_18px_-4px_color-mix(in_srgb,var(--accent)_40%,transparent)] ${isPassword ? 'pr-10' : 'pr-3'} ${mono ? 'font-mono text-[12.5px]' : ''}`} + className={`h-full w-full min-w-0 rounded-[inherit] bg-transparent text-[var(--text)] outline-none placeholder:text-[var(--text-3)] ${ + icon ? 'pl-8' : sm ? 'pl-2.5' : 'pl-3' + } ${isPassword ? 'pr-10' : sm ? 'pr-2.5' : 'pr-3'} ${sm ? 'text-xs' : 'text-sm'} ${mono ? 'font-mono text-[12.5px]' : ''}`} /> {isPassword && ( - ) - })} +
+ {options.map(o => { + const on = o.value === value + return ( + + ) + })} +
, document.body )} @@ -422,24 +452,132 @@ export function Select({value, onChange, options}: { ) } -// Toggle knob geometry (padding-box px): the two seats and the commit line. -const KNOB_MIN = 2 -const KNOB_MAX = 18 -const KNOB_MID = (KNOB_MIN + KNOB_MAX) / 2 +/** Menu: Select's popup mechanics without the value semantics — a portalled + * float on menu glass, anchored to a trigger, tracking scroll and resize, + * flipping up when the viewport below is tight, closing on click-outside and + * Esc. Rows are arbitrary children (MenuItem), so they can seat an Emblem. + * + * It portals for the same reason Select's list does: every card, and every + * chrome surface, is a backdrop-filter stacking context — an in-place absolute + * float paints underneath the next one no matter its z-index. */ +export function Menu({open, anchor, onClose, children, align = 'left', minWidth}: { + open: boolean + anchor: React.RefObject + onClose: () => void + children: ReactNode + align?: 'left' | 'right' + minWidth?: number +}) { + const listRef = useRef(null) + const [at, setAt] = useState<{top: number; bottom: number; left: number; right: number; up: boolean} | null>(null) -export function Toggle({checked, onChange, label, hint, disabled}: { - checked: boolean; onChange: (v: boolean) => void; label: string; hint?: ReactNode; disabled?: boolean + const place = () => { + const r = anchor.current?.getBoundingClientRect() + if (!r) return + const below = window.innerHeight - r.bottom + setAt({ + top: r.top, bottom: r.bottom, left: r.left, right: window.innerWidth - r.right, + up: below < 220 && r.top > below, + }) + } + + // Place before paint, so the menu never flashes at the wrong anchor. + useLayoutEffect(() => { if (open) place() }, [open]) + + useEffect(() => { + if (!open) return + const onDoc = (e: MouseEvent) => { + const t = e.target as Node + if (!anchor.current?.contains(t) && !listRef.current?.contains(t)) onClose() + } + // stopPropagation so an enclosing Modal (window listener, bubbles later) + // doesn't close alongside the menu. + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); onClose() } } + const onMove = () => place() + document.addEventListener('mousedown', onDoc) + document.addEventListener('keydown', onKey) + window.addEventListener('resize', onMove) + document.addEventListener('scroll', onMove, true) + return () => { + document.removeEventListener('mousedown', onDoc) + document.removeEventListener('keydown', onKey) + window.removeEventListener('resize', onMove) + document.removeEventListener('scroll', onMove, true) + } + }, [open, onClose]) + + if (!open || !at) return null + return createPortal( +
+
{children}
+
, + document.body + ) +} + +/** MenuItem: one row of a Menu — a lead slot (icon/Emblem), a title, a quiet + * hint underneath, and a check when it is the current choice. */ +export function MenuItem({lead, title, hint, on, disabled, onClick, onPointerEnter, onPointerLeave}: { + lead?: ReactNode; title: ReactNode; hint?: ReactNode; on?: boolean; disabled?: boolean + onClick?: () => void + onPointerEnter?: () => void + onPointerLeave?: () => void +}) { + return ( + + ) +} + +/** Switch: the bare milled-glass switch — track, knob, drag. No label, no row: + * `Toggle` is this plus a settings row, and a `Field` can stand one beside a + * Select instead. + * + * Every length is a token (tokens.css --switch-*) and the drag reads the one + * length it needs — --switch-travel, registered as a so it resolves to + * px — back out of the computed style. The knob's rest positions are CSS + * calc()s off that same token, so the pointer math and the CSS cannot disagree. + * They used to: a rem-sized track (h-5 w-9) with hard-px seats (2 / 18) left the + * knob overhanging its track by ~1.4px at scale 1 and ~2.8px at 0.9259, and that + * overhang riding the corner arc was the "pixelated corner". */ +export function Switch({checked, onChange, disabled, label}: { + checked: boolean; onChange: (v: boolean) => void; disabled?: boolean; label?: string }) { // Drag-to-flip: the pointer is captured on press and the knob follows it - // between its seats, committing to whichever side it lands nearest. A - // sub-threshold press never becomes a drag — it falls through to onClick, - // which also keeps keyboard (Enter/Space) activation working. - const [dragX, setDragX] = useState(null) - const drag = useRef({startX: 0, moved: false, suppressClick: false}) + // between its seats as a 0→1 fraction of the travel, committing to whichever + // side it lands nearest. A sub-threshold press never becomes a drag — it + // falls through to onClick, which also keeps keyboard (Enter/Space) working. + const [dragT, setDragT] = useState(null) + const drag = useRef({startX: 0, travel: 0, moved: false, suppressClick: false}) const onPointerDown = (e: React.PointerEvent) => { if (disabled || e.button !== 0) return - drag.current = {startX: e.clientX, moved: false, suppressClick: false} + const travel = parseFloat(getComputedStyle(e.currentTarget).getPropertyValue('--switch-travel')) + drag.current = {startX: e.clientX, travel: travel || 0, moved: false, suppressClick: false} e.currentTarget.setPointerCapture(e.pointerId) } const onPointerMove = (e: React.PointerEvent) => { @@ -447,7 +585,9 @@ export function Toggle({checked, onChange, label, hint, disabled}: { const dx = e.clientX - drag.current.startX if (!drag.current.moved && Math.abs(dx) < 4) return drag.current.moved = true - setDragX(Math.min(KNOB_MAX, Math.max(KNOB_MIN, (checked ? KNOB_MAX : KNOB_MIN) + dx))) + const from = checked ? 1 : 0 + const moved = drag.current.travel > 0 ? dx / drag.current.travel : 0 + setDragT(Math.min(1, Math.max(0, from + moved))) } const onPointerUp = (e: React.PointerEvent) => { if (!e.currentTarget.hasPointerCapture(e.pointerId)) return @@ -455,46 +595,56 @@ export function Toggle({checked, onChange, label, hint, disabled}: { if (!drag.current.moved) return // The click that follows a drag must not re-toggle the committed state. drag.current.suppressClick = true - const next = (dragX ?? (checked ? KNOB_MAX : KNOB_MIN)) > KNOB_MID - setDragX(null) + const next = (dragT ?? (checked ? 1 : 0)) > 0.5 + setDragT(null) if (next !== checked) onChange(next) } - const onPointerCancel = () => { setDragX(null); drag.current.moved = false } + const onPointerCancel = () => { setDragT(null); drag.current.moved = false } const onClick = () => { if (drag.current.suppressClick) { drag.current.suppressClick = false; return } if (!disabled) onChange(!checked) } - // Mid-drag the surface previews the side the knob would commit to. - const visualOn = dragX !== null ? dragX > KNOB_MID : checked + // Mid-drag the track previews the side the knob would commit to. + const visualOn = dragT !== null ? dragT > 0.5 : checked + return ( + + ) +} + +/** Toggle: the settings row — label + hint on the left, a Switch on the right. */ +export function Toggle({checked, onChange, label, hint, disabled}: { + checked: boolean; onChange: (v: boolean) => void; label: string; hint?: ReactNode; disabled?: boolean +}) { return (
{label}
{hint &&
{hint}
}
- {/* Milled-glass switch, concentric geometry: 6px track radius − 1px - border − 2px knob gap = 3px knob radius, with the same 2px gap on - every side of the knob in both positions. */} - +
+ +
) } @@ -503,16 +653,28 @@ const dotColor: Record = { good: 'var(--good)', warn: 'var(--warn)', bad: 'var(--bad)', unknown: 'var(--text-3)', } -/** StatusDot: color + label, never color alone. Breathes a halo when live. */ +/** StatusDot: color + label, never color alone. Breathes a halo when live. + * + * The dot is sized in `em` so it tracks whatever type it leads, and it aligns + * on the label's BASELINE, not the line box. Line-box centering is geometric: + * it puts the dot on the midline of a box that includes the leading and the + * descender space, which sits well off the optical centre of a run of + * lowercase text — that mismatch is what read as "the circles don't line up + * with the words" beside "No agent connected yet". Seated on the baseline and + * nudged a hair down, the dot lands on the x-height midline instead, and it + * stays there at every --ui-scale step because both lengths are em. + * + * An empty label emits no span and no gap: a zero-width span behind the gap + * left ~7px of dead air in both Overview identity cards. */ export function StatusDot({state, label, pulse}: {state: State; label: string; pulse?: boolean}) { const live = pulse && state === 'good' return ( - + - {label} + {label && {label}} ) } @@ -579,7 +741,7 @@ export function Kbd({children, className = ''}: {children: string; className?: s {children.split(' ').map((k, i) => ( {k} ))} @@ -609,13 +771,22 @@ export function SegmentedControl({value, onChange, options, cl const n = options.length const ref = useRef(null) const [dragPx, setDragPx] = useState(null) - const drag = useRef({startX: 0, moved: false, suppress: false, thumbW: 0, origin: 0}) + const drag = useRef({startX: 0, moved: false, suppress: false, thumbW: 0, origin: 0, pad: 0}) + + // The track's content box, measured — never a px literal. clientWidth already + // excludes the rim, and the padding is a rem utility that moves with + // --ui-scale, so the old "content = width − 6" was only ever right at a 16px + // root (it is 1.69px of padding here, not 2). Same family of bug as the + // toggle's hard-px seats. + const metrics = (el: HTMLDivElement) => { + const pad = parseFloat(getComputedStyle(el).paddingLeft) || 0 + return {pad, thumbW: (el.clientWidth - pad * 2) / n} + } const onPointerDown = (e: React.PointerEvent) => { if (e.button !== 0 || !ref.current) return - // Border (1px) + padding (2px) each side: content = width − 6. - const thumbW = (ref.current.getBoundingClientRect().width - 6) / n - drag.current = {startX: e.clientX, moved: false, suppress: false, thumbW, origin: idx * thumbW} + const {pad, thumbW} = metrics(ref.current) + drag.current = {startX: e.clientX, moved: false, suppress: false, thumbW, origin: idx * thumbW, pad} ref.current.setPointerCapture(e.pointerId) } const onPointerMove = (e: React.PointerEvent) => { @@ -646,7 +817,7 @@ export function SegmentedControl({value, onChange, options, cl commit(Math.round(px / drag.current.thumbW)) } else { const r = ref.current.getBoundingClientRect() - commit(Math.floor((e.clientX - r.left - 3) / drag.current.thumbW)) + commit(Math.floor((e.clientX - r.left - drag.current.pad) / drag.current.thumbW)) } } const onPointerCancel = () => { setDragPx(null); drag.current.moved = false } @@ -660,16 +831,17 @@ export function SegmentedControl({value, onChange, options, cl ref={ref} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} - className={`relative grid touch-none rounded-[var(--r-md)] border border-[var(--border)] bg-[var(--input-bg)] p-0.5 shadow-[inset_0_1px_3px_var(--bevel-bot)] ${className}`} + className={`pf-control relative grid touch-none rounded-[var(--r-md)] bg-[var(--input-bg)] p-0.5 shadow-[inset_0_1px_3px_var(--bevel-bot)] ${className}`} style={{gridTemplateColumns: `repeat(${n}, 1fr)`}} role="radiogroup" > + {/* The thumb is milled glass too, and its seats come off the track's own + padding (0.5 = 0.125rem) — the same length the drag math measures. */}
{icon && ( -
+
{icon}
)} @@ -836,7 +1008,13 @@ export function EmptyState({icon, title, hint, action}: { } /** Modal: centered glass dialog with a scrim; Esc / backdrop-click closes. - * Exits animate — [data-closing] runs the mirrored keyframes, then unmounts. */ + * Exits animate — [data-closing] runs the mirrored keyframes, then unmounts. + * + * It renders through a portal for the same reason Select's list does: every + * .pf-card is a backdrop-filter surface, which makes it a containing block for + * fixed descendants. Analytics opens the session replay from inside its history + * card — anchored there, `fixed inset-0` would size to the CARD, not the + * viewport, and the dialog would be trapped in it. */ export function Modal({title, onClose, children, footer, wide}: { title: string; onClose: () => void; children: ReactNode; footer?: ReactNode; wide?: boolean }) { @@ -852,7 +1030,7 @@ export function Modal({title, onClose, children, footer, wide}: { window.addEventListener('keydown', h) return () => window.removeEventListener('keydown', h) }, []) - return ( + return createPortal(
-
{children}
+
+
{children}
+
{footer && ( <>
@@ -876,15 +1056,25 @@ export function Modal({title, onClose, children, footer, wide}: { )}
-
+
, + document.body ) } -/** Codebox: a selectable, monospaced value with a copy affordance. */ -export function Codebox({text, action}: {text: string; action?: ReactNode}) { +/** Codebox: a selectable, monospaced value with a copy affordance. + * + * `selectable={false}` for a MASKED value: the mask is real text, so a + * select-all drag off a masked pairing code hands out a string of bullets that + * looks like it came from the app. Copy still works — callers copy the real + * string, not what is on screen. */ +export function Codebox({text, action, selectable = true}: { + text: string; action?: ReactNode; selectable?: boolean +}) { return (
- + {text} {action} diff --git a/frontend/src/devmock.ts b/frontend/src/devmock.ts index 345c4d6..ab4628d 100644 --- a/frontend/src/devmock.ts +++ b/frontend/src/devmock.ts @@ -14,6 +14,27 @@ // &fx=high — high-fx glass: refraction filter on the palette // &fx=low — low-fx glass: solid cards, no caustics/chart glow // &analytics=off — daemon without the analytics store (unsupported state) +// &fleet=multi|old — gateway only. multi: a five-agent fleet (good/fair/poor +// health spread) instead of the default single agent, to +// exercise the Agents roster, its sort/filter, and drill-in. +// The ListAgents roster ALSO carries two offline-enrolled +// agents and one revoked agent in every gateway scenario +// (they never appear in status.agents), so the roster's +// connected/offline/revoked states render without any axis. +// old: a pre-roster daemon (no agents array) → the Agents +// screen's honest-unavailable state (ListAgents also []) +// &conflict=port|clone|both +// — gateway only. Seeds GatewayEvents so the Agents screen's +// conflict callout cards + event log have data: port → +// one port-reassigned event; clone → one clone-suspected +// event (references agent0, always linkable); both → both +// plus an older historical reassign. No axis → no events +// (event log shows its written-empty state, no cards). +// Pair with &fleet=multi so the port event links to its +// contesting agent in the roster. +// &paired=0 — this machine has never been paired to a gateway, so the +// sidebar's role switcher can't become the agent and must +// route to setup instead (pair with ?mock=gateway) // &geo=off|empty|error|pending // — GeoIP axes: unconfigured / configured-but-no-locations / // database failed to open / picked but engine not restarted @@ -33,7 +54,10 @@ export function installDevMock() { const axisFatal = params.get('fatal') === '1' const axisFresh = params.get('fresh') === '1' const axisAnalyticsOff = params.get('analytics') === 'off' + const axisUnpaired = params.get('paired') === '0' const axisGeo = params.get('geo') || '' // off | empty | error | pending + const axisFleet = params.get('fleet') || '' // '' (single agent) | multi | old + const axisConflict = params.get('conflict') || '' // '' | port | clone | both const fx = params.get('fx') if (fx) document.documentElement.dataset.fx = fx // &fx=high | &fx=low @@ -165,6 +189,39 @@ export function installDevMock() { return {windowMs, bucketMs, buckets} } + // Per-agent bandwidth (Agents drill-in): the gateway-wide history scaled to + // each agent's share, with the RTT overlay shifted to that agent's baseline, + // so every agent's chart looks distinct and agrees with its roster card. + const AGENT_SCALE: Record = { + agentid: {factor: 1, rtt: 24}, + '7788990011223344aabbccddeeff0011': {factor: 0.4, rtt: 74}, + ccddeeff001122334455667788990011: {factor: 0.06, rtt: 180}, + aa00bb11cc22dd33ee44ff5566778899: {factor: 0.6, rtt: 30}, + f0e1d2c3b4a5968778695a4b3c2d1e0f: {factor: 0.25, rtt: 55}, + } + const agentBandwidth = (agentId: string, windowMs: number, maxBuckets: number) => { + const {factor, rtt} = AGENT_SCALE[agentId] ?? {factor: 0.25, rtt: 40} + const h = bandwidthHistory(windowMs, maxBuckets) + const k = rtt / 24 + // Scale the byte/candle series by the agent's share and the connections and + // players gauges too, so each agent's overlay is genuinely its own (the -1 + // "unknown" sentinel is preserved). RTT shifts to the agent's baseline; loss + // is a rate, not volume, so it rides the shared curve. + const g = (v: number): number => (v >= 0 ? Math.round(v * factor) : -1) + return { + ...h, + buckets: h.buckets.map((b: any) => ({ + ...b, + out: Math.round(b.out * factor), in: Math.round(b.in * factor), + oo: b.oo * factor, oh: b.oh * factor, ol: b.ol * factor, oc: b.oc * factor, + io: b.io * factor, ih: b.ih * factor, il: b.il * factor, ic: b.ic * factor, + co: g(b.co), ch: g(b.ch), cl: g(b.cl), cc: g(b.cc), + po: g(b.po), ph: g(b.ph), pl: g(b.pl), pc: g(b.pc), + ...(b.ro >= 0 ? {ro: b.ro * k, rh: b.rh * k, rl: b.rl * k, rc: b.rc * k} : {}), + })), + } + } + // Lifetime peer records; the first two are the live connections' IPs. const now0 = Date.now() const peerSeeds = [ @@ -188,6 +245,175 @@ export function installDevMock() { // ---- mutable world state ---- const tunnelID = 'a1b2c3d4e5f60718293a4b5c6d7e8f90' + + // Extra agents for the multi-agent gateway fleet (?mock=gateway&fleet=multi). + // Each carries its own tunnels, live sessions, health, and remote identity so + // the roster, its sort axes, the hostname filter, and the drill-in all have + // real spread to render. With agent0 the fleet is five machines whose health + // spans good / fair (jitter>30) / poor (loss>5). `upSinceMs` is stamped ONCE + // here (a fixed epoch), never recomputed per tick, so the uptime readouts + // actually advance like agent0's. + const NOW0 = Date.now() + const extraAgents = [ + { + agentId: '7788990011223344aabbccddeeff0011', hostname: 'SURVIVAL-RIG', + lan: ['10.0.0.9'], remote: '198.51.100.7', upSinceMs: NOW0 - 42 * 60_000, + jitter: 41, loss: 1.3, rtt: 74, factor: 0.4, + tunnels: [ + {id: 'bb11223344556677889900aabbccddee', name: 'Survival', port: 25566, localUp: true, bandwidthLimitMbps: 100, bandwidthLimitScope: 'combined'}, + {id: 'cc22334455667788990011aabbccddff', name: 'Lobby', port: 25567, localUp: false}, + ], + conns: [{ + id: 8801, tunnelName: 'Survival', clientAddr: '92.99.11.4:53001', + startedAt: NOW0 - 600_000, bytesIn: 900_000, bytesOut: 5_400_000, + playerName: 'DiggerDan', playerUuid: '11112222-3333-4444-5555-666677778888', rttMs: 76, + }] as any[], + }, + { + agentId: 'ccddeeff001122334455667788990011', hostname: 'MINIGAMES-PI', + lan: ['192.168.1.30'], remote: '92.184.100.23', upSinceMs: NOW0 - 12 * 60_000, + jitter: 22, loss: 7.5, rtt: 180, factor: 0.06, + tunnels: [{id: 'dd33445566778899001122aabbccdd00', name: 'Minigames', port: 25568, localUp: true}], + conns: [] as any[], + }, + { + agentId: 'aa00bb11cc22dd33ee44ff5566778899', hostname: 'CREATIVE-HUB', + lan: ['10.0.0.12'], remote: '84.113.9.201', upSinceMs: NOW0 - 6 * 3_600_000, + jitter: 5, loss: 0, rtt: 30, factor: 0.6, + tunnels: [{id: 'ab00cd11ef22ab33cd44ef5566778890', name: 'Creative', port: 25569, localUp: true}], + conns: [{ + id: 8811, tunnelName: 'Creative', clientAddr: '176.10.44.8:51900', + startedAt: NOW0 - 1_800_000, bytesIn: 1_400_000, bytesOut: 9_800_000, + playerName: 'BuilderBeth', playerUuid: '22223333-4444-5555-6666-777788889999', rttMs: 31, + }] as any[], + }, + { + agentId: 'f0e1d2c3b4a5968778695a4b3c2d1e0f', hostname: 'SKYBLOCK-BOX', + lan: ['192.168.0.20'], remote: '51.68.220.14', upSinceMs: NOW0 - 95 * 60_000, + jitter: 34, loss: 0.4, rtt: 55, factor: 0.25, + tunnels: [{id: 'c0d1e2f3a4b5c6d7e8f90a1b2c3d4e5f', name: 'Skyblock', port: 25570, localUp: true, bandwidthLimitMbps: 20, bandwidthLimitScope: 'per-connection'}], + conns: [{ + id: 8821, tunnelName: 'Skyblock', clientAddr: '203.0.113.77:52210', + startedAt: NOW0 - 300_000, bytesIn: 420_000, bytesOut: 2_100_000, + playerName: 'IslandIvy', playerUuid: '33334444-5555-6666-7777-88889999aaaa', rttMs: 57, + }] as any[], + }, + ] + const agentHealthOf = (jitter: number, loss: number): string => + loss > 5 || jitter > 100 ? 'bad' : loss > 1 || jitter > 30 ? 'warn' : 'good' + + // ---- agent directory (roster: ListAgents / RenameAgent / SetAgentScope / + // RevokeAgent) ---- + // The gateway's enrollment store as the roster sees it: every enrolled agent + // (online or offline) plus the connected shared-token agent. Connected entries + // mirror the live fleet (agent0 + the &fleet=multi extras) by agentId, so the + // roster, the status.agents health join, and the drill-in all agree; the + // offline and revoked entries exist ONLY here (never in status.agents), which + // is what exercises the roster's offline/revoked states. Renames, scope edits, + // and revokes land in `agentOverrides` and surface on the next ListAgents poll + // — the same write-then-repoll loop the real bindings drive. agentId carries + // the agt_ prefix for enrolled identities and the self-asserted string for the + // shared-token agent (agent0), matching link.AgentID / the gateway store. + const mkView = (o: any) => ({ + agentId: o.agentId, nickname: o.nickname || '', enrolled: o.enrolled !== false, + revoked: !!o.revoked, scopePorts: o.scopePorts || [], scopeTunnels: o.scopeTunnels || [], + issuedAtMs: o.issuedAtMs || 0, connected: !!o.connected, hostname: o.hostname || '', + remoteIp: o.remoteIp || '', linkUpSinceMs: o.linkUpSinceMs || 0, tunnels: o.tunnels || 0, + }) + // Enrollment metadata for the connected extras (nickname/scope/enrolled age), + // keyed by the same agentId the live fleet and AGENT_SCALE use. + const EXTRA_ENROLL: Record = { + '7788990011223344aabbccddeeff0011': {nick: 'Survival server', ports: [25566, 25567], tuns: [], days: 26}, + ccddeeff001122334455667788990011: {nick: '', ports: [25568], tuns: [], days: 12}, + aa00bb11cc22dd33ee44ff5566778899: {nick: 'Creative hub', ports: [], tuns: [], days: 47}, + f0e1d2c3b4a5968778695a4b3c2d1e0f: {nick: 'Skyblock island', ports: [], tuns: ['c0d1e2f3a4b5c6d7e8f90a1b2c3d4e5f'], days: 9}, + } + const connectedViews = () => { + if (!state.linkUp) return [] + // agent0 is the connected shared-token agent: no nickname (→ hostname title), + // enrolled:false, unrestricted. Its id is self-asserted, not agt_-prefixed. + const a0 = mkView({ + agentId: 'agentid', enrolled: false, connected: true, hostname: 'DESKTOP-DEV', + remoteIp: '84.23.101.7', linkUpSinceMs: LINK_UP_SINCE, tunnels: 1, issuedAtMs: INSTALLED_AT, + }) + if (axisFleet !== 'multi') return [a0] + const extras = extraAgents.map(a => { + const e = EXTRA_ENROLL[a.agentId] ?? {nick: '', ports: [], tuns: [], days: 20} + return mkView({ + agentId: a.agentId, nickname: e.nick, enrolled: true, connected: true, + hostname: a.hostname, remoteIp: a.remote, linkUpSinceMs: a.upSinceMs, + tunnels: a.tunnels.length, scopePorts: e.ports, scopeTunnels: e.tuns, + issuedAtMs: now0 - e.days * 86_400_000, + }) + }) + return [a0, ...extras] + } + // Enrolled agents that are NOT currently connected, plus one revoked — always + // in the roster regardless of link state, so their states always render. + const offlineViews = [ + mkView({agentId: 'agt_h4t2q9zm', nickname: 'Backup box', enrolled: true, hostname: 'BACKUP-NAS', scopePorts: [25580], issuedAtMs: now0 - 18 * 86_400_000}), + mkView({agentId: 'agt_b7k3n5pd', enrolled: true, hostname: 'SEASONAL-SMP', issuedAtMs: now0 - 40 * 86_400_000}), + ] + const revokedViews = [ + mkView({agentId: 'agt_x2m8r6ct', nickname: 'Old laptop', enrolled: true, revoked: true, hostname: 'LAPTOP-OLD', issuedAtMs: now0 - 63 * 86_400_000}), + ] + const knownAgentIds = new Set([ + 'agentid', ...extraAgents.map(a => a.agentId), + ...offlineViews.map(v => v.agentId), ...revokedViews.map(v => v.agentId), + ]) + // Mutations from the write bindings, applied over the composed roster. + const agentOverrides = new Map() + const applyOverride = (v: any) => { + const o = agentOverrides.get(v.agentId) + if (!o) return v + return { + ...v, + ...(o.nickname !== undefined ? {nickname: o.nickname} : {}), + ...(o.scopePorts !== undefined ? {scopePorts: o.scopePorts} : {}), + ...(o.scopeTunnels !== undefined ? {scopeTunnels: o.scopeTunnels} : {}), + ...(o.revoked !== undefined ? {revoked: o.revoked, connected: o.revoked ? false : v.connected} : {}), + } + } + const listAgents = () => { + // The roster is a gateway surface; an agent (or a pre-roster daemon) has none. + if (state.role !== 'gateway' || axisFleet === 'old') return [] + return [...connectedViews(), ...offlineViews, ...revokedViews].map(applyOverride) + } + + // ---- gateway conflict / auto-fix events (GatewayEvents) ---- + // Seeded once from the &conflict axis. Both event kinds ARE conflicts (there + // is no benign kind), so with no axis the ring is empty — the event log shows + // its written-empty state and no conflict cards appear. GatewayEvents(seq) is + // incremental by cursor, so the first poll returns all seeds and later polls + // return [] (a quiet ring), which is exactly what the since-cursor tail wants. + let evSeq = 0 + const gwEvents: any[] = [] + const evNow = Date.now() + const pushEvent = (o: any) => gwEvents.push({seq: ++evSeq, ...o}) + if (axisConflict === 'both') { + pushEvent({ + timeMs: evNow - 52 * 60_000, kind: 'port-reassigned', + agentId: 'aa00bb11cc22dd33ee44ff5566778899', tunnelId: 'ab00cd11ef22ab33cd44ef5566778890', + requestedPort: 25569, actualPort: 25574, + message: 'Requested public port 25569 was busy; CREATIVE-HUB’s "Creative" tunnel was auto-assigned 25574.', + }) + } + if (axisConflict === 'port' || axisConflict === 'both') { + pushEvent({ + timeMs: evNow - 8 * 60_000, kind: 'port-reassigned', + agentId: '7788990011223344aabbccddeeff0011', tunnelId: 'cc22334455667788990011aabbccddff', + requestedPort: 25567, actualPort: 25572, + message: 'Requested public port 25567 was already bound; SURVIVAL-RIG’s "Lobby" tunnel was auto-assigned 25572 instead.', + }) + } + if (axisConflict === 'clone' || axisConflict === 'both') { + pushEvent({ + timeMs: evNow - 3 * 60_000, kind: 'clone-suspected', agentId: 'agentid', + message: 'Agent "agentid" reconnected from 203.0.113.77:52210 while still linked from 84.23.101.7:53880 — possible cloned identity or a shared token in use on two machines.', + }) + } + const gatewayEvents = (sinceSeq: number) => gwEvents.filter(e => e.seq > (sinceSeq || 0)) + const up = !isWizard && !axisLinkDown && !axisFatal const state = { role: isGateway ? 'gateway' : 'agent', @@ -203,9 +429,9 @@ export function installDevMock() { } const config = { Role: state.role, - Agent: {AgentID: 'agentid', GatewayHost: 'play.example.com', GatewayPort: 8474, Token: 'tok', CertFingerprint: 'sha256:ab', Transport: 'mux', + Agent: {AgentID: 'agentid', GatewayHost: 'play.example.com', GatewayPort: 8474, Token: 'tok', CertFingerprint: 'sha256:ab', Transport: 'auto', Tunnels: [{ID: tunnelID, Name: 'Minecraft', Type: 'tcp', LocalAddr: '127.0.0.1:25565', PublicPort: 25565, Enabled: true, - Options: {MinecraftAware: true, ProxyProtocolV2: false, OfflineMOTD: 'Server is offline — back soon', BandwidthLimitMbps: 0}}]}, + Options: {MinecraftAware: true, ProxyProtocolV2: false, OfflineMOTD: 'Server is offline — back soon', BandwidthLimitMbps: 40, BandwidthLimitScope: 'per-direction'}}]}, Gateway: {BindAddr: '0.0.0.0', ControlPort: 8474, Token: 'tok', PublicHost: 'play.example.com', PortAllowlist: [], MaxConnsGlobal: 4096, MaxConnsPerIP: 32, AuthAttemptsPerMin: 10}, Metrics: {PrometheusEnabled: false, PrometheusAddr: '127.0.0.1:9464'}, @@ -213,6 +439,14 @@ export function installDevMock() { UI: {Theme: localStorage.getItem('pf-theme') || 'dark', MinimizeToTray: true, Autostart: false}, Analytics: {RetentionDays: 180, MojangLookups: true, GeoIPCityPath: '', GeoIPASNPath: ''}, } + // A machine that has never been paired holds no agent credentials — the + // gateway half of the config is complete, the agent half is empty. This is + // the one state where the role switcher cannot simply flip. + if (axisUnpaired) { + config.Agent.Token = '' + config.Agent.GatewayHost = '' + config.Agent.CertFingerprint = '' + } // Link-quality mock: healthy jitter/loss on the agent; the gateway leaves // them unknown (-1) like the real backend, since RTT is measured agent-side. @@ -228,25 +462,77 @@ export function installDevMock() { const status = () => { const jitter = isWizard || !state.linkUp ? -1 : jitterMs() const loss = isWizard || !state.linkUp ? -1 : lossPct() + // Read the LIVE role, not the URL scenario: the sidebar's role switcher + // flips state.role, and every identity below has to follow it or the app + // would keep wearing the old role's peer after a switch. + const gw = state.role === 'gateway' + + // Gateway fleet: agent0 is the always-present peer (its identity mirrors the + // coarse peer* fields so the other gateway screens stay populated); the + // extras appear only under &fleet=multi. Tunnels and connections flatten + // across the fleet, each stamped with its agentId so the Agents drill-in can + // scope them. A downed gateway link means zero agents. The agent role sends + // no roster at all (undefined), like the real backend. + const agent0 = { + agentId: 'agentid', hostname: 'DESKTOP-DEV', lanIps: ['10.0.0.5'], remoteIp: '84.23.101.7', + linkUpSinceMs: LINK_UP_SINCE, rttMillis: state.rtt, jitterMillis: jitter, packetLossPct: loss, + healthScore: healthOf(jitter, loss), + linkBytesIn: Math.round(state.bytesIn * 1.06) + 2_400_000, + linkBytesOut: Math.round(state.bytesOut * 1.06) + 3_100_000, + tunnels: 1, players: state.conns.length, + } + const fleetExtras = gw && state.linkUp && axisFleet === 'multi' ? extraAgents : [] + const gwAgents = gw && state.linkUp ? [agent0, ...fleetExtras.map(a => ({ + agentId: a.agentId, hostname: a.hostname, lanIps: a.lan, remoteIp: a.remote, + linkUpSinceMs: a.upSinceMs, rttMillis: a.rtt, jitterMillis: a.jitter, packetLossPct: a.loss, + healthScore: agentHealthOf(a.jitter, a.loss), + linkBytesIn: Math.round(a.factor * (state.bytesIn * 1.06 + 2_400_000) * 0.7), + linkBytesOut: Math.round(a.factor * (state.bytesOut * 1.06 + 3_100_000)), + tunnels: a.tunnels.length, players: a.conns.length, + }))] : [] + // &fleet=old simulates a pre-roster background service: the gateway reports + // no agents array at all, so the Agents screen shows its honest-unavailable + // state (told apart from a live gateway with zero agents connected). + const rosterOld = gw && axisFleet === 'old' + const gwTunnels = gw + ? (state.linkUp + ? [{id: tunnelID, name: 'Minecraft', publicPort: 25565, localUp: true, localKnown: true, agentId: 'agentid', bandwidthLimitMbps: 40, bandwidthLimitScope: 'per-direction'}, + ...fleetExtras.flatMap(a => a.tunnels.map((t: any) => ({ + id: t.id, name: t.name, publicPort: t.port, localUp: t.localUp, localKnown: true, agentId: a.agentId, + bandwidthLimitMbps: t.bandwidthLimitMbps ?? 0, bandwidthLimitScope: t.bandwidthLimitScope ?? '', + })))] + : []) + : [{id: tunnelID, name: 'Minecraft', publicPort: state.linkUp ? 25565 : 0, localUp: true, localKnown: true}] + const gwConns = gw + ? (state.linkUp + ? [...state.conns.map((c: any) => ({...c, agentId: 'agentid'})), + ...fleetExtras.flatMap(a => a.conns.map(c => ({...c, agentId: a.agentId})))] + : []) + : (state.linkUp ? state.conns : []) + return { mode: isWizard ? 'wizard' : axisAttached ? 'attached' : 'engine', role: isWizard ? '' : state.role, - version: '0.1.0-dev', hostname: 'DESKTOP-DEV', pid: 4242, configPath: 'C\\\\Users\\\\you\\\\AppData\\\\Roaming\\\\proxyforward\\\\config.toml', + // A real Windows path, escaped once — the config-file row is sized against + // exactly this string, so an over-escaped mock would flatter it. + version: '0.1.0-dev', hostname: 'DESKTOP-DEV', pid: 4242, configPath: 'C:\\Users\\you\\AppData\\Roaming\\proxyforward\\config.toml', linkUp: state.linkUp, rttMillis: state.linkUp ? state.rtt : 0, agentConnected: state.agentConnected, + transport: !isWizard && !gw && state.linkUp ? 'quic' : '', jitterMillis: jitter, packetLossPct: loss, healthScore: isWizard ? 'unknown' : healthOf(jitter, loss), - peerHostname: isWizard ? '' : isGateway ? 'DESKTOP-DEV' : 'GATEWAY-VPS-01', - publicIp: isWizard ? '' : isGateway ? '203.0.113.9' : '84.23.101.7', - peerPublicIp: isWizard ? '' : isGateway ? '84.23.101.7' : 'play.example.com', - localLanIps: isWizard ? [] : isGateway ? ['10.0.0.5'] : ['192.168.1.24'], - peerLanIps: isWizard ? [] : isGateway ? ['192.168.1.24'] : ['10.0.0.5'], - tunnels: [{id: tunnelID, name: 'Minecraft', publicPort: state.linkUp ? 25565 : 0, localUp: true, localKnown: true}], - connections: state.linkUp ? state.conns : [], + peerHostname: isWizard ? '' : gw ? 'DESKTOP-DEV' : 'GATEWAY-VPS-01', + publicIp: isWizard ? '' : gw ? '203.0.113.9' : '84.23.101.7', + peerPublicIp: isWizard ? '' : gw ? '84.23.101.7' : 'play.example.com', + localLanIps: isWizard ? [] : gw ? ['10.0.0.5'] : ['192.168.1.24'], + peerLanIps: isWizard ? [] : gw ? ['192.168.1.24'] : ['10.0.0.5'], + agents: gw ? (rosterOld ? undefined : gwAgents) : undefined, + tunnels: gwTunnels, + connections: gwConns, totalBytesIn: state.bytesIn, totalBytesOut: state.bytesOut, linkUpSinceMs: isWizard || !state.linkUp ? 0 : LINK_UP_SINCE, processStartMs: isWizard ? 0 : PROCESS_START, - peerAddr: isWizard ? '' : isGateway ? '84.23.101.7' : 'play.example.com:8474', + peerAddr: isWizard ? '' : gw ? '84.23.101.7' : 'play.example.com:8474', linkBytesIn: Math.round(state.bytesIn * 1.06) + 2_400_000, linkBytesOut: Math.round(state.bytesOut * 1.06) + 3_100_000, allTimeBytesIn: Math.round(3.4 * 1024 ** 3) + state.bytesIn, @@ -591,6 +877,14 @@ export function installDevMock() { } } + // A role flip in the mock is the same one-field change it is in the engine: + // the config's Role and the status the tick reports. Peers/addresses are + // derived from state.role in status(), so they follow automatically. + const becomeRole = (r: string) => { + config.Role = r + state.role = r + } + const ok = (v: T) => Promise.resolve(v) // Attached mode: a service owns the engine — gated bindings reject exactly // like the real backend so the UI's disabled/degraded states can be tested. @@ -601,6 +895,7 @@ export function installDevMock() { const App: Record = { Status: () => ok(status()), BandwidthHistory: (windowMs: number, maxBuckets: number) => ok(bandwidthHistory(windowMs, maxBuckets)), + AgentBandwidthHistory: (agentId: string, windowMs: number, maxBuckets: number) => ok(agentBandwidth(agentId, windowMs, maxBuckets)), PeerStats: () => ok(isWizard || axisFresh ? [] : peerStats()), // Analytics reads work in attached mode too (they ride the IPC envelope). Players: (q: any) => ok(playersPage(q)), @@ -618,14 +913,66 @@ export function installDevMock() { // field + status badge can be exercised. BrowseMMDB: (title: string) => ok(/asn/i.test(title) ? 'C:\\maxmind\\GeoLite2-ASN.mmdb' : 'C:\\maxmind\\GeoLite2-City.mmdb'), GetConfig: () => ok(config), - PairingCode: () => gated(() => 'pf1://play.example.com:8474/3f8a1c9e2b7d4056a1b2c3d4e5f60718#sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'), + PairingCode: () => gated(() => 'pxf://play.example.com:8474/v1/pair/3f8a1c9e2b7d4056a1b2c3d4e5f60718#sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'), + // Phase 8 agent management: the roster (ListAgents) + the gateway event ring + // (GatewayEvents) are reads (ok); the write bindings are gated and mutate the + // mock world (agentOverrides) so the UI updates on the next poll, mirroring + // the real write-then-repoll loop. + ListAgents: () => ok(listAgents()), + GatewayEvents: (sinceSeq: number) => ok(gatewayEvents(sinceSeq)), + IssuePairingCode: (_reusable: boolean, _ttlSecs: number, _ports: number[], _tunnels: string[]) => + gated(() => { + // Each call mints a fresh enrollment ticket → a different token segment, + // so "New code" and the reusable toggle visibly change the shown code. + const tok = Array.from({length: 16}, () => Math.floor(Math.random() * 256).toString(16).padStart(2, '0')).join('') + return `pxf://play.example.com:8474/v1/pair/${tok}#sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08` + }), + RenameAgent: (agentId: string, nickname: string) => gated(() => { + const o = agentOverrides.get(agentId) ?? {} + o.nickname = nickname + agentOverrides.set(agentId, o) + return undefined + }), + SetAgentScope: (agentId: string, ports: number[], tunnels: string[]) => gated(() => { + const o = agentOverrides.get(agentId) ?? {} + o.scopePorts = ports; o.scopeTunnels = tunnels + agentOverrides.set(agentId, o) + return undefined + }), + // Rejects when the agent is unknown, exactly as the real RevokeAgent does. + RevokeAgent: (agentId: string) => gated(() => + knownAgentIds.has(agentId) + ? (() => { + const o = agentOverrides.get(agentId) ?? {} + o.revoked = true; o.connected = false + agentOverrides.set(agentId, o) + return undefined + })() + : Promise.reject(new Error(`agent ${agentId} not found (it may have already disconnected or been removed)`))), + // No OS deep link in browser dev; the real app pulls this once on mount to open + // straight into pairing when launched via a clicked pxf:// link. + TakePendingDeepLink: () => ok(''), Version: () => ok('0.1.0-dev'), LogsSince: (seq: number) => ok(axisAttached ? [] : logs.filter(l => l.seq > seq)), TestReachability: () => new Promise(r => setTimeout(() => r('Reachable: play.example.com:25565 answered in 38ms — players can connect.'), 700)), - SetupGateway: () => gated(() => undefined), - SetupAgent: () => gated(() => undefined), + // Role setup actually flips the mock's role: state.role is read on every + // tick, so the whole app (accent ramp, screens, sidebar) swaps live in the + // browser with no Go running — which is the only way to exercise the + // sidebar's role switcher on both axes. + SetupGateway: () => gated(() => { becomeRole('gateway'); return undefined }), + SetupAgent: () => gated(() => { becomeRole('agent'); return undefined }), SaveTunnels: (t: any) => { config.Agent.Tunnels = t; return ok(undefined) }, - SaveSettings: () => ok(undefined), + SaveSettings: (c: any) => { + // The real SaveSettings validates before it writes: an agent with no + // pairing token is refused and nothing is persisted (config.go + // validateAgent). Mirror that, or the switcher's error path can't be seen. + if (c?.Role === 'agent' && !(config.Agent.Token && config.Agent.GatewayHost)) { + return Promise.reject(new Error('agent.token: required (pair with a gateway first)')) + } + if (c?.Role) becomeRole(c.Role) + if (c?.Logging) config.Logging = c.Logging + return ok(undefined) + }, SetTheme: (t: string) => { config.UI.Theme = t; return ok(undefined) }, RestartEngine: () => gated(() => undefined), RegenerateToken: () => gated(() => undefined), diff --git a/frontend/src/history.ts b/frontend/src/history.ts index 43b5d38..2b4899d 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -104,6 +104,13 @@ export function loadCandlePref(): boolean { } export function saveCandlePref(on: boolean) { localStorage.setItem('pf-chart-candles', on ? '1' : '0') } +/** Uptime strip: show the coverage timeline (when the app was actually + * recording) beneath the time axis. Off by default — it's a secondary read. */ +export function loadUptimePref(): boolean { + return localStorage.getItem('pf-chart-uptime') === '1' +} +export function saveUptimePref(on: boolean) { localStorage.setItem('pf-chart-uptime', on ? '1' : '0') } + /** Per-series visibility: download / upload / connections / RTT. */ export type SeriesVisibility = {dl: boolean; ul: boolean; conn: boolean; rtt: boolean} diff --git a/frontend/src/layout/Shell.tsx b/frontend/src/layout/Shell.tsx index b4ddf35..7e3b8b6 100644 --- a/frontend/src/layout/Shell.tsx +++ b/frontend/src/layout/Shell.tsx @@ -1,5 +1,6 @@ import {ReactNode, useEffect, useRef} from 'react' import {useMotion} from '../motion' +import {useRubberBand} from '../rubberband' // Content slides this many px beneath the sidebar's edge before clipping. // Inline px math — must stay a plain number; documented for token readers @@ -24,6 +25,9 @@ export function Shell({sidebar, titlebar, children}: { const rootRef = useRef(null) const {reduced} = useMotion() + // Scrollers give at their ends instead of stopping dead (rubberband.ts). + useRubberBand() + // The pointer is a lamp, and Signal Glass answers it: one delegated, // rAF-throttled listener writes local coordinates onto each .pf-signal // surface within reach (the identity surface — at most one or two per @@ -119,8 +123,13 @@ export function Shell({sidebar, titlebar, children}: { {titlebar}
+ {/* overscroll-none: the page scroller must never let WebView2 composite its own + elastic overscroll under ours — two rubber bands, one gesture. rubberband.ts + preventDefaults every delta it takes, so this is belt and braces, but it is + the standard way these ship visibly broken. It changes no ownership: ownerFor + breaks at the page before it reads overscroll-behavior. */}
{children}
+ {/* Rubber-band edge light. Its own grid cell, so it overlays the content + column exactly and — unlike a pseudo-element on
— never scrolls. + Under the island (z-20), so the glass frosts the bloom passing beneath + it. Inert until rubberband.ts stamps data-band on it. */} +
) } diff --git a/frontend/src/layout/Sidebar.tsx b/frontend/src/layout/Sidebar.tsx index ec36916..51b00ba 100644 --- a/frontend/src/layout/Sidebar.tsx +++ b/frontend/src/layout/Sidebar.tsx @@ -1,7 +1,7 @@ import {UIStatus} from '../state' -import {NAV_MAIN, NAV_SETTINGS, NavId, NavItem} from '../nav' -import {Badge, Kbd} from '../components/ui' -import {Emblem} from '../components/Emblem' +import {mainNav, settingsNav, NavId, NavItem} from '../nav' +import {Kbd} from '../components/ui' +import {RoleSwitcher} from '../components/RoleSwitcher' import {IconServer} from '../components/icons' // Nav geometry shared by the buttons and the sliding indicator. ITEM_H must @@ -14,13 +14,18 @@ const GAP = 2 * The left rail: brand, mode identity, main navigation with a sliding accent * pill, Settings pinned at the foot. Shares the title bar's glass sheet. */ -export function Sidebar({status, nav, onNav}: { +export function Sidebar({status, nav, onNav, onPair}: { status: UIStatus nav: NavId onNav: (id: NavId) => void + onPair: () => void }) { const isAgent = status.role === 'agent' - const activeIdx = NAV_MAIN.findIndex(n => n.id === nav) + // The rail is role-dependent — the gateway carries an extra Agents entry, so + // both the buttons and the sliding indicator index into the same filtered list. + const nav_main = mainNav(status.role) + const nav_settings = settingsNav(status.role) + const activeIdx = nav_main.findIndex(n => n.id === nav) return (
@@ -33,15 +38,10 @@ export function Sidebar({status, nav, onNav}: { proxyforward
- {/* Mode identity anchor: the role's emblem, riding the live accent so - a role swap washes through it with the rest of the chrome. */} -
- - {isAgent ? 'Agent' : 'Gateway'} - - {status.mode === 'attached' && Service} - -
+ {/* Mode identity anchor: the role's emblem, riding the live accent so a + role swap washes through it with the rest of the chrome — and the + control that performs that swap (RoleSwitcher). */} +
- +
@@ -71,7 +71,11 @@ export function Sidebar({status, nav, onNav}: { {status.hostname && ( )} -
v{status.version.replace(/ \(.*\)$/, '')} · pid {status.pid}
+ {/* Belt and braces: the display version is short since the ldflags + stamp got trimmed, but never let a surprise string wrap the rail. */} +
+ v{status.version.replace(/ \(.*\)$/, '')} · pid {status.pid} +
) diff --git a/frontend/src/layout/TitleBar.tsx b/frontend/src/layout/TitleBar.tsx index a5ada11..14c4792 100644 --- a/frontend/src/layout/TitleBar.tsx +++ b/frontend/src/layout/TitleBar.tsx @@ -3,7 +3,7 @@ import {toggleTheme, useTheme} from '../theme' import {ConnectionPill} from '../components/ConnectionPill' import {IconButton, Kbd} from '../components/ui' import {IconCommand, IconMoon, IconServer, IconSun} from '../components/icons' -import {NAV_MAIN, NAV_SETTINGS, NavId} from '../nav' +import {labelOf, NavId} from '../nav' import {useTitleContext} from '../pagecontext' import {WindowControls} from './WindowControls' @@ -31,7 +31,7 @@ export function TitleBar({status, nav, brand = false, onPalette}: { }) { const {resolved} = useTheme() const screenCtx = useTitleContext() - const navLabel = nav ? [...NAV_MAIN, NAV_SETTINGS].find(n => n.id === nav)?.label : undefined + const navLabel = nav ? labelOf(nav) : undefined const ctx = screenCtx ?? (status ? defaultContext(status) : null) return (
e.preventDefault()}> diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 77b09e7..fe04e6c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,9 @@ import React from 'react' import {createRoot} from 'react-dom/client' +// Self-hosted Inter Variable (wght 100-900, unicode-range subsets; non-latin +// glyphs outside the subsets fall back to Segoe). No network fetch — the woff2 +// files ship inside the binary like every other asset. +import '@fontsource-variable/inter/index.css' import './style.css' import App from './App' import {initTheme} from './theme' diff --git a/frontend/src/nav.ts b/frontend/src/nav.ts index 9f0f4a6..4ab5f55 100644 --- a/frontend/src/nav.ts +++ b/frontend/src/nav.ts @@ -1,9 +1,9 @@ import {ReactElement} from 'react' import { - IconActivity, IconAnalytics, IconConnections, IconDashboard, IconPlayers, IconSettings, IconTunnels, + IconActivity, IconAgents, IconAnalytics, IconConnections, IconDashboard, IconPlayers, IconSettings, IconTunnels, } from './components/icons' -export type NavId = 'overview' | 'traffic' | 'players' | 'analytics' | 'tunnels' | 'activity' | 'settings' +export type NavId = 'overview' | 'agents' | 'traffic' | 'players' | 'analytics' | 'tunnels' | 'activity' | 'settings' export type NavItem = { id: NavId @@ -12,16 +12,47 @@ export type NavItem = { shortcut: string // digit paired with Ctrl } -/** Main rail. Settings is pinned to the sidebar foot, outside this list. */ -export const NAV_MAIN: NavItem[] = [ - {id: 'overview', label: 'Overview', icon: IconDashboard, shortcut: '1'}, - {id: 'traffic', label: 'Traffic', icon: IconConnections, shortcut: '2'}, - {id: 'players', label: 'Players', icon: IconPlayers, shortcut: '3'}, - {id: 'analytics', label: 'Analytics', icon: IconAnalytics, shortcut: '4'}, - {id: 'tunnels', label: 'Tunnels', icon: IconTunnels, shortcut: '5'}, - {id: 'activity', label: 'Activity', icon: IconActivity, shortcut: '6'}, +// The rail definition. `roles` limits an item to certain roles (omitted = both). +// Shortcuts are NOT stored here — they are assigned by position once the list is +// filtered for a role, so a hidden item never leaves a gap in the digit run. +type Role = 'agent' | 'gateway' +type NavDef = Omit & {roles?: Role[]} + +// Agents is the gateway's fleet view; it sits right after Overview because the +// roster is the gateway operator's home base — the machines dialed into them. +const MAIN: NavDef[] = [ + {id: 'overview', label: 'Overview', icon: IconDashboard}, + {id: 'agents', label: 'Agents', icon: IconAgents, roles: ['gateway']}, + {id: 'traffic', label: 'Traffic', icon: IconConnections}, + {id: 'players', label: 'Players', icon: IconPlayers}, + {id: 'analytics', label: 'Analytics', icon: IconAnalytics}, + {id: 'tunnels', label: 'Tunnels', icon: IconTunnels}, + {id: 'activity', label: 'Activity', icon: IconActivity}, ] -export const NAV_SETTINGS: NavItem = { - id: 'settings', label: 'Settings', icon: IconSettings, shortcut: '7', +const SETTINGS: Omit = {id: 'settings', label: 'Settings', icon: IconSettings} + +/** mainNav: the visible rail for a role, with Ctrl-digit shortcuts assigned by + * position (1..n). Settings is pinned to the sidebar foot, outside this list. */ +export function mainNav(role: string): NavItem[] { + return MAIN + .filter(n => !n.roles || n.roles.includes(role as Role)) + .map((n, i) => ({...n, shortcut: String(i + 1)})) +} + +/** settingsNav: the foot-pinned Settings item; its shortcut follows the rail. */ +export function settingsNav(role: string): NavItem { + return {...SETTINGS, shortcut: String(mainNav(role).length + 1)} +} + +/** navFor: the full ordered list (rail + settings) for a role — the keyboard + * map and the command palette's Navigate section. */ +export function navFor(role: string): NavItem[] { + return [...mainNav(role), settingsNav(role)] +} + +/** labelOf: a screen's display name, role-agnostic — for the title-bar context + * strip, which only needs the label, never the shortcut. */ +export function labelOf(id: NavId): string { + return [...MAIN, SETTINGS].find(n => n.id === id)?.label ?? '' } diff --git a/frontend/src/rubberband.ts b/frontend/src/rubberband.ts new file mode 100644 index 0000000..647914e --- /dev/null +++ b/frontend/src/rubberband.ts @@ -0,0 +1,505 @@ +import {useEffect} from 'react' +import {useMotion} from './motion' + +/** + * Rubber-band overscroll. Push a scroller past its end and the content keeps + * giving — but exponentially less the harder you push; let go and it springs + * back through one overshoot. Interaction feedback, in the same family as + * .pf-press and .pf-lift (DESIGN.md rule 3): it only ever answers input, and it + * never plays on its own. + * + * One delegated listener set on `document`, mounted once from Shell — the + * pointer-wake idiom (Shell.tsx). Under reduced motion nothing is attached at + * all and every scroller reverts to a hard clamp: motion.css's kill switch only + * zeroes animation/transition durations, so it could not stop a rAF loop + * writing an inline transform. + * + * Native scrolling is never intercepted. We take over only for the delta that + * *no* scroller in the chain can consume — which is exactly where the hard stop + * used to be. + */ + +/* Physics. JS-only px math that nothing in CSS reads, so these are plain + documented consts, not tokens — the UNDERLAP precedent (Shell.tsx). */ +const FALLOFF_C = 0.55 // UIScrollView's constant: the shape of the give +const MAX_PULL_RATIO = 0.35 // asymptote as a fraction of the scroller's height… +const MAX_PULL_PX = 160 // …clamped, so a tall page can't pull half a screen +const MIN_PULL_PX = 32 // …and a short well still has somewhere to go +const HOLD_OMEGA = 34 // rad/s, ζ=1 while a notched wheel drives it (see denseSrc) +const TRACK_OMEGA = 80 // …and while a trackpad does +const TOUCH_OMEGA = 120 // a finger gets near-direct tracking +const RELEASE_OMEGA = 14 // rad/s on release (a notched mouse / finger: one graceful overshoot) +const RELEASE_OMEGA_FLING = 26 // …and after a trackpad flick, which wants a snappier snap-back +const RELEASE_ZETA = 0.55 // < 1 → exactly one visible overshoot. This is the bounce. +const IDLE_MS = 90 // wheel has no "release" event; this is the gesture end +const KEY_IMPULSE = 900 // px/s kick when a key hits a wall +const REST_PX = 0.5 // settled: offset below this (sub-pixel — the tail of an +const REST_VEL = 8 // …and velocity below this underdamped spring is long, + // and it must not hold will-change for a second after + // it is visually done). The zero-crossing of the + // overshoot is far too fast to trip REST_VEL. +const LINE_PX = 16 // deltaMode 1 (lines) → px +const LEAK_TAU = 0.1 // s — the momentum leak; see pull() and tick() +const DEFLATE_REL = 0.9 // a leaking (trackpad) band bounces once it recedes this far below its + // peak — the flick is spent; don't ride the OS momentum tail down (tick) +const FLING_MIN_PX = 4 // …but only if the pull was real; a sub-4px give just releases on idle +const NOTCH_PX = 30 // a wheel step is never smaller than this… +const NOTCH_MS = 25 // …nor closer together than this + +type Band = { + scroller: HTMLElement + content: HTMLElement + glow: HTMLElement | null // the edge-light overlay, stamped directly (see paint) + offset: number // px, signed: + = content pushed down (pulling past the top) + vel: number // px/s + target: number // where the spring is heading; 0 once released + raw: number // unconsumed input accumulated over this gesture + edge: number // +1 pushing down (bottom edge), -1 pushing up (top edge) + max: number // the falloff asymptote for this scroller + held: boolean // input is still arriving + omega: number + relOmega: number // release-spring ω: snappier for a trackpad flick than a mouse + leak: boolean // `raw` decays while held — a trackpad fling; see tick() + peakOut: number // max outward pull this gesture, for the deflate-release trigger + inRate: number // EMA of |input| — the live input strength + peakRate: number // its running max — the flick's strength; gates the inertia guard + autoRelease: boolean // wheel/key release on idle; touch waits for touchend + lastInput: number + last: number // previous frame timestamp +} + +const bands = new Map() +let raf = 0 +let wheelBand: Band | null = null +let touchBand: Band | null = null +let touchId = -1 +let touchY = 0 +let touchFrom: Element | null = null +let pageEl: HTMLElement | null = null + +/** The app's one page scroller (Shell's
). */ +function page(): HTMLElement | null { + if (!pageEl?.isConnected) pageEl = document.querySelector('main') + return pageEl +} + +/* ---------- geometry ---------- */ + +function scrolls(cs: CSSStyleDeclaration): boolean { + const o = cs.overflowY + return o === 'auto' || o === 'scroll' || o === 'overlay' +} + +/** Room left in the delta's direction. +1 = down. */ +function room(el: HTMLElement, dir: number): number { + return dir > 0 ? el.scrollHeight - el.clientHeight - el.scrollTop : el.scrollTop +} + +/** + * Native scroll-chaining semantics, with a bounce where the hard stop used to + * be: the first container with room keeps the gesture (we do nothing), an + * overscroll-behavior: contain container absorbs it (it bounces), and if nothing + * absorbs, the page hits the wall. Returns null when native scrolling handles it. + */ +function ownerFor(from: Element | null, dir: number): HTMLElement | null { + const pg = page() + for (let el: Element | null = from; el && el !== document.body; el = el.parentElement) { + if (el === pg) break + if (!(el instanceof HTMLElement)) continue // an SVG icon is a common wheel target + const cs = getComputedStyle(el) + if (scrolls(cs) && el.scrollHeight - el.clientHeight > 1) { + if (room(el, dir) > 1) return null // native scroll consumes it, untouched + const ob = cs.overscrollBehaviorY + if (ob === 'contain' || ob === 'none') return el // it absorbs → it bounces + // otherwise it chains outward, like the platform does + } + // A fixed overlay is its own world: a wheel on a modal scrim must never + // reach the page behind it. Checked after the scroll test — the Select + // listbox is both fixed and scrollable. + if (cs.position === 'fixed') return null + } + // Nothing absorbed it. The page bounces — even when it has no overflow at all + // (Activity, the wizard): a short page still answers a push. + return pg && room(pg, dir) <= 1 ? pg : null +} + +/** The element we translate: never the scroller itself — a well has a border and + * a recessed background, and sliding the box would open a gap under it. */ +function contentOf(scroller: HTMLElement): HTMLElement | null { + return scroller.querySelector(':scope > [data-band-content]') + ?? (scroller.firstElementChild as HTMLElement | null) +} + +/** The edge-light overlay for this scroller, if any: a sibling (marked + * data-band-glow by the consumer — Shell, Activity) that neither scrolls nor + * rides the band transform. We stamp --band-t / data-band straight onto it, + * never onto the scroller: the scroller's subtree is the whole screen and + * --band-t is an inherited custom property, so stamping it there would restyle + * every descendant on every frame. The glow's subtree is two pseudo-elements. */ +function glowOf(scroller: HTMLElement): HTMLElement | null { + return scroller.parentElement?.querySelector(':scope > [data-band-glow]') ?? null +} + +/* ---------- the give ---------- */ + +/** Apple's rational falloff: asymptotic, so it gives exponentially less the + * harder you push and never passes `max`. */ +function falloff(raw: number, max: number): number { + const r = Math.abs(raw) + return (r * max * FALLOFF_C) / (max + FALLOFF_C * r) +} + +/** Its inverse — used to seed `raw` when a gesture catches a bounce mid-flight, + * so grabbing it doesn't snap. */ +function unfalloff(pull: number, max: number): number { + const p = Math.min(pull, max * 0.98) + return (p * max) / (FALLOFF_C * (max - p)) +} + +/* ---------- band lifecycle ---------- */ + +function bandFor(scroller: HTMLElement): Band | null { + const existing = bands.get(scroller) + if (existing) return existing + const content = contentOf(scroller) + if (!content) return null + const now = performance.now() + const b: Band = { + scroller, content, glow: glowOf(scroller), + offset: 0, vel: 0, target: 0, raw: 0, edge: 1, + max: Math.max(MIN_PULL_PX, Math.min(MAX_PULL_RATIO * scroller.clientHeight, MAX_PULL_PX)), + held: true, omega: HOLD_OMEGA, relOmega: RELEASE_OMEGA, leak: false, + peakOut: 0, inRate: 0, peakRate: 0, autoRelease: true, lastInput: now, last: now, + } + // Only while live: a permanent will-change would make the content a containing + // block for position:fixed descendants even at rest. + content.style.willChange = 'transform' + bands.set(scroller, b) + return b +} + +/** Start a gesture on a band, carrying any bounce already in flight. */ +function claim(b: Band, edge: number, autoRelease: boolean) { + b.edge = edge + b.autoRelease = autoRelease + b.held = true + b.leak = false // the wheel re-arms it per gesture; a finger never leaks + b.peakOut = 0 + b.inRate = 0 + b.peakRate = 0 + // Seed from the current offset when it's on the side we're pushing, so + // catching a bounce continues it instead of yanking it back to zero. + const pull = -b.offset * edge + b.raw = pull > 0 ? edge * unfalloff(pull, b.max) : 0 +} + +function pull(b: Band, dy: number): boolean { + const raw = b.raw + dy + if (raw * b.edge <= 0) { + // Pulled all the way back through the edge: hand control straight back to + // native scrolling, this frame. + b.raw = 0 + b.target = 0 + b.held = false + b.autoRelease = true + pump() + return false + } + b.raw = raw + b.held = true + b.inRate += 0.3 * (Math.abs(dy) - b.inRate) // EMA of input strength + if (b.inRate > b.peakRate) b.peakRate = b.inRate // the flick's peak — the tail decays below it + b.lastInput = performance.now() + b.target = -b.edge * falloff(raw, b.max) + pump() + return true +} + +function paint(b: Band) { + const px = b.offset + b.content.style.transform = `translate3d(0, ${px.toFixed(2)}px, 0)` + // The edge light. Stamped straight onto the glow overlay — never onto the + // scroller, whose subtree is the whole screen: --band-t is an inherited custom + // property, so writing it on the scroller restyles every descendant on every + // frame (a full-page style recalc per frame). The glow's subtree is two pseudos. + // The light belongs to the edge being *pressed*, and only while it is pressed: + // keyed off b.edge, not the sign of the offset, so the release overshoot — + // which carries the content through zero and out the other side — never flashes + // the bloom on the opposite edge. Its outward travel is all that lights it. + if (b.glow) { + const out = Math.max(0, -px * b.edge) + b.glow.style.setProperty('--band-t', Math.min(1, out / b.max).toFixed(3)) + b.glow.dataset.band = b.edge > 0 ? 'bottom' : 'top' + } + // The Select menu is portaled to , so it does not ride the transform; + // it re-anchors off a capture-phase document scroll listener (ui.tsx). No + // native scroll event fires during a band, so it would otherwise detach. + b.scroller.dispatchEvent(new Event('scroll')) +} + +function rest(b: Band) { + b.content.style.transform = '' + b.content.style.willChange = '' + if (b.glow) { + b.glow.style.removeProperty('--band-t') + delete b.glow.dataset.band + } + bands.delete(b.scroller) + if (wheelBand === b) wheelBand = null + if (touchBand === b) touchBand = null + if (b.scroller.isConnected) b.scroller.dispatchEvent(new Event('scroll')) +} + +/* ---------- the spring ---------- */ + +/** + * Exact propagator for the damped oscillator, ζ ≤ 1. `target` is constant across a + * frame, so over dt the system is linear and time-invariant and has a closed form — + * which is unconditionally stable at any dt and any ω. + * + * This is why there is no fixed sub-step and no stability bound to respect when + * tuning a constant. The first-order integrator this replaced needed both: it was + * stable only while ω·h < 2(√(1+ζ²) − ζ) — 0.83 at ζ=1, NOT the 2ζω·h < 2 that the + * determinant alone suggests — so raising TOUCH_OMEGA far enough would have made it + * diverge slowly while still "passing" the looser test. It also bled ~5% of the + * overshoot's amplitude to numerical damping. Both failure modes are now unreachable, + * and the feel is bit-identical at 60 / 120 / 165 Hz. + */ +function propagate(b: Band, w: number, zeta: number, dt: number) { + const x = b.offset - b.target // displacement from where the spring is heading + const v = b.vel + const decay = Math.exp(-zeta * w * dt) + if (zeta < 1) { + const wd = w * Math.sqrt(1 - zeta * zeta) // the damped frequency + const c = Math.cos(wd * dt) + const s = Math.sin(wd * dt) + b.offset = b.target + decay * (x * c + ((v + zeta * w * x) / wd) * s) + b.vel = decay * (v * c - ((w * w * x + zeta * w * v) / wd) * s) + } else { // critically damped: the ωd → 0 limit of the above + b.offset = b.target + decay * (x + (v + w * x) * dt) + b.vel = decay * (v - w * dt * (v + w * x)) + } +} + +function tick(now: number) { + raf = 0 + for (const b of [...bands.values()]) { + if (!b.content.isConnected) { bands.delete(b.scroller); continue } + const dt = Math.max(0, (now - b.last) / 1000) || 0 // exact at any dt; no clamp needed + b.last = now + // Wheel and keys have no release event; idle input is the end of the gesture. + if (b.held && b.autoRelease && now - b.lastInput > IDLE_MS) { b.held = false; b.target = 0 } + + // The momentum leak. A trackpad fling keeps emitting wheel events for up to a + // second and a half after the fingers are gone, so `raw` — an integral — would + // saturate the falloff and *pin* the band at `max` for the whole tail: a spring + // held at full stretch by nothing. The user's model says it must recoil. So while + // a fling drives the band, raw decays: raw' = input − raw/τ, whose steady state + // is rate·τ. The pull now tracks the tail's *velocity* and melts with it, which is + // the invariant iOS actually keeps (excursion ∝ velocity at the boundary). + // Only for a fling — a mouse notch has no tail to leak, and a finger holding a + // stretch (touch: no events while it is still) must keep it. + if (b.held && b.leak) { + b.raw *= Math.exp(-dt / LEAK_TAU) + b.target = -b.edge * falloff(b.raw, b.max) + // Hand off to the bounce the moment the flick is spent, instead of riding the + // OS momentum tail down. That tail keeps arriving for up to ~1.5 s after the + // fingers lift; held (ζ=1), the band would deflate to zero with no bounce and + // sit pinned at the edge the whole time. So once the pull recedes past its peak + // — input can no longer sustain it, i.e. the fingers are gone — release into the + // underdamped spring from near the peak, and let onWheel swallow the leftover + // tail (inertiaGuard) so it can't yank the band back out. + const out = -b.offset * b.edge + if (out > b.peakOut) b.peakOut = out + else if (b.peakOut > FLING_MIN_PX && out < DEFLATE_REL * b.peakOut) { + b.held = false + b.target = 0 + inertiaEdge = b.edge + inertiaRate = b.peakRate + inertiaGuardUntil = now + IDLE_MS + } + } + + const w = b.held ? b.omega : b.relOmega + const zeta = b.held ? 1 : RELEASE_ZETA // critical while held; underdamped on release + propagate(b, w, zeta, dt) + + if (!b.held && Math.abs(b.offset) < REST_PX && Math.abs(b.vel) < REST_VEL) { rest(b); continue } + paint(b) + } + if (bands.size) raf = requestAnimationFrame(tick) +} + +function pump() { + if (!raf && bands.size) raf = requestAnimationFrame(tick) +} + +/** Snap every band to rest. Called on nav so a mid-bounce transform is never + * baked into the pf-content View Transition snapshot. */ +export function resetBands() { + for (const b of [...bands.values()]) rest(b) + wheelBand = null + touchBand = null + touchId = -1 + if (raf) { cancelAnimationFrame(raf); raf = 0 } +} + +/* ---------- input ---------- */ + +function deltaOf(e: WheelEvent): number { + if (e.deltaMode === 1) return e.deltaY * LINE_PX + if (e.deltaMode === 2) return e.deltaY * (page()?.clientHeight ?? 800) + return e.deltaY +} + +/** + * Two instruments, one API. A notched mouse emits sparse whole-pixel steps: that is a + * *rate* signal, and HOLD_OMEGA's 59 ms of glide is exactly what turns each 100px step + * into motion. A trackpad emits dense fractional deltas: that is a *position* signal, + * and running 59 ms behind a position signal is the definition of spongy. Same + * constant, opposite verdicts — so tell them apart instead of compromising, on the + * three ways they differ (step size, whole pixels, cadence). A free-spinning high-res + * wheel classifies as a trackpad, and the tighter tracking is right for it too. + */ +let wheelAt = 0 +let denseSrc = false +// A spent fling (tick released it early) leaves its inertial tail still arriving. +// Swallow that tail here so it can't re-grab the now-bouncing band; see onWheel. +let inertiaGuardUntil = 0 +let inertiaEdge = 0 +let inertiaRate = 0 + +function onWheel(e: WheelEvent) { + if (e.ctrlKey) return // pinch-zoom / browser zoom + const dy = deltaOf(e) + if (!dy || Math.abs(e.deltaX) > Math.abs(dy)) return // horizontal wheel isn't ours + + const now = performance.now() + const gap = now - wheelAt + wheelAt = now + denseSrc = !(Math.abs(dy) >= NOTCH_PX && Number.isInteger(dy) && gap >= NOTCH_MS) + + // Swallow a spent fling's momentum tail — dense, same-edge, and decayed below the + // flick's own strength — so each leftover inertial event doesn't yank the bouncing + // band back to the wall. A genuine new push (dy back up near the flick's rate) falls + // through and re-bands, so an early release that fired a touch too soon self-heals. + if (now < inertiaGuardUntil && denseSrc && Math.sign(dy) === inertiaEdge && Math.abs(dy) < 0.8 * inertiaRate) { + inertiaGuardUntil = now + IDLE_MS + e.preventDefault() + return + } + + // Latch: once a band owns the burst it keeps it, so a fling that runs into the + // wall mid-gesture keeps pushing the same edge. + let b = wheelBand + if (b && (!bands.has(b.scroller) || now - b.lastInput > IDLE_MS)) b = wheelBand = null + if (!b) { + const scroller = ownerFor(e.target as Element, Math.sign(dy)) + if (!scroller) return // native scroll handles it — untouched + b = bandFor(scroller) + if (!b) return + claim(b, Math.sign(dy), true) + // Latched for the gesture, not re-read per event: a device reclassified mid-fling + // would change the physics under the user's hand. + b.omega = denseSrc ? TRACK_OMEGA : HOLD_OMEGA + b.relOmega = denseSrc ? RELEASE_OMEGA_FLING : RELEASE_OMEGA + b.leak = denseSrc + wheelBand = b + } + if (pull(b, dy)) e.preventDefault() + else wheelBand = null +} + +function onTouchStart(e: TouchEvent) { + if (e.touches.length !== 1) { onTouchEnd(); return } // pinch: not ours + touchId = e.touches[0].identifier + touchY = e.touches[0].clientY + touchFrom = e.target as Element + touchBand = null +} + +function onTouchMove(e: TouchEvent) { + if (touchId < 0 || e.touches.length !== 1) return + const t = e.touches[0] + if (t.identifier !== touchId) return + const dy = touchY - t.clientY // finger up = scrolling down, like deltaY + touchY = t.clientY + if (!dy) return + + let b = touchBand + if (!b) { + const scroller = ownerFor(touchFrom, Math.sign(dy)) + if (!scroller) return // native scroll until the boundary + b = bandFor(scroller) + if (!b) return + claim(b, Math.sign(dy), false) + touchBand = b + } + b.omega = TOUCH_OMEGA + if (pull(b, dy)) e.preventDefault() + else touchBand = null +} + +/** Let go: the spring keeps whatever velocity the finger left it with. */ +function onTouchEnd() { + if (touchBand) { + touchBand.held = false + touchBand.target = 0 + touchBand.autoRelease = true + pump() + } + touchBand = null + touchId = -1 + touchFrom = null +} + +const KEY_DIR: Record = { + PageDown: 1, PageUp: -1, End: 1, Home: -1, ArrowDown: 1, ArrowUp: -1, ' ': 1, +} + +/** A key that hits a wall gets an impulse straight into the release spring — no + * preventDefault, because native scrolling has nothing left to do there. */ +function onKey(e: KeyboardEvent) { + if (e.ctrlKey || e.altKey || e.metaKey) return + const dir = KEY_DIR[e.key] ?? 0 + if (!dir) return + const el = e.target as HTMLElement | null + // Anything that consumes the key itself (Space on a button, arrows in a menu). + if (el?.isContentEditable || el?.closest('input, textarea, select, button, a, [role="listbox"]')) return + + const scroller = ownerFor(el ?? document.activeElement, dir) + if (!scroller) return + const b = bandFor(scroller) + if (!b) return + b.edge = dir + b.held = false + b.autoRelease = true + b.target = 0 + b.raw = 0 + b.vel = -dir * KEY_IMPULSE + b.lastInput = performance.now() + pump() +} + +/** Mount once, from Shell. Re-arms live when the Animations preference flips. */ +export function useRubberBand() { + const {reduced} = useMotion() + useEffect(() => { + if (reduced) { resetBands(); return } + document.addEventListener('wheel', onWheel, {passive: false}) + document.addEventListener('touchstart', onTouchStart, {passive: true}) + document.addEventListener('touchmove', onTouchMove, {passive: false}) + document.addEventListener('touchend', onTouchEnd, {passive: true}) + document.addEventListener('touchcancel', onTouchEnd, {passive: true}) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('wheel', onWheel) + document.removeEventListener('touchstart', onTouchStart) + document.removeEventListener('touchmove', onTouchMove) + document.removeEventListener('touchend', onTouchEnd) + document.removeEventListener('touchcancel', onTouchEnd) + document.removeEventListener('keydown', onKey) + resetBands() + } + }, [reduced]) +} diff --git a/frontend/src/screens/Activity.tsx b/frontend/src/screens/Activity.tsx index be9fff9..3a91718 100644 --- a/frontend/src/screens/Activity.tsx +++ b/frontend/src/screens/Activity.tsx @@ -88,14 +88,21 @@ export function Activity({attached}: {attached: boolean}) { ring · {CAP} max
-
- {filtered.length === 0 - ? (attached && entries.length === 0 - ? } title="The service keeps its own logs" - hint="This window views a background service, which logs to its own files. Export diagnostics to collect them." /> - : } title="No log lines" - hint="Activity streams here as the engine runs." />) - : filtered.map(e => )} + {/* The well bounces at its ends; the glow is its sibling so it neither + scrolls nor rides the band's transform (rubberband.ts). */} +
+
+
+ {filtered.length === 0 + ? (attached && entries.length === 0 + ? } title="The service keeps its own logs" + hint="This window views a background service, which logs to its own files. Export diagnostics to collect them." /> + : } title="No log lines" + hint="Activity streams here as the engine runs." />) + : filtered.map(e => )} +
+
+
diff --git a/frontend/src/screens/Agents.tsx b/frontend/src/screens/Agents.tsx new file mode 100644 index 0000000..e77581a --- /dev/null +++ b/frontend/src/screens/Agents.tsx @@ -0,0 +1,883 @@ +import {useEffect, useMemo, useRef, useState} from 'react' +import { + AgentBandwidthHistory, GatewayEvents, ListAgents, RenameAgent, RevokeAgent, SetAgentScope, +} from '../../wailsjs/go/app/App' +import {app, gateway} from '../../wailsjs/go/models' +import {BandwidthPanel} from '../components/BandwidthChart' +import {Emblem} from '../components/Emblem' +import { + Badge, Banner, Button, Card, CopyIcon, Disclosure, EmptyState, ErrorBanner, Field, Modal, + Overline, PageHeader, SegmentedControl, Skeleton, StatusDot, TextInput, +} from '../components/ui' +import { + IconActivity, IconAgents, IconArrowRight, IconChevronRight, IconClock, IconLogs, + IconSearch, IconServer, IconShield, IconTunnels, IconUsers, +} from '../components/icons' +import {HistoryResult, RANGES, RangeKey} from '../history' +import {usePolled} from '../hooks' +import {fmtBytes, fmtUptime, hasRtt, scopeLabel, UIStatus, worstHealth} from '../state' + +type AgentUI = app.AgentUI +type AgentView = gateway.AgentView +type GwEvent = gateway.GatewayEvent +type Seg = 'good' | 'warn' | 'bad' | 'unknown' + +// A roster row joins the identity/enrollment view (ListAgents — the full roster, +// online or off) with the live health snapshot (status.agents — connected only). +type Row = {view: AgentView; live: AgentUI | undefined} + +// Health language, mirrored from Overview so a verdict reads the same wherever +// it appears. Tones ARE the signal (DESIGN rule 4); the thresholds match the +// backend's score exactly (jitter/loss bands in agent.go). +const segColor: Record = {good: 'var(--good)', warn: 'var(--warn)', bad: 'var(--bad)', unknown: 'var(--text-3)'} +const HEALTH_LABEL: Record = {good: 'Healthy', warn: 'Fair', bad: 'Poor', unknown: 'Unknown'} +const HEALTH_TONE: Record = {good: 'good', warn: 'warn', bad: 'bad', unknown: 'neutral'} +// Worst-first ordering for the Health sort: the machine that needs eyes leads. +const HEALTH_RANK: Record = {bad: 0, warn: 1, unknown: 2, good: 3} + +const jitterTone = (v: number): Seg => (v < 0 ? 'unknown' : v > 100 ? 'bad' : v > 30 ? 'warn' : 'good') +const lossTone = (v: number): Seg => (v < 0 ? 'unknown' : v > 5 ? 'bad' : v > 1 ? 'warn' : 'good') +const fmtMs = (v: number): string => (v < 0 ? '—' : `${v.toFixed(1)} ms`) +const fmtPct = (v: number): string => (v < 0 ? '—' : `${v.toFixed(v > 0 && v < 10 ? 1 : 0)}%`) +const fmtRttMs = (v: number): string => (hasRtt(v) ? `${Math.round(v)} ms` : '—') +const fmtDate = (ms: number): string => (ms > 0 ? new Date(ms).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'}) : '—') +const fmtTime = (ms: number): string => new Date(ms).toLocaleTimeString(undefined, {hour12: false}) + +const seg = (a: AgentUI): Seg => (a.healthScore || 'unknown') as Seg +const rowSeg = (r: Row): Seg => (r.live ? seg(r.live) : 'unknown') +const traffic = (r: Row): number => (r.live ? r.live.linkBytesIn + r.live.linkBytesOut : 0) +const players = (r: Row): number => r.live?.players ?? 0 +const isConnected = (v: AgentView): boolean => v.connected && !v.revoked +// connected → offline → revoked, so struggling live machines and dead entries +// don't shuffle together under the traffic/health/players sorts. +const tier = (r: Row): number => (r.view.revoked ? 2 : r.view.connected ? 0 : 1) + +// The roster's title: a chosen nickname wins, else the machine's own hostname, +// else the raw id — never blank. +const displayName = (v: AgentView): string => v.nickname || v.hostname || v.agentId + +// Scope as a human phrase. Both empty = the agent may bind anything. +function scopeText(v: AgentView): string { + const ports = v.scopePorts ?? [] + const tuns = v.scopeTunnels ?? [] + if (ports.length === 0 && tuns.length === 0) return 'Unrestricted' + const parts: string[] = [] + if (ports.length) parts.push(`${ports.length === 1 ? 'port' : 'ports'} ${ports.join(', ')}`) + if (tuns.length) parts.push(`${tuns.length} tunnel${tuns.length === 1 ? '' : 's'}`) + return parts.join(' · ') +} + +type SortKey = 'health' | 'traffic' | 'players' | 'name' +type Density = 'grid' | 'list' +const loadSort = (): SortKey => (localStorage.getItem('pf-agents-sort') as SortKey) || 'health' +const loadDensity = (): Density => (localStorage.getItem('pf-agents-density') as Density) || 'grid' + +// Roster cache for usePolled: keyed by a nonce so a mutation can force an +// immediate re-poll (the key changes) without waiting out the interval. +const rosterCache = new Map() + +// Per-agent bandwidth source: a hook bound to one agentId, keyed so each agent +// keeps its own cached history. Fed to BandwidthPanel so the drill-in draws the +// same instrument as Traffic — scoped to this machine's RRD. +const agentHistCache = new Map() +function agentHistorySource(agentId: string) { + return (range: RangeKey): HistoryResult | null => { + const spec = RANGES[range] + return usePolled(agentHistCache, `${agentId}:${range}`, + () => AgentBandwidthHistory(agentId, spec.windowMs, spec.buckets), spec.pollMs) + } +} + +// GatewayEvents tail: mirrors Activity's since-cursor accumulate. Paused (key +// off) when this isn't the gateway. `null` until the first poll resolves so the +// consumers can tell "loading" from "loaded, empty". +const EVENTS_CAP = 200 +function useGatewayEvents(enabled: boolean): GwEvent[] | null { + const [events, setEvents] = useState(null) + const lastSeq = useRef(0) + useEffect(() => { + if (!enabled) { setEvents(null); lastSeq.current = 0; return } + let alive = true + const pull = async () => { + try { + const fresh = await GatewayEvents(lastSeq.current) + if (!alive) return + if (fresh.length) lastSeq.current = fresh[fresh.length - 1].seq + setEvents(prev => (fresh.length ? [...(prev ?? []), ...fresh].slice(-EVENTS_CAP) : prev ?? [])) + } catch { + // A daemon without the ring degrades to the written-empty state rather + // than a stuck skeleton; the screen-level unavailable state covers the + // pre-roster daemon before we ever get here. + if (alive) setEvents(prev => prev ?? []) + } + } + pull() + const t = setInterval(pull, 3000) + return () => { alive = false; clearInterval(t) } + }, [enabled]) + return events +} + +/** + * Agents (gateway role): the fleet — a wall of machine cards, the gateway + * analogue of the Players wall of faces. The roster now comes from ListAgents + * (every enrolled agent, online or off, plus any connected shared-token agent); + * live health/RTT joins in from status.agents for the connected ones. Selecting + * one drills into a focused per-agent view with rename/scope/revoke controls. + * One identity surface; the cards are frost, never Signal Glass — N equal cards + * can't each answer the pointer. + */ +export function Agents({status}: {status: UIStatus}) { + const [selected, setSelected] = useState(null) + const [sort, setSort] = useState(loadSort) + const [density, setDensity] = useState(loadDensity) + const [query, setQuery] = useState('') + const [nonce, setNonce] = useState(0) + const [dismissed, setDismissed] = useState>(() => new Set()) + + const isGateway = status.role === 'gateway' + const rosterKey = isGateway ? `roster:${nonce}` : null + const roster = usePolled(rosterCache, rosterKey, () => ListAgents(), 4000) + const events = useGatewayEvents(isGateway) + + // A mutation re-polls at once: carry the current data into the next key so the + // poll refreshes without flashing the skeleton, then bump the nonce. + const refreshRoster = () => { + if (roster && rosterKey) rosterCache.set(`roster:${nonce + 1}`, roster) + setNonce(n => n + 1) + } + + const liveById = useMemo( + () => new Map((status.agents ?? []).map(a => [a.agentId, a])), + [status.agents]) + const rows = useMemo( + () => (roster ?? []).map(v => ({view: v, live: liveById.get(v.agentId)})), + [roster, liveById]) + + const rosterIds = useMemo(() => new Set((roster ?? []).map(v => v.agentId)), [roster]) + + // Unresolved conflicts: dedupe the event ring by conflict identity (latest + // wins), drop the client-dismissed ones. A newer event for the same identity + // resurfaces even after an older one was dismissed. + const conflicts = useMemo(() => { + if (!events) return [] + const byKey = new Map() + for (const e of events) { + const key = `${e.kind}:${e.agentId ?? ''}:${e.tunnelId ?? ''}:${e.requestedPort ?? ''}` + const prev = byKey.get(key) + if (!prev || e.seq > prev.seq) byKey.set(key, e) + } + return [...byKey.values()].filter(e => !dismissed.has(e.seq)).sort((a, b) => b.seq - a.seq) + }, [events, dismissed]) + + const sorted = useMemo(() => { + const f = query.trim().toLowerCase() + const filtered = rows.filter(r => !f + || r.view.hostname.toLowerCase().includes(f) + || r.view.agentId.toLowerCase().includes(f) + || (r.view.nickname || '').toLowerCase().includes(f)) + return filtered.sort((a, b) => { + if (sort === 'name') return displayName(a.view).localeCompare(displayName(b.view)) + const t = tier(a) - tier(b) + if (t !== 0) return t + return sort === 'players' ? players(b) - players(a) || traffic(b) - traffic(a) + : sort === 'traffic' ? traffic(b) - traffic(a) + : HEALTH_RANK[rowSeg(a)] - HEALTH_RANK[rowSeg(b)] || traffic(b) - traffic(a) + }) + }, [rows, sort, query]) + + // A selected agent that leaves the roster entirely drops us back to the list. + useEffect(() => { + if (selected && roster && !roster.some(v => v.agentId === selected)) setSelected(null) + }, [selected, roster]) + + // Honest-unavailable, told apart from an empty roster: this machine is an agent + // (no roster to show), or the background service predates the roster and sends + // no agents array at all (status.agents === undefined; &fleet=old). + if (!isGateway) { + return ( +
+
+ } title="Roster unavailable" + hint="Agents live on the gateway. This machine is running as an agent, so it has no fleet to manage." /> +
+ ) + } + if (status.agents === undefined) { + return ( +
+
+ } title="Roster unavailable" + hint="The background service is an older version that doesn't report the agent roster. Update it to manage agents." /> +
+ ) + } + + const current = selected ? rows.find(r => r.view.agentId === selected) ?? null : null + if (current) return setSelected(null)} onChanged={refreshRoster} /> + + const pickSort = (v: SortKey) => { setSort(v); localStorage.setItem('pf-agents-sort', v) } + const pickDensity = (v: Density) => { setDensity(v); localStorage.setItem('pf-agents-density', v) } + const dismiss = (seq: number) => setDismissed(prev => new Set(prev).add(seq)) + const n = rows.length + + return ( +
+
+ + + + {roster === null ? ( + + ) : n === 0 ? ( + } title="No agents enrolled yet" + hint="Issue a pairing code (Overview, or the setup wizard) and share it with a machine. Every agent that enrolls with it appears here — online or off." /> + ) : ( + <> + + +
+ + value={sort} onChange={pickSort} + options={[ + {value: 'health', label: 'Health'}, {value: 'traffic', label: 'Traffic'}, + {value: 'players', label: 'Players'}, {value: 'name', label: 'Name'}, + ]} + /> +
+ {n > 4 && ( +
+ } value={query} onChange={setQuery} + placeholder="Filter agents…" ariaLabel="Filter agents by name, host, or id" + /> +
+ )} + + value={density} onChange={pickDensity} + options={[{value: 'grid', label: 'Grid'}, {value: 'list', label: 'List'}]} + /> +
+
+ + {sorted.length === 0 ? ( + } title="No agents match" + hint={`Nothing matches "${query.trim()}".`} /> + ) : density === 'grid' ? ( +
+ {sorted.map(r => setSelected(r.view.agentId)} />)} +
+ ) : ( + + )} + + )} + + +
+ ) +} + +function Header() { + return +} + +/** FleetSummary: the page's one hero figure (agent count) with the connected + * fleet's worst-of health and the rolled-up totals — type on whitespace, never + * another card (DESIGN rule 5). Only rendered for a non-empty roster. */ +function FleetSummary({rows}: {rows: Row[]}) { + const n = rows.length + const online = rows.filter(r => isConnected(r.view)).length + const revoked = rows.filter(r => r.view.revoked).length + const liveAgents = rows.filter(r => r.live).map(r => r.live as AgentUI) + const worst = worstHealth(liveAgents) + const tunnels = rows.reduce((s, r) => s + r.view.tunnels, 0) + const plrs = rows.reduce((s, r) => s + players(r), 0) + const verdict = worst === 'good' ? 'All healthy' : worst === 'warn' ? 'Needs attention' : 'Degraded' + return ( +
+
+ {n} + {n === 1 ? 'agent' : 'agents'} +
+ {online > 0 + ? + : } + {online} online + {revoked > 0 && {revoked} revoked} + {tunnels} tunnel{tunnels === 1 ? '' : 's'} + {plrs} player{plrs === 1 ? '' : 's'} online +
+ ) +} + +/** StatusMark: one agent's connectivity verdict — health dot when connected, a + * quiet "Offline" dot when enrolled-but-away, a Revoked badge when cut off. */ +function StatusMark({view, h}: {view: AgentView; h: Seg}) { + if (view.revoked) return Revoked + if (isConnected(view)) return + return +} + +/** Metric: tier-3 stat — type on whitespace with a hairline lead, no box; the + * status tone lives on the numeral. Shared by the cards and the detail health. */ +function Metric({label, value, tone}: {label: string; value: string; tone: Seg | 'neutral'}) { + const c = tone === 'neutral' ? 'var(--text)' : segColor[tone] + return ( +
+ {label} +
{value}
+
+ ) +} + +/** Uptime: a 1 Hz self-updating duration so the readout ticks between snapshots. */ +function Uptime({since}: {since: number}) { + const [, tick] = useState(0) + useEffect(() => { + const t = setInterval(() => tick(x => x + 1), 1000) + return () => clearInterval(t) + }, []) + return {fmtUptime(Date.now() - since)} +} + +/** ScopeChip: the agent's bind scope as a quiet inline phrase (footer/list). */ +function ScopeChip({view}: {view: AgentView}) { + const s = scopeText(view) + return ( + + {s} + + ) +} + +/** AgentCard: one machine's card — frost glass that lifts to the pointer + * (tactility, DESIGN rule 3) but never ignites; only a live health dot breathes. + * The whole card is the drill-in affordance. Connected agents show live vitals; + * offline/revoked ones show their standing instead, at matched geometry. */ +function AgentCard({row, onOpen}: {row: Row; onOpen: () => void}) { + const {view, live} = row + const h = rowSeg(row) + const connected = isConnected(view) + return ( + + ) +} + +/** AgentList: the compact density — one flush row per agent, extra columns + * revealed as the container widens (@container), so a wide fleet reads as a + * dense table and a narrow one stays legible. */ +function AgentList({rows, onOpen}: {rows: Row[]; onOpen: (id: string) => void}) { + return ( +
+ {rows.map(r => { + const {view, live} = r + const h = rowSeg(r) + const connected = isConnected(view) + return ( + + ) + })} +
+ ) +} + +/** RosterSkeleton: geometry-matched placeholder for the summary line + card grid + * (or the compact rows) while ListAgents resolves. */ +function RosterSkeleton({density}: {density: Density}) { + return ( +
+
+ + + +
+ {density === 'list' ? ( + +
+ {[0, 1, 2, 3].map(i => ( +
+ +
+ +
+ ))} +
+
+ ) : ( +
+ {[0, 1, 2].map(i => )} +
+ )} +
+ ) +} + +function SkeletonCard() { + return ( +
+
+ +
+ +
+
+ {[0, 1, 2].map(i =>
)} +
+
+ +
+
+ ) +} + +/** ConflictCards: the gateway's unresolved conflicts, surfaced above the roster. + * Informational only — there is no one-click reclaim/evict on the backend; each + * card explains the conflict and links to the contesting agent. Nothing shows + * when there are none (no empty box). */ +function ConflictCards({conflicts, rosterIds, onSelect, onDismiss}: { + conflicts: GwEvent[]; rosterIds: Set; onSelect: (id: string) => void; onDismiss: (seq: number) => void +}) { + if (conflicts.length === 0) return null + return ( +
+ {conflicts.map(e => ( + e.agentId && onSelect(e.agentId)} + onDismiss={() => onDismiss(e.seq)} + /> + ))} +
+ ) +} + +function ConflictCard({e, linkable, onSelect, onDismiss}: { + e: GwEvent; linkable: boolean; onSelect: () => void; onDismiss: () => void +}) { + const clone = e.kind === 'clone-suspected' + const hasPorts = e.requestedPort != null && e.actualPort != null + return ( + View agent : undefined} + > +
+
+ {clone ? 'Possible cloned agent' : 'Port auto-reassigned'} + {!clone && hasPorts && ( + requested {e.requestedPort} → bound {e.actualPort} + )} + {fmtTime(e.timeMs)} +
+
{e.message}
+
+
+ ) +} + +/** EventLog: a modest, collapsible tail of the gateway's conflict/auto-fix ring + * below the roster — timestamp + kind chip + message, newest first. Four states: + * skeleton while loading, rows, a written empty state, and (at the screen level) + * the honest-unavailable roster state for a pre-roster daemon. */ +function EventLog({events}: {events: GwEvent[] | null}) { + return ( + + {events === null ? ( +
+ {[0, 1, 2].map(i => ( +
+ + + +
+ ))} +
+ ) : events.length === 0 ? ( + } title="No gateway events yet" + hint="Port reassignments and clone warnings will appear here as they happen." /> + ) : ( +
+ {[...events].reverse().slice(0, 60).map(e => )} +
+ )} +
+ ) +} + +function EventRow({e}: {e: GwEvent}) { + const clone = e.kind === 'clone-suspected' + return ( +
+ {fmtTime(e.timeMs)} + {clone ? 'clone' : 'reassign'} + {e.message} +
+ ) +} + +/** AgentDetail: the drill-in. One machine's identity, its link health, the + * management controls (rename / scope / revoke), its own bandwidth graph (same + * instrument as Traffic, scoped to this agent's RRD), and the tunnels + live + * sessions attributed to it. Offline agents synthesize an unknown live snapshot + * so the identity/health panels read honestly (— everywhere), while the RRD + * chart still draws their history. */ +function AgentDetail({status, row, onBack, onChanged}: { + status: UIStatus; row: Row; onBack: () => void; onChanged: () => void +}) { + const {view, live} = row + const connected = isConnected(view) + const h = rowSeg(row) + const liveOrSynth: AgentUI = live ?? app.AgentUI.createFrom({ + agentId: view.agentId, hostname: view.hostname, lanIps: [], remoteIp: view.remoteIp, + linkUpSinceMs: connected ? view.linkUpSinceMs : 0, rttMillis: 0, jitterMillis: -1, + packetLossPct: -1, healthScore: 'unknown', linkBytesIn: 0, linkBytesOut: 0, + tunnels: view.tunnels, players: 0, + }) + const tunnels = (status.tunnels ?? []).filter(t => t.agentId === view.agentId) + const conns = (status.connections ?? []).filter(c => c.agentId === view.agentId) + const source = useMemo(() => agentHistorySource(view.agentId), [view.agentId]) + + return ( +
+
+ + + {view.agentId} + + + ) : undefined} + action={} + /> +
+ +
+
+
+
+ + + + {/* The detail's moment of light (DESIGN rules 5/7): the same bare hero + graph Traffic uses, drawing THIS agent's per-agent RRD. It breaks the + surrounding card rhythm so the drill-in isn't a stack of frost panels. */} + + +
+
+
+
+
+ ) +} + +/** AgentManagement: the gateway's controls over one agent — rename, scope, and + * revoke. Each write follows the write-then-repoll loop (persist, then re-poll + * ListAgents so the roster and this view refresh). Revoke is guarded by a Modal. */ +function AgentManagement({view, onChanged}: {view: AgentView; onChanged: () => void}) { + const [nickname, setNickname] = useState(view.nickname || '') + const [ports, setPorts] = useState((view.scopePorts ?? []).join(', ')) + const [tuns, setTuns] = useState((view.scopeTunnels ?? []).join(', ')) + const [savingName, setSavingName] = useState(false) + const [savingScope, setSavingScope] = useState(false) + const [confirming, setConfirming] = useState(false) + const [revoking, setRevoking] = useState(false) + const [err, setErr] = useState('') + + // Reseed the fields when we're pointed at a different agent, or when the + // persisted values actually change. The deps are the serialized VALUES, not + // the array references — the roster re-polls every few seconds with fresh + // objects, and depending on the arrays would wipe an in-progress edit each poll. + useEffect(() => { + setNickname(view.nickname || '') + setPorts((view.scopePorts ?? []).join(', ')) + setTuns((view.scopeTunnels ?? []).join(', ')) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [view.agentId, view.nickname, (view.scopePorts ?? []).join(','), (view.scopeTunnels ?? []).join(',')]) + + const parsePorts = (s: string): number[] => + s.split(',').map(x => parseInt(x.trim(), 10)).filter(nu => nu > 0 && nu < 65536) + const parseTuns = (s: string): string[] => s.split(',').map(x => x.trim()).filter(Boolean) + + const nameChanged = nickname.trim() !== (view.nickname || '') + const scopeChanged = parsePorts(ports).join(',') !== (view.scopePorts ?? []).join(',') + || parseTuns(tuns).join(',') !== (view.scopeTunnels ?? []).join(',') + + const saveName = async () => { + setSavingName(true); setErr('') + try { await RenameAgent(view.agentId, nickname.trim()); onChanged() } + catch (e) { setErr(String(e)) } finally { setSavingName(false) } + } + const saveScope = async () => { + setSavingScope(true); setErr('') + try { await SetAgentScope(view.agentId, parsePorts(ports), parseTuns(tuns)); onChanged() } + catch (e) { setErr(String(e)) } finally { setSavingScope(false) } + } + const doRevoke = async () => { + setRevoking(true); setErr('') + try { await RevokeAgent(view.agentId); setConfirming(false); onChanged() } + catch (e) { setErr(String(e)); setConfirming(false) } finally { setRevoking(false) } + } + + return ( + +
+ {err && setErr('')} />} + + +
+
+ +
+ +
+
+ +
+ + + + + + +
+
+ Current scope: {scopeText(view)} + +
+ +
+
+
{view.revoked ? 'Access revoked' : 'Revoke access'}
+
+ {view.revoked + ? 'This agent has been revoked. Its next connection is refused.' + : 'Removes the agent and drops its live session. The next connect is refused with “access revoked”.'} +
+
+ {!view.revoked && } +
+ +
+ Enrolled {fmtDate(view.issuedAtMs)} · {view.enrolled ? 'Ed25519 identity' : 'shared token'} +
+
+ + {confirming && ( + setConfirming(false)} + footer={<> + + + } + > +

+ Revoking {displayName(view)} removes it from the gateway and immediately + drops its live session. Its next connection attempt is refused with “access revoked”. To restore it, enroll it + again with a fresh pairing code. +

+
+ )} +
+ ) +} + +/** AgentIdentity: where this machine is — its remote address prominent, LAN + * quiet, with link uptime and lifetime link data. Wears the agent role swatch. */ +function AgentIdentity({agent}: {agent: AgentUI}) { + const lan = (agent.lanIps ?? []).filter(Boolean) + return ( + +
+ +
+
+ Remote address +
+ {agent.remoteIp || '—'} + {agent.remoteIp && } +
+
+ {lan.length > 0 && ( +
+ LAN +
{lan.join(', ')}
+
+ )} +
+
+ Link uptime +
+ {agent.linkUpSinceMs > 0 ? : '—'} +
+
+
+ Link data +
+ {agent.linkBytesIn + agent.linkBytesOut > 0 ? fmtBytes(agent.linkBytesIn + agent.linkBytesOut) : '—'} +
+
+
+
+
+
+ ) +} + +/** AgentHealth: this agent's link-health rollup — the verdict beside jitter, + * loss and round trip as explicit numbers. Mirrors Overview's HealthPanel. */ +function AgentHealth({agent}: {agent: AgentUI}) { + const h = seg(agent) + const c = segColor[h] + return ( + +
+
+ + + +
+ Link health +
{HEALTH_LABEL[h]}
+
+
+
+ + + +
+
+
+ ) +} + +/** AgentTunnels: the ports this agent has bound, with each local server's state. */ +function AgentTunnels({tunnels}: {tunnels: app.TunnelUI[]}) { + return ( + 0 ?
{tunnels.length}
: undefined}> + {tunnels.length === 0 ? ( +
} title="No tunnels bound" + hint="This agent hasn't registered any ports yet." />
+ ) : ( +
+ {tunnels.map(t => { + const capped = (t.bandwidthLimitMbps ?? 0) > 0 + const scoped = capped && (t.bandwidthLimitScope || 'combined') !== 'combined' + const sub = `port ${t.publicPort > 0 ? t.publicPort : '—'}` + + (capped ? ` · ${t.bandwidthLimitMbps} Mbps` : '') + + (scoped ? ` · ${scopeLabel(t.bandwidthLimitScope)}` : '') + return ( +
+
+
+
{t.name}
+
{sub}
+
+ + {t.localKnown ? (t.localUp ? 'Server up' : 'Server down') : 'Unknown'} + +
+ ) + })} +
+ )} +
+ ) +} + +/** AgentSessions: the live player connections attributed to this agent. */ +function AgentSessions({conns}: {conns: app.ConnUI[]}) { + return ( + {conns.length} live
}> + {conns.length === 0 ? ( +
} title="No players connected" + hint="Active player sessions on this agent appear here." />
+ ) : ( +
+ {conns.map(c => ( +
+
+
{c.playerName || c.clientAddr}
+
{c.tunnelName} · {c.clientAddr}
+
+
+
{fmtBytes(c.bytesIn + c.bytesOut)}
+ {hasRtt(c.rttMs) &&
{Math.round(c.rttMs)} ms
} +
+
+ ))} +
+ )} + + ) +} diff --git a/frontend/src/screens/Overview.tsx b/frontend/src/screens/Overview.tsx index c82a9e2..3dd3c87 100644 --- a/frontend/src/screens/Overview.tsx +++ b/frontend/src/screens/Overview.tsx @@ -6,10 +6,10 @@ import {BandwidthPanel} from '../components/BandwidthChart' import {NumberTicker} from '../components/NumberTicker' import { Badge, Banner, Button, Card, Codebox, CopyButton, CopyIcon, ErrorBanner, - Overline, PageHeader, RoleWord, SignalCard, StatTile, StatusDot, + IconButton, Overline, PageHeader, RoleWord, SignalCard, StatTile, StatusDot, } from '../components/ui' import {Emblem} from '../components/Emblem' -import {IconActivity, IconGlobe, IconLink, IconServer} from '../components/icons' +import {IconActivity, IconEye, IconEyeOff, IconGlobe, IconLink, IconServer} from '../components/icons' import {NavId} from '../nav' import {fmtBytes, fmtUptime, UIStatus} from '../state' @@ -102,15 +102,23 @@ export function Overview({status, onNavigate}: {status: UIStatus; onNavigate: (i {/* The identity surface: the traffic path as three machined stations on Signal Glass, flow streaming between them when live. It breaks the content grid once — running past the column edges — so the - pipeline reads as the page's sculpture. A warn/bad link leaks + pipeline reads as the page's sculpture. The bleed GROWS with the + container (--hero-bleed, tokens.css) rather than switching on at a + breakpoint: the old binary @7xl variant snapped the gutters from + 28px to 8px the moment the window crossed ~1080px, which is what + read as the card resizing and then clipping. A warn/bad link leaks tone-colored light from behind the glass. */} -
+
-
+ {/* Five children, in order: node / conduit / node / conduit / node — + motion.css's ignite cascade addresses the stations by nth-child. + The conduits take a range instead of a fixed 3rem so the stations + get the slack at mid widths instead of truncating their headlines. */} +
} title={isAgent ? 'Local server' : 'Agent link'} @@ -243,10 +251,21 @@ function HealthPanel({status}: {status: UIStatus}) { style={{ color: c, borderColor: `color-mix(in srgb, ${c} 40%, var(--border))`, - background: `color-mix(in srgb, ${c} 10%, transparent)`, + // Catch-light as a padding-box band; an `inset 0 1px 0` specks + // at the corners (glass.css, the rim primitive). backgroundImage, + // not the `background` shorthand — React warns when a style + // object updates a shorthand next to a longhand it subsumes + // (backgroundClip), and this tile re-tints as health changes. + backgroundImage: [ + `linear-gradient(180deg, ${score !== 'unknown' + ? 'color-mix(in srgb, var(--bevel-top) 55%, white)' + : 'var(--bevel-top)'} 0 1px, transparent 1px)`, + `linear-gradient(color-mix(in srgb, ${c} 10%, transparent), color-mix(in srgb, ${c} 10%, transparent))`, + ].join(', '), + backgroundClip: 'padding-box, border-box', boxShadow: score !== 'unknown' - ? `inset 0 1px 0 color-mix(in srgb, var(--bevel-top) 55%, white), 0 0 24px -6px color-mix(in srgb, ${c} 60%, transparent)` - : 'inset 0 1px 0 var(--bevel-top)', + ? `0 0 24px -6px color-mix(in srgb, ${c} 60%, transparent)` + : undefined, }} > @@ -387,7 +406,7 @@ function PipeNode({icon, title, state, headline, detail, extra, pulse}: { }) { const c = segColor[state] return ( -
+
{ let cancelled = false const poll = (n: number) => { @@ -497,7 +520,22 @@ function GatewayPairingStrip() {
{code - ?
} />
+ ?
+ + setShown(s => !s)}> + {shown ? : } + + +
+ } + /> +
: err ? : Generating…} diff --git a/frontend/src/screens/Players.tsx b/frontend/src/screens/Players.tsx index 95b9744..e41b364 100644 --- a/frontend/src/screens/Players.tsx +++ b/frontend/src/screens/Players.tsx @@ -6,7 +6,7 @@ import {Column, DataTable} from '../components/DataTable' import {GeoRank} from '../components/GeoRank' import {IconChevronRight, IconClose, IconPlayers, IconSearch} from '../components/icons' import { - Badge, Button, Card, CopyIcon, EmptyState, LiveDot, MonoChip, Overline, PageHeader, Pill, PillGroup, SegmentedControl, Skeleton, + Badge, Button, Card, CopyIcon, EmptyState, LiveDot, MonoChip, Overline, PageHeader, Pill, PillGroup, SegmentedControl, Skeleton, TextInput, } from '../components/ui' import {useDebounced} from '../hooks' import { @@ -143,14 +143,11 @@ function Wall({status, liveUUIDs, onOpen}: { )}
-
- - setSearch(e.target.value)} - placeholder="Search names…" - aria-label="Search players by name" - className="h-8 w-44 rounded-[var(--r-md)] border border-[var(--border)] bg-[var(--input-bg)] pl-8 pr-2.5 text-xs text-[var(--text)] outline-none transition-colors placeholder:text-[var(--text-3)] focus:border-[var(--accent)]" +
+ } + value={search} onChange={setSearch} + placeholder="Search names…" ariaLabel="Search players by name" />
diff --git a/frontend/src/screens/Settings.tsx b/frontend/src/screens/Settings.tsx index da712d3..4c83185 100644 --- a/frontend/src/screens/Settings.tsx +++ b/frontend/src/screens/Settings.tsx @@ -1,13 +1,13 @@ import {ReactNode, useEffect, useLayoutEffect, useRef, useState} from 'react' import { BrowseMMDB, CreatorAvatar, CreatorInfo, FirewallRepair, FirewallStatus, GeoStatus, GetConfig, - InstallService, OpenConfigDir, OpenCreatorURL, OpenExternal, RegenerateToken, RestartEngine, - SaveSettings, ServiceStatus, UninstallService, + InstallService, OpenConfigDir, OpenCreatorURL, OpenExternal, PairingCode, RegenerateToken, + RestartEngine, SaveSettings, ServiceStatus, UninstallService, } from '../../wailsjs/go/app/App' import {config, geo} from '../../wailsjs/go/models' import { - Badge, Banner, Button, Card, ErrorBanner, Field, FormRow, PageHeader, - SegmentedControl, Select, Skeleton, TextInput, Toggle, WarnWash, + Badge, Banner, Button, Card, CopyButton, ErrorBanner, Field, FormRow, PageHeader, + SegmentedControl, Select, Skeleton, Switch, TextInput, Toggle, WarnWash, } from '../components/ui' import {ExportSetupRow, ImportSetupFlow} from '../components/SetupBackup' import {IconExternal, IconMonitor, IconMoon, IconRefresh, IconSun} from '../components/icons' @@ -18,6 +18,31 @@ import {MotionPref, useMotion} from '../motion' type Cfg = config.Config +// Engine defaults, mirrored from internal/config DefaultConfig. Numeric and +// address fields render an emptied value as a grayed placeholder of the +// default (never a bare "0"), and save() folds empties back to these so a +// cleared field can't fail backend validation. +const DEF = { + controlPort: 8474, + bindAddr: '0.0.0.0', + maxConnsGlobal: 4096, + maxConnsPerIP: 32, + authAttemptsPerMin: 10, + retentionDays: 180, + prometheusAddr: '127.0.0.1:9464', +} as const + +/** numStr renders a staged numeric field: 0 means "cleared" and shows as an + * empty input so the default placeholder reads through. */ +const numStr = (n: number) => (n > 0 ? String(n) : '') + +/** Human labels for the active data-plane transport reported on the tick. */ +const TRANSPORT_LABEL: Record = { + quic: 'QUIC', + 'per-conn': 'Per-connection', + mux: 'Multiplexed', +} + type SectionDef = {id: string; label: string} /** Settings: a scrollspy rail beside regrouped sections. Engine-affecting @@ -33,7 +58,6 @@ export function Settings({status}: {status: UIStatus}) { const sections: SectionDef[] = [ {id: 'appearance', label: 'Appearance'}, - {id: 'behavior', label: 'Behavior'}, {id: 'connection', label: 'Connection'}, ...(!isAgent ? [{id: 'security', label: 'Security'}] : []), {id: 'analytics', label: 'Analytics'}, @@ -81,7 +105,20 @@ export function Settings({status}: {status: UIStatus}) { const save = async () => { setSaving(true); setErr(''); setNote('') try { - await SaveSettings(cfg) + // Cleared fields staged as 0/'' mean "use the default" — fold them back + // before validation so the promise the grayed placeholder makes holds. + const out = config.Config.createFrom(JSON.parse(JSON.stringify(cfg))) + out.Agent.GatewayPort = out.Agent.GatewayPort || DEF.controlPort + out.Gateway.ControlPort = out.Gateway.ControlPort || DEF.controlPort + out.Gateway.MaxConnsGlobal = out.Gateway.MaxConnsGlobal || DEF.maxConnsGlobal + out.Gateway.MaxConnsPerIP = out.Gateway.MaxConnsPerIP || DEF.maxConnsPerIP + out.Gateway.AuthAttemptsPerMin = out.Gateway.AuthAttemptsPerMin || DEF.authAttemptsPerMin + out.Analytics.RetentionDays = out.Analytics.RetentionDays || DEF.retentionDays + if (out.Metrics.PrometheusEnabled && !out.Metrics.PrometheusAddr.trim()) { + out.Metrics.PrometheusAddr = DEF.prometheusAddr + } + await SaveSettings(out) + setCfg(out) if (!attached) { try { await RestartEngine() } catch (e) { setErr(String(e)) } } refreshGeo() setDirty(false) @@ -118,13 +155,6 @@ export function Settings({status}: {status: UIStatus}) { -
- patch(c => { c.UI.MinimizeToTray = v })} - label="Minimize to tray" hint="Keep running in the background when the window closes." /> - patch(c => { c.UI.Autostart = v })} - label="Start on login" hint="Launch proxyforward when you sign in to Windows." /> -
- {isAgent ? (
@@ -132,16 +162,23 @@ export function Settings({status}: {status: UIStatus}) { patch(c => { c.Agent.GatewayHost = v })} placeholder="play.example.com" />
- patch(c => { c.Agent.GatewayPort = parseInt(v, 10) || 0 })} />
- + patch(c => { c.Logging.Level = v })} options={[ @@ -218,16 +268,18 @@ export function Settings({status}: {status: UIStatus}) { {value: 'warn', label: 'Warn'}, {value: 'error', label: 'Error'}, ]} /> -
- patch(c => { c.Logging.FileEnabled = v })} - label="Write to log file" /> -
+ +
+ patch(c => { c.Logging.FileEnabled = v })} + label="Write to log file" /> +
+
patch(c => { c.Metrics.PrometheusEnabled = v })} label="Prometheus endpoint" hint="Expose /metrics for scraping. Off by default." /> {cfg.Metrics.PrometheusEnabled && ( -
patch(c => { c.Metrics.PrometheusAddr = v })} />
)} @@ -258,7 +310,9 @@ export function Settings({status}: {status: UIStatus}) { style={{ ['--bleed' as string]: 'var(--accent)', ['--bleed-strength' as string]: '28%', - boxShadow: 'inset 0 1px 0 var(--bevel-top), inset 0 -1px 0 var(--bevel-bot), 0 0 0 1px color-mix(in srgb, var(--accent) 40%, var(--border-strong)), 0 12px 48px -12px color-mix(in srgb, var(--accent) 55%, transparent), var(--shadow-pop)', + // The bevel pair comes from .pf-menu's background bands now — + // restating it as inset shadows specks the corners (glass.css). + boxShadow: '0 0 0 1px color-mix(in srgb, var(--accent) 40%, var(--border-strong)), 0 12px 48px -12px color-mix(in srgb, var(--accent) 55%, transparent), var(--shadow-pop)', }} > Unsaved changes @@ -440,6 +494,22 @@ function FxRow() { ) } +/** CopyPairingCode: copies the full pairing code (the string an agent pairs + * with — it embeds the token) without having to reveal it on screen. Engine + * mode only: attached, the service owns the config and the code isn't + * readable from here. */ +function CopyPairingCode({attached}: {attached: boolean}) { + const [code, setCode] = useState('') + useEffect(() => { + if (attached) return + let cancelled = false + PairingCode().then(c => { if (!cancelled) setCode(c) }).catch(() => {}) + return () => { cancelled = true } + }, [attached]) + if (attached || !code) return null + return +} + function TokenRotate({attached, onDone}: {attached: boolean; onDone: () => void}) { const [busy, setBusy] = useState(false) const [confirm, setConfirm] = useState(false) @@ -486,7 +556,7 @@ function SystemSection({status}: {status: UIStatus}) { const s = svcDisplay(svc) return ( -
+
@@ -507,14 +577,19 @@ function SystemSection({status}: {status: UIStatus}) { - -
- {status.configPath} + {/* Stacked, not a FormRow: FormRow's control side is shrink-0 and this + was capped at 24rem inside a 44rem column, so a real Windows + path (C:\Users\…\AppData\Roaming\proxyforward\config.toml) still wrapped + hard against a needlessly small box. The path gets the whole column; + a truncated "…" would read as a bug on a value people paste into a + terminal, so it wraps instead. */} +
+
Config file
+
+ {status.configPath}
- - - {status.version} · pid {status.pid} · {status.mode} +
{err &&
setErr('')} />
}
) @@ -562,9 +637,13 @@ function AboutSection({status}: {status: UIStatus}) { {info.name} -
{info.url} · {status.version}
+
{info.url}
+ + + {status.version} · pid {status.pid} · {status.mode} + ) } diff --git a/frontend/src/screens/Traffic.tsx b/frontend/src/screens/Traffic.tsx index 3f6df66..f791fe6 100644 --- a/frontend/src/screens/Traffic.tsx +++ b/frontend/src/screens/Traffic.tsx @@ -217,8 +217,9 @@ function LinkStrip({status}: {status: UIStatus}) {
{title} -
- +
+ {/* --halo-gap: the live dot breathes a 5px ring (motion.css). */} + {headline} {status.peerAddr && ( diff --git a/frontend/src/screens/Tunnels.tsx b/frontend/src/screens/Tunnels.tsx index 36046ad..d4f8ccb 100644 --- a/frontend/src/screens/Tunnels.tsx +++ b/frontend/src/screens/Tunnels.tsx @@ -7,7 +7,7 @@ import { Modal, MonoChip, PageHeader, Select, Skeleton, TextInput, Toggle, } from '../components/ui' import {IconBolt, IconEdit, IconPlus, IconServer, IconTrash, IconTunnels} from '../components/icons' -import {UIStatus} from '../state' +import {BANDWIDTH_SCOPES, scopeLabel, UIStatus} from '../state' type Tunnel = config.Tunnel @@ -21,7 +21,7 @@ function blankTunnel(): Tunnel { return config.Tunnel.createFrom({ ID: newTunnelID(), Name: 'Minecraft', Type: 'tcp', LocalAddr: '127.0.0.1:25565', PublicPort: 25565, Enabled: true, - Options: {MinecraftAware: false, ProxyProtocolV2: false, OfflineMOTD: '', BandwidthLimitMbps: 0}, + Options: {MinecraftAware: false, ProxyProtocolV2: false, OfflineMOTD: '', BandwidthLimitMbps: 0, BandwidthLimitScope: 'combined'}, }) } @@ -118,13 +118,17 @@ export function Tunnels({status}: {status: UIStatus}) { ...(t.Options?.MinecraftAware ? [['Minecraft-aware', 'On'] as [string, string]] : []), ...(t.Options?.ProxyProtocolV2 ? [['PROXY protocol v2', 'On'] as [string, string]] : []), ...(t.Options?.OfflineMOTD ? [['Offline MOTD', t.Options.OfflineMOTD] as [string, string]] : []), - ...((t.Options?.BandwidthLimitMbps ?? 0) > 0 ? [['Bandwidth cap', `${t.Options.BandwidthLimitMbps} Mbps`] as [string, string]] : []), + ...((t.Options?.BandwidthLimitMbps ?? 0) > 0 + ? [['Bandwidth cap', `${t.Options.BandwidthLimitMbps} Mbps${ + (t.Options.BandwidthLimitScope || 'combined') !== 'combined' ? ` · ${scopeLabel(t.Options.BandwidthLimitScope)}` : '' + }`] as [string, string]] + : []), ] return (
-
+
@@ -267,7 +271,7 @@ function TunnelEditor({title, initial, onSave, onCancel}: {
setOpt({MinecraftAware: v})} label="Minecraft-aware" - hint="Poll the server for MOTD, player count and version; sniff usernames for the traffic view." /> + hint="Sniff player usernames from the login handshake for the traffic and players views." /> setOpt({ProxyProtocolV2: v})} label="PROXY protocol v2" hint={<>Send the real client IP to the local server (Paper/Velocity). Mutually exclusive with BungeeCord/Velocity IP-forwarding — enabling both causes ghost errors.} /> @@ -281,10 +285,23 @@ function TunnelEditor({title, initial, onSave, onCancel}: { setOpt({BandwidthLimitMbps: parseInt(v, 10) || 0})} mono /> - - setOpt({BandwidthLimitScope: v})} + options={BANDWIDTH_SCOPES} + disabled={(opt.BandwidthLimitMbps ?? 0) <= 0} + />
+ + + cannot host a pseudo-element, so TextInput puts .pf-control on its + wrapper and lets :has() carry the focus state inward. */ +.pf-control { + background-image: linear-gradient( + var(--light-angle), + rgba(255, 255, 255, 0.1), + rgba(255, 255, 255, 0.03) 40%, + rgba(255, 255, 255, 0) 62%, + rgba(255, 255, 255, 0.05) + ); + backdrop-filter: blur(var(--blur-control)) saturate(140%); + -webkit-backdrop-filter: blur(var(--blur-control)) saturate(140%); +} +.pf-control::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + border: 1px solid transparent; + pointer-events: none; + z-index: 1; + opacity: var(--control-rim-o, 0.8); + background: + linear-gradient(var(--control-edge, transparent), var(--control-edge, transparent)), + linear-gradient( + var(--light-angle), + var(--edge-hi), + var(--edge-mid) 30%, + var(--edge-lo) 60%, + var(--edge-mid) 100% + ), + linear-gradient(180deg, var(--bevel-top), transparent var(--r-md)), + linear-gradient(0deg, var(--bevel-bot), transparent var(--r-md)); + background-origin: border-box; + background-clip: border-area; + transition: opacity 0.2s ease; +} +.pf-control:hover::before { --control-rim-o: 1; } + +/* Focus and open states take the accent through the ring's top layer, so the + lit edge and the focus glow (a box-shadow the consumer owns) agree. :has() + carries it from a wrapped out to the wrapper that owns the ring. */ +.pf-control:focus-visible::before, +.pf-control:has(:focus-visible)::before, +.pf-control[data-open="true"]::before { + --control-edge: var(--accent); + --control-rim-o: 1; +} +.pf-control:disabled::before, +.pf-control[data-disabled="true"]::before { --control-rim-o: 0.35; } + +/* The switch knob: a milled cap riding inside a .pf-control track. Its + catch-light is a background BAND, clipped by the knob's own corner radius in + the same anti-aliasing pass as the fill — an `inset 0 1px 0` would paint the + knob's shape minus its shape offset 1px and speck at the tangent points (see + the rim primitive). The drop shadow stays a box-shadow: it is blurred, so it + has no crisp end. Fill and geometry come from the consumer (ui.tsx Switch, + tokens.css --switch-*). */ +.pf-knob { + background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.45) 0 1px, transparent 1px); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); +} +:root[data-theme="light"] .pf-knob { + background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.9) 0 1px, transparent 1px); + box-shadow: 0 1px 2px rgba(30, 34, 58, 0.25); +} + /* Menu glass: floating option lists (Select). Far more opaque than the other floats — options must stay legible over whatever surface they cover — but still milled: angled fill, bevel pair, deep pop shadow. */ .pf-menu { + /* Bevel pair as padding-box bands, not inset shadows — see .pf-well. */ background: + linear-gradient(180deg, var(--bevel-top) 0 1px, transparent 1px), + linear-gradient(0deg, var(--bevel-bot) 0 1px, transparent 1px), var(--noise-glass), linear-gradient( var(--light-angle), color-mix(in oklab, var(--panel-2) 96%, white), color-mix(in srgb, var(--panel) 96%, transparent) 55% ); + background-clip: padding-box, padding-box, border-box, border-box; backdrop-filter: blur(var(--blur-float)) saturate(150%); -webkit-backdrop-filter: blur(var(--blur-float)) saturate(150%); border: 1px solid var(--border-strong); - box-shadow: - inset 0 1px 0 var(--bevel-top), - inset 0 -1px 0 var(--bevel-bot), - var(--shadow-key), - var(--shadow-pop); + box-shadow: var(--shadow-key), var(--shadow-pop); } /* Frozen glare on menu glass, same optics as the other floats; motion.css @@ -487,7 +682,7 @@ aside.pf-sheet { position: absolute; inset: 0; border-radius: inherit; - padding: 1px; + border: 1px solid transparent; pointer-events: none; background: linear-gradient( var(--light-angle), @@ -496,10 +691,8 @@ aside.pf-sheet { color-mix(in srgb, var(--role-c, var(--accent)) 18%, transparent) 75%, color-mix(in srgb, var(--role-c, var(--accent)) 55%, transparent) ); - -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); - -webkit-mask-composite: xor; - mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); - mask-composite: exclude; + background-origin: border-box; + background-clip: border-area; } /* Chart line bloom: tiny per-series glow (consumed by BandwidthChart via @@ -538,8 +731,33 @@ aside.pf-sheet { -webkit-backdrop-filter: blur(var(--blur-pop)) saturate(120%) brightness(1.04); } :root[data-theme="light"] .pf-signal { - backdrop-filter: blur(var(--blur-card)) saturate(120%) brightness(1.02); - -webkit-backdrop-filter: blur(var(--blur-card)) saturate(120%) brightness(1.02); + /* Ice catches hotter whites: the gloss/arc/streak need far more strength to + read over a bright backdrop (same reasoning as the --arc/--streak tokens). */ + --signal-arc: rgba(255, 255, 255, 0.8); + --signal-streak: rgba(255, 255, 255, 0.55); + --signal-gloss: rgba(255, 255, 255, 0.55); + backdrop-filter: blur(var(--blur-card)) saturate(135%) brightness(1.04); + -webkit-backdrop-filter: blur(var(--blur-card)) saturate(135%) brightness(1.04); +} +/* Frosted ice: the backdrop is already bright, so transmission drops and the + saturation boost stays gentle — a hot one turns the aurora into stained + glass under every card on the page. */ +:root[data-theme="light"] .pf-card { + backdrop-filter: blur(var(--blur-frost)) saturate(115%) brightness(1.02); + -webkit-backdrop-filter: blur(var(--blur-frost)) saturate(115%) brightness(1.02); +} +/* Ice controls: white-on-white won't read, so the film keeps a hot catch at the + light corner and exits through a faint dark seat — same trick as .pf-btn. */ +:root[data-theme="light"] .pf-control { + background-image: linear-gradient( + var(--light-angle), + rgba(255, 255, 255, 0.5), + rgba(255, 255, 255, 0.14) 40%, + rgba(255, 255, 255, 0) 62%, + rgba(25, 28, 38, 0.05) + ); + backdrop-filter: blur(var(--blur-control)) saturate(115%); + -webkit-backdrop-filter: blur(var(--blur-control)) saturate(115%); } /* Ice buttons: white-on-white won't read, so the film keeps a hot catch at the light corner but exits through a faint dark seat — the shadow side @@ -586,9 +804,15 @@ aside.pf-sheet { /* --------------------------------------------------------------------------- Low-fx (Settings → Appearance → "Reduce glass effects", or &fx=low in dev): - Signal Glass falls back to a solid look — zero blur, light model only — - and the moving/filtered garnishes switch off. Chrome keeps its blur (the - old four-surface budget). Quiet .pf-card panels are already blur-free. + the content tiers fall back to a solid look — zero blur, light model only — + and the moving/filtered garnishes switch off. Chrome keeps its blur (the old + four-surface budget). + + Cards matter most here and this is why the hatch exists: .pf-card is now + frosted, and a Players wall or an Analytics page puts DOZENS of blurred + surfaces on screen at once. Every backdrop-filter is a separate backdrop + rasterization; a weak GPU falls off a cliff. Stripping card blur is the + single biggest win in this block — never let it rot. --------------------------------------------------------------------------- */ :root[data-fx="low"] .pf-signal { backdrop-filter: none; @@ -599,6 +823,15 @@ aside.pf-sheet { var(--panel) 50% ); } +:root[data-fx="low"] .pf-card { + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: color-mix(in srgb, var(--panel) 96%, transparent); +} +:root[data-fx="low"] .pf-control { + backdrop-filter: none; + -webkit-backdrop-filter: none; +} :root[data-fx="low"] .pf-caustic { display: none; } :root[data-fx="low"] .pf-chart-glow { filter: none; } :root[data-fx="low"] .pf-chart-glow-hot { filter: none; } @@ -611,3 +844,31 @@ aside.pf-sheet { backdrop-filter: none; -webkit-backdrop-filter: none; } + +/* --------------------------------------------------------------------------- + Rim fallback — engines without `background-clip: border-area` (pre-Chromium + 137). The old XOR ring, quarantined: a 1px band cut by compositing two + independently anti-aliased rounded rects. Their coverage fails to cancel + where the straight edge meets the corner arc, so it drops a bright speck at + the tangent points and stair-steps the arcs at fractional DPI — the feather + is what kept that survivable. Every shipping WebView2 takes the border-area + path above; this exists so an old engine degrades instead of losing its + edges. Delete it once the floor is provably ≥137. +--------------------------------------------------------------------------- */ +@supports not (background-clip: border-area) { + .pf-glass::before, + .pf-island::before, + .pf-signal::before, + .pf-card::before, + .pf-control::before, + .pf-btn::before, + .pf-role-chip::before { + border: 0; + padding: 1px; + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + mask-composite: exclude; + filter: blur(0.4px); + } +} diff --git a/frontend/src/styles/motion.css b/frontend/src/styles/motion.css index 197d26a..638e9dc 100644 --- a/frontend/src/styles/motion.css +++ b/frontend/src/styles/motion.css @@ -270,13 +270,15 @@ main { /* Tactile press: buttons compress under the pointer AND depress into the material — the bevel pair inverts for the press (instant swap, no shadow - animation; only the transform eases). */ + animation; only the transform eases). Both halves carry blur: a crisp + `inset 0 -1px 0` would round the corners as a crescent that ends dead at the + tangent points and specks there (glass.css, the rim primitive). */ .pf-press { transition: transform var(--dur-fast) var(--ease-out); } .pf-press:active:not(:disabled) { transform: scale(0.97); - box-shadow: inset 0 1px 2px var(--bevel-bot), inset 0 -1px 0 var(--bevel-top); + box-shadow: inset 0 1px 2px var(--bevel-bot), inset 0 -1px 1px var(--bevel-top); } /* Expandable sections: animate 0fr -> 1fr, content never reflows mid-flight. */ @@ -288,6 +290,49 @@ main { .pf-expand[data-open="true"] { grid-template-rows: 1fr; } .pf-expand > * { overflow: hidden; min-height: 0; } +/* Rubber-band edge light. While a scroller is banded, rubberband.ts stamps + --band-t (0→1 pull) and data-band=top|bottom straight onto this overlay — + marked data-band-glow, a *sibling* of the scroller so it neither scrolls nor + rides the content's transform. Stamped here, never on the scroller: --band-t + inherits and the scroller's subtree is the whole screen, so stamping there + would restyle every descendant each frame. Material, not signal: --text blooms + bright on dark and shades on light, so both themes read as the page + compressing under the push. Positioning stays with the consumer (Shell, + Activity). No transition: opacity tracks the spring frame by frame, so the + reduced-motion kill switch has nothing to zero — under Animations Off no band + is ever created, data-band never appears, and this stays at opacity 0. */ +.pf-band-glow { + pointer-events: none; + overflow: hidden; + border-radius: inherit; +} +.pf-band-glow::before, +.pf-band-glow::after { + content: ''; + position: absolute; + left: 0; + right: 0; + height: 40%; + max-height: 200px; + opacity: 0; +} +.pf-band-glow::before { + top: 0; + background: radial-gradient(125% 100% at 50% 0%, + color-mix(in srgb, var(--text) 14%, transparent) 0%, + color-mix(in srgb, var(--text) 4%, transparent) 45%, + transparent 75%); +} +.pf-band-glow::after { + bottom: 0; + background: radial-gradient(125% 100% at 50% 100%, + color-mix(in srgb, var(--text) 14%, transparent) 0%, + color-mix(in srgb, var(--text) 4%, transparent) 45%, + transparent 75%); +} +.pf-band-glow[data-band="top"]::before { opacity: var(--band-t, 0); } +.pf-band-glow[data-band="bottom"]::after { opacity: var(--band-t, 0); } + /* View transitions: nav changes animate only the content region; the chrome (sheet, sidebar, title bar) stays pinned. Theme swaps relight the root (pf-relight below); the wizard handover keeps the plain root crossfade. */ diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 43412eb..1c31643 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -53,6 +53,20 @@ initial-value: rgba(251, 191, 36, 0); } +/* The switch's travel is REGISTERED so it resolves to px in getComputedStyle — + an unregistered custom property hands JS back its unsubstituted + `calc(40px * var(--ui-scale) - ...)` text, not a number. It is the only + length ui.tsx's Switch reads: the knob's rest positions are CSS calc()s off + the same token, so a drag converts px of pointer travel into a 0→1 fraction + and nothing else in the drag math is a literal. That is what stops the seats + from desyncing from the track at a --ui-scale step, which is exactly how the + old hard-px seats came to overhang a rem-sized track. */ +@property --switch-travel { + syntax: ""; + inherits: true; + initial-value: 18px; +} + :root { color-scheme: dark; @@ -101,7 +115,7 @@ through opacity, not shadows — background 0, chrome ~0.52, cards ~0.45, inputs/buttons are thin white films over whatever glass they sit on. */ --glass: rgba(13, 16, 24, 0.52); - --glass-card: rgba(16, 19, 27, 0.45); + --glass-card: rgba(16, 19, 27, 0.38); --input-bg: rgba(255, 255, 255, 0.05); --input-bg-hover: rgba(255, 255, 255, 0.08); --btn-bg: rgba(255, 255, 255, 0.08); @@ -114,16 +128,23 @@ --shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.3), 0 8px 24px -12px rgba(0, 0, 0, 0.55); --shadow-pop: 0 24px 64px -16px rgba(0, 0, 0, 0.65); - /* Tier-2 "quiet panel": the standard card material. Near-solid, one subtle - border, one soft shadow — no blur, no reflections, no rim. Signal Glass - (.pf-signal) is the reward; this is the default that recedes around it. */ - --card-2-bg: color-mix(in srgb, var(--panel) 88%, transparent); - --card-2-border: rgba(255, 255, 255, 0.07); + /* Tier-2 "quiet panel": frosted glass. Transmission is deliberately low — + the fill carries most of the surface and the heavy blur (--blur-frost) + only diffuses what passes behind, so body text stays crisp and the panel + still recedes. Signal Glass is the thin, clear, reactive material; this is + the frost that refracts and stays put. Its edge is the rim ring + (glass.css .pf-card::before), so there is no border token. */ + --card-2-bg: color-mix(in srgb, var(--panel) 72%, transparent); --shadow-card-2: 0 1px 2px rgba(0, 0, 0, 0.25), 0 6px 20px -12px rgba(0, 0, 0, 0.4); - /* Blur ladder: physical hierarchy through backdrop depth — cards are the - thinnest glass, chrome is heavier, floats are the thickest. */ - --blur-card: 14px; + /* Blur ladder: physical hierarchy through backdrop depth. Controls are the + thinnest film; Signal Glass is thin and clear (it transmits — you are + meant to see the network living behind it); quiet cards are FROSTED, so + they blur hardest of the content tiers and let the least through; chrome + is heavier still and floats are the thickest. */ + --blur-control: 10px; + --blur-card: 20px; /* Signal Glass — thin and transmissive */ + --blur-frost: 30px; /* .pf-card — heavy frost, low transmission */ --blur-chrome: 36px; --blur-island: 40px; --blur-float: 48px; @@ -170,47 +191,101 @@ /* Precision-milled glass: tightly constrained corners — just enough round to kill the jaggies, never bubble-soft. rounded-full still survives only - on status dots; even toggle knobs are milled (r-sm track, 3px knob). */ + on status dots; even the toggle knob is milled (--switch-knob-r below). */ --r-xs: 4px; --r-sm: 6px; --r-md: 10px; /* inputs, buttons, nav items */ --r-lg: 14px; /* cards, the titlebar island */ --r-xl: 18px; /* modals, command palette */ - /* Type scale. Root stays 13.5px (base.css); these are the only sizes new - UI should use. Contrast BETWEEN levels is the point — metrics dominate, - labels recede. The label recipe: --fs-caption + uppercase + - --tracking-label + weight 600 + --text-3. */ - --fs-hero: 26px; /* page titles */ - --fs-title: 18px; /* section titles, card headlines that ARE sections */ - --fs-metric-hero: 36px; /* the one hero figure per page */ - --fs-metric: 26px; /* standard headline metrics */ - --fs-body: 13.5px; /* default */ - --fs-sub: 12.5px; /* secondary rows, hints */ - --fs-caption: 11px; /* labels, metadata, table headers */ - --fs-micro: 10px; /* kbd chips, overlines */ + /* Resolution scale. One knob: the root em is calc(13.5px * --ui-scale) + (base.css), and every type/rhythm token below multiplies by the same + factor, so rem utilities and px-defined tokens resize in lockstep. + Stepped (not continuous) so text never jiggles during a window resize. + Steps target effective CSS width — WebView2 already applies OS DPI + scaling on top. JS-coupled geometry (--sidebar-w, --nav-item-h) and the + radius/blur/border ladders deliberately do NOT scale. */ + --ui-scale: 1; + + /* Type scale. Values are the 13.5px-root design sizes; --ui-scale carries + them across resolutions. These are the only sizes new UI should use. + Contrast BETWEEN levels is the point — metrics dominate, labels recede. + The label recipe: --fs-caption + uppercase + --tracking-label + + weight 600 + --text-3. */ + --fs-hero: calc(26px * var(--ui-scale)); /* page titles */ + --fs-title: calc(18px * var(--ui-scale)); /* section titles, card headlines that ARE sections */ + --fs-metric-hero: calc(36px * var(--ui-scale)); /* the one hero figure per page */ + --fs-metric: calc(26px * var(--ui-scale)); /* standard headline metrics */ + --fs-body: calc(13.5px * var(--ui-scale)); /* default */ + --fs-sub: calc(12.5px * var(--ui-scale)); /* secondary rows, hints */ + --fs-caption: calc(11px * var(--ui-scale)); /* labels, metadata, table headers */ + --fs-micro: calc(10px * var(--ui-scale)); /* kbd chips, overlines */ --tracking-label: 0.08em; --lh-tight: 1.15; --lh-body: 1.45; - /* 4px spatial rhythm + shared layout geometry. Page rhythm: --sp-12 - between major page groups, --sp-6 within a group, --grid-gap inside - grids, --sp-2 label-to-value. Whitespace is an active design element. */ - --sp-1: 4px; - --sp-2: 8px; - --sp-3: 12px; - --sp-4: 16px; - --sp-5: 20px; - --sp-6: 24px; - --sp-8: 32px; - --sp-10: 40px; - --sp-12: 48px; - --content-max: 1400px; - --page-pad: 28px; - --grid-gap: 16px; + /* 4px spatial rhythm + shared layout geometry, on the same --ui-scale so + whitespace keeps its proportion to type. Page rhythm: --sp-12 between + major page groups, --sp-6 within a group, --grid-gap inside grids, + --sp-2 label-to-value. Whitespace is an active design element. */ + --sp-1: calc(4px * var(--ui-scale)); + --sp-2: calc(8px * var(--ui-scale)); + --sp-3: calc(12px * var(--ui-scale)); + --sp-4: calc(16px * var(--ui-scale)); + --sp-5: calc(20px * var(--ui-scale)); + --sp-6: calc(24px * var(--ui-scale)); + --sp-8: calc(32px * var(--ui-scale)); + --sp-10: calc(40px * var(--ui-scale)); + --sp-12: calc(48px * var(--ui-scale)); + --content-max: calc(1400px * var(--ui-scale)); + --page-pad: calc(28px * var(--ui-scale)); + --grid-gap: calc(16px * var(--ui-scale)); --sidebar-w: 224px; /* Shell grid column — Sidebar px math must agree */ --nav-item-h: 36px; /* Sidebar ITEM_H — the sliding indicator's px math */ + /* One control height for every boxed input: 1px rim + 0.5rem padding + a + 1.25rem line box, each side. Expressed in rem so it rides --ui-scale with + the type it wraps — a Field that pairs a Select with a Switch stands them + both in a box this tall, and they line up at every step. */ + --control-h: calc(2.25rem + 2px); + + /* The switch, defined entirely in scaled px so the geometry is exact at every + --ui-scale step. The old track was rem-sized (h-5 w-9 → 30.4 × 16.9px at + this root, not 36 × 20) while the knob's seats were hard px (2 / 18), so + the knob overhung the track's padding box by ~1.4px at scale 1 and ~2.8px + at 0.9259 — that overhang riding the corner arc was the "pixelated corner". + Everything below derives from --switch-w/--switch-h, so nothing can desync. + + The rim ring is 1px of the track's own edge (glass.css .pf-control), so the + knob's offset from the outer edge is rim + seat, and the same gap has to + come off the knob's height twice. Knob radius stays concentric with the + track: 10px − 1px rim − 2px seat = 7px. Travel then falls out as w − h. */ + --switch-w: calc(40px * var(--ui-scale)); + --switch-h: calc(22px * var(--ui-scale)); + --switch-pad: calc(2px * var(--ui-scale)); /* seat gap inside the rim */ + --switch-seat: calc(1px + var(--switch-pad)); /* outer edge → knob */ + --switch-r: var(--r-md); + --switch-knob: calc(var(--switch-h) - 2 * var(--switch-seat)); + --switch-knob-r: calc(var(--switch-r) - var(--switch-seat)); + --switch-travel: calc(var(--switch-w) - var(--switch-h)); /* seat to seat */ + + /* Halo clearance: .pf-halo breathes a 5px box-shadow ring out of a status + dot, so a label sitting a hair closer than that wears the ring. Every + haloed dot keeps this much air between itself and its text. */ + --halo-gap: var(--sp-3); + + /* The Overview pipeline is the app's one deliberate grid break (DESIGN.md + rule 7): the hero runs out past the content column's edges. That bleed is + CONTINUOUS — it grows with the container instead of snapping on at a + breakpoint, which is what made the card appear to "resize, then clip" as + the window crossed the old @7xl switch. Floor: no bleed at all on a narrow + window, where 8px gutters against 28px everywhere else read as a mistake + rather than a statement. Ceiling: --page-pad − 8px, so however wide the + window gets, the hero never comes closer than 8px to the window edge. + Uses cqw, so it tracks the CONTENT column (App.tsx's @container), not the + viewport — the sidebar's width is already accounted for. */ + --hero-bleed: clamp(0px, (100cqw - 640px) * 0.0455, var(--page-pad) - 8px); + /* Motion. Fast/base stay tight — press and hover feedback must feel immediate; the slow tier is where the luxurious glide lives. */ --dur-fast: 120ms; @@ -223,6 +298,14 @@ --ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1); } +/* --ui-scale steps. Roots land on clean sizes: 12.5 / 13.5 / 15 / 16.5 / 18px. + ~1200-1719px is the design width (scale 1); below is a narrowed window, + above is 1080p fullscreen, then 1440p / 4K@150%, then 4K@100%. */ +@media (max-width: 1149px) { :root { --ui-scale: 0.9259; } } +@media (min-width: 1720px) { :root { --ui-scale: 1.1111; } } +@media (min-width: 2300px) { :root { --ui-scale: 1.2222; } } +@media (min-width: 3200px) { :root { --ui-scale: 1.3333; } } + /* Agent mode: cyan ramp. Cyan is bright — primary buttons flip to dark text. The atmosphere counterweight flips warm (violet) against the cyan wash. */ :root[data-role="agent"] { @@ -283,7 +366,7 @@ --rtt: #0d9488; --glass: rgba(247, 248, 252, 0.6); - --glass-card: rgba(247, 248, 252, 0.55); + --glass-card: rgba(247, 248, 252, 0.48); --input-bg: rgba(255, 255, 255, 0.5); --input-bg-hover: rgba(255, 255, 255, 0.7); --btn-bg: rgba(25, 28, 38, 0.06); @@ -292,9 +375,9 @@ --btn-accent-fill: color-mix(in srgb, var(--accent) 92%, transparent); --btn-accent-fill-hover: color-mix(in srgb, var(--accent-hover) 96%, transparent); --sep: rgba(25, 28, 38, 0.12); - /* Tier-2 quiet panel, ice edition. */ - --card-2-bg: rgba(247, 248, 252, 0.85); - --card-2-border: #dde1ec; + /* Tier-2 frosted panel, ice edition. Ice needs a touch more fill than the + dark theme: a bright backdrop reads straight through a thin white frost. */ + --card-2-bg: rgba(247, 248, 252, 0.76); --shadow-card-2: 0 1px 2px rgba(30, 34, 58, 0.05), 0 6px 20px -12px rgba(30, 34, 58, 0.12); /* Ice catches hotter whites: the arc/streak/caustics need far more strength to read over a bright backdrop. */ diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index a83748e..671fa97 100644 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -1,13 +1,16 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT -import {http} from '../models'; import {stats} from '../models'; +import {http} from '../models'; import {app} from '../models'; +import {gateway} from '../models'; import {analytics} from '../models'; import {geo} from '../models'; import {config} from '../models'; import {logging} from '../models'; +export function AgentBandwidthHistory(arg1:string,arg2:number,arg3:number):Promise; + export function AvatarHandler():Promise; export function BandwidthHistory(arg1:number,arg2:number):Promise; @@ -28,20 +31,30 @@ export function FirewallRepair():Promise; export function FirewallStatus():Promise; +export function GatewayEvents(arg1:number):Promise>; + export function GeoSnapshot(arg1:number):Promise>; export function GeoStatus():Promise; export function GetConfig():Promise; +export function HandleDeepLink(arg1:string):Promise; + export function ImportSetup(arg1:string,arg2:string):Promise; export function InstallService():Promise; +export function IssuePairingCode(arg1:boolean,arg2:number,arg3:Array,arg4:Array):Promise; + +export function ListAgents():Promise>; + export function LogsSince(arg1:number):Promise>; export function MeasureLatency():Promise; +export function OnSecondInstance(arg1:string):Promise; + export function OpenConfigDir():Promise; export function OpenCreatorURL():Promise; @@ -64,8 +77,12 @@ export function Players(arg1:analytics.PlayersQuery):Promise; +export function RenameAgent(arg1:string,arg2:string):Promise; + export function RestartEngine():Promise; +export function RevokeAgent(arg1:string):Promise; + export function SaveSettings(arg1:config.Config):Promise; export function SaveTunnels(arg1:Array):Promise; @@ -76,6 +93,8 @@ export function SessionTimeline(arg1:number):Promise; export function Sessions(arg1:analytics.SessionsQuery):Promise; +export function SetAgentScope(arg1:string,arg2:Array,arg3:Array):Promise; + export function SetTheme(arg1:string):Promise; export function SetupAgent(arg1:string,arg2:string,arg3:number):Promise; @@ -86,6 +105,8 @@ export function Status():Promise; export function Summary(arg1:number):Promise; +export function TakePendingDeepLink():Promise; + export function TestReachability(arg1:string):Promise; export function TunnelUptime(arg1:number):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 72df096..9764e1d 100644 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -2,6 +2,10 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function AgentBandwidthHistory(arg1, arg2, arg3) { + return window['go']['app']['App']['AgentBandwidthHistory'](arg1, arg2, arg3); +} + export function AvatarHandler() { return window['go']['app']['App']['AvatarHandler'](); } @@ -42,6 +46,10 @@ export function FirewallStatus() { return window['go']['app']['App']['FirewallStatus'](); } +export function GatewayEvents(arg1) { + return window['go']['app']['App']['GatewayEvents'](arg1); +} + export function GeoSnapshot(arg1) { return window['go']['app']['App']['GeoSnapshot'](arg1); } @@ -54,6 +62,10 @@ export function GetConfig() { return window['go']['app']['App']['GetConfig'](); } +export function HandleDeepLink(arg1) { + return window['go']['app']['App']['HandleDeepLink'](arg1); +} + export function ImportSetup(arg1, arg2) { return window['go']['app']['App']['ImportSetup'](arg1, arg2); } @@ -62,6 +74,14 @@ export function InstallService() { return window['go']['app']['App']['InstallService'](); } +export function IssuePairingCode(arg1, arg2, arg3, arg4) { + return window['go']['app']['App']['IssuePairingCode'](arg1, arg2, arg3, arg4); +} + +export function ListAgents() { + return window['go']['app']['App']['ListAgents'](); +} + export function LogsSince(arg1) { return window['go']['app']['App']['LogsSince'](arg1); } @@ -70,6 +90,10 @@ export function MeasureLatency() { return window['go']['app']['App']['MeasureLatency'](); } +export function OnSecondInstance(arg1) { + return window['go']['app']['App']['OnSecondInstance'](arg1); +} + export function OpenConfigDir() { return window['go']['app']['App']['OpenConfigDir'](); } @@ -114,10 +138,18 @@ export function RegenerateToken() { return window['go']['app']['App']['RegenerateToken'](); } +export function RenameAgent(arg1, arg2) { + return window['go']['app']['App']['RenameAgent'](arg1, arg2); +} + export function RestartEngine() { return window['go']['app']['App']['RestartEngine'](); } +export function RevokeAgent(arg1) { + return window['go']['app']['App']['RevokeAgent'](arg1); +} + export function SaveSettings(arg1) { return window['go']['app']['App']['SaveSettings'](arg1); } @@ -138,6 +170,10 @@ export function Sessions(arg1) { return window['go']['app']['App']['Sessions'](arg1); } +export function SetAgentScope(arg1, arg2, arg3) { + return window['go']['app']['App']['SetAgentScope'](arg1, arg2, arg3); +} + export function SetTheme(arg1) { return window['go']['app']['App']['SetTheme'](arg1); } @@ -158,6 +194,10 @@ export function Summary(arg1) { return window['go']['app']['App']['Summary'](arg1); } +export function TakePendingDeepLink() { + return window['go']['app']['App']['TakePendingDeepLink'](); +} + export function TestReachability(arg1) { return window['go']['app']['App']['TestReachability'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index e9c9cbd..e6239f2 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -259,6 +259,7 @@ export namespace analytics { export class PlayersQuery { search: string; sort: string; + agentId: string; tunnelId: string; cc: string; offset: number; @@ -272,6 +273,7 @@ export namespace analytics { if ('string' === typeof source) source = JSON.parse(source); this.search = source["search"]; this.sort = source["sort"]; + this.agentId = source["agentId"]; this.tunnelId = source["tunnelId"]; this.cc = source["cc"]; this.offset = source["offset"]; @@ -361,6 +363,7 @@ export namespace analytics { } export class SessionsQuery { playerUuid: string; + agentId: string; tunnelId: string; cc: string; sinceMs: number; @@ -374,6 +377,7 @@ export namespace analytics { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.playerUuid = source["playerUuid"]; + this.agentId = source["agentId"]; this.tunnelId = source["tunnelId"]; this.cc = source["cc"]; this.sinceMs = source["sinceMs"]; @@ -531,8 +535,45 @@ export namespace analytics { export namespace app { + export class AgentUI { + agentId: string; + hostname: string; + lanIps: string[]; + remoteIp: string; + linkUpSinceMs: number; + rttMillis: number; + jitterMillis: number; + packetLossPct: number; + healthScore: string; + linkBytesIn: number; + linkBytesOut: number; + tunnels: number; + players: number; + + static createFrom(source: any = {}) { + return new AgentUI(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.agentId = source["agentId"]; + this.hostname = source["hostname"]; + this.lanIps = source["lanIps"]; + this.remoteIp = source["remoteIp"]; + this.linkUpSinceMs = source["linkUpSinceMs"]; + this.rttMillis = source["rttMillis"]; + this.jitterMillis = source["jitterMillis"]; + this.packetLossPct = source["packetLossPct"]; + this.healthScore = source["healthScore"]; + this.linkBytesIn = source["linkBytesIn"]; + this.linkBytesOut = source["linkBytesOut"]; + this.tunnels = source["tunnels"]; + this.players = source["players"]; + } + } export class ConnUI { id: number; + agentId?: string; tunnelName: string; clientAddr: string; startedAt: number; @@ -549,6 +590,7 @@ export namespace app { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.id = source["id"]; + this.agentId = source["agentId"]; this.tunnelName = source["tunnelName"]; this.clientAddr = source["clientAddr"]; this.startedAt = source["startedAt"]; @@ -610,11 +652,14 @@ export namespace app { } } export class TunnelUI { + agentId?: string; id: string; name: string; publicPort: number; localUp: boolean; localKnown: boolean; + bandwidthLimitMbps: number; + bandwidthLimitScope: string; static createFrom(source: any = {}) { return new TunnelUI(source); @@ -622,11 +667,14 @@ export namespace app { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); + this.agentId = source["agentId"]; this.id = source["id"]; this.name = source["name"]; this.publicPort = source["publicPort"]; this.localUp = source["localUp"]; this.localKnown = source["localKnown"]; + this.bandwidthLimitMbps = source["bandwidthLimitMbps"]; + this.bandwidthLimitScope = source["bandwidthLimitScope"]; } } export class UIStatus { @@ -639,6 +687,7 @@ export namespace app { linkUp: boolean; rttMillis: number; agentConnected: boolean; + transport: string; jitterMillis: number; packetLossPct: number; healthScore: string; @@ -647,6 +696,7 @@ export namespace app { peerPublicIp: string; localLanIps: string[]; peerLanIps: string[]; + agents: AgentUI[]; tunnels: TunnelUI[]; connections: ConnUI[]; totalBytesIn: number; @@ -681,6 +731,7 @@ export namespace app { this.linkUp = source["linkUp"]; this.rttMillis = source["rttMillis"]; this.agentConnected = source["agentConnected"]; + this.transport = source["transport"]; this.jitterMillis = source["jitterMillis"]; this.packetLossPct = source["packetLossPct"]; this.healthScore = source["healthScore"]; @@ -689,6 +740,7 @@ export namespace app { this.peerPublicIp = source["peerPublicIp"]; this.localLanIps = source["localLanIps"]; this.peerLanIps = source["peerLanIps"]; + this.agents = this.convertValues(source["agents"], AgentUI); this.tunnels = this.convertValues(source["tunnels"], TunnelUI); this.connections = this.convertValues(source["connections"], ConnUI); this.totalBytesIn = source["totalBytesIn"]; @@ -737,6 +789,7 @@ export namespace config { ProxyProtocolV2: boolean; OfflineMOTD: string; BandwidthLimitMbps: number; + BandwidthLimitScope: string; static createFrom(source: any = {}) { return new TunnelOptions(source); @@ -748,6 +801,7 @@ export namespace config { this.ProxyProtocolV2 = source["ProxyProtocolV2"]; this.OfflineMOTD = source["OfflineMOTD"]; this.BandwidthLimitMbps = source["BandwidthLimitMbps"]; + this.BandwidthLimitScope = source["BandwidthLimitScope"]; } } export class Tunnel { @@ -800,6 +854,7 @@ export namespace config { CertFingerprint: string; Transport: string; Tunnels: Tunnel[]; + EnrollTicket: string; static createFrom(source: any = {}) { return new AgentConfig(source); @@ -814,6 +869,7 @@ export namespace config { this.CertFingerprint = source["CertFingerprint"]; this.Transport = source["Transport"]; this.Tunnels = this.convertValues(source["Tunnels"], Tunnel); + this.EnrollTicket = source["EnrollTicket"]; } convertValues(a: any, classs: any, asMap: boolean = false): any { @@ -905,6 +961,8 @@ export namespace config { MaxConnsGlobal: number; MaxConnsPerIP: number; AuthAttemptsPerMin: number; + QUICEnabled: boolean; + AcceptSharedToken: boolean; static createFrom(source: any = {}) { return new GatewayConfig(source); @@ -920,6 +978,8 @@ export namespace config { this.MaxConnsGlobal = source["MaxConnsGlobal"]; this.MaxConnsPerIP = source["MaxConnsPerIP"]; this.AuthAttemptsPerMin = source["AuthAttemptsPerMin"]; + this.QUICEnabled = source["QUICEnabled"]; + this.AcceptSharedToken = source["AcceptSharedToken"]; } } export class Config { @@ -970,6 +1030,71 @@ export namespace config { +} + +export namespace gateway { + + export class AgentView { + agentId: string; + nickname: string; + enrolled: boolean; + revoked: boolean; + scopePorts: number[]; + scopeTunnels: string[]; + issuedAtMs: number; + connected: boolean; + hostname: string; + remoteIp: string; + linkUpSinceMs: number; + tunnels: number; + + static createFrom(source: any = {}) { + return new AgentView(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.agentId = source["agentId"]; + this.nickname = source["nickname"]; + this.enrolled = source["enrolled"]; + this.revoked = source["revoked"]; + this.scopePorts = source["scopePorts"]; + this.scopeTunnels = source["scopeTunnels"]; + this.issuedAtMs = source["issuedAtMs"]; + this.connected = source["connected"]; + this.hostname = source["hostname"]; + this.remoteIp = source["remoteIp"]; + this.linkUpSinceMs = source["linkUpSinceMs"]; + this.tunnels = source["tunnels"]; + } + } + export class GatewayEvent { + seq: number; + timeMs: number; + kind: string; + agentId?: string; + tunnelId?: string; + message: string; + requestedPort?: number; + actualPort?: number; + + static createFrom(source: any = {}) { + return new GatewayEvent(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.seq = source["seq"]; + this.timeMs = source["timeMs"]; + this.kind = source["kind"]; + this.agentId = source["agentId"]; + this.tunnelId = source["tunnelId"]; + this.message = source["message"]; + this.requestedPort = source["requestedPort"]; + this.actualPort = source["actualPort"]; + } + } + } export namespace geo { diff --git a/go.mod b/go.mod index b76f2cd..3d9b1ff 100644 --- a/go.mod +++ b/go.mod @@ -13,11 +13,13 @@ require ( github.com/kardianos/service v1.3.0 github.com/oschwald/maxminddb-golang/v2 v2.4.1 github.com/pelletier/go-toml/v2 v2.4.3 + github.com/quic-go/quic-go v0.60.0 github.com/spf13/cobra v1.10.2 github.com/wailsapp/wails/v2 v2.13.0 go.uber.org/goleak v1.3.0 golang.org/x/crypto v0.53.0 golang.org/x/sys v0.47.0 + golang.org/x/time v0.15.0 modernc.org/sqlite v1.53.0 ) diff --git a/go.sum b/go.sum index d5749ed..903f9be 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,10 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= +github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -93,6 +97,8 @@ github.com/wailsapp/wails/v2 v2.13.0 h1:S7OgXWpj72V91unF8iDWJKbcS9ZpwCT3R0QVru4v github.com/wailsapp/wails/v2 v2.13.0/go.mod h1:nVr/wSIEZ7xxKPkzK65mjpKpaOPQI2k4pvLwGR/i4kc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= @@ -115,6 +121,8 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 6a68012..9086b88 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -5,9 +5,11 @@ package agent import ( "context" + "crypto/ed25519" "crypto/tls" "errors" "fmt" + "io" "log/slog" "net" "os" @@ -16,6 +18,7 @@ import ( "sync/atomic" "time" + "proxyforward/internal/bwcap" "proxyforward/internal/config" "proxyforward/internal/conntrack" "proxyforward/internal/control" @@ -49,14 +52,26 @@ const ( // positive, and lands well inside controlIdleTimeout). lossWindow = 32 lossTimeout = 2 * pingInterval + + // transportReprobeAfter is how long the auto ladder parks a transport that + // failed to connect before trying it again (a network change re-probes + // immediately). Long enough that a UDP-blocked network doesn't re-cost a + // handshake every reconnect, short enough to recover without a restart. + transportReprobeAfter = 5 * time.Minute ) // Fatal configuration errors: retrying cannot fix these, so Run returns // instead of hammering the gateway. var ( - ErrBadToken = errors.New("gateway rejected our token — re-pair with the gateway's current pairing code") - ErrAgentConflict = errors.New("gateway already has a different agent connected — each gateway serves one agent identity") + ErrBadToken = errors.New("gateway rejected our token — re-pair with the gateway's current pairing code") + // ErrAgentConflict is retained for back-compat: a current gateway admits + // several agents and never sends agent_conflict, but a legacy single-agent + // gateway still can, and the agent must treat it as fatal rather than retry. + ErrAgentConflict = errors.New("this gateway (older build) already serves a different agent — use a distinct gateway or agent identity") ErrVersion = errors.New("protocol version mismatch — update the older side") + // ErrRevoked: the gateway removed this agent from its per-agent allowlist. + // Fatal — retrying cannot fix it; re-pair to enroll a fresh identity. + ErrRevoked = errors.New("this gateway revoked this agent — re-pair with a fresh code") ) type Agent struct { @@ -99,26 +114,124 @@ type Agent struct { curSession atomic.Pointer[session] // offerCaps overrides the capabilities offered in the hello; nil means - // control.SupportedCapabilities. Tests set an explicit empty slice via - // SetCapabilityOffer to simulate a legacy agent. + // defaultOffer(). Tests set an explicit empty slice via SetCapabilityOffer + // to simulate a legacy agent. offerCaps []string + // activeTransport is the transport the current session is using ("quic" | + // "per-conn" | "mux"); "" while down. Under auto mode it reflects the rung + // the fallback ladder settled on, so the GUI can show what actually connected. + activeTransport atomic.Pointer[string] + // transportCooldown tracks transports the auto ladder has parked after a + // failed connect (transport → re-probe-after time), guarded by ladderMu. + // Cleared on a successful connect of that transport and on network changes. + ladderMu sync.Mutex + transportCooldown map[string]time.Time + // Conns tracks live proxied connections for the GUI. Conns *conntrack.Registry + + // bwLimiters holds each tunnel's shared bandwidth-cap limiters, keyed by + // tunnel ID (unique within one agent). Uncapped tunnels resolve to nil. + bwLimiters *bwcap.Registry + + // sessionCache lets per-conn data connections resume the control + // connection's TLS session, skipping the full handshake on every player + // join. Process-lifetime, shared across the control conn and every data + // conn. The fingerprint pin runs on full handshakes and is inherited by + // resumed ones, so trust is preserved. + sessionCache tls.ClientSessionCache + + // dir is the config directory; the agent's long-term Ed25519 identity key + // (agent_identity.key) lives here. identOnce guards a one-time load of it. + dir string + identOnce sync.Once + identPriv ed25519.PrivateKey + identPub ed25519.PublicKey + identErr error + + // configGen is the gateway-authoritative config generation the agent currently + // holds (CapGatewayConfig); 0 until the first push. Reported in the hello and + // advanced by applyPushedConfig. In-memory in this build — the gateway re-pushes + // on reconnect, and disk persistence rides the optional configPersister below. + configGen atomic.Uint64 + // configSeedNeeded is the gateway's hello_ok answer to "should I seed you?": true + // only on first contact, when the gateway holds no config for this identity. The + // agent seeds iff it is set, so a reconnect never volunteers a set that would race + // the gateway's authoritative push. Set per hello, read by registerTunnels. + configSeedNeeded atomic.Bool + // configPersister, when set by the app, persists a pushed tunnel set + generation + // so it survives a restart. nil keeps the pushed set in memory only. + configPersister atomic.Pointer[ConfigPersister] +} + +// ConfigPersister persists a gateway-pushed tunnel set and its generation to disk so +// it survives a restart. The app wires one; with none set the agent keeps the pushed +// set in memory and the gateway re-pushes on the next reconnect. +type ConfigPersister func(tunnels []config.Tunnel, generation uint64) error + +// SetConfigPersister installs the disk-persistence hook for gateway-pushed config. +func (a *Agent) SetConfigPersister(fn ConfigPersister) { a.configPersister.Store(&fn) } + +func New(cfg *config.Config, dir string, logger *slog.Logger) *Agent { + return &Agent{ + cfg: cfg, + dir: dir, + logger: logger, + Conns: conntrack.NewRegistry(), + bwLimiters: bwcap.NewRegistry(), + sessionCache: tls.NewLRUClientSessionCache(0), + transportCooldown: map[string]time.Time{}, + } +} + +// identity lazily loads (or generates on first run) the agent's long-term Ed25519 +// identity from its config dir; the public key is the canonical identity the +// gateway allowlists, and agt_ is derived from it. +func (a *Agent) identity() (ed25519.PrivateKey, ed25519.PublicKey, error) { + a.identOnce.Do(func() { + a.identPriv, a.identPub, a.identErr = link.LoadOrCreateIdentity(a.dir) + }) + return a.identPriv, a.identPub, a.identErr } -func New(cfg *config.Config, logger *slog.Logger) *Agent { - return &Agent{cfg: cfg, logger: logger, Conns: conntrack.NewRegistry()} +// authFields returns the per-agent identity fields for a hello: the public key, a +// proof-of-possession signature bound to the gateway's pinned fingerprint, and any +// pending enrollment ticket. All empty if the identity can't be loaded, so the +// hello falls back to shared-token auth. +func (a *Agent) authFields() (pub, sig []byte, ticket string) { + priv, pubKey, err := a.identity() + if err != nil { + a.logger.Warn("agent identity unavailable; using shared token", "err", err) + return nil, nil, "" + } + return pubKey, link.SignAgentAuth(priv, a.cfg.Agent.CertFingerprint), a.cfg.Agent.EnrollTicket } // SetCapabilityOffer overrides the capabilities offered in the hello -// exchange; nil restores the default (control.SupportedCapabilities) and an -// explicit empty slice simulates a legacy agent. Call before Run. +// exchange; nil restores the default (defaultOffer) and an explicit empty +// slice simulates a legacy agent. Call before Run. func (a *Agent) SetCapabilityOffer(caps []string) { a.offerCaps = caps } +// offerFor is the capability set to offer when connecting over a specific +// transport. It is transport-independent (tunnel-sync + conn-stats + gateway-config) +// plus per-conn-data only for the per-conn transport — deliberately NOT +// control.SupportedCapabilities, which would offer per-conn regardless and force +// it on every agent. QUIC and mux offer no per-conn-data (QUIC rides the mux data +// plane; the auto ladder computes this per rung). gateway-config is offered by every +// agent but the gateway negotiates it away unless the agent is enrolled (it is keyed +// to a durable identity), so a shared-token agent falls back to tunnel-sync. +func offerFor(transport string) []string { + caps := []string{control.CapTunnelSync, control.CapConnStats, control.CapGatewayConfig} + if transport == config.TransportPerConn { + caps = append(caps, control.CapPerConn) + } + return caps +} + // Run is the blocking entrypoint used by the CLI. -func Run(ctx context.Context, cfg *config.Config, logger *slog.Logger) error { - return New(cfg, logger).Run(ctx) +func Run(ctx context.Context, cfg *config.Config, dir string, logger *slog.Logger) error { + return New(cfg, dir, logger).Run(ctx) } func (a *Agent) LinkUp() bool { return a.linkUp.Load() } @@ -244,7 +357,7 @@ func (a *Agent) Run(ctx context.Context) error { if ctx.Err() != nil { return nil } - if errors.Is(err, ErrBadToken) || errors.Is(err, ErrAgentConflict) || errors.Is(err, ErrVersion) { + if errors.Is(err, ErrBadToken) || errors.Is(err, ErrAgentConflict) || errors.Is(err, ErrVersion) || errors.Is(err, ErrRevoked) { a.logger.Error("giving up: configuration problem", "err", err) return err } @@ -256,105 +369,126 @@ func (a *Agent) Run(ctx context.Context) error { case <-netChanged: a.logger.Info("network changed — reconnecting now") backoff.Reset() + a.clearAllCooldowns() // a new network may unblock a parked transport (e.g. UDP) case <-ctx.Done(): return nil } } } -// runSession performs one full connect → serve cycle and returns why the -// session ended. -func (a *Agent) runSession(ctx context.Context) error { +// dialGateway opens a TCP+TLS connection to the gateway's control port: DNS is +// re-resolved every call, Nagle is off, and the TLS config carries the shared +// client-session cache so per-conn data connections can resume the control +// session instead of doing a full handshake per player. Used by runSession for +// the control conn and by dialBackData for each data conn. +func (a *Agent) dialGateway(ctx context.Context) (*tls.Conn, error) { addr := net.JoinHostPort(a.cfg.Agent.GatewayHost, strconv.Itoa(a.cfg.Agent.GatewayPort)) - dialer := &net.Dialer{Timeout: dialTimeout} - rawConn, err := dialer.DialContext(ctx, "tcp", addr) // resolves DNS per attempt + rawConn, err := (&net.Dialer{Timeout: dialTimeout}).DialContext(ctx, "tcp", addr) if err != nil { - return fmt.Errorf("dial gateway %s: %w", addr, err) + return nil, fmt.Errorf("dial gateway %s: %w", addr, err) } if tcp, ok := rawConn.(*net.TCPConn); ok { tcp.SetNoDelay(true) } - conn := tls.Client(rawConn, link.AgentTLSConfig(a.cfg.Agent.CertFingerprint)) - defer conn.Close() + cfg := link.AgentTLSConfig(a.cfg.Agent.CertFingerprint) + cfg.ClientSessionCache = a.sessionCache + return tls.Client(rawConn, cfg), nil +} - // Hello exchange, pre-mux, under one deadline. - offer := a.offerCaps - if offer == nil { - offer = control.SupportedCapabilities - } - conn.SetDeadline(time.Now().Add(helloTimeout)) - hn, _ := os.Hostname() - err = control.WriteMsg(conn, control.TypeHello, control.Hello{ - ProtocolVersion: control.ProtocolVersion, - Kind: control.KindControl, - AgentID: a.cfg.Agent.AgentID, - Token: a.cfg.Agent.Token, - AppVersion: version.String(), - Capabilities: offer, - Hostname: hn, - LocalIPs: netid.LocalIPv4s(), - }) - if err != nil { - return fmt.Errorf("send hello: %w", err) - } - env, err := control.ReadMsg(conn, control.PreAuthMaxFrame) - if err != nil { - return fmt.Errorf("read hello reply: %w", err) +// runSession performs one full connect → serve cycle and returns why the +// session ended. It picks a transport-specific connector (each yields an +// established transport.Session, its control stream, and the negotiated caps), +// then runs the shared serve tail. The two connectors differ only in where the +// hello rides: yamux does it pre-mux on the raw TLS conn; QUIC does it on the +// control stream of an already-handshaked session. +func (a *Agent) runSession(ctx context.Context) error { + if a.cfg.Agent.Transport == config.TransportAuto { + return a.runSessionAuto(ctx) } - var caps control.CapSet - switch env.Type { - case control.TypeHelloOK: - ok, err := control.Decode[control.HelloOK](env) - if err != nil { + _, err := a.runSessionWith(ctx, a.cfg.Agent.Transport) + return err +} + +// transportPreference is the auto ladder, best-isolation first: QUIC (one UDP +// connection, per-stream flow control) → per-conn (dedicated TCP per player, +// works where UDP is blocked) → mux (one TCP, max compatibility). +var transportPreference = []string{config.TransportQUIC, config.TransportPerConn, config.TransportMux} + +// runSessionAuto walks the fallback ladder for one connect cycle. It tries each +// non-cooled transport in preference order; a transport that *connects* is used +// (and its session served until it ends, at which point Run backs off and the +// ladder re-evaluates). A transport that fails to *connect* falls through +// immediately to the next. The UDP-blocked heuristic: a transport that failed to +// connect is only cooled once a LATER one succeeds — if every transport fails +// the link is simply down, so nothing is cooled and the full ladder is retried. +func (a *Agent) runSessionAuto(ctx context.Context) error { + var failedBefore []string + var lastErr error + for _, tr := range a.ladderOrder() { + connected, err := a.runSessionWith(ctx, tr) + if connected { + a.coolTransports(failedBefore) // the ones UDP/TCP-blocked before this success + a.clearCooldown(tr) return err } - caps = control.NewCapSet(ok.Capabilities) - a.peer.Store(&peerIdentity{hostname: ok.Hostname, localIPs: ok.LocalIPs, observedIP: ok.ObservedIP}) - a.logger.Info("connected to gateway", "gateway", addr, "generation", ok.Generation, "gateway_version", ok.AppVersion, "gateway_host", ok.Hostname, "observed_ip", ok.ObservedIP, "capabilities", ok.Capabilities) - case control.TypeHelloErr: - he, err := control.Decode[control.HelloErr](env) - if err != nil { + if ctx.Err() != nil { return err } - switch he.Code { - case control.ErrCodeBadToken: - return fmt.Errorf("%w (gateway said: %s)", ErrBadToken, he.Message) - case control.ErrCodeAgentConflict: - return fmt.Errorf("%w (gateway said: %s)", ErrAgentConflict, he.Message) - case control.ErrCodeVersion: - return fmt.Errorf("%w (gateway said: %s)", ErrVersion, he.Message) - default: - return fmt.Errorf("gateway refused connection: %s: %s", he.Code, he.Message) + if isFatal(err) { + return err // bad token/version/conflict — no other transport will help } - default: - return fmt.Errorf("unexpected reply to hello: %q", env.Type) + a.logger.Warn("transport failed to connect — trying next", "transport", tr, "err", err) + lastErr = err + failedBefore = append(failedBefore, tr) } - conn.SetDeadline(time.Time{}) + return lastErr +} - // Count every byte crossing the link from here on (yamux framing and - // control chatter included) — the "agent ↔ gateway" hop the GUI shows. +// runSessionWith performs one connect+serve cycle over a specific transport. The +// bool reports whether the transport *connected* (got past the hello): false +// means the caller (the auto ladder) may fall through to another transport; true +// means the session served and this is a normal disconnect to back off from. +func (a *Agent) runSessionWith(ctx context.Context, tr string) (bool, error) { + // Count every byte crossing the link (framing and control chatter included) — + // the "agent ↔ gateway" hop the GUI shows. The counter is live for the whole + // session; peer identity is cleared on any exit. sessCounters := &stats.LinkCounters{} a.linkSession.Store(sessCounters) defer a.linkSession.Store(nil) + defer a.peer.Store(nil) - mux, err := transport.NewMuxClient(stats.NewCountingConn(conn, &a.linkTotals, sessCounters)) - if err != nil { - return err + offer := a.offerCaps + if offer == nil { + offer = offerFor(tr) } - defer mux.Close() - ctrl, err := mux.OpenStream() + var ( + mux transport.Session + ctrl transport.Stream + caps control.CapSet + err error + ) + switch tr { + case config.TransportQUIC: + mux, ctrl, caps, err = a.connectQUIC(ctx, sessCounters, offer) + default: + mux, ctrl, caps, err = a.connectMux(ctx, sessCounters, offer) + } if err != nil { - return fmt.Errorf("open control stream: %w", err) + return false, err // connect/handshake failed — not serving } + defer mux.Close() defer ctrl.Close() - sess := &session{agent: a, mux: mux, ctrl: ctrl, caps: caps, quality: linkquality.New(lossWindow)} + active := tr + a.activeTransport.Store(&active) + defer a.activeTransport.Store(nil) + + sess := &session{agent: a, mux: mux, ctrl: ctrl, caps: caps, quality: linkquality.New(lossWindow), linkCounters: sessCounters} a.curSession.Store(sess) defer a.curSession.Store(nil) - defer a.peer.Store(nil) if err := sess.registerTunnels(); err != nil { - return err + return true, err // connected, so no fallback; Run backs off } a.linkUp.Store(true) a.linkUpSinceMs.Store(time.Now().UnixMilli()) @@ -362,7 +496,208 @@ func (a *Agent) runSession(ctx context.Context) error { a.linkUp.Store(false) a.linkUpSinceMs.Store(0) }() - return sess.serve(ctx) + return true, sess.serve(ctx) +} + +// ladderOrder returns the auto preference list with any transport still inside +// its post-failure cooldown dropped. If everything is cooled (shouldn't happen — +// mux is never cooled), it returns the full list so a connect is always tried. +func (a *Agent) ladderOrder() []string { + a.ladderMu.Lock() + defer a.ladderMu.Unlock() + now := time.Now() + out := make([]string, 0, len(transportPreference)) + for _, tr := range transportPreference { + if until, cooled := a.transportCooldown[tr]; cooled && now.Before(until) { + continue + } + out = append(out, tr) + } + if len(out) == 0 { + return transportPreference + } + return out +} + +func (a *Agent) coolTransports(trs []string) { + if len(trs) == 0 { + return + } + a.ladderMu.Lock() + defer a.ladderMu.Unlock() + until := time.Now().Add(transportReprobeAfter) + for _, tr := range trs { + a.transportCooldown[tr] = until + a.logger.Info("cooling transport after failed connect — will re-probe", "transport", tr, "after", transportReprobeAfter) + } +} + +func (a *Agent) clearCooldown(tr string) { + a.ladderMu.Lock() + defer a.ladderMu.Unlock() + delete(a.transportCooldown, tr) +} + +// clearAllCooldowns re-arms every transport for an immediate re-probe — called on +// a network change, where a previously-blocked transport (e.g. UDP) may now work. +func (a *Agent) clearAllCooldowns() { + a.ladderMu.Lock() + defer a.ladderMu.Unlock() + clear(a.transportCooldown) +} + +// ActiveTransport reports the transport the current session is using ("quic" | +// "per-conn" | "mux"), or "" while the link is down. +func (a *Agent) ActiveTransport() string { + if p := a.activeTransport.Load(); p != nil { + return *p + } + return "" +} + +// isFatal reports whether an error means retrying (on any transport) is futile. +func isFatal(err error) bool { + return errors.Is(err, ErrBadToken) || errors.Is(err, ErrAgentConflict) || errors.Is(err, ErrVersion) || errors.Is(err, ErrRevoked) +} + +// connectMux dials the gateway over TCP+TLS, performs the pre-mux hello exchange +// on the raw conn, then wraps it in a yamux client session and opens the control +// stream. The returned session owns the conn (its Close closes it). +func (a *Agent) connectMux(ctx context.Context, sessCounters *stats.LinkCounters, offer []string) (transport.Session, transport.Stream, control.CapSet, error) { + conn, err := a.dialGateway(ctx) + if err != nil { + return nil, nil, nil, err + } + caps, err := a.helloExchange(conn, conn.RemoteAddr().String(), offer) + if err != nil { + conn.Close() + return nil, nil, nil, err + } + mux, err := transport.NewMuxClient(stats.NewCountingConn(conn, &a.linkTotals, sessCounters)) + if err != nil { + conn.Close() + return nil, nil, nil, err + } + ctrl, err := mux.OpenStream() + if err != nil { + mux.Close() // closes the underlying conn too + return nil, nil, nil, fmt.Errorf("open control stream: %w", err) + } + return mux, ctrl, caps, nil +} + +// connectQUIC dials the gateway over QUIC (the QUIC handshake carries TLS), opens +// the control stream, and does the hello exchange on it — the gateway accepts +// that first stream as the control stream. The returned session owns its UDP +// socket and transport (its Close closes both). +func (a *Agent) connectQUIC(ctx context.Context, sessCounters *stats.LinkCounters, offer []string) (transport.Session, transport.Stream, control.CapSet, error) { + sess, err := a.dialGatewayQUIC(ctx, sessCounters) + if err != nil { + return nil, nil, nil, err + } + ctrl, err := sess.OpenStream() + if err != nil { + sess.Close() + return nil, nil, nil, fmt.Errorf("open control stream: %w", err) + } + caps, err := a.helloExchange(ctrl, sess.RemoteAddr().String(), offer) + if err != nil { + sess.Close() + return nil, nil, nil, err + } + return sess, ctrl, caps, nil +} + +// dialGatewayQUIC opens a client QUIC connection to the gateway over a fresh +// ephemeral UDP socket. Nagle has no analogue (QUIC paces itself); DNS is +// re-resolved inside DialQUIC. One socket carries one session, so both process +// and per-session link bytes are exact. DialQUIC closes the socket on failure. +func (a *Agent) dialGatewayQUIC(ctx context.Context, sessCounters *stats.LinkCounters) (transport.Session, error) { + udp, err := net.ListenUDP("udp", nil) // ephemeral local socket + if err != nil { + return nil, fmt.Errorf("quic local socket: %w", err) + } + addr := net.JoinHostPort(a.cfg.Agent.GatewayHost, strconv.Itoa(a.cfg.Agent.GatewayPort)) + return transport.DialQUIC(ctx, udp, addr, link.AgentTLSConfig(a.cfg.Agent.CertFingerprint), &a.linkTotals, sessCounters) +} + +// helloConn is the minimal surface the hello exchange needs: it reads and writes +// frames under one deadline. Both *tls.Conn (mux) and transport.Stream (quic) +// satisfy it, so a single hello exchange serves both transports. +type helloConn interface { + io.Reader + io.Writer + SetDeadline(time.Time) error +} + +// helloExchange sends the agent's hello and processes the gateway's reply under +// one deadline, returning the negotiated capability set. remoteAddr is only for +// the connected-gateway log line. On success it records the gateway's identity +// (a.peer) and clears the deadline; a fatal HelloErr maps to a sentinel error so +// Run stops instead of retry-hammering. +func (a *Agent) helloExchange(rw helloConn, remoteAddr string, offer []string) (control.CapSet, error) { + rw.SetDeadline(time.Now().Add(helloTimeout)) + hn, _ := os.Hostname() + pub, sig, ticket := a.authFields() + // Report our gateway-config view only when offering the capability, so an agent + // that doesn't participate sends a hello byte-identical to a legacy peer. + var cfgHash string + var cfgGen uint64 + if control.NewCapSet(offer).Has(control.CapGatewayConfig) { + cfgHash, cfgGen = a.configState() + } + if err := control.WriteMsg(rw, control.TypeHello, control.Hello{ + ProtocolVersion: control.ProtocolVersion, + Kind: control.KindControl, + AgentID: a.cfg.Agent.AgentID, + Token: a.cfg.Agent.Token, + AppVersion: version.String(), + Capabilities: offer, + Hostname: hn, + LocalIPs: netid.LocalIPv4s(), + AgentPubKey: pub, + AgentSig: sig, + EnrollTicket: ticket, + ConfigHash: cfgHash, + ConfigGeneration: cfgGen, + }); err != nil { + return nil, fmt.Errorf("send hello: %w", err) + } + env, err := control.ReadMsg(rw, control.PreAuthMaxFrame) + if err != nil { + return nil, fmt.Errorf("read hello reply: %w", err) + } + switch env.Type { + case control.TypeHelloOK: + ok, err := control.Decode[control.HelloOK](env) + if err != nil { + return nil, err + } + a.peer.Store(&peerIdentity{hostname: ok.Hostname, localIPs: ok.LocalIPs, observedIP: ok.ObservedIP}) + a.configSeedNeeded.Store(ok.ConfigSeedNeeded) + a.logger.Info("connected to gateway", "gateway", remoteAddr, "generation", ok.SessionGeneration, "gateway_version", ok.AppVersion, "gateway_host", ok.Hostname, "observed_ip", ok.ObservedIP, "capabilities", ok.Capabilities) + rw.SetDeadline(time.Time{}) + return control.NewCapSet(ok.Capabilities), nil + case control.TypeHelloErr: + he, err := control.Decode[control.HelloErr](env) + if err != nil { + return nil, err + } + switch he.Code { + case control.ErrCodeBadToken: + return nil, fmt.Errorf("%w (gateway said: %s)", ErrBadToken, he.Message) + case control.ErrCodeAgentConflict: + return nil, fmt.Errorf("%w (gateway said: %s)", ErrAgentConflict, he.Message) + case control.ErrCodeVersion: + return nil, fmt.Errorf("%w (gateway said: %s)", ErrVersion, he.Message) + case control.ErrCodeRevoked: + return nil, fmt.Errorf("%w (gateway said: %s)", ErrRevoked, he.Message) + default: + return nil, fmt.Errorf("gateway refused connection: %s: %s", he.Code, he.Message) + } + default: + return nil, fmt.Errorf("unexpected reply to hello: %q", env.Type) + } } // session is one live connection's state and goroutines. @@ -370,6 +705,10 @@ type session struct { agent *Agent mux transport.Session ctrl transport.Stream + // ctx is the session's serve ctx, cancelled when the session dies. Each + // splice is parented on it so a throttled WaitN unblocks promptly on + // teardown instead of waiting out its rate delay. + ctx context.Context // caps is the capability set negotiated in the hello exchange; immutable // for the session's lifetime. caps control.CapSet @@ -384,6 +723,11 @@ type session struct { // probe, when non-nil, is an in-flight on-demand latency measurement that // steals matching pongs; see probe.go. probe atomic.Pointer[linkquality.ProbeCollector] + + // linkCounters counts this session's link bytes — the control conn and + // every per-conn data conn — mirrored into a.linkTotals. Without counting + // the data conns the GUI's link card would under-report per-conn payload. + linkCounters *stats.LinkCounters } // Has reports whether the session negotiated a capability. @@ -397,17 +741,32 @@ func (s *session) write(msgType string, payload any) error { return control.WriteMsg(s.ctrl, msgType, payload) } -// specFromTunnel builds the wire spec the gateway needs; agent-side options -// (PP2, Minecraft awareness, bandwidth caps) stay local. +// specFromTunnel builds the wire spec the gateway needs; the bandwidth cap +// travels so the gateway can throttle its half too, while purely agent-local +// options (PP2) stay local. func specFromTunnel(t config.Tunnel) control.TunnelSpec { return control.TunnelSpec{ - ID: t.ID, - Name: t.Name, - Type: t.Type, - PublicPort: t.PublicPort, - OfflineMOTD: t.Options.OfflineMOTD, - MinecraftAware: t.Options.MinecraftAware, + ID: t.ID, + Name: t.Name, + Type: t.Type, + PublicPort: t.PublicPort, + OfflineMOTD: t.Options.OfflineMOTD, + MinecraftAware: t.Options.MinecraftAware, + BandwidthLimitMbps: t.Options.BandwidthLimitMbps, + BandwidthLimitScope: t.Options.BandwidthLimitScope, + } +} + +// enabledSpecs is the wire form of the enabled tunnels in a set — the shared +// desired-state payload for SyncTunnels, ProposeConfig, and the hello's config hash. +func enabledSpecs(tunnels []config.Tunnel) []control.TunnelSpec { + specs := make([]control.TunnelSpec, 0, len(tunnels)) + for _, t := range tunnels { + if t.Enabled { + specs = append(specs, specFromTunnel(t)) + } } + return specs } // syncTunnels sends the full desired tunnel set in one frame (CapTunnelSync @@ -415,13 +774,31 @@ func specFromTunnel(t config.Tunnel) control.TunnelSpec { // SyncResult carrying per-tunnel outcomes. func (s *session) syncTunnels(tunnels []config.Tunnel) error { seq := s.syncSeq.Add(1) - specs := make([]control.TunnelSpec, 0, len(tunnels)) + return s.write(control.TypeSyncTunnels, control.SyncTunnels{Seq: seq, Tunnels: enabledSpecs(tunnels)}) +} + +// proposeConfig promotes the agent's local tunnel set to a gateway-authoritative +// gateway (CapGatewayConfig): a first-contact seed, or a user-promoted edit. The +// gateway validates, adopts, bumps the generation, and pushes the resolved set back. +func (s *session) proposeConfig(tunnels []config.Tunnel) error { + return s.write(control.TypeProposeConfig, control.ProposeConfig{Tunnels: enabledSpecs(tunnels)}) +} + +// configState is the agent's current gateway-config view for a hello: the content +// hash of its enabled tunnel set and the generation it last applied. +func (a *Agent) configState() (string, uint64) { + return control.HashTunnels(enabledSpecs(a.snapshotTunnels())), a.configGen.Load() +} + +// seedPublicPorts publishes the concrete public ports already recorded in the agent's +// tunnels, so a gateway-config session that stays in sync (no push) still surfaces +// them to the GUI without waiting for a frame. +func (a *Agent) seedPublicPorts(tunnels []config.Tunnel) { for _, t := range tunnels { - if t.Enabled { - specs = append(specs, specFromTunnel(t)) + if t.Enabled && t.PublicPort != 0 { + a.publicPorts.Store(t.ID, t.PublicPort) } } - return s.write(control.TypeSyncTunnels, control.SyncTunnels{Seq: seq, Tunnels: specs}) } func (s *session) registerTunnels() error { @@ -429,6 +806,17 @@ func (s *session) registerTunnels() error { if len(tunnels) == 0 { s.agent.logger.Warn("no enabled tunnels in config — connected but idle") } + if s.Has(control.CapGatewayConfig) { + // Gateway-authoritative: the gateway owns the desired set. It tells us in the + // hello_ok whether to seed (first contact); otherwise it reconciles its set and + // pushes only on drift, so we just reflect the concrete ports we already hold. + if s.agent.configSeedNeeded.Load() { + s.agent.logger.Info("seeding gateway with local tunnel set", "enabled", len(enabledSpecs(tunnels))) + return s.proposeConfig(tunnels) + } + s.agent.seedPublicPorts(tunnels) + return nil + } if s.Has(control.CapTunnelSync) { if err := s.syncTunnels(tunnels); err != nil { return fmt.Errorf("sync tunnels: %w", err) @@ -446,6 +834,12 @@ func (s *session) registerTunnels() error { // serve pumps the session until it dies: a reader goroutine dispatches // control frames, the main loop drives pings and accepts data streams. func (s *session) serve(ctx context.Context) error { + // Parent each splice on a ctx cancelled when this session ends, so a + // throttled WaitN unblocks promptly on teardown instead of waiting out its + // rate delay. + sctx, cancel := context.WithCancel(ctx) + s.ctx = sctx + defer cancel() errCh := make(chan error, 3) // Route health transitions to the gateway for this session's lifetime, @@ -476,7 +870,10 @@ func (s *session) serve(ctx context.Context) error { } }() - // Data stream acceptor. + // Data stream acceptor. Feeds the same handleDataStream sink as the per-conn + // dial-back path. Not mode-gated: under per-conn transport the gateway never + // opens data streams, so this simply blocks on AcceptStream for the session's + // life — left unconditional so a mux-opened stream is always served. go func() { for { st, err := s.mux.AcceptStream() @@ -582,6 +979,16 @@ func (s *session) handleControlMsg(env *control.Envelope) error { } return nil + case control.TypePushConfig: + // Gateway-authoritative config: replace our enabled set with the gateway's, + // then confirm the generation so a later propose is checked against it. + pc, err := control.Decode[control.PushConfig](env) + if err != nil { + return err + } + s.agent.applyPushedConfig(pc.Tunnels, pc.Generation) + return s.write(control.TypeConfigAck, control.ConfigAck{Generation: pc.Generation, Hash: pc.Hash}) + case control.TypeConnStats: cs, err := control.Decode[control.ConnStats](env) if err != nil { @@ -603,6 +1010,16 @@ func (s *session) handleControlMsg(env *control.Envelope) error { } return nil + case control.TypeOpenData: + od, err := control.Decode[control.OpenData](env) + if err != nil { + return err + } + // Dial back a dedicated data connection for this player. Spawn it so a + // slow dial never blocks the control reader (which owns liveness). + go s.dialBackData(od.ConnID) + return nil + default: s.agent.logger.Debug("ignoring unknown control message", "type", env.Type) return nil @@ -610,8 +1027,10 @@ func (s *session) handleControlMsg(env *control.Envelope) error { } // handleDataStream serves one proxied client connection: read the OpenConn -// header, dial the local backend, splice. -func (s *session) handleDataStream(st transport.Stream) { +// header, dial the local backend, splice. The leg is a relay.Conn — a mux +// stream under mux transport, or a byte-counted per-conn data conn under +// per-conn transport — acquired by the caller (accept loop or dialBackData). +func (s *session) handleDataStream(st relay.Conn) { defer st.Close() st.SetReadDeadline(time.Now().Add(openConnTimeout)) env, err := control.ReadMsg(st, control.MaxFrame) @@ -656,7 +1075,7 @@ func (s *session) handleDataStream(st transport.Stream) { // connection for control-link RTT reports; an old gateway sends "" and // EntryByConnKey("") already guards. Passed into Open (never written // post-hoc) because the control goroutine reads ConnKey concurrently. - entry, closeEntry := s.agent.Conns.Open(tun.ID, tun.Name, oc.ClientAddr, oc.ConnID, false) + entry, closeEntry := s.agent.Conns.Open(s.agent.cfg.Agent.AgentID, tun.ID, tun.Name, oc.ClientAddr, oc.ConnID, false) defer closeEntry() // Minecraft-aware tunnels sniff the client's login handshake (which flows @@ -667,12 +1086,59 @@ func (s *session) handleDataStream(st transport.Stream) { if tun.Options.MinecraftAware { src = mcsniff.Tap(st, entry) } - if err := relay.Splice(tcp, src, entry.Counters); err != nil { + // Splice(local, stream): AToB is local→client (outbound), BToA is + // client→local (inbound). Parent on the session ctx so teardown cancels a + // throttled WaitN. + inbound, outbound := s.agent.bwLimiters.Resolve(tun.ID, tun.Options.BandwidthLimitMbps, tun.Options.BandwidthLimitScope) + opts := relay.SpliceOpts{Ctx: s.ctx, LimitAToB: outbound, LimitBToA: inbound} + if err := relay.Splice(tcp, src, entry.Counters, opts); err != nil { s.agent.logger.Debug("splice ended with error", "client", oc.ClientAddr, "err", err) } s.agent.logger.Debug("client disconnected", "tunnel", tun.Name, "client", oc.ClientAddr) } +// dialBackData opens a fresh KindData connection to the gateway for one player, +// identified by connID, and serves it like any other data stream. The gateway +// matches connID to the waiting player, then writes the OpenConn header this +// reads via handleDataStream. No HelloOK is expected on a data conn — the agent +// goes straight to reading OpenConn; a rejected dial-back simply fails that +// read. Per-conn transport only; runs in its own goroutine off the control +// reader. +func (s *session) dialBackData(connID string) { + conn, err := s.agent.dialGateway(s.ctx) + if err != nil { + s.agent.logger.Debug("per-conn dial-back failed", "conn_id", connID, "err", err) + return + } + conn.SetDeadline(time.Now().Add(helloTimeout)) + pub, sig, _ := s.agent.authFields() + err = control.WriteMsg(conn, control.TypeHello, control.Hello{ + ProtocolVersion: control.ProtocolVersion, + Kind: control.KindData, + AgentID: s.agent.cfg.Agent.AgentID, + Token: s.agent.cfg.Agent.Token, + ConnID: connID, + AgentPubKey: pub, + AgentSig: sig, + }) + if err != nil { + s.agent.logger.Debug("per-conn data hello failed", "conn_id", connID, "err", err) + conn.Close() + return + } + conn.SetDeadline(time.Time{}) + // Count this data conn's bytes into the session + process link totals, just + // as the counting conn under the control mux does. The wrapper preserves + // CloseWrite (the inner *tls.Conn has it), so it is a valid relay.Conn. + wrapped := stats.NewCountingConn(conn, &s.agent.linkTotals, s.linkCounters) + rc, ok := wrapped.(relay.Conn) + if !ok { + conn.Close() + return + } + s.handleDataStream(rc) +} + // writeProxyHeader prepends a PROXY protocol v2 header carrying the real // client address (src) and the local server address (dst) before any tunnel // bytes flow. clientAddr is an IP:port literal from the gateway (never a diff --git a/internal/agent/health_test.go b/internal/agent/health_test.go index ddfdc00..f5479db 100644 --- a/internal/agent/health_test.go +++ b/internal/agent/health_test.go @@ -44,7 +44,7 @@ func TestHealthCheckerTransitions(t *testing.T) { ID: "t1", Name: "test", Type: config.TunnelTCP, LocalAddr: ln.Addr().String(), PublicPort: 25565, Enabled: true, }} - a := New(cfg, slog.New(slog.DiscardHandler)) + a := New(cfg, t.TempDir(), slog.New(slog.DiscardHandler)) a.healthInterval = 30 * time.Millisecond a.healthDialTimeout = 500 * time.Millisecond diff --git a/internal/agent/hotapply.go b/internal/agent/hotapply.go index 09d0d51..bb82146 100644 --- a/internal/agent/hotapply.go +++ b/internal/agent/hotapply.go @@ -28,6 +28,7 @@ func (a *Agent) ApplyTunnels(tunnels []config.Tunnel) { if _, ok := newEnabled[id]; !ok { a.publicPorts.Delete(id) a.localUp.Delete(id) + a.bwLimiters.Release(id) } } @@ -37,6 +38,17 @@ func (a *Agent) ApplyTunnels(tunnels []config.Tunnel) { return } + if sess.Has(control.CapGatewayConfig) { + // Gateway-authoritative: a local edit is a proposal the gateway adopts, + // bumps, and pushes back (applyPushedConfig then reconciles the resolved + // set). Deterministic — the gateway, not this write, is the source of truth. + a.logger.Info("proposing tunnel edit to gateway (hot-apply)", "enabled", len(newEnabled)) + if err := sess.proposeConfig(tunnels); err != nil { + a.logger.Warn("hot-apply propose failed; gateway will re-push on reconnect", "err", err) + } + return + } + if sess.Has(control.CapTunnelSync) { // Desired-state path: one full-set frame; the gateway reconciles. // Idempotency lives there — unchanged specs keep their listeners and @@ -74,6 +86,79 @@ func (a *Agent) ApplyTunnels(tunnels []config.Tunnel) { } } +// applyPushedConfig replaces the agent's enabled tunnel set with the gateway's +// authoritative one (CapGatewayConfig). The wire spec carries only gateway-relevant +// fields, so agent-local fields (LocalAddr, ProxyProtocolV2) are kept by ID and +// disabled local drafts are left untouched. publicPorts is refreshed from the resolved +// ports and state for removed tunnels is forgotten. Disk persistence is delegated to +// the optional configPersister; with none set the running set is updated in memory and +// the gateway re-pushes on the next reconnect. +func (a *Agent) applyPushedConfig(specs []control.TunnelSpec, generation uint64) { + a.cfgMu.Lock() + old := a.cfg.Agent.Tunnels + merged := mergeTunnels(old, specs) + a.cfg.Agent.Tunnels = merged + a.cfgMu.Unlock() + a.configGen.Store(generation) + + pushed := make(map[string]bool, len(specs)) + for _, spec := range specs { + pushed[spec.ID] = true + if spec.PublicPort != 0 { + a.publicPorts.Store(spec.ID, spec.PublicPort) + } + } + // Forget state for tunnels that were enabled before but the push dropped. + for _, t := range old { + if t.Enabled && !pushed[t.ID] { + a.publicPorts.Delete(t.ID) + a.localUp.Delete(t.ID) + a.bwLimiters.Release(t.ID) + } + } + a.logger.Info("applied gateway-authoritative config", "generation", generation, "tunnels", len(specs)) + if p := a.configPersister.Load(); p != nil { + if err := (*p)(append([]config.Tunnel(nil), merged...), generation); err != nil { + a.logger.Warn("persisting gateway config failed; gateway will re-push on reconnect", "err", err) + } + } +} + +// mergeTunnels applies a gateway-authoritative spec set to the agent's tunnels: each +// pushed spec upserts by ID, keeping the agent-local fields the wire never carries +// (LocalAddr, ProxyProtocolV2); enabled tunnels the push omits are dropped; disabled +// local drafts are preserved. enabledSpecs(mergeTunnels(old, specs)) round-trips to +// specs (for the hashed fields), so the agent's next hello hash matches the gateway's. +func mergeTunnels(existing []config.Tunnel, specs []control.TunnelSpec) []config.Tunnel { + byID := make(map[string]config.Tunnel, len(existing)) + for _, t := range existing { + byID[t.ID] = t + } + pushed := make(map[string]bool, len(specs)) + out := make([]config.Tunnel, 0, len(specs)+len(existing)) + for _, spec := range specs { + pushed[spec.ID] = true + t := byID[spec.ID] // keeps LocalAddr/Options for a known id; zero for a new one + t.ID = spec.ID + t.Name = spec.Name + t.Type = spec.Type + t.PublicPort = spec.PublicPort + t.Enabled = true + t.Options.OfflineMOTD = spec.OfflineMOTD + t.Options.MinecraftAware = spec.MinecraftAware + t.Options.BandwidthLimitMbps = spec.BandwidthLimitMbps + t.Options.BandwidthLimitScope = spec.BandwidthLimitScope + out = append(out, t) + } + // Disabled local drafts survive — the authoritative set governs only enabled tunnels. + for _, t := range existing { + if !t.Enabled && !pushed[t.ID] { + out = append(out, t) + } + } + return out +} + func enabledByID(tunnels []config.Tunnel) map[string]config.Tunnel { m := make(map[string]config.Tunnel) for _, t := range tunnels { diff --git a/internal/analytics/attribution_test.go b/internal/analytics/attribution_test.go new file mode 100644 index 0000000..49253e8 --- /dev/null +++ b/internal/analytics/attribution_test.go @@ -0,0 +1,57 @@ +package analytics + +import ( + "testing" + "time" + + "proxyforward/internal/conntrack" +) + +// TestRecorderAttributesSessionsToAgent drives the real recorder through the +// real conntrack registry: two agents open connections on the SAME tunnel_id, +// and each connection's sessions row must carry its own agent_id. This is the +// end-to-end proof of the attribution fix — without it, A's and B's history on +// a shared tunnelID would commingle. +func TestRecorderAttributesSessionsToAgent(t *testing.T) { + d := openTest(t, t.TempDir()) + rec := d.NewRecorder(nil) + reg := conntrack.NewRegistry() + reg.SetHooks( + func(e *conntrack.Entry) { rec.SessionOpened(e) }, + func(e *conntrack.Entry, in, out int64) { rec.SessionClosed(e, in, out) }, + nil, nil, + ) + + // Both agents serve tunnel_id "web" — the collision that used to commingle. + _, closeA := reg.Open("agentA", "web", "mc", "203.0.113.1:1", "kA", true) + _, closeB := reg.Open("agentB", "web", "mc", "203.0.113.2:1", "kB", true) + defer closeA() + defer closeB() + d.Barrier() // flush the async writer + + type row struct{ agent, ip string } + var got []row + rows, err := d.sql.Query(`SELECT agent_id, client_ip FROM sessions ORDER BY agent_id`) + if err != nil { + t.Fatalf("read sessions: %v", err) + } + for rows.Next() { + var r row + if err := rows.Scan(&r.agent, &r.ip); err != nil { + t.Fatalf("scan: %v", err) + } + got = append(got, r) + } + rows.Close() + if len(got) != 2 || got[0].agent != "agentA" || got[1].agent != "agentB" { + t.Fatalf("session attribution = %+v, want one row each for agentA and agentB", got) + } + + // The recorded (not hand-seeded) rows isolate per agent through the query. + if s, _ := d.Sessions(SessionsQuery{AgentID: "agentA", TunnelID: "web"}, time.Now().UnixMilli()); s.Total != 1 || s.Sessions[0].ClientIP != "203.0.113.1" { + t.Fatalf("agentA/web = %+v, want only agentA's connection", s) + } + if s, _ := d.Sessions(SessionsQuery{AgentID: "agentB", TunnelID: "web"}, time.Now().UnixMilli()); s.Total != 1 || s.Sessions[0].ClientIP != "203.0.113.2" { + t.Fatalf("agentB/web = %+v, want only agentB's connection", s) + } +} diff --git a/internal/analytics/events.go b/internal/analytics/events.go index 9f74755..9810901 100644 --- a/internal/analytics/events.go +++ b/internal/analytics/events.go @@ -19,24 +19,26 @@ const ( EventEngine = "engine" ) -// RecordEvent journals one up/down transition at the current time. Nil-safe -// (persistence unavailable) so callers can fire unconditionally. -func (r *Recorder) RecordEvent(kind, tunnelID string, up bool) { +// RecordEvent journals one up/down transition at the current time. agentID +// owns the link/tunnel_local transition ("" for engine-lifecycle, or a +// single-agent gateway). Nil-safe (persistence unavailable) so callers can fire +// unconditionally. +func (r *Recorder) RecordEvent(kind, agentID, tunnelID string, up bool) { if r == nil { return } - r.db.recordEventAt(time.Now().UnixMilli(), kind, tunnelID, up) + r.db.recordEventAt(time.Now().UnixMilli(), kind, agentID, tunnelID, up) } // recordEventAt is the injectable-clock core; tests call it directly. -func (d *DB) recordEventAt(t int64, kind, tunnelID string, up bool) { +func (d *DB) recordEventAt(t int64, kind, agentID, tunnelID string, up bool) { upVal := 0 if up { upVal = 1 } d.Enqueue("event", func(tx *sql.Tx) error { - _, err := tx.Exec(`INSERT INTO events (t, kind, tunnel_id, up) VALUES (?, ?, ?, ?)`, - t, kind, tunnelID, upVal) + _, err := tx.Exec(`INSERT INTO events (t, agent_id, kind, tunnel_id, up) VALUES (?, ?, ?, ?, ?)`, + t, agentID, kind, tunnelID, upVal) return err }) } diff --git a/internal/analytics/queries.go b/internal/analytics/queries.go index 1bbcf39..63e9a20 100644 --- a/internal/analytics/queries.go +++ b/internal/analytics/queries.go @@ -21,8 +21,12 @@ const ( // PlayersQuery selects and pages the player wall. type PlayersQuery struct { - Search string `json:"search"` - Sort string `json:"sort"` // recent | name | playtime | sessions | data + Search string `json:"search"` + Sort string `json:"sort"` // recent | name | playtime | sessions | data + // AgentID scopes to one agent ("" = all agents). On a multi-agent gateway + // two agents may share a TunnelID, so a tunnel-scoped wall should set both; + // a bare TunnelID aggregates that tunnel across every agent. + AgentID string `json:"agentId"` TunnelID string `json:"tunnelId"` // "" = all tunnels CC string `json:"cc"` // "" = all countries Offset int `json:"offset"` @@ -126,10 +130,10 @@ func (d *DB) Players(q PlayersQuery, online map[string]bool, nowMs int64) (Playe prefix, orderArgs := onlineFirst(online) order = prefix + order - // The aggregate join carries the tunnel scope, so a tunnel-filtered wall - // shows tunnel-scoped sessions/playtime/bytes/RTT rather than global - // figures next to a tunnel-scoped player list. - qargs := []any{nowMs, q.TunnelID, q.TunnelID} + // The aggregate join carries the agent+tunnel scope, so a scoped wall shows + // scoped sessions/playtime/bytes/RTT rather than global figures next to a + // scoped player list. + qargs := []any{nowMs, q.AgentID, q.AgentID, q.TunnelID, q.TunnelID} qargs = append(qargs, args...) qargs = append(qargs, orderArgs...) qargs = append(qargs, limit, max(q.Offset, 0)) @@ -139,7 +143,8 @@ func (d *DB) Players(q PlayersQuery, online map[string]bool, nowMs int64) (Playe COALESCE(SUM(COALESCE(s.ended_ms, ?) - s.started_ms), 0) AS play_ms, COALESCE(SUM(s.bytes_in), 0), COALESCE(SUM(s.bytes_out), 0), `+rttAggSQL+` - FROM players p LEFT JOIN sessions s ON s.player_uuid = p.uuid AND (? = '' OR s.tunnel_id = ?) + FROM players p LEFT JOIN sessions s ON s.player_uuid = p.uuid + AND (? = '' OR s.agent_id = ?) AND (? = '' OR s.tunnel_id = ?) `+where+` GROUP BY p.uuid ORDER BY `+order+` @@ -168,8 +173,12 @@ func playersFilter(q PlayersQuery) (string, []any) { conds = append(conds, "p.name LIKE ? COLLATE NOCASE") args = append(args, "%"+s+"%") } - if q.TunnelID != "" || q.CC != "" { + if q.AgentID != "" || q.TunnelID != "" || q.CC != "" { member := "s2.player_uuid = p.uuid" + if q.AgentID != "" { + member += " AND s2.agent_id = ?" + args = append(args, q.AgentID) + } if q.TunnelID != "" { member += " AND s2.tunnel_id = ?" args = append(args, q.TunnelID) @@ -309,6 +318,7 @@ func (d *DB) PlayerDetail(uuid string, online map[string]bool, nowMs int64) (*Pl // SessionsQuery filters the connection-history table. type SessionsQuery struct { PlayerUUID string `json:"playerUuid"` + AgentID string `json:"agentId"` // "" = all agents TunnelID string `json:"tunnelId"` CC string `json:"cc"` // "" = all countries SinceMs int64 `json:"sinceMs"` @@ -334,6 +344,10 @@ func (d *DB) Sessions(q SessionsQuery, nowMs int64) (SessionsPage, error) { conds = append(conds, "player_uuid = ?") args = append(args, q.PlayerUUID) } + if q.AgentID != "" { + conds = append(conds, "agent_id = ?") + args = append(args, q.AgentID) + } if q.TunnelID != "" { conds = append(conds, "tunnel_id = ?") args = append(args, q.TunnelID) diff --git a/internal/analytics/queries_test.go b/internal/analytics/queries_test.go index 51fcf7b..26480d1 100644 --- a/internal/analytics/queries_test.go +++ b/internal/analytics/queries_test.go @@ -401,3 +401,52 @@ func TestSessionTimeline(t *testing.T) { t.Fatalf("empty timeline = %+v", empty) } } + +// TestQueryAgentScoping (T4) is the anti-commingling guarantee: two agents that +// serve the same tunnel_id must stay separate when a query is scoped by +// agent_id, while a bare tunnel_id deliberately aggregates across every agent. +func TestQueryAgentScoping(t *testing.T) { + d := openTest(t, t.TempDir()) + exec := func(q string, args ...any) { + t.Helper() + if _, err := d.sql.Exec(q, args...); err != nil { + t.Fatalf("seed: %v\n%s", err, q) + } + } + exec(`INSERT INTO players (uuid, name, offline, first_seen, last_seen, last_cc) VALUES + (?, 'Ann', 0, ?, ?, ''), + (?, 'Bob', 0, ?, ?, '')`, + steveUUID, qBase-hourMs, qBase-1000, + alexUUID, qBase-hourMs, qBase-1000) + // Both agents serve tunnel_id "web"; Ann via agentA, Bob via agentB. + exec(`INSERT INTO sessions (id, agent_id, tunnel_id, tunnel_name, client_ip, started_ms, ended_ms, bytes_in, bytes_out, player_uuid, player_name) VALUES + (1, 'agentA', 'web', 'mc', '203.0.113.1', ?, ?, 100, 200, ?, 'Ann'), + (2, 'agentB', 'web', 'mc', '203.0.113.2', ?, ?, 300, 400, ?, 'Bob')`, + qBase-hourMs, qBase-1000, steveUUID, + qBase-hourMs, qBase-1000, alexUUID) + + // Sessions scoped by (agent, tunnel) isolate each agent. + if s, err := d.Sessions(SessionsQuery{AgentID: "agentA", TunnelID: "web"}, qBase); err != nil { + t.Fatal(err) + } else if s.Total != 1 || s.Sessions[0].PlayerName != "Ann" { + t.Fatalf("agentA/web sessions = %+v, want 1 (Ann)", s) + } + if s, _ := d.Sessions(SessionsQuery{AgentID: "agentB", TunnelID: "web"}, qBase); s.Total != 1 || s.Sessions[0].PlayerName != "Bob" { + t.Fatalf("agentB/web sessions = %+v, want 1 (Bob)", s) + } + // A bare tunnel_id aggregates across agents (documented, intentional). + if s, _ := d.Sessions(SessionsQuery{TunnelID: "web"}, qBase); s.Total != 2 { + t.Fatalf("web sessions (all agents) = %d, want 2", s.Total) + } + + // Players scoped by agent show only that agent's players, with agent-scoped + // aggregates on the join. + if p, err := d.Players(PlayersQuery{AgentID: "agentA"}, nil, qBase); err != nil { + t.Fatal(err) + } else if p.Total != 1 || p.Players[0].Name != "Ann" || p.Players[0].BytesIn != 100 { + t.Fatalf("agentA players = %+v, want Ann with 100 bytesIn", p) + } + if p, _ := d.Players(PlayersQuery{AgentID: "agentB", TunnelID: "web"}, nil, qBase); p.Total != 1 || p.Players[0].Name != "Bob" || p.Players[0].BytesIn != 300 { + t.Fatalf("agentB/web players = %+v, want Bob with 300 bytesIn", p) + } +} diff --git a/internal/analytics/race_test.go b/internal/analytics/race_test.go index 6357a52..de8f9d4 100644 --- a/internal/analytics/race_test.go +++ b/internal/analytics/race_test.go @@ -38,7 +38,7 @@ func TestConcurrentReadWrite(t *testing.T) { return default: } - e, closeEntry := reg.Open("t1", "mc", "203.0.113.9:1", "k", true) + e, closeEntry := reg.Open("", "t1", "mc", "203.0.113.9:1", "k", true) e.SetPlayer(conntrack.PlayerInfo{Name: "Steve", UUID: steveUUID}) e.SetRTT(float64(i + 1)) e.Counters.AToB.Add(64) diff --git a/internal/analytics/recorder.go b/internal/analytics/recorder.go index 1269a1f..c9b23eb 100644 --- a/internal/analytics/recorder.go +++ b/internal/analytics/recorder.go @@ -73,13 +73,13 @@ func (r *Recorder) SessionOpened(e *conntrack.Entry) { id := r.nextID.Add(1) r.live.Store(e.ID, &liveSession{id: id}) ip, port := splitAddr(e.ClientAddr) - connKey, tunnelID, tunnelName := e.ConnKey, e.TunnelID, e.TunnelName + agentID, connKey, tunnelID, tunnelName := e.AgentID, e.ConnKey, e.TunnelID, e.TunnelName started := e.StartedAt.UnixMilli() r.db.Enqueue("session-open", func(tx *sql.Tx) error { if _, err := tx.Exec(`INSERT INTO sessions - (id, conn_key, tunnel_id, tunnel_name, client_ip, client_port, started_ms) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - id, connKey, tunnelID, tunnelName, ip, port, started); err != nil { + (id, agent_id, conn_key, tunnel_id, tunnel_name, client_ip, client_port, started_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + id, agentID, connKey, tunnelID, tunnelName, ip, port, started); err != nil { return err } return r.stampGeo(tx, id, ip, started) diff --git a/internal/analytics/retention.go b/internal/analytics/retention.go index 5f1b4c0..6b307ae 100644 --- a/internal/analytics/retention.go +++ b/internal/analytics/retention.go @@ -48,9 +48,9 @@ func (d *DB) prune(now time.Time) { // state on a synthetic row at the cutoff: uptime over any window then // still knows the state entering it. (Bare `up` rides SQLite's // MAX()-row rule in the subquery.) - {"event carriers", `INSERT INTO events (t, kind, tunnel_id, up) - SELECT ?, kind, tunnel_id, up FROM - (SELECT kind, tunnel_id, up, MAX(t) FROM events WHERE t < ? GROUP BY kind, tunnel_id)`, + {"event carriers", `INSERT INTO events (t, agent_id, kind, tunnel_id, up) + SELECT ?, agent_id, kind, tunnel_id, up FROM + (SELECT agent_id, kind, tunnel_id, up, MAX(t) FROM events WHERE t < ? GROUP BY agent_id, kind, tunnel_id)`, []any{cutoff, cutoff}}, {"events", `DELETE FROM events WHERE t < ?`, []any{cutoff}}, {"geo_cache", `DELETE FROM geo_cache WHERE resolved_ms < ?`, []any{geoCutoff}}, diff --git a/internal/analytics/rollup.go b/internal/analytics/rollup.go index 5863936..7a40475 100644 --- a/internal/analytics/rollup.go +++ b/internal/analytics/rollup.go @@ -63,11 +63,14 @@ func (d *DB) runRollup(now time.Time) { q string args []any }{ - // Hourly bandwidth/gauge aggregates from tier-2 rrd. + // Hourly bandwidth/gauge aggregates from tier-2 rrd, per agent. rrd + // already carries agent_id (agent_id '' is the gateway-wide series), so + // grouping by it produces the global row and each per-agent row in one + // pass. {"rollup_hourly rrd", ` - INSERT INTO rollup_hourly (hour_ms, bytes_in, bytes_out, peak_in_bps, peak_out_bps, + INSERT INTO rollup_hourly (hour_ms, agent_id, bytes_in, bytes_out, peak_in_bps, peak_out_bps, peak_players, avg_players, rtt_avg, loss_avg) - SELECT t / ? * ? AS h, + SELECT t / ? * ? AS h, agent_id, COALESCE(SUM(inb), 0), COALESCE(SUM(outb), 0), COALESCE(MAX(ih), 0), COALESCE(MAX(oh), 0), COALESCE(MAX(CASE WHEN pc >= 0 THEN ph END), -1), @@ -75,46 +78,66 @@ func (d *DB) runRollup(now time.Time) { COALESCE(AVG(CASE WHEN rc >= 0 THEN rc END), -1), COALESCE(AVG(CASE WHEN lc >= 0 THEN lc END), -1) FROM rrd WHERE tier = 2 AND t >= ? AND t < ? - GROUP BY h - ON CONFLICT(hour_ms) DO UPDATE SET + GROUP BY h, agent_id + ON CONFLICT(hour_ms, agent_id) DO UPDATE SET bytes_in = excluded.bytes_in, bytes_out = excluded.bytes_out, peak_in_bps = excluded.peak_in_bps, peak_out_bps = excluded.peak_out_bps, peak_players = excluded.peak_players, avg_players = excluded.avg_players, rtt_avg = excluded.rtt_avg, loss_avg = excluded.loss_avg`, []any{hourMillis, hourMillis, rollFrom, curHourEnd}}, - // Hourly session counts from the sessions table. - {"rollup_hourly sessions", ` - INSERT INTO rollup_hourly (hour_ms, sessions, unique_players) - SELECT started_ms / ? * ? AS h, COUNT(*), COUNT(DISTINCT player_uuid) + // Hourly session counts, per agent (sessions carry a real agent_id). + {"rollup_hourly sessions/agent", ` + INSERT INTO rollup_hourly (hour_ms, agent_id, sessions, unique_players) + SELECT started_ms / ? * ? AS h, agent_id, COUNT(*), COUNT(DISTINCT player_uuid) + FROM sessions WHERE started_ms >= ? AND started_ms < ? + GROUP BY h, agent_id + ON CONFLICT(hour_ms, agent_id) DO UPDATE SET + sessions = excluded.sessions, unique_players = excluded.unique_players`, + []any{hourMillis, hourMillis, rollFrom, curHourEnd}}, + // Hourly session counts, gateway-wide (agent_id ''): sessions have no '' + // rows of their own, so aggregate across all agents into the global row. + {"rollup_hourly sessions/global", ` + INSERT INTO rollup_hourly (hour_ms, agent_id, sessions, unique_players) + SELECT started_ms / ? * ? AS h, '', COUNT(*), COUNT(DISTINCT player_uuid) FROM sessions WHERE started_ms >= ? AND started_ms < ? GROUP BY h - ON CONFLICT(hour_ms) DO UPDATE SET + ON CONFLICT(hour_ms, agent_id) DO UPDATE SET sessions = excluded.sessions, unique_players = excluded.unique_players`, []any{hourMillis, hourMillis, rollFrom, curHourEnd}}, - // Daily aggregates rolled up from the hourly rows (whole days recomputed). + // Daily bandwidth/gauge aggregates from the hourly rows, per agent + // (grouping by agent_id keeps '' and per-agent series separate — no + // cross-agent double count). {"rollup_daily hourly", ` - INSERT INTO rollup_daily (day_ms, bytes_in, bytes_out, peak_in_bps, peak_out_bps, + INSERT INTO rollup_daily (day_ms, agent_id, bytes_in, bytes_out, peak_in_bps, peak_out_bps, peak_players, avg_players, rtt_avg, loss_avg) - SELECT hour_ms / ? * ? AS d, + SELECT hour_ms / ? * ? AS d, agent_id, SUM(bytes_in), SUM(bytes_out), MAX(peak_in_bps), MAX(peak_out_bps), COALESCE(MAX(CASE WHEN peak_players >= 0 THEN peak_players END), -1), COALESCE(AVG(CASE WHEN avg_players >= 0 THEN avg_players END), -1), COALESCE(AVG(CASE WHEN rtt_avg >= 0 THEN rtt_avg END), -1), COALESCE(AVG(CASE WHEN loss_avg >= 0 THEN loss_avg END), -1) FROM rollup_hourly WHERE hour_ms >= ? AND hour_ms < ? - GROUP BY d - ON CONFLICT(day_ms) DO UPDATE SET + GROUP BY d, agent_id + ON CONFLICT(day_ms, agent_id) DO UPDATE SET bytes_in = excluded.bytes_in, bytes_out = excluded.bytes_out, peak_in_bps = excluded.peak_in_bps, peak_out_bps = excluded.peak_out_bps, peak_players = excluded.peak_players, avg_players = excluded.avg_players, rtt_avg = excluded.rtt_avg, loss_avg = excluded.loss_avg`, []any{dayMillis, dayMillis, dayFrom, dayEnd}}, - {"rollup_daily sessions", ` - INSERT INTO rollup_daily (day_ms, sessions, unique_players) - SELECT started_ms / ? * ? AS d, COUNT(*), COUNT(DISTINCT player_uuid) + {"rollup_daily sessions/agent", ` + INSERT INTO rollup_daily (day_ms, agent_id, sessions, unique_players) + SELECT started_ms / ? * ? AS d, agent_id, COUNT(*), COUNT(DISTINCT player_uuid) + FROM sessions WHERE started_ms >= ? AND started_ms < ? + GROUP BY d, agent_id + ON CONFLICT(day_ms, agent_id) DO UPDATE SET + sessions = excluded.sessions, unique_players = excluded.unique_players`, + []any{dayMillis, dayMillis, dayFrom, dayEnd}}, + {"rollup_daily sessions/global", ` + INSERT INTO rollup_daily (day_ms, agent_id, sessions, unique_players) + SELECT started_ms / ? * ? AS d, '', COUNT(*), COUNT(DISTINCT player_uuid) FROM sessions WHERE started_ms >= ? AND started_ms < ? GROUP BY d - ON CONFLICT(day_ms) DO UPDATE SET + ON CONFLICT(day_ms, agent_id) DO UPDATE SET sessions = excluded.sessions, unique_players = excluded.unique_players`, []any{dayMillis, dayMillis, dayFrom, dayEnd}}, } @@ -147,24 +170,40 @@ func (d *DB) rerollSessionRollups(tx *sql.Tx, fromMs, toMs, nowMs int64) error { if toMs < fromMs { return nil } + hourFrom, hourTo := fromMs/hourMillis*hourMillis, toMs/hourMillis*hourMillis+hourMillis + dayFrom, dayTo := fromMs/dayMillis*dayMillis, toMs/dayMillis*dayMillis+dayMillis steps := []struct { q string args []any }{ - {`INSERT INTO rollup_hourly (hour_ms, sessions, unique_players) - SELECT started_ms / ? * ? AS h, COUNT(*), COUNT(DISTINCT player_uuid) + {`INSERT INTO rollup_hourly (hour_ms, agent_id, sessions, unique_players) + SELECT started_ms / ? * ? AS h, agent_id, COUNT(*), COUNT(DISTINCT player_uuid) + FROM sessions WHERE started_ms >= ? AND started_ms < ? + GROUP BY h, agent_id + ON CONFLICT(hour_ms, agent_id) DO UPDATE SET + sessions = excluded.sessions, unique_players = excluded.unique_players`, + []any{hourMillis, hourMillis, hourFrom, hourTo}}, + {`INSERT INTO rollup_hourly (hour_ms, agent_id, sessions, unique_players) + SELECT started_ms / ? * ? AS h, '', COUNT(*), COUNT(DISTINCT player_uuid) FROM sessions WHERE started_ms >= ? AND started_ms < ? GROUP BY h - ON CONFLICT(hour_ms) DO UPDATE SET + ON CONFLICT(hour_ms, agent_id) DO UPDATE SET + sessions = excluded.sessions, unique_players = excluded.unique_players`, + []any{hourMillis, hourMillis, hourFrom, hourTo}}, + {`INSERT INTO rollup_daily (day_ms, agent_id, sessions, unique_players) + SELECT started_ms / ? * ? AS d, agent_id, COUNT(*), COUNT(DISTINCT player_uuid) + FROM sessions WHERE started_ms >= ? AND started_ms < ? + GROUP BY d, agent_id + ON CONFLICT(day_ms, agent_id) DO UPDATE SET sessions = excluded.sessions, unique_players = excluded.unique_players`, - []any{hourMillis, hourMillis, fromMs / hourMillis * hourMillis, toMs/hourMillis*hourMillis + hourMillis}}, - {`INSERT INTO rollup_daily (day_ms, sessions, unique_players) - SELECT started_ms / ? * ? AS d, COUNT(*), COUNT(DISTINCT player_uuid) + []any{dayMillis, dayMillis, dayFrom, dayTo}}, + {`INSERT INTO rollup_daily (day_ms, agent_id, sessions, unique_players) + SELECT started_ms / ? * ? AS d, '', COUNT(*), COUNT(DISTINCT player_uuid) FROM sessions WHERE started_ms >= ? AND started_ms < ? GROUP BY d - ON CONFLICT(day_ms) DO UPDATE SET + ON CONFLICT(day_ms, agent_id) DO UPDATE SET sessions = excluded.sessions, unique_players = excluded.unique_players`, - []any{dayMillis, dayMillis, fromMs / dayMillis * dayMillis, toMs/dayMillis*dayMillis + dayMillis}}, + []any{dayMillis, dayMillis, dayFrom, dayTo}}, } for _, s := range steps { if _, err := tx.Exec(s.q, s.args...); err != nil { @@ -196,8 +235,10 @@ func rollupPeaks(tx *sql.Tx) error { for _, ps := range peakSpecs { var t int64 var v float64 + // All-time records are gateway-wide: read only the global series + // (agent_id ''), never the per-agent rrd rows. err := tx.QueryRow(`SELECT t, `+ps.col+` FROM rrd - WHERE tier = 2 `+ps.guard+` + WHERE tier = 2 AND agent_id = '' `+ps.guard+` ORDER BY `+ps.col+` DESC, t ASC LIMIT 1`).Scan(&t, &v) if err == sql.ErrNoRows { continue diff --git a/internal/analytics/rtt_test.go b/internal/analytics/rtt_test.go index 4ef5d5c..bd2cc6a 100644 --- a/internal/analytics/rtt_test.go +++ b/internal/analytics/rtt_test.go @@ -19,7 +19,7 @@ func TestRecordRTTAggregates(t *testing.T) { nil, func(e *conntrack.Entry) { rec.RecordRTT(e, e.RTT()) }, ) - e, closeEntry := reg.Open("t1", "mc", "203.0.113.9:5555", "", true) + e, closeEntry := reg.Open("", "t1", "mc", "203.0.113.9:5555", "", true) d.Barrier() // Three samples within one minute: avg 20, min 10, max 30. @@ -70,7 +70,7 @@ func TestRecordRTTIgnoresUnknown(t *testing.T) { nil, nil, func(e *conntrack.Entry) { rec.RecordRTT(e, e.RTT()) }, ) - e, _ := reg.Open("t1", "mc", "203.0.113.9:5555", "", true) + e, _ := reg.Open("", "t1", "mc", "203.0.113.9:5555", "", true) d.Barrier() e.SetRTT(-1) // unknown — must not record d.Barrier() diff --git a/internal/analytics/schema.go b/internal/analytics/schema.go index 910dfc7..a34d77c 100644 --- a/internal/analytics/schema.go +++ b/internal/analytics/schema.go @@ -205,6 +205,212 @@ CREATE INDEX sessions_backfill ON sessions(player_name COLLATE NOCASE) CREATE INDEX geo_cache_cc ON geo_cache(cc); ` +// schemaV3 introduces per-agent attribution for the multi-agent gateway. A +// gateway now admits several agents; two of them may serve the same tunnel_id, +// so topology and bandwidth history must be keyed by agent. This is a +// destructive rebuild (the "full wipe" decision): every table is dropped and +// recreated fresh — local analytics is disposable telemetry, and a clean slate +// avoids orphan-identity half-states. Because the ladder is append-only, the +// wipe rides here as schemaV3 rather than editing schemaV1/schemaV2; an existing +// v2 database climbs to v3 by dropping its tables and recreating them empty. +// +// agent_id ” is the gateway-wide / single-agent series. Topology tables +// (sessions, events, rrd, rollup_hourly, rollup_daily) gain agent scope; the +// pure identity/geo caches keep their global shape (only their data resets). +const schemaV3 = ` +DROP TABLE IF EXISTS rrd; +DROP TABLE IF EXISTS lifetime; +DROP TABLE IF EXISTS peers; +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS session_traffic; +DROP TABLE IF EXISTS session_rtt; +DROP TABLE IF EXISTS players; +DROP TABLE IF EXISTS player_names; +DROP TABLE IF EXISTS player_ips; +DROP TABLE IF EXISTS uuid_cache; +DROP TABLE IF EXISTS geo_cache; +DROP TABLE IF EXISTS rollup_hourly; +DROP TABLE IF EXISTS rollup_daily; +DROP TABLE IF EXISTS peaks; +DROP TABLE IF EXISTS events; + +-- RRD tiers, now per agent: agent_id '' is the gateway-wide series, one row per +-- (agent, tier, bucket). Column order still mirrors stats.Bucket. +CREATE TABLE rrd ( + agent_id TEXT NOT NULL DEFAULT '', + tier INTEGER NOT NULL, + t INTEGER NOT NULL, + inb INTEGER NOT NULL DEFAULT 0, + outb INTEGER NOT NULL DEFAULT 0, + io REAL NOT NULL DEFAULT 0, ih REAL NOT NULL DEFAULT 0, il REAL NOT NULL DEFAULT 0, ic REAL NOT NULL DEFAULT 0, + oo REAL NOT NULL DEFAULT 0, oh REAL NOT NULL DEFAULT 0, ol REAL NOT NULL DEFAULT 0, oc REAL NOT NULL DEFAULT 0, + co REAL NOT NULL DEFAULT -1, ch REAL NOT NULL DEFAULT -1, cl REAL NOT NULL DEFAULT -1, cc REAL NOT NULL DEFAULT -1, + ro REAL NOT NULL DEFAULT -1, rh REAL NOT NULL DEFAULT -1, rl REAL NOT NULL DEFAULT -1, rc REAL NOT NULL DEFAULT -1, + po REAL NOT NULL DEFAULT -1, ph REAL NOT NULL DEFAULT -1, pl REAL NOT NULL DEFAULT -1, pc REAL NOT NULL DEFAULT -1, + lo REAL NOT NULL DEFAULT -1, lh REAL NOT NULL DEFAULT -1, ll REAL NOT NULL DEFAULT -1, lc REAL NOT NULL DEFAULT -1, + PRIMARY KEY (agent_id, tier, t) +) WITHOUT ROWID; + +CREATE TABLE lifetime ( + id INTEGER PRIMARY KEY CHECK (id = 1), + bytes_in INTEGER NOT NULL DEFAULT 0, + bytes_out INTEGER NOT NULL DEFAULT 0, + link_bytes_in INTEGER NOT NULL DEFAULT 0, + link_bytes_out INTEGER NOT NULL DEFAULT 0, + uptime_ms INTEGER NOT NULL DEFAULT 0, + link_sessions INTEGER NOT NULL DEFAULT 0, + first_run_ms INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE peers ( + ip TEXT PRIMARY KEY, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + bytes_in INTEGER NOT NULL DEFAULT 0, + bytes_out INTEGER NOT NULL DEFAULT 0, + conns INTEGER NOT NULL DEFAULT 0 +) WITHOUT ROWID; + +-- Connection history, one row per proxied connection. agent_id owns the tunnel; +-- two agents may share a tunnel_id, so attribution needs both. +CREATE TABLE sessions ( + id INTEGER PRIMARY KEY, + agent_id TEXT NOT NULL DEFAULT '', + conn_key TEXT, + tunnel_id TEXT NOT NULL, + tunnel_name TEXT NOT NULL DEFAULT '', + client_ip TEXT NOT NULL, + client_port INTEGER NOT NULL DEFAULT 0, + started_ms INTEGER NOT NULL, + ended_ms INTEGER, -- NULL while live + bytes_in INTEGER NOT NULL DEFAULT 0, + bytes_out INTEGER NOT NULL DEFAULT 0, + player_uuid TEXT, + player_name TEXT, + protocol INTEGER, + cc TEXT, asn INTEGER, as_org TEXT, isp TEXT, + rtt_avg REAL, rtt_min REAL, rtt_max REAL, rtt_n INTEGER +); +CREATE INDEX sessions_started ON sessions(started_ms); +CREATE INDEX sessions_player ON sessions(player_uuid, started_ms); +CREATE INDEX sessions_tunnel ON sessions(agent_id, tunnel_id, started_ms); +CREATE INDEX sessions_agent ON sessions(agent_id, started_ms); +CREATE INDEX sessions_cc ON sessions(cc, started_ms); +CREATE INDEX sessions_backfill ON sessions(player_name COLLATE NOCASE) + WHERE player_uuid IS NULL OR player_uuid = ''; + +CREATE TABLE session_traffic ( + session_id INTEGER NOT NULL, + t INTEGER NOT NULL, + inb INTEGER NOT NULL DEFAULT 0, + outb INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (session_id, t) +) WITHOUT ROWID; + +CREATE TABLE session_rtt ( + session_id INTEGER NOT NULL, + t INTEGER NOT NULL, + avg REAL NOT NULL, mn REAL NOT NULL, mx REAL NOT NULL, + n INTEGER NOT NULL, + PRIMARY KEY (session_id, t) +) WITHOUT ROWID; + +CREATE TABLE players ( + uuid TEXT PRIMARY KEY, + name TEXT NOT NULL, + offline INTEGER NOT NULL DEFAULT 0, + first_seen INTEGER NOT NULL DEFAULT 0, + last_seen INTEGER NOT NULL DEFAULT 0, + last_cc TEXT NOT NULL DEFAULT '', + profile_checked_ms INTEGER NOT NULL DEFAULT 0 +) WITHOUT ROWID; + +CREATE TABLE player_names ( + uuid TEXT NOT NULL, + name TEXT NOT NULL, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + PRIMARY KEY (uuid, name) +) WITHOUT ROWID; + +CREATE TABLE player_ips ( + uuid TEXT NOT NULL, + ip TEXT NOT NULL, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + sessions INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (uuid, ip) +) WITHOUT ROWID; + +CREATE TABLE uuid_cache ( + name_lower TEXT PRIMARY KEY, + uuid TEXT NOT NULL DEFAULT '', + resolved_ms INTEGER NOT NULL +) WITHOUT ROWID; + +CREATE TABLE geo_cache ( + ip TEXT PRIMARY KEY, + cc TEXT NOT NULL DEFAULT '', + country TEXT NOT NULL DEFAULT '', + asn INTEGER NOT NULL DEFAULT 0, + as_org TEXT NOT NULL DEFAULT '', + isp TEXT NOT NULL DEFAULT '', + resolved_ms INTEGER NOT NULL +) WITHOUT ROWID; +CREATE INDEX geo_cache_cc ON geo_cache(cc); + +-- Hourly/daily rollups, now per agent (agent_id '' is gateway-wide). +CREATE TABLE rollup_hourly ( + hour_ms INTEGER NOT NULL, + agent_id TEXT NOT NULL DEFAULT '', + bytes_in INTEGER NOT NULL DEFAULT 0, + bytes_out INTEGER NOT NULL DEFAULT 0, + peak_in_bps REAL NOT NULL DEFAULT 0, + peak_out_bps REAL NOT NULL DEFAULT 0, + peak_players REAL NOT NULL DEFAULT -1, + avg_players REAL NOT NULL DEFAULT -1, + sessions INTEGER NOT NULL DEFAULT 0, + unique_players INTEGER NOT NULL DEFAULT 0, + rtt_avg REAL NOT NULL DEFAULT -1, + loss_avg REAL NOT NULL DEFAULT -1, + PRIMARY KEY (hour_ms, agent_id) +) WITHOUT ROWID; + +CREATE TABLE rollup_daily ( + day_ms INTEGER NOT NULL, + agent_id TEXT NOT NULL DEFAULT '', + bytes_in INTEGER NOT NULL DEFAULT 0, + bytes_out INTEGER NOT NULL DEFAULT 0, + peak_in_bps REAL NOT NULL DEFAULT 0, + peak_out_bps REAL NOT NULL DEFAULT 0, + peak_players REAL NOT NULL DEFAULT -1, + avg_players REAL NOT NULL DEFAULT -1, + sessions INTEGER NOT NULL DEFAULT 0, + unique_players INTEGER NOT NULL DEFAULT 0, + rtt_avg REAL NOT NULL DEFAULT -1, + loss_avg REAL NOT NULL DEFAULT -1, + PRIMARY KEY (day_ms, agent_id) +) WITHOUT ROWID; + +CREATE TABLE peaks ( + key TEXT PRIMARY KEY, + value REAL NOT NULL, + at_ms INTEGER NOT NULL +) WITHOUT ROWID; + +-- Uptime transitions, now per agent: kind 'link'/'engine' (tunnel_id '') or +-- 'tunnel_local'. agent_id '' is engine-lifecycle / single-agent. +CREATE TABLE events ( + id INTEGER PRIMARY KEY, + t INTEGER NOT NULL, + agent_id TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL, + tunnel_id TEXT NOT NULL DEFAULT '', + up INTEGER NOT NULL +); +CREATE INDEX events_t ON events(kind, agent_id, tunnel_id, t); +` + // migrations is the schema ladder; PRAGMA user_version tracks how far a // database has climbed. Append only — never edit an applied entry. -var migrations = []string{schemaV1, schemaV2} +var migrations = []string{schemaV1, schemaV2, schemaV3} diff --git a/internal/analytics/statspersist.go b/internal/analytics/statspersist.go index 4958e06..6c73cde 100644 --- a/internal/analytics/statspersist.go +++ b/internal/analytics/statspersist.go @@ -12,7 +12,7 @@ import ( "proxyforward/internal/stats" ) -const rrdCols = `tier, t, inb, outb, +const rrdCols = `agent_id, tier, t, inb, outb, io, ih, il, ic, oo, oh, ol, oc, co, ch, cl, cc, ro, rh, rl, rc, po, ph, pl, pc, lo, lh, ll, lc` @@ -50,24 +50,25 @@ func (d *DB) LoadStats() (*stats.SnapshotData, error) { return nil, fmt.Errorf("load peers: %w", err) } - brows, err := d.read.Query(`SELECT ` + rrdCols + ` FROM rrd ORDER BY tier, t`) + brows, err := d.read.Query(`SELECT ` + rrdCols + ` FROM rrd ORDER BY agent_id, tier, t`) if err != nil { return nil, fmt.Errorf("load rrd: %w", err) } defer brows.Close() var cur *stats.TierSnapshot for brows.Next() { + var agentID string var tier int var b stats.Bucket - if err := brows.Scan(&tier, &b.T, &b.In, &b.Out, + if err := brows.Scan(&agentID, &tier, &b.T, &b.In, &b.Out, &b.InO, &b.InH, &b.InL, &b.InC, &b.OutO, &b.OutH, &b.OutL, &b.OutC, &b.ConnO, &b.ConnH, &b.ConnL, &b.ConnC, &b.RttO, &b.RttH, &b.RttL, &b.RttC, &b.PlayersO, &b.PlayersH, &b.PlayersL, &b.PlayersC, &b.LossO, &b.LossH, &b.LossL, &b.LossC); err != nil { return nil, fmt.Errorf("scan bucket: %w", err) } - if cur == nil || cur.Tier != tier { - snap.Tiers = append(snap.Tiers, stats.TierSnapshot{Tier: tier}) + if cur == nil || cur.Tier != tier || cur.AgentID != agentID { + snap.Tiers = append(snap.Tiers, stats.TierSnapshot{AgentID: agentID, Tier: tier}) cur = &snap.Tiers[len(snap.Tiers)-1] } cur.Buckets = append(cur.Buckets, b) @@ -113,25 +114,31 @@ func (d *DB) SaveStats(snap *stats.SnapshotData) error { } bstmt, err := tx.Prepare(`INSERT OR REPLACE INTO rrd (` + rrdCols + `) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) if err != nil { return fmt.Errorf("save rrd: %w", err) } defer bstmt.Close() + // Drop the rrd rows of agent histories evicted since the last save. + for _, agentID := range snap.DeleteAgents { + if _, err := tx.Exec(`DELETE FROM rrd WHERE agent_id = ?`, agentID); err != nil { + return fmt.Errorf("delete agent %q rrd: %w", agentID, err) + } + } for _, ts := range snap.Tiers { - if _, err := tx.Exec(`DELETE FROM rrd WHERE tier = ? AND t < ?`, ts.Tier, ts.FloorT); err != nil { - return fmt.Errorf("expire tier %d: %w", ts.Tier, err) + if _, err := tx.Exec(`DELETE FROM rrd WHERE agent_id = ? AND tier = ? AND t < ?`, ts.AgentID, ts.Tier, ts.FloorT); err != nil { + return fmt.Errorf("expire agent %q tier %d: %w", ts.AgentID, ts.Tier, err) } for _, b := range ts.Buckets { if b.T < ts.DirtyFromT { continue // unchanged since the last successful save } - if _, err := bstmt.Exec(ts.Tier, b.T, b.In, b.Out, + if _, err := bstmt.Exec(ts.AgentID, ts.Tier, b.T, b.In, b.Out, b.InO, b.InH, b.InL, b.InC, b.OutO, b.OutH, b.OutL, b.OutC, b.ConnO, b.ConnH, b.ConnL, b.ConnC, b.RttO, b.RttH, b.RttL, b.RttC, b.PlayersO, b.PlayersH, b.PlayersL, b.PlayersC, b.LossO, b.LossH, b.LossL, b.LossC); err != nil { - return fmt.Errorf("save bucket tier=%d t=%d: %w", ts.Tier, b.T, err) + return fmt.Errorf("save bucket agent=%q tier=%d t=%d: %w", ts.AgentID, ts.Tier, b.T, err) } } } diff --git a/internal/analytics/statspersist_test.go b/internal/analytics/statspersist_test.go index df91061..3b53476 100644 --- a/internal/analytics/statspersist_test.go +++ b/internal/analytics/statspersist_test.go @@ -82,6 +82,66 @@ func TestStatsRoundTripThroughSQLite(t *testing.T) { } } +// TestAgentHistoryRoundTripThroughSQLite: two agents' bandwidth histories +// persist and restore with their agent_id, distinct from each other and from +// the gateway-wide (”) series. +func TestAgentHistoryRoundTripThroughSQLite(t *testing.T) { + dir := t.TempDir() + d := openTest(t, dir) + s := stats.Open(d, nil) + + mid := time.Now().Add(-2 * time.Minute).UnixMilli() + start := mid - 600_000 + var gIn, gOut, aIn, aOut, bIn, bOut int64 + for ts := start; ts <= mid; ts += 1000 { + now := time.UnixMilli(ts) + s.Sample(now, gIn, gOut, 0, 0, 3, -1, 25, -1) // gateway-wide series + s.SampleAgent("agentA", now, aIn, aOut, 2, -1, 25, -1) + s.SampleAgent("agentB", now, bIn, bOut, 1, -1, 40, -1) + gIn += 60_000 + gOut += 600_000 + aIn += 40_000 // agent A carries twice agent B's rate + aOut += 400_000 + bIn += 20_000 + bOut += 200_000 + } + beforeA := s.AgentHistory("agentA", 3_600_000, 240) + beforeB := s.AgentHistory("agentB", 3_600_000, 240) + if len(beforeA.Buckets) == 0 || len(beforeB.Buckets) == 0 { + t.Fatal("no per-agent buckets sampled") + } + if err := s.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + + // Restore into a second store from the same database. + s2 := stats.Open(d, nil) + afterA := s2.AgentHistory("agentA", 3_600_000, 240) + afterB := s2.AgentHistory("agentB", 3_600_000, 240) + if len(afterA.Buckets) != len(beforeA.Buckets) { + t.Fatalf("agentA restored %d buckets, want %d", len(afterA.Buckets), len(beforeA.Buckets)) + } + var sumA, sumB int64 + for _, bk := range afterA.Buckets { + sumA += bk.In + } + for _, bk := range afterB.Buckets { + sumB += bk.In + } + if sumA == 0 || sumB == 0 || sumA <= sumB { + t.Fatalf("per-agent bytes commingled or wrong: A=%d B=%d (want A>B>0)", sumA, sumB) + } + + // The rrd table carries at least three distinct series: '', agentA, agentB. + var nSeries int + if err := d.read.QueryRow(`SELECT COUNT(DISTINCT agent_id) FROM rrd`).Scan(&nSeries); err != nil { + t.Fatalf("count rrd series: %v", err) + } + if nSeries < 3 { + t.Fatalf("rrd has %d distinct agent_id series, want ≥ 3", nSeries) + } +} + func legacyV2JSON() string { return `{"v":2,"lifetime":{"bytesIn":1000,"bytesOut":10000,"linkSessions":4,"firstRunMs":1},` + `"peers":[{"ip":"198.51.100.7","firstSeen":5,"lastSeen":9,"totalBytesIn":11,"totalBytesOut":22,"totalConns":3}],` + diff --git a/internal/analytics/summary.go b/internal/analytics/summary.go index 751bdaf..06c7869 100644 --- a/internal/analytics/summary.go +++ b/internal/analytics/summary.go @@ -75,21 +75,23 @@ func (d *DB) Summary(sinceMs, nowMs int64) (Summary, error) { return s, err } + // Bandwidth/gauge tiles are gateway-wide: read the global rollup series + // (agent_id '') so per-agent rows never double-count into the totals. if err := d.read.QueryRow(`SELECT COALESCE(SUM(bytes_in), 0), COALESCE(SUM(bytes_out), 0), COALESCE(AVG(CASE WHEN rtt_avg >= 0 THEN rtt_avg END), -1), COALESCE(AVG(CASE WHEN loss_avg >= 0 THEN loss_avg END), -1) - FROM `+table+` WHERE `+timeCol+` >= ?`, sinceBucket). + FROM `+table+` WHERE `+timeCol+` >= ? AND agent_id = ''`, sinceBucket). Scan(&s.BytesIn, &s.BytesOut, &s.AvgRttMs, &s.AvgLossPct); err != nil { return s, err } - // Range peaks with the bucket they occurred in. + // Range peaks with the bucket they occurred in (global series only). peakAt := func(col, guard string) (float64, int64, error) { var v float64 var at int64 err := d.read.QueryRow(`SELECT `+col+`, `+timeCol+` FROM `+table+` - WHERE `+timeCol+` >= ? `+guard+` ORDER BY `+col+` DESC, `+timeCol+` ASC LIMIT 1`, sinceBucket).Scan(&v, &at) + WHERE `+timeCol+` >= ? AND agent_id = '' `+guard+` ORDER BY `+col+` DESC, `+timeCol+` ASC LIMIT 1`, sinceBucket).Scan(&v, &at) if err == sql.ErrNoRows { return 0, 0, nil } @@ -187,7 +189,7 @@ func (d *DB) PeakMatrix(weeks int, nowMs int64, loc *time.Location) (PeakMatrix, } since := nowMs - int64(weeks)*7*dayMillis rows, err := d.read.Query(`SELECT hour_ms, avg_players, peak_players - FROM rollup_hourly WHERE hour_ms >= ?`, since) + FROM rollup_hourly WHERE hour_ms >= ? AND agent_id = ''`, since) if err != nil { return m, err } diff --git a/internal/analytics/summary_test.go b/internal/analytics/summary_test.go index e78bf53..2255fd3 100644 --- a/internal/analytics/summary_test.go +++ b/internal/analytics/summary_test.go @@ -25,8 +25,8 @@ func TestSummary(t *testing.T) { mk(3, hB+1000, steveUUID) // Link up the whole window, engine covering it. - d.recordEventAt(hA-minuteMs, EventEngine, "", true) - d.recordEventAt(hA-minuteMs, EventLink, "", true) + d.recordEventAt(hA-minuteMs, EventEngine, "", "", true) + d.recordEventAt(hA-minuteMs, EventLink, "", "", true) d.Barrier() d.runRollup(time.UnixMilli(now)) @@ -80,13 +80,13 @@ func TestTunnelUptime(t *testing.T) { start := now - 4*hourMillis // Engine up for the whole window. - d.recordEventAt(start, EventEngine, "", true) + d.recordEventAt(start, EventEngine, "", "", true) // Link: up at start, one 1-hour outage in the middle → ~75%. - d.recordEventAt(start, EventLink, "", true) - d.recordEventAt(start+hourMillis, EventLink, "", false) - d.recordEventAt(start+2*hourMillis, EventLink, "", true) + d.recordEventAt(start, EventLink, "", "", true) + d.recordEventAt(start+hourMillis, EventLink, "", "", false) + d.recordEventAt(start+2*hourMillis, EventLink, "", "", true) // Tunnel t1: up the whole window → 100%. - d.recordEventAt(start, EventTunnelLocal, "t1", true) + d.recordEventAt(start, EventTunnelLocal, "", "t1", true) d.Barrier() rep, err := d.TunnelUptime(start, now) @@ -117,7 +117,7 @@ func TestTunnelUptimeClamped(t *testing.T) { // 30 tunnels, each with a distinct last-activity hour; t29 is the most // recent, t0 the stalest. for i := range 30 { - d.recordEventAt(start+int64(i)*hourMillis, EventTunnelLocal, fmt.Sprintf("t%d", i), true) + d.recordEventAt(start+int64(i)*hourMillis, EventTunnelLocal, "", fmt.Sprintf("t%d", i), true) } d.Barrier() diff --git a/internal/bwcap/bwcap.go b/internal/bwcap/bwcap.go new file mode 100644 index 0000000..5cefb05 --- /dev/null +++ b/internal/bwcap/bwcap.go @@ -0,0 +1,193 @@ +// Package bwcap turns a tunnel's (rate Mbps, scope) bandwidth cap into the rate +// limiters the relay splice enforces. It is the single home for the unit +// (decimal megabits/sec, one token = one byte) and for scope→sharing semantics. +// +// Ownership: a LimiterSet's scope and limiter pointers are fixed at BuildSet; a +// scope or capped/uncapped change rebuilds the set rather than mutating it, so a +// consumer holding an old *LimiterSet always sees a consistent snapshot. Only +// the applied rate changes in place — via the limiters' own concurrent-safe +// SetLimit plus an atomic mbps — so a rate-only config reload is picked up by +// in-flight combined/per-direction connections without a rebuild. Every field a +// lock-free reader (Resolve) touches is therefore either immutable or atomic. +package bwcap + +import ( + "sync" + "sync/atomic" + + "golang.org/x/time/rate" + + "proxyforward/internal/relay" +) + +// Scope selects what a tunnel's cap applies to. +type Scope string + +const ( + // ScopeCombined shares one bucket across both directions and all of the + // tunnel's connections. + ScopeCombined Scope = "combined" + // ScopePerDirection caps inbound and outbound independently, each summed + // across the tunnel's connections. + ScopePerDirection Scope = "per-direction" + // ScopePerConnection gives every connection its own bucket. + ScopePerConnection Scope = "per-connection" +) + +// bytesPerMbps converts decimal megabits/sec to bytes/sec (one rate token = one +// byte). This constant is the single source of truth for the unit. +const bytesPerMbps = 125_000 + +// NormalizeScope maps a wire/config string to a Scope, failing safe to +// ScopeCombined for empty or unrecognized values so a bad value throttles the +// tunnel rather than erroring it. (Config load rejects unknown scopes; the wire +// side normalizes whatever arrives.) +func NormalizeScope(s string) Scope { + switch Scope(s) { + case ScopePerDirection: + return ScopePerDirection + case ScopePerConnection: + return ScopePerConnection + default: + return ScopeCombined + } +} + +func newLimiter(mbps int) *rate.Limiter { + return rate.NewLimiter(rate.Limit(mbps*bytesPerMbps), relay.BufSize) +} + +// LimiterSet holds the shared limiters for one tunnel's cap. Built once at bind, +// swapped on a scope/capped change, updated in place on a rate-only change. See +// the package doc for the concurrency model. +type LimiterSet struct { + scope Scope // immutable after BuildSet + mbps atomic.Int64 // current applied rate; 0 = uncapped + // combined serves ScopeCombined (both directions share it); in/out serve + // ScopePerDirection. All nil for ScopePerConnection (minted per connection) + // and for an uncapped set. The pointers are immutable; only the limiters' + // internal rate changes, via SetLimit. + combined *rate.Limiter + in *rate.Limiter + out *rate.Limiter +} + +// BuildSet constructs the shared limiters for a (mbps, scope) cap. mbps <= 0 is +// uncapped (all limiters nil, Resolve returns nil pair). +func BuildSet(mbps int, scope string) *LimiterSet { + s := NormalizeScope(scope) + set := &LimiterSet{scope: s} + set.mbps.Store(int64(mbps)) + if mbps <= 0 { + return set + } + switch s { + case ScopePerDirection: + set.in = newLimiter(mbps) + set.out = newLimiter(mbps) + case ScopePerConnection: + // No shared limiters; Resolve mints a fresh one per connection. + default: // ScopeCombined + set.combined = newLimiter(mbps) + } + return set +} + +// Rate reports the currently applied cap in Mbps (0 = uncapped). +func (s *LimiterSet) Rate() int { return int(s.mbps.Load()) } + +// setRate applies a rate-only change (same scope, still capped) to the shared +// limiters in place, so in-flight combined/per-direction connections pick up the +// new rate immediately. Per-connection has no shared limiters, so only the +// stored rate updates — for connections opened afterward. +func (s *LimiterSet) setRate(mbps int) { + lim := rate.Limit(mbps * bytesPerMbps) + if s.combined != nil { + s.combined.SetLimit(lim) + } + if s.in != nil { + s.in.SetLimit(lim) + } + if s.out != nil { + s.out.SetLimit(lim) + } + s.mbps.Store(int64(mbps)) +} + +// Resolve returns the (inbound, outbound) limiters for one connection, called +// once per connection. ScopeCombined shares one instance both ways; +// ScopePerDirection returns the two shared instances; ScopePerConnection mints a +// fresh combined limiter so each connection gets its own bucket. An uncapped or +// nil set returns (nil, nil) — the relay fast path. +func Resolve(set *LimiterSet) (inbound, outbound relay.Limiter) { + if set == nil || set.Rate() <= 0 { + return nil, nil + } + switch set.scope { + case ScopePerDirection: + return asLimiter(set.in), asLimiter(set.out) + case ScopePerConnection: + l := newLimiter(set.Rate()) + return l, l + default: // ScopeCombined + return asLimiter(set.combined), asLimiter(set.combined) + } +} + +// Reconcile makes a live set match a new (mbps, scope) cap. A same-scope, +// still-capped, rate-only change is applied in place and returns (cur, false) +// — keep the existing set, in-flight connections stay valid. Anything else (nil +// cur, scope change, or a capped/uncapped flip) builds a fresh set and returns +// (newSet, true) — the caller must install it. +func Reconcile(cur *LimiterSet, mbps int, scope string) (set *LimiterSet, swapped bool) { + s := NormalizeScope(scope) + if cur == nil || cur.scope != s || (cur.Rate() <= 0) != (mbps <= 0) { + return BuildSet(mbps, scope), true + } + if mbps > 0 && cur.Rate() != mbps { + cur.setRate(mbps) + } + return cur, false +} + +// asLimiter converts a possibly-nil *rate.Limiter to a relay.Limiter, returning +// a true nil interface (not a non-nil interface wrapping a nil pointer, which +// would make relay's nil check pass and then panic in WaitN). +func asLimiter(l *rate.Limiter) relay.Limiter { + if l == nil { + return nil + } + return l +} + +// Registry holds each tunnel's shared LimiterSet on the agent, keyed by tunnel +// ID. An agent process serves only its own tunnels, so tunnel IDs are unique +// here — unlike the gateway, which multiplexes agents and keys buckets by +// (agentID, tunnelID) via the per-agent publicListener. Safe for concurrent use. +type Registry struct { + mu sync.Mutex + sets map[string]*LimiterSet +} + +// NewRegistry returns an empty Registry. +func NewRegistry() *Registry { + return &Registry{sets: make(map[string]*LimiterSet)} +} + +// Resolve returns the (inbound, outbound) limiters for a connection on tunID, +// building the shared set on first use and reconciling it to the current cap +// (rate-only change in place, scope/flip rebuilds). Uncapped returns (nil, nil). +func (r *Registry) Resolve(tunID string, mbps int, scope string) (inbound, outbound relay.Limiter) { + r.mu.Lock() + set, _ := Reconcile(r.sets[tunID], mbps, scope) + r.sets[tunID] = set + r.mu.Unlock() + return Resolve(set) +} + +// Release drops a tunnel's shared set (call when the tunnel is removed). +func (r *Registry) Release(tunID string) { + r.mu.Lock() + delete(r.sets, tunID) + r.mu.Unlock() +} diff --git a/internal/bwcap/bwcap_test.go b/internal/bwcap/bwcap_test.go new file mode 100644 index 0000000..5da10ed --- /dev/null +++ b/internal/bwcap/bwcap_test.go @@ -0,0 +1,174 @@ +package bwcap_test + +import ( + "context" + "testing" + "time" + + "golang.org/x/time/rate" + + "proxyforward/internal/bwcap" + "proxyforward/internal/relay" +) + +func TestNormalizeScope(t *testing.T) { + cases := map[string]bwcap.Scope{ + "": bwcap.ScopeCombined, // empty -> combined + "combined": bwcap.ScopeCombined, + "per-direction": bwcap.ScopePerDirection, + "per-connection": bwcap.ScopePerConnection, + "sideways": bwcap.ScopeCombined, // unknown -> combined (fail-safe) + "COMBINED": bwcap.ScopeCombined, // case-sensitive; unknown -> combined + } + for in, want := range cases { + if got := bwcap.NormalizeScope(in); got != want { + t.Errorf("NormalizeScope(%q) = %q, want %q", in, got, want) + } + } +} + +// The unit is decimal Mbps (Mbps*125000 bytes/sec) and the burst is relay.BufSize +// — the single source of truth both this package and the e2e assertion rely on. +func TestLimiterUnitAndBurst(t *testing.T) { + set := bwcap.BuildSet(5, "combined") + in, _ := bwcap.Resolve(set) + lim, ok := in.(*rate.Limiter) + if !ok { + t.Fatalf("Resolve returned %T, want *rate.Limiter", in) + } + if lim.Limit() != rate.Limit(5*125_000) { + t.Errorf("limit = %v, want %v (5 Mbps decimal)", lim.Limit(), rate.Limit(5*125_000)) + } + if lim.Burst() != relay.BufSize { + t.Errorf("burst = %d, want %d (relay.BufSize)", lim.Burst(), relay.BufSize) + } +} + +func TestResolveSharingByScope(t *testing.T) { + // Combined: both directions share one instance. + ci, co := bwcap.Resolve(bwcap.BuildSet(5, "combined")) + if ci == nil || ci != co { + t.Errorf("combined must share one limiter both ways (in=%v out=%v)", ci, co) + } + + // Per-direction: two distinct non-nil instances. + di, do := bwcap.Resolve(bwcap.BuildSet(5, "per-direction")) + if di == nil || do == nil || di == do { + t.Errorf("per-direction must give two distinct limiters (in=%v out=%v)", di, do) + } + + // Per-connection: shared within a connection, fresh across connections. + pset := bwcap.BuildSet(5, "per-connection") + p1a, p1b := bwcap.Resolve(pset) + p2a, _ := bwcap.Resolve(pset) + if p1a == nil || p1a != p1b { + t.Error("per-connection must share one limiter within a connection") + } + if p1a == p2a { + t.Error("per-connection must mint a fresh limiter per connection") + } + + // Uncapped: nil pair (the relay fast path). + ui, uo := bwcap.Resolve(bwcap.BuildSet(0, "combined")) + if ui != nil || uo != nil { + t.Errorf("uncapped must resolve to (nil, nil), got (%v, %v)", ui, uo) + } + // A nil set is also the fast path. + if ni, no := bwcap.Resolve(nil); ni != nil || no != nil { + t.Errorf("nil set must resolve to (nil, nil), got (%v, %v)", ni, no) + } +} + +func TestReconcileInPlaceVsRebuild(t *testing.T) { + // nil cur -> build a fresh set. + s1, sw1 := bwcap.Reconcile(nil, 5, "combined") + if !sw1 || s1 == nil || s1.Rate() != 5 { + t.Fatalf("nil cur must build a new capped set: swapped=%v set=%v", sw1, s1) + } + + // Rate-only change, same scope: applied in place, no swap, same set. + s2, sw2 := bwcap.Reconcile(s1, 10, "combined") + if sw2 || s2 != s1 || s2.Rate() != 10 { + t.Fatalf("rate-only change must update in place: swapped=%v same=%v rate=%d", sw2, s2 == s1, s2.Rate()) + } + if in, _ := bwcap.Resolve(s2); in.(*rate.Limiter).Limit() != rate.Limit(10*125_000) { + t.Errorf("rate-only change did not update the shared limiter to 10 Mbps") + } + + // Scope change: rebuild to a new set. + s3, sw3 := bwcap.Reconcile(s2, 10, "per-direction") + if !sw3 || s3 == s2 { + t.Errorf("scope change must swap to a new set") + } + + // Capped -> uncapped: rebuild (can't SetLimit a nil limiter into existence). + s4, sw4 := bwcap.Reconcile(s3, 0, "per-direction") + if !sw4 { + t.Error("capped->uncapped flip must swap") + } + if in, out := bwcap.Resolve(s4); in != nil || out != nil { + t.Error("uncapped set must resolve to nil pair") + } +} + +func TestRegistrySharesAndReleases(t *testing.T) { + r := bwcap.NewRegistry() + + // Same tunnel, combined scope: connections share one bucket. + a1, _ := r.Resolve("t1", 5, "combined") + a2, _ := r.Resolve("t1", 5, "combined") + if a1 == nil || a1 != a2 { + t.Error("combined tunnel must share one bucket across connections") + } + + // A distinct tunnel gets its own bucket. + b1, _ := r.Resolve("t2", 5, "combined") + if b1 == a1 { + t.Error("distinct tunnels must not share a bucket") + } + + // Rate-only change applies in place to the shared bucket. + a3, _ := r.Resolve("t1", 10, "combined") + if a3 != a1 { + t.Error("rate-only change must keep the shared bucket") + } + if a3.(*rate.Limiter).Limit() != rate.Limit(10*125_000) { + t.Error("rate-only change not applied to the shared bucket") + } + + // Release drops it; the next resolve builds fresh. + r.Release("t1") + a4, _ := r.Resolve("t1", 10, "combined") + if a4 == a1 { + t.Error("after Release a fresh bucket must be built") + } +} + +// TestThrottleEnforcesRate proves the constructed limiter actually delays a +// transfer to the configured rate (not just that construction looks right). +func TestThrottleEnforcesRate(t *testing.T) { + const mbps = 5 // 625000 B/s, burst = relay.BufSize + in, _ := bwcap.Resolve(bwcap.BuildSet(mbps, "combined")) + ctx := context.Background() + + // Drive ~250000 bytes beyond the initial burst; at 625000 B/s that is ~0.4s. + total := relay.BufSize + 250_000 + start := time.Now() + for sent := 0; sent < total; { + n := 50_000 + if total-sent < n { + n = total - sent + } + if err := in.WaitN(ctx, n); err != nil { + t.Fatal(err) + } + sent += n + } + elapsed := time.Since(start) + if elapsed < 250*time.Millisecond { + t.Errorf("transfer finished in %s: rate not enforced", elapsed) + } + if elapsed > 1500*time.Millisecond { + t.Errorf("transfer took %s: throttled well below the configured rate", elapsed) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 347c98e..90b669b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,22 +24,44 @@ const ( RoleGateway Role = "gateway" ) -// TransportMux multiplexes all data streams over the single control -// connection; TransportPerConn opens a fresh outbound data connection per -// proxied client (avoids TCP head-of-line blocking at the cost of more -// connections). +// Transport selects the agent's data plane. TransportMux multiplexes all +// streams over one TCP control connection (one player's loss stalls all). +// TransportPerConn opens a fresh outbound TCP connection per proxied client +// (kills cross-player head-of-line blocking at the cost of more connections and +// NAT entries). TransportQUIC keeps one connection like mux but flow-controls +// each player's stream independently, so it kills HOL without the extra +// connections — and survives the agent's IP changing (passive QUIC migration). +// TransportAuto prefers QUIC and falls back to per-conn then mux when the QUIC +// (UDP) handshake fails — e.g. an ISP blocks UDP — re-probing on reconnect and +// on network changes; the explicit values force one transport. const ( TransportMux = "mux" TransportPerConn = "per-conn" + TransportQUIC = "quic" + TransportAuto = "auto" ) const ( TunnelTCP = "tcp" - // TunnelUDP relays datagrams for Bedrock/Geyser (RakNet). Requires the - // tunnel-udp capability on both peers. + // TunnelUDP relays datagrams for Bedrock/Geyser (RakNet). Not yet + // implemented — config accepts it but the gateway rejects type:"udp". TunnelUDP = "udp" ) +// Bandwidth-cap scope values (options.bandwidth_limit_scope), selecting what a +// tunnel's cap applies to. Empty normalizes to combined. Enforcement semantics +// live in internal/bwcap. +const ( + // BandwidthScopeCombined shares one bucket across both directions and all + // of the tunnel's connections. + BandwidthScopeCombined = "combined" + // BandwidthScopePerDirection caps inbound and outbound independently, each + // summed across the tunnel's connections. + BandwidthScopePerDirection = "per-direction" + // BandwidthScopePerConnection gives every connection its own bucket. + BandwidthScopePerConnection = "per-connection" +) + type Config struct { Role Role `toml:"role"` Agent AgentConfig `toml:"agent"` @@ -59,8 +81,13 @@ type AgentConfig struct { GatewayPort int `toml:"gateway_port"` Token string `toml:"token"` CertFingerprint string `toml:"cert_fingerprint"` // "sha256:" of the gateway's pinned cert - Transport string `toml:"transport"` // "mux" | "per-conn" + Transport string `toml:"transport"` // "auto" | "quic" | "per-conn" | "mux" Tunnels []Tunnel `toml:"tunnels"` + // EnrollTicket is a one-time pairing ticket carried on the first connect to a + // gateway running per-agent identity; the agent clears it once the gateway + // confirms enrollment. Empty in steady state (the Ed25519 identity authenticates + // thereafter). + EnrollTicket string `toml:"enroll_ticket"` } type Tunnel struct { @@ -86,6 +113,9 @@ type TunnelOptions struct { OfflineMOTD string `toml:"offline_motd"` // BandwidthLimitMbps caps this tunnel's throughput; 0 = unlimited. BandwidthLimitMbps int `toml:"bandwidth_limit_mbps"` + // BandwidthLimitScope selects what the cap applies to: combined (default), + // per-direction, or per-connection. Ignored when the cap is 0. + BandwidthLimitScope string `toml:"bandwidth_limit_scope"` } type GatewayConfig struct { @@ -103,6 +133,16 @@ type GatewayConfig struct { MaxConnsGlobal int `toml:"max_conns_global"` MaxConnsPerIP int `toml:"max_conns_per_ip"` AuthAttemptsPerMin int `toml:"auth_attempts_per_min"` + // QUICEnabled binds a UDP QUIC control listener alongside the TCP one, on the + // same port, so agents configured transport="quic" (or "auto") can dial it. + // Default on; a missing key inherits that (Load overlays onto Default). Set + // false to serve TCP transports only. + QUICEnabled bool `toml:"quic_enabled"` + // AcceptSharedToken keeps the legacy one-token-for-all authenticator enabled + // alongside per-agent identity, so an existing fleet can migrate gradually. + // Default on (Load overlays onto Default); set false once every agent has + // enrolled to fully close the shared-token supersede/port-squat risk. + AcceptSharedToken bool `toml:"accept_shared_token"` } type MetricsConfig struct { @@ -147,7 +187,7 @@ func Default() *Config { return &Config{ Agent: AgentConfig{ GatewayPort: DefaultControlPort, - Transport: TransportMux, + Transport: TransportAuto, }, Gateway: GatewayConfig{ BindAddr: "0.0.0.0", @@ -155,6 +195,8 @@ func Default() *Config { MaxConnsGlobal: 4096, MaxConnsPerIP: 32, AuthAttemptsPerMin: 10, + QUICEnabled: true, + AcceptSharedToken: true, }, Metrics: MetricsConfig{ PrometheusAddr: "127.0.0.1:9464", @@ -314,8 +356,10 @@ func (c *Config) validateAgent() []error { if a.CertFingerprint != "" && !strings.HasPrefix(a.CertFingerprint, "sha256:") { errs = append(errs, fmt.Errorf("agent.cert_fingerprint: must start with \"sha256:\", got %q", a.CertFingerprint)) } - if a.Transport != TransportMux && a.Transport != TransportPerConn { - errs = append(errs, fmt.Errorf("agent.transport: must be %q or %q, got %q", TransportMux, TransportPerConn, a.Transport)) + switch a.Transport { + case TransportMux, TransportPerConn, TransportQUIC, TransportAuto: + default: + errs = append(errs, fmt.Errorf("agent.transport: must be one of %q, %q, %q, %q, got %q", TransportAuto, TransportQUIC, TransportPerConn, TransportMux, a.Transport)) } seenID := map[string]bool{} // Ports are per protocol: a TCP and a UDP tunnel may share a number. @@ -359,11 +403,27 @@ func (c *Config) validateAgent() []error { } if t.Options.BandwidthLimitMbps < 0 { errs = append(errs, fmt.Errorf("%s: bandwidth_limit_mbps: must be >= 0", where)) + } else if t.Options.BandwidthLimitMbps > 0 && !validBandwidthScope(t.Options.BandwidthLimitScope) { + // Scope is meaningless without a cap, so it is only checked when one + // is set; the gateway normalizes whatever arrives on the wire. + errs = append(errs, fmt.Errorf("%s: bandwidth_limit_scope: must be %q, %q, or %q (empty = %q), got %q", + where, BandwidthScopeCombined, BandwidthScopePerDirection, BandwidthScopePerConnection, BandwidthScopeCombined, t.Options.BandwidthLimitScope)) } } return errs } +// validBandwidthScope reports whether s is an accepted options.bandwidth_limit_scope +// value. Empty is accepted (normalizes to combined at enforcement time). +func validBandwidthScope(s string) bool { + switch s { + case "", BandwidthScopeCombined, BandwidthScopePerDirection, BandwidthScopePerConnection: + return true + default: + return false + } +} + func (c *Config) validateGateway() []error { var errs []error g := &c.Gateway diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 894ebe7..2c6c478 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -127,6 +127,13 @@ func TestValidateCatches(t *testing.T) { dup.ID = NewID() c.Agent.Tunnels = append(c.Agent.Tunnels, dup) }, "already used by another enabled udp tunnel"}, + "negative bandwidth": {func(c *Config) { + c.Agent.Tunnels[0].Options.BandwidthLimitMbps = -1 + }, "bandwidth_limit_mbps"}, + "bad bandwidth scope": {func(c *Config) { + c.Agent.Tunnels[0].Options.BandwidthLimitMbps = 10 + c.Agent.Tunnels[0].Options.BandwidthLimitScope = "sideways" + }, "bandwidth_limit_scope"}, "retention too short": {func(c *Config) { c.Analytics.RetentionDays = 0 }, "retention_days"}, "retention too long": {func(c *Config) { c.Analytics.RetentionDays = 4000 }, "retention_days"}, "geoip not mmdb": {func(c *Config) { c.Analytics.GeoIPCityPath = `C:\geo\GeoLite2-City.zip` }, "geoip_city_path"}, @@ -156,6 +163,16 @@ func TestValidateAccepts(t *testing.T) { udp.Type = TunnelUDP c.Agent.Tunnels = append(c.Agent.Tunnels, udp) }, + "bandwidth cap with explicit scope": func(c *Config) { + c.Agent.Tunnels[0].Options.BandwidthLimitMbps = 5 + c.Agent.Tunnels[0].Options.BandwidthLimitScope = BandwidthScopePerConnection + }, + "bandwidth cap with empty scope": func(c *Config) { + c.Agent.Tunnels[0].Options.BandwidthLimitMbps = 5 // empty scope normalizes to combined + }, + "bandwidth scope ignored without a cap": func(c *Config) { + c.Agent.Tunnels[0].Options.BandwidthLimitScope = "sideways" // no cap, so not validated + }, } for name, mutate := range cases { t.Run(name, func(t *testing.T) { diff --git a/internal/conntrack/conntrack.go b/internal/conntrack/conntrack.go index 9c62145..b974e36 100644 --- a/internal/conntrack/conntrack.go +++ b/internal/conntrack/conntrack.go @@ -26,7 +26,11 @@ type PlayerInfo struct { // Entry is one live proxied connection. Counter direction is explicit: // In = client → server bytes, Out = server → client bytes. type Entry struct { - ID uint64 + ID uint64 + // AgentID owns this connection's tunnel. On a multi-agent gateway two + // agents may serve the same TunnelID, so attribution needs both. Fixed at + // Open, before the entry is published. Empty on legacy/single-agent paths. + AgentID string TunnelID string TunnelName string ClientAddr string @@ -107,6 +111,7 @@ func (e *Entry) bytesOut() int64 { // Snapshot is the GUI-facing view of one connection. type Snapshot struct { ID uint64 `json:"id"` + AgentID string `json:"agentId,omitempty"` TunnelID string `json:"tunnelId"` TunnelName string `json:"tunnelName"` ClientAddr string `json:"clientAddr"` @@ -131,6 +136,12 @@ type Registry struct { closedIn atomic.Int64 closedOut atomic.Int64 + // closedByAgent accumulates closed-connection bytes per owning agent so + // AgentTotals stays monotonic across connection churn (a closing conn moves + // its bytes here, not out of the per-agent total). Guarded by mu; keyed by + // AgentID ("" on single-agent/agent-side paths). + closedByAgent map[string][2]int64 + // Optional observers; set before traffic flows. Hooks run outside the // registry lock and must not call back into the registry. onOpen func(e *Entry) @@ -140,7 +151,10 @@ type Registry struct { } func NewRegistry() *Registry { - return &Registry{conns: make(map[uint64]*Entry)} + return &Registry{ + conns: make(map[uint64]*Entry), + closedByAgent: make(map[string][2]int64), + } } // SetHooks installs connection observers: onOpen fires when a connection is @@ -153,13 +167,15 @@ func (r *Registry) SetHooks(onOpen func(e *Entry), onClose func(e *Entry, bytesI } // Open registers a connection and returns its entry plus a close func to -// call when the splice ends. connKey is the control-link correlation id ("" -// when the transport assigns none) and is fixed here, before the entry is -// published, so it is never written after another goroutine can see it. +// call when the splice ends. agentID is the owning agent ("" on the agent +// side / single-agent legacy paths). connKey is the control-link correlation +// id ("" when the transport assigns none) and is fixed here, before the entry +// is published, so it is never written after another goroutine can see it. // inIsAToB declares counter orientation: true when the splice's first // argument is the client-facing leg. -func (r *Registry) Open(tunnelID, tunnelName, clientAddr, connKey string, inIsAToB bool) (*Entry, func()) { +func (r *Registry) Open(agentID, tunnelID, tunnelName, clientAddr, connKey string, inIsAToB bool) (*Entry, func()) { e := &Entry{ + AgentID: agentID, TunnelID: tunnelID, TunnelName: tunnelName, ClientAddr: clientAddr, @@ -189,6 +205,10 @@ func (r *Registry) Open(tunnelID, tunnelName, clientAddr, connKey string, inIsAT in, out := e.bytesIn(), e.bytesOut() r.closedIn.Add(in) r.closedOut.Add(out) + cb := r.closedByAgent[e.AgentID] + cb[0] += in + cb[1] += out + r.closedByAgent[e.AgentID] = cb delete(r.conns, e.ID) r.mu.Unlock() if r.onClose != nil { @@ -206,6 +226,7 @@ func (r *Registry) Snapshot() []Snapshot { for _, e := range r.conns { s := Snapshot{ ID: e.ID, + AgentID: e.AgentID, TunnelID: e.TunnelID, TunnelName: e.TunnelName, ClientAddr: e.ClientAddr, @@ -274,6 +295,56 @@ func (r *Registry) EntryByConnKey(key string) *Entry { return nil } +// AgentTraffic is one agent's monotonic byte totals plus its current live +// connection and identified-player counts — the per-agent inputs to the +// bandwidth-history sampler. +type AgentTraffic struct { + BytesIn int64 + BytesOut int64 + Conns int + Players int +} + +// AgentTotals groups traffic by owning agent: monotonic bytes (closed + live) +// so the sampler's deltas never dip on a connection close, plus live conn and +// deduped-player counts. Keyed by AgentID; the "" key covers the single-agent / +// agent-side paths. O(live conns) under one lock hold — called at the sample +// cadence, not per byte. +func (r *Registry) AgentTotals() map[string]AgentTraffic { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]AgentTraffic, len(r.closedByAgent)+1) + for id, cb := range r.closedByAgent { + out[id] = AgentTraffic{BytesIn: cb[0], BytesOut: cb[1]} + } + seen := make(map[string]map[string]struct{}) + for _, e := range r.conns { + at := out[e.AgentID] + at.BytesIn += e.bytesIn() + at.BytesOut += e.bytesOut() + at.Conns++ + out[e.AgentID] = at + if p := e.player.Load(); p != nil { + key := p.UUID + if key == "" { + key = "name:" + strings.ToLower(p.Name) + } + s := seen[e.AgentID] + if s == nil { + s = make(map[string]struct{}) + seen[e.AgentID] = s + } + s[key] = struct{}{} + } + } + for id, s := range seen { + at := out[id] + at.Players = len(s) + out[id] = at + } + return out +} + // Totals reports lifetime bytes (closed + live), for bandwidth graphs that // sample and diff. func (r *Registry) Totals() (in, out int64) { diff --git a/internal/conntrack/conntrack_test.go b/internal/conntrack/conntrack_test.go index 879eaa0..0cb0f37 100644 --- a/internal/conntrack/conntrack_test.go +++ b/internal/conntrack/conntrack_test.go @@ -9,9 +9,9 @@ func TestOpenSnapshotClose(t *testing.T) { r := NewRegistry() // e1: splice's first arg is the client leg (inIsAToB=true). - e1, close1 := r.Open("t1", "Tunnel", "1.2.3.4:1111", "k1", true) + e1, close1 := r.Open("agentA", "t1", "Tunnel", "1.2.3.4:1111", "k1", true) // e2: reversed orientation (inIsAToB=false). - e2, close2 := r.Open("t1", "Tunnel", "1.2.3.4:2222", "", false) + e2, close2 := r.Open("agentA", "t1", "Tunnel", "1.2.3.4:2222", "", false) e1.Counters.AToB.Store(100) // in e1.Counters.BToA.Store(10) // out @@ -29,6 +29,11 @@ func TestOpenSnapshotClose(t *testing.T) { if snaps[0].ID > snaps[1].ID { t.Fatalf("Snapshot not sorted by ID: %d before %d", snaps[0].ID, snaps[1].ID) } + // AgentID passed to Open must survive into the snapshot — attribution on a + // multi-agent gateway depends on it. + if snaps[0].AgentID != "agentA" || snaps[1].AgentID != "agentA" { + t.Errorf("Snapshot AgentID = %q, %q, want both agentA", snaps[0].AgentID, snaps[1].AgentID) + } if snaps[0].BytesIn != 100 || snaps[0].BytesOut != 10 { t.Errorf("e1 snapshot bytes = in %d out %d, want in 100 out 10", snaps[0].BytesIn, snaps[0].BytesOut) } @@ -70,7 +75,7 @@ func TestConnKeySetBeforeHooks(t *testing.T) { var seen string r.SetHooks(func(e *Entry) { seen = e.ConnKey }, nil, nil, nil) - e, closeEntry := r.Open("t1", "Tunnel", "1.2.3.4:1111", "key-42", true) + e, closeEntry := r.Open("", "t1", "Tunnel", "1.2.3.4:1111", "key-42", true) defer closeEntry() if seen != "key-42" { t.Fatalf("onOpen saw ConnKey %q, want key-42", seen) @@ -87,9 +92,9 @@ func TestConnKeySetBeforeHooks(t *testing.T) { // keyed by UUID or (for UUID-less handshakes) by name. func TestPlayerCountDedupes(t *testing.T) { r := NewRegistry() - e1, c1 := r.Open("t", "T", "1.2.3.4:1", "", true) - e2, c2 := r.Open("t", "T", "1.2.3.4:2", "", true) - e3, c3 := r.Open("t", "T", "1.2.3.4:3", "", true) + e1, c1 := r.Open("", "t", "T", "1.2.3.4:1", "", true) + e2, c2 := r.Open("", "t", "T", "1.2.3.4:2", "", true) + e3, c3 := r.Open("", "t", "T", "1.2.3.4:3", "", true) defer c1() defer c2() defer c3() @@ -114,7 +119,7 @@ func TestTotalsMonotonicDuringClose(t *testing.T) { const n = 500 closers := make([]func(), 0, n) for i := 0; i < n; i++ { - e, c := r.Open("t", "T", "1.2.3.4:1", "", true) + e, c := r.Open("", "t", "T", "1.2.3.4:1", "", true) e.Counters.AToB.Store(1000) closers = append(closers, c) } diff --git a/internal/control/control.go b/internal/control/control.go index c69ccd1..e5f631b 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -5,11 +5,14 @@ package control import ( + "crypto/sha256" "encoding/binary" + "encoding/hex" "encoding/json" "errors" "fmt" "io" + "sort" ) // ProtocolVersion is negotiated in the hello exchange; incompatible peers are @@ -31,21 +34,33 @@ const ( // full-set desired-state tunnel sync instead of per-tunnel // register/unregister frames. CapTunnelSync = "tunnel-sync" - // CapTunnelUDP: the peer relays type:"udp" tunnel specs. Each UDP flow - // (one client source address) gets its own data stream: the usual - // OpenConn header, then raw datagrams framed as a 2-byte big-endian - // length + payload (not JSON envelopes). - CapTunnelUDP = "tunnel-udp" // CapConnStats: the agent accepts TypeConnStats frames carrying the // gateway's per-connection RTT measurements (keyed by OpenConn.ConnID) so // the agent's GUI and analytics can attribute a real network RTT to each // player. Gateway → agent only; a legacy agent that never offers it simply // receives no frames. CapConnStats = "conn-stats" + // CapPerConn: the peer runs the data plane as one dedicated, agent-dialed + // TCP+TLS connection per proxied client instead of a shared mux stream, + // eliminating cross-player head-of-line blocking on the gateway↔agent hop. + // The gateway signals TypeOpenData over the control stream; the agent dials + // back a KindData hello carrying the matching ConnID. The agent offers it + // only when configured for per-conn transport; the gateway advertises it + // only once it can serve the data accept path end-to-end. + CapPerConn = "per-conn-data" + // Per-agent identity (Ed25519 pubkey + proof-of-possession + enrollment ticket) + // is NOT a capability: it rides fields carried in the hello itself and is acted + // on by their presence, before capabilities are negotiated — exactly like the + // bandwidth-cap fields. See internal/gateway/auth.go. + // + // CapGatewayConfig: the gateway holds the authoritative tunnel config and + // reconciles it via TypePushConfig/TypeProposeConfig against the agent's + // reported ConfigHash. NOT advertised until implemented end-to-end. + CapGatewayConfig = "gateway-config" ) // SupportedCapabilities is everything this build implements, both roles. -var SupportedCapabilities = []string{CapTunnelSync, CapTunnelUDP, CapConnStats} +var SupportedCapabilities = []string{CapTunnelSync, CapConnStats, CapPerConn, CapGatewayConfig} // IntersectCaps returns offered ∩ supported, preserving supported's order. // Nil-safe on both arguments; unknown offered strings are simply dropped. @@ -108,6 +123,14 @@ const ( TypeSyncResult = "sync_result" // gateway → agent: per-tunnel outcomes // Per-connection RTT report, gateway → agent (requires CapConnStats). TypeConnStats = "conn_stats" + // Per-conn data-plane setup, gateway → agent control stream (requires + // CapPerConn): asks the agent to dial back a fresh KindData connection + // carrying this ConnID so the gateway can match it to the waiting player. + TypeOpenData = "open_data" + // Gateway-authoritative config sync (requires CapGatewayConfig). + TypePushConfig = "push_config" // gateway → agent: authoritative tunnel set + TypeProposeConfig = "propose_config" // agent → gateway: promote a local edit + TypeConfigAck = "config_ack" // agent → gateway: applied generation/hash ) // Hello error codes. @@ -115,6 +138,9 @@ const ( ErrCodeBadToken = "bad_token" ErrCodeAgentConflict = "agent_conflict" ErrCodeVersion = "version" + // ErrCodeRevoked: the gateway revoked this agent's identity. Fatal on the agent + // (stop and re-pair with a fresh code), never retried. + ErrCodeRevoked = "revoked" ) // Register error codes. @@ -154,12 +180,33 @@ type Hello struct { // peers stay byte-identical to v1. Hostname string `json:"hostname,omitempty"` LocalIPs []string `json:"localIps,omitempty"` + // AgentPubKey is the agent's long-term Ed25519 public key; AgentSig is its + // signature over this connection's TLS channel binding (proof of possession). + // Together they authenticate the agent per-identity against the gateway's + // allowlist (CapEnroll). Both omitempty: a legacy agent that only knows the + // shared token sends neither and the gateway falls back to the token. + AgentPubKey []byte `json:"agentPubKey,omitempty"` + AgentSig []byte `json:"agentSig,omitempty"` + // EnrollTicket is a pairing ticket present only on a first-contact enrollment + // hello; the gateway consumes it, allowlists AgentPubKey, and returns the + // assigned identity in HelloOK. Empty on every later hello. + EnrollTicket string `json:"enrollTicket,omitempty"` + // ConfigHash / ConfigGeneration report the agent's current view of the + // gateway-authoritative tunnel config so drift is caught on every reconnect + // (CapGatewayConfig). Zero on a legacy/first hello — the gateway then pushes its + // authoritative set. + ConfigHash string `json:"configHash,omitempty"` + ConfigGeneration uint64 `json:"configGeneration,omitempty"` } type HelloOK struct { - ProtocolVersion int `json:"protocolVersion"` - Generation uint64 `json:"generation"` - AppVersion string `json:"appVersion"` + ProtocolVersion int `json:"protocolVersion"` + // SessionGeneration is the gateway's per-session supersede ordinal (a newer + // connection from the same agent supersedes the older one). It is unrelated to + // Hello.ConfigGeneration, which versions the gateway-authoritative tunnel config; + // the json tag stays "generation" so frames to/from legacy peers are byte-identical. + SessionGeneration uint64 `json:"generation"` + AppVersion string `json:"appVersion"` // Capabilities is the negotiated set: offered ∩ gateway-supported. Capabilities []string `json:"capabilities,omitempty"` // Hostname / LocalIPs identify the gateway's machine for the agent's GUI. @@ -168,6 +215,18 @@ type HelloOK struct { // ObservedIP is the agent's public IP as the gateway sees it (the source // address of this connection) — the agent has no other way to learn it. ObservedIP string `json:"observedIp,omitempty"` + // AssignedAgentID / GatewayID are returned on an enrollment hello_ok: the + // derived agt_ identity the gateway recorded for this pubkey, and the gateway's + // own gw_ display identity. Both omitempty; a steady-state or legacy hello_ok + // omits them. + AssignedAgentID string `json:"assignedAgentId,omitempty"` + GatewayID string `json:"gatewayId,omitempty"` + // ConfigSeedNeeded asks an enrolled gateway-config agent to seed its tunnel set + // (send a propose_config): true only on first contact, when the gateway holds no + // authoritative config for this identity yet. Otherwise the gateway is the source + // of truth and pushes; the agent never volunteers a set. omitempty keeps legacy + // and steady-state hello_ok frames byte-identical. + ConfigSeedNeeded bool `json:"configSeedNeeded,omitempty"` } type HelloErr struct { @@ -175,12 +234,13 @@ type HelloErr struct { Message string `json:"message"` } -// TunnelSpec is what the gateway needs to know about a tunnel; agent-side -// options (PP2, Minecraft awareness, bandwidth caps) stay on the agent. +// TunnelSpec is what the gateway needs to know about a tunnel. The bandwidth cap +// rides along so the gateway can throttle its half of the splice too; purely +// agent-local options (PP2) stay on the agent. type TunnelSpec struct { ID string `json:"id"` Name string `json:"name"` - Type string `json:"type"` // tcp | udp (udp requires CapTunnelUDP) + Type string `json:"type"` // tcp | udp; udp not yet implemented (gateway rejects it) // PublicPort 0 asks the gateway to pick an ephemeral port. PublicPort int `json:"publicPort"` // OfflineMOTD, when set, keeps the public port answering Minecraft status @@ -190,6 +250,12 @@ type TunnelSpec struct { // attribute connections to player names. omitempty keeps frames to legacy // gateways byte-identical to v1. MinecraftAware bool `json:"minecraftAware,omitempty"` + // BandwidthLimitMbps caps this tunnel at the gateway; 0 = unlimited. omitempty + // keeps frames to legacy gateways byte-identical (0 = a legacy/uncapped peer). + BandwidthLimitMbps int `json:"bandwidthLimitMbps,omitempty"` + // BandwidthLimitScope selects what the cap applies to (combined | per-direction + // | per-connection); "" = combined, and a legacy peer sends nothing. + BandwidthLimitScope string `json:"bandwidthLimitScope,omitempty"` } type Register struct { @@ -271,6 +337,15 @@ type OpenConn struct { ConnID string `json:"connId,omitempty"` } +// OpenData asks the agent to dial back a fresh KindData TCP+TLS connection +// carrying this ConnID, so the gateway can match it to a pending player. +// Gateway → agent on the control stream, only under CapPerConn. Routing +// (tunnelId, clientAddr) still travels in the OpenConn header written on the +// data connection itself, so the agent's data handler is reused unchanged. +type OpenData struct { + ConnID string `json:"connId"` +} + // ConnStat is one connection's measured round-trip time. ConnID matches the // OpenConn.ConnID the gateway issued for that connection. type ConnStat struct { @@ -285,11 +360,55 @@ type ConnStats struct { Entries []ConnStat `json:"entries"` } +// PushConfig carries the gateway-authoritative desired tunnel set to the agent +// (CapGatewayConfig), gateway → agent. Generation is monotonic per agent and Hash +// is the content hash the agent echoes in later hellos so drift is detectable. +// Tunnel counts are small, so — like SyncTunnels — this rides a single frame. +type PushConfig struct { + Generation uint64 `json:"generation"` + Hash string `json:"hash"` + Tunnels []TunnelSpec `json:"tunnels"` +} + +// ProposeConfig promotes an agent-side edit to the gateway (CapGatewayConfig), +// agent → gateway. The gateway validates, adopts it as the new authoritative set, +// bumps the generation, and re-pushes — so edits reconcile deterministically +// instead of last-write-wins. +type ProposeConfig struct { + Tunnels []TunnelSpec `json:"tunnels"` +} + +// ConfigAck confirms the agent applied a PushConfig, agent → gateway, echoing the +// generation and hash it now holds. +type ConfigAck struct { + Generation uint64 `json:"generation"` + Hash string `json:"hash"` +} + // MaxConnStatsPerFrame bounds one conn_stats frame. Each entry is a short id // plus a number (well under 64 bytes), so 200 keeps the frame far below // MaxFrame's 64 KiB. const MaxConnStatsPerFrame = 200 +// HashTunnels is the canonical content hash of a desired tunnel set: the single +// value both sides compare to detect config drift (CapGatewayConfig). It is +// order-independent (specs are sorted by ID first) so an agent and gateway that +// hold the same set in different orders still agree, and covers every wire field +// of each spec, so any change the gateway cares about flips the hash. The empty +// set has a fixed, non-empty hash (both peers compute the same value for it). +func HashTunnels(specs []TunnelSpec) string { + sorted := append([]TunnelSpec(nil), specs...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID }) + h := sha256.New() + enc := json.NewEncoder(h) + for i := range sorted { + // Encode writes each spec's canonical JSON (stable struct field order, + // omitempty applied identically on both peers) plus a newline delimiter. + _ = enc.Encode(sorted[i]) + } + return hex.EncodeToString(h.Sum(nil)) +} + var ( ErrFrameTooLarge = errors.New("control: frame exceeds size limit") ErrUnknownType = errors.New("control: unknown message type") diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 775e52e..6b5c5de 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -95,7 +95,7 @@ func TestLegacyHelloCompat(t *testing.T) { if bytes.Contains(out, []byte("capabilities")) { t.Fatalf("nil capabilities leaked into JSON: %s", out) } - okOut, err := json.Marshal(HelloOK{ProtocolVersion: 1, Generation: 1}) + okOut, err := json.Marshal(HelloOK{ProtocolVersion: 1, SessionGeneration: 1}) if err != nil { t.Fatal(err) } @@ -143,18 +143,76 @@ func TestCapSet(t *testing.T) { func TestSupportedCapabilities(t *testing.T) { s := NewCapSet(SupportedCapabilities) - if !s.Has(CapTunnelSync) || !s.Has(CapTunnelUDP) || !s.Has(CapConnStats) { + if !s.Has(CapTunnelSync) || !s.Has(CapConnStats) || !s.Has(CapPerConn) || !s.Has(CapGatewayConfig) { t.Fatalf("supported set missing a built-in capability: %v", SupportedCapabilities) } - // A sync-only peer (older build) must negotiate away tunnel-udp and - // conn-stats — an old agent that never offers conn-stats gets no RTT - // frames. + // tunnel-udp is deliberately NOT advertised: it isn't implemented + // end-to-end (the gateway rejects udp specs), so offering it would be a + // protocol lie. + if s.Has("tunnel-udp") { + t.Fatal("tunnel-udp must not be advertised until it is implemented") + } + // A sync-only peer (older build) must negotiate away conn-stats — an old + // agent that never offers conn-stats gets no RTT frames. got := IntersectCaps(SupportedCapabilities, []string{CapTunnelSync}) if !reflect.DeepEqual(got, []string{CapTunnelSync}) { t.Fatalf("against a sync-only peer: got %v want [tunnel-sync]", got) } } +func TestHashTunnels(t *testing.T) { + a := []TunnelSpec{ + {ID: "t1", Name: "mc", Type: "tcp", PublicPort: 25565}, + {ID: "t2", Name: "eph", Type: "tcp", OfflineMOTD: "brb"}, + } + // Order-independent: the same set in a different order hashes identically. + reversed := []TunnelSpec{a[1], a[0]} + if HashTunnels(a) != HashTunnels(reversed) { + t.Fatalf("hash depends on order: %s != %s", HashTunnels(a), HashTunnels(reversed)) + } + // A non-empty set has a non-empty hash. + if HashTunnels(a) == "" { + t.Fatal("hash of a non-empty set must not be empty") + } + // Any wire-field change flips the hash. + changed := append([]TunnelSpec(nil), a...) + changed[0].PublicPort = 25566 + if HashTunnels(a) == HashTunnels(changed) { + t.Fatal("hash ignored a PublicPort change") + } + // The empty set is stable across nil and empty, and never collides with a + // populated set. + if HashTunnels(nil) != HashTunnels([]TunnelSpec{}) { + t.Fatalf("empty-set hash unstable: %s != %s", HashTunnels(nil), HashTunnels([]TunnelSpec{})) + } + if HashTunnels(nil) == HashTunnels(a) { + t.Fatal("empty and populated sets collide") + } + // Deterministic across calls (no map iteration order leaking in). + if HashTunnels(a) != HashTunnels(a) { + t.Fatal("hash is not deterministic") + } +} + +func TestOpenDataRoundTrip(t *testing.T) { + var buf bytes.Buffer + in := OpenData{ConnID: "12345"} + if err := WriteMsg(&buf, TypeOpenData, in); err != nil { + t.Fatalf("write: %v", err) + } + env, err := ReadMsg(&buf, MaxFrame) + if err != nil || env.Type != TypeOpenData { + t.Fatalf("read: %v type=%q", err, env.Type) + } + got, err := Decode[OpenData](env) + if err != nil { + t.Fatalf("decode: %v", err) + } + if *got != in { + t.Fatalf("round trip: got %+v want %+v", *got, in) + } +} + func TestConnStatsRoundTrip(t *testing.T) { var buf bytes.Buffer in := ConnStats{Entries: []ConnStat{{ConnID: "42", RttMs: 23.5}, {ConnID: "7", RttMs: 101}}} diff --git a/internal/control/hello_compat_test.go b/internal/control/hello_compat_test.go index 7dd3d90..5d93e49 100644 --- a/internal/control/hello_compat_test.go +++ b/internal/control/hello_compat_test.go @@ -45,13 +45,28 @@ func TestHelloWireBackCompat(t *testing.T) { } func TestHelloOKAndPongOmitEmpty(t *testing.T) { - ok := control.HelloOK{ProtocolVersion: 1, Generation: 3} + ok := control.HelloOK{ProtocolVersion: 1, SessionGeneration: 3} b, _ := json.Marshal(ok) - for _, f := range []string{"hostname", "localIps", "observedIp"} { + for _, f := range []string{"hostname", "localIps", "observedIp", "assignedAgentId", "gatewayId", "configSeedNeeded"} { if strings.Contains(string(b), f) { t.Errorf("empty HelloOK.%s leaked onto the wire: %s", f, b) } } + // SessionGeneration keeps the legacy json tag so frames stay byte-identical to + // a peer that predates the rename. + if !strings.Contains(string(b), `"generation":3`) || strings.Contains(string(b), "sessionGeneration") { + t.Errorf("SessionGeneration must marshal as \"generation\": %s", b) + } + // The enrollment identity fields round-trip when set. + ok2 := control.HelloOK{AssignedAgentID: "agt_k7q2f9m3", GatewayID: "gw_3h8xd0p5"} + b2, _ := json.Marshal(ok2) + var g2 control.HelloOK + if err := json.Unmarshal(b2, &g2); err != nil { + t.Fatal(err) + } + if g2.AssignedAgentID != "agt_k7q2f9m3" || g2.GatewayID != "gw_3h8xd0p5" { + t.Errorf("HelloOK identity round-trip failed: %+v", g2) + } // A legacy pong (no gateway receive time) must omit recvUnixNano so the // agent can detect the absence and skip one-way math. @@ -66,3 +81,84 @@ func TestHelloOKAndPongOmitEmpty(t *testing.T) { t.Errorf("set Pong.RecvUnixNano missing from the wire: %s", bp2) } } + +// The enrollment + gateway-config hello fields are additive: a legacy hello +// (shared-token only) must encode without any of them, decode them as zero, and a +// populated hello must round-trip. Byte-identical legacy frames are the invariant. +func TestHelloEnrollmentFieldsBackCompat(t *testing.T) { + legacy := control.Hello{ProtocolVersion: 1, Kind: control.KindControl, AgentID: "a", Token: "t", AppVersion: "v"} + b, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + for _, f := range []string{"agentPubKey", "agentSig", "enrollTicket", "configHash", "configGeneration"} { + if strings.Contains(string(b), f) { + t.Errorf("empty Hello.%s leaked onto the wire: %s", f, b) + } + } + + // A legacy frame decodes with every new field zero. + var got control.Hello + if err := json.Unmarshal([]byte(`{"protocolVersion":1,"kind":"control","agentId":"a","token":"t"}`), &got); err != nil { + t.Fatal(err) + } + if got.AgentPubKey != nil || got.AgentSig != nil || got.EnrollTicket != "" || got.ConfigHash != "" || got.ConfigGeneration != 0 { + t.Errorf("legacy frame produced non-zero enrollment fields: %+v", got) + } + + // A populated enrollment hello round-trips. + full := control.Hello{ + AgentPubKey: []byte{1, 2, 3}, AgentSig: []byte{4, 5, 6}, EnrollTicket: "tkt", + ConfigHash: "h", ConfigGeneration: 9, + } + fb, _ := json.Marshal(full) + var fg control.Hello + if err := json.Unmarshal(fb, &fg); err != nil { + t.Fatal(err) + } + if len(fg.AgentPubKey) != 3 || len(fg.AgentSig) != 3 || fg.EnrollTicket != "tkt" || fg.ConfigHash != "h" || fg.ConfigGeneration != 9 { + t.Errorf("enrollment field round-trip failed: %+v", fg) + } +} + +// The bandwidth-cap fields are additive: an uncapped spec must encode exactly +// like a legacy one, a legacy frame must decode with both fields zero, and a +// set cap must round-trip. +func TestTunnelSpecWireBackCompat(t *testing.T) { + // An uncapped spec must not put either bandwidth key on the wire. + uncapped := control.TunnelSpec{ID: "t1", Name: "web", Type: "tcp", PublicPort: 25565} + b, err := json.Marshal(uncapped) + if err != nil { + t.Fatal(err) + } + for _, f := range []string{"bandwidthLimitMbps", "bandwidthLimitScope"} { + if strings.Contains(string(b), f) { + t.Errorf("uncapped TunnelSpec.%s leaked onto the wire: %s", f, b) + } + } + + // A legacy frame (no bandwidth fields) decodes with both zero. + var got control.TunnelSpec + if err := json.Unmarshal([]byte(`{"id":"t1","name":"web","type":"tcp","publicPort":25565}`), &got); err != nil { + t.Fatal(err) + } + if got.BandwidthLimitMbps != 0 || got.BandwidthLimitScope != "" { + t.Errorf("legacy frame produced non-zero bandwidth fields: %+v", got) + } + + // A set cap round-trips and appears on the wire. + capped := control.TunnelSpec{ID: "t1", Name: "web", Type: "tcp", BandwidthLimitMbps: 5, BandwidthLimitScope: "per-direction"} + b2, _ := json.Marshal(capped) + for _, f := range []string{"bandwidthLimitMbps", "bandwidthLimitScope"} { + if !strings.Contains(string(b2), f) { + t.Errorf("set TunnelSpec.%s missing from the wire: %s", f, b2) + } + } + var g2 control.TunnelSpec + if err := json.Unmarshal(b2, &g2); err != nil { + t.Fatal(err) + } + if g2.BandwidthLimitMbps != 5 || g2.BandwidthLimitScope != "per-direction" { + t.Errorf("TunnelSpec bandwidth round-trip failed: %+v", g2) + } +} diff --git a/internal/e2e/attach_test.go b/internal/e2e/attach_test.go index c979714..9865ebc 100644 --- a/internal/e2e/attach_test.go +++ b/internal/e2e/attach_test.go @@ -35,6 +35,7 @@ func TestAttachedAnalytics(t *testing.T) { cfg := config.Default() cfg.Role = config.RoleAgent cfg.Agent.AgentID = config.NewID() + cfg.Agent.Transport = config.TransportMux // this test is about attach, not the transport ladder cfg.Agent.GatewayHost = "127.0.0.1" cfg.Agent.GatewayPort = 1 // nothing listens; the agent just retries cfg.Agent.Token = config.NewToken() diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index 67e2818..a1fb751 100644 --- a/internal/e2e/e2e_test.go +++ b/internal/e2e/e2e_test.go @@ -14,6 +14,7 @@ import ( "io" "log/slog" "net" + "strconv" "sync" "testing" "time" @@ -80,6 +81,7 @@ type harness struct { gw *gateway.Gateway gwCancel context.CancelFunc agentCfg *config.Config + agentDir string tunnelID string agent *agent.Agent agentCtx context.Context @@ -96,6 +98,24 @@ type harnessOpts struct { offerCaps []string // mcAware marks the tunnel Minecraft-aware so both seams sniff logins. mcAware bool + // offlineMOTD, when set, configures the tunnel's offline responder message. + offlineMOTD string + // bandwidthMbps/bandwidthScope cap the default tunnel (0 = uncapped). + bandwidthMbps int + bandwidthScope string + // transport selects the agent's data-plane transport (config.TransportMux, + // config.TransportPerConn, or config.TransportQUIC); empty leaves the config + // default (mux). The gateway binds its QUIC listener by default, so a QUIC + // agent needs no extra gateway tweak. + transport string + // interpose, if set, is called with the real gateway "host:port" and + // returns the address the agent should dial instead — e.g. a transparent + // TCP proxy that observes the agent→gateway connections. + interpose func(gwAddr string) string + // enroll drives per-agent identity: the harness issues a single-use pairing + // ticket from the gateway, sets it on the agent, and clears the shared token so + // the agent authenticates purely by its Ed25519 identity. + enroll bool } func newHarness(t *testing.T, localAddr string) *harness { @@ -130,10 +150,34 @@ func newHarnessWith(t *testing.T, localAddr string, opts harnessOpts) *harness { agentCfg := config.Default() agentCfg.Role = config.RoleAgent agentCfg.Agent.AgentID = config.NewID() - agentCfg.Agent.GatewayHost = "127.0.0.1" - agentCfg.Agent.GatewayPort = gw.ControlAddr().(*net.TCPAddr).Port + dialAddr := net.JoinHostPort("127.0.0.1", strconv.Itoa(gw.ControlAddr().(*net.TCPAddr).Port)) + if opts.interpose != nil { + dialAddr = opts.interpose(dialAddr) + } + dialHost, dialPortStr, err := net.SplitHostPort(dialAddr) + if err != nil { + t.Fatalf("interpose returned a bad address %q: %v", dialAddr, err) + } + dialPort, _ := strconv.Atoi(dialPortStr) + agentCfg.Agent.GatewayHost = dialHost + agentCfg.Agent.GatewayPort = dialPort agentCfg.Agent.Token = gwCfg.Gateway.Token agentCfg.Agent.CertFingerprint = gw.Fingerprint() + if opts.enroll { + ticket, err := gw.IssuePairingTicket(false, time.Time{}, gateway.Scope{}) + if err != nil { + t.Fatal(err) + } + agentCfg.Agent.EnrollTicket = ticket + agentCfg.Agent.Token = "" // authenticate purely by Ed25519 identity + } + // Pin mux as the harness default so existing tests keep exercising the mux + // path even though the shipped default is "auto"; per-transport coverage is + // opt-in via opts.transport. + agentCfg.Agent.Transport = config.TransportMux + if opts.transport != "" { + agentCfg.Agent.Transport = opts.transport + } agentCfg.Agent.Tunnels = []config.Tunnel{{ ID: tunnelID, Name: "test", @@ -141,10 +185,15 @@ func newHarnessWith(t *testing.T, localAddr string, opts harnessOpts) *harness { LocalAddr: localAddr, PublicPort: 0, // gateway picks Enabled: true, - Options: config.TunnelOptions{MinecraftAware: opts.mcAware}, + Options: config.TunnelOptions{ + MinecraftAware: opts.mcAware, + OfflineMOTD: opts.offlineMOTD, + BandwidthLimitMbps: opts.bandwidthMbps, + BandwidthLimitScope: opts.bandwidthScope, + }, }} - h := &harness{t: t, gw: gw, gwCancel: gwCancel, agentCfg: agentCfg, tunnelID: tunnelID, offerCaps: opts.offerCaps} + h := &harness{t: t, gw: gw, gwCancel: gwCancel, agentCfg: agentCfg, agentDir: t.TempDir(), tunnelID: tunnelID, offerCaps: opts.offerCaps} h.startAgent() t.Cleanup(h.stopAgent) return h @@ -154,7 +203,7 @@ func (h *harness) startAgent() { h.t.Helper() ctx, cancel := context.WithCancel(context.Background()) h.agentCtx, h.agentStop = ctx, cancel - h.agent = agent.New(h.agentCfg, testLogger(h.t).With("side", "agent")) + h.agent = agent.New(h.agentCfg, h.agentDir, testLogger(h.t).With("side", "agent")) if h.offerCaps != nil { h.agent.SetCapabilityOffer(h.offerCaps) } @@ -213,6 +262,142 @@ func roundTrip(t *testing.T, addr string, payload []byte) { } } +// TestPerConnRoundTrip: the per-conn transport moves bytes end to end over a +// dedicated, agent-dialed data connection, and that connection's payload is +// counted into the agent's link totals (not just the control chatter) so the +// GUI's link card stays honest. +func TestPerConnRoundTrip(t *testing.T) { + echoAddr, closeEcho := echoServer(t) + defer closeEcho() + h := newHarnessWith(t, echoAddr, harnessOpts{transport: config.TransportPerConn}) + addr := h.waitPublicPort() + + beforeIn, beforeOut := h.agent.LinkTotalBytes() + payload := bytes.Repeat([]byte("per-conn transport works "), 4096) // ~100 KiB + roundTrip(t, addr, payload) + + // The data conn's bytes must land in the link totals; a control-only count + // would be far below the payload size. + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + in, out := h.agent.LinkTotalBytes() + if in-beforeIn >= int64(len(payload)) && out-beforeOut >= int64(len(payload)) { + return + } + time.Sleep(20 * time.Millisecond) + } + in, out := h.agent.LinkTotalBytes() + t.Fatalf("per-conn data bytes not counted: in delta %d, out delta %d, want ≥ %d each", + in-beforeIn, out-beforeOut, len(payload)) +} + +// TestQUICRoundTrip: the QUIC transport moves bytes end to end over a single +// agent-dialed QUIC connection (one stream per player), and that stream's +// payload is counted into the agent's link totals so the GUI's link card stays +// honest — the QUIC twin of TestPerConnRoundTrip. +func TestQUICRoundTrip(t *testing.T) { + echoAddr, closeEcho := echoServer(t) + defer closeEcho() + h := newHarnessWith(t, echoAddr, harnessOpts{transport: config.TransportQUIC}) + addr := h.waitPublicPort() + + beforeIn, beforeOut := h.agent.LinkTotalBytes() + payload := bytes.Repeat([]byte("quic transport works "), 4096) // ~90 KiB + roundTrip(t, addr, payload) + + // The data stream's payload must land in the link totals; a control-only + // count would be far below the payload size. + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + in, out := h.agent.LinkTotalBytes() + if in-beforeIn >= int64(len(payload)) && out-beforeOut >= int64(len(payload)) { + return + } + time.Sleep(20 * time.Millisecond) + } + in, out := h.agent.LinkTotalBytes() + t.Fatalf("quic stream bytes not counted: in delta %d, out delta %d, want ≥ %d each", + in-beforeIn, out-beforeOut, len(payload)) +} + +// TestQUICMultiplexesPlayers: several players are served concurrently over one +// QUIC connection (one stream each), with no per-player dial-back. Two live +// connections that both echo while open prove the single connection multiplexes +// players — QUIC's whole point, delivered without the extra connections per-conn +// needs. (Cross-stream loss isolation under load is covered by the burst twin.) +func TestQUICMultiplexesPlayers(t *testing.T) { + echoAddr, closeEcho := echoServer(t) + defer closeEcho() + h := newHarnessWith(t, echoAddr, harnessOpts{transport: config.TransportQUIC}) + addr := h.waitPublicPort() + + const players = 3 + conns := make([]net.Conn, players) + for i := range conns { + c, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + t.Fatalf("player %d dial: %v", i, err) + } + defer c.Close() + conns[i] = c + } + // Interleave so all three streams are live at once, not served serially. + buf := make([]byte, 4) + for round := 0; round < 3; round++ { + for i, c := range conns { + c.SetDeadline(time.Now().Add(10 * time.Second)) + if _, err := c.Write([]byte("pkt!")); err != nil { + t.Fatalf("player %d write (round %d): %v", i, round, err) + } + } + for i, c := range conns { + if _, err := io.ReadFull(c, buf); err != nil { + t.Fatalf("player %d read (round %d): %v", i, round, err) + } + if !bytes.Equal(buf, []byte("pkt!")) { + t.Fatalf("player %d echo mismatch: %q", i, buf) + } + } + } +} + +// TestAutoFallsBackWhenQUICBlocked: with the gateway's QUIC listener disabled +// (simulating an ISP/firewall that blocks UDP), an auto-transport agent's QUIC +// handshake fails and it falls through to per-conn over TCP — serving players and +// reporting the transport it settled on. This is the ladder's core promise: QUIC +// by default, graceful degradation when UDP won't pass. +func TestAutoFallsBackWhenQUICBlocked(t *testing.T) { + echoAddr, closeEcho := echoServer(t) + defer closeEcho() + h := newHarnessWith(t, echoAddr, harnessOpts{ + transport: config.TransportAuto, + tweakGateway: func(cfg *config.Config) { + cfg.Gateway.QUICEnabled = false // no UDP listener → QUIC dial fails + }, + }) + addr := h.waitPublicPort() + roundTrip(t, addr, []byte("auto falls back to per-conn when UDP is blocked")) + + if got := h.agent.ActiveTransport(); got != config.TransportPerConn { + t.Fatalf("active transport = %q, want %q (should fall back from blocked QUIC)", got, config.TransportPerConn) + } +} + +// TestAutoUsesQUICWhenAvailable: the ladder's happy path — when the gateway +// serves QUIC, an auto agent connects over it (top of the preference list) rather +// than falling back. +func TestAutoUsesQUICWhenAvailable(t *testing.T) { + echoAddr, closeEcho := echoServer(t) + defer closeEcho() + h := newHarnessWith(t, echoAddr, harnessOpts{transport: config.TransportAuto}) + addr := h.waitPublicPort() + roundTrip(t, addr, []byte("auto prefers quic")) + + if got := h.agent.ActiveTransport(); got != config.TransportQUIC { + t.Fatalf("active transport = %q, want %q (QUIC available, should be preferred)", got, config.TransportQUIC) + } +} + // TestHealthPropagates: the agent's local-target probe result reaches the // gateway over the control stream (the offline responder's data source). func TestHealthPropagates(t *testing.T) { @@ -604,29 +789,478 @@ func TestAgentRestartRebinds(t *testing.T) { } } -// TestSecondAgentRejected: a different agent identity with the same token -// must be refused while the first is connected — and the refusal is fatal -// (no retry hammering). -func TestSecondAgentRejected(t *testing.T) { +// tcpTunnel is a minimal enabled TCP tunnel spec for the two-agent tests. +func tcpTunnel(id, localAddr string, publicPort int) config.Tunnel { + return config.Tunnel{ + ID: id, Name: "t-" + id, Type: config.TunnelTCP, + LocalAddr: localAddr, PublicPort: publicPort, Enabled: true, + } +} + +// addAgent starts another agent against the same gateway with a fresh agentID +// and its own tunnels, and registers its shutdown. Drive it via +// waitPublicPortOf / roundTrip. +func (h *harness) addAgent(t *testing.T, agentID string, tunnels []config.Tunnel) *agent.Agent { + t.Helper() + cfg := config.Default() + *cfg = *h.agentCfg + cfg.Agent.AgentID = agentID + cfg.Agent.Tunnels = tunnels + ag := agent.New(cfg, t.TempDir(), testLogger(t).With("side", "agent-"+agentID[:4])) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- ag.Run(ctx) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Error("added agent did not stop within 10s") + } + }) + return ag +} + +// waitPublicPortOf polls until ag's tunnel is registered, returning its address. +func waitPublicPortOf(t *testing.T, ag *agent.Agent, tunnelID string) string { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if port, ok := ag.TunnelPublicPort(tunnelID); ok { + return fmt.Sprintf("127.0.0.1:%d", port) + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("tunnel %s never became live", tunnelID) + return "" +} + +// TestSecondAgentAdmitted: on the shared token, a second agent with a distinct +// identity is admitted alongside the first (was rejected before multi-agent). +func TestSecondAgentAdmitted(t *testing.T) { + echoA, closeA := echoServer(t) + defer closeA() + echoB, closeB := echoServer(t) + defer closeB() + h := newHarness(t, echoA) + addrA := h.waitPublicPort() + roundTrip(t, addrA, []byte("A up")) + + bTun := config.NewID() + agB := h.addAgent(t, config.NewID(), []config.Tunnel{tcpTunnel(bTun, echoB, 0)}) + addrB := waitPublicPortOf(t, agB, bTun) + + // Both serve concurrently; A is unharmed by B's admission. + roundTrip(t, addrB, []byte("B up")) + roundTrip(t, addrA, []byte("A still up")) + + if tunnels := h.gw.Tunnels(); len(tunnels) != 2 { + t.Fatalf("gateway tunnels = %d, want 2 (one per agent)", len(tunnels)) + } +} + +// TestTwoAgentsSameTunnelID (T1) is the namespacing thesis: two agents use the +// SAME tunnelID string and each binds/serves independently on its own port. +// With a bare-tunnelID listener key, B would steal A's listener and A's port +// would stop serving. +func TestTwoAgentsSameTunnelID(t *testing.T) { + echoA, closeA := echoServer(t) + defer closeA() + echoB, closeB := echoServer(t) + defer closeB() + h := newHarness(t, echoA) // agent A serves h.tunnelID → echoA + addrA := h.waitPublicPort() + + // Agent B reuses the very same tunnelID with its own backend and port. + agB := h.addAgent(t, config.NewID(), []config.Tunnel{tcpTunnel(h.tunnelID, echoB, 0)}) + addrB := waitPublicPortOf(t, agB, h.tunnelID) + + if addrA == addrB { + t.Fatalf("agents sharing tunnelID %q must bind distinct ports, both got %s", h.tunnelID, addrA) + } + roundTrip(t, addrA, []byte("A's tunnel")) + roundTrip(t, addrB, []byte("B's tunnel")) +} + +// TestConcurrentPortRace (T2): two agents race to bind the same public port. +// Exactly one wins it (global FCFS); the loser is not failed but auto-reassigned +// to a free port, so *both* come up and serve — the conflict is surfaced (a GUI +// card), never a dead tunnel. (conflict, #2) +func TestConcurrentPortRace(t *testing.T) { echoAddr, closeEcho := echoServer(t) defer closeEcho() - h := newHarness(t, echoAddr) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() // free it so both agents can contend + + h := newHarness(t, echoAddr) // gateway + agent A (its own ephemeral tunnel) h.waitPublicPort() - intruderCfg := config.Default() - *intruderCfg = *h.agentCfg - intruderCfg.Agent.AgentID = config.NewID() // different identity - intruderCfg.Agent.Tunnels = nil + bTun, cTun := config.NewID(), config.NewID() + agB := h.addAgent(t, config.NewID(), []config.Tunnel{tcpTunnel(bTun, echoAddr, port)}) + agC := h.addAgent(t, config.NewID(), []config.Tunnel{tcpTunnel(cTun, echoAddr, port)}) - intruder := agent.New(intruderCfg, testLogger(t).With("side", "intruder")) - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - err := intruder.Run(ctx) - if !errors.Is(err, agent.ErrAgentConflict) { - t.Fatalf("want ErrAgentConflict, got %v", err) + var pB, pC int + var bOK, cOK bool + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + pB, bOK = agB.TunnelPublicPort(bTun) + pC, cOK = agC.TunnelPublicPort(cTun) + if bOK && cOK { + break + } + time.Sleep(20 * time.Millisecond) + } + if !bOK || !cOK { + t.Fatalf("both agents should come up (one wins %d, the other is reassigned); bOK=%v cOK=%v", port, bOK, cOK) + } + if pB == pC { + t.Fatalf("contending agents must bind distinct ports, both got %d", pB) + } + if (pB == port) == (pC == port) { + t.Fatalf("exactly one agent must win the requested port %d; got pB=%d pC=%d", port, pB, pC) + } + // Both serve — the winner on the requested port, the loser on its reassignment. + roundTrip(t, fmt.Sprintf("127.0.0.1:%d", pB), []byte("B serves")) + roundTrip(t, fmt.Sprintf("127.0.0.1:%d", pC), []byte("C serves")) +} + +// TestAgentChurnPreservesOtherAgent (T3): one agent disconnecting and +// reconnecting (which bumps the global generation) must not invalidate another +// agent's session — currency is decided by map identity, not a generation +// comparison. +func TestAgentChurnPreservesOtherAgent(t *testing.T) { + echoA, closeA := echoServer(t) + defer closeA() + echoB, closeB := echoServer(t) + defer closeB() + h := newHarness(t, echoA) + h.waitPublicPort() + + bTun := config.NewID() + agB := h.addAgent(t, config.NewID(), []config.Tunnel{tcpTunnel(bTun, echoB, 0)}) + addrB := waitPublicPortOf(t, agB, bTun) + roundTrip(t, addrB, []byte("B before")) + + // Churn agent A: stop and restart it (each readmit bumps a.generation). + h.stopAgent() + roundTrip(t, addrB, []byte("B while A down")) + h.startAgent() + h.waitPublicPort() // A back + + roundTrip(t, addrB, []byte("B after A back")) + if p, ok := agB.TunnelPublicPort(bTun); !ok || fmt.Sprintf("127.0.0.1:%d", p) != addrB { + t.Fatalf("agent B's port changed during agent A churn: ok=%v port=%d want %s", ok, p, addrB) + } +} + +// TestEvictionIsolatesAndDrains (T5): evicting one agent closes its listeners +// AND terminates its live connections (the mux is the drain boundary), while +// another agent keeps serving untouched. +func TestEvictionIsolatesAndDrains(t *testing.T) { + echoA, closeA := echoServer(t) + defer closeA() + echoB, closeB := echoServer(t) + defer closeB() + h := newHarness(t, echoA) + addrA := h.waitPublicPort() + + bTun := config.NewID() + agB := h.addAgent(t, config.NewID(), []config.Tunnel{tcpTunnel(bTun, echoB, 0)}) + addrB := waitPublicPortOf(t, agB, bTun) + + // Open a long-lived connection through agent A and confirm it echoes. + connA, err := net.DialTimeout("tcp", addrA, 5*time.Second) + if err != nil { + t.Fatal(err) + } + defer connA.Close() + connA.SetDeadline(time.Now().Add(10 * time.Second)) + if _, err := connA.Write([]byte("ping")); err != nil { + t.Fatal(err) + } + buf := make([]byte, 4) + if _, err := io.ReadFull(connA, buf); err != nil { + t.Fatalf("A conn echo before eviction: %v", err) + } + + // Evict agent A by stopping it. + h.stopAgent() + + // A's public port stops accepting. + portDead := false + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); { + c, derr := net.DialTimeout("tcp", addrA, 500*time.Millisecond) + if derr != nil { + portDead = true + break + } + c.Close() + time.Sleep(20 * time.Millisecond) + } + if !portDead { + t.Fatal("agent A's public port still accepting after eviction") + } + + // A's live connection is torn down (the read returns an error). + connA.SetDeadline(time.Now().Add(5 * time.Second)) + if _, err := connA.Read(buf); err == nil { + t.Fatal("agent A's live connection did not terminate on eviction") + } + + // Agent B is unharmed. + roundTrip(t, addrB, []byte("B survives A eviction")) +} + +// TestPerConnEvictionDrains: evicting a per-conn agent tears down its live +// per-conn data connection. Under per-conn transport the mux is no longer the +// whole drain boundary — the data splices ride dedicated conns — so closeAll +// must also close them (an uncapped splice parked in Read only unblocks on +// conn close). This is the per-conn twin of the drain half of T5. +func TestPerConnEvictionDrains(t *testing.T) { + echoAddr, closeEcho := echoServer(t) + defer closeEcho() + h := newHarnessWith(t, echoAddr, harnessOpts{transport: config.TransportPerConn}) + addr := h.waitPublicPort() + + // A long-lived connection with a confirmed echo, so its per-conn data conn + // is live and spliced. + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(10 * time.Second)) + if _, err := conn.Write([]byte("live")); err != nil { + t.Fatal(err) + } + buf := make([]byte, 4) + if _, err := io.ReadFull(conn, buf); err != nil { + t.Fatalf("echo before eviction: %v", err) + } + + // Evict the agent; the live data connection must terminate, proving + // closeAll closed the dedicated data conn and not merely the mux. + h.stopAgent() + conn.SetDeadline(time.Now().Add(5 * time.Second)) + if _, err := conn.Read(buf); err == nil { + t.Fatal("per-conn data connection did not terminate on eviction") + } +} + +// TestQUICEvictionDrains: evicting a QUIC agent tears down its live player +// stream. Under QUIC the session (a *quic.Conn) is the whole drain boundary — +// closing it kills every stream — so closeAll needs no dataConns (they ride the +// session, not dedicated conns). The QUIC twin of the drain half of T5. +func TestQUICEvictionDrains(t *testing.T) { + echoAddr, closeEcho := echoServer(t) + defer closeEcho() + h := newHarnessWith(t, echoAddr, harnessOpts{transport: config.TransportQUIC}) + addr := h.waitPublicPort() + + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(10 * time.Second)) + if _, err := conn.Write([]byte("live")); err != nil { + t.Fatal(err) + } + buf := make([]byte, 4) + if _, err := io.ReadFull(conn, buf); err != nil { + t.Fatalf("echo before eviction: %v", err) + } + + // Evict the agent; the live stream must terminate as the QUIC session closes. + h.stopAgent() + conn.SetDeadline(time.Now().Add(5 * time.Second)) + if _, err := conn.Read(buf); err == nil { + t.Fatal("quic player stream did not terminate on eviction") + } +} + +// --- Bandwidth cap --- +// +// These assert the average download throughput over ~1s (far steadier than the +// burst test's tail-RTT metric), so they run under -short/-race with generous +// bounds. The precise unit (Mbps*125000, burst = relay.BufSize) is proven in +// internal/bwcap; here we prove the cap bites end to end and is per-agent. +const ( + capMbps = 40 // 5 MB/s: ~1.3% of uncapped loopback, so the cap is the bottleneck + capPayload = 5 << 20 // ~1s at 40 Mbps; burst (128 KiB) is <3% of this +) + +// sourceServer accepts connections and streams payloadSize bytes to each, then +// half-closes. Measured at the receiver, its throughput reflects the download +// (server→client) direction's cap. +func sourceServer(t *testing.T, payloadSize int) (addr string, closeFn func()) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for { + c, err := ln.Accept() + if err != nil { + return + } + wg.Add(1) + go func(c net.Conn) { + defer wg.Done() + defer c.Close() + buf := make([]byte, 128*1024) + for sent := 0; sent < payloadSize; { + n := len(buf) + if payloadSize-sent < n { + n = payloadSize - sent + } + if _, err := c.Write(buf[:n]); err != nil { + return + } + sent += n + } + if tc, ok := c.(*net.TCPConn); ok { + tc.CloseWrite() + } + }(c) + } + }() + return ln.Addr().String(), func() { + ln.Close() + wg.Wait() + } +} + +// downloadMbps drains expectBytes from addr and returns the observed decimal +// Mbps. It returns an error (never t.Fatal) so it is safe to call from a +// goroutine for concurrent-stream tests. +func downloadMbps(addr string, expectBytes int) (float64, error) { + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + return 0, err + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(30 * time.Second)) + start := time.Now() + got, err := io.CopyN(io.Discard, conn, int64(expectBytes)) + elapsed := time.Since(start) + if err != nil { + return 0, fmt.Errorf("read %d/%d bytes: %w", got, expectBytes, err) + } + return float64(got) * 8 / 1e6 / elapsed.Seconds(), nil +} + +// cappedTunnel is tcpTunnel with a bandwidth cap, for the two-agent tests. +func cappedTunnel(id, localAddr string, mbps int, scope string) config.Tunnel { + tun := tcpTunnel(id, localAddr, 0) + tun.Options.BandwidthLimitMbps = mbps + tun.Options.BandwidthLimitScope = scope + return tun +} + +// checkCapped asserts a stream's throughput bracket: throttled (not uncapped) +// yet not throttled below ~half the cap (which would signal a shared bucket). +func checkCapped(t *testing.T, label string, mbps float64) { + t.Helper() + t.Logf("%s: %.1f Mbps (cap %d)", label, mbps, capMbps) + if mbps < capMbps*0.6 { + t.Errorf("%s throttled to %.1f Mbps (< %.0f): cap too aggressive / bucket shared?", label, mbps, capMbps*0.6) + } + if mbps > capMbps*1.4 { + t.Errorf("%s ran at %.1f Mbps (> %.0f): cap not enforced", label, mbps, capMbps*1.4) + } +} + +// TestBandwidthCapThrottlesDownload: a capped tunnel throttles a single stream +// to ~cap, in both combined and per-direction scope (a single direction is +// capped identically by either). +func TestBandwidthCapThrottlesDownload(t *testing.T) { + for _, scope := range []string{config.BandwidthScopeCombined, config.BandwidthScopePerDirection} { + t.Run(scope, func(t *testing.T) { + srcAddr, closeSrc := sourceServer(t, capPayload) + defer closeSrc() + h := newHarnessWith(t, srcAddr, harnessOpts{bandwidthMbps: capMbps, bandwidthScope: scope}) + addr := h.waitPublicPort() + mbps, err := downloadMbps(addr, capPayload) + if err != nil { + t.Fatalf("download: %v", err) + } + checkCapped(t, scope, mbps) + }) + } +} + +// TestBandwidthCapPerConnectionScope: per-connection scope gives each connection +// its own bucket, so two concurrent streams each sustain ~cap (a shared bucket +// would halve them). +func TestBandwidthCapPerConnectionScope(t *testing.T) { + srcAddr, closeSrc := sourceServer(t, capPayload) + defer closeSrc() + h := newHarnessWith(t, srcAddr, harnessOpts{bandwidthMbps: capMbps, bandwidthScope: config.BandwidthScopePerConnection}) + addr := h.waitPublicPort() + + type res struct { + mbps float64 + err error + } + ch := make(chan res, 2) + for i := 0; i < 2; i++ { + go func() { + mbps, err := downloadMbps(addr, capPayload) + ch <- res{mbps, err} + }() + } + for i := 0; i < 2; i++ { + r := <-ch + if r.err != nil { + t.Fatalf("download: %v", r.err) + } + checkCapped(t, "per-connection stream", r.mbps) + } +} + +// TestBandwidthCapPerAgentIsolation guards the (agentID, tunnelID) keying: two +// agents share the SAME tunnelID, each capped at cap. Correct per-agent +// bucketing lets each sustain ~cap concurrently; a tunnelID-only key would make +// them share one bucket (~cap/2 each). +func TestBandwidthCapPerAgentIsolation(t *testing.T) { + srcA, closeA := sourceServer(t, capPayload) + defer closeA() + srcB, closeB := sourceServer(t, capPayload) + defer closeB() + + h := newHarnessWith(t, srcA, harnessOpts{bandwidthMbps: capMbps, bandwidthScope: config.BandwidthScopeCombined}) + addrA := h.waitPublicPort() + + agB := h.addAgent(t, config.NewID(), []config.Tunnel{cappedTunnel(h.tunnelID, srcB, capMbps, config.BandwidthScopeCombined)}) + addrB := waitPublicPortOf(t, agB, h.tunnelID) + + type res struct { + mbps float64 + err error + } + ch := make(chan res, 2) + for _, addr := range []string{addrA, addrB} { + go func() { + mbps, err := downloadMbps(addr, capPayload) + ch <- res{mbps, err} + }() + } + for i := 0; i < 2; i++ { + r := <-ch + if r.err != nil { + t.Fatalf("download: %v", r.err) + } + checkCapped(t, "per-agent stream", r.mbps) } - // The original session must be unharmed. - roundTrip(t, h.waitPublicPort(), []byte("still alive")) } // TestBadTokenRejected: wrong token → fatal ErrBadToken. @@ -642,7 +1276,7 @@ func TestBadTokenRejected(t *testing.T) { thiefCfg.Agent.Token = config.NewToken() // wrong thiefCfg.Agent.Tunnels = nil - thief := agent.New(thiefCfg, testLogger(t).With("side", "thief")) + thief := agent.New(thiefCfg, t.TempDir(), testLogger(t).With("side", "thief")) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() err := thief.Run(ctx) @@ -651,16 +1285,91 @@ func TestBadTokenRejected(t *testing.T) { } } +// TestEnrollAndRevoke drives per-agent identity end to end: an agent enrolls with a +// single-use pairing ticket, joins the gateway allowlist, moves traffic over the +// identity-authenticated link, and after revocation gives up fatally on reconnect +// instead of retry-hammering. +func TestEnrollAndRevoke(t *testing.T) { + echoAddr, closeEcho := echoServer(t) + defer closeEcho() + h := newHarnessWith(t, echoAddr, harnessOpts{enroll: true}) + addr := h.waitPublicPort() + + // Enrolled exactly once, with a derived agt_ id and a stored public key. + agents := h.gw.ListAgents() + if len(agents) != 1 { + t.Fatalf("want 1 enrolled agent, got %d: %+v", len(agents), agents) + } + rec := agents[0] + if len(rec.AgentID) < 5 || rec.AgentID[:4] != "agt_" || len(rec.PubKey) == 0 { + t.Fatalf("enrolled record looks wrong: %+v", rec) + } + // Traffic flows over the identity-authenticated link (no shared token). + roundTrip(t, addr, []byte("identity works")) + + // Revoke: the live session is evicted and the agent, on reconnect, is refused + // fatally rather than looping. + if !h.gw.RevokeAgent(rec.AgentID) { + t.Fatal("revoke reported the agent was not found") + } + select { + case err := <-h.agentDone: + if !errors.Is(err, agent.ErrRevoked) { + t.Fatalf("revoked agent: want ErrRevoked, got %v", err) + } + case <-time.After(20 * time.Second): + t.Fatal("revoked agent did not give up after eviction") + } + h.agentStop() // release the ctx now that Run has returned + h.agentStop = nil // we already drained agentDone; keep cleanup from blocking +} + // TestBurstThroughputAndCrossStreamLatency pushes 64 MiB through one // connection while a second connection does small echo round-trips; the -// burst must move fast and must not starve the small stream. +// burst must move fast and must not starve the small stream. This is the +// hot-path floor gate (mux transport); TestBurstThroughputPerConn is its +// per-conn twin. func TestBurstThroughputAndCrossStreamLatency(t *testing.T) { if testing.Short() { t.Skip("burst benchmark skipped in -short") } echoAddr, closeEcho := echoServer(t) defer closeEcho() - h := newHarness(t, echoAddr) + runBurst(t, newHarness(t, echoAddr)) +} + +// TestBurstThroughputPerConn is the per-conn transport twin of the burst floor +// gate: dedicated per-player connections must clear the same throughput floor +// and, having no shared mux, must not degrade cross-stream latency. +func TestBurstThroughputPerConn(t *testing.T) { + if testing.Short() { + t.Skip("burst benchmark skipped in -short") + } + echoAddr, closeEcho := echoServer(t) + defer closeEcho() + runBurst(t, newHarnessWith(t, echoAddr, harnessOpts{transport: config.TransportPerConn})) +} + +// TestBurstThroughputQUIC is the QUIC twin of the burst floor gate. Userspace +// QUIC is CPU-heavier than kernel TCP (and GSO/GRO support varies on Windows), +// so this is the load-bearing check that QUIC clears the SAME floor: ≥20 MiB/s +// round-trip and worst cross-stream RTT ≤500 ms — the latter also proving one +// player's saturating stream doesn't head-of-line-block another's over the one +// shared QUIC connection. Do not lower the floor to make this pass. +func TestBurstThroughputQUIC(t *testing.T) { + if testing.Short() { + t.Skip("burst benchmark skipped in -short") + } + echoAddr, closeEcho := echoServer(t) + defer closeEcho() + runBurst(t, newHarnessWith(t, echoAddr, harnessOpts{transport: config.TransportQUIC})) +} + +// runBurst drives the burst-and-probe floor check against a live harness: +// ≥20 MiB/s round-trip and worst cross-stream RTT ≤500 ms (bounds in +// docs/agent/architecture.md "The numbers"). +func runBurst(t *testing.T, h *harness) { + t.Helper() addr := h.waitPublicPort() const burstSize = 64 << 20 diff --git a/internal/e2e/gatewayconfig_test.go b/internal/e2e/gatewayconfig_test.go new file mode 100644 index 0000000..ba20f26 --- /dev/null +++ b/internal/e2e/gatewayconfig_test.go @@ -0,0 +1,129 @@ +package e2e + +import ( + "fmt" + "testing" + "time" + + "proxyforward/internal/agent" + "proxyforward/internal/config" + "proxyforward/internal/gateway" +) + +// waitConfigGen polls until the gateway's single enrolled agent has reached (at least) +// the given authoritative config generation, returning its record. +func waitConfigGen(t *testing.T, gw *gateway.Gateway, want uint64) gateway.AgentRecord { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + agents := gw.ListAgents() + if len(agents) == 1 && agents[0].ConfigGen >= want { + return agents[0] + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("gateway never reached config generation %d", want) + return gateway.AgentRecord{} +} + +// waitPortForTunnel polls until the agent has learned a specific tunnel's public port. +func waitPortForTunnel(t *testing.T, a *agent.Agent, tunnelID string) string { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if port, ok := a.TunnelPublicPort(tunnelID); ok { + return fmt.Sprintf("127.0.0.1:%d", port) + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("tunnel %s never became live", tunnelID) + return "" +} + +// TestGatewayConfigSeed: an enrolled agent with no prior generation seeds the gateway +// on first contact; the gateway adopts the set as generation 1, stores it with the +// resolved (concrete) public port, and pushes it back so the tunnel goes live. +// (gateway-config) +func TestGatewayConfigSeed(t *testing.T) { + echoAddr, closeEcho := echoServer(t) + defer closeEcho() + h := newHarnessWith(t, echoAddr, harnessOpts{enroll: true}) + addr := h.waitPublicPort() + + rec := waitConfigGen(t, h.gw, 1) + if rec.ConfigGen != 1 { + t.Fatalf("want generation 1 after seed, got %d", rec.ConfigGen) + } + if len(rec.DesiredTunnels) != 1 || rec.DesiredTunnels[0].ID != h.tunnelID { + t.Fatalf("gateway did not store the seeded tunnel: %+v", rec.DesiredTunnels) + } + // The seed carried an ephemeral (0) port; the stored authoritative set must carry + // the concrete port the gateway bound, so a reconnect rebinds the same port. + if rec.DesiredTunnels[0].PublicPort == 0 { + t.Fatal("stored config must carry the resolved port, not 0") + } + roundTrip(t, addr, []byte("seeded config works")) +} + +// TestGatewayConfigPromote: a local edit is promoted to the gateway, which adopts it +// (generation bumps to 2), resolves the added tunnel's port, and pushes the set back +// so the new tunnel goes live — deterministic reconciliation, not last-write-wins. +// (gateway-config) +func TestGatewayConfigPromote(t *testing.T) { + echoA, closeA := echoServer(t) + defer closeA() + h := newHarnessWith(t, echoA, harnessOpts{enroll: true}) + h.waitPublicPort() + waitConfigGen(t, h.gw, 1) + + echoB, closeB := echoServer(t) + defer closeB() + + // Promote adding tunnel B. Keep A pinned at its resolved port so it does not flap. + portA, ok := h.agent.TunnelPublicPort(h.tunnelID) + if !ok { + t.Fatal("tunnel A has no resolved port before promote") + } + tunnelB := config.NewID() + h.agent.ApplyTunnels([]config.Tunnel{ + {ID: h.tunnelID, Name: "test", Type: config.TunnelTCP, LocalAddr: echoA, PublicPort: portA, Enabled: true}, + {ID: tunnelB, Name: "b", Type: config.TunnelTCP, LocalAddr: echoB, PublicPort: 0, Enabled: true}, + }) + + rec := waitConfigGen(t, h.gw, 2) + if len(rec.DesiredTunnels) != 2 { + t.Fatalf("gateway did not adopt both tunnels at gen %d: %+v", rec.ConfigGen, rec.DesiredTunnels) + } + addrB := waitPortForTunnel(t, h.agent, tunnelB) + roundTrip(t, addrB, []byte("promoted tunnel works")) + // A stays live on its original port through the promote. + roundTrip(t, fmt.Sprintf("127.0.0.1:%d", portA), []byte("existing tunnel survives")) +} + +// TestGatewayConfigDriftOnReconnect: after an agent restart its in-memory generation +// resets, so its hello reports a stale view; the gateway detects the drift against its +// stored authoritative set and pushes it, bringing the tunnel back live without +// re-adopting (the generation must not churn). (gateway-config) +func TestGatewayConfigDriftOnReconnect(t *testing.T) { + echoAddr, closeEcho := echoServer(t) + defer closeEcho() + h := newHarnessWith(t, echoAddr, harnessOpts{enroll: true}) + h.waitPublicPort() + waitConfigGen(t, h.gw, 1) + + // Restart: same identity (the key persists in agentDir), fresh in-memory state. + h.stopAgent() + h.startAgent() + + // The gateway re-pushes its authoritative gen-1 set; the tunnel comes back live. + addr := h.waitPublicPort() + roundTrip(t, addr, []byte("resynced after reconnect")) + + // The reconnect was a re-push, not a re-adopt: the generation is unchanged. Give a + // beat for any stray seed to be processed (and refused) before asserting. + time.Sleep(200 * time.Millisecond) + agents := h.gw.ListAgents() + if len(agents) != 1 || agents[0].ConfigGen != 1 { + t.Fatalf("reconnect must not bump the generation: %+v", agents) + } +} diff --git a/internal/e2e/offline_motd_test.go b/internal/e2e/offline_motd_test.go new file mode 100644 index 0000000..cd81603 --- /dev/null +++ b/internal/e2e/offline_motd_test.go @@ -0,0 +1,136 @@ +package e2e + +import ( + "bytes" + "encoding/binary" + "net" + "testing" + "time" + + "proxyforward/internal/mc" +) + +// TestOfflineMOTDStatusServed: when the agent reports the local backend down, a +// player who queries the tunnel's public port gets the tunnel's offline MOTD in +// the server-list status response instead of a dead socket. +func TestOfflineMOTDStatusServed(t *testing.T) { + const motd = "Server is offline — back soon" + h := newHarnessWith(t, reservedDeadAddr(t), harnessOpts{offlineMOTD: motd}) + addr := h.waitPublicPort() + waitBackendDown(t, h) + + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + t.Fatalf("dial public port: %v", err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(10 * time.Second)) + if _, err := conn.Write(mcStatusRequest(767)); err != nil { + t.Fatalf("write status request: %v", err) + } + id, body, err := mc.ReadPacket(conn, 32*1024) + if err != nil { + t.Fatalf("read status response: %v", err) + } + if id != 0x00 { + t.Fatalf("status response id = %#x, want 0x00", id) + } + if !bytes.Contains(body, []byte(motd)) { + t.Fatalf("status response does not carry the offline MOTD %q; body=%q", motd, body) + } +} + +// TestOfflineMOTDLoginDisconnect: a player who tries to JOIN a tunnel whose +// backend is down is disconnected with the offline MOTD as the reason. +func TestOfflineMOTDLoginDisconnect(t *testing.T) { + const motd = "Be right back" + h := newHarnessWith(t, reservedDeadAddr(t), harnessOpts{offlineMOTD: motd}) + addr := h.waitPublicPort() + waitBackendDown(t, h) + + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + t.Fatalf("dial public port: %v", err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(10 * time.Second)) + if _, err := conn.Write(mcLoginBytes(767, "Steve")); err != nil { + t.Fatalf("write login: %v", err) + } + id, body, err := mc.ReadPacket(conn, 32*1024) + if err != nil { + t.Fatalf("read disconnect: %v", err) + } + if id != 0x00 { + t.Fatalf("disconnect id = %#x, want 0x00", id) + } + if !bytes.Contains(body, []byte(motd)) { + t.Fatalf("disconnect reason does not carry the offline MOTD %q; body=%q", motd, body) + } +} + +// TestOfflineMOTDDisabledCleanClose: with no OfflineMOTD configured, a +// backend-down tunnel drops the connection (no MOTD emitted) — the pre-existing +// behavior the empty-string guard preserves. +func TestOfflineMOTDDisabledCleanClose(t *testing.T) { + h := newHarnessWith(t, reservedDeadAddr(t), harnessOpts{}) // no offlineMOTD + addr := h.waitPublicPort() + waitBackendDown(t, h) + + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + t.Fatalf("dial public port: %v", err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(10 * time.Second)) + if _, err := conn.Write(mcStatusRequest(767)); err != nil { + // A write may already fail if the gateway closed first; that is fine. + return + } + // The gateway serves nothing and closes; the read must not yield a packet. + if _, _, err := mc.ReadPacket(conn, 32*1024); err == nil { + t.Fatal("expected a clean close with no MOTD, but got a response packet") + } +} + +// mcStatusRequest builds a status-intent handshake followed by the empty status +// request packet. +func mcStatusRequest(proto int32) []byte { + hb := mc.AppendVarInt(nil, proto) + hb = mc.AppendString(hb, "mc.example.com") + hb = binary.BigEndian.AppendUint16(hb, 25565) + hb = mc.AppendVarInt(hb, mc.NextStateStatus) + var buf bytes.Buffer + mc.WritePacket(&buf, 0x00, hb) + mc.WritePacket(&buf, 0x00, nil) // status request + return buf.Bytes() +} + +// reservedDeadAddr returns a 127.0.0.1 address that refuses connections: it +// binds a port, learns its number, then frees it so a dial gets refused and the +// agent's health probe reports the backend down. +func reservedDeadAddr(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := ln.Addr().String() + ln.Close() + return addr +} + +// waitBackendDown blocks until the gateway has learned (over the control link) +// that the tunnel's local backend is unreachable — the offline responder's +// trigger condition. +func waitBackendDown(t *testing.T, h *harness) { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + if up, known := h.gw.TunnelLocalUp(h.tunnelID); known && !up { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("gateway never learned the backend was down") +} diff --git a/internal/e2e/perconn_test.go b/internal/e2e/perconn_test.go new file mode 100644 index 0000000..31cd74c --- /dev/null +++ b/internal/e2e/perconn_test.go @@ -0,0 +1,173 @@ +package e2e + +import ( + "io" + "net" + "sync" + "testing" + "time" + + "proxyforward/internal/config" +) + +// connCountingProxy is a transparent TCP relay between the agent and the +// gateway. It terminates no TLS (so the agent's cert pin still validates against +// the real gateway) and only forwards bytes, tracking how many agent→gateway +// connections are open at once. +type connCountingProxy struct { + ln net.Listener + gwAddr string + wg sync.WaitGroup + + mu sync.Mutex + open int +} + +func newConnCountingProxy(t *testing.T, gwAddr string) *connCountingProxy { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + p := &connCountingProxy{ln: ln, gwAddr: gwAddr} + p.wg.Add(1) + go p.acceptLoop() + return p +} + +func (p *connCountingProxy) addr() string { return p.ln.Addr().String() } + +func (p *connCountingProxy) acceptLoop() { + defer p.wg.Done() + for { + c, err := p.ln.Accept() + if err != nil { + return + } + p.wg.Add(1) + go p.handle(c) + } +} + +func (p *connCountingProxy) handle(client net.Conn) { + defer p.wg.Done() + defer client.Close() + up, err := net.Dial("tcp", p.gwAddr) + if err != nil { + return + } + defer up.Close() + + p.mu.Lock() + p.open++ + p.mu.Unlock() + defer func() { + p.mu.Lock() + p.open-- + p.mu.Unlock() + }() + + // Forward both directions; either side's EOF half-closes the other. handle + // returns only once both copies finish, so close() (which waits the group) + // is leak-free. + var cwg sync.WaitGroup + cwg.Add(2) + go func() { defer cwg.Done(); io.Copy(up, client); halfClose(up) }() + go func() { defer cwg.Done(); io.Copy(client, up); halfClose(client) }() + cwg.Wait() +} + +func halfClose(c net.Conn) { + if tc, ok := c.(*net.TCPConn); ok { + tc.CloseWrite() + } +} + +func (p *connCountingProxy) concurrent() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.open +} + +// close stops accepting and waits for in-flight relays to drain. Must run after +// the agent and gateway have stopped so their conns close and the relays exit; +// registered via t.Cleanup inside the interpose hook, whose LIFO ordering +// places it after the agent stop and before the gateway shutdown. +func (p *connCountingProxy) close() { + p.ln.Close() + p.wg.Wait() +} + +// TestPerConnOpensAConnectionPerPlayer is the structural head-of-line-blocking +// proof. TCP head-of-line blocking is, by definition, confined to a single +// connection: a lost segment stalls only the byte delivery of the connection it +// was on. Under mux transport every player shares one agent→gateway connection, +// so one player's loss stalls all of them; under per-conn transport each player +// rides a dedicated connection, so it cannot. We count the concurrent agent→ +// gateway connections through a transparent proxy while two players are held +// open and assert per-conn opens one per player (plus the control conn) while +// mux stays at one. +func TestPerConnOpensAConnectionPerPlayer(t *testing.T) { + muxConns := concurrentLinkConns(t, config.TransportMux) + perConnConns := concurrentLinkConns(t, config.TransportPerConn) + + if muxConns != 1 { + t.Errorf("mux transport used %d concurrent agent→gateway conns for 2 players, want 1 (all players share one — the HOL hazard)", muxConns) + } + if perConnConns < 3 { + t.Errorf("per-conn transport used %d concurrent agent→gateway conns for 2 players, want ≥3 (control + one dedicated conn per player)", perConnConns) + } + if perConnConns <= muxConns { + t.Errorf("per-conn (%d conns) must open more than mux (%d): that per-player separation is precisely what removes cross-player TCP head-of-line blocking", perConnConns, muxConns) + } +} + +// concurrentLinkConns brings up a tunnel through a counting proxy, holds two +// concurrent players (each with a confirmed echo, so its data path is truly +// established), and returns the concurrent agent→gateway connection count. +func concurrentLinkConns(t *testing.T, transport string) int { + echoAddr, closeEcho := echoServer(t) + defer closeEcho() + + var proxy *connCountingProxy + h := newHarnessWith(t, echoAddr, harnessOpts{ + transport: transport, + interpose: func(gwAddr string) string { + proxy = newConnCountingProxy(t, gwAddr) + t.Cleanup(proxy.close) + return proxy.addr() + }, + }) + addr := h.waitPublicPort() + + // Two concurrent players, each confirmed with an echo so its dedicated data + // connection (under per-conn) is actually up before we count. + for i := 0; i < 2; i++ { + c, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + t.Fatal(err) + } + defer c.Close() + c.SetDeadline(time.Now().Add(10 * time.Second)) + msg := []byte("player") + if _, err := c.Write(msg); err != nil { + t.Fatal(err) + } + got := make([]byte, len(msg)) + if _, err := io.ReadFull(c, got); err != nil { + t.Fatalf("player %d echo: %v", i, err) + } + } + + // Poll briefly so a just-established data conn is counted; mux reaches 1 + // immediately, per-conn settles at 3 (control + two data). + want := 1 + if transport == config.TransportPerConn { + want = 3 + } + deadline := time.Now().Add(3 * time.Second) + for proxy.concurrent() < want && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + return proxy.concurrent() +} diff --git a/internal/engine/agents_api.go b/internal/engine/agents_api.go new file mode 100644 index 0000000..5f265ff --- /dev/null +++ b/internal/engine/agents_api.go @@ -0,0 +1,139 @@ +package engine + +import ( + "encoding/json" + "fmt" + "net" + "time" + + "proxyforward/internal/gateway" + "proxyforward/internal/link" +) + +// Gateway agent-administration op names. Like the analytics ops these are the +// stable wire contract between the GUI and the daemon, dispatched over the same +// JSON envelope so each App binding is one small typed wrapper. They act on the +// live gateway (allowlist + sessions + event ring), never the analytics store. +const ( + OpListAgents = "list_agents" + OpIssuePairing = "issue_pairing" + OpRevokeAgent = "revoke_agent" + OpRenameAgent = "rename_agent" + OpSetAgentScope = "set_agent_scope" + OpGatewayEvents = "gateway_events" +) + +// isAgentAdminOp reports whether op is a gateway agent-administration op, so +// analyticsOp can route it before the analytics-store availability check. +func isAgentAdminOp(op string) bool { + switch op { + case OpListAgents, OpIssuePairing, OpRevokeAgent, OpRenameAgent, OpSetAgentScope, OpGatewayEvents: + return true + } + return false +} + +// okResult is the response for a mutating admin op: whether the target agent was +// found (a rename/revoke/scope of an unknown agentID reports ok=false). +type okResult struct { + OK bool `json:"ok"` +} + +// agentAdminOp dispatches one gateway agent-administration op. Every op requires +// the gateway role; on an agent-role daemon it reports so rather than panicking. +func (e *Engine) agentAdminOp(op string, body json.RawMessage) (json.RawMessage, error) { + if e.Gateway == nil { + return nil, fmt.Errorf("agent administration is only available on the gateway") + } + switch op { + case OpListAgents: + return encodeResult(e.Gateway.ListAgentViews(), nil) + case OpGatewayEvents: + var q struct { + SinceSeq uint64 `json:"sinceSeq"` + } + if err := decodeBody(body, &q); err != nil { + return nil, err + } + return encodeResult(e.Gateway.Events(q.SinceSeq), nil) + case OpIssuePairing: + var q struct { + Reusable bool `json:"reusable"` + TTLSecs int64 `json:"ttlSecs"` + Ports []int `json:"ports"` + Tunnels []string `json:"tunnels"` + } + if err := decodeBody(body, &q); err != nil { + return nil, err + } + var exp time.Time + if q.TTLSecs > 0 { + exp = time.Now().Add(time.Duration(q.TTLSecs) * time.Second) + } + code, err := e.issuePairingCode(q.Reusable, exp, gateway.Scope{Ports: q.Ports, TunnelIDs: q.Tunnels}) + if err != nil { + return nil, err + } + return encodeResult(struct { + Code string `json:"code"` + }{code}, nil) + case OpRevokeAgent: + var q struct { + AgentID string `json:"agentId"` + } + if err := decodeBody(body, &q); err != nil { + return nil, err + } + return encodeResult(okResult{e.Gateway.RevokeAgent(q.AgentID)}, nil) + case OpRenameAgent: + var q struct { + AgentID string `json:"agentId"` + Nickname string `json:"nickname"` + } + if err := decodeBody(body, &q); err != nil { + return nil, err + } + return encodeResult(okResult{e.Gateway.RenameAgent(q.AgentID, q.Nickname)}, nil) + case OpSetAgentScope: + var q struct { + AgentID string `json:"agentId"` + Ports []int `json:"ports"` + Tunnels []string `json:"tunnels"` + } + if err := decodeBody(body, &q); err != nil { + return nil, err + } + return encodeResult(okResult{e.Gateway.SetAgentScope(q.AgentID, gateway.Scope{Ports: q.Ports, TunnelIDs: q.Tunnels})}, nil) + default: + return nil, fmt.Errorf("unknown agent admin op %q", op) + } +} + +// issuePairingCode mints an enrollment ticket scoped as requested and returns a +// pxf:// pairing code carrying it — the enrollment analogue of PairingCode (which +// embeds the legacy shared token). reusable=false is the single-use default; a zero +// exp never expires. +func (e *Engine) issuePairingCode(reusable bool, exp time.Time, scope gateway.Scope) (string, error) { + if e.Gateway == nil { + return "", fmt.Errorf("pairing codes come from the gateway role") + } + ticket, err := e.Gateway.IssuePairingTicket(reusable, exp, scope) + if err != nil { + return "", err + } + addr := e.Gateway.ControlAddr() + if addr == nil { + return "", fmt.Errorf("gateway is still starting") + } + host := e.cfg.Gateway.PublicHost + if host == "" { + host = "YOUR-PUBLIC-ADDRESS" + } + code := link.PairingCode{ + Host: host, + Port: addr.(*net.TCPAddr).Port, + Token: ticket, + Fingerprint: e.Gateway.Fingerprint(), + } + return code.String(), nil +} diff --git a/internal/engine/analytics_api.go b/internal/engine/analytics_api.go index b77408c..4a8225f 100644 --- a/internal/engine/analytics_api.go +++ b/internal/engine/analytics_api.go @@ -24,6 +24,7 @@ const ( OpSummary = "summary" OpPeakMatrix = "peak_matrix" OpTunnelUptime = "tunnel_uptime" + OpAgentHistory = "agent_history" ) // analyticsOp runs one query by name against the store, returning the @@ -36,6 +37,27 @@ func (e *Engine) analyticsOp(op string, body json.RawMessage) (json.RawMessage, if op == OpGeoStatus { return encodeResult(e.geo.Status(), nil) } + // agent_history reads the live stats store (per-agent RRD), not the SQLite + // query layer, so it answers even when the DB failed to open. + if op == OpAgentHistory { + var q struct { + AgentID string `json:"agentId"` + WindowMs int64 `json:"windowMs"` + MaxBuckets int `json:"maxBuckets"` + } + if err := decodeBody(body, &q); err != nil { + return nil, err + } + if q.MaxBuckets <= 0 { + q.MaxBuckets = 300 + } + return encodeResult(e.Stats.AgentHistory(q.AgentID, q.WindowMs, q.MaxBuckets), nil) + } + // Gateway agent-administration ops act on the live gateway (allowlist, sessions, + // event ring), not the analytics store, so they answer even when the DB is down. + if isAgentAdminOp(op) { + return e.agentAdminOp(op, body) + } if e.DB == nil { return nil, fmt.Errorf("analytics store unavailable") } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 1fc041d..06952ea 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -87,7 +87,7 @@ func New(cfg *config.Config, configDir, configPath string, logger *slog.Logger) e := &Engine{cfg: cfg, configDir: configDir, configPath: configPath, logger: logger} switch cfg.Role { case config.RoleAgent: - e.Agent = agent.New(cfg, logger) + e.Agent = agent.New(cfg, configDir, logger) case config.RoleGateway: e.Gateway = gateway.New(cfg, configDir, logger) default: @@ -117,7 +117,7 @@ func New(cfg *config.Config, configDir, configPath string, logger *slog.Logger) // and does not probe, so it records only link events.) if e.Agent != nil { e.Agent.SetHealthObserver(func(tunnelID string, up bool) { - e.rec.RecordEvent(analytics.EventTunnelLocal, tunnelID, up) + e.rec.RecordEvent(analytics.EventTunnelLocal, e.cfg.Agent.AgentID, tunnelID, up) }) } return e, nil @@ -172,7 +172,7 @@ func (e *Engine) Run(ctx context.Context) error { // Bracket this run in the uptime journal so the uptime queries can treat // time between a graceful shutdown and the next start as unknown, not down. - e.rec.RecordEvent(analytics.EventEngine, "", true) + e.rec.RecordEvent(analytics.EventEngine, "", "", true) runCtx, cancel := context.WithCancel(ctx) defer cancel() @@ -210,6 +210,15 @@ func (e *Engine) Run(ctx context.Context) error { e.resolver.Run(runCtx) }() + // The Prometheus endpoint (if enabled) lives exactly as long as Run and is + // best-effort: a bind failure never stops proxying. It must release its + // listener before Run returns so a restart can re-bind the same port. + metricsDone := make(chan struct{}) + go func() { + defer close(metricsDone) + e.serveMetrics(runCtx) + }() + // First non-nil error (or first exit) wins; stop the other side, drain. err := <-errCh cancel() @@ -218,10 +227,11 @@ func (e *Engine) Run(ctx context.Context) error { } <-samplerDone <-resolverDone + <-metricsDone // The sampler's final flush has landed and the resolver has stopped // enqueuing; record the graceful stop and close. Close drains the writer, // so this last event still commits. - e.rec.RecordEvent(analytics.EventEngine, "", false) + e.rec.RecordEvent(analytics.EventEngine, "", "", false) if e.DB != nil { if cerr := e.DB.Close(); cerr != nil { e.logger.Warn("analytics: close failed", "err", cerr) @@ -234,6 +244,16 @@ func (e *Engine) Run(ctx context.Context) error { return err } +// linkAgentID identifies the agent on the far side of the control link, for +// attributing link-uptime events. On the agent it is our own identity; on the +// gateway it is the connected agent's. Empty when no agent is connected. +func (e *Engine) linkAgentID() string { + if e.Agent != nil { + return e.cfg.Agent.AgentID + } + return e.Gateway.AgentID() +} + // runSampler feeds the stats store: byte totals at 10 Hz, periodic flushes, // link-session-start detection, and one final flush on shutdown. func (e *Engine) runSampler(ctx context.Context) { @@ -244,6 +264,10 @@ func (e *Engine) runSampler(ctx context.Context) { sessionSample := time.NewTicker(sessionSampleInterval) defer sessionSample.Stop() var prevUpSince int64 + // linkAgentID is the agent that owned the current link session, captured at + // the up transition so the matching down event attributes to the same agent + // even after it has disconnected. + var linkAgentID string for { select { case <-ctx.Done(): @@ -281,15 +305,30 @@ func (e *Engine) runSampler(ctx context.Context) { } players := e.conns().PlayerCount() e.Stats.Sample(now, appIn, appOut, linkIn, linkOut, conns, players, rtt, loss) + // Gateway: also feed each connected agent's own bandwidth history so + // the dashboard can scope the chart per agent. Per-agent byte totals + // are monotonic (conntrack folds closed conns per agent), and rtt/loss + // come from that agent's link. The agent role has a single series + // (itself) and needs no per-agent split. + if e.Gateway != nil { + for agentID, at := range e.Gateway.Conns.AgentTotals() { + if agentID == "" { + continue + } + aRtt, aLoss := e.Gateway.AgentQuality(agentID) + e.Stats.SampleAgent(agentID, now, at.BytesIn, at.BytesOut, at.Conns, at.Players, aRtt, aLoss) + } + } // A change in upSince marks a control-link session boundary: fold it // into the lifetime counter and journal the up/down transition. if upSince != prevUpSince { if prevUpSince != 0 { - e.rec.RecordEvent(analytics.EventLink, "", false) + e.rec.RecordEvent(analytics.EventLink, linkAgentID, "", false) } if upSince != 0 { + linkAgentID = e.linkAgentID() e.Stats.LinkSessionStarted() - e.rec.RecordEvent(analytics.EventLink, "", true) + e.rec.RecordEvent(analytics.EventLink, linkAgentID, "", true) } } prevUpSince = upSince @@ -360,6 +399,7 @@ func (e *Engine) Status() ipc.Status { case e.Agent != nil: st.LinkUp = e.Agent.LinkUp() st.RTTMillis = e.Agent.RTTMillis() + st.Transport = e.Agent.ActiveTransport() st.JitterMillis = e.Agent.JitterMillis() st.PacketLossPct = e.Agent.PacketLossPct() st.LinkUpSinceMs = e.Agent.LinkUpSinceMs() @@ -371,7 +411,11 @@ func (e *Engine) Status() ipc.Status { st.PeerPublicIP = e.cfg.Agent.GatewayHost st.HealthScore = healthScore(st.LinkUp, st.JitterMillis, st.PacketLossPct, st.LinkUpSinceMs) for _, t := range e.Agent.Tunnels() { - ts := ipc.TunnelStatus{ID: t.ID, Name: t.Name} + ts := ipc.TunnelStatus{ + ID: t.ID, Name: t.Name, + BandwidthLimitMbps: t.Options.BandwidthLimitMbps, + BandwidthLimitScope: t.Options.BandwidthLimitScope, + } ts.PublicPort, _ = e.Agent.TunnelPublicPort(t.ID) ts.LocalUp, ts.LocalKnown = e.Agent.LocalUp(t.ID) st.Tunnels = append(st.Tunnels, ts) @@ -398,8 +442,39 @@ func (e *Engine) Status() ipc.Status { st.HealthScore = healthScore(st.AgentConnected, st.JitterMillis, st.PacketLossPct, st.LinkUpSinceMs) for _, t := range e.Gateway.Tunnels() { st.Tunnels = append(st.Tunnels, ipc.TunnelStatus{ - ID: t.ID, Name: t.Name, PublicPort: t.PublicPort, + AgentID: t.AgentID, + ID: t.ID, Name: t.Name, PublicPort: t.PublicPort, LocalUp: t.LocalUp, LocalKnown: t.LocalKnown, + BandwidthLimitMbps: t.BandwidthLimitMbps, + BandwidthLimitScope: t.BandwidthLimitScope, + }) + } + // Per-agent link records for the multi-agent dashboard. Tunnel counts + // come from the flat tunnel list; player counts from conntrack's + // per-agent totals. Agents is sorted (Gateway.Agents) and clamped. + tunnelCount := make(map[string]int) + for _, t := range st.Tunnels { + tunnelCount[t.AgentID]++ + } + agentTraffic := e.Gateway.Conns.AgentTotals() + for _, ag := range e.Gateway.Agents() { + if len(st.Agents) >= ipc.MaxStatusAgents { + break + } + st.Agents = append(st.Agents, ipc.AgentStatus{ + AgentID: ag.AgentID, + Hostname: ag.Hostname, + LANIPs: ag.LANIPs, + RemoteIP: ag.RemoteIP, + LinkUpSinceMs: ag.LinkUpSinceMs, + RTTMillis: ag.RTTMillis, + JitterMillis: ag.JitterMillis, + PacketLossPct: ag.PacketLossPct, + HealthScore: healthScore(true, ag.JitterMillis, ag.PacketLossPct, ag.LinkUpSinceMs), + LinkBytesIn: ag.LinkBytesIn, + LinkBytesOut: ag.LinkBytesOut, + Tunnels: tunnelCount[ag.AgentID], + Players: agentTraffic[ag.AgentID].Players, }) } setStatusConns(&st, e.Gateway.Conns.Snapshot()) diff --git a/internal/engine/metrics.go b/internal/engine/metrics.go new file mode 100644 index 0000000..c38112c --- /dev/null +++ b/internal/engine/metrics.go @@ -0,0 +1,182 @@ +package engine + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "strconv" + "strings" + "time" + + "proxyforward/internal/ipc" +) + +const ( + // Bounds on a single scrape so a slow or hostile client can't pin the + // endpoint, and how long to wait for a graceful shutdown on ctx cancel. + metricsReadTimeout = 5 * time.Second + metricsWriteTimeout = 10 * time.Second + metricsShutdownWait = 3 * time.Second + // defaultMetricsAddr matches config.Default(); used only if an enabled + // endpoint somehow reaches us with an empty address. + defaultMetricsAddr = "127.0.0.1:9464" +) + +// serveMetrics runs the Prometheus /metrics endpoint for the lifetime of ctx. +// It is best-effort and never fatal: proxying is the core job, so a bind +// failure is logged and swallowed rather than killing the engine (unlike the +// IPC pipe, which is fatal by design). It returns only after the listener is +// closed, so a restarting engine can re-bind the same address without an +// "address already in use" race. +func (e *Engine) serveMetrics(ctx context.Context) { + mc := e.cfg.Metrics + if !mc.PrometheusEnabled { + return + } + addr := mc.PrometheusAddr + if addr == "" { + addr = defaultMetricsAddr + } + warnIfMetricsPublic(e.logger, addr) + + ln, err := net.Listen("tcp", addr) + if err != nil { + e.logger.Warn("metrics: listen failed, endpoint disabled", "addr", addr, "err", err) + return + } + + mux := http.NewServeMux() + mux.HandleFunc("/metrics", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + writeMetrics(w, e.Status(), e.conns().PlayerCount()) + }) + srv := &http.Server{ + Handler: mux, + ReadTimeout: metricsReadTimeout, + WriteTimeout: metricsWriteTimeout, + } + + // Shut the listener when ctx dies; wait for that goroutine before returning + // so no goroutine outlives Run (goleak-clean) and the port is provably free. + shutDone := make(chan struct{}) + go func() { + defer close(shutDone) + <-ctx.Done() + shutCtx, cancel := context.WithTimeout(context.Background(), metricsShutdownWait) + defer cancel() + _ = srv.Shutdown(shutCtx) + }() + + e.logger.Info("metrics endpoint listening", "addr", ln.Addr().String()) + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + e.logger.Warn("metrics: server error", "err", err) + } + <-shutDone +} + +// warnIfMetricsPublic logs a warning when the endpoint binds to anything other +// than loopback, since the payload exposes traffic and player counts. It is a +// soft warning, not a hard error: an operator may deliberately front it with a +// reverse proxy or firewall. +func warnIfMetricsPublic(logger *slog.Logger, addr string) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return + } + switch { + case host == "": // empty host binds all interfaces + case host == "localhost": + return + default: + if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { + return + } + } + logger.Warn("metrics: endpoint is not loopback-only; it exposes traffic and player counts — bind to 127.0.0.1 or firewall it", "addr", addr) +} + +// writeMetrics renders the engine status as Prometheus text-format metrics. All +// series come from a single Status snapshot (plus the player count), so a scrape +// adds no work to the data path. Unknown gauges (-1 sentinels) are omitted +// entirely rather than exported as 0, preserving the honest-unknown contract. +// No player names or peer IPs appear as labels (privacy charter). +func writeMetrics(w io.Writer, st ipc.Status, players int) { + linkUp := 0 + if st.LinkUp || st.AgentConnected { + linkUp = 1 + } + + metric(w, "proxyforward_build_info", "gauge", "Build version and role; value is always 1.", + fmt.Sprintf("proxyforward_build_info{version=%q,role=%q} 1", escapeLabel(st.Version), escapeLabel(st.Role))) + metric(w, "proxyforward_link_up", "gauge", "1 when the control link to the peer is up, else 0.", + fmt.Sprintf("proxyforward_link_up %d", linkUp)) + metric(w, "proxyforward_connections", "gauge", "Currently tracked proxied connections.", + fmt.Sprintf("proxyforward_connections %d", st.ConnectionsTotal)) + metric(w, "proxyforward_players", "gauge", "Currently connected distinct players.", + fmt.Sprintf("proxyforward_players %d", players)) + + metric(w, "proxyforward_bytes_total", "counter", "Bytes relayed this engine run, by direction.", + fmt.Sprintf("proxyforward_bytes_total{direction=\"in\"} %d\nproxyforward_bytes_total{direction=\"out\"} %d", st.TotalBytesIn, st.TotalBytesOut)) + metric(w, "proxyforward_alltime_bytes_total", "counter", "Bytes relayed over all runs, by direction.", + fmt.Sprintf("proxyforward_alltime_bytes_total{direction=\"in\"} %d\nproxyforward_alltime_bytes_total{direction=\"out\"} %d", st.AllTimeBytesIn, st.AllTimeBytesOut)) + metric(w, "proxyforward_link_bytes_total", "counter", "Bytes over the control link this session, by direction.", + fmt.Sprintf("proxyforward_link_bytes_total{direction=\"in\"} %d\nproxyforward_link_bytes_total{direction=\"out\"} %d", st.LinkBytesIn, st.LinkBytesOut)) + metric(w, "proxyforward_link_sessions_total", "counter", "Control-link sessions established over all runs.", + fmt.Sprintf("proxyforward_link_sessions_total %d", st.LinkSessions)) + metric(w, "proxyforward_uptime_ms_total", "counter", "Cumulative engine uptime in milliseconds.", + fmt.Sprintf("proxyforward_uptime_ms_total %d", st.CumulativeUptimeMs)) + + // Link quality: -1 means "no sample" — omit rather than lie with a zero. + if st.RTTMillis >= 0 { + metric(w, "proxyforward_link_rtt_ms", "gauge", "Control-link round-trip time in milliseconds.", + fmt.Sprintf("proxyforward_link_rtt_ms %d", st.RTTMillis)) + } + if st.JitterMillis >= 0 { + metric(w, "proxyforward_link_jitter_ms", "gauge", "Control-link jitter in milliseconds.", + "proxyforward_link_jitter_ms "+formatFloat(st.JitterMillis)) + } + if st.PacketLossPct >= 0 { + metric(w, "proxyforward_link_loss_pct", "gauge", "Control-link packet loss percentage.", + "proxyforward_link_loss_pct "+formatFloat(st.PacketLossPct)) + } + + // Per-tunnel backend health, only when known (unknown stays absent). + printed := false + for _, t := range st.Tunnels { + if !t.LocalKnown { + continue + } + if !printed { + fmt.Fprint(w, "# HELP proxyforward_tunnel_local_up 1 when the tunnel's local backend is reachable, else 0.\n# TYPE proxyforward_tunnel_local_up gauge\n") + printed = true + } + up := 0 + if t.LocalUp { + up = 1 + } + fmt.Fprintf(w, "proxyforward_tunnel_local_up{tunnel_id=%q,name=%q} %d\n", escapeLabel(t.ID), escapeLabel(t.Name), up) + } +} + +// metric writes a HELP/TYPE header followed by one or more pre-rendered series +// lines (body may contain several \n-separated series sharing the name). +func metric(w io.Writer, name, typ, help, body string) { + fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s %s\n%s\n", name, help, name, typ, body) +} + +// escapeLabel escapes a Prometheus label value: backslash, double-quote, newline. +func escapeLabel(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + s = strings.ReplaceAll(s, "\n", `\n`) + return s +} + +// formatFloat renders a float without scientific notation, shortest round-trip. +func formatFloat(f float64) string { + return strconv.FormatFloat(f, 'f', -1, 64) +} diff --git a/internal/engine/metrics_test.go b/internal/engine/metrics_test.go new file mode 100644 index 0000000..c10caf1 --- /dev/null +++ b/internal/engine/metrics_test.go @@ -0,0 +1,223 @@ +package engine + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "path/filepath" + "strings" + "testing" + "time" + + "proxyforward/internal/config" + "proxyforward/internal/ipc" +) + +func TestWriteMetricsFormat(t *testing.T) { + st := ipc.Status{ + Version: "v1.2.3", + Role: "gateway", + AgentConnected: true, + ConnectionsTotal: 5, + TotalBytesIn: 100, + TotalBytesOut: 200, + AllTimeBytesIn: 1000, + AllTimeBytesOut: 2000, + LinkBytesIn: 10, + LinkBytesOut: 20, + LinkSessions: 3, + CumulativeUptimeMs: 45000, + RTTMillis: -1, // unknown → must be omitted + JitterMillis: 2.5, // known + PacketLossPct: -1, // unknown → must be omitted + Tunnels: []ipc.TunnelStatus{ + {ID: "abc", Name: "mc", LocalUp: true, LocalKnown: true}, + {ID: "xyz", Name: "pending", LocalKnown: false}, // unknown → omitted + }, + } + var buf bytes.Buffer + writeMetrics(&buf, st, 4) + out := buf.String() + + for _, want := range []string{ + `proxyforward_build_info{version="v1.2.3",role="gateway"} 1`, + "proxyforward_link_up 1", + "proxyforward_connections 5", + "proxyforward_players 4", + `proxyforward_bytes_total{direction="in"} 100`, + `proxyforward_bytes_total{direction="out"} 200`, + `proxyforward_alltime_bytes_total{direction="out"} 2000`, + `proxyforward_link_bytes_total{direction="in"} 10`, + "proxyforward_link_sessions_total 3", + "proxyforward_uptime_ms_total 45000", + "proxyforward_link_jitter_ms 2.5", + `proxyforward_tunnel_local_up{tunnel_id="abc",name="mc"} 1`, + "# TYPE proxyforward_bytes_total counter", + "# TYPE proxyforward_connections gauge", + } { + if !strings.Contains(out, want) { + t.Errorf("missing series %q\n--- output ---\n%s", want, out) + } + } + + // -1 sentinels are "no sample": omit the series, never export 0 or -1. + for _, absent := range []string{"proxyforward_link_rtt_ms", "proxyforward_link_loss_pct"} { + if strings.Contains(out, absent) { + t.Errorf("unknown gauge %q should be omitted\n--- output ---\n%s", absent, out) + } + } + // A tunnel with unknown backend health must not appear at all. + if strings.Contains(out, `tunnel_id="xyz"`) || strings.Contains(out, `name="pending"`) { + t.Errorf("unknown-health tunnel leaked into output\n--- output ---\n%s", out) + } +} + +func TestWriteMetricsAgentSentinels(t *testing.T) { + // An agent with a live link and a real RTT but no jitter/loss sample yet. + st := ipc.Status{ + Version: "dev", + Role: "agent", + LinkUp: true, + RTTMillis: 42, + JitterMillis: -1, + PacketLossPct: -1, + } + var buf bytes.Buffer + writeMetrics(&buf, st, 0) + out := buf.String() + if !strings.Contains(out, "proxyforward_link_up 1") { + t.Errorf("agent link should be up\n%s", out) + } + if !strings.Contains(out, "proxyforward_link_rtt_ms 42") { + t.Errorf("known RTT should be exported\n%s", out) + } + if strings.Contains(out, "proxyforward_link_jitter_ms") || strings.Contains(out, "proxyforward_link_loss_pct") { + t.Errorf("unknown jitter/loss should be omitted\n%s", out) + } +} + +func TestEscapeLabel(t *testing.T) { + // backslash, then quote, then newline — all must be escaped. + if got := escapeLabel("a\"b\\c\n"); got != `a\"b\\c\n` { + t.Errorf("escapeLabel = %q, want %q", got, `a\"b\\c\n`) + } +} + +// TestServeMetricsLifecycle serves the endpoint, scrapes it, stops it, then does +// it again on the SAME port — the second round proves the listener was released +// on ctx cancel (the RestartEngine port-reuse guarantee). +func TestServeMetricsLifecycle(t *testing.T) { + addr := fmt.Sprintf("127.0.0.1:%d", borrowPort(t)) + eng := newGatewayEngine(t, true, addr) + + serveAndScrape := func(round int) { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { eng.serveMetrics(ctx); close(done) }() + + body := pollMetrics(t, addr) + if !strings.Contains(body, "proxyforward_build_info") { + t.Fatalf("round %d: scrape missing build_info:\n%s", round, body) + } + if !strings.Contains(body, `role="gateway"`) { + t.Fatalf("round %d: scrape missing gateway role:\n%s", round, body) + } + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatalf("round %d: serveMetrics did not return after cancel (listener leaked?)", round) + } + } + + serveAndScrape(1) + serveAndScrape(2) // same port — only succeeds if round 1 freed it +} + +func TestServeMetricsDisabled(t *testing.T) { + addr := fmt.Sprintf("127.0.0.1:%d", borrowPort(t)) + eng := newGatewayEngine(t, false, addr) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan struct{}) + go func() { eng.serveMetrics(ctx); close(done) }() + + select { + case <-done: // must return immediately, having bound nothing + case <-time.After(2 * time.Second): + t.Fatal("serveMetrics should return immediately when disabled") + } + ln, err := net.Listen("tcp", addr) + if err != nil { + t.Fatalf("port should be free when metrics are disabled: %v", err) + } + ln.Close() +} + +// --- helpers --- + +func borrowPort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + p := ln.Addr().(*net.TCPAddr).Port + ln.Close() + return p +} + +func newGatewayEngine(t *testing.T, metricsEnabled bool, metricsAddr string) *Engine { + t.Helper() + dir := t.TempDir() + cfg := config.Default() + cfg.Role = config.RoleGateway + cfg.Gateway.BindAddr = "127.0.0.1" + cfg.Gateway.ControlPort = borrowPort(t) + cfg.Gateway.Token = "test-token" + cfg.Metrics.PrometheusEnabled = metricsEnabled + cfg.Metrics.PrometheusAddr = metricsAddr + if err := cfg.Validate(); err != nil { + t.Fatalf("config: %v", err) + } + eng, err := New(cfg, dir, filepath.Join(dir, "config.toml"), slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("engine.New: %v", err) + } + t.Cleanup(func() { + if eng.DB != nil { + _ = eng.DB.Close() + } + eng.geo.Close() + }) + return eng +} + +func pollMetrics(t *testing.T, addr string) string { + t.Helper() + client := &http.Client{Timeout: time.Second} + deadline := time.Now().Add(5 * time.Second) + for { + resp, err := client.Get("http://" + addr + "/metrics") + if err == nil { + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /metrics: status %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain; version=0.0.4") { + t.Errorf("Content-Type = %q, want text/plain; version=0.0.4", ct) + } + b, _ := io.ReadAll(resp.Body) + return string(b) + } + if time.Now().After(deadline) { + t.Fatalf("metrics endpoint never came up: %v", err) + } + time.Sleep(20 * time.Millisecond) + } +} diff --git a/internal/gateway/actor.go b/internal/gateway/actor.go index 82bae31..f7eb7d1 100644 --- a/internal/gateway/actor.go +++ b/internal/gateway/actor.go @@ -11,6 +11,7 @@ import ( "sync/atomic" "time" + "proxyforward/internal/bwcap" "proxyforward/internal/control" "proxyforward/internal/linkquality" "proxyforward/internal/portowner" @@ -35,14 +36,40 @@ type agentSession struct { // in the gateway GUI's identity badges. Set at admission, immutable after. hostname string localIPs []string + // scope restricts which public ports / tunnel IDs this agent may bind, from + // its per-agent allowlist record (empty = unrestricted). Set at admission, + // immutable after — enforced in validateSpec. + scope Scope + + // enrolled is true when the agent authenticated per-identity (an Ed25519 + // pubkey), not via the legacy shared token. Gateway-authoritative config + // (CapGatewayConfig) is keyed to that identity, so it engages only when enrolled. + // reportedConfigHash is the config hash the agent sent in its hello, compared + // once at connect to decide whether to push. Both immutable after admission. + enrolled bool + reportedConfigHash string + // agentConfigGen is the config generation the agent currently holds: seeded from + // its hello, advanced on each config_ack. It is the basis a propose_config is + // checked against, so a stale proposal can't clobber newer authoritative state. + agentConfigGen atomic.Uint64 // caps is the negotiated capability set, fixed before admission — // immutable for the session's lifetime, so reads need no locking. caps control.CapSet + // dp is the data plane chosen from caps at admission (mux vs per-conn); + // handleClient acquires every player's leg through it. Immutable like caps. + dp dataPlane sess atomic.Pointer[sessionBox] evicted atomic.Bool + // ctx is a child of the gateway's serving ctx, cancelled when this session + // is evicted (and on shutdown). handleClient parents each splice on it so a + // throttled WaitN unblocks promptly and per-agent on eviction — closing the + // mux alone can't unblock a copy parked in a limiter's WaitN. + ctx context.Context + cancel context.CancelFunc + // The gateway runs its own ping loop toward the agent (bidirectional // heartbeat) so it reports the same RTT/jitter/loss stats the agent does. // ctrlWriteMu serializes control-stream writes between the read loop's @@ -64,6 +91,14 @@ type agentSession struct { // sampler: connID (string) → *rttConn. Populated by handleClient for the // connection's lifetime. rttConns sync.Map + + // dataConns tracks this session's live per-conn data connections (connID → + // net.Conn) so eviction can close them. In per-conn transport the data + // splices ride dedicated conns, not the mux, so closing the mux alone can't + // tear them down — and an uncapped splice parked in Read only unblocks on + // conn close (the ctx cancel unblocks only throttled WaitN and the pending + // dial-back wait). Empty under mux transport. + dataConns sync.Map } // setCtrl publishes (or clears, with nil) the control stream for writers. @@ -105,12 +140,22 @@ func (a *agentSession) session() transport.Session { } // closeAll tears down the underlying conn (which kills the mux, its streams, -// and every splice riding them). +// and every splice riding them) plus every per-conn data connection (whose +// splices ride dedicated conns, not the mux). Together these drain exactly this +// agent's connections and none of another's. Under QUIC transport conn is nil — +// closing the session (a *quic.Conn) is itself the whole drain boundary (all its +// streams die with it) and dataConns is empty. func (a *agentSession) closeAll() { if s := a.session(); s != nil { s.Close() } - a.conn.Close() + if a.conn != nil { + a.conn.Close() + } + a.dataConns.Range(func(_, v any) bool { + v.(net.Conn).Close() + return true + }) } // publicListener is one bound public port serving one tunnel of one session. @@ -119,6 +164,11 @@ type publicListener struct { owner *agentSession ln net.Listener done chan struct{} // closed when the accept loop has fully exited + // limiters holds this tunnel's bandwidth cap, keyed structurally by + // (agentID, tunnelID) via the nested listeners map. Read off-actor by + // handleClient (hence atomic); written by bind/reconcile on the actor. nil + // pointer / uncapped set = the relay fast path. + limiters atomic.Pointer[bwcap.LimiterSet] } // actor owns all session/listener lifecycle state. Every mutation runs on @@ -132,19 +182,47 @@ type actor struct { stopped chan struct{} // closed when run() exits; unblocks late do() callers // State below is touched only from run(). - current *agentSession - generation uint64 - listeners map[string]*publicListener // tunnelID → listener + agents map[string]*agentSession // agentID → live session + generation uint64 // global-monotonic supersede ordinal + listeners map[string]map[string]*publicListener // agentID → tunnelID → listener + admitDamp map[string]dampState // agentID → anti-flap state + // events is the ring of notable auto-fixes/conflicts (port reassign, suspected + // clone) the GUI event log polls. Written on this goroutine, read via do(). + events *eventRing + + clientWG sync.WaitGroup // tracks handleClient goroutines across all agents +} - clientWG sync.WaitGroup // tracks handleClient goroutines for Shutdown +// dampState is the per-agentID anti-flap record: a genuine collision between two +// live machines claiming the same agentID degrades to a slow contest with a log +// line rather than a tight teardown/rebind loop. Touched only from run(). +type dampState struct { + lastSupersedeAt time.Time + penalty time.Duration } +// errAdmitDampened is a transient admission refusal: the newcomer should back +// off and retry, not treat it as fatal. +var errAdmitDampened = fmt.Errorf("admission dampened: same agentID reconnecting too fast") + +// supersedeFlapWindow: supersedes closer together than this look like two live +// machines contesting one agentID rather than a single reconnect. Shared by the +// anti-flap penalty (noteSupersede) and the cloned-key detector (admit). +const supersedeFlapWindow = 2 * time.Second + +// eventRingCap bounds the gateway event ring: enough to show a meaningful recent +// history in the GUI event log without unbounded growth on a busy fleet. +const eventRingCap = 256 + func newActor(logger *slog.Logger) *actor { return &actor{ logger: logger, cmds: make(chan func()), stopped: make(chan struct{}), - listeners: make(map[string]*publicListener), + agents: make(map[string]*agentSession), + listeners: make(map[string]map[string]*publicListener), + admitDamp: make(map[string]dampState), + events: newEventRing(eventRingCap), } } @@ -153,8 +231,11 @@ func (a *actor) run(ctx context.Context) { for { select { case <-ctx.Done(): - // Shutdown: evict whatever is connected, then drain splices. - a.evictLocked("gateway shutting down") + // Shutdown: evict every agent, then drain all splices. clientWG is + // global; it is waited only here, never inside a per-agent evict. + for _, sess := range snapshotSessions(a.agents) { + a.evict(sess, "gateway shutting down") + } a.clientWG.Wait() return case cmd := <-a.cmds: @@ -163,6 +244,16 @@ func (a *actor) run(ctx context.Context) { } } +// snapshotSessions copies the live sessions so an evict loop can delete from the +// map without racing the range. +func snapshotSessions(m map[string]*agentSession) []*agentSession { + out := make([]*agentSession, 0, len(m)) + for _, s := range m { + out = append(out, s) + } + return out +} + // do runs fn on the actor goroutine and waits for it, reporting whether fn // ran. After shutdown it returns false without running fn — the state fn // would touch is already torn down, and blocking here would deadlock late @@ -182,26 +273,64 @@ func (a *actor) do(fn func()) bool { } } -// admit decides what happens when an authenticated agent arrives: same -// agentID supersedes the existing session (closing it and its listeners -// first); a different agentID is rejected while one is connected. +// recordEvent appends a notable event to the ring. Must run on the actor +// goroutine (its callers — admit, bindLocked — already do), so it needs no lock. +// It stamps TimeMs; the caller sets Kind and any structured fields. +func (a *actor) recordEvent(ev GatewayEvent) { + ev.TimeMs = time.Now().UnixMilli() + a.events.push(ev) +} + +// eventsSince returns the ring's events newer than cursor, for the engine's +// incremental poll. Reads on the actor goroutine so it never races a push. +func (a *actor) eventsSince(cursor uint64) []GatewayEvent { + var out []GatewayEvent + a.do(func() { out = a.events.since(cursor) }) + return out +} + +// admit decides what happens when an authenticated agent arrives on the shared +// gateway token. A matching agentID supersedes the existing session (reconnect +// semantics — closing it and its listeners first); a *different* agentID is +// admitted alongside, so one gateway serves several agents. Supersede is +// anti-flap dampened: two live machines colliding on an agentID degrade to a +// slow contest, not a CPU-burning loop. A dampened newcomer gets a transient +// error and should back off, never a fatal one. func (a *actor) admit(sess *agentSession) (uint64, error) { var ( gen uint64 outErr error ) ran := a.do(func() { - if a.current != nil && a.current.agentID != sess.agentID { - outErr = fmt.Errorf("another agent (%s…) is already connected to this gateway; disconnect it first or reuse its identity", shortID(a.current.agentID)) - return - } - if a.current != nil { - a.logger.Info("superseding previous session from same agent", "agent", sess.agentID, "old_generation", a.current.gen) - a.evictLocked("superseded by new connection from the same agent") + if incumbent, ok := a.agents[sess.agentID]; ok { + d := a.admitDamp[sess.agentID] + if d.penalty > 0 && time.Since(d.lastSupersedeAt) < d.penalty { + outErr = errAdmitDampened + a.logger.Warn("admission dampened; keeping incumbent", "agent", sess.agentID, "penalty", d.penalty) + return + } + // A rapid supersede from a *different* IP than the incumbent is the + // fingerprint of a cloned key: a derived identity is otherwise + // unforgeable, so two machines holding the same key take turns stealing + // the session. A single supersede from a new IP is just a reconnect + // after a network change, so require a recent prior supersede before + // flagging — one contest is normal, a two-sided volley is a clone. + if incumbent.remoteIP != "" && sess.remoteIP != "" && incumbent.remoteIP != sess.remoteIP && + !d.lastSupersedeAt.IsZero() && time.Since(d.lastSupersedeAt) < supersedeFlapWindow { + a.recordEvent(GatewayEvent{ + Kind: EventCloneSuspected, + AgentID: sess.agentID, + Message: fmt.Sprintf("agent %s is being contested from two addresses (%s and %s) — its identity key looks cloned; re-enroll one machine for its own identity", sess.agentID, incumbent.remoteIP, sess.remoteIP), + }) + a.logger.Warn("suspected cloned agent identity", "agent", sess.agentID, "incumbent_ip", incumbent.remoteIP, "newcomer_ip", sess.remoteIP) + } + a.logger.Info("superseding previous session from same agent", "agent", sess.agentID, "old_generation", incumbent.gen) + a.evict(incumbent, "superseded by new connection from the same agent") + a.noteSupersede(sess.agentID) } a.generation++ gen = a.generation - a.current = sess + a.agents[sess.agentID] = sess }) if !ran { return 0, fmt.Errorf("gateway is shutting down") @@ -209,80 +338,178 @@ func (a *actor) admit(sess *agentSession) (uint64, error) { return gen, outErr } +// noteSupersede arms the anti-flap penalty only when supersedes come in rapid +// succession — the signature of two live machines contesting one agentID. An +// isolated supersede (a normal reconnect, even one that races the old session's +// teardown) leaves the penalty at zero, so legitimate restarts are never +// dampened. A sustained contest backs off exponentially to a cap. No timer — +// everything decays by timestamp comparison. +func (a *actor) noteSupersede(agentID string) { + const ( + basePenalty = 1 * time.Second + capPenalty = 30 * time.Second + ) + d := a.admitDamp[agentID] + if !d.lastSupersedeAt.IsZero() && time.Since(d.lastSupersedeAt) < supersedeFlapWindow { + if d.penalty == 0 { + d.penalty = basePenalty + } else { + d.penalty = min(2*d.penalty, capPenalty) + } + } else { + d.penalty = 0 // isolated supersede: no dampening + } + d.lastSupersedeAt = time.Now() + a.admitDamp[agentID] = d +} + // disconnected cleans up after a session's control handler exits. A session -// that was already evicted (superseded) is a no-op. +// that is no longer the live one for its agentID (superseded, or a dampened +// newcomer that never entered the map) is a no-op. func (a *actor) disconnected(sess *agentSession) { a.do(func() { - if a.current != sess { + if a.agents[sess.agentID] != sess { return } - a.evictLocked("agent disconnected") + a.evict(sess, "agent disconnected") }) } -// evictLocked runs on the actor goroutine: close every listener owned by the -// current session, wait for each accept loop to fully exit, then close the -// session itself. -func (a *actor) evictLocked(reason string) { - if a.current == nil { - return - } - sess := a.current - a.current = nil +// evict runs on the actor goroutine: drop sess from the agent map, close ONLY +// its listeners (waiting each accept loop for the ghost-listener guarantee), +// then close its mux/conn — which tears down exactly this agent's splices and +// none of another's. It never waits clientWG (that is global; waiting it here +// would block on other agents' live splices). Isolation-safe: evicting one +// agent touches no other agent's listeners or connections. +// Precondition: a.agents[sess.agentID] == sess. +func (a *actor) evict(sess *agentSession, reason string) { + delete(a.agents, sess.agentID) sess.evicted.Store(true) - for id, pl := range a.listeners { - pl.ln.Close() - <-pl.done // ghost-listener guarantee: port is free before we return - delete(a.listeners, id) + if sess.cancel != nil { + sess.cancel() // unblock any throttled WaitN riding this agent's splices + } + if subtree := a.listeners[sess.agentID]; subtree != nil { + for id, pl := range subtree { + pl.ln.Close() + <-pl.done // ghost-listener guarantee: port is free before we move on + delete(subtree, id) + } + delete(a.listeners, sess.agentID) // don't leak an empty subtree } sess.closeAll() a.logger.Debug("session evicted", "agent", sess.agentID, "generation", sess.gen, "reason", reason) } -// bindLocked runs on the actor goroutine: replace any existing listener for -// the spec's ID and open a fresh public listener. -func (a *actor) bindLocked(sess *agentSession, spec control.TunnelSpec, bindAddr string, handle func(*agentSession, control.TunnelSpec, net.Conn)) (int, error) { - if old, ok := a.listeners[spec.ID]; ok { - // Re-register of the same tunnel (config hot-apply): replace. +// bindLocked runs on the actor goroutine: replace this agent's existing listener +// for the spec's ID (config hot-apply) and open a fresh public listener. The +// nested map keys by agentID first, so a re-register can only replace the SAME +// agent's listener — agent B can never steal agent A's. A public-port clash +// across agents (or with an outside process) is caught by net.Listen (global +// FCFS); rather than fail the tunnel, bindLocked reassigns it to a policy-valid +// free port and records the clash so the GUI can offer to reclaim the port. +func (a *actor) bindLocked(sess *agentSession, spec control.TunnelSpec, bindAddr string, allowlist []int, handle func(*publicListener, net.Conn)) (int, error) { + subtree := a.listeners[sess.agentID] + if old, ok := subtree[spec.ID]; ok { old.ln.Close() <-old.done - delete(a.listeners, spec.ID) + delete(subtree, spec.ID) } ln, err := net.Listen("tcp", net.JoinHostPort(bindAddr, strconv.Itoa(spec.PublicPort))) if err != nil { - return 0, portowner.DecorateBindError(spec.PublicPort, err) + if !portowner.IsAddrInUse(err) { + return 0, portowner.DecorateBindError(spec.PublicPort, err) + } + // The requested port is taken. Bind a policy-valid alternative instead of + // failing, so the tunnel comes up; the listener keeps the *requested* spec + // so a later reconcile of the same spec is a no-op (sameListener) and the + // reassigned port stays put rather than re-contending on every sync. + reln := listenFirstFree(bindAddr, reassignCandidates(spec.PublicPort, sess.scope, allowlist)) + if reln == nil { + return 0, portowner.DecorateBindError(spec.PublicPort, err) + } + actual := reln.Addr().(*net.TCPAddr).Port + a.recordEvent(GatewayEvent{ + Kind: EventPortReassigned, + AgentID: sess.agentID, + TunnelID: spec.ID, + RequestedPort: spec.PublicPort, + ActualPort: actual, + Message: fmt.Sprintf("%q could not take port %d (in use by %s); it is on port %d until you reclaim %d", spec.Name, spec.PublicPort, a.portHolderDesc(spec.PublicPort), actual, spec.PublicPort), + }) + a.logger.Warn("public port in use; reassigned tunnel", "tunnel_id", spec.ID, "requested_port", spec.PublicPort, "actual_port", actual) + ln = reln } pl := &publicListener{spec: spec, owner: sess, ln: ln, done: make(chan struct{})} - a.listeners[spec.ID] = pl + pl.limiters.Store(bwcap.BuildSet(spec.BandwidthLimitMbps, spec.BandwidthLimitScope)) + if subtree == nil { + subtree = make(map[string]*publicListener) + a.listeners[sess.agentID] = subtree + } + subtree[spec.ID] = pl go a.acceptClients(pl, handle) return ln.Addr().(*net.TCPAddr).Port, nil } +// listenFirstFree tries to bind each candidate port in order and returns the +// first listener that opens. A candidate of 0 asks the OS for any free ephemeral +// port. Returns nil when every candidate is taken (or the list is empty). +func listenFirstFree(bindAddr string, candidates []int) net.Listener { + for _, p := range candidates { + ln, err := net.Listen("tcp", net.JoinHostPort(bindAddr, strconv.Itoa(p))) + if err == nil { + return ln + } + } + return nil +} + +// portHolderDesc names whatever holds a public port, for a reassignment message: +// another of this gateway's agents (found in the listener map — the "evict other" +// case) when it is an internal clash, else the owning OS process (portowner) when +// identifiable. Runs on the actor goroutine, so reading listeners is race-free. +func (a *actor) portHolderDesc(port int) string { + for agentID, subtree := range a.listeners { + for _, pl := range subtree { + if pl.ln.Addr().(*net.TCPAddr).Port == port { + return "agent " + agentID + } + } + } + if o, ok := portowner.Lookup(port); ok { + return o.String() + } + return "another process" +} + // unbindLocked runs on the actor goroutine: close one tunnel's listener if it // belongs to sess. func (a *actor) unbindLocked(sess *agentSession, tunnelID string) { - pl, ok := a.listeners[tunnelID] + subtree := a.listeners[sess.agentID] + pl, ok := subtree[tunnelID] if !ok || pl.owner != sess { return } pl.ln.Close() <-pl.done - delete(a.listeners, tunnelID) + delete(subtree, tunnelID) + if len(subtree) == 0 { + delete(a.listeners, sess.agentID) + } } // bindTunnel opens the public listener for a tunnel spec. Runs net.Listen on // the actor goroutine — binds are rare and this keeps port state serialized. -func (a *actor) bindTunnel(sess *agentSession, spec control.TunnelSpec, bindAddr string, handle func(*agentSession, control.TunnelSpec, net.Conn)) (int, error) { +func (a *actor) bindTunnel(sess *agentSession, spec control.TunnelSpec, bindAddr string, allowlist []int, handle func(*publicListener, net.Conn)) (int, error) { var ( port int outErr error ) ran := a.do(func() { - if a.current != sess || sess.evicted.Load() { + if a.agents[sess.agentID] != sess || sess.evicted.Load() { outErr = fmt.Errorf("session is no longer active") return } - port, outErr = a.bindLocked(sess, spec, bindAddr, handle) + port, outErr = a.bindLocked(sess, spec, bindAddr, allowlist, handle) }) if !ran { return 0, fmt.Errorf("gateway is shutting down") @@ -310,10 +537,10 @@ type reconcileOutcome struct { // the *requested* spec, so PublicPort-0 ephemeral tunnels stay stable) is // left untouched: live client connections survive re-syncs of identical // state. The bool result is false when the gateway is shutting down. -func (a *actor) reconcile(sess *agentSession, desired []control.TunnelSpec, bindAddr string, handle func(*agentSession, control.TunnelSpec, net.Conn)) ([]reconcileOutcome, bool) { +func (a *actor) reconcile(sess *agentSession, desired []control.TunnelSpec, bindAddr string, allowlist []int, handle func(*publicListener, net.Conn)) ([]reconcileOutcome, bool) { outcomes := make([]reconcileOutcome, 0, len(desired)) ran := a.do(func() { - if a.current != sess || sess.evicted.Load() { + if a.agents[sess.agentID] != sess || sess.evicted.Load() { err := fmt.Errorf("session is no longer active") for _, spec := range desired { outcomes = append(outcomes, reconcileOutcome{ID: spec.ID, Err: err}) @@ -324,23 +551,36 @@ func (a *actor) reconcile(sess *agentSession, desired []control.TunnelSpec, bind for _, spec := range desired { want[spec.ID] = struct{}{} } - for id, pl := range a.listeners { - if pl.owner != sess { - continue - } + // Remove this agent's listeners not in the desired set. Collect the ids + // first — unbindLocked may delete the subtree out from under a range. + subtree := a.listeners[sess.agentID] + var remove []string + for id := range subtree { if _, ok := want[id]; !ok { - a.unbindLocked(sess, id) - a.logger.Debug("reconcile: tunnel removed", "tunnel_id", id) + remove = append(remove, id) } } + for _, id := range remove { + a.unbindLocked(sess, id) + a.logger.Debug("reconcile: tunnel removed", "tunnel_id", id) + } for _, spec := range desired { - if pl, ok := a.listeners[spec.ID]; ok && pl.owner == sess && pl.spec == spec { - // Identical desired state: keep the listener and its live - // connections. + if pl, ok := a.listeners[sess.agentID][spec.ID]; ok && sameListener(pl.spec, spec) { + // Same public listener. If only the bandwidth cap changed, apply + // it to the existing limiter set (rate-only in place, scope/flip + // swaps the pointer) and keep the listener and its live + // connections — no rebind, no drop. pl.spec is left untouched + // (handleClient reads it off-actor); the limiter set is the live + // cap. + if pl.spec != spec { + if set, swapped := bwcap.Reconcile(pl.limiters.Load(), spec.BandwidthLimitMbps, spec.BandwidthLimitScope); swapped { + pl.limiters.Store(set) + } + } outcomes = append(outcomes, reconcileOutcome{ID: spec.ID, Port: pl.ln.Addr().(*net.TCPAddr).Port}) continue } - port, err := a.bindLocked(sess, spec, bindAddr, handle) + port, err := a.bindLocked(sess, spec, bindAddr, allowlist, handle) outcomes = append(outcomes, reconcileOutcome{ID: spec.ID, Port: port, Err: err}) } }) @@ -349,7 +589,7 @@ func (a *actor) reconcile(sess *agentSession, desired []control.TunnelSpec, bind // acceptClients is the per-listener accept loop (not on the actor // goroutine); it must close done on exit — eviction blocks on it. -func (a *actor) acceptClients(pl *publicListener, handle func(*agentSession, control.TunnelSpec, net.Conn)) { +func (a *actor) acceptClients(pl *publicListener, handle func(*publicListener, net.Conn)) { defer close(pl.done) for { conn, err := pl.ln.Accept() @@ -359,25 +599,49 @@ func (a *actor) acceptClients(pl *publicListener, handle func(*agentSession, con a.clientWG.Add(1) go func() { defer a.clientWG.Done() - handle(pl.owner, pl.spec, conn) + handle(pl, conn) }() } } -// currentSession returns the connected agent session, if any. -func (a *actor) currentSession() *agentSession { +// sameListener reports whether two specs describe the same public listener — +// everything the bound port, accept loop, and login sniffer depend on. It +// excludes the bandwidth fields, which the pl's limiter set enforces and which +// can change (rate in place, scope by swap) without rebinding the listener. +func sameListener(a, b control.TunnelSpec) bool { + return a.ID == b.ID && a.Name == b.Name && a.Type == b.Type && + a.PublicPort == b.PublicPort && a.OfflineMOTD == b.OfflineMOTD && + a.MinecraftAware == b.MinecraftAware +} + +// sessions returns every connected agent session. +func (a *actor) sessions() []*agentSession { + var out []*agentSession + a.do(func() { out = snapshotSessions(a.agents) }) + return out +} + +// session returns the live session for one agentID, or nil. +func (a *actor) session(agentID string) *agentSession { var sess *agentSession - a.do(func() { sess = a.current }) + a.do(func() { sess = a.agents[agentID] }) return sess } // TunnelSnapshot is one registered tunnel's live state, for status surfaces. type TunnelSnapshot struct { + AgentID string // the agent that registered this tunnel ID string Name string - PublicPort int // actual bound port - LocalUp bool // agent's last reported backend health - LocalKnown bool + PublicPort int // actual bound port + // RequestedPort is the port the spec asked for. When it differs from PublicPort + // (and is non-zero), the port was in use and the tunnel was auto-reassigned — the + // signal the GUI turns into a "reclaim port" card. + RequestedPort int + LocalUp bool // agent's last reported backend health + LocalKnown bool + BandwidthLimitMbps int // configured cap (0 = unlimited) + BandwidthLimitScope string // combined | per-direction | per-connection } // tunnels snapshots every registered tunnel and its bound port, joined with @@ -385,20 +649,29 @@ type TunnelSnapshot struct { func (a *actor) tunnels() []TunnelSnapshot { var out []TunnelSnapshot a.do(func() { - for _, pl := range a.listeners { - ts := TunnelSnapshot{ - ID: pl.spec.ID, - Name: pl.spec.Name, - PublicPort: pl.ln.Addr().(*net.TCPAddr).Port, - } - if v, ok := pl.owner.health.Load(pl.spec.ID); ok { - ts.LocalUp, ts.LocalKnown = v.(bool), true + for _, subtree := range a.listeners { + for _, pl := range subtree { + ts := TunnelSnapshot{ + AgentID: pl.owner.agentID, + ID: pl.spec.ID, + Name: pl.spec.Name, + PublicPort: pl.ln.Addr().(*net.TCPAddr).Port, + RequestedPort: pl.spec.PublicPort, + BandwidthLimitMbps: pl.spec.BandwidthLimitMbps, + BandwidthLimitScope: pl.spec.BandwidthLimitScope, + } + if v, ok := pl.owner.health.Load(pl.spec.ID); ok { + ts.LocalUp, ts.LocalKnown = v.(bool), true + } + out = append(out, ts) } - out = append(out, ts) } }) // Map iteration order is random; sort so the GUI list is stable. sort.Slice(out, func(i, j int) bool { + if out[i].AgentID != out[j].AgentID { + return out[i].AgentID < out[j].AgentID + } if out[i].Name != out[j].Name { return out[i].Name < out[j].Name } @@ -407,24 +680,21 @@ func (a *actor) tunnels() []TunnelSnapshot { return out } -// tunnelPort reports the bound port for a tunnel ID. +// tunnelPort reports the bound port for a tunnel ID, scanning every agent. Bare +// tunnelIDs are globally-unique UUIDs in practice, so the first match is the +// one; the multi-agent status surfaces (Phase 3) key by (agentID, tunnelID). func (a *actor) tunnelPort(tunnelID string) (int, bool) { var ( port int found bool ) a.do(func() { - if pl, ok := a.listeners[tunnelID]; ok { - port = pl.ln.Addr().(*net.TCPAddr).Port - found = true + for _, subtree := range a.listeners { + if pl, ok := subtree[tunnelID]; ok { + port, found = pl.ln.Addr().(*net.TCPAddr).Port, true + return + } } }) return port, found } - -func shortID(id string) string { - if len(id) > 8 { - return id[:8] - } - return id -} diff --git a/internal/gateway/agentstore.go b/internal/gateway/agentstore.go new file mode 100644 index 0000000..62c2fd4 --- /dev/null +++ b/internal/gateway/agentstore.go @@ -0,0 +1,316 @@ +package gateway + +// AgentStore is the gateway's per-agent identity allowlist plus the outstanding +// enrollment tickets. It is the authority that replaces the shared token: the +// canonical identity of an agent is its Ed25519 public key (the map key), and the +// agt_ agentID is a derived display label. Revoking one agent removes its entry +// without touching the rest of the fleet. +// +// Concurrency: a single mutex guards both maps; every mutation persists the whole +// (small) store atomically before returning, so a crash never leaves a half-written +// allowlist. Writes happen only on pair / enroll / revoke / rename — never on the +// data path — so the coarse lock is fine. + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "proxyforward/internal/control" + "proxyforward/internal/link" +) + +const agentStoreFile = "gateway_agents.json" + +// Ticket-redemption failures, surfaced to the enrolling agent as a hello error. +var ( + ErrTicketUnknown = errors.New("pairing code not recognized (it may have been rotated)") + ErrTicketConsumed = errors.New("this pairing code was already used — ask for a fresh one") + ErrTicketExpired = errors.New("this pairing code has expired — ask for a fresh one") +) + +// Scope restricts which public ports and tunnel IDs an agent may bind. An empty +// list means "any" — the permissive default that preserves pre-scope behavior. +type Scope struct { + Ports []int `json:"ports,omitempty"` + TunnelIDs []string `json:"tunnelIds,omitempty"` +} + +// AllowsPort reports whether p is within scope (empty Ports = unrestricted). +func (s Scope) AllowsPort(p int) bool { + if len(s.Ports) == 0 { + return true + } + for _, x := range s.Ports { + if x == p { + return true + } + } + return false +} + +// AllowsTunnel reports whether id is within scope (empty TunnelIDs = unrestricted). +func (s Scope) AllowsTunnel(id string) bool { + if len(s.TunnelIDs) == 0 { + return true + } + for _, x := range s.TunnelIDs { + if x == id { + return true + } + } + return false +} + +// AgentRecord is one enrolled agent's persisted identity plus the +// gateway-authoritative tunnel config the gateway holds for it (CapGatewayConfig). +// DesiredTunnels is the resolved desired set (concrete ports); ConfigGen is a +// per-agent monotonic generation bumped on every adopt so drift is detectable on +// each reconnect. Both zero for an agent that has never synced a config. +type AgentRecord struct { + AgentID string `json:"agentId"` + PubKey []byte `json:"pubKey"` + Nickname string `json:"nickname,omitempty"` + Scope Scope `json:"scope,omitempty"` + IssuedAt time.Time `json:"issuedAt"` + Revoked bool `json:"revoked,omitempty"` + DesiredTunnels []control.TunnelSpec `json:"desiredTunnels,omitempty"` + ConfigGen uint64 `json:"configGen,omitempty"` +} + +// enrollNonce is one outstanding pairing ticket. +type enrollNonce struct { + Exp time.Time `json:"exp,omitempty"` // zero = never expires + Reusable bool `json:"reusable,omitempty"` // false = single-use (the safe default) + Scope Scope `json:"scope,omitempty"` + Consumed bool `json:"consumed,omitempty"` +} + +type storeFile struct { + Agents []AgentRecord `json:"agents"` + Nonces map[string]enrollNonce `json:"nonces"` +} + +// AgentStore is safe for concurrent use. +type AgentStore struct { + path string + mu sync.Mutex + agents map[string]AgentRecord // key: hex(pubkey) — the canonical identity + nonces map[string]enrollNonce // key: ticket nonce +} + +// LoadAgentStore reads the allowlist under dir, returning an empty store if none +// exists yet. +func LoadAgentStore(dir string) (*AgentStore, error) { + s := &AgentStore{ + path: filepath.Join(dir, agentStoreFile), + agents: map[string]AgentRecord{}, + nonces: map[string]enrollNonce{}, + } + data, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + if err != nil { + return nil, fmt.Errorf("read agent store: %w", err) + } + var f storeFile + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("parse agent store %s: %w", s.path, err) + } + for _, r := range f.Agents { + s.agents[hex.EncodeToString(r.PubKey)] = r + } + if f.Nonces != nil { + s.nonces = f.Nonces + } + return s, nil +} + +// save persists the store atomically. The caller must hold s.mu. +func (s *AgentStore) save() error { + f := storeFile{Nonces: s.nonces} + for _, r := range s.agents { + f.Agents = append(f.Agents, r) + } + data, err := json.MarshalIndent(f, "", " ") + if err != nil { + return fmt.Errorf("encode agent store: %w", err) + } + return atomicWriteFile(s.path, data, 0o600) +} + +// IssueEnrollment mints a fresh pairing ticket and records it. reusable=false is +// single-use (the safe default); a zero exp never expires. Returns the opaque +// ticket nonce for embedding in the pairing code. +func (s *AgentStore) IssueEnrollment(reusable bool, exp time.Time, scope Scope) (string, error) { + ticket, err := link.NewEnrollTicket() + if err != nil { + return "", err + } + s.mu.Lock() + defer s.mu.Unlock() + s.nonces[ticket] = enrollNonce{Exp: exp, Reusable: reusable, Scope: scope} + if err := s.save(); err != nil { + return "", err + } + return ticket, nil +} + +// Enroll validates ticket and binds pubKey to the allowlist under the derived +// agentID, consuming a single-use ticket. Re-enrolling an existing pubkey keeps its +// nickname. now is injected for deterministic expiry checks. +func (s *AgentStore) Enroll(pubKey []byte, agentID, ticket string, now time.Time) (AgentRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + + n, ok := s.nonces[ticket] + if !ok { + return AgentRecord{}, ErrTicketUnknown + } + if n.Consumed { + return AgentRecord{}, ErrTicketConsumed + } + if !n.Exp.IsZero() && now.After(n.Exp) { + return AgentRecord{}, ErrTicketExpired + } + + key := hex.EncodeToString(pubKey) + rec := AgentRecord{ + AgentID: agentID, + PubKey: append([]byte(nil), pubKey...), + Scope: n.Scope, + IssuedAt: now, + } + if existing, ok := s.agents[key]; ok { + rec.Nickname = existing.Nickname // preserve a rename across re-enrollment + } + s.agents[key] = rec + if !n.Reusable { + n.Consumed = true + s.nonces[ticket] = n + } + if err := s.save(); err != nil { + return AgentRecord{}, err + } + return rec, nil +} + +// Lookup returns the record for a public key, if enrolled. +func (s *AgentStore) Lookup(pubKey []byte) (AgentRecord, bool) { + s.mu.Lock() + defer s.mu.Unlock() + rec, ok := s.agents[hex.EncodeToString(pubKey)] + return rec, ok +} + +// Revoke marks an agent revoked by agentID, reporting whether it was found. The +// record is kept (not deleted) so a revoked agent's next connect gets a clear +// "revoked" answer rather than an "unknown identity" one. +func (s *AgentStore) Revoke(agentID string) bool { + return s.mutate(agentID, func(r *AgentRecord) { r.Revoked = true }) +} + +// Rename sets an agent's display nickname, reporting whether it was found. +func (s *AgentStore) Rename(agentID, nickname string) bool { + return s.mutate(agentID, func(r *AgentRecord) { r.Nickname = nickname }) +} + +// SetScope replaces an agent's bind scope, reporting whether it was found. +func (s *AgentStore) SetScope(agentID string, scope Scope) bool { + return s.mutate(agentID, func(r *AgentRecord) { r.Scope = scope }) +} + +// mutate applies fn to the record with the given agentID and persists. +func (s *AgentStore) mutate(agentID string, fn func(*AgentRecord)) bool { + s.mu.Lock() + defer s.mu.Unlock() + for key, r := range s.agents { + if r.AgentID == agentID { + fn(&r) + s.agents[key] = r + _ = s.save() + return true + } + } + return false +} + +// DesiredConfig returns the gateway-authoritative tunnel set and its generation for +// an agent. ok is false only when the agent is unknown; a known agent with no config +// yet returns (nil, 0, true) — the caller distinguishes "no config" (bootstrap) from +// "unknown agent" (reject). +func (s *AgentStore) DesiredConfig(agentID string) ([]control.TunnelSpec, uint64, bool) { + s.mu.Lock() + defer s.mu.Unlock() + for _, r := range s.agents { + if r.AgentID == agentID { + return append([]control.TunnelSpec(nil), r.DesiredTunnels...), r.ConfigGen, true + } + } + return nil, 0, false +} + +// AdoptConfig replaces an agent's authoritative tunnel set, bumps its generation, +// and persists. It reports false only for an unknown agent. The specs are stored +// verbatim (already resolved to concrete ports by the caller). +func (s *AgentStore) AdoptConfig(agentID string, specs []control.TunnelSpec) (uint64, bool) { + var gen uint64 + ok := s.mutate(agentID, func(r *AgentRecord) { + r.ConfigGen++ + r.DesiredTunnels = append([]control.TunnelSpec(nil), specs...) + gen = r.ConfigGen + }) + return gen, ok +} + +// List returns a snapshot of all enrolled agents. +func (s *AgentStore) List() []AgentRecord { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]AgentRecord, 0, len(s.agents)) + for _, r := range s.agents { + out = append(out, r) + } + return out +} + +// atomicWriteFile writes data to path via a temp file + rename, retrying the rename +// once for Windows AV scanners that briefly hold fresh files (mirrors +// setup.atomicWrite, which is unexported). +func atomicWriteFile(path string, data []byte, mode os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".agents-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(mode); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, path); err != nil { + time.Sleep(100 * time.Millisecond) + if err = os.Rename(tmpName, path); err != nil { + return err + } + } + return nil +} diff --git a/internal/gateway/agentstore_test.go b/internal/gateway/agentstore_test.go new file mode 100644 index 0000000..6be4b53 --- /dev/null +++ b/internal/gateway/agentstore_test.go @@ -0,0 +1,170 @@ +package gateway + +import ( + "bytes" + "errors" + "testing" + "time" + + "proxyforward/internal/control" +) + +func pub(b byte) []byte { return bytes.Repeat([]byte{b}, 32) } + +// TestAgentStoreEnrollLookupRevoke: a single-use ticket enrolls one agent, is then +// spent, and revoke flips the record. (identity) +func TestAgentStoreEnrollLookupRevoke(t *testing.T) { + s, err := LoadAgentStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + now := time.Now() + ticket, err := s.IssueEnrollment(false, time.Time{}, Scope{}) + if err != nil { + t.Fatal(err) + } + + rec, err := s.Enroll(pub(1), "agt_one", ticket, now) + if err != nil { + t.Fatalf("enroll: %v", err) + } + if rec.AgentID != "agt_one" { + t.Fatalf("record agentID: %q", rec.AgentID) + } + if got, ok := s.Lookup(pub(1)); !ok || got.AgentID != "agt_one" || got.Revoked { + t.Fatalf("lookup after enroll: %+v ok=%v", got, ok) + } + + // A single-use ticket cannot enroll a second identity. + if _, err := s.Enroll(pub(2), "agt_two", ticket, now); !errors.Is(err, ErrTicketConsumed) { + t.Fatalf("reused single-use ticket: want ErrTicketConsumed, got %v", err) + } + + if !s.Revoke("agt_one") { + t.Fatal("revoke should report the agent was found") + } + if got, _ := s.Lookup(pub(1)); !got.Revoked { + t.Fatalf("record should be revoked: %+v", got) + } + if s.Revoke("agt_missing") { + t.Fatal("revoking an unknown agent should report not-found") + } +} + +// TestAgentStoreReusableTicket: a reusable ticket enrolls many agents. (identity) +func TestAgentStoreReusableTicket(t *testing.T) { + s, _ := LoadAgentStore(t.TempDir()) + now := time.Now() + ticket, err := s.IssueEnrollment(true, time.Time{}, Scope{}) + if err != nil { + t.Fatal(err) + } + if _, err := s.Enroll(pub(1), "agt_one", ticket, now); err != nil { + t.Fatal(err) + } + if _, err := s.Enroll(pub(2), "agt_two", ticket, now); err != nil { + t.Fatalf("reusable ticket should enroll again: %v", err) + } +} + +// TestAgentStoreExpiredTicket: an expired ticket is refused. (identity) +func TestAgentStoreExpiredTicket(t *testing.T) { + s, _ := LoadAgentStore(t.TempDir()) + now := time.Now() + ticket, _ := s.IssueEnrollment(false, now.Add(-time.Minute), Scope{}) + if _, err := s.Enroll(pub(1), "agt_one", ticket, now); !errors.Is(err, ErrTicketExpired) { + t.Fatalf("want ErrTicketExpired, got %v", err) + } + if _, err := s.Enroll(pub(1), "agt_one", "no-such-ticket", now); !errors.Is(err, ErrTicketUnknown) { + t.Fatalf("want ErrTicketUnknown, got %v", err) + } +} + +// TestAgentStorePersistence: enrollments and spent tickets survive a reload from +// disk. (identity) +func TestAgentStorePersistence(t *testing.T) { + dir := t.TempDir() + s1, _ := LoadAgentStore(dir) + now := time.Now() + ticket, _ := s1.IssueEnrollment(false, time.Time{}, Scope{Ports: []int{25565}}) + if _, err := s1.Enroll(pub(7), "agt_seven", ticket, now); err != nil { + t.Fatal(err) + } + + s2, err := LoadAgentStore(dir) + if err != nil { + t.Fatal(err) + } + got, ok := s2.Lookup(pub(7)) + if !ok || got.AgentID != "agt_seven" || len(got.Scope.Ports) != 1 || got.Scope.Ports[0] != 25565 { + t.Fatalf("record did not persist: %+v ok=%v", got, ok) + } + // The spent single-use ticket stays spent across the reload. + if _, err := s2.Enroll(pub(8), "agt_eight", ticket, now); !errors.Is(err, ErrTicketConsumed) { + t.Fatalf("spent ticket after reload: want ErrTicketConsumed, got %v", err) + } +} + +// TestAgentStoreDesiredConfig: the gateway-authoritative tunnel set is keyed to an +// agent identity, its generation bumps monotonically on each adopt, and both survive +// a reload from disk. (gateway-config) +func TestAgentStoreDesiredConfig(t *testing.T) { + dir := t.TempDir() + s, _ := LoadAgentStore(dir) + now := time.Now() + ticket, _ := s.IssueEnrollment(false, time.Time{}, Scope{}) + if _, err := s.Enroll(pub(3), "agt_three", ticket, now); err != nil { + t.Fatal(err) + } + + // A freshly enrolled agent is known but has no config yet: generation 0. + if specs, gen, ok := s.DesiredConfig("agt_three"); !ok || gen != 0 || len(specs) != 0 { + t.Fatalf("fresh agent config: specs=%v gen=%d ok=%v", specs, gen, ok) + } + // An unknown agent reports not-found (distinct from the empty-config case). + if _, _, ok := s.DesiredConfig("agt_missing"); ok { + t.Fatal("unknown agent must report not-found") + } + + // Adopting a set bumps the generation from 0 to 1. + set1 := []control.TunnelSpec{{ID: "tnl_a", Name: "mc", Type: "tcp", PublicPort: 25565}} + if gen, ok := s.AdoptConfig("agt_three", set1); !ok || gen != 1 { + t.Fatalf("first adopt: gen=%d ok=%v", gen, ok) + } + // A second adopt bumps monotonically to 2. + set2 := append(append([]control.TunnelSpec(nil), set1...), control.TunnelSpec{ID: "tnl_b", Name: "web", Type: "tcp", PublicPort: 8080}) + if gen, ok := s.AdoptConfig("agt_three", set2); !ok || gen != 2 { + t.Fatalf("second adopt: gen=%d ok=%v", gen, ok) + } + // Adopting for an unknown agent reports not-found without panicking. + if _, ok := s.AdoptConfig("agt_missing", set1); ok { + t.Fatal("adopt for unknown agent must report not-found") + } + + // The stored config and generation survive a reload. + s2, err := LoadAgentStore(dir) + if err != nil { + t.Fatal(err) + } + specs, gen, ok := s2.DesiredConfig("agt_three") + if !ok || gen != 2 || len(specs) != 2 { + t.Fatalf("config did not persist: specs=%v gen=%d ok=%v", specs, gen, ok) + } + if control.HashTunnels(specs) != control.HashTunnels(set2) { + t.Fatalf("persisted config content drifted: %v", specs) + } +} + +// TestScopeAllows: an empty scope is permissive; a set scope restricts. (scope) +func TestScopeAllows(t *testing.T) { + if !(Scope{}).AllowsPort(25565) || !(Scope{}).AllowsTunnel("tnl_x") { + t.Fatal("empty scope must allow anything") + } + sc := Scope{Ports: []int{25565}, TunnelIDs: []string{"tnl_x"}} + if !sc.AllowsPort(25565) || sc.AllowsPort(25566) { + t.Fatal("port scope enforcement wrong") + } + if !sc.AllowsTunnel("tnl_x") || sc.AllowsTunnel("tnl_y") { + t.Fatal("tunnel scope enforcement wrong") + } +} diff --git a/internal/gateway/auth.go b/internal/gateway/auth.go new file mode 100644 index 0000000..77fd14f --- /dev/null +++ b/internal/gateway/auth.go @@ -0,0 +1,118 @@ +package gateway + +import ( + "crypto/ed25519" + "crypto/subtle" + "errors" + "time" + + "proxyforward/internal/control" + "proxyforward/internal/link" +) + +// Identity is what a Validator resolves a hello to. AgentID is the display label; +// PubKey is the canonical cryptographic identity and is non-nil only when the agent +// authenticated per-identity (not via the legacy shared token). Scope limits which +// ports and tunnels the agent may bind. +type Identity struct { + AgentID string + PubKey ed25519.PublicKey + Scope Scope +} + +// Validator authenticates a hello on both the control and data accept paths. +// gatewayCertFP is this gateway's pinned certificate fingerprint; a per-agent +// validator checks the agent's proof-of-possession signature against it. +type Validator interface { + Validate(hello *control.Hello, gatewayCertFP string) (Identity, error) +} + +// Auth failures. The accept paths map these to HelloErr codes. +var ( + ErrBadToken = errors.New("bad token") + ErrMissingAgentID = errors.New("missing agentId") + ErrRevoked = errors.New("agent identity revoked") +) + +// sharedTokenValidator is the legacy authenticator: one token admits every agent, +// told apart only by the self-asserted agentID. Retained as a migration fallback, +// gated by Gateway.AcceptSharedToken. +type sharedTokenValidator struct { + token string +} + +func (v sharedTokenValidator) Validate(hello *control.Hello, _ string) (Identity, error) { + if subtle.ConstantTimeCompare([]byte(hello.Token), []byte(v.token)) != 1 { + return Identity{}, ErrBadToken + } + if hello.AgentID == "" { + return Identity{}, ErrMissingAgentID + } + return Identity{AgentID: hello.AgentID}, nil +} + +// identityValidator authenticates an agent by its Ed25519 public key. It first +// verifies the proof-of-possession signature against the gateway fingerprint, then +// either enrolls the key (first contact, carrying a valid ticket) or checks it +// against the allowlist. A revoked key returns ErrRevoked so the agent stops rather +// than retry-hammering. +type identityValidator struct { + store *AgentStore + now func() time.Time +} + +func (v identityValidator) Validate(hello *control.Hello, fp string) (Identity, error) { + pub := ed25519.PublicKey(hello.AgentPubKey) + if !link.VerifyAgentAuth(pub, fp, hello.AgentSig) { + return Identity{}, ErrBadToken + } + // Already enrolled: authenticate by the allowlist and ignore any (possibly + // spent) ticket the agent still carries, so a reconnect never re-consumes it. + if rec, ok := v.store.Lookup(pub); ok { + if rec.Revoked { + return Identity{}, ErrRevoked + } + return Identity{AgentID: rec.AgentID, PubKey: pub, Scope: rec.Scope}, nil + } + // Not enrolled: a valid ticket is required to join the allowlist. + if hello.EnrollTicket == "" { + return Identity{}, ErrBadToken + } + rec, err := v.store.Enroll(pub, link.AgentID(pub), hello.EnrollTicket, v.now()) + if err != nil { + return Identity{}, err // ErrTicket* — surfaced with its own message + } + return Identity{AgentID: rec.AgentID, PubKey: pub, Scope: rec.Scope}, nil +} + +// compositeValidator prefers per-agent identity and falls back to the shared token +// during migration. shared is nil once shared-token acceptance is turned off, at +// which point a keyless (legacy) hello is rejected. +type compositeValidator struct { + identity identityValidator + shared *sharedTokenValidator +} + +func (v compositeValidator) Validate(hello *control.Hello, fp string) (Identity, error) { + if len(hello.AgentPubKey) > 0 { + id, err := v.identity.Validate(hello, fp) + switch { + case err == nil, errors.Is(err, ErrRevoked): + // Authenticated, or a definitive revocation — never mask either. + return id, err + case errors.Is(err, ErrTicketUnknown), errors.Is(err, ErrTicketConsumed), errors.Is(err, ErrTicketExpired): + // An explicit enrollment attempt failed; surface it, don't fall back. + return id, err + } + // Unknown key or bad signature and no ticket: during migration, fall back to + // the shared token if the agent offered one and the gateway still accepts it. + if v.shared != nil && hello.Token != "" { + return v.shared.Validate(hello, fp) + } + return id, err + } + if v.shared != nil { + return v.shared.Validate(hello, fp) + } + return Identity{}, ErrBadToken +} diff --git a/internal/gateway/auth_test.go b/internal/gateway/auth_test.go new file mode 100644 index 0000000..1c5b8ec --- /dev/null +++ b/internal/gateway/auth_test.go @@ -0,0 +1,144 @@ +package gateway + +import ( + "crypto/ed25519" + "crypto/rand" + "errors" + "testing" + "time" + + "proxyforward/internal/control" + "proxyforward/internal/link" +) + +// TestSharedTokenValidator characterizes the v1 authenticator: a constant-time +// shared-token compare plus a non-empty agentID check, returning a typed +// Identity or a sentinel error the accept paths map to a HelloErr code. +func TestSharedTokenValidator(t *testing.T) { + v := sharedTokenValidator{token: "s3cret"} + + cases := []struct { + name string + hello control.Hello + wantID string + wantErr error + }{ + {"good token and agentID", control.Hello{Token: "s3cret", AgentID: "agent-1"}, "agent-1", nil}, + {"wrong token", control.Hello{Token: "nope", AgentID: "agent-1"}, "", ErrBadToken}, + {"empty token", control.Hello{Token: "", AgentID: "agent-1"}, "", ErrBadToken}, + {"good token but empty agentID", control.Hello{Token: "s3cret", AgentID: ""}, "", ErrMissingAgentID}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + id, err := v.Validate(&tc.hello, "") + if !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want %v", err, tc.wantErr) + } + if id.AgentID != tc.wantID { + t.Fatalf("identity agentID = %q, want %q", id.AgentID, tc.wantID) + } + }) + } +} + +// enrollHello builds a per-identity hello: pubkey + proof-of-possession signature +// bound to fp, optionally carrying an enrollment ticket. +func enrollHello(t *testing.T, priv ed25519.PrivateKey, pub ed25519.PublicKey, fp, ticket string) control.Hello { + t.Helper() + return control.Hello{ + AgentPubKey: pub, + AgentSig: link.SignAgentAuth(priv, fp), + EnrollTicket: ticket, + } +} + +// TestIdentityValidator: a signed hello with a valid ticket enrolls; afterwards the +// keyed hello authenticates by allowlist; a revoked key is rejected with ErrRevoked; +// a bad signature or unknown key is ErrBadToken. +func TestIdentityValidator(t *testing.T) { + const fp = "sha256:deadbeef" + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + store, err := LoadAgentStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + v := identityValidator{store: store, now: time.Now} + + // Unknown key, no ticket → rejected. + if _, err := v.Validate(ptr(enrollHello(t, priv, pub, fp, "")), fp); !errors.Is(err, ErrBadToken) { + t.Fatalf("unknown key: want ErrBadToken, got %v", err) + } + + // Enroll with a ticket. + ticket, _ := store.IssueEnrollment(false, time.Time{}, Scope{}) + id, err := v.Validate(ptr(enrollHello(t, priv, pub, fp, ticket)), fp) + if err != nil { + t.Fatalf("enroll: %v", err) + } + if id.AgentID != link.AgentID(pub) || id.PubKey == nil { + t.Fatalf("enrolled identity wrong: %+v", id) + } + + // Steady state: keyed hello, no ticket, authenticates by allowlist. + if _, err := v.Validate(ptr(enrollHello(t, priv, pub, fp, "")), fp); err != nil { + t.Fatalf("steady-state auth: %v", err) + } + + // Wrong fingerprint invalidates the proof of possession. + if _, err := v.Validate(ptr(enrollHello(t, priv, pub, fp, "")), "sha256:other"); !errors.Is(err, ErrBadToken) { + t.Fatalf("wrong fingerprint: want ErrBadToken, got %v", err) + } + + // Revoke → ErrRevoked. + if !store.Revoke(link.AgentID(pub)) { + t.Fatal("revoke failed") + } + if _, err := v.Validate(ptr(enrollHello(t, priv, pub, fp, "")), fp); !errors.Is(err, ErrRevoked) { + t.Fatalf("revoked: want ErrRevoked, got %v", err) + } +} + +// TestCompositeValidator: a keyed hello takes the identity path; a keyless hello +// falls back to the shared token when enabled, and is rejected when it is nil. +func TestCompositeValidator(t *testing.T) { + const fp = "sha256:deadbeef" + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + store, _ := LoadAgentStore(t.TempDir()) + ticket, _ := store.IssueEnrollment(false, time.Time{}, Scope{}) + if _, err := store.Enroll(pub, link.AgentID(pub), ticket, time.Now()); err != nil { + t.Fatal(err) + } + + withShared := compositeValidator{ + identity: identityValidator{store: store, now: time.Now}, + shared: &sharedTokenValidator{token: "s3cret"}, + } + // Keyed hello → identity path. + if id, err := withShared.Validate(ptr(enrollHello(t, priv, pub, fp, "")), fp); err != nil || id.PubKey == nil { + t.Fatalf("keyed hello via composite: id=%+v err=%v", id, err) + } + // Keyless hello with the shared token → fallback path. + if id, err := withShared.Validate(&control.Hello{Token: "s3cret", AgentID: "legacy"}, fp); err != nil || id.AgentID != "legacy" { + t.Fatalf("shared fallback: id=%+v err=%v", id, err) + } + + // A keyed hello for an UNenrolled key with no ticket falls back to the shared + // token during migration (an existing agent now also sends its pubkey). + upub, upriv, _ := ed25519.GenerateKey(rand.Reader) + h := enrollHello(t, upriv, upub, fp, "") + h.Token, h.AgentID = "s3cret", "legacy2" + if id, err := withShared.Validate(&h, fp); err != nil || id.AgentID != "legacy2" || id.PubKey != nil { + t.Fatalf("migration fallback for unenrolled key: id=%+v err=%v", id, err) + } + + // With shared acceptance off, a keyless hello is rejected outright. + noShared := compositeValidator{identity: identityValidator{store: store, now: time.Now}} + if _, err := noShared.Validate(&control.Hello{Token: "s3cret", AgentID: "legacy"}, fp); !errors.Is(err, ErrBadToken) { + t.Fatalf("shared off: want ErrBadToken, got %v", err) + } +} + +func ptr(h control.Hello) *control.Hello { return &h } diff --git a/internal/gateway/conflict.go b/internal/gateway/conflict.go new file mode 100644 index 0000000..21e73af --- /dev/null +++ b/internal/gateway/conflict.go @@ -0,0 +1,134 @@ +package gateway + +import "sort" + +// Conflict resolution: the gateway prefers to keep an agent online and *report* a +// clash rather than fail it. Two clashes are handled here — a public port already +// in use (auto-reassign to a policy-valid alternative) and a same-agentID contest +// from two IPs (a suspected cloned key) — and both are recorded as events the GUI +// surfaces as resolvable cards. Detection lives here; the one-click fixes are the +// GUI's. Everything in this file is touched only from the actor goroutine (the +// event ring) or is pure (reassignCandidates), so nothing here locks. + +// Event kinds recorded in the gateway event ring. Stable strings — the GUI event +// log and any test match on them. +const ( + // EventPortReassigned: a requested public port was in use, so the tunnel was + // bound to a free alternative instead of failing. The card offers to reclaim it. + EventPortReassigned = "port-reassigned" + // EventCloneSuspected: the same agentID was rapidly contested from two IPs, + // which a derived (pubkey-bound) identity should make impossible unless the key + // was copied. The card nudges the user to re-enroll for a distinct identity. + EventCloneSuspected = "clone-suspected" +) + +// GatewayEvent is one notable, user-facing thing the gateway did or noticed — +// an auto-fix or a detected conflict. It is polled incrementally by the engine +// (since a cursor) and rendered in the GUI event log. Fields beyond the message +// are structured so a card can act on them without parsing prose. +type GatewayEvent struct { + Seq uint64 `json:"seq"` + TimeMs int64 `json:"timeMs"` + Kind string `json:"kind"` + AgentID string `json:"agentId,omitempty"` + TunnelID string `json:"tunnelId,omitempty"` + Message string `json:"message"` + RequestedPort int `json:"requestedPort,omitempty"` + ActualPort int `json:"actualPort,omitempty"` +} + +// eventRing is a bounded, monotonically-sequenced buffer of GatewayEvents. It is +// not safe for concurrent use — the actor owns one and mutates it only on its +// goroutine; reads funnel through the actor via do(). The since-cursor mirrors the +// engine log's LogsSince so the GUI can poll incrementally. +type eventRing struct { + cap int + nextSeq uint64 + events []GatewayEvent +} + +func newEventRing(capacity int) *eventRing { + return &eventRing{cap: capacity} +} + +// push assigns the next seq + records ev, dropping the oldest event once the ring +// is full. The caller sets Kind and any structured fields (and TimeMs); push owns +// Seq so cursors stay monotonic across drops. +func (r *eventRing) push(ev GatewayEvent) { + r.nextSeq++ + ev.Seq = r.nextSeq + r.events = append(r.events, ev) + if len(r.events) > r.cap { + // Drop the oldest, keeping the tail. Re-slice into a fresh backing array so + // the dropped head can be GC'd rather than pinned by the underlying array. + r.events = append([]GatewayEvent(nil), r.events[len(r.events)-r.cap:]...) + } +} + +// since returns every retained event with Seq > cursor, oldest first. A cursor at +// or beyond the newest seq returns nothing; a zero cursor returns all retained. +func (r *eventRing) since(cursor uint64) []GatewayEvent { + var out []GatewayEvent + for _, e := range r.events { + if e.Seq > cursor { + out = append(out, e) + } + } + return out +} + +// reassignCandidates returns the ordered public ports to try when `requested` (a +// specific, already-validated port) is found in use. Each candidate is +// policy-valid — it passes the same scope + allowlist checks validateSpec applied +// to the requested port — and the requested port itself is excluded. When neither +// a scope nor an allowlist constrains the choice, the single candidate 0 means +// "let the OS pick any free ephemeral port"; a constrained gateway must instead +// stay inside its allowed set and never hand out an arbitrary port. An empty +// result means no reassignment is possible (every allowed alternative is taken or +// the request was already ephemeral) and the caller hard-fails as before. +func reassignCandidates(requested int, scope Scope, allowlist []int) []int { + if requested == 0 { + return nil // an ephemeral request never conflicts, so it never reassigns + } + constrained := len(scope.Ports) > 0 || len(allowlist) > 0 + if !constrained { + return []int{0} // unconstrained: any ephemeral port is policy-valid + } + // Enumerate the constraining set (scope narrows first; the allowlist filters), + // keep only policy-valid ports other than the requested one, sorted ascending. + universe := allowlist + if len(scope.Ports) > 0 { + universe = scope.Ports + } + seen := make(map[int]struct{}, len(universe)) + var out []int + for _, p := range universe { + if p == requested || p <= 0 { + continue + } + if _, dup := seen[p]; dup { + continue + } + if !scope.AllowsPort(p) || !portInAllowlist(p, allowlist) { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + sort.Ints(out) + return out +} + +// portInAllowlist reports whether p is permitted by the gateway allowlist. An +// empty allowlist permits everything (mirrors validateSpec). +func portInAllowlist(p int, allowlist []int) bool { + if len(allowlist) == 0 { + return true + } + for _, a := range allowlist { + if a == p { + return true + } + } + return false +} diff --git a/internal/gateway/conflict_test.go b/internal/gateway/conflict_test.go new file mode 100644 index 0000000..0cd1763 --- /dev/null +++ b/internal/gateway/conflict_test.go @@ -0,0 +1,184 @@ +package gateway + +import ( + "context" + "io" + "log/slog" + "net" + "reflect" + "testing" + + "proxyforward/internal/config" + "proxyforward/internal/control" +) + +func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +// TestReassignCandidates: when a requested public port is in use, the gateway +// offers a policy-valid alternative. Unconstrained → an OS-ephemeral port (0). A +// scope or allowlist confines the alternatives to the allowed set (ascending, +// excluding the requested port); nothing outside policy is ever offered, and an +// exhausted set yields nothing (the caller then hard-fails as before). (conflict, #2) +func TestReassignCandidates(t *testing.T) { + tests := []struct { + name string + requested int + scope Scope + allowlist []int + want []int + }{ + {"unconstrained falls back to ephemeral", 25565, Scope{}, nil, []int{0}}, + {"allowlist confines and excludes requested", 25565, Scope{}, []int{25567, 25565, 25566}, []int{25566, 25567}}, + {"scope confines to its ports", 25565, Scope{Ports: []int{25565, 25566}}, nil, []int{25566}}, + {"scope intersect allowlist", 25565, Scope{Ports: []int{25565, 25566}}, []int{25566, 25567}, []int{25566}}, + {"ephemeral request never reassigns", 0, Scope{}, nil, nil}, + {"exhausted allowed set yields nothing", 25565, Scope{}, []int{25565}, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := reassignCandidates(tt.requested, tt.scope, tt.allowlist) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("reassignCandidates(%d, %+v, %v) = %v, want %v", tt.requested, tt.scope, tt.allowlist, got, tt.want) + } + }) + } +} + +// TestEventRingCapAndCursor: the gateway event ring keeps the most recent events +// up to its cap (dropping oldest), assigns monotonically increasing seqs, and +// since(seq) returns only events newer than the cursor — the incremental-poll +// contract the GUI event log reads. (conflict) +func TestEventRingCapAndCursor(t *testing.T) { + r := newEventRing(3) + for i := 0; i < 5; i++ { + r.push(GatewayEvent{Kind: EventPortReassigned, AgentID: "agt_a"}) + } + got := r.since(0) + if len(got) != 3 { + t.Fatalf("since(0) len = %d, want 3 (capped, oldest dropped)", len(got)) + } + if got[0].Seq != 3 || got[2].Seq != 5 { + t.Fatalf("seqs = %d..%d, want 3..5", got[0].Seq, got[2].Seq) + } + if n := len(r.since(4)); n != 1 { + t.Fatalf("since(4) len = %d, want 1", n) + } + if n := len(r.since(5)); n != 0 { + t.Fatalf("since(5) len = %d, want 0 (fully drained)", n) + } +} + +// TestBindLockedReassignsBusyPort: a bind to an in-use public port is no longer a +// hard failure — the actor binds a free alternative so the tunnel comes up, keeps +// the tunnel's *requested* spec (so a later reconcile of the same spec is a no-op +// rather than re-contending), and records a port-reassigned event naming both the +// requested and the actual port. (conflict, #2) +func TestBindLockedReassignsBusyPort(t *testing.T) { + occupied, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("occupy a port: %v", err) + } + defer occupied.Close() + busy := occupied.Addr().(*net.TCPAddr).Port + + a := newActor(discardLogger()) + ctx, cancel := context.WithCancel(context.Background()) + go a.run(ctx) + t.Cleanup(cancel) + + sess := &agentSession{agentID: "agt_test", scope: Scope{}} + if _, err := a.admit(sess); err != nil { + t.Fatalf("admit: %v", err) + } + spec := control.TunnelSpec{ID: "tnl_smp", Name: "smp", Type: config.TunnelTCP, PublicPort: busy} + got, err := a.bindTunnel(sess, spec, "127.0.0.1", nil, func(*publicListener, net.Conn) {}) + if err != nil { + t.Fatalf("bindTunnel on busy port should reassign, got err: %v", err) + } + if got == busy { + t.Fatalf("expected reassignment off the busy port %d, got the same port", busy) + } + if got == 0 { + t.Fatalf("reassigned port must be concrete, got 0") + } + + // The snapshot keeps the requested port so a reconcile of the same spec is a no-op. + var snap TunnelSnapshot + for _, ts := range a.tunnels() { + if ts.ID == "tnl_smp" { + snap = ts + } + } + if snap.RequestedPort != busy { + t.Fatalf("snapshot RequestedPort = %d, want the requested %d", snap.RequestedPort, busy) + } + if snap.PublicPort != got { + t.Fatalf("snapshot PublicPort = %d, want the actual bound %d", snap.PublicPort, got) + } + + var ev *GatewayEvent + for i, e := range a.eventsSince(0) { + if e.Kind == EventPortReassigned { + ev = &a.eventsSince(0)[i] + } + } + if ev == nil { + t.Fatalf("expected a %q event", EventPortReassigned) + } + if ev.RequestedPort != busy || ev.ActualPort != got { + t.Fatalf("event ports = requested %d / actual %d, want %d / %d", ev.RequestedPort, ev.ActualPort, busy, got) + } +} + +// TestAdmitFlagsSuspectedClone: two machines from different IPs rapidly contesting +// the same agentID (the signature of a shared/cloned key) supersede as before but +// also raise a clone-suspected event, so the GUI can nudge the user to re-enroll. +// A single supersede (a laptop that changed networks) does NOT flag — one +// reconnect from a new IP is normal, only a rapid two-sided contest is a clone. (conflict, #1) +func TestAdmitFlagsSuspectedClone(t *testing.T) { + mk := func(ip string) *agentSession { return &agentSession{agentID: "agt_x", remoteIP: ip} } + + t.Run("rapid two-sided contest flags", func(t *testing.T) { + a := newActor(discardLogger()) + ctx, cancel := context.WithCancel(context.Background()) + go a.run(ctx) + t.Cleanup(cancel) + if _, err := a.admit(mk("1.1.1.1")); err != nil { + t.Fatalf("admit 1: %v", err) + } + if _, err := a.admit(mk("2.2.2.2")); err != nil { // supersede #1, no flag yet + t.Fatalf("admit 2: %v", err) + } + if _, err := a.admit(mk("1.1.1.1")); err != nil { // supersede #2 rapid + different IP → flag + t.Fatalf("admit 3: %v", err) + } + if !hasEventKind(a.eventsSince(0), EventCloneSuspected) { + t.Fatalf("expected a %q event after a rapid two-sided contest", EventCloneSuspected) + } + }) + + t.Run("single supersede from a new IP does not flag", func(t *testing.T) { + a := newActor(discardLogger()) + ctx, cancel := context.WithCancel(context.Background()) + go a.run(ctx) + t.Cleanup(cancel) + if _, err := a.admit(mk("1.1.1.1")); err != nil { + t.Fatalf("admit 1: %v", err) + } + if _, err := a.admit(mk("2.2.2.2")); err != nil { // network change: one supersede + t.Fatalf("admit 2: %v", err) + } + if hasEventKind(a.eventsSince(0), EventCloneSuspected) { + t.Fatalf("a single supersede from a new IP must not look like a clone") + } + }) +} + +func hasEventKind(evs []GatewayEvent, kind string) bool { + for _, e := range evs { + if e.Kind == kind { + return true + } + } + return false +} diff --git a/internal/gateway/dataplane.go b/internal/gateway/dataplane.go new file mode 100644 index 0000000..db8adc5 --- /dev/null +++ b/internal/gateway/dataplane.go @@ -0,0 +1,75 @@ +package gateway + +import ( + "errors" + + "proxyforward/internal/control" + "proxyforward/internal/relay" +) + +// errNoDataLeg means a player's data leg could not be acquired; handleClient +// answers with the offline responder instead of dropping the player. +var errNoDataLeg = errors.New("gateway: no data leg") + +// dataPlane acquires the data leg for one player, hiding the mux-vs-per-conn +// choice so handleClient stays transport-agnostic — the leg is always a +// relay.Conn spliced identically. The choice is made once per session at +// admission (pickDataPlane) and is immutable thereafter. A future QUIC session +// slots in behind muxDataPlane: its streams already satisfy transport.Stream (a +// relay.Conn), so handleClient needs no change to carry them. +type dataPlane interface { + // openFlow returns the player's data leg plus a cleanup to run when the leg + // closes. A non-nil error tells the caller to serve the offline responder. + openFlow(sess *agentSession, connID string) (leg relay.Conn, cleanup func(), err error) +} + +// muxDataPlane opens a stream on the agent's shared session (yamux today, any +// transport.Session tomorrow). The legacy/default transport: all players +// multiplex over the one gateway↔agent link. +type muxDataPlane struct{} + +func (muxDataPlane) openFlow(sess *agentSession, _ string) (relay.Conn, func(), error) { + mux := sess.session() + if mux == nil { + return nil, nil, errNoDataLeg + } + st, err := mux.OpenStream() + if err != nil { + return nil, nil, err + } + // A mux stream is torn down by the session; no per-flow cleanup needed. + return st, func() {}, nil +} + +// perConnDataPlane signals the agent to dial back a dedicated TCP+TLS connection +// per player (no cross-player head-of-line blocking on the gateway↔agent hop). +// The dialed conn is tracked in sess.dataConns so eviction (closeAll) can drain +// it — its splice rides a dedicated conn, not the mux, so closing the mux alone +// cannot; cleanup removes that entry when the splice ends. +type perConnDataPlane struct{ g *Gateway } + +func (p perConnDataPlane) openFlow(sess *agentSession, connID string) (relay.Conn, func(), error) { + raw := p.g.openDataConn(sess, connID) + if raw == nil { + return nil, nil, errNoDataLeg + } + rc, ok := raw.(relay.Conn) + if !ok { + // The counting-conn wrapper always preserves CloseWrite, so a failed + // assertion is a programming error, not a runtime condition. Drop the + // conn rather than splice something that cannot half-close. + raw.Close() + return nil, nil, errNoDataLeg + } + sess.dataConns.Store(connID, raw) + return rc, func() { sess.dataConns.Delete(connID) }, nil +} + +// pickDataPlane selects a session's data plane from its negotiated capabilities, +// once at admission. The result is immutable for the session's lifetime. +func (g *Gateway) pickDataPlane(caps control.CapSet) dataPlane { + if caps.Has(control.CapPerConn) { + return perConnDataPlane{g: g} + } + return muxDataPlane{} +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index d761224..b203140 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -10,23 +10,25 @@ package gateway import ( "context" - "crypto/subtle" "crypto/tls" "errors" "fmt" "log/slog" "net" "os" + "sort" "strconv" "sync" "sync/atomic" "time" + "proxyforward/internal/bwcap" "proxyforward/internal/config" "proxyforward/internal/conntrack" "proxyforward/internal/control" "proxyforward/internal/link" "proxyforward/internal/linkquality" + "proxyforward/internal/mc" "proxyforward/internal/mcsniff" "proxyforward/internal/netid" "proxyforward/internal/relay" @@ -46,6 +48,8 @@ const ( controlIdleTimeout = 15 * time.Second // controlWriteTimeout bounds any single control-frame write. controlWriteTimeout = 10 * time.Second + // offlineServeTimeout bounds one offline MOTD exchange with a player. + offlineServeTimeout = 10 * time.Second // The gateway pings the agent on this cadence so it measures the same // RTT/jitter/loss the agent does. lossWindow/lossTimeout mirror the agent: @@ -74,6 +78,10 @@ type Gateway struct { fingerprint string controlLn net.Listener + // quicLn is the UDP QUIC control listener bound alongside controlLn on the + // same port when Gateway.QUICEnabled; nil otherwise. Its Transport and socket + // are shared by every QUIC agent session (closed once, on Shutdown). + quicLn *transport.QUICListener // actor is published by Start while status surfaces (the engine's stats // sampler, the GUI) may already be polling: the reads below genuinely race @@ -82,6 +90,15 @@ type Gateway struct { actor atomic.Pointer[actor] authLim *authLimiter gate *connGate + // validator authenticates every hello, on the control accept path and (with + // the per-conn data plane) the data accept path. It is a compositeValidator: + // per-agent Ed25519 identity backed by agents, with the shared token as a + // migration fallback. + validator Validator + // agents is the per-agent identity allowlist + outstanding enrollment tickets; + // gatewayID is this gateway's derived gw_ display label (from its cert). + agents *AgentStore + gatewayID string // Conns tracks live proxied connections for the GUI. Conns *conntrack.Registry @@ -90,9 +107,15 @@ type Gateway struct { // is visible to anyone. connSeq atomic.Uint64 // linkTotals counts raw control-link bytes across all agent sessions of - // this process. + // this process (control conns and per-conn data conns alike). linkTotals stats.LinkCounters + // pendingData holds per-conn data connections the gateway has asked agents + // to dial back, keyed by the global connID, awaiting their match. Kept off + // the actor so the data accept path matches in O(1) without a lifecycle + // round-trip. + pendingData sync.Map + wg sync.WaitGroup cancel context.CancelFunc } @@ -133,6 +156,12 @@ func (g *Gateway) Start(ctx context.Context) error { return err } g.fingerprint = fp + g.gatewayID = link.GatewayID(cert.Certificate[0]) + store, err := LoadAgentStore(g.dir) + if err != nil { + return err + } + g.agents = store addr := net.JoinHostPort(g.cfg.Gateway.BindAddr, strconv.Itoa(g.cfg.Gateway.ControlPort)) ln, err := net.Listen("tcp", addr) @@ -145,6 +174,14 @@ func (g *Gateway) Start(ctx context.Context) error { g.cancel = cancel g.authLim = newAuthLimiter(g.cfg.Gateway.AuthAttemptsPerMin) g.gate = newConnGate(g.cfg.Gateway.MaxConnsGlobal, g.cfg.Gateway.MaxConnsPerIP) + var shared *sharedTokenValidator + if g.cfg.Gateway.AcceptSharedToken { + shared = &sharedTokenValidator{token: g.cfg.Gateway.Token} + } + g.validator = compositeValidator{ + identity: identityValidator{store: g.agents, now: time.Now}, + shared: shared, + } a := newActor(g.logger) g.actor.Store(a) g.wg.Add(2) @@ -156,15 +193,55 @@ func (g *Gateway) Start(ctx context.Context) error { defer g.wg.Done() g.acceptControl(runCtx) }() + if g.cfg.Gateway.QUICEnabled { + // A QUIC bind failure must never take the gateway down — TCP transports + // still serve every agent. Log and carry on with QUIC disabled. + if err := g.startQUIC(runCtx, cert); err != nil { + g.logger.Error("quic control listener disabled: bind failed", "err", err) + } + } g.logger.Info("control listener up", "addr", g.controlLn.Addr().String(), "fingerprint", fp) return nil } +// startQUIC binds a UDP socket on the TCP control port and starts accepting QUIC +// agent sessions on it. TCP and UDP port spaces are independent, so the shared +// port number keeps the pairing code (one host:port) valid for both transports. +func (g *Gateway) startQUIC(ctx context.Context, cert tls.Certificate) error { + port := g.controlLn.Addr().(*net.TCPAddr).Port // resolved even when ControlPort==0 + udpAddr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(g.cfg.Gateway.BindAddr, strconv.Itoa(port))) + if err != nil { + return fmt.Errorf("resolve quic addr: %w", err) + } + pc, err := net.ListenUDP("udp", udpAddr) + if err != nil { + return fmt.Errorf("bind quic port: %w", err) + } + ln, err := transport.ListenQUIC(pc, link.GatewayTLSConfig(cert), &g.linkTotals) + if err != nil { + pc.Close() + return err + } + g.quicLn = ln + g.wg.Add(1) + go func() { + defer g.wg.Done() + g.acceptQUIC(ctx) + }() + g.logger.Info("quic control listener up", "addr", ln.Addr().String()) + return nil +} + // Shutdown stops accepting, evicts the current session (closing its // listeners and splices), and waits for every goroutine to exit. func (g *Gateway) Shutdown() { g.cancel() g.controlLn.Close() + if g.quicLn != nil { + // Joins quic-go's transport goroutines and releases the UDP socket; the + // cancel above already unblocked acceptQUIC and every serving ctx. + g.quicLn.Close() + } g.wg.Wait() } @@ -176,6 +253,122 @@ func (g *Gateway) ControlAddr() net.Addr { } func (g *Gateway) Fingerprint() string { return g.fingerprint } +// GatewayID returns this gateway's derived gw_ display identity (empty before Start). +func (g *Gateway) GatewayID() string { return g.gatewayID } + +// IssuePairingTicket mints a fresh enrollment ticket. reusable=false is single-use +// (the safe default); a zero exp never expires; scope restricts what the enrolling +// agent may bind. +func (g *Gateway) IssuePairingTicket(reusable bool, exp time.Time, scope Scope) (string, error) { + if g.agents == nil { + return "", fmt.Errorf("gateway not started") + } + return g.agents.IssueEnrollment(reusable, exp, scope) +} + +// ListAgents returns the per-agent identity allowlist (nil before Start). +func (g *Gateway) ListAgents() []AgentRecord { + if g.agents == nil { + return nil + } + return g.agents.List() +} + +// RevokeAgent removes an agent from the allowlist and immediately evicts any live +// session it holds, so revocation takes effect at once rather than only on the next +// connect. Reports whether the agent was found in the allowlist. +func (g *Gateway) RevokeAgent(agentID string) bool { + if g.agents == nil { + return false + } + ok := g.agents.Revoke(agentID) + if a := g.act(); a != nil { + // One actor hop: read the live session and evict it in the same fn. (Calling + // a.session here would nest a.do inside a.do and deadlock.) + a.do(func() { + if s := a.agents[agentID]; s != nil { + a.evict(s, "agent revoked") + } + }) + } + return ok +} + +// RenameAgent sets an agent's display nickname; SetAgentScope replaces its bind +// scope. Both report whether the agent was found. +func (g *Gateway) RenameAgent(agentID, nickname string) bool { + return g.agents != nil && g.agents.Rename(agentID, nickname) +} + +func (g *Gateway) SetAgentScope(agentID string, scope Scope) bool { + return g.agents != nil && g.agents.SetScope(agentID, scope) +} + +// AgentView is one agent's management row for the GUI roster: its persisted +// identity and policy joined with live-session status. An enrolled agent that is +// offline still appears (Connected=false); a live shared-token agent that never +// enrolled appears too (Enrolled=false). Scope is flattened to slices so the Wails +// binding generator can model it without a nested cross-package type. +type AgentView struct { + AgentID string `json:"agentId"` + Nickname string `json:"nickname"` + Enrolled bool `json:"enrolled"` + Revoked bool `json:"revoked"` + ScopePorts []int `json:"scopePorts"` + ScopeTunnels []string `json:"scopeTunnels"` + IssuedAtMs int64 `json:"issuedAtMs"` + Connected bool `json:"connected"` + Hostname string `json:"hostname"` + RemoteIP string `json:"remoteIp"` + LinkUpSinceMs int64 `json:"linkUpSinceMs"` + Tunnels int `json:"tunnels"` +} + +// ListAgentViews joins the enrollment allowlist with live sessions and tunnel +// counts into the roster the GUI polls: every enrolled agent (online or not) plus +// any connected shared-token agent that isn't enrolled, sorted by agentID. +func (g *Gateway) ListAgentViews() []AgentView { + live := map[string]AgentLink{} + for _, l := range g.Agents() { + live[l.AgentID] = l + } + tunCount := map[string]int{} + for _, t := range g.Tunnels() { + tunCount[t.AgentID]++ + } + seen := map[string]bool{} + var out []AgentView + for _, r := range g.ListAgents() { + seen[r.AgentID] = true + v := AgentView{ + AgentID: r.AgentID, + Nickname: r.Nickname, + Enrolled: true, + Revoked: r.Revoked, + ScopePorts: r.Scope.Ports, + ScopeTunnels: r.Scope.TunnelIDs, + IssuedAtMs: r.IssuedAt.UnixMilli(), + Tunnels: tunCount[r.AgentID], + } + if l, ok := live[r.AgentID]; ok { + v.Connected, v.Hostname, v.RemoteIP, v.LinkUpSinceMs = true, l.Hostname, l.RemoteIP, l.LinkUpSinceMs + } + out = append(out, v) + } + for id, l := range live { + if seen[id] { + continue + } + out = append(out, AgentView{ + AgentID: id, Enrolled: false, Connected: true, + Hostname: l.Hostname, RemoteIP: l.RemoteIP, LinkUpSinceMs: l.LinkUpSinceMs, + Tunnels: tunCount[id], + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].AgentID < out[j].AgentID }) + return out +} + // TunnelPort reports the actual bound public port of a tunnel (0, false if // not currently bound). Used by status surfaces and tests. func (g *Gateway) TunnelPort(tunnelID string) (int, bool) { @@ -195,10 +388,30 @@ func (g *Gateway) Tunnels() []TunnelSnapshot { return a.tunnels() } -// AgentConnected reports whether an agent session is currently admitted. +// Events returns the notable auto-fixes/conflicts (port reassignment, suspected +// clone) recorded since the cursor, oldest first — the incremental feed the GUI +// event log polls. A zero cursor returns the whole retained ring. +func (g *Gateway) Events(sinceSeq uint64) []GatewayEvent { + a := g.act() + if a == nil { + return nil // Start not called yet (status polled early) + } + return a.eventsSince(sinceSeq) +} + +// AgentConnected reports whether any agent session is currently admitted. func (g *Gateway) AgentConnected() bool { a := g.act() - return a != nil && a.currentSession() != nil + return a != nil && len(a.sessions()) > 0 +} + +// AgentID reports the connected agent's identity, or "" when no agent is +// connected. Used to attribute link/uptime events to their agent. +func (g *Gateway) AgentID() string { + if sess := g.session(); sess != nil { + return sess.agentID + } + return "" } // AgentLinkUpSinceMs reports when the current agent session was admitted @@ -260,6 +473,72 @@ func (g *Gateway) PacketLossPct() float64 { return -1 } +// AgentLink is one connected agent's link state for the multi-agent status +// surface. The engine maps it to ipc.AgentStatus (adding health + counts). +type AgentLink struct { + AgentID string + Hostname string + LANIPs []string + RemoteIP string + LinkUpSinceMs int64 + RTTMillis int64 + JitterMillis float64 + PacketLossPct float64 + LinkBytesIn int64 + LinkBytesOut int64 +} + +// Agents returns every connected agent's link state, sorted by agentID so the +// status surface (and any legacy first-agent fallback) is deterministic. +func (g *Gateway) Agents() []AgentLink { + a := g.act() + if a == nil { + return nil + } + sessions := a.sessions() + out := make([]AgentLink, 0, len(sessions)) + for _, s := range sessions { + in, outB := s.link.Bytes() + jitter, loss := -1.0, -1.0 + if s.quality != nil { + jitter, loss = s.quality.JitterMillis(), s.quality.LossPct() + } + out = append(out, AgentLink{ + AgentID: s.agentID, + Hostname: s.hostname, + LANIPs: s.localIPs, + RemoteIP: s.remoteIP, + LinkUpSinceMs: s.connectedAt.UnixMilli(), + RTTMillis: s.rttMillis.Load(), + JitterMillis: jitter, + PacketLossPct: loss, + LinkBytesIn: in, + LinkBytesOut: outB, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].AgentID < out[j].AgentID }) + return out +} + +// AgentQuality reports one agent's link RTT (ms, -1 unknown) and packet loss +// (0–100, -1 unknown), the per-agent gauges for the bandwidth-history sampler. +// Returns (-1, -1) when that agent is not the connected session. +func (g *Gateway) AgentQuality(agentID string) (rttMs, lossPct float64) { + sess := g.session() + if sess == nil || sess.agentID != agentID { + return -1, -1 + } + rttMs = -1 + if r := sess.rttMillis.Load(); r > 0 { + rttMs = float64(r) + } + lossPct = -1 + if sess.quality != nil { + lossPct = sess.quality.LossPct() + } + return rttMs, lossPct +} + // ProbeLatency runs an on-demand latency burst toward the connected agent, // mirroring the agent's own latency test. Errors when no agent is connected or // a probe is already running. @@ -329,27 +608,37 @@ func (g *Gateway) LinkTotalBytes() (in, out int64) { return g.linkTotals.Bytes() // act returns the running actor, or nil before Start has published it. func (g *Gateway) act() *actor { return g.actor.Load() } -// session returns the current agent session, nil-safe before Start. +// session returns the sole connected agent session, or nil when there are zero +// or several. The coarse single-agent status accessors funnel through it, so +// with several agents they read their "unknown" sentinels rather than name an +// arbitrary agent — honest until Phase 3's Status.Agents carries per-agent +// fidelity. Nil-safe before Start. func (g *Gateway) session() *agentSession { a := g.act() if a == nil { return nil } - return a.currentSession() + ss := a.sessions() + if len(ss) == 1 { + return ss[0] + } + return nil } -// TunnelLocalUp reports the agent's last word on a tunnel's local backend; -// known is false when no agent is connected or it has not reported yet. +// TunnelLocalUp reports an agent's last word on a tunnel's local backend, +// scanning every connected agent for the tunnelID (globally-unique in +// practice); known is false when no agent has reported it yet. func (g *Gateway) TunnelLocalUp(tunnelID string) (up, known bool) { - sess := g.session() - if sess == nil { + a := g.act() + if a == nil { return false, false } - v, ok := sess.health.Load(tunnelID) - if !ok { - return false, false + for _, sess := range a.sessions() { + if v, ok := sess.health.Load(tunnelID); ok { + return v.(bool), true + } } - return v.(bool), true + return false, false } func (g *Gateway) acceptControl(ctx context.Context) { @@ -370,6 +659,151 @@ func (g *Gateway) acceptControl(ctx context.Context) { } } +func (g *Gateway) acceptQUIC(ctx context.Context) { + for { + sess, err := g.quicLn.Accept(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + g.logger.Error("quic accept failed", "err", err) + return + } + g.wg.Add(1) + go func() { + defer g.wg.Done() + g.handleQUICSession(ctx, sess) + }() + } +} + +// handleQUICSession walks one accepted QUIC connection through the same pre-auth +// prologue and admission as handleControlConn, differing only in the transport: +// the QUIC (and thus TLS) handshake is already complete, so the hello rides the +// first stream instead of a raw TLS conn, and that same stream becomes the +// control stream. Pre-auth guards mirror the TCP path — per-IP rate limit, a +// preAuthTimeout deadline on the hello read, the PreAuthMaxFrame cap — and +// QUIC's own Retry/address validation covers amplification. +func (g *Gateway) handleQUICSession(ctx context.Context, sess transport.Session) { + remote := sess.RemoteAddr().String() + ip := ipFromAddr(sess.RemoteAddr()) + logger := g.logger.With("remote", remote, "transport", "quic") + + // --- Pre-auth boundary: rate limit before spending a stream on an unauth peer. --- + if !g.authLim.allow(ip) { + logger.Debug("pre-auth: rate limited after repeated failures") + sess.Close() + return + } + // The agent opens the control stream first; the hello is its first frame. + ctrl, err := acceptStreamTimeout(sess, preAuthTimeout) + if err != nil { + logger.Debug("pre-auth: no control stream", "err", err) + g.authLim.fail(ip) + sess.Close() + return + } + ctrl.SetReadDeadline(time.Now().Add(preAuthTimeout)) + env, err := control.ReadMsg(ctrl, control.PreAuthMaxFrame) + if err != nil { + logger.Debug("pre-auth read failed", "err", err) + g.authLim.fail(ip) + sess.Close() + return + } + if env.Type != control.TypeHello { + logger.Debug("pre-auth: first frame was not hello", "type", env.Type) + g.authLim.fail(ip) + sess.Close() + return + } + hello, err := control.Decode[control.Hello](env) + if err != nil { + logger.Debug("pre-auth: bad hello", "err", err) + g.authLim.fail(ip) + sess.Close() + return + } + if hello.ProtocolVersion != control.ProtocolVersion { + control.WriteMsg(ctrl, control.TypeHelloErr, control.HelloErr{ + Code: control.ErrCodeVersion, + Message: fmt.Sprintf("gateway speaks protocol %d, agent sent %d — update the older side", control.ProtocolVersion, hello.ProtocolVersion), + }) + sess.Close() + return + } + identity, err := g.validator.Validate(hello, g.fingerprint) + if err != nil { + code, msg, countFail := authErrorReply(err) + if countFail { + g.authLim.fail(ip) + } + logger.Warn("agent rejected", "code", code) + control.WriteMsg(ctrl, control.TypeHelloErr, control.HelloErr{Code: code, Message: msg}) + sess.Close() + return + } + // QUIC has no per-conn dial-back — data streams ride this same connection, so + // only a control kind is valid; a data hello would have nothing to match. + if hello.Kind != control.KindControl { + control.WriteMsg(ctrl, control.TypeHelloErr, control.HelloErr{ + Code: control.ErrCodeVersion, + Message: fmt.Sprintf("connection kind %q not supported over quic", hello.Kind), + }) + sess.Close() + return + } + + // --- Admission (shared with the TCP path; conn is nil — the session's own + // Close is the whole drain boundary). --- + admitted, negotiated, err := g.buildAndAdmit(ctx, nil, hello, identity, ip, logger) + if err != nil { + sess.Close() + return + } + defer g.act().disconnected(admitted) + admitted.setSession(sess) + defer sess.Close() + + gwHost, _ := os.Hostname() + okMsg := control.HelloOK{ + ProtocolVersion: control.ProtocolVersion, + SessionGeneration: admitted.gen, + AppVersion: version.String(), + Capabilities: negotiated, + Hostname: gwHost, + LocalIPs: netid.LocalIPv4s(), + ObservedIP: ip, + } + if identity.PubKey != nil { + okMsg.AssignedAgentID = identity.AgentID + okMsg.GatewayID = g.gatewayID + okMsg.ConfigSeedNeeded = g.needsConfigSeed(identity, negotiated) + } + if err := control.WriteMsg(ctrl, control.TypeHelloOK, okMsg); err != nil { + logger.Debug("hello_ok write failed", "err", err) + return + } + ctrl.SetReadDeadline(time.Time{}) // pre-auth over; liveness moves to the control stream + + g.serveAdmitted(ctx, admitted, ctrl, hello, negotiated) +} + +// authErrorReply maps a validator error to a hello error code, a user-facing +// message, and whether it counts as a failed credential for the per-IP limiter. +func authErrorReply(err error) (code, msg string, countFail bool) { + switch { + case errors.Is(err, ErrRevoked): + return control.ErrCodeRevoked, "this gateway revoked this agent — re-pair with a fresh code", true + case errors.Is(err, ErrTicketUnknown), errors.Is(err, ErrTicketConsumed), errors.Is(err, ErrTicketExpired): + return control.ErrCodeBadToken, err.Error(), true + case errors.Is(err, ErrMissingAgentID): + return control.ErrCodeBadToken, "hello is missing agentId", false + default: // ErrBadToken and anything unexpected + return control.ErrCodeBadToken, "invalid token — re-pair with the gateway's current pairing code", true + } +} + // handleControlConn walks one agent connection through the pre-auth // prologue, admission, and then serves its control stream until the session // dies. @@ -413,70 +847,60 @@ func (g *Gateway) handleControlConn(ctx context.Context, conn net.Conn) { conn.Close() return } - if subtle.ConstantTimeCompare([]byte(hello.Token), []byte(g.cfg.Gateway.Token)) != 1 { - logger.Warn("agent rejected: bad token") - g.authLim.fail(ip) - control.WriteMsg(conn, control.TypeHelloErr, control.HelloErr{ - Code: control.ErrCodeBadToken, - Message: "invalid token — re-pair with the gateway's current pairing code", - }) + identity, err := g.validator.Validate(hello, g.fingerprint) + if err != nil { + // A credential failure counts toward the per-IP auth limiter; a malformed + // hello (missing agentID) does not. + code, msg, countFail := authErrorReply(err) + if countFail { + g.authLim.fail(ip) + } + logger.Warn("agent rejected", "code", code) + control.WriteMsg(conn, control.TypeHelloErr, control.HelloErr{Code: code, Message: msg}) conn.Close() return } - if hello.Kind != control.KindControl { - // KindData arrives with the per-conn transport mode (milestone 5). - control.WriteMsg(conn, control.TypeHelloErr, control.HelloErr{ - Code: control.ErrCodeVersion, - Message: fmt.Sprintf("connection kind %q not supported yet", hello.Kind), - }) - conn.Close() + switch hello.Kind { + case control.KindControl: + // Falls through to admission below. + case control.KindData: + // Per-conn data plane: this is a dial-back for a waiting player. Match + // it and hand off the raw conn; it never enters agent admission. + g.handleDataConn(conn, hello, identity) return - } - if hello.AgentID == "" { + default: control.WriteMsg(conn, control.TypeHelloErr, control.HelloErr{ - Code: control.ErrCodeBadToken, - Message: "hello is missing agentId", + Code: control.ErrCodeVersion, + Message: fmt.Sprintf("connection kind %q not supported", hello.Kind), }) conn.Close() return } - // --- Admission: supersede same agent, reject a second distinct agent. --- - negotiated := control.IntersectCaps(hello.Capabilities, control.SupportedCapabilities) - sess := &agentSession{ - agentID: hello.AgentID, - conn: conn, - logger: logger.With("agent", hello.AgentID), - connectedAt: time.Now(), - remoteIP: ip, - hostname: hello.Hostname, - localIPs: hello.LocalIPs, - caps: control.NewCapSet(negotiated), - quality: linkquality.New(lossWindow), - } - gen, err := g.act().admit(sess) + // --- Admission: supersede same agentID, admit distinct agents alongside. --- + sess, negotiated, err := g.buildAndAdmit(ctx, conn, hello, identity, ip, logger) if err != nil { - logger.Warn("agent rejected", "agent", hello.AgentID, "err", err) - control.WriteMsg(conn, control.TypeHelloErr, control.HelloErr{ - Code: control.ErrCodeAgentConflict, - Message: err.Error(), - }) conn.Close() return } - sess.gen = gen defer g.act().disconnected(sess) gwHost, _ := os.Hostname() - if err := control.WriteMsg(conn, control.TypeHelloOK, control.HelloOK{ - ProtocolVersion: control.ProtocolVersion, - Generation: gen, - AppVersion: version.String(), - Capabilities: negotiated, - Hostname: gwHost, - LocalIPs: netid.LocalIPv4s(), - ObservedIP: ip, - }); err != nil { + okMsg := control.HelloOK{ + ProtocolVersion: control.ProtocolVersion, + SessionGeneration: sess.gen, + AppVersion: version.String(), + Capabilities: negotiated, + Hostname: gwHost, + LocalIPs: netid.LocalIPv4s(), + ObservedIP: ip, + } + if identity.PubKey != nil { + okMsg.AssignedAgentID = identity.AgentID + okMsg.GatewayID = g.gatewayID + okMsg.ConfigSeedNeeded = g.needsConfigSeed(identity, negotiated) + } + if err := control.WriteMsg(conn, control.TypeHelloOK, okMsg); err != nil { logger.Debug("hello_ok write failed", "err", err) conn.Close() return @@ -501,9 +925,91 @@ func (g *Gateway) handleControlConn(ctx context.Context, conn net.Conn) { logger.Warn("agent never opened control stream", "err", err) return } - sess.logger.Info("agent connected", "generation", gen, "agent_version", hello.AppVersion, "capabilities", negotiated) + g.serveAdmitted(ctx, sess, ctrl, hello, negotiated) +} + +// buildAndAdmit intersects capabilities, constructs the agent session, and +// admits it on the actor. conn may be nil (the QUIC accept path, where the +// session's own Close is the whole drain boundary). On a transient admission +// refusal it cancels the session ctx and returns the error, leaving the caller +// to close its transport without a HelloOK so the agent retries with backoff. +// The returned session already has gen set. +// needsConfigSeed reports whether the gateway should ask an enrolled gateway-config +// agent to seed its tunnel set (send a propose_config). It is true only on first +// contact, when the gateway holds no authoritative config for this identity yet; +// afterwards the gateway is the source of truth and pushes, so the agent never +// volunteers a set (which would race the connect-time push). +func (g *Gateway) needsConfigSeed(identity Identity, negotiated []string) bool { + if identity.PubKey == nil || !control.NewCapSet(negotiated).Has(control.CapGatewayConfig) { + return false + } + _, gen, ok := g.agents.DesiredConfig(identity.AgentID) + return ok && gen == 0 +} + +// withoutCap returns caps with one capability removed, preserving order. Used to +// negotiate away a capability the peer offered but this session can't honor. +func withoutCap(caps []string, drop string) []string { + out := caps[:0:0] + for _, c := range caps { + if c != drop { + out = append(out, c) + } + } + return out +} + +func (g *Gateway) buildAndAdmit(ctx context.Context, conn net.Conn, hello *control.Hello, identity Identity, peerIP string, logger *slog.Logger) (*agentSession, []string, error) { + negotiated := control.IntersectCaps(hello.Capabilities, control.SupportedCapabilities) + // Gateway-authoritative config is keyed to a durable Ed25519 identity, so it is + // negotiated only for enrolled agents. A shared-token agent that offered the cap + // has it dropped here and keeps the agent-push SyncTunnels path. + enrolled := identity.PubKey != nil + if !enrolled { + negotiated = withoutCap(negotiated, control.CapGatewayConfig) + } + caps := control.NewCapSet(negotiated) + // Session ctx: a child of the serving ctx, cancelled on eviction so throttled + // splices unblock per-agent (evict) and on shutdown (parent). + sctx, cancel := context.WithCancel(ctx) + sess := &agentSession{ + agentID: identity.AgentID, + scope: identity.Scope, + conn: conn, + logger: logger.With("agent", hello.AgentID), + connectedAt: time.Now(), + remoteIP: peerIP, + hostname: hello.Hostname, + localIPs: hello.LocalIPs, + caps: caps, + dp: g.pickDataPlane(caps), + quality: linkquality.New(lossWindow), + ctx: sctx, + cancel: cancel, + + enrolled: enrolled, + reportedConfigHash: hello.ConfigHash, + } + sess.agentConfigGen.Store(hello.ConfigGeneration) + gen, err := g.act().admit(sess) + if err != nil { + // The only admission refusals left are transient — anti-flap dampening + // of a same-agentID contest, or a shutting-down gateway. Neither is fatal. + logger.Warn("agent admission deferred", "agent", hello.AgentID, "err", err) + cancel() // never entered the map, so evict won't cancel it + return nil, nil, err + } + sess.gen = gen + return sess, negotiated, nil +} + +// serveAdmitted is the shared serving tail for both accept paths: it brackets +// serveControl with the connected/disconnected log lines. The caller has already +// published sess.session() and deferred the session teardown + actor disconnect. +func (g *Gateway) serveAdmitted(ctx context.Context, sess *agentSession, ctrl transport.Stream, hello *control.Hello, negotiated []string) { + sess.logger.Info("agent connected", "generation", sess.gen, "agent_version", hello.AppVersion, "capabilities", negotiated) g.serveControl(ctx, sess, ctrl) - sess.logger.Info("agent disconnected", "generation", gen) + sess.logger.Info("agent disconnected", "generation", sess.gen) } func acceptStreamTimeout(sess transport.Session, d time.Duration) (transport.Stream, error) { @@ -540,6 +1046,12 @@ func (g *Gateway) serveControl(ctx context.Context, sess *agentSession, ctrl tra defer sess.setCtrl(nil) go g.pingLoop(ctx, sess) go g.rttSampler(ctx, sess) + // A fresh session starts with no listeners, so an enrolled gateway-config agent + // gets its authoritative set reconciled (and pushed on drift) before the read + // loop; a legacy/shared-token agent instead pushes its own set via SyncTunnels. + if sess.enrolled && sess.Has(control.CapGatewayConfig) { + g.pushConfigOnConnect(sess) + } for { if ctx.Err() != nil { return @@ -688,6 +1200,31 @@ func (g *Gateway) handleControlMsg(sess *agentSession, ctrl transport.Stream, en } return write(control.TypeSyncResult, g.syncTunnels(sess, sync)) + case control.TypeProposeConfig: + // The agent promotes a local edit (or seeds the gateway on first contact). + // Only enrolled gateway-config sessions reach the authoritative store. + if !sess.enrolled || !sess.Has(control.CapGatewayConfig) { + sess.logger.Warn("ignoring propose_config: not an enrolled gateway-config session") + return nil + } + prop, err := control.Decode[control.ProposeConfig](env) + if err != nil { + return err + } + g.adoptProposal(sess, prop.Tunnels) + return nil + + case control.TypeConfigAck: + // The agent confirms it applied a pushed generation; track it so a later + // propose is checked against the right basis. + ack, err := control.Decode[control.ConfigAck](env) + if err != nil { + return err + } + sess.agentConfigGen.Store(ack.Generation) + sess.logger.Debug("agent acked config", "generation", ack.Generation) + return nil + case control.TypeHealth: h, err := control.Decode[control.Health](env) if err != nil { @@ -711,13 +1248,22 @@ type bindError struct { // validateSpec checks a tunnel spec against protocol and policy limits; it is // shared by the legacy register path and the desired-state sync path. -func (g *Gateway) validateSpec(spec control.TunnelSpec) *bindError { +func (g *Gateway) validateSpec(spec control.TunnelSpec, scope Scope) *bindError { if spec.ID == "" || spec.Type != config.TunnelTCP { return &bindError{control.ErrCodeBadTunnel, fmt.Sprintf("tunnel %q: only tcp tunnels with an id are supported", spec.Name)} } if spec.PublicPort < 0 || spec.PublicPort > 65535 { return &bindError{control.ErrCodeBadTunnel, fmt.Sprintf("tunnel %q: invalid public port %d", spec.Name, spec.PublicPort)} } + // Per-agent scope: a scoped agent may bind only its allowed ports/tunnels. An + // ephemeral (0) port is gateway-chosen, so it is not port-scoped (mirrors the + // PortAllowlist rule below). + if spec.PublicPort != 0 && !scope.AllowsPort(spec.PublicPort) { + return &bindError{control.ErrCodePortNotAllowed, fmt.Sprintf("port %d is outside this agent's allowed ports", spec.PublicPort)} + } + if !scope.AllowsTunnel(spec.ID) { + return &bindError{control.ErrCodeBadTunnel, fmt.Sprintf("tunnel %q is outside this agent's allowed scope", spec.ID)} + } if len(g.cfg.Gateway.PortAllowlist) > 0 && spec.PublicPort != 0 { ok := false for _, p := range g.cfg.Gateway.PortAllowlist { @@ -736,10 +1282,10 @@ func (g *Gateway) validateSpec(spec control.TunnelSpec) *bindError { // bindTunnel validates a spec and asks the actor to bind its public // listener; accepted client conns are spliced onto fresh streams. func (g *Gateway) bindTunnel(sess *agentSession, spec control.TunnelSpec) (int, *bindError) { - if berr := g.validateSpec(spec); berr != nil { + if berr := g.validateSpec(spec, sess.scope); berr != nil { return 0, berr } - port, err := g.act().bindTunnel(sess, spec, g.cfg.Gateway.BindAddr, g.handleClient) + port, err := g.act().bindTunnel(sess, spec, g.cfg.Gateway.BindAddr, g.cfg.Gateway.PortAllowlist, g.handleClient) if err != nil { return 0, &bindError{control.ErrCodePortInUse, err.Error()} } @@ -753,14 +1299,14 @@ func (g *Gateway) syncTunnels(sess *agentSession, sync *control.SyncTunnels) con results := make([]control.SyncTunnelResult, 0, len(sync.Tunnels)) valid := make([]control.TunnelSpec, 0, len(sync.Tunnels)) for _, spec := range sync.Tunnels { - if berr := g.validateSpec(spec); berr != nil { + if berr := g.validateSpec(spec, sess.scope); berr != nil { sess.logger.Warn("tunnel sync rejected spec", "tunnel", spec.Name, "port", spec.PublicPort, "err", berr.msg) results = append(results, control.SyncTunnelResult{TunnelID: spec.ID, Code: berr.code, Message: berr.msg}) continue } valid = append(valid, spec) } - outcomes, ran := g.act().reconcile(sess, valid, g.cfg.Gateway.BindAddr, g.handleClient) + outcomes, ran := g.act().reconcile(sess, valid, g.cfg.Gateway.BindAddr, g.cfg.Gateway.PortAllowlist, g.handleClient) if !ran { for _, spec := range valid { results = append(results, control.SyncTunnelResult{TunnelID: spec.ID, Code: control.ErrCodePortInUse, Message: "gateway is shutting down"}) @@ -779,11 +1325,104 @@ func (g *Gateway) syncTunnels(sess *agentSession, sync *control.SyncTunnels) con return control.SyncResult{Seq: sync.Seq, Results: results} } +// validSpecs drops specs that fail validation against the agent's scope/policy, +// logging each, and returns the survivors — the set actually reconciled onto +// listeners under gateway-authoritative config. +func (g *Gateway) validSpecs(sess *agentSession, specs []control.TunnelSpec) []control.TunnelSpec { + valid := make([]control.TunnelSpec, 0, len(specs)) + for _, spec := range specs { + if berr := g.validateSpec(spec, sess.scope); berr != nil { + sess.logger.Warn("config spec rejected", "tunnel", spec.Name, "port", spec.PublicPort, "err", berr.msg) + continue + } + valid = append(valid, spec) + } + return valid +} + +// resolvePorts returns specs with each PublicPort replaced by the port the gateway +// actually bound (from reconcile outcomes), so the stored + pushed config carries +// concrete ports even when the agent proposed an ephemeral (0) port. +func resolvePorts(specs []control.TunnelSpec, outcomes []reconcileOutcome) []control.TunnelSpec { + bound := make(map[string]int, len(outcomes)) + for _, o := range outcomes { + if o.Err == nil && o.Port != 0 { + bound[o.ID] = o.Port + } + } + out := make([]control.TunnelSpec, 0, len(specs)) + for _, s := range specs { + if p, ok := bound[s.ID]; ok { + s.PublicPort = p + } + out = append(out, s) + } + return out +} + +// pushConfigOnConnect reconciles the gateway-authoritative tunnel set into this +// fresh session's listeners and, when the agent's reported view has drifted, pushes +// the authoritative config so the agent re-syncs. When the gateway holds no config +// yet (generation 0) it does nothing — the agent seeds it with a propose_config. +func (g *Gateway) pushConfigOnConnect(sess *agentSession) { + stored, gen, ok := g.agents.DesiredConfig(sess.agentID) + if !ok || gen == 0 { + return // bootstrap: await the agent's seed propose_config + } + g.act().reconcile(sess, g.validSpecs(sess, stored), g.cfg.Gateway.BindAddr, g.cfg.Gateway.PortAllowlist, g.handleClient) + hash := control.HashTunnels(stored) + if sess.reportedConfigHash == hash && sess.agentConfigGen.Load() == gen { + return // agent is already in sync; listeners are (re)bound, nothing to push + } + if err := sess.writeControl(control.TypePushConfig, control.PushConfig{Generation: gen, Hash: hash, Tunnels: stored}); err != nil { + sess.logger.Warn("push_config on connect failed", "err", err) + return + } + sess.logger.Info("pushed authoritative config", "generation", gen, "tunnels", len(stored)) +} + +// adoptProposal handles a propose_config from an enrolled gateway-config agent: a +// promoted local edit, or the seed on first contact. The gateway is authoritative, +// so a proposal is adopted only when it is based on the current generation (or the +// gateway holds no config yet); a stale proposal is refused and the authoritative +// set re-pushed, so config reconciles deterministically instead of last-write-wins. +func (g *Gateway) adoptProposal(sess *agentSession, proposed []control.TunnelSpec) { + stored, storedGen, ok := g.agents.DesiredConfig(sess.agentID) + if !ok { + return // unknown agent — cannot happen on an enrolled session + } + if storedGen != 0 && sess.agentConfigGen.Load() != storedGen { + sess.logger.Warn("refusing stale config proposal; re-pushing authoritative set", + "agent_gen", sess.agentConfigGen.Load(), "gateway_gen", storedGen) + hash := control.HashTunnels(stored) + _ = sess.writeControl(control.TypePushConfig, control.PushConfig{Generation: storedGen, Hash: hash, Tunnels: stored}) + return + } + valid := g.validSpecs(sess, proposed) + outcomes, ran := g.act().reconcile(sess, valid, g.cfg.Gateway.BindAddr, g.cfg.Gateway.PortAllowlist, g.handleClient) + if !ran { + return // gateway shutting down + } + resolved := resolvePorts(valid, outcomes) + gen, ok := g.agents.AdoptConfig(sess.agentID, resolved) + if !ok { + return + } + sess.agentConfigGen.Store(gen) + hash := control.HashTunnels(resolved) + if err := sess.writeControl(control.TypePushConfig, control.PushConfig{Generation: gen, Hash: hash, Tunnels: resolved}); err != nil { + sess.logger.Warn("push_config after propose failed", "err", err) + return + } + sess.logger.Info("adopted proposed config", "generation", gen, "tunnels", len(resolved)) +} + // handleClient runs per accepted public connection: open a stream to the -// agent, send the OpenConn header, splice. A session dying mid-accept gets a -// graceful close, not a goroutine panic (milestone 5 adds the offline MOTD -// here). -func (g *Gateway) handleClient(sess *agentSession, spec control.TunnelSpec, clientConn net.Conn) { +// agent, send the OpenConn header, splice. When there is no live session or the +// agent reports the local backend down, it answers with the tunnel's offline +// MOTD (when configured) instead of dropping the connection. +func (g *Gateway) handleClient(pl *publicListener, clientConn net.Conn) { + sess, spec := pl.owner, pl.spec defer clientConn.Close() ip := remoteIP(clientConn) if !g.gate.admit(ip) { @@ -792,15 +1431,13 @@ func (g *Gateway) handleClient(sess *agentSession, spec control.TunnelSpec, clie } defer g.gate.release(ip) mux := sess.session() - if mux == nil { - return // session died between accept and here - } - stream, err := mux.OpenStream() - if err != nil { - sess.logger.Debug("open stream for client failed", "client", clientConn.RemoteAddr().String(), "err", err) + if mux == nil || backendDown(sess, spec.ID) { + // No live session, or the agent reports the local server down: answer + // with the offline MOTD when configured, else fall through to a clean + // close. + g.serveOffline(clientConn, spec) return } - defer stream.Close() tcp, ok := clientConn.(*net.TCPConn) if !ok { return @@ -808,19 +1445,34 @@ func (g *Gateway) handleClient(sess *agentSession, spec control.TunnelSpec, clie // ConnID correlates this connection with the RTT reports the gateway sends // the agent; the entry's own key lets the local recorder attribute gateway // RTT samples. Issued before Open so the key is set before the entry is - // published (ConnKey is immutable after Open). + // published (ConnKey is immutable after Open). It also matches a per-conn + // dial-back to this player, so it must be minted before the data leg. connID := strconv.FormatUint(g.connSeq.Add(1), 10) // Splice(client, stream): AToB is client→server, so inIsAToB=true. - entry, closeEntry := g.Conns.Open(spec.ID, spec.Name, clientConn.RemoteAddr().String(), connID, true) + entry, closeEntry := g.Conns.Open(sess.agentID, spec.ID, spec.Name, clientConn.RemoteAddr().String(), connID, true) defer closeEntry() + // Acquire the data leg through the session's data plane. Per-conn transport + // signals the agent to dial back a dedicated TCP+TLS connection for this + // player (no cross-player head-of-line blocking on the gateway↔agent hop); + // mux transport opens a stream on the shared session. Either way the leg is a + // relay.Conn spliced identically below, and an unavailable leg falls back to + // the offline responder. + stream, releaseFlow, err := sess.dp.openFlow(sess, connID) + if err != nil { + sess.logger.Debug("open data leg failed", "client", clientConn.RemoteAddr().String(), "err", err) + g.serveOffline(clientConn, spec) + return + } + defer releaseFlow() + defer stream.Close() + stream.SetWriteDeadline(time.Now().Add(controlWriteTimeout)) - err = control.WriteMsg(stream, control.TypeOpenConn, control.OpenConn{ + if err := control.WriteMsg(stream, control.TypeOpenConn, control.OpenConn{ TunnelID: spec.ID, ClientAddr: clientConn.RemoteAddr().String(), ConnID: connID, - }) - if err != nil { + }); err != nil { sess.logger.Debug("open_conn write failed", "err", err) return } @@ -837,7 +1489,39 @@ func (g *Gateway) handleClient(sess *agentSession, spec control.TunnelSpec, clie if spec.MinecraftAware { client = mcsniff.Tap(tcp, entry) } - if err := relay.Splice(client, stream, entry.Counters); err != nil { + // Splice(client, stream): AToB is client→server (inbound), BToA is + // server→client (outbound). Parent on the session ctx so eviction cancels a + // throttled WaitN. + inbound, outbound := bwcap.Resolve(pl.limiters.Load()) + opts := relay.SpliceOpts{Ctx: sess.ctx, LimitAToB: inbound, LimitBToA: outbound} + if err := relay.Splice(client, stream, entry.Counters, opts); err != nil { sess.logger.Debug("splice ended with error", "client", clientConn.RemoteAddr().String(), "err", err) } } + +// backendDown reports whether the agent has told us this tunnel's local server +// is unreachable. Unknown health (never probed) counts as up, so a working +// backend is never pre-empted by the offline responder. +func backendDown(sess *agentSession, tunnelID string) bool { + v, ok := sess.health.Load(tunnelID) + if !ok { + return false + } + up, _ := v.(bool) + return !up +} + +// serveOffline answers a player with the tunnel's offline MOTD when the backend +// is unavailable. An empty OfflineMOTD means the feature is off, so it returns +// and lets handleClient's deferred Close drop the connection cleanly — the +// pre-existing behavior. mc.ServeOffline requires the caller to own the deadline +// and the close, both of which handleClient already does. +func (g *Gateway) serveOffline(conn net.Conn, spec control.TunnelSpec) { + if spec.OfflineMOTD == "" { + return + } + conn.SetDeadline(time.Now().Add(offlineServeTimeout)) + if err := mc.ServeOffline(conn, mc.OfflineInfo{MOTD: spec.OfflineMOTD}); err != nil { + g.logger.Debug("offline responder ended", "tunnel", spec.Name, "err", err) + } +} diff --git a/internal/gateway/limits.go b/internal/gateway/limits.go index e4dc7d8..3a46c40 100644 --- a/internal/gateway/limits.go +++ b/internal/gateway/limits.go @@ -108,9 +108,13 @@ func (g *connGate) release(ip string) { // remoteIP extracts the bare IP from a net.Conn's remote address, falling // back to the whole string for exotic transports. -func remoteIP(conn net.Conn) string { - if host, _, err := net.SplitHostPort(conn.RemoteAddr().String()); err == nil { +func remoteIP(conn net.Conn) string { return ipFromAddr(conn.RemoteAddr()) } + +// ipFromAddr extracts the bare IP from any net.Addr (a QUIC session reports a +// *net.UDPAddr), falling back to the whole string for exotic transports. +func ipFromAddr(addr net.Addr) string { + if host, _, err := net.SplitHostPort(addr.String()); err == nil { return host } - return conn.RemoteAddr().String() + return addr.String() } diff --git a/internal/gateway/perconn.go b/internal/gateway/perconn.go new file mode 100644 index 0000000..0b139a7 --- /dev/null +++ b/internal/gateway/perconn.go @@ -0,0 +1,123 @@ +package gateway + +import ( + "context" + "net" + "sync" + "time" + + "proxyforward/internal/control" + "proxyforward/internal/stats" +) + +// dataDialTimeout bounds how long a player waits for the agent to dial back a +// per-conn data connection before the gateway falls back to the offline +// responder. Kept strictly longer than a data conn's own pre-auth deadline +// (preAuthTimeout) so a pre-auth failure on the agent's dial-back surfaces +// first. A healthy dial-back completes in well under a round-trip (the TLS +// session resumes), so this only ever fires on a genuinely failed dial-back. +const dataDialTimeout = 12 * time.Second + +// pendingConn is a per-conn data connection the gateway has asked an agent to +// dial back. handleClient parks on it (take); the control accept path fills it +// when the matching KindData connection arrives (deliver). The handoff is +// exactly-once and loser-closes: whichever side runs after the other has marked +// the entry done closes the conn it holds, so no descriptor leaks on a +// timeout/dial-back race (goleak does not catch a leaked conn). +type pendingConn struct { + agentID string // only this agent may answer (shared token today) + link *stats.LinkCounters // this agent's session counters, for byte accounting + ready chan struct{} + + mu sync.Mutex + conn net.Conn + done bool +} + +// deliver hands a dialed-back data conn to the waiting handleClient. It returns +// false if the waiter already gave up (timed out / evicted), in which case the +// caller must close conn. +func (pc *pendingConn) deliver(conn net.Conn) bool { + pc.mu.Lock() + if pc.done { + pc.mu.Unlock() + return false + } + pc.conn = conn + pc.done = true + pc.mu.Unlock() + close(pc.ready) + return true +} + +// take blocks until deliver hands over a conn, ctx is cancelled (the agent was +// evicted), or timeout fires. It returns nil on failure, marking the entry done +// so a racing deliver loses and closes its conn instead of leaking it. +func (pc *pendingConn) take(ctx context.Context, timeout time.Duration) net.Conn { + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-pc.ready: + return pc.conn + case <-ctx.Done(): + case <-timer.C: + } + pc.mu.Lock() + defer pc.mu.Unlock() + if pc.done { + // deliver won the race between our select waking and this lock; use its + // conn rather than leaking it. + return pc.conn + } + pc.done = true + return nil +} + +// handleDataConn matches an authenticated KindData dial-back to the player +// waiting for it and hands over the raw, byte-counted conn. Called from the +// control accept path once the KindData hello authenticates; the conn's +// pre-auth deadline is cleared here on a match. +func (g *Gateway) handleDataConn(conn net.Conn, hello *control.Hello, identity Identity) { + if hello.ConnID == "" { + conn.Close() + return + } + v, ok := g.pendingData.Load(hello.ConnID) + if !ok { + // A valid agent's late dial-back after the player gave up: not an auth + // failure (it is the agent's own IP), just close it. + conn.Close() + return + } + pc := v.(*pendingConn) + if pc.agentID != identity.AgentID { + // All agents share the gateway token today, so this check — not the + // token — is what stops agent B answering agent A's open_data. It is + // load-bearing until per-agent identity lands, and is the natural place + // that identity plugs in. + conn.Close() + return + } + conn.SetDeadline(time.Time{}) + // Count this data conn's bytes into the same process + session link totals + // as the control conn, so the GUI's link card reflects per-conn payload. + dataConn := stats.NewCountingConn(conn, &g.linkTotals, pc.link) + if !pc.deliver(dataConn) { + dataConn.Close() + } +} + +// openDataConn drives the per-conn data plane for one player: register a +// pending slot, ask the agent to dial back a dedicated conn, and wait for it. +// Returns nil (→ offline responder) if the agent never dials back in time or is +// evicted meanwhile. The returned conn's bytes are already counted. +func (g *Gateway) openDataConn(sess *agentSession, connID string) net.Conn { + pc := &pendingConn{agentID: sess.agentID, link: &sess.link, ready: make(chan struct{})} + g.pendingData.Store(connID, pc) + defer g.pendingData.Delete(connID) + if err := sess.writeControl(control.TypeOpenData, control.OpenData{ConnID: connID}); err != nil { + sess.logger.Debug("open_data write failed", "conn_id", connID, "err", err) + return nil + } + return pc.take(sess.ctx, dataDialTimeout) +} diff --git a/internal/gateway/perconn_test.go b/internal/gateway/perconn_test.go new file mode 100644 index 0000000..7e250ad --- /dev/null +++ b/internal/gateway/perconn_test.go @@ -0,0 +1,97 @@ +package gateway + +import ( + "context" + "net" + "sync" + "sync/atomic" + "testing" + "time" +) + +// fakeConn is a net.Conn stand-in that only records Close; the pending-handoff +// logic touches nothing else. Other net.Conn methods promote from the nil +// embedded interface and would panic if called — they never are here. +type fakeConn struct { + net.Conn + closed atomic.Bool +} + +func (c *fakeConn) Close() error { c.closed.Store(true); return nil } + +// TestPendingConnDeliverThenTake: the normal path — deliver hands the conn to a +// waiting take, and the conn is not closed (handleClient owns it). +func TestPendingConnDeliverThenTake(t *testing.T) { + pc := &pendingConn{ready: make(chan struct{})} + c := &fakeConn{} + if !pc.deliver(c) { + t.Fatal("deliver to a fresh pending entry must succeed") + } + got := pc.take(context.Background(), time.Second) + if got != net.Conn(c) { + t.Fatalf("take returned %v, want the delivered conn", got) + } + if c.closed.Load() { + t.Fatal("a delivered conn must stay open — handleClient owns it") + } +} + +// TestPendingConnTakeTimeoutThenDeliver: the waiter gives up first, so a later +// deliver must lose (return false) — the loser-closes contract that tells +// handleDataConn to close the conn instead of leaking it. +func TestPendingConnTakeTimeoutThenDeliver(t *testing.T) { + pc := &pendingConn{ready: make(chan struct{})} + if got := pc.take(context.Background(), 5*time.Millisecond); got != nil { + t.Fatalf("take returned %v, want nil on timeout", got) + } + if pc.deliver(&fakeConn{}) { + t.Fatal("deliver after the waiter gave up must return false (loser closes)") + } +} + +// TestPendingConnTakeCtxCancel: eviction (ctx cancel) unblocks a parked take, +// and a subsequent deliver loses. +func TestPendingConnTakeCtxCancel(t *testing.T) { + pc := &pendingConn{ready: make(chan struct{})} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if got := pc.take(ctx, time.Second); got != nil { + t.Fatalf("take returned %v, want nil after ctx cancel", got) + } + if pc.deliver(&fakeConn{}) { + t.Fatal("deliver after an evicted waiter must return false") + } +} + +// TestPendingConnRaceExactlyOnce hammers deliver against a take that is timing +// out at the same moment. The invariant: take-got-a-conn iff deliver-succeeded. +// The two illegal outcomes are a leaked conn (take gave up but deliver claimed +// success, so nobody closes) and double ownership (take got a conn deliver said +// it never handed over). +func TestPendingConnRaceExactlyOnce(t *testing.T) { + for i := 0; i < 3000; i++ { + pc := &pendingConn{ready: make(chan struct{})} + c := &fakeConn{} + var delivered atomic.Bool + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + delivered.Store(pc.deliver(c)) + }() + got := pc.take(context.Background(), time.Duration(i%3)*time.Microsecond) + wg.Wait() + + switch { + case got != nil && !delivered.Load(): + t.Fatalf("iter %d: take got a conn but deliver reported failure", i) + case got == nil && delivered.Load(): + t.Fatalf("iter %d: take gave up but deliver succeeded — conn leaked", i) + } + // Consistent: whichever side lost, the conn has exactly one owner. + // (In real code, take==nil ⇒ handleDataConn closes on deliver==false.) + if got == nil { + c.Close() // stand in for handleDataConn's loser-closes + } + } +} diff --git a/internal/gateway/scope_test.go b/internal/gateway/scope_test.go new file mode 100644 index 0000000..85f670a --- /dev/null +++ b/internal/gateway/scope_test.go @@ -0,0 +1,43 @@ +package gateway + +import ( + "testing" + + "proxyforward/internal/config" + "proxyforward/internal/control" +) + +// TestValidateSpecScope: a per-agent scope restricts which public ports a tunnel +// may bind, an empty scope allows anything, and an ephemeral (0) port is never +// scope-checked (the gateway picks it). (scope) +func TestValidateSpecScope(t *testing.T) { + g := &Gateway{cfg: config.Default()} + scoped := Scope{Ports: []int{25565}} + + tcp := func(id string, port int) control.TunnelSpec { + return control.TunnelSpec{ID: id, Name: id, Type: config.TunnelTCP, PublicPort: port} + } + + if berr := g.validateSpec(tcp("t1", 25565), scoped); berr != nil { + t.Fatalf("in-scope port rejected: %v", berr) + } + berr := g.validateSpec(tcp("t1", 25566), scoped) + if berr == nil || berr.code != control.ErrCodePortNotAllowed { + t.Fatalf("out-of-scope port: want port_not_allowed, got %v", berr) + } + if berr := g.validateSpec(tcp("t1", 25566), Scope{}); berr != nil { + t.Fatalf("empty scope must allow any port: %v", berr) + } + if berr := g.validateSpec(tcp("t1", 0), scoped); berr != nil { + t.Fatalf("ephemeral port must not be scope-checked: %v", berr) + } + + // Tunnel-ID scope. + idScope := Scope{TunnelIDs: []string{"tnl_ok"}} + if berr := g.validateSpec(tcp("tnl_ok", 0), idScope); berr != nil { + t.Fatalf("in-scope tunnel rejected: %v", berr) + } + if berr := g.validateSpec(tcp("tnl_no", 0), idScope); berr == nil || berr.code != control.ErrCodeBadTunnel { + t.Fatalf("out-of-scope tunnel: want bad_tunnel, got %v", berr) + } +} diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index f5ad377..73ea51e 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -97,6 +97,10 @@ type Status struct { // Agent-side fields. LinkUp bool `json:"linkUp,omitempty"` RTTMillis int64 `json:"rttMillis,omitempty"` + // Transport is the data plane the live agent session settled on ("quic" | + // "per-conn" | "mux"); empty while down or on the gateway role. Lets the GUI + // show what the auto ladder actually connected over. + Transport string `json:"transport,omitempty"` // Link quality (agent-side; the gateway reports -1/unknown). Jitter and // packet loss drive the tunnel health badge alongside RTT and uptime. @@ -121,6 +125,13 @@ type Status struct { // Gateway-side fields. AgentConnected bool `json:"agentConnected,omitempty"` + // Agents is the per-agent link state on a gateway (empty on the agent + // role). Sorted by AgentID so the flat legacy Peer* fields (derived from + // Agents[0] when there is exactly one) are deterministic. Tunnels and + // Connections stay flat and carry agentId, so the GUI groups them per agent + // without duplicating them here — keeping Status well under MaxFrame. + Agents []AgentStatus `json:"agents,omitempty"` + Tunnels []TunnelStatus `json:"tunnels,omitempty"` // Live proxied connections and lifetime byte totals (both roles). @@ -151,13 +162,44 @@ type Status struct { ConfigPath string `json:"configPath,omitempty"` } +// MaxStatusAgents bounds the per-agent link records in a Status frame. With +// Connections already clamped to MaxStatusConns (~51 KB) and each AgentStatus +// ~200 bytes, this keeps the whole frame under the 64 KiB MaxFrame even at the +// cap. Far beyond the "several friends" topology; excess agents are dropped +// from the status view (they keep serving — only the dashboard row is elided). +const MaxStatusAgents = 24 + +// AgentStatus is one connected agent's link state on a gateway: identity, link +// quality, and tunnel/player counts. The tunnel and connection lists live flat +// on Status (keyed by agentId) rather than nested here. +type AgentStatus struct { + AgentID string `json:"agentId"` + Hostname string `json:"hostname,omitempty"` + LANIPs []string `json:"lanIps,omitempty"` + RemoteIP string `json:"remoteIp,omitempty"` + LinkUpSinceMs int64 `json:"linkUpSinceMs,omitempty"` + RTTMillis int64 `json:"rttMillis"` + JitterMillis float64 `json:"jitterMillis"` + PacketLossPct float64 `json:"packetLossPct"` + HealthScore string `json:"healthScore"` + LinkBytesIn int64 `json:"linkBytesIn"` + LinkBytesOut int64 `json:"linkBytesOut"` + Tunnels int `json:"tunnels"` // count of registered tunnels + Players int `json:"players"` // count of identified players +} + // TunnelStatus is one tunnel's live state. type TunnelStatus struct { + AgentID string `json:"agentId,omitempty"` // owning agent (gateway role) ID string `json:"id"` Name string `json:"name"` PublicPort int `json:"publicPort,omitempty"` // confirmed bound port LocalUp bool `json:"localUp"` LocalKnown bool `json:"localKnown"` + // BandwidthLimitMbps/Scope surface a tunnel's configured cap read-only (0 = + // unlimited); omitempty keeps uncapped tunnels' frames unchanged. + BandwidthLimitMbps int `json:"bandwidthLimitMbps,omitempty"` + BandwidthLimitScope string `json:"bandwidthLimitScope,omitempty"` } // StatusSource produces the current Status snapshot for each request. diff --git a/internal/link/cert.go b/internal/link/cert.go index ee6a362..5264a3e 100644 --- a/internal/link/cert.go +++ b/internal/link/cert.go @@ -101,7 +101,11 @@ func generateCert() (certPEM, keyPEM []byte, err error) { return certPEM, keyPEM, nil } -// GatewayTLSConfig is the listener-side TLS setup. +// GatewayTLSConfig is the listener-side TLS setup. CurvePreferences is left +// unset deliberately: Go's default key-exchange list leads with the +// X25519MLKEM768 post-quantum hybrid, so tunneled bytes get PQ confidentiality +// (against harvest-now-decrypt-later) for free. Do not pin it — that would +// silently drop the hybrid (asserted by pq_test.go). func GatewayTLSConfig(cert tls.Certificate) *tls.Config { return &tls.Config{ Certificates: []tls.Certificate{cert}, @@ -112,6 +116,8 @@ func GatewayTLSConfig(cert tls.Certificate) *tls.Config { // AgentTLSConfig trusts exactly one certificate: the one whose SHA-256 // fingerprint was delivered out-of-band in the pairing code. Standard chain // verification is disabled (self-signed) and replaced by the pin. +// CurvePreferences is left unset so the X25519MLKEM768 PQ hybrid is negotiated +// (see GatewayTLSConfig; asserted by pq_test.go). func AgentTLSConfig(pinnedFingerprint string) *tls.Config { return &tls.Config{ MinVersion: tls.VersionTLS13, diff --git a/internal/link/cred.go b/internal/link/cred.go new file mode 100644 index 0000000..99c915d --- /dev/null +++ b/internal/link/cred.go @@ -0,0 +1,181 @@ +package link + +// This file owns the agent's cryptographic identity — a long-term Ed25519 keypair +// generated once and persisted on the machine — and the human-facing IDs derived +// from it (agt_/gw_/tnl_). Because an agent's ID is derived from its public key it +// is stable across re-pairs and cannot be forged by a mere token holder; the +// gateway allowlists the raw public key, and these rendered strings are display +// labels over that canonical identity. + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base32" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// EnrollTicketPrefix marks a pairing code's token as a single-use enrollment +// ticket, as opposed to the legacy shared gateway token (bare hex). The token is +// thus self-describing — the agent routes a pasted code to per-identity enrollment +// or shared-token auth without a second field in the code — mirroring the typed +// agt_/gw_/tnl_ ids above. It must stay distinct from a 32-hex shared token. +const EnrollTicketPrefix = "tkt_" + +// NewEnrollTicket mints a random single-use enrollment ticket (128-bit nonce) +// carrying the tkt_ prefix. The gateway stores it and embeds it in a pairing code; +// the agent replays it once to join the allowlist. +func NewEnrollTicket() (string, error) { + var raw [16]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", fmt.Errorf("generate enrollment ticket: %w", err) + } + return EnrollTicketPrefix + hex.EncodeToString(raw[:]), nil +} + +// IsEnrollTicket reports whether a pairing-code token is an enrollment ticket +// rather than a legacy shared token, so a pasted code routes to the right auth. +func IsEnrollTicket(token string) bool { + return strings.HasPrefix(token, EnrollTicketPrefix) +} + +// crockfordLower is Crockford base32 (no confusable i/l/o/u) lowercased, so derived +// IDs read like modern API keys. It is frozen: changing the alphabet would silently +// rename every already-issued ID. +const crockfordLower = "0123456789abcdefghjkmnpqrstvwxyz" + +var idEncoding = base32.NewEncoding(crockfordLower).WithPadding(base32.NoPadding) + +// fingerprint renders the first 40 bits of sha256(seed) as an 8-char base32 tag. +// 40 bits is ample to tell a home fleet's agents apart; the astronomically rare +// clash is resolved with a -2 suffix by whoever mints against a namespace. +func fingerprint(seed []byte) string { + sum := sha256.Sum256(seed) + return idEncoding.EncodeToString(sum[:5]) +} + +// AgentID derives an agent's stable, unforgeable public identity from its Ed25519 +// public key: agt_. Derived rather than stored, so the same +// machine always re-derives the same ID and no two keys render alike. +func AgentID(pub ed25519.PublicKey) string { + return "agt_" + fingerprint(pub) +} + +// GatewayID derives the gateway's display identity from its (pinned) certificate +// DER, giving the UI a stable gw_ name without a second gateway key. +func GatewayID(certDER []byte) string { + return "gw_" + fingerprint(certDER) +} + +// TunnelID renders a tunnel identity from its human name (tnl_survival-smp), +// suffixing -2, -3… until taken reports the candidate free. An empty name falls +// back to tnl_tunnel. +func TunnelID(name string, taken func(string) bool) string { + s := slug(name) + if s == "" { + s = "tunnel" + } + base := "tnl_" + s + cand := base + for n := 2; taken(cand); n++ { + cand = fmt.Sprintf("%s-%d", base, n) + } + return cand +} + +// slug lowercases name and keeps only [a-z0-9], collapsing every other run into a +// single hyphen and trimming hyphens at the ends. +func slug(name string) string { + var b strings.Builder + prevHyphen := false + for _, r := range strings.ToLower(name) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + prevHyphen = false + default: + if !prevHyphen && b.Len() > 0 { + b.WriteByte('-') + prevHyphen = true + } + } + } + return strings.TrimRight(b.String(), "-") +} + +// LoadOrCreateIdentity returns the agent's long-term Ed25519 identity, generating +// one on first run and persisting the PKCS#8 private key under dir (0600). The +// public half is what the gateway allowlists; the private key never leaves the +// machine. A corrupt or non-Ed25519 key file is a fatal, actionable error rather +// than a silent regeneration (which would orphan the agent's allowlist entry). +func LoadOrCreateIdentity(dir string) (ed25519.PrivateKey, ed25519.PublicKey, error) { + keyPath := filepath.Join(dir, "agent_identity.key") + + pemBytes, err := os.ReadFile(keyPath) + switch { + case err == nil: + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, nil, fmt.Errorf("agent identity key at %s is not valid PEM (delete it to regenerate, then re-pair)", keyPath) + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("parse agent identity key: %w", err) + } + priv, ok := key.(ed25519.PrivateKey) + if !ok { + return nil, nil, fmt.Errorf("agent identity key at %s is not Ed25519 (delete it to regenerate, then re-pair)", keyPath) + } + return priv, priv.Public().(ed25519.PublicKey), nil + case !errors.Is(err, os.ErrNotExist): + return nil, nil, fmt.Errorf("read agent identity key: %w", err) + } + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generate agent identity: %w", err) + } + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, nil, fmt.Errorf("marshal agent identity: %w", err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, nil, fmt.Errorf("create identity dir: %w", err) + } + out := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + if err := os.WriteFile(keyPath, out, 0o600); err != nil { + return nil, nil, fmt.Errorf("write agent identity key: %w", err) + } + return priv, pub, nil +} + +// AgentAuthMessage is the exact byte string an agent signs with its identity key to +// prove possession to a specific gateway. Binding to the gateway's pinned cert +// fingerprint means a signature made for one gateway cannot be replayed to another; +// the pinned TLS 1.3 channel already rules out capture by anyone but a compromised +// endpoint, which would hold the private key regardless — so no per-connection +// nonce is needed, and the identical message works over both TCP and QUIC. +func AgentAuthMessage(gatewayCertFP string) []byte { + return append([]byte("proxyforward-agent-auth-v1\x00"), gatewayCertFP...) +} + +// SignAgentAuth signs AgentAuthMessage with the agent's identity key. +func SignAgentAuth(priv ed25519.PrivateKey, gatewayCertFP string) []byte { + return ed25519.Sign(priv, AgentAuthMessage(gatewayCertFP)) +} + +// VerifyAgentAuth reports whether sig proves possession of pub's private key, bound +// to gatewayCertFP. Safe on a malformed key or signature (returns false, no panic). +func VerifyAgentAuth(pub ed25519.PublicKey, gatewayCertFP string, sig []byte) bool { + if len(pub) != ed25519.PublicKeySize { + return false + } + return ed25519.Verify(pub, AgentAuthMessage(gatewayCertFP), sig) +} diff --git a/internal/link/cred_test.go b/internal/link/cred_test.go new file mode 100644 index 0000000..9c94600 --- /dev/null +++ b/internal/link/cred_test.go @@ -0,0 +1,175 @@ +package link + +import ( + "crypto/ed25519" + "crypto/rand" + "strings" + "testing" +) + +// TestEnrollTicket: a minted enrollment ticket is self-describing (tkt_ prefix) +// so a pasted pairing code routes to per-identity enrollment, while a bare-hex +// legacy shared token does not; two mints never collide. (identity, enroll) +func TestEnrollTicket(t *testing.T) { + a, err := NewEnrollTicket() + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(a, EnrollTicketPrefix) { + t.Fatalf("ticket %q missing %q prefix", a, EnrollTicketPrefix) + } + if !IsEnrollTicket(a) { + t.Fatalf("IsEnrollTicket(%q) = false, want true", a) + } + // A legacy shared token is bare hex — never mistaken for an enrollment ticket. + if IsEnrollTicket("3f8a1c9e2b7d4056a1b2c3d4e5f60718") { + t.Fatalf("a bare-hex shared token must not read as an enrollment ticket") + } + b, err := NewEnrollTicket() + if err != nil { + t.Fatal(err) + } + if a == b { + t.Fatalf("two minted tickets collided: %q", a) + } +} + +// TestAgentIDDerivation: the agentID is deterministic for a given public key, +// carries the agt_ prefix, and two distinct keys never render the same ID. +// (identity) +func TestAgentIDDerivation(t *testing.T) { + pub1, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + pub2, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + id1 := AgentID(pub1) + if id1 != AgentID(pub1) { + t.Fatal("agentID must be deterministic for the same key") + } + if !strings.HasPrefix(id1, "agt_") { + t.Fatalf("agentID must be prefixed agt_: %q", id1) + } + if id1 == AgentID(pub2) { + t.Fatalf("distinct keys must derive distinct agentIDs (both %q)", id1) + } +} + +// TestAgentIDAlphabet: the fingerprint is 8 chars of lowercase Crockford base32, +// so it never contains the confusable i/l/o/u. (identity) +func TestAgentIDAlphabet(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + fp := strings.TrimPrefix(AgentID(pub), "agt_") + if len(fp) != 8 { + t.Fatalf("fingerprint should be 8 chars, got %d (%q)", len(fp), fp) + } + const allowed = "0123456789abcdefghjkmnpqrstvwxyz" + for _, r := range fp { + if !strings.ContainsRune(allowed, r) { + t.Fatalf("fingerprint char %q is not lowercase Crockford base32", r) + } + } +} + +// TestGatewayIDDerivation: the gateway's display ID derives deterministically from +// its cert DER with the gw_ prefix. (identity) +func TestGatewayIDDerivation(t *testing.T) { + der := []byte("some-certificate-der-bytes") + id := GatewayID(der) + if !strings.HasPrefix(id, "gw_") { + t.Fatalf("gatewayID must be prefixed gw_: %q", id) + } + if id != GatewayID(der) { + t.Fatal("gatewayID must be deterministic") + } +} + +// TestTunnelID: slugs the human name, suffixes -2/-3… past collisions, and falls +// back to tnl_tunnel for an empty name. (identity) +func TestTunnelID(t *testing.T) { + never := func(string) bool { return false } + cases := []struct { + name string + taken map[string]bool + want string + }{ + {"Survival SMP", nil, "tnl_survival-smp"}, + {" Creative!! Realm ", nil, "tnl_creative-realm"}, + {"", nil, "tnl_tunnel"}, + {"Survival SMP", map[string]bool{"tnl_survival-smp": true, "tnl_survival-smp-2": true}, "tnl_survival-smp-3"}, + } + for _, c := range cases { + taken := never + if c.taken != nil { + taken = func(s string) bool { return c.taken[s] } + } + if got := TunnelID(c.name, taken); got != c.want { + t.Errorf("TunnelID(%q): got %q want %q", c.name, got, c.want) + } + } +} + +// TestAgentAuthSignVerify: a proof-of-possession signature verifies only for the +// signing key and the exact gateway fingerprint it was bound to; a different key, +// a different fingerprint, or a tampered signature all fail. (auth) +func TestAgentAuthSignVerify(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + otherPub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + const fp = "sha256:" + "deadbeef" + + sig := SignAgentAuth(priv, fp) + if !VerifyAgentAuth(pub, fp, sig) { + t.Fatal("valid signature must verify") + } + if VerifyAgentAuth(otherPub, fp, sig) { + t.Fatal("signature must not verify under a different key") + } + if VerifyAgentAuth(pub, "sha256:other", sig) { + t.Fatal("signature bound to one gateway must not verify for another") + } + bad := append([]byte(nil), sig...) + bad[0] ^= 0xff + if VerifyAgentAuth(pub, fp, bad) { + t.Fatal("tampered signature must not verify") + } + if VerifyAgentAuth([]byte{1, 2, 3}, fp, sig) { + t.Fatal("malformed public key must not panic or verify") + } +} + +// TestLoadOrCreateIdentityPersists: the same dir re-derives one identity; a fresh +// dir mints a different one. (identity) +func TestLoadOrCreateIdentityPersists(t *testing.T) { + dir := t.TempDir() + _, pub1, err := LoadOrCreateIdentity(dir) + if err != nil { + t.Fatal(err) + } + _, pub2, err := LoadOrCreateIdentity(dir) + if err != nil { + t.Fatal(err) + } + if !pub1.Equal(pub2) { + t.Fatal("agent identity must persist across loads") + } + _, pub3, err := LoadOrCreateIdentity(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if pub1.Equal(pub3) { + t.Fatal("different dirs must generate different identities") + } +} diff --git a/internal/link/link_test.go b/internal/link/link_test.go index 39311fe..a510278 100644 --- a/internal/link/link_test.go +++ b/internal/link/link_test.go @@ -35,18 +35,74 @@ func TestPairingParseWhitespaceTolerant(t *testing.T) { } } +// TestPairingEmitsPxfV1 pins the current wire shape: the code an agent pastes is a +// pxf:// URL carrying the format version and a "pair" role marker, so the frontend +// and the OS deep-link handler can tell a pairing invite from any other pxf:// link. +func TestPairingEmitsPxfV1(t *testing.T) { + s := PairingCode{Host: "gw.example.com", Port: 8474, Token: "tok123", Fingerprint: "sha256:" + strings.Repeat("ab", 32)}.String() + if !strings.HasPrefix(s, "pxf://") { + t.Errorf("pairing code must use the pxf:// scheme, got %q", s) + } + if !strings.Contains(s, "/v1/pair/") { + t.Errorf("pairing code must carry the /v1/pair/ version+role marker, got %q", s) + } +} + +// TestPairingParsesPxfV1 is the RED driver: a valid v1 code parses to its parts. +func TestPairingParsesPxfV1(t *testing.T) { + fp := "sha256:" + strings.Repeat("ab", 32) + got, err := ParsePairingCode("pxf://gw.example.com:8474/v1/pair/tok123#" + fp) + if err != nil { + t.Fatalf("valid v1 code rejected: %v", err) + } + want := PairingCode{Host: "gw.example.com", Port: 8474, Token: "tok123", Fingerprint: fp} + if got != want { + t.Fatalf("got %+v want %+v", got, want) + } +} + +// TestPairingRejectsPxfShape rejects pxf:// codes whose version or role marker the +// parser does not understand, so a future kind can never be mistaken for a pairing +// invite and a stale-format code fails loudly instead of half-parsing. +func TestPairingRejectsPxfShape(t *testing.T) { + fp := "sha256:" + strings.Repeat("ab", 32) + bad := map[string]string{ + "unknown version": "pxf://gw:8474/v2/pair/tok#" + fp, + "unknown kind": "pxf://gw:8474/v1/join/tok#" + fp, + "missing kind": "pxf://gw:8474/v1/tok#" + fp, + "missing token": "pxf://gw:8474/v1/pair/#" + fp, + "no path": "pxf://gw:8474#" + fp, + "extra segment": "pxf://gw:8474/v1/pair/tok/extra#" + fp, + } + for name, s := range bad { + if _, err := ParsePairingCode(s); err == nil { + t.Errorf("%s: expected error for %q", name, s) + } + } +} + +// TestPairingRejectsOverlong caps the input before any real parsing — a pxf:// deep +// link is attacker-reachable (a web page can fire one), so a pathological string +// must be refused cheaply, not parsed. +func TestPairingRejectsOverlong(t *testing.T) { + fp := "sha256:" + strings.Repeat("ab", 32) + huge := "pxf://gw:8474/v1/pair/" + strings.Repeat("a", 4096) + "#" + fp + if _, err := ParsePairingCode(huge); err == nil { + t.Errorf("expected an over-length pairing code to be rejected") + } +} + func TestPairingParseRejects(t *testing.T) { fp := "sha256:" + strings.Repeat("ab", 32) bad := map[string]string{ - "wrong scheme": "https://gw:8474/tok#" + fp, - "no host": "pf1://:8474/tok#" + fp, - "no port": "pf1://gw/tok#" + fp, - "bad port": "pf1://gw:99999/tok#" + fp, - "no token": "pf1://gw:8474/#" + fp, - "no fingerprint": "pf1://gw:8474/tok", - "short fp": "pf1://gw:8474/tok#sha256:abcd", - "non-hex fp": "pf1://gw:8474/tok#sha256:" + strings.Repeat("zz", 32), - "md5 fingerprint": "pf1://gw:8474/tok#md5:" + strings.Repeat("ab", 32), + "wrong scheme": "https://gw:8474/v1/pair/tok#" + fp, + "no host": "pxf://:8474/v1/pair/tok#" + fp, + "no port": "pxf://gw/v1/pair/tok#" + fp, + "bad port": "pxf://gw:99999/v1/pair/tok#" + fp, + "no fingerprint": "pxf://gw:8474/v1/pair/tok", + "short fp": "pxf://gw:8474/v1/pair/tok#sha256:abcd", + "non-hex fp": "pxf://gw:8474/v1/pair/tok#sha256:" + strings.Repeat("zz", 32), + "md5 fingerprint": "pxf://gw:8474/v1/pair/tok#md5:" + strings.Repeat("ab", 32), } for name, s := range bad { if _, err := ParsePairingCode(s); err == nil { @@ -55,6 +111,27 @@ func TestPairingParseRejects(t *testing.T) { } } +// TestIsPairingURL covers the cheap scheme sniff the OS deep-link router uses: it +// matches the pxf:// scheme (tolerating copy-paste whitespace) without fully +// validating, so a malformed link still routes to the pairing UI to show its error. +func TestIsPairingURL(t *testing.T) { + fp := "sha256:" + strings.Repeat("ab", 32) + cases := map[string]bool{ + "pxf://gw:8474/v1/pair/tok#" + fp: true, + " pxf://gw:8474/v1/pair/tok#" + fp + " ": true, + "pxf://malformed": true, // scheme match, not full validation + "https://gw:8474/v1/pair/tok#" + fp: false, + "pf1://gw:8474/tok#" + fp: false, // legacy scheme is no longer ours + "pxfnope": false, + "": false, + } + for in, want := range cases { + if got := IsPairingURL(in); got != want { + t.Errorf("IsPairingURL(%q) = %v, want %v", in, got, want) + } + } +} + func TestLoadOrCreateCertPersists(t *testing.T) { dir := t.TempDir() _, fp1, err := LoadOrCreateCert(dir) diff --git a/internal/link/pairing.go b/internal/link/pairing.go index a1c9181..c5aa667 100644 --- a/internal/link/pairing.go +++ b/internal/link/pairing.go @@ -10,10 +10,15 @@ import ( // PairingCode is the single line a gateway shows and an agent pastes: // -// pf1://host:8474/#sha256: +// pxf://host:8474/v1/pair/#sha256: // -// Host may be a DNS name (preferred — survives gateway IP changes) or an IP; -// IPv6 literals are bracketed. The fragment pins the gateway's TLS cert. +// The /v1/ segment versions the code's shape independently of the wire protocol +// (bumped only when the layout itself changes), and the /pair/ segment is a role +// marker so a pasted code — or a pxf:// deep link the OS hands the app — is +// recognizably a gateway pairing invite; a wrong-role paste or an unknown pxf:// +// link kind fails with a clear message instead of half-parsing. Host may be a DNS +// name (preferred — survives gateway IP changes) or an IP; IPv6 literals are +// bracketed. The fragment pins the gateway's TLS cert. type PairingCode struct { Host string Port int @@ -21,21 +26,45 @@ type PairingCode struct { Fingerprint string // "sha256:" } -const pairingScheme = "pf1" +const ( + // pairingScheme is the URI scheme; it doubles as the OS deep-link protocol, so + // a clicked pxf:// link opens the app straight into pairing. + pairingScheme = "pxf" + // pairingFormat versions the pxf:// path shape, independent of the wire + // ProtocolVersion — see the version-axes table in the identity design. + pairingFormat = "v1" + // pairingKindPair marks a pxf:// link as a gateway→agent pairing invite, told + // apart from any future link kind by the path's role segment. + pairingKindPair = "pair" + // maxPairingCodeLen bounds the input before any parsing. A pxf:// deep link is + // attacker-reachable (a web page can fire one); a real code is well under this. + maxPairingCodeLen = 512 +) + +// IsPairingURL reports whether s carries the pxf:// pairing scheme, a cheap sniff +// (no full validation) used to route an OS deep-link launch into the pairing UI — +// which then surfaces any parse error itself, rather than the link being silently +// dropped. +func IsPairingURL(s string) bool { + return strings.HasPrefix(strings.TrimSpace(s), pairingScheme+"://") +} func (p PairingCode) String() string { u := url.URL{ Scheme: pairingScheme, Host: net.JoinHostPort(p.Host, strconv.Itoa(p.Port)), - Path: "/" + p.Token, + Path: "/" + pairingFormat + "/" + pairingKindPair + "/" + p.Token, Fragment: p.Fingerprint, } return u.String() } -// ParsePairingCode parses and fully validates a pairing code. +// ParsePairingCode parses and fully validates a pxf:// v1 pairing code. func ParsePairingCode(s string) (PairingCode, error) { s = strings.TrimSpace(s) + if len(s) > maxPairingCodeLen { + return PairingCode{}, fmt.Errorf("pairing code is implausibly long (%d bytes) — re-copy it from the gateway", len(s)) + } u, err := url.Parse(s) if err != nil { return PairingCode{}, fmt.Errorf("not a valid pairing code: %w", err) @@ -43,6 +72,11 @@ func ParsePairingCode(s string) (PairingCode, error) { if u.Scheme != pairingScheme { return PairingCode{}, fmt.Errorf("not a proxyforward pairing code (expected %s://, got %q)", pairingScheme, u.Scheme) } + token, err := tokenFromV1Path(u.Path) + if err != nil { + return PairingCode{}, err + } + host := u.Hostname() if host == "" { return PairingCode{}, fmt.Errorf("pairing code is missing the gateway host") @@ -55,13 +89,6 @@ func ParsePairingCode(s string) (PairingCode, error) { if err != nil || port < 1 || port > 65535 { return PairingCode{}, fmt.Errorf("pairing code has an invalid port %q", portStr) } - token := strings.TrimPrefix(u.Path, "/") - if token == "" { - return PairingCode{}, fmt.Errorf("pairing code is missing the token") - } - if strings.Contains(token, "/") { - return PairingCode{}, fmt.Errorf("pairing code token contains unexpected %q", "/") - } fp := u.Fragment if !strings.HasPrefix(fp, "sha256:") || len(fp) != len("sha256:")+64 { return PairingCode{}, fmt.Errorf("pairing code is missing a valid sha256 certificate fingerprint") @@ -73,3 +100,23 @@ func ParsePairingCode(s string) (PairingCode, error) { } return PairingCode{Host: host, Port: port, Token: token, Fingerprint: strings.ToLower(fp)}, nil } + +// tokenFromV1Path validates the /// path of a pxf:// code and +// returns the token. A wrong version or role marker fails loudly rather than +// silently accepting a stale-format or wrong-kind link. +func tokenFromV1Path(path string) (string, error) { + segs := strings.Split(strings.TrimPrefix(path, "/"), "/") + if len(segs) != 3 { + return "", fmt.Errorf("pairing code path must be /%s/%s/", pairingFormat, pairingKindPair) + } + if segs[0] != pairingFormat { + return "", fmt.Errorf("unsupported pairing code version %q (this app understands %q) — update the app or re-copy the code", segs[0], pairingFormat) + } + if segs[1] != pairingKindPair { + return "", fmt.Errorf("not a pairing invite (expected a %q link, got %q)", pairingKindPair, segs[1]) + } + if segs[2] == "" { + return "", fmt.Errorf("pairing code is missing the token") + } + return segs[2], nil +} diff --git a/internal/link/pairing_fuzz_test.go b/internal/link/pairing_fuzz_test.go new file mode 100644 index 0000000..05d8849 --- /dev/null +++ b/internal/link/pairing_fuzz_test.go @@ -0,0 +1,48 @@ +package link + +import ( + "strings" + "testing" +) + +// FuzzParsePairingCode throws arbitrary strings at the pairing/deep-link parser. A +// pxf:// code reaches this parser straight from an OS deep link (a web page can fire +// one), so it must never panic, must never accept a code that violates its own +// invariants, and — the sharp property — must faithfully re-emit anything it +// accepts: parse∘String is a fixed point, or a clicked link could round-trip into a +// different gateway/token than the one shown. +func FuzzParsePairingCode(f *testing.F) { + fp := "sha256:" + strings.Repeat("ab", 32) + f.Add("pxf://gw.example.com:8474/v1/pair/deadbeef#" + fp) + f.Add("pxf://gw.example.com:8474/v1/pair/anothertok#" + fp) + f.Add("pxf://[2001:db8::1]:8474/v1/pair/tok#" + fp) + f.Add("pxf://gw:8474/v1/join/tok#" + fp) + f.Add("pxf://a%20b:8474/v1/pair/t%2Fok#" + fp) + f.Add("not-a-url") + f.Add("") + f.Fuzz(func(t *testing.T, s string) { + pc, err := ParsePairingCode(s) + if err != nil { + return + } + if pc.Host == "" { + t.Fatalf("accepted empty host from %q", s) + } + if pc.Port < 1 || pc.Port > 65535 { + t.Fatalf("accepted out-of-range port %d from %q", pc.Port, s) + } + if pc.Token == "" || strings.Contains(pc.Token, "/") { + t.Fatalf("accepted bad token %q from %q", pc.Token, s) + } + if !strings.HasPrefix(pc.Fingerprint, "sha256:") || len(pc.Fingerprint) != len("sha256:")+64 { + t.Fatalf("accepted bad fingerprint %q from %q", pc.Fingerprint, s) + } + got, err := ParsePairingCode(pc.String()) + if err != nil { + t.Fatalf("re-parse of emitted code %q (from %q) failed: %v", pc.String(), s, err) + } + if got != pc { + t.Fatalf("parse∘String not idempotent: %+v -> %+v (from %q)", pc, got, s) + } + }) +} diff --git a/internal/link/pq_test.go b/internal/link/pq_test.go new file mode 100644 index 0000000..eff3399 --- /dev/null +++ b/internal/link/pq_test.go @@ -0,0 +1,88 @@ +package link + +import ( + "crypto/tls" + "net" + "testing" + "time" +) + +// TestControlHandshakeUsesMLKEM proves the gateway↔agent TLS handshake +// negotiates the post-quantum hybrid key exchange (X25519MLKEM768) by default. +// Key exchange is the half of PQ that a harvest-now-decrypt-later attacker +// targets — the confidentiality of tunneled bytes — so it is the urgent half. +// It costs nothing here because neither TLS config pins CurvePreferences, so +// Go's default (which includes the hybrid on 1.25+) applies. The per-conn data +// connections reuse these exact configs, so this covers them too. (The ECDSA +// identity — the authentication half — is deliberately left classical: forging +// a signature needs a quantum computer at attack time, so there is no +// retroactive risk and it is safe to migrate later.) +func TestControlHandshakeUsesMLKEM(t *testing.T) { + dir := t.TempDir() + cert, fp, err := LoadOrCreateCert(dir) + if err != nil { + t.Fatal(err) + } + ln, err := tls.Listen("tcp", "127.0.0.1:0", GatewayTLSConfig(cert)) + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + serverCurve := make(chan tls.CurveID, 1) + go func() { + c, err := ln.Accept() + if err != nil { + serverCurve <- 0 + return + } + defer c.Close() + tc := c.(*tls.Conn) + tc.SetDeadline(time.Now().Add(5 * time.Second)) + if err := tc.Handshake(); err != nil { + serverCurve <- 0 + return + } + serverCurve <- tc.ConnectionState().CurveID + }() + + raw, err := (&net.Dialer{Timeout: 5 * time.Second}).Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer raw.Close() + tc := tls.Client(raw, AgentTLSConfig(fp)) + defer tc.Close() + tc.SetDeadline(time.Now().Add(5 * time.Second)) + if err := tc.Handshake(); err != nil { + t.Fatalf("handshake: %v", err) + } + + if got := tc.ConnectionState().CurveID; got != tls.X25519MLKEM768 { + t.Fatalf("client negotiated key exchange = %v, want X25519MLKEM768 (did someone pin CurvePreferences?)", got) + } + select { + case got := <-serverCurve: + if got != tls.X25519MLKEM768 { + t.Fatalf("server negotiated key exchange = %v, want X25519MLKEM768", got) + } + case <-time.After(5 * time.Second): + t.Fatal("server handshake did not complete") + } +} + +// TestNoCurvePreferencesPinned guards the free-PQ property: pinning +// CurvePreferences on either config would silently drop the X25519MLKEM768 +// hybrid negotiated above. Leave both nil. +func TestNoCurvePreferencesPinned(t *testing.T) { + cert, _, err := LoadOrCreateCert(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if cp := GatewayTLSConfig(cert).CurvePreferences; cp != nil { + t.Fatalf("GatewayTLSConfig pins CurvePreferences=%v; leaving it nil keeps the PQ hybrid", cp) + } + if cp := AgentTLSConfig("sha256:pin").CurvePreferences; cp != nil { + t.Fatalf("AgentTLSConfig pins CurvePreferences=%v; leaving it nil keeps the PQ hybrid", cp) + } +} diff --git a/internal/portowner/portowner.go b/internal/portowner/portowner.go index 38e7584..43c71e3 100644 --- a/internal/portowner/portowner.go +++ b/internal/portowner/portowner.go @@ -25,6 +25,13 @@ func Lookup(port int) (Owner, bool) { return lookup(port) } +// IsAddrInUse reports whether err is a "port already in use" bind failure +// (EADDRINUSE / WSAEADDRINUSE), as opposed to a permission or other bind error — +// so a caller can reassign only genuine port clashes and surface the rest. +func IsAddrInUse(err error) bool { + return isAddrInUse(err) +} + // DecorateBindError enriches a failed-bind error with the owning process // when the failure is a port conflict and an owner can be found. func DecorateBindError(port int, err error) error { diff --git a/internal/relay/relay.go b/internal/relay/relay.go index 0c14f3c..94f465e 100644 --- a/internal/relay/relay.go +++ b/internal/relay/relay.go @@ -11,6 +11,7 @@ package relay import ( + "context" "errors" "io" "net" @@ -20,7 +21,11 @@ import ( "time" ) -const bufSize = 128 * 1024 +// BufSize is the pooled copy-buffer size, and thus the largest chunk handed to a +// single Write (io.Copy's default 32 KiB throttles chunk-load bursts on fat +// pipes). A rate limiter's burst is sized to this so WaitN(n) with n ≤ BufSize +// never exceeds the burst. +const BufSize = 128 * 1024 // WriteStallTimeout is how long a single Write may make no progress before // the splice declares the peer dead. Generous: a healthy but slow client @@ -29,7 +34,7 @@ const WriteStallTimeout = 2 * time.Minute var bufPool = sync.Pool{ New: func() any { - b := make([]byte, bufSize) + b := make([]byte, BufSize) return &b }, } @@ -48,10 +53,30 @@ type Counters struct { BToA atomic.Int64 } +// Limiter throttles a copy direction: WaitN blocks until n tokens (bytes) are +// available or ctx is done. *golang.org/x/time/rate.Limiter satisfies this +// structurally; a nil Limiter is the uncapped fast path. Kept as an interface so +// relay imports no rate library. +type Limiter interface { + WaitN(ctx context.Context, n int) error +} + +// SpliceOpts carries the optional bandwidth cap for one splice. The zero value +// (nil Ctx, nil limiters) reproduces the uncapped fast path exactly. LimitAToB +// throttles the a→b direction, LimitBToA the b→a direction (either may be nil); +// Ctx is the parent whose cancellation (e.g. agent eviction) unblocks a parked +// WaitN. +type SpliceOpts struct { + Ctx context.Context + LimitAToB Limiter + LimitBToA Limiter +} + // Splice pumps bytes both ways until both directions have finished, then // fully closes both conns. It returns the first error that wasn't a normal -// EOF/close (nil for a clean shutdown). -func Splice(a, b Conn, counters *Counters) error { +// EOF/close (nil for a clean shutdown). opts optionally rate-limits each +// direction; the zero value is the uncapped path and touches no context. +func Splice(a, b Conn, counters *Counters, opts SpliceOpts) error { var ( wg sync.WaitGroup errA error @@ -61,14 +86,37 @@ func Splice(a, b Conn, counters *Counters) error { if counters != nil { cA, cB = &counters.AToB, &counters.BToA } + + // Only capped splices need a cancellable context. The child cancels when a + // half returns an *error* (so an abnormal teardown promptly unblocks the + // other half's throttled WaitN), but never on a clean EOF — the opposite + // direction must keep draining (final bytes arrive intact). The parent ctx + // cancels on eviction, unblocking both. + ctx := opts.Ctx + cancel := func() {} + if opts.LimitAToB != nil || opts.LimitBToA != nil { + parent := ctx + if parent == nil { + parent = context.Background() + } + ctx, cancel = context.WithCancel(parent) + defer cancel() + } + wg.Add(2) go func() { defer wg.Done() - errA = copyHalf(b, a, cA) // a -> b + errA = copyHalf(b, a, cA, ctx, opts.LimitAToB) // a -> b + if errA != nil { + cancel() + } }() go func() { defer wg.Done() - errB = copyHalf(a, b, cB) // b -> a + errB = copyHalf(a, b, cB, ctx, opts.LimitBToA) // b -> a + if errB != nil { + cancel() + } }() wg.Wait() a.Close() @@ -84,8 +132,10 @@ func Splice(a, b Conn, counters *Counters) error { } // copyHalf copies src → dst until EOF, then half-closes dst so its reader -// sees EOF while the opposite direction keeps flowing. -func copyHalf(dst, src Conn, count *atomic.Int64) error { +// sees EOF while the opposite direction keeps flowing. When lim is non-nil it +// waits for n tokens before each write; a cancelled wait (teardown) tears the +// half down like a write error. +func copyHalf(dst, src Conn, count *atomic.Int64, ctx context.Context, lim Limiter) error { bufp := bufPool.Get().(*[]byte) defer bufPool.Put(bufp) buf := *bufp @@ -93,6 +143,14 @@ func copyHalf(dst, src Conn, count *atomic.Int64) error { for { n, rerr := src.Read(buf) if n > 0 { + if lim != nil { + // Throttle before writing. n ≤ BufSize = the limiter's burst, so + // WaitN only blocks for the rate delay, never errors on burst. + if werr := lim.WaitN(ctx, n); werr != nil { + src.Close() + return werr + } + } dst.SetWriteDeadline(time.Now().Add(WriteStallTimeout)) wn, werr := dst.Write(buf[:n]) if count != nil && wn > 0 { @@ -124,13 +182,17 @@ func copyHalf(dst, src Conn, count *atomic.Int64) error { // isExpectedCloseErr filters the errors a normal teardown produces: EOFs, // "use of closed network connection" from our own Close racing a blocked -// Read, and reset-by-peer when the far side vanished abruptly (a killed -// Minecraft client). Write stalls (deadline exceeded) are NOT expected — -// they indicate a parked peer and are worth surfacing. +// Read, reset-by-peer when the far side vanished abruptly (a killed Minecraft +// client), and a cancelled WaitN (the splice's own child ctx on abnormal +// teardown, or the agent-session ctx on eviction). Write stalls (deadline +// exceeded, a net timeout — distinct from context.DeadlineExceeded) are NOT +// expected: they indicate a parked peer and are worth surfacing. func isExpectedCloseErr(err error) bool { return err == nil || errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || errors.Is(err, syscall.ECONNRESET) || - errors.Is(err, syscall.ECONNABORTED) + errors.Is(err, syscall.ECONNABORTED) || + errors.Is(err, context.Canceled) || + errors.Is(err, context.DeadlineExceeded) } diff --git a/internal/relay/relay_test.go b/internal/relay/relay_test.go index 5af07da..7796160 100644 --- a/internal/relay/relay_test.go +++ b/internal/relay/relay_test.go @@ -2,10 +2,12 @@ package relay import ( "bytes" + "context" "crypto/rand" "io" "net" "sync" + "sync/atomic" "testing" "time" ) @@ -42,7 +44,7 @@ func TestSpliceFinalBytesSurviveHalfClose(t *testing.T) { clientSide, a := tcpPair(t) // a is one leg of the splice b, serverSide := tcpPair(t) // b is the other leg done := make(chan error, 1) - go func() { done <- Splice(a, b, nil) }() + go func() { done <- Splice(a, b, nil, SpliceOpts{}) }() payload := []byte("Disconnected: server is restarting") if _, err := clientSide.Write(payload); err != nil { @@ -89,7 +91,7 @@ func TestSpliceBulkIntegrity(t *testing.T) { b, serverSide := tcpPair(t) var counters Counters done := make(chan error, 1) - go func() { done <- Splice(a, b, &counters) }() + go func() { done <- Splice(a, b, &counters, SpliceOpts{}) }() const size = 8 << 20 up := make([]byte, size) @@ -130,7 +132,7 @@ func TestSpliceAbruptClientDeath(t *testing.T) { clientSide, a := tcpPair(t) b, serverSide := tcpPair(t) done := make(chan error, 1) - go func() { done <- Splice(a, b, nil) }() + go func() { done <- Splice(a, b, nil, SpliceOpts{}) }() // Server writes steadily; client dies mid-stream with unread data (RST). go func() { @@ -154,3 +156,93 @@ func TestSpliceAbruptClientDeath(t *testing.T) { } serverSide.Close() } + +// countingLimiter records the total bytes passed to WaitN and never blocks. +type countingLimiter struct{ total atomic.Int64 } + +func (c *countingLimiter) WaitN(_ context.Context, n int) error { + c.total.Add(int64(n)) + return nil +} + +// blockingLimiter parks in WaitN until ctx is cancelled. +type blockingLimiter struct{} + +func (blockingLimiter) WaitN(ctx context.Context, _ int) error { + <-ctx.Done() + return ctx.Err() +} + +// TestSpliceInvokesLimiterPerDirection: a non-nil limiter must see exactly the +// bytes flowing in its own direction (distinct sizes catch a swapped mapping). +func TestSpliceInvokesLimiterPerDirection(t *testing.T) { + clientSide, a := tcpPair(t) + b, serverSide := tcpPair(t) + var counters Counters + ab := &countingLimiter{} // a->b == client->server + ba := &countingLimiter{} // b->a == server->client + done := make(chan error, 1) + go func() { + done <- Splice(a, b, &counters, SpliceOpts{Ctx: context.Background(), LimitAToB: ab, LimitBToA: ba}) + }() + + const up = 3 << 20 // client -> server + const down = 2 << 20 // server -> client + upBuf := make([]byte, up) + downBuf := make([]byte, down) + rand.Read(upBuf) + rand.Read(downBuf) + + var wg sync.WaitGroup + wg.Add(4) + go func() { defer wg.Done(); clientSide.Write(upBuf); clientSide.CloseWrite() }() + go func() { defer wg.Done(); serverSide.Write(downBuf); serverSide.CloseWrite() }() + go func() { defer wg.Done(); io.Copy(io.Discard, serverSide) }() + go func() { defer wg.Done(); io.Copy(io.Discard, clientSide) }() + wg.Wait() + + if err := <-done; err != nil { + t.Fatalf("splice error: %v", err) + } + if got := ab.total.Load(); got != up { + t.Errorf("a->b limiter saw %d bytes, want %d", got, up) + } + if got := ba.total.Load(); got != down { + t.Errorf("b->a limiter saw %d bytes, want %d", got, down) + } + // The limiter total must match the byte counter for the same direction. + if ab.total.Load() != counters.AToB.Load() || ba.total.Load() != counters.BToA.Load() { + t.Errorf("limiter/counter mismatch: ab=%d/%d ba=%d/%d", + ab.total.Load(), counters.AToB.Load(), ba.total.Load(), counters.BToA.Load()) + } +} + +// TestSpliceCancelUnblocksThrottledCopy: a copy parked in WaitN must unblock +// promptly when the parent ctx is cancelled (agent eviction), and the splice +// returns cleanly (context.Canceled is filtered, not surfaced). +func TestSpliceCancelUnblocksThrottledCopy(t *testing.T) { + clientSide, a := tcpPair(t) + b, serverSide := tcpPair(t) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- Splice(a, b, nil, SpliceOpts{Ctx: ctx, LimitAToB: blockingLimiter{}, LimitBToA: blockingLimiter{}}) + }() + + // Feed each direction a byte so both copies read it and park in WaitN. + clientSide.Write([]byte("x")) + serverSide.Write([]byte("y")) + time.Sleep(100 * time.Millisecond) + cancel() + + select { + case err := <-done: + if err != nil { + t.Fatalf("cancelled splice should return cleanly, got %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("splice did not unblock after ctx cancel") + } + clientSide.Close() + serverSide.Close() +} diff --git a/internal/relay/tap_test.go b/internal/relay/tap_test.go index 7a09a63..a79f6bb 100644 --- a/internal/relay/tap_test.go +++ b/internal/relay/tap_test.go @@ -27,7 +27,7 @@ func TestTapPassthroughFidelity(t *testing.T) { return n >= 8 // stop tapping after the first 8 bytes }) - go Splice(tapped, b, nil) + go Splice(tapped, b, nil, SpliceOpts{}) payload := make([]byte, 200*1024) rand.Read(payload) diff --git a/internal/stats/persister.go b/internal/stats/persister.go index f9f02c1..cee2ed2 100644 --- a/internal/stats/persister.go +++ b/internal/stats/persister.go @@ -8,8 +8,10 @@ package stats // TierSnapshot is one persisted tier's valid buckets plus the bookkeeping a // Persister needs to write incrementally and expire lapped slots. type TierSnapshot struct { - Tier int // index into the store's tier ladder - ResMs int64 // bucket resolution + // AgentID owns this tier's series; "" is the gateway-wide/global history. + AgentID string + Tier int // index into the store's tier ladder + ResMs int64 // bucket resolution // FloorT is the oldest bucket start still inside the ring window; rows // with T < FloorT have lapped out and should be deleted by the persister. @@ -28,6 +30,11 @@ type SnapshotData struct { Lifetime Lifetime Peers []PeerStat Tiers []TierSnapshot + + // DeleteAgents names agent histories evicted since the last save; the + // Persister must drop their rrd rows so a gateway that has cycled through + // many agent ids does not accumulate dead series on disk. + DeleteAgents []string } // Persister stores and restores snapshots. LoadStats returning (nil, nil) diff --git a/internal/stats/stats.go b/internal/stats/stats.go index a029620..4884276 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -208,26 +208,114 @@ func (r *ring) valid(i int64) bool { return r.buf[r.pos(i)].T == i*r.resMs } -// Store owns the tier rings, peer records, and lifetime counters. All methods -// are safe for concurrent use; a single mutex suffices at these rates. -type Store struct { - mu sync.Mutex - persist Persister // nil = memory-only (persistence unavailable) - logger *slog.Logger - tiers []*ring - peers map[string]*PeerStat - life Lifetime +// history is one bandwidth-history ring ladder: the tier rings plus the +// per-tier dirty watermark and this series' sampler baseline. The Store owns +// one global history (the gateway-wide series) and one per connected agent. +// Every method assumes the Store mutex is held — the ladder carries no lock of +// its own. +type history struct { + tiers []*ring // lastCurT tracks, per persisted tier, the current bucket's start time at // the last successful save: only buckets at or after it can have changed // since, so the next save skips everything older. lastCurT map[int]int64 - // Sampler baselines: totals are monotonic per engine run; the first - // sample only records them, a negative delta re-baselines. - baselined bool - lastT int64 - lastAppIn, lastAppOut, lastLinkIn, lastLinkOut int64 + // Sampler baseline for this series' app-byte totals: monotonic per engine + // run; the first sample only records it, a negative delta re-baselines. + baselined bool + lastT int64 + lastIn, lastOut int64 + + // touched is the unix-millis of the last sample, for LRU eviction of the + // per-agent histories. + touched int64 +} + +func newHistory() *history { + h := &history{lastCurT: make(map[int]int64)} + for _, ts := range tierSpecs { + h.tiers = append(h.tiers, newRing(ts.resMs, ts.slots)) + } + return h +} + +// sample folds one reading of the monotonic app-byte totals into tier 0 (which +// cascades up) and returns the byte deltas plus whether a real delta was +// produced. advanced is false on the very first sample (baseline only) and on a +// non-advancing clock, so the caller can gate lifetime accounting on it. Gauges +// record unknown when unmeasured: players < 0, rttMs <= 0, lossPct < 0. +func (h *history) sample(now time.Time, appIn, appOut int64, conns, players int, rttMs, lossPct float64) (dIn, dOut int64, advanced bool) { + t := now.UnixMilli() + h.touched = t + if !h.baselined { + h.baselined = true + h.lastT, h.lastIn, h.lastOut = t, appIn, appOut + return 0, 0, false + } + dt := t - h.lastT + if dt <= 0 { + return 0, 0, false + } + dIn = monotonicDelta(appIn, h.lastIn) + dOut = monotonicDelta(appOut, h.lastOut) + h.lastT, h.lastIn, h.lastOut = t, appIn, appOut + + inRate := float64(dIn) * 1000 / float64(dt) + outRate := float64(dOut) * 1000 / float64(dt) + c := float64(conns) + rtt := -1.0 + if rttMs > 0 { + rtt = rttMs + } + ply := -1.0 + if players >= 0 { + ply = float64(players) + } + loss := -1.0 + if lossPct >= 0 { + loss = lossPct + } + h.add(0, Bucket{ + T: t, In: dIn, Out: dOut, + InO: inRate, InH: inRate, InL: inRate, InC: inRate, + OutO: outRate, OutH: outRate, OutL: outRate, OutC: outRate, + ConnO: c, ConnH: c, ConnL: c, ConnC: c, + RttO: rtt, RttH: rtt, RttL: rtt, RttC: rtt, + PlayersO: ply, PlayersH: ply, PlayersL: ply, PlayersC: ply, + LossO: loss, LossH: loss, LossL: loss, LossC: loss, + }) + return dIn, dOut, true +} + +// maxAgentHistories bounds the per-agent bandwidth histories a gateway keeps in +// memory and on disk. The "several friends" topology needs a handful; the cap +// is a safety valve against a gateway that has cycled through many agent ids. +const maxAgentHistories = 32 + +// Store owns the tier histories, peer records, and lifetime counters. All +// methods are safe for concurrent use; a single mutex suffices at these rates. +type Store struct { + mu sync.Mutex + persist Persister // nil = memory-only (persistence unavailable) + logger *slog.Logger + + // global is the gateway-wide bandwidth history (agent_id "" on disk); + // agents holds one history per connected agent, LRU-capped. + global *history + agents map[string]*history + + // evicted names agent histories dropped from `agents` since the last save + // so SaveStats can delete their persisted rrd rows. + evicted map[string]struct{} + + peers map[string]*PeerStat + life Lifetime + + // Link-byte baseline for the lifetime counter (the tier histories keep + // their own app-byte baselines). Totals are monotonic per engine run. + linkBaselined bool + lastLinkIn, lastLinkOut int64 // upMark is when uptime was last folded into life.UptimeMs. upMark time.Time @@ -244,13 +332,12 @@ func Open(p Persister, logger *slog.Logger) *Store { logger = slog.New(slog.NewTextHandler(io.Discard, nil)) } s := &Store{ - persist: p, - logger: logger, - peers: make(map[string]*PeerStat), - lastCurT: make(map[int]int64), - } - for _, ts := range tierSpecs { - s.tiers = append(s.tiers, newRing(ts.resMs, ts.slots)) + persist: p, + logger: logger, + global: newHistory(), + agents: make(map[string]*history), + evicted: make(map[string]struct{}), + peers: make(map[string]*PeerStat), } s.load() if s.life.FirstRunMs == 0 { @@ -282,10 +369,53 @@ func (s *Store) load() { s.peers[p.IP] = &pc } for _, ts := range snap.Tiers { - if ts.Tier < 0 || ts.Tier >= len(s.tiers) { + h := s.historyForLoad(ts.AgentID) + if h == nil || ts.Tier < 0 || ts.Tier >= len(h.tiers) { continue } - restoreTier(s.tiers[ts.Tier], ts.Buckets) + restoreTier(h.tiers[ts.Tier], ts.Buckets) + } +} + +// historyFor returns the history for an agent id ("" = global), or nil if an +// agent is not tracked. Callers hold s.mu. +func (s *Store) historyFor(agentID string) *history { + if agentID == "" { + return s.global + } + return s.agents[agentID] +} + +// historyForLoad returns the history to restore into, creating the per-agent +// one on demand (persisted agents are already bounded by the save-time cap). +func (s *Store) historyForLoad(agentID string) *history { + if agentID == "" { + return s.global + } + h := s.agents[agentID] + if h == nil { + h = newHistory() + s.agents[agentID] = h + } + return h +} + +// evictAgentIfFullLocked drops the least-recently-sampled agent history when the +// map is at capacity, marking it for rrd deletion on the next save. +func (s *Store) evictAgentIfFullLocked() { + if len(s.agents) < maxAgentHistories { + return + } + var victim string + oldest := int64(1<<63 - 1) + for id, h := range s.agents { + if h.touched < oldest { + oldest, victim = h.touched, id + } + } + if victim != "" { + delete(s.agents, victim) + s.evicted[victim] = struct{}{} } } @@ -331,15 +461,27 @@ func (s *Store) snapshotLocked() *SnapshotData { for _, p := range s.peers { snap.Peers = append(snap.Peers, *p) } + s.appendTiersLocked(snap, "", s.global) + for id, h := range s.agents { + s.appendTiersLocked(snap, id, h) + } + for id := range s.evicted { + snap.DeleteAgents = append(snap.DeleteAgents, id) + } + return snap +} + +// appendTiersLocked adds one history's persisted tiers to snap under agentID. +func (s *Store) appendTiersLocked(snap *SnapshotData, agentID string, h *history) { for ti, spec := range tierSpecs { if !spec.persist { continue } - r := s.tiers[ti] + r := h.tiers[ti] if r.cur < 0 { continue } - ts := TierSnapshot{Tier: ti, ResMs: r.resMs, DirtyFromT: s.lastCurT[ti]} + ts := TierSnapshot{AgentID: agentID, Tier: ti, ResMs: r.resMs, DirtyFromT: h.lastCurT[ti]} floorIdx := r.cur - int64(len(r.buf)) + 1 ts.FloorT = max(floorIdx*r.resMs, 0) for i := floorIdx; i <= r.cur; i++ { @@ -349,7 +491,6 @@ func (s *Store) snapshotLocked() *SnapshotData { } snap.Tiers = append(snap.Tiers, ts) } - return snap } // Flush saves the store through the persister and folds accrued uptime into @@ -379,9 +520,16 @@ func (s *Store) Flush() error { s.mu.Lock() for _, ts := range snap.Tiers { if n := len(ts.Buckets); n > 0 { - s.lastCurT[ts.Tier] = ts.Buckets[n-1].T + if h := s.historyFor(ts.AgentID); h != nil { + h.lastCurT[ts.Tier] = ts.Buckets[n-1].T + } } } + // The evicted agents' rrd rows are gone now; stop re-requesting their + // deletion (a returning agent re-creates a fresh history). + for _, id := range snap.DeleteAgents { + delete(s.evicted, id) + } s.mu.Unlock() return nil } @@ -395,55 +543,41 @@ func (s *Store) Flush() error { func (s *Store) Sample(now time.Time, appIn, appOut, linkIn, linkOut int64, conns, players int, rttMs, lossPct float64) { s.mu.Lock() defer s.mu.Unlock() - t := now.UnixMilli() - if !s.baselined { - s.baselined = true - s.lastT, s.lastAppIn, s.lastAppOut, s.lastLinkIn, s.lastLinkOut = t, appIn, appOut, linkIn, linkOut - return - } - dt := t - s.lastT - if dt <= 0 { + // The global history owns the app-byte baseline and the tier cascade; the + // link bytes feed only the lifetime counter and keep their own baseline. + dIn, dOut, advanced := s.global.sample(now, appIn, appOut, conns, players, rttMs, lossPct) + if !s.linkBaselined { + s.linkBaselined = true + s.lastLinkIn, s.lastLinkOut = linkIn, linkOut + } + if !advanced { return } - dIn := monotonicDelta(appIn, s.lastAppIn) - dOut := monotonicDelta(appOut, s.lastAppOut) - dLinkIn := monotonicDelta(linkIn, s.lastLinkIn) - dLinkOut := monotonicDelta(linkOut, s.lastLinkOut) - s.lastT, s.lastAppIn, s.lastAppOut, s.lastLinkIn, s.lastLinkOut = t, appIn, appOut, linkIn, linkOut - s.life.BytesIn += dIn s.life.BytesOut += dOut - s.life.LinkBytesIn += dLinkIn - s.life.LinkBytesOut += dLinkOut + s.life.LinkBytesIn += monotonicDelta(linkIn, s.lastLinkIn) + s.life.LinkBytesOut += monotonicDelta(linkOut, s.lastLinkOut) + s.lastLinkIn, s.lastLinkOut = linkIn, linkOut +} - inRate := float64(dIn) * 1000 / float64(dt) - outRate := float64(dOut) * 1000 / float64(dt) - c := float64(conns) - // RTT is a gauge; a non-positive reading (no link, or a role that does not - // measure it) records as unknown so it never plots a bogus zero. - rtt := -1.0 - if rttMs > 0 { - rtt = rttMs - } - // Players and loss are gauges where zero is a real reading; only negative - // means unmeasured. - ply := -1.0 - if players >= 0 { - ply = float64(players) - } - loss := -1.0 - if lossPct >= 0 { - loss = lossPct +// SampleAgent folds one reading of an agent's proxied byte totals and gauges +// into that agent's bandwidth history (created on demand, LRU-capped). agentID +// "" is a no-op — the gateway-wide series is Sample's job. Lifetime/link +// counters are global and are not touched here. +func (s *Store) SampleAgent(agentID string, now time.Time, appIn, appOut int64, conns, players int, rttMs, lossPct float64) { + if agentID == "" { + return } - s.add(0, Bucket{ - T: t, In: dIn, Out: dOut, - InO: inRate, InH: inRate, InL: inRate, InC: inRate, - OutO: outRate, OutH: outRate, OutL: outRate, OutC: outRate, - ConnO: c, ConnH: c, ConnL: c, ConnC: c, - RttO: rtt, RttH: rtt, RttL: rtt, RttC: rtt, - PlayersO: ply, PlayersH: ply, PlayersL: ply, PlayersC: ply, - LossO: loss, LossH: loss, LossL: loss, LossC: loss, - }) + s.mu.Lock() + defer s.mu.Unlock() + h := s.agents[agentID] + if h == nil { + s.evictAgentIfFullLocked() + h = newHistory() + s.agents[agentID] = h + delete(s.evicted, agentID) // re-created before its rows were deleted + } + h.sample(now, appIn, appOut, conns, players, rttMs, lossPct) } // monotonicDelta treats a shrinking total (engine restart, counter reset) as @@ -457,8 +591,8 @@ func monotonicDelta(cur, prev int64) int64 { // add folds a point into tier level; when the tier's current bucket // completes, the completed bucket cascades one level up. mu must be held. -func (s *Store) add(level int, b Bucket) { - r := s.tiers[level] +func (h *history) add(level int, b Bucket) { + r := h.tiers[level] idx := b.T / r.resMs switch { case r.cur < 0: @@ -466,8 +600,8 @@ func (s *Store) add(level int, b Bucket) { case idx > r.cur: completed := r.buf[r.pos(r.cur)] r.start(idx) - if level+1 < len(s.tiers) { - s.add(level+1, completed) + if level+1 < len(h.tiers) { + h.add(level+1, completed) } case idx < r.cur: return // clock went backward; drop rather than corrupt the ring @@ -477,14 +611,32 @@ func (s *Store) add(level int, b Bucket) { // History returns up to maxBuckets buckets covering the trailing windowMs // (0 = everything the store has, from the daily tier). The newest bucket may -// still be in progress. +// still be in progress. This is the gateway-wide series. func (s *Store) History(windowMs int64, maxBuckets int) HistoryResult { return s.historyAt(time.Now().UnixMilli(), windowMs, maxBuckets) } +// historyAt is History on the global series with an injected clock (tests). func (s *Store) historyAt(nowMs, windowMs int64, maxBuckets int) HistoryResult { s.mu.Lock() defer s.mu.Unlock() + return s.global.historyAt(nowMs, windowMs, maxBuckets) +} + +// AgentHistory is History scoped to one agent's series; an unknown agent (never +// sampled, or evicted from the LRU) yields an empty result. +func (s *Store) AgentHistory(agentID string, windowMs int64, maxBuckets int) HistoryResult { + s.mu.Lock() + defer s.mu.Unlock() + h := s.agents[agentID] + if h == nil { + return HistoryResult{Buckets: []Bucket{}} + } + return h.historyAt(time.Now().UnixMilli(), windowMs, maxBuckets) +} + +// historyAt walks one history's rings; the caller holds s.mu. +func (h *history) historyAt(nowMs, windowMs int64, maxBuckets int) HistoryResult { if maxBuckets <= 0 || maxBuckets > maxHistoryBuckets { maxBuckets = maxHistoryBuckets } @@ -495,7 +647,7 @@ func (s *Store) historyAt(nowMs, windowMs int64, maxBuckets int) HistoryResult { ) if windowMs <= 0 { // All time: serve the daily tier from its oldest data. - r = s.tiers[len(s.tiers)-1] + r = h.tiers[len(h.tiers)-1] if r.cur < 0 { return HistoryResult{Buckets: []Bucket{}} } @@ -509,14 +661,14 @@ func (s *Store) historyAt(nowMs, windowMs int64, maxBuckets int) HistoryResult { startIdx, endIdx = oldest, r.cur windowMs = max(nowMs-oldest*r.resMs, r.resMs) } else { - for _, t := range s.tiers { + for _, t := range h.tiers { if t.resMs*int64(len(t.buf)) >= windowMs { r = t break } } if r == nil { - r = s.tiers[len(s.tiers)-1] + r = h.tiers[len(h.tiers)-1] } n := (windowMs + r.resMs - 1) / r.resMs endIdx = nowMs / r.resMs diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go index 525ef9f..4bf0cff 100644 --- a/internal/stats/stats_test.go +++ b/internal/stats/stats_test.go @@ -591,6 +591,76 @@ func TestLoadLegacyJSONV1(t *testing.T) { } } +// TestSampleAgentEquivalence: with one agent whose traffic is all the traffic, +// its per-agent history must match the global series bucket-for-bucket. This is +// the single-agent-equivalence proof that per-agent sampling is faithful. +func TestSampleAgentEquivalence(t *testing.T) { + s := fresh(t) + const agentID = "agent-1" + var appIn, appOut int64 + for i := int64(0); i <= 650; i++ { + ts := time.UnixMilli(base + i*100) + conns := int(3 + i%4) + s.Sample(ts, appIn, appOut, 0, 0, conns, 2, 25, 0) + s.SampleAgent(agentID, ts, appIn, appOut, conns, 2, 25, 0) + appIn += 1000 + appOut += 10_000 + } + end := base + 650*100 + s.mu.Lock() + g := s.global.historyAt(end, 60_000, 300) + a := s.agents[agentID].historyAt(end, 60_000, 300) + s.mu.Unlock() + if len(a.Buckets) != len(g.Buckets) || len(g.Buckets) == 0 { + t.Fatalf("agent history %d buckets, global %d", len(a.Buckets), len(g.Buckets)) + } + for i := range g.Buckets { + if a.Buckets[i] != g.Buckets[i] { + t.Fatalf("bucket %d: agent %+v != global %+v", i, a.Buckets[i], g.Buckets[i]) + } + } + // An unknown/never-sampled agent has no history. + if h := s.AgentHistory("nope", 60_000, 300); len(h.Buckets) != 0 { + t.Fatalf("unknown agent returned %d buckets", len(h.Buckets)) + } + // The '' series is Sample's job; SampleAgent("") is a no-op. + s.SampleAgent("", time.UnixMilli(end), 1, 1, 1, 1, 1, 1) + s.mu.Lock() + _, empty := s.agents[""] + s.mu.Unlock() + if empty { + t.Fatal(`SampleAgent("") must not create a series`) + } +} + +// TestAgentHistoryLRUCap: more distinct agents than the cap keeps the map +// bounded and evicts the least-recently-sampled. +func TestAgentHistoryLRUCap(t *testing.T) { + s := fresh(t) + for i := 0; i < maxAgentHistories+5; i++ { + id := "agent-" + strconv.Itoa(i) + s.SampleAgent(id, time.UnixMilli(base+int64(i)), 0, 0, 1, 0, -1, -1) + } + s.mu.Lock() + n := len(s.agents) + _, stalestAlive := s.agents["agent-0"] + _, newestAlive := s.agents["agent-"+strconv.Itoa(maxAgentHistories+4)] + nEvicted := len(s.evicted) + s.mu.Unlock() + if n > maxAgentHistories { + t.Fatalf("agent histories = %d, want ≤ %d", n, maxAgentHistories) + } + if stalestAlive { + t.Fatal("stalest agent (agent-0) should have been evicted") + } + if !newestAlive { + t.Fatal("newest agent should be retained") + } + if nEvicted == 0 { + t.Fatal("evicted agents should be marked for rrd deletion") + } +} + func TestCountingConn(t *testing.T) { // A pipe-backed conn check would drag in networking; the arithmetic is // what matters and lives in LinkCounters via countingConn's Add calls. diff --git a/internal/transport/quic.go b/internal/transport/quic.go new file mode 100644 index 0000000..42962db --- /dev/null +++ b/internal/transport/quic.go @@ -0,0 +1,235 @@ +package transport + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "time" + + "github.com/quic-go/quic-go" + + "proxyforward/internal/stats" +) + +// QUIC transport: a Session backed by one QUIC connection, one Stream per QUIC +// stream. Loss on one player's stream cannot head-of-line-block another's — the +// per-conn benefit over a single connection/handshake/NAT-entry. Only this +// package imports quic-go, exactly as with yamux. +// +// Link-byte accounting differs from yamux by necessity. yamux muxes over one +// net.Conn, so the gateway/agent wrap that conn once (stats.NewCountingConn) and +// every byte is counted. QUIC handshakes over a UDP socket; wrapping that socket +// in a plain net.PacketConn would drop quic-go off its GSO/GRO/ECN fast path +// (quic-go only enables those for an *net.UDPConn), costing throughput. So we +// pass the raw socket to quic-go and count stream payload here instead — the sum +// excludes QUIC's own framing/ACK overhead, an acceptable approximation for the +// GUI's link-throughput display. The gateway shares one socket across sessions, +// so it counts process totals only (session counter nil → per-agent link bytes +// render "—"); the agent's socket is 1:1 with its session, so both are exact. + +// quicStream adapts a *quic.Stream to transport.Stream (net.Conn + CloseWrite). +// A quic.Stream is not a net.Conn — it lacks LocalAddr/RemoteAddr — so those are +// snapshotted from the owning connection at wrap time. totals/session (either may +// be nil) accumulate payload bytes read/written. +type quicStream struct { + s *quic.Stream + localAddr net.Addr + remoteAddr net.Addr + totals, session *stats.LinkCounters +} + +func (q *quicStream) Read(p []byte) (int, error) { + n, err := q.s.Read(p) + if n > 0 { + if q.totals != nil { + q.totals.In.Add(int64(n)) + } + if q.session != nil { + q.session.In.Add(int64(n)) + } + } + return n, err +} + +func (q *quicStream) Write(p []byte) (int, error) { + n, err := q.s.Write(p) + if n > 0 { + if q.totals != nil { + q.totals.Out.Add(int64(n)) + } + if q.session != nil { + q.session.Out.Add(int64(n)) + } + } + return n, err +} + +func (q *quicStream) SetDeadline(t time.Time) error { return q.s.SetDeadline(t) } +func (q *quicStream) SetReadDeadline(t time.Time) error { return q.s.SetReadDeadline(t) } +func (q *quicStream) SetWriteDeadline(t time.Time) error { return q.s.SetWriteDeadline(t) } +func (q *quicStream) LocalAddr() net.Addr { return q.localAddr } +func (q *quicStream) RemoteAddr() net.Addr { return q.remoteAddr } + +// CloseWrite half-closes: quic.Stream.Close sends a FIN on the send side (peer +// reads io.EOF) while our reads keep working — identical to yamux CloseWrite, +// required for correct splice shutdown. +func (q *quicStream) CloseWrite() error { return q.s.Close() } + +// Close fully tears the stream down. CancelRead sends STOP_SENDING so a peer that +// is still writing stops (and unblocks any parked Read); Close FINs the send side +// (a no-op if CloseWrite already ran). Callers defer this only after the splice +// has drained both directions, so it never truncates live payload on the happy +// path — matching muxStream, whose Close also full-closes. +func (q *quicStream) Close() error { + q.s.CancelRead(0) + return q.s.Close() +} + +// quicSession is the accepting (server) side: it owns only the *quic.Conn. The +// listener's Transport and UDP socket are shared across every agent's session, so +// closing one session must not touch them — Close closes just this connection, +// which tears down all of its streams. The client side (quicClientSession) owns +// its Transport/socket 1:1 and closes them too. +type quicSession struct { + conn *quic.Conn + totals, session *stats.LinkCounters +} + +func (q *quicSession) OpenStream() (Stream, error) { + // OpenStreamSync waits (rather than erroring) if the peer's MAX_STREAMS is + // momentarily exhausted; the conn context ends the wait when the conn dies. + st, err := q.conn.OpenStreamSync(q.conn.Context()) + if err != nil { + return nil, err + } + return q.wrap(st), nil +} + +func (q *quicSession) AcceptStream() (Stream, error) { + st, err := q.conn.AcceptStream(q.conn.Context()) + if err != nil { + return nil, err + } + return q.wrap(st), nil +} + +func (q *quicSession) wrap(st *quic.Stream) *quicStream { + return &quicStream{ + s: st, + localAddr: q.conn.LocalAddr(), + remoteAddr: q.conn.RemoteAddr(), + totals: q.totals, + session: q.session, + } +} + +func (q *quicSession) Close() error { return q.conn.CloseWithError(0, "") } +func (q *quicSession) CloseChan() <-chan struct{} { return q.conn.Context().Done() } +func (q *quicSession) RemoteAddr() net.Addr { return q.conn.RemoteAddr() } + +// TLSConnectionState exposes the negotiated TLS state so callers/tests can assert +// the handshake (e.g. the PQ hybrid KEM) — the QUIC analogue of *tls.Conn's +// ConnectionState. +func (q *quicSession) TLSConnectionState() tls.ConnectionState { + return q.conn.ConnectionState().TLS +} + +// quicClientSession is the dialing side. Unlike the server, a client dial is 1:1 +// with its UDP socket and Transport, so Close tears down all three: the QUIC +// connection, the Transport (which joins quic-go's background goroutines), then +// the packet conn (Transport.Close does not close a caller-supplied socket). +type quicClientSession struct { + quicSession + tr *quic.Transport + pc net.PacketConn +} + +func (q *quicClientSession) Close() error { + _ = q.conn.CloseWithError(0, "") + _ = q.tr.Close() + return q.pc.Close() +} + +// QUICListener accepts inbound QUIC connections on one UDP socket. Its Transport +// and socket are shared by every session it produces; totals accumulate every +// session's link bytes (per-session counting is not attributable on a shared +// socket). +type QUICListener struct { + tr *quic.Transport + ln *quic.Listener + pc net.PacketConn + totals *stats.LinkCounters +} + +// ListenQUIC starts a QUIC listener over an already-bound UDP socket (the caller +// owns binding). tlsConf is the plain gateway TLS config; withALPN injects the +// mandatory ALPN without touching CurvePreferences (so the PQ hybrid KEM +// survives). totals (may be nil) accumulates process link bytes across sessions. +func ListenQUIC(pc net.PacketConn, tlsConf *tls.Config, totals *stats.LinkCounters) (*QUICListener, error) { + tr := &quic.Transport{Conn: pc} + ln, err := tr.Listen(withALPN(tlsConf), quicConfig()) + if err != nil { + _ = tr.Close() + return nil, fmt.Errorf("transport: quic listen: %w", err) + } + return &QUICListener{tr: tr, ln: ln, pc: pc, totals: totals}, nil +} + +// Accept returns the next agent session after its QUIC (and thus TLS) handshake +// completes. +func (l *QUICListener) Accept(ctx context.Context) (Session, error) { + conn, err := l.ln.Accept(ctx) + if err != nil { + return nil, err + } + return &quicSession{conn: conn, totals: l.totals}, nil +} + +func (l *QUICListener) Addr() net.Addr { return l.ln.Addr() } + +// Close stops accepting and joins quic-go's transport goroutines, then releases +// the UDP socket. Individual accepted sessions are closed separately (eviction), +// leaving the shared listener and other agents untouched. +func (l *QUICListener) Close() error { + _ = l.ln.Close() + _ = l.tr.Close() + return l.pc.Close() +} + +// DialQUIC opens a client QUIC connection to remoteAddr over the given UDP socket +// and returns the established session (handshake complete). The returned session +// owns pc and its Transport and closes both on Session.Close. totals/session (may +// be nil) accumulate this session's link bytes — both exact, since one client +// socket carries exactly one session. +func DialQUIC(ctx context.Context, pc net.PacketConn, remoteAddr string, tlsConf *tls.Config, totals, session *stats.LinkCounters) (Session, error) { + ua, err := net.ResolveUDPAddr("udp", remoteAddr) + if err != nil { + _ = pc.Close() + return nil, fmt.Errorf("transport: resolve %q: %w", remoteAddr, err) + } + tr := &quic.Transport{Conn: pc} + conn, err := tr.Dial(ctx, ua, withALPN(tlsConf), quicConfig()) + if err != nil { + _ = tr.Close() + _ = pc.Close() + return nil, fmt.Errorf("transport: quic dial %s: %w", remoteAddr, err) + } + return &quicClientSession{ + quicSession: quicSession{conn: conn, totals: totals, session: session}, + tr: tr, + pc: pc, + }, nil +} + +// withALPN returns a clone of cfg with a QUIC ALPN set when the caller left one +// unset. It clones to avoid mutating the shared link config, and it never touches +// CurvePreferences — leaving it nil keeps Go's X25519MLKEM768 hybrid default. +func withALPN(cfg *tls.Config) *tls.Config { + if len(cfg.NextProtos) > 0 { + return cfg + } + c := cfg.Clone() + c.NextProtos = []string{quicALPN} + return c +} diff --git a/internal/transport/quic_test.go b/internal/transport/quic_test.go new file mode 100644 index 0000000..4df18a8 --- /dev/null +++ b/internal/transport/quic_test.go @@ -0,0 +1,160 @@ +package transport + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "io" + "net" + "testing" + "time" + + "go.uber.org/goleak" + + "proxyforward/internal/link" + "proxyforward/internal/stats" +) + +// goleak guards that a fully-closed QUIC listener/dial leaves no lingering +// quic-go goroutines — the same contract the e2e suite enforces, checked here at +// the source so a transport-level leak fails fast. +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +type tlsStater interface { + TLSConnectionState() tls.ConnectionState +} + +// quicLoopback stands up a real QUIC server+client over loopback UDP using the +// production TLS configs (pinned self-signed cert), completes the handshake, and +// returns both sessions plus the client's link-byte counter. Everything is torn +// down via t.Cleanup. +func quicLoopback(t *testing.T) (server, client Session, clientTotals *stats.LinkCounters) { + t.Helper() + cert, fp, err := link.LoadOrCreateCert(t.TempDir()) + if err != nil { + t.Fatalf("cert: %v", err) + } + + spc, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("server udp: %v", err) + } + ln, err := ListenQUIC(spc, link.GatewayTLSConfig(cert), nil) + if err != nil { + t.Fatalf("listen: %v", err) + } + + cpc, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("client udp: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + clientTotals = &stats.LinkCounters{} + type dialRes struct { + s Session + err error + } + dialed := make(chan dialRes, 1) + go func() { + s, err := DialQUIC(ctx, cpc, ln.Addr().String(), link.AgentTLSConfig(fp), clientTotals, nil) + dialed <- dialRes{s, err} + }() + + server, err = ln.Accept(ctx) + if err != nil { + t.Fatalf("accept: %v", err) + } + dr := <-dialed + if dr.err != nil { + t.Fatalf("dial: %v", dr.err) + } + client = dr.s + + t.Cleanup(func() { + client.Close() // closes conn + client transport + client socket + server.Close() // closes the accepted conn + ln.Close() // closes listener + shared transport + server socket + }) + return server, client, clientTotals +} + +// TestQUICHandshakeUsesMLKEM guards that carrying the production TLS configs over +// QUIC (with withALPN injecting ALPN) still negotiates the X25519MLKEM768 PQ +// hybrid on both sides — i.e. withALPN did not clobber CurvePreferences. The +// link package can't host this (it must not import quic-go), so it lives here. +func TestQUICHandshakeUsesMLKEM(t *testing.T) { + server, client, _ := quicLoopback(t) + for _, tc := range []struct { + name string + sess Session + }{{"server", server}, {"client", client}} { + st, ok := tc.sess.(tlsStater) + if !ok { + t.Fatalf("%s: session does not expose TLS state", tc.name) + } + if got := st.TLSConnectionState().CurveID; got != tls.X25519MLKEM768 { + t.Errorf("%s: CurveID = %v, want X25519MLKEM768 (PQ hybrid dropped)", tc.name, got) + } + } +} + +// TestQUICHalfClose proves quic-go's stream Close matches yamux CloseWrite: a +// CloseWrite surfaces io.EOF to the peer's reader while the reverse direction +// keeps carrying bytes. This is the invariant the splice's FIN propagation relies +// on (relay.Splice / TestFinalBytesThroughTunnel). +func TestQUICHalfClose(t *testing.T) { + server, client, clientTotals := quicLoopback(t) + + cs, err := client.OpenStream() + if err != nil { + t.Fatalf("open: %v", err) + } + // The server accepts a stream only once the client has sent on it. + if _, err := cs.Write([]byte("ping")); err != nil { + t.Fatalf("client write: %v", err) + } + ss, err := server.AcceptStream() + if err != nil { + t.Fatalf("accept stream: %v", err) + } + buf := make([]byte, 4) + if _, err := io.ReadFull(ss, buf); err != nil { + t.Fatalf("server read: %v", err) + } + if !bytes.Equal(buf, []byte("ping")) { + t.Fatalf("server got %q, want ping", buf) + } + + // Client half-closes its send side; the server must see EOF after draining. + if err := cs.CloseWrite(); err != nil { + t.Fatalf("client CloseWrite: %v", err) + } + ss.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, err := ss.Read(buf); !errors.Is(err, io.EOF) { + t.Fatalf("server read after client CloseWrite: err = %v, want io.EOF", err) + } + + // Reverse direction still flows after the client's CloseWrite. + if _, err := ss.Write([]byte("pong")); err != nil { + t.Fatalf("server write: %v", err) + } + cs.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, err := io.ReadFull(cs, buf); err != nil { + t.Fatalf("client read reverse: %v", err) + } + if !bytes.Equal(buf, []byte("pong")) { + t.Fatalf("client got %q, want pong", buf) + } + + // Stream payload is counted into the client's link totals (4 out "ping", 4 + // in "pong"). Exact-equal, since nothing else crossed this session's streams. + if in, out := clientTotals.Bytes(); in != 4 || out != 4 { + t.Errorf("client link bytes = (in %d, out %d), want (4, 4)", in, out) + } +} diff --git a/internal/transport/quicconfig.go b/internal/transport/quicconfig.go new file mode 100644 index 0000000..f045cab --- /dev/null +++ b/internal/transport/quicconfig.go @@ -0,0 +1,52 @@ +package transport + +import ( + "time" + + "github.com/quic-go/quic-go" +) + +// quicALPN is the ALPN protocol id offered on every QUIC handshake. QUIC +// mandates ALPN (unlike yamux-over-TCP, which sets none), so withALPN injects +// this when the caller's tls.Config leaves NextProtos empty. The "/1" tracks the +// framing on the wire, not ProtocolVersion — both peers of a given build agree. +const quicALPN = "pf-quic/1" + +// Deliberate quic-go tuning — the QUIC analogue of muxConfig, same rationale: +// - KeepAlivePeriod 0 (OFF): the application-level ping (agent every 5 s) is +// the single liveness owner, exactly as with yamux keepalive. Two mechanisms +// produce confusing failures. +// - MaxIdleTimeout 30 s: QUIC tears down a connection after this much silence. +// It must sit ABOVE the app liveness budget (15 s idle read deadline + margin) +// so the heartbeat — not QUIC — decides when the link is dead. Mirrors yamux's +// 30 s ConnectionWriteTimeout. +// - Receive windows: 1 MiB initial stream window matches yamux MaxStreamWindowSize +// so a Minecraft chunk burst fits in flight before auto-tuning ramps up; the max +// windows give auto-tune headroom on fat pipes. +// - MaxIncomingStreams 1<<16: yamux imposes no per-session stream cap; quic-go +// defaults to 100, which would block OpenStreamSync once a busy proxy crosses +// 100 concurrent players. One player = one bidi stream, so lift the ceiling. +// - MaxIncomingUniStreams -1: we only use bidirectional streams; disallow uni. +// +// Congestion control is not pluggable in quic-go's public API (no BBR); we accept +// its CUBIC-family default. +func quicConfig() *quic.Config { + return &quic.Config{ + KeepAlivePeriod: 0, + MaxIdleTimeout: 30 * time.Second, + // HandshakeIdleTimeout bounds how long a dial waits for the server's + // handshake reply (the dial aborts at 2×). It is the auto ladder's + // UDP-blocked detector: when UDP is dropped the QUIC dial fails after this + // and the agent falls back to per-conn. Generous enough to tolerate a + // slow/lossy but working link (a handshake is 1 RTT + PTO retransmits) so + // QUIC isn't false-rejected; the cost is paid at most once per re-probe. + HandshakeIdleTimeout: 5 * time.Second, + InitialStreamReceiveWindow: 1 << 20, + MaxStreamReceiveWindow: 6 << 20, + InitialConnectionReceiveWindow: 2 << 20, + MaxConnectionReceiveWindow: 12 << 20, + MaxIncomingStreams: 1 << 16, + MaxIncomingUniStreams: -1, + EnableDatagrams: false, + } +} diff --git a/internal/version/version.go b/internal/version/version.go index c7ad0b9..9a9525b 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,6 +1,8 @@ // Package version holds build metadata injected via -ldflags. package version +import "strings" + var ( // Version is overridden at release build time with // -ldflags "-X proxyforward/internal/version.Version=v1.2.3". @@ -8,6 +10,18 @@ var ( Commit = "unknown" ) +// String is the display form used everywhere the version reaches a human +// (UI status, --version, diagnostics). The commit is truncated to the short +// 7-hex form and dropped entirely when Version already embeds it (CI dev +// builds stamp "0.0.0-dev+") — a full 40-char SHA overflows every +// card and footer it lands in. func String() string { - return Version + " (" + Commit + ")" + c := Commit + if len(c) > 7 { + c = c[:7] + } + if c == "" || c == "unknown" || strings.Contains(Version, c) { + return Version + } + return Version + " (" + c + ")" } diff --git a/main.go b/main.go index 6b616f1..37242e3 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "os/signal" "path/filepath" "runtime/debug" + "strings" "syscall" "time" @@ -38,7 +39,16 @@ func main() { // GUI when run with no arguments, so double-clicking must work. cobra.MousetrapHelpText = "" crashLog := installCrashLog() - if err := newRootCmd().Execute(); err != nil { + + // A pxf:// protocol launch (a clicked pairing link) arrives as the process's + // sole argument. Pull it out before cobra parses argv — otherwise the URL looks + // like an unknown subcommand — and run the GUI straight into pairing with it. + deepLink := deepLinkArg(os.Args[1:]) + if deepLink != "" { + os.Args = os.Args[:1] + } + + if err := newRootCmd(deepLink).Execute(); err != nil { fmt.Fprintln(os.Stderr, "error:", err) if crashLog != nil { fmt.Fprintf(crashLog, "%s error: %v\n", time.Now().Format(time.RFC3339), err) @@ -68,7 +78,19 @@ func installCrashLog() *os.File { return f } -func newRootCmd() *cobra.Command { +// deepLinkArg returns the pxf:// pairing link if args represent an OS protocol +// launch — the app was handed a single pxf:// URL and nothing else. A subcommand +// (including `pair `, which also carries a pxf:// string as its own argument) +// has more than one element or a non-URL first token, so it is never mistaken for a +// deep link. +func deepLinkArg(args []string) string { + if len(args) != 1 || !link.IsPairingURL(args[0]) { + return "" + } + return strings.TrimSpace(args[0]) +} + +func newRootCmd(deepLink string) *cobra.Command { root := &cobra.Command{ Use: "proxyforward", Short: "ngrok-style reverse tunnel for Minecraft servers behind NAT", @@ -76,7 +98,7 @@ func newRootCmd() *cobra.Command { SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { - return runGUI() + return runGUI(deepLink) }, } root.AddCommand( @@ -396,7 +418,7 @@ func newFirewallCmd() *cobra.Command { return cmd } -func runGUI() error { +func runGUI(deepLink string) error { configPath := config.DefaultPath(false) cfg, err := config.Load(configPath) if err != nil { @@ -423,6 +445,11 @@ func runGUI() error { } a := app.New(configPath, cfg, ring, logger) + // A cold start via the OS protocol handler: the frontend isn't listening yet, + // so stash the link now for it to pull once it mounts (App.TakePendingDeepLink). + if deepLink != "" { + a.HandleDeepLink(deepLink) + } // Wails reports webview/runtime failures through its own logger and can // os.Exit without returning an error; in a windowsgui process that output // is invisible, so route it to a file next to the crash log. @@ -433,6 +460,15 @@ func runGUI() error { Height: 820, MinWidth: 1280, MinHeight: 760, + // One GUI window owns the pairing flow: a second launch (e.g. clicking + // another pxf:// link) is forwarded here and exits, so the link always + // lands in the running window instead of opening a duplicate. + SingleInstanceLock: &options.SingleInstanceLock{ + UniqueId: "proxyforward-gui-single-instance", + OnSecondInstanceLaunch: func(data options.SecondInstanceData) { + a.OnSecondInstance(deepLinkArg(data.Args)) + }, + }, // The window is frameless: the frontend draws its own title bar and // window controls. DWM decorations stay on (default) so the frameless // window keeps its drop shadow, rounded corners and Snap Layouts. diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..49b4cad --- /dev/null +++ b/main_test.go @@ -0,0 +1,24 @@ +package main + +import "testing" + +// TestDeepLinkArg pins how a pxf:// protocol launch is told apart from a normal CLI +// invocation. The OS hands the app the link as its sole argument; a subcommand (even +// `pair `, which also carries a pxf:// string) must never be mistaken for one. +func TestDeepLinkArg(t *testing.T) { + if got := deepLinkArg([]string{"pxf://gw:8474/v1/pair/tok#sha256:x"}); got == "" { + t.Error("a lone pxf:// argument should be treated as a deep link") + } + if got := deepLinkArg([]string{" pxf://gw:8474/v1/pair/tok "}); got == "" { + t.Error("a whitespace-padded pxf:// argument should still be a deep link") + } + if got := deepLinkArg([]string{"pair", "pxf://gw:8474/v1/pair/tok"}); got != "" { + t.Errorf("the pair subcommand must not be read as a deep link, got %q", got) + } + if got := deepLinkArg([]string{"gateway"}); got != "" { + t.Errorf("a subcommand is not a deep link, got %q", got) + } + if got := deepLinkArg(nil); got != "" { + t.Errorf("no arguments is not a deep link, got %q", got) + } +} diff --git a/wails.json b/wails.json index b238d05..0d467d4 100644 --- a/wails.json +++ b/wails.json @@ -14,6 +14,13 @@ "companyName": "xeri", "productName": "proxyforward", "copyright": "© xeri", - "comments": "Created by xeri — https://github.com/xeri" + "comments": "Created by xeri — https://github.com/xeri", + "protocols": [ + { + "scheme": "pxf", + "description": "proxyforward pairing link", + "role": "Viewer" + } + ] } }