//go:build linux package platform import ( "bufio" "fmt" "net" "os" "os/exec" "strings" ) // LinuxNetworkDiscoverer discovers network configuration using ip(8) and // /etc/resolv.conf. On systemd-resolved systems it falls back to resolvectl(1) // when all nameservers are stub addresses. type LinuxNetworkDiscoverer struct{} // stubAddrs are local stub resolver addresses used by systemd-resolved. var stubAddrs = map[string]bool{ "127.0.0.53": true, "127.0.0.1": true, "127.0.1.1": true, } // Discover returns the default gateway and DNS server list for this host. func (d *LinuxNetworkDiscoverer) Discover() (NetworkInfo, error) { info := NetworkInfo{} // Step 1: Determine default gateway and interface via "ip route show default". gateway, iface, err := linuxDefaultRoute() if err != nil { return info, fmt.Errorf("reading default route: %w", err) } info.Gateway = gateway info.Interface = iface // Step 2: Parse /etc/resolv.conf for nameserver lines. servers, err := linuxResolvConf() if err != nil { // A missing resolv.conf is not fatal — return empty list. return info, nil } // Step 3: If all servers are stub addresses, try resolvectl for upstream IPs. if len(servers) > 0 && allStubs(servers) && iface != "" { if upstream, err := linuxResolvectl(iface); err == nil && len(upstream) > 0 { servers = upstream } } info.DNSServers = servers return info, nil } // linuxDefaultRoute parses "ip route show default" and returns the gateway IP // and interface name for the best (lowest metric) default route. func linuxDefaultRoute() (gateway, iface string, err error) { out, err := exec.Command("ip", "route", "show", "default").Output() if err != nil { return "", "", fmt.Errorf("ip route show default: %w", err) } bestMetric := -1 bestGW := "" bestIface := "" scanner := bufio.NewScanner(strings.NewReader(string(out))) for scanner.Scan() { line := scanner.Text() fields := strings.Fields(line) if len(fields) == 0 || fields[0] != "default" { continue } // Parse by keyword: "via " and "dev " and optional "metric ". var gw, dev string metric := 0 for i := 1; i < len(fields)-1; i++ { switch fields[i] { case "via": gw = fields[i+1] case "dev": dev = fields[i+1] case "metric": if n, err := parseInt(fields[i+1]); err == nil { metric = n } } } if gw == "" || net.ParseIP(gw) == nil { continue } if bestMetric < 0 || metric < bestMetric { bestMetric = metric bestGW = gw bestIface = dev } } if bestGW == "" { return "", "", fmt.Errorf("no default route found") } return bestGW, bestIface, nil } // linuxResolvConf reads /etc/resolv.conf and returns the nameserver IPs. func linuxResolvConf() ([]string, error) { f, err := os.Open("/etc/resolv.conf") if err != nil { return nil, err } defer f.Close() var servers []string scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { continue } if !strings.HasPrefix(line, "nameserver") { continue } fields := strings.Fields(line) if len(fields) < 2 { continue } ip := fields[1] if net.ParseIP(ip) != nil { servers = append(servers, ip) } } return servers, nil } // linuxResolvectl runs "resolvectl status " and returns the upstream DNS // servers. Returns an error if resolvectl is not available. func linuxResolvectl(iface string) ([]string, error) { path, err := exec.LookPath("resolvectl") if err != nil { return nil, fmt.Errorf("resolvectl not found: %w", err) } out, err := exec.Command(path, "status", iface).Output() if err != nil { return nil, fmt.Errorf("resolvectl status %s: %w", iface, err) } // Parse "DNS Servers: ..." and continuation lines. var servers []string inDNS := false scanner := bufio.NewScanner(strings.NewReader(string(out))) for scanner.Scan() { line := scanner.Text() stripped := strings.TrimSpace(line) if strings.HasPrefix(stripped, "DNS Servers:") { inDNS = true // Extract IPs from the same line after the label. rest := strings.TrimPrefix(stripped, "DNS Servers:") for _, f := range strings.Fields(rest) { if net.ParseIP(f) != nil { servers = append(servers, f) } } continue } if inDNS { // Continuation lines are indented; stop at a non-indented line. if len(line) > 0 && line[0] != ' ' && line[0] != '\t' { break } for _, f := range strings.Fields(stripped) { if net.ParseIP(f) != nil { servers = append(servers, f) } } } } return servers, nil } // allStubs reports whether every address in servers is a known stub resolver. func allStubs(servers []string) bool { for _, s := range servers { if !stubAddrs[s] { return false } } return true } func parseInt(s string) (int, error) { n := 0 for _, c := range s { if c < '0' || c > '9' { return 0, fmt.Errorf("not an int: %q", s) } n = n*10 + int(c-'0') } return n, nil }