Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9b35d0e
test(ios): cover tailscale add flow regressions
austinywang Aug 31, 2026
0a42f5d
fix(ios): make tailscale pairing explicit and dismiss reliably
austinywang Aug 31, 2026
24df01c
fix(ios): clarify manual host trust and sheet cancellation
austinywang Aug 31, 2026
f78947f
fix(ios): unify pairing outcomes and bound route matching
austinywang Aug 31, 2026
7e86f4e
fix(ios): name MagicDNS in pairing recovery guidance
austinywang Aug 31, 2026
29436a5
fix(ios): preserve manual grants across reconnects
austinywang Sep 1, 2026
0512a95
fix(ios): preserve tailscale grant authority across reconnects
austinywang Sep 1, 2026
a138476
fix(ios): preserve stored pairing authority
austinywang Sep 1, 2026
5ce1de7
test(ios): consolidate manual pairing coverage
austinywang Sep 1, 2026
894746f
fix(ios): harden mobile pairing and present Iroh first
austinywang Sep 1, 2026
9510760
Merge remote-tracking branch 'origin/main' into issue-11241-tailscale…
austinywang Sep 1, 2026
62ac50a
fix(ios): preserve authorized routes in pairing aliases
austinywang Sep 1, 2026
39d84b8
fix(ios): complete pairing presentation closure wiring
austinywang Sep 1, 2026
31bac5f
fix(ios): retain pairing grants through registry coalescing
austinywang Sep 1, 2026
db0770e
fix(ios): preserve usable grants during alias selection
austinywang Sep 1, 2026
867d4ad
ui(mac): restore polished mobile connection chooser
austinywang Sep 1, 2026
e30e6e3
build(mac): wire mobile transport view
austinywang Sep 1, 2026
d9fc8f7
fix(mac): restore markdown renderer initializer
austinywang Sep 1, 2026
2002119
fix(mac): initialize renderer attach callback
austinywang Sep 1, 2026
c43f822
fix(mac): import shared pairing view styling
austinywang Sep 1, 2026
8f3a13b
fix(mac): preserve transport readiness default
austinywang Sep 1, 2026
d597822
fix(browser): import app link request in popup
austinywang Sep 1, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public enum CmxLegacyTailscaleAuthorizationEvidenceError: Error, Equatable, Send
///
/// This value is transport evidence, not route discovery. It authorizes only
/// one canonical Mac device ID, numeric Tailscale peer address, and TCP port.
public struct CmxLegacyTailscaleAuthorizationEvidence: Equatable, Sendable {
public struct CmxLegacyTailscaleAuthorizationEvidence: Equatable, Hashable, Sendable {
/// The canonical paired Mac device identifier.
public let macDeviceID: String
/// The canonical numeric Tailscale peer address.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import Darwin
import Foundation

/// A normalized host entered for an explicit mobile pairing attempt.
///
/// This value deliberately covers DNS names and IP literals in
/// addition to numeric Tailscale addresses. It is used only at an explicit
/// pairing boundary; automatic route discovery continues to use its own route
/// evidence and never treats this type as authorization.
public struct CmxManualHost: Equatable, Sendable {
/// The normalized bare host, with IPv6 brackets and a DNS root dot removed.
/// Scoped IPv6 literals retain their validated `%interface` zone suffix.
public let rawValue: String

/// Creates a normalized host from user input.
/// - Parameter rawHost: A DNS name or IP literal. IPv6 input may be bracketed
/// and may carry a scoped-interface suffix such as `%en0`.
public init?(_ rawHost: String) {
guard let normalized = cmxManualHostNormalize(rawHost) else {
return nil
}
rawValue = normalized
}
}

private func cmxManualHostNormalize(_ rawHost: String) -> String? {
let trimmed = rawHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }

let host: String
let isBracketed = trimmed.hasPrefix("[") || trimmed.hasSuffix("]")
if isBracketed {
guard trimmed.hasPrefix("["), trimmed.hasSuffix("]"), trimmed.count > 2 else {
return nil
}
host = String(trimmed.dropFirst().dropLast())
} else {
host = trimmed
}

guard !host.isEmpty,
host.rangeOfCharacter(from: .whitespacesAndNewlines) == nil,
host.rangeOfCharacter(from: .controlCharacters) == nil,
host.range(of: "://") == nil,
host.rangeOfCharacter(from: CharacterSet(charactersIn: "/?#@")) == nil else {
return nil
}

if isBracketed && !host.contains(":") { return nil }
if host.contains(":") {
let components = host.split(separator: "%", omittingEmptySubsequences: false)
guard components.count <= 2,
!components.contains(where: { $0.isEmpty }) else {
return nil
}
let literal = String(components[0])
guard let canonicalIPv6 = cmxManualHostCanonicalIPv6(literal) else {
return nil
}
if components.count == 2 {
let zone = String(components[1])
guard cmxManualHostValidIPv6Zone(zone) else { return nil }
return "\(canonicalIPv6)%\(zone)"
Comment thread
austinywang marked this conversation as resolved.
}
return canonicalIPv6
}

// A dotted, all-numeric value is intended to be an IPv4 literal. Do
// not let a malformed or ambiguous spelling become a DNS hostname.
let numericHost = host.hasSuffix(".") ? String(host.dropLast()) : host
if numericHost.contains("."), numericHost.utf8.allSatisfy({
(48...57).contains($0) || $0 == UInt8(ascii: ".")
}) {
guard let canonicalIPv4 = cmxManualHostCanonicalIPv4(numericHost),
canonicalIPv4 == numericHost else {
return nil
}
return canonicalIPv4
}

guard host.utf8.allSatisfy({ byte in
(48...57).contains(byte)
|| (65...90).contains(byte)
|| (97...122).contains(byte)
|| byte == UInt8(ascii: ".")
|| byte == UInt8(ascii: "-")
|| byte == UInt8(ascii: "_")
}) else {
return nil
}

let lowercased = host.lowercased()
let canonical = lowercased.hasSuffix(".")
? String(lowercased.dropLast())
: lowercased
guard !canonical.isEmpty,
canonical.utf8.count <= 253,
!canonical.hasSuffix(".") else { return nil }
let labels = canonical.split(separator: ".", omittingEmptySubsequences: false)
guard labels.allSatisfy({ label in
!label.isEmpty
&& label.count <= 63
&& label.first != "-"
&& label.last != "-"
}) else {
return nil
}
return canonical
}

private func cmxManualHostCanonicalIPv4(_ host: String) -> String? {
var address = in_addr()
guard host.withCString({ inet_pton(AF_INET, $0, &address) == 1 }) else {
return nil
}
var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
guard inet_ntop(AF_INET, &address, &buffer, socklen_t(buffer.count)) != nil else {
return nil
}
return String(
decoding: buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) },
as: UTF8.self
)
}

