Files
offscale-mobile/modules/officer-net/ios/OfficerNetModule.swift
T
pastilhas ef3582f2aa init: OffScale — standalone MIT Tailscale/Headscale client
Extracted from the officer-suite monorepo into its own public repo. The app is
open source and free; the rest of the suite is not, so it could not stay in a
tree that ships an account layer.

What moved:

- apps/offscale/*                  -> repo root (App shell, app.json, assets)
- packages/officer-net/            -> modules/officer-net/, a `file:` dependency
- packages/core VPN closure        -> src/vpn/ (screen, hook, native bridge, invite)
- packages/core shared primitives  -> src/ui/ (theme, cards, tabs, backdrop, splash)
- scripts/build-{ios,android}.sh   -> scripts/, cut down to this one app

What deliberately did NOT move: the platform account layer. VpnScreen used to
render core's AppSettingsScreen, which drags in ServersSection -> servers.ts,
useAuth, api.ts, shared-session, install-guard, api-keys. None of that exists
here. src/settings/SettingsScreen.tsx is a fresh replacement rendering exactly
what VpnScreen passed it — the app's own sections plus About — with no Servers
tab, no Appearance control (the app is pinned to dark) and no sign-out.

The one platform reference left in the closure was a dead `import { request }
from './api'` in officer-net.ts, unused since enrollVpn() was removed. Gone. The
app now contacts nothing but the control server you point it at, its peers, and
the enrolment sidecar named inside an invite link you tapped.

Dependencies are pinned to the exact versions the monorepo has installed, not to
ranges: this repo starts from versions already proven to build, and an unpinned
install is a supply-chain decision nobody made. react-query, react-dom,
react-native-screens, expo-secure-store, track-player, expo-audio and
expo-file-system were all declared by the old app and imported by none of this
code, so they are not here.

NOTICE carries Tailscale's BSD-3 text, names the built artifacts that contain
their compiled code, and treats the trademark question separately — including
that src/ui/TailscaleMark.tsx reproduces their logo in the attribution strip.
2026-08-14 20:32:17 +01:00

741 lines
37 KiB
Swift

import ExpoModulesCore
import NetworkExtension
import TailCore // the vendored gomobile Tailscale core (packages/officer-net/ios/libs/TailCore.xcframework)
// OffTail VPN engine — iOS RN-process module. Parity with android/.../OfficerNetModule.kt and the JS
// bridge in packages/core/src/services/officer-net.ts (requireNativeModule('OfficerNet')).
//
// This module runs in the *main app process*. It does NOT touch packets. Its whole job is to configure
// and drive a NETunnelProviderManager, which in turn starts/stops the Network Extension target
// (PacketTunnelProvider.swift) that actually hosts the Tailscale core (libtailscale). The two processes
// share config/keys through the App Group. See TAILSCALE_VPN.md → "Architecture".
//
// The libtailscale core is NOT linked into this process — it lives in the NE. So there are no
// `// TODO(libtailscale):` call-sites here; the TODOs here are all about NE plumbing. The core call-
// sites live in PacketTunnelProvider.swift.
/**
The Network Extension target that embeds PacketTunnelProvider.swift + the Tailscale core.
DERIVED from the host app's bundle id, never hardcoded. It was `dev.officer.mobile.net` — Officer's
extension — which is correct for Officer and wrong for every other host. The standalone app ships
`dev.officer.tail.net`, so it was asking iOS to start an extension that is not part of it: the start
command is accepted, nothing matches, and the tunnel silently never launches. No crash, no trace, a
dead toggle.
It appeared to work only because Officer is installed on the same device, so there WAS a
`dev.officer.mobile.net` for iOS to find — the standalone app was driving Officer's extension. The
moment the configuration was rebuilt from scratch that stopped being honoured, which is why this
surfaced as "it used to connect and now it will not".
The value is read from `OfficerNetExtensionBundleId` in Info.plist, which the Expo plugin writes from
each app's own `neBundleId` prop — apps/tail already declared `dev.officer.tail.net` correctly, and
this file simply never looked. Deriving from the app's bundle id is the fallback for a host that
predates the plist key, and it matches the plugin's own `.net` convention.
*/
private var neBundleId: String {
if let declared = Bundle.main.object(forInfoDictionaryKey: "OfficerNetExtensionBundleId") as? String,
!declared.isEmpty {
return declared
}
return (Bundle.main.bundleIdentifier ?? "dev.officer.mobile") + ".net"
}
// Local scratch store (UserDefaults.standard) for the config the NE needs (controlUrl/hostname/authKey);
// it's copied into the manager's providerConfiguration, which is how the NE actually receives it. No App
// Group — see the plugin's withIosEntitlements for why.
// Keys shared with providerConfiguration.
private let kControlUrl = "controlUrl"
private let kHostname = "hostname"
private let kAuthKey = "authKey" // single-use Headscale pre-auth key; consumed by the core on first up().
private let kRegistered = "registered" // set by the NE once the node has a persisted identity.
/// One-shot guard so a checked continuation is resumed exactly once when both a timeout and a real
/// response can race (see neCoreStatus).
private final class ResumeOnce {
private let lock = NSLock()
private var done = false
func take() -> Bool {
lock.lock(); defer { lock.unlock() }
if done { return false }
done = true
return true
}
}
public class OfficerNetModule: Module {
// The lazily-loaded/created tunnel manager. One per app; NE state persists in the system prefs.
private var manager: NETunnelProviderManager?
private var statusObserver: NSObjectProtocol?
public func definition() -> ModuleDefinition {
Name("OfficerNet")
// Native → JS status/peer stream. Mirrors the `onStatus` event the JS bridge subscribes to.
Events("onStatus")
// Proof-of-life: the embedded Tailscale core's version, read straight from the vendored gomobile
// framework — this runs real tailscale.com code inside the app. (Called here in the main process for
// the demo; the shipping design hosts the core in the NE.) No network.
AsyncFunction("coreVersion") { () -> String in
return TailcoreVersion()
}
// ── Profiles ─────────────────────────────────────────────────────────────────────────────
//
// Everything here round-trips through the extension, because that is where the core runs. An
// empty reply means success; a non-empty one is the error text.
AsyncFunction("listProfiles") { () async -> String in
return await neCommand("profiles:list") ?? "[]"
}
AsyncFunction("switchProfile") { (id: String) async throws in
guard let err = await neCommand("profiles:switch:\(id)", timeout: 15) else {
throw NSError(domain: "officer-net", code: 2, userInfo: [
NSLocalizedDescriptionKey: "The VPN service did not respond. Connect first, then try again.",
])
}
if !err.isEmpty { throw NSError(domain: "officer-net", code: 1, userInfo: [NSLocalizedDescriptionKey: err]) }
}
/*
nil is NOT success.
`neCommand` returns nil when the message never got a reply — no live session, or the 1.5s
timeout. Treating that as success meant `newProfile()` could return cleanly having done nothing
at all, and the caller would go on to `configure` + `up` believing a fresh profile existed. The
result is a second tailnet silently REPLACING the first instead of joining it.
Same shape as the startTunnel optional-chaining bug: a failure that returns quietly is worse
than one that throws.
*/
AsyncFunction("newProfile") { () async throws in
guard let err = await neCommand("profiles:new", timeout: 15) else {
throw NSError(domain: "officer-net", code: 2, userInfo: [
NSLocalizedDescriptionKey: "The VPN service did not respond. Connect first, then try again.",
])
}
if !err.isEmpty { throw NSError(domain: "officer-net", code: 1, userInfo: [NSLocalizedDescriptionKey: err]) }
}
/// Join another tailnet without restarting the tunnel. See LoginTo in tailcore.go for why both
/// steps have to happen inside the core.
AsyncFunction("loginToNewProfile") { (controlUrl: String, authKey: String?, hostname: String?) async throws in
let payload: [String: Any] = [
"controlUrl": controlUrl,
"authKey": authKey ?? "",
"hostname": hostname ?? "",
]
let json = try JSONSerialization.data(withJSONObject: payload)
guard let err = await neCommand("profiles:login:\(json.base64EncodedString())", timeout: 20) else {
throw NSError(domain: "officer-net", code: 2, userInfo: [
NSLocalizedDescriptionKey: "The VPN service did not respond. Connect first, then try again.",
])
}
if !err.isEmpty { throw NSError(domain: "officer-net", code: 1, userInfo: [NSLocalizedDescriptionKey: err]) }
}
// ── Live preferences ────────────────────────────────────────────────────────────────────
//
// Applied with EditPrefs on the running node, so choosing an exit node does not drop the tunnel.
AsyncFunction("setExitNode") { (stableId: String?) async throws in
guard let err = await neCommand("prefs:exitnode:\(stableId ?? "")", timeout: 15) else {
throw NSError(domain: "officer-net", code: 2, userInfo: [
NSLocalizedDescriptionKey: "The VPN service did not respond. Connect first, then try again.",
])
}
if !err.isEmpty { throw NSError(domain: "officer-net", code: 1, userInfo: [NSLocalizedDescriptionKey: err]) }
}
AsyncFunction("setAcceptRoutes") { (on: Bool) async throws in
guard let err = await neCommand("prefs:routes:\(on ? 1 : 0)", timeout: 15) else {
throw NSError(domain: "officer-net", code: 2, userInfo: [
NSLocalizedDescriptionKey: "The VPN service did not respond. Connect first, then try again.",
])
}
if !err.isEmpty { throw NSError(domain: "officer-net", code: 1, userInfo: [NSLocalizedDescriptionKey: err]) }
}
AsyncFunction("prefs") { () async -> String in
return await neCommand("prefs:get") ?? "{}"
}
/*
Collect a pending report about how the LAST tunnel run ended, or "" if it ended cleanly.
Needs a LIVE extension, because the marker lives in the extension's container and this channel is
the only way to it. That is a real limitation: a tunnel that dies and is never started again keeps
its report to itself. Collecting on the next successful connect is the trade — it costs the last
crash on a permanently-broken install and keeps the app from having to hold an App Group
entitlement solely to read one file.
Also a TAKE, not a get: the extension drops it once handed over, so a crash is reported once
rather than on every launch forever.
*/
AsyncFunction("lastExitReport") { () async -> String in
return await neCommand("diag:lastexit") ?? ""
}
AsyncFunction("deleteProfile") { (id: String) async throws in
guard let err = await neCommand("profiles:delete:\(id)", timeout: 15) else {
throw NSError(domain: "officer-net", code: 2, userInfo: [
NSLocalizedDescriptionKey: "The VPN service did not respond. Connect first, then try again.",
])
}
if !err.isEmpty { throw NSError(domain: "officer-net", code: 1, userInfo: [NSLocalizedDescriptionKey: err]) }
}
OnCreate {
// Observe NEVPNStatusDidChange so state transitions (connecting/connected/…) push to JS without
// polling. Peer-level detail comes from the NE via the App Group / status(), emitted on change.
self.statusObserver = NotificationCenter.default.addObserver(
forName: .NEVPNStatusDidChange, object: nil, queue: .main
) { [weak self] _ in
guard let self = self else { return }
Task { self.sendEvent("onStatus", try await self.buildStatus()) }
}
}
OnDestroy {
if let o = self.statusObserver { NotificationCenter.default.removeObserver(o) }
}
// Point the node at a Headscale control server. Stored for the NE to pick up on up(). Does not
// start anything. `hostname` overrides the node's advertised name (defaults to the device name).
AsyncFunction("configure") { (controlUrl: String, hostname: String?) in
let defs = Optional(UserDefaults.standard)
defs?.set(controlUrl, forKey: kControlUrl)
defs?.set(hostname, forKey: kHostname)
// Also stamp it onto the manager's protocol config so the NE has it even before it reads the group.
let mgr = try await self.loadOrCreateManager()
try await self.applyProviderConfig(mgr)
}
// Register the node with a Headscale pre-auth key. We only stash the key here; the actual
// registration happens inside the core on the next up() (the core owns the node/WireGuard keys and
// persists them to the Keychain). The key is single-use and short-lived.
AsyncFunction("loginWithAuthKey") { (authKey: String) in
Optional(UserDefaults.standard)?.set(authKey, forKey: kAuthKey)
let mgr = try await self.loadOrCreateManager()
try await self.applyProviderConfig(mgr)
}
// Whether a node identity already exists on this device. Written by up() in THIS process — the
// NE also writes it, but into a mismatched App Group, so that copy never arrives. See up().
AsyncFunction("isRegistered") { () -> Bool in
Optional(UserDefaults.standard)?.bool(forKey: kRegistered) ?? false
}
/**
Whether the OS has granted this app the right to run a tunnel.
On iOS the grant IS the saved VPN configuration: you cannot write one without the user approving
the system prompt, and removing the profile in Settings deletes it again. So "does a configuration
of ours exist" is exactly the question, and `loadAllFromPreferences` is the authority.
Distinct from `isRegistered`, which asks whether a node identity exists on some tailnet — that can
be true while consent is absent, and vice versa. The first-run flow needs them separate.
ENABLED, not merely present — reversed 2026-08-07.
This used to answer "does a configuration exist", on the reasoning that a user who switched the
VPN off in Settings had still granted permission, and that re-enabling does not prompt again. Both
of those are true, and the result was still wrong: switch the VPN off in iOS Settings and the app
came back saying "you're already set up on this device, turn the switch on to reconnect" over a
tunnel the OS had disabled. Technically accurate, and useless — the one thing that would fix it
was the one thing the screen never offered.
Nothing in this app ever sets `isEnabled = false`; every path here sets it true. So a disabled
configuration means the USER disabled it, in Settings or by letting another VPN take the slot, and
in both cases the honest answer to "are we authorised to run" is no, with Allow VPN as the way
back. Allowing re-enables the existing configuration and does not prompt, exactly as before — the
difference is that the screen now offers it.
Still distinct from `isRegistered`, which asks whether a node identity exists on some tailnet:
that can be true while consent is absent, and vice versa. The first-run flow needs them separate.
Never cache this — a profile can be deleted or disabled at any moment, and a stale `true` leaves a
connect toggle that silently does nothing.
*/
AsyncFunction("isAuthorized") { () async -> Bool in
let all = (try? await NETunnelProviderManager.loadAllFromPreferences()) ?? []
return all.contains { $0.isEnabled }
}
/**
Ask for tunnel authorisation on its own, without connecting.
Saving the configuration is what raises the system prompt (plus Face ID / passcode). A refusal
surfaces as a thrown `NEVPNError`, which is a normal answer here rather than a failure, so it maps
to `false` and the caller simply keeps showing "Allow VPN".
Deliberately does NOT set `onDemandRules` or start the session — that is `up()`'s job. At this
point in the first-run flow no control URL has been chosen, so there is nothing to connect to, and
an on-demand rule here would have the system endlessly retry a tunnel pointed at nowhere.
*/
AsyncFunction("requestAuthorization") { () async -> Bool in
do {
let mgr = try await self.loadOrCreateManager()
try await self.applyProviderConfig(mgr)
mgr.isEnabled = true
try await mgr.saveToPreferences()
try await mgr.loadFromPreferences() // re-load so the handle stays valid after save
return true
} catch {
// Drop the cached manager: it may describe a configuration that was never written.
self.manager = nil
return false
}
}
// Bring the tunnel up: ensure the manager exists + is enabled, then start the NE session. The core
// registers (if needed) and connects inside the NE.
AsyncFunction("up") { () in
let mgr = try await self.loadOrCreateManager()
try await self.applyProviderConfig(mgr)
mgr.isEnabled = true
/*
On-demand: the system keeps the tunnel up, including across reboots.
Without it the VPN comes back DOWN after a restart and stays down until the app is opened and
Connect tapped — which is what Andre hit: sites behind the tailnet failed until he noticed.
A single "connect always" rule. `down()` turns this OFF before stopping, otherwise the system
would immediately bring the tunnel back and a manual disconnect would be unhonourable — off
has to mean off until the user says otherwise.
*/
mgr.onDemandRules = [NEOnDemandRuleConnect()]
mgr.isOnDemandEnabled = true
try await mgr.saveToPreferences()
try await mgr.loadFromPreferences() // re-load so the session handle is valid after save
/*
Start the tunnel, and do NOT fail silently.
This was `try (mgr.connection as? NETunnelProviderSession)?.startTunnel(...)`. Optional chaining
on a failed cast makes the whole statement a no-op that throws nothing, so a tap on the toggle
did exactly nothing, reported nothing, and left the UI on "not connected" — indistinguishable
from a lost registration.
The stale-configuration case is the one that actually bites, and it bites on every reinstall:
iOS keeps the saved configuration but invalidates it against the new build's extension, so
loadAllFromPreferences returns it happily, saveToPreferences reports "unchanged", and
startTunnel then refuses. The recovery is to remove the configuration and write a fresh one —
which is why this retries rather than surfacing an error the user cannot act on.
*/
func start(_ m: NETunnelProviderManager) throws {
guard let session = m.connection as? NETunnelProviderSession else {
throw NSError(domain: "OfficerNet", code: 2, userInfo: [
NSLocalizedDescriptionKey: "VPN configuration is not usable (no provider session)",
])
}
try session.startTunnel(options: nil)
}
/*
Wait out a teardown before starting.
stopVPNTunnel() is asynchronous: it returns at once and the session sits in .disconnecting.
A startTunnel issued in that window is dropped by iOS, silently — which is why switching a
profile (switch, then down, then up) reliably ended DISCONNECTED and needed the toggle pressed
by hand to actually apply.
*/
if mgr.connection.status == .disconnecting {
for _ in 0..<40 {
if mgr.connection.status != .disconnecting { break }
try? await Task.sleep(nanoseconds: 100_000_000)
}
}
do {
try start(mgr)
} catch {
// Rebuild the configuration from scratch and try once more. Anything still failing after this
// is a real error and is allowed to propagate to JS.
try? await mgr.removeFromPreferences()
self.manager = nil
let fresh = try await self.loadOrCreateManager()
try await self.applyProviderConfig(fresh)
fresh.isEnabled = true
fresh.onDemandRules = [NEOnDemandRuleConnect()]
fresh.isOnDemandEnabled = true
try await fresh.saveToPreferences()
try await fresh.loadFromPreferences()
try start(fresh)
}
/*
Remember that this device has a node identity.
The NE writes the same flag into an App Group — but into `group.pastilhas.officer.mobile`,
while the app is entitled to `group.dev.officer.mobile`. Different name, so the write goes
nowhere and `isRegistered` always answered false. The app then showed the enrolment form and
asked for another pre-auth key, on a device whose identity was already valid and persisted:
Andre was minting Headscale keys to answer a question the app was wrong about.
Recorded here, in the app's own storage, because the honest fix needs App Groups enabled on
dev.officer.mobile.net in the Apple portal and a wrong move there breaks code-signing for the
whole app. See _local_todos/OFFTAIL.md.
"startTunnel did not throw" is a slightly weaker claim than "the core has an identity", but the
core only gets that far with a usable key or an existing identity, so in practice they coincide.
*/
UserDefaults.standard.set(true, forKey: kRegistered)
}
/*
Bring the tunnel down and KEEP it down. Node identity is untouched.
On-demand is disabled first and saved before stopping: with it still on, the system treats a
stopped tunnel as something to fix and reconnects within seconds, so a manual disconnect would
not stick. Turning it off is what makes "off" mean off until the user reconnects — and it
survives reboots the same way "on" does.
*/
AsyncFunction("down") { () in
let mgr = try await self.loadOrCreateManager()
mgr.isOnDemandEnabled = false
try? await mgr.saveToPreferences()
mgr.connection.stopVPNTunnel()
}
// Current status snapshot: connection state from the manager + self/peer detail from the NE.
AsyncFunction("status") { () async throws -> [String: Any?] in
try await self.buildStatus()
}
// Debug: the NE's diagnostic ring buffer (bring-up flow), fetched via provider message. Reliable —
// unlike idevicesyslog, which drops NE os_log lines under burst load.
AsyncFunction("coreDiag") { () async -> String in
if self.manager == nil {
self.manager = try? await NETunnelProviderManager.loadAllFromPreferences().first
}
guard let session = self.manager?.connection as? NETunnelProviderSession else { return "(no session)" }
return await self.neDiag(session)
}
// Forget the node identity + keys (full reset): tear down, tell the NE to wipe the core's keychain
// identity, remove the manager, and clear stored config.
AsyncFunction("logout") { () in
/*
Order matters, and getting it wrong is what made logout cosmetic.
The node identity is `tailscaled.state` in the EXTENSION's container — nothing in this process
can reach it, so removing the VPN configuration and our own UserDefaults left a fully valid
node on disk. Logging out and reinstalling came back as the same machine.
So: ask the running extension to wipe it FIRST, while there is still a session to talk to, and
only then stop the tunnel and remove the configuration. Tearing the tunnel down first would
kill the only channel that can do the deletion.
*/
if let mgr = try? await self.loadOrCreateManager() {
/*
The identity can only be deleted by the extension, and the extension only exists while the
tunnel runs. Logging out from a DISCONNECTED state therefore wiped nothing — and because the
app-side flags below clear regardless, the UI showed a convincing first run over an identity
that was still on disk. The next "fresh" sign-in then reconnected instantly as the same node.
So bring the tunnel up briefly, purely to have someone to give the order to. Ugly, but the
alternative is a reset that quietly does not reset, which is worse than slow.
*/
let status = mgr.connection.status
if status != .connected && status != .connecting {
try? await self.applyProviderConfig(mgr)
mgr.isEnabled = true
// No on-demand rule here: this tunnel exists to be torn down a second later, and an
// on-demand rule would have the system dutifully bring it straight back.
mgr.isOnDemandEnabled = false
try? await mgr.saveToPreferences()
try? await mgr.loadFromPreferences()
try? (mgr.connection as? NETunnelProviderSession)?.startTunnel(options: nil)
// Up to ~5s. A node with a stored identity needs no network to reach `connected`, and the
// wipe only needs the process alive, not a working tailnet.
for _ in 0..<20 {
if mgr.connection.status == .connected || mgr.connection.status == .disconnected { break }
try? await Task.sleep(nanoseconds: 250_000_000)
}
}
_ = await self.neCommand("wipe")
mgr.connection.stopVPNTunnel()
try? await mgr.removeFromPreferences()
}
self.manager = nil
let defs = Optional(UserDefaults.standard)
[kControlUrl, kHostname, kAuthKey, kRegistered].forEach { defs?.removeObject(forKey: $0) }
}
}
// MARK: - NETunnelProviderManager plumbing
/// Load the existing OffTail manager or create a fresh one. There is at most one per app.
private func loadOrCreateManager() async throws -> NETunnelProviderManager {
if let m = manager { return m }
let all = try await NETunnelProviderManager.loadAllFromPreferences()
let m = all.first ?? NETunnelProviderManager()
manager = m
return m
}
/// Write the NETunnelProviderProtocol (which NE target + control server) and providerConfiguration
/// (controlUrl/hostname/authKey the NE reads at startTunnel). serverAddress is display-only for iOS.
private func applyProviderConfig(_ mgr: NETunnelProviderManager) async throws {
let defs = Optional(UserDefaults.standard)
let controlUrl = defs?.string(forKey: kControlUrl) ?? ""
let proto = (mgr.protocolConfiguration as? NETunnelProviderProtocol) ?? NETunnelProviderProtocol()
proto.providerBundleIdentifier = neBundleId
// Display-only on iOS: the NE gets the real control URL through providerConfiguration below, so
// this string is free to be branding rather than a hostname. It is the likelier of the two
// candidates for the second line iOS shows under the VPN entry in Settings.
proto.serverAddress = "Officer Suite"
var cfg: [String: Any] = [:]
if let v = defs?.string(forKey: kControlUrl) { cfg[kControlUrl] = v }
if let v = defs?.string(forKey: kHostname) { cfg[kHostname] = v }
if let v = defs?.string(forKey: kAuthKey) { cfg[kAuthKey] = v }
proto.providerConfiguration = cfg
mgr.protocolConfiguration = proto
// The name iOS shows for this VPN configuration in Settings. Was "OffTail", which predates the
// rename and is the string users actually see in Settings > General > VPN.
mgr.localizedDescription = "OffScale"
}
/// Map the NE connection state to the JS VpnState, and merge in self/peer detail the NE publishes to
/// the App Group. The rich peer list originates from the Tailscale core inside the NE.
private func buildStatus() async throws -> [String: Any?] {
// Ensure we're reading the live system VPN state even after an app relaunch / backgrounding, when
// `self.manager` may not be cached yet — otherwise status falsely reads "stopped" while connected.
if manager == nil {
manager = try? await NETunnelProviderManager.loadAllFromPreferences().first
}
let state: String
switch manager?.connection.status ?? .invalid {
case .connecting, .reasserting: state = "connecting"
case .connected: state = "connected"
case .disconnecting, .disconnected, .invalid: state = "stopped"
@unknown default: state = "stopped"
}
// Pull self/peer detail straight from the core inside the NE via a provider message — this works
// cross-process without an App Group (the NE's handleAppMessage returns TailcoreStatus() JSON, in
// Tailscale's ipnstate shape: Self.TailscaleIPs / Self.DNSName / Peer{}). Only meaningful once the
// tunnel is up.
var selfNode: [String: Any?]? = nil
var peers: [[String: Any?]] = []
var authUrl: String? = nil
var backendState = state
// Kept outside the block below so the return value can read the exit-node health from it.
var coreStatus: [String: Any]? = nil
if state == "connected" || state == "connecting",
let session = manager?.connection as? NETunnelProviderSession,
let fetched = await neCoreStatus(session) {
coreStatus = fetched
let core = fetched
if let s = core["Self"] as? [String: Any] {
let ips = (s["TailscaleIPs"] as? [String]) ?? []
// MagicDNS names arrive fully qualified with a trailing dot ("host.tailnet.ts.net.") — correct
// as DNS, but it reads like a typo in a UI.
let dns = (s["DNSName"] as? String).map { $0.hasSuffix(".") ? String($0.dropLast()) : $0 }
let host = s["HostName"] as? String
/*
The account this node was registered under.
`ipnstate.Status` carries a `User` map keyed by the numeric user id, and `Self.UserID` points
into it — so the login email has been in this JSON all along, just never read. It is the one
piece that answers "which account is this?", which matters as soon as more than one tailnet
can be signed in.
*/
var login: String?
let uid = (s["UserID"] as? NSNumber)?.stringValue ?? (s["UserID"] as? String)
if let uid, let users = core["User"] as? [String: Any],
let u = users[uid] as? [String: Any] {
login = (u["LoginName"] as? String) ?? (u["DisplayName"] as? String)
}
let tailnet = (core["CurrentTailnet"] as? [String: Any])?["Name"] as? String
/*
The other machines on the tailnet.
`peers` was declared and returned empty from the day this was written, so the peer list in the
UI — tap to copy an IP, long-press for the MagicDNS name — has never had anything to render.
ipnstate.Status keys them by public key under "Peer".
ExitNodeOption / ExitNode ride along because picking an exit node needs both: which peers offer
to be one, and which one is currently in use.
*/
if let peerMap = core["Peer"] as? [String: Any] {
peers = peerMap.values.compactMap { raw in
guard let p = raw as? [String: Any] else { return nil }
let ips = (p["TailscaleIPs"] as? [String]) ?? []
let dns = (p["DNSName"] as? String).map { $0.hasSuffix(".") ? String($0.dropLast()) : $0 }
let host = p["HostName"] as? String
return [
// StableID is the durable identifier; the public key is the map key and changes on rekey.
"id": (p["ID"] as? String) ?? dns ?? host ?? UUID().uuidString,
"name": (dns ?? host ?? "") as Any?,
"hostName": host as Any?,
"ips": ips,
"online": (p["Online"] as? Bool) ?? false,
"os": p["OS"] as Any?,
"lastSeen": p["LastSeen"] as Any?,
"exitNodeOption": (p["ExitNodeOption"] as? Bool) ?? false,
"exitNode": (p["ExitNode"] as? Bool) ?? false,
]
}
// Online first, then by name: a tailnet of any size is unreadable in map order, which is
// whatever the JSON decoder produced.
.sorted {
let (a, b) = (($0["online"] as? Bool) ?? false, ($1["online"] as? Bool) ?? false)
if a != b { return a }
return (($0["name"] as? String) ?? "") .localizedCaseInsensitiveCompare(($1["name"] as? String) ?? "") == .orderedAscending
}
}
/*
When does this device's key expire, and has it already?
A node key expires on the tailnet's own schedule — often 180 days — and when it does the
device silently stops working until re-authorised. Reporting it lets the app warn first
rather than presenting expiry as a mysterious disconnection.
*/
let keyExpiry = s["KeyExpiry"] as? String
let expired = (s["Expired"] as? Bool) ?? false
selfNode = [
"keyExpiry": keyExpiry as Any?,
"expired": expired,
// Prefer the short host name for display; the fully-qualified one is still available.
"name": (host ?? dns) as Any?,
"magicDNSName": dns as Any?,
"ips": ips,
"hostName": host as Any?,
"os": s["OS"] as Any?,
"loginName": login as Any?,
"tailnet": tailnet as Any?,
]
}
// The NE being up says nothing about whether the NODE is authorised. `state` above comes from
// NEVPNConnection, which reports "connected" the moment the tunnel exists — while the core can
// still be sitting in NeedsLogin waiting for a human. Android reads BackendState directly and
// so must this, or interactive login is invisible on iOS.
/*
Every backend state is mapped, not just NeedsLogin.
Handling only NeedsLogin left `Starting` and `NoState` falling through as "connected", which is
the window right after startTunnel — the tunnel exists, the core has not finished coming up, and
nothing is authorised. The UI showed a connected toggle with no data for several seconds, then
jumped to a sign-in prompt. From the outside that reads as "it connected, then changed its mind".
`Running` is the only state that earns "connected".
*/
switch core["BackendState"] as? String {
case "NeedsLogin":
backendState = "needsLogin"
// Straight from ipnstate.Status.AuthURL — already in this JSON, never read until now.
authUrl = (core["AuthURL"] as? String).flatMap { $0.isEmpty ? nil : $0 }
case "Running":
backendState = "connected"
case "Starting", "NoState", "Stopped":
backendState = "connecting"
default:
break // unknown state: leave the NE-derived value rather than inventing one
}
}
return [
"state": backendState,
"self": selfNode as Any?,
"peers": peers,
"error": nil,
"authUrl": authUrl as Any?,
// Which control plane this node is talking to. The app knows it because it configured it, but
// sending it with the status keeps "who am I connected to" answerable from one object rather
// than correlating two — and it is what distinguishes Tailscale from a self-hosted Headscale.
"controlUrl": Optional(UserDefaults.standard)?.string(forKey: kControlUrl) as Any?,
/*
The exit node's own health, straight from the core.
When an exit node goes offline the traffic stops rather than escaping around the tunnel — the
safe failure — but the app went on saying "Connected" with no explanation. Reported so the
screen can say what is actually happening.
*/
"exitNodeOnline": (coreStatus?["ExitNodeStatus"] as? [String: Any])?["Online"] as Any?,
/*
Our configuration exists but iOS has deselected it — which is what happens when another VPN
app (the official Tailscale client, say) takes the slot, since only one can be active.
Reported so the UI can SAY that. Without it, being superseded looks identical to a plain
failure to connect: the toggle sits at "Not connected" with no reason given, and the honest
explanation — something else is using the tunnel — is invisible. Last-connected-wins is fine
as a rule; silently losing is not.
*/
"supersededByOtherVpn": (manager != nil && manager?.isEnabled == false),
]
}
/// Ask the running NE for the core's status JSON (self IPs + backend state) via a provider message.
/// Returns the decoded ipnstate object, or nil if the tunnel isn't up / doesn't answer promptly.
/**
Send a raw command to the NE and get its reply as a string.
The generalisation of `neCoreStatus`, needed because the core lives in the extension: profile
operations act on the running LocalBackend, and the app process does not have one. Calling
`TailcoreListProfiles()` here would compile and return "[]" forever.
*/
/// - Parameter timeout: 1.5s suits a status read. Profile MUTATIONS need far longer — creating or
/// switching a profile makes the backend re-key and fetch a fresh netmap, which does not finish in
/// a second and a half. Timing out there returns nil, and nil used to be read as success.
private func neCommand(_ command: String, timeout: TimeInterval = 1.5) async -> String? {
guard let session = manager?.connection as? NETunnelProviderSession else { return nil }
return await withCheckedContinuation { (cont: CheckedContinuation<String?, Never>) in
let resumed = ResumeOnce()
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { if resumed.take() { cont.resume(returning: nil) } }
do {
try session.sendProviderMessage(Data(command.utf8)) { resp in
guard resumed.take() else { return }
cont.resume(returning: resp.flatMap { String(data: $0, encoding: .utf8) })
}
} catch {
if resumed.take() { cont.resume(returning: nil) }
}
}
}
private func neCoreStatus(_ session: NETunnelProviderSession) async -> [String: Any]? {
await withCheckedContinuation { (cont: CheckedContinuation<[String: Any]?, Never>) in
let resumed = ResumeOnce()
// Guard against a hung continuation if the NE never calls back.
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { if resumed.take() { cont.resume(returning: nil) } }
do {
try session.sendProviderMessage(Data("status".utf8)) { resp in
guard resumed.take() else { return }
if let resp = resp, let obj = try? JSONSerialization.jsonObject(with: resp) as? [String: Any] {
cont.resume(returning: obj)
} else {
cont.resume(returning: nil)
}
}
} catch {
if resumed.take() { cont.resume(returning: nil) }
}
}
}
/// Ask the running NE for its diagnostic ring buffer (a plain string) via a provider message.
private func neDiag(_ session: NETunnelProviderSession) async -> String {
await withCheckedContinuation { (cont: CheckedContinuation<String, Never>) in
let resumed = ResumeOnce()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { if resumed.take() { cont.resume(returning: "(diag timeout)") } }
do {
try session.sendProviderMessage(Data("diag".utf8)) { resp in
guard resumed.take() else { return }
cont.resume(returning: resp.flatMap { String(data: $0, encoding: .utf8) } ?? "(no diag)")
}
} catch {
if resumed.take() { cont.resume(returning: "(diag err: \(error.localizedDescription))") }
}
}
}
}