docs GitHub

Architecture

ADR 0046: The relay introduces, Tailscale carries

Superseded on 2 Oct 2026 by ADR 0050, the built-in network. Vyre no longer uses another VPN product to carry its traffic. This record stays as history of how it worked through 0.2.12.

Status: accepted, 28 Sep 2026 (the reviewer's HIGH and two MEDIUMs from the first pass fixed the same day, the key never on argv, the box binds a proven stable ID rather than trusting the desktop's own report, no tailnet:<owner> path for a tagged node) · Workstream: tailnet · Builds on ADR 0002 (network and identity), ADR 0014 (tailnet), ADR 0026 (the relay), ADR 0032 (the person and the device). Renumbered from this workstream's own "ADR 0038" (team/ADR-NUMBERS.md). Supersedes the relay-first design this workstream sent for review as "ADR 0038" on 28 Sep, which the user's later, approved direction below replaces.

Context

Two designs were considered for what a box's default network looks like without the person doing any Tailscale setup themselves.

The first, "relay-first everywhere," made the relay the box's only inbound path by default, Tailscale moved to Advanced. The reviewer's HIGHs and MEDIUMs on that design (owner-arrived never set with no whois, first-owner pairing becoming the entire root of trust, the web origin serving the code that holds the keys, relay metadata exposure, a LAN fallback) are carried forward below, because they apply just as much to the design that replaces it, every one of them is about "how does a box get its first owner and its later devices without whois," which is unchanged.

The user's decision, approved 28 Sep 2026, is different and simpler: keep Tailscale as the transport. It costs nothing (the Personal plan), and every feature ADR 0014 already built on it, Taildrive, Taildrop, agent nodes, guests, exit-node egress, Tailscale SSH, keeps working exactly as today, with no separate relay-only fallback to design and maintain for each of them. What changes is who does the Tailscale setup. Today a person runs through the admin console, installs the app on every device, signs in on each one, and sometimes enables HTTPS, before they see anything. That is the actual pain the relay-first design was trying to remove, and it can be removed without giving up Tailscale: the relay's job becomes introducing devices to each other and to the box, and Tailscale's job stays carrying the traffic, exactly as ADR 0026 already built the former and ADR 0002/0014 already built the latter.

Decision

1. A server signs in to Tailscale once, at setup