private func cmxManualHostCanonicalIPv6(_ host: String) -> String? {
var address = in6_addr()
guard host.withCString({ inet_pton(AF_INET6, $0, &address) == 1 }) else {
return nil
}
var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN))
guard inet_ntop(AF_INET6, &address, &buffer, socklen_t(buffer.count)) != nil else {
return nil
}
return String(
decoding: buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) },
as: UTF8.self
).lowercased()
}

private func cmxManualHostValidIPv6Zone(_ zone: String) -> Bool {
guard !zone.isEmpty, zone.utf8.count <= 63 else { return false }
return zone.utf8.allSatisfy { byte in
(48...57).contains(byte)
|| (65...90).contains(byte)
|| (97...122).contains(byte)
|| byte == UInt8(ascii: ".")
|| byte == UInt8(ascii: "-")
|| byte == UInt8(ascii: "_")
}
}
Original file line number Diff line number Diff line change
@@ -1,46 +1,61 @@
import Foundation

/// Invalid input for a user-entered Tailscale compatibility pairing code.
/// Invalid input for a user-entered compatibility pairing destination.
public enum CmxUserTailscalePairingAuthorizationError: Error, Equatable, Sendable {
/// The host was not a numeric Tailscale peer address.
/// The host was not a valid DNS name or IP address.
case invalidHost
/// The port fell outside `1...65535`.
case invalidPort(Int)
}

