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.
92 lines
3.3 KiB
Go
92 lines
3.3 KiB
Go
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}
|
|
}
|