Documentation
¶
Overview ¶
Package daemon wires sessions, the wire protocol, and a transport into an HTTP server. It owns attachment bookkeeping: ref allocation, which client is primary for a session, and promotion when a primary leaves.
Endpoint policy ¶
The GET surface is read-only. The routes are the app shell, a JSON session listing, the WebSocket upgrade, the handoff mint, the pairing POST, and the two static reads the pairing page needs before it holds any credential — the shell at PairPagePath and the build output under uiAssetPrefix. No GET among them changes any state; the mint and the pairing POST answer nothing but a POST. Spawning, signalling, resizing and closing a session are reachable only over an established WebSocket.
That is a security constraint rather than a stylistic one. Task 5's authenticator accepts Sec-Fetch-Site: none, because that is what a typed URL or a bookmark sends and the handoff exchange on first load depends on it. But per the Fetch Metadata spec the redirect-downgrade loop is skipped when the value is already "none", so a user-initiated navigation to a co-resident untrusted origin — an unrelated dev server on another loopback port — that answers 302 -> http://127.0.0.1:<flue>/<path> arrives at this daemon still carrying "none", with no Origin, a correct Host, and the flue_token cookie, because SameSite is blind to the port. Every check in Task 5 passes. The impact ceiling is a blind authenticated GET: the attacker cannot read the response, and the same trick through window.open or an iframe is page-initiated, arrives as "same-site", and is already rejected.
So the defence is structural, in five parts:
- Only GET and HEAD are routed, with exactly two allowlisted exceptions: POST to MintPath and POST to PairPath. Anything else is 405 before it reaches a handler, which also means no CORS preflight ever succeeds, since OPTIONS is refused everywhere.
- The privileged operation — minting a handoff token, which converts "I can read the token file" into "here is a fresh credential" — is that POST, and it authenticates on a request header rather than the cookie. Neither a laundered navigation (which is a GET) nor any browser (which cannot set a custom header cross-origin without a preflight that always 405s) can reach it.
- The other POST, PairPath, is the one endpoint that does not authenticate on the session token at all, because the device it enrols holds none yet. It is bounded instead: it does nothing unless the user has opened a pairing window from an already-trusted UI, it refuses anything that is not same-origin before comparing the token, and the window closes on the presentation that pairs a device. A wrong one closes nothing: the token is 256 bits and this endpoint is reachable from the internet over a relay, so burn-on-wrong-guess only ever cost the user. See pairing.go.
- The page that makes that POST cannot authenticate either, so the two GETs which serve it — PairPagePath and uiAssetPrefix — are exempt from the token as well. What the exemption covers is decided by exemptStaticPath rather than by the routing patterns, which are coarser than they look: http.ServeMux unescapes each segment after cleaning the escaped target, so a percent-encoded traversal matches the asset subtree and resolves outside it. A path that is not already its own cleaned form is refused, and everything the exemption does not cover is answered by withAuth as though it were absent. See withProvenance.
- The upgrade — the one GET that leads to state changes — refuses Sec-Fetch-Site: none outright. See handleWS for why that costs a real client nothing.
- The one remaining GET that does change state is the handoff exchange in local.Auth.Middleware, which must admit "none" to work at all. It is deliberate and bounded: a laundered request carrying an unknown token changes nothing, and one carrying a token the attacker already knows makes the victim's browser receive a cookie it is already entitled to — strictly worse for the attacker than spending the same token themselves, which would hand them the Set-Cookie header directly. The cookie's value is a constant chosen by the daemon and never anything from the request, so session fixation is structurally impossible.
Index ¶
- Constants
- Variables
- func ClearRuntime() error
- func ReadRuntime() (int, bool)
- func ReadRuntimeRecord() (port, pid int, ok bool)
- func WriteRuntime(port int) error
- type ConnMeta
- type Identity
- type MessageConn
- type PairOutcome
- type RelayUI
- type RelayUIAccount
- type RelayUIDeployRequest
- type RelayUIDeployResult
- type RelayUIStatus
- type ReleaseChecker
- type ReleaseStatus
- type Server
- func (s *Server) Handler() http.Handler
- func (s *Server) ListenAndServe(ctx context.Context, port int) error
- func (s *Server) PairDevice(body []byte, peer string) PairOutcome
- func (s *Server) ServeConn(ctx context.Context, mc MessageConn, meta ConnMeta)
- func (s *Server) SetAuth(a *local.Auth)
- func (s *Server) SetLogger(l *slog.Logger)
- func (s *Server) SetRelayMachine(id, name string)
- func (s *Server) SetRelayStatus(status, origin string)
- func (s *Server) SetRelayUI(ui RelayUI)
- func (s *Server) SetReleaseChecker(c ReleaseChecker)
- func (s *Server) Shutdown()
Constants ¶
const ( RelayInfoPath = "/api/relay/info" RelayDeployPath = "/api/relay/deploy" RelayUpdatePath = "/api/relay/update" // RelayJoinPath answers the join line for adding another machine — the // same string the deploy result shows, rebuilt from relay.json on demand. // It carries the fleet secret, which is why it is its own endpoint behind // a click rather than a field on Info that every page load would fetch: // the secret should cross into a page exactly when a human asked to see // it. Loopback + auth is the boundary that makes even that acceptable — // the cookie behind withAuth already spawns shells, so a page that can // call this could do worse. RelayJoinPath = "/api/relay/join" // RelayAddressPath takes {"address": "wss://..."} and repoints relay.json // at a custom domain the user routed to the Worker themselves. No // Cloudflare call, no token; a POST because it mutates. RelayAddressPath = "/api/relay/address" )
The Remote screen's relay endpoints. The daemon owns the HTTP surface — auth, method policy, body bounds — and an injected RelayUI (wired up by cmd/flue, which owns the embedded bundles and the config files) does the deploying. The split keeps this package free of any Cloudflare knowledge.
These endpoints exist on the loopback origin only, by construction rather than by check: the daemon binds 127.0.0.1, and a relay forwards exactly one piece of HTTP (pairing) — every other remote interaction rides the Noise channel's wire protocol, which has no operation that reaches these paths. That is the property that makes a Cloudflare API token acceptable in a request body here at all. It must never be given a wire-protocol equivalent: a token typed into a remote tab would ride the relay, and a hostile relay origin serving that tab its JavaScript could read it.
const ( // LocalCSP is what this daemon serves its own UI under. LocalCSP = cspHead + cspLoopbackSockets + cspTail // RelayCSP is what a relay origin serves the same bundle under. It is // carried to the deploy as the `_headers` file the Worker's static assets // are served with — see cmd/flue/relay.go. It grants nothing beyond // 'self': the bundle on a relay origin talks to that origin alone. RelayCSP = cspHead + cspTail )
The Content-Security-Policy the UI is served under, in the two shapes the two origins that can serve it need.
It is not decoration on either. The browser's static Noise key lives in IndexedDB as raw bytes (web/src/crypto/keys.ts — a non-extractable CryptoKey cannot feed a userland Noise implementation), so a stored key is a standing grant to a shell and an injected script would be key theft. `script-src 'self'` is the compensating control that file names, and it has to hold on every origin that serves the bundle, not only on the one that is already unreachable from the internet.
The two differ in `connect-src`, and that difference is why there are two.
The daemon serves the UI over http on loopback and the socket the UI opens is `ws://127.0.0.1:7717`, which `'self'` does not cover; a relay serves it over https and the socket is a same-origin `wss://`, which `'self'` does. Those loopback entries carry wildcard ports — an injected script could reach every other service on the machine through them (docs/FOLLOW-UPS.md §6) — so an internet-facing origin must not inherit them just because the daemon needs them.
Composed rather than written twice, so the shared half cannot drift.
const ( // RelayOff is a daemon with no relay: none configured, or one that has // stopped being dialled. It is never sent on the wire — the welcome omits // the relay field entirely — so no client has to branch on it. RelayOff = "off" // RelayConnecting is a relay this daemon is trying to reach: dialling, // backing off between dials, or waiting out a refusal. Nothing is reachable // through it, so it names no origin. RelayConnecting = "connecting" // RelayConnected is a live socket to the relay, and the one state that // carries an origin. RelayConnected = "connected" )
The three states the relay leg can be in, and the only three strings that ever reach a client in wire.RelayInfo.Status.
They are exported because the transport that reports them lives in another package (internal/transport/relay) and the compiler is the only thing that can keep the two spellings identical.
const MintPath = "/api/handoff"
MintPath is the one route that may be reached by a method other than GET or HEAD. It hands the flue CLI a one-time handoff token so that the session token never has to appear in a URL — and therefore never in the browser opener's argv, which any local user can read.
const PairPagePath = "/pair"
PairPagePath is the client-side route that makes that POST: the app shell, served to a device that has nothing to authenticate with, carrying the token in ?t=. It is the path the QR code names — see conn.go, which builds that URL from this constant so the page the daemon serves without a session token and the page it sends the second device to cannot drift apart.
Serving it needs no token for the same reason the POST does not: the device holds none. It is still refused unless the request's provenance is this daemon's own, and it answers with the app shell and nothing else. See Server.withProvenance.
const PairPath = "/api/pair"
PairPath is the second of the two routes that may be reached by a method other than GET or HEAD, and the only endpoint on this daemon that is not authenticated by the session token.
That is the ceremony, not an oversight. The device being paired is by definition a device that holds no credential of this daemon's yet — if it had one it would not need pairing — so the thing it presents instead is the pairing token, which the user carried across from an already-trusted UI by scanning a code or following a link. Four rules keep that narrow:
- There has to be an open window. Outside one, this endpoint is inert.
- The window closes on the presentation that succeeds (see pairingState.redeem), so a token pairs exactly one device.
- The window closes on its own after PairingTTL. Nothing else closes it: a wrong guess costs the guesser a request and the user nothing, because this endpoint is reachable from the internet over a relay and the token it protects is 256 bits.
- The request must be same-origin, checked before the token is compared, so a page that is not the /pair page this daemon served cannot even reach the comparison.
const PairingTTL = 2 * time.Minute
PairingTTL is how long a pairing window stays open.
Two minutes is the span of the ceremony itself: pick up the second device, scan or open the link, confirm. It is not a span anyone is meant to walk away during, and a token found afterwards — in a screenshot, in a photo of a QR code, in a chat message — is inert.
const ReleasePath = "/api/flue/release"
ReleasePath answers one question: is there a flue newer than this one?
Loopback only, like the relay endpoints beside it, and for a reason that is about the answer rather than about secrecy. Upgrading flue means replacing a binary on the machine the daemon runs on — a browser cannot do it from anywhere, and a phone reading a laptop's fleet over the relay least of all. An endpoint that only the machine's own tab can reach is the honest shape for a fact only the machine's own operator can act on.
It is also the only place in the app that talks to a third party. The check runs here rather than in the tab deliberately: one machine asks GitHub on an interval, instead of every open tab asking on every load — including remote tabs, which would be telling GitHub about a flue instance from whatever network the reader happens to be on.
Variables ¶
var ( // ErrNoAuth is returned when the daemon is asked to serve without an // authenticator. A daemon that spawns shells must never fall open: a // token that could not be loaded is a fatal startup condition, not a // reason to serve anonymously. ErrNoAuth = errors.New("daemon: no authenticator configured") // ErrFetchSite rejects a request whose Sec-Fetch-Site value is anything // other than same-origin on an endpoint that requires one. ErrFetchSite = errors.New("daemon: this endpoint requires a same-origin request") )
var ErrRelayUIBadRequest = errors.New("bad relay request")
ErrRelayUIBadRequest marks a service failure the caller caused — a missing token, an account id the token cannot see, a bad worker name — so the handler can answer 400 rather than 502. Wrap it.
Functions ¶
func ClearRuntime ¶
func ClearRuntime() error
ClearRuntime removes this process's runtime record. A daemon calls it on the way out so a later flue invocation reports "not running" outright instead of probing a port that may have been taken over by something else in the meantime.
It removes the record only if the record is still ours. A second daemon started by hand on another port overwrites the file while the first is still running; the first one exiting must not then delete the survivor's record and make a live daemon undiscoverable. This is best-effort by nature — nothing runs on SIGKILL — which is exactly why readers still have to confirm what is listening rather than trust the file.
func ReadRuntime ¶
ReadRuntime returns the recorded port, if any.
A record is a hint, not proof: it survives a daemon that was killed, and the port it names may by then belong to an unrelated process. Callers must confirm what is actually listening before trusting it with anything — above all before sending it the auth token.
func ReadRuntimeRecord ¶
ReadRuntimeRecord returns the recorded port together with the PID of the process that wrote it.
The PID is the only part of the record that says anything about *whose* daemon this is. What is listening on a port can be identified as flue by asking it, but one flue daemon looks exactly like another — including one belonging to a different user on a shared machine, which must never be sent this user's token. A live process the caller is allowed to signal is the available evidence that the recorded daemon is its own.
func WriteRuntime ¶
WriteRuntime records the port the daemon is listening on so other flue invocations can find it.
The replacement lands on a fresh inode and is renamed into place rather than truncating runtime.json in place: os.Rename is atomic on the filesystems flue targets, but truncate-then-write is not, and this file is read by other flue invocations (open, status) that may run concurrently with the daemon that owns it. A torn read here just means one extra "not running" false negative, not a security issue the way it is for the auth token — but there's no reason to accept even that when the fix is the same few lines config.LoadOrCreateToken already uses.
Types ¶
type ConnMeta ¶
type ConnMeta struct {
Peer string // resolved peer identity, for the audit log
Origin string // absolute origin pairing URLs may be built from
DeviceID string // paired device id; "" on the local transport
}
ConnMeta identifies the peer a MessageConn speaks for.
It is what the transport resolved before handing the connection over, and the connection never re-derives any of it: by the time a client asks to pair, the request that carried the origin is long gone, and the handshake that proved the device is over.
type Identity ¶
type Identity struct {
Key noise.DHKey
Devices *crypto.DeviceStore
}
Identity is the daemon's cryptographic identity: the static keypair every paired device knows it by, and the registry of the devices that have been paired to it.
It is one parameter rather than two because the halves are useless apart — a key with nowhere to record who holds it, or a registry of devices paired to no key — and because the zero value then has an unambiguous meaning: a daemon that cannot pair. Tests and any embedder without a config directory construct exactly that, and pairing refuses rather than half-running.
type MessageConn ¶
type MessageConn interface {
// Read blocks until the next message arrives. The implementation is
// responsible for bounding what it will accept: the WebSocket transport
// sets readLimit on the socket at accept, and a relay's reader has to bound
// its own frames, because nothing past this seam does.
Read(ctx context.Context) (text bool, data []byte, err error)
Write(ctx context.Context, text bool, data []byte) error
// Close ends the stream. It must be safe to call more than once and
// safe to call concurrently with Read/Write. It need not interrupt a Read
// already in flight — cancel the connection's context for that.
Close() error
}
MessageConn is one client's ordered message stream, however it reached the daemon. text distinguishes control JSON (true) from binary data frames.
The seam exists because a WebSocket is not the only way a client arrives. A relayed connection is a Noise-encrypted channel multiplexed with others over a single socket, and from the connection state machine's point of view the only thing the two have in common is this: ordered messages, each either control or data, until one end stops.
type PairOutcome ¶
PairOutcome is the transport-neutral answer to a pairing attempt: the HTTP status and the JSON body the request should be answered with.
Body is always a JSON value, on every path including refusal. That is the relay leg's requirement rather than a preference: relaywire.PairResult carries the body the Worker writes as an application/json response, and its encoder refuses anything else — a refusal that travelled as the bare text the local handler writes would be a frame the daemon could not send at all, leaving the browser waiting for an answer that never comes. See spec/relay-protocol.md, `pairResult.body`, which fixes both halves of this: JSON over the relay, the bare text over the daemon's own origin.
func PairRefusal ¶
func PairRefusal() PairOutcome
PairRefusal is the outcome every refused pairing attempt gets.
It is exported for the one caller that has to refuse a pairing request without running the ceremony: the relay adapter, which drops a `pair` whose announced origin is not the relay this daemon dialled. That request must be answered — a Worker holding a parked HTTP request would otherwise wait out its own deadline — and it must be answered without redeeming anything. A wrong token no longer spends a window (pairingState.redeem), but a relay lying about its origin is a relay that can *see* the live token on channel 0 (spec/relay-protocol.md, what the relay sees), and presenting that would spend it against a device of the relay's choosing.
The body is a fresh copy, so no caller can edit the refusal every other caller is about to send.
type RelayUI ¶
type RelayUI interface {
Status(ctx context.Context) RelayUIStatus
Provision(ctx context.Context, req RelayUIDeployRequest) (RelayUIDeployResult, error)
Update(ctx context.Context, req RelayUIDeployRequest) (RelayUIDeployResult, error)
// JoinCommand rebuilds the hand-off line from relay.json; ok is false
// when no relay is configured.
JoinCommand(ctx context.Context) (cmd string, ok bool, err error)
// SetAddress repoints relay.json at a new host for the same Worker — the
// custom-domain move. It returns the new origin; the transport follows
// on the next daemon start, which the result must say.
SetAddress(ctx context.Context, address string) (RelayUIDeployResult, error)
}
RelayUI is the service behind the endpoints. Implementations own every long-running or stateful part: the Cloudflare calls, relay.json, and starting the transport after a first deploy.
type RelayUIAccount ¶
RelayUIAccount is one Cloudflare account a token can reach, for the picker.
type RelayUIDeployRequest ¶
type RelayUIDeployRequest struct {
Token string `json:"token"`
AccountID string `json:"account_id,omitempty"`
Worker string `json:"worker,omitempty"`
}
RelayUIDeployRequest is the POST body for deploy and update. Token may be empty when one is stored (config/cloudflare.json) — the service falls back to it; a token that does arrive here is stored on success, by product decision, so the next update is a click. Either way the token never rides a response and never reaches a log.
type RelayUIDeployResult ¶
type RelayUIDeployResult struct {
NeedsAccount bool `json:"needs_account,omitempty"`
Accounts []RelayUIAccount `json:"accounts,omitempty"`
// Steps are the ✓ lines, in order — the same sentences the CLI prints.
Steps []string `json:"steps,omitempty"`
Origin string `json:"origin,omitempty"`
// JoinCommand is the hand-off line for other machines, secret included.
// Shown once, like the CLI's; it is not retrievable from Status.
JoinCommand string `json:"join_command,omitempty"`
// RestartNeeded is true when a relay transport was already running in
// this daemon: the new relay.json takes effect on the next restart, and
// the UI must say so instead of pretending.
RestartNeeded bool `json:"restart_needed,omitempty"`
}
RelayUIDeployResult is what a deploy or update answers. NeedsAccount is the one non-terminal shape: the token reaches several accounts and the UI must ask which, then POST again with account_id set.
type RelayUIStatus ¶
type RelayUIStatus struct {
// Configured says relay.json exists and parsed.
Configured bool `json:"configured"`
Origin string `json:"origin,omitempty"`
Worker string `json:"worker,omitempty"`
// CanDeploy is false in a dev build, which embeds no Worker; Reason is
// the sentence the UI shows instead of a button.
CanDeploy bool `json:"can_deploy"`
CanDeployReason string `json:"can_deploy_reason,omitempty"`
// Version is this binary's; DeployedVersion is what the relay's
// /api/health reported, empty when unreachable or unstamped. The UI
// offers an update when the two differ.
Version string `json:"version"`
DeployedVersion string `json:"deployed_version,omitempty"`
// HasToken says a Cloudflare token is stored (config/cloudflare.json), so
// deploy and update need no token in the request; AccountName is the
// account it deploys into. The token itself is never in any response.
HasToken bool `json:"has_token"`
AccountName string `json:"account_name,omitempty"`
// Transport and TransportOrigin are the relay leg's live state, straight
// from the daemon (SetRelayStatus) rather than from any config file. They
// exist because the welcome's relay snapshot is per-connection: a tab
// greeted while the daemon was still dialling never hears "connected" on
// that socket, and polling this is how the Remote screen and the Pair
// gate catch up without a reconnect. Filled by the handler, not the
// service — the handler is the one on the Server that holds the state.
Transport string `json:"transport,omitempty"`
TransportOrigin string `json:"transport_origin,omitempty"`
}
RelayUIStatus is what GET /api/relay/info reports: enough for the Remote screen to decide which of connect / update / nothing to offer. It carries no secret — the join secret stays in relay.json and in the one-time deploy result.
type ReleaseChecker ¶
type ReleaseChecker interface {
// Release answers from cache and refreshes out of band. It must not block
// on the network: this is behind a page load.
Release(ctx context.Context) ReleaseStatus
}
ReleaseChecker is the service behind the endpoint. cmd/flue implements it; this package holds no knowledge of GitHub, of tags, or of how often to ask.
type ReleaseStatus ¶
type ReleaseStatus struct {
// Current is this daemon's own version — "dev" for a source build.
Current string `json:"current"`
// Latest is the newest published release, without its leading v.
Latest string `json:"latest,omitempty"`
// URL is that release's page, for a reader who wants the notes.
URL string `json:"url,omitempty"`
// Update says Latest is genuinely newer than Current. A build ahead of
// the newest tag — anything from main — is not behind, and neither is a
// "dev" build, which corresponds to no release and so cannot be compared
// to one.
Update bool `json:"update"`
}
ReleaseStatus is what GET ReleasePath reports.
Latest is empty until a check has succeeded, and stays empty for a daemon that cannot reach GitHub. That is not an error state to render: a version nobody could look up is not news, and a tab that said "could not check for updates" would be reporting on the app's plumbing rather than on flue.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server serves the flue API and the embedded UI on loopback.
func (*Server) ListenAndServe ¶
ListenAndServe binds 127.0.0.1 only. No adapter ever binds 0.0.0.0.
It blocks until ctx is cancelled or the listener fails. A shutdown caused by ctx returns nil rather than http.ErrServerClosed: being asked to stop, and stopping, is not an error the caller has to recognise and filter out.
func (*Server) PairDevice ¶
func (s *Server) PairDevice(body []byte, peer string) PairOutcome
PairDevice runs the pairing ceremony on a request body whose provenance the transport has already checked: shape checks, redeem, register, broadcast.
It is everything handlePair used to be from the third check down, moved here so the relay leg answers a browser byte for byte the way the daemon's own origin does. The provenance check itself stays with each transport, because the two have nothing in common: over HTTP it is Host, Origin and fetch metadata; over the relay it is the announced origin matching the relay this daemon dialled.
It never returns a body naming a filesystem path or a refusal reason — see refusePair.
func (*Server) ServeConn ¶
func (s *Server) ServeConn(ctx context.Context, mc MessageConn, meta ConnMeta)
ServeConn serves one established, authenticated connection until it ends. It blocks. Authentication is the transport's job and is already done: this is the point past which every transport is the same.
The one thing it re-checks is the one thing that can have changed in between: a device whose credential was revoked while its handshake was in flight is told so and not served. See the comment on the markDeviceSeen call for why that check has to come after the connection is registered rather than before.
ctx is the caller's, and it deliberately bounds nothing here. The connection is parented to the server's base context instead, because the context that accepted it belongs to whoever handed it over — a request net/http stopped tracking at the hijack, or a relay's accept loop — and only baseCtx is what Shutdown cancels. See Server.baseCtx.
func (*Server) SetAuth ¶
SetAuth swaps the authenticator. Used by tests, which learn their port only after the listener is bound.
func (*Server) SetLogger ¶
SetLogger swaps the audit logger. The default writes to stderr, which launchd and systemd already capture; tests substitute a buffer.
func (*Server) SetRelayMachine ¶
SetRelayMachine records which machine this daemon is on the relay: the id it dials /daemon/<id> as, and the human label that goes with it. Both come from relay.json, read once at startup by the process that wires the transport up — which is the only caller, and why there is no un-set: a machine identity does not change while the daemon runs.
It is separate from SetRelayStatus because the two describe different things: the status is the socket's, reported by the transport as it dials and loses and regains it; the identity is the configuration's, true from the first welcome even while the transport is still connecting.
func (*Server) SetRelayStatus ¶
SetRelayStatus records what the relay transport is doing. It is the only way into that state and the transport is its only caller.
Nothing is broadcast. The status decides two things — what a welcome reports and which origin a pairing URL names — and both are read at the moment they are needed rather than pushed: a client that connected before the relay came up learns about it on its next connection, which is also when it could first act on it.
Two inputs are refused rather than stored, because both would have something downstream act on a state that cannot be true:
- A status outside the three constants above. The wire field is a closed set and the TypeScript type is a union of the same three, so a fourth would reach a client with no branch for it. Treated as off.
- "connected" with no origin. The origin is the entire use of a connected relay: it is the address a pairing URL names and the one a client shows. A socket that cannot name one is, to everything downstream, still dialling — and recording it as connected would hand pairStart an empty string to build a URL from.
func (*Server) SetRelayUI ¶
SetRelayUI installs the deploy service. Nil (a Server nobody wired) leaves the endpoints answering 404, which is also what a dev harness that constructs a bare Server gets.
func (*Server) SetReleaseChecker ¶
func (s *Server) SetReleaseChecker(c ReleaseChecker)
SetReleaseChecker installs the checker. Nil — a Server nobody wired, which is every test harness that does not care — leaves the endpoint answering 404, and the sidebar simply never offers an upgrade.