macOS: port native CLAT DNS-target fallback to master

This commit is contained in:
Dev Scribe authored and Cuong Manh Le committed 2026-09-25 13:20:27 +07:00
1 parent 6e66ea82b4
commit 55190faaee
24 files changed
+2311 -35

No files matched your search

+3
View File
@@ -150,6 +150,8 @@ func writePFAnchorFile(rules string) error {
// 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.
@@ -2460,6 +2462,7 @@ func (p *prog) probePFIntercept() pfProbeObservation {
Str("outcome", observed.result.String()).Uint64("repeated_results", repeats).
Bool("repair_eligible", observed.result == pfProbeNotIntercepted).
Int64("elapsed_ms", time.Since(started).Milliseconds()).Msg("DNS intercept probe result")
p.logPFIPv6Diagnostic(observed, family)
return observed
}
+79 -20
View File
@@ -3,10 +3,13 @@
package cli
import (
"context"
"encoding/json"
"errors"
"net"
"os"
"path/filepath"
"slices"
"tailscale.com/net/netmon"
@@ -20,16 +23,21 @@ import (
const interceptDNSTargetStateFile = ".intercept_dns_target"
var (
interceptDNSTargetStatePathFn = interceptDNSTargetStatePath
interceptDefaultRouteInterfaceFn = netmon.DefaultRouteInterface
interceptInterfaceByNameFn = net.InterfaceByName
interceptPatchNetIfaceNameFn = patchNetIfaceName
interceptCurrentStaticDNSFn = currentStaticDNS
interceptDNSTargetStatePathFn = interceptDNSTargetStatePath
interceptDefaultRouteInterfaceFn = netmon.DefaultRouteInterface
interceptInterfaceByNameFn = net.InterfaceByName
interceptPatchNetIfaceNameFn = patchNetIfaceName
interceptCurrentStaticDNSFn = readTargetStaticDNS
interceptNativeCLATDefaultServiceFn = nativeCLATDefaultService
interceptNativeStaticDNSFn = readNativeTargetStaticDNS
interceptSaveStaticDNSSnapshotFn = func(iface *net.Interface, dns []string, owned string) error {
return saveTargetStaticDNSSnapshot(ctrld.SavedStaticDnsSettingsFilePath(iface), dns, owned)
}
interceptSaveCurrentStaticDNSFn = saveCurrentStaticDNS
interceptSetDNSFn = setDNS
interceptSavedStaticNameserversFn = ctrld.SavedStaticNameservers
interceptResetDNSIgnoreUnusableIfaceFn = resetDnsIgnoreUnusableInterface
interceptDHCPNameserversForInterfaceFn = ctrld.DHCPNameserversForInterface
interceptDHCPNameserversForInterfaceFn = readTargetDHCPNameservers
)
func interceptDNSTargetStatePath() string {
@@ -148,25 +156,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.
@@ -178,11 +214,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) {
@@ -198,16 +242,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)
@@ -216,6 +274,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)
}
+31
View File
@@ -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
}
+54
View File
@@ -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")
}
})
}
}
+317
View File
@@ -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
}
+84
View File
@@ -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")
}
+41
View File
@@ -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)
}
+380
View File
@@ -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")
}
}
+312
View File
@@ -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")
}
}
+55
View File
@@ -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)
}
}
}
+192
View File
@@ -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
}
+50
View File
@@ -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)
}
+63
View File
@@ -0,0 +1,63 @@
//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) {
logs := captureTransitionLogs(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)
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(logs.String(), "\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)
}
}
+47
View File
@@ -0,0 +1,47 @@
package cli
import ctrld "github.com/Control-D-Inc/ctrld"
func logPFDiagnosticRule(e *ctrld.LogEvent, 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).
Int64("capture_age_ms", e.Age.Milliseconds()).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")
}
+47
View File
@@ -0,0 +1,47 @@
package cli
import (
"encoding/json"
"strings"
"testing"
)
func TestPFDiagnosticLogAllowlist(t *testing.T) {
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)
}
}
})
}
}
+208
View File
@@ -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")
}
}
+3
View File
@@ -119,6 +119,7 @@ func TestPFProbeRepairCorrelation(t *testing.T) {
func TestPFProbeMissingTargetLogTransitions(t *testing.T) {
logs := captureTransitionLogs(t)
stubPFDiagnosticCapture(t)
old := pfProbeNameservers
t.Cleanup(func() { pfProbeNameservers = old; pfProbeLogs = pfProbeLogState{} })
pfProbeLogs = pfProbeLogState{}
@@ -147,6 +148,7 @@ func TestPFProbeMissingTargetLogTransitions(t *testing.T) {
func TestPFProbeUnsentDoesNotReload(t *testing.T) {
logs := captureTransitionLogs(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"} }
+29
View File
@@ -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.
+38
View File
@@ -254,6 +254,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 -15
View File
@@ -6,7 +6,6 @@ import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net"
"os/exec"
@@ -88,21 +87,17 @@ func getDNSFromScutil(ctx context.Context) []string {
}
func getDHCPNameservers(iface string) ([]string, error) {
// 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.
+18
View File
@@ -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)
}
}
+41
View File
@@ -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
}
+112
View File
@@ -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)
}
}