/// A narrow capability allowing one user-entered Tailscale compatibility code
/// to dial the exact peer address it named.
/// to dial the exact host and port it named.
///
/// The authorization event is the user reading the code off their Mac's
/// pairing window (QR scan or pasted text) in this app session. Unlike
/// The authorization event is the user reading a code from their Mac or
/// explicitly entering its destination in this app session. Unlike
/// ``CmxLegacyTailscaleAuthorizationEvidence`` there is no Mac device binding:
/// any identity a code claims is self-reported and carries no authority, so
/// this value anchors on the exact destination alone and never persists. Once
/// the host authenticates, the shell records a device-bound grant and later
/// dials use the evidence path.
public struct CmxUserTailscalePairingAuthorization: Equatable, Sendable {
/// The canonical numeric Tailscale peer address from the entered code.
/// this value anchors on the exact destination alone and never persists. Numeric
/// Tailscale addresses receive interface-bound transport proof. A MagicDNS name,
/// private-LAN address, or other explicitly entered host uses the existing
/// manual-host trust warning and remains exact-destination-only. Once the host
/// authenticates, the shell records a device-local grant and later dials use that
/// grant.
public struct CmxUserTailscalePairingAuthorization: Equatable, Hashable, Sendable {
/// The canonical host from the entered code.
public let host: String
/// The exact legacy mobile listener port from the entered code.
public let port: Int

/// Validates and canonicalizes one user-entered compatibility destination.
public init(host: String, port: Int) throws {
guard let peerAddress = CmxTailscalePeerAddress(host) else {
guard let normalizedHost = cmxUserTailscaleNormalizedHost(host) else {
throw CmxUserTailscalePairingAuthorizationError.invalidHost
}
guard (1 ... 65_535).contains(port) else {
throw CmxUserTailscalePairingAuthorizationError.invalidPort(port)
}
self.host = peerAddress.value
self.host = normalizedHost
self.port = port
}

/// Whether a dial still names the exact peer the user entered.
public func authorizes(host: String, port: Int) -> Bool {
guard let peerAddress = CmxTailscalePeerAddress(host) else {
guard let normalizedHost = cmxUserTailscaleNormalizedHost(host) else {
return false
}
return peerAddress.value == self.host && port == self.port
return normalizedHost == self.host && port == self.port
}

}

private func cmxUserTailscaleNormalizedHost(_ rawHost: String) -> String? {
if let peerAddress = CmxTailscalePeerAddress(rawHost) {
return peerAddress.value
}
guard let manualHost = CmxManualHost(rawHost)?.rawValue,
!CmxLoopbackHost().matches(manualHost) else {
return nil
}
return manualHost
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,76 @@ import Testing
#expect(authorization.authorizes(host: "fd7a:115c:a1e0::1234", port: 58_465))
}

@Test func rejectsNonTailscaleDestinations() {
@Test func canonicalizesDirectIPv4AndIPv6Spellings() throws {
let ipv4 = try CmxUserTailscalePairingAuthorization(
host: "192.168.1.20.",
port: 58_465
)
#expect(ipv4.host == "192.168.1.20")

let ipv6 = try CmxUserTailscalePairingAuthorization(
host: "[fe80:0:0:0:0:0:0:1]",
port: 58_465
)
#expect(ipv6.host == "fe80::1")
#expect(ipv6.authorizes(host: "fe80::1", port: 58_465))
}

@Test func preservesValidatedIPv6ZoneForLanPairing() throws {
let authorization = try CmxUserTailscalePairingAuthorization(
host: "[fe80:0:0:0:0:0:0:1%en0]",
port: 58_465
)

#expect(authorization.host == "fe80::1%en0")
#expect(authorization.authorizes(host: "fe80::1%en0", port: 58_465))
#expect(!authorization.authorizes(host: "fe80::1%pdp_ip0", port: 58_465))
}

@Test(arguments: [
"work-mac.tailnet.ts.net",
"work-mac",
"192.168.1.20",
"devbox.local",
])
func acceptsValidatedMagicDNSAndDirectHosts(_ host: String) throws {
let authorization = try CmxUserTailscalePairingAuthorization(
host: host,
port: 58_465
)

#expect(authorization.authorizes(host: host, port: 58_465))
#expect(!authorization.authorizes(host: "other-(host)", port: 58_465))
}

@Test func rejectsMalformedHosts() {
#expect(throws: CmxUserTailscalePairingAuthorizationError.invalidHost) {
_ = try CmxUserTailscalePairingAuthorization(
host: "https://work-mac.tailnet.ts.net/path",
port: 58_465
)
}
#expect(throws: CmxUserTailscalePairingAuthorizationError.invalidHost) {
_ = try CmxUserTailscalePairingAuthorization(
host: "192.168.001.20",
port: 58_465
)
}
#expect(throws: CmxUserTailscalePairingAuthorizationError.invalidHost) {
_ = try CmxUserTailscalePairingAuthorization(
host: "[work-mac]",
port: 58_465
)
}
#expect(throws: CmxUserTailscalePairingAuthorizationError.invalidHost) {
_ = try CmxUserTailscalePairingAuthorization(
host: "work-mac.tailnet.ts.net",
host: "fe80::1%en0/extra",
port: 58_465
)
}
#expect(throws: CmxUserTailscalePairingAuthorizationError.invalidHost) {
_ = try CmxUserTailscalePairingAuthorization(
host: "192.168.1.20",
host: "127.0.0.1",
port: 58_465
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ public struct MobilePairedMac: Codable, Equatable, Sendable, Identifiable {
/// from ``CodingKeys`` so it never rides account backup to another device.
/// `nil` = fall back to the app's default method.
public var connectionMethodRawValue: String? = nil
/// Exact Tailscale/manual hosts this iPhone authorized by entering a
/// pairing code or explicit destination. Device-local and excluded from
/// ``CodingKeys``; migration grants remain in ``legacyTailscaleRoutes`` but
/// are not included here.
public var userAuthorizedTailscaleRoutes: [CmxAttachRoute]? = nil
/// THIS iPhone's Direct-method dial candidates for this Mac app instance,
/// stored as JSON. Device-local and excluded from ``CodingKeys`` like the
/// connection method.
Expand Down Expand Up @@ -133,7 +138,8 @@ public struct MobilePairedMac: Codable, Equatable, Sendable, Identifiable {
instanceTag: String? = nil,
legacyTailscaleRoutes: [CmxAttachRoute]? = nil,
connectionMethodRawValue: String? = nil,
directAddressesRawJSON: String? = nil
directAddressesRawJSON: String? = nil,
userAuthorizedTailscaleRoutes: [CmxAttachRoute]? = nil
) {
self.macDeviceID = macDeviceID
self.displayName = displayName
Expand All @@ -150,6 +156,7 @@ public struct MobilePairedMac: Codable, Equatable, Sendable, Identifiable {
self.legacyTailscaleRoutes = legacyTailscaleRoutes
self.connectionMethodRawValue = connectionMethodRawValue
self.directAddressesRawJSON = directAddressesRawJSON
self.userAuthorizedTailscaleRoutes = userAuthorizedTailscaleRoutes
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
public import CMUXMobileCore

/// Optional capability for stores that can commit a user Tailscale grant and
/// its per-device connection method in one transaction.
public protocol MobilePairedMacAtomicPairingStoring: MobilePairedMacStoring {
/// Atomically record the exact user-authorized routes and method for one
/// paired-Mac row. Production decorators forward this to their inner store.
func authorizeUserTailscaleRoutesAndSetConnectionMethod(
macDeviceID: String,
instanceTag: String?,
stackUserID: String?,
teamID: String?,
routes: [CmxAttachRoute],
rawValue: String
) async throws
}

/// Raised when a wrapper is asked to persist a security-sensitive pairing
/// mutation but its inner store does not provide a transaction boundary.
public enum MobilePairedMacAtomicPairingError: Error, Equatable, Sendable {
case unavailable
}

extension MobilePairedMacAtomicPairingStoring {
/// Forward a combined grant/method mutation only to an inner store that
/// explicitly provides the same atomic capability. There is intentionally
/// no two-write fallback: a grant without its Tailscale-only method would
/// widen the reconnect surface after a partial failure.
public func authorizeUserTailscaleRoutesAndSetConnectionMethod(
forwardingTo inner: any MobilePairedMacStoring,
macDeviceID: String,
instanceTag: String?,
stackUserID: String?,
teamID: String?,
routes: [CmxAttachRoute],
rawValue: String
) async throws {
guard let atomicInner = inner as? any MobilePairedMacAtomicPairingStoring else {
throw MobilePairedMacAtomicPairingError.unavailable
}
try await atomicInner.authorizeUserTailscaleRoutesAndSetConnectionMethod(
macDeviceID: macDeviceID,
instanceTag: instanceTag,
stackUserID: stackUserID,
teamID: teamID,
routes: routes,
rawValue: rawValue
)
}
}
Loading
Loading