The first fix widened matchDomains but only where the netmap refine runs — first
connect. Every connect after that takes the fast path, which applies settings before
any netmap exists and then deliberately does not touch the live tunnel again.
So its match domains could only come from the file the previous refine wrote. Fine
for the suffix, which never changes; wrong for extra_records and split-DNS domains,
because on the first connect there is no file yet — the tunnel comes up resolving
tailnet names only and stays that way, since nothing re-applies on any later connect.
That is the shape of what Andre still saw after reinstalling and reconnecting:
officer.pertento.ai going to public DNS and failing TLS against 100.64.0.4, on a
device that had connected more than once.
The fast path now watches for the overrides and re-applies once if they differ from
what it used. Bounded like the existing refine, silent when they match, and
re-applying on a live tunnel is not novel — the exit-node and accept-routes paths
have always done it.
Still unconfirmed on hardware: this is the most likely cause found by reading, not a
diagnosis from device logs. The tunnel now says which domains it claimed, so the next
attempt can be settled rather than reasoned about.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
gomobile upgraded six indirect golang.org/x modules while building the core — crypto,
mobile, mod, net, text, tools — and go.mod recorded it. Committing so a fresh clone
compiles the same core rather than whatever those pseudo-versions resolve to next week.
All indirect, all golang.org/x, no change to the tailscale.com pin at v1.102.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The extraction typechecked from day one and had never been built. It took five
attempts, each failing further along, and every cause was the same shape: something the
shared node_modules or the monorepo's layout supplied without anyone declaring it.
1. The config plugin had the monorepo's geometry written into it —
../../../packages/officer-net/ios/libs, correct from apps/<name>/ios and meaningless
from a repo root. It reported a path belonging to neither repo. Now derived from the
plugin's own location with path.relative, so it is right wherever the module sits.
2. That fix appeared to do nothing, because officer-net was a dependency and bun
COPIES those. Prebuild ran a week-old copy while the source was edited — a clean edit
and a stale build, the same shadow-copy trap that cost a night in the monorepo.
Making it a workspace turns node_modules/officer-net into a symlink.
3. @babel/plugin-transform-react-jsx was never declared by anything. It was hoisted into
the monorepo by some other package, so babel-preset-expo with jsxImportSource found
it by luck.
4. Same for react-native-reanimated and react-native-worklets: nativewind depends on
react-native-css-interop, which lists reanimated as a PEER, whose babel plugin
requires react-native-worklets/plugin. Three levels of nobody-declared-it.
5. And the self-inflicted one. Adding for (2) silently flipped bun to its
isolated linker — only direct dependencies at top level, everything else symlinked
into node_modules/.bun, which Metro cannot resolve. It surfaced as 'Unable to resolve
module react-native-css-interop/jsx-runtime', naming a package that was installed and
in the lockfile and simply not where the resolver looks. bunfig.toml now pins both
properties that are needed at once: workspaces for the symlink, hoisted for the
layout.
Result: OffScale.app, 82 MB, with OffTailTunnel.appex at 20 MB carrying 129 Tailcore
symbols — the Go core is in the extension, which is the half that matters.
Not yet run on a device. It builds; that is a different claim.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.