diff --git a/cmd/cli/dns_intercept_darwin.go b/cmd/cli/dns_intercept_darwin.go index 77b911e..68a7dbb 100644 --- a/cmd/cli/dns_intercept_darwin.go +++ b/cmd/cli/dns_intercept_darwin.go @@ -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 } diff --git a/cmd/cli/dns_target_darwin.go b/cmd/cli/dns_target_darwin.go index 28d6844..4f2fb32 100644 --- a/cmd/cli/dns_target_darwin.go +++ b/cmd/cli/dns_target_darwin.go @@ -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) } diff --git a/cmd/cli/dns_target_darwin_test.go b/cmd/cli/dns_target_darwin_test.go index 0bdb181..b22d9dd 100644 --- a/cmd/cli/dns_target_darwin_test.go +++ b/cmd/cli/dns_target_darwin_test.go @@ -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 } diff --git a/cmd/cli/dns_target_dhcp_darwin_test.go b/cmd/cli/dns_target_dhcp_darwin_test.go new file mode 100644 index 0000000..747409a --- /dev/null +++ b/cmd/cli/dns_target_dhcp_darwin_test.go @@ -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") + } +} diff --git a/cmd/cli/dns_target_integration_darwin_test.go b/cmd/cli/dns_target_integration_darwin_test.go new file mode 100644 index 0000000..3183850 --- /dev/null +++ b/cmd/cli/dns_target_integration_darwin_test.go @@ -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") + } + }) + } +} diff --git a/cmd/cli/dns_target_native.go b/cmd/cli/dns_target_native.go new file mode 100644 index 0000000..f07e033 --- /dev/null +++ b/cmd/cli/dns_target_native.go @@ -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] != " {" || 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] != " {" || 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 != " {" { + 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 +} diff --git a/cmd/cli/dns_target_native_capture_test.go b/cmd/cli/dns_target_native_capture_test.go new file mode 100644 index 0000000..084e9f7 --- /dev/null +++ b/cmd/cli/dns_target_native_capture_test.go @@ -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 = ` { + PrimaryInterface : en0 + PrimaryService : 11111111-2222-3333-4444-555555555555 + Router : 192.0.0.1 +}` + const state = ` { + AdditionalRoutes : { + 0 : { + DestinationAddress : 192.0.0.2 + SubnetMask : 255.255.255.255 + } + } + Addresses : { + 0 : 192.0.0.2 + } + CLAT46 : TRUE + InterfaceName : en0 + Router : 192.0.0.1 +}` + const setup = ` { + 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") +} diff --git a/cmd/cli/dns_target_native_darwin.go b/cmd/cli/dns_target_native_darwin.go new file mode 100644 index 0000000..a8700a4 --- /dev/null +++ b/cmd/cli/dns_target_native_darwin.go @@ -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) +} diff --git a/cmd/cli/dns_target_native_darwin_test.go b/cmd/cli/dns_target_native_darwin_test.go new file mode 100644 index 0000000..e6b866a --- /dev/null +++ b/cmd/cli/dns_target_native_darwin_test.go @@ -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") + } +} diff --git a/cmd/cli/dns_target_native_reader_test.go b/cmd/cli/dns_target_native_reader_test.go new file mode 100644 index 0000000..62c33e2 --- /dev/null +++ b/cmd/cli/dns_target_native_reader_test.go @@ -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(" {\n PrimaryInterface : %s\n PrimaryService : %s\n Router : 192.0.0.1\n}", device, id) +} + +func nativeTargetTestState() string { + return " {\n CLAT46 : TRUE\n InterfaceName : en1\n Router : 192.0.0.1\n Addresses : {\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(), " {\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] = " {\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(" {\n UserDefinedName : " + name + "\n}") + if err != nil || got != name { + t.Fatalf("%q: %q %v", name, got, err) + } + } + for _, out := range []string{"", "No such key", " {\n UserDefinedName : \n}", " {\n UserDefinedName : Wi-Fi\n UserDefinedName : Evil\n}", " {\n DHCPPacket : {\n UserDefinedName : Evil\n}\n}", " {\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(" {}") + 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") + } +} diff --git a/cmd/cli/dns_target_native_test.go b/cmd/cli/dns_target_native_test.go new file mode 100644 index 0000000..9d20702 --- /dev/null +++ b/cmd/cli/dns_target_native_test.go @@ -0,0 +1,55 @@ +package cli + +import ( + "net" + "strings" + "testing" +) + +func TestNativeIPv4Store(t *testing.T) { + valid := " {\n CLAT46 : TRUE\n InterfaceName : en7\n Router : 192.0.0.1\n Addresses : {\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 {}", " {\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) + } + } +} diff --git a/cmd/cli/pf_diagnostic.go b/cmd/cli/pf_diagnostic.go new file mode 100644 index 0000000..14969e6 --- /dev/null +++ b/cmd/cli/pf_diagnostic.go @@ -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 +} diff --git a/cmd/cli/pf_diagnostic_darwin.go b/cmd/cli/pf_diagnostic_darwin.go new file mode 100644 index 0000000..51c5752 --- /dev/null +++ b/cmd/cli/pf_diagnostic_darwin.go @@ -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) +} diff --git a/cmd/cli/pf_diagnostic_darwin_test.go b/cmd/cli/pf_diagnostic_darwin_test.go new file mode 100644 index 0000000..332e323 --- /dev/null +++ b/cmd/cli/pf_diagnostic_darwin_test.go @@ -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) + } +} diff --git a/cmd/cli/pf_diagnostic_log.go b/cmd/cli/pf_diagnostic_log.go new file mode 100644 index 0000000..e328799 --- /dev/null +++ b/cmd/cli/pf_diagnostic_log.go @@ -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") +} diff --git a/cmd/cli/pf_diagnostic_log_test.go b/cmd/cli/pf_diagnostic_log_test.go new file mode 100644 index 0000000..0676fd0 --- /dev/null +++ b/cmd/cli/pf_diagnostic_log_test.go @@ -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) + } + } + }) + } +} diff --git a/cmd/cli/pf_diagnostic_test.go b/cmd/cli/pf_diagnostic_test.go new file mode 100644 index 0000000..2dcd12f --- /dev/null +++ b/cmd/cli/pf_diagnostic_test.go @@ -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") + } +} diff --git a/cmd/cli/pf_probe_darwin_test.go b/cmd/cli/pf_probe_darwin_test.go index 87f4d72..9fa3f6d 100644 --- a/cmd/cli/pf_probe_darwin_test.go +++ b/cmd/cli/pf_probe_darwin_test.go @@ -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"} } diff --git a/docs/ipv6-only-dns-target-qa.md b/docs/ipv6-only-dns-target-qa.md new file mode 100644 index 0000000..e37d2f9 --- /dev/null +++ b/docs/ipv6-only-dns-target-qa.md @@ -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. diff --git a/docs/network-recovery-diagnostics.md b/docs/network-recovery-diagnostics.md index ed02baa..224fa93 100644 --- a/docs/network-recovery-diagnostics.md +++ b/docs/network-recovery-diagnostics.md @@ -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. diff --git a/nameservers_darwin.go b/nameservers_darwin.go index 0133dc4..2ae1c29 100644 --- a/nameservers_darwin.go +++ b/nameservers_darwin.go @@ -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. diff --git a/nameservers_darwin_deadline_test.go b/nameservers_darwin_deadline_test.go new file mode 100644 index 0000000..32caee3 --- /dev/null +++ b/nameservers_darwin_deadline_test.go @@ -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) + } +} diff --git a/nameservers_dhcp_commands.go b/nameservers_dhcp_commands.go new file mode 100644 index 0000000..493d499 --- /dev/null +++ b/nameservers_dhcp_commands.go @@ -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 +} diff --git a/nameservers_dhcp_commands_test.go b/nameservers_dhcp_commands_test.go new file mode 100644 index 0000000..acc8159 --- /dev/null +++ b/nameservers_dhcp_commands_test.go @@ -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) + } +}