Skip to content

Relays ​

A paired client can reach more than one gateway, and a gateway can be reachable through more than one relay. Each gateway carries its own relay membership in config.json: a system path, a set of edges, and optionally a default chosen from those edges. Bootstrap seeds one gateway and names its system relay; you grow from there — pairing more relays with relays pair, pairing more gateways by running bootstrap again, or letting a gateway hand you its whole relay set with gateways resync. Whichever relay carries your traffic, the end-to-end encryption is unaffected: a relay only ever sees sealed bytes.

json
"gateways": {
  "<gateway-fingerprint>": {
    "relays": {
      "system":  "<relay-id>",
      "edges":   ["<relay-id>", "<relay-id>"],
      "default": "<relay-id>"
    }
  }
}

Those arrays hold full relay ids, and every relay's own details (name, origin, LAN addresses, pinned certificate fingerprint, kind) live once in a shared relays catalog keyed by the same ids.

  • system is the gateway's system path — exactly one, assigned by the cloud console and mirrored down by gateways resync. A gateway does not accumulate system paths: an extra system relay bound to it is not stored as one of its paths.
  • edges is the gateway's edge-relay set.
  • default is a pick within that set, so it is always an edge, never the system relay. The system relay is what answers when there is no default, which is why naming it as a default is refused rather than quietly accepted.

With no relay named, connect/ssh dial the gateway's default (while it is still one of its edges), else its system path. There is no global default relay: which relay answers is the gateway's own business. defaults.gateway — the global default gateway — is unchanged.

The CLI never picks between relays for you beyond that — connect/ssh always resolve to exactly one (gateway, relay) pair, plus at most one address on that relay (see Connect & SSH). routes --json exists so an extension app can apply its own priority/condition logic and drive connect explicitly.

The relay axis ​

sh
burrowee relays list

Reads config.json directly (no daemon needed) and prints one table: a parent row per relay, and a child row under it per published LAN address.

   ID                       NAME        KIND     ORIGIN                         FINGERPRINT
*  a38adb05-0369-4aa4-9d... seoul       system   wss://sys.example.net          —
   c2345678-90ab-4cde-8f... closet      edge     —                              0011223344556677
                                                 wss://10.0.0.9:8448@c2345678
   d0000000-0000-4000-80... —           edge     —                              —
   wss://legacy.example.net —           edge     wss://legacy.example.net       eeff001122334455
                                                 wss://10.0.0.77:8448
   b41c9f22-7d18-4a6e-9c... office*     edge     wss://office.example.net       a1b2c3d4e5f60718
                                                 wss://10.0.0.5:8448@b41c9f22
                                                 wss://192.168.1.20:8448@b41c9f22

a relay with no ORIGIN is dialled over its LAN addresses with no pick; a child token picks one
* local name (set with `burrowee relays rename`; not synced from console)

The * in the left column marks what the default gateway dials when you name no relay. ORIGIN is the relay's public address, or — for an edge the console minted with no domain — that dash is not "unknown", it is the whole reason that relay's LAN children are the way to reach it. FINGERPRINT is the relay's LAN TLS certificate fingerprint truncated to 16 characters (— when it publishes none). Rows are ordered system relays first, then edges, each block alphabetical by origin.

There are no REMOTE / LAN sections any more, and no VIA column. The two sections split one relay across two places and made the LAN block read like a second kind of relay, when a LAN address is a face of one; the VIA column showed a value that nothing stores (see No stored dial face).

The pick token, and the short id ​

Each child row prints the token that names that address unambiguously, and that token is exactly what you pass to --relay:

wss://10.0.0.5:8448@b41c9f22
└───── address ───┘ └ short id

The separator is the last @ in the token, so an address that contains one of its own is still split correctly.

The short id is the first eight hex digits of the relay's console relay id with hyphens stripped, extended further only if two configured ids share that prefix. It is called the short id everywhere — it is not the LAN cert fingerprint, which is a different column on the same row. The token deliberately is not derived from the fingerprint: a certificate rotation would otherwise change every token you had written down.

