Architecture
ADR 0002: Network and identity
Status: accepted, 26 Sep 2026. Its identification of callers by Tailscale's whois is superseded by ADR 0050: a caller is a device paired to the person's identity chain. Supersedes the "served with tailscale serve, identified by
Tailscale's identity headers" sentence in SPEC 7.1 and 7.10.
Context
The Deck lives at https://<you>.vyre.run, which resolves to the box's Tailscale address, so
only the user's own devices can reach it. There is no separate login: the person is whoever
Tailscale says is on the other end. Only the box's owner (network.owner, a Tailscale login)
is served.
The box is not a quiet machine. Claude Code sessions, watchers and whatever commands they run
all execute on it. Any of them can open a TCP connection to 127.0.0.1 and write any header it
likes. So whatever tells vyred "this request is the owner, on their phone" must be something a
local process cannot produce.
We looked at tailscale serve first, because the spec named it. Three ways to use it, and why
each fails:
- HTTPS serve to a loopback port, trust
Tailscale-User-Login. Serve strips those headers from tailnet clients and sets them itself, but any local process can connect straight to the loopback port and set them. Forgeable. This is the problem the brief named. - Serve to a unix socket, or add a per-boot secret to the proxied path. A socket blocks
other Unix users but not processes of the user vyred runs as, and those are exactly Claude's
processes. A path secret lives in the serve config, which any local user can read through
tailscaled's LocalAPI (
tailscale serve status --jsonneeds no privilege). Both are forgeable by the processes we care about. - Serve's certificate. Serve terminates TLS only for the node's own
*.ts.netname. It cannot present a certificate for<you>.vyre.run, so the address the product promises would fail the TLS handshake. Raw TCP forwarding (serve --tcp) passes our own TLS through, but vyred then sees every connection coming from loopback; the real source arrives only in a PROXY protocol header, which a local process can forge on the same loopback port.
The source address of a packet that arrived over WireGuard is the one thing a local non-root
process cannot fake. Tailscale authenticates it cryptographically, and tailscaled maps it to a
node and a login (tailscale whois).
Decision
vyred terminates TLS itself, on the tailnet interface, and identifies each connection by its
source address through tailscale whois. tailscale serve is not used, and no identity header
is ever read.
Listeners
| Listener | Where | Who | Identity |
|---|---|---|---|
| Local API | ~/.vyre/vyred.sock, mode 0600 | the Unix user vyred runs as: the CLI, the Harness hooks, the Capsule | x-vyre-caller, a label only (cli, harness, hook, mcp, capsule); anything else, module:* and tailnet:* included, becomes local |
| Tailnet | the box's Tailscale addresses, port 443, TLS | people on their own devices | tailnet:<login>, from whois of the TCP peer |
| Onboarding | 127.0.0.1:7300 (next free port if taken), plain HTTP; 7301 on a Mac chosen as Server (ADR 0039), which never attempts 7300 at all | the person installing, before an owner exists | onboard, from a one-time token exchanged for a session header |
-
The tailnet listener binds only Tailscale addresses. On Linux under systemd it is a socket unit,
ListenStream=443withBindToDevice=tailscale0, handed to vyred as file descriptor 3. vyred needs no capability to own port 443, and no child process (Claude, a watcher) can take the port first. Without systemd (a Mac acting as a box, development), vyred binds each address fromtailscale statusitself, onnetwork.port(default 443). -
Every connection is identified once, by its peer address. vyred runs
tailscale whois --json <ip>(cached per address for a minute) and accepts the connection only when all hold:- the peer is in
100.64.0.0/10orfd7a:115c:a1e0::/48; - whois names a node that is not this box (a connection from the box's own tailnet address is a local process, and is refused);
- the node is not tagged, and its login equals
network.owner.
Anything else gets
403 not_ownerwith no further detail. The caller passed toregistry.callistailnet:<login>. The node name goes to the log, never into tool input. - the peer is in
-
Headers are ignored, not stripped and trusted.
Tailscale-User-*,X-Forwarded-*andx-vyre-callerfrom the network change nothing. The listener hands vyred's router the caller it established itself. -
The owner's browser is not a trusted client. It visits other sites too, and a page there can make it send a request whose source address is the owner's. So every POST must carry
application/json, which forces a CORS preflight vyred never answers. A browser'sOriginmust be this box's own address, andHostmust be the box's name or one of its tailnet addresses (otherwise421). A client with noOrigin(the Mac's vyred, curl) is not a browser and passes on identity alone. -
Self is checked twice: by the box's own addresses, which are read before the first connection on every path, fd 3 included, and by whois naming this node's own stable ID.
-
TLS: the Let's Encrypt certificate for
<you>.vyre.run, or thetailscale certcertificate for the ts.net name when there is no vyre.run name. HSTS on every response. -
Containers. An agent's container reaches the host from a bridge address, which is not a tailnet address, so it is refused like any stranger. Agents talk to vyred through the Gate.
-
Root is out of scope. A root process can spoof addresses or read the vault's key. Vyre defends the owner against other Unix users and against the owner's own unprivileged processes pretending to be the owner on a device.
Caller classes
Tools and the Gate can tell these apart and must not treat them as equal:
tailnet:<login>: a person, on a device, through a browser or the phone.cli,hook,capsule(the socket): the owner's Unix account. Also where Claude's own processes live, so floor rule 1 (the user sees final words before anything goes out) cannot be satisfied by a socket caller alone. Approvals needtailnet:*or an interactive terminal the Gate asks directly. (For the gate workstream: this ADR only names the classes.)module:<name>: another module, throughctx.call.onboard: the onboarding page before an owner exists. Only the onboarding tools.
Onboarding before an owner exists
vyre upasks vyred (over the socket) for a link. vyred makes a 32-byte random token, keeps only its hash in memory, and opens the onboarding listener. The link ishttp://127.0.0.1:7300/onboard?t=<token>. On a headless boxvyre upalso printsssh -N -L 7300:127.0.0.1:7300 <user>@<host>.- The token is single use. The first
GET /onboard?t=exchanges it for a session and redirects to/onboard#s=<session>, so the token leaves the address bar and history. An unredeemed token expires after an hour. A newvyre upmakes a new link and voids the old unredeemed one; open sessions carry on. - The session is not a cookie. Browsers share cookies across every port of 127.0.0.1, and
SameSitetreats them as one site, so any other local web server the person visits (another Unix user's included) would receive it. The session rides in the fragment, which is never sent to a server. The page keeps it in memory and sends it asx-vyre-onboardon every tool call, or as?s=on the event stream, sinceEventSourcecannot set headers. A custom header also forces a CORS preflight, so no other page can send one. - The listener serves the onboarding page's files under
/onboard/to anyone on loopback, since they are the open-source Deck and carry nothing secret. Behind the session it servesPOST /v1/tools/onboard.*(plusprojects.catalog,projects.createandrecall.statusfor step 5) andGET /v1/events/stream, limited toonboard.*events. It checksHostis loopback (against DNS rebinding) and that every POST is JSON with a loopbackOrigin. - Other Unix users on the box can reach port 7300 but have neither token nor session. The
owner's own processes could ask the socket for a link, which
onboard.linkgives only to thecli,localandcapsulelabels. They are already the owner's account, so this gives them nothing the socket did not. - Closing. The onboarding listener closes the moment the owner is first served over the
tailnet address. Closing it earlier, when the name is claimed, could lock out someone whose
laptop is not on the tailnet yet. If Tailscale is skipped, it stays open (still only the
onboarding) and
vyre upprints a fresh link each time.
Who the owner is
When Tailscale connects, network.owner becomes the login of the Tailscale user that owns this
node. When the node is tagged (signed in with an auth key) it has no user, so the onboarding
page shows a claim link on the tailnet address instead: https://<address>/onboard/claim?c=<code>,
a one-time code minted for the session holder. The first tailnet login to open it becomes the
owner. Changing owner later is vyre owner <login> on the socket.
Names
<you>.vyre.runis an A record at the box's tailnet IPv4 address, DNS only, never proxied through Cloudflare. It resolves on the public internet to a100.xaddress that is unreachable from anywhere but the tailnet. This leaks the tailnet IP, which is not a secret.- Interim: the user's own Cloudflare API token for the vyre.run zone, from
CLOUDFLARE_VYRE_TOKENin vyred's environment or the vault itemcloudflare-vyre-token. Never a global key. Code only ever looks up the zone by its configured name (network.domain, defaultvyre.run) and refuses any record not inside it. Available means no record exists, or the one there already points at this box. - Later: the hosted name directory. A small service at
api.vyre.run, the only thing Vyre AI runs. Not built yet. Its design:- Claim. The box generates an Ed25519 key (
~/.vyre/names/directory.key) and sends{ name, publicKey }. The directory reserves the name for ten minutes. - Prove the tailnet. The directory cannot join the tailnet, so it asks for proof that the
box is a real Tailscale node. It returns a nonce. The box signs the nonce and its directory
key with the private key of its
tailscale certcertificate and sends the certificate. The directory checks the chain, finds the certificate in Certificate Transparency, and verifies the signature, which only that node can make. The tailnet IP is accepted as the box states it: pointing a name at an unreachable 100.x address harms no one else. One ts.net node holds at most three names. - Records. The directory writes
A <name>and, on request,_acme-challenge.<name>TXT values for DNS-01, each request signed by the box's key. It never sees a certificate key. - Keep. The box renews its claim every 30 days with a signed request; a name idle for 90 days is released. A lost key is recovered by proving the same ts.net name again.
- Holds nothing else. No email, no account, no logs beyond the record and its key.
- Claim. The box generates an Ed25519 key (
Certificates
- Let's Encrypt, DNS-01, one certificate per name, ECDSA P-256, account and certificate keys in
~/.vyre/certs/at 0600. DNS-01 works for a name that resolves to a private address, which HTTP-01 and TLS-ALPN-01 cannot. - vyred checks daily and renews at 30 days before expiry, then swaps the certificate into the
running listener without a restart. A failed renewal retries with backoff and raises
certificate.failedfor the Now view once fewer than 14 days remain. - Fallback:
tailscale certfor the node's ts.net name, when there is no vyre.run name or DNS-01 fails. The address is thenhttps://<node>.<tailnet>.ts.net. It needs HTTPS enabled in the tailnet's admin console, which the onboarding page links to. - Development uses Let's Encrypt staging (
network.acme: "staging").
The Mac
On a Mac vyre up sets role local and opens no tailnet listener. It connects to the box as a
client of the box's tailnet listener (network.box, the box's address), and the box sees the
Mac as tailnet:<login> like any other device. A Mac can instead be the box (vyre up --box);
then everything above applies, except the tailnet listener binds its addresses directly.
Consequences
- vyred owns a TLS listener, an ACME client and certificate renewal. That is code we would not
need with
tailscale serve, and it is the price of an unforgeable identity on a custom name. - Kernel Tailscale is required on the box. In userspace networking mode there is no tailnet
interface to bind;
vyre updetects it and says so. - The owner's own browser on the box cannot use the tailnet address (its source is the box itself, which is refused). On a headless box nobody browses locally; on a Mac box the local user uses the Capsule and the CLI.
- vyred must reach
tailscale whois. On Linux that works for any local user. - No login screen, no passwords, no sessions on the tailnet address.
Amendment, 26 Sep 2026 · Docker Compose on Linux
A Linux box runs Vyre in Docker Compose, not under systemd. The host needs only Docker; the
installer (scripts/install-box.sh) writes the stack to /srv/vyre and a small vyre wrapper
to /usr/local/bin. The reasons are install and upgrade: one prerequisite instead of Node,
Tailscale and Claude Code on the host, vyre update is a pull and a recreate, and the agents'
containers (spec 7.9) come from the same Docker. The systemd socket unit described above stays
as the no-Docker alternative (vyre up --system), and a Mac is unchanged.
The decision above holds as written, because of how the two containers share a network:
- The
tailscalecontainer is the only way in. It runs the official image with kernel networking (TS_USERSPACE=false), sotailscale0is a real interface, and it publishes no port but the onboarding one. - vyred shares its network namespace (
network_mode: service:tailscale). vyred seestailscale0as it would on the host, binds the tailnet addresses on 443 itself with itsvyre.runcertificate (the "binds each address fromtailscale status" path, not fd 3), and identifies every connection bytailscale whoisof its WireGuard source address. No header, notailscale serve, no proxy hop between the WireGuard packet and vyred'saccept.net.ipv4.ip_unprivileged_port_start=0in that namespace only lets vyred bind 443 as uid 1000. - Other containers and the host are strangers. They reach the namespace from a bridge or loopback address, never a tailnet one, so whois refuses them like any other non-tailnet peer.
- The onboarding listener binds the container's address on the
vyrenetwork (the aliasvyred), never0.0.0.0, which in this namespace would includetailscale0. Docker publishes it on the host's127.0.0.1:7300only, and thessh -Lline reaches it as before. - vyred is Tailscale's operator (
--operator=vyre, uid 1000), so it can runtailscale upandtailscale certthrough the shared socket volume without root.
What changes: the Unix-user boundary is now the container. Claude's processes run as uid 1000 in
the vyre container beside vyred, as they did under the owner's account, so the caller classes
are the same. Root on the host is still out of scope, and so is anyone who can reach the Docker
socket, which is root-equivalent.