Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
d15dc83
wip: v2 frontend (glass pass) + supporting tooling
xeri Jul 15, 2026
628a4a9
docs(spec): honesty-pass design — wire up/stop over-advertising 4 stubs
xeri Jul 15, 2026
cf115f3
fix(control): stop advertising the unimplemented tunnel-udp capability
xeri Jul 15, 2026
6bc92ce
feat(ui): make honest the Settings/Tunnels stub controls
xeri Jul 15, 2026
ad15853
feat(engine): implement the Prometheus /metrics endpoint
xeri Jul 15, 2026
c745936
feat(gateway): serve the offline MOTD when a tunnel's backend is down
xeri Jul 15, 2026
2581f88
feat(analytics): attribute connections and history to their agent
xeri Jul 15, 2026
66c4944
feat(stats): per-agent bandwidth histories
xeri Jul 15, 2026
c6ad2cd
feat(engine): sample per-agent bandwidth into the history store
xeri Jul 15, 2026
6773849
feat(analytics): agent-scoped tunnel snapshots and player/session que…
xeri Jul 15, 2026
3378d69
test(analytics): end-to-end agent attribution through the recorder
xeri Jul 15, 2026
02f6cac
feat(gateway): admit multiple agents on one shared token
xeri Jul 15, 2026
d45a94b
feat(engine): expose per-agent status and bandwidth to the GUI
xeri Jul 15, 2026
4bde80a
docs(spec): per-tunnel bandwidth-cap enforcement + scope-selector design
xeri Jul 15, 2026
cf7e61a
feat(ui): gateway Agents dashboard with per-agent drill-in
xeri Jul 15, 2026
03de9a4
test(ui): five-agent gateway devmock fixture (&fleet=multi|old)
xeri Jul 15, 2026
665b893
config: add per-tunnel bandwidth_limit_scope and validate it
xeri Jul 15, 2026
061df31
control: carry the bandwidth cap in TunnelSpec
xeri Jul 15, 2026
4a422e9
relay: thread an optional per-direction rate limiter through Splice
xeri Jul 15, 2026
0d460da
bwcap: new package turning a tunnel cap into relay limiters
xeri Jul 15, 2026
eb6bde1
gateway,agent: enforce the per-tunnel bandwidth cap on the splice
xeri Jul 15, 2026
4303046
engine,ipc,gateway,app: surface a tunnel's bandwidth cap in status
xeri Jul 15, 2026
d77b243
ui: per-tunnel bandwidth-cap scope selector + gateway cap display
xeri Jul 15, 2026
a54a6d6
gateway: extract shared-token check behind a Validator seam
xeri Jul 15, 2026
bfc75fe
link: assert X25519MLKEM768 KEM + guard CurvePreferences
xeri Jul 15, 2026
b682775
control: add per-conn capability + open_data message
xeri Jul 15, 2026
9750a0a
gateway,agent: multi-TCP per-conn data plane
xeri Jul 15, 2026
670a98e
ui: offer the per-connection transport now that it works
xeri Jul 15, 2026
18f32ca
deps: add quic-go for the QUIC transport
xeri Jul 16, 2026
110f07f
config: agent identity, transport selector, and gateway migration knobs
xeri Jul 16, 2026
d65fa35
control: per-agent identity + gateway-authoritative config wire
xeri Jul 16, 2026
019ac8d
link: agent Ed25519 identity + typed base32 ids
xeri Jul 16, 2026
5195ef7
transport: QUIC session/listener behind transport.Session
xeri Jul 16, 2026
0e129dd
gateway: multi-agent identity, enrollment, scope, and config authority
xeri Jul 16, 2026
523abc5
agent: identity auth, QUIC/per-conn transports, and gateway-config path
xeri Jul 16, 2026
5ebf790
engine,ipc,app: thread identity and config through the daemon
xeri Jul 16, 2026
3060431
e2e: identity, enrollment, transport, and gateway-config coverage
xeri Jul 16, 2026
b84ba96
ui: wire the transport selector and regen bindings
xeri Jul 16, 2026
645de64
docs: transport, identity, and gateway-config architecture
xeri Jul 16, 2026
d005743
link,ui: pxf:// pairing scheme with format-version + role marker
xeri Jul 16, 2026
50c3b07
app,main: click-to-pair deep link + single-instance forwarding
xeri Jul 16, 2026
de0bfd0
gateway: auto-reassign clashing public ports and flag suspected clones
xeri Jul 16, 2026
b4fd959
engine,app: agent-management ops and enrollment-ticket routing
xeri Jul 16, 2026
83b022f
@
xeri Jul 16, 2026
d1dbe78
ui: multi-agent enrollment codes + agents dashboard drill-in
xeri Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .claude/agents/ui-design-reviewer.md
Original file line number Diff line number Diff line change
@@ -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:<port>/?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.
16 changes: 15 additions & 1 deletion .claude/rules/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,9 @@
"enabledPlugins": {
"gopls-lsp@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": true
},
"attribution": {
"commit": "",
"pr": ""
}
}
4 changes: 3 additions & 1 deletion .claude/skills/ui-change/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 3 additions & 2 deletions .claude/skills/wire-protocol/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
61 changes: 37 additions & 24 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,21 +61,27 @@ Each entry: the rule, why, and the symbol that embodies it today. Numbers live i
arms in both `agent.go` and `gateway.go`). Replies that can grow are chunked or
clamped under the frame cap (`MaxConnStatsPerFrame`, `ipc.MaxStatusConns`) —
never raise the cap itself.
- Never advertise a capability that isn't implemented end-to-end. (Currently violated
by `CapTunnelUDP` — see Reality check. Don't repeat this.)
- Never advertise a capability that isn't implemented end-to-end — the peer acts on
the offer, then fails. (`tunnel-udp` violated this until it was un-advertised.)

