Files
offscale-mobile/modules/officer-net/ios/PacketTunnelProvider.swift
T
pastilhasandClaude Opus 5 2e8bd497df fix(dns): re-apply on the fast path, where overrides were never picked up
The first fix widened matchDomains but only where the netmap refine runs — first
connect. Every connect after that takes the fast path, which applies settings before
any netmap exists and then deliberately does not touch the live tunnel again.

So its match domains could only come from the file the previous refine wrote. Fine
for the suffix, which never changes; wrong for extra_records and split-DNS domains,
because on the first connect there is no file yet — the tunnel comes up resolving
tailnet names only and stays that way, since nothing re-applies on any later connect.

That is the shape of what Andre still saw after reinstalling and reconnecting:
officer.pertento.ai going to public DNS and failing TLS against 100.64.0.4, on a
device that had connected more than once.

The fast path now watches for the overrides and re-applies once if they differ from
what it used. Bounded like the existing refine, silent when they match, and
re-applying on a live tunnel is not novel — the exit-node and accept-routes paths
have always done it.

Still unconfirmed on hardware: this is the most likely cause found by reading, not a
diagnosis from device logs. The tunnel now says which domains it claimed, so the next
attempt can be settled rather than reasoned about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 06:02:07 +01:00

897 lines
44 KiB
Swift

