mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
macOS: gate DNS-target fallback on native CLAT and diagnose IPv6 blocking
This commit is contained in:
1 parent
48fa94eb7f
commit
7613c05076
24 files changed
+2317
-36
No files matched your search
@@ -106,6 +106,8 @@ const (
|
||||
|
||||
// pfState holds the state of the pf DNS interception on macOS.
|
||||
type pfState struct {
|
||||
ipv6Diagnostic pfDiagnosticState
|
||||
|
||||
anchorFile string
|
||||
anchorName string
|
||||
// Serialized by prog.interceptDNSTargetMu; bounded to one failed decision.
|
||||
@@ -2426,6 +2428,7 @@ func (p *prog) probePFIntercept() pfProbeObservation {
|
||||
Str("outcome", observed.result.String()).Uint64("repeated_results", repeats).
|
||||
Bool("repair_eligible", observed.result == pfProbeNotIntercepted).
|
||||
Dur("elapsed_ms", time.Since(started)).Msg("DNS intercept probe result")
|
||||
p.logPFIPv6Diagnostic(observed, family)
|
||||
return observed
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"slices"
|
||||
|
||||
"tailscale.com/net/netmon"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
// interceptDNSTargetStateFile persists which service/value ctrld set, so a
|
||||
@@ -19,16 +20,21 @@ import (
|
||||
const interceptDNSTargetStateFile = ".intercept_dns_target"
|
||||
|
||||
var (
|
||||
interceptDNSTargetStatePathFn = func() string { return absHomeDir(interceptDNSTargetStateFile) }
|
||||
interceptDefaultRouteInterfaceFn = netmon.DefaultRouteInterface
|
||||
interceptInterfaceByNameFn = net.InterfaceByName
|
||||
interceptPatchNetIfaceNameFn = patchNetIfaceName
|
||||
interceptCurrentStaticDNSFn = currentStaticDNS
|
||||
interceptDNSTargetStatePathFn = func() string { return absHomeDir(interceptDNSTargetStateFile) }
|
||||
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(savedStaticDnsSettingsFilePath(iface), dns, owned)
|
||||
}
|
||||
interceptSaveCurrentStaticDNSFn = saveCurrentStaticDNS
|
||||
interceptSetDNSFn = setDNS
|
||||
interceptSavedStaticNameserversFn = savedStaticNameservers
|
||||
interceptResetDNSIgnoreUnusableIfaceFn = resetDnsIgnoreUnusableInterface
|
||||
interceptDHCPNameserversForInterfaceFn = ctrld.DHCPNameserversForInterface
|
||||
interceptDHCPNameserversForInterfaceFn = readTargetDHCPNameservers
|
||||
)
|
||||
|
||||
type interceptDNSTargetState struct {
|
||||
@@ -139,25 +145,53 @@ func (p *prog) ensureInterceptDNSTarget(systemDiscovery []string) {
|
||||
diagnostic.failed(decision, "interface_lookup", err)
|
||||
return
|
||||
}
|
||||
// Resolve the network service name (e.g. en5 -> "iPhone USB") so
|
||||
// networksetup operates on the right service.
|
||||
if _, err := interceptPatchNetIfaceNameFn(iface); err != nil {
|
||||
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 := interceptCurrentStaticDNSFn(iface)
|
||||
staticDNS, err := readStatic(iface)
|
||||
if err != nil {
|
||||
// Interfaces without a network service (utun/VPN tunnels) land here:
|
||||
// networksetup cannot address them, ctrld never writes to them, and
|
||||
// any target set on the underlying physical service stays in place —
|
||||
// still correct while ctrld runs.
|
||||
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
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
@@ -169,11 +203,19 @@ func (p *prog) ensureInterceptDNSTarget(systemDiscovery []string) {
|
||||
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
|
||||
}
|
||||
|
||||
routeDHCPDNS, err := interceptDHCPNameserversForInterfaceFn(drIfaceName)
|
||||
if err != nil {
|
||||
diagnostic.failed(decision, "dhcp_dns", err)
|
||||
mainLog.Load().Debug().Err(err).Msgf("intercept DNS target: could not read DHCP DNS for default-route service %q", iface.Name)
|
||||
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) {
|
||||
@@ -189,16 +231,30 @@ func (p *prog) ensureInterceptDNSTarget(systemDiscovery []string) {
|
||||
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 err := interceptSaveCurrentStaticDNSFn(iface); err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msgf("intercept DNS target: could not save static DNS for %q", iface.Name)
|
||||
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)
|
||||
}
|
||||
}
|
||||
if err := interceptSetDNSFn(iface, []string{target}); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msgf("intercept DNS target: could not set %s on %q", target, iface.Name)
|
||||
@@ -207,6 +263,7 @@ func (p *prog) ensureInterceptDNSTarget(systemDiscovery []string) {
|
||||
p.setInterceptDNSTargetLocked(iface.Name, target)
|
||||
p.persistInterceptDNSTargetStateLocked()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
@@ -20,6 +21,9 @@ type interceptTargetHarness struct {
|
||||
serviceByDev map[string]string
|
||||
dhcp []string
|
||||
dhcpErr error
|
||||
nativeCLAT bool
|
||||
nativeErr error
|
||||
saveErr error
|
||||
readErr error
|
||||
setErr error
|
||||
resetErr error
|
||||
@@ -47,6 +51,9 @@ func newInterceptTargetHarness(t *testing.T) *interceptTargetHarness {
|
||||
origSaved := interceptSavedStaticNameserversFn
|
||||
origReset := interceptResetDNSIgnoreUnusableIfaceFn
|
||||
origDHCP := interceptDHCPNameserversForInterfaceFn
|
||||
origNative := interceptNativeCLATDefaultServiceFn
|
||||
origNativeStatic := interceptNativeStaticDNSFn
|
||||
origSnapshot := interceptSaveStaticDNSSnapshotFn
|
||||
origIntercept := dnsIntercept
|
||||
t.Cleanup(func() {
|
||||
interceptDNSTargetStatePathFn = origPath
|
||||
@@ -59,9 +66,30 @@ func newInterceptTargetHarness(t *testing.T) *interceptTargetHarness {
|
||||
interceptSavedStaticNameserversFn = origSaved
|
||||
interceptResetDNSIgnoreUnusableIfaceFn = origReset
|
||||
interceptDHCPNameserversForInterfaceFn = origDHCP
|
||||
interceptNativeCLATDefaultServiceFn = origNative
|
||||
interceptNativeStaticDNSFn = origNativeStatic
|
||||
interceptSaveStaticDNSSnapshotFn = origSnapshot
|
||||
dnsIntercept = origIntercept
|
||||
})
|
||||
|
||||
interceptNativeCLATDefaultServiceFn = func(_ context.Context, device string) (nativeTargetService, error) {
|
||||
if !h.nativeCLAT || h.nativeErr != nil {
|
||||
return nativeTargetService{}, h.nativeErr
|
||||
}
|
||||
return nativeTargetService{ID: nativeTargetTestServiceID, Name: h.serviceByDev[device], Device: device}, nil
|
||||
}
|
||||
interceptNativeStaticDNSFn = func(_ context.Context, iface *net.Interface) ([]string, error) {
|
||||
return interceptCurrentStaticDNSFn(iface)
|
||||
}
|
||||
interceptSaveStaticDNSSnapshotFn = func(iface *net.Interface, snapshot []string, owned string) error {
|
||||
if h.saveErr != nil {
|
||||
return h.saveErr
|
||||
}
|
||||
if !isInterceptDNSTargetOnly(snapshot, owned) {
|
||||
h.saved[iface.Name] = slices.Clone(filterOwnTarget(snapshot, owned))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
dnsIntercept = true
|
||||
interceptDNSTargetStatePathFn = func() string { return h.statePath }
|
||||
interceptDefaultRouteInterfaceFn = func() (string, error) { return "en1", nil }
|
||||
@@ -81,6 +109,9 @@ func newInterceptTargetHarness(t *testing.T) *interceptTargetHarness {
|
||||
return slices.Clone(h.dns[iface.Name]), nil
|
||||
}
|
||||
interceptSaveCurrentStaticDNSFn = func(iface *net.Interface) error {
|
||||
if h.saveErr != nil {
|
||||
return h.saveErr
|
||||
}
|
||||
h.saved[iface.Name] = slices.Clone(h.dns[iface.Name])
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//go:build darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInterceptDNSTargetDHCPDeadline(t *testing.T) {
|
||||
if reflect.ValueOf(interceptDHCPNameserversForInterfaceFn).Pointer() != reflect.ValueOf(readTargetDHCPNameservers).Pointer() {
|
||||
t.Fatal("target discovery bypasses bounded DHCP reader")
|
||||
}
|
||||
h := newInterceptTargetHarness(t)
|
||||
h.dns["Wi-Fi"] = []string{"192.0.2.53"}
|
||||
original := targetDHCPReadFn
|
||||
t.Cleanup(func() { targetDHCPReadFn = original })
|
||||
entered := make(chan struct{})
|
||||
targetDHCPReadFn = func(ctx context.Context, device string) ([]string, error) {
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok || time.Until(deadline) > nativeTargetReadBudget || device != "en1" {
|
||||
t.Error("missing bounded context or wrong interface")
|
||||
close(entered)
|
||||
return nil, errors.New("invalid context")
|
||||
}
|
||||
close(entered)
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
interceptDHCPNameserversForInterfaceFn = readTargetDHCPNameservers
|
||||
p := newInterceptTargetProg()
|
||||
finished := make(chan struct{})
|
||||
go func() { p.ensureInterceptDNSTarget([]string{}); close(finished) }()
|
||||
<-entered
|
||||
cleaned := make(chan struct{})
|
||||
go func() { p.removeInterceptDNSTarget("test shutdown"); close(cleaned) }()
|
||||
select {
|
||||
case <-finished:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("DHCP lookup did not release reconciliation")
|
||||
}
|
||||
select {
|
||||
case <-cleaned:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("cleanup remained blocked after DHCP deadline")
|
||||
}
|
||||
if !slices.Equal(h.dns["Wi-Fi"], []string{"192.0.2.53"}) || len(h.setCalls) != 0 || len(h.resetCalls) != 0 {
|
||||
t.Fatal("deadline changed existing static DNS")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//go:build darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNativeCLATDecisionJournalIntegration(t *testing.T) {
|
||||
for _, local := range []bool{false, true} {
|
||||
name := "target installed"
|
||||
if local {
|
||||
name = "local resolver preserved"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
logs := captureDebugMainLog(t)
|
||||
h := newInterceptTargetHarness(t)
|
||||
p := newInterceptTargetProg()
|
||||
h.dhcpErr = errors.New("no DHCP packet")
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
wantField(t, oneRecoveryEvent(t, logs, dnsTargetFailedMessage), "stage", "dhcp_dns")
|
||||
h.nativeCLAT = true
|
||||
reason := "dns_less_network"
|
||||
action := "set"
|
||||
if local {
|
||||
h.dns["Wi-Fi"] = []string{"::1"}
|
||||
reason = "static_loopback_dns"
|
||||
action = "unchanged"
|
||||
}
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
event := oneRecoveryEvent(t, logs, dnsTargetResolvedMessage)
|
||||
wantField(t, event, "reason", reason)
|
||||
wantField(t, event, "action", action)
|
||||
wantField(t, event, "repeat_count", float64(1))
|
||||
if p.dnsInterceptState.(*pfState).targetDiagnostic.pending {
|
||||
t.Fatal("native decision left stale failure")
|
||||
}
|
||||
if local && (len(h.setCalls) != 0 || len(h.resetCalls) != 0 || h.dns["Wi-Fi"][0] != "::1") {
|
||||
t.Fatal("local resolver changed")
|
||||
}
|
||||
if !local && len(h.setCalls) != 1 {
|
||||
t.Fatal("native target not installed exactly once")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeCLATRecheckFailureJournal(t *testing.T) {
|
||||
for _, static := range []bool{false, true} {
|
||||
name := "native recheck"
|
||||
if static {
|
||||
name = "static recheck"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
logs := captureDebugMainLog(t)
|
||||
h := newInterceptTargetHarness(t)
|
||||
h.dhcpErr = errors.New("no DHCP packet")
|
||||
h.nativeCLAT = true
|
||||
p := newInterceptTargetProg()
|
||||
stage := "native_recheck"
|
||||
calls := 0
|
||||
if static {
|
||||
stage = "static_recheck"
|
||||
orig := interceptNativeStaticDNSFn
|
||||
interceptNativeStaticDNSFn = func(ctx context.Context, i *net.Interface) ([]string, error) {
|
||||
calls++
|
||||
if calls%2 == 0 {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
return orig(ctx, i)
|
||||
}
|
||||
} else {
|
||||
orig := interceptNativeCLATDefaultServiceFn
|
||||
interceptNativeCLATDefaultServiceFn = func(ctx context.Context, device string) (nativeTargetService, error) {
|
||||
calls++
|
||||
if calls%2 == 0 {
|
||||
return nativeTargetService{}, context.DeadlineExceeded
|
||||
}
|
||||
return orig(ctx, device)
|
||||
}
|
||||
}
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
event := oneRecoveryEvent(t, logs, dnsTargetFailedMessage)
|
||||
wantField(t, event, "stage", stage)
|
||||
wantField(t, event, "error_class", "timeout")
|
||||
if len(h.setCalls) != 0 || len(h.resetCalls) != 0 {
|
||||
t.Fatal("failed recheck changed DNS")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// One budget covers the entire native evidence transaction, including
|
||||
// both identity/static rechecks; it is not renewed for each subprocess.
|
||||
nativeTargetReadBudget = 2 * time.Second
|
||||
nativeTargetMaxOutput = 64 * 1024
|
||||
)
|
||||
|
||||
var errNativeTargetOutputLimit = errors.New("native DNS evidence output exceeds limit")
|
||||
|
||||
type nativeTargetOutput struct{ buf bytes.Buffer }
|
||||
|
||||
func (w *nativeTargetOutput) Write(p []byte) (int, error) {
|
||||
if len(p) > nativeTargetMaxOutput-w.buf.Len() {
|
||||
return 0, errNativeTargetOutputLimit
|
||||
}
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
func runNativeTargetCommand(ctx context.Context, input, path string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.Stdin = strings.NewReader(input)
|
||||
var out nativeTargetOutput
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = io.Discard
|
||||
// Also bound waiting for inherited pipes if a child outlives the command.
|
||||
cmd.WaitDelay = 100 * time.Millisecond
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type nativeTargetService struct {
|
||||
ID, Name, Device string
|
||||
}
|
||||
|
||||
type nativeTargetReader struct {
|
||||
run func(context.Context, string, string, ...string) ([]byte, error)
|
||||
interfaceByName func(string) (*net.Interface, error)
|
||||
interfaceAddrs func(*net.Interface) ([]net.Addr, error)
|
||||
}
|
||||
|
||||
func (r nativeTargetReader) store(ctx context.Context, key string) (map[string]string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := r.run(ctx, "show "+key+"\nquit\n", "/usr/sbin/scutil")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseNativeIPv4Store(string(out))
|
||||
}
|
||||
|
||||
// The service root contains only the configured name. Reject multiline or
|
||||
// unfamiliar layouts rather than searching arbitrary DHCP/SSID/packet text.
|
||||
// The UUID in the key comes exclusively from the validated Global dictionary.
|
||||
func parseNativeServiceName(out string) (string, error) {
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
if len(lines) != 3 || lines[0] != "<dictionary> {" || lines[2] != "}" {
|
||||
return "", errors.New("invalid native service dictionary")
|
||||
}
|
||||
name, ok := strings.CutPrefix(strings.TrimLeft(lines[1], " \t"), "UserDefinedName : ")
|
||||
if !ok || name == "" || strings.ContainsAny(name, "\r\n\x00/{}") {
|
||||
return "", errors.New("invalid native service name")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// defaultService returns the actual primary service, not the first service
|
||||
// networksetup happens to list for the device. Unknown or changing state is
|
||||
// never absence evidence. The caller must carry and recheck the whole tuple.
|
||||
func (r nativeTargetReader) defaultService(ctx context.Context, device string) (nativeTargetService, error) {
|
||||
unknown := nativeTargetService{}
|
||||
before, err := r.store(ctx, "State:/Network/Global/IPv4")
|
||||
if err != nil {
|
||||
return unknown, err
|
||||
}
|
||||
id := before["PrimaryService"]
|
||||
if !nativeServiceID.MatchString(id) || before["PrimaryInterface"] != device {
|
||||
return unknown, nil
|
||||
}
|
||||
state, err := r.store(ctx, "State:/Network/Service/"+id+"/IPv4")
|
||||
if err != nil {
|
||||
return unknown, err
|
||||
}
|
||||
if state["CLAT46"] != "TRUE" || state["InterfaceName"] != device || state["Router"] != "192.0.0.1" {
|
||||
return unknown, nil
|
||||
}
|
||||
iface, err := r.interfaceByName(device)
|
||||
if err != nil {
|
||||
return unknown, err
|
||||
}
|
||||
if iface == nil || iface.Name != device || iface.Flags&net.FlagUp == 0 {
|
||||
return unknown, nil
|
||||
}
|
||||
addrs, err := r.interfaceAddrs(iface)
|
||||
if err != nil || !nativeCLATAddresses(addrs) {
|
||||
return unknown, err
|
||||
}
|
||||
published := strings.Fields(state["AddressList"])
|
||||
if len(published) != 1 {
|
||||
return unknown, nil
|
||||
}
|
||||
matched := false
|
||||
for _, addr := range addrs {
|
||||
ip, _, parseErr := net.ParseCIDR(addr.String())
|
||||
if parseErr == nil && ip.String() == published[0] {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return unknown, nil
|
||||
}
|
||||
out, err := r.run(ctx, "show Setup:/Network/Service/"+id+"\nquit\n", "/usr/sbin/scutil")
|
||||
if err != nil {
|
||||
return unknown, err
|
||||
}
|
||||
name, err := parseNativeServiceName(string(out))
|
||||
if err != nil {
|
||||
return unknown, err
|
||||
}
|
||||
after, err := r.store(ctx, "State:/Network/Global/IPv4")
|
||||
if err != nil {
|
||||
return unknown, err
|
||||
}
|
||||
if after["PrimaryService"] != id || after["PrimaryInterface"] != device {
|
||||
return unknown, errors.New("default service changed during CLAT discovery")
|
||||
}
|
||||
return nativeTargetService{ID: id, Name: name, Device: device}, nil
|
||||
}
|
||||
|
||||
// saveTargetStaticDNSSnapshot saves only the already validated/rechecked
|
||||
// snapshot. Never re-read via the legacy permissive static DNS reader, and
|
||||
// never replace the original backup with ctrld's own (possibly non-loopback)
|
||||
// target when a listener changes after restart.
|
||||
func saveTargetStaticDNSSnapshot(path string, snapshot []string, owned string) error {
|
||||
if isInterceptDNSTargetOnly(snapshot, owned) {
|
||||
// Missing means the original service used automatic DNS. A present
|
||||
// but unreadable/corrupt backup must not authorize a listener change.
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, s := range strings.Split(string(data), ",") {
|
||||
if net.ParseIP(s) == nil || s == owned {
|
||||
return errors.New("invalid retained static DNS backup")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var dns []string
|
||||
for _, s := range snapshot {
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
return errors.New("invalid static DNS snapshot")
|
||||
}
|
||||
if s == owned {
|
||||
continue
|
||||
}
|
||||
// The restore reader filters loopbacks. Refuse an unrestorable
|
||||
// user snapshot rather than silently discarding a local resolver.
|
||||
if ip.IsLoopback() {
|
||||
return errors.New("non-owned loopback in static DNS snapshot")
|
||||
}
|
||||
dns = append(dns, ip.String())
|
||||
}
|
||||
if len(dns) == 0 {
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(path, []byte(strings.Join(dns, ",")), 0600)
|
||||
}
|
||||
|
||||
var nativeServiceID = regexp.MustCompile(`^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$`)
|
||||
|
||||
// parseNativeIPv4Store reads only the two native IPv4 dictionaries, never
|
||||
// ipconfig getsummary (which embeds unescaped, network-controlled DHCP text).
|
||||
// Unknown layouts fail closed. These fields are generated by configd from
|
||||
// interface names, service UUIDs, booleans and binary IP addresses.
|
||||
func parseNativeIPv4Store(out string) (map[string]string, error) {
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
if len(lines) < 2 || lines[0] != "<dictionary> {" || lines[len(lines)-1] != "}" {
|
||||
return nil, errors.New("invalid native IPv4 dictionary")
|
||||
}
|
||||
values := make(map[string]string)
|
||||
depth := 1
|
||||
arrayKey := ""
|
||||
for _, line := range lines[1 : len(lines)-1] {
|
||||
s := strings.TrimSpace(line)
|
||||
if s == "}" {
|
||||
depth--
|
||||
if depth < 1 {
|
||||
return nil, errors.New("unbalanced native IPv4 dictionary")
|
||||
}
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(s, " : ")
|
||||
if !ok {
|
||||
return nil, errors.New("invalid native IPv4 field")
|
||||
}
|
||||
if depth == 1 {
|
||||
if _, exists := values[key]; exists {
|
||||
return nil, errors.New("duplicate native IPv4 field")
|
||||
}
|
||||
switch key {
|
||||
case "PrimaryService":
|
||||
if !nativeServiceID.MatchString(value) {
|
||||
return nil, errors.New("invalid service ID")
|
||||
}
|
||||
case "PrimaryInterface", "InterfaceName":
|
||||
if value == "" || strings.ContainsAny(value, " \t\r\n{}") {
|
||||
return nil, errors.New("invalid interface")
|
||||
}
|
||||
case "CLAT46":
|
||||
if value != "TRUE" && value != "FALSE" {
|
||||
return nil, errors.New("invalid CLAT flag")
|
||||
}
|
||||
case "Router":
|
||||
if net.ParseIP(value).To4() == nil {
|
||||
return nil, errors.New("invalid router")
|
||||
}
|
||||
case "Addresses", "SubnetMasks", "DestinationAddresses", "AdditionalRoutes", "ExcludedRoutes":
|
||||
if value != "<array> {" {
|
||||
return nil, errors.New("invalid IPv4 array")
|
||||
}
|
||||
default:
|
||||
return nil, errors.New("unknown native IPv4 field")
|
||||
}
|
||||
values[key] = value
|
||||
arrayKey = key
|
||||
} else if depth == 2 && arrayKey == "Addresses" {
|
||||
ip := net.ParseIP(value)
|
||||
if ip.To4() == nil {
|
||||
return nil, errors.New("invalid native IPv4 address")
|
||||
}
|
||||
values["AddressList"] += ip.String() + " "
|
||||
}
|
||||
if strings.HasSuffix(value, "{") {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
if depth != 1 {
|
||||
return nil, errors.New("unbalanced native IPv4 dictionary")
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// A successful networksetup exit alone is not absence evidence: reject all
|
||||
// text except IP literals and its exact no-static-DNS response.
|
||||
func parseTargetStaticDNS(out, service string) ([]string, error) {
|
||||
out = strings.TrimSpace(out)
|
||||
if out == "There aren't any DNS Servers set on "+service+"." {
|
||||
return nil, nil
|
||||
}
|
||||
if out == "" {
|
||||
return nil, errors.New("empty static DNS response")
|
||||
}
|
||||
var dns []string
|
||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
ip := net.ParseIP(strings.TrimSpace(scanner.Text()))
|
||||
if ip == nil {
|
||||
return nil, errors.New("invalid static DNS response")
|
||||
}
|
||||
dns = append(dns, ip.String())
|
||||
}
|
||||
return dns, scanner.Err()
|
||||
}
|
||||
|
||||
func nativeCLATAddresses(addrs []net.Addr) bool {
|
||||
clat, ipv6 := false, false
|
||||
for _, addr := range addrs {
|
||||
ip, _, err := net.ParseCIDR(addr.String())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
// Apple allocates one of these four CLAT addresses, not only 192.0.0.2.
|
||||
if v4[0] != 192 || v4[1] != 0 || v4[2] != 0 || v4[3] < 2 || v4[3] > 5 {
|
||||
return false
|
||||
}
|
||||
clat = true
|
||||
} else if ip.IsGlobalUnicast() {
|
||||
// ULA is legitimate IPv6 connectivity. Addresses alone never
|
||||
// authorize a target: configd must independently publish CLAT.
|
||||
ipv6 = true
|
||||
}
|
||||
}
|
||||
return clat && ipv6
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Captured macOS 15.7.7 CLAT dictionaries; UUID and interface IPv6 addresses
|
||||
// are sanitized. Keep the native field shapes, including nested routes.
|
||||
func TestCapturedCLATNativeReader(t *testing.T) {
|
||||
const id = "11111111-2222-3333-4444-555555555555"
|
||||
const global = `<dictionary> {
|
||||
PrimaryInterface : en0
|
||||
PrimaryService : 11111111-2222-3333-4444-555555555555
|
||||
Router : 192.0.0.1
|
||||
}`
|
||||
const state = `<dictionary> {
|
||||
AdditionalRoutes : <array> {
|
||||
0 : <dictionary> {
|
||||
DestinationAddress : 192.0.0.2
|
||||
SubnetMask : 255.255.255.255
|
||||
}
|
||||
}
|
||||
Addresses : <array> {
|
||||
0 : 192.0.0.2
|
||||
}
|
||||
CLAT46 : TRUE
|
||||
InterfaceName : en0
|
||||
Router : 192.0.0.1
|
||||
}`
|
||||
const setup = `<dictionary> {
|
||||
UserDefinedName : Wi-Fi
|
||||
}`
|
||||
iface := &net.Interface{Name: "en0", Flags: net.FlagUp | net.FlagRunning}
|
||||
var addrs []net.Addr
|
||||
for _, s := range []string{"192.0.0.2/32", "fe80::1/64", "2001:db8::1/64", "2001:db8::2/64", "2001:db8::3/64"} {
|
||||
ip, n, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n.IP = ip
|
||||
addrs = append(addrs, n)
|
||||
}
|
||||
calls := 0
|
||||
reader := nativeTargetReader{
|
||||
run: func(_ context.Context, input, path string, args ...string) ([]byte, error) {
|
||||
calls++
|
||||
if path != "/usr/sbin/scutil" || len(args) != 0 {
|
||||
t.Fatal("unexpected command")
|
||||
}
|
||||
switch strings.TrimSpace(input) {
|
||||
case "show State:/Network/Global/IPv4\nquit":
|
||||
return []byte(global), nil
|
||||
case "show State:/Network/Service/" + id + "/IPv4\nquit":
|
||||
return []byte(state), nil
|
||||
case "show Setup:/Network/Service/" + id + "\nquit":
|
||||
return []byte(setup), nil
|
||||
default:
|
||||
t.Fatal("unexpected key", input)
|
||||
return nil, nil
|
||||
}
|
||||
},
|
||||
interfaceByName: func(name string) (*net.Interface, error) {
|
||||
if name != "en0" {
|
||||
t.Fatal(name)
|
||||
}
|
||||
return iface, nil
|
||||
},
|
||||
interfaceAddrs: func(got *net.Interface) ([]net.Addr, error) {
|
||||
if got != iface {
|
||||
t.Fatal("wrong interface")
|
||||
}
|
||||
return addrs, nil
|
||||
},
|
||||
}
|
||||
got, err := reader.defaultService(context.Background(), "en0")
|
||||
want := nativeTargetService{ID: id, Name: "Wi-Fi", Device: "en0"}
|
||||
if err != nil || got != want || calls != 4 {
|
||||
t.Fatalf("got=%+v err=%v reads=%d", got, err, calls)
|
||||
}
|
||||
t.Log("Captured native dictionaries accepted; primary Wi-Fi service on en0 selected")
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
ctrld "github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
var targetDHCPReadFn = ctrld.DHCPNameserversForInterfaceContext
|
||||
|
||||
// Bound the DHCP prerequisite separately from the native evidence transaction.
|
||||
func readTargetDHCPNameservers(device string) ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nativeTargetReadBudget)
|
||||
defer cancel()
|
||||
return targetDHCPReadFn(ctx, device)
|
||||
}
|
||||
|
||||
func readTargetStaticDNS(iface *net.Interface) ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nativeTargetReadBudget)
|
||||
defer cancel()
|
||||
return readNativeTargetStaticDNS(ctx, iface)
|
||||
}
|
||||
|
||||
func readNativeTargetStaticDNS(ctx context.Context, iface *net.Interface) ([]string, error) {
|
||||
out, err := runNativeTargetCommand(ctx, "", "/usr/sbin/networksetup", "-getdnsservers", iface.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseTargetStaticDNS(string(out), iface.Name)
|
||||
}
|
||||
|
||||
func nativeCLATDefaultService(ctx context.Context, device string) (nativeTargetService, error) {
|
||||
return (nativeTargetReader{
|
||||
run: runNativeTargetCommand,
|
||||
interfaceByName: net.InterfaceByName,
|
||||
interfaceAddrs: (*net.Interface).Addrs,
|
||||
}).defaultService(ctx, device)
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//go:build darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNativeCLATTargetPreservesNonOwnedLoopback(t *testing.T) {
|
||||
for _, static := range [][]string{{"::1"}, {"::1", "2001:db8::53"}, {"127.0.0.1"}, {"127.0.0.1", "::1"}} {
|
||||
t.Run(strings.Join(static, ","), func(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
h.nativeCLAT = true
|
||||
h.dhcpErr = errors.New("no DHCPv4")
|
||||
h.dns["Wi-Fi"] = slices.Clone(static)
|
||||
backup := filepath.Join(t.TempDir(), ".dns_Wi-Fi")
|
||||
interceptSaveStaticDNSSnapshotFn = func(_ *net.Interface, snapshot []string, owned string) error {
|
||||
return saveTargetStaticDNSSnapshot(backup, snapshot, owned)
|
||||
}
|
||||
p := newInterceptTargetProg()
|
||||
p.cfg.Listener["0"].Port = 53
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
if len(h.setCalls) != 0 || len(h.resetCalls) != 0 || p.interceptDNSTargetService != "" {
|
||||
t.Fatalf("local resolver overwritten: set=%v reset=%v owner=%q", h.setCalls, h.resetCalls, p.interceptDNSTargetService)
|
||||
}
|
||||
p.removeInterceptDNSTarget("shutdown")
|
||||
if !slices.Equal(h.dns["Wi-Fi"], static) {
|
||||
t.Fatalf("lost user DNS: got %v, want %v", h.dns["Wi-Fi"], static)
|
||||
}
|
||||
if _, err := os.Stat(backup); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("backup unexpectedly changed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeCLATTargetRejectsLoopbackBeforeSnapshot(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
h.nativeCLAT = true
|
||||
h.dhcpErr = errors.New("no DHCPv4")
|
||||
h.dns["Wi-Fi"] = []string{"::1", "2001:db8::53"}
|
||||
h.dns["Old service"] = []string{"127.0.0.53"}
|
||||
interceptSaveStaticDNSSnapshotFn = func(*net.Interface, []string, string) error {
|
||||
t.Fatal("non-owned IPv6 loopback admitted to snapshot")
|
||||
return nil
|
||||
}
|
||||
p := newInterceptTargetProg()
|
||||
persistInterceptTargetForTest(t, p, "Old service", "127.0.0.53")
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
if len(h.setCalls) != 0 || len(h.resetCalls) != 0 || p.interceptDNSTargetService != "Old service" {
|
||||
t.Fatal("rejected admission changed old ownership")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeCLATTargetLoopbackKeepsIPv4CleanupFirst(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
h.nativeCLAT = true
|
||||
h.dhcpErr = errors.New("no DHCPv4")
|
||||
h.dns["Wi-Fi"] = []string{"::1", "127.0.0.1"}
|
||||
h.dns["Old service"] = []string{"127.0.0.53"}
|
||||
p := newInterceptTargetProg()
|
||||
persistInterceptTargetForTest(t, p, "Old service", "127.0.0.53")
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
if !slices.Equal(h.resetCalls, []string{"Old service"}) || len(h.setCalls) != 0 || p.interceptDNSTargetService != "" {
|
||||
t.Fatalf("IPv4 cleanup skipped: reset=%v set=%v owner=%q", h.resetCalls, h.setCalls, p.interceptDNSTargetService)
|
||||
}
|
||||
if !slices.Equal(h.dns["Wi-Fi"], []string{"::1", "127.0.0.1"}) {
|
||||
t.Fatal("external local resolvers changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeCLATTargetSnapshotRejectsNonOwnedLoopback(t *testing.T) {
|
||||
for _, snapshot := range [][]string{{"::1"}, {"2001:db8::53", "::1"}, {"127.0.0.1"}, {"127.0.0.53", "::1"}} {
|
||||
t.Run(strings.Join(snapshot, ","), func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "backup")
|
||||
const original = "2001:db8::54"
|
||||
if err := os.WriteFile(path, []byte(original), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := saveTargetStaticDNSSnapshot(path, snapshot, "127.0.0.53"); err == nil {
|
||||
t.Fatal("accepted unrestorable loopback snapshot")
|
||||
}
|
||||
if got, err := os.ReadFile(path); err != nil || string(got) != original {
|
||||
t.Fatalf("failed snapshot changed backup: %q %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, owned := range []string{"127.0.0.53", "::1", "10.0.0.53"} {
|
||||
t.Run("owned "+owned, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "backup")
|
||||
const original = "2001:db8::54"
|
||||
if err := os.WriteFile(path, []byte(original), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := saveTargetStaticDNSSnapshot(path, []string{owned}, owned); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := os.ReadFile(path); err != nil || string(got) != original {
|
||||
t.Fatalf("owned target replaced backup: %q %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeCLATTargetFallback(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
clat bool
|
||||
nativeErr, readErr, saveErr error
|
||||
static, dhcp []string
|
||||
want bool
|
||||
}{
|
||||
{name: "CLAT", clat: true, want: true},
|
||||
{name: "unknown"},
|
||||
{name: "native read error", clat: true, nativeErr: errors.New("read failed")},
|
||||
{name: "static read error", clat: true, readErr: errors.New("read failed")},
|
||||
{name: "backup error", clat: true, saveErr: errors.New("disk full")},
|
||||
{name: "static IPv4", clat: true, static: []string{"9.9.9.9"}},
|
||||
{name: "partial DHCP IPv4", clat: true, dhcp: []string{"1.1.1.1"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
h.dhcpErr = errors.New("getoption and getpacket exit 1")
|
||||
h.nativeCLAT, h.nativeErr, h.readErr, h.saveErr = tc.clat, tc.nativeErr, tc.readErr, tc.saveErr
|
||||
h.dns["Wi-Fi"], h.dhcp = tc.static, tc.dhcp
|
||||
p := newInterceptTargetProg()
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
if (len(h.setCalls) > 0) != tc.want || len(h.resetCalls) != 0 {
|
||||
t.Fatalf("set=%v reset=%v", h.setCalls, h.resetCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeCLATTargetUnknownPreservesStaticIPv4Cleanup(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
h.dhcpErr = errors.New("DHCP read failed")
|
||||
h.nativeErr = errors.New("native state unavailable")
|
||||
h.dns["Wi-Fi"] = []string{"192.0.2.53"}
|
||||
h.dns["Old service"] = []string{"127.0.0.53"}
|
||||
p := newInterceptTargetProg()
|
||||
persistInterceptTargetForTest(t, p, "Old service", "127.0.0.53")
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
if p.interceptDNSTargetService != "" || !slices.Equal(h.resetCalls, []string{"Old service"}) || len(h.setCalls) != 0 || !slices.Equal(h.dns["Wi-Fi"], []string{"192.0.2.53"}) {
|
||||
t.Fatalf("existing static IPv4 cleanup changed: owner=%q reset=%v set=%v", p.interceptDNSTargetService, h.resetCalls, h.setCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeCLATTargetTransitionAndReadFailure(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
p := newInterceptTargetProg()
|
||||
h.nativeCLAT = true
|
||||
h.dhcpErr = errors.New("no DHCPv4 packet")
|
||||
h.dns["Wi-Fi"] = []string{"2001:db8::53"}
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
if p.interceptDNSTargetService != "Wi-Fi" {
|
||||
t.Fatal("no target")
|
||||
}
|
||||
h.nativeErr = errors.New("store unavailable")
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
if p.interceptDNSTargetService != "Wi-Fi" || len(h.setCalls) != 1 || len(h.resetCalls) != 0 {
|
||||
t.Fatal("unknown changed ownership")
|
||||
}
|
||||
h.nativeErr = nil
|
||||
h.dhcpErr = nil
|
||||
h.dhcp = []string{"192.168.1.1"}
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
if p.interceptDNSTargetService != "" || !slices.Equal(h.dns["Wi-Fi"], []string{"2001:db8::53"}) {
|
||||
t.Fatal("dual stack did not restore IPv6 static DNS")
|
||||
}
|
||||
}
|
||||
|
||||
// Use the real evidence reader: en1 has two configured services, and the
|
||||
// second is primary. Legacy first-match mapping must not even be consulted.
|
||||
func TestNativeCLATTargetExactPrimaryService(t *testing.T) {
|
||||
for _, static := range [][]string{{"9.9.9.9"}, {"2001:db8::53"}} {
|
||||
t.Run(strings.Join(static, ","), func(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
h.dhcpErr = errors.New("no packet")
|
||||
h.serviceByDev["en1"] = "First Wi-Fi"
|
||||
h.dns["First Wi-Fi"] = nil
|
||||
h.dns["Primary Wi-Fi"] = slices.Clone(static)
|
||||
var calls []string
|
||||
outputs := append(nativeTargetTestOutputs(), nativeTargetTestOutputs()...)
|
||||
r := nativeTargetTestReader(t, outputs, &calls)
|
||||
interceptNativeCLATDefaultServiceFn = r.defaultService
|
||||
interceptPatchNetIfaceNameFn = func(*net.Interface) (bool, error) {
|
||||
t.Fatal("used first-match mapping for native fallback")
|
||||
return false, nil
|
||||
}
|
||||
var reads []string
|
||||
read := interceptNativeStaticDNSFn
|
||||
interceptNativeStaticDNSFn = func(ctx context.Context, iface *net.Interface) ([]string, error) {
|
||||
reads = append(reads, iface.Name)
|
||||
return read(ctx, iface)
|
||||
}
|
||||
p := newInterceptTargetProg()
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
if !slices.Equal(reads, []string{"Primary Wi-Fi", "Primary Wi-Fi"}) || len(calls) != 8 {
|
||||
t.Fatalf("reads=%v calls=%v", reads, calls)
|
||||
}
|
||||
if len(h.dns["First Wi-Fi"]) != 0 {
|
||||
t.Fatal("inactive service mutated")
|
||||
}
|
||||
if hasIPv4DNS(static) {
|
||||
if !slices.Equal(h.dns["Primary Wi-Fi"], static) || len(h.setCalls) != 0 || len(h.resetCalls) != 0 {
|
||||
t.Fatal("primary static IPv4 overwritten")
|
||||
}
|
||||
} else {
|
||||
if !slices.Equal(h.setCalls, []string{"Primary Wi-Fi"}) || p.interceptDNSTargetService != "Primary Wi-Fi" {
|
||||
t.Fatalf("set=%v ownership=%q", h.setCalls, p.interceptDNSTargetService)
|
||||
}
|
||||
p.removeInterceptDNSTarget("test shutdown")
|
||||
if !slices.Equal(h.dns["Primary Wi-Fi"], static) {
|
||||
t.Fatal("original static IPv6 not restored")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeCLATTargetIdentityRecheck(t *testing.T) {
|
||||
for _, field := range []string{"UUID", "name", "device", "read error", "static DNS"} {
|
||||
t.Run(field, func(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
h.nativeCLAT = true
|
||||
h.dhcpErr = errors.New("no packet")
|
||||
p := newInterceptTargetProg()
|
||||
persistInterceptTargetForTest(t, p, "Old service", "127.0.0.53")
|
||||
h.dns["Old service"] = []string{"127.0.0.53"}
|
||||
read := interceptNativeCLATDefaultServiceFn
|
||||
calls := 0
|
||||
var shared context.Context
|
||||
interceptNativeCLATDefaultServiceFn = func(ctx context.Context, device string) (nativeTargetService, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
shared = ctx
|
||||
} else if shared != ctx {
|
||||
t.Fatal("budget renewed on recheck")
|
||||
}
|
||||
svc, err := read(ctx, device)
|
||||
if calls == 2 {
|
||||
switch field {
|
||||
case "UUID":
|
||||
svc.ID = nativeTargetOtherServiceID
|
||||
case "name":
|
||||
svc.Name = "Other Wi-Fi"
|
||||
case "device":
|
||||
svc.Device = "en9"
|
||||
case "read error":
|
||||
err = errors.New("unknown")
|
||||
case "static DNS":
|
||||
h.dns["Wi-Fi"] = []string{"2001:db8::53"}
|
||||
}
|
||||
}
|
||||
return svc, err
|
||||
}
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
if calls != 2 || len(h.setCalls) != 0 || len(h.resetCalls) != 0 || p.interceptDNSTargetService != "Old service" {
|
||||
t.Fatalf("uncertain recheck mutated state: calls=%d set=%v reset=%v owner=%q", calls, h.setCalls, h.resetCalls, p.interceptDNSTargetService)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeCLATTargetRestartListenerChangePreservesBackup(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
h.nativeCLAT = true
|
||||
h.dhcpErr = errors.New("no packet")
|
||||
backup := filepath.Join(t.TempDir(), ".dns_Wi-Fi")
|
||||
original := "2001:db8::53"
|
||||
if err := os.WriteFile(backup, []byte(original), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(h.statePath, []byte(`{"service":"Wi-Fi","value":"10.0.0.53"}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.dns["Wi-Fi"] = []string{"10.0.0.53"}
|
||||
var snapshots [][]string
|
||||
interceptSaveStaticDNSSnapshotFn = func(_ *net.Interface, snapshot []string, owned string) error {
|
||||
snapshots = append(snapshots, slices.Clone(snapshot))
|
||||
return saveTargetStaticDNSSnapshot(backup, snapshot, owned)
|
||||
}
|
||||
interceptSaveCurrentStaticDNSFn = func(*net.Interface) error { t.Fatal("legacy unvalidated snapshot read"); return nil }
|
||||
interceptSavedStaticNameserversFn = func(*net.Interface) []string {
|
||||
data, err := os.ReadFile(backup)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return strings.Split(string(data), ",")
|
||||
}
|
||||
p := newInterceptTargetProg()
|
||||
p.cfg.Listener["0"].IP, p.cfg.Listener["0"].Port = "10.0.0.54", 53
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
if !slices.Equal(h.dns["Wi-Fi"], []string{"10.0.0.54"}) || p.interceptDNSTargetSetValue != "10.0.0.54" || len(snapshots) != 1 {
|
||||
t.Fatalf("listener migration failed: dns=%v target=%q snapshots=%v", h.dns["Wi-Fi"], p.interceptDNSTargetSetValue, snapshots)
|
||||
}
|
||||
if got, err := os.ReadFile(backup); err != nil || string(got) != original {
|
||||
t.Fatalf("original backup lost: %q %v", got, err)
|
||||
}
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
if len(h.setCalls) != 2 {
|
||||
t.Fatalf("target churn after reconciliation: %v", h.setCalls)
|
||||
}
|
||||
// A second restart must recover new ownership and still restore the original.
|
||||
restarted := newInterceptTargetProg()
|
||||
restarted.removeInterceptDNSTarget("shutdown")
|
||||
if !slices.Equal(h.dns["Wi-Fi"], []string{original}) || restarted.interceptDNSTargetService != "" {
|
||||
t.Fatalf("shutdown restored owned target: %v", h.dns["Wi-Fi"])
|
||||
}
|
||||
if _, err := os.Stat(h.statePath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("ownership not removed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeCLATTargetSnapshotFailureRetainsOwnership(t *testing.T) {
|
||||
for _, mode := range []string{"write", "remove", "cleanup"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
h.nativeCLAT = true
|
||||
h.dhcpErr = errors.New("no packet")
|
||||
p := newInterceptTargetProg()
|
||||
persistInterceptTargetForTest(t, p, "Old service", "10.0.0.53")
|
||||
h.dns["Old service"] = []string{"10.0.0.53"}
|
||||
path := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(path, "block"), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode == "write" {
|
||||
h.dns["Wi-Fi"] = []string{"2001:db8::53"}
|
||||
}
|
||||
if mode == "cleanup" {
|
||||
path = filepath.Join(t.TempDir(), "backup")
|
||||
h.resetErr = errors.New("cleanup failed")
|
||||
}
|
||||
interceptSaveStaticDNSSnapshotFn = func(_ *net.Interface, snapshot []string, owned string) error {
|
||||
return saveTargetStaticDNSSnapshot(path, snapshot, owned)
|
||||
}
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
if p.interceptDNSTargetService != "Old service" || len(h.setCalls) != 0 {
|
||||
t.Fatal("failed backup/cleanup lost old ownership")
|
||||
}
|
||||
if mode != "cleanup" && len(h.resetCalls) != 0 {
|
||||
t.Fatal("backup failure attempted cleanup")
|
||||
}
|
||||
if got := h.dns["Old service"]; !slices.Equal(got, []string{"10.0.0.53"}) {
|
||||
t.Fatal("old service changed", got)
|
||||
}
|
||||
if _, err := os.Stat(h.statePath); err != nil {
|
||||
t.Fatal("ownership file lost", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeCLATTargetRouteChange(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
p := newInterceptTargetProg()
|
||||
h.nativeCLAT = true
|
||||
h.dhcpErr = errors.New("no packet")
|
||||
calls := 0
|
||||
interceptDefaultRouteInterfaceFn = func() (string, error) {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
return "en9", nil
|
||||
}
|
||||
return "en1", nil
|
||||
}
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
if len(h.setCalls) != 0 || len(h.resetCalls) != 0 {
|
||||
t.Fatal("route change mutated DNS")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const nativeTargetTestServiceID = "22222222-2222-2222-2222-222222222222"
|
||||
const nativeTargetOtherServiceID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
func nativeTargetTestGlobal(id, device string) string {
|
||||
return fmt.Sprintf("<dictionary> {\n PrimaryInterface : %s\n PrimaryService : %s\n Router : 192.0.0.1\n}", device, id)
|
||||
}
|
||||
|
||||
func nativeTargetTestState() string {
|
||||
return "<dictionary> {\n CLAT46 : TRUE\n InterfaceName : en1\n Router : 192.0.0.1\n Addresses : <array> {\n 0 : 192.0.0.2\n }\n}"
|
||||
}
|
||||
|
||||
func nativeTargetTestAddrs(ips ...string) []net.Addr {
|
||||
var out []net.Addr
|
||||
for _, s := range ips {
|
||||
ip, n, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
n.IP = ip
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func nativeTargetTestReader(t *testing.T, outputs []string, calls *[]string) nativeTargetReader {
|
||||
t.Helper()
|
||||
return nativeTargetReader{
|
||||
run: func(ctx context.Context, input, path string, args ...string) ([]byte, error) {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if path != "/usr/sbin/scutil" || len(args) != 0 {
|
||||
t.Fatalf("unexpected command %q %v", path, args)
|
||||
}
|
||||
*calls = append(*calls, input)
|
||||
if len(*calls) > len(outputs) {
|
||||
t.Fatalf("unexpected read: %q", input)
|
||||
}
|
||||
return []byte(outputs[len(*calls)-1]), nil
|
||||
},
|
||||
interfaceByName: func(name string) (*net.Interface, error) { return &net.Interface{Name: name, Flags: net.FlagUp}, nil },
|
||||
interfaceAddrs: func(*net.Interface) ([]net.Addr, error) {
|
||||
return nativeTargetTestAddrs("192.0.0.2/32", "2001:db8::1/64"), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func nativeTargetTestOutputs() []string {
|
||||
global := nativeTargetTestGlobal(nativeTargetTestServiceID, "en1")
|
||||
return []string{global, nativeTargetTestState(), "<dictionary> {\n UserDefinedName : Primary Wi-Fi\n}", global}
|
||||
}
|
||||
|
||||
func TestNativeTargetReaderIdentityAndEvidence(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
change func(*nativeTargetReader, []string)
|
||||
want bool
|
||||
}{
|
||||
{name: "primary UUID exact service", want: true},
|
||||
{name: "ULA with native proof", want: true, change: func(r *nativeTargetReader, _ []string) {
|
||||
r.interfaceAddrs = func(*net.Interface) ([]net.Addr, error) {
|
||||
return nativeTargetTestAddrs("192.0.0.2/32", "fd12:3456::1/64"), nil
|
||||
}
|
||||
}},
|
||||
{name: "ULA without native proof", change: func(_ *nativeTargetReader, out []string) { out[1] = strings.Replace(out[1], "TRUE", "FALSE", 1) }},
|
||||
{name: "down interface", change: func(r *nativeTargetReader, _ []string) {
|
||||
r.interfaceByName = func(name string) (*net.Interface, error) { return &net.Interface{Name: name}, nil }
|
||||
}},
|
||||
{name: "missing interface", change: func(r *nativeTargetReader, _ []string) {
|
||||
r.interfaceByName = func(string) (*net.Interface, error) { return nil, errors.New("gone") }
|
||||
}},
|
||||
{name: "address error", change: func(r *nativeTargetReader, _ []string) {
|
||||
r.interfaceAddrs = func(*net.Interface) ([]net.Addr, error) { return nil, errors.New("gone") }
|
||||
}},
|
||||
{name: "ordinary IPv4", change: func(r *nativeTargetReader, _ []string) {
|
||||
r.interfaceAddrs = func(*net.Interface) ([]net.Addr, error) {
|
||||
return nativeTargetTestAddrs("192.0.0.2/32", "2001:db8::1/64", "10.0.0.2/24"), nil
|
||||
}
|
||||
}},
|
||||
{name: "published address mismatch", change: func(_ *nativeTargetReader, out []string) {
|
||||
out[1] = strings.Replace(out[1], "192.0.0.2", "192.0.0.5", 1)
|
||||
}},
|
||||
{name: "wrong device", change: func(_ *nativeTargetReader, out []string) {
|
||||
out[0] = nativeTargetTestGlobal(nativeTargetTestServiceID, "en9")
|
||||
}},
|
||||
{name: "wrong service device", change: func(_ *nativeTargetReader, out []string) { out[1] = strings.Replace(out[1], "en1", "en9", 1) }},
|
||||
{name: "invalid UUID", change: func(_ *nativeTargetReader, out []string) { out[0] = nativeTargetTestGlobal("untrusted", "en1") }},
|
||||
{name: "primary changed", change: func(_ *nativeTargetReader, out []string) {
|
||||
out[3] = nativeTargetTestGlobal(nativeTargetOtherServiceID, "en1")
|
||||
}},
|
||||
{name: "missing name", change: func(_ *nativeTargetReader, out []string) { out[2] = "No such key" }},
|
||||
{name: "DHCP embedded dictionary", change: func(_ *nativeTargetReader, out []string) { out[1] = "DHCPPacket : " + out[1] }},
|
||||
{name: "SSID embedded name", change: func(_ *nativeTargetReader, out []string) {
|
||||
out[2] = "<dictionary> {\n SSID : network\n UserDefinedName : Primary Wi-Fi\n}"
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := nativeTargetTestOutputs()
|
||||
var calls []string
|
||||
r := nativeTargetTestReader(t, out, &calls)
|
||||
if tc.change != nil {
|
||||
tc.change(&r, out)
|
||||
}
|
||||
got, err := r.defaultService(context.Background(), "en1")
|
||||
if (got.ID != "" && err == nil) != tc.want {
|
||||
t.Fatalf("service=%+v err=%v calls=%v", got, err, calls)
|
||||
}
|
||||
if tc.want {
|
||||
want := nativeTargetService{ID: nativeTargetTestServiceID, Name: "Primary Wi-Fi", Device: "en1"}
|
||||
if got != want {
|
||||
t.Fatalf("got %+v want %+v", got, want)
|
||||
}
|
||||
wantCalls := []string{"show State:/Network/Global/IPv4\nquit\n", "show State:/Network/Service/" + nativeTargetTestServiceID + "/IPv4\nquit\n", "show Setup:/Network/Service/" + nativeTargetTestServiceID + "\nquit\n", "show State:/Network/Global/IPv4\nquit\n"}
|
||||
if !slices.Equal(calls, wantCalls) {
|
||||
t.Fatalf("calls=%v", calls)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeTargetReaderFailuresAndSharedBudget(t *testing.T) {
|
||||
for failAt := 1; failAt <= 4; failAt++ {
|
||||
t.Run(fmt.Sprint(failAt), func(t *testing.T) {
|
||||
var calls []string
|
||||
r := nativeTargetTestReader(t, nativeTargetTestOutputs(), &calls)
|
||||
run := r.run
|
||||
count := 0
|
||||
r.run = func(ctx context.Context, input, path string, args ...string) ([]byte, error) {
|
||||
count++
|
||||
if count == failAt {
|
||||
return nil, errors.New("read failed")
|
||||
}
|
||||
return run(ctx, input, path, args...)
|
||||
}
|
||||
if got, err := r.defaultService(context.Background(), "en1"); got.ID != "" || err == nil || count != failAt {
|
||||
t.Fatalf("got=%v err=%v count=%d", got, err, count)
|
||||
}
|
||||
})
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
var calls []string
|
||||
r := nativeTargetTestReader(t, nativeTargetTestOutputs(), &calls)
|
||||
run := r.run
|
||||
r.run = func(actual context.Context, input, path string, args ...string) ([]byte, error) {
|
||||
if actual != ctx {
|
||||
t.Fatal("reader renewed the shared context")
|
||||
}
|
||||
out, err := run(actual, input, path, args...)
|
||||
if len(calls) == 2 {
|
||||
cancel()
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
if got, err := r.defaultService(ctx, "en1"); got.ID != "" || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("got=%v err=%v", got, err)
|
||||
}
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("reads after cancellation: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeServiceNameStrict(t *testing.T) {
|
||||
for _, name := range []string{"Wi-Fi", "iPhone USB", "Office : Wi-Fi", "Téléphone", "Wi-Fi ", " Wi-Fi"} {
|
||||
got, err := parseNativeServiceName("<dictionary> {\n UserDefinedName : " + name + "\n}")
|
||||
if err != nil || got != name {
|
||||
t.Fatalf("%q: %q %v", name, got, err)
|
||||
}
|
||||
}
|
||||
for _, out := range []string{"", "No such key", "<dictionary> {\n UserDefinedName : \n}", "<dictionary> {\n UserDefinedName : Wi-Fi\n UserDefinedName : Evil\n}", "<dictionary> {\n DHCPPacket : <dictionary> {\n UserDefinedName : Evil\n}\n}", "<dictionary> {\n UserDefinedName : ../bad\n}"} {
|
||||
if _, err := parseNativeServiceName(out); err == nil {
|
||||
t.Fatalf("accepted %q", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise the real exec runner without macOS tools or host network effects.
|
||||
func TestNativeTargetCommandHelper(t *testing.T) {
|
||||
i := slices.Index(os.Args, "--native-target-helper")
|
||||
if i < 0 {
|
||||
return
|
||||
}
|
||||
switch os.Args[i+1] {
|
||||
case "large":
|
||||
_, _ = os.Stdout.Write([]byte(strings.Repeat("x", nativeTargetMaxOutput+1)))
|
||||
case "exact":
|
||||
_, _ = os.Stdout.Write([]byte(strings.Repeat("x", nativeTargetMaxOutput)))
|
||||
case "slow":
|
||||
time.Sleep(10 * time.Second)
|
||||
case "fail":
|
||||
fmt.Print("<dictionary> {}")
|
||||
os.Exit(1)
|
||||
default:
|
||||
fmt.Print("ok")
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func TestNativeTargetCommandBounds(t *testing.T) {
|
||||
for _, mode := range []string{"ok", "exact", "large", "slow", "fail"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
budget := 3 * time.Second
|
||||
if mode == "slow" {
|
||||
budget = 100 * time.Millisecond
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), budget)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
out, err := runNativeTargetCommand(ctx, "", os.Args[0], "-test.run=^TestNativeTargetCommandHelper$", "--", "--native-target-helper", mode)
|
||||
if mode == "ok" || mode == "exact" {
|
||||
want := 2
|
||||
if mode == "exact" {
|
||||
want = nativeTargetMaxOutput
|
||||
}
|
||||
if err != nil || len(out) != want {
|
||||
t.Fatalf("len=%d err=%v", len(out), err)
|
||||
}
|
||||
} else if err == nil || out != nil {
|
||||
t.Fatalf("accepted incomplete evidence len=%d err=%v", len(out), err)
|
||||
}
|
||||
if time.Since(start) > budget+2*time.Second {
|
||||
t.Fatal("command exceeded deadline slack")
|
||||
}
|
||||
})
|
||||
}
|
||||
var out nativeTargetOutput
|
||||
if _, err := out.Write([]byte(strings.Repeat("x", nativeTargetMaxOutput))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := out.Write([]byte("x")); !errors.Is(err, errNativeTargetOutputLimit) || out.buf.Len() != nativeTargetMaxOutput {
|
||||
t.Fatalf("unbounded writer: %d %v", out.buf.Len(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveTargetStaticDNSSnapshotRetainedBackup(t *testing.T) {
|
||||
for _, content := range []string{"", "2001:db8::53", "corrupt", "10.0.0.53"} {
|
||||
t.Run(content, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "backup")
|
||||
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := saveTargetStaticDNSSnapshot(path, []string{"10.0.0.53"}, "10.0.0.53")
|
||||
wantErr := content == "corrupt" || content == "10.0.0.53"
|
||||
if (err != nil) != wantErr {
|
||||
t.Fatalf("retained %q: %v", content, err)
|
||||
}
|
||||
if got, readErr := os.ReadFile(path); readErr != nil || string(got) != content {
|
||||
t.Fatalf("retained backup changed: %q %v", got, readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := saveTargetStaticDNSSnapshot(t.TempDir(), []string{"10.0.0.53"}, "10.0.0.53"); err == nil {
|
||||
t.Fatal("unreadable retained backup accepted")
|
||||
}
|
||||
if err := saveTargetStaticDNSSnapshot(filepath.Join(t.TempDir(), "missing"), []string{"10.0.0.53"}, "10.0.0.53"); err != nil {
|
||||
t.Fatal("original automatic DNS rejected", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveTargetStaticDNSSnapshot(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "backup")
|
||||
original := "2001:db8::53"
|
||||
if err := saveTargetStaticDNSSnapshot(path, []string{original}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := saveTargetStaticDNSSnapshot(path, []string{"10.0.0.53"}, "10.0.0.53"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := os.ReadFile(path); err != nil || string(got) != original {
|
||||
t.Fatalf("owned value replaced original: %q %v", got, err)
|
||||
}
|
||||
if err := saveTargetStaticDNSSnapshot(path, []string{"bad"}, ""); err == nil {
|
||||
t.Fatal("invalid snapshot accepted")
|
||||
}
|
||||
if err := saveTargetStaticDNSSnapshot(path, nil, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("empty snapshot left stale backup")
|
||||
}
|
||||
if err := saveTargetStaticDNSSnapshot(path, nil, ""); err != nil {
|
||||
t.Fatal("missing backup removal should succeed", err)
|
||||
}
|
||||
if err := os.Mkdir(path, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(path, "block"), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := saveTargetStaticDNSSnapshot(path, nil, ""); err == nil {
|
||||
t.Fatal("remove failure swallowed")
|
||||
}
|
||||
if err := saveTargetStaticDNSSnapshot(path, []string{original}, ""); err == nil {
|
||||
t.Fatal("write failure swallowed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNativeIPv4Store(t *testing.T) {
|
||||
valid := "<dictionary> {\n CLAT46 : TRUE\n InterfaceName : en7\n Router : 192.0.0.1\n Addresses : <array> {\n 0 : 192.0.0.2\n }\n}"
|
||||
if got, err := parseNativeIPv4Store(valid); err != nil || got["CLAT46"] != "TRUE" {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
for _, bad := range []string{"", "No such key", strings.Replace(valid, "TRUE", "1", 1), strings.Replace(valid, "InterfaceName", "DHCPPacket", 1), strings.Replace(valid, " Router", " CLAT46 : TRUE\n Router", 1), valid + "\n<dictionary> {}", "<dictionary> {\nPrimaryService : bad\n}"} {
|
||||
if _, err := parseNativeIPv4Store(bad); err == nil {
|
||||
t.Fatalf("accepted %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetStaticDNSStrict(t *testing.T) {
|
||||
for _, good := range []string{"There aren't any DNS Servers set on Wi-Fi.", "2001:db8::53\n", "1.1.1.1\n2001:db8::53"} {
|
||||
if _, err := parseTargetStaticDNS(good, "Wi-Fi"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "read failed", "There aren't any DNS Servers set on Other.", "1.1.1.1\nunexpected"} {
|
||||
if _, err := parseTargetStaticDNS(bad, "Wi-Fi"); err == nil {
|
||||
t.Fatalf("accepted %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeCLATAddresses(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
ips []string
|
||||
want bool
|
||||
}{
|
||||
{[]string{"192.0.0.2/32", "2001:db8::1/64"}, true},
|
||||
{[]string{"192.0.0.5/32", "2001:db8::1/64", "fe80::1/64"}, true},
|
||||
{[]string{"192.0.0.2/32", "2001:db8::1/64", "192.168.1.2/24"}, false},
|
||||
{[]string{"192.0.0.2/32", "fe80::1/64"}, false},
|
||||
{[]string{"2001:db8::1/64"}, false},
|
||||
} {
|
||||
var addrs []net.Addr
|
||||
for _, s := range tc.ips {
|
||||
ip, n, _ := net.ParseCIDR(s)
|
||||
n.IP = ip
|
||||
addrs = append(addrs, n)
|
||||
}
|
||||
if got := nativeCLATAddresses(addrs); got != tc.want {
|
||||
t.Fatalf("%v: %v", tc.ips, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
pfDiagnosticInterval = time.Minute
|
||||
pfDiagnosticTimeout = 500 * time.Millisecond
|
||||
pfDiagnosticMaxBytes = 64 << 10
|
||||
)
|
||||
|
||||
// Only these two exact, ctrld-generated rules are identified. PF's @N indexes
|
||||
// are not stable identifiers. No rule text or other anchor contents are logged.
|
||||
var pfDiagnosticRule = regexp.MustCompile(`^@[0-9]+ block drop out quick on ! ?lo0 inet6 proto (udp|tcp) from any to any port = 53$`)
|
||||
var pfDiagnosticCounters = regexp.MustCompile(`^\[ Evaluations: +([0-9]+) +Packets: +([0-9]+) +Bytes: +([0-9]+) +States: +([0-9]+) +\]$`)
|
||||
|
||||
type pfDiagnosticRuleCount struct {
|
||||
Installed bool
|
||||
Known bool
|
||||
Packets uint64
|
||||
}
|
||||
|
||||
type pfDiagnosticSnapshot struct {
|
||||
Code string
|
||||
UDP, TCP pfDiagnosticRuleCount
|
||||
RouteV4, RouteV6 string
|
||||
AddressCode string
|
||||
NativeIPv4, CLAT bool
|
||||
}
|
||||
|
||||
// A missing or unrecognized rule is not a zero counter. A counter describes all
|
||||
// traffic matching that rule since its last reset, not delivery of our UDP4 probe.
|
||||
func parsePFDiagnostic(data []byte) pfDiagnosticSnapshot {
|
||||
s := pfDiagnosticSnapshot{Code: "rules_not_observed"}
|
||||
var current *pfDiagnosticRuleCount
|
||||
duplicates := false
|
||||
for _, raw := range strings.Split(string(data), "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(line, "@") {
|
||||
current = nil
|
||||
if m := pfDiagnosticRule.FindStringSubmatch(line); m != nil {
|
||||
current = &s.UDP
|
||||
if m[1] == "tcp" {
|
||||
current = &s.TCP
|
||||
}
|
||||
if current.Installed {
|
||||
duplicates = true
|
||||
}
|
||||
current.Installed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[ Evaluations:") {
|
||||
m := pfDiagnosticCounters.FindStringSubmatch(line)
|
||||
if m != nil {
|
||||
valid := true
|
||||
for _, value := range m[1:] {
|
||||
if _, err := strconv.ParseUint(value, 10, 64); err != nil {
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
current.Packets, _ = strconv.ParseUint(m[2], 10, 64)
|
||||
current.Known = true
|
||||
}
|
||||
}
|
||||
current = nil // never attach a later rule's counters to this rule
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case duplicates:
|
||||
s.Code = "ambiguous_rules"
|
||||
s.UDP.Known, s.TCP.Known = false, false
|
||||
case s.UDP.Known && s.TCP.Known:
|
||||
s.Code = "ok"
|
||||
case s.UDP.Installed || s.TCP.Installed:
|
||||
s.Code = "partial_or_malformed"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var errPFDiagnosticLimit = errors.New("PF diagnostic output limit")
|
||||
|
||||
type pfDiagnosticBuffer struct {
|
||||
data bytes.Buffer // do not embed: promoted ReadFrom would bypass Write's cap
|
||||
limited bool
|
||||
}
|
||||
|
||||
func (b *pfDiagnosticBuffer) Len() int { return b.data.Len() }
|
||||
|
||||
func (b *pfDiagnosticBuffer) Write(p []byte) (int, error) {
|
||||
if len(p) > pfDiagnosticMaxBytes-b.Len() {
|
||||
b.limited = true
|
||||
return 0, errPFDiagnosticLimit
|
||||
}
|
||||
return b.data.Write(p)
|
||||
}
|
||||
|
||||
// Stdout is bounded, stderr is discarded, and no raw command error escapes.
|
||||
// WaitDelay also bounds inherited stdout pipes if a helper fails to close them.
|
||||
func runPFDiagnosticCommand(ctx context.Context, cmd *exec.Cmd) pfDiagnosticSnapshot {
|
||||
var out pfDiagnosticBuffer
|
||||
cmd.Stdout, cmd.Stderr = &out, io.Discard
|
||||
cmd.WaitDelay = 100 * time.Millisecond
|
||||
err := cmd.Run()
|
||||
code := ""
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
code = pfProbeContextCode(ctx)
|
||||
case out.limited:
|
||||
code = "output_limit"
|
||||
case err != nil:
|
||||
code = "read_failed"
|
||||
}
|
||||
if code != "" {
|
||||
return pfDiagnosticSnapshot{Code: code}
|
||||
}
|
||||
return parsePFDiagnostic(out.data.Bytes())
|
||||
}
|
||||
|
||||
type pfDiagnosticEvent struct {
|
||||
Snapshot pfDiagnosticSnapshot
|
||||
Observation pfProbeObservation
|
||||
Generation uint64
|
||||
Age time.Duration
|
||||
Cached bool
|
||||
UDPDelta, TCPDelta uint64
|
||||
UDPDeltaCode, TCPDeltaCode string
|
||||
}
|
||||
|
||||
// Deltas are shared-traffic observations, never per-probe attribution. Even an
|
||||
// increasing counter may span an unobserved reload, so it is not an exact total.
|
||||
func pfDiagnosticDelta(before, after pfDiagnosticRuleCount) (uint64, string) {
|
||||
if !before.Known || !after.Known {
|
||||
return 0, "unknown"
|
||||
}
|
||||
if after.Packets < before.Packets {
|
||||
return 0, "reset"
|
||||
}
|
||||
return after.Packets - before.Packets, "shared_traffic"
|
||||
}
|
||||
|
||||
type pfDiagnosticState struct {
|
||||
mu sync.Mutex
|
||||
active bool
|
||||
captured time.Time
|
||||
generation uint64
|
||||
snapshot pfDiagnosticSnapshot
|
||||
lastCondition pfProbeCondition
|
||||
}
|
||||
|
||||
// Healthy polls do no work. Failures capture at most once a minute; a restoration
|
||||
// inside that interval names the cached capture and its age, not a fresh reading.
|
||||
// Identical outcomes and snapshots are quiet. This state never controls repair.
|
||||
func (s *pfDiagnosticState) observe(now time.Time, o pfProbeObservation, capture func() pfDiagnosticSnapshot) (pfDiagnosticEvent, bool) {
|
||||
if o.code == "canceled" || !s.mu.TryLock() {
|
||||
return pfDiagnosticEvent{}, false
|
||||
}
|
||||
defer s.mu.Unlock()
|
||||
if o.result == pfProbeIntercepted && !s.active {
|
||||
return pfDiagnosticEvent{}, false
|
||||
}
|
||||
condition := pfProbeCondition{o.result, o.stage, o.code, o.target}
|
||||
e := pfDiagnosticEvent{Observation: o, Cached: true, UDPDeltaCode: "unknown", TCPDeltaCode: "unknown"}
|
||||
changed := condition != s.lastCondition
|
||||
if s.captured.IsZero() || now.Sub(s.captured) >= pfDiagnosticInterval {
|
||||
next := capture()
|
||||
changed = changed || next != s.snapshot
|
||||
e.UDPDelta, e.UDPDeltaCode = pfDiagnosticDelta(s.snapshot.UDP, next.UDP)
|
||||
e.TCPDelta, e.TCPDeltaCode = pfDiagnosticDelta(s.snapshot.TCP, next.TCP)
|
||||
s.snapshot, s.captured = next, now
|
||||
s.generation++
|
||||
e.Cached = false
|
||||
}
|
||||
s.active = o.result != pfProbeIntercepted
|
||||
s.lastCondition = condition
|
||||
e.Snapshot, e.Generation, e.Age = s.snapshot, s.generation, now.Sub(s.captured)
|
||||
return e, changed
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This is the only command added by diagnostics: read only the exact ctrld
|
||||
// anchor, never recurse or dump system rules/states. Tests replace this seam
|
||||
// before driving the real probe, including on privileged native runners.
|
||||
var capturePFDiagnosticFn = capturePFDiagnostic
|
||||
|
||||
func capturePFDiagnostic() pfDiagnosticSnapshot {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), pfDiagnosticTimeout)
|
||||
defer cancel()
|
||||
s := runPFDiagnosticCommand(ctx, exec.CommandContext(ctx, "/sbin/pfctl", "-a", pfAnchorName, "-vvsr"))
|
||||
v4, v6 := defaultRoutesFn()
|
||||
s.RouteV4, s.RouteV6 = v4.Interface, v6.Interface
|
||||
state, err := readNetworkSourceStateFn()
|
||||
s.AddressCode = "read_failed"
|
||||
if err == nil && state != nil {
|
||||
s.CLAT = dns64StateHasCLAT(state)
|
||||
s.AddressCode = "default_v4_unavailable"
|
||||
if iface, ok := state.Interface[v4.Interface]; ok && iface.Interface != nil && iface.IsUp() {
|
||||
s.AddressCode = "ok"
|
||||
for _, prefix := range state.InterfaceIPs[v4.Interface] {
|
||||
addr := prefix.Addr().Unmap()
|
||||
if addr.Is4() && !clatPrefix.Contains(addr) && !addr.IsLoopback() && !addr.IsLinkLocalUnicast() && !addr.IsUnspecified() && !addr.IsMulticast() {
|
||||
s.NativeIPv4 = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *prog) logPFIPv6Diagnostic(o pfProbeObservation, family string) {
|
||||
state, ok := p.dnsInterceptState.(*pfState)
|
||||
if !ok || state == nil {
|
||||
return
|
||||
}
|
||||
e, emit := state.ipv6Diagnostic.observe(time.Now(), o, capturePFDiagnosticFn)
|
||||
if !emit {
|
||||
return
|
||||
}
|
||||
logPFDiagnostic(e, family)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//go:build darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func stubPFDiagnosticCapture(t *testing.T) *int {
|
||||
t.Helper()
|
||||
old := capturePFDiagnosticFn
|
||||
t.Cleanup(func() { capturePFDiagnosticFn = old })
|
||||
calls := 0
|
||||
capturePFDiagnosticFn = func() pfDiagnosticSnapshot {
|
||||
calls++
|
||||
s := parsePFDiagnostic([]byte(pfDiagnosticFixture))
|
||||
s.RouteV4, s.RouteV6, s.AddressCode = "en0", "en0", "ok"
|
||||
s.CLAT = true
|
||||
return s
|
||||
}
|
||||
return &calls
|
||||
}
|
||||
|
||||
// Exercise the production probe -> diagnostic handoff, without executing a PF
|
||||
// command or sending DNS. Removing that handoff must make this test fail.
|
||||
func TestPFDiagnosticProbeWiringAndPrivacy(t *testing.T) {
|
||||
reads := stubPFDiagnosticCapture(t)
|
||||
old := pfProbeNameservers
|
||||
t.Cleanup(func() { pfProbeNameservers = old; pfProbeLogs = pfProbeLogState{} })
|
||||
pfProbeNameservers = func() []string { return []string{"[fe80::1%en0]:53", "76.76.2.0:53"} }
|
||||
p := &prog{dnsInterceptState: &pfState{}}
|
||||
p.recoveryGen.Store(7)
|
||||
before := len(logOutput.String())
|
||||
p.probePFIntercept()
|
||||
p.probePFIntercept()
|
||||
p.logPFIPv6Diagnostic(pfProbeObservation{result: pfProbeIntercepted, recoveryGeneration: 7, stage: "received", target: "192.0.2.1:53"}, "ipv4")
|
||||
p.logPFIPv6Diagnostic(pfProbeObservation{result: pfProbeIntercepted}, "ipv4")
|
||||
var events []map[string]any
|
||||
for _, line := range strings.Split(logOutput.String()[before:], "\n") {
|
||||
var e map[string]any
|
||||
if json.Unmarshal([]byte(line), &e) != nil || e["message"] != "DNS intercept IPv6 block diagnostic" {
|
||||
continue
|
||||
}
|
||||
events = append(events, e)
|
||||
for _, forbidden := range []string{"_pf-probe", "Owner", "Inserted", "Bytes:", "from any", "probe_id", "client_address"} {
|
||||
if strings.Contains(line, forbidden) {
|
||||
t.Fatalf("raw payload escaped: %s", forbidden)
|
||||
}
|
||||
}
|
||||
if e["counter_scope"] != "shared_traffic_not_probe_attribution" || e["ipv6_dns_udp_packets"] != float64(812) || e["recovery_generation"] != float64(7) {
|
||||
t.Fatalf("missing evidence: %v", e)
|
||||
}
|
||||
}
|
||||
if *reads != 1 || len(events) != 2 {
|
||||
t.Fatalf("reads=%d events=%v", *reads, events)
|
||||
}
|
||||
if events[0]["outcome"] != "indeterminate" || events[1]["outcome"] != "intercepted" || events[1]["capture_cached"] != true {
|
||||
t.Fatalf("missing restore: %v", events)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cli
|
||||
|
||||
import "github.com/rs/zerolog"
|
||||
|
||||
func logPFDiagnosticRule(e *zerolog.Event, id string, r pfDiagnosticRuleCount, delta uint64, deltaCode string) {
|
||||
// false means this exact rule was not observed, not proof no IPv6 block exists.
|
||||
e.Bool(id+"_rule_observed", r.Installed).Bool(id+"_counter_known", r.Known)
|
||||
if r.Known {
|
||||
e.Uint64(id+"_packets", r.Packets)
|
||||
}
|
||||
e.Str(id+"_delta_status", deltaCode)
|
||||
if deltaCode == "shared_traffic" {
|
||||
e.Uint64(id+"_packet_delta", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func logPFDiagnostic(e pfDiagnosticEvent, family string) {
|
||||
o := e.Observation
|
||||
s := e.Snapshot
|
||||
// Recovery generation and target associate this snapshot with the existing
|
||||
// probe-result event without repeating the probe's DNS query name.
|
||||
probeFamily := "none"
|
||||
if o.stage != "target" && o.target != "" && family == "ipv4" {
|
||||
probeFamily = "udp4"
|
||||
}
|
||||
log := mainLog.Load().Debug().
|
||||
Uint64("capture_generation", e.Generation).
|
||||
Dur("capture_age_ms", e.Age).Bool("capture_cached", e.Cached).
|
||||
Uint64("recovery_generation", o.recoveryGeneration).
|
||||
Str("resolver_target", o.target).Str("resolver_family", family).
|
||||
Str("probe_transport", probeFamily).Str("outcome", o.result.String()).
|
||||
Str("stage", o.stage).Str("probe_error_code", o.code).
|
||||
Str("pf_read_status", s.Code).
|
||||
Str("counter_scope", "shared_traffic_not_probe_attribution").
|
||||
Str("default_route_v4_interface", s.RouteV4).
|
||||
Str("default_route_v6_interface", s.RouteV6).
|
||||
Str("address_status", s.AddressCode)
|
||||
if s.AddressCode == "ok" || s.AddressCode == "default_v4_unavailable" {
|
||||
log.Bool("clat_address_present", s.CLAT)
|
||||
}
|
||||
if s.AddressCode == "ok" {
|
||||
log.Bool("native_ipv4_on_default_interface", s.NativeIPv4)
|
||||
}
|
||||
logPFDiagnosticRule(log, "ipv6_dns_udp", s.UDP, e.UDPDelta, e.UDPDeltaCode)
|
||||
logPFDiagnosticRule(log, "ipv6_dns_tcp", s.TCP, e.TCPDelta, e.TCPDeltaCode)
|
||||
log.Msg("DNS intercept IPv6 block diagnostic")
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPFDiagnosticLogAllowlist(t *testing.T) {
|
||||
// Windows tests can replace the package's initial logger before this test.
|
||||
previous := mainLog.Load()
|
||||
replacement := previous.Output(io.Discard)
|
||||
mainLog.Store(&replacement)
|
||||
t.Cleanup(func() { mainLog.Store(previous) })
|
||||
|
||||
for _, code := range []string{"ok", "read_failed", "partial_or_malformed"} {
|
||||
t.Run(code, func(t *testing.T) {
|
||||
s := parsePFDiagnostic([]byte(pfDiagnosticFixture))
|
||||
s.Code, s.AddressCode = code, "read_failed"
|
||||
if code != "ok" {
|
||||
s.UDP.Known, s.TCP.Known = false, false
|
||||
}
|
||||
e := pfDiagnosticEvent{Snapshot: s, Generation: 3, UDPDeltaCode: "unknown", TCPDeltaCode: "unknown",
|
||||
Observation: pfProbeObservation{result: pfProbeNotIntercepted, probeID: "private-query.invalid", target: "192.0.2.1:53", recoveryGeneration: 7, stage: "delivery", code: "timeout"}}
|
||||
output := captureDebugMainLog(t)
|
||||
logPFDiagnostic(e, "ipv4")
|
||||
line := strings.TrimSpace(output.String())
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forbidden := range []string{"private-query", "probe_id", "Owner", "Inserted", "from any", "Bytes:"} {
|
||||
if strings.Contains(line, forbidden) {
|
||||
t.Fatalf("raw payload escaped: %s", forbidden)
|
||||
}
|
||||
}
|
||||
if event["pf_read_status"] != code || event["probe_transport"] != "udp4" || event["capture_generation"] != float64(3) || event["recovery_generation"] != float64(7) || event["counter_scope"] != "shared_traffic_not_probe_attribution" {
|
||||
t.Fatalf("missing evidence: %v", event)
|
||||
}
|
||||
for _, field := range []string{"ipv6_dns_udp_packets", "ipv6_dns_tcp_packets"} {
|
||||
_, exists := event[field]
|
||||
if exists != (code == "ok") {
|
||||
t.Fatalf("unknown counters serialized as zero: %v", event)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"ipv6_dns_udp_packet_delta", "ipv6_dns_tcp_packet_delta", "clat_address_present", "native_ipv4_on_default_interface"} {
|
||||
if _, exists := event[field]; exists {
|
||||
t.Fatalf("unknown evidence serialized: %s", field)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Native-format provenance: macOS pfctl's @N / Owner / Evaluations / Inserted
|
||||
// layout is published at https://apple.stackexchange.com/questions/335019
|
||||
// (pfctl -vvv -s all); the counter layout is also reported on macOS 26.3 at
|
||||
// https://apple.stackexchange.com/questions/486150 (pfctl -s rules -v).
|
||||
// This is an ADAPTED fixture, not a capture from a ctrld host: headers use the
|
||||
// two expanded forms of generatePFRules' IPv6 DNS block, and numbers are test
|
||||
// values. Keep the native spacing/metadata; do not use pf.conf input as output.
|
||||
const pfDiagnosticFixture = `@30 block drop out quick on ! lo0 inet6 proto udp from any to any port = 53
|
||||
[ Owner : nil Priority : 0 ]
|
||||
[ Evaluations: 574091 Packets: 812 Bytes: 51968 States: 0 ]
|
||||
[ Inserted: uid 0 pid 68 ]
|
||||
@31 block drop out quick on ! lo0 inet6 proto tcp from any to any port = 53
|
||||
[ Owner : nil Priority : 0 ]
|
||||
[ Evaluations: 573678 Packets: 210 Bytes: 13152 States: 0 ]
|
||||
[ Inserted: uid 0 pid 68 ]
|
||||
`
|
||||
|
||||
func TestPFDiagnosticNativeFormat(t *testing.T) {
|
||||
s := parsePFDiagnostic([]byte(pfDiagnosticFixture))
|
||||
if s.Code != "ok" || !s.UDP.Installed || !s.TCP.Installed || !s.UDP.Known || !s.TCP.Known || s.UDP.Packets != 812 || s.TCP.Packets != 210 {
|
||||
t.Fatalf("native-format fixture: %+v", s)
|
||||
}
|
||||
// IDs and metadata do not become logged identity, and need not be stable.
|
||||
changed := strings.NewReplacer("@30", "@1", "@31", "@2", "! lo0", "!lo0", "pid 68", "pid 99").Replace(pfDiagnosticFixture)
|
||||
if got := parsePFDiagnostic([]byte(changed)); got != s {
|
||||
t.Fatalf("unstable identity: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPFDiagnosticUnknownIsNotZero(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, input, code string
|
||||
installed bool
|
||||
}{
|
||||
{"empty", "", "rules_not_observed", false},
|
||||
{"unrelated", strings.ReplaceAll(pfDiagnosticFixture, "inet6", "inet"), "rules_not_observed", false},
|
||||
{"other interface", strings.ReplaceAll(pfDiagnosticFixture, "! lo0", "en0"), "rules_not_observed", false},
|
||||
{"not exact", strings.ReplaceAll(pfDiagnosticFixture, "port = 53", "port = 53 label private"), "rules_not_observed", false},
|
||||
{"malformed", strings.ReplaceAll(pfDiagnosticFixture, "Packets:", "Packets: invalid"), "partial_or_malformed", true},
|
||||
{"overflow", strings.ReplaceAll(pfDiagnosticFixture, "Packets:", "Packets: 18446744073709551616"), "partial_or_malformed", true},
|
||||
{"duplicate", pfDiagnosticFixture + pfDiagnosticFixture, "ambiguous_rules", true},
|
||||
{"no counters", strings.Split(pfDiagnosticFixture, "\n")[0], "partial_or_malformed", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := parsePFDiagnostic([]byte(tc.input))
|
||||
if s.Code != tc.code || s.UDP.Installed != tc.installed || s.UDP.Known || s.TCP.Known {
|
||||
t.Fatalf("unknown misreported: %+v", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
zero := strings.NewReplacer("812", "0", "210", "0").Replace(pfDiagnosticFixture)
|
||||
if s := parsePFDiagnostic([]byte(zero)); s.Code != "ok" || !s.UDP.Known || s.UDP.Packets != 0 {
|
||||
t.Fatalf("real zero: %+v", s)
|
||||
}
|
||||
// A missing UDP counter must not consume the following unrelated rule's one.
|
||||
input := strings.Split(pfDiagnosticFixture, "\n")[0] + "\n@32 pass out all\n [ Evaluations: 1 Packets: 900 Bytes: 100 States: 0 ]\n"
|
||||
if s := parsePFDiagnostic([]byte(input)); s.UDP.Known {
|
||||
t.Fatalf("borrowed unrelated counter: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPFDiagnosticProcess(t *testing.T) {
|
||||
switch os.Getenv("CTRLD_PF_DIAGNOSTIC_TEST") {
|
||||
case "":
|
||||
return
|
||||
case "ok":
|
||||
fmt.Print(pfDiagnosticFixture)
|
||||
case "failure":
|
||||
fmt.Print(pfDiagnosticFixture)
|
||||
fmt.Fprint(os.Stderr, "private rules/client-address")
|
||||
os.Exit(1)
|
||||
case "limit":
|
||||
fmt.Print(strings.Repeat("x", pfDiagnosticMaxBytes+1))
|
||||
case "hang":
|
||||
time.Sleep(time.Minute)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func pfDiagnosticTestCommand(ctx context.Context, mode string) *exec.Cmd {
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestPFDiagnosticProcess$")
|
||||
cmd.Env = append(os.Environ(), "CTRLD_PF_DIAGNOSTIC_TEST="+mode)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestPFDiagnosticCommandBounds(t *testing.T) {
|
||||
for _, tc := range []struct{ mode, code string }{{"ok", "ok"}, {"failure", "read_failed"}, {"limit", "output_limit"}, {"hang", "timeout"}} {
|
||||
t.Run(tc.mode, func(t *testing.T) {
|
||||
d := 5 * time.Second
|
||||
if tc.mode == "hang" {
|
||||
d = 100 * time.Millisecond
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
s := runPFDiagnosticCommand(ctx, pfDiagnosticTestCommand(ctx, tc.mode))
|
||||
if s.Code != tc.code {
|
||||
t.Fatalf("got %+v, want %s", s, tc.code)
|
||||
}
|
||||
if tc.mode != "ok" && (s.UDP.Known || s.TCP.Known || s.UDP.Installed) {
|
||||
t.Fatalf("failed read trusted: %+v", s)
|
||||
}
|
||||
if tc.mode == "hang" && time.Since(start) > time.Second {
|
||||
t.Fatal("deadline not bounded")
|
||||
}
|
||||
})
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if got := runPFDiagnosticCommand(ctx, pfDiagnosticTestCommand(ctx, "ok")); got.Code != "canceled" {
|
||||
t.Fatalf("cancellation: %+v", got)
|
||||
}
|
||||
if got := runPFDiagnosticCommand(context.Background(), exec.Command(t.TempDir()+"/missing")); got.Code != "read_failed" {
|
||||
t.Fatalf("start failure: %+v", got)
|
||||
}
|
||||
var b pfDiagnosticBuffer
|
||||
_, _ = b.Write(make([]byte, pfDiagnosticMaxBytes))
|
||||
if _, err := b.Write([]byte{1}); err == nil || b.Len() != pfDiagnosticMaxBytes {
|
||||
t.Fatal("buffer is unbounded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPFDiagnosticCadenceTransitionsAndReset(t *testing.T) {
|
||||
var s pfDiagnosticState
|
||||
now := time.Unix(1, 0)
|
||||
reads := 0
|
||||
fixture := parsePFDiagnostic([]byte(pfDiagnosticFixture))
|
||||
capture := func() pfDiagnosticSnapshot { reads++; return fixture }
|
||||
o := pfProbeObservation{result: pfProbeIntercepted, target: "192.0.2.1:53"}
|
||||
if _, emit := s.observe(now, o, capture); emit || reads != 0 {
|
||||
t.Fatal("healthy poll did work")
|
||||
}
|
||||
o.result, o.stage, o.code = pfProbeNotIntercepted, "delivery", "timeout"
|
||||
e, emit := s.observe(now, o, capture)
|
||||
if !emit || e.Cached || e.Generation != 1 || e.UDPDeltaCode != "unknown" {
|
||||
t.Fatalf("first failure: %+v %v", e, emit)
|
||||
}
|
||||
for i := 0; i < 100; i++ {
|
||||
o.probeID = fmt.Sprint(i)
|
||||
o.recoveryGeneration++
|
||||
if _, emit := s.observe(now.Add(time.Second), o, capture); emit {
|
||||
t.Fatal("duplicate failure noisy")
|
||||
}
|
||||
}
|
||||
if reads != 1 {
|
||||
t.Fatalf("unbounded reads: %d", reads)
|
||||
}
|
||||
if _, emit := s.observe(now.Add(time.Minute), o, capture); emit || reads != 2 {
|
||||
t.Fatal("unchanged refresh noisy")
|
||||
}
|
||||
fixture.UDP.Packets++
|
||||
e, emit = s.observe(now.Add(2*time.Minute), o, capture)
|
||||
if !emit || e.UDPDelta != 1 || e.UDPDeltaCode != "shared_traffic" {
|
||||
t.Fatalf("delta: %+v", e)
|
||||
}
|
||||
fixture.UDP.Packets = 0
|
||||
e, _ = s.observe(now.Add(3*time.Minute), o, capture)
|
||||
if e.UDPDeltaCode != "reset" || e.UDPDelta != 0 {
|
||||
t.Fatalf("reset: %+v", e)
|
||||
}
|
||||
o.result = pfProbeIntercepted
|
||||
e, emit = s.observe(now.Add(3*time.Minute+time.Second), o, capture)
|
||||
if !emit || !e.Cached || e.Age != time.Second || e.Generation != 4 || e.UDPDeltaCode != "unknown" {
|
||||
t.Fatalf("restored: %+v", e)
|
||||
}
|
||||
if _, emit := s.observe(now.Add(4*time.Minute), o, capture); emit || reads != 4 {
|
||||
t.Fatal("healthy repeats did work")
|
||||
}
|
||||
o.code = "canceled"
|
||||
o.result = pfProbeIndeterminate
|
||||
if _, emit := s.observe(now.Add(5*time.Minute), o, capture); emit || reads != 4 {
|
||||
t.Fatal("shutdown did work")
|
||||
}
|
||||
fixture.Code = "read_failed"
|
||||
fixture.UDP.Known = false
|
||||
fixture.TCP.Known = false
|
||||
o.code = "unavailable"
|
||||
e, emit = s.observe(now.Add(6*time.Minute), o, capture)
|
||||
if !emit || e.UDPDeltaCode != "unknown" {
|
||||
t.Fatalf("failed read: %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPFDiagnosticConcurrentCaptureDoesNotBlock(t *testing.T) {
|
||||
var s pfDiagnosticState
|
||||
started, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
s.observe(time.Now(), pfProbeObservation{}, func() pfDiagnosticSnapshot { close(started); <-release; return pfDiagnosticSnapshot{} })
|
||||
}()
|
||||
<-started
|
||||
defer func() { close(release); <-done }()
|
||||
if _, emit := s.observe(time.Now(), pfProbeObservation{}, func() pfDiagnosticSnapshot { t.Error("overlapping capture"); return pfDiagnosticSnapshot{} }); emit {
|
||||
t.Fatal("concurrent capture should stand down")
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,7 @@ func TestPFProbeRepairCorrelation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPFProbeMissingTargetLogTransitions(t *testing.T) {
|
||||
stubPFDiagnosticCapture(t)
|
||||
old := pfProbeNameservers
|
||||
t.Cleanup(func() { pfProbeNameservers = old; pfProbeLogs = pfProbeLogState{} })
|
||||
pfProbeLogs = pfProbeLogState{}
|
||||
@@ -146,6 +147,7 @@ func TestPFProbeMissingTargetLogTransitions(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPFProbeUnsentDoesNotReload(t *testing.T) {
|
||||
stubPFDiagnosticCapture(t)
|
||||
oldServers, oldCommand, oldReload := pfProbeNameservers, newPFProbeCommand, forceReloadPFInterceptFn
|
||||
t.Cleanup(func() {
|
||||
pfProbeNameservers, newPFProbeCommand, forceReloadPFInterceptFn = oldServers, oldCommand, oldReload
|
||||
@@ -203,6 +205,7 @@ func TestPFProbeWatchdogSkipsIndeterminateAnchor(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPFProbeNoIPv4TargetIsIndeterminate(t *testing.T) {
|
||||
stubPFDiagnosticCapture(t)
|
||||
old := pfProbeNameservers
|
||||
t.Cleanup(func() { pfProbeNameservers = old })
|
||||
pfProbeNameservers = func() []string { return []string{"[fe80::1%en0]:53", "76.76.2.0:53"} }
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# IPv6-only DNS-target validation
|
||||
|
||||
Relates to #617. Native CLAT/PF behavior is not proven by parser tests or cross-compilation. This change must not disable IPv6 DNS enforcement, change PF routing, or reclassify every failed DHCP command as empty DNS.
|
||||
|
||||
## Regression gates
|
||||
|
||||
- Ordinary static IPv4 DNS and successful DHCPv4 discovery retain their existing behavior.
|
||||
- An explicit non-owned IPv6 loopback resolver (`::1`), alone or mixed with other static DNS, prevents the expanded fallback. Its settings and backup must remain unchanged through reconciliation and cleanup.
|
||||
- The target-specific DHCP lookup shares a two-second deadline across both commands, with a 100 ms pipe-cleanup grace. Timeout remains an error, not confirmed absence. This does not impose a deadline on legacy global discovery or every operation under the target lock.
|
||||
- An unknown, truncated, malformed, timed-out or permission-denied native-state read authorizes no new DNS mutation.
|
||||
- A CLAT-looking address alone is insufficient. Positive native publication must match the current default interface/service, with no contradictory native IPv4 state.
|
||||
- Confirmed IPv6-only state can reach the existing listener-aware target installation path despite unavailable DHCPv4 data.
|
||||
- Repeated reconciliation is idempotent. Existing targets, external DNS changes, default-service switches, DHCP return and restart/stop cleanup preserve ownership.
|
||||
- A failed preservation snapshot cannot authorize the new fallback write.
|
||||
- Logs distinguish configured IPv6 blocking, observed rule counters, missing evidence, counter resets and successful local receipt. Shared counters cannot identify which packet was blocked.
|
||||
- Diagnostics are bounded, omit raw PF dumps/query names and do not cause DNS/PF changes or repeated repair actions.
|
||||
|
||||
## Native comparison
|
||||
|
||||
Use an owned disposable Mac with a local console, an identified baseline/candidate and the same profile/listener. Save exact original DNS settings. Do not run host-mutating tests on shared CI hosts; native unit tests must intercept the first mutation boundary.
|
||||
|
||||
1. Verify a healthy baseline on ordinary IPv4/DHCP, then establish actual host-side CLAT using an owned hotspot or isolated NAT64/PREF64 fixture. Record the interface/service mapping, native state, routes, static DNS, raw scutil DNS and both DHCP commands' exit/output. A carrier label or synthetic address assignment is not a fixture.
|
||||
2. Confirm upstream and direct-listener resolution independently. Keep a pre-existing manual loopback DNS override from masking the target decision, without deleting unrelated configuration.
|
||||
3. Compare startup already attached, disconnected startup followed by attachment, and a same-process transition to the IPv6-only network. Test sleep/wake separately and exclude intentionally offline/sleep periods from recovery measurements.
|
||||
4. On the candidate, require the appropriate temporary target and fresh system-resolution success while still on CLAT. Record any independent PF-probe failure rather than treating working system DNS as proof of repaired transparent interception.
|
||||
5. Return to IPv4/DHCP; verify target removal/restoration. Repeat with explicit static DNS, an existing tracked target, external DNS changes and an injected unknown/read-error state. Stop/uninstall must not leave DNS pointing to a dead listener.
|
||||
6. Read the retained diagnostics after a failed probe and after restored receipt. Distinguish missing rules/read errors from a present IPv6 block with cumulative traffic counters. A reload or rising shared counter does not prove a particular probe's fate.
|
||||
|
||||
Preserve captures privately and record the exact artifact. A successful owned-lab case supports the scoped fix; a customer retest remains a separate validation gate.
|
||||
@@ -252,6 +252,44 @@ A probe-triggered reload does not count as a missing-anchor repair.
|
||||
|
||||
See [PF DNS interception](pf-dns-intercept.md) and [DNS intercept mode](dns-intercept-mode.md) for the rule lifecycle.
|
||||
|
||||
## IPv6-only target fallback and PF diagnostics
|
||||
|
||||
On macOS, a failed DHCPv4 read remains unknown unless configd positively publishes
|
||||
CLAT for the current primary service/interface and the live interface addresses
|
||||
agree. A CLAT-looking address alone is insufficient. Unknown or unsupported native
|
||||
state leaves DNS unchanged. The fallback reuses the existing listener-aware target
|
||||
and cleanup paths; it does not change PF enforcement or treat every command error
|
||||
as empty DNS. Native output compatibility and the real hotspot scenario require
|
||||
[owned-Mac validation](ipv6-only-dns-target-qa.md).
|
||||
|
||||
`DNS intercept IPv6 block diagnostic` is a change-only debug event after unsuccessful
|
||||
probes and their first restored receipt. It reads only the ctrld anchor using
|
||||
`pfctl -a com.controld.ctrld -vvsr`, at most once per minute. The command has a
|
||||
500 ms deadline and a 64 KiB output cap. A restored receipt inside the interval can
|
||||
refer to the cached capture; `capture_cached`, `capture_age_ms` and
|
||||
`capture_generation` make this explicit. Collection is synchronous before the
|
||||
caller can repair: the 500 ms bound covers the command, not its pipe-cleanup grace
|
||||
or the subsequent kernel route/interface reads.
|
||||
|
||||
`pf_read_status` distinguishes unavailable/failed/unrecognized data from recognized
|
||||
rules and counters. `ipv6_dns_udp_rule_observed` and its TCP equivalent mean the
|
||||
exact generated rule was observed, not that it received the probe. A missing rule
|
||||
match does not prove that no other IPv6 blocking exists. Packet fields are emitted
|
||||
only when their counters are known. Delta status is `unknown`, `reset` or
|
||||
`shared_traffic`; decreases are never reported as negative packet counts.
|
||||
|
||||
Counters include all matching traffic, not just the probe, and unobserved reloads
|
||||
can reset them between captures. A counter increase does not establish that CLAT
|
||||
translated or PF blocked a particular packet. The event names the probe transport,
|
||||
resolver target/family, recovery generation and default-route interfaces.
|
||||
`clat_address_present` is an address hint, not the positive native evidence used to
|
||||
authorize DNS changes. Raw rules, customer query names and interface-address lists
|
||||
are not included.
|
||||
|
||||
These debug events do not guarantee journal retention. !1020 separately handles
|
||||
retained DNS-target decision failures. Reload completion, a configured block rule,
|
||||
and successful local probe receipt remain distinct observations.
|
||||
|
||||
## Reproduction boundaries
|
||||
|
||||
Compare the affected release with the exact candidate artifact on an owned host.
|
||||
|
||||
+10
-14
@@ -100,21 +100,17 @@ func getDHCPNameservers(iface string) ([]string, error) {
|
||||
return nil, fmt.Errorf("ipconfig not available on mobile")
|
||||
}
|
||||
|
||||
// getoption returns the selected interface's DHCP option directly and does
|
||||
// not expose unrelated packet addresses to the parser.
|
||||
output, err := exec.Command("ipconfig", "getoption", iface, "domain_name_server").Output()
|
||||
if err == nil {
|
||||
return parseDHCPOptionNameservers(output), nil
|
||||
}
|
||||
return dhcpNameserversFromCommands(context.Background(), iface, func(_ context.Context, args ...string) ([]byte, error) {
|
||||
return exec.Command("ipconfig", args...).Output()
|
||||
})
|
||||
}
|
||||
|
||||
// Older macOS releases can fail getoption while still exposing the packet.
|
||||
// Parse the real macOS field shape, for example:
|
||||
// domain_name_server (ip_mult): {192.168.1.1, 8.8.8.8}
|
||||
output, packetErr := exec.Command("ipconfig", "getpacket", iface).Output()
|
||||
if packetErr != nil {
|
||||
return nil, fmt.Errorf("error reading DHCP DNS option: getoption: %w; getpacket: %w", err, packetErr)
|
||||
}
|
||||
return parseDHCPPacketNameservers(output), nil
|
||||
// DHCPNameserversForInterfaceContext bounds both commands with the caller's
|
||||
// context. Other system-discovery callers retain their existing behavior.
|
||||
func DHCPNameserversForInterfaceContext(ctx context.Context, iface string) ([]string, error) {
|
||||
return dhcpNameserversFromCommands(ctx, iface, func(ctx context.Context, args ...string) ([]byte, error) {
|
||||
return dhcpCommandOutput(ctx, "/usr/sbin/ipconfig", args...)
|
||||
})
|
||||
}
|
||||
|
||||
// DHCPNameserversForInterface returns DHCP option 6 for exactly iface.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build darwin
|
||||
|
||||
package ctrld
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDHCPInterfaceContextCanceled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
got, err := DHCPNameserversForInterfaceContext(ctx, "unused-test-interface")
|
||||
if got != nil || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("canceled discovery: got=%v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package ctrld
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// dhcpNameserversFromCommands shares one context across both discovery attempts.
|
||||
// Cancellation is unknown state, never a successful empty DNS result.
|
||||
func dhcpNameserversFromCommands(ctx context.Context, iface string, run func(context.Context, ...string) ([]byte, error)) ([]string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output, optionErr := run(ctx, "getoption", iface, "domain_name_server")
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if optionErr == nil {
|
||||
return parseDHCPOptionNameservers(output), nil
|
||||
}
|
||||
output, packetErr := run(ctx, "getpacket", iface)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if packetErr != nil {
|
||||
return nil, fmt.Errorf("error reading DHCP DNS option: getoption: %w; getpacket: %w", optionErr, packetErr)
|
||||
}
|
||||
return parseDHCPPacketNameservers(output), nil
|
||||
}
|
||||
|
||||
func dhcpCommandOutput(ctx context.Context, path string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.WaitDelay = 100 * time.Millisecond
|
||||
output, err := cmd.Output()
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return output, err
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package ctrld
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDHCPNameserversCommandFallback(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, option, packet string
|
||||
optionErr, packetErr error
|
||||
want []string
|
||||
calls int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "option", option: "192.0.2.53\n", want: []string{"192.0.2.53"}, calls: 1},
|
||||
{name: "empty option", calls: 1},
|
||||
{name: "packet", optionErr: errors.New("exit 1"), packet: "domain_name_server (ip_mult): {192.0.2.53, 192.0.2.54}", want: []string{"192.0.2.53", "192.0.2.54"}, calls: 2},
|
||||
{name: "empty packet", optionErr: errors.New("exit 1"), calls: 2},
|
||||
{name: "both fail", optionErr: errors.New("exit 1"), packetErr: errors.New("exit 1"), calls: 2, wantErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calls := 0
|
||||
got, err := dhcpNameserversFromCommands(context.Background(), "en0", func(_ context.Context, args ...string) ([]byte, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
if !slices.Equal(args, []string{"getoption", "en0", "domain_name_server"}) {
|
||||
t.Fatal(args)
|
||||
}
|
||||
return []byte(tc.option), tc.optionErr
|
||||
}
|
||||
if !slices.Equal(args, []string{"getpacket", "en0"}) {
|
||||
t.Fatal(args)
|
||||
}
|
||||
return []byte(tc.packet), tc.packetErr
|
||||
})
|
||||
if calls != tc.calls || (err != nil) != tc.wantErr || !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("calls=%d got=%v err=%v", calls, got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDHCPNameserversRetainsCommandCauses(t *testing.T) {
|
||||
option, packet := &exec.ExitError{}, &exec.ExitError{}
|
||||
calls := 0
|
||||
_, err := dhcpNameserversFromCommands(context.Background(), "en0", func(context.Context, ...string) ([]byte, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return nil, option
|
||||
}
|
||||
return nil, packet
|
||||
})
|
||||
var exit *exec.ExitError
|
||||
if !errors.As(err, &exit) || !errors.Is(err, option) || !errors.Is(err, packet) {
|
||||
t.Fatalf("DHCP command causes were flattened: %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDHCPNameserversCancellation(t *testing.T) {
|
||||
for _, cancelAt := range []int{0, 1, 2} {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
calls := 0
|
||||
if cancelAt == 0 {
|
||||
cancel()
|
||||
}
|
||||
got, err := dhcpNameserversFromCommands(ctx, "en0", func(received context.Context, _ ...string) ([]byte, error) {
|
||||
if received != ctx {
|
||||
t.Fatal("discovery renewed the context")
|
||||
}
|
||||
calls++
|
||||
if calls == cancelAt {
|
||||
cancel()
|
||||
}
|
||||
return nil, errors.New("unavailable")
|
||||
})
|
||||
cancel()
|
||||
if !errors.Is(err, context.Canceled) || got != nil || calls != cancelAt {
|
||||
t.Fatalf("cancel at %d: got=%v err=%v calls=%d", cancelAt, got, err, calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDHCPCommandDeadlineHelper(t *testing.T) {
|
||||
if os.Getenv("CTRLD_DHCP_DEADLINE_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
time.Sleep(4 * time.Second)
|
||||
}
|
||||
|
||||
func TestDHCPCommandDeadline(t *testing.T) {
|
||||
t.Setenv("CTRLD_DHCP_DEADLINE_HELPER", "1")
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 750*time.Millisecond)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
_, err = dhcpCommandOutput(ctx, exe, "-test.run=^TestDHCPCommandDeadlineHelper$")
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed >= 3*time.Second {
|
||||
t.Fatalf("command ignored deadline: %v", elapsed)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user