-
-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Fix iOS Tailscale add flow for repeat devices and manual hosts #11274
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
austinywang
wants to merge
22
commits into
main
Choose a base branch
from
issue-11241-tailscale-add-flow
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 0a42f5d
fix(ios): make tailscale pairing explicit and dismiss reliably
austinywang 24df01c
fix(ios): clarify manual host trust and sheet cancellation
austinywang f78947f
fix(ios): unify pairing outcomes and bound route matching
austinywang 7e86f4e
fix(ios): name MagicDNS in pairing recovery guidance
austinywang 29436a5
fix(ios): preserve manual grants across reconnects
austinywang 0512a95
fix(ios): preserve tailscale grant authority across reconnects
austinywang a138476
fix(ios): preserve stored pairing authority
austinywang 5ce1de7
test(ios): consolidate manual pairing coverage
austinywang 894746f
fix(ios): harden mobile pairing and present Iroh first
austinywang 9510760
Merge remote-tracking branch 'origin/main' into issue-11241-tailscale…
austinywang 62ac50a
fix(ios): preserve authorized routes in pairing aliases
austinywang 39d84b8
fix(ios): complete pairing presentation closure wiring
austinywang 31bac5f
fix(ios): retain pairing grants through registry coalescing
austinywang db0770e
fix(ios): preserve usable grants during alias selection
austinywang 867d4ad
ui(mac): restore polished mobile connection chooser
austinywang e30e6e3
build(mac): wire mobile transport view
austinywang d9fc8f7
fix(mac): restore markdown renderer initializer
austinywang 2002119
fix(mac): initialize renderer attach callback
austinywang c43f822
fix(mac): import shared pairing view styling
austinywang 8f3a13b
fix(mac): preserve transport readiness default
austinywang d597822
fix(browser): import app link request in popup
austinywang File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
151 changes: 151 additions & 0 deletions
151
Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxManualHost.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)" | ||
| } | ||
| 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: "_") | ||
| } | ||
| } | ||
43 changes: 29 additions & 14 deletions
43
...s/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxUserTailscalePairingAuthorization.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
50 changes: 50 additions & 0 deletions
50
...CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacAtomicPairingStoring.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.