//go:build darwin package platform import ( "bufio" "fmt" "net" "os/exec" "strings" ) // DarwinNetworkDiscoverer discovers network configuration using route(8) and // scutil(8). Both commands are available on every macOS version since 10.5. type DarwinNetworkDiscoverer struct{} // Discover returns the default gateway IP and DNS server list for this host. func (d *DarwinNetworkDiscoverer) Discover() (NetworkInfo, error) { info := NetworkInfo{} // Step 1: Determine default gateway and interface via "route -n get default". gateway, iface, err := darwinDefaultRoute() if err != nil { return info, fmt.Errorf("reading default route: %w", err) } info.Gateway = gateway info.Interface = iface // Step 2: Extract DNS servers from scutil --dns, matching the interface. servers, err := darwinDNSServers(iface) if err != nil { // Non-fatal — return what we have. return info, nil } info.DNSServers = servers return info, nil } // darwinDefaultRoute runs "route -n get default" and returns the gateway IP // and interface name for the default route. func darwinDefaultRoute() (gateway, iface string, err error) { out, err := exec.Command("route", "-n", "get", "default").Output() if err != nil { return "", "", fmt.Errorf("route -n get default: %w", err) } scanner := bufio.NewScanner(strings.NewReader(string(out))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "gateway:") { fields := strings.Fields(line) if len(fields) >= 2 { ip := fields[1] if net.ParseIP(ip) != nil { gateway = ip } } } if strings.HasPrefix(line, "interface:") { fields := strings.Fields(line) if len(fields) >= 2 { iface = fields[1] } } } if gateway == "" { return "", "", fmt.Errorf("no default gateway found") } return gateway, iface, nil } // darwinDNSServers runs "scutil --dns" and extracts DNS server IPs from the // resolver block that matches iface. Falls back to resolver #1 if no match. func darwinDNSServers(iface string) ([]string, error) { out, err := exec.Command("scutil", "--dns").Output() if err != nil { return nil, fmt.Errorf("scutil --dns: %w", err) } type resolverBlock struct { nameservers []string ifaceMatch bool isMDNS bool number int } var blocks []resolverBlock var current *resolverBlock inScopedSection := false scanner := bufio.NewScanner(strings.NewReader(string(out))) for scanner.Scan() { line := scanner.Text() stripped := strings.TrimSpace(line) // Detect the "scoped queries" section — stop parsing there. if strings.Contains(line, "DNS configuration (for scoped queries)") { inScopedSection = true } if inScopedSection { continue } // Start of a new resolver block. if strings.HasPrefix(stripped, "resolver #") { if current != nil { blocks = append(blocks, *current) } num := 0 fmt.Sscanf(stripped, "resolver #%d", &num) current = &resolverBlock{number: num} continue } if current == nil { continue } // Parse nameserver entries. if strings.HasPrefix(stripped, "nameserver[") { // Format: "nameserver[0] : 8.8.8.8" idx := strings.Index(stripped, ": ") if idx >= 0 { ip := strings.TrimSpace(stripped[idx+2:]) if net.ParseIP(ip) != nil { current.nameservers = append(current.nameservers, ip) } } continue } // Check for interface match via if_index line: "if_index : 5 (en0)" if strings.HasPrefix(stripped, "if_index") && iface != "" { if strings.Contains(stripped, "("+iface+")") { current.ifaceMatch = true } continue } // Detect mDNS blocks: domain + options lines. if strings.HasPrefix(stripped, "domain") && strings.Contains(stripped, ": local") { current.isMDNS = true } if strings.HasPrefix(stripped, "options") && strings.Contains(stripped, "mdns") { current.isMDNS = true } } if current != nil { blocks = append(blocks, *current) } // Find the best block: interface-matching, non-mDNS. for _, b := range blocks { if b.ifaceMatch && !b.isMDNS && len(b.nameservers) > 0 { return b.nameservers, nil } } // Fallback: use resolver #1 if it's not mDNS. for _, b := range blocks { if b.number == 1 && !b.isMDNS && len(b.nameservers) > 0 { return b.nameservers, nil } } return nil, fmt.Errorf("no usable DNS resolver block found in scutil output") }