From bc468b7a777ef07dbcb88a61daf42c2d9098f6b1 Mon Sep 17 00:00:00 2001 From: William Myers Date: Fri, 5 Jun 2026 21:35:58 +0000 Subject: [PATCH] Backfill interface DNS fields from host resolv.conf When the ACS payload omits DomainNameServers, netlib copies the host's resolv.conf but left the interface struct fields empty. Parse the copied content back into the interface fields. The parser follows resolv.conf(5) using the same rules as Go's net.dnsReadConfig (comment handling, whitespace splitting, last-search-wins). --- ecs-agent/netlib/platform/common_linux.go | 52 ++++++++++++- .../netlib/platform/common_linux_test.go | 75 +++++++++++++++++++ 2 files changed, 123 insertions(+), 4 deletions(-) diff --git a/ecs-agent/netlib/platform/common_linux.go b/ecs-agent/netlib/platform/common_linux.go index 2b8bb0a4829..98a80d01860 100644 --- a/ecs-agent/netlib/platform/common_linux.go +++ b/ecs-agent/netlib/platform/common_linux.go @@ -566,10 +566,54 @@ func (c *common) createResolvConf(netNSDir string, return err } - // Copy Host's resolv.conf file. - return c.copyFile(filepath.Join(netNSDir, ResolveConfFileName), - filepath.Join(c.resolvConfPath, ResolveConfFileName), - taskDNSConfigFileMode) + // Copy Host's resolv.conf file and backfill DNS fields on the interface + // so downstream consumers can read DNS config from the model. + src := filepath.Join(c.resolvConfPath, ResolveConfFileName) + contents, err := c.ioutil.ReadFile(src) + if err != nil { + return errors.Wrapf(err, "unable to read %s", src) + } + dst := filepath.Join(netNSDir, ResolveConfFileName) + if err := c.ioutil.WriteFile(dst, contents, taskDNSConfigFileMode); err != nil { + return errors.Wrapf(err, "unable to write to %s", dst) + } + servers, searches := parseResolvConf(contents) + iface.DomainNameServers = servers + iface.DomainNameSearchList = searches + return nil +} + +// parseResolvConf extracts nameserver addresses and search domains from content +// in the resolv.conf(5) format. +// +// Parsing rules (matching the behavior of Go's net.dnsReadConfig in +// src/net/dnsconfig_unix.go and glibc's res_init): +// - Lines starting with ';' or '#' are treated as comments and skipped. +// - Fields are separated by any combination of spaces and tabs. +// - A "nameserver" line contributes one IP address (the second field). +// - A "search" line contributes all subsequent fields as search domains; +// if multiple search lines appear, only the last is used. +// - All other directives are ignored. +func parseResolvConf(content []byte) (servers, searches []string) { + for _, line := range bytes.Split(content, []byte("\n")) { + if len(line) == 0 || line[0] == ';' || line[0] == '#' { + continue + } + fields := bytes.Fields(line) + if len(fields) < 2 { + continue + } + switch string(fields[0]) { + case "nameserver": + servers = append(servers, string(fields[1])) + case "search": + searches = nil + for _, f := range fields[1:] { + searches = append(searches, string(f)) + } + } + } + return servers, searches } func (c *common) copyFile(dst, src string, fileMode os.FileMode) error { diff --git a/ecs-agent/netlib/platform/common_linux_test.go b/ecs-agent/netlib/platform/common_linux_test.go index 5866f5dcbd3..f4d2916cb6b 100644 --- a/ecs-agent/netlib/platform/common_linux_test.go +++ b/ecs-agent/netlib/platform/common_linux_test.go @@ -606,3 +606,78 @@ func TestParseHosts(t *testing.T) { }) } } + +func TestParseResolvConf(t *testing.T) { + tests := []struct { + name string + input string + expectedServers []string + expectedSearch []string + }{ + { + // Test fixture from Go's net package resolv.conf parser tests: + // https://github.com/golang/go/blob/master/src/net/testdata/resolv.conf + name: "Go stdlib test fixture", + input: "# /etc/resolv.conf\n" + + "domain localdomain\n" + + "nameserver 8.8.8.8\n" + + "nameserver 2001:4860:4860::8888\n" + + "nameserver fe80::1%lo0\n" + + "options ndots:5 timeout:10 attempts:3 rotate\n" + + "options attempts 3\n", + expectedServers: []string{"8.8.8.8", "2001:4860:4860::8888", "fe80::1%lo0"}, + expectedSearch: nil, + }, + { + name: "semicolon comments", + input: "; this is a comment\nnameserver 8.8.8.8\n", + expectedServers: []string{"8.8.8.8"}, + expectedSearch: nil, + }, + { + name: "tabs as separators", + input: "nameserver\t10.0.0.2\nsearch\tlocal\texample.com\n", + expectedServers: []string{"10.0.0.2"}, + expectedSearch: []string{"local", "example.com"}, + }, + { + name: "multiple spaces between keyword and value", + input: "nameserver 8.8.8.8\nsearch example.com\n", + expectedServers: []string{"8.8.8.8"}, + expectedSearch: []string{"example.com"}, + }, + { + name: "last search directive wins", + input: "search first.com\n" + + "search second.com third.com\n", + expectedServers: nil, + expectedSearch: []string{"second.com", "third.com"}, + }, + { + name: "empty input", + input: "", + expectedServers: nil, + expectedSearch: nil, + }, + { + name: "only comments and blanks", + input: "# comment\n\n; another\n", + expectedServers: nil, + expectedSearch: nil, + }, + { + name: "unknown directives ignored", + input: "options ndots:5\nnameserver 10.0.0.2\ndomain example.com\nsortlist 130.155.160.0\n", + expectedServers: []string{"10.0.0.2"}, + expectedSearch: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + servers, searches := parseResolvConf([]byte(tc.input)) + assert.Equal(t, tc.expectedServers, servers) + assert.Equal(t, tc.expectedSearch, searches) + }) + } +}