Part 3 of The Operator’s Cockpit. The bundle from Part 2 can be served from
anywhere, so the first thing it must learn is which daemon to talk to and whether
it may write. This post is the pairing handshake from both ends: the browser’s
connect screen and the daemon’s authState, meeting over the same
/api/v1/health and /api/v1/mutations reads that the terminal also uses.
TL;DR: A browser pairs with a daemon by entering a server URL and an optional bearer token; the SPA probes
GET /api/v1/healthfirst and only stores the credentials if the probe succeeds, so typos fail fast. On the daemon,authStateruns one of three policies —auto(loopback and trusted CIDRs bypass, everything else needs a token),required(token always),disabled(open) — and every mutation route is wrapped ins.gate(...). A separate capability probe,GET /api/v1/mutations, returnscan_mutatefor this request, so a front-end greys out write controls up front instead of surprising the operator with a 401.
Key takeaways
- Validate before you save. The connect screen calls
api.healthwith the entered URL + token and stores nothing until it returns 200 — an unreachable host or wrong token surfaces as a human-readable banner, not a broken session. - Three policies, one gate.
AuthModeAuto/Required/Disabledall flow throughauthorize, and every write route iss.gate(handler). Reads are never gated. - Loopback is trusted under
auto. A daemon bound to127.0.0.1treats every request as loopback-sourced; a token is only required on a non-loopback bind, so the common single-host setup needs no token at all. - Capability, not trial and error.
GET /api/v1/mutationsreportscan_mutatefor the caller’s own credentials, so both the SPA and the TUI light up write controls only when the daemon would accept them.
Cheat sheet
| Piece | What it does | Where it lives |
|---|---|---|
| Connect screen | URL + token entry, health-probe gate | web/src/components/ConnectScreen.tsx |
| Health probe | validates credentials before saving | web/src/api/client.ts (probe, api.health) |
| Credential store | holds URL/token, persists per device | web/src/store/shared.ts (setCredentials) |
| Auth policy | parse + evaluate the three modes | internal/api/auth.go (authState, authorize) |
| Mutation gate | wrap every write route | internal/api/server.go (gate) |
| Capability probe | report can_mutate for this request |
internal/api/handlers_mutations.go |
In this post
- The connect screen — server URL, optional token, and validate-before-save.
- The three auth modes — and why loopback gets a bypass.
- The gate — the one middleware every mutation shares.
- The capability probe — how a client knows before it tries.
- Where the token lives — storage, quick-links, and the header, not the query.
The connect screen
Because the same bundle runs against a daemon on localhost, a Pi across the
room, or a laptop behind a proxy (Part 2), the SPA’s first screen is a form: a
Server URL and an optional Bearer token. The critical design choice is
that it validates before it saves — it probes the daemon’s health with the
entered credentials, and only commits them to the store if the probe returns 200:
// web/src/components/ConnectScreen.tsx (shape)
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = url.trim().replace(/\/+$/, "");
if (!/^https?:\/\//i.test(trimmed)) {
setErr("Server URL must start with http:// or https://");
return;
}
try {
await api.health({ baseURL: trimmed, token: token || null }); // probe first
setCredentials(trimmed, token || null, remember); // then save
setConnected(true);
} catch (e) {
if (e instanceof HTTPError) {
setErr(`Daemon refused the request (${e.status}). ${e.body || ""}`);
} else if (e instanceof Error) {
setErr(`Could not reach ${trimmed}: ${e.message}`);
}
}
};
The two failure paths are distinct on purpose. An HTTPError means the daemon
answered but refused — wrong token, most likely — and the banner shows the
status. A plain Error means the host was unreachable — typo, wrong port, daemon
down — and the banner says so. A first-run operator gets a specific reason instead
of a spinner that never resolves.
Two smaller touches earn their keep. When the page is served over http(s) by the
daemon itself, suggestedServerURL() pre-fills the field with
window.location.origin — the “open the daemon’s own URL” case is one click. And
the URL hash can carry #server=…&token=… for a one-click bookmark; the screen
reads it, then immediately history.replaceStates the hash away so a screenshot
or shared bookmark doesn’t leak the token.
The three auth modes
On the daemon side, the policy the connect screen is negotiating with is
AuthMode, and there are exactly three:
// internal/api/auth.go (shape)
const (
AuthModeAuto AuthMode = iota // loopback + trusted CIDRs bypass; else token
AuthModeRequired // token always, even loopback
AuthModeDisabled // open — the legacy allow_mutations behaviour
)
// authorize returns 0,"" when the request may proceed; a 401/403 otherwise.
func (s *authState) authorize(r *http.Request) (int, string) {
switch s.mode {
case AuthModeDisabled:
return 0, ""
case AuthModeRequired:
return s.checkToken(r)
case AuthModeAuto:
if s.sourceTrusted(r) {
return 0, ""
}
return s.checkToken(r)
}
return http.StatusInternalServerError, "auth: invalid mode"
}
The interesting mode is auto, and the interesting idea in it is loopback
trust. A GopherTrunk daemon is overwhelmingly deployed on a closed LAN where the
operator is the only one with shell access; requiring a bearer token there is
friction with no matching threat. So under auto, if the listener is bound to
loopback only, every request is loopback-sourced by definition — there is no
kernel path for an off-host request to reach the socket — and sourceTrusted
returns true without looking at a token:
// internal/api/auth.go (shape)
func (s *authState) sourceTrusted(r *http.Request) bool {
if s.loopback { // bound to 127.0.0.1 / ::1 only
return true
}
ip := remoteIP(r) // from RemoteAddr — never X-Forwarded-For
for _, n := range loopbackCIDRs {
if n.Contains(ip) {
return true
}
}
for _, n := range s.trusted { // operator-configured CIDRs
if n.Contains(ip) {
return true
}
}
return false
}
remoteIP reads RemoteAddr and deliberately ignores X-Forwarded-For,
because the loopback bypass must not be forgeable by a hostile upstream proxy
inserting a fake header. And the config is validated at startup, not at first
request: newAuthState refuses to construct an auto policy on a non-loopback
bind with no token and no trusted networks, so a wide-open public listener is a
startup error, not a silent hole.
Token comparison itself is crypto/subtle.ConstantTimeCompare, and a token_file
is re-read on every request so operators can rotate a token without a daemon
restart or a SIGHUP handler.
The gate
Every mutation route wears the same one-line middleware — the same s.gate we met
in Part 1, here in its role as the enforcement point for all of the above:
// internal/api/server.go (shape)
mux.HandleFunc("PATCH /api/v1/audio", s.gate(s.handleAudioPatch))
mux.HandleFunc("POST /api/v1/hunt/start", s.gate(s.handleHuntStart))
mux.HandleFunc("PATCH /api/v1/settings", s.gate(s.handleSettingsPatch))
mux.HandleFunc("POST /api/v1/spectrum/devices/{serial}/tune", s.gate(s.handleSpectrumTune))
Reads — GET /api/v1/systems, /calls/active, /scanner — are never wrapped.
The rule is grep-able: if a route mutates state, it is s.gate(...); if it only
reads, it isn’t. That uniformity is what lets the browser’s client.ts attach the
Authorization: Bearer header to every request unconditionally (it’s harmless
on reads) and let the gate sort out whether a given write is allowed.
The capability probe
Here is the piece that turns a security boundary into good UX. A front-end could just try a mutation and handle the 401 — but then the write button looks live until you press it and it fails. Instead there’s a read that reports the answer in advance:
// internal/api/handlers_mutations.go (shape)
func (s *Server) handleMutationStatus(w http.ResponseWriter, r *http.Request) {
canMutate := s.auth.canMutate(r) // would THIS request pass authorize()?
writeJSON(w, http.StatusOK, map[string]any{
"auth_mode": s.auth.mode.String(), // "auto" | "required" | "disabled"
"can_mutate": canMutate,
"allow_mutations": canMutate, // legacy alias
"engine_writable": s.mutator != nil,
"retention_writable": s.retention != nil,
"tones_writable": s.tones != nil,
})
}
canMutate runs the same authorize logic against the current request without
performing any mutation, so it’s a faithful preview. The browser store folds that
into a single gate that also honours the operator’s own write-mode toggle:
// web/src/store/shared.ts (shape)
// Can-mutate gate combining write-mode toggle with daemon capability.
export function selectCanMutate(s: SharedState): boolean {
return s.writeMode && (s.mutations?.allow_mutations ?? false);
}
So a control is live only when both the operator has opted into write mode and
the daemon says this request would pass. The TUI does the identical thing —
cmdMutationStatus in its Init batch, AND-ed with its --write flag — which is
why “can I change this?” resolves the same way in a terminal and a browser. It’s
the capability contract, not two independent guesses.
Where the token lives
The token is the operator’s, and it stays that way. setCredentials(url, token,
persist) writes it into the browser store (and, when “Remember on this device” is
checked, to local storage via prefs); the connect screen’s fine print — “The
token stays in your browser. The page never phones home; every request is direct
to the daemon” — is literally true, because the SPA has no server of its own to
phone.
On the wire, the token is always an Authorization: Bearer header, never a query
parameter, so it never lands in an access log. That’s client.ts’s job on every
request, and it’s also why the live audio stream (Part 5) uses fetch() rather
than a bare <audio src> element — only fetch can set the header. The one place
that constraint bites is the browser EventSource, which can’t set headers at
all; that’s why the SPA reaches for the WebSocket event twin instead, which is
exactly where Part 4 begins.
FAQ
Do I need a token on my home LAN?
Usually no. Under the default auto policy a loopback-bound daemon trusts every
request, and you can add your LAN’s CIDR to trusted_networks to extend that to
other boxes. A token is only required when you bind to a public interface (or
pick required explicitly).
Why probe health before saving credentials? So a bad URL or wrong token fails immediately with a specific banner — “daemon refused (401)” vs. “could not reach host” — instead of saving broken credentials and leaving every subsequent panel spinning.
Why does X-Forwarded-For get ignored?
Because the loopback/trusted-network bypass keys off the source IP, and honouring a
client-settable header would let a hostile upstream forge a trusted source.
remoteIP reads only RemoteAddr.
What’s the difference between can_mutate and allow_mutations?
None functionally — allow_mutations is a legacy alias for the same boolean. Both
report whether this request would pass the gate, computed by running the real
authorize logic without side effects.
Can I rotate the token without restarting?
Yes, if you use token_file. The daemon re-reads the file on every request, so
writing a new token takes effect on the next call — no restart, no SIGHUP.
Series navigation
Part 3 of 14 · ← Part 2: A React SPA Inside a Go Binary · Next → Part 4: The Event Stream — SSE to React </content>