Files
offscale-mobile/modules/officer-net
pastilhasandClaude Opus 5 a8e9dd712c fix(dns): iOS never matched the domains control overrides
Reported from an iPad on 2026-08-18: officer.pertento.ai, gitea.pertento.ai and
*.edge.pertento.ai all failed with "couldn't establish a secure connection", while
desktop clients on the same tailnet reached them with a valid certificate.

The provider DID apply dnsSettings — it just scoped them to the MagicDNS suffix
alone. Everything Headscale overrides lives under an unrelated domain: dns.extra_records
serves an A record for a name like officer.pertento.ai, and dns.nameservers.split
gives one domain its own resolver. Neither is under the tailnet suffix, so iOS never
sent those queries to us. The device asked public DNS instead and got the public
answer — which for these names is a TAILNET address belonging to a DIFFERENT machine
(100.64.0.4 rather than the bridge at 100.64.0.12). TLS then failed against a host
holding no certificate for that name, which is exactly what Safari reported.

Nothing was missing from the server: CorpDNS is true, so the netmap already carried
the split routes and the extra records. The data simply had no way across to Swift.

So Go gains DNSMatchDomains(), returning the split-DNS route keys, the tailnet search
domains, and the PARENT zone of each extra record — the parent because a record is a
host, and one entry then covers its siblings without the device being told about each
one. Swift builds matchDomains from the suffix plus those, and remembers them exactly
as it remembers the suffix, because the fast path applies settings before any netmap
exists and would otherwise reintroduce this bug on every reconnect. Forgotten with the
suffix when the profile changes: those domains belong to the tailnet that sent them.

Deliberately NOT the catch-all [""], which the COMMS note suggested as an option. That
is already in this file and reserved for exit-node mode. Off an exit node it would put
every lookup on the phone through a resolver that cannot always forward — the failure
Android hit, and worse than this one: a broken tailnet name is an app that will not
load, a broken everything is a phone with no internet.

Android needs no change: it has no matchDomains equivalent and already routes all DNS
at the core.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 05:53:27 +01:00
..

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:

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:

grep Tailcore modules/officer-net/ios/libs/TailCore.xcframework/ios-arm64/Tailcore.framework/Headers/*.h

Requires Go plus gomobile on PATH:

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:

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.