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:
2026-08-14 20:32:17 +01:00
commit ef3582f2aa
68 changed files with 11534 additions and 0 deletions
+91
View File
@@ -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}
}
+55
View File
@@ -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
+141
View File
@@ -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
}
+65
View File
@@ -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")
})
}
+509
View File
@@ -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)
}