Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions apple/Sources/Truffle/Backend/LoopbackBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -29,24 +33,38 @@ 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() {}

public func setWithholdWhoIs(_ value: Bool) {
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
Expand Down Expand Up @@ -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 {
Expand All @@ -105,6 +124,7 @@ public actor LoopbackNetwork {
dnsName: "\(me.hostname).loopback.ts.net",
tailnetIPs: [me.ip],
tailscaleId: me.tailscaleId,
loginName: me.loginName,
peers: peers)
}

Expand Down Expand Up @@ -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)
}
}

Expand Down
32 changes: 29 additions & 3 deletions apple/Sources/Truffle/Backend/NetworkBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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
}
}

Expand All @@ -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
Expand All @@ -85,6 +110,7 @@ public struct BackendStatus: Sendable, Equatable {
self.dnsName = dnsName
self.tailnetIPs = tailnetIPs
self.tailscaleId = tailscaleId
self.loginName = loginName
self.peers = peers
}
}
Expand Down
236 changes: 236 additions & 0 deletions apple/Sources/Truffle/Identity/LoginGlob.swift
Original file line number Diff line number Diff line change
@@ -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<Unicode.Scalar>())
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<Unicode.Scalar>
) -> (star: Bool, chunk: ArraySlice<Unicode.Scalar>, rest: ArraySlice<Unicode.Scalar>) {
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<Unicode.Scalar>, _ s: ArraySlice<Unicode.Scalar>
) -> (rest: ArraySlice<Unicode.Scalar>, 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<Unicode.Scalar>(), false, true)
}
chunk = afterLo
var hi = lo
if chunk.first == "-" {
guard let (h, afterHi) = getEsc(chunk.dropFirst()) else {
return (ArraySlice<Unicode.Scalar>(), 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<Unicode.Scalar>(), 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<Unicode.Scalar>(), 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<Unicode.Scalar>
) -> (scalar: Unicode.Scalar, rest: ArraySlice<Unicode.Scalar>)? {
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)
}
}
Loading
Loading