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:
parent
2af3b24fd8
commit
56478d7a7d
3 changed files with 99 additions and 18 deletions
3
changelog.d/160.md
Normal file
3
changelog.d/160.md
Normal 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)
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
test/cli.sh
61
test/cli.sh
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue