go-network-configurator/dns_linux.go
2026-07-01 15:17:11 -05:00

154 lines
5 KiB
Go

package netconfig
import (
"bufio"
"context"
"fmt"
"net"
"os"
"os/exec"
"strings"
)
const (
resolvectlBin = "resolvectl"
resolvConf = "/etc/resolv.conf"
)
// applyLiveDNS applies the DNS servers and search domains to the running
// resolver in addition to whatever the configuration backends persist. It is
// best-effort: errors are logged but never returned, because a failure to
// update the live resolver does not undo the (already-written) persistent
// configuration.
//
// Two mechanisms are used, in order of preference:
// 1. systemd-resolved via resolvectl, which keeps DNS per-interface and is the
// correct mechanism when resolved manages /etc/resolv.conf.
// 2. A direct rewrite of /etc/resolv.conf, used only when resolvectl is absent
// and the file is a regular file (not a symlink to the resolved stub). This
// is a global resolver change rather than a per-interface one.
func applyLiveDNS(ctx context.Context, iface string, servers []net.IP, searchDomains []string) {
serverStrs := ipStrings(servers)
if _, err := exec.LookPath(resolvectlBin); err == nil {
if applyResolvectlDNS(ctx, iface, serverStrs, searchDomains) {
return
}
logger.Println("live DNS: resolvectl failed, falling back to /etc/resolv.conf")
}
// The /etc/resolv.conf fallback is a global change with no per-interface
// concept, so an empty server list would strip every nameserver and take
// down all resolution on the host. Clearing DNS is only meaningful
// per-interface (handled above by resolvectl), so skip the file rewrite
// rather than wipe the global resolver.
if len(serverStrs) == 0 {
return
}
if err := writeResolvConf(serverStrs, searchDomains); err != nil {
logger.Printf("live DNS: %v", err)
}
}
// applyResolvectlDNS sets per-interface DNS servers and search domains via
// resolvectl. An empty server/domain list clears that field for the interface.
// It returns true if the resolvectl calls succeeded.
func applyResolvectlDNS(ctx context.Context, iface string, servers, searchDomains []string) bool {
// When both lists are empty the intent is to drop this interface's DNS
// entirely; "resolvectl revert LINK" is the documented reset and avoids
// relying on the version-sensitive behaviour of passing an empty string.
if len(servers) == 0 && len(searchDomains) == 0 {
if _, err := runCommand(ctx, resolvectlBin, "revert", iface); err != nil {
logger.Printf("live DNS: resolvectl revert: %v", err)
return false
}
return true
}
ok := true
// resolvectl dns LINK [SERVER...]; passing a single empty string clears the
// per-link server list.
dnsArgs := []string{"dns", iface}
if len(servers) > 0 {
dnsArgs = append(dnsArgs, servers...)
} else {
dnsArgs = append(dnsArgs, "")
}
if _, err := runCommand(ctx, resolvectlBin, dnsArgs...); err != nil {
logger.Printf("live DNS: resolvectl dns: %v", err)
ok = false
}
// resolvectl domain LINK [DOMAIN...]; passing a single empty string clears
// the per-link search list.
domainArgs := []string{"domain", iface}
if len(searchDomains) > 0 {
domainArgs = append(domainArgs, searchDomains...)
} else {
domainArgs = append(domainArgs, "")
}
if _, err := runCommand(ctx, resolvectlBin, domainArgs...); err != nil {
logger.Printf("live DNS: resolvectl domain: %v", err)
ok = false
}
return ok
}
// writeResolvConf rewrites /etc/resolv.conf with the given servers and search
// domains, preserving any non-nameserver/non-search lines (comments, options,
// etc.). It only writes a regular file: when /etc/resolv.conf is a symlink (as
// it is when managed by systemd-resolved or resolvconf), it is left untouched
// so the manager keeps ownership.
func writeResolvConf(servers, searchDomains []string) error {
fi, err := os.Lstat(resolvConf)
if err != nil {
return fmt.Errorf("stat %s: %w", resolvConf, err)
}
if fi.Mode()&os.ModeSymlink != 0 {
// resolv.conf is a symlink to a manager-owned target; do not clobber it.
return nil
}
perm := fi.Mode().Perm()
data, err := os.ReadFile(resolvConf)
if err != nil {
return fmt.Errorf("read %s: %w", resolvConf, err)
}
var kept []string
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
field := strings.Fields(scanner.Text())
if len(field) > 0 && (field[0] == "nameserver" || field[0] == "search") {
continue
}
kept = append(kept, scanner.Text())
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scan %s: %w", resolvConf, err)
}
var b strings.Builder
for _, line := range kept {
b.WriteString(line)
b.WriteByte('\n')
}
if len(searchDomains) > 0 {
b.WriteString("search ")
b.WriteString(strings.Join(searchDomains, " "))
b.WriteByte('\n')
}
for _, s := range servers {
b.WriteString("nameserver ")
b.WriteString(s)
b.WriteByte('\n')
}
tmp := resolvConf + ".tmp"
if err := os.WriteFile(tmp, []byte(b.String()), perm); err != nil {
return fmt.Errorf("write %s: %w", tmp, err)
}
if err := fileMove(tmp, resolvConf); err != nil {
os.Remove(tmp)
return fmt.Errorf("commit %s: %w", resolvConf, err)
}
return nil
}