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>
| Flag | Env | Meaning |
|---|---|---|
--listen | INTENDANT_CONNECT_LISTEN | Bind address (put a TLS reverse proxy in front) |
--origin | INTENDANT_CONNECT_ORIGIN | Public origin browsers use; also the WebAuthn origin |
--rp-id | INTENDANT_CONNECT_RP_ID | WebAuthn relying-party id (a registrable suffix of the origin’s host) |
--static-root | INTENDANT_CONNECT_STATIC_ROOT | Deprecated 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-file | INTENDANT_CONNECT_DATA_FILE | JSON state (accounts, claims, fleet records) |
--daemon-token | INTENDANT_CONNECT_TOKEN | Shared deployment bearer required at registration unless open registration is enabled; also the admin-API credential. It is not the per-daemon polling credential |
--release-token | INTENDANT_CONNECT_RELEASE_TOKEN | Dedicated 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-required | INTENDANT_CONNECT_INVITE_REQUIRED | Require a valid invite code for new-account registration; existing accounts are unaffected |
--open-registration | INTENDANT_CONNECT_OPEN_REGISTRATION | Skip 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-zone | INTENDANT_CONNECT_DNS_ZONE | Fleet DNS: the delegated subzone this service answers for authoritatively (see below). All three --dns-* values or none |
--dns-ns-name | INTENDANT_CONNECT_DNS_NS_NAME | The NS host the parent zone delegates to (served in the apex SOA/NS) |
--dns-listen | INTENDANT_CONNECT_DNS_LISTEN | UDP+TCP bind for the DNS server, e.g. 0.0.0.0:53 |
--relay-listen | INTENDANT_CONNECT_RELAY_LISTEN | Raw TLS/SNI passthrough listener. Requires at least one relay address |
--relay-address | INTENDANT_CONNECT_RELAY_ADDRESS | Comma-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:
- In the parent zone (wherever
example.comis hosted), add two records:A ns-fleet.example.com → <this box's public IP>andNS fleet.example.com → ns-fleet.example.com. Pin that IP (an elastic/static address) — replacing the box means keeping it. - Open 53/udp and 53/tcp to the box. Binding :53 as a non-root service
needs
AmbientCapabilities=CAP_NET_BIND_SERVICEin the systemd unit. - 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.
Claiming, co-signed route links, and release
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:rootclient-key grant with originconnect-bootstrapon first claim. Loading that state now revokes every active grant on those legacy principals, records arevoke_legacy_connect_bootstrapaudit event, and restores both compatibility ceilings torole:none. The account/route link survives as discovery metadata, but trusted re-enrollment is required.
Mixed-version rollout. The current Connect service returns
403for authenticated browseroffer,ice, andclosecalls 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 legacyconnect-bootstrapgrants.
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-listenbehind 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-addressinstead of the daemon’s own (NAT’d) addresses. The store/serve split is unchanged —dns.rsserves 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 inpush.rspins 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.dev → intendant.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.json → rendezvous_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.