Files
ctrld/cmd/cli/intercept_mode_test.go
T
Cuong Manh Le bf7c264464 cli: default to dns intercept mode for new desktop installations
When no intercept mode is selected by flag or config, a new
installation on macOS or a Windows desktop edition now uses "dns".
Windows Server, iOS and other platforms keep interface DNS mode unless
intercept is selected explicitly, and so does a config that cannot run
in intercept mode.

Existing Standard installations are not switched on upgrade. ctrld
start marks the services it installs fresh, and the service applies
the default below service.intercept_mode against the config it
fetches, so a service with neither a mode nor the marker predates the
default and keeps interface DNS mode, whichever path upgrades it; the
macOS package also pins such a service to "off" explicitly. An
explicit "off" stays final and is persisted.
2026-10-20 17:23:47 +07:00

384 lines
17 KiB
Go

package cli
import (
"testing"
"github.com/Control-D-Inc/ctrld"
)
// useInterceptByDefault pins whether this host intercepts when no mode is
// selected, so the default is tested the same way on every CI host. It also
// pins the process to a foreground run; useRunningAsService changes that.
func useInterceptByDefault(t *testing.T, supported bool) {
t.Helper()
old, oldService := interceptByDefault, runningAsService
interceptByDefault = supported
runningAsService = func() bool { return false }
t.Cleanup(func() { interceptByDefault, runningAsService = old, oldService })
}
// useRunningAsService pins whether this process runs as the installed service.
// Call it after useInterceptByDefault.
func useRunningAsService(t *testing.T, asService bool) {
t.Helper()
old := runningAsService
runningAsService = func() bool { return asService }
t.Cleanup(func() { runningAsService = old })
}
// useInterceptBlockedByOSUpstream pins whether an OS-resolver upstream rules
// out intercept mode, as it does on macOS.
func useInterceptBlockedByOSUpstream(t *testing.T, blocked bool) {
t.Helper()
old := interceptBlockedByOSUpstream
interceptBlockedByOSUpstream = blocked
t.Cleanup(func() { interceptBlockedByOSUpstream = old })
}
// useInterceptModeState restores the package intercept state a test changes.
func useInterceptModeState(t *testing.T) {
t.Helper()
mode, source, intercept, hard := interceptMode, interceptModeSource, dnsIntercept, hardIntercept
t.Cleanup(func() {
interceptMode, interceptModeSource, dnsIntercept, hardIntercept = mode, source, intercept, hard
})
}
// The full precedence table (issue #589): --intercept-mode, then
// service.intercept_mode, then the platform default. "off" at either explicit
// level is final, even over a persisted "dns".
func TestResolveInterceptModePrecedence(t *testing.T) {
for _, tc := range []struct {
flag, config string
supported bool
want, source string
}{
// Supported desktop platforms (macOS, Windows workstation).
{flag: "", config: "", supported: true, want: "dns", source: interceptModeSourceDefault},
{flag: "", config: "dns", supported: true, want: "dns", source: interceptModeSourceConfig},
{flag: "", config: "hard", supported: true, want: "hard", source: interceptModeSourceConfig},
{flag: "", config: "off", supported: true, want: "off", source: interceptModeSourceConfig},
{flag: "dns", config: "", supported: true, want: "dns", source: interceptModeSourceFlag},
{flag: "hard", config: "dns", supported: true, want: "hard", source: interceptModeSourceFlag},
{flag: "off", config: "", supported: true, want: "off", source: interceptModeSourceFlag},
{flag: "off", config: "dns", supported: true, want: "off", source: interceptModeSourceFlag},
{flag: "off", config: "hard", supported: true, want: "off", source: interceptModeSourceFlag},
{flag: "dns", config: "off", supported: true, want: "dns", source: interceptModeSourceFlag},
// Windows Server and unsupported platforms keep interface DNS mode
// by default; explicit selections still mean what they say, and
// intercept works on Windows Server when selected.
{flag: "", config: "", supported: false, want: "", source: interceptModeSourceDefault},
{flag: "", config: "dns", supported: false, want: "dns", source: interceptModeSourceConfig},
{flag: "off", config: "dns", supported: false, want: "off", source: interceptModeSourceFlag},
{flag: "hard", config: "", supported: false, want: "hard", source: interceptModeSourceFlag},
} {
useInterceptByDefault(t, tc.supported)
cfg := &ctrld.Config{}
cfg.Service.InterceptMode = tc.config
mode, source := resolveInterceptMode(tc.flag, cfg)
if mode != tc.want || source != tc.source {
t.Errorf("flag=%q config=%q supported=%v: got (%q, %q), want (%q, %q)",
tc.flag, tc.config, tc.supported, mode, source, tc.want, tc.source)
}
}
}
// iOS builds the darwin files and counts as a desktop platform there, but it
// has no pf: it must never intercept by default.
func TestInterceptByDefaultOn(t *testing.T) {
for _, tc := range []struct {
goos string
desktop bool
want bool
}{
{goos: "darwin", desktop: true, want: true},
{goos: "windows", desktop: true, want: true},
{goos: "windows", desktop: false, want: false}, // Windows Server
{goos: "ios", desktop: true, want: false},
{goos: "android", desktop: false, want: false},
{goos: "linux", desktop: false, want: false},
} {
if got := interceptByDefaultOn(tc.goos, tc.desktop); got != tc.want {
t.Errorf("interceptByDefaultOn(%q, %v) = %v, want %v", tc.goos, tc.desktop, got, tc.want)
}
}
}
// On macOS an OS-resolver upstream cannot run in intercept mode, because pf
// redirects ctrld's own port-53 queries to it back to ctrld. The default then
// keeps interface DNS mode, where such a config works; an explicit selection
// still selects intercept mode and fails on it, as before.
func TestDefaultInterceptModeStepsBackForOSUpstream(t *testing.T) {
useInterceptByDefault(t, true)
withUpstream := func(typ string) *ctrld.Config {
return &ctrld.Config{Upstream: map[string]*ctrld.UpstreamConfig{"0": {Type: typ}}}
}
for _, tc := range []struct {
name string
blockedByOS bool
cfg *ctrld.Config
flag string
want string
wantBlocked bool
}{
{name: "doh upstream on macOS", blockedByOS: true, cfg: withUpstream(ctrld.ResolverTypeDOH), want: "dns"},
{name: "os upstream on macOS", blockedByOS: true, cfg: withUpstream(ctrld.ResolverTypeOS), want: "", wantBlocked: true},
{name: "untyped upstream on macOS", blockedByOS: true, cfg: withUpstream(""), want: "", wantBlocked: true},
{name: "os upstream on Windows", blockedByOS: false, cfg: withUpstream(ctrld.ResolverTypeOS), want: "dns"},
{name: "explicit dns with os upstream on macOS", blockedByOS: true, cfg: withUpstream(ctrld.ResolverTypeOS), flag: "dns", want: "dns"},
} {
t.Run(tc.name, func(t *testing.T) {
useInterceptBlockedByOSUpstream(t, tc.blockedByOS)
if mode, _ := resolveInterceptMode(tc.flag, tc.cfg); mode != tc.want {
t.Fatalf("resolveInterceptMode = %q, want %q", mode, tc.want)
}
if tc.flag == "" {
if _, blocked := defaultInterceptMode(tc.cfg); (blocked != nil) != tc.wantBlocked {
t.Fatalf("default blocked = %v, want blocked = %v", blocked, tc.wantBlocked)
}
}
})
}
}
// The listener binding strategy follows the same precedence, so a macOS start
// that intercepts by default also binds the way intercept mode needs.
func TestListenerInterceptModeUsesPlatformDefault(t *testing.T) {
useInterceptModeState(t)
interceptMode = ""
cfg := &ctrld.Config{}
useInterceptByDefault(t, true)
if got := listenerInterceptMode(cfg); got != "dns" {
t.Errorf("supported, unset: listenerInterceptMode = %q, want dns", got)
}
cfg.Service.InterceptMode = "off"
if got := listenerInterceptMode(cfg); got != "off" {
t.Errorf("supported, config off: listenerInterceptMode = %q, want off", got)
}
cfg.Service.InterceptMode = ""
useInterceptByDefault(t, false)
if got := listenerInterceptMode(cfg); got != "" {
t.Errorf("unsupported, unset: listenerInterceptMode = %q, want empty", got)
}
}
// setDNS starts intercept mode by default on the supported desktop platforms,
// and never by default anywhere else: on Windows Server an unset mode keeps
// interface DNS mode, and on an unsupported platform it must not even try, so
// nothing logs an intercept-start failure.
func TestSetDNSInterceptDefault(t *testing.T) {
for _, tc := range []struct {
name string
flag, config string
supported bool
wantIntercept bool
wantSource string
}{
{name: "supported, unset", supported: true, wantIntercept: true, wantSource: interceptModeSourceDefault},
{name: "supported, explicit off", flag: "off", supported: true, wantSource: interceptModeSourceFlag},
{name: "supported, explicit off over persisted dns", flag: "off", config: "dns", supported: true, wantSource: interceptModeSourceFlag},
{name: "supported, persisted off", config: "off", supported: true, wantSource: interceptModeSourceConfig},
{name: "supported, persisted hard", config: "hard", supported: true, wantIntercept: true, wantSource: interceptModeSourceConfig},
{name: "not a desktop platform, unset", supported: false, wantSource: interceptModeSourceDefault},
{name: "not a desktop platform, explicit dns", flag: "dns", supported: false, wantIntercept: true, wantSource: interceptModeSourceFlag},
} {
t.Run(tc.name, func(t *testing.T) {
useInterceptModeState(t)
useInterceptByDefault(t, tc.supported)
h := newInterceptFallbackHarness(t, &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 5354})
cfg.Service.InterceptMode = tc.config
interceptMode, interceptModeSource, dnsIntercept, hardIntercept = tc.flag, "", false, false
h.run(t)
if got := h.interceptCalls > 0; got != tc.wantIntercept {
t.Fatalf("intercept start attempted = %v, want %v", got, tc.wantIntercept)
}
if interceptModeSource != tc.wantSource {
t.Fatalf("interceptModeSource = %q, want %q", interceptModeSource, tc.wantSource)
}
})
}
}
func TestInterceptModeFromArgs(t *testing.T) {
for _, tc := range []struct {
args []string
want string
wantOK bool
}{
{args: nil},
{args: []string{"/usr/local/bin/ctrld", "run", "--cd=abc"}},
{args: []string{"/usr/local/bin/ctrld", "run", "--intercept-mode", "dns", "--cd=abc"}, want: "dns", wantOK: true},
{args: []string{"ctrld.exe", "run", "--intercept-mode=hard"}, want: "hard", wantOK: true},
{args: []string{"ctrld", "run", "--intercept-mode", "off"}, want: "off", wantOK: true},
{args: []string{"ctrld", "run", "--intercept-mode"}, want: "", wantOK: true},
} {
got, ok := interceptModeFromArgs(tc.args)
if got != tc.want || ok != tc.wantOK {
t.Errorf("interceptModeFromArgs(%q) = (%q, %v), want (%q, %v)", tc.args, got, ok, tc.want, tc.wantOK)
}
}
}
// useInterceptModeDefault pins the hidden --intercept-mode-default flag.
func useInterceptModeDefault(t *testing.T, on bool) {
t.Helper()
old := interceptModeDefault
interceptModeDefault = on
t.Cleanup(func() { interceptModeDefault = old })
}
func TestInterceptDefaultFromArgs(t *testing.T) {
for _, tc := range []struct {
args []string
want bool
}{
{args: nil},
{args: []string{"ctrld", "run", "--cd=abc"}},
{args: []string{"ctrld", "run", "--intercept-mode-default=true"}, want: true},
{args: []string{"ctrld", "run", "--intercept-mode-default"}, want: true},
{args: []string{"ctrld", "run", "--intercept-mode-default=false"}},
{args: []string{"ctrld", "run", "--intercept-mode=dns"}},
} {
if got := interceptDefaultFromArgs(tc.args); got != tc.want {
t.Errorf("interceptDefaultFromArgs(%q) = %v, want %v", tc.args, got, tc.want)
}
}
// The marker must not read as --intercept-mode, and the reverse.
if _, ok := interceptModeFromArgs([]string{"ctrld", "run", "--intercept-mode-default=true"}); ok {
t.Error("the marker read as --intercept-mode")
}
}
// "ctrld start" records what a service it installs gets when no mode is
// selected. A fresh install gets the --intercept-mode-default marker, so the
// service applies the default itself, below service.intercept_mode. A
// reinstall keeps the existing service's mode or marker, and an existing
// service that records neither was a Standard install, so it is pinned to
// "off" (#589).
func TestInstallInterceptMode(t *testing.T) {
legacyArgs := []string{"/usr/local/bin/ctrld", "run", "--cd=abc", "--config=/etc/controld/ctrld.toml"}
with := func(extra ...string) []string { return append(append([]string{}, legacyArgs...), extra...) }
withConfigMode := func(mode string) *ctrld.Config {
cfg := &ctrld.Config{}
cfg.Service.InterceptMode = mode
return cfg
}
for _, tc := range []struct {
name string
desktop bool
flag string
cfg *ctrld.Config
installed bool
existingArgs []string
wantMode string
wantMarker bool
}{
{name: "fresh install is marked for the default", desktop: true, cfg: &ctrld.Config{}, wantMarker: true},
{name: "fresh install with a config mode is still marked", desktop: true, cfg: withConfigMode("hard"), wantMarker: true},
{name: "explicit flag records nothing more", desktop: true, flag: "off", cfg: &ctrld.Config{}},
{name: "fresh install on Windows Server", desktop: false, cfg: &ctrld.Config{}},
{name: "existing Standard service is pinned off", desktop: true, cfg: &ctrld.Config{}, installed: true, existingArgs: legacyArgs, wantMode: "off"},
{name: "unreadable existing service is pinned off", desktop: true, cfg: &ctrld.Config{}, installed: true, existingArgs: nil, wantMode: "off"},
{name: "existing service with a config mode is left to the config", desktop: true, cfg: withConfigMode("dns"), installed: true, existingArgs: legacyArgs},
{name: "existing default service keeps the marker", desktop: true, cfg: &ctrld.Config{}, installed: true,
existingArgs: with("--intercept-mode-default=true"), wantMarker: true},
{name: "existing dns service keeps dns", desktop: true, cfg: &ctrld.Config{}, installed: true,
existingArgs: with("--intercept-mode", "dns"), wantMode: "dns"},
{name: "existing hard service keeps hard", desktop: true, cfg: &ctrld.Config{}, installed: true,
existingArgs: with("--intercept-mode=hard"), wantMode: "hard"},
{name: "existing off service keeps off", desktop: true, cfg: &ctrld.Config{}, installed: true,
existingArgs: with("--intercept-mode", "off"), wantMode: "off"},
{name: "existing service on Windows Server records nothing", desktop: false, cfg: &ctrld.Config{}, installed: true, existingArgs: legacyArgs},
} {
t.Run(tc.name, func(t *testing.T) {
useInterceptByDefault(t, tc.desktop)
mode, marker := installInterceptMode(tc.flag, tc.cfg, tc.installed, tc.existingArgs)
if mode != tc.wantMode || marker != tc.wantMarker {
t.Fatalf("installInterceptMode = (%q, marker %v), want (%q, marker %v)", mode, marker, tc.wantMode, tc.wantMarker)
}
})
}
}
// How an installed service resolves its mode when its arguments select none.
//
// - The legacy upgrade case (#589): a Standard service installed before the
// dns default has neither a mode nor the marker. Whatever upgrades its
// binary, ctrld upgrade from an older release included, the new binary
// runs it with the same arguments, and it must stay in interface DNS mode.
// - A service "ctrld start" marked as a fresh install applies the default,
// but below service.intercept_mode: a mode from a Control D custom config
// or a --base64_config wins.
// - The default is decided against the config the service runs with, so an
// os upstream there steps it back, with that reason, not the legacy one.
// - A foreground run uses the default.
func TestInstalledServiceInterceptModeResolution(t *testing.T) {
osUpstream := map[string]*ctrld.UpstreamConfig{"0": {Type: ctrld.ResolverTypeOS}}
for _, tc := range []struct {
name string
asService bool
marker bool
configMode string
upstreams map[string]*ctrld.UpstreamConfig
wantMode string
wantIntercept bool
wantReason error // checked when the default is not applied
}{
{name: "upgraded legacy Standard service", asService: true, wantMode: "", wantReason: errInterceptDefaultLegacyService},
{name: "fresh default service", asService: true, marker: true, wantMode: "dns", wantIntercept: true},
{name: "fresh default service, config selects hard", asService: true, marker: true, configMode: "hard", wantMode: "hard", wantIntercept: true},
{name: "fresh default service, config selects off", asService: true, marker: true, configMode: "off", wantMode: "off"},
{name: "fresh default service, os upstream", asService: true, marker: true, upstreams: osUpstream, wantMode: ""},
{name: "foreground run", asService: false, wantMode: "dns", wantIntercept: true},
} {
t.Run(tc.name, func(t *testing.T) {
useInterceptModeState(t)
useInterceptByDefault(t, true)
useInterceptBlockedByOSUpstream(t, true)
useRunningAsService(t, tc.asService)
useInterceptModeDefault(t, tc.marker)
h := newInterceptFallbackHarness(t, &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 5354})
cfg.Service.InterceptMode = tc.configMode
cfg.Upstream = tc.upstreams
interceptMode, interceptModeSource, dnsIntercept, hardIntercept = "", "", false, false
if got := listenerInterceptMode(&cfg); got != tc.wantMode {
t.Fatalf("listenerInterceptMode = %q, want %q", got, tc.wantMode)
}
h.run(t)
if got := h.interceptCalls > 0; got != tc.wantIntercept {
t.Fatalf("intercept start attempted = %v, want %v", got, tc.wantIntercept)
}
if tc.configMode == "" && tc.wantMode == "" {
_, reason := defaultInterceptMode(&cfg)
if tc.wantReason != nil && reason != tc.wantReason {
t.Fatalf("default reason = %v, want %v", reason, tc.wantReason)
}
if tc.wantReason == nil && (reason == nil || reason == errInterceptDefaultLegacyService) {
t.Fatalf("default reason = %v, want the os upstream reason", reason)
}
}
})
}
}
// The marker is a hidden flag of "ctrld run", recorded as one argument.
func TestRunCmdInterceptModeDefaultFlag(t *testing.T) {
useInterceptModeDefault(t, false)
cmd := initRunCmd()
t.Cleanup(func() { rootCmd.RemoveCommand(cmd) })
f := cmd.Flags().Lookup("intercept-mode-default")
if f == nil || !f.Hidden {
t.Fatalf("--intercept-mode-default flag = %+v, want a hidden flag", f)
}
if err := cmd.ParseFlags([]string{"--intercept-mode-default=true"}); err != nil {
t.Fatal(err)
}
if !interceptModeDefault {
t.Fatal("--intercept-mode-default=true did not set the marker")
}
}