The relay is named first because two edges in front of different gateways can publish the same LAN address. 10.0.0.5 on its own would pick a gateway by accident; wss://10.0.0.5:8448@b41c9f22 finds that relay, then dials that address on it, pinned with that relay's certificate fingerprint.

sh
burrowee connect --relay wss://10.0.0.5:8448@b41c9f22 --svc 22

A bare wss://<address> with no @ also resolves, but only when exactly one configured relay publishes it; when more than one does, the refusal lists the short ids and the @ form. A relay id, host, name, or full origin URL still resolves as before.

A pick lasts one connection

The picked address is never written to config.json. There is no command that records "use the LAN address for this relay" — the choice belongs to the connection, not to the config.

Rows without a token ​

Two kinds of child row carry no token, and they mean different things:

  • A relay that predates relay ids — wss://legacy.example.net in the table above — is keyed on its origin and has no id to shorten. Its addresses print bare and the relay is picked by its origin, exactly as it always was.
  • An address the CLI would refuse to dial is marked (not dialable), with a footnote saying why: a LAN dial is cert-pinned, so the relay needs a LAN cert fingerprint and the address must be wss://. Printing a token beside such an address would hand you a command that fails the instant you ran it.

Names ​

The NAME column shows the relay's display name. A relay learns its console-assigned name from the pairing blob, and gateways resync refreshes it as the operator renames it on their end — but a relay the CLI learned only from a resync has no name of its own and shows —, because the relay set a gateway reports does not carry one. You can pin a local name that only this client sees; it wins over the console name and is never synced back:

sh
burrowee relays rename <id|host|name> <new-name>   # set a local name
burrowee relays rename <id|host|name>              # clear it (fall back to the console name)

A locally-named relay is marked with a trailing * on the name, with a footnote explaining it. gateways rename does the same for the gateway axis.

Choosing and removing ​

sh
burrowee relays use <id|host>       # set the default gateway's default edge
burrowee relays rm <id|host>        # remove a relay and its memberships

use writes relays.default on the default gateway, then tells the running daemon to switch live; if the daemon is down, the preference is still saved and picked up on next start. The relay must be one of that gateway's edges — naming its system relay is refused, because the system path is what answers when there is no default, so "use the system relay" is a request to prefer the fallback.

relays use on a single-relay config now refuses

A host whose gateway has exactly one relay — every host still on a pre-relay-id config — migrates that relay in as the gateway's system path, and a default must be an edge. So relays use refuses there. Nothing is broken by the refusal: the one relay it has is already what it dials. There is simply no default to set until an edge is paired in.

rm deletes the relay from the catalog and from every gateway that named it, as a system path, an edge or a default.

sh
burrowee relays gateway [<relay>]                 # list a relay's gateways (omit → default relay)
burrowee relays gateway [<relay>] default <gw>    # set a relay's default gateway

relays gateway is the reverse lookup: which gateways this relay reaches, with * on the relay's default gateway. Setting one lets you dial with just --relay and no --gw when the relay belongs to more than one gateway.

sh
burrowee relays pair <blob> <pin>

Adds a console-minted relay to your config — see Bootstrap → Adding more relays later.

The gateway axis ​

sh
burrowee gateways list

Prints ID, NAME, RELAYS (how many that gateway names, in any role), and DEFAULT-RELAY — which is the relay that gateway dials when nothing is picked, so it reads the gateway's default while that is still an edge, and its system path otherwise. * marks your default gateway.

sh
burrowee gateways use <id>          # set the default gateway
burrowee gateways rm <id>           # remove a gateway and its memberships
sh
burrowee gateways relays [<gw>]                    # list a gateway's relays (omit → default gateway)
burrowee gateways relays [<gw>] default <relay>    # set the gateway's default path (must be an edge)
burrowee gateways relays [<gw>] system <relay>     # override the gateway's system path

gateways relays is the forward lookup: which relays this gateway is reachable through. default is the per-gateway form of relays use and takes an id, host, or wss:// origin that is already one of that gateway's edges. system is the operator's local override of the console's assignment, and it takes a system-kind relay — it is not a way to promote an edge. The relay it displaces stays in the catalog until the next resync, which is what lets you set the old one back.

