feat(apple): RFC 025 on the Apple plane — the tailnet login on every identity surface, and the login gate - #191
Merged
Merged
Conversation
RFC 025 §3.6 (D7) and VibeField petition T4: the Apple plane could not say
who was signed in. A grep for LoginName over apple/ was empty, so the phone
could not label a peer as its own account's, nor scope account-owned state.
The login is now a first-class field, optional and honest — absent, never
fabricated. An empty string on the wire becomes nil, because RFC 022's
honesty rule forbids surfacing "" as an identity and RFC 025 §3.2 fails
closed on an empty login.
- AuthenticatedPeer gains loginName/displayName (the WhoIs answer's
UserProfile); BackendPeer and BackendStatus gain loginName (a peer's, and
the node's own). Every new init parameter defaults to nil, so no existing
call site changes.
- Peer.loginName joins the public snapshot AND its equality, so a SwiftUI row
re-renders when a login appears. Hashing stays ref-only.
- MeshModel.loginName projects the node's own login beside phase/peers.
- LoopbackBackend grows the seams the gate's tests need: a per-node login on
join, setLogin for a profile switch, and setWithholdWhoIsLogin to model a
WhoIs answer that resolves a node ID but no login.
FINDING — RFC 025 §3.3 specified the Swift mapping as
status.User[String(peer.UserID)]. That is not expressible: the pinned
TailscaleKit decodes IpnState.PeerStatus WITHOUT UserID, and Status.SelfStatus
is a PeerStatus too, so neither a peer's nor the node's own login can be keyed
out of Status.User — which does exist, and is keyed by the stringified user
id, with nothing to key it. Verified against the vendored source
(.vendor/libtailscale-59d4bb82…/swift/TailscaleKit/LocalAPI/Types.swift:230-249),
the shipped arm64-apple-ios.swiftinterface, and this repo's own libtailscale
patches (which touch only NotifyWatchOpt). RFC 025 §3.3 now carries a dated
correction at its source.
tsnet's JSON does carry UserID; TailscaleKit drops it at decode, and
LocalAPIClient.backendStatus() returns a decoded Status with no raw-bytes
variant. So TailscaleKitBackend.refreshStatus now also GETs
/localapi/v0/status through the same authenticated loopback the WhoIs path
uses, decodes only Self.UserID / Peer[].{ID,UserID} / User{}, and overlays the
logins onto the mapped BackendStatus. Cost: one extra loopback GET per status
refresh. A failed overlay leaves every login nil, so a gated node admits
nobody — the fail-closed answer. When PeerStatus grows UserID the overlay
collapses back into the decoder.
The decoders live in Sources/TruffleTailscale/LocalAPIIdentity.swift, OUTSIDE
TailscaleKitBackend's `#if os(iOS) && canImport(TailscaleKit)` — the same
reason TailscaleEndpoint is its own file. That guard compiles to nothing on
the macOS host, so decoding placed inside it has no test anywhere. #188's
WhoIs address normalisation is untouched; its LocalAPI HTTP plumbing is
factored into a shared localAPIGet and both callers use it, with the WhoIs
error strings byte-identical.
What is real: every field is wired end to end and covered by tests on the
macOS host. What is NOT witnessed: the production LocalAPI reads themselves —
TailscaleKitBackend is iOS-only and has no device gate in this lane, so the
status overlay and the WhoIs profile are proven by their decoders and by
compilation for both iOS slices, not by a live tailnet.
Gates: swift test --package-path apple 70 -> 95 tests in 17 suites, exit 0 ·
root "as published" swift package resolve + build + test likewise 95/17 ·
xcodebuild -scheme TruffleTailscale for both generic/platform=iOS and
generic/platform=iOS Simulator BUILD SUCCEEDED, zero warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
RFC 025 §3.1-§3.4 (D1-D5) and VibeField petition T5, Swift half. Until now a
Swift node admitted every tailnet node carrying the app's hostname prefix, and
its hello admitted any caller that claimed the app id. The Swift core is a
truffle peer that speaks the same hello, so without this gate a foreign
desktop could dial the phone's listener and feed its store.
A node declares the gate once, for its lifetime:
MeshConfiguration(appId: ..., deviceName: ..., loginAllow: ["*@corp.com"])
Empty (the default) is today's behaviour EXACTLY: the whole tailnet,
hostname-prefix discovery, and the existing IdentityPolicy alone on the
inbound path. Nothing on the wire changes for an ungated node, and the hello
envelope stays at version 2 — the login is never self-declared, WhoIs is its
only authority (D8).
- LoginGlob (Identity/LoginGlob.swift) is the grammar: a faithful port of Go's
path.Match — scanChunk / matchChunk / getEsc — walking Unicode SCALARS, not
grapheme clusters, so `?` and the class ranges count and order exactly what
Go's rune and Rust's char do. match(_:_:) is the case-sensitive primitive
and throws BadPattern rather than trapping; allowed(_:login:) is the gate —
empty list true, absent or empty login under a non-empty list false,
malformed globs skipped. One grammar, three planes.
- Layer 3: MeshNode.upsertFromLayer3 admits a row only if
Hostname.isAppPeer(...) && LoginGlob.allowed(loginAllow, login:). On a gated
node a row with NO login is not a peer. Provisional entries from a raced
inbound hello keep merging as before — that hello already passed the gate at
Handshake.server, so re-gating here would drop a peer the node has a live
authenticated session with.
- The hello: Handshake.server gains loginAllow (defaulted, so every existing
caller and test compiles unchanged) and implements §3.4's table in order —
validate hello, then absent authenticated identity -> 4003, then claimed
tailscale_id mismatch -> 4003, then login absent or matching no glob -> 4004
(SessionCloseCode.loginRefused, MeshError.loginRefused(login:)). All of it
BEFORE our hello is sent, so a refused caller never learns our identity
block. The dialing side needs no new check: a gated node only dials peers
Layer 3 reported, and Layer 3 filtered them.
- A gate is never bypassed by the test policy: on a gated node an absent
authenticated identity is refused under EITHER IdentityPolicy, including
.allowUnverified, because without WhoIs there is no login to gate on.
- A tagged node's tagged-devices pseudo-login is passed through, not
special-cased: refused by a personal glob, admitted by one that names it.
MeshNode also carries its T4 half here — the self login from the last status
(MeshNode.loginName), the registry entry's login, and makePeer/localPeer
projecting it — because the same file owns both and splitting the hunks would
leave neither commit building.
Honest degradation rather than a silent empty mesh: where the Rust provider
refuses to start when its sidecar cannot report logins (§3.3), a Swift node
has no sidecar to interrogate, so a gated node that sees a login-less app peer
in a Layer 3 snapshot emits ONE .health notice. Note this fires from
apply(status:), which is the only Layer 3 path production takes —
TailscaleKitBackend emits .status, .authRequired and .health, never
.peerUpsert; the .peerUpsert path is the loopback's.
Close-code docs corrected at their source: SessionCloseCode gains 4004 and
says 4003 now also covers a gated node under any policy; HelloValidationError
records that validation covers only 4001/4002 and that the refusals depending
on evidence outside the hello are decided afterwards in Handshake.server.
Tested by the following commit: both of the Rust port's glob tables verbatim,
every row of the §3.4 table asserted on the close code the CLIENT sees, and a
gated loopback pair. All three gates green — swift test --package-path apple
and the root "as published" manifest at 95 tests in 17 suites, and both iOS
slices BUILD SUCCEEDED.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
…ack pair
25 new tests (70 -> 95 in 12 -> 17 suites), all on the macOS host.
- LoginGlobTests reproduces the Rust port's tables VERBATIM
(crates/truffle-core/src/network/login_allow.rs): the Go reference table
from TestAllowedLogin, the path.Match behaviour table, and the bad-pattern
table. If a row here disagrees with a row there, one of the three planes has
drifted and the gate is no longer one grammar. All three passed on the first
run of the port, including the rows that matter for the scalar-vs-grapheme
choice ("a?b" vs "a☺b", "[a-ζ]*" vs "α").
- HandshakeLoginGateTests walks every row of RFC 025 §3.4 for Handshake.server
and asserts the close code the CLIENT's frames see — and asserts it is the
FIRST frame the client receives, which is what proves our hello was never
revealed to a refused caller. Rows: absent identity (nil AND an empty stable
ID) refused 4003 under .allowUnverified, node-id mismatch still 4003 even
when the login WOULD have passed, login absent 4004, login unmatched 4004,
matching login exchanges hellos, the ungated column accepts a foreign AND an
absent login, and tagged-devices is refused by a personal glob but admitted
by one that names it.
- NodeLoginGateTests drives the gate end to end over the loopback tailnet: a
gated pair with a matching glob converges and messages flow; a foreign login
is NEITHER listed by Layer 3 NOR admitted at the hello (the ungated peer
still discovers and dials the gated node, which is exactly what the hello
gate must stop, and the refusal leaves no provisional entry behind); a
login-less row on a gated node is not a peer, the node emits its one health
notice, and the SAME row with a matching login is then admitted — which is
what proves it was dropped for its login and not its hostname.
- LocalAPIIdentityTests pins the decoders from literal JSON: the WhoIs answer
with and without UserProfile, empty profile strings becoming nil, the
tagged-devices passthrough, absent Addresses, and the status overlay —
resolution through the stringified user map, a UserID with no profile and a
row with no UserID contributing nothing, the merge onto a mapped
BackendStatus by stable node ID, and the clearing of a stale login the
overlay does not own (otherwise a departed user's login could gate a new
node in).
Verified not vacuous. Three mutations were applied and the suites re-run:
neutering the hello's login guard reddened exactly the three 4004 rows and
left the identity/mismatch/ungated rows green; neutering the Layer 3 filter
reddened both gated discovery tests and left the ungated one green; making the
overlay keep a stale login reddened only clearsStaleLoginsItDoesNotOwn. 13
issues across 3 suites. All three files were then restored and checked back
against their pre-mutation SHA-256.
No interop fixture was added or changed, so the Rust side needs no mirror.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
RFC 025 is the normative text for the grammar, the Layer 3 filter and the hello table; §8.1.2 records only what the Swift surfaces are and where they live, and points at RFC 025 for the rest. RFC 025 itself is untouched here — it lands from the Rust branch. - §8.1 item 5's close-code list is corrected AT ITS SOURCE rather than only downstream: 4004 is added, and 4003 now reads "or no authenticated identity at all", which is what a gated node does under either IdentityPolicy. - §8.1.2 names LoginGlob and its two entry points, the MeshConfiguration declaration, every surface the login appears on (including that Peer.loginName is part of Peer's equality so a SwiftUI row re-renders), the Layer 3 predicate and why provisional entries are exempt, the ordered §3.4 table with its close codes and thrown errors, and what is unchanged for an ungated node. - It closes by pointing at RFC 025 §3.3's dated correction for the one place TailscaleKit's binding could not supply what the RFC specified, and records that the status-login overlay collapses back into the decoder when IpnState.PeerStatus grows UserID. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
Four tests asked for on review, covering the gap between "the status names a
UserID" and "we know who that is". The overlay resolves a row's UserID through
the status's User{} map; a UserID the map does not describe must therefore
resolve to NOTHING, on both the peer and the self side.
- unresolvableUserIdYieldsNoLoginRatherThanEmptyString: the `nORPHAN` row names
user 55555, which User{} has no profile for. Its login comes out nil — and is
explicitly asserted not to be "" — while the resolvable row beside it still
carries its login, so the nil is the missing profile and not a wholesale
decode failure.
- aGatedNodeDropsAnUnresolvableOwnersRow asserts the consequence at the seam
where the overlay and the gate meet, using the exact predicate
MeshNode.upsertFromLayer3 applies. Of four rows — a UserID with no profile, a
row with no UserID at all, a real login on the wrong domain, and a matching
login — only the last survives the gate.
- unresolvableSelfUserIdLeavesTheSelfLoginNil: the same rule for the node's own
login, so BackendStatus.loginName and therefore MeshNode.loginName come out
nil rather than "".
- aPeerlessStatusStillResolvesTheSelfLogin records an upstream fact for whoever
is tempted to read the lighter endpoint: on tailscale 1.102.3
StatusWithoutPeers (status?peers=false) deliberately keeps the self user's
profile in User{} (tailscale/tailscale#19894). So an empty Peer{} means "no
peers were asked for", never "unknown owner". The overlay reads the FULL
status because the peers' logins need it; this test guards the self half if
that ever changes.
Verified not vacuous: mutating LoginOverlay.applied(to:) to fabricate a login
for a row it has none for — the exact bug the "absent, never fabricated" rule
forbids — turned five rows red, with the gate test reporting
admitted ["nORPHAN", "nNOUSER", "nBOB"] against the expected ["nBOB"]. The file
was then restored and checked back against its pre-mutation SHA-256.
Gates after: swift test --package-path apple 95 -> 99 tests in 17 suites,
exit 0 · root "as published" resolve + build + test likewise 99/17 ·
xcodebuild -scheme TruffleTailscale BUILD SUCCEEDED with zero warnings for both
generic/platform=iOS Simulator and generic/platform=iOS.
Note for anyone re-running these: the two xcodebuild destinations must run
SERIALLY. Run concurrently they share one DerivedData and the second dies with
"unable to attach DB ... database is locked", which reads as a build failure
and is only contention.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
On review: RFC 025 §3.3 is normative for the RULE — the login is a Layer 3
fact, and a gated node treats a row without one as not a peer — but not for the
mechanism, which on the Apple plane could not be what it first described. The
first draft of §8.1.2 pointed at RFC 025's dated correction for the mechanism
too, which leaves this plane's actual behaviour readable only by chasing
another document. It is now written out here, and RFC 025 is cited only for the
rule.
Now stated in §8.1.2:
- Why the specified mapping is not expressible: the pinned TailscaleKit decodes
IpnState.PeerStatus without UserID, Status.SelfStatus is a PeerStatus too, and
Status.User exists keyed by the stringified user id with nothing to key it.
- The exact read: one additional authenticated GET of the FULL LocalAPI status,
/localapi/v0/status, over the same loopback the WhoIs path uses, decoding only
Self.UserID, Peer[].{ID,UserID} and User{} — with the note that Peer is keyed
by node key while each row's own ID is the stable node ID BackendPeer uses.
- The cost, said plainly: one extra loopback GET per status refresh, and a
refresh runs on every IPN bus notify.
- Which endpoint and why the full one: the lighter status?peers=false would
still resolve the SELF login, because on tailscale 1.102.3 StatusWithoutPeers
keeps the self user's profile in User{} (tailscale/tailscale#19894). So an
empty Peer{} means "no peers were asked for", never "unknown owner" — a
distinction that only matters if this moves to the lighter endpoint, and one
the new tests guard.
- That an unresolvable UserID, a row with no UserID, and a failed overlay read
all resolve identically: the login is absent, never fabricated and never "",
a gated node admits nobody, and the one-shot .health notice keeps the emptied
mesh from being silent.
- The future-work line: when TailscaleKit's PeerStatus decodes UserID, the
overlay collapses into the decoder and the extra GET goes away.
- Why the decoders sit outside TailscaleKitBackend's
`#if os(iOS) && canImport(TailscaleKit)` — that guard compiles to nothing on
the macOS host, so decoding placed inside it would have no test anywhere.
Docs only; no code changed. Gates re-run all the same: 99 tests in 17 suites on
both manifests, both iOS slices BUILD SUCCEEDED.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
HIGH-1 from review, and a real hole in the gate I shipped. I wrote upsertFromLayer3's existing-entry branch to merge Layer 3 metadata and return BEFORE both guards, and documented that as deliberate — "that hello already passed the gate". It is wrong for the case that matters: a stable node ID survives a device transfer, so a node that RE-SIGNS under a different login arrives as an UPDATE to the existing row, not as a new one. The gate therefore ran only at entry creation. The reviewer proved it live: gated Alice admits Bob as bob@corp.com, Bob re-signs as mallory@evil.com, and Alice keeps him, dials him, and accepts his frames. RFC 025 §3.3's rule, as amended: a row whose login the gate REFUSES is a departure. The entry is evicted, its session closed, and peerLeft emitted — exactly as if the peer had left the tailnet. A later row that passes readmits it as a NEW generation, because a rejoin is never the same row (RFC 022 §7.7). - upsertFromLayer3 now returns whether the row must be evicted. It is sync and removeEntry is async, so both callers do the eviction: apply(status:)'s peer loop and the .peerUpsert push path. removeEntry already closes the session and emits peerLeft, so a refused login produces exactly the events a departure does. - An ABSENT login on an existing row stays STICKY rather than evicting: a transient failure to read the logins must not empty a gated mesh. Only a login that is present and refused is a departure. - Provisional entries are not exempt. They still merge, but they are evicted on a refused login like any other row — a raced hello is not a permanent pass. - Ungated nodes are unaffected: LoginGlob.allowed([], login:) is always true, so nothing is ever evicted without a gate. Also LOW from the same review: adoptInbound now carries the WhoIs loginName that PASSED the hello gate onto the provisional entry, which knew it and discarded it. confirm(identity:tailscaleId:loginName:) never overwrites a known login with nothing. MEDIUM-1 lands here too, because it is the same file. `MeshNode.whoIs( remoteEndpoint:)` is new and public: a passthrough to the backend's WhoIs, so an app that opens its own port with `listen(port:)` has an identity to gate on. The raw plane is deliberately NOT gated by loginAllow (RFC 025 §3.7, D9) — the node's list admits Layer 3 peers and session-plane hellos, and the app owns admission on its own port. Until now `MeshAcceptedConnection` carried only `remoteEndpoint` and there was no way to resolve it, so RFC 025 §3.7's assumption that "the app reads the connection's identity" had nothing to read. The MeshNode half of MEDIUM-2 is here as well: establishSession no longer echoes a 4002 at a remote that already closed with its own code. The Handshake half is the next commit. Tested by the following commit, and verified against the ORIGINAL bug: reverting this branch's guard reproduces the reviewer's scenario exactly — no peerLeft, the peer retained, send does NOT throw, and the generation unchanged at 1. Gates: swift test --package-path apple 99 -> 104 tests in 17 suites, exit 0 · root "as published" likewise 104/17 · xcodebuild -scheme TruffleTailscale BUILD SUCCEEDED with zero warnings for both iOS destinations, run serially. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
MEDIUM-2 from review, Handshake half. `receiveHello` turned every close frame
into `protocolViolation`, so a 4004 the gate sent arrived at the dialing side
indistinguishable from a dead socket — `loginRefused` was only ever thrown in
the SERVER role, by the side that ran the gate. A dialer could not tell "this
node refuses my login" from "the connection broke", which is exactly the
distinction a caller needs in order to stop retrying.
Mirroring the Rust core:
- `MeshError.helloRefused(code: UInt16, reason: String)` is the dialing side's
view of a refusal, carrying the code the remote actually sent: 4001 app
mismatch, 4002 hello protocol, 4003 identity, 4004 login refused.
- `receiveHello` raises it for ANY application close (4000–4999) received before
the hello. Codes outside that range stay `protocolViolation`, since they are
transport-level closures and not a peer's decision.
- `MeshError.loginRefused(login:)` is unchanged and remains the SERVER-role
error — the two are different viewpoints on the same event, and both are
wanted.
- Neither role echoes a 4002 back at a refusal. The socket is already closed
from the far end, so the echo does nothing except bury the reason in the logs
of whoever reads them next. `Handshake.isRefusal` is the single predicate both
roles and `MeshNode.establishSession` use.
Verified against the pre-fix behaviour: disabling the 4000–4999 classification
reproduces the reported symptom exactly — the dialer receives
`protocolViolation("peer closed connection before hello (code 4004: login
refused)")` instead of `helloRefused(code: 4004, reason: "login refused")`.
Gates: swift test --package-path apple 104 tests in 17 suites, exit 0 · root
"as published" likewise 104/17 · xcodebuild -scheme TruffleTailscale BUILD
SUCCEEDED with zero warnings for both iOS destinations.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
Five tests for the review findings, each written to fail against the code as it
was rather than to describe the code as it now is.
- aPeerThatResignsAsAForeignLoginIsEvicted is the HIGH-1 scenario end to end:
gated Alice admits Bob at bob@corp.com and establishes a real session;
setLogin re-signs the SAME stable node ID as mallory@evil.com; Alice must emit
peerLeft, drop the row, and stop resolving the snapshot taken while he was
admitted. Then the reverse — re-signing back onto the allow-list readmits him
at a NEW generation, which is what distinguishes a rejoin from a row that
never left.
- anAbsentLoginOnAnAdmittedRowIsSticky pins the other half of the rule, so the
fix cannot be over-applied: a row that loses its login keeps its place, its
generation, and the login it was admitted with.
- aRefusedDialerSeesTheCloseCodeNotAProtocolViolation is MEDIUM-2 from the
dialing side: the error is helloRefused(code: 4004, reason: "login refused"),
code and reason both.
- whoIsResolvesAnAcceptedAddressOnTheRawPlane is MEDIUM-1: MeshNode.whoIs
returns the authenticated login and display name for an accepted address, and
the test gates on it with LoginGlob.allowed both ways, which is the sequence
an app on its own port would write.
- aProvisionalEntryCarriesTheLoginThatPassedTheGate is LOW, using a hidden node
— WhoIs-resolvable and able to dial, absent from snapshots — so the entry can
only have come from the hello. Its hostname is empty and its login is the one
WhoIs supplied.
Verified against the ORIGINAL defects, not merely green. Reverting the eviction
guard reproduces the reviewer's scenario exactly: no peerLeft (departed → nil),
the peer retained, send does NOT throw, and the generation unchanged at 1.
Disabling the 4000–4999 close classification yields
protocolViolation("peer closed connection before hello (code 4004: login
refused)") in place of helloRefused. Six issues across the suite; both source
files were then restored and checked back against their pre-mutation SHA-256.
Gates: swift test --package-path apple 99 -> 104 tests in 17 suites, exit 0 ·
root "as published" likewise 104/17 · xcodebuild -scheme TruffleTailscale BUILD
SUCCEEDED with zero warnings for generic/platform=iOS Simulator and
generic/platform=iOS, run SERIALLY — concurrently they share one DerivedData
and the second dies with "unable to attach DB ... database is locked", which
reads as a build failure and is only contention.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
…not gated Three corrections to §8.1.2 from the review, each at its source. - The Layer 3 bullet said provisional entries were the exception and left the impression the predicate runs at entry creation. It now states that the predicate runs on creation AND on every later row, explains WHY (a stable node ID survives a device transfer, so a re-signed node arrives as an update), and gives the rule: a refused login is a departure — evicted, session closed, peerLeft emitted, readmitted later as a new generation. The sticky-on-absent half is stated beside it so the rule cannot be over-read, and provisional entries are described as carrying the login they passed the gate with rather than as exempt from it. - A new bullet records that the dialing side distinguishes a refusal from a broken pipe via MeshError.helloRefused(code:reason:), that neither role echoes a 4002 at an already-closed socket, and that loginRefused(login:) remains the server-role error. - A new bullet records that the raw plane is NOT gated by loginAllow (RFC 025 §3.7, D9) and how an app gates an accepted connection itself with MeshNode.whoIs(remoteEndpoint:) — including that an empty tailscaleId in the answer means WhoIs produced no concrete identity and must be treated as untrusted. Docs only; no code changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
…icate
Review of the 29d4d4d delta: aGatedNodeDropsAnUnresolvableOwnersRow (211e711)
re-implemented the gate inline — merged.peers.filter { LoginGlob.allowed(gate,
login:) } — and never touched MeshNode. So it asserted my construction against
my construction: it would pass with the node's gate guard deleted, and it
silently dropped the isAppPeer half that sits beside the login check. That is a
test of the grammar wearing the name of a test of the node.
- NodeLoginGateTests.aGatedNodeAdmitsOnlyTheRowThatPassesBothHalves is the real
witness: a live MeshNode over a LoopbackNetwork applying its OWN predicate to
five rows — a UserID with no profile, a row with no UserID, a resolvable owner
on the wrong domain, an ALLOWED login on a node that is not an app peer, and
one row passing both halves. Only the last is admitted.
The stranger row is the half the inline predicate would have let through; the
two absent-login rows are kept separate because they are separate nodes, not
because the node can tell them apart — the overlay renders both as no login.
- The overlay-level row stays, renamed to
onlyAResolvableAllowedOwnerYieldsAnAcceptedLogin, with a comment that says
what it actually does: checks the overlay's OUTPUT against the gate's grammar,
cannot witness the node applying it, and names the test that can. Narrower
than its old name claimed, and still worth pinning.
Verified as the reviewer asked: deleting the creation-time guard
(MeshNode.swift, `guard LoginGlob.allowed(config.loginAllow, login:)`) turns the
new row red with exactly the right diagnostic —
admitted ["ts-bob", "ts-mallory", "ts-nouser", "ts-orphan"] against the expected
["ts-bob"] — while ts-stranger stays out, so the row isolates the login half
cleanly. The overlay-level test stayed GREEN under that same deletion, which is
the blindness the review found, now stated in its own comment.
gatedNodeTreatsLoginlessRowAsNotAPeer and foreignLoginIsNeitherListedNorAdmitted
also reddened; aPeerThatResignsAsAForeignLoginIsEvicted correctly did not, since
it exercises the existing-entry eviction path and not the creation guard.
MeshNode.swift was then restored and checked back against its pre-mutation
SHA-256.
Gates: swift test --package-path apple 104 -> 105 tests in 17 suites, exit 0 ·
root "as published" likewise 105/17 · xcodebuild -scheme TruffleTailscale BUILD
SUCCEEDED with zero warnings for both iOS destinations, run serially.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
…ialed RFC 025 §3.3 as refined (e34e838): the sticky last-known login was wrong in both directions, and this fixes both. REPORTING. upsertFromLayer3's existing-entry branch kept the previous login when a row carried none (`peer.loginName ?? existing.loginName`). That made the node assert an owner the current netmap does not name — the absent-never- fabricated rule (RFC 022) applies to a login that can no longer be SOURCED just as much as to one never had. The row's login now passes through as-is, so it reads nil. The entry itself is still KEPT: that is the part the stickiness was protecting, and it is protected directly instead, so a transient overlay failure still cannot empty a gated mesh. `confirm` is unchanged and still restores a login WhoIs authenticated, so an inbound hello re-attributes a peer Layer 3 has gone quiet about. DIALING. A gated node now opens no NEW connection to a kept peer whose login is nil: send / sendBytes / sendJSON / confirmIdentity (via `session(for:)`) and the raw `dial(to:port:)` throw the new `MeshError.loginUnknown(peer:)` instead. We would otherwise be opening a connection to someone we cannot attribute, while the inbound gate already refuses that same peer's fresh hello (WhoIs with no login → 4004) — both directions now agree. The rule is about OPENING, never tearing down: - an EXISTING session stands, so a momentary gap in the logins cannot flap a live connection; - a dial already in flight under a known login is JOINED, not re-judged — the guard sits after the in-flight check, so it only ever stops a dial STARTING; - an ungated node ignores the field entirely (`config.loginAllow.isEmpty` short- circuits before the login is ever consulted). `MeshError.loginUnknown(peer: String)` carries the peer's ref description, like `peerGone`. The spelling matches what the VibeField consumers were briefed to expect. Tested by the following commit. Verified by DELETING both `requireKnownLogin` call sites rather than mutating the logic: that reddens exactly the three dial assertions (send, raw dial, confirmIdentity) and leaves the reporting test and the live-session assertion green, which is the attribution I wanted. Gates: swift test --package-path apple 105 -> 107 tests in 17 suites, exit 0 · root "as published" likewise 107/17 · xcodebuild -scheme TruffleTailscale BUILD SUCCEEDED with zero warnings for both iOS destinations, run serially. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
Three rows for RFC 025 §3.3's refinement, each written to fail against the
behaviour it replaces.
- anAbsentLoginIsKeptAsAPeerButReportedAbsent is the inverse of the test it
replaces (anAbsentLoginOnAnAdmittedRowIsSticky). It asserts BOTH halves,
because either alone would pass under a wrong fix: the entry survives and
keeps its generation, AND its loginName goes nil rather than holding the
last-known value. A later resolvable row restores the login in the SAME
generation, which is what separates "went quiet" from "left and rejoined".
- aGatedNodeWillNotOpenASessionToAnUnattributablePeer uses two peers on purpose.
Bob has an OPEN session when his login goes absent; Carol is admitted with no
session. Bob's session must keep delivering — asserted with a real message
through a real subscription, because a fix that tore down the live session
would otherwise pass every other assertion here. Carol's send, raw dial and
confirmIdentity must each throw loginUnknown(peer:). Restoring her login makes
her dialable again.
- anUngatedNodeDialsAPeerWithNoLogin pins the short circuit: without a gate an
absent login is simply a fact about a peer, never a refusal.
Verified by DELETION, not mutation. Removing both requireKnownLogin call sites
— the call sites, not the predicate — reddens exactly three assertions, all in
the dial test: send throws nothing, the raw dial reports
dialFailed("connection refused: 100.64.0.3:9500") instead of
loginUnknown(peer: "ts-c:2"), and confirmIdentity throws nothing. The reporting
test stays green under that deletion, and the live-session assertion stays green
too, which is the attribution I wanted: each half of the rule has its own
witness. Separately restoring the sticky `?? existing.loginName` reddens the
reporting test alone (kept?.loginName → "bob@corp.com" against nil).
MeshNode.swift was restored after each run and checked back against its
pre-mutation SHA-256.
Gates: swift test --package-path apple 105 -> 107 tests in 17 suites, exit 0 ·
root "as published" likewise 107/17 · xcodebuild -scheme TruffleTailscale BUILD
SUCCEEDED with zero warnings for both iOS destinations, run serially.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
…not dialed RFC 025 §3.3's refinement, recorded on the Swift plane. Cites §3.3 for the rule. - The sticky sentence is gone. §8.1.2 now says a row that names no owner is neither an eviction nor a sticky keep: the entry stays (so a transient failure to read the logins cannot empty a gated mesh) and the login is reported absent, with the reason — RFC 022's absent-never-fabricated rule applies to a login that can no longer be sourced as much as to one never had. - A new bullet states the dial-side half: send / sendBytes / sendJSON / confirmIdentity / raw dial throw MeshError.loginUnknown(peer:) on a gated node; an existing session STANDS and an in-flight dial is joined rather than re-judged, because the rule is about opening and not about tearing down; an ungated node ignores the field. It notes that the inbound gate already refuses such a peer's fresh hello with 4004, so both directions agree. - The provisional-entry sentence gains the fact that `confirm` restores a login WhoIs authenticated even after Layer 3 has stopped naming one. Docs only; no code changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #188 (the Apple identity and watch-recovery fixes); rebased onto main once #188 merged.
AuthenticatedPeer.loginName/displayName(from the WhoIsUserProfile),BackendPeer.loginName,BackendStatus.loginName,Peer.loginName,MeshNode.loginName;MeshConfiguration.loginAllowwith the same grammar as the Go and Rust gates (LoginGlob, a faithfulpath.Matchport, verified against 1.7 M generated pairs);Handshake.serverapplies RFC 025 §3.4's table (a gate is never bypassed by.allowUnverified);MeshNode.upsertFromLayer3admits onlyisAppPeer && allowedand EVICTS an admitted peer whose row turns refused (closing its session,peerLeft);MeshError.helloRefused(code:reason:)on the dial side;MeshError.loginRefused(login:)server side;MeshError.loginUnknown(peer:)when a gated node declines to open a new session to a kept peer whose owner is unresolved (its login reads nil; an existing session stands);SessionCloseCode.loginRefused = 4004;MeshNode.whoIs(remoteEndpoint:)so an app can gate a raw accepted connection (the raw plane is not gated byloginAllow).The pinned TailscaleKit decodes
IpnState.PeerStatuswithoutUserID, soTailscaleKitBackend.refreshStatusalso GETs/localapi/v0/statusover the authenticated loopback and overlays the logins (LocalAPIIdentity.swift; one extra GET per refresh; a failed overlay leaves logins absent, so a gated node admits nobody, with a one-shot health notice). RFC 024 §8.1.2 documents it; RFC 025 §3.3 carries the dated correction.Gates:
swift test --package-path apple70 → 107 tests / 17 suites · the root "as published" build+test 107/17 ·xcodebuild -scheme TruffleTailscalefor both iOS destinations (serially) BUILD SUCCEEDED, zero warnings · reviewed read-only with a live probe (the login-change bypass found, fixed, re-proven). Not witnessed here: the production LocalAPI reads on a real device (owed as a device gate).🤖 Generated with Claude Code
https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.