mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-24 14:08:27 +02:00
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.
533 lines
17 KiB
Go
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
|
|
}
|