diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 4c19717..d57167d 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -353,7 +353,10 @@ func (a *Agent) loadAllowedDNSServers() error { if err != nil { return err } - mergedDNSServers := append(defaultDNSServers, dnsServer) + mergedDNSServers := append([]string{}, defaultDNSServers...) + if dnsServer != "" { + mergedDNSServers = append(mergedDNSServers, dnsServer) + } for _, dns := range mergedDNSServers { a.allowedDNSServers[dns] = true diff --git a/pkg/agent/network_info.go b/pkg/agent/network_info.go index efcbbae..1078ccf 100644 --- a/pkg/agent/network_info.go +++ b/pkg/agent/network_info.go @@ -2,7 +2,9 @@ package agent import ( "fmt" + "os" "os/exec" + "strings" ) type INetInfoProvider interface { @@ -13,27 +15,98 @@ type INetInfoProvider interface { type LinuxNetInfoProvider struct { } +// resolvConfPaths are read, in order, when resolvectl reports no DNS server. +// /run/systemd/resolve/resolv.conf lists systemd-resolved's upstream servers; +// /etc/resolv.conf covers hosts where resolv.conf points at a resolver directly. +var resolvConfPaths = []string{"/run/systemd/resolve/resolv.conf", "/etc/resolv.conf"} + +// GetDNSServer returns the upstream DNS server the host resolves through, or "" +// when none can be determined. It never fails on empty command output: some +// runners (e.g. Depot) report no per-link DNS server for the default interface. func (l *LinuxNetInfoProvider) GetDNSServer() (string, error) { - networkInterface, err := exec.Command("sh", "-c", "ip route | grep default | awk '{print $5}'").Output() + networkInterface := "" + out, err := exec.Command("sh", "-c", "ip route | grep default | awk '{print $5}'").Output() if err != nil { fmt.Printf("Error getting default network interface: %s\n", err) - return "", err + } else { + networkInterface = firstLine(string(out)) } - // remove new line of networkInterface - networkInterface = networkInterface[:len(networkInterface)-1] fmt.Printf("Network interface: %s\n", networkInterface) - cmd := fmt.Sprintf("resolvectl status %s | grep 'DNS Servers' | awk '{print $3}'", networkInterface) + if networkInterface != "" { + if out, err := exec.Command("resolvectl", "status", networkInterface).CombinedOutput(); err == nil { + if dnsServer := parseResolvectlDNSServer(string(out)); dnsServer != "" { + fmt.Printf("DNS server: %s\n", dnsServer) + return dnsServer, nil + } + } + } - dnsServer, err := exec.Command("sh", "-c", cmd).CombinedOutput() - if err != nil { - fmt.Println("Error getting DNS server: ", err) - return "", err + if out, err := exec.Command("resolvectl", "status").CombinedOutput(); err == nil { + if dnsServer := parseResolvectlDNSServer(string(out)); dnsServer != "" { + fmt.Printf("DNS server (global): %s\n", dnsServer) + return dnsServer, nil + } + } + + for _, path := range resolvConfPaths { + content, err := os.ReadFile(path) + if err != nil { + continue + } + if dnsServer := parseResolvConfNameserver(string(content)); dnsServer != "" { + fmt.Printf("DNS server (%s): %s\n", path, dnsServer) + return dnsServer, nil + } + } + + fmt.Println("No upstream DNS server found; allowing only the default DNS servers") + return "", nil +} + +// parseResolvectlDNSServer extracts the first DNS server from `resolvectl status` +// output, preferring "Current DNS Server" over the "DNS Servers" list. +func parseResolvectlDNSServer(output string) string { + for _, key := range []string{"Current DNS Server:", "DNS Servers:"} { + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, key) { + continue + } + if fields := strings.Fields(strings.TrimPrefix(line, key)); len(fields) > 0 { + return fields[0] + } + } } - // remove new line of dnsServer - dnsServer = dnsServer[:len(dnsServer)-1] - fmt.Printf("DNS server: %s\n", dnsServer) - return string(dnsServer), nil + return "" +} + +// parseResolvConfNameserver returns the first nameserver in resolv.conf content +// that is not already one of the default DNS servers (the systemd-resolved stub). +func parseResolvConfNameserver(content string) string { + for _, line := range strings.Split(content, "\n") { + fields := strings.Fields(line) + if len(fields) < 2 || fields[0] != "nameserver" { + continue + } + if !isDefaultDNSServer(fields[1]) { + return fields[1] + } + } + return "" +} + +func isDefaultDNSServer(server string) bool { + for _, d := range defaultDNSServers { + if d == server { + return true + } + } + return false +} + +func firstLine(s string) string { + return strings.TrimSpace(strings.SplitN(s, "\n", 2)[0]) } func (l *LinuxNetInfoProvider) FlushDNSCache() error { diff --git a/pkg/agent/network_info_test.go b/pkg/agent/network_info_test.go new file mode 100644 index 0000000..792f989 --- /dev/null +++ b/pkg/agent/network_info_test.go @@ -0,0 +1,33 @@ +package agent + +import "testing" + +func TestParseResolvectlDNSServer(t *testing.T) { + cases := map[string]struct{ in, want string }{ + "per-link list": {"Link 2 (eth0)\n Current Scopes: DNS\n DNS Servers: 168.63.129.16 10.0.0.2\n", "168.63.129.16"}, + "current wins": {"Global\n Current DNS Server: 10.0.0.2\n DNS Servers: 8.8.8.8 10.0.0.2\n", "10.0.0.2"}, + // Depot: the default interface has no DNS server at all (previously a panic). + "no server": {"Link 2 (enp0s6)\n Current Scopes: none\n", ""}, + "empty output": {"", ""}, + "empty value": {" DNS Servers:\n", ""}, + } + for name, c := range cases { + if got := parseResolvectlDNSServer(c.in); got != c.want { + t.Errorf("%s: got %q, want %q", name, got, c.want) + } + } +} + +func TestParseResolvConfNameserver(t *testing.T) { + cases := map[string]struct{ in, want string }{ + "stub skipped": {"# managed\nnameserver 127.0.0.53\nnameserver 10.1.2.3\nsearch x\n", "10.1.2.3"}, + "stub only": {"nameserver 127.0.0.53\noptions edns0\n", ""}, + "direct": {"nameserver 169.254.169.253\n", "169.254.169.253"}, + "empty": {"", ""}, + } + for name, c := range cases { + if got := parseResolvConfNameserver(c.in); got != c.want { + t.Errorf("%s: got %q, want %q", name, got, c.want) + } + } +}