diff --git a/net/dns/manager.go b/net/dns/manager.go index a955a3c52..68ea279d1 100644 --- a/net/dns/manager.go +++ b/net/dns/manager.go @@ -282,7 +282,10 @@ func compileHostEntries(cfg Config) (hosts []*HostEntry) { return hosts } -var osConfigurationReadWarnable = health.Register(&health.Warnable{ +// OSConfigurationReadWarnable is a Warnable set when Tailscale cannot read the +// DNS configuration the OS was using before Tailscale took over. It is +// exported so that a test can name it rather than repeat its wording. +var OSConfigurationReadWarnable = health.Register(&health.Warnable{ Code: "dns-read-os-config-failed", Title: "Failed to read system DNS configuration", Text: func(args health.Args) string { @@ -436,14 +439,14 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig // config instead of erroring and leaving the old OS config. // Sandboxed macOS is excluded: it does have a base config // (/etc/resolv.conf), so this error is a real read failure there. - m.health.SetHealthy(osConfigurationReadWarnable) + m.health.SetHealthy(OSConfigurationReadWarnable) ocfg.MatchDomains = cfg.matchDomains() return rcfg, ocfg, nil } - m.health.SetUnhealthy(osConfigurationReadWarnable, health.Args{health.ArgError: err.Error()}) + m.health.SetUnhealthy(OSConfigurationReadWarnable, health.Args{health.ArgError: err.Error()}) return resolver.Config{}, OSConfig{}, err } - m.health.SetHealthy(osConfigurationReadWarnable) + m.health.SetHealthy(OSConfigurationReadWarnable) // On iOS only (for now), check if all route names point to resources inside the tailnet. // If so, we can set those names as MatchDomains to enable a split DNS configuration diff --git a/tstest/natlab/vmtest/cloudinit.go b/tstest/natlab/vmtest/cloudinit.go index e4ef3b6ba..1e0aff6ce 100644 --- a/tstest/natlab/vmtest/cloudinit.go +++ b/tstest/natlab/vmtest/cloudinit.go @@ -112,9 +112,18 @@ func (e *Env) generateLinuxUserData(n *Node) string { ud.WriteString(fmt.Sprintf(" ssh_authorized_keys:\n - %s\n", strings.TrimSpace(string(pubkey)))) } + var files []cloudInitFile if n.systemdUnit { - e.writeSystemdUnitFiles(&ud, n) + files = append(files, e.systemdUnitFiles(n)...) } + if n.dnsMode == DNSOpenresolv { + orFiles, err := buildOpenresolv() + if err != nil { + e.t.Fatalf("building openresolv for the guest: %v", err) + } + files = append(files, orFiles...) + } + writeCloudInitFiles(&ud, files) ud.WriteString("runcmd:\n") @@ -190,6 +199,26 @@ func writeLinuxDNSModeSetup(ud *strings.Builder, mode DNSMode) { fmt.Fprintf(ud, " - [\"/bin/sh\", \"-c\", \"systemctl disable --now systemd-resolved 2>/dev/null || true\"]\n") fmt.Fprintf(ud, " - [\"/bin/sh\", \"-c\", \"systemctl mask systemd-resolved 2>/dev/null || true\"]\n") fmt.Fprintf(ud, " - [\"/bin/sh\", \"-c\", \"rm -f /etc/resolv.conf && printf 'nameserver %s\\\\n' >/etc/resolv.conf\"]\n", vnet.FakeDNSIPv4()) + case DNSOpenresolv: + // Mask systemd-resolved, so that resolvconfStyle() in + // net/dns/resolvconf.go finds a resolvconf that is not Debian's. + // dnsMode() then selects the "openresolv" manager. openresolv's own + // files are installed by write_files; see buildOpenresolv. + fmt.Fprintf(ud, " - [\"/bin/sh\", \"-c\", \"systemctl disable --now systemd-resolved 2>/dev/null || true\"]\n") + fmt.Fprintf(ud, " - [\"/bin/sh\", \"-c\", \"systemctl mask systemd-resolved 2>/dev/null || true\"]\n") + // Create resolvconf's key directory, empty. Left alone, resolvconf + // would not create it until the first snippet was registered, and a + // missing directory makes "resolvconf -l" exit 0 instead of 2. Only an + // existing empty directory produces the state [DNSOpenresolv] + // describes. write_files cannot create this one, because it creates a + // directory only as a side effect of writing a file into it. + fmt.Fprintf(ud, " - [\"mkdir\", \"-p\", \"%s\"]\n", openresolvKeyDir) + // The signature line is required verbatim: openresolv's libc + // subscriber refuses to overwrite a resolv.conf that lacks it, and + // resolvOwner() in net/dns/direct.go needs "resolvconf" in the leading + // comments for dnsMode() to take its resolvconf branch. The nameserver + // keeps the guest resolving until tailscaled takes over. + fmt.Fprintf(ud, " - [\"/bin/sh\", \"-c\", \"rm -f /etc/resolv.conf && printf '# Generated by resolvconf\\\\nnameserver %s\\\\n' >/etc/resolv.conf\"]\n", vnet.FakeDNSIPv4()) } } @@ -272,12 +301,39 @@ func (e *Env) generateFreeBSDUserData(n *Node) string { return ud.String() } -// writeSystemdUnitFiles appends a cloud-init write_files section that -// installs the stock tailscaled systemd unit from the source tree, along -// with the packaging's /etc/default/tailscaled EnvironmentFile (plus any -// per-node TailscaledEnv variables). File contents are base64-encoded to -// sidestep YAML quoting. -func (e *Env) writeSystemdUnitFiles(ud *strings.Builder, n *Node) { +// cloudInitFile is one file cloud-init writes into the guest. cloud-init +// writes them during its config stage, which runs before the runcmd entries, +// and it creates any missing parent directories. +type cloudInitFile struct { + path string + content []byte + mode string // octal permissions, e.g. "0755"; empty leaves cloud-init's default +} + +// writeCloudInitFiles appends the write_files section. There can be only one +// such section per user-data document, so every file a node needs has to be +// collected before this is called. +// +// Contents are base64-encoded to sidestep YAML quoting. +func writeCloudInitFiles(ud *strings.Builder, files []cloudInitFile) { + if len(files) == 0 { + return + } + ud.WriteString("write_files:\n") + for _, f := range files { + fmt.Fprintf(ud, " - path: %s\n", f.path) + fmt.Fprintf(ud, " encoding: b64\n") + if f.mode != "" { + fmt.Fprintf(ud, " permissions: '%s'\n", f.mode) + } + fmt.Fprintf(ud, " content: %s\n", base64.StdEncoding.EncodeToString(f.content)) + } +} + +// systemdUnitFiles returns the stock tailscaled systemd unit from the source +// tree, along with the packaging's /etc/default/tailscaled EnvironmentFile +// (plus any per-node TailscaledEnv variables). +func (e *Env) systemdUnitFiles(n *Node) []cloudInitFile { modRoot, err := findModRoot() if err != nil { e.t.Fatalf("finding module root for tailscaled.service: %v", err) @@ -296,14 +352,10 @@ func (e *Env) writeSystemdUnitFiles(ud *strings.Builder, n *Node) { fmt.Fprintf(&envFile, "%s=%q\n", env.Key, env.Value) } - ud.WriteString("write_files:\n") - writeFile := func(path string, content []byte) { - fmt.Fprintf(ud, " - path: %s\n", path) - fmt.Fprintf(ud, " encoding: b64\n") - fmt.Fprintf(ud, " content: %s\n", base64.StdEncoding.EncodeToString(content)) + return []cloudInitFile{ + {path: "/etc/systemd/system/tailscaled.service", content: unit}, + {path: "/etc/default/tailscaled", content: []byte(envFile.String())}, } - writeFile("/etc/systemd/system/tailscaled.service", unit) - writeFile("/etc/default/tailscaled", []byte(envFile.String())) } func tailscaledEnvPrefix(n *Node) string { diff --git a/tstest/natlab/vmtest/dns_openresolv_test.go b/tstest/natlab/vmtest/dns_openresolv_test.go new file mode 100644 index 000000000..dafa76f1c --- /dev/null +++ b/tstest/natlab/vmtest/dns_openresolv_test.go @@ -0,0 +1,213 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package vmtest_test + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + "testing" + "time" + + "tailscale.com/cmd/tailscale/tsdnsjsonv0" + "tailscale.com/health" + "tailscale.com/net/dns" + "tailscale.com/tailcfg" + "tailscale.com/tstest" + "tailscale.com/tstest/natlab/vmtest" + "tailscale.com/tstest/natlab/vnet" + "tailscale.com/types/dnstype" +) + +// The openresolv tests use a routed domain that is answered locally from an +// extra record. That is the cheapest DNS config that still reaches +// GetBaseConfig. It has no default resolvers, which would short-circuit +// compileConfig. It also takes no split-DNS shortcut, because openresolv +// reports SupportsSplitDNS() == false. +const ( + orMagicDNSDomain = "tailnet.test" + orLocalDomain = "local.example" + orLocalName = "host." + orLocalDomain + + // This address is outside the 100.64.x.y block testcontrol assigns to + // nodes, so an answer can only have come from the extra record. + orLocalIP = "100.99.99.99" + + // The signature line openresolv writes at the top of resolv.conf. + orSignature = "# Generated by resolvconf" + + // Tailscale's own resolver. tailscaled points resolv.conf at it once it has + // configured DNS. + orQuad100 = "100.100.100.100" +) + +// newOpenresolvEnv brings up a single Ubuntu node running upstream openresolv +// with an existing but empty snippet directory, so the only resolvconf snippet +// will be Tailscale's own. +func newOpenresolvEnv(t *testing.T) (*vmtest.Env, *vmtest.Node) { + t.Helper() + env := vmtest.New(t, + vmtest.ControlDNS(orMagicDNSDomain, &tailcfg.DNSConfig{ + Proxied: true, // MagicDNS, so there's a route and quad-100 is in play + Domains: []string{orLocalDomain}, + Routes: map[string][]*dnstype.Resolver{ + orLocalDomain: nil, // answer locally, from ExtraRecords + }, + ExtraRecords: []tailcfg.DNSRecord{ + {Name: orLocalName, Type: "A", Value: orLocalIP}, + }, + })) + node := env.AddNode("node", + env.AddNetwork("2.1.1.1", "192.168.1.1/24", vnet.EasyNAT), + vmtest.OS(vmtest.Ubuntu2404), + vmtest.WithDNSMode(vmtest.DNSOpenresolv)) + env.Start() + + // Otherwise a pass could come from a different DNS manager. + env.AssertDNSBackend(node, "openresolv") + return env, node +} + +// TestOpenresolvDNS checks that tailscaled configures DNS on a host where +// Tailscale owns the only resolvconf snippet. Before the fix for +// tailscale/tailscale#20825, such a host got no DNS configuration at all. +func TestOpenresolvDNS(t *testing.T) { + env, node := newOpenresolvEnv(t) + + // tailscaled must have taken over resolv.conf. Checking for quad-100 rather + // than the signature line alone matters, because natlab provisions a + // resolv.conf carrying that same signature. + assertOpenresolvResolvConf(t, env, node, + []string{orSignature, orQuad100}, + []string{vnet.FakeDNSIPv4().String()}) + + assertNoDNSReadWarning(t, env, node) + + // Tailscale's snippet must be registered, and it must be the only one. + // Otherwise the base-config check below would be running against a host + // this test did not set up. + if out, err := env.SSHExec(node, "resolvconf -i"); err != nil { + t.Errorf("resolvconf -i: %v (%s)", err, strings.TrimSpace(out)) + } else if got := strings.Fields(out); !slices.Equal(got, []string{"tailscale"}) { + t.Errorf("resolvconf -i = %q, want just \"tailscale\"", strings.TrimSpace(out)) + } + + // Resolving through the OS resolver proves that libc really uses that + // resolv.conf. + assertResolves(t, env, node, orLocalName, orLocalIP) + + // Tailscale's snippet is the only one registered, so openresolv has no OS + // config to report: both the nameservers and the search domains must come + // back empty. net/dns filters the Tailscale service IPs out of the + // nameservers anyway (tailscale/tailscale#7816), so the search domains are + // what this check really rests on. + base := openresolvBaseConfig(t, env, node) + if base == nil { + return // openresolvBaseConfig already reported it + } + if len(base.Nameservers) != 0 || len(base.SearchDomains) != 0 { + t.Errorf("OS base config = %+v, want it empty: Tailscale owns the only "+ + "resolvconf snippet, so openresolv has no OS config to report "+ + "(tailscale/tailscale#20825)", *base) + } +} + +// assertOpenresolvResolvConf waits for the guest's /etc/resolv.conf to contain +// every string in want and none in notWant. +func assertOpenresolvResolvConf(t *testing.T, env *vmtest.Env, n *vmtest.Node, want, notWant []string) { + t.Helper() + const cmd = "cat /etc/resolv.conf" + var last string + if err := tstest.WaitFor(60*time.Second, func() error { + out, err := env.SSHExec(n, cmd) + last = out + if err != nil { + return fmt.Errorf("%s: %v (%s)", cmd, err, strings.TrimSpace(out)) + } + for _, w := range want { + if !strings.Contains(out, w) { + return fmt.Errorf("resolv.conf is missing %q", w) + } + } + for _, w := range notWant { + if strings.Contains(out, w) { + return fmt.Errorf("resolv.conf unexpectedly has %q", w) + } + } + return nil + }); err != nil { + // The health lines are the fastest way to tell "DNS was never applied" + // from "DNS was applied but looks wrong". + t.Fatalf("%v\nwant all of %q and none of %q in resolv.conf:\n%s\nhealth: %q", + err, want, notWant, last, env.Status(n).Health) + } +} + +// openresolvBaseConfig returns what tailscaled reads back as the node's own OS +// DNS configuration. On failure it calls t.Errorf and returns nil. +// +// "tailscale dns status" fetches it over the local API, which calls straight +// through to openresolvManager.GetBaseConfig, so this is the live result of +// running resolvconf in the guest, not something inferred from its consequences. +func openresolvBaseConfig(t *testing.T, env *vmtest.Env, n *vmtest.Node) *tsdnsjsonv0.SystemConfig { + t.Helper() + const cmd = "tailscale dns status --json" + out, err := env.SSHExec(n, cmd) + if err != nil { + t.Errorf("%s: %v (%s)", cmd, err, strings.TrimSpace(out)) + return nil + } + var st tsdnsjsonv0.StatusResponse + if err := json.Unmarshal([]byte(out), &st); err != nil { + t.Errorf("parsing %s output: %v\n%s", cmd, err, out) + return nil + } + if st.SystemDNSError != "" { + t.Errorf("tailscaled could not read the OS DNS config: %q", st.SystemDNSError) + return nil + } + if st.SystemDNS == nil { + t.Errorf("%s reported no OS DNS config:\n%s", cmd, out) + return nil + } + return st.SystemDNS +} + +// assertNoDNSReadWarning fails if the node is reporting that it could not read +// the OS DNS configuration. +func assertNoDNSReadWarning(t *testing.T, env *vmtest.Env, n *vmtest.Node) { + t.Helper() + // The warning ends with the error text, so match on what comes before it. + // Taking that from the Warnable rather than copying its wording matters + // here: this check asserts an absence, so a copy that stopped matching + // would leave the check passing while testing nothing. + want := dns.OSConfigurationReadWarnable.Text(health.Args{health.ArgError: ""}) + for _, h := range env.Status(n).Health { + if strings.HasPrefix(h, want) { + t.Errorf("node is unhealthy: %q", h) + } + } +} + +// assertResolves waits for name to resolve to want on the node. It asks for A +// records only, so a name with both an A and a AAAA record cannot come back as +// the wrong address family. +func assertResolves(t *testing.T, env *vmtest.Env, n *vmtest.Node, name, want string) { + t.Helper() + cmd := "getent ahostsv4 " + name + if err := tstest.WaitFor(30*time.Second, func() error { + out, err := env.SSHExec(n, cmd) + if err != nil { + return fmt.Errorf("%s: %v (%s)", cmd, err, strings.TrimSpace(out)) + } + if !strings.Contains(out, want) { + return fmt.Errorf("%s = %q, want it to contain %s", cmd, strings.TrimSpace(out), want) + } + return nil + }); err != nil { + out, _ := env.SSHExec(n, "cat /etc/resolv.conf; resolvconf -i") + t.Errorf("%v\nresolver state:\n%s", err, out) + } +} diff --git a/tstest/natlab/vmtest/openresolv.go b/tstest/natlab/vmtest/openresolv.go new file mode 100644 index 000000000..fa27130b5 --- /dev/null +++ b/tstest/natlab/vmtest/openresolv.go @@ -0,0 +1,168 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package vmtest + +import ( + "bytes" + "crypto/sha256" + "embed" + "encoding/hex" + "fmt" + "regexp" +) + +// These paths are where openresolv gets installed in the guest. openresolv's +// build bakes them into its scripts with sed substitutions (see its Makefile), +// so they have to agree with openresolvSubst below. +const ( + // openresolvSbinDir must be on the default PATH, because net/dns finds + // resolvconf with exec.LookPath (see resolvconfStyle in + // net/dns/resolvconf.go). + openresolvSbinDir = "/sbin" + + // openresolvSysconfDir is where resolvconf looks for resolvconf.conf. + openresolvSysconfDir = "/etc" + + // openresolvLibexecDir holds the subscriber scripts resolvconf runs after + // a snippet is added or removed. + openresolvLibexecDir = "/usr/libexec/resolvconf" + + // openresolvVarDir is resolvconf's runtime state directory. Its "keys" + // subdirectory is where registered config snippets live. + openresolvVarDir = "/run/resolvconf" + + // openresolvKeyDir is created empty by provisioning; see [DNSOpenresolv]. + openresolvKeyDir = openresolvVarDir + "/keys" +) + +// openresolvVersion is the upstream release the vendored sources come from. +// The files keep their upstream names, so this is where the version is +// recorded. See testdata/openresolv/README.md. +const openresolvVersion = "v3.17.4" + +// openresolvSrc holds the vendored openresolv sources. See +// testdata/openresolv/README.md for their provenance. +// +//go:embed testdata/openresolv/*.in +var openresolvSrc embed.FS + +// openresolvFile is one file natlab installs into the guest to provide +// openresolv. +type openresolvFile struct { + guestPath string // absolute path to install to in the guest + srcName string // vendored source to substitute, relative to testdata/openresolv + srcSHA256 string // hex SHA256 the vendored source must have, before substitution + inline string // literal contents, for files with no upstream source + mode string // octal permissions for cloud-init to apply +} + +// openresolvInstall is the subset of openresolv's own files this mode installs. +// +// Upstream ships further subscriber scripts, for dnsmasq and unbound among +// others. Leaving those out is safe, because each of openresolv's subscriber +// loops skips a script that is not present. libc is the one script that has to +// be installed, because it writes resolv.conf. +var openresolvInstall = []openresolvFile{ + { + guestPath: openresolvSbinDir + "/resolvconf", + srcName: "resolvconf.in", + srcSHA256: "c806bd4aa0d1c59736beae3af8a7e4c7cfd6a24664cfbc01be10b28af89f5b8c", + mode: "0755", + }, + { + // resolvconf sources this rather than exec'ing it if it isn't + // executable, so it needs no exec bit. + guestPath: openresolvLibexecDir + "/libc", + srcName: "libc.in", + srcSHA256: "25b7ba247cb033130035a09751d78be40786ee449a93f31c61b93409dabd54ea", + mode: "0644", + }, + { + // Upstream's own default, which its "make install" installs too. + // What matters here is that the file exists at all: without it, + // resolvconf switches to the original Debian layout if + // /etc/resolvconf happens to be a directory, which is not what this + // mode tests. + guestPath: openresolvSysconfDir + "/resolvconf.conf", + inline: "resolv_conf=/etc/resolv.conf\n", + mode: "0644", + }, +} + +// openresolvSubst is openresolv's build-time substitution table. Its Makefile +// seds these placeholders out of the .in files; natlab does the same, so the +// test needs no configure-and-make step. +// +// RCDIR, RESTARTCMD and STATUSARG are empty, as they are in an unconfigured +// upstream build. resolvconf's detect_init() then picks a restart command at +// runtime, which is systemctl on these guests. That command restarts the libc +// service only when it is already active. Here the libc service is nscd, which +// these guests do not run. +var openresolvSubst = map[string]string{ + "@SBINDIR@": openresolvSbinDir, + "@SYSCONFDIR@": openresolvSysconfDir, + "@LIBEXECDIR@": openresolvLibexecDir, + "@VARDIR@": openresolvVarDir, + "@RCDIR@": "", + "@RESTARTCMD@": "", + "@STATUSARG@": "", +} + +var openresolvPlaceholderRx = regexp.MustCompile(`@[A-Z_]+@`) + +// substOpenresolv applies openresolvSubst to b, and fails if any placeholder +// survives. An openresolv release that adds one would otherwise install a +// shell script containing a literal "@FOO@", which fails in the guest in some +// far less obvious way. +func substOpenresolv(name string, b []byte) ([]byte, error) { + for k, v := range openresolvSubst { + b = bytes.ReplaceAll(b, []byte(k), []byte(v)) + } + if m := openresolvPlaceholderRx.Find(b); m != nil { + return nil, fmt.Errorf("%s: unsubstituted placeholder %q; openresolvSubst needs updating for the vendored openresolv", name, m) + } + return b, nil +} + +// checkOpenresolvSHA256 reports whether b is the vendored source natlab +// expects. It does not authenticate the file against upstream, because nothing +// here reaches the network. What it catches is a vendored file whose contents +// changed while its recorded provenance stayed put, which is what a half-done +// version bump or a stray local edit looks like. +func checkOpenresolvSHA256(name string, b []byte, want string) error { + sum := sha256.Sum256(b) + if got := hex.EncodeToString(sum[:]); got != want { + return fmt.Errorf("testdata/openresolv/%s has SHA256 %s, want %s for openresolv %s; change the file and its srcSHA256 together, per testdata/openresolv/README.md", name, got, want, openresolvVersion) + } + return nil +} + +// buildOpenresolv returns the files to install in the guest, in the order +// openresolvInstall lists them. +func buildOpenresolv() ([]cloudInitFile, error) { + out := make([]cloudInitFile, 0, len(openresolvInstall)) + for _, f := range openresolvInstall { + content := []byte(f.inline) + if f.srcName != "" { + b, err := openresolvSrc.ReadFile("testdata/openresolv/" + f.srcName) + if err != nil { + return nil, err + } + // Before substitution, so the hash is the upstream file's own. + if err := checkOpenresolvSHA256(f.srcName, b, f.srcSHA256); err != nil { + return nil, err + } + if b, err = substOpenresolv(f.srcName, b); err != nil { + return nil, err + } + content = b + } + out = append(out, cloudInitFile{ + path: f.guestPath, + content: content, + mode: f.mode, + }) + } + return out, nil +} diff --git a/tstest/natlab/vmtest/openresolv_test.go b/tstest/natlab/vmtest/openresolv_test.go new file mode 100644 index 000000000..63b9090a1 --- /dev/null +++ b/tstest/natlab/vmtest/openresolv_test.go @@ -0,0 +1,129 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package vmtest + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// TestBuildOpenresolv checks that the vendored openresolv sources still build +// into what the guest expects. buildOpenresolv verifies their recorded hashes +// and rejects a surviving build placeholder, so an edited source or an upstream +// bump fails here in milliseconds. The alternative is a puzzling DNS failure +// several minutes into TestOpenresolvDNS. +func TestBuildOpenresolv(t *testing.T) { + files, err := buildOpenresolv() + if err != nil { + t.Fatal(err) + } + if len(files) != len(openresolvInstall) { + t.Fatalf("built %d files, want %d", len(files), len(openresolvInstall)) + } + + byPath := map[string]cloudInitFile{} + for _, f := range files { + byPath[f.path] = f + } + for _, want := range openresolvInstall { + f, ok := byPath[want.guestPath] + if !ok { + t.Errorf("%s: not built", want.guestPath) + continue + } + if len(f.content) == 0 { + t.Errorf("%s: empty", want.guestPath) + } + if f.mode != want.mode { + t.Errorf("%s: mode %q, want %q", want.guestPath, f.mode, want.mode) + } + if want.srcName != "" && !strings.HasPrefix(string(f.content), "#!/bin/sh") { + t.Errorf("%s: does not start with a /bin/sh shebang", want.guestPath) + } + } + + // The substitutions have to have actually happened: resolvconf derives its + // key directory from VARDIR, and net/dns's whole openresolv path hinges on + // that directory being the one the test provisions. + rc := string(byPath[openresolvSbinDir+"/resolvconf"].content) + for _, want := range []string{ + "VARDIR=" + openresolvVarDir, + "LIBEXECDIR=" + openresolvLibexecDir, + `KEYDIR="$VARDIR/keys"`, + } { + if !strings.Contains(rc, want) { + t.Errorf("built resolvconf does not contain %q", want) + } + } +} + +// TestOpenresolvSHA256 checks the hash check in both directions. On its own, +// TestBuildOpenresolv only ever runs it against sources that match, so a check +// that accepted anything would still look fine there. +func TestOpenresolvSHA256(t *testing.T) { + const content = "not the vendored file" + sum := sha256.Sum256([]byte(content)) + + if err := checkOpenresolvSHA256("libc.in", []byte(content), hex.EncodeToString(sum[:])); err != nil { + t.Errorf("matching content was rejected: %v", err) + } + if err := checkOpenresolvSHA256("libc.in", []byte(content), strings.Repeat("0", 64)); err == nil { + t.Error("content that does not match its recorded hash was accepted") + } +} + +// TestOpenresolvWriteFiles checks that the guest files survive a round trip +// through the cloud-init write_files section. resolvconf.in is 31 kB, so its +// base64 encoding is one very long YAML scalar, and a quoting or line-length +// mistake would show up as a guest that boots with a truncated shell script. +func TestOpenresolvWriteFiles(t *testing.T) { + files, err := buildOpenresolv() + if err != nil { + t.Fatal(err) + } + var ud strings.Builder + ud.WriteString("#cloud-config\n") + writeCloudInitFiles(&ud, files) + + var got struct { + WriteFiles []struct { + Path string `yaml:"path"` + Encoding string `yaml:"encoding"` + Permissions string `yaml:"permissions"` + Content string `yaml:"content"` + } `yaml:"write_files"` + } + if err := yaml.Unmarshal([]byte(ud.String()), &got); err != nil { + t.Fatalf("user-data is not valid YAML: %v", err) + } + if len(got.WriteFiles) != len(files) { + t.Fatalf("write_files has %d entries, want %d", len(got.WriteFiles), len(files)) + } + for i, want := range files { + g := got.WriteFiles[i] + if g.Path != want.path { + t.Errorf("entry %d: path %q, want %q", i, g.Path, want.path) + } + if g.Encoding != "b64" { + t.Errorf("%s: encoding %q, want b64", want.path, g.Encoding) + } + if g.Permissions != want.mode { + t.Errorf("%s: permissions %q, want %q", want.path, g.Permissions, want.mode) + } + content, err := base64.StdEncoding.DecodeString(g.Content) + if err != nil { + t.Errorf("%s: decoding content: %v", want.path, err) + continue + } + if string(content) != string(want.content) { + t.Errorf("%s: content survived the round trip as %d bytes, want %d", + want.path, len(content), len(want.content)) + } + } +} diff --git a/tstest/natlab/vmtest/testdata/openresolv/README.md b/tstest/natlab/vmtest/testdata/openresolv/README.md new file mode 100644 index 000000000..c06eb490a --- /dev/null +++ b/tstest/natlab/vmtest/testdata/openresolv/README.md @@ -0,0 +1,50 @@ +# Vendored openresolv sources + +These are two unmodified source files from upstream openresolv, the resolvconf +implementation behind `net/dns`'s "openresolv" backend. `DNSOpenresolv` installs +them into a guest so a vmtest can exercise that backend. See `../../openresolv.go`. + +No cloud image ships openresolv, and a guest cannot download it, because vnet +has no route to the real internet. They are vendored rather than fetched at test +time so that running a vmtest locally needs no network access beyond the cloud +image it already downloads. + +openresolv's build applies a handful of `sed` substitutions to these `.in` files +and compiles nothing, so `openresolv.go` can do the same substitutions in +process. That is why only the `.in` sources are here. + +## Provenance + +Both files come from commit +[`6489889ce5631364ad2f17d391e1a3ad969619f2`](https://github.com/NetworkConfiguration/openresolv/tree/6489889ce5631364ad2f17d391e1a3ad969619f2), +which is tagged `v3.17.4`. They are byte-identical to the same-named members of +the `v3.17.4` release tarball. + +The SHA256 of each file is recorded in `openresolvInstall`, in +`../../openresolv.go`, next to the version. `buildOpenresolv` checks both files +against those hashes every time it runs, so a file that changes without its +recorded hash changing too fails the tests rather than reaching a guest. The +hashes say nothing about whether these files came from upstream, since none of +this reaches the network. They are what makes the version above a claim the +tests can hold the files to. + +To update, copy the files from a new upstream revision: + +```sh +ref= +for f in resolvconf.in libc.in; do + curl -fsSLo "$f" "https://raw.githubusercontent.com/NetworkConfiguration/openresolv/$ref/$f" +done +sha256sum resolvconf.in libc.in +``` + +Then replace the commit above, and in `../../openresolv.go` replace +`openresolvVersion` and both `srcSHA256` values. Run +`go test ./tstest/natlab/vmtest -run 'TestBuildOpenresolv|TestOpenresolvSHA256'` +afterwards. It fails if a hash was missed, or if the new revision adds a build +placeholder that `openresolvSubst` does not know about. + +## Licensing + +openresolv is BSD-2-Clause, copyright Roy Marples. Each file carries the full +license text in its header, and neither file is modified here. diff --git a/tstest/natlab/vmtest/testdata/openresolv/libc.in b/tstest/natlab/vmtest/testdata/openresolv/libc.in new file mode 100644 index 000000000..78fe86ce0 --- /dev/null +++ b/tstest/natlab/vmtest/testdata/openresolv/libc.in @@ -0,0 +1,284 @@ +#!/bin/sh +# Copyright (c) 2007-2025 Roy Marples +# All rights reserved + +# libc subscriber for resolvconf + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided +# with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +SYSCONFDIR=@SYSCONFDIR@ +LIBEXECDIR=@LIBEXECDIR@ +VARDIR=@VARDIR@ +KEYDIR="$VARDIR/keys" +# Compat +if [ ! -d "$KEYDIR" ] && [ -d "$VARDIR/interfaces" ]; then + KEYDIR="$VARDIR/interfaces" +fi + +CMD="$1" +KEY="$2" + +NL=" +" + +warn() +{ + echo "${0##*/}: $*" >&2 +} + +# sed may not be available, and this is faster on small files +key_get_value() +{ + key="$1" + shift + + if [ $# -eq 0 ]; then + while read -r line; do + case "$line" in + "$key"*) echo "${line##$key}";; + esac + done + else + for x do + while read -r line; do + case "$line" in + "$key"*) echo "${line##$key}";; + esac + done < "$x" + done + fi +} + +keys_remove() +{ + while read -r line; do + found=false + for key do + case "$line" in + "$key"*|"#"*|" "*|" "*|"") found=true;; + esac + $found && break + done + $found || echo "$line" + done +} + +local_nameservers="127.* 0.0.0.0 255.255.255.255 ::1" + +# Support original resolvconf configuration layout +# as well as the openresolv config file +if [ -f "$SYSCONFDIR"/resolvconf.conf ]; then + . "$SYSCONFDIR"/resolvconf.conf +elif [ -d "$SYSCONFDIR"/resolvconf ]; then + SYSCONFDIR="$SYSCONFDIR/resolvconf" + base="$SYSCONFDIR/resolv.conf.d/base" + if [ -f "$base" ]; then + prepend_nameservers="$(key_get_value "nameserver " "$base")" + domain="$(key_get_value "domain " "$base")" + prepend_search="$(key_get_value "search " "$base")" + resolv_conf_options="$(key_get_value "options " "$base")" + resolv_conf_sortlist="$(key_get_value "sortlist " "$base")" + fi + if [ -f "$SYSCONFDIR"/resolv.conf.d/head ]; then + resolv_conf_head="$(cat "${SYSCONFDIR}"/resolv.conf.d/head)" + fi + if [ -f "$SYSCONFDIR"/resolv.conf.d/tail ]; then + resolv_conf_tail="$(cat "$SYSCONFDIR"/resolv.conf.d/tail)" + fi +fi +: ${resolv_conf:=/etc/resolv.conf} +if [ "$resolv_conf" = "/dev/null" ]; then + exit 0 +fi +: ${resolv_conf_tmp:="$resolv_conf.$$.openresolv"} +: ${libc_service:=nscd} +: ${list_resolv:=@SBINDIR@/resolvconf -L} +if [ "${resolv_conf_head-x}" = x ] && [ -f "$SYSCONFDIR"/resolv.conf.head ] +then + resolv_conf_head="$(cat "${SYSCONFDIR}"/resolv.conf.head)" +fi +if [ "${resolv_conf_tail-x}" = x ] && [ -f "$SYSCONFDIR"/resolv.conf.tail ] +then + resolv_conf_tail="$(cat "$SYSCONFDIR"/resolv.conf.tail)" +fi + +signature="# Generated by resolvconf" + +uniqify() +{ + result= + while [ -n "$1" ]; do + case " $result " in + *" $1 "*);; + *) result="$result $1";; + esac + shift + done + echo "${result# *}" +} + +case "${resolv_conf_passthrough:-NO}" in +[Yy][Ee][Ss]|[Tt][Rr][Uu][Ee]|[Oo][Nn]|1) + newest= + for conf in "$KEYDIR"/*; do + if [ -z "$newest" ] || [ "$conf" -nt "$newest" ]; then + newest="$conf" + fi + done + [ -z "$newest" ] && exit 0 + newconf="$signature$NL$(cat "$newest")$NL" + ;; +/dev/null|[Nn][Uu][Ll][Ll]) + : ${resolv_conf_local_only:=NO} + if [ "$local_nameservers" = "127.* 0.0.0.0 255.255.255.255 ::1" ]; then + local_nameservers= + fi + # Need to overwrite our variables. + eval "$(@SBINDIR@/resolvconf -V)" + ;; + +*) + [ -z "$RESOLVCONF" ] && eval "$(@SBINDIR@/resolvconf -v)" + ;; +esac +case "${resolv_conf_passthrough:-NO}" in +[Yy][Ee][Ss]|[Tt][Rr][Uu][Ee]|[Oo][Nn]|1) ;; +*) + : ${domain:=$DOMAIN} + newsearch="$(uniqify $prepend_search $SEARCH $append_search)" + NS="$LOCALNAMESERVERS $NAMESERVERS" + newns= + gotlocal=false + for n in $(uniqify $prepend_nameservers $NS $append_nameservers); do + add=true + islocal=false + for l in $local_nameservers; do + case "$n" in + $l) islocal=true; gotlocal=true; break;; + esac + done + if ! $islocal; then + case "${resolv_conf_local_only:-YES}" in + [Yy][Ee][Ss]|[Tt][Rr][Uu][Ee]|[Oo][Nn]|1) + $gotlocal && add=false;; + esac + fi + $add && newns="$newns $n" + done + + # Hold our new resolv.conf in a variable to save on temporary files + newconf="$signature$NL" + if [ -n "$resolv_conf_head" ]; then + newconf="$newconf$resolv_conf_head$NL" + fi + + [ -n "$domain" ] && newconf="${newconf}domain $domain$NL" + if [ -n "$newsearch" ] && [ "$newsearch" != "$domain" ]; then + newconf="${newconf}search $newsearch$NL" + fi + for n in $newns; do + newconf="${newconf}nameserver $n$NL" + done + + # Now add anything we don't care about such as sortlist and options + stuff="$($list_resolv | keys_remove nameserver domain search)" + if [ -n "$stuff" ]; then + newconf="$newconf$stuff$NL" + fi + + # Append any user defined ones + if [ -n "$resolv_conf_options" ]; then + newconf="${newconf}options $resolv_conf_options$NL" + fi + if [ -n "$resolv_conf_sortlist" ]; then + newconf="${newconf}sortlist $resolv_conf_sortlist$NL" + fi + + if [ -n "$resolv_conf_tail" ]; then + newconf="$newconf$resolv_conf_tail$NL" + fi + ;; +esac + +# Check if the file has actually changed or not +if [ -e "$resolv_conf" ]; then + if [ "$CMD" != u ] && \ + [ "$(cat "$resolv_conf")" = "$(printf %s "$newconf")" ] + then + exit 0 + fi + read line <"$resolv_conf" + if [ "$line" != "$signature" ]; then + if [ "$CMD" != u ]; then + warn "signature mismatch: $resolv_conf" + warn "run \`resolvconf -u\` to update" + exit 1 + fi + cp "$resolv_conf" "$resolv_conf.bak" + fi +fi + +# There are pros and cons for writing directly to resolv.conf +# instead of a temporary file and then moving it over. +# The default is to write to resolv.conf as it has the least +# issues and has been the long standing default behaviour. +# resolv.conf could also be bind mounted for network namespaces +# so we cannot move in this instance. +case "${resolv_conf_mv:-NO}" in +[Yy][Ee][Ss]|[Tt][Rr][Uu][Ee]|[Oo][Nn]|1) + # Protect against symlink attack, ensure new file does not exist + rm -f "$resolv_conf_tmp" + # Keep original file owner, group and mode + [ -r "$resolv_conf" ] && cp -p "$resolv_conf" "$resolv_conf_tmp" + # Create our resolv.conf now + if (umask 022; printf %s "$newconf" >"$resolv_conf_tmp"); then + mv "$resolv_conf_tmp" "$resolv_conf" + fi + ;; +*) + (umask 022; printf %s "$newconf" >"$resolv_conf") + ;; +esac + +if [ -n "$libc_restart" ]; then + eval $libc_restart +elif [ -n "$RESTARTCMD" ]; then + set -- ${libc_service} + eval "$RESTARTCMD" +else + @SBINDIR@/resolvconf -r ${libc_service} +fi + +retval=0 +# Notify users of the resolver +for script in "$LIBEXECDIR"/libc.d/*; do + if [ -f "$script" ]; then + if [ -x "$script" ]; then + "$script" "$@" + else + (. "$script") + fi + retval=$(($retval + $?)) + fi +done +exit $retval diff --git a/tstest/natlab/vmtest/testdata/openresolv/resolvconf.in b/tstest/natlab/vmtest/testdata/openresolv/resolvconf.in new file mode 100644 index 000000000..2f55a9de0 --- /dev/null +++ b/tstest/natlab/vmtest/testdata/openresolv/resolvconf.in @@ -0,0 +1,1504 @@ +#!/bin/sh +# Copyright (c) 2007-2025 Roy Marples +# All rights reserved + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided +# with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +RESOLVCONF="$0" +OPENRESOLV_VERSION="3.17.4" +SYSCONFDIR=@SYSCONFDIR@ +LIBEXECDIR=@LIBEXECDIR@ +VARDIR=@VARDIR@ +RCDIR=@RCDIR@ +RESTARTCMD=@RESTARTCMD@ + +if [ "$1" = "--version" ]; then + echo "openresolv $OPENRESOLV_VERSION" + echo "Copyright (c) 2007-2025 Roy Marples" + exit 0 +fi + +# Disregard dhcpcd setting +unset interface_order state_dir + +# If you change this, change the test in VFLAG and libc.in as well +local_nameservers="127.* 0.0.0.0 255.255.255.255 ::1" + +dynamic_order="tap[0-9]* tun[0-9]* vpn vpn[0-9]* wg[0-9]* ppp[0-9]* ippp[0-9]*" +interface_order="lo lo[0-9]*" +name_server_blacklist="0.0.0.0" + +# Poor mans cat +# /usr might not be available +cat() +{ + OIFS="$IFS" + IFS='' + if [ -n "$1" ]; then + while read -r line; do + printf "%s\n" "$line" + done < "$1" + else + while read -r line; do + printf "%s\n" "$line" + done + fi + retval=$? + IFS="$OIFS" + return $retval +} + + +# Support original resolvconf configuration layout +# as well as the openresolv config file +if [ -f "$SYSCONFDIR"/resolvconf.conf ]; then + . "$SYSCONFDIR"/resolvconf.conf + [ -n "$state_dir" ] && VARDIR="$state_dir" +elif [ -d "$SYSCONFDIR/resolvconf" ]; then + SYSCONFDIR="$SYSCONFDIR/resolvconf" + if [ -f "$SYSCONFDIR"/interface-order ]; then + interface_order="$(cat "$SYSCONFDIR"/interface-order)" + fi +fi + +KEYDIR="$VARDIR/keys" +METRICDIR="$VARDIR/metrics" +PRIVATEDIR="$VARDIR/private" +NOSEARCHDIR="$VARDIR/nosearch" +EXCLUSIVEDIR="$VARDIR/exclusive" +DEPRECATEDDIR="$VARDIR/deprecated" +LOCKDIR="$VARDIR/lock" +_PWD="$PWD" + +# Compat +if [ ! -d "$KEYDIR" ] && [ -d "$VARDIR/interfaces" ]; then + KEYDIR="$VARDIR/interfaces" +fi +: ${allow_keys:="$allow_interfaces"} +: ${deny_keys:="$deny_interfaces"} +: ${key_order:="$interface_order"} +: ${inclusive_keys:="$inclusive_interfaces"} +: ${exclusive_keys:="$exclusive_interfaces"} +: ${private_keys:="$private_interfaces"} +: ${public_keys:="$public_interfaces"} + +warn() +{ + echo "${RESOLVCONF##*/}: $*" >&2 +} + +error_exit() +{ + warn "$*" + exit 1 +} + +usage() +{ + cat <<-EOF + Usage: ${RESOLVCONF##*/} [options] command [argument] + + Inform the system about any DNS updates. + + Commands: + -a \$KEY Add DNS information to the specified key + (DNS supplied via stdin in resolv.conf format) + -C \$PATTERN Deprecate DNS information for matched key + -c \$PATTERN Configure DNS information for matched key + -d \$PATTERN Delete DNS information from the matched key + -h Show this help cruft + -i [\$PATTERN] Show keys that have supplied DNS information + optionally from keys that match the specified + pattern + -l [\$PATTERN] Show DNS information, optionally from keys + that match the specified pattern + -L [\$PATTERN] Same as -l, but adjusted by our config + + -u Run updates from our current DNS information + --version Echo the ${RESOLVCONF##*/} version + + Options: + -f Ignore non existent keys + -m metric Give the added DNS information a metric + -p Mark the resolv.conf as private + -x Mark the resolv.conf as exclusive + + Subscriber and System Init Commands: + -I Init the state dir + -r \$SERVICE Restart the system service + (restarting a non-existent or non-running service + should have no output and return 0) + -R Show the system service restart command + -v [\$PATTERN] echo NEWDOMAIN, NEWSEARCH and NEWNS variables to + the console + -V [\$PATTERN] Same as -v, but only uses configuration in + $SYSCONFDIR/resolvconf.conf + EOF + [ -z "$1" ] && exit 0 + echo + error_exit "$@" +} + +public_key() { + key="$1" + + # Allow expansion + cd "$KEYDIR" + + # Public keys override private ones. + for p in $public_keys; do + case "$key" in + "$p"|"$p":*) return 0;; + esac + done + + return 1 +} + +private_key() +{ + key="$1" + + if public_key "$key"; then + return 1 + fi + + if [ -e "$PRIVATEDIR/$key" ]; then + return 0 + fi + + for p in $private_keys; do + case "$key" in + "$p"|"$p":*) return 0;; + esac + done + + # Not a private key + return 1 +} + +nosearch_key() +{ + key="$1" + + if public_key "$key"; then + return 1 + fi + + if [ -e "$NOSEARCHDIR/$key" ]; then + return 0 + fi + + for p in $nosearch_keys; do + case "$key" in + "$p"|"$p":*) return 0;; + esac + done + + # Not a non searchable key + return 1 +} + +exclusive_key() +{ + key="$1" + + for x in "$EXCLUSIVEDIR/"*" $key"; do + if [ -f "$x" ]; then + return 0 + fi + done + + # Not an exclusive key + return 1 +} + +# Quote input so it can be safely used for variable assignment via eval +quote() +{ + if [ -z "$1" ]; then + R="''" + else + R= + for W; do + while [ -n "$W" ]; do + case "$W" in + \'*) R="$R\\'"; W=${W#?};; + ?\'*) R="$R\\${W%%\'*}"; W="${W#?}";; + *\'*) R="$R'${W%%\'*}'"; W="'${W#*\'}";; + ?) R="$R\\$W"; W=;; + *) R="$R'$W'"; W=;; + esac + done + done + fi + + printf '%s\n' "$R" + return 0 +} + +# Parse resolv.conf's and make variables +# for domain name servers, search name servers and global nameservers +# Important! Each printf here should use the above quote function +# to ensure that user input is quoted for eval. +parse_resolv() +{ + domain= + new=true + newns= + ns= + private=false + nosearch=false + search= + + while read -r line; do + value="${line#* }" + case "$line" in + "# resolv.conf from "*) + if ${new}; then + key="${line#\# resolv.conf from *}" + new=false + if nosearch_key "$key"; then + private=true + nosearch=true + elif private_key "$key"; then + private=true + nosearch=false + else + private=false + nosearch=false + fi + fi + ;; + "nameserver "*) + islocal=false + for l in $local_nameservers; do + case "$value" in + $l) + islocal=true + break + ;; + esac + done + if $islocal; then + printf 'LOCALNAMESERVERS="$LOCALNAMESERVERS "%s\n' "$(quote "$value")" + else + ns="$ns${ns:+ }$value" + fi + ;; + "domain "*) + search="$value" + if [ -z "$domain" ]; then + domain="$search" + if ! $nosearch; then + printf 'DOMAIN=%s\n' "$(quote "$domain")" + fi + fi + ;; + "search "*) + search="$value" + ;; + *) + [ -n "$line" ] && continue + if [ -n "$ns" ] && [ -n "$search" ]; then + newns= + for n in $ns; do + newns="$newns${newns:+,}$n" + done + ds= + for d in $search; do + ds="$ds${ds:+ }$d:$newns" + done + printf 'DOMAINS="$DOMAINS "%s\n' "$(quote "$ds")" + fi + if ! $nosearch; then + printf 'SEARCH="$SEARCH "%s\n' "$(quote "$search")" + fi + if ! $private; then + printf 'NAMESERVERS="$NAMESERVERS "%s\n' "$(quote "$ns")" + fi + ns= + search= + new=true + ;; + esac + done +} + +uniqify() +{ + result= + while [ -n "$1" ]; do + case " $result " in + *" $1 "*);; + *) result="$result $1";; + esac + shift + done + echo "${result# *}" +} + +dirname() +{ + OIFS="$IFS" + IFS=/ + set -- $@ + IFS="$OIFS" + if [ -n "$1" ]; then + printf %s . + else + shift + fi + while [ -n "$2" ]; do + printf "/%s" "$1" + shift + done + printf "\n" +} + +config_mkdirs() +{ + for f; do + [ -n "$f" ] || continue + d="$(dirname "$f")" + if [ ! -d "$d" ]; then + mkdir -p "$d" || return $? + fi + done + return 0 +} + +# With the advent of alternative init systems, it's possible to have +# more than one installed. So we need to try and guess what one we're +# using unless overridden by configure. +# Note that restarting a service is a last resort - the subscribers +# should make a reasonable attempt to reconfigure the service via some +# method, normally SIGHUP. +detect_init() +{ + [ -n "$RESTARTCMD" ] && return 0 + + # Detect the running init system. + # As systemd and OpenRC can be installed on top of legacy init + # systems we try to detect them first. + status="@STATUSARG@" + : ${status:=status} + if [ -x /bin/systemctl ] && [ -S /run/systemd/private ]; then + RESTARTCMD=' + if /bin/systemctl --quiet is-active $1.service + then + /bin/systemctl restart $1.service + fi' + elif [ -x /usr/bin/systemctl ] && [ -S /run/systemd/private ]; then + RESTARTCMD=' + if /usr/bin/systemctl --quiet is-active $1.service + then + /usr/bin/systemctl restart $1.service + fi' + elif [ -x /sbin/rc-service ] && + { [ -s /libexec/rc/init.d/softlevel ] || + [ -s /run/openrc/softlevel ]; } + then + RESTARTCMD='/sbin/rc-service -i $1 -- -Ds restart' + elif [ -x /usr/sbin/invoke-rc.d ]; then + RCDIR=/etc/init.d + RESTARTCMD=' + if /usr/sbin/invoke-rc.d --quiet $1 status >/dev/null 2>&1 + then + /usr/sbin/invoke-rc.d $1 restart + fi' + elif [ -x /usr/bin/s6-rc ] && [ -x /usr/bin/s6-svc ]; then + RESTARTCMD=' + if s6-rc -a list 2>/dev/null | grep -qFx $1-srv + then + s6-svc -r /run/service/$1-srv + fi' + elif [ -x /sbin/service ]; then + # Old RedHat + RCDIR=/etc/init.d + RESTARTCMD=' + if /sbin/service $1; then + /sbin/service $1 restart + fi' + elif [ -x /usr/sbin/service ]; then + # Could be FreeBSD + RESTARTCMD=" + if /usr/sbin/service \$1 $status >/dev/null 2>&1 + then + /usr/sbin/service \$1 restart + fi" + elif [ -x /bin/sv ]; then + RESTARTCMD='/bin/sv status $1 >/dev/null 2>&1 && + /bin/sv try-restart $1' + elif [ -x /usr/bin/sv ]; then + RESTARTCMD='/usr/bin/sv status $1 >/dev/null 2>&1 && + /usr/bin/sv try-restart $1' + elif [ -e /etc/arch-release ] && [ -d /etc/rc.d ]; then + RCDIR=/etc/rc.d + RESTARTCMD=' + if [ -e /var/run/daemons/$1 ] + then + /etc/rc.d/$1 restart + fi' + elif [ -e /etc/slackware-version ] && [ -d /etc/rc.d ]; then + RESTARTCMD=' + if /etc/rc.d/rc.$1 status >/dev/null 2>&1 + then + /etc/rc.d/rc.$1 restart + fi' + elif [ -e /etc/rc.d/rc.subr ] && [ -d /etc/rc.d ]; then + # OpenBSD + RESTARTCMD=' + if /etc/rc.d/$1 check >/dev/null 2>&1 + then + /etc/rc.d/$1 restart + fi' + elif [ -d /etc/dinit.d ] && command -v dinitctl >/dev/null 2>&1; then + RESTARTCMD='dinitctl --quiet restart --ignore-unstarted $1' + else + for x in /etc/init.d/rc.d /etc/rc.d /etc/init.d; do + [ -d $x ] || continue + RESTARTCMD=" + if $x/\$1 $status >/dev/null 2>&1 + then + $x/\$1 restart + fi" + break + done + fi + + if [ -z "$RESTARTCMD" ]; then + if [ "$_NOINIT_WARNED" != true ]; then + warn "could not detect a useable init system" + _NOINIT_WARNED=true + fi + return 1 + fi + _NOINIT_WARNED= + return 0 +} + +echo_resolv() +{ + OIFS="$IFS" + + [ -n "$1" ] && [ -f "$KEYDIR/$1" ] || return 1 + echo "# resolv.conf from $1" + # Our variable maker works of the fact each resolv.conf per key + # is separated by blank lines. + # So we remove them when echoing them. + while read -r line; do + IFS="$OIFS" + if [ -n "$line" ]; then + # We need to set IFS here to preserve any whitespace + IFS='' + printf "%s\n" "$line" + fi + done < "$KEYDIR/$1" + IFS="$OIFS" +} + +deprecated_key() +{ + [ -d "$DEPRECATEDDIR" ] || return 1 + + cd "$DEPRECATEDDIR" + for da; do + for daf in *; do + [ -f "$daf" ] || continue + case "$da" in + $daf) return 0;; + esac + done + done + return 1 +} + +match() +{ + match="$1" + file="$2" + retval=1 + count=0 + + while read -r keyword value; do + new_match= + for om in $match; do + m="$om" + keep= + while [ -n "$m" ]; do + k="${m%%/*}" + r="${m#*/}" + f="${r%%/*}" + r="${r#*/}" + # If the length of m is the same as k/f then + # we know that we are done + if [ ${#m} = $((${#k} + 1 + ${#f})) ]; then + r= + fi + m="$r" + matched=false + case "$keyword" in + $k) + case "$value" in + $f) + matched=true + ;; + esac + ;; + esac + if ! $matched; then + keep="$keep${keep:+/}$k/$f" + fi + done + if [ -n "$om" ] && [ -z "$keep" ]; then + retval=0 + break 2 + fi + new_match="${new_match}${new_match:+ }${keep}" + done + match="${new_match}" + done < "$file" + return $retval +} + +list_keys() { + list_cmd="$1" + shift + + [ -d "$KEYDIR" ] || return 0 + cd "$KEYDIR" + + [ -n "$1" ] || set -- "*" + list= + retval=0 + if [ "$list_cmd" = -i ] || [ "$list_cmd" = -l ]; then + for i in $@; do + if [ ! -f "$i" ]; then + if ! $force && [ "$i" != "*" ]; then + echo "No resolv.conf for key $i" >&2 + fi + retval=2 + continue + fi + list="$list $i" + done + [ -z "$list" ] || uniqify $list + return $retval + fi + + if [ "$list_cmd" != -I ] && [ "$list_cmd" != -L ]; then + echo "list_keys: unknown command $list_cmd" >&2 + return 1 + fi + + if [ -d "$EXCLUSIVEDIR" ]; then + cd "$EXCLUSIVEDIR" + for i in $EXCLUSIVEDIR/*; do + if [ -f "$i" ]; then + cd "$KEYDIR" + for ii in $inclusive_keys; do + if [ -f "$ii" ] && [ "${i#* }" = "$ii" ]; then + continue 2 + fi + done + list="${i#* }" + break + fi + done + cd "$KEYDIR" + if [ -n "$list" ]; then + for i in $@; do + # list will be one item due to the above + if [ -f "$i" ] && [ "$i" = "$list" ]; then + echo "$i" + return 0 + fi + done + return 0 + fi + fi + + for i in $key_order; do + for ii in "$i" "$i":* "$i".*; do + [ -f "$ii" ] && list="$list $ii" + done + done + + for i in $dynamic_order; do + for ii in "$i" "$i":* "$i".*; do + if [ -f "$ii" ] && ! [ -e "$METRICDIR/"*" $ii" ] + then + list="$list $ii" + fi + done + done + + # Interfaces have an implicit metric of 0 if not specified. + for i in *; do + if [ -f "$i" ] && ! [ -e "$METRICDIR/"*" $i" ]; then + list="$list $i" + fi + done + + if [ -d "$METRICDIR" ]; then + cd "$METRICDIR" + for i in *; do + [ -f "$i" ] && list="$list ${i#* }" + done + cd "$KEYDIR" + fi + + # Move deprecated keys to the back + active= + deprecated= + for i in $list; do + if deprecated_key "$i"; then + deprecated="$deprecated $i" + else + active="$active $i" + fi + done + list="$active $deprecated" + + retval=0 + if [ "$1" != "*" ]; then + cd "$KEYDIR" + matched= + for i in $@; do + if ! [ -f "$i" ]; then + if ! $force; then + echo "No resolv.conf for key $i" >&2 + fi + retval=2 + continue + fi + for ii in $list; do + if [ "$i" = "$ii" ]; then + matched="$matched${matched:+ }$i" + break + fi + done + done + if [ -z "$matched" ]; then + return $retval + fi + list="$matched" + fi + + allowed= + for i in $(uniqify $list); do + if [ -n "$allow_keys" ]; then + x=false + for ii in $allow_keys; do + if [ "$i" = "$ii" ]; then + x=true + break + fi + done + $x || continue + fi + for ii in $deny_keys; do + if [ "$i" = "$ii" ]; then + continue 2 + fi + done + + if [ -n "$exclude" ] && match "$exclude" "$i"; then + continue + fi + allowed="$allowed${allowed:+ }$i" + done + + cd "$KEYDIR" + for i in $exclusive_keys; do + for ii in $allowed; do + if [ "$i" = "$ii" ]; then + echo "$i" + return + fi + done + done + [ -z "$allowed" ] || echo "$allowed" +} + +list_resolv() +{ + keys="$(list_keys "$@")" + retval=$? + if [ "$retval" != 0 ]; then + return $retval + fi + for i in $keys; do + echo_resolv "$i" && echo + done +} + +list_private() +{ + KEYS= + cd "$KEYDIR" + if [ -z "$1" ]; then + set -- "*" + fi + for i in $@; do + if private_key "$i"; then + KEYS="${KEYS}${KEYS:+ }$i" + fi + done + if [ -n "$KEYS" ]; then + echo "$KEYS" + fi +} + +list_nosearch() +{ + + KEYS= + cd "$KEYDIR" + if [ -z "$1" ]; then + set -- "*" + fi + for i in $@; do + if nosearch_key "$i"; then + KEYS="${KEYS}${KEYS:+ }$i" + fi + done + if [ -n "$KEYS" ]; then + echo "$KEYS" + fi +} + +list_exclusive() +{ + KEYS= + cd "$KEYDIR" + if [ -z "$1" ]; then + set -- "*" + fi + for i in $@; do + if exclusive_key "$i"; then + KEYS="${KEYS}${KEYS:+ }$i" + fi + done + if [ -n "$KEYS" ]; then + echo "$KEYS" + fi +} + +list_remove() +{ + [ -z "$2" ] && return 0 + eval list=\"\$$1\" + shift + result= + retval=0 + + set -f + for e; do + found=false + for l in $list; do + case "$e" in + $l) found=true;; + esac + $found && break + done + if $found; then + retval=$(($retval + 1)) + else + result="$result $e" + fi + done + set +f + echo "${result# *}" + return $retval +} + +echo_prepend() +{ + echo "# Generated by resolvconf" + if [ -n "$search_domains" ]; then + echo "search $search_domains" + fi + for n in $name_servers; do + echo "nameserver $n" + done + echo +} + +echo_append() +{ + echo "# Generated by resolvconf" + if [ -n "$search_domains_append" ]; then + echo "search $search_domains_append" + fi + for n in $name_servers_append; do + echo "nameserver $n" + done + echo +} + +tolower() { + # There is no good way of doing this portably in shell :( + # Luckily we are only doing this for domain names which we + # know have to be ASCII. + # Non ASCII domains *should* be translated to ASCII *before* + # we get to this stage. + # We could use echo "$@" | tr '[:upper:]' '[:lower:]' but + # tr is in /usr/bin and may not be available when data is fed + # to resolvconf. + # So it's the cost of a pipe + fork vs this slow loop + # + for word; do + # Check if we have any upper to avoid looping per char + case "$word" in + *[A-Z]*) ;; + *) printf "%s " "$word"; continue;; + esac + + while [ -n "$word" ]; do + # Remove everything except the first character + afterchar="${word#?}" + # Remove the afterchar to get the first character + char="${word%%$afterchar}" + # Assign afterchar back to word for looping + word="$afterchar" + + # Now enforce lowercase a-z + case "$char" in + A) char=a;; + B) char=b;; + C) char=c;; + D) char=d;; + E) char=e;; + F) char=f;; + G) char=g;; + H) char=h;; + I) char=i;; + J) char=j;; + K) char=k;; + L) char=l;; + M) char=m;; + N) char=n;; + O) char=o;; + P) char=p;; + Q) char=q;; + R) char=r;; + S) char=s;; + T) char=t;; + U) char=u;; + V) char=v;; + W) char=w;; + X) char=x;; + Y) char=y;; + Z) char=z;; + esac + printf %s "$char" + done + printf " " + done + printf "\n" +} + +# Strip any trailing dot from each name as a FQDN does not belong +# in resolv.conf(5). +# While DNS is not case sensitive, our labels for building the zones +# are, so ensure it's lower case. +process_domain() +{ + for word in $(tolower "$@"); do + printf "%s " "${word%.}" + done + printf "\n" +} + +process_resolv() +{ + while read -r keyword value; do + for r in $replace; do + k="${r%%/*}" + r="${r#*/}" + f="${r%%/*}" + r="${r#*/}" + v="${r%%/*}" + case "$keyword" in + $k) + case "$value" in + $f) value="$v";; + esac + ;; + esac + done + val= + for sub in $value; do + for r in $replace_sub; do + k="${r%%/*}" + r="${r#*/}" + f="${r%%/*}" + r="${r#*/}" + v="${r%%/*}" + case "$keyword" in + $k) + case "$sub" in + $f) sub="$v";; + esac + ;; + esac + done + val="$val${val:+ }$sub" + done + case "$keyword" in + \#) + case "$val" in + "resolv.conf from "*) ;; + *) continue;; + esac + ;; + \#*) continue;; + esac + case "$keyword" in + domain|search) val="$(process_domain $val)";; + esac + printf "%s %s\n" "$keyword" "$val" + done +} + +make_vars() +{ + # Clear variables + DOMAIN= + DOMAINS= + SEARCH= + NAMESERVERS= + LOCALNAMESERVERS= + + if [ -n "${name_servers}${search_domains}" ]; then + eval "$(echo_prepend | parse_resolv)" + fi + if [ -z "$VFLAG" ]; then + eval "$(list_resolv -L "$@" | process_resolv | parse_resolv)" + fi + if [ -n "${name_servers_append}${search_domains_append}" ]; then + eval "$(echo_append | parse_resolv)" + fi + + # Ensure that we only list each domain once + newdomains= + for d in $DOMAINS; do + dn="${d%%:*}" + list_remove domain_blacklist "$dn" >/dev/null || continue + case " $newdomains" in + *" ${dn}:"*) continue;; + esac + newns= + for nd in $DOMAINS; do + if [ "$dn" = "${nd%%:*}" ]; then + ns="${nd#*:}" + while [ -n "$ns" ]; do + case ",$newns," in + *,${ns%%,*},*) ;; + *) list_remove name_server_blacklist \ + "${ns%%,*}" >/dev/null \ + && newns="$newns${newns:+,}${ns%%,*}";; + esac + [ "$ns" = "${ns#*,}" ] && break + ns="${ns#*,}" + done + fi + done + if [ -n "$newns" ]; then + newdomains="$newdomains${newdomains:+ }$dn:$newns" + fi + done + + DOMAIN="$(list_remove domain_blacklist $DOMAIN)" + SEARCH="$(uniqify $SEARCH)" + SEARCH="$(list_remove domain_blacklist $SEARCH)" + NAMESERVERS="$(uniqify $NAMESERVERS)" + NAMESERVERS="$(list_remove name_server_blacklist $NAMESERVERS)" + LOCALNAMESERVERS="$(uniqify $LOCALNAMESERVERS)" + LOCALNAMESERVERS="$(list_remove name_server_blacklist $LOCALNAMESERVERS)" + + # Ensure output is quoted for eval + printf 'DOMAIN=%s\n' "$(quote "$DOMAIN")" + printf 'SEARCH=%s\n' "$(quote "$SEARCH")" + printf 'NAMESERVERS=%s\n' "$(quote "$NAMESERVERS")" + printf 'LOCALNAMESERVERS=%s\n' "$(quote "$LOCALNAMESERVERS")" + printf 'DOMAINS=%s\n' "$(quote "$newdomains")" +} + +force=false +LFLAG= +VFLAG= +while getopts a:C:c:Dd:fhIiLlm:pRruvVx OPT; do + case "$OPT" in + f) force=true;; + h) usage;; + m) IF_METRIC="$OPTARG";; + p) + if [ "$IF_PRIVATE" = 1 ]; then + IF_NOSEARCH=1 + else + IF_PRIVATE=1 + fi + ;; + V) + VFLAG=1 + if [ "$local_nameservers" = \ + "127.* 0.0.0.0 255.255.255.255 ::1" ] + then + local_nameservers= + fi + ;; + x) IF_EXCLUSIVE=1;; + '?') exit 1;; + *) + [ "$OPT" != L ] || LFLAG=1 + cmd="$OPT"; key="$OPTARG";; + esac +done +shift $(($OPTIND - 1)) +if [ -n "$key" ]; then + set -- "$key" "$@" +fi + +if [ -z "$cmd" ]; then + if [ "$IF_PRIVATE" = 1 ]; then + cmd=p + elif [ "$IF_EXCLUSIVE" = 1 ]; then + cmd=x + fi +fi + +# -D ensures that the listed config file base dirs exist +if [ "$cmd" = D ]; then + config_mkdirs "$@" + exit $? +fi + +# -i lists which keys have a resolv file +if [ "$cmd" = i ]; then + # If the -L modifier is given, the list is post-processed + if [ "$LFLAG" = 1 ]; then + cmd="L" + fi + list_keys "-$cmd" "$@" + exit $? +fi + +# -l lists our resolv files, optionally for a specific key +if [ "$cmd" = l ]; then + list_resolv "-$cmd" "$@" + exit $? +fi +# -L is the same as -l, but post-processed from our config +if [ "$cmd" = L ]; then + list_resolv "-$cmd" "$@" | process_resolv + exit $? +fi + +if [ "$cmd" = p ]; then + if [ "$IF_NOSEARCH" = 1 ]; then + list_nosearch "$@" + else + list_private "$@" + fi + exit $? +fi + +if [ "$cmd" = x ]; then + list_exclusive "$@" + exit $? +fi + +# Restart a service or echo the command to restart a service +if [ "$cmd" = r ] || [ "$cmd" = R ]; then + detect_init || exit 1 + if [ "$cmd" = r ]; then + eval "$RESTARTCMD" + else + echo "$RESTARTCMD" | + sed -e '/^$/d' -e 's/^ //g' + fi + exit $? +fi + +# Not normally needed, but subscribers should be able to run independently +if [ "$cmd" = v ] || [ -n "$VFLAG" ]; then + make_vars "$@" + exit $? +fi + +# Test that we have valid options +case "$cmd" in +a|d|C|c) + if [ -z "$key" ]; then + error_exit "Key not specified" + fi + ;; +I|u) ;; +*) + if [ -n "$cmd" ] && [ "$cmd" != h ]; then + error_exit "Unknown option $cmd" + fi + usage + ;; +esac + +if [ "$cmd" = a ]; then + for x in '/' '\' ' ' '*'; do + case "$key" in + "$x"|"$x"*|*"$x"|*"$x"*) error_exit "$x not allowed in key name";; + esac + done + for x in '.' '-' '~'; do + case "$key" in + "$x"*) error_exit \ + "$x not allowed at start of key name";; + esac + done + [ "$cmd" = a ] && [ -t 0 ] && error_exit "No file given via stdin" +fi + +if [ ! -d "$VARDIR" ]; then + if [ -L "$VARDIR" ]; then + dir="$(readlink "$VARDIR")" + # link maybe relative + cd "${VARDIR%/*}" + if ! mkdir -m 0755 -p "$dir"; then + error_exit "Failed to create needed" \ + "directory $dir" + fi + else + if ! mkdir -m 0755 -p "$VARDIR"; then + error_exit "Failed to create needed" \ + "directory $VARDIR" + fi + fi +fi + +if [ ! -d "$KEYDIR" ]; then + mkdir -m 0755 -p "$KEYDIR" || \ + error_exit "Failed to create needed directory $KEYDIR" + if [ "$cmd" = d ]; then + # Provide the same error messages as below + if ! ${force}; then + cd "$KEYDIR" + for i in $@; do + warn "No resolv.conf for key $i" + done + fi + ${force} + exit $? + fi +fi + +# A key was added, changed, deleted or a general update was called. +# Due to exclusivity we need to ensure that this is an atomic operation. +# Our subscribers *may* need this as well if the init system is sub par. +# As such we spinlock at this point as best we can. +# We don't use flock(1) because it's not widely available and normally resides +# in /usr which we do our very best to operate without. +[ -w "$VARDIR" ] || error_exit "Cannot write to $LOCKDIR" +: ${lock_timeout:=10} +: ${clear_nopids:=5} +have_pid=false +had_pid=false +while true; do + if mkdir "$LOCKDIR" 2>/dev/null; then + trap 'rm -rf "$LOCKDIR";' EXIT + trap 'rm -rf "$LOCKDIR"; exit 1' INT QUIT ABRT SEGV ALRM TERM + echo $$ >"$LOCKDIR/pid" + break + fi + pid=$(cat "$LOCKDIR/pid" 2>/dev/null) + if [ "$pid" -gt 0 ] 2>/dev/null; then + have_pid=true + had_pid=true + else + have_pid=false + clear_nopids=$(($clear_nopids - 1)) + if [ "$clear_nopids" -le 0 ]; then + warn "not seen a pid, clearing lock directory" + rm -rf "$LOCKDIR" + else + lock_timeout=$(($lock_timeout - 1)) + sleep 1 + fi + continue + fi + if $have_pid && ! kill -0 "$pid"; then + warn "clearing stale lock pid $pid" + rm -rf "$LOCKDIR" + continue + fi + lock_timeout=$(($lock_timeout - 1)) + if [ "$lock_timeout" -le 0 ]; then + if $have_pid; then + error_exit "timed out waiting for lock from pid $pid" + else + if $had_pid; then + error_exit "timed out waiting for lock" \ + "from some pids" + else + error_exit "timed out waiting for lock" + fi + fi + fi + sleep 1 +done +unset have_pid had_pid clear_nopids + +case "$cmd" in +a) + # Read resolv.conf from stdin + resolv="$(cat)" + changed=false + changedfile=false + # If what we are given matches what we have, then do nothing + if [ -e "$KEYDIR/$key" ]; then + if [ "$(echo "$resolv")" != \ + "$(cat "$KEYDIR/$key")" ] + then + changed=true + changedfile=true + fi + else + changed=true + changedfile=true + fi + + # Set metric and private before creating the resolv.conf file + # to ensure that it will have the correct flags + [ ! -d "$METRICDIR" ] && mkdir "$METRICDIR" + oldmetric="$METRICDIR/"*" $key" + newmetric= + if [ -n "$IF_METRIC" ]; then + # Pad metric to 6 characters, so 5 is less than 10 + while [ ${#IF_METRIC} -le 6 ]; do + IF_METRIC="0$IF_METRIC" + done + newmetric="$METRICDIR/$IF_METRIC $key" + fi + rm -f "$METRICDIR/"*" $key" + [ "$oldmetric" != "$newmetric" ] && + [ "$oldmetric" != "$METRICDIR/* $key" ] && + changed=true + [ -n "$newmetric" ] && echo " " >"$newmetric" + + case "$IF_PRIVATE" in + [Yy][Ee][Ss]|[Tt][Rr][Uu][Ee]|[Oo][Nn]|1) + if [ ! -d "$PRIVATEDIR" ]; then + [ -e "$PRIVATEDIR" ] && rm "$PRIVATEDIR" + mkdir "$PRIVATEDIR" + fi + [ -e "$PRIVATEDIR/$key" ] || changed=true + [ -d "$PRIVATEDIR" ] && echo " " >"$PRIVATEDIR/$key" + ;; + *) + if [ -e "$PRIVATEDIR/$key" ]; then + rm -f "$PRIVATEDIR/$key" + changed=true + fi + ;; + esac + + case "$IF_NOSEARCH" in + [Yy][Ee][Ss]|[Tt][Rr][Uu][Ee]|[Oo][Nn]|1) + if [ ! -d "$NOSEARCHDIR" ]; then + [ -e "$NOSEARCHDIR" ] && rm "$NOSEARCHDIR" + mkdir "$NOSEARCHDIR" + fi + [ -e "$NOSEARCHDIR/$key" ] || changed=true + [ -d "$NOSEARCHDIR" ] && echo " " >"$NOSEARCHDIR/$key" + ;; + *) + if [ -e "$NOSEARCHDIR/$key" ]; then + rm -f "$NOSEARCHDIR/$key" + changed=true + fi + ;; + esac + set +x + + oldexcl= + for x in "$EXCLUSIVEDIR/"*" $key"; do + if [ -f "$x" ]; then + oldexcl="$x" + break + fi + done + case "$IF_EXCLUSIVE" in + [Yy][Ee][Ss]|[Tt][Rr][Uu][Ee]|[Oo][Nn]|1) + if [ ! -d "$EXCLUSIVEDIR" ]; then + [ -e "$EXCLUSIVEDIR" ] && rm "$EXCLUSIVEDIR" + mkdir "$EXCLUSIVEDIR" + fi + cd "$EXCLUSIVEDIR" + for x in *; do + [ -f "$x" ] && break + done + if [ "${x#* }" != "$key" ]; then + if [ "$x" = "${x% *}" ]; then + x=10000000 + else + x="${x% *}" + fi + if [ "$x" = "0000000" ]; then + warn "exclusive underflow" + else + x=$(($x - 1)) + fi + if [ -d "$EXCLUSIVEDIR" ]; then + echo " " >"$EXCLUSIVEDIR/$x $key" + fi + changed=true + fi + ;; + *) + if [ -f "$oldexcl" ]; then + rm -f "$oldexcl" + changed=true + fi + ;; + esac + + if $changedfile; then + printf "%s\n" "$resolv" >"$KEYDIR/$key" || exit $? + elif ! $changed && [ ! -e "$VARDIR"/error ]; then + exit 0 + fi + unset changed changedfile oldmetric newmetric x oldexcl + ;; + +d) + # Delete any existing information about the key + cd "$KEYDIR" + changed=false + for i in $@; do + if [ -e "$i" ]; then + changed=true + elif ! ${force}; then + warn "No resolv.conf for key $i" + fi + rm -f "$i" "$METRICDIR/"*" $i" \ + "$PRIVATEDIR/$i" \ + "$EXCLUSIVEDIR/"*" $i" || exit $? + done + + if ! $changed && [ ! -e "$VARDIR"/error ]; then + # Set the return code based on the forced flag + $force + exit $? + fi + unset changed i + ;; + +C) + # Mark key as deprecated + [ ! -d "$DEPRECATEDDIR" ] && mkdir "$DEPRECATEDDIR" + cd "$DEPRECATEDDIR" + changed=false + for i in $@; do + if [ ! -e "$i" ]; then + changed=true + echo " " >"$i" || exit $? + fi + done + if ! $changed && [ ! -e "$VARDIR"/error ]; then + exit 0 + fi + unset changed i + ;; + +c) + # Mark key as active + if [ -d "$DEPRECATEDDIR" ]; then + cd "$DEPRECATEDDIR" + changed=false + for i in $@; do + if [ -e "$i" ]; then + changed=true + rm "$i" || exit $? + fi + done + if ! $changed && [ ! -e "$VARDIR"/error ]; then + exit 0 + fi + unset changed i + fi + ;; +I) + # Init the state dir, keeping our lock and key directories only + for i in "$VARDIR"/*; do + case "$i" in + "$LOCKDIR") ;; + "$KEYDIR") rm -rf "$KEYDIR"/*;; + *) rm -rf "$i";; + esac + done + ;; +esac + +case "${resolvconf:-YES}" in +[Yy][Ee][Ss]|[Tt][Rr][Uu][Ee]|[Oo][Nn]|1) ;; +*) exit 0;; +esac + +# Try and detect a suitable init system for our scripts +detect_init +export RESTARTCMD RCDIR _NOINIT_WARNED + +eval "$(make_vars)" +export RESOLVCONF DOMAINS SEARCH NAMESERVERS LOCALNAMESERVERS +: ${list_resolv:=list_resolv -L} +retval=0 + +# Run scripts in the same directory resolvconf is run from +# in case any scripts accidentally dump files in the wrong place. +cd "$_PWD" +for script in "$LIBEXECDIR"/*; do + if [ -f "$script" ]; then + script_var="${script##*/}" + while [ "${script_var%%-*}" != "$script_var" ]; do + script_var="${script_var%%-*}_${script_var#*-}" + done + eval script_enabled="\$$script_var" + case "${script_enabled:-YES}" in + [Yy][Ee][Ss]|[Tt][Rr][Uu][Ee]|[Oo][Nn]|1) ;; + *) continue;; + esac + if [ -x "$script" ]; then + "$script" "$cmd" "$key" + else + (set -- "$cmd" "$key"; . "$script") + fi + retval=$(($retval + $?)) + fi +done +if [ "$retval" = 0 ]; then + rm -f "$VARDIR"/error +else + echo "$retval" >"$VARDIR"/error +fi +exit $retval diff --git a/tstest/natlab/vmtest/vmtest.go b/tstest/natlab/vmtest/vmtest.go index 67858f715..c484eb63e 100644 --- a/tstest/natlab/vmtest/vmtest.go +++ b/tstest/natlab/vmtest/vmtest.go @@ -489,7 +489,7 @@ func (e *Env) AddNode(name string, opts ...any) *Node { n.webServerPort = int(o) case nodeOptDNSMode: switch DNSMode(o) { - case DNSDefault, DNSDirect: + case DNSDefault, DNSDirect, DNSOpenresolv: default: e.t.Fatalf("AddNode(%q): unsupported DNSMode %q", name, DNSMode(o)) } @@ -579,6 +579,16 @@ type nodeOptSystemdUnit struct{ // DNSDirect masks systemd-resolved and installs a plain /etc/resolv.conf // so tailscaled selects the "direct" manager (rewrites resolv.conf itself). DNSDirect DNSMode = "direct" + + // DNSOpenresolv masks systemd-resolved and installs upstream openresolv + // (which no cloud image ships), so tailscaled selects the "openresolv" + // manager. Its key directory is created empty, so the only snippet ever + // registered is Tailscale's own. Before the fix for tailscale/tailscale#20825, + // tailscaled handled that state wrong. openresolv reports "no snippets" by + // exiting 2, and net/dns treated that non-zero exit as a hard failure. + // + // openresolv's sources are vendored into the tree; see openresolv.go. + DNSOpenresolv DNSMode = "openresolv" ) // OS returns a NodeOption that sets the node's operating system image.