Skip to content

[Bug]: SwiftUI client cannot pair over a Tailscale IP — ATS blocks cleartext to 100.64.0.0/10 #5818

Description

@alecramos-sudo

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/swift-ios (experimental SwiftUI client, #5178)

Steps to reproduce

  1. Run a T3 Code desktop server reachable over a Tailscale tailnet.
  2. In the desktop app, create a mobile connection. The pairing dialog offers the tailnet address as a raw CGNAT IP, e.g. http://100.x.y.z:3773.
  3. In the SwiftUI client, pair using that address (typed, pasted link, or scanned QR — all use the same value).
  4. Enter a freshly generated pairing code within its 5-minute window.

Expected behavior

Pairing completes, matching the React Native client, which pairs successfully against the exact same address from the same device.

Actual behavior

Pairing fails with "T3 Code could not complete pairing. Check the server address and use a fresh pairing code."

The request never leaves the device. On the server, the pairing link row stays active and is never consumed — consumed_at remains NULL until it expires. The server logs no request at all.

Cause: App Transport Security. apps/swift-ios/Resources/Info.plist declares:

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsLocalNetworking</key><true/>
    <key>NSExceptionDomains</key>
    <dict>
        <key>ts.net</key>
        <dict>
            <key>NSExceptionAllowsInsecureHTTPLoads</key><true/>
            <key>NSIncludesSubdomains</key><true/>
        </dict>
    </dict>
</dict>

Neither exception covers a Tailscale IP literal:

  • NSAllowsLocalNetworking permits cleartext only to RFC1918 ranges (10/8, 172.16/12, 192.168/16), .local, and link-local. Tailscale uses 100.64.0.0/10 (CGNAT, RFC 6598), which is not in that set.
  • The ts.net exception is domain-scoped and cannot match a bare IP.

So ATS blocks the cleartext load inside the app before any packet is sent. The first request in PairingService.pair is EnvironmentAPI.descriptorGET /.well-known/t3/environment (Core/HTTP.swift), which fails, so the flow aborts before the /oauth/token exchange — hence the untouched pairing link.

This is self-inflicted in the sense that the desktop app itself surfaces the IP form as the address to use.

Impact

Tailscale users cannot pair the SwiftUI client at all using the address T3 Code presents, while the RN client works fine. The error text ("check the server address and use a fresh pairing code") points at two things that are both correct, so it is hard to diagnose.

Version or commit

Environment

iOS 26 device, Tailscale tailnet with MagicDNS enabled, desktop server on macOS (arm64) bound to *:3773.

Logs or stack traces

Server side, nothing is logged — the request never arrives. Evidence is the pairing link lifecycle in auth_pairing_links, two attempts ~80 seconds apart from the same device:

created   expires   state              label
10:36:03  10:41:03  active             swift    <- SwiftUI: never redeemed, expired unused
10:34:41  10:39:41  CONSUMED 10:35:53  phone    <- RN client: paired in 72s

The server itself is healthy and the client's request shape is correct. Replaying the SwiftUI token exchange verbatim with curl (deliberately invalid code) returns a well-formed rejection, i.e. the form schema validates:

POST /oauth/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<invalid>
subject_token_type=urn:t3:params:oauth:token-type:environment-bootstrap
requested_token_type=urn:ietf:params:oauth:token-type:access_token
client_device_type=mobile&client_os=iOS

HTTP 401 {"_tag":"EnvironmentAuthInvalidError","code":"auth_invalid","reason":"invalid_credential"}

GET /.well-known/t3/environment also returns valid JSON (content-type: application/json) when fetched off-device.

Screenshots, recordings, or supporting files

n/a

Workaround

Pair using the MagicDNS hostname (http://<host>.<tailnet>.ts.net:3773) instead of the IP, which matches the ts.net exception. This only works if MagicDNS is enabled and resolving on the device, and it is not what the desktop pairing dialog offers by default.

Possible fixes:

  • Add a CGNAT exception so 100.64.0.0/10 is treated like local networking (an NSExceptionDomains entry cannot express a CIDR, so this likely means allowing arbitrary loads for cleartext-with-explicit-address or special-casing the range in the client).
  • Or have the desktop advertise the MagicDNS name rather than the tailnet IP when Tailscale is detected.
  • Either way, surfacing the underlying transport error instead of the generic pairing message would make this diagnosable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions