154 lines
5 KiB
Go
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
|
|
}
|