fix: scope the netmap tag read to Self

json_string_array took the first "Tags" array anywhere in the document,
justified by Self-before-Peer field order. An untagged Self omits the key
entirely (Go omitempty), so the match fell through into Peer and returned
a peer's tag — false-refusing every login join and false-verifying
untagged authkey joins on any tailnet with a tagged node. Extract Self by
brace counting (Location nests an object, so slicing to the next key
would truncate) and read the array inside it.

Refs #160

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
claude-bot-andresmgsl 2026-07-25 16:02:54 +00:00
parent 2af3b24fd8
commit 56478d7a7d
3 changed files with 99 additions and 18 deletions

3
changelog.d/160.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- The netmap tag read is scoped to `Self`: an untagged node next to tagged peers no longer reads a peer's tag, false-refusing `--join login` and false-verifying untagged authkey joins (#160)

View file

@ -14,8 +14,8 @@ json_field() {
| head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//' || true | head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//' || true
} }
# json_string_array <file> <key> — the elements of the FIRST array named <key>, # json_string_array <file> <key> — the elements of the array named <key> inside
# one per line, empty when the key is absent or the array is empty. # the netmap's `Self` object, one per line; empty when Self or the key is absent.
# #
# json_field's sibling for the one shape it cannot read: `.Self.Tags` from # json_field's sibling for the one shape it cannot read: `.Self.Tags` from
# `tailscale status --json` is a JSON array, and bootstrap must assert on it to # `tailscale status --json` is a JSON array, and bootstrap must assert on it to
@ -23,21 +23,44 @@ json_field() {
# not the tag rig requested. Same grep/sed spirit, same jq-free reason: a # not the tag rig requested. Same grep/sed spirit, same jq-free reason: a
# rig-bootstrapped box has no jq and we will not install one to read one field. # rig-bootstrapped box has no jq and we will not install one to read one field.
# #
# `tr -d '\n'` first, because tailscale pretty-prints its JSON and an array # Scoped to Self, NOT document-global. The previous body took the first "Tags"
# spans lines — grep is line-oriented and would never see `[ ... ]` whole # array anywhere in the file and justified it with Self-before-Peer field order.
# otherwise. `\[[^]]*\]` then captures the first flat array body for <key> # That holds only when Self HAS tags: an untagged Self omits the key entirely
# (tag strings never contain `]`, so this is safe); the inner `grep -o` pulls # (Go omitempty), so the match fell through into Peer and returned a PEER's tag
# every quoted token out of it, and `sed 1d` drops the key's own name — which # — silently inverting both callers on any tailnet with a tagged node (#160).
# `"key":[...]` leads with — leaving just the elements.
# #
# FIRST array wins by design, and the caller leans on it: `tailscale status # Self is brace-counted rather than sliced to the next key: PeerStatus carries a
# --json` emits Self before Peer (Go struct field order, stable), so the first # nested object (Location, a pointer with omitempty), which would end a naive
# "Tags" is the node's OWN, never a peer's. An absent key omits itself entirely # slice early whenever it is present. Known limit of staying jq-free: a `{` or
# (Go's omitempty) rather than emitting `[]` — which is exactly the untagged, # `}` inside a STRING value within Self would miscount — no PeerStatus string
# user-owned node bootstrap must catch. Never fails under `set -e`+pipefail: a # field (hostnames, DNS names, OS, key strings) can contain one, so this is
# non-match is a fact to test for, like json_field, not a reason to die. # sound in practice, but it is a real assumption, written down on purpose.
#
# `tr -d '\n'` first, because tailscale pretty-prints its JSON and the object
# spans lines — awk and grep are line-oriented and would never see it whole
# otherwise. `\[[^]]*\]` then captures the flat array body for <key> (tag
# strings never contain `]`, so this is safe); the inner `grep -o` pulls every
# quoted token out of it, and `sed 1d` drops the key's own name — which
# `"key":[...]` leads with — leaving just the elements. Never fails under
# `set -e`+pipefail: a non-match is a fact to test for, not a reason to die.
json_string_array() { json_string_array() {
tr -d '\n' < "$1" 2>/dev/null \ local self
self="$(tr -d '\n' < "$1" 2>/dev/null | awk '
{
i = index($0, "\"Self\"")
if (i == 0) exit
s = substr($0, i)
j = index(s, "{")
if (j == 0) exit
depth = 0
for (k = j; k <= length(s); k++) {
c = substr(s, k, 1)
if (c == "{") depth++
else if (c == "}") { depth--; if (depth == 0) { print substr(s, j, k - j + 1); exit } }
}
}')" || true
[ -n "$self" ] || return 0
printf '%s' "$self" \
| grep -o "\"$2\"[[:space:]]*:[[:space:]]*\[[^]]*\]" \ | grep -o "\"$2\"[[:space:]]*:[[:space:]]*\[[^]]*\]" \
| head -n1 | grep -o '"[^"]*"' | sed '1d; s/^"//; s/"$//' || true | head -n1 | grep -o '"[^"]*"' | sed '1d; s/^"//; s/"$//' || true
} }

View file

@ -1214,6 +1214,7 @@ tags_empty() { # tags_empty <file> — exit 0 iff the reader prints NOTHING
} }
FIX_TAGGED="$(mktemp)" # Self carries two tags; a peer carries a third FIX_TAGGED="$(mktemp)" # Self carries two tags; a peer carries a third
FIX_UNTAGGED="$(mktemp)" # Self has no Tags key at all — the untagged hazard FIX_UNTAGGED="$(mktemp)" # Self has no Tags key at all — the untagged hazard
FIX_NESTED="$(mktemp)" # tagged Self carrying a nested Location object
cat > "$FIX_TAGGED" <<'JSON' cat > "$FIX_TAGGED" <<'JSON'
{ {
"BackendState": "Running", "BackendState": "Running",
@ -1234,23 +1235,77 @@ cat > "$FIX_TAGGED" <<'JSON'
} }
} }
JSON JSON
# The peers are the point (#160): an untagged Self OMITS its Tags key (Go
# omitempty), and the old document-global reader then fell through into Peer and
# returned tag:server here. Every real tailnet has this shape — untagged Self
# next to tagged peers — which the peerless fixture this replaces never covered.
cat > "$FIX_UNTAGGED" <<'JSON' cat > "$FIX_UNTAGGED" <<'JSON'
{ {
"BackendState": "Running", "BackendState": "Running",
"Self": { "Self": {
"HostName": "user-owned-box" "HostName": "user-owned-box"
},
"Peer": {
"nodekey:aaa": {
"HostName": "coolify-box",
"Tags": [
"tag:server"
]
},
"nodekey:bbb": {
"HostName": "ci-box",
"Tags": [
"tag:ci"
]
}
}
}
JSON
# Location is a nested object INSIDE Self (a pointer with omitempty in the real
# netmap): a reader that sliced Self to the next key would end early at its
# closing brace and drop the Tags that follow — the brace counter must not.
cat > "$FIX_NESTED" <<'JSON'
{
"BackendState": "Running",
"Self": {
"HostName": "coolify-box",
"Location": {
"Country": "Croatia",
"CountryCode": "HR"
},
"Tags": [
"tag:server",
"tag:prod"
]
},
"Peer": {
"nodekey:abc": {
"HostName": "ci-box",
"Tags": [
"tag:ci"
]
}
} }
} }
JSON JSON
check "json_string_array: reads the first array element" 0 "tag:ci" tags "$FIX_TAGGED" check "json_string_array: reads the first array element" 0 "tag:ci" tags "$FIX_TAGGED"
check "json_string_array: reads a later array element" 0 "tag:build" tags "$FIX_TAGGED" check "json_string_array: reads a later array element" 0 "tag:build" tags "$FIX_TAGGED"
# Self precedes Peer in the netmap, so the FIRST "Tags" is the node's own: exactly # The reader is scoped to the Self object: exactly two elements read proves the
# two elements read proves the peer's tag:server did not leak into Self's tags. # peer's tag:server did not leak into Self's tags.
check "json_string_array: reads Self's array, not a peer's" 0 "2" tags_count "$FIX_TAGGED" check "json_string_array: reads Self's array, not a peer's" 0 "2" tags_count "$FIX_TAGGED"
# An absent key omits itself (Go omitempty), never emits []: empty is the signal # An absent key omits itself (Go omitempty), never emits []: empty is the signal
# bootstrap turns into a hard untagged-key refusal, so it must read as empty here. # bootstrap turns into a hard untagged-key refusal, so it must read as empty here.
check "json_string_array: absent Tags key prints nothing" 0 "" tags_empty "$FIX_UNTAGGED" check "json_string_array: absent Tags key prints nothing" 0 "" tags_empty "$FIX_UNTAGGED"
rm -f "$FIX_TAGGED" "$FIX_UNTAGGED" # Regression, #160: with tagged peers present, an untagged Self must STILL read
# empty — pre-fix this returned the peer's tag:server, false-refusing every
# login join and false-verifying untagged authkey joins as tagged.
check "json_string_array: untagged Self + tagged peers reads empty (#160)" \
0 "" tags_empty "$FIX_UNTAGGED"
check "json_string_array: nested Location does not truncate Self's tags" \
0 "2" tags_count "$FIX_NESTED"
check "json_string_array: reads past a nested object to a later element" \
0 "tag:prod" tags "$FIX_NESTED"
rm -f "$FIX_TAGGED" "$FIX_UNTAGGED" "$FIX_NESTED"
# The guard is only worth something if it runs BEFORE the box is touched: the # The guard is only worth something if it runs BEFORE the box is touched: the
# token prompt, the download, configure and svc.sh start all come after it. # token prompt, the download, configure and svc.sh start all come after it.