Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ linters:
# comments are the spec and its explicitness is deliberate; those checks
# would fight it for no correctness gain.
checks: ["all", "-QF*", "-ST*"]
misspell:
# "alltime" is a Prometheus metric-name token (proxyforward_alltime_bytes_total).
# Metric names cannot contain hyphens, so "all-time" is not a valid alternative.
ignore-rules:
- alltime

exclusions:
presets:
Expand Down
25 changes: 16 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,20 +75,27 @@ Each entry: the rule, why, and the symbol that embodies it today. Numbers live i
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`).
- 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.
- **Per-agent identity is the trust model.** Each agent proves possession of a long-term
Ed25519 key the gateway allowlists; its derived `agentID` (`agt_…`) is unforgeable,
individually **scoped** (ports/tunnels) and **revocable** — revocation evicts the live
session and makes the next connect fatal `ErrCodeRevoked`. Agents join via a single-use
enrollment ticket in the pairing code (`gateway/auth.go identityValidator`, `agentstore.go`,
`link/cred.go`; mechanics in `docs/agent/architecture.md`). 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`).
- The legacy **shared token** survives only as a migration fallback, accepted while
`Gateway.AcceptSharedToken` is on (default for now). A shared-token agent self-asserts its
`agentID`, so the old residual risk — supersede or port-squat, recoverable only by rotating
the token — persists **for that path only**; enrolled agents are protected by their key and
by revocation, and disabling `AcceptSharedToken` once every agent is enrolled closes it.
- 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
stable sha256 pseudonyms (`app/tools.go`, leak-tested in `app/tools_test.go`).
Anything new that exports data must pass the same no-leak test style.
- Fatal auth errors (`bad_token`, `agent_conflict`, `version`) stop the agent instead
of retry-hammering the gateway (fatal classification in `agent.go Run`), and
- Fatal auth errors (`bad_token`, `agent_conflict`, `version`, `revoked`) stop the agent
instead of retry-hammering the gateway (fatal classification in `agent.go isFatal`), and
surface in the UI via `EngineFatal` on the tick.

