Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Self-Hosted Rendezvous

intendant-connect — the hosted rendezvous behind connect.intendant.dev — is an open, first-class deployable, not a chokepoint. It introduces browsers to daemon route and presence records, carries account/route metadata, delivers optional encrypted Web Push notifications, serves the discovery client (the install scripts are release-pinned GitHub assets it at most redirects to), stores client-signed fleet metadata, and can carry daemon-terminated TLS through its optional reachability relay. It does not serve the daemon dashboard SPA or its WASM/static assets. A claim creates no daemon IAM principal or grant. The daemon stamps ambient hosted provenance role:none, so Connect-served code cannot reuse a claim, account assertion, or browser-key grant as authority. A separately enabled hosted-control lane can admit only a short-lived lease minted by the target daemon after trusted confirmation. Org grant documents are verified by the target daemon against its own trusted keys, and root authority remains on trusted local or independently reached direct-mTLS surfaces.

That is a precise boundary, not an absolute “zero-authority” claim. Connect is trusted for availability, its account and route metadata, push delivery, relay delivery, and the code it serves. Push payloads are encrypted to the browser subscription, but a malicious Connect-served page can lie about or exfiltrate anything exposed in the Connect UI. The install scripts sit outside its serving surface: they ship as release-pinned, transparency-logged GitHub assets, and /install.sh / /install.ps1 answer with at most a redirect whose target the out-of-band verifier pins. What it cannot do is turn any claim, account assertion, browser-key grant, or generic configuration edit into daemon authority. Its signed navigation hint also grants nothing: lease approval and enforcement terminate at the daemon. See Trust Architecture.

Build and run

cargo build --release --bin intendant-connect
./target/release/intendant-connect \
  --listen 127.0.0.1:9876 \
  --origin https://connect.example.com \
  --rp-id example.com \
  --data-file /var/lib/intendant-connect/state.json \
  --daemon-token <random-shared-token>
FlagEnvMeaning
--listenINTENDANT_CONNECT_LISTENBind address (put a TLS reverse proxy in front)
--originINTENDANT_CONNECT_ORIGINPublic origin browsers use; also the WebAuthn origin
--rp-idINTENDANT_CONNECT_RP_IDWebAuthn relying-party id (a registrable suffix of the origin’s host)
--static-rootINTENDANT_CONNECT_STATIC_ROOTDeprecated compatibility input; accepted but ignored. Connect serves only embedded discovery pages/assets. /app and /app.html redirect to /connect; every other unknown path is 404
--data-fileINTENDANT_CONNECT_DATA_FILEJSON state (accounts, claims, fleet records)
--daemon-tokenINTENDANT_CONNECT_TOKENShared deployment bearer required at registration unless open registration is enabled; also the admin-API credential. It is not the per-daemon polling credential
--release-tokenINTENDANT_CONNECT_RELEASE_TOKENDedicated bearer for release-manifest submissions; deliberately separate from the operator/admin daemon token. Without it, the submission endpoint returns 503 while reads remain public
--invite-requiredINTENDANT_CONNECT_INVITE_REQUIREDRequire a valid invite code for new-account registration; existing accounts are unaffected
--open-registrationINTENDANT_CONNECT_OPEN_REGISTRATIONSkip only the shared deployment bearer on registration (rate-limited; stale unlinked records expire). Registration still requires a fresh daemon-key signature; each success rotates a short-lived daemon-session credential required for poll/answer/error/dry/claim-proof. The token keeps guarding the admin API
--dns-zoneINTENDANT_CONNECT_DNS_ZONEFleet DNS: the delegated subzone this service answers for authoritatively (see below). All three --dns-* values or none
--dns-ns-nameINTENDANT_CONNECT_DNS_NS_NAMEThe NS host the parent zone delegates to (served in the apex SOA/NS)
--dns-listenINTENDANT_CONNECT_DNS_LISTENUDP+TCP bind for the DNS server, e.g. 0.0.0.0:53
--relay-listenINTENDANT_CONNECT_RELAY_LISTENRaw TLS/SNI passthrough listener. Requires at least one relay address
--relay-addressINTENDANT_CONNECT_RELAY_ADDRESSComma-separated public IP list published for relay-mode fleet DNS

The service speaks plain HTTP; terminate TLS in front of it (nginx, Caddy, a cloud load balancer). WebAuthn requires the public origin to be HTTPS. A systemd unit is just the command above with Restart=always and a writable state directory; the deploy script the default instance uses (scripts/deploy-connect-prod-alpha.sh) is a worked example.

A worked Caddy site block (the shape the default instance runs — the forwarding headers are load-bearing, see Reachability):

connect.example.com {
	encode gzip zstd

	reverse_proxy 127.0.0.1:9876 {
		header_up Host {host}
		header_up -X-Forwarded-Host
		header_up X-Forwarded-For {remote_host}
		header_up X-Real-IP {remote_host}
		header_up X-Forwarded-Proto {scheme}
	}
}

Fleet DNS: real certificates for daemons