### Security (`internal/link/`, `internal/gateway/`)
- TLS 1.3 only, both sides (`cert.go GatewayTLSConfig` / `AgentTLSConfig`). Trust =
the gateway's self-signed ECDSA P-256 cert pinned by SHA-256 fingerprint carried
out-of-band in the pairing code `pf1://host:port/<token>#sha256:<hex>`
(`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/<token>#sha256:<hex>`
(`link/pairing.go`; `pxf` doubles as the OS deep-link scheme, `/v1/pair/` is a
format-version + role marker so a wrong-kind link fails loudly). No CA, ever. Token and fingerprint compare in constant time
(`crypto/subtle` — `gateway.go handleControlConn`, `cert.go`).
- The pre-auth prologue (accept → TLS → hello) finishes within `preAuthTimeout` or
dies; failed auth rate-limits per IP, fail2ban-style — successes never count
(`limits.go authLimiter`). Public conns gate globally and per-IP (`limits.go
connGate`; defaults in `config.go`).
- Same agentID reconnect **supersedes** (generation counter); a different agentID on
the same token is **rejected** — no flapping (`actor.go admit`).
- One shared gateway token admits **many** agents, told apart by self-asserted
`agentID`: a matching agentID **supersedes** (reconnect), a distinct one is admitted
**alongside**. Supersede is anti-flap dampened so an ID collision degrades to a slow
contest, not a loop (`actor.go admit`, `noteSupersede`). Residual risk that ships
(shared token + self-asserted ID + FCFS ports + no per-agent revocation): a
token-holder can supersede or port-squat any agent, recoverable only by rotating the
shared token — the mitigation (per-agent tokens/revocation) is deferred.
- The IPC pipe ACL admits Administrators, SYSTEM, and the interactive user only
(`ipc/server_windows.go pipeSecurity`).
- Diagnostics bundles redact every secret, host, IP, and identity; peer IPs become
Expand All @@ -87,18 +93,25 @@ Each entry: the rule, why, and the symbol that embodies it today. Numbers live i

### Liveness & lifecycle
- **One liveness owner**: app-level ping in **both** directions (`pingInterval`) with
an idle read deadline (`controlIdleTimeout`); yamux keepalive OFF and its write
timeout deliberately long so the heartbeat, not yamux, declares death
(`transport/yamux.go muxConfig`).
- Nothing outside `internal/transport` imports yamux. Agent and gateway program
against `transport.Session` / `Stream` so the mux can be swapped.
an idle read deadline (`controlIdleTimeout`); the transport's own keepalive is OFF
and its write/idle timeout deliberately long so the heartbeat, not the transport,
declares death (yamux keepalive off + long write timeout in `transport/yamux.go
muxConfig`; QUIC `KeepAlivePeriod=0` + a `MaxIdleTimeout` above the liveness budget
in `transport/quicconfig.go quicConfig`).
- Nothing outside `internal/transport` imports yamux or quic-go. Agent and gateway
program against `transport.Session` / `Stream` so the transport can be swapped
(yamux-over-TCP, per-conn multi-TCP, or QUIC); the gateway chooses the data
plane once per session behind `dataPlane.openFlow` (QUIC rides the shared-session
mux plane), keeping `handleClient` transport-agnostic (`dataplane.go pickDataPlane`).
- Reconnect: full-jitter exponential backoff, sequence resets after a stable period;
network-change/resume ticks short-circuit it; DNS re-resolves every attempt
(`link/backoff.go`, `netnotify/`, `agent.go runSession`).
- **Ghost-listener guarantee**: all session/listener lifecycle runs on the gateway's
single actor goroutine; eviction closes each listener and waits for its accept loop
before anything else proceeds — a rebound port is provably free
(`actor.go evictLocked` / `bindLocked`; regression: e2e `TestAgentRestartRebinds`).
single actor goroutine; per-agent eviction closes that agent's listeners and waits
each accept loop before anything else proceeds — a rebound port is provably free, and
evicting one agent leaves the others' listeners and connections untouched (the
per-agent mux is the connection-drain boundary). (`actor.go evict` / `bindLocked`;
regressions: e2e `TestAgentRestartRebinds`, `TestEvictionIsolatesAndDrains`.)
- Exactly one process owns ports and config: every engine serves the named pipe
`\\.\pipe\proxyforward`; a GUI that finds it attaches as a thin client; pipe
conflict is fatal by design (`engine.go Run`, `app/app.go Startup`).
Expand All @@ -110,8 +123,9 @@ Each entry: the rule, why, and the symbol that embodies it today. Numbers live i
intact (`relay_test.go`, e2e `TestFinalBytesThroughTunnel`). Every write refreshes
a progress deadline so a parked peer can't leak a goroutine.
- No Nagle anywhere: Go's default `TCP_NODELAY` end-to-end, set explicitly on the
agent's two dials (`SetNoDelay` in `agent.go runSession` and `handleDataStream`);
the yamux window is sized so a chunk burst fits in flight.
agent's dials (`SetNoDelay` in `agent.go dialGateway` — control conn and every
per-conn data conn — and `handleDataStream` for the local dial); the yamux
window is sized so a chunk burst fits in flight.
- **Enforced floor**: `TestBurstThroughputAndCrossStreamLatency` in
`internal/e2e/e2e_test.go` — throughput and worst cross-stream RTT bounds are in
the numbers table. Run it before and after any hot-path change (`hot-path` skill).
Expand All @@ -131,12 +145,15 @@ Each entry: the rule, why, and the symbol that embodies it today. Numbers live i
alone (`ui.tsx StatusDot`).
- All motion gates on `prefersReduced()` / `data-motion` (`motion.ts`, kill switch at
the bottom of `motion.css`); data changes are instant under reduced motion, never
eased (`NumberTicker`, `charts/util.ts useTweenedValues`).
eased (`NumberTicker`, `charts/util.ts useTweenedValues`). That kill switch only
zeroes CSS durations, so *scripted* motion must gate itself in JS — the scroll
rubber band attaches no listener at all under it (`rubberband.ts useRubberBand`).
- Chart series tokens `--dl/--ul/--conn/--rtt` are load-bearing names; direction
mapping (wire "in/out" → UI "upload/download") happens in exactly one place,
the `frontend/src/history.ts` header. Never re-map elsewhere.
- The design charter is `frontend/DESIGN.md` — one identity surface per screen, glass
as a reward, motion communicates network state, color is signal.
- The design charter is `frontend/DESIGN.md` — one identity surface per screen; every
surface is glass but only Signal Glass *answers the pointer* (never give a card the
caustics/streak/wake); motion communicates network state; color is signal.

