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.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# Vendored gomobile Tailscale core (libtailscale) — built/fetched by scripts/build-libtailscale-*.sh
|
||||
# and pinned to a Headscale-compatible tag. Track the Swift/Kotlin bridge + gradle + plugin, but NOT
|
||||
# the binaries (mirrors how bw-vault gitignores its vendored Bitwarden SDK). See README.md.
|
||||
libs/
|
||||
ios/vendor/
|
||||
android/build/
|
||||
android/libs/
|
||||
android/src/main/jniLibs/
|
||||
@@ -0,0 +1,133 @@
|
||||
# officer-net — the OffScale VPN engine (native)
|
||||
|
||||
The native half of an in-app Tailscale client. It embeds the **Tailscale core** (Go, via `gomobile`)
|
||||
behind the OS VPN APIs — **iOS `NEPacketTunnelProvider`** and **Android `VpnService`** — and talks
|
||||
directly to a control server: your own **Headscale**, or Tailscale's.
|
||||
|
||||
It contacts nothing else. There is no backend, no telemetry and no account layer anywhere in this
|
||||
package; the data path is the mesh, and the only host it reaches is the control URL it was configured
|
||||
with. Node/WireGuard keys are generated and persisted by the core in the **Keychain / Keystore** and
|
||||
never cross into Swift, Kotlin or JS.
|
||||
|
||||
The JS side is one line — `requireNativeModule('OfficerNet')`, in `src/vpn/officer-net.ts` at the repo
|
||||
root. This package is Swift + Kotlin + Go + the Expo config plugin + the two build scripts.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
officer-net/
|
||||
package.json index.ts expo-module.config.json app.plugin.js .gitignore
|
||||
gocore/ # the Go shim bound by gomobile
|
||||
tailcore.go # the whole exported surface (profiles, up/down, prefs, status)
|
||||
dns.go protect.go maketun_apple.go maketun_droid.go interfaces_droid.go
|
||||
go.mod go.sum # pins tailscale.com — see ../../NOTICE
|
||||
ios/
|
||||
OfficerNet.podspec # vendors libs/TailCore.xcframework, compiles the Swift below
|
||||
OfficerNetModule.swift # RN process: NETunnelProviderManager config + commands
|
||||
PacketTunnelProvider.swift # NE target: hosts the core, owns packetFlow
|
||||
android/
|
||||
build.gradle
|
||||
src/main/AndroidManifest.xml
|
||||
src/main/java/expo/modules/officernet/
|
||||
OfficerNetModule.kt # RN process: prepare()/start/stop/status
|
||||
OfficerVpnService.kt # VpnService: builds the tun, hands the fd to the core
|
||||
BootReceiver.kt # reconnect-on-boot
|
||||
plugin/
|
||||
app.plugin.js # entitlements, App Group, Android service, iOS NE target
|
||||
withNetworkExtensionTarget.js # synthesises the NE Xcode target at prebuild
|
||||
README.md
|
||||
scripts/
|
||||
build-libtailscale-ios.sh # gomobile bind → ios/libs/TailCore.xcframework
|
||||
build-libtailscale-android.sh # gomobile bind → android/libs/tailcore.aar
|
||||
libs/ ios/libs/ android/libs/ # the built core (gitignored — see below)
|
||||
```
|
||||
|
||||
## The built core is NOT in the repo, and NOT rebuilt by an app build
|
||||
|
||||
`gomobile bind` output is tens of megabytes per platform, so it is gitignored and produced on the
|
||||
machine that builds the app. **Nothing rebuilds it automatically.** Changing anything under `gocore/`
|
||||
means running the script for each platform you are about to build:
|
||||
|
||||
```bash
|
||||
PATH="$PATH:$HOME/go/bin" modules/officer-net/scripts/build-libtailscale-ios.sh # a few minutes
|
||||
PATH="$PATH:$HOME/go/bin" modules/officer-net/scripts/build-libtailscale-android.sh
|
||||
```
|
||||
|
||||
Forgetting is silent until much later, and the error names a symbol rather than the cause:
|
||||
|
||||
| platform | how it fails | when |
|
||||
|----------|-------------------------------------------------------|---------------------|
|
||||
| Android | `e: Unresolved reference 'loginTo'` | Kotlin compile — loud and early |
|
||||
| iOS | `error: cannot find 'TailcoreListProfiles' in scope` | Swift compile — long after the Go compiled cleanly |
|
||||
|
||||
The repo's `scripts/ensure-core.sh` compares mtimes and runs the right script for you before each app
|
||||
build, which is why the two build scripts source it. Confirm the symbols landed if you are debugging:
|
||||
|
||||
```bash
|
||||
grep Tailcore modules/officer-net/ios/libs/TailCore.xcframework/ios-arm64/Tailcore.framework/Headers/*.h
|
||||
```
|
||||
|
||||
Requires Go plus gomobile on `PATH`:
|
||||
|
||||
```bash
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
go install golang.org/x/mobile/cmd/gobind@latest
|
||||
```
|
||||
|
||||
`TAILSCALE_REF` at the top of each script pins the Tailscale version. Keep it paired with the Headscale
|
||||
release you run — Headscale implements a specific client protocol version, and a core built far ahead
|
||||
of it will fail to register.
|
||||
|
||||
## Native surface
|
||||
|
||||
Mirrored one-for-one by `src/vpn/officer-net.ts`; that file is the readable index of it. Roughly:
|
||||
`configure(controlUrl, hostname?)`, `loginWithAuthKey`, `loginTo` (add a second tailnet in one core
|
||||
operation), `isRegistered`, `up`/`down`, `status`, `prefs`, `setExitNode`, `setAcceptRoutes`, profile
|
||||
list/switch/delete, `logout`, and an `onStatus` event carrying `{ state, self?, peers, error? }`.
|
||||
|
||||
## App wiring
|
||||
|
||||
The config plugin is enabled from `app.json` at the repo root:
|
||||
|
||||
```js
|
||||
plugins: [["officer-net", {
|
||||
appGroup: "group.pastilhas.officer.offscale",
|
||||
neBundleId: "dev.officer.offscale.net",
|
||||
enableNetworkExtension: true,
|
||||
}]]
|
||||
```
|
||||
|
||||
It adds the iOS Network Extensions entitlement, synthesises the NE Xcode target, and registers the
|
||||
Android `VpnService`. See `plugin/README.md`.
|
||||
|
||||
## Apple provisioning prerequisites (one-time)
|
||||
|
||||
- An **App ID for the extension** (`dev.officer.offscale.net` by default) with the **Network
|
||||
Extensions** capability.
|
||||
- The **Network Extensions** capability on the main app ID.
|
||||
- Provisioning profiles for both.
|
||||
|
||||
`-allowProvisioningUpdates` cannot create these while Xcode has no account signed in — it falls back to
|
||||
the wildcard team profile and fails with *"Provisioning profile … doesn't include the Network
|
||||
Extensions capability"*. Sign in under Xcode → Settings → Accounts first. Android has no equivalent
|
||||
gate.
|
||||
|
||||
## Notes and traps
|
||||
|
||||
- **iOS jetsams a packet tunnel over roughly 50 MB**, so the core must run WireGuard in userspace
|
||||
(netstack). See the header comment in `PacketTunnelProvider.swift`. Crashes are *inferred* rather
|
||||
than caught — no handler runs — via a start marker that is still `running` at the next start.
|
||||
- **`neBundleId` must come from `OfficerNetExtensionBundleId` in `Info.plist`**, never a constant. A
|
||||
hardcoded value asks iOS to launch an extension the binary does not contain: the start command is
|
||||
accepted, nothing matches, and the toggle silently does nothing.
|
||||
- **Android protects the core's sockets per-socket** (`gocore/protect.go` registers
|
||||
`netns.SetAndroidProtectFunc`), not by excluding the whole app from its own tunnel. `gomobile`
|
||||
generates `boolean protect(int)` from `tailcore.Protector` — the same signature as
|
||||
`VpnService.protect(int)` — so a service implementing the interface directly would have its override
|
||||
call itself forever. It delegates through `CoreProtector`.
|
||||
- **Logging out needs a RUNNING extension.** The identity is `tailscaled.state` inside the extension's
|
||||
container (Android: `filesDir`), unreachable from the app process, so `logout()` starts the tunnel
|
||||
briefly just to issue the wipe.
|
||||
- **Reinstalling invalidates the saved VPN configuration.** iOS hands it back from
|
||||
`loadAllFromPreferences`, reports "unchanged" on save, then refuses to start it. `up()` removes and
|
||||
rewrites it on failure, which is self-healing.
|
||||
@@ -0,0 +1,31 @@
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
group = 'expo.modules.officernet'
|
||||
version = '0.1.0'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
apply from: expoModulesCorePlugin
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useCoreDependencies()
|
||||
useDefaultAndroidSdkVersions()
|
||||
useExpoPublishing()
|
||||
|
||||
android {
|
||||
namespace "expo.modules.officernet"
|
||||
defaultConfig {
|
||||
minSdkVersion 24
|
||||
}
|
||||
// The gomobile-built Tailscale core (libtailscale) ships as officernet.aar → exploded into
|
||||
// libs/officernet.jar (Kotlin/Java bindings) + src/main/jniLibs/<abi>/libgojni.so (the Go runtime),
|
||||
// by scripts/build-libtailscale-android.sh. jniLibs/<abi> is auto-picked up from src/main/jniLibs.
|
||||
// arm64-v8a at minimum; add x86_64 for the emulator. All gitignored (see ../.gitignore).
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The vendored gomobile Tailscale core bindings (tailcore.Tailcore). `api` so the consuming app links
|
||||
// the types too. Produced by scripts/build-libtailscale-android.sh (aar → libs/tailcore.jar +
|
||||
// src/main/jniLibs/arm64-v8a/libgojni.so). Gitignored; present after that script runs.
|
||||
api files('libs/tailcore.jar')
|
||||
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1'
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<!-- The FOREGROUND_SERVICE / VpnService permissions + the <service> registration are injected into the
|
||||
consuming app's manifest by the Expo config plugin (plugin/app.plugin.js → withAndroidManifest), so
|
||||
they can be toggled via plugin props. This module manifest stays empty, mirroring bw-vault. -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"/>
|
||||
@@ -0,0 +1,55 @@
|
||||
package expo.modules.officernet
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Bring the tunnel back after a reboot — but only if the user had it on.
|
||||
*
|
||||
* Android's counterpart to iOS's on-demand rules. Without this the VPN comes back DOWN after a
|
||||
* restart and stays down until someone opens the app and taps Connect, which is silent and easy to
|
||||
* miss: sites behind the tailnet simply fail while everything else works.
|
||||
*
|
||||
* `KEY_WANT_CONNECTED` is the user's intent, not the tunnel's state. `up()` sets it, `down()` clears
|
||||
* it, and only `down()` clears it — a crash, a reboot or the system killing the service all leave it
|
||||
* set, so those recover. A deliberate disconnect stays disconnected until the user says otherwise.
|
||||
*
|
||||
* `VpnService.prepare` is re-checked because consent can be revoked while the phone is off (another
|
||||
* VPN taking over, for instance). It returns non-null when consent is needed, and that needs an
|
||||
* activity we do not have at boot — so we stand down rather than crash, and the next manual connect
|
||||
* will show the dialog properly.
|
||||
*/
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
// BOOT_COMPLETED only. LOCKED_BOOT_COMPLETED fires before the user has unlocked, when there is
|
||||
// no network and our prefs live in credential-encrypted storage we cannot even read yet.
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
|
||||
val prefs = context.getSharedPreferences("officer_net", Context.MODE_PRIVATE)
|
||||
if (!prefs.getBoolean(KEY_WANT_CONNECTED, false)) {
|
||||
Log.i(TAG, "boot: user had the tunnel off — leaving it off")
|
||||
return
|
||||
}
|
||||
if (!prefs.getBoolean(KEY_REGISTERED, false)) {
|
||||
Log.i(TAG, "boot: no node identity yet — nothing to reconnect")
|
||||
return
|
||||
}
|
||||
if (VpnService.prepare(context) != null) {
|
||||
Log.w(TAG, "boot: VPN consent not granted; a manual connect is needed")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(TAG, "boot: reconnecting")
|
||||
context.startForegroundService(
|
||||
Intent(context, OfficerVpnService::class.java).setAction(ACTION_UP),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "OfficerVpnBoot"
|
||||
}
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
package expo.modules.officernet
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import expo.modules.kotlin.Promise
|
||||
import tailcore.Tailcore
|
||||
|
||||
// OffTail VPN engine — Android RN-process module. Parity with ios/OfficerNetModule.swift and the JS
|
||||
// bridge in packages/core/src/services/officer-net.ts (requireNativeModule('OfficerNet')).
|
||||
//
|
||||
// Unlike iOS (a separate NE process), Android's VpnService runs in THIS app's process — so this module
|
||||
// binds/starts OfficerVpnService directly. The Tailscale core (libtailscale, gomobile .aar) is hosted
|
||||
// inside OfficerVpnService, which owns the tun fd. This module just: persists config, drives the
|
||||
// one-time system consent (VpnService.prepare), and starts/stops the service.
|
||||
//
|
||||
// The core call-sites (register/up/status via the gomobile bindings) live in OfficerVpnService.kt.
|
||||
|
||||
private const val PREFS = "officer_net"
|
||||
const val KEY_CONTROL_URL = "controlUrl"
|
||||
const val KEY_HOSTNAME = "hostname"
|
||||
const val KEY_AUTH_KEY = "authKey" // single-use Headscale pre-auth key; consumed by the core on up().
|
||||
const val KEY_REGISTERED = "registered" // set by the service once the core has a persisted identity.
|
||||
/** The USER's intent, not the tunnel's state — only down() clears it, so crashes and reboots recover. */
|
||||
const val KEY_WANT_CONNECTED = "wantConnected"
|
||||
|
||||
const val ACTION_UP = "dev.officer.mobile.net.UP"
|
||||
const val ACTION_DOWN = "dev.officer.mobile.net.DOWN"
|
||||
|
||||
// Request code for the VpnService.prepare() consent activity result, reached via up().
|
||||
private const val VPN_PREPARE_REQUEST = 0xF00D
|
||||
|
||||
// Request code for consent asked for ON ITS OWN, by requestAuthorization(), before any tailnet is
|
||||
// chosen. It has to be distinct from the one above: that path starts the tunnel as soon as consent
|
||||
// lands, and here there is nothing to start — no control URL has been entered yet.
|
||||
private const val VPN_AUTHORIZE_REQUEST = 0xF00E
|
||||
|
||||
class OfficerNetModule : Module() {
|
||||
private val context: Context
|
||||
get() = requireNotNull(appContext.reactContext) { "React context unavailable" }
|
||||
|
||||
private fun prefs() = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Forget the cached node IP after anything that changes which tailnet we are on.
|
||||
*
|
||||
* The cached address is per-DEVICE but describes the CURRENT tailnet, and startTunnel trusts it to
|
||||
* build the tun up front. After a switch it belongs to the network just left, so the tun comes up
|
||||
* holding an address from the wrong tailnet — peers become unreachable even by IP, which reads as a
|
||||
* routing or server fault rather than a stale cache.
|
||||
*
|
||||
* iOS has the same fix in the extension (forgetCachedNetmap); this is its Android counterpart, and
|
||||
* it was missed when the rest of that family was closed.
|
||||
*/
|
||||
private fun forgetCachedNodeIp() {
|
||||
// The exit node goes with it: it is a peer on the tailnet being left, so keeping it would claim
|
||||
// the default route for a machine that is not on the new network.
|
||||
prefs().edit().remove("nodeIpv4").remove("nodeIpv6").remove(KEY_EXIT_NODE).apply()
|
||||
}
|
||||
|
||||
// Pending up() promise, resolved after the user answers the consent dialog (see OnActivityResult).
|
||||
private var pendingUp: Promise? = null
|
||||
|
||||
// Pending requestAuthorization() promise. Separate from pendingUp so the two flows cannot resolve
|
||||
// each other's caller — they answer different questions and only one of them starts the tunnel.
|
||||
private var pendingAuthorize: Promise? = null
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("OfficerNet")
|
||||
|
||||
// Native → JS status/peer stream. Emitted by OfficerVpnService via the module (see emitStatus()).
|
||||
Events("onStatus")
|
||||
|
||||
OnCreate {
|
||||
// OfficerVpnService pushes status snapshots here so onStatus/status() can carry peer detail.
|
||||
OfficerVpnService.statusSink = { snapshot -> this@OfficerNetModule.sendEvent("onStatus", snapshot) }
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
OfficerVpnService.statusSink = null
|
||||
}
|
||||
|
||||
// Point the node at a Headscale control server. Stored for the service to read on up(). No I/O.
|
||||
AsyncFunction("configure") { controlUrl: String, hostname: String? ->
|
||||
prefs().edit()
|
||||
.putString(KEY_CONTROL_URL, controlUrl)
|
||||
.putString(KEY_HOSTNAME, hostname)
|
||||
.apply()
|
||||
}
|
||||
|
||||
// Register the node with a Headscale pre-auth key. We only stash it; the core consumes it on the
|
||||
// next up() (the core owns + persists the node/WireGuard keys in the Keystore).
|
||||
AsyncFunction("loginWithAuthKey") { authKey: String ->
|
||||
prefs().edit().putString(KEY_AUTH_KEY, authKey).apply()
|
||||
}
|
||||
|
||||
// The embedded Tailscale core version, from the vendored gomobile .aar (tailcore.Tailcore).
|
||||
AsyncFunction("coreVersion") {
|
||||
Tailcore.version()
|
||||
}
|
||||
|
||||
// ── Profiles ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Several tailnets on one device. The core has always supported it; these four just expose it.
|
||||
// All of them require the node to be started, because they operate on the running LocalBackend —
|
||||
// calling them while the tunnel is down returns "not started" from Go rather than crashing.
|
||||
|
||||
/** Every stored login as JSON, with the active one flagged. */
|
||||
AsyncFunction("listProfiles") {
|
||||
Tailcore.listProfiles()
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an existing login active.
|
||||
*
|
||||
* The tun is NOT recycled here: the node re-registers under the new identity and the netmap —
|
||||
* and therefore the IPs and routes — change underneath it. The caller brings the tunnel down and
|
||||
* up around this, which is also what makes the UI honest about what is happening.
|
||||
*/
|
||||
AsyncFunction("switchProfile") { id: String ->
|
||||
Tailcore.switchProfile(id)
|
||||
forgetCachedNodeIp()
|
||||
}
|
||||
|
||||
/** Begin an additional login. Lands in NeedsLogin, so status().authUrl carries where to go. */
|
||||
AsyncFunction("newProfile") {
|
||||
Tailcore.newProfile()
|
||||
}
|
||||
|
||||
/**
|
||||
* Join ANOTHER tailnet on the live backend, without recycling the tunnel.
|
||||
*
|
||||
* newProfile() alone cannot do it. It creates a blank profile, but the control URL and pre-auth
|
||||
* key only reach the core through Start — and restarting to trigger that LOSES the blank profile,
|
||||
* because an unregistered one is not persisted: the reload falls back to the previous profile and
|
||||
* Start overwrites ITS control URL. The second tailnet then silently replaces the first, keeping
|
||||
* the same profile id. Doing both inside the core is the only ordering where neither step undoes
|
||||
* the other. See LoginTo in gocore/tailcore.go.
|
||||
*/
|
||||
AsyncFunction("loginToNewProfile") { controlUrl: String, authKey: String?, hostname: String? ->
|
||||
Tailcore.loginTo(controlUrl, authKey ?: "", hostname ?: "")
|
||||
// Keep the app's own record in step, or the next tunnel start hands the core the PREVIOUS
|
||||
// tailnet's URL — the same global-vs-per-profile trap that put a Tailscale node on Headscale.
|
||||
prefs().edit().putString(KEY_CONTROL_URL, controlUrl).putString(KEY_HOSTNAME, hostname).apply()
|
||||
// The new tailnet issues its own address; the cached one belongs to the previous network.
|
||||
forgetCachedNodeIp()
|
||||
Unit
|
||||
}
|
||||
|
||||
// ── Live preferences ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// EditPrefs on the running node, so choosing an exit node does not drop the tunnel.
|
||||
|
||||
/**
|
||||
* Route all traffic through a peer. Empty id turns it off.
|
||||
*
|
||||
* The choice is also written to prefs, because the TUN has to be built differently for it — an
|
||||
* exit node needs the default route, not just the tailnet ranges. The service watches this and
|
||||
* re-establishes; setting the core's preference alone changes nothing observable.
|
||||
*/
|
||||
AsyncFunction("setExitNode") { stableId: String? ->
|
||||
Tailcore.setExitNode(stableId ?: "")
|
||||
prefs().edit().putString(KEY_EXIT_NODE, stableId ?: "").apply()
|
||||
}
|
||||
|
||||
/** Use subnet routes advertised by other nodes. Off by default in Tailscale. */
|
||||
AsyncFunction("setAcceptRoutes") { on: Boolean ->
|
||||
Tailcore.setAcceptRoutes(on)
|
||||
}
|
||||
|
||||
/** Live prefs as JSON — what IS set, not what was last asked for. */
|
||||
AsyncFunction("prefs") {
|
||||
Tailcore.prefs()
|
||||
}
|
||||
|
||||
/*
|
||||
Collect a pending report about how the LAST tunnel run ended, or "" if it ended cleanly.
|
||||
|
||||
Read straight off disk rather than through the service: the file is in the app's own filesDir, and
|
||||
requiring a running service would mean the one case worth reporting — a tunnel that died and was
|
||||
never restarted — is the case that can never be read. iOS has no such luxury and must ask the
|
||||
extension.
|
||||
|
||||
A TAKE, not a get: the report is cleared once handed over, so a crash is reported once.
|
||||
*/
|
||||
AsyncFunction("lastExitReport") {
|
||||
val f = java.io.File(context.filesDir, "last-exit.json")
|
||||
if (!f.exists()) return@AsyncFunction ""
|
||||
runCatching {
|
||||
val obj = org.json.JSONObject(f.readText())
|
||||
val previous = obj.optJSONObject("previous") ?: return@runCatching ""
|
||||
obj.put("previous", org.json.JSONObject.NULL)
|
||||
f.writeText(obj.toString())
|
||||
previous.toString()
|
||||
}.getOrElse { "" }
|
||||
}
|
||||
|
||||
/** Forget a login. Deleting the ACTIVE one leaves the node logged out — switch away first. */
|
||||
AsyncFunction("deleteProfile") { id: String ->
|
||||
Tailcore.deleteProfile(id)
|
||||
forgetCachedNodeIp()
|
||||
}
|
||||
|
||||
// Whether a node identity already exists on this device (the service flips this once registered).
|
||||
AsyncFunction("isRegistered") {
|
||||
prefs().getBoolean(KEY_REGISTERED, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the OS has granted this app the right to run a tunnel.
|
||||
*
|
||||
* This is NOT `isRegistered` — that answers "does this device have a node identity on some
|
||||
* tailnet", which is a completely different question and can be true while consent is absent
|
||||
* (reinstall, restore, or the user revoking VPN rights). The first-run flow needs them apart:
|
||||
* "Allow VPN" comes before choosing a tailnet, and choosing a tailnet is what creates identity.
|
||||
*
|
||||
* `VpnService.prepare()` is the authority and is cheap — it returns an Intent when consent is
|
||||
* needed and null once granted. Never cache the answer: consent is revocable at any time from
|
||||
* Settings, or by another VPN app taking over, and a stale `true` leaves a connect toggle that
|
||||
* silently does nothing.
|
||||
*/
|
||||
AsyncFunction("isAuthorized") {
|
||||
VpnService.prepare(context) == null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for tunnel consent on its own, without connecting.
|
||||
*
|
||||
* Deliberately does not start the service on success: at this point in the first-run flow no
|
||||
* control URL exists, so there is nothing to connect to. Starting here would fail confusingly, or
|
||||
* worse, come up against whatever stale prefs a previous install left behind.
|
||||
*/
|
||||
AsyncFunction("requestAuthorization") { promise: Promise ->
|
||||
val consent = VpnService.prepare(context)
|
||||
if (consent == null) {
|
||||
promise.resolve(true) // already granted — nothing to ask
|
||||
} else {
|
||||
val activity = appContext.currentActivity
|
||||
if (activity == null) {
|
||||
// Nothing to host the system dialog. Report "not granted" rather than throwing: the caller
|
||||
// shows the same "Allow VPN" button either way, and it will succeed once foregrounded.
|
||||
promise.resolve(false)
|
||||
} else {
|
||||
pendingAuthorize = promise
|
||||
activity.startActivityForResult(consent, VPN_AUTHORIZE_REQUEST)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bring the tunnel up. VpnService needs a ONE-TIME system consent dialog: VpnService.prepare()
|
||||
// returns a consent Intent the first time, or null once granted. If it returns an intent we launch
|
||||
// it from the current activity and resolve after OnActivityResult; otherwise we start the service now.
|
||||
AsyncFunction("up") { promise: Promise ->
|
||||
val consent = VpnService.prepare(context)
|
||||
if (consent != null) {
|
||||
val activity = appContext.currentActivity
|
||||
if (activity == null) {
|
||||
// No foreground activity to host the dialog — tell JS consent is needed so it can retry.
|
||||
promise.resolve(mapOf("needsConsent" to true))
|
||||
} else {
|
||||
pendingUp = promise
|
||||
activity.startActivityForResult(consent, VPN_PREPARE_REQUEST)
|
||||
}
|
||||
} else {
|
||||
prefs().edit().putBoolean(KEY_WANT_CONNECTED, true).apply()
|
||||
startVpn()
|
||||
promise.resolve(mapOf("needsConsent" to false))
|
||||
}
|
||||
}
|
||||
|
||||
// Bring the tunnel down.
|
||||
AsyncFunction("down") {
|
||||
// Cleared here and ONLY here: this is the one path that means the user chose to be off, so
|
||||
// BootReceiver leaves it off. Anything else — a crash, a reboot, the system reclaiming the
|
||||
// service — leaves the flag set and recovers.
|
||||
prefs().edit().putBoolean(KEY_WANT_CONNECTED, false).apply()
|
||||
context.startService(serviceIntent(ACTION_DOWN))
|
||||
// Explicit Unit: startService returns a ComponentName, and Kotlin would make that this
|
||||
// lambda's return value. Expo cannot bridge it and rejects the whole call — the function runs,
|
||||
// then fails on the way back, so the tunnel stops but JS sees an error.
|
||||
Unit
|
||||
}
|
||||
|
||||
// Current status snapshot. Sourced from the running service (self + peers from the core's netmap);
|
||||
// "stopped" when the service isn't running.
|
||||
AsyncFunction("status") {
|
||||
OfficerVpnService.lastStatus ?: mapOf(
|
||||
"state" to "stopped",
|
||||
"self" to null,
|
||||
"peers" to emptyList<Any>(),
|
||||
"error" to null,
|
||||
)
|
||||
}
|
||||
|
||||
// Forget the node identity + keys (full reset): stop the tunnel, tell the service to wipe the core
|
||||
// identity, and clear stored config.
|
||||
AsyncFunction("logout") {
|
||||
context.startService(serviceIntent(ACTION_DOWN).putExtra("logout", true))
|
||||
prefs().edit()
|
||||
.remove(KEY_CONTROL_URL).remove(KEY_HOSTNAME).remove(KEY_AUTH_KEY).remove(KEY_REGISTERED)
|
||||
.remove(KEY_WANT_CONNECTED)
|
||||
.apply()
|
||||
}
|
||||
|
||||
// Resolve the pending up() promise once the consent dialog returns.
|
||||
OnActivityResult { _, payload ->
|
||||
if (payload.requestCode == VPN_PREPARE_REQUEST) {
|
||||
val granted = payload.resultCode == Activity.RESULT_OK
|
||||
if (granted) {
|
||||
// Same as the no-consent-needed path: the user asked for this, so remember it for boot.
|
||||
prefs().edit().putBoolean(KEY_WANT_CONNECTED, true).apply()
|
||||
startVpn()
|
||||
}
|
||||
pendingUp?.resolve(mapOf("needsConsent" to false, "granted" to granted))
|
||||
pendingUp = null
|
||||
}
|
||||
// Consent asked for on its own: report the answer and start nothing. The first-run flow moves
|
||||
// to "pick a tailnet" next, and the tunnel starts when that produces a control URL.
|
||||
if (payload.requestCode == VPN_AUTHORIZE_REQUEST) {
|
||||
pendingAuthorize?.resolve(payload.resultCode == Activity.RESULT_OK)
|
||||
pendingAuthorize = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun serviceIntent(action: String): Intent =
|
||||
Intent(context, OfficerVpnService::class.java).setAction(action)
|
||||
|
||||
private fun startVpn() {
|
||||
// Foreground service — required for a long-running VpnService (foregroundServiceType=specialUse).
|
||||
context.startForegroundService(serviceIntent(ACTION_UP))
|
||||
}
|
||||
}
|
||||
+1003
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
// Root plugin entry so `plugins: ["officer-net", {...}]` resolves. Real logic in ./plugin/app.plugin.js.
|
||||
module.exports = require('./plugin/app.plugin.js');
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"platforms": ["apple", "android"],
|
||||
"apple": {
|
||||
"modules": ["OfficerNetModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.officernet.OfficerNetModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package tailcore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/netip"
|
||||
"sync"
|
||||
|
||||
"tailscale.com/net/dns"
|
||||
"tailscale.com/util/dnsname"
|
||||
)
|
||||
|
||||
// MagicDNS — the piece that makes short names like `alpha` resolve.
|
||||
//
|
||||
// The resolver itself lives in the Tailscale core and answers on 100.100.100.100; netstack routes
|
||||
// queries to it, but only if a dns.Manager exists. Ours never created one, so that address answered
|
||||
// nothing and pointing the OS at it black-holed every lookup. See OFFTAIL_ANALYSIS_2026-08-02.md.
|
||||
//
|
||||
// The manager needs an OSConfigurator. On desktop that writes /etc/resolv.conf or talks to
|
||||
// NetworkManager; on mobile the OS applies DNS itself (VpnService.Builder / NEPacketTunnelNetworkSettings),
|
||||
// so the platform-specific ones are wrong here — manager_darwin.go would try to rewrite the phone's
|
||||
// resolv.conf. Hence this no-op.
|
||||
//
|
||||
// One method is NOT a no-op and is the whole reason this file is subtle: GetBaseConfig supplies the
|
||||
// UPSTREAM resolvers the tailnet resolver forwards non-tailnet queries to. Return nothing and only
|
||||
// tailnet names resolve while the rest of the internet goes dark — the exact failure we just spent an
|
||||
// evening removing. The platform hands them in via SetUpstreamDNS before Start.
|
||||
|
||||
var (
|
||||
upstreamMu sync.Mutex
|
||||
upstreamDNS []netip.Addr
|
||||
)
|
||||
|
||||
// SetUpstreamDNS gives the core the resolvers of the underlying network, as a JSON array of IP
|
||||
// strings. Call BEFORE Start. Mirrors SetAndroidInterfaces: gomobile-friendly, JSON in, nothing out.
|
||||
//
|
||||
// The platform already knows these — Android reads them off the non-VPN network's LinkProperties,
|
||||
// iOS gets them from the system — and the core cannot discover them itself once the tunnel is up,
|
||||
// because by then it would only see its own.
|
||||
func SetUpstreamDNS(jsonArray string) {
|
||||
var ips []string
|
||||
if err := json.Unmarshal([]byte(jsonArray), &ips); err != nil {
|
||||
log.Printf("[tailcore] SetUpstreamDNS: %v", err)
|
||||
return
|
||||
}
|
||||
var addrs []netip.Addr
|
||||
for _, s := range ips {
|
||||
if a, err := netip.ParseAddr(s); err == nil {
|
||||
addrs = append(addrs, a)
|
||||
}
|
||||
}
|
||||
upstreamMu.Lock()
|
||||
upstreamDNS = addrs
|
||||
upstreamMu.Unlock()
|
||||
log.Printf("[tailcore] upstream DNS = %v", addrs)
|
||||
}
|
||||
|
||||
func baseResolvers() []netip.Addr {
|
||||
upstreamMu.Lock()
|
||||
defer upstreamMu.Unlock()
|
||||
return append([]netip.Addr(nil), upstreamDNS...)
|
||||
}
|
||||
|
||||
// mobileOSConfigurator applies nothing: the OS already has the DNS settings, handed to it by
|
||||
// VpnService.Builder / NEPacketTunnelNetworkSettings alongside the routes.
|
||||
type mobileOSConfigurator struct{}
|
||||
|
||||
func (mobileOSConfigurator) SetDNS(dns.OSConfig) error { return nil }
|
||||
|
||||
// False: we cannot split DNS at the OS level on mobile, so the core must handle every query itself —
|
||||
// answering tailnet names and forwarding the rest to the base config below. Claiming true would have
|
||||
// the core assume the OS routes only tailnet names to it, and everything else would be dropped.
|
||||
func (mobileOSConfigurator) SupportsSplitDNS() bool { return false }
|
||||
|
||||
func (mobileOSConfigurator) GetBaseConfig() (dns.OSConfig, error) {
|
||||
return dns.OSConfig{Nameservers: baseResolvers()}, nil
|
||||
}
|
||||
|
||||
func (mobileOSConfigurator) Close() error { return nil }
|
||||
|
||||
// searchDomainsFor is a convenience for callers that want the tailnet suffix as a search domain.
|
||||
func searchDomainsFor(suffix string) []dnsname.FQDN {
|
||||
if suffix == "" {
|
||||
return nil
|
||||
}
|
||||
f, err := dnsname.ToFQDN(suffix)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return []dnsname.FQDN{f}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
module officer.dev/tailcore
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0
|
||||
tailscale.com v1.102.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/akutz/memconn v0.1.0 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/creachadair/msync v0.8.1 // indirect
|
||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/gaissmai/bart v0.26.1 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||
github.com/jsimonetti/rtnetlink v1.4.1 // indirect
|
||||
github.com/klauspost/compress v1.19.1 // indirect
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
|
||||
github.com/mdlayher/socket v0.5.0 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
github.com/pires/go-proxyproto v0.8.1 // indirect
|
||||
github.com/safchain/ethtool v0.3.0 // indirect
|
||||
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
||||
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd // indirect
|
||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/mobile v0.0.0-20260803200217-62cee1672c8e // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
||||
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect
|
||||
)
|
||||
|
||||
tool golang.org/x/mobile/cmd/gobind
|
||||
@@ -0,0 +1,141 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
|
||||
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
|
||||
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
||||
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
|
||||
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ=
|
||||
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c=
|
||||
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
|
||||
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/creachadair/mds v0.25.13 h1:PsSUHV6zsfPd29k4kvm1rMoee1YFia7JyNGeMPmDcPM=
|
||||
github.com/creachadair/mds v0.25.13/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs=
|
||||
github.com/creachadair/msync v0.8.1 h1:QRd8si3qZ2Q4TaDL7tS/MG/lFE3YND7U7J9fy42eAFM=
|
||||
github.com/creachadair/msync v0.8.1/go.mod h1:dt0bscS09J8Ie3AdccK9JpCb7LfStaDGlAmDLukOlY4=
|
||||
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
|
||||
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
|
||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk=
|
||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ=
|
||||
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8=
|
||||
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
|
||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
|
||||
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689 h1:0psnKZ+N2IP43/SZC8SKx6OpFJwLmQb9m9QyV9BC2f8=
|
||||
github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689/go.mod h1:OGmRfY/9QEK2P5zCRtmqfbCF283xPkU2dvVA4MvbvpI=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
|
||||
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
|
||||
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
||||
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
|
||||
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
|
||||
github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNaPO3hE=
|
||||
github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
|
||||
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
|
||||
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
|
||||
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
|
||||
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
|
||||
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
||||
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
||||
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
|
||||
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
|
||||
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
|
||||
github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ=
|
||||
github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
||||
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE=
|
||||
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
||||
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA=
|
||||
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo=
|
||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
|
||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0 h1:CnIEL2n7Xql6Ux1k+Vu5S5ubDHCT/kxFgkKCY8FjefU=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
|
||||
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
|
||||
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 h1:Mn1OzFmF0ZKX/ZayHz/UdnWHufPp1wlD9lZ5U8LRDFY=
|
||||
golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA=
|
||||
golang.org/x/mobile v0.0.0-20260730202154-c700fe717e6e h1:v5PGTbI7uer7FhpDeGryGytfbI8t7CDWkKukozvIsNU=
|
||||
golang.org/x/mobile v0.0.0-20260730202154-c700fe717e6e/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA=
|
||||
golang.org/x/mobile v0.0.0-20260803200217-62cee1672c8e h1:hi1b7Lmv+xvnrjeEv8BoDmU7H/FAOuH7hEHSvjMl2VM=
|
||||
golang.org/x/mobile v0.0.0-20260803200217-62cee1672c8e/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
||||
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA=
|
||||
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
|
||||
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
|
||||
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
tailscale.com v1.102.0 h1:yAYrykMZWlgp7Qq2ECDrDnxeySWBhNXNMkXdDmVMyrE=
|
||||
tailscale.com v1.102.0/go.mod h1:NdndXfkSklVPbRUZMAzzRuDNaR5B+YWGbAyE1HpSQZg=
|
||||
@@ -0,0 +1,116 @@
|
||||
//go:build linux
|
||||
|
||||
package tailcore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"tailscale.com/net/netmon"
|
||||
)
|
||||
|
||||
// Android interface enumeration bridge.
|
||||
//
|
||||
// Android (SDK 30+) blocks Go's net.Interfaces()/net.Interface.Addrs() — they make raw netlink
|
||||
// RTM_GETLINK/GETADDR syscalls the app sandbox denies, surfacing as "route ip+net: netlinkrib:
|
||||
// permission denied" during wgengine bring-up. Tailscale's netmon anticipates this
|
||||
// (net/netmon/state.go: "Android SDK 30 no longer permits Go's net.Interfaces to work") and exposes
|
||||
// RegisterInterfaceGetter so the host app can supply interfaces via a permitted path.
|
||||
//
|
||||
// We use a push model: the Kotlin VpnService enumerates interfaces with java.net.NetworkInterface (which
|
||||
// Android DOES permit) and pushes a JSON snapshot here — once before Tailcore.start and again whenever
|
||||
// connectivity changes. Our registered getter just parses the latest snapshot. AltAddrs is populated so
|
||||
// netmon.Interface.Addrs() returns them directly and never falls back to the netlink path.
|
||||
|
||||
var androidIfaceJSON atomic.Value // string; latest snapshot pushed from Kotlin
|
||||
|
||||
type jsonIface struct {
|
||||
Name string `json:"name"`
|
||||
Index int `json:"index"`
|
||||
MTU int `json:"mtu"`
|
||||
Up bool `json:"up"`
|
||||
Loopback bool `json:"loopback"`
|
||||
P2P bool `json:"pointToPoint"`
|
||||
Multicast bool `json:"multicast"`
|
||||
MAC string `json:"mac"`
|
||||
Addrs []string `json:"addrs"` // "10.0.0.2/24", "fe80::1%wlan0/64"
|
||||
}
|
||||
|
||||
func init() {
|
||||
netmon.RegisterInterfaceGetter(androidInterfaces)
|
||||
}
|
||||
|
||||
// SetAndroidInterfaces is called from Kotlin with a JSON array snapshot of the device's network
|
||||
// interfaces. Must be called before Tailcore.start (netmon reads interfaces during bring-up) and on
|
||||
// each connectivity change to keep endpoint discovery current.
|
||||
func SetAndroidInterfaces(jsonStr string) {
|
||||
androidIfaceJSON.Store(jsonStr)
|
||||
}
|
||||
|
||||
func androidInterfaces() ([]netmon.Interface, error) {
|
||||
s, _ := androidIfaceJSON.Load().(string)
|
||||
if s == "" {
|
||||
// No snapshot yet — return empty rather than falling back to the (denied) netlink path.
|
||||
return nil, nil
|
||||
}
|
||||
var raw []jsonIface
|
||||
if err := json.Unmarshal([]byte(s), &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]netmon.Interface, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
var flags net.Flags
|
||||
if r.Up {
|
||||
flags |= net.FlagUp | net.FlagRunning
|
||||
}
|
||||
if r.Loopback {
|
||||
flags |= net.FlagLoopback
|
||||
}
|
||||
if r.P2P {
|
||||
flags |= net.FlagPointToPoint
|
||||
}
|
||||
if r.Multicast {
|
||||
flags |= net.FlagMulticast
|
||||
}
|
||||
var mac net.HardwareAddr
|
||||
if r.MAC != "" {
|
||||
mac, _ = net.ParseMAC(r.MAC)
|
||||
}
|
||||
ni := &net.Interface{
|
||||
Index: r.Index,
|
||||
MTU: r.MTU,
|
||||
Name: r.Name,
|
||||
HardwareAddr: mac,
|
||||
Flags: flags,
|
||||
}
|
||||
var altAddrs []net.Addr
|
||||
for _, a := range r.Addrs {
|
||||
pfx, err := netip.ParsePrefix(stripIPZone(a))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
altAddrs = append(altAddrs, &net.IPNet{
|
||||
IP: pfx.Addr().AsSlice(),
|
||||
Mask: net.CIDRMask(pfx.Bits(), pfx.Addr().BitLen()),
|
||||
})
|
||||
}
|
||||
out = append(out, netmon.Interface{Interface: ni, AltAddrs: altAddrs})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// stripIPZone removes an IPv6 scope zone from a CIDR string ("fe80::1%wlan0/64" → "fe80::1/64") so
|
||||
// netip.ParsePrefix accepts it. Java's InterfaceAddress hands us link-local v6 with a %zone suffix.
|
||||
func stripIPZone(s string) string {
|
||||
host, suffix := s, ""
|
||||
if slash := strings.LastIndexByte(s, '/'); slash >= 0 {
|
||||
host, suffix = s[:slash], s[slash:]
|
||||
}
|
||||
if pct := strings.IndexByte(host, '%'); pct >= 0 {
|
||||
host = host[:pct]
|
||||
}
|
||||
return host + suffix
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build darwin
|
||||
|
||||
package tailcore
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
wgtun "github.com/tailscale/wireguard-go/tun"
|
||||
)
|
||||
|
||||
// makeTUN wraps the OS-provided utun fd (handed over by NEPacketTunnelProvider) as a wireguard-go
|
||||
// device. Pass mtu=0 so CreateTUNFromFile does NOT setMTU — inside a Network Extension that ioctl is
|
||||
// EPERM ("operation not permitted"); iOS owns the utun MTU via NEPacketTunnelNetworkSettings.mtu.
|
||||
// (wireguard-go only calls setMTU when mtu > 0.)
|
||||
func makeTUN(fd int32) (wgtun.Device, error) {
|
||||
return wgtun.CreateTUNFromFile(os.NewFile(uintptr(fd), "tun"), 0)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build linux
|
||||
|
||||
package tailcore
|
||||
|
||||
import (
|
||||
wgtun "github.com/tailscale/wireguard-go/tun"
|
||||
)
|
||||
|
||||
// makeTUN wraps the OS-provided VpnService fd as a wireguard-go device using the Android-safe entry
|
||||
// point. The Linux CreateTUNFromFile would createNetlinkSocket() + setMTU() — both CAP_NET_ADMIN, which
|
||||
// an unprivileged Android app is DENIED (fails as "tun from fd: permission denied"). Android's OS owns
|
||||
// routes + MTU via VpnService.Builder, so the core must not touch them. CreateUnmonitoredTUNFromFD skips
|
||||
// netlink + MTU and uses only the read-only TUNGETIFF ioctl, which a VpnService fd permits — the same
|
||||
// path the official tailscale-android client takes.
|
||||
func makeTUN(fd int32) (wgtun.Device, error) {
|
||||
dev, _, err := wgtun.CreateUnmonitoredTUNFromFD(int(fd))
|
||||
return dev, err
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//go:build android
|
||||
|
||||
package tailcore
|
||||
|
||||
/*
|
||||
Give the Tailscale core a way to keep its own sockets out of its own tunnel.
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
A VPN has a bootstrap problem: the core's WireGuard sockets must reach the internet, but the tunnel
|
||||
they create captures the internet. Left alone, the encrypted packets are routed back into the tunnel
|
||||
that produced them and loop forever.
|
||||
|
||||
Android's answer is `VpnService.protect(fd)`, which marks ONE socket as belonging outside the tunnel.
|
||||
This app has been using a much blunter substitute — `addDisallowedApplication(packageName)`, which
|
||||
excludes the ENTIRE app from its own VPN. That does stop the loop, and it also means nothing this app
|
||||
does can ever travel through the tunnel it is holding open: not an in-app browser, not Officer's calls
|
||||
to the platform, not the core reaching a service that only exists on the tailnet.
|
||||
|
||||
`SetProtector` narrows it to what actually needs excluding. The core protects the sockets it dials
|
||||
out; everything else the app does is free to use the tunnel like any other app's traffic.
|
||||
|
||||
WHAT THIS DOES NOT DO
|
||||
|
||||
It does not decide whether an address inside the tailnet is reachable — that is tsdial's business and
|
||||
goes over WireGuard rather than any OS socket. Do not expect this to make an in-tailnet DNS resolver
|
||||
start answering; that was a separate claim and it was never verified.
|
||||
|
||||
Android only. iOS Network Extensions are excluded from their own tunnel by the OS, with no equivalent
|
||||
call and no equivalent problem — hence the build tag.
|
||||
*/
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"tailscale.com/net/netns"
|
||||
)
|
||||
|
||||
// Protector is implemented on the Java side by the VpnService, which is the only object that can call
|
||||
// protect(). gomobile generates a Java interface from this; int32 maps to Java's int, matching
|
||||
// VpnService.protect(int).
|
||||
type Protector interface {
|
||||
// Protect marks a socket as belonging outside the tunnel. False means the call failed, which
|
||||
// Android reports as a plain boolean with no reason attached.
|
||||
Protect(fd int32) bool
|
||||
}
|
||||
|
||||
// SetProtector installs the callback, or removes it when p is nil.
|
||||
//
|
||||
// Must be called BEFORE Start: the core dials as soon as it comes up, and a socket created before the
|
||||
// hook is registered is an unprotected one that will loop.
|
||||
func SetProtector(p Protector) {
|
||||
if p == nil {
|
||||
netns.SetAndroidProtectFunc(nil)
|
||||
return
|
||||
}
|
||||
netns.SetAndroidProtectFunc(func(fd int) error {
|
||||
if p.Protect(int32(fd)) {
|
||||
return nil
|
||||
}
|
||||
// Returning an error here fails the dial, which is correct: an unprotected socket would loop
|
||||
// back into the tunnel, and a hung connection is harder to diagnose than a refused one.
|
||||
return errors.New("VpnService.protect failed")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
// Package tailcore is the Go shim that gomobile-binds the real Tailscale core (tailscale.com) into a
|
||||
// native framework (iOS .xcframework / Android .aar) for OffTail. Everything the NE / VpnService needs
|
||||
// from the core is exposed here as gomobile-friendly functions (simple params + return types).
|
||||
//
|
||||
// This is the WHOLE-DEVICE path: Start() takes the OS tun file descriptor handed over by the platform
|
||||
// (iOS NEPacketTunnelProvider's utun, Android VpnService.establish()) and drives it through wgengine +
|
||||
// LocalBackend, pointed at a self-hosted Headscale. Modeled directly on tsnet.Server.start() — the
|
||||
// canonical in-repo node bring-up — but with a real fd-backed tun instead of netstack-only.
|
||||
//
|
||||
// STATUS: compiles + binds; the control-plane bring-up (register with Headscale via the pre-auth key)
|
||||
// is wired. The last mile is the native ROUTER/DNS bridge — routes + DNS must be applied on the OS side
|
||||
// (NEPacketTunnelNetworkSettings / VpnService.Builder), which needs on-device testing. Until that's in,
|
||||
// a nil Go router means packets reach the tun but the OS isn't told which routes to send through it.
|
||||
package tailcore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
"tailscale.com/ipn/store"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/types/logid"
|
||||
"tailscale.com/types/opt"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/wgengine"
|
||||
"tailscale.com/wgengine/netstack"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
backend *ipnlocal.LocalBackend
|
||||
cancel context.CancelFunc
|
||||
// Kept alongside `cancel` so operations started AFTER Start — LoginTo's interactive login — share
|
||||
// the tunnel's lifetime and are cancelled with it, rather than running on a detached Background.
|
||||
runCtx context.Context
|
||||
)
|
||||
|
||||
// Version returns the embedded Tailscale core version — a no-network call proving the core is linked in.
|
||||
func Version() string { return version.Long() }
|
||||
|
||||
// Ping is a trivial liveness check for the native bridge.
|
||||
func Ping() string { return "tailcore-ok" }
|
||||
|
||||
// Start brings up a whole-device Tailscale node on the OS tun file descriptor, pointed at a Headscale
|
||||
// control server, registering with a pre-auth key. gomobile-friendly signature (int32/string/error).
|
||||
func Start(tunFD int32, controlURL, authKey, hostname, stateDir string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if backend != nil {
|
||||
return nil // already running
|
||||
}
|
||||
|
||||
// Give Tailscale's logpolicy a writable directory for its log-state/ID. On Android (GOOS=android)
|
||||
// logpolicy.LogsDir() matches none of its OS cases and its fallbacks all fail — DefaultTailscaledStateFile
|
||||
// is empty, UserCacheDir errors (no $HOME), cwd is "/", and MkdirTemp("") fails (no writable /tmp) — so it
|
||||
// PANICS ("no safe place found to store log state"). TS_LOGS_DIR is the first thing it honors; point it at
|
||||
// the per-app state dir the platform handed us (Android filesDir / iOS Application Support). Harmless on
|
||||
// iOS, where a cache dir already exists.
|
||||
_ = os.MkdirAll(stateDir, 0o700)
|
||||
_ = os.Setenv("TS_LOGS_DIR", stateDir)
|
||||
|
||||
// Log to stderr → surfaces in the device's unified log (Console.app, process OffTailTunnel) so we can
|
||||
// watch the core register/connect. TODO: quiet or gate this once the tunnel is proven.
|
||||
logf := func(format string, args ...any) { log.Printf("[tailcore] "+format, args...) }
|
||||
|
||||
// Wrap the OS-provided tun fd (NEPacketTunnelProvider's utun on iOS, VpnService.establish() on
|
||||
// Android). The wrapping differs per-OS — iOS must skip setMTU, Android must skip netlink — so it
|
||||
// lives behind build-tagged makeTUN (maketun_apple.go / maketun_droid.go).
|
||||
dev, err := makeTUN(tunFD)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tun from fd: %w", err)
|
||||
}
|
||||
|
||||
sys := tsd.NewSystem()
|
||||
dialer := &tsdial.Dialer{Logf: logf}
|
||||
dialer.SetBus(sys.Bus.Get())
|
||||
|
||||
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
|
||||
Tun: dev,
|
||||
EventBus: sys.Bus.Get(),
|
||||
Dialer: dialer,
|
||||
SetSubsystem: sys.Set,
|
||||
ControlKnobs: sys.ControlKnobs(),
|
||||
HealthTracker: sys.HealthTracker.Get(),
|
||||
Metrics: sys.UserMetricsRegistry(), // required by NewUserspaceEngine
|
||||
// Router stays nil: the OS applies routes itself (NE settings / VpnService.Builder).
|
||||
//
|
||||
// DNS is NOT nil, and that is the fix for MagicDNS. Left nil, the engine substitutes a fake
|
||||
// no-op OSConfigurator ("using fake (no-op) DNS configurator") and builds its dns.Manager
|
||||
// around it — so the resolver at 100.100.100.100 had no base config to forward non-tailnet
|
||||
// queries to. Ours returns the platform's real resolvers from GetBaseConfig; see dns.go.
|
||||
//
|
||||
// Passed here rather than constructed separately: the engine creates and registers the
|
||||
// Manager itself, and calling sys.Set on a second one panics with "*dns.Manager is already
|
||||
// set".
|
||||
DNS: mobileOSConfigurator{},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wgengine: %w", err)
|
||||
}
|
||||
sys.Set(eng)
|
||||
|
||||
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
|
||||
if err != nil {
|
||||
return fmt.Errorf("netstack: %w", err)
|
||||
}
|
||||
sys.Tun.Get().Start()
|
||||
sys.Set(ns)
|
||||
|
||||
st, err := store.New(logf, filepath.Join(stateDir, "tailscaled.state"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: %w", err)
|
||||
}
|
||||
sys.Set(st)
|
||||
|
||||
lb, err := ipnlocal.NewLocalBackend(logf, logid.PublicID{}, sys, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("localbackend: %w", err)
|
||||
}
|
||||
|
||||
// Start netstack against the LocalBackend. netstack.Create() only INSTALLS its tstun hooks (incl. the
|
||||
// PreFilterPacketOutboundToWireGuardNetstackIntercept); ns.Start() is what marks netstack ready, wires
|
||||
// its LocalBackend, and launches the inject goroutine. WITHOUT this call the outbound intercept is live
|
||||
// but netstack isn't running, so every outbound packet is dropped and no peer is ever materialized in
|
||||
// wireguard-go (peers stay in the netmap + magicsock but never reach the engine — disco works, data
|
||||
// doesn't). tsnet and tailscaled both call ns.Start(lb); omitting it was the whole-device data-path bug.
|
||||
// Defaults (ProcessLocalIPs/ProcessSubnets = false) are correct here: this is a real-tun whole-device
|
||||
// node, so tailnet traffic must flow through the OS tun to peers, not be terminated in netstack.
|
||||
if err := ns.Start(lb); err != nil {
|
||||
return fmt.Errorf("netstack start: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancelFn := context.WithCancel(context.Background())
|
||||
cancel = cancelFn
|
||||
runCtx = ctx
|
||||
|
||||
/*
|
||||
NEVER rewrite an existing profile's control server.
|
||||
|
||||
The app keeps ONE stored control URL, but every profile has its own — and this Start runs on
|
||||
whichever profile happens to be current, on every single tunnel start. So a restart while the app
|
||||
held a different tailnet's URL silently rewrote the current profile to point at the wrong control
|
||||
server: a Tailscale profile ended up carrying headscale.pastilhas.eu, mislabelled and unable to
|
||||
reach its own tailnet.
|
||||
|
||||
A profile's control server is set once, when the profile is created (here for the first one,
|
||||
LoginTo for the rest). After that the stored value wins and the caller's is ignored.
|
||||
*/
|
||||
effectiveControlURL := controlURL
|
||||
// Hostname is the SAME class of bug: one app-level value, applied to whichever profile is current,
|
||||
// on every start. Each profile advertises its own node name, and a restart would rename the node
|
||||
// on one tailnet to whatever was last typed for another.
|
||||
effectiveHostname := hostname
|
||||
if p := lb.Prefs(); p.Valid() {
|
||||
if p.ControlURL() != "" {
|
||||
effectiveControlURL = p.ControlURL()
|
||||
}
|
||||
if p.Hostname() != "" {
|
||||
effectiveHostname = p.Hostname()
|
||||
}
|
||||
}
|
||||
|
||||
if err := lb.Start(ipn.Options{
|
||||
UpdatePrefs: &ipn.Prefs{
|
||||
ControlURL: effectiveControlURL,
|
||||
WantRunning: true,
|
||||
Hostname: effectiveHostname,
|
||||
// Do stateful packet filtering (double negative: NoStatefulFiltering=false ⇒ ON) so replies to
|
||||
// THIS node's outbound connections are allowed back through the netmap's inbound filter. Without
|
||||
// it, phone→service traffic goes out but every reply is dropped (the connection just hangs) unless
|
||||
// the ACL happens to also grant the reverse direction. tailscaled's NewPrefs defaults this OFF
|
||||
// (NoStatefulFiltering=true); a minimal bring-up like ours must opt in explicitly.
|
||||
NoStatefulFiltering: opt.NewBool(false),
|
||||
// Accept the DNS config the control plane sends — MagicDNS. Same trap as the line above:
|
||||
// tailscaled's NewPrefs() defaults this ON, but we build Prefs as a struct literal so it
|
||||
// gets Go's zero value, false. The node then tells Headscale it does not want DNS, and the
|
||||
// netmap arrives with {DefaultResolvers:[] Routes:{} SearchDomains:[] Hosts:0} — which is
|
||||
// why `alpha` never resolved no matter what the server was configured to send. Headscale's
|
||||
// own docs call this out: MagicDNS only helps a node with accept-dns on (CorpDNS true).
|
||||
CorpDNS: true,
|
||||
},
|
||||
AuthKey: authKey,
|
||||
}); err != nil {
|
||||
cancelFn()
|
||||
return fmt.Errorf("localbackend start: %w", err)
|
||||
}
|
||||
if lb.State() == ipn.NeedsLogin {
|
||||
if err := lb.StartLoginInteractive(ctx); err != nil {
|
||||
cancelFn()
|
||||
return fmt.Errorf("login: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
backend = lb
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Exit nodes ─────────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Routing ALL traffic through another node on the tailnet. The single most-used control in the real
|
||||
// Tailscale app after the on/off switch, and the reason a prefs-editing verb had to exist at all:
|
||||
// everything else here is set once at Start, but an exit node is chosen and changed while running.
|
||||
|
||||
// SetExitNode routes all traffic through the peer with this stable node id. An empty id turns it off.
|
||||
//
|
||||
// EditPrefs rather than Start: this changes a preference on a RUNNING node, and restarting to apply
|
||||
// it would drop the tunnel for something the core can do live. `ExitNodeIP` is deliberately not used
|
||||
// — an IP can be reassigned, while the stable id survives the node changing address.
|
||||
func SetExitNode(stableID string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
lb := backend
|
||||
if lb == nil {
|
||||
return fmt.Errorf("not started")
|
||||
}
|
||||
mp := &ipn.MaskedPrefs{ExitNodeIDSet: true}
|
||||
mp.ExitNodeID = tailcfg.StableNodeID(stableID)
|
||||
// Allowing LAN access alongside an exit node is what people expect: routing everything through a
|
||||
// remote node should not cut off the printer in the same room.
|
||||
mp.ExitNodeAllowLANAccessSet = true
|
||||
mp.ExitNodeAllowLANAccess = true
|
||||
if _, err := lb.EditPrefs(mp); err != nil {
|
||||
return fmt.Errorf("set exit node: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAcceptRoutes turns on using subnet routes other nodes advertise.
|
||||
//
|
||||
// Off by default in Tailscale, and the reason a node can see its peers but not the network behind
|
||||
// one of them — which looks like a broken route rather than a preference.
|
||||
func SetAcceptRoutes(on bool) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
lb := backend
|
||||
if lb == nil {
|
||||
return fmt.Errorf("not started")
|
||||
}
|
||||
mp := &ipn.MaskedPrefs{RouteAllSet: true}
|
||||
mp.RouteAll = on
|
||||
if _, err := lb.EditPrefs(mp); err != nil {
|
||||
return fmt.Errorf("set accept routes: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SubnetRoutes returns the subnet routes this node should carry, as a JSON array of CIDR strings.
|
||||
//
|
||||
// Empty unless RouteAll is on — accepting routes is opt-in, and a tun that claimed them regardless
|
||||
// would capture traffic the user never asked us to carry.
|
||||
//
|
||||
// These come from peers' PrimaryRoutes: the prefixes each node is currently the primary router for.
|
||||
// The tailnet's own ranges are filtered out because the tun already claims those, and re-adding them
|
||||
// as subnet routes would be redundant at best.
|
||||
//
|
||||
// It exists because 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 reaches us — the same gap that
|
||||
// made choosing an exit node do nothing observable.
|
||||
func SubnetRoutes() string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if backend == nil {
|
||||
return "[]"
|
||||
}
|
||||
p := backend.Prefs()
|
||||
if !p.Valid() || !p.RouteAll() {
|
||||
return "[]"
|
||||
}
|
||||
st := backend.Status()
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, ps := range st.Peer {
|
||||
if ps == nil || ps.PrimaryRoutes == nil {
|
||||
continue
|
||||
}
|
||||
for _, r := range ps.PrimaryRoutes.All() {
|
||||
// Skip the tailnet's own space and default routes: the first is already claimed, and the
|
||||
// second is an exit node advertising itself, which is a separate choice the user makes
|
||||
// explicitly rather than something to inherit by turning on subnet routes.
|
||||
if r.Bits() == 0 {
|
||||
continue
|
||||
}
|
||||
a := r.Addr()
|
||||
if a.Is4() && a.As4()[0] == 100 && a.As4()[1] >= 64 && a.As4()[1] <= 127 {
|
||||
continue
|
||||
}
|
||||
cidr := r.String()
|
||||
if !seen[cidr] {
|
||||
seen[cidr] = true
|
||||
out = append(out, cidr)
|
||||
}
|
||||
}
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Prefs returns the live preferences as JSON, so the app can show what is actually set rather than
|
||||
// what it last asked for. gomobile binds simple types only, hence a string.
|
||||
func Prefs() string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
lb := backend
|
||||
if lb == nil {
|
||||
return "{}"
|
||||
}
|
||||
p := lb.Prefs()
|
||||
if !p.Valid() {
|
||||
return "{}"
|
||||
}
|
||||
out := map[string]any{
|
||||
"exitNodeID": string(p.ExitNodeID()),
|
||||
"acceptRoutes": p.RouteAll(),
|
||||
"corpDNS": p.CorpDNS(),
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// NOTE ON LOCKING
|
||||
//
|
||||
// These accessors used to take the mutex only to COPY the backend pointer, then release it and call
|
||||
// methods on that copy. That is a use-after-shutdown waiting to happen: Stop() calls Shutdown() and
|
||||
// nils the pointer under the same mutex, so a caller that had already copied it goes on to use a
|
||||
// backend that is being torn down. Android polls Status() every two seconds and a profile switch
|
||||
// calls Stop() — the two race, and the result is a native crash with no Java stack, intermittently.
|
||||
//
|
||||
// iOS never showed it because the extension is a separate process torn down whole.
|
||||
//
|
||||
// So the short operations below hold the lock for the WHOLE call. They are microseconds of work;
|
||||
// serialising them against Stop costs nothing. LoginTo is deliberately excluded — it blocks on
|
||||
// StartLoginInteractive, and holding the mutex across a network round trip would block Stop instead.
|
||||
|
||||
// ── Profiles ───────────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Several tailnets on one device, switched between: a self-hosted Headscale and a login.tailscale.com
|
||||
// account, say. The core has supported this the whole time — LocalBackend keeps a profile per login,
|
||||
// each with its own control URL, node key and prefs — it was simply never exposed.
|
||||
//
|
||||
// gomobile only binds simple types, so these take and return strings; the list is JSON.
|
||||
|
||||
// ProfileInfo is the gomobile-safe shape of a login profile. `ipn.LoginProfileView` cannot cross the
|
||||
// binding, and most of it is uninteresting to a picker.
|
||||
type profileInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // the login name, e.g. andre@example.com
|
||||
Tailnet string `json:"tailnet"` // NetworkProfile.DomainName — which tailnet this is
|
||||
Current bool `json:"current"`
|
||||
// ControlURL is which control plane this profile talks to, and it is the ONLY authoritative way to
|
||||
// say whether a stored login is Tailscale or a self-hosted Headscale. Inferring it from the tailnet
|
||||
// name does not work: a personal Tailscale tailnet is named after the login email, and a Headscale
|
||||
// namespace can be called anything at all.
|
||||
ControlURL string `json:"controlUrl"`
|
||||
}
|
||||
|
||||
// ListProfiles returns every stored login as JSON, with the active one flagged.
|
||||
func ListProfiles() string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
lb := backend
|
||||
if lb == nil {
|
||||
return "[]"
|
||||
}
|
||||
currentID := lb.CurrentProfile().ID()
|
||||
out := []profileInfo{}
|
||||
for _, p := range lb.ListProfiles() {
|
||||
out = append(out, profileInfo{
|
||||
ID: string(p.ID()),
|
||||
Name: p.Name(),
|
||||
Tailnet: p.NetworkProfile().DomainName,
|
||||
Current: p.ID() == currentID,
|
||||
ControlURL: p.ControlURL(),
|
||||
})
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// SwitchProfile makes an existing login the active one. The node re-registers under that identity;
|
||||
// the caller is responsible for tearing the tun down and back up around it.
|
||||
func SwitchProfile(id string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
lb := backend
|
||||
if lb == nil {
|
||||
return fmt.Errorf("not started")
|
||||
}
|
||||
return lb.SwitchProfile(ipn.ProfileID(id))
|
||||
}
|
||||
|
||||
// NewProfile begins an additional login, leaving the existing ones intact. The node lands in
|
||||
// NeedsLogin, which is the point: Status().AuthURL then carries where to send the user.
|
||||
/*
|
||||
LoginTo joins ANOTHER tailnet without restarting the tunnel.
|
||||
|
||||
NewProfile alone is not enough. It creates a blank profile on the running backend, but the control
|
||||
URL and the pre-auth key only reach the core through Start — and on iOS Start only happens when the
|
||||
Network Extension launches. Recycling the tunnel to trigger it *loses the blank profile*: an
|
||||
unregistered profile is not persisted, so on reload the core falls back to the previous one and
|
||||
Start then overwrites ITS control URL. The result is a second tailnet silently replacing the first,
|
||||
same profile id and all.
|
||||
|
||||
Doing both here, against the live backend, is the only ordering where neither step undoes the other:
|
||||
branch to a new profile, then start it with its own control URL and key. An empty authKey lands in
|
||||
NeedsLogin and begins interactive login, exactly as the initial Start does.
|
||||
*/
|
||||
func LoginTo(controlURL, authKey, hostname string) error {
|
||||
mu.Lock()
|
||||
lb := backend
|
||||
ctxLocal := runCtx
|
||||
mu.Unlock()
|
||||
if lb == nil {
|
||||
return fmt.Errorf("not started")
|
||||
}
|
||||
if err := lb.NewProfile(); err != nil {
|
||||
return fmt.Errorf("new profile: %w", err)
|
||||
}
|
||||
// Same prefs as the initial Start — see the comments there for why CorpDNS and stateful
|
||||
// filtering must be set explicitly rather than left at Go's zero values.
|
||||
if err := lb.Start(ipn.Options{
|
||||
UpdatePrefs: &ipn.Prefs{
|
||||
ControlURL: controlURL,
|
||||
WantRunning: true,
|
||||
Hostname: hostname,
|
||||
NoStatefulFiltering: opt.NewBool(false),
|
||||
CorpDNS: true,
|
||||
},
|
||||
AuthKey: authKey,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("start profile: %w", err)
|
||||
}
|
||||
if lb.State() == ipn.NeedsLogin {
|
||||
if ctxLocal == nil {
|
||||
ctxLocal = context.Background()
|
||||
}
|
||||
if err := lb.StartLoginInteractive(ctxLocal); err != nil {
|
||||
return fmt.Errorf("login: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewProfile() error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
lb := backend
|
||||
if lb == nil {
|
||||
return fmt.Errorf("not started")
|
||||
}
|
||||
return lb.NewProfile()
|
||||
}
|
||||
|
||||
// DeleteProfile forgets a login. Deleting the ACTIVE profile is allowed by the core and leaves the
|
||||
// node logged out, so callers should switch away first if that is not what they meant.
|
||||
func DeleteProfile(id string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
lb := backend
|
||||
if lb == nil {
|
||||
return fmt.Errorf("not started")
|
||||
}
|
||||
return lb.DeleteProfile(ipn.ProfileID(id))
|
||||
}
|
||||
|
||||
// Stop tears the node down.
|
||||
func Stop() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
cancel = nil
|
||||
}
|
||||
if backend != nil {
|
||||
backend.Shutdown()
|
||||
backend = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Status returns the node status as JSON (self IPs, peers, backend state) — drives the RN UI.
|
||||
func Status() string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if backend == nil {
|
||||
return `{"BackendState":"Stopped"}`
|
||||
}
|
||||
b, err := json.Marshal(backend.Status())
|
||||
if err != nil {
|
||||
return `{"BackendState":"Error"}`
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// The JS surface for this native module lives in the app, at src/vpn/officer-net.ts, which reaches it
|
||||
// with requireNativeModule('OfficerNet'). This file is intentionally empty — it exists only to mark
|
||||
// the directory as a module for Expo autolinking.
|
||||
export {};
|
||||
@@ -0,0 +1,33 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'OfficerNet'
|
||||
s.version = '0.1.0'
|
||||
s.summary = 'OffTail VPN engine — Tailscale core bridge'
|
||||
s.description = 'Embeds the gomobile-built Tailscale core (libtailscale) as OfficerNet.xcframework and drives it behind an NEPacketTunnelProvider. Run scripts/build-libtailscale-ios.sh to populate libs/ before pod install. Talks directly to a self-hosted Headscale control server.'
|
||||
s.author = 'André Padez'
|
||||
s.homepage = 'https://pastilhas.dev'
|
||||
s.license = { :type => 'BSD-3-Clause' }
|
||||
s.platforms = { :ios => '15.1' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
# The gomobile-built Tailscale core. Produced once by scripts/build-libtailscale-ios.sh and vendored
|
||||
# under libs/ (gitignored). Vendored ONLY when present — until the build script runs, the pod compiles
|
||||
# as a stub (the Swift references no libtailscale symbols directly), so the app still builds + installs
|
||||
# with an inert engine. Once the xcframework exists it links automatically on the next pod install.
|
||||
# TODO(libtailscale): ship OfficerNet.xcframework here (arm64 device + arm64 sim slices).
|
||||
if File.directory?(File.join(__dir__, 'libs', 'TailCore.xcframework'))
|
||||
s.vendored_frameworks = 'libs/TailCore.xcframework'
|
||||
end
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
# Only the RN-process module compiles into this pod (the main app). PacketTunnelProvider.swift compiles
|
||||
# into the Network Extension target instead — the config plugin (plugin/withNetworkExtensionTarget.js)
|
||||
# copies it into ios/OffTailTunnel/ and adds it to that target's sources, along with TailCore.xcframework.
|
||||
s.source_files = 'OfficerNetModule.swift'
|
||||
end
|
||||
@@ -0,0 +1,740 @@
|
||||
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))") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
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()
|
||||
} 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)
|
||||
d("cleared cached node IP + MagicDNS suffix (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 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.
|
||||
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 if let suffix, !suffix.isEmpty {
|
||||
let dns = NEDNSSettings(servers: ["100.100.100.100"])
|
||||
dns.matchDomains = [suffix]
|
||||
settings.dnsSettings = dns
|
||||
saveSuffix(suffix)
|
||||
d("MagicDNS on for .\(suffix)")
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "officer-net",
|
||||
"version": "0.1.0",
|
||||
"description": "OffTail VPN engine — Tailscale core (gomobile/libtailscale) native bridge",
|
||||
"main": "index.ts",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@expo/config-plugins": "^57.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# officer-net Expo config plugin
|
||||
|
||||
Wires the OS-level capabilities the VPN needs into the app at prebuild. Enabled from `app.json`:
|
||||
|
||||
```js
|
||||
plugins: [
|
||||
["officer-net", {
|
||||
appGroup: "group.pastilhas.officer.offscale",
|
||||
neBundleId: "dev.officer.offscale.net",
|
||||
enableNetworkExtension: true,
|
||||
}],
|
||||
]
|
||||
```
|
||||
|
||||
Props (all optional; defaults shown):
|
||||
|
||||
| prop | default | what it controls |
|
||||
|--------------------------|------------------------------------|---------------------------------------------------------|
|
||||
| `appGroup` | `group.pastilhas.officer.mobile` | iOS App Group shared by the app and the NE |
|
||||
| `neBundleId` | `dev.officer.mobile.net` | the Network Extension target's bundle id |
|
||||
| `specialUseSubtype` | VPN mesh tunnel justification | Android `FOREGROUND_SERVICE_SPECIAL_USE` purpose |
|
||||
| `enableNetworkExtension` | `false` | add the iOS entitlement and synthesise the NE target |
|
||||
|
||||
`enableNetworkExtension` is **off by default** because the entitlement fails device code-signing until
|
||||
the Apple provisioning below exists. With it off the app signs, installs and renders; the engine is
|
||||
inert. The repo's own `app.json` turns it on.
|
||||
|
||||
## What the plugin does
|
||||
|
||||
- **iOS** — adds `com.apple.developer.networking.networkextension` (`packet-tunnel-provider`) to the
|
||||
main app, and writes `OfficerNetExtensionBundleId` into `Info.plist`. That key is **load-bearing**,
|
||||
not a breadcrumb: the Swift module reads it to fill `providerBundleIdentifier`, which is how iOS
|
||||
knows which extension to launch. A hardcoded value there means the start command is accepted, nothing
|
||||
matches, and the tunnel silently never starts.
|
||||
- **iOS, under `enableNetworkExtension`** — synthesises the Network Extension Xcode target
|
||||
(`withNetworkExtensionTarget.js`): a new target compiling this package's
|
||||
`ios/PacketTunnelProvider.swift`, its `Info.plist` (`NSExtensionPointIdentifier =
|
||||
com.apple.networkextension.packet-tunnel`), its own entitlements, `NetworkExtension.framework`, the
|
||||
vendored `TailCore.xcframework`, and the app's *Embed App Extensions* phase. Doing this by hand after
|
||||
every `expo prebuild --clean` is what this replaces.
|
||||
- **Android** — adds `FOREGROUND_SERVICE` and `FOREGROUND_SERVICE_SPECIAL_USE`, and registers
|
||||
`OfficerVpnService` with `BIND_VPN_SERVICE`, `foregroundServiceType="specialUse"` and the
|
||||
`android.net.VpnService` intent-filter.
|
||||
|
||||
## Apple provisioning prerequisites (one-time, in the Apple Developer portal)
|
||||
|
||||
- A separate **App ID for the extension** (`neBundleId`) with the **Network Extensions** capability.
|
||||
- The **Network Extensions** capability on the main app ID.
|
||||
- Provisioning profiles for both, with those capabilities.
|
||||
|
||||
`xcodebuild -allowProvisioningUpdates` cannot create these while Xcode has no account signed in: it
|
||||
falls back to the wildcard team profile and fails with *"Provisioning profile 'iOS Team Provisioning
|
||||
Profile: \*' doesn't include the Network Extensions capability"*. Sign in under **Xcode → Settings →
|
||||
Accounts** first. Android needs none of this.
|
||||
@@ -0,0 +1,141 @@
|
||||
// Expo config plugin for officer-net. Wires the OS-level capabilities the VPN engine needs into the
|
||||
// consuming app at prebuild time. Native code lives in the package; this plugin touches manifests,
|
||||
// entitlements and Info.plist, plus (under enableNetworkExtension) the iOS Network Extension target.
|
||||
//
|
||||
// Usage in app.json:
|
||||
// plugins: [
|
||||
// ["officer-net", { appGroup: "group.pastilhas.officer.offscale",
|
||||
// neBundleId: "dev.officer.offscale.net",
|
||||
// enableNetworkExtension: true }]
|
||||
// ]
|
||||
//
|
||||
// iOS is the hard part: a packet tunnel requires a SECOND native target (the Network Extension) that
|
||||
// embeds PacketTunnelProvider.swift. withNetworkExtensionTarget.js synthesises it — see
|
||||
// plugin/README.md for what that needs from the Apple Developer portal first.
|
||||
|
||||
const {
|
||||
withEntitlementsPlist,
|
||||
withInfoPlist,
|
||||
withAndroidManifest,
|
||||
AndroidConfig,
|
||||
createRunOncePlugin,
|
||||
} = require('@expo/config-plugins');
|
||||
const { withNetworkExtensionTarget } = require('./withNetworkExtensionTarget');
|
||||
|
||||
const DEFAULTS = {
|
||||
// App Group IDs are globally unique across ALL Apple accounts, so this uses your own domain
|
||||
// (group.pastilhas.*) to guarantee it's free + yours.
|
||||
appGroup: 'group.pastilhas.officer.mobile',
|
||||
neBundleId: 'dev.officer.mobile.net',
|
||||
// Android FOREGROUND_SERVICE_SPECIAL_USE (API 34+) needs a stated purpose in the manifest.
|
||||
specialUseSubtype: 'VPN mesh tunnel (Tailscale/Headscale) for OffTail',
|
||||
// The iOS Network Extension entitlement + App Group require Apple provisioning (a registered App
|
||||
// Group, an NE App ID, the Network Extensions capability) that must exist BEFORE the app can sign.
|
||||
// Until that's set up, adding the entitlement fails device code-signing — so it's OFF by default.
|
||||
// Flip to true once provisioning is in place (see ../README.md). With it off the app still signs and
|
||||
// installs, the VPN screen renders, and the engine is inert.
|
||||
enableNetworkExtension: false,
|
||||
};
|
||||
|
||||
// ── iOS: main-app entitlements ──────────────────────────────────────────────
|
||||
// The main app needs the Network Extensions capability (packet-tunnel-provider) to create/drive the
|
||||
// NETunnelProviderManager. NO App Group: config reaches the NE via the manager's providerConfiguration
|
||||
// and status comes back via handleAppMessage — so there's no App-Group entitlement to provision (which
|
||||
// -allowProvisioningUpdates won't auto-assign anyway, especially on Xcode-managed App IDs).
|
||||
function withIosEntitlements(config) {
|
||||
return withEntitlementsPlist(config, (cfg) => {
|
||||
cfg.modResults['com.apple.developer.networking.networkextension'] = ['packet-tunnel-provider'];
|
||||
return cfg;
|
||||
});
|
||||
}
|
||||
|
||||
// Record the NE bundle id in the main app's Info.plist. This is LOAD-BEARING, not a breadcrumb: the
|
||||
// Swift module reads it to fill providerBundleIdentifier, which is how iOS knows which extension to
|
||||
// launch. It used to be described as "purely informational" while the module used a hardcoded
|
||||
// dev.officer.mobile.net — so every host but Officer pointed at an extension it does not ship, and
|
||||
// the tunnel silently never started. Keep this in step with each app's `neBundleId` prop.
|
||||
function withIosInfo(config, props) {
|
||||
return withInfoPlist(config, (cfg) => {
|
||||
cfg.modResults.OfficerNetExtensionBundleId = props.neBundleId;
|
||||
return cfg;
|
||||
});
|
||||
// TODO: create the Network Extension target here (or as a separate withXcodeProject mod):
|
||||
// - add a new PBXNativeTarget of productType com.apple.product-type.app-extension
|
||||
// - embed PacketTunnelProvider.swift + NetworkExtension.framework
|
||||
// - set its bundle id = props.neBundleId, its own entitlements (networkextension +
|
||||
// the same App Group), and an Info.plist with NSExtension →
|
||||
// NSExtensionPointIdentifier = com.apple.networkextension.packet-tunnel
|
||||
// NSExtensionPrincipalClass = $(PRODUCT_MODULE_NAME).PacketTunnelProvider
|
||||
// - add it to the app's "Embed App Extensions" build phase
|
||||
// This is intentionally NOT automated yet — see plugin/README.md for the manual Xcode steps.
|
||||
}
|
||||
|
||||
// ── Android: manifest ───────────────────────────────────────────────────────
|
||||
// Adds the foreground-service permissions and registers OfficerVpnService with the BIND_VPN_SERVICE
|
||||
// permission + the android.net.VpnService intent-filter (what makes it a system VPN provider).
|
||||
function withAndroidVpn(config, props) {
|
||||
config = AndroidConfig.Permissions.withPermissions(config, [
|
||||
'android.permission.FOREGROUND_SERVICE',
|
||||
'android.permission.FOREGROUND_SERVICE_SPECIAL_USE',
|
||||
// Reconnect after a reboot if the user had the tunnel on. See BootReceiver.
|
||||
'android.permission.RECEIVE_BOOT_COMPLETED',
|
||||
]);
|
||||
|
||||
return withAndroidManifest(config, (cfg) => {
|
||||
const app = AndroidConfig.Manifest.getMainApplicationOrThrow(cfg.modResults);
|
||||
app.service = app.service || [];
|
||||
|
||||
const name = 'expo.modules.officernet.OfficerVpnService';
|
||||
if (!app.service.some((s) => s.$?.['android:name'] === name)) {
|
||||
app.service.push({
|
||||
$: {
|
||||
'android:name': name,
|
||||
'android:permission': 'android.permission.BIND_VPN_SERVICE',
|
||||
'android:foregroundServiceType': 'specialUse',
|
||||
'android:exported': 'false',
|
||||
},
|
||||
'intent-filter': [{ action: [{ $: { 'android:name': 'android.net.VpnService' } }] }],
|
||||
// API 34+ requires declaring the special-use subtype/justification.
|
||||
property: [
|
||||
{
|
||||
$: {
|
||||
'android:name': 'android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE',
|
||||
'android:value': props.specialUseSubtype,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
// Boot receiver: brings the tunnel back after a restart, but only when the user left it on.
|
||||
// exported=true is required — the sender is the system, not us.
|
||||
app.receiver = app.receiver || [];
|
||||
const receiver = 'expo.modules.officernet.BootReceiver';
|
||||
if (!app.receiver.some((r) => r.$?.['android:name'] === receiver)) {
|
||||
app.receiver.push({
|
||||
$: { 'android:name': receiver, 'android:exported': 'true', 'android:enabled': 'true' },
|
||||
'intent-filter': [
|
||||
{
|
||||
action: [{ $: { 'android:name': 'android.intent.action.BOOT_COMPLETED' } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return cfg;
|
||||
});
|
||||
}
|
||||
|
||||
const withOfficerNet = (config, userProps = {}) => {
|
||||
const props = { ...DEFAULTS, ...userProps };
|
||||
// The NE entitlement + App Group + the NE target only go on once Apple provisioning exists — otherwise
|
||||
// device signing fails. Gated so the inert-shell builds stay signable.
|
||||
if (props.enableNetworkExtension) {
|
||||
config = withIosEntitlements(config, props);
|
||||
config = withNetworkExtensionTarget(config, props);
|
||||
}
|
||||
config = withIosInfo(config, props);
|
||||
config = withAndroidVpn(config, props);
|
||||
return config;
|
||||
};
|
||||
|
||||
const pkg = require('../package.json');
|
||||
module.exports = createRunOncePlugin(withOfficerNet, pkg.name, pkg.version);
|
||||
@@ -0,0 +1,163 @@
|
||||
// Adds the iOS Network Extension (Packet Tunnel Provider) target to the app's Xcode project at prebuild
|
||||
// time, so it survives `expo prebuild` (a hand-added target would be wiped). Mirrors the proven pattern
|
||||
// expo-share-intent uses to add its share-extension target (addPbxGroup → addTarget "app_extension" →
|
||||
// addBuildPhase → build settings), plus the two things a VPN NE needs that a share extension doesn't:
|
||||
// the packet-tunnel Info.plist + entitlements, and linking the vendored TailCore.xcframework (the
|
||||
// gomobile Tailscale core) into the NE target — the core runs in the NE process.
|
||||
//
|
||||
// Only runs when props.enableNetworkExtension is true (needs Apple provisioning first; see README).
|
||||
|
||||
const { withXcodeProject, withDangerousMod } = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const EXT_NAME = 'OffTailTunnel'; // Xcode target + group name (bundle id is set separately)
|
||||
|
||||
// The NE's Info.plist: declares it a packet-tunnel provider whose principal class is our PacketTunnelProvider.
|
||||
const infoPlist = () => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key><string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleDisplayName</key><string>OffTail Tunnel</string>
|
||||
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
|
||||
<key>CFBundlePackageType</key><string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key><string>com.apple.networkextension.packet-tunnel</string>
|
||||
<key>NSExtensionPrincipalClass</key><string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
|
||||
// The NE's entitlements: just the packet-tunnel capability. No App Group — the app↔NE channel is
|
||||
// providerConfiguration (config in) + handleAppMessage (status out), so nothing to provision.
|
||||
const entitlements = () => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
<array><string>packet-tunnel-provider</string></array>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
|
||||
// Pull DEVELOPMENT_TEAM from the main-app target (same helper expo-share-intent uses).
|
||||
function mainAppDevTeam(pbx) {
|
||||
const configs = pbx.pbxXCBuildConfigurationSection();
|
||||
for (const key in configs) {
|
||||
const bs = configs[key].buildSettings;
|
||||
if (!bs || !bs.PRODUCT_NAME) continue;
|
||||
const name = bs.PRODUCT_NAME.replace(/"/g, '');
|
||||
if (name.includes('Extension') || name.includes('Widget') || name.includes('Tunnel')) continue;
|
||||
if (bs.DEVELOPMENT_TEAM) return bs.DEVELOPMENT_TEAM.replace(/"/g, '');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Stage 1 (dangerous mod): write the NE's own files (provider source, Info.plist, entitlements) into
|
||||
// ios/<EXT_NAME>/ before the Xcode-project mod references them.
|
||||
function withNeFiles(config, props) {
|
||||
return withDangerousMod(config, [
|
||||
'ios',
|
||||
(cfg) => {
|
||||
const iosRoot = cfg.modRequest.platformProjectRoot;
|
||||
const extDir = path.join(iosRoot, EXT_NAME);
|
||||
fs.mkdirSync(extDir, { recursive: true });
|
||||
// Copy our authoritative provider source out of the package into the target folder.
|
||||
const src = path.join(__dirname, '..', 'ios', 'PacketTunnelProvider.swift');
|
||||
fs.copyFileSync(src, path.join(extDir, 'PacketTunnelProvider.swift'));
|
||||
fs.writeFileSync(path.join(extDir, `${EXT_NAME}-Info.plist`), infoPlist());
|
||||
fs.writeFileSync(path.join(extDir, `${EXT_NAME}.entitlements`), entitlements());
|
||||
return cfg;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// Stage 2 (Xcode project): create the target, its group + build phases, link TailCore, set build settings.
|
||||
function withNeTarget(config, props) {
|
||||
return withXcodeProject(config, (cfg) => {
|
||||
const proj = cfg.modResults;
|
||||
if (proj.pbxTargetByName(EXT_NAME)) {
|
||||
console.log(`[officer-net] ${EXT_NAME} target already exists — skipping.`);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
const sourceFiles = ['PacketTunnelProvider.swift'];
|
||||
const configFiles = [`${EXT_NAME}-Info.plist`, `${EXT_NAME}.entitlements`];
|
||||
|
||||
// Group holding the NE files, hung off the project root group.
|
||||
const group = proj.addPbxGroup([...sourceFiles, ...configFiles], EXT_NAME, EXT_NAME);
|
||||
const groups = proj.hash.project.objects.PBXGroup;
|
||||
Object.keys(groups).forEach((key) => {
|
||||
if (typeof groups[key] === 'object' && groups[key].name === undefined && groups[key].path === undefined) {
|
||||
proj.addToPbxGroup(group.uuid, key);
|
||||
}
|
||||
});
|
||||
|
||||
// addTarget bug workaround (single-target projects lack these sections).
|
||||
const objects = proj.hash.project.objects;
|
||||
objects.PBXTargetDependency = objects.PBXTargetDependency || {};
|
||||
objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {};
|
||||
|
||||
const target = proj.addTarget(EXT_NAME, 'app_extension', EXT_NAME);
|
||||
proj.addBuildPhase(sourceFiles, 'PBXSourcesBuildPhase', 'Sources', target.uuid);
|
||||
proj.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid);
|
||||
proj.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid);
|
||||
|
||||
// Link the vendored gomobile Tailscale core (TailCore.xcframework) into the NE. The `xcode` lib has
|
||||
// no .xcframework extension mapping, so we MUST force lastKnownFileType='wrapper.xcframework' (else it
|
||||
// types it 'unknown' and Xcode never resolves the per-slice module → "no such module 'TailCore'").
|
||||
// Path is relative to SOURCE_ROOT (the ios/ project dir) and includes a dirname so the search path
|
||||
// is registered. Located in the package's libs/ (../../../ from apps/*/ios up to packages/).
|
||||
proj.addFramework('../../../packages/officer-net/ios/libs/TailCore.xcframework', {
|
||||
target: target.uuid,
|
||||
customFramework: true,
|
||||
lastKnownFileType: 'wrapper.xcframework',
|
||||
sourceTree: 'SOURCE_ROOT',
|
||||
embed: false,
|
||||
link: true,
|
||||
sign: false,
|
||||
});
|
||||
|
||||
const devTeam = mainAppDevTeam(proj);
|
||||
// Framework search path → the package's libs dir (from ios/: up to the monorepo, into packages).
|
||||
const fwSearch = '"$(PROJECT_DIR)/../../../packages/officer-net/ios/libs"';
|
||||
|
||||
const configs = proj.pbxXCBuildConfigurationSection();
|
||||
for (const key in configs) {
|
||||
const bs = configs[key].buildSettings;
|
||||
if (!bs || bs.PRODUCT_NAME !== `"${EXT_NAME}"`) continue;
|
||||
bs.INFOPLIST_FILE = `"${EXT_NAME}/${EXT_NAME}-Info.plist"`;
|
||||
bs.CODE_SIGN_ENTITLEMENTS = `"${EXT_NAME}/${EXT_NAME}.entitlements"`;
|
||||
bs.CODE_SIGN_STYLE = 'Automatic';
|
||||
bs.PRODUCT_BUNDLE_IDENTIFIER = `"${props.neBundleId}"`;
|
||||
bs.GENERATE_INFOPLIST_FILE = 'NO';
|
||||
bs.CLANG_ENABLE_MODULES = 'YES';
|
||||
bs.SWIFT_VERSION = '5.0';
|
||||
bs.SWIFT_EMIT_LOC_STRINGS = 'YES';
|
||||
bs.TARGETED_DEVICE_FAMILY = '"1,2"';
|
||||
bs.IPHONEOS_DEPLOYMENT_TARGET = '16.4';
|
||||
bs.ENABLE_BITCODE = 'NO';
|
||||
bs.MARKETING_VERSION = `"${config.version || '1.0.0'}"`;
|
||||
bs.CURRENT_PROJECT_VERSION = `"${config.ios?.buildNumber || '1'}"`;
|
||||
bs.FRAMEWORK_SEARCH_PATHS = ['"$(inherited)"', fwSearch];
|
||||
if (devTeam) bs.DEVELOPMENT_TEAM = devTeam;
|
||||
}
|
||||
|
||||
if (devTeam) {
|
||||
proj.addTargetAttribute('DevelopmentTeam', devTeam);
|
||||
proj.addTargetAttribute('DevelopmentTeam', devTeam, proj.pbxTargetByName(EXT_NAME));
|
||||
}
|
||||
|
||||
console.log(`[officer-net] Added Network Extension target ${EXT_NAME} (${props.neBundleId}).`);
|
||||
return cfg;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { withNetworkExtensionTarget: (config, props) => withNeTarget(withNeFiles(config, props), props), EXT_NAME };
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the OffTail Tailscale core into an Android .aar via gomobile, from the Go shim in ../gocore.
|
||||
# Output: modules/officer-net/android/libs/tailcore.aar (gitignored; consumed by android/build.gradle).
|
||||
# Requires: Go + gomobile + gobind on PATH, Android SDK + NDK. Proven pair with the iOS build (v1.102.0).
|
||||
set -euo pipefail
|
||||
TAILSCALE_REF="${TAILSCALE_REF:-v1.102.0}"
|
||||
export ANDROID_HOME="${ANDROID_HOME:-/opt/homebrew/share/android-commandlinetools}"
|
||||
export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$ANDROID_HOME/ndk/26.3.11579264}"
|
||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
GOCORE="$HERE/gocore"; OUT="$HERE/android/libs/tailcore.aar"
|
||||
cd "$GOCORE"
|
||||
[ -f go.mod ] || go mod init officer.dev/tailcore
|
||||
go get "tailscale.com@${TAILSCALE_REF}"; go get -tool golang.org/x/mobile/cmd/gobind; go get golang.org/x/mobile/bind
|
||||
mkdir -p "$HERE/android/libs"
|
||||
echo "→ gomobile bind -target=android/arm64 (compiles the core for Android)"
|
||||
gomobile bind -target=android/arm64 -androidapi 24 -o "$OUT" ./
|
||||
[ -f "$OUT" ] || { echo "✗ no aar produced" >&2; exit 1; }
|
||||
# Gradle does NOT consume the .aar — build.gradle has `api files('libs/tailcore.jar')`, and the native
|
||||
# code ships from src/main/jniLibs. Producing the .aar alone therefore has NO EFFECT on the app, and
|
||||
# the failure is silent: everything builds, installs and runs the PREVIOUS core. That cost two hours
|
||||
# on 2026-08-02, with a jar three days older than the .aar beside it.
|
||||
echo "→ refreshing libs/tailcore.jar + jniLibs/libgojni.so from the .aar (what Gradle actually uses)"
|
||||
LIBS="$HERE/android/libs"
|
||||
JNI="$HERE/android/src/main/jniLibs/arm64-v8a"
|
||||
mkdir -p "$JNI"
|
||||
unzip -o -q "$OUT" classes.jar -d "$LIBS" && mv -f "$LIBS/classes.jar" "$LIBS/tailcore.jar"
|
||||
unzip -o -q -j "$OUT" "jni/arm64-v8a/libgojni.so" -d "$JNI"
|
||||
echo "✔ built $OUT"; ls -la "$OUT" "$LIBS/tailcore.jar" "$JNI/libgojni.so"
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the OffTail Tailscale core into an iOS .xcframework via gomobile, from the Go shim in ../gocore.
|
||||
# Output: modules/officer-net/ios/libs/TailCore.xcframework (gitignored; the podspec vendors it when present).
|
||||
#
|
||||
# Unlike bw-vault (which fetches a prebuilt Bitwarden release asset), Tailscale publishes no third-party
|
||||
# gomobile framework — WE produce it once with `gomobile bind`, then vendor it. PROVEN working on
|
||||
# Go 1.26 + Xcode 26: binds tailscale.com (tsnet/ipn/wgengine/magicsock/netstack) to arm64 + sim.
|
||||
#
|
||||
# Requires: Go + gomobile + gobind on PATH:
|
||||
# go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
# go install golang.org/x/mobile/cmd/gobind@latest
|
||||
#
|
||||
# Keep TAILSCALE_REF paired with the Headscale version you run — Headscale tracks a specific Tailscale
|
||||
# client protocol version, and a core built far ahead of it will fail to register.
|
||||
set -euo pipefail
|
||||
|
||||
TAILSCALE_REF="${TAILSCALE_REF:-v1.102.0}"
|
||||
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)" # modules/officer-net
|
||||
GOCORE="$HERE/gocore"
|
||||
OUT="$HERE/ios/libs/TailCore.xcframework"
|
||||
|
||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||
command -v gomobile >/dev/null 2>&1 || { echo "✗ gomobile not on PATH — go install golang.org/x/mobile/cmd/gomobile@latest"; exit 1; }
|
||||
|
||||
cd "$GOCORE"
|
||||
[ -f go.mod ] || go mod init officer.dev/tailcore
|
||||
echo "→ pinning tailscale.com@${TAILSCALE_REF}"
|
||||
go get "tailscale.com@${TAILSCALE_REF}"
|
||||
# gomobile (Go 1.26) needs x/mobile as a tool dep AND bind imported, or `go mod tidy` prunes it and the
|
||||
# bind fails with "missing golang.org/x/mobile dependency".
|
||||
go get -tool golang.org/x/mobile/cmd/gobind
|
||||
go get golang.org/x/mobile/bind
|
||||
|
||||
echo "→ gomobile bind -target=ios (compiles the whole Tailscale core; a few minutes)"
|
||||
mkdir -p "$HERE/ios/libs"
|
||||
rm -rf "$OUT"
|
||||
gomobile bind -target=ios -o "$OUT" ./
|
||||
|
||||
[ -d "$OUT" ] || { echo "✗ bind produced no framework" >&2; exit 1; }
|
||||
echo "✔ built $OUT"; du -sh "$OUT"
|
||||
Reference in New Issue
Block a user