The warning-free discovery option (Trust Tiers): delegate one subzone to the service and every registered daemon gets a real name — d-<hash>.<zone>, an opaque sha256-derived label (these names land in public CT logs) — plus a one-click Let’s Encrypt certificate from its Access card. The daemon publishes its own addresses (LAN addresses are the point: public name + real certificate

  • private address gives a warning-free public shell on your own network with no port forwarding), answers the ACME DNS-01 challenge through the service with daemon-signed requests, and keeps its private keys local. The service’s DNS authority covers exactly the delegated subzone and nothing else. That still makes it an authority over code at every name in the subzone, so the daemon classifies fleet SNI before IAM. With hosted control off, it serves only public shell/discovery bytes there. With hosted control on, unproved traffic remains anonymous role:none; protected HTTP/WSS admission requires a fresh proof by an approved lease and the compiled hosted route/method/frame/action wall. Direct signaling, MCP, and trusted-local fallback never become available merely because the request used a fleet name.

Setup, one time:

  1. In the parent zone (wherever example.com is hosted), add two records: A ns-fleet.example.com → <this box's public IP> and NS fleet.example.com → ns-fleet.example.com. Pin that IP (an elastic/static address) — replacing the box means keeping it.
  2. Open 53/udp and 53/tcp to the box. Binding :53 as a non-root service needs AmbientCapabilities=CAP_NET_BIND_SERVICE in the systemd unit.
  3. Run with --dns-zone fleet.example.com --dns-ns-name ns-fleet.example.com --dns-listen 0.0.0.0:53.

The register response then carries each daemon’s fleet_dns name; the daemon’s Connect card shows it with an Enable HTTPS discovery button. The daemon accepts a present hint only when its zone and name agree in the canonical d-<20hex>.<zone> form; incomplete or inconsistent metadata does not become durable fleet provenance. Address records persist in the state file and follow the daemon-record lifecycle (they survive link/release; the stale-unlinked sweep drops them). Each address generation is serialized per daemon, persisted before it replaces the live zone answer, and leaves both memory and the live answer on the prior generation if persistence fails. The commit runs as an owned transaction, so an HTTP deadline or disconnected caller cannot stop it between durable persistence and in-memory/live-zone publication. ACME TXT challenges are in-memory and self-expire. Posture: authoritative-only, Refused outside the zone, no AXFR, RFC 8482 minimal ANY, 60 s TTLs. Daemons validating against Let’s Encrypt staging set INTENDANT_ACME_DIRECTORY to the staging directory URL. Honest caveats: a single NS is a SPOF for fleet names (independently remembered direct routes keep working; renewals retry), Let’s Encrypt rate-limits new certificates per registered domain (~50/week — request a limit raise before any large fleet), and a hostile zone operator could redirect fleet names and mint certificates for them. CT logs make issuance public evidence, but that evidence does not protect an enrolled browser credential from same-origin code. The fleet-SNI gate plus the exact lease/preset evaluator is the authority boundary.

Pointing daemons at it

In intendant.toml:

[connect]
enabled = true
rendezvous_url = "https://connect.example.com"
daemon_id = "my-daemon"          # optional; defaults to the daemon public key
auth_token = "<the --daemon-token value>"
hosted_control_enabled = false   # daemon-local, restart-only, no env override

or via INTENDANT_CONNECT_RENDEZVOUS_URL, INTENDANT_CONNECT_DAEMON_ID, and INTENDANT_CONNECT_TOKEN. enabled = true with no rendezvous_url defaults to the hosted instance. The dashboard’s Access → Intendant Connect card drives all of this without touching the file: it toggles enabled, shows registration/link state, and reveals the one-time claim code to manage-grade sessions. The separate Hosted control card manages the lease ceiling and pending/active leases; ordinary Connect configuration does not enable it.