### Privacy
- Player/traffic analytics are local-only (SQLite next to the config); the only
Expand All @@ -150,11 +167,7 @@ The README and the Settings/Tunnels UI **oversell**. Ground truth at 4a8b0c9:

| Feature (advertised in README/UI) | Actual state |
|---|---|
| Offline MOTD responder | `mc.ServeOffline` built + fuzzed, **never called**; gateway closes dead-session conns (`gateway.go handleClient`). |
| `per-conn` transport | Config-valid only; agent never reads it, gateway rejects `KindData` (`handleControlConn`). |
| UDP tunnels | No UDP socket code; `validateSpec` rejects `type:"udp"` — yet `CapTunnelUDP` is **advertised** (live protocol-bug risk). |
| Bandwidth cap | `BandwidthLimitMbps` stored, never enforced. |
| Prometheus `/metrics` | `MetricsConfig` stored, no server exists. |
| UDP tunnels | Not implemented: no UDP socket code, `validateSpec` rejects `type:"udp"`. No longer advertised (the `tunnel-udp` capability was removed); config still accepts `type:"udp"` but the gateway rejects it — a latent gap, not an oversell. |
| MC status polling (MOTD/players) | Only login sniffing (`mcsniff/`); the health probe is a bare TCP dial (`health.go probeOnce`). |
| Tray / minimize-to-tray / autostart | Hidden `tray_spike.go` command only; `MinimizeToTray` / `Autostart` stored, unused. |
| Linux / macOS binaries | CI **builds** them (`.github/workflows/ci.yml`) so the `*_other.go` stubs can't rot, but they **cannot run**: `ipc.Serve` returns `ErrUnsupported` off Windows and every engine must serve the pipe (`engine.go Run`), so the window opens and the engine dies. Unpublished artifacts, never release assets. Fixing this means a real unix-socket IPC port. |
Expand Down
Loading
Loading