daemon

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 10, 2026 License: MIT Imports: 29 Imported by: 0

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

View Source
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 both of the relay's credentials — the `DAEMON_SECRET` the
	// Worker also holds, and the fleet key's seed, which nothing but this
	// fleet's own machines ever holds (spec/fleet-trust.md) — which is why it
	// is its own endpoint behind a click rather than a field on Info that
	// every page load would fetch: they should cross into a page exactly when
	// a human asked to see them. 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.

View Source
const (

	// LocalCSP is what this daemon serves its own UI under when it has no relay
	// configured. A daemon that has one serves LocalCSPFor(origin) instead —
	// see there for why the relay origin has to be in `connect-src`.
	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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.
View Source
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.

View Source
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

View Source
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")
)
View Source
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 DeviceLabel added in v0.2.0

func DeviceLabel(raw string) string

DeviceLabel normalises a device's self-chosen name: trimmed, bounded, and never empty, since it is what the devices screen shows next to a revoke button — an unlabelled row is one the user cannot safely act on.

Exported for the other place a device name enters this machine's registry: the Name on a fleet device certificate, which the relay transport writes into devices.json for a device some sibling machine paired (internal/transport/relay/channel.go). Signed is not the same as tame — the cert encoding bounds that field at 512 bytes and permits newlines and control characters — so it goes through this normaliser rather than a second, laxer one. One rule for what a device may be called, wherever the name came from.

func LocalCSPFor added in v0.2.0

func LocalCSPFor(relayOrigin string) string

LocalCSPFor is what this daemon serves its own UI under when relay.json names a relay: LocalCSP, plus that one origin in `connect-src`.

It is not a convenience. A page the daemon served on loopback reaches its own daemon over `ws://127.0.0.1:7717`, and everything *else* it reaches is on the relay: `wss://<relay>/client/<id>` for every other machine in the fleet, and now `https://<relay>/directory` to find out which machines those are. Neither is covered by `'self'` — a different origin is a different origin, whatever this daemon's relationship with it — so under the policy without this clause the browser blocks both. The socket failure looks like a machine that will not connect; the fetch failure looks like nothing at all, because `readDirectory` answers "no machines" for every fault by design. A loopback tab would quietly show a fleet of one.

One exact origin, and only the two schemes the page uses on it. That is a far narrower grant than the loopback clause beside it — which carries wildcard ports and is the standing item in docs/FOLLOW-UPS.md §6 — and it is the origin this daemon is configured to dial anyway. It comes from relay.json rather than from the live transport because a policy is fixed when the document is served: a tab loaded while the relay was still dialling would otherwise carry a policy that forbids the connection the page makes a second later, and nothing would correct it short of a reload.

The relay-served copy of the same bundle keeps RelayCSP, which grants nothing beyond 'self': there the relay *is* the origin.

func ReadRuntime

func ReadRuntime() (int, bool)

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

func ReadRuntimeRecord() (port, pid int, ok bool)

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

func WriteRuntime(port int) error

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

	// DeviceKey is the device's static public key — the thing the handshake
	// actually proved, and the identity every registry lookup that decides
	// something should be made against. Nil on the local transport, which
	// authenticates a machine-local session token rather than a device.
	//
	// It rides alongside DeviceID rather than replacing it because the two
	// answer different questions. The id is a 48-bit digest of these bytes and
	// is what the log lines, the Devices screen and the connection buckets
	// speak in; the bytes are what crypto.FindByKey compares, for the reason
	// that function spells out at length — Add deliberately permits two
	// devices with colliding digests, so an id alone can name more than one
	// key.
	DeviceKey []byte
}

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 DirectoryCounts added in v0.2.0

type DirectoryCounts struct {
	Connected   bool `json:"connected"`
	Entries     int  `json:"entries"`
	Verified    int  `json:"verified"`
	Machines    int  `json:"machines"`
	Devices     int  `json:"devices"`
	Revocations int  `json:"revocations"`
}

DirectoryCounts is what this daemon last read out of the fleet directory.

Entries is what the relay claimed; Verified is how much of it carried a signature under this fleet's key, and the gap between the two is the only interesting number here — a relay serving blobs this fleet did not sign is either a fleet key that has rotated or a relay that is not the one this machine thinks it is.

It is a layout shared with internal/transport/relay (DirectoryCounts there), converted rather than imported: this package must not depend on a transport, which is the same reason RelayUI is an interface.

type FleetPublisher added in v0.2.0

type FleetPublisher interface {
	PublishFleetBlob(blob []byte)
}

FleetPublisher publishes one signed fleet artifact — a device certificate or a revocation this daemon has just minted — to the relay's fleet directory, so the other machines in the fleet learn of it without being asked (spec/fleet-trust.md, "The fleet directory").

Publish must not block: its callers are the pairing ceremony and the revoke op, both of which are answering a client that is waiting, and neither has anywhere to put a network failure. Losing a publish is survivable by construction — everything publishable is also written to disk, and the implementation re-publishes what this machine holds on every reconnect — so the contract is "take this and go away", not "deliver this".

type Identity

type Identity struct {
	Key     noise.DHKey
	Devices *crypto.DeviceStore

	// Fleet is the fleet key from relay.json (spec/fleet-trust.md), and the
	// zero value means this daemon has none — a daemon that never joined a
	// relay, or joined one from before the key existed. With it, a pairing
	// ceremony also mints a fleet device cert and a revocation also mints
	// the signed record that keeps the key dead fleet-wide; without it,
	// both paths run exactly as they always did, minus the signing.
	Fleet fleet.Key
}

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

