mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
Port !997 from v1.0 onto the context-aware master recovery lifecycle. Preserve master logging and resolver APIs while adding macOS default-route DHCP detection, temporary DNS-target cleanup, and atomic recovery ownership. Includes parser, lifecycle, failure, and concurrency regressions plus the corrected macOS QA helper. Relates to #533 and #597.
241 lines
6.4 KiB
Go
241 lines
6.4 KiB
Go
//go:build darwin
|
|
|
|
package ctrld
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os/exec"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"tailscale.com/net/netmon"
|
|
)
|
|
|
|
func dnsFns() []dnsFn {
|
|
return []dnsFn{dnsFromResolvConf, getDNSFromScutil, getAllDHCPNameservers}
|
|
}
|
|
|
|
func getDNSFromScutil(ctx context.Context) []string {
|
|
logger := LoggerFromCtx(ctx)
|
|
|
|
const (
|
|
maxRetries = 10
|
|
retryInterval = 100 * time.Millisecond
|
|
)
|
|
|
|
regularIPs, loopbackIPs, _ := netmon.LocalAddresses()
|
|
|
|
var nameservers []string
|
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
time.Sleep(retryInterval)
|
|
}
|
|
|
|
cmd := exec.Command("scutil", "--dns")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
Log(context.Background(), logger.Error(), "Failed to execute scutil --dns (attempt %d/%d): %v", attempt+1, maxRetries, err)
|
|
continue
|
|
}
|
|
|
|
var localDNS []string
|
|
seen := make(map[string]bool)
|
|
|
|
scanner := bufio.NewScanner(bytes.NewReader(output))
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if strings.HasPrefix(line, "nameserver[") {
|
|
parts := strings.Split(line, ":")
|
|
if len(parts) == 2 {
|
|
ns := strings.TrimSpace(parts[1])
|
|
if ip := net.ParseIP(ns); ip != nil {
|
|
// skip loopback IPs
|
|
isLocal := false
|
|
for _, v := range slices.Concat(regularIPs, loopbackIPs) {
|
|
ipStr := v.String()
|
|
if ip.String() == ipStr {
|
|
isLocal = true
|
|
break
|
|
}
|
|
}
|
|
if !isLocal && !seen[ip.String()] {
|
|
seen[ip.String()] = true
|
|
localDNS = append(localDNS, ip.String())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
Log(context.Background(), logger.Error(), "Error scanning scutil output (attempt %d/%d): %v", attempt+1, maxRetries, err)
|
|
continue
|
|
}
|
|
|
|
// If we successfully read the output and found nameservers, return them
|
|
if len(localDNS) > 0 {
|
|
return localDNS
|
|
}
|
|
}
|
|
|
|
return nameservers
|
|
}
|
|
|
|
func getDHCPNameservers(iface string) ([]string, error) {
|
|
// getoption returns the selected interface's DHCP option directly and does
|
|
// not expose unrelated packet addresses to the parser.
|
|
output, err := exec.Command("ipconfig", "getoption", iface, "domain_name_server").Output()
|
|
if err == nil {
|
|
return parseDHCPOptionNameservers(output), nil
|
|
}
|
|
|
|
// 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 {
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error reading DHCP DNS option: getoption: %v; getpacket: %v", err, packetErr)
|
|
}
|
|
return nil, fmt.Errorf("error reading DHCP packet: %v", packetErr)
|
|
}
|
|
return parseDHCPPacketNameservers(output), nil
|
|
}
|
|
|
|
// DHCPNameserversForInterface returns DHCP option 6 for exactly iface.
|
|
func DHCPNameserversForInterface(iface string) ([]string, error) {
|
|
return getDHCPNameservers(iface)
|
|
}
|
|
|
|
func getAllDHCPNameservers(ctx context.Context) []string {
|
|
logger := LoggerFromCtx(ctx)
|
|
|
|
interfaces, err := net.Interfaces()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
regularIPs, loopbackIPs, _ := netmon.LocalAddresses()
|
|
|
|
var allNameservers []string
|
|
seen := make(map[string]bool)
|
|
|
|
for _, iface := range interfaces {
|
|
// Skip interfaces that are:
|
|
// - down
|
|
// - loopback
|
|
// - not physical (virtual)
|
|
// - point-to-point (like VPN interfaces)
|
|
// - without MAC address (non-physical)
|
|
if iface.Flags&net.FlagUp == 0 ||
|
|
iface.Flags&net.FlagLoopback != 0 ||
|
|
iface.Flags&net.FlagPointToPoint != 0 ||
|
|
(iface.Flags&net.FlagBroadcast == 0 &&
|
|
iface.Flags&net.FlagMulticast == 0) ||
|
|
len(iface.HardwareAddr) == 0 ||
|
|
strings.HasPrefix(iface.Name, "utun") ||
|
|
strings.HasPrefix(iface.Name, "llw") ||
|
|
strings.HasPrefix(iface.Name, "awdl") {
|
|
continue
|
|
}
|
|
|
|
// Verify it's a valid MAC address (should be 6 bytes for IEEE 802 MAC-48)
|
|
if len(iface.HardwareAddr) != 6 {
|
|
continue
|
|
}
|
|
|
|
nameservers, err := getDHCPNameservers(iface.Name)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Add unique nameservers to the result, skipping local IPs
|
|
for _, ns := range nameservers {
|
|
if ip := net.ParseIP(ns); ip != nil {
|
|
// skip loopback and local IPs
|
|
isLocal := false
|
|
for _, v := range slices.Concat(regularIPs, loopbackIPs) {
|
|
if ip.String() == v.String() {
|
|
isLocal = true
|
|
break
|
|
}
|
|
}
|
|
if !isLocal && !seen[ns] {
|
|
seen[ns] = true
|
|
allNameservers = append(allNameservers, ns)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// if we have static DNS servers saved for the current default route, we should add them to the list
|
|
drIfaceName, err := netmon.DefaultRouteInterface()
|
|
Log(context.Background(), logger.Debug(), "Checking for static DNS servers for default route interface: %s", drIfaceName)
|
|
if err != nil {
|
|
Log(context.Background(), logger.Debug(),
|
|
"Failed to get default route interface: %v", err)
|
|
} else {
|
|
drIface, err := net.InterfaceByName(drIfaceName)
|
|
if err != nil {
|
|
Log(context.Background(), logger.Debug(),
|
|
"Failed to get interface by name %s: %v", drIfaceName, err)
|
|
} else if drIface != nil {
|
|
if _, err := patchNetIfaceName(drIface); err != nil {
|
|
Log(context.Background(), logger.Debug(),
|
|
"Failed to patch interface name %s: %v", drIfaceName, err)
|
|
}
|
|
staticNs, file := SavedStaticNameserversAndPath(drIface)
|
|
Log(context.Background(), logger.Debug(),
|
|
"static dns servers from %s: %v", file, staticNs)
|
|
if len(staticNs) > 0 {
|
|
Log(context.Background(), logger.Debug(),
|
|
"Adding static DNS servers from %s: %v", drIface.Name, staticNs)
|
|
allNameservers = append(allNameservers, staticNs...)
|
|
}
|
|
}
|
|
}
|
|
|
|
return allNameservers
|
|
}
|
|
|
|
func patchNetIfaceName(iface *net.Interface) (bool, error) {
|
|
b, err := exec.Command("networksetup", "-listnetworkserviceorder").Output()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
patched := false
|
|
if name := networkServiceName(iface.Name, bytes.NewReader(b)); name != "" {
|
|
patched = true
|
|
iface.Name = name
|
|
}
|
|
return patched, nil
|
|
}
|
|
|
|
func networkServiceName(ifaceName string, r io.Reader) string {
|
|
scanner := bufio.NewScanner(r)
|
|
prevLine := ""
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.Contains(line, "*") {
|
|
// Network services is disabled.
|
|
continue
|
|
}
|
|
if !strings.Contains(line, "Device: "+ifaceName) {
|
|
prevLine = line
|
|
continue
|
|
}
|
|
parts := strings.SplitN(prevLine, " ", 2)
|
|
if len(parts) == 2 {
|
|
return strings.TrimSpace(parts[1])
|
|
}
|
|
}
|
|
return ""
|
|
}
|