//go:build freebsd package tun import ( "context" "errors" "io/fs" "net/netip" "strings" "testing" ) type probeCall struct { inboundTag string source string address string } // stubDNSRouting replaces the routing probe for the duration of a test and // records how it was called, so tests can assert the probe is representative. func stubDNSRouting(t *testing.T, err error) *[]probeCall { t.Helper() original := verifyDNSRouting calls := []probeCall{} verifyDNSRouting = func(_ context.Context, inboundTag, source, address string) error { calls = append(calls, probeCall{inboundTag, source, address}) return err } t.Cleanup(func() { verifyDNSRouting = original }) return &calls } // recorder installs a resolvconf stub for the duration of a test and returns // the captured invocations, each as the arguments followed by the stdin in // angle brackets when there was one. An empty failOn succeeds every call; // otherwise the named subcommand fails. func recorder(t *testing.T, failOn string) *[]string { t.Helper() original := resolvconfRunner calls := []string{} resolvconfRunner = func(stdin string, args ...string) ([]byte, error) { call := strings.Join(args, " ") if stdin != "" { call += " <" + stdin + ">" } calls = append(calls, call) if failOn != "" && len(args) > 0 && args[0] == failOn { return nil, errors.New("boom") } return nil, nil } t.Cleanup(func() { resolvconfRunner = original }) return &calls } // files replaces the resolver files read by the applied check for the // duration of a test. A path not in the map does not exist. func files(t *testing.T, contents map[string]string) { t.Helper() original := readResolverFile readResolverFile = func(path string) ([]byte, error) { if data, ok := contents[path]; ok { return []byte(data), nil } return nil, fs.ErrNotExist } t.Cleanup(func() { readResolverFile = original }) } // takenOver is what the libc subscriber writes once the TUN's nameserver is // the exclusive one. const takenOver = "# Generated by resolvconf\nnameserver 192.168.100.1\n" // untouched is the DHCP-supplied resolv.conf that resolvconf=NO leaves in // place. const untouched = "# Generated by resolvconf\nnameserver 10.0.0.1\n" func optedInTun() *FreeBSDTun { return &FreeBSDTun{ options: &Config{ Name: "tun7", Gateway: []string{"192.168.100.1/30"}, AutoSystemDnsToGateway: true, }, name: "tun7", gateway: netip.MustParsePrefix("192.168.100.1/30"), local: netip.MustParseAddr("192.168.100.2"), } } func joined(calls []string) string { return strings.Join(calls, " | ") } func TestConfigureSystemDNSDisabledByDefault(t *testing.T) { probes := stubDNSRouting(t, nil) calls := recorder(t, "") files(t, map[string]string{"/etc/resolv.conf": takenOver}) t1 := optedInTun() t1.options.AutoSystemDnsToGateway = false if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil { t.Fatalf("unexpected error: %v", err) } if len(*calls) != 0 || len(*probes) != 0 { t.Errorf("nothing may run when the option is off, got %q and %d probes", joined(*calls), len(*probes)) } if t1.systemDNSSet { t.Error("systemDNSSet must stay false when the option is off") } } func TestConfigureSystemDNSLeavesOSDNSWhenNoRoute(t *testing.T) { stubDNSRouting(t, errors.New("no route")) calls := recorder(t, "") files(t, map[string]string{"/etc/resolv.conf": takenOver}) t1 := optedInTun() if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil { t.Fatal("expected an error when the probe fails") } if len(*calls) != 0 { t.Errorf("resolvconf must not run when the probe fails, got %q", joined(*calls)) } if t1.systemDNSSet { t.Error("systemDNSSet must stay false when the probe fails") } } // On FreeBSD the addresses are the other way round from Linux: the local // address is the one after the gateway, and the gateway itself has no owner, // so a packet to it is routed into the TUN. The probe has to carry that pair, // and the resolver has to be pointed at the gateway. func TestConfigureSystemDNSProbeCarriesFreeBSDPair(t *testing.T) { probes := stubDNSRouting(t, nil) recorder(t, "") files(t, map[string]string{"/etc/resolv.conf": takenOver}) t1 := optedInTun() if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil { t.Fatalf("unexpected error: %v", err) } if len(*probes) != 1 { t.Fatalf("expected one probe, got %d", len(*probes)) } want := probeCall{"tun", "192.168.100.2", "192.168.100.1"} if (*probes)[0] != want { t.Errorf("probe = %+v, want %+v", (*probes)[0], want) } } func TestConfigureSystemDNSAppliesResolvconf(t *testing.T) { stubDNSRouting(t, nil) calls := recorder(t, "") files(t, map[string]string{"/etc/resolv.conf": takenOver}) t1 := optedInTun() if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil { t.Fatalf("unexpected error: %v", err) } if !t1.systemDNSSet { t.Fatal("systemDNSSet should be true after a successful takeover") } want := "-a tun7 -x " if got := joined(*calls); got != want { t.Errorf("resolvconf calls = %q, want %q", got, want) } } // resolvconf exits 0 with resolvconf=NO in resolvconf.conf, and still records // the entry for the interface, but runs no subscriber, so success has to be // confirmed in what the subscribers wrote. func TestConfigureSystemDNSRefusesWhenResolvconfDidNotApply(t *testing.T) { stubDNSRouting(t, nil) calls := recorder(t, "") files(t, map[string]string{"/etc/resolv.conf": untouched}) t1 := optedInTun() if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil { t.Fatal("expected an error when the nameserver did not reach a resolver") } if t1.systemDNSSet { t.Error("systemDNSSet must stay false when the takeover did not take") } if !strings.HasSuffix(joined(*calls), "-d tun7") { t.Errorf("expected a removal after the failed check, got %q", joined(*calls)) } } func TestConfigureSystemDNSIdempotent(t *testing.T) { stubDNSRouting(t, nil) calls := recorder(t, "") files(t, map[string]string{"/etc/resolv.conf": takenOver}) t1 := optedInTun() if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil { t.Fatalf("unexpected error: %v", err) } before := len(*calls) if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil { t.Fatalf("unexpected error: %v", err) } if len(*calls) != before { t.Errorf("second call must be a no-op, calls went %d -> %d", before, len(*calls)) } } func TestConfigureSystemDNSAddFailureIsNotDirty(t *testing.T) { stubDNSRouting(t, nil) calls := recorder(t, "-a") files(t, map[string]string{"/etc/resolv.conf": takenOver}) t1 := optedInTun() if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil { t.Fatal("expected an error when resolvconf -a fails") } if t1.systemDNSSet || t1.systemDNSDirty { t.Error("a failed add leaves nothing to clean up") } if len(*calls) != 1 { t.Errorf("nothing else may run after a failed add, got %q", joined(*calls)) } } // If the removal after a failed check fails too, the entry may still be // installed, so the state has to be remembered rather than silently dropped. func TestConfigureSystemDNSRollbackFailureKeepsDirty(t *testing.T) { stubDNSRouting(t, nil) recorder(t, "-d") files(t, map[string]string{"/etc/resolv.conf": untouched}) t1 := optedInTun() if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err == nil { t.Fatal("expected an error when the check fails") } if !t1.systemDNSDirty { t.Error("a failed removal must leave the resolver marked dirty") } if t1.systemDNSSet { t.Error("systemDNSSet must stay false when the takeover did not complete") } } // A dirty resolver is retried before anything new is applied. func TestConfigureSystemDNSRetriesDirtyBeforeApplying(t *testing.T) { stubDNSRouting(t, nil) calls := recorder(t, "") files(t, map[string]string{"/etc/resolv.conf": takenOver}) t1 := optedInTun() t1.systemDNSDirty = true if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil { t.Fatalf("unexpected error: %v", err) } got := joined(*calls) if !strings.HasPrefix(got, "-d tun7 | -a tun7") { t.Errorf("expected the stale removal first, got %q", got) } if t1.systemDNSDirty { t.Error("a successful retry should clear the dirty flag") } if !t1.systemDNSSet { t.Error("the takeover should proceed once the retry succeeds") } } func TestUnsetSystemDNSRemoves(t *testing.T) { stubDNSRouting(t, nil) calls := recorder(t, "") files(t, map[string]string{"/etc/resolv.conf": takenOver}) t1 := optedInTun() if err := t1.ConfigureSystemDNS(context.Background(), "tun"); err != nil { t.Fatalf("setup failed: %v", err) } *calls = nil t1.unsetSystemDNS() if t1.systemDNSSet { t.Error("systemDNSSet should be false after unset") } if got := joined(*calls); got != "-d tun7" { t.Errorf("unset calls = %q, want %q", got, "-d tun7") } t1.unsetSystemDNS() if len(*calls) != 1 { t.Errorf("unsetSystemDNS must be idempotent, got %q", joined(*calls)) } } func TestUnsetSystemDNSKeepsDirtyWhenRemoveFails(t *testing.T) { stubDNSRouting(t, nil) calls := recorder(t, "-d") files(t, map[string]string{"/etc/resolv.conf": takenOver}) t1 := optedInTun() t1.systemDNSSet = true t1.unsetSystemDNS() if !t1.systemDNSDirty { t.Error("a failed removal during unset must be remembered") } if got := joined(*calls); got != "-d tun7" { t.Errorf("expected a removal attempt, got %q", got) } } func TestResolvconfAppliedLibc(t *testing.T) { files(t, map[string]string{"/etc/resolv.conf": takenOver}) if ok, err := resolvconfApplied(netip.MustParseAddr("192.168.100.1")); err != nil || !ok { t.Errorf("resolv.conf listing the nameserver must count as applied, got %v, %v", ok, err) } } func TestResolvconfAppliedNotWhenUntouched(t *testing.T) { files(t, map[string]string{"/etc/resolv.conf": untouched}) if ok, err := resolvconfApplied(netip.MustParseAddr("192.168.100.1")); err != nil || ok { t.Errorf("an unchanged resolv.conf and no resolvconf.conf must not count as applied, got %v, %v", ok, err) } } // With inclusive_interfaces in resolvconf.conf the exclusive marking is // ignored and the DHCP nameservers stay listed, so the system falls back to // them; that is the leak the option exists to close, and it must not pass. func TestResolvconfAppliedNotWhenOthersRemain(t *testing.T) { files(t, map[string]string{"/etc/resolv.conf": "nameserver 192.168.100.1\nnameserver 10.0.0.1\n"}) if ok, _ := resolvconfApplied(netip.MustParseAddr("192.168.100.1")); ok { t.Error("another nameserver alongside the TUN's must not count as applied") } } // A loopback nameserver alongside is a local resolver that resolvconf feeds // (local_unbound with the libc subscriber left on), not a leak. func TestResolvconfAppliedWithLoopbackAlongside(t *testing.T) { files(t, map[string]string{"/etc/resolv.conf": "nameserver 127.0.0.1\nnameserver 192.168.100.1\n"}) if ok, err := resolvconfApplied(netip.MustParseAddr("192.168.100.1")); err != nil || !ok { t.Errorf("a loopback nameserver alongside must still count as applied, got %v, %v", ok, err) } } func TestResolvconfAppliedIgnoresPrefixMatches(t *testing.T) { files(t, map[string]string{"/etc/resolv.conf": "nameserver 192.168.100.10\n"}) if ok, _ := resolvconfApplied(netip.MustParseAddr("192.168.100.1")); ok { t.Error("192.168.100.10 must not match 192.168.100.1") } } // local-unbound-setup turns the libc subscriber off and has resolvconf write // unbound's forwarders instead; resolv.conf then stays pointed at loopback. func TestResolvconfAppliedUnbound(t *testing.T) { files(t, map[string]string{ "/etc/resolv.conf": "# Generated by resolvconf\nnameserver 127.0.0.1\noptions edns0\n", "/etc/resolvconf.conf": "libc=\"NO\"\nunbound_conf=\"/var/unbound/forward.conf\"\n", "/var/unbound/forward.conf": "forward-zone:\n\tname: .\n\tforward-addr: 192.168.100.1\n", }) if ok, err := resolvconfApplied(netip.MustParseAddr("192.168.100.1")); err != nil || !ok { t.Errorf("the nameserver in unbound's forwarders must count as applied, got %v, %v", ok, err) } } func TestResolvconfAppliedNotWhenUnboundUntouched(t *testing.T) { files(t, map[string]string{ "/etc/resolv.conf": "nameserver 127.0.0.1\n", "/etc/resolvconf.conf": "libc=\"NO\"\nunbound_conf=\"/var/unbound/forward.conf\"\n", "/var/unbound/forward.conf": "forward-zone:\n\tname: .\n\tforward-addr: 10.0.0.1\n", }) if ok, _ := resolvconfApplied(netip.MustParseAddr("192.168.100.1")); ok { t.Error("unbound still forwarding to the old server must not count as applied") } } func TestResolvconfAppliedReportsUnreadableResolvConf(t *testing.T) { files(t, map[string]string{}) if _, err := resolvconfApplied(netip.MustParseAddr("192.168.100.1")); err == nil { t.Error("a missing resolv.conf is an error, not a verdict") } }