On this page

Onboard a machine by invitation

invite

The operator mints the machine's channel key and never touches the machine. The method has two modes, and which one applies is not a preference: it is whether the machine being added can reach this fleet's control point.

stado fleet invite create --name <target-name> --expires <duration>             # probe the control point, then pick the mode
stado fleet invite create --name <target-name> --expires <duration> --offline    # do not probe: fragment only

--name is optional: without it the target is named invited-<first eight hex digits of the invite id>, and a name that collides with an existing target or another open invite is a hard error asking for --name rather than a silent suffix. Both modes mint the key for role stado-ssh-<target-name> through the same fleet key generate path and print its fingerprint. Both require --expires <duration>, how long the invitation stays usable, and accept --uses N, how many machines may redeem it (one unless stated): a duration is an integer plus one of s, m, h, d, a bare number is refused rather than guessed, and no lifetime is assumed — an invite without --expires is refused by name. In both the private half stays in the operator's Skarbiec. Stado Desktop asks for the same lifetime in How long it stays usable before it mints.

Before it prints anything, the command works out the control address and fetches /join.sh from it. Three sources are tried in order: STADO_ENROLLMENT_URL / enrollment.url if configured, then the entrance stado fleet ingress has published and verified (enrollments/ingress.json, used only while its address still answers), then STADO_API_URL / api.url. No control host is compiled into Stado, so an address nobody configured and nobody published is a reason to fall back, not a name to invent. A published entrance that cannot be read is not a reason to fall back: when the store cannot be reached, or enrollments/ingress.json is not JSON or lacks its address or process groups, the invite is refused with that cause (infra_down for a damaged object, the store's own class for an unreachable store), because handing out a one-liner on api.url while the fleet's own entrance is in an unknown state is a guess. If the address does not resolve, or refuses the connection, or answers /join.sh with anything other than 200, the one line is not printed at all: those three cases are reported apart — a missing DNS record, a missing listener or tunnel, and a release too old for the routes are three different repairs — and the command continues in offline mode. The stado fleet invite reference lists the exact verdicts.

Stado's examples/fleet/invite-a-machine.sh is this method end to end from the operator's side, offline mode first.

The offline mode

The mode that works today, and the default for any machine that cannot reach the control point. It mints no token and needs no HTTP route at all: the carrier is the channel you are already using to talk to whoever holds the machine.

stado fleet invite create --name <target-name> --expires <duration> --offline prints a self-contained sh fragment between two markers. Pasted into a terminal on the machine by its owner, it creates ~/.ssh at mode 700 and ~/.ssh/authorized_keys at mode 600, appends the fleet's public line there idempotently — the line is inside the fragment, not fetched from anywhere — reports whether anything answers on port 22 and prints the exact System Settings › General › Sharing › Remote Login path, or its Linux equivalent, when nothing does, and ends by printing the user@address its owner sends back to you.

The fragment is not a secret, and it says so in its own output: the only key in it is a public one, so reading it gains nobody anything. That sentence is there for a reason. A fragment treated as a credential gets routed down whichever channel feels secret rather than the one that actually reaches the machine's owner, and then the invitation simply stalls.

The invite object lands in enrollments/invites/<id>.json with mode: "offline", status open, and no secret_sha256, because no secret exists. stado fleet invite list reports it as open (offline, awaiting address) — the one state in this lifecycle that waits on a person rather than on a clock. When the address arrives, the operator closes it with ordinary probe-then-write enrollment:

stado fleet enroll <target-name> --ssh <user@address> --bootstrap

That is adopt's command without --install-key, and it works precisely because the key is already in place: enrollment reads hostname, uname -s and uname -m over the channel, writes the entry from what it read, installs the agent, and rolls the entry back if that install fails. The invitation is then spent. There is no fleet pending step, because nothing self-reported: an offline invitation produces no request to approve, and the operator's own enrollment is the registry write.

The one-line mode

Available only when the check above succeeded. The command then prints the token once — <id>.<secret>, a 16-hex id and 32 CSPRNG bytes in unpadded base64url — states that nothing can reprint it, names the minted channel key's fingerprint, and gives the single line to forward to whoever holds the machine:

curl -fsSL <control-point>/join.sh | sh -s -- <id>.<secret>

<control-point> is the address that just answered the check, never a name built into Stado. When it came from stado fleet ingress, the command says so and repeats what that means: the address is a temporary Cloudflare quick tunnel, the one-liner dies with the ingress, and a restarted ingress comes back under a different address, so an invitation handed out before a restart is dead. The store keeps only secret_sha256, in enrollments/invites/<id>.json alongside target_name, created_at, expires_at, uses_allowed, uses_spent, status, created_by and the mode that distinguishes the two kinds of invitation. stado fleet invite list lists live invites with their status and spend, and stado fleet invite revoke <id> closes one immediately. --json carries the same token in token next to the ready join_command — plus base_source and base_is_temporary, so a script can tell a durable origin from a tunnel — so redirecting that output to a file writes a live credential to disk; nothing can reprint the token if it is lost, and the answer to a lost token is a new invite plus invite revoke.

On the machine, that one line fetches GET /join.sh, reads the fleet's public key from GET /api/fleet/invite/key, appends it to ~/.ssh/authorized_keys, and announces the machine through POST /api/fleet/join with the hostname, OS, architecture, the destination it worked out for itself, the fingerprint it installed, and whether its SSH channel was answering. Both API routes are authorized by the invite token alone and neither can write the registry; the script itself carries no secret, because the secret is the argument the user supplies, and it installs no software — the agent arrives with your approval. See Enrollment trust for the exact contract.

The listener enforces the deployment's fleet_join.request_bytes and fleet_join.field_bytes inside dashboard.request_limits. Oversized reports return HTTP 413; missing, empty or oversized required fields return HTTP 400. The bootstrap command prints the failed POST address, status and returned reason. Authentication refusals remain uniform and do not reveal which invitation state caused them. A size-valid report without a usable token still returns HTTP 401 and writes no enrollment.

The machine is then a pending request, exactly like join, and the operator finishes it:

stado fleet pending
stado fleet approve <hostname>
stado host beacon list

stado fleet pending shows, for an invited request, the channel destination approval will probe, the invite id it came from, the fingerprint of the key the machine installed, and whether that machine's SSH channel was answering when it reported; --json emits the same. A stored request it cannot read — not JSON, no recorded status, or pending without a hostname — is listed as unreadable request <path>: <reason> (unreadable in --json) and the command fails while any remains, so "no pending join requests" never stands over a machine waiting to join; a request that records another decision is left out. Approving a machine whose channel is not answering yet is the one wasted round trip this view prevents. approve then takes that destination and runs the same probe-then-write enrollment as every other path — it reads hostname, uname -s and uname -m over the channel before writing, and rolls the entry back if the agent install fails. Approval is not a shortcut around verification. A spent, expired, revoked or unknown token is refused identically, without telling the caller which of those it was.

The machine is registered under the invitation's target name — the one --name reserved, which is also the name the owner's terminal printed and the name whose key the invitation minted — so stado fleet key check <target-name> and stado repair stado --step host --target <target-name> --apply afterwards use that name, not whatever local hostname the machine happens to have. Its hostname is not discarded: it lands in the entry's hostnames, probed rather than trusted. stado fleet approve addresses the request, and a request is keyed by hostname; that is why the two arguments differ, and why fleet pending prints both.

What the one-line mode needs, and how to stand it up

The one-line mode needs one thing the offline mode does not: an origin the machine being added can actually reach over HTTPS, serving /join.sh. That is not something an invitation can conjure, which is why fleet invite create proves it before printing anything. There are two ways to have it.

The published deployment. A DNS record for the control host pointing at something that terminates TLS, an authenticated reverse proxy in front of the loopback bind the dashboard keeps by design, and a release on that host new enough to serve GET /api/fleet/invite/key, POST /api/fleet/join and GET /join.sh. All three, or the check reports which one is missing: a name in no zone fails as a name that does not resolve, a loopback-only bind fails as a refused connection, and an older release answers non-200 and fails as an unknown route. Point STADO_ENROLLMENT_URL at it and every invitation uses it.

stado fleet ingress, which needs none of that. One command stands the entrance up with no Cloudflare account, no API token and no DNS record:

stado fleet ingress up          # stand it up and verify it from the internet
stado fleet ingress status      # what is published, whether it still answers, how old it is
stado fleet invite create --name <target-name> --expires <duration>
stado fleet ingress down        # close it when the machine has joined

up picks its own free loopback port, starts stado serve --api --enrollment-only --inherited-listener on it — the listener that serves those three routes and answers 404 to every other path and method, before authorization, before the store and before the vault — and starts a Cloudflare quick tunnel in front of it. It then fetches /join.sh back through the public address, from the internet, and compares the bytes with the script this binary would have served. Only a match publishes enrollments/ingress.json; anything else stops both processes and reports the stage that failed. There is no state in which the command claims an entrance that is not there.

Three things about that tunnel, all of them said by the command itself:

  1. Cloudflare calls quick tunnels non-production and rate limits them. That is an acceptable trade for an entrance used a handful of times a month to add a machine, and it is not one for anything a service depends on.
  2. The address changes on every start. Stopping and restarting the ingress invalidates every one-liner already handed out. Keep it standing until the machine has joined; fleet invite create repeats this whenever it builds a one-liner on an ingress address.
  3. --named is refused today, and not because something needs configuring: a named tunnel on the fleet's own domain wants a Cloudflare API token, and this Stado stands up only the accountless quick tunnel and reads no Cloudflare token from any vault role. Storing a token changes nothing.

ingress up will not adopt a port it did not open. --port N on a port already in use is refused before any process starts, because putting a public tunnel in front of somebody else's service is the one mistake this command must never make.

ingress down and ingress status act only on the machine the published object names in pid_hint.machine. down run anywhere else is refused naming that machine, because the process-group ids it carries are some other process's here. status reports the listener and tunnel as unknown on this machine (listener_alive and tunnel_alive are null in --json). An object that names no machine, no log path, a process group at or below init's pid, or a listener port that is not a port is refused as unreadable instead of being acted on. A started_at or verified_at that is not an RFC 3339 time leaves standing_seconds or seconds_since_verified null, and stamp_errors says which stamp failed and why.

stado fleet ingress up|status|down refuse with the causes of stado.fleet.ingress (stado errors show stado.fleet.ingress prints each with its fix):

cause when exit
named-unsupported up --named: this Stado stands up only the quick tunnel 1
already-published up while an entrance is published 1
cloudflared-setting-unreadable STADO_CLOUDFLARED_BIN is not a UTF-8 path 1
cloudflared-not-a-file STADO_CLOUDFLARED_BIN names no file 1
cloudflared-missing no cloudflared in the known prefixes or on PATH 1
stado-missing no stado program to run the listener with 1
port-taken up --port N on a port another process holds; nothing is started 1
stage-failed the listener, tunnel or verification stage did not complete; what was started is stopped and nothing is published 69
record-unreadable the published object is not JSON or not an entrance (the cases above) 1
machine-elsewhere down of an entrance another machine runs 1
ps-unreadable /bin/ps cannot be run to tell whether the processes still run 69
signal-refused the system refused SIGTERM to a process group 1

tests/fleet/ingress.rs provokes a named tunnel, a second entrance, a cloudflared path naming nothing and a held port; tests/fleet/ingress_machine.rs the unreadable object and the entrance on another machine. An account with neither HOME nor a user-database home is stado.files.home-unknown, the same for every command that keeps something under the home.

Without either of the two, the fragment remains the way a machine is added — and it remains a perfectly good way.

How the invite commands refuse

stado fleet invite create|list|revoke refuse with the causes of stado.fleet.invite (stado errors show stado.fleet.invite prints each with its fix). Everything below is refused before a channel key is minted, and the store keeps what it held:

cause when exit
uses-none --uses 0 2
expiry-invalid --expires without a unit, or not positive 2
random-unavailable the system's random source did not answer the id or secret 69
invite-open an open invite already reserves the name 1
invite-id-taken the store already holds an invite with the minted id; the key minted for it is removed and said so 1
invite-damaged a stored invite under enrollments/invites/ does not parse 1
invite-unknown revoke of an id no invite has 1

A --name that is not a lowercase target identifier is refused first as stado.fleet.request.name-invalid, with the name enrollment would suggest. The catalog switching invitations off is stado.fleet.catalog.read.invite-mint-disabled, and a name a registered target already carries stado.registry.targets.already-registered. tests/fleet/invite.rs provokes six of them through the real binary.

Source: this website