vyre up --box on a server (or the Docker Compose install, ADR 0002's amendment) still ends with a Tailscale sign-in, unchanged from today, the onboarding page's existing Tailscale step, which already accepts any Tailscale identity provider, GitHub included. Nothing here changes that step; it is not additional friction, because it happens once, by the person setting the server up, not by every device that will ever reach it. This ADR does not touch core/names/tailscale.js's installCommand/upArgs/operator or the onboarding page's tailnet step.

2. Wink pairs; the relay never carries anything but the introduction

ADR 0045 (Wink, relay.pair.ticket/resolveTicket/pairOffer) is unchanged and already built. Every device, phone or desktop, first meets the box this way: scan the Vyre code, resolve and verify the ticket, confirm the name and fingerprint, pair over the relay's Noise channel. Nothing in this ADR proposes a second pairing mechanism.

3. A confirmed pairing mints a short-lived, tagged Tailscale key and hands it over the same channel

Once a desktop Vyre (Mac, Windows, or Linux) finishes pairing over the relay, Touch ID already confirmed it, per ADR 0045/0026 section 6, no new prompt, the box:

  1. Calls the Tailscale API (https://api.tailscale.com/api/v2/tailnet/-/keys) with an OAuth client credential (below) to create an auth key that is:
    • single-use (reusable: false) and pre-approved (preauthorized: true, without it the join still waits on device approval in the console, defeating the point);
    • short-lived, not the default 90 days: expirySeconds 300, or 600 if installs run slow, single-use either way, so a longer window costs nothing (reviewer's answer to open question 1, below);
    • not ephemeral (ephemeral: false) for anything meant to persist, a desktop, or a server acting as a device, since an ephemeral node is removed the moment it disconnects; true only for a machine explicitly meant to be temporary (reviewer's answer to open question 2);
    • pre-tagged with a dedicated tag, tag:vyre-device (distinct from tag:vyre-box and tag:vyre-agent, ADR 0014 parts 1 and 9), so the tailnet policy can grant it only what a paired device needs and nothing else. No role or trust is ever derived from this tag or any tag (reviewer's rule, answering open question 3): whoever holds a key for a tag can set it, so a tag names an ACL bucket, never an identity. anywhere confirmed separately that nothing in config.role/config.machine reads a Tailscale tag either, so this holds end to end.
    • This mint is an internal function the pairing handler calls directly, never a registered tool, nothing else in Vyre, no script, no agent, can ask for one on its own.
  2. Sends the key to the desktop inside the already-open, already-authenticated Noise channel the pairing just established, never through the relay's HTTP surface, never in a URL, never logged. This is the entire reason the key can be short-lived and single-use instead of a standing secret: the channel that carries it already proved both ends are who they say they are.
  3. The desktop never puts the key on a command line. On macOS, ps shows every user's process arguments, and any process at the same uid sees them too (the exact threat ADR 0002 already defends against for everything else), a racing process could read tailscale up's argv, spend the single-use key first, and join as tag:vyre-device itself, which the ACL then lets reach tag:vyre-box (reviewer's HIGH). The key goes in a 0600 file in a private temp directory, passed as --auth-key=file:<path>, deleted immediately after the command returns (success or failure), never argv, never an environment variable a child process could also read. The desktop holds the key only for that one call and never writes it anywhere else. A failed or aborted tailscale up does not retry with the same key, it asks the box to mint a fresh one on the next attempt (the old one is single-use and short-lived regardless).
  4. The box never trusts a stable ID the desktop reports. A paired device could claim another node's ID, or its own racer's (reviewer's MEDIUM 1). Instead, the box learns it the same way it already trusts anything on the tailnet: the newly-joined node makes its first tailnet connection back to the box, which runs tailscale whois on that connection and requires the connection to prove the same Noise identity it paired with (the device's own static key, ADR 0026) before binding whois's stable ID to that paired device's row (relay_devices, the same row device:<id> already keys off). Only a bound, proven node is admitted. The desktop reporting "I am node X" proves nothing by itself; only the two facts together (whois says this connection is node X, and this connection also proves the paired device's own key) bind it.
  5. Vyre now has two ways to reach this desktop: the relay (still device:<id>) and the tailnet. Both are, and stay, device:<id>, a tagged node's whois never returns a login (see the identity section below), so there is no tailnet:<owner> path for it, on either transport. Vyre prefers the tailnet for this device once bound, using exactly relay/client/paths.js's existing direct-then-relay preference (ADR 0026 section 8, ADR 0029), no new preference logic, since that mechanism was already built for "the tailnet name first when the device can reach it, then the relay."
  6. Tagged auth keys do not expire on their own once redeemed, Tailscale's own default for a node, not something this ADR can shorten. Revoke (below) is the only off-switch for a device once it has joined; this ADR does not propose, and should not be read as proposing, any time-based expiry for an already-joined desktop.

4. Phones stay on the relay; joining is optional, in Settings

A phone pairs over the relay exactly as ADR 0045 already built, and stays there for chat, approvals and notifications, this is deliberately not automatic for phones. Joining the tailnet is a one-tap "Faster connection" link in Settings, built the same way as step 3 above (the box mints a key, sends it over the already-paired channel, the phone runs its own tailnet join through whatever the platform offers, the Tailscale app via its own auth-key deep link on iOS/Android, or tailscale up if the phone is itself a desktop-class OS). Not part of first pairing, and not required for anything to work.

5. No Tailscale installed on the desktop

If tailscale up --auth-key=file:<path> fails because Tailscale is not installed on the desktop, Vyre:

  • offers the platform's install command (core/names/tailscale.js's existing installCommand(platform), already returns the right one-liner per OS) with a "Faster connection available" note, not a blocking step;
  • falls back to the relay for that device in the meantime, the pairing itself already succeeded over the relay in step 2 above, so there is nothing broken, only a device that stays on the slower path until Tailscale is installed (or never is, and that is fine);
  • retries the mint-and-join automatically the next time that device's Vyre starts, if Tailscale has since appeared.

No device is ever blocked on Tailscale. The relay is always sufficient by itself; Tailscale is always an upgrade a device can pick up later.

Identity, once a desktop is on the tailnet

This is the reviewer's HIGH 1 and MEDIUM/HIGH from the shelved relay-first note, carried forward because auto-joining a tagged node raises the same class of problem from a different angle.

Every reader of network.owner/ownerSeen (ADR 0002's "Who the owner is," core/names/ service.js), and what it means once the relay can introduce an owner without whois ever firing:

ReaderToday (whois-only)With Wink introducing the owner
network.owner (core/names/service.js:85, set on tailnet sign-in)The Tailscale login of whoever signed the box's node inSet atomically when the FIRST owner device pairs over the relay (ADR 0045's first-pairing path, relay.pair.first/onboard), not only on a Tailscale sign-in. A box that later also gets a Tailscale sign-in (section 1) does not get a second, different owner, the sign-in step becomes the server's own tailnet identity, and does not overwrite an owner already set by pairing.
network.ownerSeen (core/names/service.js:269, set once the owner is first seen live)Set only by the tailnet listener (core/names/service.js:241)Also set the moment the first owner device completes pairing (not just a later tailnet sign-in), since that is now a real, presence-backed arrival. Every "no owner yet" branch below is audited against this.
onboard/index.js:103,423,437, the loopback onboarding link keeps reopening while !ownerSeenStayed open forever with no tailnetCloses once ownerSeen is set by pairing, same as it would by a tailnet sign-in today.
anywhere's onboard.machine first-setup presence exemption (role box && !ownerSeen, c5d7d318)Exempted until a tailnet owner signs inExempted only until ownerSeen, audited to fail closed the instant it is set by a pairing, not left open because "no tailnet sign-in has happened yet."
ownerOverTailnet(caller) / ownerDevice(caller) (core/modules/index.js)tailnet:<owner> only, or device:<id> (ADR 0026)Unchanged, and a desktop that joins the tailnet via section 3 never becomes tailnet:<owner>. A tag:vyre-device node's whois returns the tag, never a login (below), so it is admitted as device:<id> over the tailnet transport too, exactly as it already is over the relay, ownerDevice() needs nothing new, since device:<id> already covers both.

A tagged node has no login, whois breaks, and that is fine, because nothing relies on it here

tailscale whois on a node signed in with an auth key that carries --advertise-tags returns the tag, not a person's login (this is Tailscale's own behavior, not a Vyre choice: a tagged node has no user behind it). So ownerOverTailnet()'s tailnet:<login> never fires for a tag:vyre-device node, and it must not be made to, a tag is not a promise about which person's device this is, only that it is some device this tailnet's ACLs decided to trust with that tag.

The fix is: never derive identity from the tag. The device is already identified, correctly, by the pairing record from step 3 above (device:<id>, bound to a specific relay_devices row that a specific Touch ID confirmed). The tailnet join is purely a transport upgrade for that already-known device, never a second identity check:

  • Admission over Tailscale for a tag:vyre-device node is still gated on the stable ID bound at step 3, looked up against relay_devices, and admitted as device:<id>, the same caller label the relay path already uses, not a new tailnet:tag:vyre-device label and not tailnet:<owner>. core/link/box.js's existing exclusion of tailnet:agent:* from ownerOverTailnet is the precedent: a tag on the wire is never treated as the owner.
  • The tailnet ACL for tag:vyre-device must not let one tagged device reach another's ports: a grant shaped { src: ["tag:vyre-device"], dst: ["tag:vyre-device"], ... } would let any paired device probe any other paired device's box directly, bypassing the box entirely. The generated policy (this workstream's onboard.tailscale{action:"policy"}, core/onboard/index.js) grants tag:vyre-device reaching only tag:vyre-box, on the box's own port, nothing else, mirroring the existing Taildrive/Taildrop grants' own owner-only scoping (fixed 28 Sep, 064fd9cd).
  • A server that also has its own real Tailscale login (section 1) keeps working exactly as ADR 0002 describes today; nothing above changes that path. The tag question only applies to devices that joined through a Wink pairing.

The minting credential is a new crown-jewel secret, scoped tightly

The box needs a Tailscale OAuth client that can create auth keys, to mint step 3's key on demand. This is real, new, sensitive material, creating any key on that tailnet, even a short-lived one, is a meaningful capability, so:

  • it lives in the vault, under a new item (tailscale-mint-oauth, mirroring computers.tailnet's tailscale-agent-authkey naming), never in the container env or a log;
  • the OAuth client itself is scoped, in Tailscale's own console, to auth-key creation for tag:vyre-device only, not full admin, not other tags, following the same "least the feature needs" rule ADR 0014 already states for tag:vyre-egress's OAuth client;
  • minting a key happens only as a direct consequence of a pairing's own presence proof (the Touch ID that already confirmed the device, ADR 0026 section 6), never on its own trigger, and never callable as a bare tool a script could invoke to mint keys for no device in particular.
  • on a Mac acting as a server, this credential waits for vyre-core (ADR 0040's GA list): the same reasoning that already keeps relay hosting off by default on a Mac (core/relay/keys.js, macCoreRefusal) applies here too, a process at the person's own uid can read anything a Mac process holds, so a Tailscale OAuth client capable of minting keys is exactly the kind of secret that needs vyre-core's own holder, not ~/.vyre/, before it ships on a Mac. A Linux server has no such gap and is unaffected.

Revoke goes both directions

  • Removing a paired device (relay.devices.remove, ADR 0026) also removes its tailnet node, if it has one, through the Tailscale API, a device is either fully gone or not removed at all, never half-trusted on one path and revoked on the other.
  • Conversely, a node deleted from the tailnet's own admin console (outside Vyre) does not leave a relay_devices row still marked as reachable over Tailscale, the box notices the node is gone (the same "a device went unused too long" pattern ADR 0026 already has for web devices) and falls back to treating that device as relay-only until it re-pairs.
  • A failed or aborted join (step 3, the desktop never came up on the tailnet) expires its key on its own short TTL; nothing further to revoke, since it was never bound to a stable ID.

Wink's own fixes stand, unchanged

The scan-to-pair mechanism this section leans on (ADR 0045) already has the reviewer's two blocking fixes from its own review: the ticket-to-key derivations under distinct tags so the relay never learns the pairing secret (B1), and the MAC-authenticated record so the relay cannot substitute its own identity (B2), plus the resolve-then-confirm split (resolveTicket/pairOffer) so a person sees who they are pairing with before anything pairs. Nothing in this ADR weakens or bypasses any of that; the auth-key mint in section 3 happens strictly after a pairing those fixes already protect.

Testing

Never runs tailscale up, mints a real auth key, or touches the real tailnet, anywhere in a test. Every test in this area uses the existing fake-tailscale-binary pattern (test/helpers.js, core/names/tailscale.test.js) for the desktop side, and a fake HTTP client for the Tailscale API mint call (matching how core/computers/tailnet.js's tests already fake the agent-node join). The box's outbound call to api.tailscale.com is injected exactly like every other outbound dependency in this codebase (a seam), never the real endpoint, in any test file, on the Mac or on the test box.

As built (28 Sep 2026, for 0.1.1)

Where each decision lives, and the few concrete choices the text above left open:

  • The mint is tailscaleApi().mintKey() in core/relay/tailnet.js, called only from the relay channel's own handler for POST /v1/relay/tailnet/key (core/relay/index.js, tailnetKey). That path is answered before vyred's router sees the request, so it exists only inside a paired device's Noise channel, and no registry tool, script or agent can reach it.
  • Only a pairing grants a key. A desktop's pairing hello carries tailnet: "join" (relay.join sets it; a phone does not), which sets join_grant on its relay_devices row. A key needs that grant, an app (never web) device, no node already bound, at most one key per 5 minutes and 5 per pairing. A retry after a restart ("Tailscale has since appeared") spends the same grant; pairing again resets it.
  • The proven Noise identity (section 3, step 4) is a 16-byte bind code minted with the key and handed over in the same channel answer, kept only as a hash on the box, single use, 15 minutes. The new node presents it over the tailnet to POST /v1/tailnet/bind, the one route the names listener opens to an unbound tag:vyre-device node. The listener passes whois's own stable id (never anything in the body) to the internal relay.devices.bind, which binds it only if the code matches that device. A racer that spent the key would still need the code, which never left the paired channel.
  • Admission is classify() in core/names/identity.js: a tag:vyre-device node is the device its stable id is bound to (relay.devices.tailnet, internal), caller device:<id>, peer { via: "tailnet" }, the same as over the relay; unbound, it is refused except for the bind. No tag produces a caller by itself.
  • The desktop's join is desktopJoin()/joinWithKey(): the key goes to tailscale up --auth-key=file:<path> --advertise-tags=tag:vyre-device from a 0600 file in a 0700 mkdtemp directory, removed in a finally. canJoin() fails closed: only a status that parses, in NeedsLogin or NoState, with no node of its own, is ready. Anything else (unreadable status, signed in but stopped, an expired node key) is the person's own tailnet and left alone, since joining would switch their account. No Tailscale: the device stays on the relay and relay.status shows the install line.
  • Revoke: relay.devices.remove unbinds the node at once (a re-pairing of the same device never re-admits it without a new bind) and deletes it through the API. A failed delete is logged and emitted (tailnet.revoke-failed), kept as orphan_node and retried from the device list until Tailscale confirms. relay.devices.list clears nodes the API no longer lists (asked at most every 10 minutes, off the list's own path).
  • Mac server: the key path answers not_available_here on darwin until vyre-core.
  • The OAuth client needs auth_keys and devices:core (delete and list), both limited to tag:vyre-device, stored in the vault as tailscale-mint-oauth, {"client_id": "...", "client_secret": "..."}. onboard.tailscale {action: "policy"} adds the tagOwners entry and one grant from tag:vyre-device to the box's own port.
  • Not built yet: section 4 (a phone's optional "Faster connection") and a desktop Vyre that stays connected and prefers the tailnet path through relay/client/paths.js; the join, bind and admission it would ride on are in place.

Consequences

  • No paid Tailscale plan is required: the Personal plan already supports OAuth clients, tags and auth keys at the scale one owner's devices need.
  • Every ADR 0014 Tailscale feature (Taildrive, Taildrop, agent nodes, guests, exit-node egress, SSH) keeps working exactly as today, for a box reached this way, nothing about them is relay- specific or needs a second implementation, unlike the relay-first design this replaces.
  • A new, real secret (the minting OAuth client) exists that did not before; scoped and vaulted per above, and it is the one genuinely new attack surface this ADR introduces.
  • A device that never installs Tailscale works exactly as well as one that does, just slower on the relay path, this is a deliberate design property, not a degraded fallback to fix later.

Resolved (the reviewer's verdict, 28 Sep)

The HIGH (the key on argv) and both MEDIUMs (the box trusting a self-reported stable ID; the tailnet:<owner> line) are fixed above, in sections 3 and the identity table. The reviewer's answers to the three open questions this note originally asked, now folded into the decision rather than left open:

  1. expirySeconds: 300 is fine, 600 if installs run slow, single-use either way, so the exact number is not security-critical. preauthorized: true is required alongside it, or the join blocks on device approval in the console.
  2. ephemeral: false for anything meant to persist (a desktop, a server acting as a device); true only for an explicitly temporary machine.
  3. No role or trust is ever derived from a tag, tag:vyre-device included, confirmed separately by anywhere: nothing in config.role/config.machine reads Tailscale status or tags at all, and core/onboard/index.js's own claim flow already treats a tagged node as having no inherent owner for exactly this reason.