An unlinked daemon locally mints a single-use 12-word BIP39 claim code and URL and shows them in its startup log and Access card. Its fresh, identity-signed registration sends Connect only the code’s SHA-256 hash, timestamp, daemon id, and public key. Connect stores the hash with a 10-minute TTL and never receives or returns the plaintext. The printed URL places the phrase in a fragment (/connect#claim_code=...), which browsers do not include in HTTP requests or referrers. The Connect page reads and immediately scrubs that fragment, normalizes and hashes the phrase locally with Web Crypto, and sends only the base64url SHA-256 digest to the strict claim API. There is no plaintext or query-string compatibility path. The service matches the digest once, challenges the daemon, and verifies the daemon’s signed proof.

Every successful registration is itself single-use: the fresh signature must be newer than the previous accepted proof, and the response rotates a short-lived daemon-session credential. /api/daemon/next, answer, error, dry, and claim-proof require that credential even when --open-registration is enabled. Open registration removes only the shared deployment bearer; it never makes a public key or daemon id a polling token. The public registration edge is additionally bounded: request bodies cap at 4 KiB; daemon ids cap at 128 ASCII A-Z a-z 0-9 . _ - : bytes; public keys and signatures must be canonical unpadded base64url encodings of exactly 32 and 64 bytes; the general endpoint allows 120 requests per observed source per minute, while new identities allow 30 per observed source per hour; and at most 1,024 unclaimed daemon records may exist after the stale-record sweep. Existing identities can still refresh at capacity. If a reverse proxy supplies client-IP headers, it must overwrite rather than append or trust inbound forwarding headers, as in the Caddy example above; the global unclaimed-record cap remains the backstop against distributed sources.

Registration binds a daemon_id to its first identity key even before the route is linked. A second key cannot replace that binding or inherit the code already printed for the first key; an unlinked stale record must age out before the id can be registered afresh. This prevents open registration from moving a live one-time code between daemon identities.

Treat the code as a short-lived bearer token for this route link, not as an owner secret. The claim page asks for this one-time code only; it must never ask for a password, API key, recovery phrase, private key, or passkey secret.

Claim proofs come in two shapes. intendant-connect-claim-v1 is account-blind (legacy). intendant-connect-claim-v2 — signed whenever the challenge names the claiming account — binds the account’s user id and handle into the payload the daemon signs, so the account↔daemon route link is co-signed by the daemon’s own identity key instead of resting on the service’s assertion. The daemon persists that acknowledgment beside its identity key, and every later register response returns the service-asserted linked account (claimed_by_user_id/claimed_by_handle; the claimed_* names are retained for wire compatibility); the daemon cross-checks the two and surfaces the result in the Access card as co-signed, service-asserted, or mismatch (a re-bind the daemon never acknowledged). The transparency log records which proof protocol sealed each claim.

Claiming grants no authority, including on a fresh box with empty IAM. It creates no principal or grant; it only associates discovery and routing metadata with the account. A hosted Connect account assertion never authenticates to the daemon. Ambient hosted provenance remains role:none; a separately enrolled browser key, stored generic grant, hand-edited compatibility ceiling, or disclosure cannot enable hosted control. The optional lane begins with a separate fleet-origin request and trusted confirmation, then mints an exact expiring hosted_lease. Use a trusted local or independently reached daemon-served direct/mTLS surface for root access and lease confirmation. The packaged macOS app is not an alternative distribution anchor in this alpha: no Developer ID-signed/notarized release has been published.

Root bootstrap is deliberately outside this flow. Use intendant access setup from the machine’s console/SSH session or a direct mTLS root session. The former --owner <browser-key> shortcut is retired in this alpha because a fingerprint alone is not a complete certless remote authentication protocol. Never treat a Connect claim code as a password, owner secret, or proof of root authority.

Alpha migration. Earlier alpha builds could create an uncapped role:root client-key grant with origin connect-bootstrap on first claim. Loading that state now revokes every active grant on those legacy principals, records a revoke_legacy_connect_bootstrap audit event, and restores both compatibility ceilings to role:none. The account/route link survives as discovery metadata, but trusted re-enrollment is required.

Mixed-version rollout. The current Connect service returns 403 for authenticated browser offer, ice, and close calls before it mutates a queue, rate-limit bucket, or active-session record. Current daemons also drop those three event kinds before touching dashboard-control, IAM, or enrollment state, which protects them against older/self-hosted services. Restarting only the service cannot revoke a legacy peer-to-peer DataChannel that is already established: upgrade and restart the daemon, close every old Connect tab, and let the IAM migration revoke legacy connect-bootstrap grants.

Bindings are releasable from both sides, and both paths append daemon_unclaimed transparency-log entries: the linked account releases from the service UI, and the daemon posts a timestamp-fresh release signed with its identity key to POST /api/daemon/unclaim — the recovery verb for a squatted or mis-linked route (whose linked account would never release it), also exposed as the Access card’s Unlink account. After release, the daemon locally rotates its one-time claim code and registers the new signed hash.

Reachability for NAT’d daemons

Register responses echo observed_ip — the public address the service saw the poll arrive from. This remains useful reachability metadata and keeps the lower-level ICE-TCP transport testable, but it is not authority: the default build refuses hosted-provenance control before a Connect tab can use that candidate. A cloud box’s interfaces carry only private addresses (the public IP lives on the provider’s 1:1 NAT), so transport experiments can advertise an ICE-TCP candidate at observed_ip:gateway_port. The echoed address family follows how the daemon reached the service — a daemon polling over IPv6 advertises a v6 candidate that v4-only visitors cannot dial, so pin the daemon’s egress (or the service hostname it resolves) to v4 if your visitors are. Reachability metadata only: a lying proxy chain could at worst advertise an unreachable candidate.

Because the service reads the caller’s address from X-Forwarded-For (falling back to X-Real-IP), the reverse proxy in front of a self-hosted instance must set one of those headers if it relies on this reachability metadata. With a plain proxy pass and no forwarding headers, observed_ip stays empty. Verify the full chain after deploying with the operator bearer (the deliberately unsigned deployment probe is accepted only because that token already guards the admin API):

curl -s -X POST https://connect.example.com/api/daemon/register \
  -H "authorization: Bearer $INTENDANT_CONNECT_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"protocol":"intendant-connect-rendezvous-v1","daemon_id":"observed-ip-probe","daemon_public_key":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","claim_code_hash":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","issued_at_unix_ms":0,"signature":""}' \
  | grep observed_ip   # must echo YOUR public IP, not null

(scripts/deploy-connect-prod-alpha.sh runs this probe automatically after every deploy and fails loudly when the echo is missing.)

Caddy gotcha (this bit the default instance): within a reverse_proxy block, header_up -X-Forwarded-For deletions are applied after header_up X-Forwarded-For {remote_host} sets, so the strip-then-set idiom deletes the value it just set. Use the set alone — a set already replaces anything the client supplied.

Reachability relay (ciphertext SNI passthrough)

observed_ip only helps a client that can open a direct connection. A daemon behind NAT with no port forward is unreachable at its fleet name except on the LAN. The reachability relay closes that gap without Connect ever terminating TLS or seeing plaintext:

  • A raw TCP listener (--relay-listen) peeks each connection’s TLS ClientHello to read the SNI without terminating the handshake (fragmented ClientHellos are handled; non-TLS bytes are refused). When the SNI names a registered fleet label whose daemon holds an active tunnel, the relay splices the raw bytes to that daemon. Everything else is refused.
  • Each daemon holds a persistent control channel to the service (POST /api/relay/next, a long-poll authenticated by the daemon identity key with the same signed/freshness discipline as /api/dns/publish). When a browser connects, the relay mints a single-use nonce, hands it to the daemon over the control channel, and the daemon dials back a data connection carrying that nonce. The relay correlates the two and splices them 1:1 (the dial-back hello is read under one overall deadline and a small byte cap, so a slow writer cannot hold the slot). On the daemon side the tunnel opens a second connection to a dedicated, ephemeral, loopback-only gateway ingress instead of the public gateway listener. Each fallback or exact-name queue has its own wake signal, so activity for one route does not wake parked polls for unrelated routes.
  • Control protocol v2 also binds a normalized, sorted list of exact custom names and a process-stable poller id into that daemon signature. Exact-name liveness and dialbacks remain attached to the v2 poller that supplied the certificate proof; a rolling v1 sibling can refresh and consume only the derived fleet-label fallback. An exact SNI claim routes only when one daemon identity owns it; competing live claims fail closed. A separate daemon-signed disconnect protocol removes that poller’s exact and fallback registration immediately instead of waiting for the liveness timeout. Each accepted exact poll has a keyed generation: disconnect or a superseding poll wakes and invalidates the prior generation, which can neither recreate the withdrawn route nor consume dialbacks queued for its replacement.
  • The browser’s TLS handshake therefore completes end-to-end against the daemon’s own fleet certificate. Connect moves only ciphertext.

The relay is availability-only: it terminates no TLS, holds no certificate, mints no authority, and logs no plaintext. The gateway records which listener accepted each connection before it parses TLS or HTTP. Connections from the relay-only ingress remain anonymous role:none unless one of two independent per-daemon opt-ins validates its own credential class. The optional hosted-control lane validates a fresh lease proof: that carve admits only explicitly classified HTTPS routes, while /mcp, direct signaling, trusted-local fallback, unknown routes, and every unproved request remain refused, and WebSocket upgrade additionally consumes a seconds-lived one-use ticket minted by a proof-bound HTTPS request. The optional peer-admission lane ([connect] relay_peer_admission, below) admits Approved, unexpired peer identity records into the ordinary peer transport-auth ladder. The tunnel’s loopback last hop therefore cannot qualify for trusted-local authority even when the encrypted request contains Host: localhost; fleet-SNI classification is an independent second gate. Non-TLS bytes are dropped before the gateway’s raw ICE-TCP and cleartext-MCP demultiplexer, so the relay-only listener cannot reach either local lane.

Relay peer admission (daemon-to-daemon federation through the relay)

[connect] relay_peer_admission = true (default false; boot-pinned — read once at gateway spawn, restart to apply; deliberately no environment override) lets this daemon’s own paired peer daemons reach it through the relay and, equivalently, through any fleet-name dial. The admitted class is exactly a TLS client certificate that resolves to an Approved, unexpired peer identity record — a daemon-side credential the target’s own access CA minted through an owner-consented lane (pairing invite, doorbell approval, or org-grant materialization). The record check runs per request, so revoking or expiring an identity kills its next request. Admission converts transport provenance only: the connection proceeds into the same remote-client-auth, grant-minting, and per-operation profile/IAM ladder a direct-lane peer walks, and the peer profile the owner granted remains the sole control-depth authority. Browser-enrolled mTLS certificates resolve no peer record and keep the discovery-only refusal byte-identical in both key states; the hosted-control browser lane and its switch are independent in both directions. Connect needs no changes and learns nothing — under TLS 1.3 the client certificate crosses the relay inside the encrypted handshake.

Relayed federation links are episodic by design, not permanent: the relay tears down splices idle for 120 s in either direction, caps each splice at 512 MiB per direction, and drops everything on a relay restart. Two daemon-side mechanisms make that livable. The dialing peer’s control link originates a WebSocket ping every ~30–35 s (jittered per peer), so an idle-but-healthy link never hits the idle teardown and a half-open link surfaces as a prompt write error instead of a silent hang. When a splice does die — byte cap, relay restart, network blip — the peer actor walks its ordinary jittered reconnect backoff (0.5 s–30 s) and re-probes every transport candidate, so the link self-heals at the cost of a fresh dial-back. Consumers should treat a relayed peer as a session-with-reconnects, exactly like a direct peer on a flaky network. Control operations and signaling ride this link; display media and file-transfer bytes ride WebRTC and need a direct route or provisioned TURN — a relay-only-reachable peer has no media path.

Enable it with the all-or-nothing --relay-* group (both flags or neither; default off, mirroring --dns-*):

intendant-connect \
  --relay-listen 0.0.0.0:443 \      # raw passthrough port (browsers + dial-backs)
  --relay-address 203.0.113.10      # public address published in fleet DNS

Equivalently INTENDANT_CONNECT_RELAY_LISTEN / INTENDANT_CONNECT_RELAY_ADDRESS.

Deployment notes:

  • The relay must receive raw TLS. Do not place --relay-listen behind a TLS-terminating reverse proxy — that would break the passthrough. Expose the port directly, or front it with a TCP (layer-4) passthrough only.
  • The relay and fleet DNS are usually co-deployed: a relay-mode daemon publishes POST /api/dns/relay (daemon-signed) so the zone answers its fleet label with --relay-address instead of the daemon’s own (NAT’d) addresses. The store/serve split is unchanged — dns.rs serves the substituted address verbatim.
  • Abuse is bounded by pre-demux global and per-source-IP connection caps, a per-tunnel cap, a per-connection byte cap, idle teardown, and a bounded dial-back wait. The daemon caps active dialback tasks, applies one deadline across relay connect, nonce hello, private-ingress connect, and provenance preamble, and incrementally caps every control response before JSON or error decoding. For daemon-local public-ceremony admission, the relay maps each route and browser source address to a process-salted opaque bucket and carries that bucket beside the dialback nonce. The gateway uses it only for availability fairness; it never participates in identity or authority.

A daemon opts in through [connect] relay_enabled + relay_endpoint (see the configuration reference). It then holds the control channel, dials back browser connections into the gateway’s private relay ingress, and publishes relay-mode fleet DNS only after a successful control poll has established this process’s readiness. Every boot with a configured rendezvous starts the DNS reconciler: disabled, invalid, disconnected, or not-yet-ready tunnel state explicitly converges toward enable = false, including stale relay publication left by a prior process. A successful withdrawal is not complete until the daemon has republished its direct addresses against the same pinned rendezvous generation; failure of either step keeps the whole transition retryable. Certificate issuance and renewal publish address refreshes through the same coordinator: while relay mode is established they retain the daemon’s direct fallback locally without overwriting the live relay answer. That ingress binds only to an ephemeral 127.0.0.1 port, is never advertised, and does not share the public listener’s trusted-local classification. Connect runtime settings may move the ordinary registration client to another destination, but an already running tunnel keeps its signed control URL, relay-mode DNS calls, credentials, daemon identity, and raw dialback endpoint on one boot configuration generation until restart. Disabling Connect cancels that generation’s in-flight control poll and dialbacks, sends its signed disconnect, and publishes enable = false to /api/dns/relay; re-enabling resumes the same boot generation only after the registration gate is closed for fresh fleet-zone observation. Connect shows its Request control navigation only when registration carries the daemon-signed hosted-capability hint and that daemon has a relay-mode DNS record; older daemons and direct-mode DNS remain discovery-only in the directory.

End-to-end transport validation

scripts/connect-transport-e2e.cjs asserts the flag-off baseline — a browser registers at the rendezvous, links a fresh daemon with its locally minted one-time code, receives no authority from that claim, and remains refused at immutable role:none even when a trusted fixture creates a local grant for its browser key — entirely locally: no cloud resources, no real accounts. It spawns intendant-connect (--open-registration, localhost WebAuthn origin), a ~30-line X-Forwarded-For-injecting forward proxy standing in for the production reverse proxy, and a scratch-HOME daemon (keyless, PROVIDER=mock) whose empty IAM receives no claim-time mutation; headless Chromium then mints the passkey account with a CDP virtual authenticator and walks the real /connect signup → claim-code link flow.

The validator requires the daemon IAM to remain unchanged by linking, checks that registration never exposes plaintext code and rotates a daemon-session credential, and proves authenticated browser offer, ice, and close calls return 403 at the service before enqueueing anything, both before and after an adversarial local operator grant. A daemon-side regression separately feeds all three event kinds as if they came from an older/self-hosted service and verifies that dashboard-control, IAM, and enrollment state stay unchanged. Direct/local dashboard-control validators cover successful DataChannel and ICE transport; the Connect validator expects zero hosted control sessions.

The Rust gateway E2E web_gateway::listener::tests::hosted_lease_is_the_only_relay_control_authority then covers the opt-in carve end to end: signed doorbell creation through the real relay ingress, trusted-local approval, proof-bound HTTP, proof replay refusal, /mcp refusal, one-use ticketed WebSocket admission, preset action denial, ticket-reuse refusal, and live socket closure after revocation. Its peer-lane siblings (relay_peer_admission_*, same module) pin the admission key’s whole contract: key off, refusals stay byte-identical with an active peer identity presented; key on, active records (org-materialized included) land in the existing per-profile ladder with direct-lane parity while browser-enrolled certificates, revoked/expired records, and anonymous callers keep today’s refusals.

cargo build --bin intendant --bin intendant-runtime --bin intendant-connect
node scripts/connect-transport-e2e.cjs      # target/debug; --release for release bins

Operator battery, not CI: it needs a Chromium (Playwright’s browser or CHROME_PATH/CHROME_BIN; see scripts/lib/browser-automation.cjs) and one routable non-loopback IPv4 interface (auto-detected; --lan-ip overrides). Prints staged progress, exits 0/1, cleans up its scratch state (kept on failure for inspection).

The service stores each org’s latest root-signed revocation list, blind: POST /api/orgs/revocations/publish accepts a list whose embedded signature verifies and whose seq is not lower than the stored one; GET /api/orgs/revocations?handle=&root_key= serves it publicly. Member browsers fetch the list for orgs they hold documents for and carry it to every daemon they visit (the daemon’s own public apply endpoint enforces signature and monotonic seq again). A malicious board cannot forge a newer list because it lacks the root signature, and it cannot make a daemon that already applied sequence N accept an older sequence. It can withhold the latest list—or serve a still-valid older list to a fresh daemon with no local sequence history—so availability and first-sync freshness still depend on the courier. The list contains only org-public revocation data.

Publication is bounded: a list is capped at 64 KiB, the handle must be shaped like an org handle, per-source rate limits apply (with a separate, much tighter hourly budget for never-before-seen (handle, root_key) pairs), and the board holds at most 1024 distinct records — new pairs are refused once it is full, while an org already on the board can always publish an updated list. Republishing the currently stored sequence is acknowledged without a store write.

Notifications

Signed-in browsers can opt into Web Push alerts (Advanced → Notifications). Two alert kinds exist, flagged per subscription (GET /api/push/subscriptions lists yours; POST /api/push/preferences flips notify_presence / notify_requests per endpoint):

  • Presence (notify_presence, on by default when you enable push): a linked daemon stopped polling (default: offline for 3 minutes; INTENDANT_CONNECT_PRESENCE_OFFLINE_MS) or came back. Composed purely from the polling presence the rendezvous already sees.
  • Pending agent requests (notify_requests, strictly opt-in): a daemon reports that an agent→user request — a command approval or a question — has sat unanswered with no dashboard connected (POST /api/daemon/notify, signed with the daemon’s registered identity key like unlink/DNS publishes, rate-limited, linked daemons only). Privacy posture, load-bearing: the nudge wire and the push payload carry only the request kind, the daemon’s display label, and a session display label — never command text, question text, file paths, or any other work content. The service stays zero-knowledge about the work itself; the payload constructor in push.rs pins this by test. The daemon side is conservative by construction: a 45-second grace period, only when no dashboard has connected since the request appeared, one nudge per session per 10 minutes, silent degrade when unlinked or offline (attention_nudge.rs).
  • Hosted lease requests (also under notify_requests): the daemon may report only that a hosted-control doorbell is waiting. The nudge carries no request id, browser key, preset, lifetime, or requester label; it tells the owner to open a trusted daemon client for the actual review.

Payloads are encrypted to each browser subscription (RFC 8291 — the push relay carries ciphertext), and the VAPID signing key is generated automatically into the state file on first start. Dead subscriptions are pruned on 404/410. Self-hosters get both kinds with zero extra configuration — daemons pointed at your rendezvous nudge it exactly as they would the hosted one.

Transparency log and attestations

Every name binding the service hands out is committed to an append-only RFC 6962-shaped Merkle log: which public key a computer had when its route was linked, handle creations, org revocation-list publications, verified badges, handle reclamations — and the served-artifact and release manifests described below. The signed tree head is public (/api/log/sth, ES256 key auto-generated into the state file) along with entries, inclusion proofs, and consistency proofs (/api/log/{entries,proof,consistency,find,artifact-manifest,release-manifest}). Browsers pin the tree head and verify consistency on every visit (Advanced → Transparency log), so rewriting history is detectable, not merely forbidden.

Accounts can attach verified identities as decoration (Advanced → Verified identity): a _intendant.<domain> TXT record checked over DNS-over-HTTPS (INTENDANT_CONNECT_DOH_URL overrides the resolver) or a public gist containing the claim line (INTENDANT_CONNECT_GIST_BASE). Badges appear in the public directory (/api/directory/<handle>) and in the log. Verification never gates anything — keys stay the identity.

Code transparency for the served Connect client

The log also commits what the service serves, not just what it says (Trust Tiers, first-contact rung three: the hosted origin’s residual power is serving a different bundle). At startup the service hashes every embedded artifact it can serve, exactly as this instance renders it (/, /connect, /access, /trust, /logo.svg, /favicon.png, the embedded /sw.js push worker, and the landing screenshots; the install scripts are deliberately absent — they are release assets committed to this same log as release_manifest entries, and the installer routes serve only a redirect the verifier checks against its pinned targets) — and appends an artifact_manifest entry when the result or its canonical serving origin differs from the latest logged one. The entry carries artifact_origin (the configured public origin serving those bytes), artifacts (a path-sorted list of {path, sha256} with lowercase-hex hashes, comparable to sha256sum output), bundle_version (the crate version), git_sha (the build’s commit, -dirty suffixed for uncommitted trees), and manifest_hash — sha256 over the canonical byte string intendant-artifact-manifest-v1\n then {path}\t{sha256}\n per artifact. The v1 hash intentionally remains bytes-only for rolling-upgrade compatibility; inclusion of the whole leaf in the signed tree authenticates artifact_origin too. GET /api/log/artifact-manifest returns the current entry with its log index, an inclusion proof, and the signed tree head, all computed coherently.

Verification is deliberately out of band — page JS can never honestly verify the origin that serves it:

intendant hosted-verify                     # the default rendezvous
intendant hosted-verify --connect https://connect.example.com

The verifier fetches the logged manifest, checks the tree-head signature, the entry’s inclusion proof, and consistency against the tree head pinned under the daemon state root (~/.intendant/hosted-verify/<host>.json, honoring $INTENDANT_HOME), and the highest verified artifact-manifest index pinned beside it (~/.intendant/hosted-verify/<host>.artifact-manifest.json). A lower manifest index, or different manifest hash at the pinned index, is a loud verification failure even though that older leaf remains included in the append-only tree. Once observed, the signed artifact origin is pinned there too; a later origin change or omission is a loud failure. Leaves written before artifact_origin existed retain the old behavior of fetching below the rendezvous URL, and the first new leaf upgrades the companion pin. The verifier then downloads every listed artifact from the signed canonical origin exactly as a browser would and compares hashes — nonzero exit and a per-artifact diff on divergence. When the manifest omits the installer routes (every redirect-era deployment), the verifier additionally requires /install.sh and /install.ps1 to answer with a redirect to exactly the pinned GitHub release-asset URLs — a quietly re-aimed convenience redirect is a loud failure. Manifests that still list installer bodies (pre-redirect deployments) get them hash-verified like any other listed artifact. Metadata bodies, proof arrays, artifact lists, and their strings are all bounded before verification work. Manifests must have unique, strictly sorted paths and cover the stable Connect pages, service worker, and brand routes; those minimum routes cannot be removed and an empty declaration cannot pass. Each artifact is capped at 64 MiB; one manifest run is additionally capped at 256 MiB and five minutes, with bytes consumed by an over-limit stream charged to that aggregate budget and with bounded response-header and between-chunk idle waits. Verification fetches never follow HTTP redirects. A declared split artifact origin must keep the rendezvous scheme and use either the same host or its single-label apex/subdomain counterpart (for example, connect.intendant.devintendant.dev). Before fetching a split origin, the verifier resolves it directly without ambient proxies, rejects local, loopback, link-local, private, metadata, documentation, benchmark, multicast, reserved, or mixed public/private answers, and pins the request client to the validated addresses to close DNS rebinding. Thus the signed field supports the production metadata/site split without giving the service an arbitrary network fetch primitive. Every daemon with Connect enabled also runs this check on boot and then daily as an advisory tripwire (the CT tripwire’s sibling): a divergence flips hosted_bundle_state to alert on the Connect status payload and raises HOSTED CODE ALERT on the dashboard’s Connect card; network failures only stamp hosted_bundle_last_error and never block anything. A deploy that changes the compiled Connect pages/assets without restarting the service cannot change the running bytes; restart to serve and log the new embedded bundle.

Honest limits. A malicious server can still serve targeted different bytes to one victim, once — no log prevents that. What the log plus independent monitors from multiple vantage points buy is that sustained or later-denied equivocation becomes evidenced: the operator is publicly committed to a manifest history, every daemon is a monitor from its own vantage point, and “we never served that” stops being deniable. The verifier fixes a minimum route set independently of the service’s declaration. Additional embedded assets remain service-declared, but an undeclared executable payload still requires a modified (hash-diverging) entrypoint or service worker. A transforming proxy between verifier and service (one that rewrites bodies) will surface as a divergence; the verifier sends no Accept-Encoding, so ordinary compression layers do not.

Reproducibility. A manifest entry maps back to source: take the entry’s git_sha, check out that commit, and rebuild. The embedded Connect pages are deterministic functions of the public origin; run intendant-connect locally with the same --origin and hash the manifest’s paths. The daemon-only static/app.html, WASM, and vault kernel are intentionally absent because this binary cannot serve them.

Release transparency

The same log commits what the project releases, closing the update channel’s side of the story (Trust Tiers): after publishing to GitHub Releases, the tag-triggered release pipeline (.github/workflows/release.yml) hashes every uploaded artifact — the app bundle, the stamped install.sh / install.ps1 release assets, each artifact’s detached PGP .asc signature, and the public signing key itself, so the canonical install one-liner’s bytes and the signatures over everything are all logged — and submits a release_manifest entry — tag, version, platforms, pgp_fingerprint (the release signing key’s primary fingerprint, uppercase hex), and a name-sorted artifacts list of {name, sha256, size} (lowercase-hex hashes, comparable to sha256sum output), plus manifest_hash: sha256 over the canonical byte string intendant-release-manifest-v1\n{tag}\n then {name}\t{sha256}\t{size}\n per artifact. (manifest_hash deliberately covers {tag, artifacts} only — external monitors keep recomputing it from any faithful copy — while the log tree binds every leaf field, pgp_fingerprint included.) Submission is POST /api/log/release-manifest, gated by a dedicated bearer token (--release-token / INTENDANT_CONNECT_RELEASE_TOKEN; the pipeline holds it as the CONNECT_RELEASE_TOKEN repository secret). The token is deliberately not the operator daemon_token: the CI credential can only ever append release manifests. Submission is also fail-closed on signature coverage: a manifest is refused unless the public key asset RELEASE-SIGNING-KEY.asc is in the list, every non-.asc artifact is accompanied by its detached .asc, no signature is stray, and pgp_fingerprint is well-formed — an unsigned or half-signed release cannot even be logged. With no token configured the endpoint answers 503; identical re-submissions dedupe; a changed manifest for an existing tag appends a new entry — republished artifacts become public history, never silent replacement. Reads stay public: GET /api/log/release-manifest[?tag=<tag>] returns the latest entry (for a tag) with its index, inclusion proof, and signed tree head, coherently.

intendant hosted-verify --releases              # the latest logged release
intendant hosted-verify --releases v0.3.0       # this tag MUST be logged
intendant hosted-verify --releases v0.3.0 --download

The default mode verifies the log legs exactly like the bundle check (tree-head signature, inclusion proof, consistency against the same per-host pin), then checks the release’s PGP identity against the verifying binary itself — the logged pgp_fingerprint must equal the compiled-in pin of the repo-committed RELEASE-SIGNING-KEY.asc, the published key asset must hash to exactly the committed key bytes, and per-artifact .asc coverage must be complete (cryptographic signature verification stays gpg --verify’s job: the ritual in Getting Started’s “Verifying a release”) — then compares the logged artifact list against the GitHub release’s asset metadata — names, sizes, and the sha256 digests the API reports — without downloading multi-hundred-MB artifacts; assets on the release that the log never blessed are flagged too. Release tags are encoded as one GitHub API path segment (including tags that contain /). --download upgrades to fetching every artifact and hashing it against the log — the strongest check. Release downloads permit progressing large transfers up to the per-artifact cap instead of imposing the metadata client’s short total timeout; connection, response-header, and between-chunk idle waits remain bounded. With an explicit tag, a release absent from the log is a failure (exit 1): an unlogged release is exactly what the check exists to catch. (--repo <owner/name> points self-hosted forks at their own repository, --github-api <url> points at a GitHub Enterprise or rig origin, and the optional CONNECT_RELEASE_URL repository variable points their pipeline at their own rendezvous. A fork that mints its own release key builds its own hosted-verify — the compiled pin is the trust anchor, so a foreign key against this binary is a loud failure, not a soft note.) The macOS app’s update check (launch and “Check for Updates…”) also asks the log about the release it is offering and appends a “publicly committed / NOT committed / couldn’t check” advisory line — fail-open like the bundle tripwire: a log outage never blocks updating, and the error is surfaced instead of swallowed.

Dormant-handle reclamation is stated policy: an account with zero linked daemon routes and no sign-in for the configured window loses its handle (the account survives, renamed). Enforcement is opt-in via INTENDANT_CONNECT_RECLAIM_AFTER_MS (unset/0 = off) and every reclamation is logged.

Discovery

A daemon with Connect enabled advertises its rendezvous in its agent card (/.well-known/agent-card.jsonrendezvous_base, connect_daemon_id), and the dashboard records it in the signed fleet records (connect_signaling_base, fleet-record payload v2). Those fields are retained as route/protocol compatibility metadata; the hosted directory never turns them into the retired /app?connect=1 signaling path. A claimed daemon may separately sign a hosted-control capability on registration. Connect displays Request control only when that signature verifies and the daemon has a relay-mode fleet-DNS route, then performs a plain navigation to https://d-<hash>.<zone>/. The public doorbell at that origin grants nothing; the target daemon—not the rendezvous—serves the page, verifies the tab’s proof, and mints any approved lease. An enabled daemon may also attach its separately signed fleet-certificate ledger to registration. Connect stores and projects that document verbatim for signed-application observation; its daemon signature, exact fleet origin, and canonical serial set remain independently verifiable. An independently recorded direct daemon URL still opens the daemon’s own HTTPS/mTLS root-capable origin.

A daemon can additionally register an opt-in user-owned custom name. The registration is signed by the same daemon identity key and carries a possession proof made with the matching publicly trusted certificate key. Connect verifies the certificate chain, singleton exact-name SAN, and proof signature before using it as an exact SNI routing hint. Certificate issuance, WebAuthn, and bounded lease minting remain on the daemon.