sh
burrowee gateways bridges [<gw>]

Lists the gateway's bridges — chained-edge routes that reach the gateway through an intermediate edge rather than a directly-paired relay — as entry origin, end, and the entry's CLI path. Bridges are not membership: they stay a list of their own, and name their entry relay by origin. Pass that entry origin as --relay (with an explicit --gw) on connect/ssh to dial through one.

sh
burrowee gateways resync [<gw-id-or-name>]

Full-mirrors a gateway's current relay and bridge set from the live gateway into your config — relays the gateway has added since you last synced get added, ones it dropped get pruned. Omit the argument to resync every configured gateway; a failure on one gateway doesn't stop the others. Useful after an operator adds or removes an edge relay on their end, without needing a fresh pairing blob.

What resync does to each gateway's membership:

FieldRule
systemKeeps the stored id while the gateway still reports it as a system relay; otherwise takes the one the console flagged as this gateway's system path. Extra system relays the gateway reports are not stored as paths.
edgesReplaced by the edge relays the gateway reports.
defaultCleared if the stored one is no longer one of the edges; if it is then empty, the console's flagged default edge fills it. A default you set yourself is otherwise sticky.

A gateway's second system relay leaves your config

A gateway has one system path, so a second system relay it reports is no longer stored on that gateway at all — and a relay no gateway names is collected. You will see it go: the row disappears from relays list, and --relay <that-id> stops resolving. Under the old model it was kept as an ordinary edge and stayed dialable, so this is a real loss of something you could reach. It comes back the moment the console makes it that gateway's system path or binds it as an edge; what does not survive the round trip is anything local you had set on that row, such as a name from relays rename.

The matrix export — routes ​

sh
burrowee routes [--json] [--config <path>]

The default (human) form is a compact adjacency listing — each gateway, then its relays indented underneath in the same order every surface uses (the system path first, then the edges), with * on the default gateway and → on the relay that gateway dials when nothing is picked:

* gateway 1a2b3c4d5e6f7a8b
      a38adb05-0369-4aa4-9dc2-b44b872f45e8
    → b41c9f22-7d18-4a6e-9c03-5f7a1e8d2b40

The arrow is on the second line here because this gateway has a default edge set, and an edge is never listed before the system path. A gateway with no default carries the arrow on its system relay, on the first line.

--json emits the same thing as a fixed, machine-readable contract — the export an extension app reads to apply its own routing policy and then call connect --gw <id> --relay <pick> explicitly. There's no priority/failover ordering command in the CLI itself; that logic lives in whatever's reading routes --json.

The JSON export is "schema": 2. Each gateway carries relays: {default, system, edges, dial}, and each relay reports its faces — one entry per address it can be reached at, with the token that picks each one:

json
{ "id": "b41c9f22-7d18-4a6e-9c03-5f7a1e8d2b40", "short_id": "b41c9f22", "kind": "edge",
  "origin": "wss://office.example.net",
  "faces": [
    {"address": "wss://office.example.net", "face": "remote",
     "pick": "b41c9f22-7d18-4a6e-9c03-5f7a1e8d2b40", "dialable": true},
    {"address": "wss://10.0.0.5:8448", "face": "lan",
     "pick": "wss://10.0.0.5:8448@b41c9f22", "dialable": true}
  ] }

face is a label, not a setting — it is spelled face and not via on purpose, because nothing writes one. An empty pick is an explicit "no token for this", and dialable says which reason applies: a relay with no short id, or an address a LAN dial cannot use. Each gateway's relays.dial is the relay that gateway answers on with no pick, computed by the CLI rather than left for each consumer to re-derive.

Schema 2 is a clean break — upgrade the on-top CLIs with this one

The flat top-level edges[] list and defaults.relay are gone, not deprecated, and gateways[].relays changed from an array into an object. Neither on-top reader fails gracefully on that: one accepts any schema ≥ 1 and meets the array-to-object change as a JSON type error, the other never reads schema at all and degrades to an empty adjacency, reporting every relay as absent. Upgrading burrowee-cli on its own breaks them until their own upgrades land — install both together.

