diff --git a/apple/Sources/Truffle/Backend/LoopbackBackend.swift b/apple/Sources/Truffle/Backend/LoopbackBackend.swift index a30f9f91..775ff1e5 100644 --- a/apple/Sources/Truffle/Backend/LoopbackBackend.swift +++ b/apple/Sources/Truffle/Backend/LoopbackBackend.swift @@ -15,6 +15,10 @@ public actor LoopbackNetwork { var hostname: String var ip: String var online: Bool + /// The node owner's tailnet login (RFC 025 §3.3). `nil` models a + /// backend that cannot report one — the gate's fail-closed row. + var loginName: String? + var displayName: String? /// Hidden nodes can dial and are WhoIs-resolvable but never appear /// in snapshots or join announcements — simulates an inbound hello /// racing ahead of the netmap (RFC 024 §7.2). @@ -29,6 +33,9 @@ public actor LoopbackNetwork { /// When true, `whoIs` reports no concrete identity (exercises the /// fail-closed path). private var withholdWhoIs = false + /// When true, `whoIs` still reports a concrete node ID but no login — + /// the RFC 025 §3.4 row "`nodeId` ok, `loginName` absent". + private var withholdWhoIsLogin = false public init() {} @@ -36,17 +43,28 @@ public actor LoopbackNetwork { withholdWhoIs = value } + public func setWithholdWhoIsLogin(_ value: Bool) { + withholdWhoIsLogin = value + } + + /// Change a registered node's login after `join` (a profile switch). + public func setLogin(tailscaleId: String, loginName: String?) { + nodes[tailscaleId]?.loginName = loginName + } + /// Register a node and return its backend. `hostname` should follow the /// `truffle-{appId}-{slug}` scheme for discovery (RFC 024 §7.2). /// `hidden` nodes stay out of snapshots/announcements (raced-netmap /// simulation) until `reveal(tailscaleId:)`. public func join( - tailscaleId: String, hostname: String, hidden: Bool = false + tailscaleId: String, hostname: String, hidden: Bool = false, + loginName: String? = nil, displayName: String? = nil ) -> LoopbackBackend { let ip = "100.64.0.\(nextIP)" nextIP += 1 let node = Node( - tailscaleId: tailscaleId, hostname: hostname, ip: ip, online: true, hidden: hidden) + tailscaleId: tailscaleId, hostname: hostname, ip: ip, online: true, + loginName: loginName, displayName: displayName, hidden: hidden) nodes[tailscaleId] = node let backend = LoopbackBackend(network: self, tailscaleId: tailscaleId, ip: ip) nodes[tailscaleId]?.backend = backend @@ -90,7 +108,8 @@ public actor LoopbackNetwork { hostname: node.hostname, dnsName: "\(node.hostname).loopback.ts.net", tailnetIPs: [node.ip], - online: node.online) + online: node.online, + loginName: node.loginName) } func snapshot(for selfId: String) -> BackendStatus { @@ -105,6 +124,7 @@ public actor LoopbackNetwork { dnsName: "\(me.hostname).loopback.ts.net", tailnetIPs: [me.ip], tailscaleId: me.tailscaleId, + loginName: me.loginName, peers: peers) } @@ -159,7 +179,10 @@ public actor LoopbackNetwork { let ip = remoteEndpoint.split(separator: ":").first.map(String.init) ?? "" let match = nodes.values.first { $0.ip == ip } return AuthenticatedPeer( - tailscaleId: match?.tailscaleId ?? "", remoteAddresses: [remoteEndpoint]) + tailscaleId: match?.tailscaleId ?? "", + remoteAddresses: [remoteEndpoint], + loginName: withholdWhoIsLogin ? nil : match?.loginName, + displayName: withholdWhoIsLogin ? nil : match?.displayName) } } diff --git a/apple/Sources/Truffle/Backend/NetworkBackend.swift b/apple/Sources/Truffle/Backend/NetworkBackend.swift index bf8fc7d6..dc7a787a 100644 --- a/apple/Sources/Truffle/Backend/NetworkBackend.swift +++ b/apple/Sources/Truffle/Backend/NetworkBackend.swift @@ -31,13 +31,30 @@ public protocol MeshListener: Sendable { /// stable Tailscale node ID and the normalized remote addresses used for the /// comparison. An empty `tailscaleId` means WhoIs produced no concrete /// identity — the production inbound policy fails closed on that. +/// +/// `loginName` / `displayName` carry the caller's tailnet user profile +/// (RFC 025 §3.6, D7). Both are ABSENT, never fabricated: a WhoIs answer with +/// no user profile — or with an empty string in one — leaves the field `nil`. +/// A tagged node reports Tailscale's `tagged-devices` pseudo-login, which is +/// passed through unchanged rather than special-cased. public struct AuthenticatedPeer: Sendable, Hashable { public let tailscaleId: String public let remoteAddresses: [String] + /// The caller's tailnet login (`UserProfile.LoginName`), e.g. + /// `alice@corp.com`. The login gate's only authority — never + /// self-declared (RFC 025 §3.7, D8). + public let loginName: String? + /// The caller's human-readable profile name (`UserProfile.DisplayName`). + public let displayName: String? - public init(tailscaleId: String, remoteAddresses: [String]) { + public init( + tailscaleId: String, remoteAddresses: [String], loginName: String? = nil, + displayName: String? = nil + ) { self.tailscaleId = tailscaleId self.remoteAddresses = remoteAddresses + self.loginName = loginName + self.displayName = displayName } } @@ -48,16 +65,21 @@ public struct BackendPeer: Sendable, Equatable { public var dnsName: String? public var tailnetIPs: [String] public var online: Bool + /// The login of the tailnet user who owns this node (RFC 025 §3.3) — + /// a netmap fact, `nil` when the backend cannot report one. A gated node + /// treats a row without a login as NOT a peer (fail closed). + public var loginName: String? public init( tailscaleId: String, hostname: String, dnsName: String? = nil, - tailnetIPs: [String] = [], online: Bool = true + tailnetIPs: [String] = [], online: Bool = true, loginName: String? = nil ) { self.tailscaleId = tailscaleId self.hostname = hostname self.dnsName = dnsName self.tailnetIPs = tailnetIPs self.online = online + self.loginName = loginName } } @@ -71,12 +93,15 @@ public struct BackendStatus: Sendable, Equatable { public var tailnetIPs: [String] /// Our own stable Tailscale node ID (empty until known). public var tailscaleId: String + /// The login this node is signed in as (RFC 025 §3.6, D7) — `nil` until + /// known, never fabricated. A tagged node reports `tagged-devices`. + public var loginName: String? public var peers: [BackendPeer] public init( running: Bool = false, needsLogin: Bool = false, needsMachineAuth: Bool = false, authURL: String? = nil, dnsName: String? = nil, tailnetIPs: [String] = [], - tailscaleId: String = "", peers: [BackendPeer] = [] + tailscaleId: String = "", loginName: String? = nil, peers: [BackendPeer] = [] ) { self.running = running self.needsLogin = needsLogin @@ -85,6 +110,7 @@ public struct BackendStatus: Sendable, Equatable { self.dnsName = dnsName self.tailnetIPs = tailnetIPs self.tailscaleId = tailscaleId + self.loginName = loginName self.peers = peers } } diff --git a/apple/Sources/Truffle/Identity/LoginGlob.swift b/apple/Sources/Truffle/Identity/LoginGlob.swift new file mode 100644 index 00000000..4bba139c --- /dev/null +++ b/apple/Sources/Truffle/Identity/LoginGlob.swift @@ -0,0 +1,236 @@ +/// Login allow-lists (RFC 025 §3.2). +/// +/// A node may declare which tailnet **logins** may join its session plane. +/// The list is a set of shell-style globs evaluated against a caller's WhoIs +/// `loginName` — the same gate the Go sidecar applies to served routes +/// (RFC 023 §9.7, `allowedLogin` in `sidecar-slim/main.go`) and the Rust core +/// applies to its peer filter and hello (`network/login_allow.rs`). One +/// grammar and one test table cover all three planes. +/// +/// The grammar is Go's `path.Match`, applied after lowercasing both sides: +/// +/// - `*` matches any run (including empty) of characters other than `/`; +/// - `?` matches exactly one character other than `/`; +/// - `[abc]`, `[a-z]`, `[^abc]` character classes (ranges, negation, `\` +/// escapes inside); +/// - `\x` matches `x` literally; +/// - a malformed pattern (an unterminated class, a trailing `\`, an empty or +/// reversed range) never matches and never traps. +/// +/// An **empty list means no gate**. A non-empty list against an **absent or +/// empty login fails closed** — tagged nodes report Tailscale's +/// `tagged-devices` pseudo-login and only match a glob that names it. +/// +/// Matching walks Unicode **scalars**, not grapheme clusters, so `?` and the +/// class ranges count and order exactly what Go's `rune` and Rust's `char` +/// count and order. +public enum LoginGlob { + /// A pattern `path.Match` would reject with `ErrBadPattern`. + public struct BadPattern: Error, Equatable, CustomStringConvertible, Sendable { + public init() {} + public var description: String { "syntax error in login glob" } + } + + /// The gate: does `login` pass `globs`? + /// + /// `globs` empty → `true` (no gate). `login` `nil` or empty with a + /// non-empty list → `false` (fail closed). Otherwise `true` iff at least + /// one glob matches, case-insensitively; malformed globs are skipped. + public static func allowed(_ globs: [String], login: String?) -> Bool { + if globs.isEmpty { return true } + guard let login, !login.isEmpty else { return false } + let lowered = login.lowercased() + return globs.contains { glob in + ((try? match(glob.lowercased(), lowered)) ?? false) + } + } + + /// A faithful port of Go's `path.Match(pattern, name)`: case-sensitive, + /// `*` and `?` never cross `/`. Callers wanting the gate's semantics use + /// ``allowed(_:login:)``, which lowercases and treats a throw as + /// "no match". + public static func match(_ pattern: String, _ name: String) throws -> Bool { + var pattern = ArraySlice(Array(pattern.unicodeScalars)) + var name = ArraySlice(Array(name.unicodeScalars)) + + patternLoop: while !pattern.isEmpty { + let (star, chunk, rest) = scanChunk(pattern) + pattern = rest + if star && chunk.isEmpty { + // A trailing `*` matches the rest of the name unless it has a `/`. + return !name.contains("/") + } + // Look for a match at the current position. + let (t, ok, err) = matchChunk(chunk, name) + // If this is the last chunk, the name must be exhausted here; + // otherwise a later chunk could still match via the star. + if ok && (t.isEmpty || !pattern.isEmpty) { + name = t + continue + } + if err { throw BadPattern() } + if star { + // Look for a match skipping i+1 characters. Cannot skip `/`. + let scalars = Array(name) + var i = 0 + while i < scalars.count && scalars[i] != "/" { + let (t, ok, err) = matchChunk(chunk, name.dropFirst(i + 1)) + if ok { + // If this is the last chunk, the name must be exhausted. + if pattern.isEmpty && !t.isEmpty { + i += 1 + continue + } + name = t + continue patternLoop + } + if err { throw BadPattern() } + i += 1 + } + } + // Before answering "no match", check the remainder of the pattern + // is syntactically valid (Go reports ErrBadPattern first). + while !pattern.isEmpty { + let (_, chunk, rest) = scanChunk(pattern) + pattern = rest + let (_, _, err) = matchChunk(chunk, ArraySlice()) + if err { throw BadPattern() } + } + return false + } + return name.isEmpty + } + + // MARK: - Go `path.Match` internals + + /// Split `pattern` into a leading run of `*`s, the next literal chunk (up + /// to but not including the next unescaped `*` outside a class), and the + /// rest. + private static func scanChunk( + _ pattern: ArraySlice + ) -> (star: Bool, chunk: ArraySlice, rest: ArraySlice) { + var star = false + var p = pattern + while p.first == "*" { + p = p.dropFirst() + star = true + } + let scalars = Array(p) + var inRange = false + var i = 0 + scan: while i < scalars.count { + switch scalars[i] { + case "\\": + // An escaped character never ends the chunk. + if i + 1 < scalars.count { i += 1 } + case "[": + inRange = true + case "]": + inRange = false + case "*": + if !inRange { break scan } + default: + break + } + i += 1 + } + return (star, p.prefix(i), p.dropFirst(i)) + } + + /// Match `chunk` (which has no `*`) against the start of `s`. Returns the + /// remainder of `s`, whether it matched, and whether the chunk was + /// malformed. Like Go, syntax is checked to the end of the chunk even + /// after the match has already failed. + private static func matchChunk( + _ chunk: ArraySlice, _ s: ArraySlice + ) -> (rest: ArraySlice, ok: Bool, err: Bool) { + var chunk = chunk + var s = s + var failed = false + while let head = chunk.first { + if !failed && s.isEmpty { failed = true } + switch head { + case "[": + // Character class. + var r: Unicode.Scalar = "\0" + if !failed { + r = s.first! + s = s.dropFirst() + } + chunk = chunk.dropFirst() + // Possibly negated. + var negated = false + if chunk.first == "^" { + negated = true + chunk = chunk.dropFirst() + } + // Parse all ranges. + var matched = false + var nrange = 0 + while true { + if chunk.first == "]" && nrange > 0 { + chunk = chunk.dropFirst() + break + } + guard let (lo, afterLo) = getEsc(chunk) else { + return (ArraySlice(), false, true) + } + chunk = afterLo + var hi = lo + if chunk.first == "-" { + guard let (h, afterHi) = getEsc(chunk.dropFirst()) else { + return (ArraySlice(), false, true) + } + hi = h + chunk = afterHi + } + if lo <= r && r <= hi { matched = true } + nrange += 1 + } + if matched == negated { failed = true } + case "?": + if !failed { + if s.first! == "/" { failed = true } + s = s.dropFirst() + } + chunk = chunk.dropFirst() + case "\\": + chunk = chunk.dropFirst() + if chunk.isEmpty { + return (ArraySlice(), false, true) + } + // Fall through to the literal comparison. + fallthrough + default: + if !failed { + if chunk.first! != s.first! { failed = true } + s = s.dropFirst() + } + chunk = chunk.dropFirst() + } + } + if failed { + return (ArraySlice(), false, false) + } + return (s, true, false) + } + + /// Read one possibly-escaped character of a class body. `nil` is Go's + /// `ErrBadPattern`: an empty body, a `-` or `]` where a character is + /// required, a trailing `\`, or a class that ends right after the + /// character. + private static func getEsc( + _ chunk: ArraySlice + ) -> (scalar: Unicode.Scalar, rest: ArraySlice)? { + guard let head = chunk.first, head != "-", head != "]" else { return nil } + var c = chunk + if c.first == "\\" { + c = c.dropFirst() + if c.isEmpty { return nil } + } + let r = c.first! + let rest = c.dropFirst() + if rest.isEmpty { return nil } + return (r, rest) + } +} diff --git a/apple/Sources/Truffle/Mesh/MeshError.swift b/apple/Sources/Truffle/Mesh/MeshError.swift index eed0fb09..9e1c1525 100644 --- a/apple/Sources/Truffle/Mesh/MeshError.swift +++ b/apple/Sources/Truffle/Mesh/MeshError.swift @@ -11,6 +11,21 @@ public enum MeshError: Error, Sendable, Equatable { case peerGone(String) case identityUnavailable(String) case identityMismatch(claimed: String, authenticated: String) + /// The caller's WhoIs login is absent from, or matches no glob in, this + /// node's `loginAllow` list (RFC 025 §3.4, D4). Close code 4004. This is + /// the SERVER-role error — the side that ran the gate. + case loginRefused(login: String?) + /// A GATED node declined to open a NEW connection to a kept peer whose + /// current Layer 3 row cannot name its owner (RFC 025 §3.3). The peer is + /// still listed — its `loginName` reads `nil` — and any EXISTING session + /// to it still works; only opening a new one is refused. An ungated node + /// never raises this. + case loginUnknown(peer: String) + /// The remote closed with an application code (4000–4999) before sending + /// its hello: 4001 app mismatch, 4002 hello protocol, 4003 identity, + /// 4004 login refused. This is the DIALING side's view of a refusal, and + /// it carries the code so a caller can tell a gate from a broken pipe. + case helloRefused(code: UInt16, reason: String) case invalidPayload(String) case payloadTooLarge(actual: Int, limit: Int) case protocolViolation(String) diff --git a/apple/Sources/Truffle/Mesh/MeshNode.swift b/apple/Sources/Truffle/Mesh/MeshNode.swift index a890e595..64400736 100644 --- a/apple/Sources/Truffle/Mesh/MeshNode.swift +++ b/apple/Sources/Truffle/Mesh/MeshNode.swift @@ -35,6 +35,9 @@ public actor MeshNode { var hostname: String var tailnetIPs: [String] var online: Bool + /// The owner's tailnet login as Layer 3 reported it (RFC 025 §3.3); + /// `nil` for a provisional entry whose netmap row has not arrived. + var loginName: String? /// Confirmed identity after a completed hello; nil for candidates. var identity: PeerIdentity? /// Created from an inbound hello that raced ahead of the netmap @@ -86,6 +89,10 @@ public actor MeshNode { private var localTailscaleId = "" private var localDnsName: String? private var localIPs: [String] = [] + private var localLoginName: String? + /// Set once a gated node has seen a Layer 3 snapshot with no self login, + /// so the health notice is emitted once rather than per refresh. + private var warnedAboutMissingLogins = false // MARK: - Lifecycle @@ -270,6 +277,14 @@ public actor MeshNode { public var dnsName: String? { localDnsName } public var tailnetIPs: [String] { localIPs } + /// The tailnet login this node is signed in as (RFC 025 §3.6, D7), from + /// the last Layer 3 status. `nil` until known — never fabricated. + public var loginName: String? { localLoginName } + + /// The login allow-list this node was started with (RFC 025 §3.1). + /// Empty means ungated. Fixed for the node's lifetime. + public var loginAllow: [String] { config.loginAllow } + public var localPeer: Peer { Peer( ref: PeerRef(tailscaleId: localTailscaleId, generation: 0), @@ -281,7 +296,8 @@ public actor MeshNode { tailnetIPs: localIPs, online: phaseValue == .running, appId: appId.value, - isLocal: true) + isLocal: true, + loginName: localLoginName) } /// Each access mints a NEW independently-buffered stream (RFC 024 §6.2): @@ -413,7 +429,8 @@ public actor MeshNode { tailnetIPs: entry.tailnetIPs, online: entry.online, appId: entry.identity != nil ? appId.value : nil, - isLocal: false) + isLocal: false, + loginName: entry.loginName) } /// Generation-checked live lookup for peer-taking calls (RFC 024 §6.3). @@ -495,9 +512,26 @@ public actor MeshNode { public func dial(to peer: Peer, port: UInt16) async throws -> any MeshConnection { let entry = try resolveLive(peer) + // The raw plane is not gated for INBOUND connections (§3.7, D9), but + // an outbound dial is this node's own act: a gated node does not open + // one to a peer it cannot attribute (RFC 025 §3.3). + try requireKnownLogin(entry) return try await backend.dial(host: dialHost(for: entry), port: port) } + /// WhoIs for an address this node accepted on the RAW plane + /// (RFC 025 §3.7, D9). `listen(port:)` is NOT gated by `loginAllow` — the + /// node's list admits peers and session-plane hellos, and the app owns + /// admission on its own port. This is how an app gates an accepted + /// connection: resolve `MeshAcceptedConnection.remoteEndpoint`, then + /// decide on `loginName` (with `LoginGlob.allowed` if it wants the same + /// grammar). Throws when the lookup fails; an empty `tailscaleId` means + /// WhoIs produced no concrete identity and must be treated as untrusted. + public func whoIs(remoteEndpoint: String) async throws -> AuthenticatedPeer { + guard !isStopped else { throw MeshError.stopped } + return try await backend.whoIs(remoteEndpoint: remoteEndpoint) + } + public func listen(port: UInt16) async throws -> any MeshListener { guard !isStopped else { throw MeshError.stopped } guard port != SessionLimits.sessionPort else { @@ -529,7 +563,9 @@ public actor MeshNode { case .status(let status): await apply(status: status) case .peerUpsert(let peer): - upsertFromLayer3(peer) + if upsertFromLayer3(peer) { + await removeEntry(tailscaleId: peer.tailscaleId) + } case .peerLeft(let tailscaleId): await removeEntry(tailscaleId: tailscaleId) case .authRequired(let url): @@ -543,6 +579,7 @@ public actor MeshNode { localTailscaleId = status.tailscaleId localDnsName = status.dnsName localIPs = status.tailnetIPs + localLoginName = status.loginName if status.running { setPhase(.running) @@ -565,7 +602,25 @@ public actor MeshNode { var seen = Set() for peer in status.peers { seen.insert(peer.tailscaleId) - upsertFromLayer3(peer) + if upsertFromLayer3(peer) { + await removeEntry(tailscaleId: peer.tailscaleId) + } + } + // RFC 025 §3.3: a gated node cannot admit a peer whose login Layer 3 + // never reported. Say so — once — rather than presenting an + // unexplained empty mesh (the Rust provider refuses to start; a Swift + // node has no sidecar to interrogate, so it reports honestly). + if !config.loginAllow.isEmpty, !warnedAboutMissingLogins, + status.peers.contains(where: { + $0.loginName == nil + && Hostname.isAppPeer(hostname: $0.hostname, appId: appId.value) + }) + { + warnedAboutMissingLogins = true + emit( + .health( + "login gate active but Layer 3 reported an app peer with no login; " + + "peers without a login are not admitted")) } // Entries absent from a full snapshot have left Layer 3 — EXCEPT // provisional entries, whose netmap event hasn't arrived yet @@ -576,22 +631,54 @@ public actor MeshNode { } } - /// Candidate filtering (RFC 024 §7.2): only hostnames matching - /// `truffle-{appId}-{slug}` enter the registry — except provisional - /// entries created by a raced inbound hello, which merge Layer 3 - /// metadata into the same generation. - private func upsertFromLayer3(_ peer: BackendPeer) { + /// Candidate filtering (RFC 024 §7.2, RFC 025 §3.3): a hostname matching + /// `truffle-{appId}-{slug}` AND — on a login-gated node — a login on the + /// allow-list. A gated node treats a row WITHOUT a login as not a peer + /// (fail closed). + /// + /// The gate runs on entry CREATION and on every later row for an entry + /// that already exists. A stable node ID survives a device transfer, so + /// the netmap reports a re-signed node as an UPDATE, not a new row: if the + /// gate ran only at creation, a peer admitted as `bob@corp.com` would keep + /// its place after re-signing as a foreign login. A row whose login the + /// gate REFUSES is therefore a departure — the caller evicts the entry, + /// which closes its session and emits `peerLeft`. + /// + /// A row that names NO owner neither evicts nor keeps the last-known + /// login: the entry stays (a transient failure to read the logins must not + /// empty a gated mesh) and its `loginName` goes `nil`, because the row + /// names no owner and so neither do we — RFC 022's absent-never-fabricated + /// rule applies to a login we can no longer source as much as to one we + /// never had. A gated node then opens no NEW session to that peer + /// (`requireKnownLogin`), which is the dial-side half of the same rule. + /// Provisional entries from a raced inbound hello keep merging — that hello + /// passed the gate in `Handshake.server`, and `confirm` restores the login + /// WhoIs authenticated — but they are evicted on a refused login like any + /// other row. + /// + /// - Returns: `true` when this row must be evicted. Eviction is async and + /// this is not, so the caller performs it. + private func upsertFromLayer3(_ peer: BackendPeer) -> Bool { if var existing = entries[peer.tailscaleId] { + if let login = peer.loginName, + !LoginGlob.allowed(config.loginAllow, login: login) + { + return true + } existing.hostname = peer.hostname existing.tailnetIPs = peer.tailnetIPs existing.online = peer.online + existing.loginName = peer.loginName existing.provisional = false entries[peer.tailscaleId] = existing emit(.peerUpsert(makePeer(from: existing))) - return + return false } guard Hostname.isAppPeer(hostname: peer.hostname, appId: appId.value) else { - return + return false + } + guard LoginGlob.allowed(config.loginAllow, login: peer.loginName) else { + return false } generationCounter += 1 let entry = RegistryEntry( @@ -600,10 +687,12 @@ public actor MeshNode { hostname: peer.hostname, tailnetIPs: peer.tailnetIPs, online: peer.online, + loginName: peer.loginName, identity: nil, provisional: false) entries[peer.tailscaleId] = entry emit(.peerUpsert(makePeer(from: entry))) + return false } private func removeEntry(tailscaleId: String) async { @@ -635,13 +724,33 @@ public actor MeshNode { entry.tailnetIPs.first ?? entry.hostname } + /// A gated node opens no NEW connection to a kept peer whose current row + /// cannot name its owner (RFC 025 §3.3): we would be dialing someone we + /// cannot attribute, and the inbound gate already refuses that peer's own + /// fresh hello (WhoIs with no login → 4004), so both directions agree. + /// + /// It is a rule about OPENING, never about tearing down: an existing + /// session stands, so a momentary gap in the logins cannot flap a live + /// connection. An ungated node ignores the field entirely. + private func requireKnownLogin(_ entry: RegistryEntry) throws { + guard config.loginAllow.isEmpty || entry.loginName != nil else { + throw MeshError.loginUnknown( + peer: PeerRef( + tailscaleId: entry.tailscaleId, generation: entry.generation + ).description) + } + } + /// Get or create the session for a peer. Concurrent callers share one /// in-flight dial (no duplicate sessions across actor reentrancy). private func session(for entry: RegistryEntry) async throws -> SessionState { if let existing = sessions[entry.tailscaleId] { return existing } if let inFlight = dialsInFlight[entry.tailscaleId] { + // A dial already opened under a known login is joined, not + // re-judged: the check below guards STARTING one. return try await inFlight.value } + try requireKnownLogin(entry) let tailscaleId = entry.tailscaleId let host = dialHost(for: entry) let dial = Task { [weak self] () throws -> SessionState in @@ -669,7 +778,12 @@ public actor MeshNode { expectedTailscaleId: tailscaleId) } } catch { - await frames.close(code: SessionCloseCode.helloProtocol, reason: "handshake failed") + // A refusal (4001–4004) already closed the socket from the far + // end; echoing a close would be noise. Anything else gets one. + if !Handshake.isRefusal(error) { + await frames.close( + code: SessionCloseCode.helloProtocol, reason: "handshake failed") + } throw error } @@ -704,9 +818,11 @@ public actor MeshNode { /// Record a completed hello: confirm an existing candidate, or create a /// provisional entry when the hello raced ahead of the netmap /// (RFC 024 §7.2). - private func confirm(identity: PeerIdentity, tailscaleId: String) { + private func confirm(identity: PeerIdentity, tailscaleId: String, loginName: String? = nil) { if var entry = entries[tailscaleId] { entry.identity = identity + // Never overwrite a known login with nothing. + if let loginName { entry.loginName = loginName } entries[tailscaleId] = entry emit(.peerUpsert(makePeer(from: entry))) } else { @@ -717,6 +833,10 @@ public actor MeshNode { hostname: "", tailnetIPs: [], online: true, + // On a gated node this login is the one that PASSED the hello + // gate, so the provisional row is honest about who it admitted + // instead of waiting for the netmap to say. + loginName: loginName, identity: identity, provisional: true) entries[tailscaleId] = entry @@ -851,7 +971,7 @@ public actor MeshNode { // handshake deadline (RFC 024 §8.1 step 4), not just the hello. let identity = try await withDeadline( tuning.handshakeTimeout, label: "inbound handshake" - ) { [transport, backend, localHello, identityPolicy] in + ) { [transport, backend, localHello, identityPolicy, loginAllow = config.loginAllow] in let frames = try await transport.serverFrames(over: accepted.connection) let authenticated = try? await backend.whoIs( remoteEndpoint: accepted.remoteEndpoint) @@ -859,8 +979,11 @@ public actor MeshNode { frames: frames, localHello: localHello, authenticated: authenticated, - policy: identityPolicy) - return InboundHandshake(frames: frames, identity: identity) + policy: identityPolicy, + loginAllow: loginAllow) + return InboundHandshake( + frames: frames, identity: identity, + loginName: authenticated?.loginName) } await adoptInbound(identity) } catch { @@ -872,10 +995,14 @@ public actor MeshNode { private struct InboundHandshake: Sendable { let frames: any SessionFrames let identity: PeerIdentity + /// The WhoIs login this caller passed the gate with, if any. + let loginName: String? } private func adoptInbound(_ handshake: InboundHandshake) async { - confirm(identity: handshake.identity, tailscaleId: handshake.identity.tailscaleId) + confirm( + identity: handshake.identity, tailscaleId: handshake.identity.tailscaleId, + loginName: handshake.loginName) if let previous = sessions.removeValue(forKey: handshake.identity.tailscaleId) { previous.pump?.cancel() previous.heartbeat?.cancel() diff --git a/apple/Sources/Truffle/Mesh/MeshTypes.swift b/apple/Sources/Truffle/Mesh/MeshTypes.swift index a3107a90..920a355e 100644 --- a/apple/Sources/Truffle/Mesh/MeshTypes.swift +++ b/apple/Sources/Truffle/Mesh/MeshTypes.swift @@ -65,6 +65,25 @@ public struct MeshConfiguration: Sendable { public var ephemeral: Bool public var auth: MeshAuth + /// Login allow-list — the tailnet **logins** that may join this node's + /// mesh (RFC 025 §3.1/§3.2, D1/D2). Shell-style globs in Go `path.Match` + /// grammar, matched case-insensitively by ``LoginGlob``. + /// + /// **Empty (the default) = no gate**: today's behaviour exactly — the + /// whole tailnet, hostname-prefix discovery, and the existing + /// ``Handshake/IdentityPolicy`` alone on the inbound path. + /// + /// **Non-empty = gated**: only peers whose Layer 3 login matches are + /// reported (``MeshNode/peers()`` and `peerUpsert`), and every inbound + /// hello whose WhoIs login does not match is refused with close code + /// 4004 before our own hello is revealed. Under a gate an absent login + /// fails closed on both paths, and a caller with no authenticated + /// identity is refused with 4003 REGARDLESS of the identity policy. + /// + /// The list is fixed for the node's lifetime; to change it, restart the + /// node (RFC 025 §3.1). + public var loginAllow: [String] + public var logger: (any MeshLogger)? public init( @@ -74,6 +93,7 @@ public struct MeshConfiguration: Sendable { controlURL: URL? = nil, ephemeral: Bool = false, auth: MeshAuth = .existingState, + loginAllow: [String] = [], logger: (any MeshLogger)? = nil ) { self.appId = appId @@ -82,6 +102,7 @@ public struct MeshConfiguration: Sendable { self.controlURL = controlURL self.ephemeral = ephemeral self.auth = auth + self.loginAllow = loginAllow self.logger = logger } } diff --git a/apple/Sources/Truffle/Mesh/Peer.swift b/apple/Sources/Truffle/Mesh/Peer.swift index 538029fe..ddb2a795 100644 --- a/apple/Sources/Truffle/Mesh/Peer.swift +++ b/apple/Sources/Truffle/Mesh/Peer.swift @@ -44,6 +44,11 @@ public struct Peer: Identifiable, Hashable, Sendable { public let tailscaleId: String public let generation: UInt64 + /// The login of the tailnet user who owns this node (RFC 025 §3.6, D7) — + /// `nil` when Layer 3 reported none. On a gated node every listed peer + /// matched the node's allow-list, so this is the login that passed it. + public let loginName: String? + public let displayName: String public let hostname: String public let tailnetIPs: [String] @@ -54,12 +59,13 @@ public struct Peer: Identifiable, Hashable, Sendable { init( ref: PeerRef, deviceId: String?, tailscaleId: String, generation: UInt64, displayName: String, hostname: String, tailnetIPs: [String], online: Bool, - appId: String?, isLocal: Bool + appId: String?, isLocal: Bool, loginName: String? = nil ) { self.ref = ref self.deviceId = deviceId self.tailscaleId = tailscaleId self.generation = generation + self.loginName = loginName self.displayName = displayName self.hostname = hostname self.tailnetIPs = tailnetIPs @@ -81,6 +87,7 @@ public struct Peer: Identifiable, Hashable, Sendable { && lhs.tailnetIPs == rhs.tailnetIPs && lhs.online == rhs.online && lhs.appId == rhs.appId + && lhs.loginName == rhs.loginName } public func hash(into hasher: inout Hasher) { diff --git a/apple/Sources/Truffle/Session/Handshake.swift b/apple/Sources/Truffle/Session/Handshake.swift index a618fe8f..b4161cab 100644 --- a/apple/Sources/Truffle/Session/Handshake.swift +++ b/apple/Sources/Truffle/Session/Handshake.swift @@ -73,6 +73,14 @@ public enum Handshake { throw MeshError.protocolViolation("too many control frames before hello") } case .close(let code, let reason): + // An application close before the hello is the remote's + // REFUSAL, not a broken pipe — 4001 app mismatch, 4002 hello + // protocol, 4003 identity, 4004 login (RFC 025 §3.4). Carry + // the code so the dialing side can tell them apart; the Rust + // core surfaces the same distinction. + if (4000...4999).contains(code) { + throw MeshError.helloRefused(code: code, reason: reason) + } throw MeshError.protocolViolation( "peer closed connection before hello (code \(code): \(reason))") } @@ -100,9 +108,13 @@ public enum Handshake { } } catch { // Malformed / missing hello → 4002, mirroring desktop. Without - // this close the remote side would wait out its own timeout. - await frames.close( - code: SessionCloseCode.helloProtocol, reason: "hello not received") + // this close the remote side would wait out its own timeout. A + // REFUSAL is different: the remote already closed with its own + // code, so echoing 4002 at a dead socket only hides the reason. + if !isRefusal(error) { + await frames.close( + code: SessionCloseCode.helloProtocol, reason: "hello not received") + } throw error } @@ -135,11 +147,29 @@ public enum Handshake { /// a missing or empty stable node ID rejects with 4003; under /// `.allowUnverified` (tests only) the claim is accepted unverified — /// mirroring, explicitly, what desktop currently does implicitly. + /// + /// `loginAllow` is the node's login gate (RFC 025 §3.4, D4). Empty is + /// today's behaviour exactly. Non-empty applies the §3.4 table, in this + /// order, all of it BEFORE our own hello is sent so an impostor never + /// learns our identity block: + /// + /// | bridge identity | ungated | gated | + /// |--------------------------------------|--------------------|------------| + /// | absent / no `tailscaleId` | policy decides | **4003** | + /// | `tailscaleId` ≠ claimed | 4003 | 4003 | + /// | ok, `loginName` absent | accept | **4004** | + /// | ok, `loginName` matches no glob | accept | **4004** | + /// | ok, `loginName` matches | accept | accept | + /// + /// A gate is never bypassed by `.allowUnverified`: on a gated node an + /// absent identity is refused under EITHER policy, because the login the + /// gate needs can only come from an authenticated WhoIs answer. public static func server( frames: any SessionFrames, localHello: HelloEnvelope, authenticated: AuthenticatedPeer?, - policy: IdentityPolicy + policy: IdentityPolicy, + loginAllow: [String] = [] ) async throws -> PeerIdentity { let remote: HelloEnvelope do { @@ -147,10 +177,12 @@ public enum Handshake { try await receiveHello(frames) } } catch { - // Malformed / missing hello → 4002, mirroring desktop. Without - // this close the remote side would wait out its own timeout. - await frames.close( - code: SessionCloseCode.helloProtocol, reason: "hello not received") + // As in the client role: a remote that already closed with its own + // application code gets no 4002 echoed back at it. + if !isRefusal(error) { + await frames.close( + code: SessionCloseCode.helloProtocol, reason: "hello not received") + } throw error } @@ -162,16 +194,23 @@ public enum Handshake { throw map(error) } + let gated = !loginAllow.isEmpty let authenticatedId = authenticated?.tailscaleId ?? "" if authenticatedId.isEmpty { + let refuse: Bool switch policy { case .failClosed: + refuse = true + case .allowUnverified: + // A gate is never bypassed by the test policy: without an + // authenticated identity there is no login to gate on. + refuse = gated + } + if refuse { await frames.close( code: SessionCloseCode.identityMismatch, reason: "identity unavailable") throw MeshError.identityUnavailable( "no authenticated identity for incoming connection") - case .allowUnverified: - break } } else if authenticatedId != identity.tailscaleId { await frames.close( @@ -181,6 +220,17 @@ public enum Handshake { claimed: identity.tailscaleId, authenticated: authenticatedId) } + if gated { + // WhoIs is the only authority for the login — the hello never + // declares one (RFC 025 §3.7, D8). + let login = authenticated?.loginName + guard LoginGlob.allowed(loginAllow, login: login) else { + await frames.close( + code: SessionCloseCode.loginRefused, reason: "login refused") + throw MeshError.loginRefused(login: login) + } + } + let payload = String(decoding: try localHello.encoded(), as: UTF8.self) try await frames.send(.text(payload)) return identity @@ -188,6 +238,13 @@ public enum Handshake { // MARK: helpers + /// True for the error `receiveHello` raises when the remote closed with + /// an application code instead of sending a hello. + static func isRefusal(_ error: any Error) -> Bool { + guard let error = error as? MeshError, case .helloRefused = error else { return false } + return true + } + static func map(_ error: HelloValidationError) -> MeshError { switch error { case .malformed(let msg): diff --git a/apple/Sources/Truffle/Wire/HelloEnvelope.swift b/apple/Sources/Truffle/Wire/HelloEnvelope.swift index 72b51fc5..e8c635d8 100644 --- a/apple/Sources/Truffle/Wire/HelloEnvelope.swift +++ b/apple/Sources/Truffle/Wire/HelloEnvelope.swift @@ -80,6 +80,14 @@ public struct HelloEnvelope: Codable, Sendable, Equatable { // MARK: - Validation (port of websocket.rs::validate_hello) /// Classified hello failures. Each maps to an RFC 017 close code. +/// +/// Validation covers only what the hello itself can be wrong about: 4001 and +/// 4002. The refusals that depend on evidence OUTSIDE the hello are decided +/// afterwards by `Handshake.server`, which closes with +/// `SessionCloseCode.identityMismatch` (4003) for a contradicted or absent +/// WhoIs identity and `SessionCloseCode.loginRefused` (4004) for a login the +/// node's `loginAllow` does not admit (RFC 025 §3.4). The login is never +/// declared in the hello — WhoIs is its only authority (RFC 025 §3.7, D8). public enum HelloValidationError: Error, Sendable, Equatable { /// Malformed / invalid hello → close code 4002. case malformed(String) diff --git a/apple/Sources/Truffle/Wire/SessionLimits.swift b/apple/Sources/Truffle/Wire/SessionLimits.swift index cd46d348..0a304877 100644 --- a/apple/Sources/Truffle/Wire/SessionLimits.swift +++ b/apple/Sources/Truffle/Wire/SessionLimits.swift @@ -58,8 +58,13 @@ public enum SessionCloseCode { /// Malformed, invalid, or missing hello envelope. public static let helloProtocol: UInt16 = 4002 /// Claimed `tailscale_id` contradicts the authenticated identity, or no - /// authenticated identity was available under the fail-closed policy. + /// authenticated identity was available — under the fail-closed policy, + /// or on a login-gated node under ANY policy (RFC 025 §3.4). public static let identityMismatch: UInt16 = 4003 + /// The caller's WhoIs login is absent from, or matches no glob in, this + /// node's `loginAllow` list (RFC 025 §3.4/§4, D4). Sent before our own + /// hello, so a refused caller never learns our identity block. + public static let loginRefused: UInt16 = 4004 /// RFC 6455 normal closure. public static let normal: UInt16 = 1000 } diff --git a/apple/Sources/TruffleSwiftUI/MeshModel.swift b/apple/Sources/TruffleSwiftUI/MeshModel.swift index 676be2e8..9df90495 100644 --- a/apple/Sources/TruffleSwiftUI/MeshModel.swift +++ b/apple/Sources/TruffleSwiftUI/MeshModel.swift @@ -16,6 +16,9 @@ import Truffle public final class MeshModel { public private(set) var phase: MeshPhase = .stopped public private(set) var peers: [Peer] = [] + /// The tailnet login this node is signed in as (RFC 025 §3.6, D7), as of + /// the last Layer 3 status. `nil` until known — never fabricated. + public private(set) var loginName: String? public private(set) var authURL: URL? public private(set) var lastError: String? @@ -83,10 +86,12 @@ public final class MeshModel { authURL = nil } peers = await node.peers() + loginName = await node.loginName case .authRequired(let url): authURL = url case .peerUpsert, .peerLeft: peers = await node.peers() + loginName = await node.loginName case .message: break // chat-level concerns live in the host app case .health(let message): diff --git a/apple/Sources/TruffleTailscale/LocalAPIIdentity.swift b/apple/Sources/TruffleTailscale/LocalAPIIdentity.swift new file mode 100644 index 00000000..76417887 --- /dev/null +++ b/apple/Sources/TruffleTailscale/LocalAPIIdentity.swift @@ -0,0 +1,132 @@ +import Foundation + +/// Pure decoding of the two LocalAPI answers that carry tailnet **identity** +/// (RFC 025 §3.3/§3.6): the WhoIs response for an accepted connection, and +/// the login half of the node status. +/// +/// These types live outside `TailscaleKitBackend.swift`'s +/// `#if os(iOS) && canImport(TailscaleKit)` on purpose — the same reason +/// `TailscaleEndpoint` does. The decoding is the part that can be wrong in a +/// way tests can catch, and the macOS test target is the only place that runs. +/// +/// ## Why the status logins are read separately +/// +/// TailscaleKit's `IpnState.PeerStatus` models neither `UserID` nor a login, +/// and `IpnState.Status.SelfStatus` is a `PeerStatus` too, so +/// `status.User[String(peer.UserID)]` — the mapping RFC 025 §3.3 specifies — +/// cannot be expressed against the vendored binding at all: the field the map +/// is keyed by is dropped at decode. The JSON tsnet serves does carry it, so +/// the logins are read from the same `/localapi/v0/status` endpoint with a +/// decoder that keeps `UserID`, and merged onto the mapped `BackendStatus`. +/// Absent, never fabricated: a failed or partial read leaves the fields `nil`, +/// and a gated node then admits nobody (fail closed, RFC 025 §3.2). + +// MARK: - WhoIs + +/// A LocalAPI `/localapi/v0/whois` answer (`tailscale.com/client/tailscale/apitype`). +/// +/// `UserProfile` is optional: a tagged node or an unresolvable caller has +/// none, and the fields stay `nil` rather than becoming empty strings. +struct WhoIsResponse: Decodable, Equatable { + struct NodeInfo: Decodable, Equatable { + let StableID: String + let Addresses: [String]? + } + + struct UserProfileInfo: Decodable, Equatable { + let LoginName: String? + let DisplayName: String? + } + + let Node: NodeInfo + let UserProfile: UserProfileInfo? + + /// The caller's login, or `nil` when absent or empty on the wire. + var loginName: String? { LocalAPIIdentity.present(UserProfile?.LoginName) } + /// The caller's profile name, or `nil` when absent or empty on the wire. + var displayName: String? { LocalAPIIdentity.present(UserProfile?.DisplayName) } +} + +// MARK: - Status logins + +/// The login-bearing subset of a LocalAPI `/localapi/v0/status` answer. +/// +/// Only the fields RFC 025 §3.3 needs are modelled; everything else in the +/// status keeps coming from TailscaleKit's own decode, which stays the +/// authority for the rest of `BackendStatus`. +struct LocalAPIStatusLogins: Decodable, Equatable { + struct NodeRow: Decodable, Equatable { + let ID: String? + let UserID: Int64? + } + + struct Profile: Decodable, Equatable { + let LoginName: String? + let DisplayName: String? + } + + let SelfStatus: NodeRow? + let Peer: [String: NodeRow]? + let User: [String: Profile]? + + enum CodingKeys: String, CodingKey { + case Peer, User + case SelfStatus = "Self" + } +} + +/// The logins a status answer yields, keyed the way `BackendStatus` is: the +/// node's own login, and each peer's by **stable node ID**. +struct LoginOverlay: Equatable, Sendable { + var selfLogin: String? + var byStableNodeId: [String: String] + + init(selfLogin: String? = nil, byStableNodeId: [String: String] = [:]) { + self.selfLogin = selfLogin + self.byStableNodeId = byStableNodeId + } + + /// Resolve every node row's `UserID` through the status's user map. + /// A row with no `UserID`, no `ID`, or no matching profile contributes + /// nothing — its peer keeps a `nil` login. + init(_ decoded: LocalAPIStatusLogins) { + func login(for row: LocalAPIStatusLogins.NodeRow?) -> String? { + guard let userID = row?.UserID else { return nil } + return LocalAPIIdentity.present(decoded.User?[String(userID)]?.LoginName) + } + selfLogin = login(for: decoded.SelfStatus) + var byStableNodeId: [String: String] = [:] + for row in decoded.Peer?.values ?? [String: LocalAPIStatusLogins.NodeRow]().values { + guard let stableID = LocalAPIIdentity.present(row.ID), let name = login(for: row) + else { continue } + byStableNodeId[stableID] = name + } + self.byStableNodeId = byStableNodeId + } + + /// Merge onto a mapped status. This overlay is the authority for the + /// login fields of the snapshot it was read with: a peer it has no login + /// for gets `nil`, never a stale value from an earlier read. + func applied(to status: BackendStatus) -> BackendStatus { + var merged = status + merged.loginName = selfLogin + merged.peers = status.peers.map { peer in + var row = peer + row.loginName = byStableNodeId[peer.tailscaleId] + return row + } + return merged + } +} + +// MARK: - Shared helpers + +enum LocalAPIIdentity { + /// `nil` for an absent OR empty string: an empty login is not a login + /// (RFC 025 §3.2 fails closed on it), and RFC 022's honesty rule forbids + /// surfacing `""` as if it were an identity. + static func present(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return value + } +} diff --git a/apple/Sources/TruffleTailscale/TailscaleKitBackend.swift b/apple/Sources/TruffleTailscale/TailscaleKitBackend.swift index 02564a45..bedca275 100644 --- a/apple/Sources/TruffleTailscale/TailscaleKitBackend.swift +++ b/apple/Sources/TruffleTailscale/TailscaleKitBackend.swift @@ -151,10 +151,50 @@ } public func whoIs(remoteEndpoint: String) async throws -> AuthenticatedPeer { - guard let node else { throw MeshError.stopped } + guard node != nil else { throw MeshError.stopped } guard let endpoint = TailscaleEndpoint(remoteEndpoint) else { throw MeshError.protocolViolation("invalid accepted Tailscale endpoint") } + let data = try await localAPIGet( + path: "/localapi/v0/whois", + queryItems: [URLQueryItem(name: "addr", value: endpoint.whoIsAddress)], + label: "WhoIs") + let decoded = try JSONDecoder().decode(WhoIsResponse.self, from: data) + let stableID = decoded.Node.StableID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !stableID.isEmpty else { + throw MeshError.protocolViolation("LocalAPI WhoIs returned no stable node ID") + } + let addresses = decoded.Node.Addresses?.compactMap { + TailscaleEndpoint(Self.stripPrefix($0))?.ip + } ?? [] + guard addresses.contains(endpoint.ip) else { + throw MeshError.protocolViolation( + "LocalAPI WhoIs address does not match accepted endpoint") + } + return AuthenticatedPeer( + tailscaleId: stableID, + remoteAddresses: addresses + [remoteEndpoint], + loginName: decoded.loginName, + displayName: decoded.displayName) + } + + /// The tailnet logins for the current netmap (RFC 025 §3.3), read + /// from the same status endpoint TailscaleKit reads — with a decoder + /// that keeps `UserID`, which its binding drops. See + /// `LocalAPIIdentity.swift` for why this cannot come from + /// `IpnState.Status`. + private func statusLogins() async throws -> LoginOverlay { + let data = try await localAPIGet( + path: "/localapi/v0/status", queryItems: nil, label: "status") + return LoginOverlay( + try JSONDecoder().decode(LocalAPIStatusLogins.self, from: data)) + } + + /// One authenticated GET against this node's LocalAPI loopback. + private func localAPIGet( + path: String, queryItems: [URLQueryItem]?, label: String + ) async throws -> Data { + guard let node else { throw MeshError.stopped } let (sessionConfiguration, loopback) = try await URLSessionConfiguration.tailscaleSession(node) guard let ip = loopback.ip, let port = loopback.port else { @@ -164,10 +204,10 @@ components.scheme = "http" components.host = ip components.port = port - components.path = "/localapi/v0/whois" - components.queryItems = [URLQueryItem(name: "addr", value: endpoint.whoIsAddress)] + components.path = path + components.queryItems = queryItems guard let url = components.url else { - throw MeshError.transport("could not form LocalAPI WhoIs URL") + throw MeshError.transport("could not form LocalAPI \(label) URL") } var request = URLRequest(url: url) request.timeoutInterval = 15 @@ -178,23 +218,9 @@ .data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { let status = (response as? HTTPURLResponse)?.statusCode ?? -1 - throw MeshError.transport("LocalAPI WhoIs failed with HTTP \(status)") - } - let decoded = try JSONDecoder().decode(WhoIsResponse.self, from: data) - let stableID = decoded.Node.StableID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !stableID.isEmpty else { - throw MeshError.protocolViolation("LocalAPI WhoIs returned no stable node ID") + throw MeshError.transport("LocalAPI \(label) failed with HTTP \(status)") } - let addresses = decoded.Node.Addresses?.compactMap { - TailscaleEndpoint(Self.stripPrefix($0))?.ip - } ?? [] - guard addresses.contains(endpoint.ip) else { - throw MeshError.protocolViolation( - "LocalAPI WhoIs address does not match accepted endpoint") - } - return AuthenticatedPeer( - tailscaleId: stableID, - remoteAddresses: addresses + [remoteEndpoint]) + return data } public func makeURLSession( @@ -296,7 +322,13 @@ private func refreshStatus(emitChange: Bool) async throws -> BackendStatus { guard let localAPI else { throw MeshError.stopped } let raw = try await localAPI.backendStatus() - let mapped = Self.map(raw) + var mapped = Self.map(raw) + // The logins TailscaleKit's status binding drops (RFC 025 §3.3). + // A failed read leaves them nil — absent, never fabricated; a + // gated node then admits nobody, which is the fail-closed answer. + if let overlay = try? await statusLogins() { + mapped = overlay.applied(to: mapped) + } if emitChange, mapped != latest { emit(.status(mapped)) } if let authURL = mapped.authURL.flatMap(URL.init(string:)) { emit(.authRequired(authURL)) @@ -394,14 +426,6 @@ } } - private struct WhoIsResponse: Decodable { - struct NodeInfo: Decodable { - let StableID: String - let Addresses: [String]? - } - let Node: NodeInfo - } - private struct TailscaleLogAdapter: LogSink, @unchecked Sendable { let logFileHandle: Int32? = nil private let logger: (any MeshLogger)? diff --git a/apple/Tests/TruffleTailscaleTests/LocalAPIIdentityTests.swift b/apple/Tests/TruffleTailscaleTests/LocalAPIIdentityTests.swift new file mode 100644 index 00000000..cff80abd --- /dev/null +++ b/apple/Tests/TruffleTailscaleTests/LocalAPIIdentityTests.swift @@ -0,0 +1,310 @@ +import Foundation +import Testing + +@testable import TruffleTailscale + +/// The LocalAPI identity decoders (RFC 025 §3.3/§3.6), driven from literal +/// JSON so the mapping is pinned without a node, a tailnet, or a device. +/// +/// These live in `TruffleTailscaleTests` and not beside `TailscaleKitBackend` +/// because that file is `#if os(iOS) && canImport(TailscaleKit)` and compiles +/// to nothing on the macOS host the test target runs on — the same reason +/// `TailscaleEndpoint` is its own file. The decoding is the part that can be +/// wrong in a way a test can catch. +private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(type, from: Data(json.utf8)) +} + +@Suite struct WhoIsResponseTests { + @Test func carriesLoginAndDisplayNameWhenTheProfileIsPresent() throws { + let decoded = try decode( + WhoIsResponse.self, + """ + { + "Node": { + "ID": 4711, + "StableID": "nABC123", + "Name": "truffle-demo-alice.corp.ts.net.", + "Addresses": ["100.64.0.2/32", "fd7a:115c:a1e0::2/128"] + }, + "UserProfile": { + "ID": 12345, + "LoginName": "alice@corp.com", + "DisplayName": "Alice Example", + "ProfilePicURL": "https://example.com/a.png" + } + } + """) + #expect(decoded.Node.StableID == "nABC123") + #expect(decoded.Node.Addresses == ["100.64.0.2/32", "fd7a:115c:a1e0::2/128"]) + #expect(decoded.loginName == "alice@corp.com") + #expect(decoded.displayName == "Alice Example") + } + + /// The shape #188 already handled: no profile at all. The node ID still + /// decodes and the identity fields stay absent rather than becoming "". + @Test func hasNoLoginWhenTheProfileIsAbsent() throws { + let decoded = try decode( + WhoIsResponse.self, + """ + {"Node": {"ID": 1, "StableID": "nNOPROFILE", "Addresses": ["100.64.0.3/32"]}} + """) + #expect(decoded.Node.StableID == "nNOPROFILE") + #expect(decoded.loginName == nil) + #expect(decoded.displayName == nil) + } + + /// An empty string on the wire is not an identity (RFC 022's honesty + /// rule; RFC 025 §3.2 fails closed on an empty login). + @Test func emptyProfileStringsBecomeNil() throws { + let decoded = try decode( + WhoIsResponse.self, + """ + { + "Node": {"ID": 2, "StableID": "nEMPTY", "Addresses": []}, + "UserProfile": {"ID": 0, "LoginName": "", "DisplayName": ""} + } + """) + #expect(decoded.loginName == nil) + #expect(decoded.displayName == nil) + } + + /// A tagged node's pseudo-login is passed through, never special-cased — + /// it only matches a glob that names it (RFC 025 §3.2). + @Test func taggedDevicesLoginIsPassedThrough() throws { + let decoded = try decode( + WhoIsResponse.self, + """ + { + "Node": {"ID": 3, "StableID": "nTAGGED", "Addresses": ["100.64.0.4/32"]}, + "UserProfile": {"ID": 99, "LoginName": "tagged-devices", "DisplayName": "Tagged"} + } + """) + #expect(decoded.loginName == "tagged-devices") + } + + /// Missing `Addresses` stays `nil` rather than failing the decode — the + /// tolerant-reader rule, and the shape #188's address check relies on. + @Test func absentAddressesDecodeAsNil() throws { + let decoded = try decode( + WhoIsResponse.self, #"{"Node": {"ID": 5, "StableID": "nNOADDR"}}"#) + #expect(decoded.Node.Addresses == nil) + } +} + +@Suite struct LoginOverlayTests { + /// A realistic `/localapi/v0/status` answer: `Peer` is keyed by node key + /// while each row's own `ID` is the stable node ID `BackendPeer` uses, + /// and `User` is keyed by the STRINGIFIED numeric user id. + private static let statusJSON = """ + { + "Version": "1.102.3", + "BackendState": "Running", + "AuthURL": "", + "TailscaleIPs": ["100.64.0.1"], + "Self": { + "ID": "nSELF", "UserID": 12345, + "HostName": "truffle-demo-alice", "Online": true + }, + "Peer": { + "nodekey:aaaa": { + "ID": "nBOB", "UserID": 12345, + "HostName": "truffle-demo-bob", "Online": true + }, + "nodekey:bbbb": { + "ID": "nMALLORY", "UserID": 67890, + "HostName": "truffle-demo-mallory", "Online": true + }, + "nodekey:cccc": { + "ID": "nORPHAN", "UserID": 55555, + "HostName": "truffle-demo-orphan", "Online": true + }, + "nodekey:dddd": { + "ID": "nNOUSER", + "HostName": "truffle-demo-nouser", "Online": true + } + }, + "User": { + "12345": {"ID": 12345, "LoginName": "alice@corp.com", "DisplayName": "Alice"}, + "67890": {"ID": 67890, "LoginName": "mallory@evil.com", "DisplayName": "Mallory"} + } + } + """ + + private func overlay() throws -> LoginOverlay { + LoginOverlay(try decode(LocalAPIStatusLogins.self, Self.statusJSON)) + } + + @Test func resolvesSelfAndPeerLoginsThroughTheUserMap() throws { + let overlay = try overlay() + #expect(overlay.selfLogin == "alice@corp.com") + #expect(overlay.byStableNodeId["nBOB"] == "alice@corp.com") + #expect(overlay.byStableNodeId["nMALLORY"] == "mallory@evil.com") + // A UserID with no profile in the map, and a row with no UserID at + // all, contribute nothing — absent, never fabricated. + #expect(overlay.byStableNodeId["nORPHAN"] == nil) + #expect(overlay.byStableNodeId["nNOUSER"] == nil) + #expect(overlay.byStableNodeId.count == 2) + } + + @Test func mergesOntoAMappedStatusByStableNodeId() throws { + let mapped = BackendStatus( + running: true, + dnsName: "truffle-demo-alice.corp.ts.net", + tailnetIPs: ["100.64.0.1"], + tailscaleId: "nSELF", + peers: [ + BackendPeer(tailscaleId: "nBOB", hostname: "truffle-demo-bob"), + BackendPeer(tailscaleId: "nMALLORY", hostname: "truffle-demo-mallory"), + BackendPeer(tailscaleId: "nNOUSER", hostname: "truffle-demo-nouser"), + ]) + let merged = try overlay().applied(to: mapped) + + #expect(merged.loginName == "alice@corp.com") + #expect(merged.peers.map(\.loginName) == ["alice@corp.com", "mallory@evil.com", nil]) + // Everything the overlay does not own is carried through untouched. + #expect(merged.tailscaleId == "nSELF") + #expect(merged.dnsName == "truffle-demo-alice.corp.ts.net") + #expect(merged.tailnetIPs == ["100.64.0.1"]) + #expect(merged.peers.map(\.hostname) == mapped.peers.map(\.hostname)) + #expect(merged.peers.map(\.tailscaleId) == mapped.peers.map(\.tailscaleId)) + #expect(merged.running) + } + + /// The overlay is the authority for the snapshot it was read with: a peer + /// it has no login for is cleared, never left holding an earlier read's + /// value. Otherwise a departed user's login could gate a new node in. + @Test func clearsStaleLoginsItDoesNotOwn() throws { + let stale = BackendStatus( + tailscaleId: "nSELF", + loginName: "someone-else@corp.com", + peers: [ + BackendPeer( + tailscaleId: "nNOUSER", hostname: "truffle-demo-nouser", + loginName: "alice@corp.com") + ]) + let merged = try overlay().applied(to: stale) + #expect(merged.loginName == "alice@corp.com") + #expect(merged.peers[0].loginName == nil) + } + + /// A status with no `Self`, no `Peer` and no `User` decodes and yields + /// nothing — the shape a not-yet-running backend returns. + @Test func emptyStatusYieldsNoLogins() throws { + let overlay = LoginOverlay( + try decode(LocalAPIStatusLogins.self, #"{"BackendState": "NeedsLogin"}"#)) + #expect(overlay.selfLogin == nil) + #expect(overlay.byStableNodeId.isEmpty) + + let merged = overlay.applied( + to: BackendStatus( + tailscaleId: "nSELF", + peers: [BackendPeer(tailscaleId: "nBOB", hostname: "truffle-demo-bob")])) + #expect(merged.loginName == nil) + #expect(merged.peers[0].loginName == nil) + } + + /// An empty `LoginName` in the user map is not a login. + @Test func emptyLoginNameInTheUserMapIsAbsent() throws { + let overlay = LoginOverlay( + try decode( + LocalAPIStatusLogins.self, + """ + { + "Self": {"ID": "nSELF", "UserID": 7}, + "Peer": {"k": {"ID": "nBOB", "UserID": 7}}, + "User": {"7": {"ID": 7, "LoginName": "", "DisplayName": "Nameless"}} + } + """)) + #expect(overlay.selfLogin == nil) + #expect(overlay.byStableNodeId["nBOB"] == nil) + } + + /// A `UserID` the status's `User{}` does not describe resolves to NOTHING + /// — absent, never fabricated, and never the empty string. `nORPHAN` + /// names user 55555, which the map has no profile for. + @Test func unresolvableUserIdYieldsNoLoginRatherThanEmptyString() throws { + let merged = try overlay().applied( + to: BackendStatus( + tailscaleId: "nSELF", + peers: [ + BackendPeer(tailscaleId: "nORPHAN", hostname: "truffle-demo-orphan"), + BackendPeer(tailscaleId: "nBOB", hostname: "truffle-demo-bob"), + ])) + #expect(merged.peers[0].loginName == nil) + #expect(merged.peers[0].loginName != "") + #expect(merged.peers[1].loginName == "alice@corp.com") + } + + /// Checks the overlay's OUTPUT against the gate's grammar — not against a + /// node. It re-states `LoginGlob.allowed` here rather than driving + /// `MeshNode`, so it cannot witness the node applying the predicate (nor + /// the `isAppPeer` half beside it); `NodeLoginGateTests`' + /// `aGatedNodeAdmitsOnlyTheRowThatPassesBothHalves` is the witness for + /// that. What this pins is narrower and still worth pinning: of the row + /// shapes the overlay can emit, only a resolvable owner on an allowed + /// login yields a login the grammar accepts. + @Test func onlyAResolvableAllowedOwnerYieldsAnAcceptedLogin() throws { + let gate = ["*@corp.com"] + let merged = try overlay().applied( + to: BackendStatus( + tailscaleId: "nSELF", + peers: [ + BackendPeer(tailscaleId: "nORPHAN", hostname: "truffle-demo-orphan"), + BackendPeer(tailscaleId: "nNOUSER", hostname: "truffle-demo-nouser"), + BackendPeer(tailscaleId: "nBOB", hostname: "truffle-demo-bob"), + BackendPeer(tailscaleId: "nMALLORY", hostname: "truffle-demo-mallory"), + ])) + let admitted = merged.peers + .filter { LoginGlob.allowed(gate, login: $0.loginName) } + .map(\.tailscaleId) + // nORPHAN: UserID with no profile. nNOUSER: no UserID at all. + // nMALLORY: a real login on the wrong domain. Only nBOB survives. + #expect(admitted == ["nBOB"]) + } + + /// The node's OWN login gets the same treatment: a self `UserID` the map + /// does not describe leaves `BackendStatus.loginName` — and so + /// `MeshNode.loginName` — nil, not "". + @Test func unresolvableSelfUserIdLeavesTheSelfLoginNil() throws { + let overlay = LoginOverlay( + try decode( + LocalAPIStatusLogins.self, + """ + { + "BackendState": "Running", + "Self": {"ID": "nSELF", "UserID": 999}, + "Peer": {"k": {"ID": "nBOB", "UserID": 12345}}, + "User": {"12345": {"ID": 12345, "LoginName": "alice@corp.com"}} + } + """)) + #expect(overlay.selfLogin == nil) + #expect(overlay.selfLogin != "") + // The peer whose owner IS described still resolves, so the nil above + // is the missing profile and not a wholesale decode failure. + #expect(overlay.byStableNodeId["nBOB"] == "alice@corp.com") + + let merged = overlay.applied(to: BackendStatus(tailscaleId: "nSELF")) + #expect(merged.loginName == nil) + } + + /// The overlay reads the FULL status, but record the upstream fact that + /// makes a lighter read possible: on tailscale 1.102.3 `status?peers=false` + /// still carries the SELF user's profile in `User{}` (tailscale/tailscale + /// #19894), so a peer-less status resolves the self login. An empty + /// `Peer{}` therefore means "no peers asked for", never "unknown owner". + @Test func aPeerlessStatusStillResolvesTheSelfLogin() throws { + let overlay = LoginOverlay( + try decode( + LocalAPIStatusLogins.self, + """ + { + "BackendState": "Running", + "Self": {"ID": "nSELF", "UserID": 12345}, + "User": {"12345": {"ID": 12345, "LoginName": "alice@corp.com"}} + } + """)) + #expect(overlay.selfLogin == "alice@corp.com") + #expect(overlay.byStableNodeId.isEmpty) + } +} diff --git a/apple/Tests/TruffleTests/HandshakeTests.swift b/apple/Tests/TruffleTests/HandshakeTests.swift index 64a5beca..a41e1b84 100644 --- a/apple/Tests/TruffleTests/HandshakeTests.swift +++ b/apple/Tests/TruffleTests/HandshakeTests.swift @@ -196,3 +196,182 @@ private func makeHello( #expect(try await b.receive() == nil) } } + +// MARK: - The login gate (RFC 025 §3.4, D4) + +/// Every row of the §3.4 table for `Handshake.server`. Each refusal 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 caller the gate rejected. +@Suite struct HandshakeLoginGateTests { + private static let gate = ["*@corp.com"] + + private func firstFrame(_ frames: any SessionFrames) async throws -> SessionFrame? { + try await frames.receive() + } + + private func sendClientHello(_ frames: any SessionFrames, tailscaleId: String = "ts-a") + async throws + { + try await frames.send( + .text(String(decoding: try makeHello(tailscaleId: tailscaleId).encoded(), as: UTF8.self)) + ) + } + + // Row 1, gated column: absent identity is refused 4003 under EITHER + // policy — a gate is never bypassed by the test policy. + @Test func gatedRefusesAbsentIdentityEvenUnderAllowUnverified() async throws { + let (clientFrames, serverFrames) = FramePipe.makePair() + try await sendClientHello(clientFrames) + + await #expect(throws: MeshError.self) { + try await Handshake.server( + frames: serverFrames, localHello: makeHello(tailscaleId: "ts-b"), + authenticated: nil, policy: .allowUnverified, loginAllow: Self.gate) + } + guard case .close(let code, _) = try await firstFrame(clientFrames) else { + Issue.record("expected close frame") + return + } + #expect(code == SessionCloseCode.identityMismatch) + } + + // Same row via an AuthenticatedPeer whose stable ID is empty. + @Test func gatedRefusesEmptyStableIdEvenUnderAllowUnverified() async throws { + let (clientFrames, serverFrames) = FramePipe.makePair() + try await sendClientHello(clientFrames) + + await #expect(throws: MeshError.self) { + try await Handshake.server( + frames: serverFrames, localHello: makeHello(tailscaleId: "ts-b"), + authenticated: AuthenticatedPeer( + tailscaleId: "", remoteAddresses: [], loginName: "alice@corp.com"), + policy: .allowUnverified, loginAllow: Self.gate) + } + guard case .close(let code, _) = try await firstFrame(clientFrames) else { + Issue.record("expected close frame") + return + } + #expect(code == SessionCloseCode.identityMismatch) + } + + // Row 2: a node id mismatch is still 4003, and is decided BEFORE the + // login — even when the login would have passed the gate. + @Test func gatedNodeIdMismatchStillCloses4003() async throws { + let (clientFrames, serverFrames) = FramePipe.makePair() + try await sendClientHello(clientFrames) + + await #expect(throws: MeshError.identityMismatch(claimed: "ts-a", authenticated: "ts-EVIL")) + { + try await Handshake.server( + frames: serverFrames, localHello: makeHello(tailscaleId: "ts-b"), + authenticated: AuthenticatedPeer( + tailscaleId: "ts-EVIL", remoteAddresses: [], loginName: "alice@corp.com"), + policy: .failClosed, loginAllow: Self.gate) + } + guard case .close(let code, _) = try await firstFrame(clientFrames) else { + Issue.record("expected close frame") + return + } + #expect(code == SessionCloseCode.identityMismatch) + } + + // Row 3: the node id is good but WhoIs carried no login — 4004, closed. + @Test func gatedRefusesAbsentLoginWith4004() async throws { + let (clientFrames, serverFrames) = FramePipe.makePair() + try await sendClientHello(clientFrames) + + await #expect(throws: MeshError.loginRefused(login: nil)) { + try await Handshake.server( + frames: serverFrames, localHello: makeHello(tailscaleId: "ts-b"), + authenticated: AuthenticatedPeer(tailscaleId: "ts-a", remoteAddresses: []), + policy: .failClosed, loginAllow: Self.gate) + } + guard case .close(let code, _) = try await firstFrame(clientFrames) else { + Issue.record("expected close frame") + return + } + #expect(code == SessionCloseCode.loginRefused) + } + + // Row 4: a real login that matches no glob — 4004. + @Test func gatedRefusesUnmatchedLoginWith4004() async throws { + let (clientFrames, serverFrames) = FramePipe.makePair() + try await sendClientHello(clientFrames) + + await #expect(throws: MeshError.loginRefused(login: "mallory@evil.com")) { + try await Handshake.server( + frames: serverFrames, localHello: makeHello(tailscaleId: "ts-b"), + authenticated: AuthenticatedPeer( + tailscaleId: "ts-a", remoteAddresses: [], loginName: "mallory@evil.com"), + policy: .failClosed, loginAllow: Self.gate) + } + guard case .close(let code, _) = try await firstFrame(clientFrames) else { + Issue.record("expected close frame") + return + } + #expect(code == SessionCloseCode.loginRefused) + } + + // Row 5: a matching login exchanges hellos exactly as before. + @Test func gatedAcceptsMatchingLoginAndExchangesHellos() async throws { + let (clientFrames, serverFrames) = FramePipe.makePair() + let clientHello = makeHello(deviceId: "01HZZZZZZZZZZZZZZZZZZZZZZ1", tailscaleId: "ts-a") + let serverHello = makeHello(deviceId: "01HZZZZZZZZZZZZZZZZZZZZZZ2", tailscaleId: "ts-b") + + async let serverSide = Handshake.server( + frames: serverFrames, + localHello: serverHello, + authenticated: AuthenticatedPeer( + tailscaleId: "ts-a", remoteAddresses: ["100.64.0.1:9417"], + loginName: "Alice@CORP.com", displayName: "Alice"), + policy: .failClosed, + loginAllow: Self.gate) + async let clientSide = Handshake.client( + frames: clientFrames, localHello: clientHello, expectedTailscaleId: "ts-b") + + let (serverSeen, clientSeen) = try await (serverSide, clientSide) + #expect(serverSeen.tailscaleId == "ts-a") + #expect(clientSeen.tailscaleId == "ts-b") + #expect(clientSeen.deviceId == "01HZZZZZZZZZZZZZZZZZZZZZZ2") + } + + // The ungated column: an empty list is today's behaviour exactly, so a + // foreign login — and an absent one — are both still accepted. + @Test func ungatedAcceptsAnyLogin() async throws { + for login in ["mallory@evil.com", nil] { + let (clientFrames, serverFrames) = FramePipe.makePair() + try await sendClientHello(clientFrames) + + let identity = try await Handshake.server( + frames: serverFrames, localHello: makeHello(tailscaleId: "ts-b"), + authenticated: AuthenticatedPeer( + tailscaleId: "ts-a", remoteAddresses: [], loginName: login), + policy: .failClosed) + #expect(identity.tailscaleId == "ts-a") + } + } + + // A tagged node's pseudo-login is passed through, not special-cased: it + // is refused by a personal glob and admitted by one that names it. + @Test func taggedDevicesPseudoLoginIsNotSpecialCased() async throws { + let (refusedClient, refusedServer) = FramePipe.makePair() + try await sendClientHello(refusedClient) + await #expect(throws: MeshError.loginRefused(login: "tagged-devices")) { + try await Handshake.server( + frames: refusedServer, localHello: makeHello(tailscaleId: "ts-b"), + authenticated: AuthenticatedPeer( + tailscaleId: "ts-a", remoteAddresses: [], loginName: "tagged-devices"), + policy: .failClosed, loginAllow: Self.gate) + } + + let (acceptedClient, acceptedServer) = FramePipe.makePair() + try await sendClientHello(acceptedClient) + let identity = try await Handshake.server( + frames: acceptedServer, localHello: makeHello(tailscaleId: "ts-b"), + authenticated: AuthenticatedPeer( + tailscaleId: "ts-a", remoteAddresses: [], loginName: "tagged-devices"), + policy: .failClosed, loginAllow: ["tagged-devices"]) + #expect(identity.tailscaleId == "ts-a") + } +} diff --git a/apple/Tests/TruffleTests/IdentityTests.swift b/apple/Tests/TruffleTests/IdentityTests.swift index 8c35fc90..9eb2c870 100644 --- a/apple/Tests/TruffleTests/IdentityTests.swift +++ b/apple/Tests/TruffleTests/IdentityTests.swift @@ -161,3 +161,104 @@ import Testing #expect(!Hostname.isAppPeer(hostname: "laptop", appId: "demo")) } } + +// MARK: - LoginGlob (RFC 025 §3.2 — one grammar, three planes) + +/// Both tables are reproduced verbatim from the Rust port +/// (`crates/truffle-core/src/network/login_allow.rs`), which in turn +/// reproduces the Go reference (`TestAllowedLogin` / `path.Match`'s `TestMatch` +/// in `sidecar-slim`). If a row here disagrees with a row there, one of the +/// three planes has drifted and the gate is no longer one grammar. +@Suite struct LoginGlobTests { + @Test func allowedLoginMatchesTheGoTable() { + let cases: [(name: String, globs: [String], login: String, want: Bool)] = [ + ("empty globs allow all", [], "anyone@example.com", true), + ("empty globs allow even empty login", [], "", true), + ("non-empty gate, empty login fails closed", ["*@corp.com"], "", false), + ("exact match", ["alice@corp.com"], "alice@corp.com", true), + ("exact non-match", ["alice@corp.com"], "bob@corp.com", false), + ("domain glob matches", ["*@corp.com"], "alice@corp.com", true), + ("domain glob rejects other domain", ["*@corp.com"], "alice@evil.com", false), + ("case-insensitive glob vs login", ["*@CORP.com"], "Alice@corp.COM", true), + ("case-insensitive exact", ["Alice@Corp.Com"], "alice@corp.com", true), + ("second glob in list matches", ["*@other.com", "*@corp.com"], "bob@corp.com", true), + ("no glob in list matches", ["*@other.com", "*@more.com"], "bob@corp.com", false), + ("star does not cross slash", ["*@corp.com"], "a/b@corp.com", false), + ("invalid glob does not match", ["[unterminated"], "alice@corp.com", false), + ("invalid glob skipped, valid one still matches", ["[bad", "*@corp.com"], + "alice@corp.com", true), + ] + for row in cases { + #expect( + LoginGlob.allowed(row.globs, login: row.login) == row.want, + "\(row.name): allowed(\(row.globs), login: \"\(row.login)\")") + } + // `nil` is the absent login: fails closed under a gate, passes without one. + #expect(!LoginGlob.allowed(["*@corp.com"], login: nil)) + #expect(LoginGlob.allowed([], login: nil)) + // A tagged node only passes a glob that names the pseudo-login. + #expect(!LoginGlob.allowed(["*@corp.com"], login: "tagged-devices")) + #expect(LoginGlob.allowed(["tagged-devices"], login: "tagged-devices")) + } + + @Test func globMatchFollowsPathMatch() throws { + func ok(_ p: String, _ n: String) throws -> Bool { try LoginGlob.match(p, n) } + #expect(try ok("abc", "abc")) + #expect(try ok("*", "abc")) + #expect(try ok("*c", "abc")) + #expect(try !ok("a*", "a/b")) + #expect(try ok("a*", "ab")) + #expect(try !ok("a*", "abc/d")) + #expect(try ok("a*/b", "abc/b")) + #expect(try !ok("a*/b", "a/c/b")) + #expect(try ok("a*b*c*d*e*/f", "axbxcxdxe/f")) + #expect(try ok("a*b*c*d*e*/f", "axbxcxdxexxx/f")) + #expect(try !ok("a*b*c*d*e*/f", "axbxcxdxe/xxx/f")) + #expect(try !ok("a*b*c*d*e*/f", "axbxcxdxexxx/fff")) + #expect(try ok("a*b?c*x", "abxbbxdbxebxczzx")) + #expect(try !ok("a*b?c*x", "abxbbxdbxebxczzy")) + #expect(try ok("ab[c]", "abc")) + #expect(try ok("ab[b-d]", "abc")) + #expect(try !ok("ab[e-g]", "abc")) + #expect(try !ok("ab[^c]", "abc")) + #expect(try !ok("ab[^b-d]", "abc")) + #expect(try ok("ab[^e-g]", "abc")) + #expect(try ok("a\\*b", "a*b")) + #expect(try !ok("a\\*b", "ab")) + #expect(try ok("a?b", "a☺b")) + #expect(try ok("a[^a]b", "a☺b")) + #expect(try !ok("a???b", "a☺b")) + #expect(try !ok("a[^a][^a][^a]b", "a☺b")) + #expect(try ok("[a-ζ]*", "α")) + #expect(try !ok("*[a-ζ]", "A")) + #expect(try ok("a?b", "a/b") == false) + #expect(try ok("a*b", "a/b") == false) + #expect(try ok("[\\]a]", "]")) + #expect(try ok("[\\-]", "-")) + #expect(try ok("[x\\-]", "x")) + #expect(try ok("[x\\-]", "-")) + #expect(try !ok("[x\\-]", "z")) + #expect(try ok("[\\-x]", "x")) + #expect(try ok("[\\-x]", "-")) + #expect(try !ok("[\\-x]", "a")) + #expect(try ok("*x", "xxx")) + #expect(try !ok("", "a")) + #expect(try ok("", "")) + } + + @Test func globMatchReportsBadPatternsLikeGo() { + for bad in [ + "[]a]", "[-]", "[x-]", "[-x]", "\\", "[a-b-c]", "[", "[^", "[^bc", "a[", + "[unterminated", + ] { + #expect(throws: LoginGlob.BadPattern.self, "\(bad) must be a bad pattern") { + try LoginGlob.match(bad, "a") + } + } + // A bad pattern is an error even when an earlier chunk already failed + // to match — Go checks the remainder's syntax before answering false. + #expect(throws: LoginGlob.BadPattern.self) { + try LoginGlob.match("a*[", "b") + } + } +} diff --git a/apple/Tests/TruffleTests/NodeLoopbackTests.swift b/apple/Tests/TruffleTests/NodeLoopbackTests.swift index 4bb8a7bf..7d2232e6 100644 --- a/apple/Tests/TruffleTests/NodeLoopbackTests.swift +++ b/apple/Tests/TruffleTests/NodeLoopbackTests.swift @@ -56,18 +56,21 @@ struct PongDroppingTransport: FrameTransport { deviceName: String, advertisedHostname: String? = nil, hidden: Bool = false, - identityPolicy: Handshake.IdentityPolicy = .failClosed + identityPolicy: Handshake.IdentityPolicy = .failClosed, + loginName: String? = nil, + loginAllow: [String] = [] ) async throws -> (MeshNode, URL) { let derived = Hostname.tailscaleHostname( appId: try AppId(parsing: appId), deviceName: DeviceName(deviceName)) let hostname = advertisedHostname ?? derived let backend = await network.join( - tailscaleId: tailscaleId, hostname: hostname, hidden: hidden) + tailscaleId: tailscaleId, hostname: hostname, hidden: hidden, + loginName: loginName) let dir = tempDir() let node = try await MeshNode.start( MeshConfiguration( appId: appId, deviceName: deviceName, stateDirectory: dir, - auth: .existingState), + auth: .existingState, loginAllow: loginAllow), backend: backend, frameTransport: LengthPrefixFrameTransport(), identityPolicy: identityPolicy) @@ -563,3 +566,591 @@ struct PongDroppingTransport: FrameTransport { await alice.stop() } } + +// MARK: - The login gate, end to end (RFC 025 §3.3/§3.4, D1–D5) + +/// A gated pair over the loopback tailnet: the Layer 3 filter, the hello +/// refusal, and the fail-closed row where Layer 3 reports no login at all. +@Suite struct NodeLoginGateTests { + struct ChatPayload: Codable, Equatable { + var text: String + } + + private func tempDir() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("truffle-gate-test-\(UUID().uuidString)") + } + + private func startNode( + network: LoopbackNetwork, + tailscaleId: String, + deviceName: String, + loginName: String? = nil, + displayName: String? = nil, + loginAllow: [String] = [] + ) async throws -> (MeshNode, URL) { + let hostname = Hostname.tailscaleHostname( + appId: try AppId(parsing: "demo"), deviceName: DeviceName(deviceName)) + let backend = await network.join( + tailscaleId: tailscaleId, hostname: hostname, loginName: loginName, + displayName: displayName) + let dir = tempDir() + let node = try await MeshNode.start( + MeshConfiguration( + appId: "demo", deviceName: deviceName, stateDirectory: dir, + auth: .existingState, loginAllow: loginAllow), + backend: backend, + frameTransport: LengthPrefixFrameTransport(), + identityPolicy: .failClosed) + return (node, dir) + } + + /// Await one value with a deadline, so a missing event fails the test + /// instead of hanging it. + private func firstOrNil( + timeout: Duration, _ produce: @escaping @Sendable () async -> T? + ) async -> T? { + await withTaskGroup(of: T?.self) { group in + group.addTask { await produce() } + group.addTask { + try? await Task.sleep(for: timeout) + return nil + } + let first = await group.next() ?? nil + group.cancelAll() + return first + } + } + + /// (a) A matching glob: the pair converges and messages flow, exactly as + /// an ungated pair does. + @Test func gatedPairWithMatchingLoginConverges() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob", + loginName: "bob@CORP.com", loginAllow: ["*@corp.com"]) + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + let inbox = Mailbox() + let subscription = await bob.onMessage(namespace: "chat") { message in + await inbox.put(message) + } + + guard let bobPeer = try await alice.peer("ts-b", waitMs: 2_000) else { + Issue.record("gated alice never listed bob under a matching glob") + return + } + // The login is a first-class field on the snapshot (D7), carried + // through Layer 3 in the case the netmap reported it. + #expect(bobPeer.loginName == "bob@CORP.com") + #expect(await alice.loginName == "alice@corp.com") + #expect(await alice.localPeer.loginName == "alice@corp.com") + #expect(await alice.loginAllow == ["*@corp.com"]) + + try await alice.sendJSON( + to: bobPeer, namespace: "chat", payload: ChatPayload(text: "hi bob")) + guard let received = await inbox.take() else { + Issue.record("bob received nothing") + return + } + #expect(try received.decodePayload(ChatPayload.self) == ChatPayload(text: "hi bob")) + #expect(received.from.tailscaleId == "ts-a") + + await subscription.cancel() + await alice.stop() + await bob.stop() + } + + /// (b) A foreign glob: the peer is never listed, AND the hello that peer + /// dials with is refused — the two halves of the gate, separately. + @Test func foreignLoginIsNeitherListedNorAdmitted() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + // Bob is ungated and on a different login: he still discovers and + // dials Alice, which is exactly what the hello gate must stop. + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob", + loginName: "mallory@evil.com") + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + try await alice.waitUntilRunning(timeout: .seconds(2)) + try await bob.waitUntilRunning(timeout: .seconds(2)) + + // Layer 3: Alice never lists Bob, though the hostname prefix matches. + #expect(try await alice.peer("ts-b", waitMs: 500) == nil) + #expect(await alice.peers().isEmpty) + + // Layer 4/5: Bob DOES list Alice and dials her; the hello is refused. + guard let alicePeer = try await bob.peer("ts-a", waitMs: 2_000) else { + Issue.record("ungated bob should still discover alice") + return + } + await #expect(throws: MeshError.self) { + try await bob.sendJSON( + to: alicePeer, namespace: "chat", payload: ChatPayload(text: "let me in")) + } + // The refusal left no provisional entry behind on the gated node. + #expect(try await alice.peer("ts-b") == nil) + #expect(await alice.peers().isEmpty) + + await alice.stop() + await bob.stop() + } + + /// (c) Fail closed: on a gated node a Layer 3 row with NO login is not a + /// peer — and the node says so rather than showing an empty mesh. + @Test func gatedNodeTreatsLoginlessRowAsNotAPeer() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + defer { try? FileManager.default.removeItem(at: dirA) } + + let notices = Mailbox() + let stream = await alice.events + let drain = Task { + for await event in stream { + if case .health(let message) = event { + _ = await notices.put(message) + } + } + } + defer { drain.cancel() } + + // A well-named app peer whose netmap row carries no login at all. + _ = await network.join( + tailscaleId: "ts-nologin", + hostname: Hostname.tailscaleHostname( + appId: try AppId(parsing: "demo"), deviceName: DeviceName("Ghost")), + loginName: nil) + try await alice.refresh() + + #expect(try await alice.peer("ts-nologin") == nil) + #expect(await alice.peers().isEmpty) + + let notice = await firstOrNil(timeout: .seconds(2)) { await notices.take() } + #expect(notice?.contains("login gate active") == true) + + // The same row WITH a matching login is admitted — proving the row + // was dropped for its login and not for its hostname. + await network.setLogin(tailscaleId: "ts-nologin", loginName: "ghost@corp.com") + try await alice.refresh() + let admitted = try await alice.peer("ts-nologin", waitMs: 1_000) + #expect(admitted?.loginName == "ghost@corp.com") + + await alice.stop() + } + + /// HIGH-1 (found on review): the gate must run on every row, not only at + /// entry creation. A stable node ID survives a device transfer, so a + /// re-signed node arrives as an UPDATE to the existing row — and before + /// this fix an admitted peer kept its place after re-signing as a foreign + /// login, sessions and all. + @Test func aPeerThatResignsAsAForeignLoginIsEvicted() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob", + loginName: "bob@corp.com") + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + let departures = Mailbox() + let stream = await alice.events + let drain = Task { + for await event in stream { + if case .peerLeft(let peer) = event { _ = await departures.put(peer.tailscaleId) } + } + } + defer { drain.cancel() } + + // Admitted, and a real session established. + guard let bobPeer = try await alice.peer("ts-b", waitMs: 2_000) else { + Issue.record("alice never admitted bob") + return + } + try await alice.sendJSON( + to: bobPeer, namespace: "chat", payload: ChatPayload(text: "hi")) + let admittedGeneration = bobPeer.generation + + // The device is transferred: same stable node ID, foreign login. + await network.setLogin(tailscaleId: "ts-b", loginName: "mallory@evil.com") + try await alice.refresh() + + // A refused login is a DEPARTURE: the row goes, and removeEntry — + // which is what emits this event — is also what closes the session. + let departed = await firstOrNil(timeout: .seconds(2)) { await departures.take() } + #expect(departed == "ts-b") + #expect(try await alice.peer("ts-b") == nil) + #expect(await alice.peers().isEmpty) + // The snapshot taken while he was admitted no longer resolves. + await #expect(throws: MeshError.peerGone(bobPeer.ref.description)) { + try await alice.sendJSON( + to: bobPeer, namespace: "chat", payload: ChatPayload(text: "still there?")) + } + + // And the reverse: re-signing back onto the allow-list readmits him, + // as a NEW generation — a rejoin is never the same row. + await network.setLogin(tailscaleId: "ts-b", loginName: "bob@corp.com") + try await alice.refresh() + guard let readmitted = try await alice.peer("ts-b", waitMs: 1_000) else { + Issue.record("alice never readmitted bob") + return + } + #expect(readmitted.loginName == "bob@corp.com") + #expect(readmitted.generation != admittedGeneration) + + await alice.stop() + await bob.stop() + } + + /// A row that names no owner is KEPT but reports no login (RFC 025 §3.3 as + /// refined). Two halves, and both matter: the entry survives, so a + /// transient failure to read the logins cannot empty a gated mesh; and the + /// last-known login does NOT stick, because the row names no owner and so + /// neither may we. A later resolvable row restores it, in the same + /// generation — this is not a departure. + @Test func anAbsentLoginIsKeptAsAPeerButReportedAbsent() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob", + loginName: "bob@corp.com") + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + guard let bobPeer = try await alice.peer("ts-b", waitMs: 2_000) else { + Issue.record("alice never admitted bob") + return + } + #expect(bobPeer.loginName == "bob@corp.com") + + await network.setLogin(tailscaleId: "ts-b", loginName: nil) + try await alice.refresh() + + let kept = try await alice.peer("ts-b") + #expect(kept != nil) + #expect(kept?.generation == bobPeer.generation) + #expect(kept?.loginName == nil) + + // Restored, same generation: a login that comes back is not a rejoin. + await network.setLogin(tailscaleId: "ts-b", loginName: "bob@corp.com") + try await alice.refresh() + let restored = try await alice.peer("ts-b") + #expect(restored?.loginName == "bob@corp.com") + #expect(restored?.generation == bobPeer.generation) + + await alice.stop() + await bob.stop() + } + + /// The dial-side half: a gated node opens no NEW session to a peer whose + /// row cannot name its owner, but never tears down one that is already + /// open. Both halves are asserted here because a fix that closed the live + /// session would also make the first assertion pass. + @Test func aGatedNodeWillNotOpenASessionToAnUnattributablePeer() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob", + loginName: "bob@corp.com", loginAllow: ["*@corp.com"]) + let (carol, dirC) = try await startNode( + network: network, tailscaleId: "ts-c", deviceName: "Carol", + loginName: "carol@corp.com", loginAllow: ["*@corp.com"]) + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + try? FileManager.default.removeItem(at: dirC) + } + + let bobInbox = Mailbox() + let subBob = await bob.onMessage(namespace: "chat") { await bobInbox.put($0) } + + // Bob: a session is OPEN before his login goes absent. + guard let bobPeer = try await alice.peer("ts-b", waitMs: 2_000) else { + Issue.record("alice never admitted bob") + return + } + try await alice.sendJSON( + to: bobPeer, namespace: "chat", payload: ChatPayload(text: "before")) + #expect(await bobInbox.take() != nil) + + // Carol: admitted, but NO session opened yet. + guard try await alice.peer("ts-c", waitMs: 2_000) != nil else { + Issue.record("alice never admitted carol") + return + } + + await network.setLogin(tailscaleId: "ts-b", loginName: nil) + await network.setLogin(tailscaleId: "ts-c", loginName: nil) + try await alice.refresh() + + // The live session stands — no flap. + guard let bobNow = try await alice.peer("ts-b") else { + Issue.record("bob should still be listed") + return + } + try await alice.sendJSON( + to: bobNow, namespace: "chat", payload: ChatPayload(text: "after")) + #expect(await bobInbox.take() != nil) + + // Carol has no session to stand on, so opening one is refused. + guard let carolNow = try await alice.peer("ts-c") else { + Issue.record("carol should still be listed") + return + } + #expect(carolNow.loginName == nil) + await #expect(throws: MeshError.loginUnknown(peer: carolNow.ref.description)) { + try await alice.sendJSON( + to: carolNow, namespace: "chat", payload: ChatPayload(text: "who are you?")) + } + // The raw plane's outbound dial is this node's act too. + await #expect(throws: MeshError.loginUnknown(peer: carolNow.ref.description)) { + _ = try await alice.dial(to: carolNow, port: 9500) + } + await #expect(throws: MeshError.loginUnknown(peer: carolNow.ref.description)) { + _ = try await alice.confirmIdentity(of: carolNow) + } + + // A login that comes back makes her dialable again. + await network.setLogin(tailscaleId: "ts-c", loginName: "carol@corp.com") + try await alice.refresh() + guard let carolBack = try await alice.peer("ts-c") else { + Issue.record("carol should still be listed") + return + } + let confirmed = try await alice.confirmIdentity(of: carolBack) + #expect(confirmed.deviceId != nil) + + await subBob.cancel() + await alice.stop() + await bob.stop() + await carol.stop() + } + + /// An UNGATED node ignores the field: an absent login never blocks a dial. + @Test func anUngatedNodeDialsAPeerWithNoLogin() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice") + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob") + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + guard let bobPeer = try await alice.peer("ts-b", waitMs: 2_000) else { + Issue.record("alice never discovered bob") + return + } + #expect(bobPeer.loginName == nil) + let confirmed = try await alice.confirmIdentity(of: bobPeer) + #expect(confirmed.deviceId != nil) + + await alice.stop() + await bob.stop() + } + + /// MEDIUM-2: the dialer must be able to tell a gate from a broken pipe. + /// The refusal reaches the dialing side as the close code the gate sent. + @Test func aRefusedDialerSeesTheCloseCodeNotAProtocolViolation() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob", + loginName: "mallory@evil.com") + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + guard let alicePeer = try await bob.peer("ts-a", waitMs: 2_000) else { + Issue.record("ungated bob should still discover alice") + return + } + await #expect( + throws: MeshError.helloRefused( + code: SessionCloseCode.loginRefused, reason: "login refused") + ) { + try await bob.sendJSON( + to: alicePeer, namespace: "chat", payload: ChatPayload(text: "let me in")) + } + + await alice.stop() + await bob.stop() + } + + /// MEDIUM-1: the raw plane has an identity surface. `listen(port:)` is NOT + /// gated by loginAllow, so an app that opens its own port gates itself + /// with this — the login it returns is the one WhoIs authenticated. + @Test func whoIsResolvesAnAcceptedAddressOnTheRawPlane() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob", + loginName: "bob@corp.com", displayName: "Bob Example") + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + guard let bobPeer = try await alice.peer("ts-b", waitMs: 2_000), + let bobIP = bobPeer.tailnetIPs.first + else { + Issue.record("alice never discovered bob's address") + return + } + let identity = try await alice.whoIs(remoteEndpoint: "\(bobIP):40001") + #expect(identity.tailscaleId == "ts-b") + #expect(identity.loginName == "bob@corp.com") + #expect(identity.displayName == "Bob Example") + // The grammar an app would gate with is the same one the node uses. + #expect(LoginGlob.allowed(["*@corp.com"], login: identity.loginName)) + #expect(!LoginGlob.allowed(["*@other.com"], login: identity.loginName)) + + await alice.stop() + await bob.stop() + } + + /// LOW: a provisional entry from a raced inbound hello carries the login + /// that PASSED the gate, rather than waiting for the netmap to say. Bob is + /// hidden — WhoIs-resolvable and able to dial, but absent from snapshots. + @Test func aProvisionalEntryCarriesTheLoginThatPassedTheGate() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + let bobHostname = Hostname.tailscaleHostname( + appId: try AppId(parsing: "demo"), deviceName: DeviceName("Bob")) + let bobBackend = await network.join( + tailscaleId: "ts-b", hostname: bobHostname, hidden: true, + loginName: "bob@corp.com") + let dirB = tempDir() + let bob = try await MeshNode.start( + MeshConfiguration( + appId: "demo", deviceName: "Bob", stateDirectory: dirB, auth: .existingState), + backend: bobBackend, + frameTransport: LengthPrefixFrameTransport(), + identityPolicy: .failClosed) + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + guard let alicePeer = try await bob.peer("ts-a", waitMs: 2_000) else { + Issue.record("bob never discovered alice") + return + } + try await bob.sendJSON( + to: alicePeer, namespace: "chat", payload: ChatPayload(text: "hello")) + + // Alice knows him only from the hello — no netmap row exists yet. + guard let provisional = try await alice.peer("ts-b", waitMs: 2_000) else { + Issue.record("alice never created a provisional entry for bob") + return + } + #expect(provisional.hostname.isEmpty) + #expect(provisional.deviceId != nil) + #expect(provisional.loginName == "bob@corp.com") + + await alice.stop() + await bob.stop() + } + + /// The witness the overlay-level row cannot be: a REAL `MeshNode` over a + /// real `LoopbackNetwork`, applying its own predicate to every row shape + /// the status overlay can produce — plus the `isAppPeer` half, which an + /// inline re-statement of the login check silently drops. + /// + /// The overlay renders BOTH "a `UserID` with no profile" and "a row with + /// no `UserID`" as an absent login, so those two arrive at the node + /// identically; they are kept as separate rows here because they are + /// separate nodes on the tailnet, not because the node can tell them + /// apart. + @Test func aGatedNodeAdmitsOnlyTheRowThatPassesBothHalves() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + defer { try? FileManager.default.removeItem(at: dirA) } + + func appHostname(_ name: String) throws -> String { + Hostname.tailscaleHostname( + appId: try AppId(parsing: "demo"), deviceName: DeviceName(name)) + } + + // A UserID the status's User{} does not describe. + _ = await network.join( + tailscaleId: "ts-orphan", hostname: try appHostname("Orphan"), loginName: nil) + // A row carrying no UserID at all. + _ = await network.join( + tailscaleId: "ts-nouser", hostname: try appHostname("NoUser"), loginName: nil) + // A resolvable owner, on the wrong domain. + _ = await network.join( + tailscaleId: "ts-mallory", hostname: try appHostname("Mallory"), + loginName: "mallory@evil.com") + // An allowed login that is NOT an app peer — the half a login-only + // predicate would admit. + _ = await network.join( + tailscaleId: "ts-stranger", hostname: "workstation-corp", + loginName: "bob@corp.com") + // Both halves. + _ = await network.join( + tailscaleId: "ts-bob", hostname: try appHostname("Bob"), loginName: "bob@corp.com") + + try await alice.refresh() + let admitted = await alice.peers().map(\.tailscaleId).sorted() + #expect(admitted == ["ts-bob"]) + #expect(try await alice.peer("ts-bob")?.loginName == "bob@corp.com") + + await alice.stop() + } + + /// An ungated node is unchanged: a login-less row is still a peer. + @Test func ungatedNodeStillAdmitsLoginlessRows() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice") + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob") + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + let bobPeer = try await alice.peer("ts-b", waitMs: 2_000) + #expect(bobPeer != nil) + #expect(bobPeer?.loginName == nil) + #expect(await alice.loginName == nil) + #expect(await alice.loginAllow.isEmpty) + + await alice.stop() + await bob.stop() + } +} diff --git a/docs/rfcs/024-truffle-swift.md b/docs/rfcs/024-truffle-swift.md index d6cb510e..9120dd87 100644 --- a/docs/rfcs/024-truffle-swift.md +++ b/docs/rfcs/024-truffle-swift.md @@ -597,7 +597,7 @@ Phase 1 (they are what Swift↔Swift messaging runs on); Phase 2 is *verificatio 2. **Role ordering:** the dialing/client side completes the RFC 6455 upgrade, sends its hello, then reads the server hello. The accepting/server side upgrades, reads and validates the client hello, performs inbound identity verification, then sends its hello. 3. **Hello frame:** emitted as a WebSocket **Text** frame containing hello v2 (§8.2). Receivers accept Text or Binary JSON for compatibility. The first application-level frame in each direction must be hello; up to 16 control frames may precede it. 4. **Timeouts:** hello read timeout **5s**. The complete incoming upgrade + hello exchange is bounded by **10s**. -5. **Close codes:** `appId` mismatch → **4001**; malformed, invalid, or missing hello → **4002**; claimed `tailscale_id` contradicting authenticated identity → **4003**. A rejected hello never confirms the peer and never enables application traffic. +5. **Close codes:** `appId` mismatch → **4001**; malformed, invalid, or missing hello → **4002**; claimed `tailscale_id` contradicting authenticated identity, or no authenticated identity at all → **4003**; a WhoIs login that no `loginAllow` glob admits → **4004** (added 2026-09-16, §8.1.2). A rejected hello never confirms the peer and never enables application traffic. 6. **Application frames:** compact JSON envelopes (§8.3) are emitted as WebSocket **Binary** frames; receivers also accept Text frames containing JSON. 7. **Bounds:** maximum WebSocket frame/message size **16 MiB**; maximum **256** simultaneous incoming upgrade/hello handshakes. Envelope field and payload bounds in §8.3 apply within that transport limit. 8. **Keepalive:** after hello, send Ping every **10s** and require a Pong within **30s**. Peers must answer Ping according to RFC 6455. Ping payload contents are not semantically significant. @@ -619,6 +619,137 @@ desired Swift security policy. It does not prevent interop when WhoIs succeeds, and the difference must be covered by the live interop matrix. Swift cannot claim 4003 support until the Phase 0 backend exposes WhoIs. +#### 8.1.2 Login gate (RFC 025, 2026-09-16) + +RFC 025 makes a node's **tailnet login** the mesh boundary. Its §3 is the +normative text for the grammar, the Layer 3 filter, and the hello table; this +section records only what the Swift surfaces are and where they live. + +A node declares the gate once, for its lifetime: + +```swift +MeshConfiguration(appId: "field-tools", deviceName: "Alice's iPhone", + loginAllow: ["*@corp.com"]) // empty (default) = no gate +``` + +- **`LoginGlob`** (`Sources/Truffle/Identity/LoginGlob.swift`) is the grammar — + a port of Go's `path.Match`, matched after lowercasing both sides. + `LoginGlob.match(_:_:)` is the case-sensitive primitive and throws + `LoginGlob.BadPattern` on a malformed pattern; `LoginGlob.allowed(_:login:)` + is the gate: empty list → `true`, absent or empty login under a non-empty + list → `false`, malformed globs skipped. Both of the Rust port's test tables + (`network/login_allow.rs`) are reproduced verbatim in `LoginGlobTests`, so + the Go sidecar, the Rust core, and the Swift core cannot drift. +- **The login is on every identity surface** (RFC 025 §3.6, D7), optional and + never fabricated — an empty string on the wire becomes `nil`: + `AuthenticatedPeer.loginName` / `.displayName`, `BackendPeer.loginName`, + `BackendStatus.loginName`, `Peer.loginName` (part of `Peer`'s equality, so a + SwiftUI row re-renders when it changes), `MeshNode.loginName` (self) and + `MeshNode.loginAllow`, and `MeshModel.loginName`. A tagged node's + `tagged-devices` pseudo-login is passed through, not special-cased. +- **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**. + The predicate runs on entry **creation AND on every later row for an entry + that already exists** — not only at creation. A stable node ID survives a + device transfer, so the netmap reports a re-signed node as an UPDATE to the + existing row; a gate that ran only at creation would let a peer admitted as + `bob@corp.com` keep its place, its session and its frames after re-signing + as a foreign login. 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). + A row that names **no owner** is neither an eviction nor a sticky keep + (RFC 025 §3.3 as refined): the entry **stays**, so a transient failure to + read the logins cannot empty a gated mesh, and its `loginName` is reported + **absent** — the last-known login does NOT persist, because the row names no + owner and so neither may we (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 + gated node that sees a login-less app peer emits one `.health` notice, so a + mesh emptied by the gate is never silent. Provisional entries from a raced + inbound hello merge as before — that hello already passed the gate — and + carry the WhoIs login they passed it with; `confirm` restores a login WhoIs + authenticated even after Layer 3 has stopped naming one. Provisional rows are + evicted on a refused login like any other. +- **The hello** — `Handshake.server(..., loginAllow:)` implements RFC 025 + §3.4's table in order: validate hello → absent authenticated identity → + **4003** (on a gated node under EITHER `IdentityPolicy`; a gate is never + bypassed by `.allowUnverified`, because without WhoIs there is no login to + gate on) → claimed `tailscale_id` mismatch → **4003** → login absent or + matching no glob → **4004** `SessionCloseCode.loginRefused`, thrown as + `MeshError.loginRefused(login:)`. All of it happens **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. +- **The dialing side of an unattributable peer** — a gated node opens no NEW + connection to a kept peer whose current row cannot name its owner + (RFC 025 §3.3): `send`, `sendBytes`, `sendJSON`, `confirmIdentity` and the + raw `dial(to:port:)` all throw `MeshError.loginUnknown(peer:)` instead of + dialing, because we would be opening a connection to someone we cannot + attribute — and the inbound gate already refuses that peer's own fresh hello + (WhoIs with no login → 4004), so both directions agree. It is a rule about + OPENING, never about tearing down: an **existing session stands**, so a + momentary gap in the logins cannot flap a live connection, and a dial already + in flight under a known login is joined rather than re-judged. An ungated node + ignores the field entirely. +- **The dialing side** distinguishes a refusal from a broken pipe. Any + application close (4000–4999) received before the hello surfaces as + `MeshError.helloRefused(code:reason:)`, carrying the code the remote sent — + 4001 app mismatch, 4002 hello protocol, 4003 identity, 4004 login. Neither + role echoes a 4002 back at a refusal, because the socket is already closed + from the far end and the echo would only bury the reason. + `MeshError.loginRefused(login:)` remains the SERVER-role error, raised by the + side that ran the gate. +- **The raw plane is NOT gated** by `loginAllow` (RFC 025 §3.7, D9): the list + admits Layer 3 peers and session-plane hellos, and an app that opens its own + port with `listen(port:)` owns admission there. `MeshNode.whoIs(remoteEndpoint:)` + is how it does so — resolve `MeshAcceptedConnection.remoteEndpoint`, then + decide on the returned `loginName`, with `LoginGlob.allowed` if the app wants + the same grammar the node uses. An empty `tailscaleId` in the answer means + WhoIs produced no concrete identity and must be treated as untrusted. +- **Unchanged**: the hello envelope stays at version 2 and never carries a + login — WhoIs is the only authority (RFC 025 §3.7, D8). An empty + `loginAllow` is today's behaviour exactly, including the existing fail-open + under `.allowUnverified`. + +##### Where the login comes from on this plane + +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. It is not normative for the +mechanism, which on the Apple plane could not be what it first described (that +sentence now carries a dated correction there): the pinned TailscaleKit decodes +`IpnState.PeerStatus` **without `UserID`**, and `Status.SelfStatus` is a +`PeerStatus` too, so nothing in the decoded status can key `Status.User` — which +does exist, and is keyed by the stringified user id, with nothing to key it. + +The mechanism is therefore: + +- `TailscaleKitBackend.refreshStatus` issues **one additional authenticated GET + of the full LocalAPI status, `/localapi/v0/status`**, over the same loopback + the WhoIs path uses, and decodes only `Self.UserID`, `Peer[].{ID,UserID}` and + `User{}`. `Peer` is keyed by node key, so each row's own `ID` is the stable + node ID `BackendPeer` uses, and its `UserID` resolves through `User{}`. +- The result overlays the logins onto the mapped `BackendStatus`; TailscaleKit's + own decode stays the authority for every other field. Cost: one extra loopback + GET per status refresh, and a refresh runs on every IPN bus notify. +- The **full** status is read deliberately, because the peers' logins need it. + The lighter `status?peers=false` would still resolve the SELF login — 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". That distinction matters only if this + ever moves to the lighter endpoint. +- A `UserID` the map does not describe, a row carrying no `UserID`, and a failed + overlay read all resolve identically: the login is **absent** — never + fabricated, never `""`. A gated node then admits nobody, which is the + fail-closed answer, with the one-shot `.health` notice above so the emptied + mesh is not silent. +- **Future work:** when TailscaleKit's `PeerStatus` decodes `UserID`, the overlay + collapses into the decoder and this extra GET goes away. + +The decoders are `Sources/TruffleTailscale/LocalAPIIdentity.swift`, deliberately +outside `TailscaleKitBackend.swift`'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. `LocalAPIIdentityTests` covers it there. + ### 8.2 Hello envelope (hello v2 — `session/hello.rs`, RFC 017 §8) ```json