import NetworkExtension
import TailCore // the vendored gomobile Tailscale core (Tailcore{Start,Stop,Status,…})
import os.log
// OffTail VPN data plane — iOS. This is the Network Extension: a SEPARATE executable bundled inside the
// same app (NE App ID `dev.officer.mobile.net`), running in its own process. It owns the utun device and
// hands its raw file descriptor to the embedded Tailscale core (TailCore.xcframework), which then does
// all packet I/O + WireGuard/magicsock/DERP itself — the same fd-handoff pattern WireGuard-iOS uses, so
// there's no Swift packet loop.
//
// This file compiles into the NE target (added as a manual Xcode step — see plugin/README.md) AND into
// the OfficerNet pod, so its TailcoreStart call is compile-checked in every app build.
//
// ⚠️ MEMORY BUDGET: iOS gives a packet tunnel a HARD ceiling (~50 MB; jetsam-killed above it). The core
// runs WireGuard in USERSPACE via netstack (no kernel WireGuard on iOS). Keep the peer set small. See
// TAILSCALE_VPN.md → "Memory budget".
//
// LAST MILE (needs on-device testing + a reachable Headscale): the network settings below use a
// placeholder address/route. The REAL per-node IP, routes, and MagicDNS come from the core's netmap
// (poll TailcoreStatus / add a netmap callback) and must be applied via setTunnelNetworkSettings — the
// router/DNS bridge. Until that's wired from the live netmap, routing won't be correct.
private let appGroup = "group.pastilhas.officer.mobile"
private let log = OSLog(subsystem: "dev.officer.mobile.net", category: "PacketTunnel")
class PacketTunnelProvider: NEPacketTunnelProvider {
// In-NE diagnostic ring buffer, surfaced to the app via handleAppMessage("diag"). idevicesyslog drops
// os_log lines under burst load, so this is the reliable way to see the bring-up flow on-device.
private var diag: [String] = []
private let diagLock = NSLock()
private func d(_ s: String) {
diagLock.lock()
diag.append(s)
if diag.count > 80 { diag.removeFirst() }
diagLock.unlock()
// .default, NOT .info: idevicesyslog drops OS_LOG_TYPE_INFO, so an .info line reads exactly like
// code that never ran — which is how the identity wipe was nearly misdiagnosed as not executing.
os_log("DIAG %{public}@", log: log, type: .default, s)
}
override func startTunnel(options: [String: NSObject]?,
completionHandler: @escaping (Error?) -> Void) {
os_log("startTunnel", log: log, type: .info)
d("startTunnel")
markRunStarted()
// 1. Read config the main app stashed (providerConfiguration wins; App Group is the fallback).
let proto = protocolConfiguration as? NETunnelProviderProtocol
let cfg = proto?.providerConfiguration ?? [:]
let defs = UserDefaults(suiteName: appGroup)
let controlUrl = (cfg["controlUrl"] as? String) ?? defs?.string(forKey: "controlUrl") ?? ""
let hostname = (cfg["hostname"] as? String) ?? defs?.string(forKey: "hostname") ?? ""
let authKey = (cfg["authKey"] as? String) ?? defs?.string(forKey: "authKey") ?? ""
guard !controlUrl.isEmpty else {
completionHandler(NSError(domain: "OfficerNet", code: 1,
userInfo: [NSLocalizedDescriptionKey: "No controlUrl configured"]))
return
}
// 2. Apply tunnel network settings so iOS creates the utun and starts routing into it.
// TODO(netmap): replace the placeholder IP/route with the core's assigned node IP + routes from
// TailcoreStatus (100.64.0.0/10 + advertised subnets) and MagicDNS — the router/DNS last mile.
// tunnelRemoteAddress MUST be a valid IP literal, not a URL — it's informational for a mesh VPN
// (there's no single server). Passing the control URL here fails with "Invalid tunnelRemoteAddress"
// and the tunnel dies before the core starts. Use a placeholder IP.
//
// KNOWN node IP → apply the REAL settings once, here, before the core starts. That is the whole
// fix: the previous flow applied a placeholder, started the core, then re-applied real settings
// on a live tunnel, and re-applying is a documented iOS teardown trap. The identity persists in
// the state dir so the IP is stable — only the very first connect on a device lacks one.
let known = savedIPs()
let settings: NEPacketTunnelNetworkSettings
if known.isEmpty {
d("no saved node IP — first connect, using placeholder and refining after the netmap")
settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
let ipv4 = NEIPv4Settings(addresses: ["100.64.0.1"], subnetMasks: ["255.192.0.0"])
ipv4.includedRoutes = [NEIPv4Route(destinationAddress: "100.64.0.0", subnetMask: "255.192.0.0")]
settings.ipv4Settings = ipv4
settings.mtu = 1280
} else {
d("saved node IP \(known) — applying real settings up front, no swap")
settings = tunnelSettings(selfIPs: known, magicDNSSuffix: nil)
}
setTunnelNetworkSettings(settings) { [weak self] error in
guard let self = self else { return }
if let error = error {
os_log("setTunnelNetworkSettings failed: %{public}@", log: log, type: .error, error.localizedDescription)
self.d("placeholder setTunnelNetworkSettings FAILED: \(error.localizedDescription)")
completionHandler(error)
return
}
self.d("placeholder settings applied")
// 3. Hand the utun fd to the Tailscale core and bring the node up against Headscale. The core reads
// /writes the fd itself — no Swift packet loop. The auth key is single-use; drop it after.
guard let fd = self.utunFileDescriptor() else {
self.d("utun fd NOT FOUND")
completionHandler(NSError(domain: "OfficerNet", code: 2,
userInfo: [NSLocalizedDescriptionKey: "Could not locate the utun file descriptor"]))
return
}
self.d("utun fd = \(fd)")
// Persist the node identity in a STABLE dir (Application Support), not NSTemporaryDirectory — else
// the core loses its keys on every restart and registers a brand-new node each connect.
let stateDir = self.stateDirectory()
// gomobile exposes Start as a C function returning BOOL with an NSError out-param (not throwing).
// NO upstream DNS here, deliberately — unlike Android. matchDomains below scopes the capture
// to the tailnet suffix, so only short names ever reach the core's resolver and it never has
// to forward anything. Android has no matchDomains equivalent, must route ALL DNS at the core,
// and therefore does need TailcoreSetUpstreamDNS.
var startErr: NSError?
let ok = TailcoreStart(fd, controlUrl, authKey, hostname, stateDir, &startErr)
if ok {
self.d("TailcoreStart OK — waiting for netmap IP, then completing")
defs?.set(true, forKey: "registered") // an identity now exists on this device
defs?.removeObject(forKey: "authKey") // single-use pre-auth key — don't keep it
if !known.isEmpty {
// Settings are already correct. Complete NOW: a provider that sits on its completion
// handler risks being torn down, and there is nothing left to wait for.
self.d("real settings already applied — completing immediately")
completionHandler(nil)
// Still watch the netmap, purely to keep the saved IP current if Headscale reassigns it.
self.refreshSavedIPs()
/*
And re-apply if the tailnet's DNS overrides turn out to differ from what we just used.
The fast path applies settings BEFORE any netmap exists, so its match domains can only come
from the file the last refine wrote. That is fine for the suffix, which never changes, and
wrong for `extra_records` / split-DNS domains: on the very first connect there is no file
yet, so the tunnel comes up resolving tailnet names only and STAYS that way — every later
connect takes this path and nothing ever re-applies.
Which is exactly the shape of the bug reported on 2026-08-18: an override that was supposed
to be captured kept going to public DNS, on a device that had connected more than once.
*/
self.reapplyIfDNSChanged(selfIPs: known)
} else {
// First connect only. Complete on the placeholder rather than blocking for up to 15s —
// the previous version waited before completing, which is its own teardown risk — and
// refine once the netmap lands so the NEXT connect takes the fast path above.
self.d("first connect — completing on placeholder, refining in background")
completionHandler(nil)
self.refineSettingsFromNetmap()
}
} else {
let e = startErr ?? NSError(domain: "OfficerNet", code: 3,
userInfo: [NSLocalizedDescriptionKey: "TailcoreStart failed"])
os_log("TailcoreStart failed: %{public}@", log: log, type: .error, e.localizedDescription)
self.d("TailcoreStart FAILED: \(e.localizedDescription)")
completionHandler(e)
}
}
}
override func stopTunnel(with reason: NEProviderStopReason,
completionHandler: @escaping () -> Void) {
os_log("stopTunnel: %{public}d", log: log, type: .info, reason.rawValue)
d("stopTunnel reason=\(reason.rawValue)")
markRunStopped(reason: reason.rawValue)
TailcoreStop()
completionHandler()
}
/// Answer app-messages from the main process. "diag" → the NE's diagnostic ring buffer (reliable, since
/// idevicesyslog drops lines); anything else → the core status JSON (self IPs + peers).
/**
The app's only channel to the core.
On iOS the Tailscale core runs in THIS process, not the app's — so anything the app wants from
`LocalBackend` has to come through here. That is invisible until you try to add a feature: calling
`TailcoreListProfiles()` from the app compiles perfectly and returns "[]", because the app's copy
of the package has a nil backend. Android has no such split, which is why its module can call the
Go directly and this one cannot.
The protocol is a string command. It stays deliberately dumb — a verb, optionally a colon and an
argument — because it is a private channel between two halves of the same app, and a real
serialisation format here would be ceremony.
*/
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
let msg = String(data: messageData, encoding: .utf8) ?? ""
if msg == "diag" {
diagLock.lock(); let out = diag.joined(separator: "\n"); diagLock.unlock()
completionHandler?(out.data(using: .utf8))
return
}
/*
Destroy the node identity, for real.
`logout()` in the app could only ever remove the VPN configuration and its own UserDefaults. The
identity is `tailscaled.state`, written by the CORE inside THIS process's container — so nothing
the app can reach touches it, and it survived logout and reinstall alike. That is why signing out
and installing again silently came back as the same node.
Only the extension can do this, and only with the core stopped: `store.New` holds the file open,
and deleting it underneath a running backend leaves the identity in memory to be written back.
Stop first, then delete, and let the app tear the tunnel down afterwards.
*/
if msg == "wipe" {
d("wipe: stopping core and deleting persisted identity")
TailcoreStop()
let fm = FileManager.default
let dir = URL(fileURLWithPath: stateDirectory())
// The whole directory, not just tailscaled.state: the cached node IPs and MagicDNS suffix are
// derived from the identity, and leaving them behind reapplies settings for a node that no
// longer exists.
for name in ["tailscaled.state", "node-ips.json", "magicdns-suffix.txt"] {
try? fm.removeItem(at: dir.appendingPathComponent(name))
}
d("wipe: done")
completionHandler?("ok".data(using: .utf8))
return
}
// Preferences that change while running: the exit node and route acceptance. Everything else the
// core is configured with is set once at Start; these two are chosen and changed live.
/*
Hand over a pending abnormal-exit report, once.
The app cannot read this file itself: the marker lives in the EXTENSION's container, and the two
do not share an App Group (see the group.pastilhas.officer.mobile vs group.dev.officer.mobile
mismatch documented above). This channel is the only way across, which is also why it is a `take`
rather than a `get` — collected means reported, and reporting the same crash on every launch would
be worse than not reporting it.
*/
if msg == "diag:lastexit" {
let report = takeLastExitReport()
if !report.isEmpty { d("handing over last-exit report: \(report)") }
completionHandler?(report.data(using: .utf8))
return
}
if msg.hasPrefix("prefs:exitnode:") {
let id = String(msg.dropFirst("prefs:exitnode:".count))
d("prefs:exitnode \(id.isEmpty ? "(none)" : id)")
var err: NSError?
TailcoreSetExitNode(id, &err)
d("prefs:exitnode -> \(err?.localizedDescription ?? "ok")")
/*
Re-apply the tunnel settings, because the ROUTES changed.
The preference lives in the core and the routes live in the tun; only this reconciles them.
Without it, selecting an exit node sets a preference and the OS carries on sending everything
but tailnet traffic straight out over Wi-Fi — the exit node never sees a packet, and on and off
look identical.
Applied on the live tunnel rather than restarting it: the same path the netmap refine already
uses, and far less disruptive than dropping the connection to change a route.
*/
if err == nil {
let ips = savedIPs()
if !ips.isEmpty {
applySettings(selfIPs: ips, magicDNSSuffix: savedSuffix())
} else {
d("prefs:exitnode: no saved IPs yet — routes will follow on the next netmap")
}
}
completionHandler?((err?.localizedDescription ?? "").data(using: .utf8))
return
}
if msg.hasPrefix("prefs:routes:") {
let on = msg.hasSuffix(":1")
d("prefs:routes \(on)")
var err: NSError?
TailcoreSetAcceptRoutes(on, &err)
d("prefs:routes -> \(err?.localizedDescription ?? "ok")")
// Same reasoning as the exit node: the preference is in the core, the routes are in the tun,
// and only re-applying reconciles them.
if err == nil {
let ips = savedIPs()
if !ips.isEmpty { applySettings(selfIPs: ips, magicDNSSuffix: savedSuffix()) }
}
completionHandler?((err?.localizedDescription ?? "").data(using: .utf8))
return
}
if msg == "prefs:get" {
completionHandler?(TailcorePrefs().data(using: .utf8))
return
}
if msg == "profiles:list" {
let json = TailcoreListProfiles()
// Traced because an empty list is ambiguous from the app side: it looks identical whether the
// core has no profiles, the message timed out, or the reply failed to parse. The trace says
// which, and costs nothing.
d("profiles:list -> \(json.count) chars: \(json.prefix(300))")
completionHandler?(json.data(using: .utf8))
return
}
/*
Join another tailnet on the LIVE backend.
Payload is base64 JSON because a control URL and a pre-auth key both contain characters that a
`:`-delimited command cannot carry safely.
This exists because NewProfile + restart cannot work: restarting drops the unregistered blank
profile and Start then overwrites the surviving one, so a second tailnet replaced the first
under the same profile id. Doing both steps inside the core, without a restart, is the only
ordering where neither undoes the other.
*/
if msg.hasPrefix("profiles:login:") {
let b64 = String(msg.dropFirst("profiles:login:".count))
guard let data = Data(base64Encoded: b64),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
d("profiles:login -> bad payload")
completionHandler?("bad payload".data(using: .utf8))
return
}
let controlUrl = (obj["controlUrl"] as? String) ?? ""
let authKey = (obj["authKey"] as? String) ?? ""
let hostname = (obj["hostname"] as? String) ?? ""
d("profiles:login control=\(controlUrl) key=\(authKey.isEmpty ? "none" : "yes")")
var err: NSError?
TailcoreLoginTo(controlUrl, authKey, hostname, &err)
d("profiles:login -> \(err?.localizedDescription ?? "ok")")
// The new profile has its own node IP; the cached one belongs to the tailnet we just left.
if err == nil { self.forgetCachedNetmap() }
completionHandler?((err?.localizedDescription ?? "").data(using: .utf8))
return
}
if msg == "profiles:new" {
// Errors come back as a string rather than a thrown NSError: the channel carries Data, and the
// app only needs to know whether it worked and why not.
d("profiles:new — creating a blank profile")
var err: NSError?
TailcoreNewProfile(&err)
d("profiles:new -> \(err?.localizedDescription ?? "ok")")
completionHandler?((err?.localizedDescription ?? "").data(using: .utf8))
return
}
if msg.hasPrefix("profiles:switch:") {
var err: NSError?
let switchID = String(msg.dropFirst("profiles:switch:".count))
d("profiles:switch \(switchID)")
TailcoreSwitchProfile(switchID, &err)
d("profiles:switch -> \(err?.localizedDescription ?? "ok")")
if err == nil { self.forgetCachedNetmap() }
completionHandler?((err?.localizedDescription ?? "").data(using: .utf8))
return
}
if msg.hasPrefix("profiles:delete:") {
var err: NSError?
let deleteID = String(msg.dropFirst("profiles:delete:".count))
d("profiles:delete \(deleteID)")
TailcoreDeleteProfile(deleteID, &err)
d("profiles:delete -> \(err?.localizedDescription ?? "ok")")
completionHandler?((err?.localizedDescription ?? "").data(using: .utf8))
return
}
let status = TailcoreStatus()
completionHandler?(status.data(using: .utf8))
}
// MARK: - State dir + netmap-driven settings
/**
The node's tailnet IPs, remembered from the last successful netmap.
Kept in the NE's OWN state dir, not an App Group. The NE currently has no App Group entitlement at
all, and adding one needs App Groups enabled on the `dev.officer.mobile.net` App ID in the Apple
portal — requesting an entitlement the App ID does not carry fails code-signing for the WHOLE app.
Nothing else needs to read this, so the extension's own container is both sufficient and free.
*/
/*
Forget the cached node IP and MagicDNS suffix.
Both describe the tailnet the node is currently in, but they live in one per-DEVICE file. Change
profile and they become another tailnet's answers — and startTunnel trusts them, applying "real
settings up front, no swap". After a switch the tun would come up holding an address that belongs
to the network you just left. Clearing forces the slower first-connect path, which refines from the
netmap and is correct.
*/
private func forgetCachedNetmap() {
let fm = FileManager.default
try? fm.removeItem(at: savedIPsURL)
try? fm.removeItem(at: savedSuffixURL)
// The override domains belong to the tailnet that sent them; a different profile is a different
// tailnet, and claiming the old one's names would capture DNS for a network we are no longer on.
try? fm.removeItem(at: savedMatchURL)
d("cleared cached node IP, MagicDNS suffix and DNS match domains (profile changed)")
}
private var savedIPsURL: URL {
URL(fileURLWithPath: stateDirectory()).appendingPathComponent("node-ips.json")
}
/*
How the LAST tunnel run ended — the only crash reporting a packet tunnel can honestly do.
The interesting failures in this app live in the extension, and they are the ones no reporter
catches. iOS gives a packet tunnel a hard memory ceiling (~50 MB) and jetsams it on breach: the
process is killed outright, so no signal handler, no exception handler and no in-process crash
library ever runs. A reporter embedded here would also be competing with the Tailscale core for the
very budget that gets it killed.
So this infers rather than catches. `startTunnel` marks the run as `running`; `stopTunnel` marks it
`stopped` with the OS's own reason. If a run STARTS and finds the previous one still marked
`running`, that run never reached `stopTunnel` — killed, crashed, or the device rebooted. The
distinction between a jetsam kill and a crash is not available to us, and pretending otherwise would
be worse than saying "ended abnormally".
It carries no identifiers by construction — a timestamp and a numeric reason code, nothing derived
from the tailnet, the login or any address. That is deliberate: this is the one diagnostic intended
to leave the device, and the cheapest way to keep a crash report free of secrets is to give it
nothing to leak.
*/
private var lastExitURL: URL {
URL(fileURLWithPath: stateDirectory()).appendingPathComponent("last-exit.json")
}
private func iso(_ d: Date = Date()) -> String {
let f = ISO8601DateFormatter()
return f.string(from: d)
}
/// Called at the top of startTunnel. Returns what happened to the previous run, and claims this one.
@discardableResult
private func markRunStarted() -> [String: Any]? {
let fm = FileManager.default
var previous: [String: Any]? = nil
if let data = try? Data(contentsOf: lastExitURL),
let prev = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
if (prev["state"] as? String) == "running" {
// Never reached stopTunnel. Jetsam, crash or reboot — indistinguishable from in here.
previous = [
"kind": "abnormal",
"startedAt": prev["startedAt"] as? String ?? "",
"detectedAt": iso(),
]
} else {
previous = [
"kind": "clean",
"reason": prev["reason"] as? Int ?? -1,
"at": prev["at"] as? String ?? "",
]
}
}
let now: [String: Any] = [
"state": "running",
"startedAt": iso(),
// Carried forward so the app can still collect it after a reconnect it did not witness.
"previous": previous as Any,
]
if let data = try? JSONSerialization.data(withJSONObject: now) {
try? data.write(to: lastExitURL, options: .atomic)
}
_ = fm
if let previous, (previous["kind"] as? String) == "abnormal" {
d("PREVIOUS RUN ENDED ABNORMALLY (no stopTunnel) — started \(previous["startedAt"] ?? "?")")
}
return previous
}
/// Called from stopTunnel. Records an orderly shutdown so the next start does not report a crash.
private func markRunStopped(reason: Int) {
var payload: [String: Any] = ["state": "stopped", "reason": reason, "at": iso()]
// Preserve any not-yet-collected report, so an orderly stop cannot erase the crash before it.
if let data = try? Data(contentsOf: lastExitURL),
let prev = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let carried = prev["previous"] {
payload["previous"] = carried
}
if let data = try? JSONSerialization.data(withJSONObject: payload) {
try? data.write(to: lastExitURL, options: .atomic)
}
}
/// The pending abnormal-exit report, if any. Cleared once handed over, so it is reported once.
private func takeLastExitReport() -> String {
guard let data = try? Data(contentsOf: lastExitURL),
var obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return "" }
guard let previous = obj["previous"] as? [String: Any] else { return "" }
obj["previous"] = nil
if let out = try? JSONSerialization.data(withJSONObject: obj) {
try? out.write(to: lastExitURL, options: .atomic)
}
guard let data = try? JSONSerialization.data(withJSONObject: previous),
let s = String(data: data, encoding: .utf8) else { return "" }
return s
}
private var savedSuffixURL: URL {
URL(fileURLWithPath: stateDirectory()).appendingPathComponent("magicdns-suffix.txt")
}
/// Remembered alongside the node IP: the fast path applies settings before any netmap arrives, so
/// without this a reconnect would silently drop MagicDNS until the next refresh.
private func savedSuffix() -> String? {
try? String(contentsOf: savedSuffixURL, encoding: .utf8)
}
private func saveSuffix(_ s: String) {
try? s.write(to: savedSuffixURL, atomically: true, encoding: .utf8)
}
private var savedMatchURL: URL {
URL(fileURLWithPath: stateDirectory()).appendingPathComponent("dns-match-domains.json")
}
/*
The domains control overrides, remembered for exactly the reason the suffix is.
The fast path applies settings before any netmap exists, so without this a reconnect resolves
tailnet names and silently misses every `extra_records` / split-DNS name until the netmap lands —
which is the bug this whole change is about, arriving a second time through a different door.
*/
private func savedMatchDomains() -> [String] {
guard let data = try? Data(contentsOf: savedMatchURL),
let list = try? JSONSerialization.jsonObject(with: data) as? [String] else { return [] }
return list
}
private func saveMatchDomains(_ list: [String]) {
guard let data = try? JSONSerialization.data(withJSONObject: list) else { return }
try? data.write(to: savedMatchURL, options: .atomic)
}
/*
Every domain the tunnel must answer for: the tailnet suffix, plus whatever control overrides.
The core has always known the second half — `CorpDNS: true` means the netmap carries the split-DNS
routes and the extra records — but nothing carried it across to here, so matchDomains was the
suffix alone and an overridden name like `officer.pertento.ai` went to public DNS. Public DNS
answers with a TAILNET address, which on this tailnet is a different machine: the TLS handshake
then fails against a host with no certificate for that name. Confirmed on an iPad, 2026-08-18.
Falls back to what was saved when the core has no netmap yet, so the fast path is not a downgrade.
*/
private func matchDomains(suffix: String?) -> [String] {
var out: [String] = []
var seen = Set<String>()
let add = { (d: String) in
let clean = d.trimmingCharacters(in: CharacterSet(charactersIn: " .")).lowercased()
// Never emit the empty entry: that is Apple's catch-all, and claiming every name off an exit
// node would route all resolution through a resolver that cannot always forward.
if !clean.isEmpty && !seen.contains(clean) {
seen.insert(clean)
out.append(clean)
}
}
if let suffix { add(suffix) }
var overrides: [String] = []
if let data = TailcoreDNSMatchDomains().data(using: .utf8),
let list = try? JSONSerialization.jsonObject(with: data) as? [String] {
overrides = list
}
if overrides.isEmpty { overrides = savedMatchDomains() } else { saveMatchDomains(overrides) }
overrides.forEach(add)
return out
}
private func savedIPs() -> [String] {
guard let data = try? Data(contentsOf: savedIPsURL),
let ips = try? JSONSerialization.jsonObject(with: data) as? [String] else { return [] }
return ips
}
private func saveIPs(_ ips: [String]) {
guard let data = try? JSONSerialization.data(withJSONObject: ips) else { return }
try? data.write(to: savedIPsURL, options: .atomic)
}
/// A stable per-extension directory for the core's persisted node identity/keys.
private func stateDirectory() -> String {
let fm = FileManager.default
let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSTemporaryDirectory())
let dir = base.appendingPathComponent("offtail", isDirectory: true)
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.path
}
/// Poll the core until it has a netmap (self Tailscale IPs), then re-apply the tunnel settings with the
/// real per-node address + tailnet routes + MagicDNS. The initial settings in startTunnel are just
/// placeholders to bring the utun up; this makes routing actually correct.
/*
Watch for the netmap's DNS overrides and re-apply settings once, if they are not what we used.
Cheap and bounded: it polls the same way the netmap refine does, stops the moment the domains match
what is already applied, and gives up after the same window. Re-applying on a live tunnel is not
novel — the exit-node and accept-routes paths have always done it.
*/
private func reapplyIfDNSChanged(selfIPs: [String]) {
let applied = Set(savedMatchDomains())
Task {
for _ in 0..<120 {
try? await Task.sleep(nanoseconds: 500_000_000)
guard let data = TailcoreDNSMatchDomains().data(using: .utf8),
let list = try? JSONSerialization.jsonObject(with: data) as? [String],
!list.isEmpty else { continue }
if Set(list) == applied {
self.d("DNS overrides unchanged (\(list.count) domains)")
return
}
self.d("DNS overrides changed — re-applying: \(list.joined(separator: ", "))")
self.saveMatchDomains(list)
self.applySettings(selfIPs: selfIPs, magicDNSSuffix: self.savedSuffix())
return
}
self.d("no DNS overrides seen from the netmap within 60s")
}
}
private func refineSettingsFromNetmap() {
d("refine: start polling")
Task {
// Poll up to ~60s at 0.5s — the netmap can be slow (esp. right after control comes back up). The
// placeholder tun address (100.64.0.1) MUST be replaced with the real node IP or outbound packets
// carry the wrong source and replies never come back.
var lastBackend = ""
for i in 0..<120 {
if let data = TailcoreStatus().data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
let backend = (obj["BackendState"] as? String) ?? "?"
if backend != lastBackend { self.d("refine: poll \(i) backend=\(backend)"); lastBackend = backend }
if let selfNode = obj["Self"] as? [String: Any],
let ips = selfNode["TailscaleIPs"] as? [String], !ips.isEmpty {
self.d("refine: got self IPs after \(i) polls (backend=\(backend)): \(ips)")
self.applySettings(selfIPs: ips, magicDNSSuffix: obj["MagicDNSSuffix"] as? String)
return
}
}
try? await Task.sleep(nanoseconds: 500_000_000)
}
self.d("refine: GAVE UP — no self IPs after 120 polls; tun stuck on placeholder")
}
}
/**
The tunnel settings for a set of node IPs. Pure — builds, does not apply.
Extracted so `startTunnel` and the refine path cannot drift: the whole bug class here is one code
path applying different settings than another.
*/
/**
Whether an exit node is in use, so the tun knows whether to claim everything.
Read from the core rather than remembered, because the preference can change from the app at any
time and the routes have to follow it.
*/
/**
RFC1918 and link-local, kept out of a default-route tunnel.
Fixed ranges rather than the phone's actual subnet: NEPacketTunnelNetworkSettings is built before
the tunnel is up and enumerating interfaces from inside the extension is unreliable. Excluding all
private space is broader than strictly needed and errs the right way — a LAN address should never
be carried to an exit node on the other side of the world.
*/
/**
Subnet routes the core has accepted, as NEIPv4Routes.
Accepting a route in the core is only half of it: the OS decides what enters the tunnel, so unless
the tun claims these prefixes the traffic never arrives and "use subnet routes" does nothing —
the same gap that made choosing an exit node inert. Empty unless the preference is on.
IPv4 only for now: advertised v6 subnets are rare and untested here, and claiming a prefix wrongly
is worse than not claiming it.
*/
private func acceptedSubnetRoutes() -> [NEIPv4Route] {
guard let data = TailcoreSubnetRoutes().data(using: .utf8),
let cidrs = try? JSONSerialization.jsonObject(with: data) as? [String] else { return [] }
return cidrs.compactMap { cidr in
let parts = cidr.split(separator: "/")
guard parts.count == 2, let bits = Int(parts[1]), bits > 0, bits <= 32,
parts[0].contains(".") else { return nil }
// Prefix length to dotted mask, which is the only form NEIPv4Route takes.
let mask = (0..<4).map { i -> String in
let b = max(0, min(8, bits - i * 8))
return String(256 - (1 << (8 - b)) % 256)
}.joined(separator: ".")
return NEIPv4Route(destinationAddress: String(parts[0]), subnetMask: mask)
}
}
private func privateIPv4Ranges() -> [NEIPv4Route] {
[
NEIPv4Route(destinationAddress: "10.0.0.0", subnetMask: "255.0.0.0"),
NEIPv4Route(destinationAddress: "172.16.0.0", subnetMask: "255.240.0.0"),
NEIPv4Route(destinationAddress: "192.168.0.0", subnetMask: "255.255.0.0"),
NEIPv4Route(destinationAddress: "169.254.0.0", subnetMask: "255.255.0.0"),
]
}
private func exitNodeActive() -> Bool {
guard let data = TailcorePrefs().data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let id = obj["exitNodeID"] as? String else { return false }
return !id.isEmpty
}
private func tunnelSettings(selfIPs: [String], magicDNSSuffix: String?) -> NEPacketTunnelNetworkSettings {
// tunnelRemoteAddress MUST be a valid IP literal, not a URL — it is informational for a mesh VPN
// (there is no single server). A control URL here fails with "Invalid tunnelRemoteAddress" and
// the tunnel dies before the core starts.
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
settings.mtu = 1280
/*
With an exit node, the tun must claim EVERYTHING.
Routing only the tailnet ranges is correct for ordinary use and useless for an exit node: iOS
hands us tailnet packets and sends the rest straight out over Wi-Fi, so the exit node never sees
the traffic it exists to carry. Selecting one changed the core's preference and nothing else.
*/
let allTraffic = exitNodeActive()
// Advertised subnets the user has accepted. Empty unless the toggle is on; skipped entirely with
// an exit node, whose default route already covers them.
let subnets = allTraffic ? [] : acceptedSubnetRoutes()
d("tun routes: \(allTraffic ? "DEFAULT (exit node active)" : "tailnet ranges + \(subnets.count) subnet route(s)")")
let v4 = selfIPs.filter { $0.contains(".") }
let v6 = selfIPs.filter { $0.contains(":") }
if !v4.isEmpty {
let ipv4 = NEIPv4Settings(addresses: v4, subnetMasks: v4.map { _ in "255.255.255.255" })
ipv4.includedRoutes = allTraffic
? [NEIPv4Route.default()]
: [NEIPv4Route(destinationAddress: "100.64.0.0", subnetMask: "255.192.0.0")] // 100.64.0.0/10
+ subnets
// The default route swallows the LAN as well, so the printer, the NAS and the router's own UI
// would go dark. Prefs already set ExitNodeAllowLANAccess; this is the tun honouring it, which
// is the half that decides where packets actually go.
if allTraffic {
ipv4.excludedRoutes = privateIPv4Ranges()
}
settings.ipv4Settings = ipv4
}
if !v6.isEmpty {
let ipv6 = NEIPv6Settings(addresses: v6, networkPrefixLengths: v6.map { _ in NSNumber(value: 128) })
ipv6.includedRoutes = allTraffic
? [NEIPv6Route.default()]
: [NEIPv6Route(destinationAddress: "fd7a:115c:a1e0::", networkPrefixLength: 48)] // Tailscale ULA
settings.ipv6Settings = ipv6
}
/*
DNS — scoped to the tailnet normally, everything once an exit node is carrying the traffic.
100.100.100.100 is the core's own resolver, which now exists: a dns.Manager is created before
netstack.Create, where previously there was none and this address answered nothing.
WITHOUT an exit node, matchDomains keeps the capture to tailnet names, which is why iOS never
lost the internet the way Android did — every other name keeps using the system resolvers
directly. Android has no matchDomains equivalent and has to route ALL DNS at the core.
WITH an exit node that scoping becomes a leak, and a quiet one. Ordinary names go to whatever
resolver the Wi-Fi handed out, which is usually the router — and the router is on the LAN, which
`privateIPv4Ranges()` deliberately excludes from the tunnel so the printer and the NAS keep
working. So every domain the user visits is still sent, in the clear, to the network they chose
an exit node to get away from. The packets go through the exit node; the names never did.
Claiming ALL names fixes it: resolution moves to the core, which is the only component that knows
an exit node exists, and its forwarded queries travel over WireGuard rather than out the Wi-Fi.
The catch-all is an empty matchDomains entry — `[""]` — which is Apple's documented way to say
"this tunnel resolves everything", not a placeholder.
The trade is real and worth stating: with an exit node, a core resolver that cannot forward now
takes ALL name resolution down instead of just tailnet names — the exact failure Android hit.
That is the right trade only because it is scoped to the case where the user has explicitly asked
for every packet to leave through another machine. Off the exit node, the safe scoping returns.
*/
let suffix = magicDNSSuffix ?? savedSuffix()
if allTraffic {
let dns = NEDNSSettings(servers: ["100.100.100.100"])
dns.matchDomains = [""]
settings.dnsSettings = dns
if let suffix, !suffix.isEmpty { saveSuffix(suffix) }
d("DNS: ALL names via core (exit node active)")
} else {
/*
The tailnet suffix AND every domain control overrides — see matchDomains(suffix:).
Previously `[suffix]`, which silently excluded every `extra_records` and split-DNS name because
those live under an unrelated domain. Still narrow, still not the catch-all: this claims the
names the tailnet actually answers for and leaves the rest of the internet on the system
resolver, which is what keeps a core that cannot forward from taking the whole phone offline.
*/
let domains = matchDomains(suffix: suffix)
if !domains.isEmpty {
let dns = NEDNSSettings(servers: ["100.100.100.100"])
dns.matchDomains = domains
settings.dnsSettings = dns
if let suffix, !suffix.isEmpty { saveSuffix(suffix) }
d("DNS via core for: \(domains.joined(separator: ", "))")
}
}
return settings
}
/**
Keep the saved node IP AND the MagicDNS suffix current, without touching a live tunnel's settings.
The suffix matters as much as the IP: the fast path applies settings before any netmap exists, so
it can only enable MagicDNS from what a previous connect wrote down. Recording only the IP — which
this did — meant `savedSuffix()` stayed empty forever on a device that always took the fast path,
and short names never resolved no matter what the core was capable of.
Written for the NEXT connect rather than re-applied now: re-applying settings on a live tunnel is
the teardown trap this whole file exists to avoid.
*/
private func refreshSavedIPs() {
Task {
for _ in 0..<60 {
if let data = TailcoreStatus().data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let suffix = obj["MagicDNSSuffix"] as? String, !suffix.isEmpty, suffix != self.savedSuffix() {
self.d("saved MagicDNS suffix -> \(suffix); next connect will use it")
self.saveSuffix(suffix)
}
if let selfNode = obj["Self"] as? [String: Any],
let ips = selfNode["TailscaleIPs"] as? [String], !ips.isEmpty {
if ips != self.savedIPs() {
self.d("saved node IP changed -> \(ips); next connect will use it")
self.saveIPs(ips)
}
return
}
}
try? await Task.sleep(nanoseconds: 500_000_000)
}
}
}
private func applySettings(selfIPs: [String], magicDNSSuffix: String?, then: ((Error?) -> Void)? = nil) {
d("applySettings IPs=\(selfIPs) suffix=\(magicDNSSuffix ?? "-")")
// Remembered so the NEXT connect can apply the right settings up front and skip the two-phase
// swap entirely — the identity is stable across connects, so this IP is too.
saveIPs(selfIPs)
let settings = tunnelSettings(selfIPs: selfIPs, magicDNSSuffix: magicDNSSuffix)
let v4 = selfIPs.filter { $0.contains(".") }
let v6 = selfIPs.filter { $0.contains(":") }
d("refine: calling setTunnelNetworkSettings v4=\(v4) v6=\(v6)")
setTunnelNetworkSettings(settings) { [weak self] err in
if let err = err {
os_log("refine settings failed: %{public}@", log: log, type: .error, err.localizedDescription)
self?.d("refine: setTunnelNetworkSettings FAILED: \(err.localizedDescription)")
} else {
os_log("applied netmap settings v4=%{public}@ v6=%{public}@", log: log, type: .info, v4.description, v6.description)
self?.d("refine: APPLIED real settings v4=\(v4) v6=\(v6) ✓")
}
// On the first-connect path this is startTunnel's completionHandler: the tunnel goes "connected"
// only now, already carrying the real settings.
then?(err)
}
}
// MARK: - utun fd
/// The utun fd this Network Extension owns. The RELIABLE way is NEPacketTunnelFlow's underlying socket
/// fd via KVC — that is unambiguously OUR tun (the one the OS routes into). WireGuard-iOS uses this.
///
/// The old approach scanned open fds for the first "utun"-named control socket, but a device can have
/// MORE than one utun — iCloud Private Relay, a personal Tailscale/VPN — and the scan could return the
/// wrong one. The core would then read/write a tun the OS never routes into: control plane works (the
/// core uses its own sockets, so it registers + gets a netmap + IP), but no app traffic ever flows.
private func utunFileDescriptor() -> Int32? {
// NOTE: do NOT use packetFlow.value(forKeyPath: "socket.fileDescriptor") — on iOS 18 that key path
// throws an Objective-C NSUnknownKeyException, which Swift can't catch, crashing the whole NE before
// TailcoreStart. Scan the open fds for the utun control socket instead (WireGuard-iOS's technique).
//
// EVERY match is logged, not just the first one taken. A phone routinely has more than one utun —
// iCloud Private Relay, a personal VPN, a leftover — and picking the wrong one gives a control
// plane that works perfectly (the core registers, gets a netmap and an IP, because it uses its own
// sockets) while no app traffic ever flows. That failure is indistinguishable from a dozen others
// unless the candidates are on the record, so: log them all, take the first, and say which.
var buf = [Int8](repeating: 0, count: 256)
var found: [(Int32, String)] = []
for fd: Int32 in 0...1024 {
var len = socklen_t(buf.count)
// SYSPROTO_CONTROL (2), UTUN_OPT_IFNAME (2): getsockopt succeeds only on a utun control socket.
let ret = buf.withUnsafeMutableBytes { getsockopt(fd, 2, 2, $0.baseAddress, &len) }
if ret == 0 && strncmp(buf, "utun", 4) == 0 {
found.append((fd, String(cString: buf)))
}
}
guard let chosen = found.first else {
os_log("utun fd: no utun socket found", log: log, type: .error)
d("utun fd: NONE FOUND")
return nil
}
let all = found.map { "\($0.1)=fd\($0.0)" }.joined(separator: " ")
os_log("utun candidates: %{public}@ — taking %{public}d", log: log, type: .info, all, chosen.0)
d("utun candidates: \(all) — taking \(chosen.1)=fd\(chosen.0)\(found.count > 1 ? " ⚠️ MORE THAN ONE" : "")")
return chosen.0
}
}