type PairOutcome struct {
	Status int
	Body   []byte
}

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

type RelayUIAccount struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

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, carrying both of
	// the credentials a machine needs to join: the daemon secret and the
	// fleet key's seed. Shown once, like the CLI's; it is not retrievable
	// from Status — RelayJoinPath rebuilds it, behind a click, for the same
	// reason.
	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"`
	// Problems are the faults that stop this daemon dialling the relay.json
	// it has — the same list, in the same words, `flue status` and `flue
	// relay status` print. Empty for a healthy file and for a machine with
	// no relay at all.
	//
	// It exists because Configured answers a narrower question than the
	// screen is asking. A file that parses is configured; a file the
	// transport refuses is not *usable*; and reporting the first as though
	// it were the second is exactly how an upgrade that silently ended
	// remote access looked from every surface flue has — one stderr warning
	// at startup, and three status reports all saying it was fine.
	Problems []string `json:"problems,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 serves.
	// Normally that is what its /api/health reported — empty when
	// unreachable or unstamped — except right after a deploy this daemon
	// performed, when it is the stamp the daemon shipped: the edge keeps
	// serving the previous Worker for a while after the API accepts a new
	// one, and a health read taken in that window would re-offer an update
	// that just succeeded. 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"`

	// Directory is the fleet directory as this daemon last saw it, or nil on a
	// daemon that is not reading one (no relay, or a relay from before the
	// directory existed). It is the second half of the answer to "is my fleet
	// wired up": Transport says this machine can be *reached*, and this says
	// it can hear what the other machines have signed — which is what a
	// revocation travels on.
	Directory *DirectoryCounts `json:"directory,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 New

func New(reg *session.Registry, auth *local.Auth, ui http.Handler, version string, identity Identity) *Server

func (*Server) ApplyFleetRevocation added in v0.2.0

func (s *Server) ApplyFleetRevocation(deviceKey, blob []byte) error

ApplyFleetRevocation is the receiving half of the fleet-wide kill switch: a revocation minted on some other machine, verified under the fleet public key by the transport that read it out of the directory, and now applied here.

Three things, in this order, and the order is the same one revokeDevice keeps for a locally-minted revocation:

  1. Record the revocation. This is what makes the key dead to both acceptance paths — crypto.FindByKey for rule 1, AddFromFleetCert for rule 2 — and it is first because it is the half that must not be skipped: an entry removed without its revocation on file is not revoked at all, since the device's own fleet cert would walk it straight back in on the next handshake.
  2. Drop the local registry row, if this machine had one. By key rather than by id: the caller holds 32 bytes and the id is a 48-bit digest of them (crypto.RemoveByKey says what that difference is worth).
  3. Close the device's live channels with the reason every revoked device gets. This is the part that makes it a kill switch rather than a bookkeeping change — a socket already established is the access, and a registry nothing re-reads would not take it away.

It is idempotent, which the push socket and the reconnect GET both require: hearing the same revocation twice records nothing new, removes nothing twice, and closes an empty set of connections.

Verification is deliberately not repeated here. It belongs to the reader that owns the fleet public key, and this method's contract is that it has already happened — which is why the parameters are the parsed key and the blob rather than a blob to be trusted.

func (*Server) Handler

func (s *Server) Handler() http.Handler

Handler returns the full HTTP handler: UI, JSON API, WebSocket, and mint.

func (*Server) ListenAndServe

func (s *Server) ListenAndServe(ctx context.Context, port int) error

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

func (s *Server) SetAuth(a *local.Auth)

SetAuth swaps the authenticator. Used by tests, which learn their port only after the listener is bound.

func (*Server) SetDirectoryCounts added in v0.2.0

func (s *Server) SetDirectoryCounts(read func() DirectoryCounts)

SetDirectoryCounts installs the reader for the line above. A func rather than a value because the numbers change under a socket this package does not own, and a snapshot pushed on every change would be a push per revocation for a field nothing subscribes to.

func (*Server) SetFleetPublisher added in v0.2.0

func (s *Server) SetFleetPublisher(p FleetPublisher)

SetFleetPublisher installs the directory client. Nil — a daemon with no relay, or any test's — means minted artifacts stay on this machine, which is exactly what a machine that is not on a relay should do with them.

func (*Server) SetLogger

func (s *Server) SetLogger(l *slog.Logger)

SetLogger swaps the audit logger. The default writes to stderr, which launchd and systemd already capture; tests substitute a buffer.

func (*Server) SetRelayMachine

func (s *Server) SetRelayMachine(id, name string)

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) SetRelayOrigin added in v0.2.0

func (s *Server) SetRelayOrigin(origin string)

SetRelayOrigin records the origin relay.json names, for the Content-Security- Policy alone.

Deliberately not the same field as the transport's `relayOrigin`, which is socket state and is empty until a dial succeeds. This is configuration: it is true from the moment relay.json is read, and the CSP on a document has to permit the connections that document will make later, including the ones it makes while the relay is still coming up.

func (*Server) SetRelayStatus

func (s *Server) SetRelayStatus(status, origin string)

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

func (s *Server) SetRelayUI(ui RelayUI)

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.

func (*Server) Shutdown

func (s *Server) Shutdown()

Shutdown closes every established connection. ListenAndServe calls it when its context is cancelled; it is exported so an embedder driving Handler directly can reach the same teardown.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL