cli: default to dns intercept mode on the supported desktop platforms

When no intercept mode is selected by flag or config, a new installation
on macOS or a Windows desktop edition 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-client
start marks the services it installs fresh, a service with neither a mode
nor the marker keeps interface DNS mode, and the macOS package pins such a
service to "off" explicitly. An explicit "off" stays final and is
persisted.

Master adaptation: the run flag and start install hunk move from
commands.go to commands_run.go and commands_service_start.go and use the
start command's logger and svcConfig; setDNS logs through p.Info(); the
flag test builds the run command with InitRunCmd; user-facing commands,
docs and the pkg test use the ctrld-client binary name.
This commit is contained in:
Dev Scribe authored and Cuong Manh Le committed 2026-10-12 14:42:31 +07:00
1 parent 4792b0eeab
commit d47e6fa173
20 files changed
+1122 -131

No files matched your search

+5 -3
View File
@@ -248,6 +248,8 @@ The above will start a foreground process and:
## DNS Intercept Mode
When running `ctrld` alongside VPN software, DNS conflicts can cause intermittent failures, bypassed filtering, or configuration loops. DNS Intercept Mode prevents these issues by transparently capturing all DNS traffic on the system and routing it through `ctrld`, without modifying network adapter DNS settings.
DNS Intercept Mode (`dns`) is the default for new installations on macOS and on Windows desktop editions when no mode is selected with `--intercept-mode` or `service.intercept_mode`; existing Standard installations keep interface DNS mode across upgrades. Windows Server and other platforms keep interface DNS mode unless intercept is selected explicitly. See [Intercept Mode Default](docs/intercept-mode-default.md).
### When to Use
Enable DNS Intercept Mode if you:
- Use corporate VPN software (F5, Cisco AnyConnect, Palo Alto GlobalProtect, Zscaler)
@@ -268,9 +270,9 @@ macOS
sudo ctrld-client start --intercept-mode dns --cd RESOLVER_ID_HERE
```
`--intercept-mode dns` automatically detects VPN internal domains and routes them to the VPN's DNS server, while Control D handles everything else.
`--intercept-mode dns` automatically detects VPN internal domains and routes them to the VPN's DNS server, while Control D handles everything else. On macOS and Windows desktop editions the flag can be left out: `dns` is the default. On Windows Server it must be given.
To disable intercept mode on a service that already has it enabled:
To use interface DNS mode instead, on a new or an existing service:
Windows (Admin Shell)
```shell
@@ -282,7 +284,7 @@ macOS
sudo ctrld-client start --intercept-mode off
```
This removes the intercept rules and reverts to standard interface-based DNS configuration.
This removes the intercept rules and reverts to standard interface-based DNS configuration. The selection is persisted (`intercept_mode = "off"` in `ctrld.toml`), so it survives restarts and upgrades.
### Platform Support
| Platform | Supported | Mechanism |
+16 -15
View File
@@ -189,15 +189,15 @@ var (
cleanupStaleDNSInterceptStateFn = cleanupStaleDNSInterceptState
)
// updateConfigInterceptMode persists an explicit --intercept-mode selection in
// service.intercept_mode. "off" is written as "off", not cleared: with an unset
// value meaning the platform default ("dns" on macOS and Windows desktop), a cleared
// value would turn interception back on at the next start without the flag.
func updateConfigInterceptMode(cfg *ctrld.Config, mode string) bool {
desired := ""
var desired string
switch mode {
case "dns", "hard":
case "dns", "hard", "off":
desired = mode
case "off":
desired = ""
case "":
return false
default:
return false
}
@@ -463,8 +463,9 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
}
// Keep config and the explicit CLI/service mode in sync. In particular, "off"
// must clear a previously persisted dns/hard value or the next service start
// would silently re-enable interception from config.
// must replace a previously persisted dns/hard value, and must be kept as
// "off": an unset value means the platform default, which is "dns" on
// macOS and Windows desktop editions.
if updateConfigInterceptMode(&cfg, interceptMode) {
updated = true
p.Info().Msgf("writing intercept_mode = %q to config (requested %q)", cfg.Service.InterceptMode, interceptMode)
@@ -1897,14 +1898,14 @@ func isExplicitInterceptListener(ip string, port int) bool {
}
// listenerInterceptMode resolves the mode that selects the listener binding
// strategy. An explicit "off" is final here, the same as in setDNS. A fallback
// to the config value would select the intercept strategy from a stale
// persisted mode on the first start after a revert to standard mode.
// strategy, with the same precedence as setDNS. An explicit "off" is final
// here: a fallback to the config value would select the intercept strategy from
// a stale persisted mode on the first start after a revert to standard mode.
// With neither set, the platform default applies, so a macOS start that
// intercepts by default also binds the way intercept mode needs.
func listenerInterceptMode(cfg *ctrld.Config) string {
if interceptMode == "" {
return cfg.Service.InterceptMode
}
return interceptMode
mode, _ := resolveInterceptMode(interceptMode, cfg)
return mode
}
// tryUpdateListenerConfig tries updating listener config with a working one.
+8 -1
View File
@@ -1,6 +1,8 @@
package cli
import (
"strings"
"github.com/spf13/cobra"
"github.com/Control-D-Inc/ctrld"
@@ -51,7 +53,12 @@ func InitRunCmd(rootCmd *cobra.Command) *cobra.Command {
_ = runCmd.Flags().MarkHidden("iface")
runCmd.Flags().StringVarP(&cdUpstreamProto, "proto", "", ctrld.ResolverTypeDOH, `Control D upstream type, either "doh" or "doh3"`)
runCmd.Flags().BoolVarP(&rfc1918, "rfc1918", "", false, "Listen on RFC1918 addresses when 127.0.0.1 is the only listener")
runCmd.Flags().StringVarP(&interceptMode, "intercept-mode", "", "", "OS-level DNS interception mode: 'off' (disable interception and clear a persisted intercept_mode), 'dns' (with VPN split routing), or 'hard' (all DNS through ctrld, no VPN split routing)")
runCmd.Flags().StringVarP(&interceptMode, "intercept-mode", "", "", "OS-level DNS interception mode: 'dns' (with VPN split routing), 'hard' (all DNS through ctrld, no VPN split routing), or 'off' (interface DNS mode, persisted as intercept_mode = \"off\"). Unset uses service.intercept_mode, then the platform default: 'dns' on macOS and Windows desktop editions, interface DNS mode elsewhere (Windows Server included)")
// Set by "ctrld-client start" on a service it installs with no intercept
// mode selected; not for users. See interceptModeDefaultFlag.
runCmd.Flags().BoolVar(&interceptModeDefault, strings.TrimPrefix(interceptModeDefaultFlag, "--"), false, "Apply the platform default intercept mode (set by ctrld-client start)")
_ = runCmd.Flags().MarkHidden(strings.TrimPrefix(interceptModeDefaultFlag, "--"))
runCmd.Flags().StringVarP(&firewallMode, "firewall-mode", "", "off", "DNS-resolved IP allowlist: 'on' blocks connections to IPs not resolved by ctrld, 'off' allows all")
runCmd.FParseErrWhitelist = cobra.FParseErrWhitelist{UnknownFlags: true}
+28 -1
View File
@@ -200,6 +200,9 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
default:
logger.Notice().Msg("DNS intercept mode active — all DNS traffic intercepted via OS packet filter")
}
if res.InterceptModeSource == interceptModeSourceDefault {
logger.Notice().Msg("Intercept mode is the default on this platform; run \"ctrld-client start --intercept-mode off\" to use interface DNS mode instead")
}
} else {
name := res.Name
if iff, err := net.InterfaceByName(name); err == nil {
@@ -371,6 +374,30 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
initInteractiveLogging()
// Record the intercept mode in the service's arguments, so the installed
// service can tell a fresh install from one that predates the dns default
// (#589). See installInterceptMode.
var existingArgs []string
if isCtrldInstalled && interceptMode == "" {
args, err := installedServiceArgs()
if err != nil {
logger.Debug().Err(err).Msg("Could not read the arguments of the existing service")
}
existingArgs = args
}
mode, markDefault := installInterceptMode(interceptMode, &cfg, isCtrldInstalled, existingArgs)
switch {
case mode != "":
if _, hadMode := interceptModeFromArgs(existingArgs); !hadMode {
logger.Notice().Msg("Existing service selects no intercept mode; keeping interface DNS mode (--intercept-mode off)")
}
svcConfig.Arguments = append(svcConfig.Arguments, "--intercept-mode", mode)
interceptMode = mode
case markDefault:
// One argument with an inline value: see interceptModeDefaultFlag.
svcConfig.Arguments = append(svcConfig.Arguments, interceptModeDefaultFlag+"=true")
}
if nextdns != "" {
removeNextDNSFromArgs(svcConfig)
}
@@ -530,7 +557,7 @@ NOTE: running "ctrld start" without any arguments will start already installed c
startCmd.Flags().BoolVarP(&startOnly, "start_only", "", false, "Do not install new service")
_ = startCmd.Flags().MarkHidden("start_only")
startCmd.Flags().BoolVarP(&rfc1918, "rfc1918", "", false, "Listen on RFC1918 addresses when 127.0.0.1 is the only listener")
startCmd.Flags().StringVarP(&interceptMode, "intercept-mode", "", "", "OS-level DNS interception mode: 'off' (disable interception and clear a persisted intercept_mode), 'dns' (with VPN split routing), or 'hard' (all DNS through ctrld, no VPN split routing)")
startCmd.Flags().StringVarP(&interceptMode, "intercept-mode", "", "", "OS-level DNS interception mode: 'dns' (with VPN split routing), 'hard' (all DNS through ctrld, no VPN split routing), or 'off' (interface DNS mode, persisted as intercept_mode = \"off\"). Unset uses service.intercept_mode, then the platform default: 'dns' on macOS and Windows desktop editions, interface DNS mode elsewhere (Windows Server included)")
startCmd.Flags().StringVarP(&firewallMode, "firewall-mode", "", "off", "DNS-resolved IP allowlist: 'on' blocks connections to IPs not resolved by ctrld, 'off' allows all")
// Start command alias
+4
View File
@@ -39,6 +39,9 @@ type ifaceResponse struct {
All bool `json:"all"`
OK bool `json:"ok"`
InterceptMode string `json:"intercept_mode,omitempty"` // "dns", "hard", or "" (not intercepting)
// InterceptModeSource is where InterceptMode came from: "flag", "config",
// or "default" (the platform default). Empty from a service that predates it.
InterceptModeSource string `json:"intercept_mode_source,omitempty"`
}
// controlServer represents an HTTP server for handling control requests
@@ -324,6 +327,7 @@ func (p *prog) registerControlServerHandler() {
// Report intercept mode to the start command for proper log output.
if interceptMode == "dns" || interceptMode == "hard" {
res.InterceptMode = interceptMode
res.InterceptModeSource = interceptModeSource
}
}
}
+1 -10
View File
@@ -827,16 +827,7 @@ func (p *prog) validateDNSIntercept() error {
return fmt.Errorf("dns intercept: cannot create anchor directory: %w", err)
}
if p.cfg != nil {
for name, uc := range p.cfg.Upstream {
if uc.Type == "os" || uc.Type == "" {
return fmt.Errorf("dns intercept: upstream %q uses OS resolver (port 53) which would create "+
"a redirect loop with pf. Use DoH upstreams (--proto doh) with dns-intercept mode", name)
}
}
}
return nil
return interceptConfigBlocker(p.cfg)
}
// buildPFAnchorRules generates the pf anchor rules for DNS interception.
+100
View File
@@ -0,0 +1,100 @@
//go:build linux
package cli
import (
"net"
"strconv"
"testing"
ctrld "github.com/Control-D-Inc/ctrld"
)
// heldListener holds a port on one IP for both UDP and TCP, the two binds
// tryUpdateListenerConfigIntercept makes.
type heldListener struct {
port int
udp net.PacketConn
tcp net.Listener
}
func (h *heldListener) Close() {
if h.udp != nil {
h.udp.Close()
h.udp = nil
}
if h.tcp != nil {
h.tcp.Close()
h.tcp = nil
}
}
// holdListenerPort binds a port on ip for both UDP and TCP and keeps it bound
// until the returned listener is closed. The test cleanup closes it too.
func holdListenerPort(t *testing.T, ip string) *heldListener {
t.Helper()
for range 20 {
udp, err := net.ListenPacket("udp4", net.JoinHostPort(ip, "0"))
if err != nil {
t.Fatal(err)
}
port := udp.LocalAddr().(*net.UDPAddr).Port
tcp, err := net.Listen("tcp4", net.JoinHostPort(ip, strconv.Itoa(port)))
if err != nil {
udp.Close()
continue
}
h := &heldListener{port: port, udp: udp, tcp: tcp}
t.Cleanup(h.Close)
return h
}
t.Fatalf("no port free for both UDP and TCP on %s", ip)
return nil
}
func explicitListenerConfig(ip string, port int) *ctrld.Config {
return &ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{"0": {IP: ip, Port: port}}}
}
// Socket-only Linux control for an explicit intercept listener: an address the
// configuration sets binds unchanged, and once occupied it fails rather than
// silently falling back to another address. Native macOS PF delivery is a
// separate QA gate.
//
// The ports are the test's own, so the result does not depend on what else the
// host runs. An occupied port stays held by the test for the whole check. A
// free one has to be released before the check, which binds it itself, so it
// is released immediately before.
func TestExplicitInterceptListenerBinding(t *testing.T) {
captureDebugMainLog(t)
for _, ip := range []string{"127.0.0.1", "127.0.0.2"} {
t.Run(ip+"/free", func(t *testing.T) {
held := holdListenerPort(t, ip)
cfg := explicitListenerConfig(ip, held.port)
held.Close()
changed, ok := tryUpdateListenerConfigIntercept(cfg, func() {}, false)
if !ok || changed {
t.Fatalf("custom listener did not bind unchanged: changed=%v ok=%v", changed, ok)
}
})
for _, occupied := range []string{"udp", "tcp", "udp+tcp"} {
t.Run(ip+"/occupied "+occupied, func(t *testing.T) {
held := holdListenerPort(t, ip)
switch occupied {
case "udp":
held.tcp.Close()
held.tcp = nil
case "tcp":
held.udp.Close()
held.udp = nil
}
cfg := explicitListenerConfig(ip, held.port)
changed, ok := tryUpdateListenerConfigIntercept(cfg, func() {}, false)
if ok || changed || cfg.Listener["0"].IP != ip || cfg.Listener["0"].Port != held.port {
t.Fatalf("occupied explicit listener silently fell back: changed=%v ok=%v listener=%s:%d",
changed, ok, cfg.Listener["0"].IP, cfg.Listener["0"].Port)
}
})
}
}
}
+215
View File
@@ -0,0 +1,215 @@
package cli
import (
"errors"
"fmt"
"runtime"
"strconv"
"strings"
"github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld"
)
// interceptByDefault reports whether this host intercepts DNS when no mode is
// selected: the supported desktop platforms, macOS and Windows workstation
// editions. Windows Server is excluded: a server, a domain controller running
// its own DNS server in particular, keeps interface DNS mode unless intercept
// is selected explicitly, which still works there. Elsewhere intercept is not
// supported at all. It is a variable so tests pin both behaviors on any host.
var interceptByDefault = interceptByDefaultOn(runtime.GOOS, ctrld.IsDesktopPlatform())
// interceptByDefaultOn is interceptByDefault for a given GOOS. iOS builds the
// darwin files, so ctrld.IsDesktopPlatform reports it as a desktop, but it has
// no pf to intercept with.
func interceptByDefaultOn(goos string, desktop bool) bool {
return desktop && goos != "ios"
}
// interceptBlockedByOSUpstream reports whether intercept mode cannot serve an
// OS-resolver upstream on this platform. On macOS, pf redirects ctrld's own
// port-53 queries to such an upstream back to ctrld. It is a variable so tests
// pin both behaviors on any host.
var interceptBlockedByOSUpstream = runtime.GOOS == "darwin" || runtime.GOOS == "ios"
// interceptConfigBlocker reports why cfg cannot run in intercept mode on this
// platform, or nil. An explicit selection of intercept mode fails on it in
// validateDNSIntercept; the default does not select intercept mode for it.
func interceptConfigBlocker(cfg *ctrld.Config) error {
if !interceptBlockedByOSUpstream || cfg == nil {
return nil
}
for name, uc := range cfg.Upstream {
if uc != nil && (uc.Type == ctrld.ResolverTypeOS || uc.Type == "") {
return fmt.Errorf("dns intercept: upstream %q uses OS resolver (port 53) which would create "+
"a redirect loop with pf. Use DoH upstreams (--proto doh) with dns-intercept mode", name)
}
}
return nil
}
// Where the effective intercept mode came from, for logs and status output.
const (
interceptModeSourceFlag = "flag"
interceptModeSourceConfig = "config"
interceptModeSourceDefault = "default"
)
// interceptModeSource records where setDNS took the effective mode from. setDNS
// replaces interceptMode with the resolved value, so the flag alone no longer
// tells an explicit selection from the platform default afterwards.
var interceptModeSource string
// runningAsService reports whether this process runs as the installed service,
// started by the OS service manager, rather than interactively. It is a
// variable so tests pin both behaviors.
var runningAsService = func() bool { return !service.Interactive() }
// interceptModeDefaultFlag marks a service that "ctrld start" installed with
// no intercept mode selected: the service applies the platform default
// (defaultInterceptMode). It is recorded as one --intercept-mode-default=true
// argument. Every ctrld run since 2023 ignores unknown flags, so the marker is
// safe for a downgraded binary, and the inline value keeps an older parser
// from taking the next argument as its value.
const interceptModeDefaultFlag = "--intercept-mode-default"
// interceptModeDefault is set by the hidden --intercept-mode-default flag of
// "ctrld run".
var interceptModeDefault bool
// errInterceptDefaultLegacyService is why an installed service that records
// no intercept mode keeps interface DNS mode.
var errInterceptDefaultLegacyService = errors.New("the service records no intercept mode, so it was installed " +
"before dns intercept mode became the default; keeping interface DNS mode")
// platformDefaultInterceptMode is the mode a new installation, or a foreground
// run, uses for cfg when neither --intercept-mode nor service.intercept_mode
// selects one: "dns" on the supported desktop platforms, and none (interface
// DNS mode) elsewhere, so Windows Server keeps its DNS setup and an
// unsupported platform never enters an intercept start that is known to fail.
//
// The default also steps back to interface DNS mode for a config that cannot
// run in intercept mode, and returns the reason: a config that worked in
// interface DNS mode must not start failing because the default changed.
func platformDefaultInterceptMode(cfg *ctrld.Config) (mode string, blocked error) {
if !interceptByDefault {
return "", nil
}
if err := interceptConfigBlocker(cfg); err != nil {
return "", err
}
return "dns", nil
}
// defaultInterceptMode is the mode this process uses for cfg when neither
// --intercept-mode nor service.intercept_mode selects one. It ranks below both,
// so a mode in a Control D custom config or a --base64_config still wins.
//
// An installed service gets the platform default only if "ctrld start"
// marked it as a fresh install with --intercept-mode-default
// (installInterceptMode). A service with neither a mode nor the marker was
// installed before dns intercept mode became the default, in what was then
// Standard mode. It keeps interface DNS mode: an upgrade, whichever path
// performs it, must not silently switch an existing Standard install to
// intercept mode.
//
// The default is decided here, against the config the service runs with,
// which in Control D mode is only known once the service fetched it.
func defaultInterceptMode(cfg *ctrld.Config) (mode string, blocked error) {
if interceptByDefault && runningAsService() && !interceptModeDefault {
return "", errInterceptDefaultLegacyService
}
return platformDefaultInterceptMode(cfg)
}
// interceptModeFromArgs returns the --intercept-mode value in a service's
// arguments, in either the "--intercept-mode value" or the
// "--intercept-mode=value" form, and whether the flag is present.
func interceptModeFromArgs(args []string) (mode string, ok bool) {
for i, arg := range args {
switch {
case arg == "--intercept-mode":
if i+1 < len(args) {
return args[i+1], true
}
return "", true
case strings.HasPrefix(arg, "--intercept-mode="):
return strings.TrimPrefix(arg, "--intercept-mode="), true
}
}
return "", false
}
// interceptDefaultFromArgs reports whether a service's arguments carry the
// --intercept-mode-default marker, as "--intercept-mode-default" or
// "--intercept-mode-default=<bool>".
func interceptDefaultFromArgs(args []string) bool {
for _, arg := range args {
switch {
case arg == interceptModeDefaultFlag:
return true
case strings.HasPrefix(arg, interceptModeDefaultFlag+"="):
on, err := strconv.ParseBool(strings.TrimPrefix(arg, interceptModeDefaultFlag+"="))
return err == nil && on
}
}
return false
}
// installInterceptMode decides what "ctrld start" records in the arguments of
// the service it installs when the command line selects no mode: an explicit
// mode, the --intercept-mode-default marker (markDefault), or nothing.
//
// Recording is what lets an installed service tell a fresh install from one
// that predates the dns default (defaultInterceptMode):
//
// - a fresh install: the marker. The service applies the platform default
// itself, below service.intercept_mode and against the config it fetches,
// so a mode or an os upstream in a Control D custom config still counts.
// - a reinstall over an existing service: keep what its arguments record,
// a mode or the marker. If they record neither, it was installed before the
// default and ran in Standard mode, so record "off". existingArgs is nil
// when they could not be read; that also records "off", so a read failure
// cannot switch a Standard install to intercept mode. A reinstall whose
// config sets service.intercept_mode records nothing: the config decides.
//
// Platforms without the dns default record nothing, as before.
func installInterceptMode(flag string, cfg *ctrld.Config, installed bool, existingArgs []string) (mode string, markDefault bool) {
if flag != "" || !interceptByDefault {
return "", false
}
if !installed {
return "", true
}
if mode, ok := interceptModeFromArgs(existingArgs); ok && validInterceptMode(mode) {
return mode, false
}
if interceptDefaultFromArgs(existingArgs) {
return "", true
}
if cfg != nil && cfg.Service.InterceptMode != "" {
return "", false
}
return "off", false
}
// resolveInterceptMode applies the intercept mode precedence: an explicit
// --intercept-mode value, then service.intercept_mode, then the platform
// default for cfg. "off" at either explicit level is a selection like "dns" or
// "hard", and stops the lookup: it selects interface DNS mode even where the
// default is "dns".
func resolveInterceptMode(flag string, cfg *ctrld.Config) (mode, source string) {
configured := ""
if cfg != nil {
configured = cfg.Service.InterceptMode
}
switch {
case flag != "":
return flag, interceptModeSourceFlag
case configured != "":
return configured, interceptModeSourceConfig
}
mode, _ = defaultInterceptMode(cfg)
return mode, interceptModeSourceDefault
}
+33 -24
View File
@@ -14,24 +14,28 @@ func TestUpdateConfigInterceptMode(t *testing.T) {
want string
wantUpdated bool
}{
{name: "empty flag preserves config", current: "dns", mode: "", want: "dns"},
{name: "dns is persisted", mode: "dns", want: "dns", wantUpdated: true},
{name: "hard is persisted", current: "dns", mode: "hard", want: "hard", wantUpdated: true},
{name: "off clears persisted mode", current: "dns", mode: "off", want: "", wantUpdated: true},
{name: "off is idempotent", mode: "off", want: ""},
{name: "invalid flag preserves config", current: "hard", mode: "invalid", want: "hard"},
// "off" is kept as "off": an unset value now means the platform
// default, which is "dns" on Windows and macOS (issue #589).
{name: "off replaces persisted dns", current: "dns", mode: "off", want: "off", wantUpdated: true},
{name: "off replaces persisted hard", current: "hard", mode: "off", want: "off", wantUpdated: true},
{name: "off persists over unset", current: "", mode: "off", want: "off", wantUpdated: true},
{name: "off already persisted", current: "off", mode: "off", want: "off"},
{name: "dns replaces persisted off", current: "off", mode: "dns", want: "dns", wantUpdated: true},
{name: "empty flag preserves config", current: "hard", mode: "", want: "hard"},
{name: "empty flag leaves unset alone", current: "", mode: "", want: ""},
{name: "new mode persists", current: "", mode: "dns", want: "dns", wantUpdated: true},
{name: "same mode is unchanged", current: "dns", mode: "dns", want: "dns"},
{name: "invalid mode is ignored", current: "dns", mode: "bogus", want: "dns"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &ctrld.Config{}
cfg.Service.InterceptMode = tc.current
updated := updateConfigInterceptMode(cfg, tc.mode)
if updated != tc.wantUpdated {
t.Fatalf("updateConfigInterceptMode() updated = %v, want %v", updated, tc.wantUpdated)
cfg.Service.InterceptMode = tt.current
if updated := updateConfigInterceptMode(cfg, tt.mode); updated != tt.wantUpdated {
t.Fatalf("updateConfigInterceptMode() updated = %v, want %v", updated, tt.wantUpdated)
}
if cfg.Service.InterceptMode != tc.want {
t.Fatalf("service.intercept_mode = %q, want %q", cfg.Service.InterceptMode, tc.want)
if cfg.Service.InterceptMode != tt.want {
t.Fatalf("service.intercept_mode = %q, want %q", cfg.Service.InterceptMode, tt.want)
}
})
}
@@ -41,21 +45,26 @@ func TestConfiguredInterceptMode(t *testing.T) {
oldInterceptMode := interceptMode
t.Cleanup(func() { interceptMode = oldInterceptMode })
p := &prog{cfg: &ctrld.Config{}}
p.cfg.Service.InterceptMode = "dns"
tests := []struct {
name string
flag string
want string
name string
flag string
config string
supported bool
want string
}{
{name: "empty flag falls back to config", flag: "", want: "dns"},
{name: "explicit off is final", flag: "off", want: "off"},
{name: "explicit hard wins over config", flag: "hard", want: "hard"},
{name: "empty flag falls back to config", flag: "", config: "dns", supported: true, want: "dns"},
{name: "explicit off is final", flag: "off", config: "dns", supported: true, want: "off"},
{name: "explicit hard wins over config", flag: "hard", config: "dns", supported: true, want: "hard"},
{name: "config off is final over the default", flag: "", config: "off", supported: true, want: "off"},
{name: "unset uses the dns default", flag: "", config: "", supported: true, want: "dns"},
{name: "unset on an unsupported platform stays off", flag: "", config: "", supported: false, want: ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
useInterceptByDefault(t, tc.supported)
p := &prog{cfg: &ctrld.Config{}}
p.cfg.Service.InterceptMode = tc.config
interceptMode = tc.flag
if got := p.configuredInterceptMode(); got != tc.want {
t.Fatalf("configuredInterceptMode() = %q, want %q", got, tc.want)
+384
View File
@@ -0,0 +1,384 @@
package cli
import (
"testing"
"github.com/spf13/cobra"
"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(&cobra.Command{})
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")
}
}
-32
View File
@@ -1,32 +0,0 @@
//go:build linux
package cli
import (
ctrld "github.com/Control-D-Inc/ctrld"
"net"
"testing"
)
// Socket-only Linux control. Native macOS PF delivery is a separate QA gate.
func TestCustomListener5353Binding(t *testing.T) {
captureDebugMainLog(t)
for _, ip := range []string{"127.0.0.1", "127.0.0.2"} {
t.Run(ip, func(t *testing.T) {
cfg := &ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{"0": {IP: ip, Port: 5353}}}
changed, ok := tryUpdateListenerConfigIntercept(cfg, func() {}, false)
if !ok || changed {
t.Fatalf("custom listener did not bind unchanged: changed=%v ok=%v", changed, ok)
}
occupied, err := net.Listen("tcp4", net.JoinHostPort(ip, "5353"))
if err != nil {
t.Fatal(err)
}
defer occupied.Close()
changed, ok = tryUpdateListenerConfigIntercept(cfg, func() {}, false)
if ok || changed || cfg.Listener["0"].IP != ip || cfg.Listener["0"].Port != 5353 {
t.Fatal("occupied explicit listener silently fell back")
}
})
}
}
+16 -6
View File
@@ -242,16 +242,26 @@ func Test_logHeaderFromConfigKeepsUpstreamSecretsOut(t *testing.T) {
}
}
func Test_logHeaderFromConfigReportsOffWhenUnset(t *testing.T) {
// An unset intercept mode reads as the platform default: "dns" where ctrld can
// intercept (issue #589), and off (interface DNS mode) elsewhere.
func Test_logHeaderFromConfigReportsPlatformDefaultWhenUnset(t *testing.T) {
old := interceptMode
t.Cleanup(func() { interceptMode = old })
interceptMode = ""
var in logHeaderInput
logHeaderFromConfig(&ctrld.Config{}, &in)
if in.InterceptMode != "off" {
t.Errorf("intercept mode = %q, want %q", in.InterceptMode, "off")
for _, tc := range []struct {
supported bool
want string
}{
{supported: false, want: "off"},
{supported: true, want: "dns"},
} {
useInterceptByDefault(t, tc.supported)
var in logHeaderInput
logHeaderFromConfig(&ctrld.Config{}, &in)
if in.InterceptMode != tc.want {
t.Errorf("supported=%v: intercept mode = %q, want %q", tc.supported, in.InterceptMode, tc.want)
}
}
}
+22 -13
View File
@@ -1609,18 +1609,29 @@ func (p *prog) setDNS(systemNameservers []string) {
p.csSetDnsOk = setDnsOK
}()
// Validate and resolve intercept mode.
// CLI flag (--intercept-mode) takes priority over config file.
// Valid values: "" (use config), "off" (explicitly disable), "dns" (with VPN
// split routing), and "hard" (all DNS through ctrld).
// Validate and resolve intercept mode: --intercept-mode, then
// service.intercept_mode, then the platform default (resolveInterceptMode).
// Valid values: "off" (interface DNS mode), "dns" (with VPN split routing),
// and "hard" (all DNS through ctrld).
if interceptMode != "" && !validInterceptMode(interceptMode) {
p.Fatal().Msgf("invalid --intercept-mode value %q: must be 'off', 'dns', or 'hard'", interceptMode)
}
if interceptMode == "" {
interceptMode = p.configuredInterceptMode()
if interceptMode != "" && interceptMode != "off" {
p.Info().Msgf("Intercept mode enabled via config (intercept_mode = %q)", interceptMode)
mode, source := resolveInterceptMode("", p.cfg)
interceptMode, interceptModeSource = mode, source
switch {
case source == interceptModeSourceDefault && mode == "":
if _, reason := defaultInterceptMode(p.cfg); reason != nil {
p.Info().Err(reason).Msg("Intercept mode default not applied; using interface DNS mode")
}
case mode == "" || mode == "off":
case source == interceptModeSourceConfig:
p.Info().Msgf("Intercept mode enabled via config (intercept_mode = %q)", mode)
case source == interceptModeSourceDefault:
p.Info().Msgf("Intercept mode %q is the default on this platform; use --intercept-mode off for interface DNS mode", mode)
}
} else if interceptModeSource == "" {
interceptModeSource = interceptModeSourceFlag
}
// Derive convenience bools from interceptMode.
@@ -1768,13 +1779,11 @@ func (p *prog) setDNS(systemNameservers []string) {
// configuredInterceptMode resolves the service's effective intercept mode without
// mutating package state. An explicit flag value, including "off", takes priority
// over the persisted config value.
// over the persisted config value, and the platform default applies when neither
// is set.
func (p *prog) configuredInterceptMode() string {
im := interceptMode
if im == "" {
im = p.cfg.Service.InterceptMode
}
return im
mode, _ := resolveInterceptMode(interceptMode, p.cfg)
return mode
}
func (p *prog) setDnsForRunningIface(nameservers []string) (runningIface *net.Interface) {
+31 -20
View File
@@ -72,26 +72,7 @@ func removeServiceFlag(flag string) error {
return fmt.Errorf("failed to read plist ProgramArguments: %w (output: %s)", err, strings.TrimSpace(string(out)))
}
// Parse the PlistBuddy output to find the flag's index.
// PlistBuddy prints arrays as:
// Array {
// /path/to/ctrld
// run
// --cd=xxx
// --intercept-mode
// dns
// }
lines := strings.Split(string(out), "\n")
var entries []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "Array {" || trimmed == "}" || trimmed == "" {
continue
}
entries = append(entries, trimmed)
}
index, hasValue := serviceFlagPosition(entries, flag)
index, hasValue := serviceFlagPosition(plistArrayEntries(out), flag)
if index < 0 {
mainLog.Load().Debug().Msgf("Service flag %q not present in plist, skipping removal", flag)
@@ -124,6 +105,36 @@ func removeServiceFlag(flag string) error {
return nil
}
// installedServiceArgs returns the launch arguments of the installed service.
func installedServiceArgs() ([]string, error) {
out, err := exec.Command("/usr/libexec/PlistBuddy", "-c", "Print :ProgramArguments", launchdPlistPath).CombinedOutput()
if err != nil {
return nil, fmt.Errorf("failed to read plist ProgramArguments: %w (output: %s)", err, strings.TrimSpace(string(out)))
}
return plistArrayEntries(out), nil
}
// plistArrayEntries parses the entries of an array PlistBuddy printed:
//
// Array {
// /path/to/ctrld
// run
// --cd=xxx
// --intercept-mode
// dns
// }
func plistArrayEntries(out []byte) []string {
var entries []string
for _, line := range strings.Split(string(out), "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "Array {" || trimmed == "}" || trimmed == "" {
continue
}
entries = append(entries, trimmed)
}
return entries
}
func serviceArgumentPresent(out []byte, argument string) bool {
for _, line := range strings.Split(string(out), "\n") {
if strings.TrimSpace(line) == argument {
+6
View File
@@ -36,6 +36,12 @@ func verifyServiceRegistration() error {
return nil
}
// installedServiceArgs is not implemented on this platform. It has no dns
// intercept default, so installInterceptMode never needs the arguments here.
func installedServiceArgs() ([]string, error) {
return nil, errServiceFlagsUnsupported
}
// removeServiceFlag is not yet implemented on this platform.
func removeServiceFlag(flag string) error {
return errServiceFlagsUnsupported
+22
View File
@@ -139,6 +139,28 @@ func removeServiceFlag(flag string) error {
return nil
}
// installedServiceArgs returns the launch arguments of the installed service,
// split from its binary path the way the other service-argument helpers do.
func installedServiceArgs() ([]string, error) {
m, err := mgr.Connect()
if err != nil {
return nil, fmt.Errorf("failed to connect to Windows SCM: %w", err)
}
defer m.Disconnect()
s, err := m.OpenService(ctrldServiceName)
if err != nil {
return nil, fmt.Errorf("failed to open service %q: %w", ctrldServiceName, err)
}
defer s.Close()
config, err := s.Config()
if err != nil {
return nil, fmt.Errorf("failed to read service config: %w", err)
}
return strings.Fields(config.BinaryPathName), nil
}
func binaryPathArgumentPresent(binaryPath, argument string) bool {
for _, part := range strings.Fields(binaryPath) {
if part == argument {
+15
View File
@@ -309,6 +309,21 @@ Specifying the `ip` and `port` of the Prometheus metrics server. The Prometheus
- Required: no
- Default: ""
### intercept_mode
How ctrld captures the host's DNS traffic. `dns` intercepts it in the OS packet filter (WFP on Windows, pf on macOS) and
forwards VPN internal domains to the VPN's DNS server; `hard` intercepts it with no VPN split routing; `off` uses interface
DNS mode, setting ctrld's listener as the DNS server of the network interfaces.
The `--intercept-mode` flag takes precedence over this value. When neither is set, the default applies: `dns` on macOS and
Windows desktop editions, interface DNS mode elsewhere, Windows Server included. An installed service that predates the
default keeps interface DNS mode. `ctrld-client start --intercept-mode off` writes `off` here, because an unset value means the
default. See [Intercept Mode Default](intercept-mode-default.md).
- Type: string
- Required: no
- Valid values: `off`, `dns`, `hard`
- Default: `dns` for new installations on macOS and Windows desktop editions; interface DNS mode elsewhere, Windows Server included, and for services installed before this default
### dns_watchdog_enabled
Watches all physical interfaces for DNS changes and reverts them to ctrld's settings.The DNS watchdog process only runs on Windows and MacOS.
+13 -3
View File
@@ -24,8 +24,15 @@ DNS intercept mode works at a lower level than interface settings:
## Usage
On macOS and Windows desktop editions, `dns` is the default mode for new installations: a fresh
start that selects no mode, by flag or by `service.intercept_mode`, intercepts. An existing
Standard installation keeps interface DNS mode across upgrades. `--intercept-mode off`
selects interface DNS mode and is persisted. See
[Intercept Mode Default](intercept-mode-default.md) for the full precedence.
```bash
# Start ctrld with DNS intercept mode (auto-detects VPN search domains)
# Start ctrld with DNS intercept mode (the default on macOS and Windows desktop editions)
ctrld-client start --cd <resolver-uid>
ctrld-client start --intercept-mode dns --cd <resolver-uid>
# Hard intercept: all DNS through ctrld, no VPN split routing
@@ -37,6 +44,9 @@ ctrld-client start --intercept-mode dns -c /path/to/ctrld.toml
# Run in foreground (debug)
ctrld-client run --intercept-mode dns --cd <resolver-uid>
ctrld-client run --intercept-mode hard --cd <resolver-uid>
# Interface DNS mode instead (persisted as intercept_mode = "off")
ctrld-client start --intercept-mode off --cd <resolver-uid>
```
### Intercept Modes
@@ -202,9 +212,9 @@ This is periodic (not just network-event-driven) because VPN software can clear
**ctrld's own traffic**: ctrld's upstream queries use DoH (HTTPS on port 443), not plain DNS on port 53, so the pf redirect does not create a loop for DoH upstreams. **Warning:** If an "os" upstream is configured (which uses plain DNS on port 53 to external servers), the pf redirect will capture ctrld's own outbound queries and create a loop. ctrld will log a warning at startup if this is detected. Use DoH upstreams when DNS intercept mode is active.
## What Changes vs Default Mode
## What Changes vs Interface DNS Mode
| Behavior | Default Mode | DNS Intercept Mode |
| Behavior | Interface DNS Mode | DNS Intercept Mode |
|----------|-------------|-------------------|
| Interface DNS settings | Set to `127.0.0.1` | **Not modified** |
| DNS watchdog | Active (polls every 20s) | **Disabled** |
+149
View File
@@ -0,0 +1,149 @@
# Intercept Mode Default
On the supported desktop platforms, macOS and Windows desktop (workstation)
editions, a new ctrld installation runs in DNS intercept mode (`dns`) unless
you select a mode explicitly. Everywhere else, Windows Server included, it
keeps interface DNS mode, which sets ctrld's listener as the DNS server of the
network interfaces. An existing installation keeps the mode it had: upgrading
never switches a Standard (interface DNS) install to intercept mode. See
[Upgrades](#upgrades).
Windows Server is excluded on purpose. A server, and a domain controller
running its own DNS server in particular, is not switched to intercept mode by
default. Intercept mode still works there when selected explicitly.
This applies to a service that `ctrld-client start` installs and to `ctrld-client run` in
the foreground alike. See [DNS Intercept Mode](dns-intercept-mode.md) for what
intercept mode does.
## Precedence
The effective mode is the first of these that is set:
1. `--intercept-mode` on the command line, or in the service's arguments.
2. `service.intercept_mode` in the configuration the service runs with:
`ctrld.toml`, a `--base64_config`, or a Control D custom configuration.
3. The default: `dns` on macOS and Windows desktop editions; interface DNS
mode elsewhere, Windows Server included. An installed service that predates
the default is the exception, see [Upgrades](#upgrades).
`off` is a selection like `dns` and `hard`, not an absence of one. At either
explicit level it stops the lookup and selects interface DNS mode, including
over a persisted `intercept_mode = "dns"`.
| `--intercept-mode` | `service.intercept_mode` | macOS / Windows desktop | Windows Server | Linux and others |
|---|---|---|---|---|
| unset | unset | `dns` (default)² | interface DNS mode | interface DNS mode |
| unset | `dns` / `hard` | that mode | that mode | that mode¹ |
| unset | `off` | interface DNS mode | interface DNS mode | interface DNS mode |
| `dns` / `hard` | any | that mode | that mode | that mode¹ |
| `off` | any | interface DNS mode | interface DNS mode | interface DNS mode |
² For a fresh install or a foreground run. An installed service that predates
the default keeps interface DNS mode, see [Upgrades](#upgrades).
¹ Intercept mode is not supported outside Windows and macOS, so an explicit
`dns` or `hard` there fails to start intercept, as before. An unset mode never
tries: those platforms do not log an intercept-start failure just because the
default changed.
Desktop versus server follows `ctrld.IsDesktopPlatform`, which ctrld already
uses elsewhere: on Windows it is the workstation product type
(`VER_NT_WORKSTATION`), so Windows Server and domain controllers are not
desktop platforms.
iOS is not a desktop platform here either, although it builds ctrld's macOS
code: it has no pf to intercept with.
The default also keeps interface DNS mode for a configuration that cannot run
in intercept mode. On macOS that is any `os` or untyped upstream: pf would
redirect ctrld's own port-53 queries to it back to ctrld. Such a configuration
worked in interface DNS mode and keeps doing so; the service log says why the
default was not used. Selecting `dns` or `hard` explicitly for it still fails
to start intercept, as before.
The service decides the default itself, on every start, against the
configuration it runs with. In Control D mode (`--cd`) that is the resolver's
configuration it fetches, so an `os` upstream or an `intercept_mode` in a
custom configuration counts, including one added after installation.
## Opting out
Use interface DNS mode explicitly:
```shell
# Windows (Admin Shell)
ctrld-client.exe start --intercept-mode off --cd RESOLVER_ID_HERE
# macOS
sudo ctrld-client start --intercept-mode off --cd RESOLVER_ID_HERE
```
On an installed service, `ctrld-client start --intercept-mode off` with no other
arguments switches the existing service to interface DNS mode, keeping its
other arguments.
The selection is stored in two places, so it survives restarts and upgrades:
- in the service's arguments, as `--intercept-mode off`;
- in `ctrld.toml`, as `intercept_mode = "off"`.
Earlier builds cleared `intercept_mode` when switching to `off`. That no longer
works as an opt-out: an unset value now means the platform default, so
switching to `off` writes `off`. Older builds read `off` the same way, so a
downgrade keeps interface DNS mode too.
## Upgrades
Only a fresh installation gets the `dns` default. An existing Standard
(interface DNS) installation stays in Standard mode across upgrades, whichever
path upgrades it.
To tell the two apart, `ctrld-client start` marks the services it installs, in the
service's arguments, when no mode is selected:
- A fresh install records `--intercept-mode-default=true`, an internal flag.
It ranks lowest: the service applies the default only if neither
`--intercept-mode` nor `service.intercept_mode` selects a mode, against the
configuration it fetched.
- A reinstall over an existing service keeps what that service recorded, its
mode or the marker. If it recorded neither, it was installed in Standard mode
before this default, and the reinstall records `--intercept-mode off`, unless
`service.intercept_mode` selects a mode.
- Platforms without the `dns` default record nothing, as before.
Older ctrld builds ignore flags they do not know in the service's arguments,
so the marker is safe if a device is downgraded.
An installed service with neither a mode nor the marker therefore predates
the default. It keeps interface DNS mode when the new binary runs it, and the
service log says why. That covers upgrades that rewrite nothing, such as
`ctrld-client upgrade` run by an older binary, which swaps the binary and restarts the
service with its existing arguments. A foreground `ctrld-client run` with no mode
selected uses the default.
The macOS package pins such a service explicitly when it upgrades it with a
profile that has no `InterceptMode`: it runs `ctrld-client start --intercept-mode
off`, the same as for a `standard` profile. See
[macOS MDM Package](macos-pkg-mdm.md).
To move an existing Standard installation to intercept mode, select it
explicitly with `ctrld-client start --intercept-mode dns`.
The Control D installer and dashboard should generate `--intercept-mode off` for
a device where Standard mode is chosen, so a fresh install never gets intercept
mode against that choice.
## Where the mode is reported
- `ctrld-client start` output: `DNS intercept mode active ...` once the service runs
in intercept mode, followed, when the default selected it, by how to switch
to interface DNS mode. A reinstall over a Standard install prints `Existing
service selects no intercept mode; keeping interface DNS mode
(--intercept-mode off)`.
- The service log, at startup: `Intercept mode enabled via config` when the
configuration set the mode, `Intercept mode "dns" is the default on this
platform; ...` when the default applied, or `Intercept mode default not
applied; using interface DNS mode` with the reason, for a service that
predates the default or a configuration that cannot run in intercept mode.
- The log header field `intercept_mode`, which reads `off` for interface DNS
mode.
+54 -3
View File
@@ -21,6 +21,10 @@ case "$key" in
[ "${FAKE_MODE_PRESENT:-0}" = "1" ] || exit 1
printf '%s\n' "${FAKE_MODE:-}"
;;
ProgramArguments)
[ "${FAKE_ARGS_PRESENT:-0}" = "1" ] || exit 1
printf '%s\n' "${FAKE_ARGS:-}"
;;
CustomHostname|UseDevEnvironment)
exit 1
;;
@@ -139,9 +143,10 @@ case_dir=$(run_case fresh-legacy 0 0 '')
assert_contains 'ctrld-client start --cd-org=test-token' "$case_dir/calls"
assert_not_contains '--intercept-mode' "$case_dir/calls"
# Standard must be selected explicitly: with no flag, ctrld defaults to dns
# intercept mode on macOS.
case_dir=$(run_case fresh-standard 0 1 standard)
assert_contains 'ctrld-client start --cd-org=test-token' "$case_dir/calls"
assert_not_contains '--intercept-mode' "$case_dir/calls"
assert_contains 'ctrld-client start --cd-org=test-token --intercept-mode off' "$case_dir/calls"
case_dir=$(run_case fresh-intercept 0 1 intercept-dns)
assert_contains 'ctrld-client start --cd-org=test-token --intercept-mode dns' "$case_dir/calls"
@@ -173,4 +178,50 @@ assert_contains 'ctrld-client start --intercept-mode off' "$case_dir/calls"
assert_contains 'ERROR: upgrade installed but managed InterceptMode could not be applied' "$case_dir/output"
assert_not_contains 'launchctl load' "$case_dir/calls"
printf 'PASS: pkg postinstall preserves legacy mode and applies standard/intercept-dns policy\n'
# The legacy upgrade case (#589): a profile without InterceptMode upgrading a
# service installed in Standard mode before ctrld defaulted to dns intercept
# mode. Its arguments select no mode, so it is pinned to Standard
# (--intercept-mode off) instead of switching to the new default.
legacy_args='(
"/usr/local/bin/ctrld-client",
run,
"--cd=abc123",
"--config=/etc/controld/ctrld.toml"
)'
export FAKE_ARGS_PRESENT=1 FAKE_ARGS="$legacy_args"
case_dir=$(run_case upgrade-legacy-standard 1 0 '')
assert_contains 'existing service selects no intercept mode; keeping Standard mode' "$case_dir/output"
assert_contains 'ctrld-client start --intercept-mode off' "$case_dir/calls"
assert_not_contains 'launchctl load' "$case_dir/calls"
# An invalid profile value warns, and still keeps a legacy service in Standard.
case_dir=$(run_case upgrade-invalid-legacy-standard 1 1 invalid)
assert_contains 'WARNING: unsupported InterceptMode in managed preferences; preserving existing service mode' "$case_dir/output"
assert_contains 'ctrld-client start --intercept-mode off' "$case_dir/calls"
# A service whose arguments already select a mode keeps it unchanged.
export FAKE_ARGS='(
"/usr/local/bin/ctrld-client",
run,
"--cd=abc123",
"--intercept-mode",
dns
)'
case_dir=$(run_case upgrade-legacy-intercept 1 0 '')
assert_contains 'launchctl load' "$case_dir/calls"
assert_not_contains 'ctrld-client start' "$case_dir/calls"
# A service a fresh install marked for the default keeps the marker, and the
# new binary applies the default to it.
export FAKE_ARGS='(
"/usr/local/bin/ctrld-client",
run,
"--cd=abc123",
"--intercept-mode-default=true"
)'
case_dir=$(run_case upgrade-default-marker 1 0 '')
assert_contains 'launchctl load' "$case_dir/calls"
assert_not_contains 'ctrld-client start' "$case_dir/calls"
unset FAKE_ARGS_PRESENT FAKE_ARGS
printf 'PASS: pkg postinstall preserves the existing service mode and applies standard/intercept-dns policy\n'