Diagnostics ​

sh
burrowee relays probe [<relay>] [--json]

Asks the running daemon to actively probe your configured relays — every relay by default, or just one when you name it (id, host, or name) — a cold and a warm pass per pair, with per-pass dial/query/total latency, the carrier's connection state, target and session counts. Output is grouped by gateway, then by relay, with each relay header carrying the relay's own reported version (and the gateway header carrying the gateway's). Within each gateway the relays are ranked fastest-first by warm-pass query latency, so the quickest relay sits on top; a relay whose warm pass failed sinks to the bottom of its group. Naming a relay the daemon doesn't know refuses the probe outright (exit 2). Needs a running daemon.

sh
burrowee relays ping [<relay>|all] [<gw>] [--transport <mode>]

An end-to-end application-layer ping — unlike probe, this doesn't need the daemon; it dials the relay itself. Name a single relay (optionally disambiguated with <gw> when it belongs to more than one gateway) for a continuous, ICMP-ping-style stream of RTT samples until you Ctrl-C, followed by a min/avg/max/jitter/loss summary. Pass all (or omit the relay entirely) to sample every configured relay instead — 30 pings each, run concurrently — and print one summary table grouped by gateway. --transport auto|ws|quic (default auto) forces the same transport constraint connect/ssh would use, so you can check whether a ws-only network path pings differently than QUIC.

sh
burrowee relays reset <id|host|name>

The remedy for a stuck relay that a plain reconnect won't recover: reset tells the daemon to retire every cached carrier for that relay, across all gateways, so the next session dials it completely fresh. Reach for it when probe keeps showing a relay in a bad state after the relay itself has recovered.

All relays/gateways subcommands accept --socket <path> and --config <path> if you've moved the daemon socket or the config file.

LAN addresses and the pinned certificate ​

An edge relay running on your own network can publish LAN addresses — direct local addresses like wss://192.168.1.20:8448 — which appear as the child rows of its entry in relays list.

A LAN address can't be verified by a public certificate authority — there's no hostname to vouch for. So each LAN-capable relay also carries the relay's certificate fingerprint (a SHA-256 hash of its LAN TLS certificate, stored as lan_cert_fp in config.json, and shown truncated in relays list). When dialing a LAN address, the CLI accepts the connection only if the certificate it is shown hashes to exactly that fingerprint — in plain words: the config remembers which certificate this relay is supposed to present, and refuses imposters.

No fingerprint, no LAN dial, and the same for an address published as plain ws://: those addresses are marked (not dialable) and get no pick token, rather than being offered and then refused.

No stored dial face ​

A relay's face is where it is dialled: its public origin, or its published, cert-pinned LAN addresses. Nothing stores one. There is no via field in config.json and no relays via command — both are gone — because there was never a choice to record:

  • a relay with a domain is dialled over its origin;
  • a relay the console minted with no domain — the — in the ORIGIN column — has exactly one face, so an unpicked dial already uses its published LAN addresses, pinned with its fingerprint. Nothing extra to type, and such a relay is a perfectly legitimate default.

Dialling is strict: a face is dialled over its own addresses and never falls back to the other one. A relay with neither a public origin nor a publishable LAN address is an error naming that relay and the missing address, not a silent fallback.

A pick token is the one way to name a single address, and it applies to one connection: --relay wss://10.0.0.5:8448@b41c9f22 dials that address and only that address, instead of trying each published address in turn.

A hand-set LAN preference on a relay that has a domain is lost

If you had previously pinned a relay that does have a domain to its LAN addresses by hand, that pin does not survive the upgrade — the field it lived in is gone, and that relay is dialled over its domain from now on. Nothing warns you at the time. Use a pick token per connection where you want a specific LAN address, or ask the relay's operator whether it should publish no public origin at all.

LAN addresses and fingerprints arrive in pairing and relay-add blobs, or via gateways resync; the CLI doesn't receive live pushes otherwise. If a relay's published endpoints change, run burrowee relays pair with a freshly minted blob (it updates the existing entry in place), burrowee gateways resync, or re-bootstrap.