bootstrap: infer the tailnet tag from the pre-auth key — verify the tag control granted, don't assert the one rig requested #16
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:bootstrap
scope:coolify
scope:db
scope:docs
scope:drill
scope:installer
scope:labels
scope:platform
scope:runner
scope:users
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/rig#16
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
rig bootstraptakes--ts-tagand passes it totailscale up --advertise-tags. But a pre-auth key already carries the tags it was minted with. So the tag is stated twice — once in the Tailscale admin console when the key is created, once on rig's command line — and rig has no idea whether the two agree. Two sources of truth for one fact, reconciled by the operator's memory.They can be collapsed: the key is already the source of truth, and rig can just stop overriding it.
The tag is the sshd bug wearing a different hat
rig asserts the tag it requested, never the tag control actually granted. That is exactly the shape of the
99-rig.conffirst-wins failure — rig asserted the file it wrote rather than whatsshdresolved, and shipped green for a month while every Hetzner box servedpasswordauthentication yes.bootstrapnow refuses to claim success unlesssshd -Tagrees.The tag has had no such assert. And the fleet already has the scar: per #12, both M900s were carrying
tag:serverand had to be retagged by hand. Nothing in rig noticed, because nothing in rig ever looked.What the research actually says
A tagged key applies its tags with no
--advertise-tagsat all. "if you use tags with an auth key, after a device logs in as the user who generated the auth key, the device assumes the identity of the auth key's tags." (auth-keys, tags) — so inference is not a trick, it is the documented default.A mismatch is usually a loud failure, not a silent wrong tag — requested tags must "exactly match the full set of tags on the auth key, or each requested tag must be owned by one of the authenticating entity's tags as defined in
tagOwners" (tags). So today's risk is mostly a failed bootstrap. Mostly — atagOwnersentry that lets the key's tag own the requested one turns it back into a silent override. rig should not depend on our ACL never growing that edge.An UNTAGGED key is the real hazard, and it is silent. "The owner of a device authenticated by an auth key is either the user that created the auth key, or one or more tags that the user specified." With no tags the node joins owned by the key creator's user identity — it inherits that human's ACL grants, is subject to key expiry (tagged devices default to expiry disabled), and vanishes if the user is deleted. Today
--ts-tagpapers over this: pass an untagged key, and rig's--advertise-tagstags the node anyway. Dropping--advertise-tagsremoves that accidental safety net — which is why this issue is a verify story, not just a delete story.Tags cannot converge post-join.
tailscale sethas no tag flag (verified againstcmd/tailscale/cli/set.go; corroborated by the CLI KB). Re-tagging requiresup --force-reauth— i.e. a fresh key — because "Re-tagging a device ... requires reauthentication." The--hostnametrick (#12'stailscale set --hostname) has no analogue here. rig can detect a wrong tag; it cannot fix one. So it must fail loudly and print the repair, never claim convergence.A key's tags cannot be read before use.
GET /api/v2/tailnet/{tailnet}/keys/{keyId}needs an API token (tskey-api-…) or OAuth client; atskey-auth-…is a device-registration credential, a different credential class. Pre-flight validation would mean rig holding an API token — which the "no credential, ever" contract forbids. Post-join verification is the only lever available.Proposed
Stop requesting a tag. Verify the granted one.
tailscale updrops--advertise-tagsentirely; the key's tags apply.tailscale status --jsonfor.Self.Tagsand assert on it. Tags arrive with the netmap, not synchronously fromup— a single read right afterupcan legitimately come back empty, so poll until non-empty or.BackendState == "Running", then decide.tailscale logoutto back the node out rather than leave a half-joined user-owned device behind, then die naming the fix (mint a tagged key).runnerrefusal survives, strictly stronger than today: it stops being "you typed the wrong flag" and becomes "the key you actually used grantstag:serverto repo-controlled code."--ts-tagis removed, not demotedThe flag goes away entirely. The key decides the tag; role policy guards the outcome. One source of truth, and the flag that could disagree with it no longer exists.
The alternative considered and rejected was demoting it to an
--expect-tagassertion — never sent totailscale up, only checked against.Self.Tags. It would catch "I grabbed thetag:cikey for a workload box", which removal only catches forrunner. Rejected because it reintroduces the thing this issue exists to delete: a second place to state the tag, and therefore a second thing to keep in sync. The untagged-key refusal and therunnerpolicy cover the failures that actually cost something.Implementation notes
jqon a rig box, andlib/runner-config.sh'sjson_fieldonly reads string values —.Self.Tagsis an array. Needs a small array-aware reader (same grep/sed spirit, same "installing jq to read one field is a poor trade" reasoning), ortailscale status --jsonpiped through a targeted match. Whatever it is, it belongs next tojson_fieldand gets its own test.tailscale debug prefsis the wrong source and will lie — it printsAdvertiseTags, i.e. what was requested, not what control granted. That trap has a bug of its own (#2641)..Self.Tagsreflects control's netmap and is ground truth. Using prefs here would re-commit the original sin in a new file.--ts-tagshould die with a message, not an "unknown flag" error. It is in runbooks and muscle memory;bootstrap.shshould keep matching it and exit 2 saying the tag now comes from the key. A bareunknown flag: --ts-tagreads like a rig bug and invites a re-run with the flag deleted but the untagged key still in hand — the one case this issue must not let through quietly.test/cli.sh:39must change. It asserts therunnerrefusal through the flag (bootstrap.sh runner --ts-tag tag:server→ exit 2). With the flag gone, that test asserts nothing real: the refusal moves onto the effective tag, which needs a tailnet and cannot be reached fromtest/cli.sh. Replace it with the--ts-tag-is-removed exit-2 assertion, and let the rehearsal cover the actual policy — deleting the test outright would quietly drop the onlytag:servercoverage we have.tailscale uperrors (#5597, closed as not planned). rig dodges it because it skipsupon an already-joined box — a nice accident of the convergence design that this change must not undo.Rehearsal
The Incus rehearsal is structurally blind to this (as it was to the sshd bug — no cloud-init drop-in to lose to). Cover in
test/cli.shwhat is testable without a tailnet (arg surface, refusal exits), and rehearse the real thing on a throwaway node with three keys: tagged-correct, tagged-wrong-for-role, and untagged — the last is the one that matters, and the one no test we have today would catch.