### Liveness & lifecycle
Expand Down
6 changes: 6 additions & 0 deletions app/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ func redactConfig(cfg *config.Config) config.Config {
if r.Agent.CertFingerprint != "" {
r.Agent.CertFingerprint = secret
}
if r.Agent.EnrollTicket != "" {
// A pending single-use enrollment ticket is a live credential until the
// gateway confirms enrollment — exactly the failing-to-pair window in which
// a user grabs a bundle. Never let it ride along.
r.Agent.EnrollTicket = secret
}
if r.Agent.GatewayHost != "" {
r.Agent.GatewayHost = host
}
Expand Down
3 changes: 3 additions & 0 deletions app/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func sampleConfig() *config.Config {
cfg.Agent.Token = "AGENTTOKENSECRET"
cfg.Agent.GatewayHost = "gw.secret.example.com"
cfg.Agent.CertFingerprint = "sha256:deadbeefsecret"
cfg.Agent.EnrollTicket = "tkt_ENROLLTICKETSECRET"
cfg.Agent.Tunnels = []config.Tunnel{{ID: "t1", Name: "mc", LocalAddr: "10.9.8.7:25565"}}
cfg.Gateway.Token = "GWTOKENSECRET"
cfg.Gateway.PublicHost = "public.secret.example.com"
Expand All @@ -35,8 +36,10 @@ func TestRedactConfigMasksEverySecret(t *testing.T) {
"AGENTIDSECRET", "AGENTTOKENSECRET", "gw.secret.example.com",
"deadbeefsecret", "10.9.8.7", "GWTOKENSECRET",
"public.secret.example.com", "192.168.50.1", "127.0.0.99",
"ENROLLTICKETSECRET",
}
blob := r.Agent.AgentID + r.Agent.Token + r.Agent.GatewayHost + r.Agent.CertFingerprint +
r.Agent.EnrollTicket +
r.Gateway.Token + r.Gateway.PublicHost + r.Gateway.BindAddr + r.Metrics.PrometheusAddr
for _, t2 := range r.Agent.Tunnels {
blob += t2.LocalAddr
Expand Down
100 changes: 100 additions & 0 deletions docs/agent/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,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` |
| Key exchange | X25519MLKEM768 (PQ hybrid, Go default; `CurvePreferences` unset) | `link/cert.go`, `link/pq_test.go` |
| Agent identity | Ed25519 (`agent_identity.key`, PKCS#8 PEM, `0600`); `agentID` = `agt_` + 8-char Crockford base32 of sha256(pubkey)[:5] = 40 bits | `link/cred.go LoadOrCreateIdentity fingerprint` |
| Enrollment ticket | `tkt_` + 128-bit nonce; single-use default, reusable optional; TTL caller-set, 0 = never (UI single-use default 10 min) | `link/cred.go NewEnrollTicket`, `gateway/agentstore.go IssueEnrollment` |
| Agent allowlist | `gateway_agents.json` (`0600`, atomic write + AV-retry): identity + scope + desired config per agent | `gateway/agentstore.go AgentStore` |
| Pairing code | `pxf://host:port/v1/pair/<tkt>#sha256:<64hex>`, ≤ 512 B before parse | `link/pairing.go ParsePairingCode` |
| Agent auth | Ed25519 proof-of-possession over `proxyforward-agent-auth-v1` + gateway cert FP — no bearer token in steady state | `link/cred.go AgentAuthMessage` |
| 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` |
Expand Down Expand Up @@ -180,6 +185,101 @@ change) only once a later rung succeeds — the "UDP blocked" tell; if every run
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.

## Per-agent identity, enrollment & revocation (`internal/link/cred.go`, `internal/gateway/agentstore.go`, `internal/gateway/auth.go`)

The trust root is still the gateway's pinned self-signed cert (`link/cert.go`); layered on
top is a per-agent cryptographic identity so agents are told apart, scoped, and revoked
individually rather than sharing one bearer token.

**Identity.** On first run the agent generates a long-term Ed25519 keypair and persists the
PKCS#8 private key `0600` beside its config (`link/cred.go LoadOrCreateIdentity`); the private
half never leaves the machine, and a corrupt/non-Ed25519 file is a fatal, actionable error,
never a silent regeneration (which would orphan the allowlist entry). The **canonical
identity is the raw public key** — the gateway allowlist is keyed by it. The human-facing
`agentID` is *derived*: `agt_` + an 8-char Crockford-base32 fingerprint (no confusable
`i/l/o/u`) of the first 40 bits of sha256(pubkey) (`link/cred.go AgentID fingerprint`).
Derived, so it is stable (the same machine always re-derives it; re-pairing never dupes) and
unforgeable (bound to a private key nobody else holds). The ID grammar is
`<type>_<fingerprint>`: `gw_` over the cert DER, `agt_` over the pubkey, `tnl_` a slug of the
tunnel name with a `-2` collision suffix (`link/cred.go GatewayID TunnelID`). A freely-editable
nickname layers on top as display sugar.

**Steady-state auth is proof-of-possession, not a bearer token.** In the hello the agent
sends `AgentPubKey` plus an Ed25519 `AgentSig` over `AgentAuthMessage` = the constant
`proxyforward-agent-auth-v1` joined to the *pinned gateway cert fingerprint* (`link/cred.go
AgentAuthMessage SignAgentAuth`). The gateway verifies the signature, checks the pubkey is
allowlisted and not revoked (`gateway/auth.go identityValidator`), and admits — no extra
round-trip, and the agent still speaks first, so hello frames to a legacy gateway stay
byte-identical (all new fields `omitempty`). Binding to the cert fingerprint (rather than the
originally-specced per-session TLS exporter) means a signature made for one gateway can never
be replayed to another; same-gateway replay resistance rests on TLS 1.3 confidentiality —
only the real gateway or the agent itself ever sees the signature and either already holds the
private key — so the signature is static per (agent, gateway) pair and the identical message
works over both TCP and QUIC. The bearer token now survives only as the enrollment ticket.

**Enrollment.** The gateway mints a single-use ticket `tkt_` + 128-bit nonce (`link/cred.go
NewEnrollTicket`) and embeds it in a pairing code. On first contact the agent replays it in
`Hello.EnrollTicket`; the gateway validates-and-consumes it under one lock (a spent single-use
ticket is refused — `ErrTicketConsumed`), records the pubkey, derives and stores the
`agentID`, and returns it in `HelloOK.AssignedAgentID` alongside `GatewayID`. Single-use is
the default; a **reusable** ticket (enrolls many agents until revoked) and an optional expiry
(zero = never) are the flagged alternatives. Enrollment is **field-driven, not a capability**
— acted on before capability negotiation, so there is deliberately no `CapEnroll`.

**AgentStore.** The allowlist and outstanding tickets persist to `gateway_agents.json` (`0600`,
single writer, atomic temp+rename with the AV-retry of `setup.atomicWrite`) — deliberately
*not* in `analytics.db`, which is role-blind history (`gateway/agentstore.go AgentStore
LoadAgentStore`). Each record carries identity, nickname, scope, and the gateway-authoritative
desired tunnel set (see "Control-plane message flow").

**Validators.** A `compositeValidator` tries the identity path first, then — only while
`Gateway.AcceptSharedToken` is on (a migration default) — falls back to the legacy shared token
(`gateway/auth.go compositeValidator sharedTokenValidator`; token and fingerprint still compare
in constant time). Every accept path (TCP control, QUIC, per-conn data) funnels through the one
`Validator.Validate` seam, so identity is enforced uniformly.

**Revocation.** `Gateway.RevokeAgent` removes the pubkey from the allowlist and evicts any live
session at once; the next connect is a fatal `ErrCodeRevoked`, which the agent classifies as
fatal (`agent.go isFatal`) and stops on rather than retry-hammering — surfaced in the GUI via
`EngineFatal`. Regression: e2e `TestEnrollAndRevoke`.

**Scope.** A ticket/record carries `Scope{Ports, TunnelIDs}` (empty = unrestricted), enforced
at bind in `validateSpec` — an out-of-scope port is `ErrCodePortNotAllowed`, an out-of-scope
tunnel `ErrCodeBadTunnel` — on *both* the register path and the gateway-config push/adopt path,
so a scoped agent can never bind outside its grant by any route (`gateway/agentstore.go Scope`;
`gateway/gateway.go validSpecs`). Regressions: `TestGatewayConfigScopeNarrowingHidesTunnel`,
`gateway/scope_test.go`.

**Pairing scheme & click-to-pair.** The pairing code is `pxf://host:port/v1/pair/<tkt>#sha256:<hex>`
(`link/pairing.go PairingCode`). `pxf` is a permanent brand *and* the OS deep-link scheme
(`wails.json` registers it via the NSIS macros); the `/v1/` segment versions the code's *shape*
independently of the wire `ProtocolVersion`; the `/pair/` segment is a role/kind marker so a
wrong-kind link fails loudly instead of half-parsing (`link/pairing.go tokenFromV1Path`). The
parser caps input at 512 bytes before parsing and validates host, port, and the 64-hex
fingerprint; it is fuzzed (`link/pairing_fuzz_test.go FuzzParsePairingCode`). A clicked `pxf://`
link reaches the app as `os.Args` on a cold launch or via single-instance forwarding on a warm
one (`main.go deepLinkArg`, `app/app.go HandleDeepLink TakePendingDeepLink`); the frontend drains
it on mount and opens the wizard straight onto the agent paste step with the code prefilled —
confirm-to-connect, never auto-connect (`frontend/src/App.tsx`, `frontend/src/screens/Wizard.tsx`).

**Version axes** (independent, so any one evolves alone):

| Axis | Where | Bumps when |
|---|---|---|
| Scheme | `pxf` | Never (permanent brand) |
| Pairing format | the `/v1/` path segment | The code's shape changes |
| Protocol | `Hello.ProtocolVersion` (stays 1) | The hello exchange itself breaks |
| Config authority | `Hello.ConfigGeneration` (per-agent, monotonic) | The gateway adopts a new desired set |
| Crypto suite | TLS-negotiated (`X25519MLKEM768` today) | An algorithm is added/retired |

**Deferred (honest state).** Per-agent **key rotation** ("old key signs new") and PQ
*signatures* are roadmap, not shipped (the KEM is already PQ; forging Ed25519 needs a quantum
computer at attack time, so there is no harvest-now risk). There is no on-disk `config_version`
schema field yet — there are no config migrations and the local config dir is disposable. The
`pf1://` scheme was dropped outright rather than kept as a compat parser (pre-release, no codes
in the wild). Several agent-management surfaces are backend-complete but not yet fully exposed in
the GUI — tracked in `docs/agent/polish-backlog.md`.

## Analytics data model (`internal/analytics/schema.go`)

- `rrd(tier,t,…)` — persisted image of stats tiers 2–4 (28 OHLC/gauge columns; −1 =
Expand Down
37 changes: 37 additions & 0 deletions docs/agent/polish-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,40 @@ Ordered by user-visible impact. "Fix" describes the smallest on-system change.
`*_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.

## Agent-management surfacing (backend ahead of the GUI)

The per-agent identity/enrollment/config work landed backend-first; these surfaces are
implemented and tested in Go but only partly exposed. None is a bug — each is an unbuilt
GUI affordance, so they belong here, not in the "Reality check" (nothing is *oversold*).

26. **Enrollment issuance is under-surfaced** — `IssuePairingCode` (mode + scope + ttl) is
reachable only from the first-run wizard's reusable toggle (`frontend/src/screens/Wizard.tsx`),
with ttl and scope fixed. The Overview pairing strip still hands out the *legacy
shared-token* code (`frontend/src/screens/Overview.tsx`), and the Agents screen has no
issue-code control — so the headline per-agent-enrollment path isn't the default one.
Fix: an issue-code control (mode/scope/ttl) on the Agents screen, and switch Overview's
strip to `IssuePairingCode`.
27. **Port-conflict cards are informational, not one-click** — the gateway auto-reassigns a
clashing public port and logs it, but the roster's conflict cards
(`frontend/src/screens/Agents.tsx ConflictCards`) only offer "View agent"; the planned
one-click "take the port / evict the other" needs a gateway reclaim op. Auto-reassign
already avoids the hard failure, so this is a convenience affordance. Fix: a reclaim
engine op + a card action.
28. **No per-agent "Update" nudge** — `AgentView` (`gateway.go AgentView`) carries no agent
app version, so a version-skewed agent can't be flagged in the roster. Fix: thread the
agent's reported version onto `AgentView` and render a card badge.
29. **Config overlay/promote has no GUI** — gateway-authoritative sync works at the wire
level (push/propose/adopt), but no App method or view shows a pending local edit and a
"propose to gateway" action. The gateway reconciles silently, so nothing is broken; the
L2 overlay view is simply unbuilt. Fix: surface the pending overlay + a promote button
when the agent's set diverges.
30. **Per-agent transport pin absent** — transport (auto / quic / per-conn / mux) is one
global agent setting; per-agent management (`frontend/src/screens/Agents.tsx`) can't pin
a single agent's transport. Low priority. Fix: a per-agent override if ever needed.
31. **`link.TunnelID` minter is unused in production** — the typed `tnl_` + `-2`-suffix
minter (`link/cred.go TunnelID`, tested by `TestTunnelID`) is not on the real
tunnel-create path, which still mints random hex (`config/config.go NewID`). Cross-agent
tunnel-ID uniqueness isn't required (per-agent namespacing + port auto-reassign handle
clashes), so it's a latent primitive, not a bug. Fix: adopt `tnl_` IDs on create, or drop
the unused minter.
18 changes: 17 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {Spinner} from './components/ui'
import {UIStatus, useTick} from './state'
import {prefersReduced} from './motion'
import {resetBands} from './rubberband'
import {TakePendingDeepLink} from '../wailsjs/go/app/App'
import {EventsOn} from '../wailsjs/runtime/runtime'

const supportsVT = typeof (document as Document & {startViewTransition?: unknown}).startViewTransition === 'function'

Expand Down Expand Up @@ -117,7 +119,21 @@ export default function App() {
useEffect(() => {
if (backendWizard) setWizardHold(true)
}, [backendWizard])

// A clicked pxf:// pairing invite arrives from the OS via the backend: stashed for
// a cold launch (drained once) or emitted on a warm one. Either way, open the wizard
// straight onto the agent paste step with the code prefilled. The user still reviews
// and confirms — a clicked link never auto-connects.
const [deepLink, setDeepLink] = useState('')
useEffect(() => {
let done = false
TakePendingDeepLink().then(url => { if (!done && url) { setDeepLink(url); setWizardHold(true) } }).catch(() => {})
const off = EventsOn('pxf:deeplink', (url: string) => { setDeepLink(url); setWizardHold(true) })
return () => { done = true; off() }
}, [])

const finishWizard = () => {
setDeepLink('') // a later manual reopen starts at role selection, not a stale link
const doc = document as Document & {startViewTransition?: (cb: () => void) => void}
if (!prefersReduced() && doc.startViewTransition) {
// Glaze the handover: the ambient glow flares and a glare sweep crosses
Expand All @@ -142,7 +158,7 @@ export default function App() {
if (backendWizard || wizardHold) {
return (
<Shell titlebar={<TitleBar brand />}>
<Wizard status={status} onDone={finishWizard} />
<Wizard status={status} onDone={finishWizard} deepLink={deepLink} />
</Shell>
)
}
Expand Down
Loading