Files
ctrld/cmd/cli/dns_target_native.go
T
Dev Scribe 1dd94eb02e fix(cli): set the Intercept DNS target on IPv6-only networks before CLAT starts
A failed DHCP read now admits the macOS Intercept DNS target when the
network gives the device no usable IPv4 at all, confirmed over a short
hold, so an IPv6-only network without DHCPv4 or PREF64 no longer ends
in a total DNS outage before macOS starts its CLAT.

Master adaptation: the new prog fields use master's inline
lint:ignore style, and the confirm-delay journal field is logged as
confirm_after_ms with Int64, because master's ctrld.LogEvent has no
Dur.
2026-10-12 14:40:46 +07:00

533 lines
17 KiB
Go

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
)
// ipv6OnlyObservation is the first sighting of IPv6-only evidence that an
// admission must still match after ipv6OnlyConfirmDelay.
type ipv6OnlyObservation struct {
//lint:ignore U1000 used in Darwin code.
service nativeTargetService
//lint:ignore U1000 used in Darwin code.
since time.Time
}
var errNativeTargetOutputLimit = errors.New("native DNS evidence output exceeds limit")
type nativeTargetOutput struct{ buf bytes.Buffer }
func (w *nativeTargetOutput) Write(p []byte) (int, error) {
if len(p) > nativeTargetMaxOutput-w.buf.Len() {
return 0, errNativeTargetOutputLimit
}
return w.buf.Write(p)
}
func runNativeTargetCommand(ctx context.Context, input, path string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, path, args...)
cmd.Stdin = strings.NewReader(input)
var out nativeTargetOutput
cmd.Stdout = &out
cmd.Stderr = io.Discard
// Also bound waiting for inherited pipes if a child outlives the command.
cmd.WaitDelay = 100 * time.Millisecond
if err := cmd.Run(); err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
return out.buf.Bytes(), nil
}
type nativeTargetService struct {
ID, Name, Device string
}
type nativeTargetReader struct {
run func(context.Context, string, string, ...string) ([]byte, error)
interfaceByName func(string) (*net.Interface, error)
interfaceAddrs func(*net.Interface) ([]net.Addr, error)
}
func (r nativeTargetReader) store(ctx context.Context, key string) (map[string]string, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
out, err := r.run(ctx, "show "+key+"\nquit\n", "/usr/sbin/scutil")
if err != nil {
return nil, err
}
return parseNativeIPv4Store(string(out))
}
// The service root contains only the configured name. Reject multiline or
// unfamiliar layouts rather than searching arbitrary DHCP/SSID/packet text.
// The UUID in the key comes exclusively from the validated Global dictionary.
func parseNativeServiceName(out string) (string, error) {
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) != 3 || lines[0] != "<dictionary> {" || lines[2] != "}" {
return "", errors.New("invalid native service dictionary")
}
name, ok := strings.CutPrefix(strings.TrimLeft(lines[1], " \t"), "UserDefinedName : ")
if !ok || name == "" || strings.ContainsAny(name, "\r\n\x00/{}") {
return "", errors.New("invalid native service name")
}
return name, nil
}
// serviceName validates the configured service root for id and returns the
// unique enabled networksetup service mapped to device. Both native readers
// share it, so a fix to either check applies to both.
func (r nativeTargetReader) serviceName(ctx context.Context, id, device string) (string, error) {
out, err := r.run(ctx, "show Setup:/Network/Service/"+id+"\nquit\n", "/usr/sbin/scutil")
if err != nil {
return "", err
}
if _, err := parseNativeServiceName(string(out)); err != nil {
return "", err
}
out, err = r.run(ctx, "", "/usr/sbin/networksetup", "-listnetworkserviceorder")
if err != nil {
return "", err
}
return targetNetworkServiceName(string(out), device)
}
// 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
}
name, err := r.serviceName(ctx, id, device)
if err != nil {
return unknown, err
}
after, err := r.store(ctx, "State:/Network/Global/IPv4")
if err != nil {
return unknown, err
}
if after["PrimaryService"] != id || after["PrimaryInterface"] != device {
return unknown, errors.New("default service changed during CLAT discovery")
}
return nativeTargetService{ID: id, Name: name, Device: device}, nil
}
// saveTargetStaticDNSSnapshot saves only the already validated/rechecked
// snapshot. Never re-read via the legacy permissive static DNS reader, and
// never replace the original backup with ctrld's own (possibly non-loopback)
// target when a listener changes after restart.
func saveTargetStaticDNSSnapshot(path string, snapshot []string, owned string) error {
if isInterceptDNSTargetOnly(snapshot, owned) {
// Missing means the original service used automatic DNS. A present
// but unreadable/corrupt backup must not authorize a listener change.
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if len(data) == 0 {
return nil
}
for _, s := range strings.Split(string(data), ",") {
if net.ParseIP(s) == nil || s == owned {
return errors.New("invalid retained static DNS backup")
}
}
return nil
}
var dns []string
for _, s := range snapshot {
ip := net.ParseIP(s)
if ip == nil {
return errors.New("invalid static DNS snapshot")
}
if s == owned {
continue
}
// The restore reader filters loopbacks. Refuse an unrestorable
// user snapshot rather than silently discarding a local resolver.
if ip.IsLoopback() {
return errors.New("non-owned loopback in static DNS snapshot")
}
dns = append(dns, ip.String())
}
if len(dns) == 0 {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
return os.WriteFile(path, []byte(strings.Join(dns, ",")), 0600)
}
var nativeServiceID = regexp.MustCompile(`^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$`)
// parseNativeIPv4Store reads only the two native IPv4 dictionaries, never
// ipconfig getsummary (which embeds unescaped, network-controlled DHCP text).
// Unknown layouts fail closed. These fields are generated by configd from
// interface names, service UUIDs, booleans and binary IP addresses.
func parseNativeIPv4Store(out string) (map[string]string, error) {
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) < 2 || lines[0] != "<dictionary> {" || lines[len(lines)-1] != "}" {
return nil, errors.New("invalid native IPv4 dictionary")
}
values := make(map[string]string)
depth := 1
arrayKey := ""
for _, line := range lines[1 : len(lines)-1] {
s := strings.TrimSpace(line)
if s == "}" {
depth--
if depth < 1 {
return nil, errors.New("unbalanced native IPv4 dictionary")
}
continue
}
key, value, ok := strings.Cut(s, " : ")
if !ok {
return nil, errors.New("invalid native IPv4 field")
}
if depth == 1 {
if _, exists := values[key]; exists {
return nil, errors.New("duplicate native IPv4 field")
}
switch key {
case "PrimaryService":
if !nativeServiceID.MatchString(value) {
return nil, errors.New("invalid service ID")
}
case "PrimaryInterface", "InterfaceName", "ConfirmedInterfaceName":
if value == "" || strings.ContainsAny(value, " \t\r\n{}") {
return nil, errors.New("invalid interface")
}
case "CLAT46":
if value != "TRUE" && value != "FALSE" {
return nil, errors.New("invalid CLAT flag")
}
case "Router":
if net.ParseIP(value).To4() == nil {
return nil, errors.New("invalid router")
}
case "Addresses", "SubnetMasks", "DestinationAddresses", "AdditionalRoutes", "ExcludedRoutes":
if value != "<array> {" {
return nil, errors.New("invalid IPv4 array")
}
default:
return nil, errors.New("unknown native IPv4 field")
}
values[key] = value
arrayKey = key
} else if depth == 2 && arrayKey == "Addresses" {
ip := net.ParseIP(value)
if ip.To4() == nil {
return nil, errors.New("invalid native IPv4 address")
}
values["AddressList"] += ip.String() + " "
}
if strings.HasSuffix(value, "{") {
depth++
}
}
if depth != 1 {
return nil, errors.New("unbalanced native IPv4 dictionary")
}
return values, nil
}
// A successful networksetup exit alone is not absence evidence: reject all
// text except IP literals and its exact no-static-DNS response.
func parseTargetStaticDNS(out, service string) ([]string, error) {
out = strings.TrimSpace(out)
if out == "There aren't any DNS Servers set on "+service+"." {
return nil, nil
}
if out == "" {
return nil, errors.New("empty static DNS response")
}
var dns []string
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
ip := net.ParseIP(strings.TrimSpace(scanner.Text()))
if ip == nil {
return nil, errors.New("invalid static DNS response")
}
dns = append(dns, ip.String())
}
return dns, scanner.Err()
}
func nativeCLATAddresses(addrs []net.Addr) bool {
clat, ipv6 := false, false
for _, addr := range addrs {
ip, _, err := net.ParseCIDR(addr.String())
if err != nil {
return false
}
if v4 := ip.To4(); v4 != nil {
// Apple allocates one of these four CLAT addresses, not only 192.0.0.2.
if v4[0] != 192 || v4[1] != 0 || v4[2] != 0 || v4[3] < 2 || v4[3] > 5 {
return false
}
clat = true
} else if ip.IsGlobalUnicast() {
// ULA is legitimate IPv6 connectivity. Addresses alone never
// authorize a target: configd must independently publish CLAT.
ipv6 = true
}
}
return clat && ipv6
}
// nativeStoreAbsent reports whether scutil printed its exact missing-key
// response. Absence is evidence only in that exact form; a dictionary means
// present, and anything else is unknown.
func nativeStoreAbsent(out string) (bool, error) {
s := strings.TrimSpace(out)
if s == "No such key" {
return true, nil
}
if strings.HasPrefix(s, "<dictionary> {") {
return false, nil
}
return false, errors.New("unknown native store response")
}
func (r nativeTargetReader) absent(ctx context.Context, key string) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
out, err := r.run(ctx, "show "+key+"\nquit\n", "/usr/sbin/scutil")
if err != nil {
return false, err
}
return nativeStoreAbsent(string(out))
}
// parseNativeIPv6Global reads only State:/Network/Global/IPv6. Unknown,
// nested or duplicate fields fail closed, like the IPv4 reader.
func parseNativeIPv6Global(out string) (id, device string, err error) {
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) < 2 || lines[0] != "<dictionary> {" || lines[len(lines)-1] != "}" {
return "", "", errors.New("invalid native IPv6 dictionary")
}
seen := make(map[string]bool)
for _, line := range lines[1 : len(lines)-1] {
key, value, ok := strings.Cut(strings.TrimSpace(line), " : ")
if !ok || strings.HasSuffix(value, "{") {
return "", "", errors.New("invalid native IPv6 field")
}
if seen[key] {
return "", "", errors.New("duplicate native IPv6 field")
}
seen[key] = true
switch key {
case "PrimaryService":
if !nativeServiceID.MatchString(value) {
return "", "", errors.New("invalid service ID")
}
id = value
case "PrimaryInterface":
if value == "" || strings.ContainsAny(value, " \t\r\n{}") {
return "", "", errors.New("invalid interface")
}
device = value
case "Router":
if ip := net.ParseIP(value); ip == nil || ip.To4() != nil {
return "", "", errors.New("invalid IPv6 router")
}
default:
return "", "", errors.New("unknown native IPv6 field")
}
}
if id == "" || device == "" {
return "", "", errors.New("incomplete native IPv6 dictionary")
}
return id, device, nil
}
// nativeIPv6OnlyAddresses requires routable IPv6 and no usable IPv4. An
// IPv4 link-local address (169.254.0.0/16) is allowed: macOS self-assigns one
// when DHCPv4 gives up, and it reaches no DNS server.
func nativeIPv6OnlyAddresses(addrs []net.Addr) bool {
ipv6 := false
for _, addr := range addrs {
ip, _, err := net.ParseCIDR(addr.String())
if err != nil {
return false
}
if ip.To4() != nil {
if !ip.IsLinkLocalUnicast() {
return false
}
continue
}
if ip.IsGlobalUnicast() {
ipv6 = true
}
}
return ipv6
}
// ipv6OnlyService returns the primary service of a network that gives the
// device no usable IPv4: configd publishes no global IPv4 state and at most a
// link-local per-service IPv4 state, the IPv6 primary is this device, and the
// interface holds only IPv6 plus at most an IPv4 link-local address. Before
// macOS starts its CLAT, such a network has no DHCPv4 lease to read and no
// CLAT to prove, yet it is just as DNS-less (issue #629). Unknown or changing
// state is never absence evidence; the caller rechecks the tuple.
func (r nativeTargetReader) ipv6OnlyService(ctx context.Context, device string) (nativeTargetService, error) {
unknown := nativeTargetService{}
if gone, err := r.absent(ctx, "State:/Network/Global/IPv4"); err != nil || !gone {
return unknown, err
}
out, err := r.run(ctx, "show State:/Network/Global/IPv6\nquit\n", "/usr/sbin/scutil")
if err != nil {
return unknown, err
}
id, primary, err := parseNativeIPv6Global(string(out))
if err != nil || primary != device {
return unknown, err
}
if none, err := r.serviceNoUsableIPv4(ctx, id, device); err != nil || !none {
return unknown, err
}
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 || !nativeIPv6OnlyAddresses(addrs) {
return unknown, err
}
name, err := r.serviceName(ctx, id, device)
if err != nil {
return unknown, err
}
if gone, err := r.absent(ctx, "State:/Network/Global/IPv4"); err != nil || !gone {
return unknown, errors.Join(err, errors.New("IPv4 appeared during IPv6-only discovery"))
}
out, err = r.run(ctx, "show State:/Network/Global/IPv6\nquit\n", "/usr/sbin/scutil")
if err != nil {
return unknown, err
}
if afterID, afterDevice, err := parseNativeIPv6Global(string(out)); err != nil || afterID != id || afterDevice != device {
return unknown, errors.Join(err, errors.New("default service changed during IPv6-only discovery"))
}
return nativeTargetService{ID: id, Name: name, Device: device}, nil
}
// serviceNoUsableIPv4 reports whether the per-service IPv4 state is absent or
// only the link-local address macOS self-assigns after DHCPv4 gives up. On
// the ctrld-qa macOS 26 guest (IPv6-only, no DHCPv4, CLAT blocked) that state
// appears about 25 s after joining: one 169.254.0.0/16 address, no Router, no
// CLAT46, and Global/IPv4 still absent. A DHCP, static or CLAT configuration
// always fails this check, so it never hides a network that provides IPv4.
func (r nativeTargetReader) serviceNoUsableIPv4(ctx context.Context, id, device string) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
out, err := r.run(ctx, "show State:/Network/Service/"+id+"/IPv4\nquit\n", "/usr/sbin/scutil")
if err != nil {
return false, err
}
if gone, err := nativeStoreAbsent(string(out)); err != nil || gone {
return gone, err
}
values, err := parseNativeIPv4Store(string(out))
if err != nil {
return false, err
}
return nativeIPv4LinkLocalOnly(values, device), nil
}
// nativeIPv4LinkLocalOnly accepts only an IPv4 link-local configuration of
// device: no router, no CLAT, not a primary, and every address in
// 169.254.0.0/16.
func nativeIPv4LinkLocalOnly(values map[string]string, device string) bool {
for _, key := range []string{"Router", "CLAT46", "PrimaryService", "PrimaryInterface", "DestinationAddresses", "ExcludedRoutes"} {
if _, ok := values[key]; ok {
return false
}
}
if values["InterfaceName"] != device {
return false
}
if c, ok := values["ConfirmedInterfaceName"]; ok && c != device {
return false
}
addrs := strings.Fields(values["AddressList"])
if len(addrs) == 0 {
return false
}
for _, a := range addrs {
if ip := net.ParseIP(a); ip == nil || ip.To4() == nil || !ip.IsLinkLocalUnicast() {
return false
}
}
return true
}