bootstrap: infer the tailnet tag from the pre-auth key — verify the tag granted, not the one requested #20

Merged
dan-claude-bot merged 1 commit from feat/bootstrap-infer-tailnet-tag into main 2026-07-17 15:47:12 +00:00
dan-claude-bot commented 2026-07-17 15:27:44 +00:00 (Migrated from github.com)

Plan

rig bootstrap took --ts-tag and passed it to tailscale up --advertise-tags — but a pre-auth key already carries the tags it was minted with. The tag was stated twice, and rig had no idea whether the two agreed. This is the sshd first-wins bug wearing a different hat: rig asserted the tag it requested, never the tag control granted. The fleet already has the scar — both M900s carried tag:server and had to be retagged by hand; nothing in rig noticed because nothing ever looked.

Collapse the two sources of truth onto one — the key — and verify the granted tag, mirroring the sshd -T "assert the EFFECTIVE state, not the file we wrote" fix:

  • tailscale up drops --advertise-tags entirely. The key's own tags apply (documented default for a tagged key).
  • Post-join, poll tailscale status --json for .Self.Tags — the netmap's ground truth, not tailscale debug prefs, which prints AdvertiseTags (what was requested, the very thing we're deleting). Tags arrive with the netmap, not synchronously from up, so a single read can legitimately be empty; poll until .Self.Tags is non-empty or .BackendState == Running, then decide.
  • Untagged key → hard refusal. Dropping --advertise-tags also drops the accidental net that used to tag an untagged key's node anyway. An untagged node joins owned by the key creator's user identity (inherits that human's ACL grants, dies with the key, vanishes if the account is deleted) — a fleet-shaped mistake, not a warning. rig runs tailscale logout to back the half-joined node out, then dies naming the fix (mint a tagged key). A wrong tag can't converge in place (tailscale set has no tag flag; re-tagging needs up --force-reauth with a fresh key), so rig detects and refuses, never claiming a convergence it can't perform.
  • Role policy moves onto the EFFECTIVE tag. The runner refusal survives, strictly stronger: no longer "don't advertise tag:server" but "the key you actually used must not grant tag:server to repo-controlled code." Verified on the already-joined path too, so a box bootstrapped before this change — or retagged behind rig's back — is caught on the next ordinary re-run.
  • --ts-tag is REMOVED, not demoted. It dies exit 2 with a message pointing at the key (consuming its value), rather than an "unknown flag" that leaves an operator guessing where the tag went. (An --expect-tag assertion was considered and rejected — it would reintroduce the second source of truth this change exists to delete.)

What changed

  • commands/lib/runner-config.sh — new json_string_array <file> <key>, the array-aware sibling of json_field (jq-free grep/sed, one element per line, empty when absent, never fails under set -e). .Self.Tags is an array; json_field only reads strings.
  • commands/bootstrap.sh — sources the lib (adds HERE + source line); removes --ts-tag as a real option and its TS_TAG defaults; --ts-tag now dies exit 2 with the "tag comes from the key" message; tailscale up drops --advertise-tags; adds verify_effective_tag (bounded poll of .Self.Tags, untagged→logout+die, runner+tag:server→die, logs the verified tag) called on both the fresh-join and already-joined branches; usage() rewritten.
  • test/cli.sh — replaces the old runner-refuses-tag:server-through---ts-tag test with the --ts-tag-is-removed exit-2 assertions (value present and absent); adds unit tests for json_string_array (fixture netmap with a Tags array + a peer to prove first-wins reads Self's, and a no-Tags fixture → empty).
  • README.md — rewrites the --ts-tag bullet and the runner-tag paragraph to describe the new model (key is the source of truth; verify granted, not requested; untagged = hard refusal).

Testing

  • bash test/cli.sh71 passed, 0 failed
  • shellcheck -x over bin/* **/*.shclean

The array reader is unit-tested directly (the bootstrap path that calls it needs a real tailnet the harness can't fake). The effective-tag role policy is rehearsal-only for the same reason — it belongs to the 3-key rehearsal:

  1. tagged-correct — key minted with the role's tag → joins, verify_effective_tag logs it, exit 0.
  2. tagged-wrong-for-role — runner box, key minted with tag:server → hard die naming the CI-tag repair.
  3. untagged — key with no tags → tailscale logout, hard die naming "mint a tagged key".

Interactions with other PRs

Conflicts textually with the dev role PR (#12) and the admin-user PR (#17), since all three edit bootstrap.sh. This PR changes the tag model, so ordering matters: if #12 (dev role, tag:local) merges after this, dev's tag policy must move onto the same effective-tag check (verify_effective_tag), not a request-time --ts-tag/--advertise-tags path — which no longer exists.

Closes #16.

## Plan `rig bootstrap` took `--ts-tag` and passed it to `tailscale up --advertise-tags` — but a pre-auth key **already carries the tags it was minted with**. The tag was stated **twice**, and rig had no idea whether the two agreed. This is the sshd first-wins bug wearing a different hat: rig asserted the tag it **requested**, never the tag control **granted**. The fleet already has the scar — both M900s carried `tag:server` and had to be retagged by hand; nothing in rig noticed because nothing ever looked. Collapse the two sources of truth onto one — the key — and **verify the granted tag**, mirroring the `sshd -T` "assert the EFFECTIVE state, not the file we wrote" fix: - **`tailscale up` drops `--advertise-tags` entirely.** The key's own tags apply (documented default for a tagged key). - **Post-join, poll `tailscale status --json` for `.Self.Tags`** — the netmap's ground truth, *not* `tailscale debug prefs`, which prints `AdvertiseTags` (what was requested, the very thing we're deleting). Tags arrive with the netmap, not synchronously from `up`, so a single read can legitimately be empty; poll until `.Self.Tags` is non-empty **or** `.BackendState == Running`, then decide. - **Untagged key → hard refusal.** Dropping `--advertise-tags` also drops the accidental net that used to tag an untagged key's node anyway. An untagged node joins owned by the *key creator's user identity* (inherits that human's ACL grants, dies with the key, vanishes if the account is deleted) — a fleet-shaped mistake, not a warning. rig runs `tailscale logout` to back the half-joined node out, then dies naming the fix (mint a tagged key). A wrong tag can't converge in place (`tailscale set` has no tag flag; re-tagging needs `up --force-reauth` with a fresh key), so rig detects and refuses, never claiming a convergence it can't perform. - **Role policy moves onto the EFFECTIVE tag.** The runner refusal survives, strictly stronger: no longer "don't advertise `tag:server`" but "the key you actually used must not grant `tag:server` to repo-controlled code." Verified on the already-joined path too, so a box bootstrapped before this change — or retagged behind rig's back — is caught on the next ordinary re-run. - **`--ts-tag` is REMOVED, not demoted.** It dies exit 2 with a message pointing at the key (consuming its value), rather than an "unknown flag" that leaves an operator guessing where the tag went. (An `--expect-tag` assertion was considered and rejected — it would reintroduce the second source of truth this change exists to delete.) ## What changed - `commands/lib/runner-config.sh` — new `json_string_array <file> <key>`, the array-aware sibling of `json_field` (jq-free grep/sed, one element per line, empty when absent, never fails under `set -e`). `.Self.Tags` is an array; `json_field` only reads strings. - `commands/bootstrap.sh` — sources the lib (adds `HERE` + source line); removes `--ts-tag` as a real option and its `TS_TAG` defaults; `--ts-tag` now dies exit 2 with the "tag comes from the key" message; `tailscale up` drops `--advertise-tags`; adds `verify_effective_tag` (bounded poll of `.Self.Tags`, untagged→logout+die, runner+tag:server→die, logs the verified tag) called on **both** the fresh-join and already-joined branches; `usage()` rewritten. - `test/cli.sh` — replaces the old runner-refuses-`tag:server`-through-`--ts-tag` test with the `--ts-tag`-is-removed exit-2 assertions (value present and absent); adds unit tests for `json_string_array` (fixture netmap with a Tags array + a peer to prove first-wins reads Self's, and a no-Tags fixture → empty). - `README.md` — rewrites the `--ts-tag` bullet and the runner-tag paragraph to describe the new model (key is the source of truth; verify granted, not requested; untagged = hard refusal). ## Testing - `bash test/cli.sh` → **71 passed, 0 failed** - `shellcheck -x` over `bin/* **/*.sh` → **clean** The array reader is unit-tested directly (the bootstrap path that calls it needs a real tailnet the harness can't fake). The **effective-tag role policy** is rehearsal-only for the same reason — it belongs to the 3-key rehearsal: 1. **tagged-correct** — key minted with the role's tag → joins, `verify_effective_tag` logs it, exit 0. 2. **tagged-wrong-for-role** — runner box, key minted with `tag:server` → hard die naming the CI-tag repair. 3. **untagged** — key with no tags → `tailscale logout`, hard die naming "mint a tagged key". ## Interactions with other PRs Conflicts **textually** with the `dev` role PR (#12) and the admin-user PR (#17), since all three edit `bootstrap.sh`. This PR changes the tag **model**, so ordering matters: if #12 (dev role, `tag:local`) merges *after* this, dev's tag policy must move onto the same effective-tag check (`verify_effective_tag`), not a request-time `--ts-tag`/`--advertise-tags` path — which no longer exists. Closes #16.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/rig#20
No description provided.