mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
Retain master's context-aware VPN and resolver APIs, per-program logger, shutdown fencing, native CLAT service identity and firewall behavior. Keep saved-DNS paths owned by the root ctrld package; isolate the added ownership/sweep tests with a shared path seam and temporary backups. Preserve the source DNS64 DNSSEC/cache gates, longest-suffix VPN routing, DNS-journal convergence, scoped IPv6 handling, target ownership checks, custom-listener regressions and documented native QA limits.
417 lines
17 KiB
Go
417 lines
17 KiB
Go
//go:build darwin
|
|
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
|
|
"tailscale.com/net/netmon"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
)
|
|
|
|
// interceptDNSTargetStateFile persists which service/value ctrld set, so a
|
|
// daemon restart (crash, upgrade, plain restart) does not orphan the entry:
|
|
// without it a restarted daemon would not know the entry is ctrld's own and
|
|
// could neither remove it on shutdown nor keep its bookkeeping consistent.
|
|
const interceptDNSTargetStateFile = ".intercept_dns_target"
|
|
|
|
var (
|
|
interceptDNSTargetStatePathFn = interceptDNSTargetStatePath
|
|
interceptDefaultRouteInterfaceFn = netmon.DefaultRouteInterface
|
|
interceptInterfaceByNameFn = net.InterfaceByName
|
|
interceptPatchNetIfaceNameFn = patchNetIfaceName
|
|
interceptCurrentStaticDNSFn = readTargetStaticDNS
|
|
interceptNativeCLATDefaultServiceFn = nativeCLATDefaultService
|
|
interceptNativeStaticDNSFn = readNativeTargetStaticDNS
|
|
interceptSaveStaticDNSSnapshotFn = func(iface *net.Interface, dns []string, owned string) error {
|
|
return saveTargetStaticDNSSnapshot(ctrld.SavedStaticDnsSettingsFilePath(iface), dns, owned)
|
|
}
|
|
interceptSaveCurrentStaticDNSFn = saveCurrentStaticDNS
|
|
interceptRemoveSavedDNSFn = func(iface *net.Interface) error {
|
|
err := os.Remove(savedStaticDNSPathFn(iface))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
interceptSetDNSFn = setDNS
|
|
interceptSavedStaticNameserversFn = ctrld.SavedStaticNameservers
|
|
interceptResetDNSIgnoreUnusableIfaceFn = resetDnsIgnoreUnusableInterface
|
|
interceptDHCPNameserversForInterfaceFn = readTargetDHCPNameservers
|
|
)
|
|
|
|
func interceptDNSTargetStatePath() string {
|
|
dir, err := userHomeDir()
|
|
if err != nil {
|
|
return interceptDNSTargetStateFile
|
|
}
|
|
return filepath.Join(dir, interceptDNSTargetStateFile)
|
|
}
|
|
|
|
// A retained target record means cleanup was not completed. The broad saved
|
|
// static sweep must not bypass the target's ownership check or retry failures
|
|
// through an unguarded restore. Unknown state is conservatively left alone.
|
|
func interceptTargetAllowsStaticRestore(service string) bool {
|
|
data, err := os.ReadFile(interceptDNSTargetStatePathFn())
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return true
|
|
}
|
|
if err != nil {
|
|
return false
|
|
}
|
|
var st interceptDNSTargetState
|
|
if json.Unmarshal(data, &st) != nil || st.Service == "" {
|
|
return false
|
|
}
|
|
return st.Service != service
|
|
}
|
|
|
|
type interceptDNSTargetState struct {
|
|
Service string `json:"service"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
// loadInterceptDNSTargetStateLocked hydrates in-memory tracking from the
|
|
// state file once (only when memory is empty). Callers must hold
|
|
// interceptDNSTargetMu.
|
|
func (p *prog) loadInterceptDNSTargetStateLocked() {
|
|
if p.interceptDNSTargetService != "" || p.interceptDNSTargetLoaded {
|
|
return
|
|
}
|
|
p.interceptDNSTargetLoaded = true
|
|
data, err := os.ReadFile(interceptDNSTargetStatePathFn())
|
|
if err != nil {
|
|
return
|
|
}
|
|
var st interceptDNSTargetState
|
|
if err := json.Unmarshal(data, &st); err != nil || st.Service == "" || st.Value == "" {
|
|
return
|
|
}
|
|
p.setInterceptDNSTargetLocked(st.Service, st.Value)
|
|
mainLog.Load().Debug().Msgf("intercept DNS target: restored tracking of %s on %q from previous run", st.Value, st.Service)
|
|
}
|
|
|
|
// persistInterceptDNSTargetStateLocked writes (or clears) the state file to
|
|
// match in-memory tracking. Callers must hold interceptDNSTargetMu.
|
|
func (p *prog) persistInterceptDNSTargetStateLocked() error {
|
|
file := interceptDNSTargetStatePathFn()
|
|
if p.interceptDNSTargetService == "" {
|
|
if err := os.Remove(file); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
data, err := json.Marshal(interceptDNSTargetState{Service: p.interceptDNSTargetService, Value: p.interceptDNSTargetSetValue})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Replace atomically; a failed write must not truncate prior ownership.
|
|
tmp, err := os.CreateTemp(filepath.Dir(file), ".intercept-target-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
_, writeErr := tmp.Write(data)
|
|
err = errors.Join(writeErr, tmp.Close())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp.Name(), file)
|
|
}
|
|
|
|
// ensureInterceptDNSTarget provides an emittable DNS target when ownership
|
|
// and discovery permit it; externally changed DNS is not permission to reinstall.
|
|
//
|
|
// Intercept mode deliberately never manages interface DNS: pf redirects DNS
|
|
// packets in flight. But pf can only redirect packets macOS actually sends,
|
|
// and mDNSResponder emits none when the active network service has no DNS
|
|
// configured. IPv6-only networks (e.g. iPhone tethering with 464XLAT) supply
|
|
// no IPv4 DNS, and the pf ruleset blocks all outbound IPv6 port 53, so such
|
|
// networks otherwise end in a total DNS outage with a healthy upstream
|
|
// (issue #533).
|
|
//
|
|
// Only when the default-route service has no usable IPv4 DNS at all does
|
|
// ctrld set a loopback DNS value on it — chosen by interceptDNSTargetValue to
|
|
// respect the configured listener: the listener IP directly when it serves
|
|
// port 53, else a distinct loopback address so the pf lo0 rdr rule rewrites
|
|
// to the listener's real port. The entry is removed when the network regains
|
|
// IPv4 DNS and on intercept shutdown. Networks that provide IPv4 DNS are
|
|
// never modified.
|
|
//
|
|
// Callers pass a non-nil raw system discovery result to prove discovery ran;
|
|
// an empty slice is a valid DNS-less result. The decision itself uses static
|
|
// DNS plus DHCP option 6 from the default-route interface, so resolvers on a
|
|
// second physical interface cannot suppress the target. Invoked during
|
|
// startup, debounced network recovery, and periodic pf watchdog reconciliation.
|
|
func (p *prog) ensureInterceptDNSTarget(systemDiscovery []string) {
|
|
if !dnsIntercept || p.dnsInterceptState == nil {
|
|
return
|
|
}
|
|
p.interceptDNSTargetMu.Lock()
|
|
defer p.interceptDNSTargetMu.Unlock()
|
|
p.loadInterceptDNSTargetStateLocked()
|
|
var diagnostic *dnsTargetDecisionDiagnostic
|
|
if state, ok := p.dnsInterceptState.(*pfState); ok {
|
|
diagnostic = &state.targetDiagnostic
|
|
}
|
|
decision := dnsTargetDecisionContext{
|
|
ownership: p.interceptDNSTargetOwnershipLocked(),
|
|
generation: p.recoveryGen.Load(),
|
|
transitionID: p.networkAcceptedGen.Load(),
|
|
}
|
|
// Only completed discovery resolves an outstanding diagnostic. A resolution
|
|
// describes the decision and observed ownership, not successful DNS repair.
|
|
resolvedReason := ""
|
|
defer func() {
|
|
if resolvedReason != "" {
|
|
diagnostic.resolved(decision, p.interceptDNSTargetOwnershipLocked(), resolvedReason)
|
|
}
|
|
}()
|
|
if systemDiscovery == nil {
|
|
diagnostic.failed(decision, "system_discovery", nil)
|
|
mainLog.Load().Debug().Msg("intercept DNS target: system DNS discovery was not performed; not changing DNS")
|
|
return
|
|
}
|
|
|
|
drIfaceName, err := interceptDefaultRouteInterfaceFn()
|
|
decision.iface = drIfaceName
|
|
if err != nil || drIfaceName == "" {
|
|
// Mid-transition with no default route; the next recovery decides.
|
|
diagnostic.failed(decision, "default_route", err)
|
|
return
|
|
}
|
|
iface, err := interceptInterfaceByNameFn(drIfaceName)
|
|
if err != nil || iface == nil {
|
|
diagnostic.failed(decision, "interface_lookup", err)
|
|
return
|
|
}
|
|
routeDHCPDNS, dhcpErr := interceptDHCPNameserversForInterfaceFn(drIfaceName)
|
|
nativeFallback := false
|
|
ctx, cancel := context.WithTimeout(context.Background(), nativeTargetReadBudget)
|
|
defer cancel()
|
|
var nativeService nativeTargetService
|
|
readStatic := interceptCurrentStaticDNSFn
|
|
if dhcpErr != nil {
|
|
// Resolve the exact primary UUID before any expanded mutation. A
|
|
// device can have multiple services; the first listed is not proof.
|
|
var nativeErr error
|
|
nativeService, nativeErr = interceptNativeCLATDefaultServiceFn(ctx, drIfaceName)
|
|
nativeFallback = nativeErr == nil && nativeService.ID != "" && nativeService.Name != "" && nativeService.Device == drIfaceName && !hasIPv4DNS(routeDHCPDNS)
|
|
mainLog.Load().Debug().Err(nativeErr).Bool("native_clat_verified", nativeFallback).
|
|
Str("interface", drIfaceName).Msg("intercept DNS target: native fallback evidence")
|
|
}
|
|
if nativeFallback {
|
|
iface.Name = nativeService.Name
|
|
readStatic = func(iface *net.Interface) ([]string, error) { return interceptNativeStaticDNSFn(ctx, iface) }
|
|
} else if _, err := interceptPatchNetIfaceNameFn(iface); err != nil {
|
|
diagnostic.failed(decision, "service_lookup", err)
|
|
mainLog.Load().Debug().Err(err).Msgf("intercept DNS target: could not resolve network service for %s", drIfaceName)
|
|
return
|
|
}
|
|
decision.service = iface.Name
|
|
|
|
staticDNS, err := readStatic(iface)
|
|
if err != nil {
|
|
diagnostic.failed(decision, "static_dns", err)
|
|
mainLog.Load().Debug().Err(err).Msgf("intercept DNS target: could not read static DNS for %q", iface.Name)
|
|
return
|
|
}
|
|
// Retain the exact validated snapshot for backup and equality checks,
|
|
// including an owned non-loopback value that must never be snapshotted.
|
|
snapshot := staticDNS
|
|
if nativeFallback {
|
|
device, routeErr := interceptDefaultRouteInterfaceFn()
|
|
check, serviceErr := interceptNativeCLATDefaultServiceFn(ctx, drIfaceName)
|
|
if routeErr != nil || serviceErr != nil || device != drIfaceName || check != nativeService {
|
|
diagnostic.failed(decision, "native_recheck", errors.Join(routeErr, serviceErr))
|
|
return
|
|
}
|
|
current, readErr := readStatic(iface)
|
|
if readErr != nil || ctx.Err() != nil || !slices.Equal(current, snapshot) {
|
|
diagnostic.failed(decision, "static_recheck", errors.Join(readErr, ctx.Err()))
|
|
return
|
|
}
|
|
}
|
|
// Tracking is not proof that the target is still installed. An external
|
|
// edit (including clearing DNS) is not permission to reinstall it. Retain
|
|
// the cleanup record until shutdown, preventing periodic reconciliation
|
|
// from reinterpreting the same edit as a new DNS-less admission.
|
|
if p.interceptDNSTargetService == iface.Name && !isInterceptDNSTargetOnly(snapshot, p.interceptDNSTargetSetValue) && !hasIPv4DNS(filterOwnTarget(snapshot, p.interceptDNSTargetSetValue)) {
|
|
diagnostic.failed(decision, "owned_target_changed", nil)
|
|
return
|
|
}
|
|
// Never count ctrld's own previously-set entry as network-provided DNS,
|
|
// or the next recovery on the same DNS-less network would remove it and
|
|
// the one after re-add it.
|
|
if p.interceptDNSTargetService == iface.Name {
|
|
staticDNS = filterOwnTarget(staticDNS, p.interceptDNSTargetSetValue)
|
|
}
|
|
if hasIPv4DNS(staticDNS) {
|
|
resolvedReason = "static_ipv4_dns"
|
|
p.removeInterceptDNSTargetLocked("network has usable static IPv4 DNS")
|
|
return
|
|
}
|
|
if nativeFallback && slices.ContainsFunc(staticDNS, func(s string) bool {
|
|
ip := net.ParseIP(s)
|
|
return ip != nil && ip.To4() == nil && ip.IsLoopback()
|
|
}) {
|
|
// lo0 can serve another local resolver. The static backup reader
|
|
// discards loopback, so expanded admission cannot safely replace it.
|
|
resolvedReason = "static_loopback_dns"
|
|
return
|
|
}
|
|
|
|
if dhcpErr != nil && !nativeFallback {
|
|
diagnostic.failed(decision, "dhcp_dns", dhcpErr)
|
|
mainLog.Load().Debug().Err(dhcpErr).Msgf("intercept DNS target: could not read DHCP DNS for default-route service %q", iface.Name)
|
|
return
|
|
}
|
|
if hasIPv4DNS(routeDHCPDNS) {
|
|
resolvedReason = "dhcp_ipv4_dns"
|
|
// The default-route service regained DHCP option 6. Remove a target
|
|
// previously set on this or another service.
|
|
p.removeInterceptDNSTargetLocked("network has usable DHCP IPv4 DNS")
|
|
return
|
|
}
|
|
|
|
resolvedReason = "dns_less_network"
|
|
target := p.interceptDNSTargetValue()
|
|
if p.interceptDNSTargetService == iface.Name && p.interceptDNSTargetSetValue == target {
|
|
return // already set on this service
|
|
}
|
|
if nativeFallback {
|
|
owned := ""
|
|
if p.interceptDNSTargetService == iface.Name {
|
|
owned = p.interceptDNSTargetSetValue
|
|
}
|
|
if err := interceptSaveStaticDNSSnapshotFn(iface, snapshot, owned); err != nil {
|
|
return // no expanded mutation without a restorable backup
|
|
}
|
|
}
|
|
// Default route moved to a different DNS-less service (or the listener
|
|
// config changed): clear the stale entry first.
|
|
p.removeInterceptDNSTargetLocked("default route service changed")
|
|
if p.interceptDNSTargetService != "" {
|
|
return // cleanup failed; never overwrite ownership of the old service
|
|
}
|
|
|
|
// Preserve any existing (IPv6-only) static entries for later restore.
|
|
// saveCurrentStaticDNS filters loopback on write, and
|
|
// savedStaticNameservers filters loopback on read, so ctrld's own
|
|
// loopback target can never be recorded or restored as user DNS.
|
|
if !nativeFallback {
|
|
if err := interceptSaveCurrentStaticDNSFn(iface); err != nil {
|
|
mainLog.Load().Debug().Err(err).Msgf("intercept DNS target: could not save static DNS for %q", iface.Name)
|
|
}
|
|
}
|
|
p.setInterceptDNSTargetLocked(iface.Name, target)
|
|
if err := p.persistInterceptDNSTargetStateLocked(); err != nil {
|
|
p.setInterceptDNSTargetLocked("", "")
|
|
resolvedReason = ""
|
|
diagnostic.failed(decision, "ownership_persistence", err)
|
|
mainLog.Load().Warn().Err(err).Msg("intercept DNS target: not changing DNS without persisted ownership")
|
|
return
|
|
}
|
|
if err := interceptSetDNSFn(iface, []string{target}); err != nil {
|
|
// A command error can follow a partial OS mutation. Keep the durable
|
|
// cleanup record; the next observation decides what still belongs to us.
|
|
resolvedReason = ""
|
|
mainLog.Load().Warn().Err(err).Msgf("intercept DNS target: could not set %s on %q", target, iface.Name)
|
|
return
|
|
}
|
|
journal(mainLog.Load().Warn()).Str("service", iface.Name).Str("target", target).
|
|
Bool("native_clat_fallback", nativeFallback).
|
|
Str("reason", "dns_less_network").
|
|
Msgf("intercept DNS target: service %q provides no usable IPv4 DNS; set %s so macOS can emit DNS queries (removed automatically when the network provides IPv4 DNS)", iface.Name, target)
|
|
}
|
|
|
|
// removeInterceptDNSTarget removes a previously set intercept DNS target,
|
|
// restoring the service's saved static DNS (or empty). Safe no-op when no
|
|
// target was set.
|
|
func (p *prog) removeInterceptDNSTarget(reason string) {
|
|
p.interceptDNSTargetMu.Lock()
|
|
defer p.interceptDNSTargetMu.Unlock()
|
|
p.loadInterceptDNSTargetStateLocked()
|
|
p.removeInterceptDNSTargetLocked(reason)
|
|
}
|
|
|
|
// removeInterceptDNSTargetLocked is removeInterceptDNSTarget without locking;
|
|
// callers must hold interceptDNSTargetMu.
|
|
func (p *prog) removeInterceptDNSTargetLocked(reason string) {
|
|
svc := p.interceptDNSTargetService
|
|
val := p.interceptDNSTargetSetValue
|
|
if svc == "" {
|
|
return
|
|
}
|
|
iface := &net.Interface{Name: svc}
|
|
// Only remove what ctrld set. If the service's DNS changed externally,
|
|
// leave that value alone and discard our stale ownership record.
|
|
cur, err := interceptCurrentStaticDNSFn(iface)
|
|
if err != nil {
|
|
mainLog.Load().Debug().Err(err).Msgf("intercept DNS target: could not read %q DNS; retaining cleanup state (%s)", svc, reason)
|
|
return
|
|
}
|
|
if !isInterceptDNSTargetOnly(cur, val) {
|
|
// The generic stop/uninstall sweep must not replay a stale snapshot
|
|
// over the external edit. On failure keep ownership so its guard skips.
|
|
if err := interceptRemoveSavedDNSFn(iface); err != nil {
|
|
mainLog.Load().Warn().Err(err).Msg("intercept DNS target: could not discard stale backup; retaining cleanup state")
|
|
return
|
|
}
|
|
if !p.clearInterceptDNSTargetStateLocked() {
|
|
return
|
|
}
|
|
// ctrld owns the DNS of the service no longer, and a later outage
|
|
// report needs the moment that ownership ended.
|
|
journal(mainLog.Load().Info()).Str("service", svc).Str("target", val).
|
|
Str("reason", "external_change").
|
|
Msgf("intercept DNS target: %q DNS changed externally; not removing (%s)", svc, reason)
|
|
return
|
|
}
|
|
if saved := interceptSavedStaticNameserversFn(iface); len(saved) > 0 {
|
|
if err := interceptSetDNSFn(iface, saved); err != nil {
|
|
mainLog.Load().Warn().Err(err).Msgf("intercept DNS target: could not restore saved DNS on %q; retaining cleanup state", svc)
|
|
return
|
|
}
|
|
} else if err := interceptResetDNSIgnoreUnusableIfaceFn(iface); err != nil {
|
|
mainLog.Load().Warn().Err(err).Msgf("intercept DNS target: could not reset DNS on %q; retaining cleanup state", svc)
|
|
return
|
|
}
|
|
if !p.clearInterceptDNSTargetStateLocked() {
|
|
return
|
|
}
|
|
journal(mainLog.Load().Info()).Str("service", svc).Str("target", val).Str("reason", reason).
|
|
Msgf("intercept DNS target: removed %s from %q (%s)", val, svc, reason)
|
|
}
|
|
|
|
func (p *prog) interceptDNSTargetOwnershipLocked() dnsTargetOwnership {
|
|
return dnsTargetOwnership{p.interceptDNSTargetService, p.interceptDNSTargetSetValue}
|
|
}
|
|
|
|
func (p *prog) clearInterceptDNSTargetStateLocked() bool {
|
|
service, value := p.interceptDNSTargetService, p.interceptDNSTargetSetValue
|
|
p.setInterceptDNSTargetLocked("", "")
|
|
if err := p.persistInterceptDNSTargetStateLocked(); err != nil {
|
|
p.setInterceptDNSTargetLocked(service, value)
|
|
mainLog.Load().Warn().Err(err).Msg("intercept DNS target: could not clear ownership; retaining cleanup state")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// setInterceptDNSTargetLocked stores the service that ctrld owns and the value
|
|
// it wrote, and publishes the value for the readers that take no lock. Callers
|
|
// must hold interceptDNSTargetMu.
|
|
func (p *prog) setInterceptDNSTargetLocked(service, value string) {
|
|
p.interceptDNSTargetService = service
|
|
p.interceptDNSTargetSetValue = value
|
|
p.publishInterceptTarget(value)
|
|
}
|