From d15dc83130d8517dda4a4ce1a76cae6bd582bf94 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:21:55 +1200 Subject: [PATCH 01/45] wip: v2 frontend (glass pass) + supporting tooling Checkpoint of in-progress work predating the honesty-pass commits: the glass design system (styles/*, DESIGN.md, component/screen restyle), the RoleSwitcher, the scroll rubber band, the ui-design-reviewer agent, doc updates, CI/version bumps, and dependency refreshes. Committed as a baseline so the honesty-pass features land as clean, separate commits on top. Reword or split as you like. --- .claude/agents/ui-design-reviewer.md | 53 +++ .claude/rules/frontend.md | 16 +- .claude/settings.json | 4 + .claude/skills/ui-change/SKILL.md | 4 +- .github/workflows/ci.yml | 4 +- CLAUDE.md | 9 +- docs/agent/architecture.md | 15 +- docs/agent/commands.md | 2 +- docs/agent/polish-backlog.md | 71 ++- frontend/DESIGN.md | 32 +- frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/package.json.md5 | 2 +- frontend/src/App.tsx | 10 +- frontend/src/components/BandwidthChart.tsx | 268 ++++++++--- frontend/src/components/CommandPalette.tsx | 26 +- frontend/src/components/ConnectionPill.tsx | 15 +- frontend/src/components/Emblem.tsx | 13 +- frontend/src/components/GeoRank.tsx | 4 + frontend/src/components/RoleSwitcher.tsx | 189 ++++++++ frontend/src/components/ui.tsx | 376 +++++++++++---- frontend/src/devmock.ts | 60 ++- frontend/src/history.ts | 7 + frontend/src/layout/Shell.tsx | 21 +- frontend/src/layout/Sidebar.tsx | 26 +- frontend/src/main.tsx | 4 + frontend/src/rubberband.ts | 505 +++++++++++++++++++++ frontend/src/screens/Activity.tsx | 23 +- frontend/src/screens/Overview.tsx | 60 ++- frontend/src/screens/Players.tsx | 15 +- frontend/src/screens/Settings.tsx | 129 ++++-- frontend/src/screens/Traffic.tsx | 5 +- frontend/src/screens/Tunnels.tsx | 2 +- frontend/src/screens/Wizard.tsx | 10 +- frontend/src/style.css | 2 +- frontend/src/styles/base.css | 14 +- frontend/src/styles/glass.css | 501 +++++++++++++++----- frontend/src/styles/motion.css | 49 +- frontend/src/styles/tokens.css | 165 +++++-- internal/version/version.go | 16 +- 40 files changed, 2264 insertions(+), 474 deletions(-) create mode 100644 .claude/agents/ui-design-reviewer.md create mode 100644 frontend/src/components/RoleSwitcher.tsx create mode 100644 frontend/src/rubberband.ts 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/.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/CLAUDE.md b/CLAUDE.md index d30ce91..a5ccb7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,12 +131,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 diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index 60c608c..1ab3db0 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -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 ``` @@ -92,6 +93,11 @@ persisted to config. | 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` | +| 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 @@ -181,9 +187,12 @@ 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`. 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..33d73a9 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`. 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..79c1377 100644 --- a/docs/agent/polish-backlog.md +++ b/docs/agent/polish-backlog.md @@ -46,70 +46,103 @@ 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). + ## 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/frontend/DESIGN.md b/frontend/DESIGN.md index 69cf449..6b8749c 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -14,17 +14,29 @@ operating a live network. Every design decision is held to these rules: - 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 +50,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..d799d17 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,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 +74,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') @@ -137,7 +140,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,6 +153,7 @@ export default function App() { not-yet-redesigned screens at their designed width; each redesign deletes its own clamp. */}
diff --git a/frontend/src/components/BandwidthChart.tsx b/frontend/src/components/BandwidthChart.tsx index 6d0b3d7..13da70b 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 @@ -41,6 +45,7 @@ export function BandwidthPanel({historyUnsupported, compact = false, hero = fals 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) const spec = RANGES[range] @@ -50,6 +55,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 +79,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. */} +
+ + +
+ )} ) @@ -320,6 +340,47 @@ function toPlots(buckets: Bucket[], bucketMs: number, mode: ChartMode, nowMs: nu }) } +/** zeroBucket synthesizes an all-quiet bucket at slot time t. The app wasn't + * running, so the tunnel relayed zero bytes — but the connection count and RTT + * are genuinely *unknown* for that stretch, so their gauges stay at the -1 + * sentinel (the crosshair reads "0 B/s" yet omits conn/RTT rather than + * inventing a fake 0 — same rule as any unknown gauge). */ +function zeroBucket(t: number): Bucket { + return { + t, in: 0, out: 0, + io: 0, ih: 0, il: 0, ic: 0, + oo: 0, oh: 0, ol: 0, oc: 0, + co: -1, ch: -1, cl: -1, cc: -1, + ro: -1, rh: -1, rl: -1, rc: -1, + po: -1, ph: -1, pl: -1, pc: -1, + lo: -1, lh: -1, ll: -1, lc: -1, + } +} + +/** resolveHover maps a cursor x to the bucket under it: the real bucket whose + * drawn slot the cursor sits in, or a synthetic zero bucket when the cursor is + * over a time the app wasn't recording — a mid-history gap or the empty + * pre-history lead-in before the window filled. So every x on the axis reads + * out, quiet stretches honestly as 0 B/s, instead of snapping the crosshair to + * the nearest moment the app happened to be open. */ +function resolveHover( + hoverX: number, buckets: Bucket[], bucketMs: number, + t0: number, t1: number, span: number, padL: number, plotW: number, + x: (t: number) => 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 +431,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 +448,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 +462,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 +470,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 +490,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 +531,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 +575,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 +635,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 +671,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 +679,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 +705,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 +735,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 +775,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 +822,7 @@ export function BandwidthChart({buckets: rawBuckets, bucketMs: rawBucketMs, mode - {showDn && <> + {showDn && !dnIdle && <> {plots.length === 1 && } } - {showUp && <> + {showUp && !upIdle && <> ))} - {showUp && } + {showUp && !upIdle && } )} @@ -794,6 +897,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..72c812e 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -107,18 +107,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..67d123e 100644 --- a/frontend/src/components/ConnectionPill.tsx +++ b/frontend/src/components/ConnectionPill.tsx @@ -44,11 +44,16 @@ export function ConnectionPill({status}: {status: UIStatus}) { return (
- - {label} + {/* 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} + {up && · {status.rttMillis} ms} {showLoss && ( 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/ui.tsx b/frontend/src/components/ui.tsx index a84bfbb..c7a1da0 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..5ea50ef 100644 --- a/frontend/src/devmock.ts +++ b/frontend/src/devmock.ts @@ -14,6 +14,9 @@ // &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) +// &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,6 +36,7 @@ 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 fx = params.get('fx') if (fx) document.documentElement.dataset.fx = fx // &fx=high | &fx=low @@ -213,6 +217,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 +240,31 @@ 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' 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, 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'], + 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'], tunnels: [{id: tunnelID, name: 'Minecraft', publicPort: state.linkUp ? 25565 : 0, localUp: true, localKnown: true}], connections: state.linkUp ? state.conns : [], 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 +609,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. @@ -622,10 +648,24 @@ export function installDevMock() { 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..b23887f 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 {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,10 +14,11 @@ 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) @@ -33,15 +34,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). */} +
) 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/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/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..df2abcc 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,24 @@ 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) : '') + type SectionDef = {id: string; label: string} /** Settings: a scrollspy rail beside regrouped sections. Engine-affecting @@ -81,7 +99,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) @@ -132,7 +163,7 @@ 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 })} />
@@ -151,29 +182,36 @@ export function Settings({status}: {status: UIStatus}) { patch(c => { c.Gateway.PublicHost = v })} placeholder="play.example.com" />
- patch(c => { c.Gateway.ControlPort = parseInt(v, 10) || 0 })} />
- patch(c => { c.Gateway.BindAddr = v })} /> + patch(c => { c.Gateway.BindAddr = v })} />
)} {!isAgent && (
- - + +
+ + +
Abuse limits
- patch(c => { c.Gateway.MaxConnsGlobal = parseInt(v, 10) || 0 })} /> - patch(c => { c.Gateway.MaxConnsPerIP = parseInt(v, 10) || 0 })} /> - patch(c => { c.Gateway.AuthAttemptsPerMin = parseInt(v, 10) || 0 })} />
@@ -182,7 +220,7 @@ export function Settings({status}: {status: UIStatus}) {
- patch(c => { c.Analytics.RetentionDays = parseInt(v, 10) || 0 })} />
@@ -211,6 +249,12 @@ export function Settings({status}: {status: UIStatus}) {
+ {/* Both cells are Fields, so the two labels share a baseline, and + the switch stands in a box exactly as tall as the Select + (--control-h) so it centers on the dropdown instead of floating + above it. Pairing a Field with a whole Toggle ROW here — label + beside control, nudged by an ad-hoc `items-end pb-1` — is why + nothing lined up. */}
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/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 + ")" } From 628a4a936663d9887627377ac6e5adca30ff3c82 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:23:29 +1200 Subject: [PATCH 02/45] =?UTF-8?q?docs(spec):=20honesty-pass=20design=20?= =?UTF-8?q?=E2=80=94=20wire=20up/stop=20over-advertising=204=20stubs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design of record for closing four Reality-check lies (each deletes its CLAUDE.md row when it lands): stop advertising CapTunnelUDP, correct/hide stub UI copy, implement Prometheus /metrics, and wire the offline MOTD responder into the gateway. Bandwidth cap and multi-agent namespacing are carved out into their own specs. Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-07-15-honesty-pass-design.md | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-honesty-pass-design.md 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. From cf115f39eb2f2ea05fbc3f48aff6e9dc8b6a4f4a Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:23:51 +1200 Subject: [PATCH 03/45] fix(control): stop advertising the unimplemented tunnel-udp capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CapTunnelUDP was in SupportedCapabilities but no UDP relay exists and validateSpec rejects type:"udp" — the gateway would echo the capability back in hello_ok and the agent could act on a udp spec that then dies. A live protocol lie against the "never advertise what isn't implemented end-to-end" invariant. Remove CapTunnelUDP from the shared SupportedCapabilities slice (drops it from both the agent offer and the gateway's accepted intersection in one edit), delete the const, and correct the now-stale comments in control.go and config.go. TestSupportedCapabilities gains a positive guard that "tunnel-udp" is never advertised. No golden-frame impact (capabilities is a live-negotiated omitempty field, absent from the byte-identical compat assertions) and no ProtocolVersion bump (dropping a capability is backward-compatible). Protocol-only change; UDP support is a later lane. Deletes the UDP Reality-check row's oversell framing and the invariant bullet's live-violation note; retires the tunnel-udp counterexample in the wire-protocol skill. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/wire-protocol/SKILL.md | 5 +++-- CLAUDE.md | 6 +++--- internal/config/config.go | 4 ++-- internal/control/control.go | 9 ++------- internal/control/control_test.go | 13 +++++++++---- 5 files changed, 19 insertions(+), 18 deletions(-) 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/CLAUDE.md b/CLAUDE.md index a5ccb7c..5d733fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,8 +61,8 @@ 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 = @@ -155,7 +155,7 @@ The README and the Settings/Tunnels UI **oversell**. Ground truth at 4a8b0c9: |---|---| | 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). | +| 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. | | Bandwidth cap | `BandwidthLimitMbps` stored, never enforced. | | Prometheus `/metrics` | `MetricsConfig` stored, no server exists. | | MC status polling (MOTD/players) | Only login sniffing (`mcsniff/`); the health probe is a bare TCP dial (`health.go probeOnce`). | diff --git a/internal/config/config.go b/internal/config/config.go index 347c98e..4e2260b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,8 +35,8 @@ const ( 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" ) diff --git a/internal/control/control.go b/internal/control/control.go index c69ccd1..5f6f6b3 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -31,11 +31,6 @@ 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 @@ -45,7 +40,7 @@ const ( ) // SupportedCapabilities is everything this build implements, both roles. -var SupportedCapabilities = []string{CapTunnelSync, CapTunnelUDP, CapConnStats} +var SupportedCapabilities = []string{CapTunnelSync, CapConnStats} // IntersectCaps returns offered ∩ supported, preserving supported's order. // Nil-safe on both arguments; unknown offered strings are simply dropped. @@ -180,7 +175,7 @@ type HelloErr struct { 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 diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 775e52e..f53c4bc 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -143,12 +143,17 @@ 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) { 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) From 6bc92ce779064a5d04ea204d0e9cdb2b31fce3de Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:24:25 +1200 Subject: [PATCH 04/45] feat(ui): make honest the Settings/Tunnels stub controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three copy/visibility fixes so the UI stops promising what the backend doesn't do: - Minecraft-aware hint: drop the false "poll for MOTD/player count/version" claim (no status poller exists); keep the true username-sniffing half. - Remove the "Per-connection" transport option the agent never honors, and correct the Transport field hint. - Remove the inert "Minimize to tray" / "Start on login" toggles and their now-empty Behavior section, plus its SectionRail entry (a dangling rail entry would mis-measure the scrollspy). The Offline-MOTD, Bandwidth-cap, and Prometheus controls stay — they become real in the other honesty-pass commits. Per-conn / tray / autostart remain in the Reality-check table (hidden, not implemented). Co-Authored-By: Claude Opus 4.8 --- frontend/src/screens/Settings.tsx | 11 +---------- frontend/src/screens/Tunnels.tsx | 2 +- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/frontend/src/screens/Settings.tsx b/frontend/src/screens/Settings.tsx index df2abcc..9d8aa8f 100644 --- a/frontend/src/screens/Settings.tsx +++ b/frontend/src/screens/Settings.tsx @@ -51,7 +51,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'}, @@ -149,13 +148,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 ? (
@@ -167,10 +159,9 @@ export function Settings({status}: {status: UIStatus}) { onChange={v => patch(c => { c.Agent.GatewayPort = parseInt(v, 10) || 0 })} />
- + {}} options={[{value: 'tcp', label: 'TCP (Java Edition)'}]} /> + 0 + ? 'What the cap applies to: both directions together, each direction, or each connection.' + : 'Set a cap first.' + }> + {}} options={[{value: 'tcp', label: 'TCP (Java Edition)'}]} /> +
diff --git a/frontend/src/state.ts b/frontend/src/state.ts index b1c727d..c4283f3 100644 --- a/frontend/src/state.ts +++ b/frontend/src/state.ts @@ -58,6 +58,18 @@ export function fmtRate(bytesPerSec: number): string { return `${fmtBytes(bytesPerSec)}/s` } +// Bandwidth-cap scope options and their labels — shared by the agent Tunnels +// editor (select + summary chip) and the gateway Agents drill-in. Values match +// config.go's BandwidthScope* constants; empty normalizes to combined. +export const BANDWIDTH_SCOPES = [ + {value: 'combined', label: 'Combined'}, + {value: 'per-direction', label: 'Per-direction'}, + {value: 'per-connection', label: 'Per-connection'}, +] +export function scopeLabel(s: string): string { + return BANDWIDTH_SCOPES.find(o => o.value === (s || 'combined'))?.label ?? 'Combined' +} + export function fmtDuration(ms: number): string { const s = Math.max(0, Math.floor(ms / 1000)) const h = Math.floor(s / 3600) diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 5c1fe80..fb564f5 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -658,6 +658,8 @@ export namespace app { publicPort: number; localUp: boolean; localKnown: boolean; + bandwidthLimitMbps: number; + bandwidthLimitScope: string; static createFrom(source: any = {}) { return new TunnelUI(source); @@ -671,6 +673,8 @@ export namespace app { this.publicPort = source["publicPort"]; this.localUp = source["localUp"]; this.localKnown = source["localKnown"]; + this.bandwidthLimitMbps = source["bandwidthLimitMbps"]; + this.bandwidthLimitScope = source["bandwidthLimitScope"]; } } export class UIStatus { @@ -783,6 +787,7 @@ export namespace config { ProxyProtocolV2: boolean; OfflineMOTD: string; BandwidthLimitMbps: number; + BandwidthLimitScope: string; static createFrom(source: any = {}) { return new TunnelOptions(source); @@ -794,6 +799,7 @@ export namespace config { this.ProxyProtocolV2 = source["ProxyProtocolV2"]; this.OfflineMOTD = source["OfflineMOTD"]; this.BandwidthLimitMbps = source["BandwidthLimitMbps"]; + this.BandwidthLimitScope = source["BandwidthLimitScope"]; } } export class Tunnel { From a54a6d6641b848056981104235c8c480d331f7db Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:51:22 +1200 Subject: [PATCH 24/45] gateway: extract shared-token check behind a Validator seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route a hello's credential through a Validator interface instead of an inline constant-time compare. The v1 sharedTokenValidator preserves the exact behavior — constant-time shared-token check, non-empty agentID, identical HelloErr codes, and auth-limiter accounting (bad token counts, missing agentID does not). A later per-agent-identity validator swaps in without touching the accept paths or the wire; the per-conn data accept path will authenticate through the same seam. No wire change. --- internal/gateway/auth.go | 45 +++++++++++++++++++++++++++++++++++ internal/gateway/auth_test.go | 38 +++++++++++++++++++++++++++++ internal/gateway/gateway.go | 43 ++++++++++++++++++++------------- 3 files changed, 109 insertions(+), 17 deletions(-) create mode 100644 internal/gateway/auth.go create mode 100644 internal/gateway/auth_test.go diff --git a/internal/gateway/auth.go b/internal/gateway/auth.go new file mode 100644 index 0000000..c7a88e6 --- /dev/null +++ b/internal/gateway/auth.go @@ -0,0 +1,45 @@ +package gateway + +import ( + "crypto/subtle" + "errors" + + "proxyforward/internal/control" +) + +// Identity is what a Validator resolves a hello to. Today it carries only the +// self-asserted agentID; a later per-agent-credential validator will bind it to +// a real principal without changing the accept paths or the wire. +type Identity struct { + AgentID string +} + +// Validator authenticates a hello on both the control and data accept paths. +// The credential lives in Hello.Token (an opaque string); v1 checks a shared +// token, so per-agent identity + revocation later is a validator swap plus an +// allowlist, not another handshake rewrite. +type Validator interface { + Validate(hello *control.Hello) (Identity, error) +} + +// Sentinel auth failures. The accept paths map these to HelloErr codes. +var ( + ErrBadToken = errors.New("bad token") + ErrMissingAgentID = errors.New("missing agentId") +) + +// sharedTokenValidator is the v1 authenticator: one token admits every agent, +// told apart only by the self-asserted agentID. +type sharedTokenValidator struct { + token string +} + +func (v sharedTokenValidator) Validate(hello *control.Hello) (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 +} diff --git a/internal/gateway/auth_test.go b/internal/gateway/auth_test.go new file mode 100644 index 0000000..4d9f493 --- /dev/null +++ b/internal/gateway/auth_test.go @@ -0,0 +1,38 @@ +package gateway + +import ( + "errors" + "testing" + + "proxyforward/internal/control" +) + +// 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) + } + }) + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index d9fe0e8..4de41ce 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -10,7 +10,6 @@ package gateway import ( "context" - "crypto/subtle" "crypto/tls" "errors" "fmt" @@ -87,6 +86,11 @@ 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. v1 checks the shared token; + // swapping it in adds per-agent identity + revocation without touching the + // accept paths or the wire. + validator Validator // Conns tracks live proxied connections for the GUI. Conns *conntrack.Registry @@ -150,6 +154,7 @@ 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) + g.validator = sharedTokenValidator{token: g.cfg.Gateway.Token} a := newActor(g.logger) g.actor.Store(a) g.wg.Add(2) @@ -503,13 +508,25 @@ 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) + if err != nil { + // A bad token is a credential failure — log it and count it toward the + // per-IP auth limiter. A missing agentID is a malformed hello, not a + // failed credential, so it does not count. + switch { + case errors.Is(err, ErrBadToken): + 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", + }) + case errors.Is(err, ErrMissingAgentID): + control.WriteMsg(conn, control.TypeHelloErr, control.HelloErr{ + Code: control.ErrCodeBadToken, + Message: "hello is missing agentId", + }) + } conn.Close() return } @@ -522,14 +539,6 @@ func (g *Gateway) handleControlConn(ctx context.Context, conn net.Conn) { conn.Close() return } - if hello.AgentID == "" { - control.WriteMsg(conn, control.TypeHelloErr, control.HelloErr{ - Code: control.ErrCodeBadToken, - Message: "hello is missing agentId", - }) - conn.Close() - return - } // --- Admission: supersede same agentID, admit distinct agents alongside. --- negotiated := control.IntersectCaps(hello.Capabilities, control.SupportedCapabilities) @@ -537,7 +546,7 @@ func (g *Gateway) handleControlConn(ctx context.Context, conn net.Conn) { // splices unblock per-agent (evict) and on shutdown (parent). sctx, cancel := context.WithCancel(ctx) sess := &agentSession{ - agentID: hello.AgentID, + agentID: identity.AgentID, conn: conn, logger: logger.With("agent", hello.AgentID), connectedAt: time.Now(), From bfc75fe0cf2ed5e38b389532dbec73a174f1202b Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:54:10 +1200 Subject: [PATCH 25/45] link: assert X25519MLKEM768 KEM + guard CurvePreferences The gateway<->agent TLS configs never pin CurvePreferences, so Go's default already negotiates the X25519MLKEM768 post-quantum hybrid key exchange. That is the harvest-now-decrypt-later half of PQ and it is free. Add a live-handshake test asserting the negotiated key exchange on both sides, a guard test that both configs leave CurvePreferences nil so a future edit can't silently drop the hybrid, do-not-pin comments, and a numbers-table row. The ECDSA authentication half stays classical (no retroactive risk); per-agent identity + PQ signatures are deferred. --- docs/agent/architecture.md | 1 + internal/link/cert.go | 8 +++- internal/link/pq_test.go | 88 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 internal/link/pq_test.go diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index d027336..71802e8 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -103,6 +103,7 @@ 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` | +| 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) | `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` | 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/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) + } +} From b682775fb1fd6efa7dcfb9018beecca4d661b943 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:57:27 +1200 Subject: [PATCH 26/45] control: add per-conn capability + open_data message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire vocabulary only, no behavior: CapPerConn ("per-conn-data") and the gateway->agent TypeOpenData message with an OpenData{ConnID} payload. The capability is deliberately NOT appended to SupportedCapabilities and the gateway still rejects KindData — advertising a capability before the data accept path exists end-to-end is the same protocol lie the tunnel-udp cap once was. A guard test asserts per-conn-data stays un-advertised until the implementation commit; OpenData gets a framing round-trip. ProtocolVersion unchanged (additive). --- internal/control/control.go | 21 +++++++++++++++++++++ internal/control/control_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/internal/control/control.go b/internal/control/control.go index 0b1af7c..525ac1f 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -37,6 +37,14 @@ const ( // 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" ) // SupportedCapabilities is everything this build implements, both roles. @@ -103,6 +111,10 @@ 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" ) // Hello error codes. @@ -273,6 +285,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 { diff --git a/internal/control/control_test.go b/internal/control/control_test.go index f53c4bc..d7def60 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -152,6 +152,12 @@ func TestSupportedCapabilities(t *testing.T) { if s.Has("tunnel-udp") { t.Fatal("tunnel-udp must not be advertised until it is implemented") } + // per-conn-data is likewise not advertised in this commit: this is the wire + // vocabulary only; the gateway data accept path lands in a later commit. + // Offering it before it works end-to-end would be the same protocol lie. + if s.Has(CapPerConn) { + t.Fatal("per-conn-data must not be advertised until the data plane 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}) @@ -160,6 +166,25 @@ func TestSupportedCapabilities(t *testing.T) { } } +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}}} From 9750a0a47d438f3e9500012eb723c194e1d178d2 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:25:05 +1200 Subject: [PATCH 27/45] gateway,agent: multi-TCP per-conn data plane Implement the reserved per-conn transport: one dedicated agent-dialed TCP+TLS connection per player instead of one shared yamux stream, so a lost segment on the gateway<->agent hop can no longer head-of-line-block every player at once (the one real defect of yamux-over-one-TCP). The control plane is unchanged. When CapPerConn is negotiated the gateway sends open_data{connId} on the control stream; the agent dials back a KindData conn (dialBackData), which the gateway authenticates via the Validator and matches to the waiting player through a lock-free, exactly-once/loser-closes pending registry (perconn.go). Both modes then converge on the identical open_conn + splice tail, so mcsniff, bwcap, and RTT sampling ride unchanged. Data conns resume the control conn's TLS session (shared ClientSessionCache) to avoid a full handshake per join, count into the same link totals, and are drained on eviction alongside the mux (agentSession.dataConns). The gateway advertises CapPerConn only now that it serves the accept path end-to-end; a mux/legacy agent never offers it and rides OpenStream. Tests: per-conn round-trip + byte-counting, eviction drain, a burst-floor twin (clears the floor with tighter cross-stream RTT than mux), the pending-handoff race, and a structural HOL proof (per-conn opens one agent->gateway conn per player; mux stays at one). Deletes the per-conn Reality-check row, updates the SetNoDelay invariant, and documents the data plane in architecture.md. --- CLAUDE.md | 6 +- docs/agent/architecture.md | 14 +++ internal/agent/agent.go | 129 ++++++++++++++++++++--- internal/control/control.go | 2 +- internal/control/control_test.go | 8 +- internal/e2e/e2e_test.go | 114 +++++++++++++++++++- internal/e2e/perconn_test.go | 173 +++++++++++++++++++++++++++++++ internal/gateway/actor.go | 16 ++- internal/gateway/gateway.go | 67 +++++++++--- internal/gateway/perconn.go | 123 ++++++++++++++++++++++ internal/gateway/perconn_test.go | 97 +++++++++++++++++ 11 files changed, 701 insertions(+), 48 deletions(-) create mode 100644 internal/e2e/perconn_test.go create mode 100644 internal/gateway/perconn.go create mode 100644 internal/gateway/perconn_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 137164b..73f42c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,8 +117,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). @@ -160,7 +161,6 @@ The README and the Settings/Tunnels UI **oversell**. Ground truth at 4a8b0c9: | Feature (advertised in README/UI) | Actual state | |---|---| -| `per-conn` transport | Config-valid only; agent never reads it, gateway rejects `KindData` (`handleControlConn`). | | 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. | diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index 71802e8..0683d4c 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -82,6 +82,7 @@ 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` (`BufSize` / `WriteStallTimeout`) | @@ -126,6 +127,19 @@ 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`. + ## Analytics data model (`internal/analytics/schema.go`) - `rrd(tier,t,…)` — persisted image of stats tiers 2–4 (28 OHLC/gauge columns; −1 = diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 9a55695..42aae8d 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -103,8 +103,8 @@ 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 // Conns tracks live proxied connections for the GUI. @@ -113,17 +113,43 @@ type Agent struct { // 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 } func New(cfg *config.Config, logger *slog.Logger) *Agent { - return &Agent{cfg: cfg, logger: logger, Conns: conntrack.NewRegistry(), bwLimiters: bwcap.NewRegistry()} + return &Agent{ + cfg: cfg, + logger: logger, + Conns: conntrack.NewRegistry(), + bwLimiters: bwcap.NewRegistry(), + sessionCache: tls.NewLRUClientSessionCache(0), + } } // 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 } +// defaultOffer is the capability set a normally-configured agent offers. It is +// transport-independent (tunnel-sync + conn-stats) plus per-conn-data only when +// this agent is configured for the per-conn transport — deliberately NOT +// control.SupportedCapabilities, which would offer per-conn regardless of +// config and force it on every agent. +func (a *Agent) defaultOffer() []string { + caps := []string{control.CapTunnelSync, control.CapConnStats} + if a.cfg.Agent.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) @@ -270,25 +296,38 @@ func (a *Agent) Run(ctx context.Context) error { } } -// 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)) + cfg := link.AgentTLSConfig(a.cfg.Agent.CertFingerprint) + cfg.ClientSessionCache = a.sessionCache + return tls.Client(rawConn, cfg), nil +} + +// runSession performs one full connect → serve cycle and returns why the +// session ended. +func (a *Agent) runSession(ctx context.Context) error { + conn, err := a.dialGateway(ctx) + if err != nil { + return err + } defer conn.Close() // Hello exchange, pre-mux, under one deadline. offer := a.offerCaps if offer == nil { - offer = control.SupportedCapabilities + offer = a.defaultOffer() } conn.SetDeadline(time.Now().Add(helloTimeout)) hn, _ := os.Hostname() @@ -318,7 +357,7 @@ func (a *Agent) runSession(ctx context.Context) error { } 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) + a.logger.Info("connected to gateway", "gateway", conn.RemoteAddr().String(), "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 { @@ -357,7 +396,7 @@ func (a *Agent) runSession(ctx context.Context) error { } defer ctrl.Close() - sess := &session{agent: a, mux: mux, ctrl: ctrl, caps: caps, quality: linkquality.New(lossWindow)} + 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) @@ -396,6 +435,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. @@ -624,6 +668,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 @@ -631,8 +685,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) @@ -699,6 +755,45 @@ func (s *session) handleDataStream(st transport.Stream) { 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)) + 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, + }) + 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/control/control.go b/internal/control/control.go index 525ac1f..8a74a7c 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -48,7 +48,7 @@ const ( ) // SupportedCapabilities is everything this build implements, both roles. -var SupportedCapabilities = []string{CapTunnelSync, CapConnStats} +var SupportedCapabilities = []string{CapTunnelSync, CapConnStats, CapPerConn} // IntersectCaps returns offered ∩ supported, preserving supported's order. // Nil-safe on both arguments; unknown offered strings are simply dropped. diff --git a/internal/control/control_test.go b/internal/control/control_test.go index d7def60..aff6cb6 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -143,7 +143,7 @@ func TestCapSet(t *testing.T) { func TestSupportedCapabilities(t *testing.T) { s := NewCapSet(SupportedCapabilities) - if !s.Has(CapTunnelSync) || !s.Has(CapConnStats) { + if !s.Has(CapTunnelSync) || !s.Has(CapConnStats) || !s.Has(CapPerConn) { t.Fatalf("supported set missing a built-in capability: %v", SupportedCapabilities) } // tunnel-udp is deliberately NOT advertised: it isn't implemented @@ -152,12 +152,6 @@ func TestSupportedCapabilities(t *testing.T) { if s.Has("tunnel-udp") { t.Fatal("tunnel-udp must not be advertised until it is implemented") } - // per-conn-data is likewise not advertised in this commit: this is the wire - // vocabulary only; the gateway data accept path lands in a later commit. - // Offering it before it works end-to-end would be the same protocol lie. - if s.Has(CapPerConn) { - t.Fatal("per-conn-data must not be advertised until the data plane 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}) diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index b76992f..b317cfc 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" @@ -101,6 +102,13 @@ type harnessOpts struct { // bandwidthMbps/bandwidthScope cap the default tunnel (0 = uncapped). bandwidthMbps int bandwidthScope string + // transport selects the agent's data-plane transport (config.TransportMux + // or config.TransportPerConn); empty leaves the config default (mux). + 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 } func newHarness(t *testing.T, localAddr string) *harness { @@ -135,10 +143,22 @@ 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.transport != "" { + agentCfg.Agent.Transport = opts.transport + } agentCfg.Agent.Tunnels = []config.Tunnel{{ ID: tunnelID, Name: "test", @@ -223,6 +243,35 @@ 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)) +} + // 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) { @@ -831,6 +880,42 @@ func TestEvictionIsolatesAndDrains(t *testing.T) { 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") + } +} + // --- Bandwidth cap --- // // These assert the average download throughput over ~1s (far steadier than the @@ -1035,14 +1120,35 @@ func TestBadTokenRejected(t *testing.T) { // 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})) +} + +// 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/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/gateway/actor.go b/internal/gateway/actor.go index 5fcde21..505ee7d 100644 --- a/internal/gateway/actor.go +++ b/internal/gateway/actor.go @@ -72,6 +72,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. @@ -113,12 +121,18 @@ 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. func (a *agentSession) closeAll() { if s := a.session(); s != nil { s.Close() } 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. diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 4de41ce..93378bb 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -99,9 +99,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 } @@ -530,11 +536,18 @@ func (g *Gateway) handleControlConn(ctx context.Context, conn net.Conn) { conn.Close() return } - if hello.Kind != control.KindControl { - // KindData arrives with the per-conn transport mode (milestone 5). + 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 + default: control.WriteMsg(conn, control.TypeHelloErr, control.HelloErr{ Code: control.ErrCodeVersion, - Message: fmt.Sprintf("connection kind %q not supported yet", hello.Kind), + Message: fmt.Sprintf("connection kind %q not supported", hello.Kind), }) conn.Close() return @@ -906,13 +919,6 @@ func (g *Gateway) handleClient(pl *publicListener, clientConn net.Conn) { g.serveOffline(clientConn, spec) return } - stream, err := mux.OpenStream() - if err != nil { - sess.logger.Debug("open stream for client failed", "client", clientConn.RemoteAddr().String(), "err", err) - g.serveOffline(clientConn, spec) - return - } - defer stream.Close() tcp, ok := clientConn.(*net.TCPConn) if !ok { return @@ -920,19 +926,50 @@ func (g *Gateway) handleClient(pl *publicListener, clientConn net.Conn) { // 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(sess.agentID, spec.ID, spec.Name, clientConn.RemoteAddr().String(), connID, true) defer closeEntry() + // Acquire the data leg. 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. + var stream relay.Conn + if sess.Has(control.CapPerConn) { + raw := g.openDataConn(sess, connID) + if raw == nil { + g.serveOffline(clientConn, spec) + return + } + rc, ok := raw.(relay.Conn) + if !ok { + raw.Close() + return + } + sess.dataConns.Store(connID, raw) + defer sess.dataConns.Delete(connID) + stream = rc + } else { + st, err := mux.OpenStream() + if err != nil { + sess.logger.Debug("open stream for client failed", "client", clientConn.RemoteAddr().String(), "err", err) + g.serveOffline(clientConn, spec) + return + } + stream = st + } + 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 } 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 + } + } +} From 670a98ea0eae109e09648640ca82280562755884 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:27:06 +1200 Subject: [PATCH 28/45] ui: offer the per-connection transport now that it works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Transport selector only offered mux, and its hint claimed all player traffic is multiplexed over one connection — no longer true now that the per-conn data plane is real. Add the per-connection option (value matches config transport "per-conn") and reword the hint to describe the trade-off honestly: one shared connection vs a dedicated connection per player that keeps one player's loss from stalling the others. Existing Select/Field kit, no new surface. --- frontend/src/screens/Settings.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/screens/Settings.tsx b/frontend/src/screens/Settings.tsx index 9d8aa8f..257980c 100644 --- a/frontend/src/screens/Settings.tsx +++ b/frontend/src/screens/Settings.tsx @@ -159,9 +159,10 @@ export function Settings({status}: {status: UIStatus}) { onChange={v => patch(c => { c.Agent.GatewayPort = parseInt(v, 10) || 0 })} />
- + patch(c => { c.Agent.Transport = v })} options={[ - {value: 'mux', label: 'Multiplexed (default) — one connection'}, + {value: 'auto', label: 'Automatic (recommended) — prefer QUIC, fall back'}, + {value: 'quic', label: 'QUIC — one connection, no head-of-line blocking'}, {value: 'per-conn', label: 'Per-connection — one per player'}, + {value: 'mux', label: 'Multiplexed — one shared connection'}, ]} /> + {status.linkUp && status.transport && ( +
+ Currently connected via {TRANSPORT_LABEL[status.transport] ?? status.transport} +
+ )}
) : ( diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index fb564f5..dd82181 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -687,6 +687,7 @@ export namespace app { linkUp: boolean; rttMillis: number; agentConnected: boolean; + transport: string; jitterMillis: number; packetLossPct: number; healthScore: string; @@ -730,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"]; @@ -957,6 +959,7 @@ export namespace config { MaxConnsGlobal: number; MaxConnsPerIP: number; AuthAttemptsPerMin: number; + QUICEnabled: boolean; static createFrom(source: any = {}) { return new GatewayConfig(source); @@ -972,6 +975,7 @@ export namespace config { this.MaxConnsGlobal = source["MaxConnsGlobal"]; this.MaxConnsPerIP = source["MaxConnsPerIP"]; this.AuthAttemptsPerMin = source["AuthAttemptsPerMin"]; + this.QUICEnabled = source["QUICEnabled"]; } } export class Config { From 645de6476715aae59f843d13218f263aeb25030e Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:27:31 +1200 Subject: [PATCH 39/45] docs: transport, identity, and gateway-config architecture Claude-Session: https://claude.ai/code/session_01EvsrY4Tq6uZCkAqm9gacMe --- CLAUDE.md | 15 ++++++++---- docs/agent/architecture.md | 44 ++++++++++++++++++++++++++++++++++-- docs/agent/polish-backlog.md | 23 ++++++++++++++++--- 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 73f42c2..573c858 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,11 +92,16 @@ 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`). diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index 0683d4c..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 @@ -85,6 +85,9 @@ persisted to config. | 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` | +| 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` | @@ -105,7 +108,7 @@ persisted to config. | 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` | | 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) | `e2e_test.go:716,719` | +| 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` | @@ -120,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 @@ -140,6 +156,30 @@ TCP). Data conns resume the control conn's TLS session (`dialGateway` shares an `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 = diff --git a/docs/agent/polish-backlog.md b/docs/agent/polish-backlog.md index 79c1377..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). @@ -122,6 +122,23 @@ Ordered by user-visible impact. "Fix" describes the smallest on-system change. 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 19. **Gateway jitter/loss comment drift** — `ipc.go:101-103` says "the gateway From d0057434e818e23777eded4181aa1a8364ccf60b Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:40:57 +1200 Subject: [PATCH 40/45] link,ui: pxf:// pairing scheme with format-version + role marker Replace pf1:// with pxf://host:port/v1/pair/#sha256:. The /v1/ segment versions the code's shape independently of the wire ProtocolVersion, and /pair/ is a role marker so a wrong-kind or stale-format link fails loudly instead of half-parsing. Bound the input before parsing (a pxf:// deep link is attacker-reachable) and add a FuzzParsePairingCode target. pxf doubles as the OS deep-link scheme, wired up next. Claude-Session: https://claude.ai/code/session_01EvsrY4Tq6uZCkAqm9gacMe --- .github/workflows/fuzz.yml | 1 + CLAUDE.md | 5 +- README.md | 2 +- frontend/src/screens/Wizard.tsx | 6 +- internal/link/link_test.go | 95 +++++++++++++++++++++++++++--- internal/link/pairing.go | 73 +++++++++++++++++++---- internal/link/pairing_fuzz_test.go | 48 +++++++++++++++ 7 files changed, 202 insertions(+), 28 deletions(-) create mode 100644 internal/link/pairing_fuzz_test.go 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 573c858..ec49b00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,8 +67,9 @@ Each entry: the rule, why, and the symbol that embodies it today. Numbers live i ### 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 diff --git a/README.md b/README.md index 5a140f8..39a1c47 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ 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*). diff --git a/frontend/src/screens/Wizard.tsx b/frontend/src/screens/Wizard.tsx index 3fbe1d0..ff66020 100644 --- a/frontend/src/screens/Wizard.tsx +++ b/frontend/src/screens/Wizard.tsx @@ -143,8 +143,8 @@ export function Wizard({status, onDone}: {status: UIStatus | null; onDone: () => router and firewall changes happen on the gateway side only.
- - + + {pairing.trim() && ( parsed @@ -376,7 +376,7 @@ function Panel({children}: {children: React.ReactNode}) { * authoritative validation on submit. */ function parsePairing(s: string): {host: string; port: number} | null { const t = s.trim() - const m = /^pf1:\/\/(\[[^\]]+\]|[^/:]+):(\d+)\/([^#]+)#sha256:[0-9a-fA-F]{64}$/.exec(t) + const m = /^pxf:\/\/(\[[^\]]+\]|[^/:]+):(\d+)\/v1\/pair\/([^/#]+)#sha256:[0-9a-fA-F]{64}$/.exec(t) if (!m) return null const port = parseInt(m[2], 10) if (port < 1 || port > 65535) return null 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) + } + }) +} From 50c3b07fc27e3e628e8bbb1cf1fbb74b51c68ad4 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:41:33 +1200 Subject: [PATCH 41/45] app,main: click-to-pair deep link + single-instance forwarding Register the pxf protocol (wails.json) so a clicked pairing link launches the app; pull the pxf:// URL out of os.Args before cobra parses it (else it looks like an unknown subcommand) and route it into pairing. A cold protocol launch stashes the link for the frontend to drain on mount (TakePendingDeepLink); a warm one emits a pxf:deeplink event and surfaces the window. SingleInstanceLock forwards a second launch's link to the running window rather than duplicating it. Regen bindings. Claude-Session: https://claude.ai/code/session_01EvsrY4Tq6uZCkAqm9gacMe --- app/app.go | 51 ++++++++++++++++++++++++++++++++ app/app_test.go | 17 +++++++++++ frontend/src/devmock.ts | 5 +++- frontend/wailsjs/go/app/App.d.ts | 6 ++++ frontend/wailsjs/go/app/App.js | 12 ++++++++ frontend/wailsjs/go/models.ts | 4 +++ main.go | 44 ++++++++++++++++++++++++--- main_test.go | 24 +++++++++++++++ wails.json | 9 +++++- 9 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 main_test.go diff --git a/app/app.go b/app/app.go index 3bd10cc..f6fcadc 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() 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/frontend/src/devmock.ts b/frontend/src/devmock.ts index f29069a..da9b386 100644 --- a/frontend/src/devmock.ts +++ b/frontend/src/devmock.ts @@ -787,7 +787,10 @@ 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'), + // 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)), diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 10e0d5d..10978de 100644 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -36,6 +36,8 @@ 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; @@ -44,6 +46,8 @@ export function LogsSince(arg1:number):Promise>; export function MeasureLatency():Promise; +export function OnSecondInstance(arg1:string):Promise; + export function OpenConfigDir():Promise; export function OpenCreatorURL():Promise; @@ -88,6 +92,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 066f72f..77383bb 100644 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -58,6 +58,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); } @@ -74,6 +78,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'](); } @@ -162,6 +170,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 dd82181..13b4e9c 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -854,6 +854,7 @@ export namespace config { CertFingerprint: string; Transport: string; Tunnels: Tunnel[]; + EnrollTicket: string; static createFrom(source: any = {}) { return new AgentConfig(source); @@ -868,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 { @@ -960,6 +962,7 @@ export namespace config { MaxConnsPerIP: number; AuthAttemptsPerMin: number; QUICEnabled: boolean; + AcceptSharedToken: boolean; static createFrom(source: any = {}) { return new GatewayConfig(source); @@ -976,6 +979,7 @@ export namespace config { this.MaxConnsPerIP = source["MaxConnsPerIP"]; this.AuthAttemptsPerMin = source["AuthAttemptsPerMin"]; this.QUICEnabled = source["QUICEnabled"]; + this.AcceptSharedToken = source["AcceptSharedToken"]; } } export class Config { 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" + } + ] } } From de0bfd04167f1784aa5e379cdb61e0e397944628 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:41:43 +1200 Subject: [PATCH 42/45] gateway: auto-reassign clashing public ports and flag suspected clones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A public-port clash no longer fails the tunnel: bindLocked reassigns it to a policy-valid free port (scope/allowlist honored, else an OS ephemeral) and keeps the requested spec so a later reconcile is a no-op and the port stays put. A rapid same-agentID contest from two IPs — the fingerprint of a cloned identity key — is superseded as before but also flagged. Both surface through a bounded gateway event ring (Gateway.Events) the GUI polls, and TunnelSnapshot now carries the requested port so a reassignment is visible. TestConcurrentPortRace asserts the new both-come-up contract. Claude-Session: https://claude.ai/code/session_01EvsrY4Tq6uZCkAqm9gacMe --- internal/e2e/e2e_test.go | 31 +++-- internal/gateway/actor.go | 135 +++++++++++++++++++--- internal/gateway/conflict.go | 134 ++++++++++++++++++++++ internal/gateway/conflict_test.go | 184 ++++++++++++++++++++++++++++++ internal/gateway/gateway.go | 19 ++- internal/portowner/portowner.go | 7 ++ 6 files changed, 479 insertions(+), 31 deletions(-) create mode 100644 internal/gateway/conflict.go create mode 100644 internal/gateway/conflict_test.go diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index 9c526a3..a1fb751 100644 --- a/internal/e2e/e2e_test.go +++ b/internal/e2e/e2e_test.go @@ -882,8 +882,10 @@ func TestTwoAgentsSameTunnelID(t *testing.T) { roundTrip(t, addrB, []byte("B's tunnel")) } -// TestConcurrentPortRace (T2): two agents race to bind the same public port; -// exactly one wins (global FCFS) and keeps serving, the other never binds it. +// 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() @@ -901,22 +903,29 @@ func TestConcurrentPortRace(t *testing.T) { agB := h.addAgent(t, config.NewID(), []config.Tunnel{tcpTunnel(bTun, echoAddr, port)}) agC := h.addAgent(t, config.NewID(), []config.Tunnel{tcpTunnel(cTun, echoAddr, port)}) + var pB, pC int + var bOK, cOK bool deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { - _, bOK := agB.TunnelPublicPort(bTun) - _, cOK := agC.TunnelPublicPort(cTun) - if bOK || cOK { + pB, bOK = agB.TunnelPublicPort(bTun) + pC, cOK = agC.TunnelPublicPort(cTun) + if bOK && cOK { break } time.Sleep(20 * time.Millisecond) } - time.Sleep(500 * time.Millisecond) // let the loser's bind definitively fail - _, bOK := agB.TunnelPublicPort(bTun) - _, cOK := agC.TunnelPublicPort(cTun) - if bOK == cOK { - t.Fatalf("exactly one agent must win port %d; bOK=%v cOK=%v", port, bOK, cOK) + if !bOK || !cOK { + t.Fatalf("both agents should come up (one wins %d, the other is reassigned); bOK=%v cOK=%v", port, bOK, cOK) } - roundTrip(t, fmt.Sprintf("127.0.0.1:%d", port), []byte("winner serves")) + 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 diff --git a/internal/gateway/actor.go b/internal/gateway/actor.go index 0609d90..f7eb7d1 100644 --- a/internal/gateway/actor.go +++ b/internal/gateway/actor.go @@ -186,6 +186,9 @@ type actor struct { 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 } @@ -202,6 +205,15 @@ type dampState struct { // 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, @@ -210,6 +222,7 @@ func newActor(logger *slog.Logger) *actor { agents: make(map[string]*agentSession), listeners: make(map[string]map[string]*publicListener), admitDamp: make(map[string]dampState), + events: newEventRing(eventRingCap), } } @@ -260,6 +273,22 @@ func (a *actor) do(fn func()) bool { } } +// 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 @@ -274,11 +303,27 @@ func (a *actor) admit(sess *agentSession) (uint64, error) { ) ran := a.do(func() { if incumbent, ok := a.agents[sess.agentID]; ok { - if d := a.admitDamp[sess.agentID]; d.penalty > 0 && time.Since(d.lastSupersedeAt) < d.penalty { + 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) @@ -301,12 +346,11 @@ func (a *actor) admit(sess *agentSession) (uint64, error) { // everything decays by timestamp comparison. func (a *actor) noteSupersede(agentID string) { const ( - flapThreshold = 2 * time.Second // supersedes closer than this look like a flap - basePenalty = 1 * time.Second - capPenalty = 30 * time.Second + basePenalty = 1 * time.Second + capPenalty = 30 * time.Second ) d := a.admitDamp[agentID] - if !d.lastSupersedeAt.IsZero() && time.Since(d.lastSupersedeAt) < flapThreshold { + if !d.lastSupersedeAt.IsZero() && time.Since(d.lastSupersedeAt) < supersedeFlapWindow { if d.penalty == 0 { d.penalty = basePenalty } else { @@ -360,8 +404,10 @@ func (a *actor) evict(sess *agentSession, reason string) { // 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 is caught by net.Listen (global FCFS). -func (a *actor) bindLocked(sess *agentSession, spec control.TunnelSpec, bindAddr string, handle func(*publicListener, net.Conn)) (int, error) { +// 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() @@ -370,7 +416,28 @@ func (a *actor) bindLocked(sess *agentSession, spec control.TunnelSpec, bindAddr } 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{})} pl.limiters.Store(bwcap.BuildSet(spec.BandwidthLimitMbps, spec.BandwidthLimitScope)) @@ -383,6 +450,37 @@ func (a *actor) bindLocked(sess *agentSession, spec control.TunnelSpec, bindAddr 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) { @@ -401,7 +499,7 @@ func (a *actor) unbindLocked(sess *agentSession, tunnelID string) { // 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(*publicListener, 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 @@ -411,7 +509,7 @@ func (a *actor) bindTunnel(sess *agentSession, spec control.TunnelSpec, bindAddr 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") @@ -439,7 +537,7 @@ 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(*publicListener, 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.agents[sess.agentID] != sess || sess.evicted.Load() { @@ -482,7 +580,7 @@ func (a *actor) reconcile(sess *agentSession, desired []control.TunnelSpec, bind 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}) } }) @@ -532,10 +630,14 @@ func (a *actor) session(agentID string) *agentSession { // 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 + AgentID string // the agent that registered this tunnel + ID string + Name string + 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) @@ -554,6 +656,7 @@ func (a *actor) tunnels() []TunnelSnapshot { 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, } 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/gateway.go b/internal/gateway/gateway.go index 2aa895c..0b876a5 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -323,6 +323,17 @@ func (g *Gateway) Tunnels() []TunnelSnapshot { return a.tunnels() } +// 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() @@ -1209,7 +1220,7 @@ func (g *Gateway) bindTunnel(sess *agentSession, spec control.TunnelSpec) (int, 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()} } @@ -1230,7 +1241,7 @@ func (g *Gateway) syncTunnels(sess *agentSession, sync *control.SyncTunnels) con } 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"}) @@ -1293,7 +1304,7 @@ func (g *Gateway) pushConfigOnConnect(sess *agentSession) { 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.handleClient) + 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 @@ -1323,7 +1334,7 @@ func (g *Gateway) adoptProposal(sess *agentSession, proposed []control.TunnelSpe return } valid := g.validSpecs(sess, proposed) - 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 { return // gateway shutting down } 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 { From b4fd9590a019c47b87599f1b83c5fd34e3a67b5f Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:55:21 +1200 Subject: [PATCH 43/45] engine,app: agent-management ops and enrollment-ticket routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the gateway's agent allowlist to the GUI over the analytics op envelope (dual-mode, no new IPC message type): ListAgents (allowlist joined with live sessions), IssuePairingCode (a pxf:// code carrying a fresh enrollment ticket), Revoke/Rename/SetAgentScope, and GatewayEvents (the conflict/auto-fix ring). Enrollment tickets now carry a tkt_ prefix so a pasted code is self-describing: SetupAgent routes a ticket to per-identity enrollment and a bare-hex token to legacy shared-token auth. That routing is required — the composite validator treats a failed enrollment as definitive and never falls back to the token. Regen bindings. Claude-Session: https://claude.ai/code/session_01EvsrY4Tq6uZCkAqm9gacMe --- app/agents.go | 97 +++++++++++++++++++++ app/app.go | 12 ++- frontend/wailsjs/go/app/App.d.ts | 13 +++ frontend/wailsjs/go/app/App.js | 24 ++++++ frontend/wailsjs/go/models.ts | 65 +++++++++++++++ internal/engine/agents_api.go | 139 +++++++++++++++++++++++++++++++ internal/engine/analytics_api.go | 5 ++ internal/gateway/agentstore.go | 9 +- internal/gateway/gateway.go | 65 +++++++++++++++ internal/link/cred.go | 25 ++++++ internal/link/cred_test.go | 27 ++++++ 11 files changed, 475 insertions(+), 6 deletions(-) create mode 100644 app/agents.go create mode 100644 internal/engine/agents_api.go 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/app.go b/app/app.go index f6fcadc..432f632 100644 --- a/app/app.go +++ b/app/app.go @@ -547,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/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 10978de..671fa97 100644 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -3,6 +3,7 @@ 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'; @@ -30,6 +31,8 @@ export function FirewallRepair():Promise; export function FirewallStatus():Promise; +export function GatewayEvents(arg1:number):Promise>; + export function GeoSnapshot(arg1:number):Promise>; export function GeoStatus():Promise; @@ -42,6 +45,10 @@ 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; @@ -70,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; @@ -82,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; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 77383bb..9764e1d 100644 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -46,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); } @@ -70,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); } @@ -126,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); } @@ -150,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); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 13b4e9c..e6239f2 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1030,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/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 c609db7..4a8225f 100644 --- a/internal/engine/analytics_api.go +++ b/internal/engine/analytics_api.go @@ -53,6 +53,11 @@ func (e *Engine) analyticsOp(op string, body json.RawMessage) (json.RawMessage, } 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/gateway/agentstore.go b/internal/gateway/agentstore.go index 37126cb..62c2fd4 100644 --- a/internal/gateway/agentstore.go +++ b/internal/gateway/agentstore.go @@ -12,7 +12,6 @@ package gateway // data path — so the coarse lock is fine. import ( - "crypto/rand" "encoding/hex" "encoding/json" "errors" @@ -23,6 +22,7 @@ import ( "time" "proxyforward/internal/control" + "proxyforward/internal/link" ) const agentStoreFile = "gateway_agents.json" @@ -149,11 +149,10 @@ func (s *AgentStore) save() error { // 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) { - var raw [16]byte - if _, err := rand.Read(raw[:]); err != nil { - return "", fmt.Errorf("generate pairing ticket: %w", err) + ticket, err := link.NewEnrollTicket() + if err != nil { + return "", err } - ticket := hex.EncodeToString(raw[:]) s.mu.Lock() defer s.mu.Unlock() s.nonces[ticket] = enrollNonce{Exp: exp, Reusable: reusable, Scope: scope} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 0b876a5..b203140 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -304,6 +304,71 @@ 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) { diff --git a/internal/link/cred.go b/internal/link/cred.go index bbc04e4..99c915d 100644 --- a/internal/link/cred.go +++ b/internal/link/cred.go @@ -13,6 +13,7 @@ import ( "crypto/sha256" "crypto/x509" "encoding/base32" + "encoding/hex" "encoding/pem" "errors" "fmt" @@ -21,6 +22,30 @@ import ( "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. diff --git a/internal/link/cred_test.go b/internal/link/cred_test.go index 03fce8d..9c94600 100644 --- a/internal/link/cred_test.go +++ b/internal/link/cred_test.go @@ -7,6 +7,33 @@ import ( "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) From 83b022f0210cbf2013499ba9cd430892532ab6a4 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:08:03 +1200 Subject: [PATCH 44/45] @ docs: rewrite README networking for QUIC/multi-TCP/multi-agent Bring the README up to the multi-agent-gateway branch: the three data planes (yamux mux, per-conn multi-TCP, QUIC), the head-of-line-blocking rationale and its burst-floor gate, the auto fallback ladder, per-agent Ed25519 identity/enrollment/scope/revoke, gateway-authoritative config, and self-healing reconnect. Adds mermaid diagrams and a Contributing section covering the CI gates and house style. @ --- README.md | 417 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 351 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 39a1c47..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: `pxf://host:8474/v1/pair/…#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) + + From d1dbe78c43dacd2ba49a8a0dd9f9a0f8886501f9 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:10:29 +1200 Subject: [PATCH 45/45] ui: multi-agent enrollment codes + agents dashboard drill-in wizard issues single-use/reusable enrollment tickets (IssuePairingCode) instead of the shared PairingCode; Agents roster gains per-agent drill-in and conflict cards; devmock adds the matching fleet fixtures. --- frontend/src/devmock.ts | 165 ++++++++- frontend/src/screens/Agents.tsx | 636 ++++++++++++++++++++++++++------ frontend/src/screens/Wizard.tsx | 47 ++- 3 files changed, 731 insertions(+), 117 deletions(-) diff --git a/frontend/src/devmock.ts b/frontend/src/devmock.ts index da9b386..ab4628d 100644 --- a/frontend/src/devmock.ts +++ b/frontend/src/devmock.ts @@ -17,8 +17,21 @@ // &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 +// 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) @@ -43,7 +56,8 @@ export function installDevMock() { 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 + 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 @@ -288,6 +302,118 @@ export function installDevMock() { 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', @@ -788,6 +914,41 @@ export function installDevMock() { BrowseMMDB: (title: string) => ok(/asn/i.test(title) ? 'C:\\maxmind\\GeoLite2-ASN.mmdb' : 'C:\\maxmind\\GeoLite2-City.mmdb'), GetConfig: () => ok(config), 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(''), diff --git a/frontend/src/screens/Agents.tsx b/frontend/src/screens/Agents.tsx index 63a7c63..e77581a 100644 --- a/frontend/src/screens/Agents.tsx +++ b/frontend/src/screens/Agents.tsx @@ -1,22 +1,31 @@ -import {useEffect, useMemo, useState} from 'react' -import {AgentBandwidthHistory} from '../../wailsjs/go/app/App' -import {app} from '../../wailsjs/go/models' +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, Card, CopyIcon, EmptyState, Overline, PageHeader, SegmentedControl, StatusDot, TextInput, + 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, - IconSearch, IconServer, IconTunnels, IconUsers, + 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). @@ -31,15 +40,42 @@ const lossTone = (v: number): Seg => (v < 0 ? 'unknown' : v > 5 ? 'bad' : v > 1 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 traffic = (a: AgentUI): number => a.linkBytesIn + a.linkBytesOut +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. @@ -52,67 +88,153 @@ function agentHistorySource(agentId: string) { } } +// 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 health cards, the - * gateway analogue of the Players wall of faces. Selecting one drills into a - * focused per-agent view (identity, health, its own bandwidth graph, tunnels, - * sessions) without leaving this screen. One identity surface; the cards are - * frost, never Signal Glass — N equal cards can't each answer the pointer. + * 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 agents = status.agents - const list = agents ?? [] + 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) + } - // A selected agent that disconnects while we're looking at it drops us back to - // the roster rather than stranding an empty detail. - useEffect(() => { - if (selected && !list.some(a => a.agentId === selected)) setSelected(null) - }, [selected, list]) + 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 rows = list.filter(a => - !f || a.hostname.toLowerCase().includes(f) || a.agentId.toLowerCase().includes(f)) - return rows.sort((a, b) => - sort === 'name' ? a.hostname.localeCompare(b.hostname) - : sort === 'players' ? b.players - a.players || traffic(b) - traffic(a) - : sort === 'traffic' ? traffic(b) - traffic(a) - : HEALTH_RANK[seg(a)] - HEALTH_RANK[seg(b)] || traffic(b) - traffic(a)) - }, [list, sort, query]) - - // Honest-unavailable: an older background service predates per-agent status, - // so it sends no roster at all — told apart from a gateway with zero agents. - if (agents === undefined) { + 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="The background service is an older version that doesn't report per-agent status. Update it to see the fleet." /> + 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 ? list.find(a => a.agentId === selected) ?? null : null - if (current) return setSelected(null)} /> + 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 n = list.length + const dismiss = (seq: number) => setDismissed(prev => new Set(prev).add(seq)) + const n = rows.length return (
- {n === 0 ? ( - } title="No agents connected" - hint="Share your pairing code from Overview. Every agent that dials in with it appears here." /> + + + {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." /> ) : ( <> - +
@@ -127,7 +249,7 @@ export function Agents({status}: {status: UIStatus}) {
} value={query} onChange={setQuery} - placeholder="Filter agents…" ariaLabel="Filter agents by hostname" + placeholder="Filter agents…" ariaLabel="Filter agents by name, host, or id" />
)} @@ -143,43 +265,60 @@ export function Agents({status}: {status: UIStatus}) { hint={`Nothing matches "${query.trim()}".`} /> ) : density === 'grid' ? (
- {sorted.map(a => setSelected(a.agentId)} />)} + {sorted.map(r => setSelected(r.view.agentId)} />)}
) : ( - + )} )} + +
) } function Header() { - return + return } -/** FleetSummary: the page's one hero figure (agent count) with the fleet's - * worst-of health and the rolled-up tunnel/player totals — type on whitespace, - * never another card (DESIGN rule 5). Only rendered for a non-empty fleet. */ -function FleetSummary({agents}: {agents: AgentUI[]}) { - const n = agents.length - const worst = worstHealth(agents) - const tunnels = agents.reduce((s, a) => s + a.tunnels, 0) - const players = agents.reduce((s, a) => s + a.players, 0) +/** 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 + {n === 1 ? 'agent' : 'agents'}
- + {online > 0 + ? + : } + {online} online + {revoked > 0 && {revoked} revoked} {tunnels} tunnel{tunnels === 1 ? '' : 's'} - {players} player{players === 1 ? '' : 's'} online + {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'}) { @@ -202,36 +341,59 @@ function Uptime({since}: {since: number}) { return {fmtUptime(Date.now() - since)} } -/** AgentCard: one machine's vital-signs monitor — frost glass that lifts to the - * pointer (tactility, DESIGN rule 3) but never ignites; only the live health dot - * breathes. The whole card is the drill-in affordance. */ -function AgentCard({agent, onOpen}: {agent: AgentUI; onOpen: () => void}) { - const h = seg(agent) +/** 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 ( ) @@ -269,14 +433,162 @@ function AgentList({agents, onOpen}: {agents: AgentUI[]; onOpen: (id: string) => ) } -/** AgentDetail: the drill-in. One machine's identity, its link health, its own - * bandwidth graph (same instrument as Traffic, scoped to this agent's RRD), and - * the tunnels + live sessions attributed to it. */ -function AgentDetail({status, agent, onBack}: {status: UIStatus; agent: AgentUI; onBack: () => void}) { - const h = seg(agent) - const tunnels = (status.tunnels ?? []).filter(t => t.agentId === agent.agentId) - const conns = (status.connections ?? []).filter(c => c.agentId === agent.agentId) - const source = useMemo(() => agentHistorySource(agent.agentId), [agent.agentId]) +/** 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 (
@@ -288,28 +600,27 @@ function AgentDetail({status, agent, onBack}: {status: UIStatus; agent: AgentUI; All agents - {agent.agentId} - + {view.agentId} + ) : undefined} - action={} + 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 — range, - candles, and series controls all reused. It breaks the surrounding - card rhythm so the drill-in isn't a stack of frost panels. The old- - daemon "history unavailable" state is threaded, told apart from the - collecting-data empty state, exactly as Traffic/Overview do it. */} + 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. */}
@@ -320,6 +631,117 @@ function AgentDetail({status, agent, onBack}: {status: UIStatus; agent: AgentUI; ) } +/** 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}) { @@ -327,7 +749,7 @@ function AgentIdentity({agent}: {agent: AgentUI}) { return (
- +
Remote address @@ -351,7 +773,9 @@ function AgentIdentity({agent}: {agent: AgentUI}) {
Link data -
{fmtBytes(agent.linkBytesIn + agent.linkBytesOut)}
+
+ {agent.linkBytesIn + agent.linkBytesOut > 0 ? fmtBytes(agent.linkBytesIn + agent.linkBytesOut) : '—'} +
diff --git a/frontend/src/screens/Wizard.tsx b/frontend/src/screens/Wizard.tsx index ff66020..4dc3dd3 100644 --- a/frontend/src/screens/Wizard.tsx +++ b/frontend/src/screens/Wizard.tsx @@ -1,6 +1,6 @@ import {useEffect, useMemo, useState} from 'react' -import {GetConfig, PairingCode, SetupAgent, SetupGateway} from '../../wailsjs/go/app/App' -import {Button, Codebox, CopyButton, ErrorBanner, Field, Spinner, TextInput} from '../components/ui' +import {GetConfig, IssuePairingCode, SetupAgent, SetupGateway} from '../../wailsjs/go/app/App' +import {Button, Codebox, CopyButton, Disclosure, ErrorBanner, Field, Spinner, TextInput, Toggle} from '../components/ui' import {Emblem} from '../components/Emblem' import {ImportSetupFlow} from '../components/SetupBackup' import {IconCheck, IconGlobe, IconRefresh, IconServer, IconShield, IconSpark} from '../components/icons' @@ -179,22 +179,31 @@ export function Wizard({status, onDone}: {status: UIStatus | null; onDone: () => ) } -/** GatewayLive: the gateway is up — hand over the pairing code and listen for - * the real handshake on the live ticks. */ +/** GatewayLive: the gateway is up — issue a single-use enrollment code, hand it + * over, and listen for the real handshake on the live ticks. Each machine gets a + * fresh code; the reusable toggle (advanced) trades that for a multi-agent code. */ function GatewayLive({status, controlPort, onDone}: { status: UIStatus | null; controlPort: number; onDone: () => void }) { const [code, setCode] = useState('') const [err, setErr] = useState('') + const [busy, setBusy] = useState(false) + const [reusable, setReusable] = useState(false) + const [gen, setGen] = useState(0) + + // (Re)issue the enrollment ticket on mount, whenever the reusable toggle + // flips, and whenever "New code" bumps `gen`. Retries briefly in case the + // gateway's listener isn't ready yet, and cancels cleanly on the next issue. useEffect(() => { let cancelled = false + setBusy(true); setCode(''); setErr('') const poll = (n: number) => { - PairingCode().then(c => { if (!cancelled) setCode(c) }) - .catch(e => { if (!cancelled) { if (n < 20) setTimeout(() => poll(n + 1), 250); else setErr(String(e)) } }) + IssuePairingCode(reusable, 600, [], []).then(c => { if (!cancelled) { setCode(c); setBusy(false) } }) + .catch(e => { if (!cancelled) { if (n < 20) setTimeout(() => poll(n + 1), 250); else { setErr(String(e)); setBusy(false) } } }) } poll(0) return () => { cancelled = true } - }, []) + }, [reusable, gen]) const paired = !!status?.agentConnected return ( @@ -203,13 +212,33 @@ function GatewayLive({status, controlPort, onDone}: { Gateway is live

- Copy this pairing code and paste it into proxyforward on your Minecraft machine: + Copy this one-time pairing code and paste it into proxyforward on your Minecraft machine:

{code ? } /> : err - ? + ? setErr('')} /> :
Generating code…
} +
+ + {reusable + ? 'Reusable — enrolls multiple agents until you revoke it.' + : 'Single-use — enrolls one agent, then expires. Valid for 10 minutes.'} + + +
+ +
+ + + +
+
  1. 1. Open proxyforward on the Minecraft machine.
  2. 2. Choose "This hosts Minecraft".