From e884668f00968c3ff7e6500d45ec4262151535fe Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Thu, 17 Sep 2026 03:11:39 +0000 Subject: [PATCH] fix(dns): write the file the stage names, not the symlink's target os.WriteFile follows symlinks, and most systemd distributions ship /etc/resolv.conf as a link into systemd-resolved's runtime directory. The nameservers therefore landed in a file resolved owns and regenerates, on a tmpfs, so the dns stage never stuck. In an initramfs that directory does not exist yet and the whole stage failed with ENOENT. Write a sibling and rename over the path instead. Rename acts on the link rather than its target, so the caller gets a regular file where it asked for one, resolved's file is left alone, and no reader can observe a half-written resolv.conf. The same function ignored the vfs.FS it was handed and went straight to the os package, which wrote the host's /etc/resolv.conf no matter which filesystem the executor was pointed at. Route it through fs, like every other plugin, and create the parent directory when it is missing. Signed-off-by: Ettore Di Giacinto --- pkg/plugins/dns.go | 30 +++++++--- pkg/plugins/dns_test.go | 125 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 141 insertions(+), 14 deletions(-) diff --git a/pkg/plugins/dns.go b/pkg/plugins/dns.go index 848df8f1..87c9838a 100644 --- a/pkg/plugins/dns.go +++ b/pkg/plugins/dns.go @@ -2,7 +2,7 @@ package plugins import ( "bytes" - "os" + "path/filepath" "strings" "github.com/mudler/yip/pkg/logger" @@ -12,21 +12,20 @@ import ( func DNS(l logger.Interface, s schema.Stage, fs vfs.FS, console Console) error { if len(s.Dns.Nameservers) != 0 { - return applyDNS(s) + return applyDNS(s, fs) } return nil } -func applyDNS(s schema.Stage) error { +func applyDNS(s schema.Stage, fs vfs.FS) error { path := s.Dns.Path if path == "" { path = "/etc/resolv.conf" } - err := Build(path, s.Dns.Nameservers, s.Dns.DnsSearch, s.Dns.DnsOptions) - return err + return Build(path, s.Dns.Nameservers, s.Dns.DnsSearch, s.Dns.DnsOptions, fs) } -func Build(path string, nameservers, dnsSearch, dnsOptions []string) error { +func Build(path string, nameservers, dnsSearch, dnsOptions []string, fs vfs.FS) error { content := bytes.NewBuffer(nil) if len(dnsSearch) > 0 { if searchString := strings.Join(dnsSearch, " "); strings.Trim(searchString, " ") != "." { @@ -48,7 +47,24 @@ func Build(path string, nameservers, dnsSearch, dnsOptions []string) error { } } - if err := os.WriteFile(path, content.Bytes(), 0o644); err != nil { + dir := filepath.Dir(path) + if err := vfs.MkdirAll(fs, dir, 0755); err != nil { + return err + } + + // Most systemd distributions ship /etc/resolv.conf as a symlink into + // systemd-resolved's runtime directory, and Kairos creates that link itself. + // Writing through it lands the nameservers in a file resolved owns and + // regenerates, on a tmpfs that does not exist yet in the initramfs. Write a + // sibling and rename over the path instead: rename acts on the link rather + // than its target, so the caller gets a regular file where it asked for one + // and no reader ever sees a half-written resolv.conf. + tmp := filepath.Join(dir, "."+filepath.Base(path)+".yip") + if err := fs.WriteFile(tmp, content.Bytes(), 0644); err != nil { + return err + } + if err := fs.Rename(tmp, path); err != nil { + fs.Remove(tmp) return err } return nil diff --git a/pkg/plugins/dns_test.go b/pkg/plugins/dns_test.go index 1a1f7bdf..565d2686 100644 --- a/pkg/plugins/dns_test.go +++ b/pkg/plugins/dns_test.go @@ -15,13 +15,13 @@ package plugins_test import ( - "io/ioutil" "os" . "github.com/mudler/yip/pkg/plugins" "github.com/mudler/yip/pkg/schema" consoletests "github.com/mudler/yip/tests/console" "github.com/sirupsen/logrus" + "github.com/twpayne/go-vfs/v5" "github.com/twpayne/go-vfs/v5/vfst" . "github.com/onsi/ginkgo/v2" @@ -32,25 +32,136 @@ var _ = Describe("Dns", func() { Context("parsing yip file", func() { testConsole := consoletests.TestConsole{} + var fs vfs.FS + var cleanup func() + var err error + BeforeEach(func() { testConsole.Reset() }) + + AfterEach(func() { + if cleanup != nil { + cleanup() + } + }) + It("sets dns", func() { - fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{"/tmp/test/bar": "boo"}) + fs, cleanup, err = vfst.NewTestFS(map[string]interface{}{"/tmp/test/bar": "boo"}) + Expect(err).Should(BeNil()) + + err = DNS(logrus.New(), schema.Stage{Dns: schema.DNS{Path: "/tmp/test/foo", Nameservers: []string{"8.8.8.8"}}}, fs, &testConsole) + Expect(err).ShouldNot(HaveOccurred()) + + b, err := fs.ReadFile("/tmp/test/foo") + Expect(err).ShouldNot(HaveOccurred()) + Expect(string(b)).Should(Equal("nameserver 8.8.8.8\n")) + }) + + It("writes search and options too", func() { + fs, cleanup, err = vfst.NewTestFS(map[string]interface{}{"/tmp/test/bar": "boo"}) + Expect(err).Should(BeNil()) + + err = DNS(logrus.New(), schema.Stage{Dns: schema.DNS{ + Path: "/tmp/test/foo", + Nameservers: []string{"8.8.8.8", "8.8.4.4"}, + DnsSearch: []string{"kairos.io"}, + DnsOptions: []string{"ndots:1"}, + }}, fs, &testConsole) + Expect(err).ShouldNot(HaveOccurred()) + + b, err := fs.ReadFile("/tmp/test/foo") + Expect(err).ShouldNot(HaveOccurred()) + Expect(string(b)).Should(Equal("search kairos.io\nnameserver 8.8.8.8\nnameserver 8.8.4.4\noptions ndots:1\n")) + }) + + // The plugin is handed a vfs.FS and used to ignore it, writing the host's + // real /etc/resolv.conf instead of the one inside the filesystem it was + // given. + It("writes inside the filesystem it is given", func() { + fs, cleanup, err = vfst.NewTestFS(map[string]interface{}{"/etc/resolv.conf": "nameserver 127.0.0.53\n"}) + Expect(err).Should(BeNil()) + + err = DNS(logrus.New(), schema.Stage{Dns: schema.DNS{Nameservers: []string{"8.8.8.8"}}}, fs, &testConsole) + Expect(err).ShouldNot(HaveOccurred()) + + b, err := fs.ReadFile("/etc/resolv.conf") + Expect(err).ShouldNot(HaveOccurred()) + Expect(string(b)).Should(Equal("nameserver 8.8.8.8\n")) + + // The host file must not have been touched. + host, err := os.ReadFile("/etc/resolv.conf") + if err == nil { + Expect(string(host)).ShouldNot(Equal("nameserver 8.8.8.8\n")) + } + }) + + // Kairos points /etc/resolv.conf at /run/systemd/resolve/resolv.conf, so + // following the symlink writes a file systemd-resolved owns and + // regenerates, and the nameservers never stick. + It("replaces a symlink instead of writing through it", func() { + fs, cleanup, err = vfst.NewTestFS(map[string]interface{}{ + "/run/systemd/resolve/resolv.conf": "nameserver 127.0.0.53\n", + }) Expect(err).Should(BeNil()) - temp := fs.TempDir() + Expect(fs.Mkdir("/etc", 0o755)).Should(Succeed()) + Expect(fs.Symlink("/run/systemd/resolve/resolv.conf", "/etc/resolv.conf")).Should(Succeed()) + + err = DNS(logrus.New(), schema.Stage{Dns: schema.DNS{Nameservers: []string{"8.8.8.8"}}}, fs, &testConsole) + Expect(err).ShouldNot(HaveOccurred()) - defer cleanup() + info, err := fs.Lstat("/etc/resolv.conf") + Expect(err).ShouldNot(HaveOccurred()) + Expect(info.Mode() & os.ModeSymlink).Should(Equal(os.FileMode(0))) + + b, err := fs.ReadFile("/etc/resolv.conf") + Expect(err).ShouldNot(HaveOccurred()) + Expect(string(b)).Should(Equal("nameserver 8.8.8.8\n")) + + // systemd-resolved's own file is left alone. + b, err = fs.ReadFile("/run/systemd/resolve/resolv.conf") + Expect(err).ShouldNot(HaveOccurred()) + Expect(string(b)).Should(Equal("nameserver 127.0.0.53\n")) + }) + + // In the initramfs /run/systemd/resolve does not exist yet, so following + // the dangling symlink failed the whole stage with ENOENT. + It("replaces a dangling symlink", func() { + fs, cleanup, err = vfst.NewTestFS(map[string]interface{}{"/tmp/test/bar": "boo"}) + Expect(err).Should(BeNil()) + Expect(fs.Mkdir("/etc", 0o755)).Should(Succeed()) + Expect(fs.Symlink("/run/systemd/resolve/resolv.conf", "/etc/resolv.conf")).Should(Succeed()) - err = DNS(logrus.New(), schema.Stage{Dns: schema.DNS{Path: temp + "/foo", Nameservers: []string{"8.8.8.8"}}}, fs, &testConsole) + err = DNS(logrus.New(), schema.Stage{Dns: schema.DNS{Nameservers: []string{"8.8.8.8"}}}, fs, &testConsole) Expect(err).ShouldNot(HaveOccurred()) - file, err := os.Open(temp + "/foo") + + b, err := fs.ReadFile("/etc/resolv.conf") Expect(err).ShouldNot(HaveOccurred()) + Expect(string(b)).Should(Equal("nameserver 8.8.8.8\n")) + }) - b, err := ioutil.ReadAll(file) + It("creates the parent directory when it is missing", func() { + fs, cleanup, err = vfst.NewTestFS(map[string]interface{}{"/tmp/test/bar": "boo"}) + Expect(err).Should(BeNil()) + + err = DNS(logrus.New(), schema.Stage{Dns: schema.DNS{Path: "/etc/resolv.conf", Nameservers: []string{"8.8.8.8"}}}, fs, &testConsole) Expect(err).ShouldNot(HaveOccurred()) + b, err := fs.ReadFile("/etc/resolv.conf") + Expect(err).ShouldNot(HaveOccurred()) Expect(string(b)).Should(Equal("nameserver 8.8.8.8\n")) }) + + It("does nothing without nameservers", func() { + fs, cleanup, err = vfst.NewTestFS(map[string]interface{}{"/etc/resolv.conf": "nameserver 127.0.0.53\n"}) + Expect(err).Should(BeNil()) + + err = DNS(logrus.New(), schema.Stage{}, fs, &testConsole) + Expect(err).ShouldNot(HaveOccurred()) + + b, err := fs.ReadFile("/etc/resolv.conf") + Expect(err).ShouldNot(HaveOccurred()) + Expect(string(b)).Should(Equal("nameserver 127.0.0.53\n")) + }) }) })