Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1804e6d
fix(windows): improve DNS server discovery for domain-joined machines
cuonglm Jan 14, 2026
f05519d
refactor(network): consolidate network change monitoring
cuonglm Jan 20, 2026
8d63a75
Removing outdated netlink codes
cuonglm Jan 22, 2026
e8d1a46
perf(doq): implement connection pooling for improved performance
cuonglm Jan 6, 2026
1f4c473
refactor(config): consolidate transport setup and eliminate duplication
cuonglm Jan 6, 2026
2e8a0f0
fix(config): use three-state atomic for rebootstrap to prevent data race
cuonglm Jan 7, 2026
acbebcf
perf(dot): implement connection pooling for improved performance
cuonglm Jan 8, 2026
209c921
fix(dns): handle empty and invalid IP addresses gracefully
cuonglm Jan 26, 2026
da3ea05
fix(dot): validate connections before reuse to prevent io.EOF errors
cuonglm Jan 28, 2026
4790eb2
refactor(dot): simplify DoT connection pool implementation
cuonglm Jan 28, 2026
3f30ec3
refactor(doq): simplify DoQ connection pool implementation
cuonglm Jan 28, 2026
40c68a1
fix(metadata): detect login user via logname when running under sudo
cuonglm Feb 10, 2026
a4f0418
fix(darwin): handle mDNSResponder on port 53 to avoid bind conflicts
cuonglm Feb 9, 2026
147106f
fix(darwin): use scutil for provisioning hostname (#485)
Feb 12, 2026
12715e6
fix: include hostname hints in metadata for API-side fallback
Feb 12, 2026
1e8240b
feat: introduce DNS intercept mode infrastructure
Mar 3, 2026
289a46d
feat: add macOS pf DNS interception
Mar 3, 2026
768cc81
feat: add Windows NRPT and WFP DNS interception
Mar 3, 2026
e7040bd
feat: add VPN DNS split routing
Mar 3, 2026
9b2e51f
feat: robust username detection and CI updates
Mar 3, 2026
9be15ae
fix(windows): make staticcheck happy
cuonglm Mar 3, 2026
fe08f00
fix(darwin): correct pf rules tests
cuonglm Mar 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ ctrld-*

# generated file
cmd/cli/rsrc_*.syso
ctrld
ctrld.exe
105 changes: 103 additions & 2 deletions cmd/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,16 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
processLogAndCacheFlags(v, &cfg)
}

// Persist intercept_mode to config when provided via CLI flag on full install.
// This ensures the config file reflects the actual running mode for RMM/MDM visibility.
if interceptMode == "dns" || interceptMode == "hard" {
if cfg.Service.InterceptMode != interceptMode {
cfg.Service.InterceptMode = interceptMode
updated = true
mainLog.Load().Info().Msgf("writing intercept_mode = %q to config", interceptMode)
}
}

if updated {
if err := writeConfigFile(&cfg); err != nil {
notifyExitToLogServer()
Expand Down Expand Up @@ -647,7 +657,7 @@ func processCDFlags(cfg *ctrld.Config) (*controld.ResolverConfig, error) {
req := &controld.ResolverConfigRequest{
RawUID: cdUID,
Version: rootCmd.Version,
Metadata: ctrld.SystemMetadata(ctx),
Metadata: ctrld.SystemMetadataRuntime(context.Background()),
}
resolverConfig, err := controld.FetchResolverConfig(req, cdDev)
for {
Expand Down Expand Up @@ -1220,10 +1230,99 @@ func updateListenerConfig(cfg *ctrld.Config, notifyToLogServerFunc func()) bool
return updated
}

// tryUpdateListenerConfigIntercept handles listener binding for dns-intercept mode on macOS.
// In intercept mode, pf redirects all outbound port-53 traffic to ctrld's listener,
// so ctrld can safely listen on a non-standard port if port 53 is unavailable
// (e.g., mDNSResponder holds *:53).
//
// Flow:
// 1. If config has explicit (non-default) IP:port → use exactly that, no fallback
// 2. Otherwise → try 127.0.0.1:53, then 127.0.0.1:5354, then fatal
func tryUpdateListenerConfigIntercept(cfg *ctrld.Config, notifyFunc func(), fatal bool) (updated, ok bool) {
ok = true
lc := cfg.FirstListener()
if lc == nil {
return false, true
}

hasExplicitConfig := lc.IP != "" && lc.IP != "0.0.0.0" && lc.Port != 0
if !hasExplicitConfig {
// Set defaults for intercept mode
if lc.IP == "" || lc.IP == "0.0.0.0" {
lc.IP = "127.0.0.1"
updated = true
}
if lc.Port == 0 {
lc.Port = 53
updated = true
}
}

tryListen := func(ip string, port int) bool {
addr := net.JoinHostPort(ip, strconv.Itoa(port))
udpLn, udpErr := net.ListenPacket("udp", addr)
if udpLn != nil {
udpLn.Close()
}
tcpLn, tcpErr := net.Listen("tcp", addr)
if tcpLn != nil {
tcpLn.Close()
}
return udpErr == nil && tcpErr == nil
}

addr := net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port))
if tryListen(lc.IP, lc.Port) {
mainLog.Load().Debug().Msgf("DNS intercept: listener available at %s", addr)
return updated, true
}

mainLog.Load().Info().Msgf("DNS intercept: cannot bind %s", addr)

if hasExplicitConfig {
// User specified explicit address — don't guess, just fail
if fatal {
notifyFunc()
mainLog.Load().Fatal().Msgf("DNS intercept: cannot listen on configured address %s", addr)
}
return updated, false
}

// Fallback: try port 5354 (mDNSResponder likely holds *:53)
if tryListen("127.0.0.1", 5354) {
mainLog.Load().Info().Msg("DNS intercept: port 53 unavailable (likely mDNSResponder), using 127.0.0.1:5354")
lc.IP = "127.0.0.1"
lc.Port = 5354
return true, true
}

if fatal {
notifyFunc()
mainLog.Load().Fatal().Msg("DNS intercept: cannot bind 127.0.0.1:53 or 127.0.0.1:5354")
}
return updated, false
}

// tryUpdateListenerConfig tries updating listener config with a working one.
// If fatal is true, and there's listen address conflicted, the function do
// fatal error.
func tryUpdateListenerConfig(cfg *ctrld.Config, infoLogger *zerolog.Logger, notifyFunc func(), fatal bool) (updated, ok bool) {
// In intercept mode (macOS), pf redirects all port-53 traffic to ctrld's listener,
// so ctrld can safely listen on a non-standard port. Use a simple two-attempt flow:
// 1. If config has explicit non-default IP:port, use exactly that
// 2. Otherwise: try 127.0.0.1:53, then 127.0.0.1:5354, then fatal
// This bypasses the full cd-mode listener probing loop entirely.
// Check interceptMode (CLI flag) first, then fall back to config value.
// dnsIntercept bool is derived later in prog.run(), but we need to know
// the intercept mode here to select the right listener probing strategy.
im := interceptMode
if im == "" || im == "off" {
im = cfg.Service.InterceptMode
}
if (im == "dns" || im == "hard") && runtime.GOOS == "darwin" {
return tryUpdateListenerConfigIntercept(cfg, notifyFunc, fatal)
}

ok = true
lcc := make(map[string]*listenerConfigCheck)
cdMode := cdUID != ""
Expand Down Expand Up @@ -1868,10 +1967,12 @@ func runningIface(s service.Service) *ifaceResponse {

// doValidateCdRemoteConfig fetches and validates custom config for cdUID.
func doValidateCdRemoteConfig(cdUID string, fatal bool) error {
// Username is only sent during initial provisioning (cdUIDFromProvToken).
// All subsequent calls use lightweight metadata to avoid EDR triggers.
req := &controld.ResolverConfigRequest{
RawUID: cdUID,
Version: rootCmd.Version,
Metadata: ctrld.SystemMetadata(context.Background()),
Metadata: ctrld.SystemMetadataRuntime(context.Background()),
}
rc, err := controld.FetchResolverConfig(req, cdDev)
if err != nil {
Expand Down
154 changes: 139 additions & 15 deletions cmd/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ func initRunCmd() *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: 'dns' (with VPN split routing) or 'hard' (all DNS through ctrld, no VPN split routing)")

runCmd.FParseErrWhitelist = cobra.FParseErrWhitelist{UnknownFlags: true}
rootCmd.AddCommand(runCmd)
Expand Down Expand Up @@ -229,6 +230,14 @@ NOTE: running "ctrld start" without any arguments will start already installed c
setDependencies(sc)
sc.Arguments = append([]string{"run"}, osArgs...)

// Validate --intercept-mode early, before installing the service.
// Without this, a typo like "--intercept-mode fds" would install the service,
// the child process would Fatal() on the invalid value, and the parent would
// then uninstall — confusing and destructive.
if interceptMode != "" && !validInterceptMode(interceptMode) {
mainLog.Load().Fatal().Msgf("invalid --intercept-mode value %q: must be 'off', 'dns', or 'hard'", interceptMode)
}

p := &prog{
router: router.New(&cfg, cdUID != ""),
cfg: &cfg,
Expand All @@ -247,6 +256,49 @@ NOTE: running "ctrld start" without any arguments will start already installed c
// Get current running iface, if any.
var currentIface *ifaceResponse

// Handle "ctrld start --intercept-mode dns|hard" on an existing
// service BEFORE the pin check. Adding intercept mode is an enhancement, not
// deactivation, so it doesn't require the deactivation pin. We modify the
// plist/registry directly and restart the service via the OS service manager.
osArgsEarly := os.Args[2:]
if os.Args[1] == "service" {
osArgsEarly = os.Args[3:]
}
osArgsEarly = filterEmptyStrings(osArgsEarly)
interceptOnly := onlyInterceptFlags(osArgsEarly)
svcExists := serviceConfigFileExists()
mainLog.Load().Debug().Msgf("intercept upgrade check: args=%v interceptOnly=%v svcConfigExists=%v interceptMode=%q", osArgsEarly, interceptOnly, svcExists, interceptMode)
if interceptOnly && svcExists {
// Remove any existing intercept flags before applying the new value.
_ = removeServiceFlag("--intercept-mode")

if interceptMode == "off" {
// "off" = remove intercept mode entirely (just the removal above).
mainLog.Load().Notice().Msg("Existing service detected — removing --intercept-mode from service arguments")
} else {
// Add the new mode value.
mainLog.Load().Notice().Msgf("Existing service detected — appending --intercept-mode %s to service arguments", interceptMode)
if err := appendServiceFlag("--intercept-mode"); err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to append intercept flag to service arguments")
}
if err := appendServiceFlag(interceptMode); err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to append intercept mode value to service arguments")
}
}

// Stop the service if running (bypasses ctrld pin — this is an
// enhancement, not deactivation). Then fall through to the normal
// startOnly path which handles start, self-check, and reporting.
if isCtrldRunning {
mainLog.Load().Notice().Msg("Stopping service for intercept mode upgrade")
_ = s.Stop()
isCtrldRunning = false
}
startOnly = true
isCtrldInstalled = true
// Fall through to startOnly path below.
}

// If pin code was set, do not allow running start command.
if isCtrldRunning {
if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) {
Expand All @@ -271,20 +323,31 @@ NOTE: running "ctrld start" without any arguments will start already installed c
return
}
if res.OK {
name := res.Name
if iff, err := net.InterfaceByName(name); err == nil {
_, _ = patchNetIfaceName(iff)
name = iff.Name
}
logger := mainLog.Load().With().Str("iface", name).Logger()
logger.Debug().Msg("setting DNS successfully")
if res.All {
// Log that DNS is set for other interfaces.
withEachPhysicalInterfaces(
name,
"set DNS",
func(i *net.Interface) error { return nil },
)
// In intercept mode, show intercept-specific status instead of
// per-interface DNS messages (which are irrelevant).
if res.InterceptMode != "" {
switch res.InterceptMode {
case "hard":
mainLog.Load().Notice().Msg("DNS hard intercept mode active — all DNS traffic intercepted, no VPN split routing")
default:
mainLog.Load().Notice().Msg("DNS intercept mode active — all DNS traffic intercepted via OS packet filter")
}
} else {
name := res.Name
if iff, err := net.InterfaceByName(name); err == nil {
_, _ = patchNetIfaceName(iff)
name = iff.Name
}
logger := mainLog.Load().With().Str("iface", name).Logger()
logger.Debug().Msg("setting DNS successfully")
if res.All {
// Log that DNS is set for other interfaces.
withEachPhysicalInterfaces(
name,
"set DNS",
func(i *net.Interface) error { return nil },
)
}
}
}
}
Expand Down Expand Up @@ -344,6 +407,7 @@ NOTE: running "ctrld start" without any arguments will start already installed c
if !startOnly {
startOnly = len(osArgs) == 0
}

// If user run "ctrld start" and ctrld is already installed, starting existing service.
if startOnly && isCtrldInstalled {
tryReadingConfigWithNotice(false, true)
Expand Down Expand Up @@ -384,6 +448,10 @@ NOTE: running "ctrld start" without any arguments will start already installed c
os.Exit(1)
}
reportSetDnsOk(sockDir)
// Verify service registration after successful start.
if err := verifyServiceRegistration(); err != nil {
mainLog.Load().Warn().Err(err).Msg("Service registry verification failed")
}
} else {
mainLog.Load().Error().Err(err).Msg("Failed to start existing ctrld service")
os.Exit(1)
Expand All @@ -392,7 +460,8 @@ NOTE: running "ctrld start" without any arguments will start already installed c
}

if cdUID != "" {
_ = doValidateCdRemoteConfig(cdUID, true)
// Skip doValidateCdRemoteConfig() here - run command will handle
// validation and config fetch via processCDFlags().
} else if uid := cdUIDFromProvToken(); uid != "" {
cdUID = uid
mainLog.Load().Debug().Msg("using uid from provision token")
Expand Down Expand Up @@ -509,6 +578,10 @@ NOTE: running "ctrld start" without any arguments will start already installed c
os.Exit(1)
}
reportSetDnsOk(sockDir)
// Verify service registration after successful start.
if err := verifyServiceRegistration(); err != nil {
mainLog.Load().Warn().Err(err).Msg("Service registry verification failed")
}
}
},
}
Expand All @@ -533,6 +606,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: 'dns' (with VPN split routing) or 'hard' (all DNS through ctrld, no VPN split routing)")

routerCmd := &cobra.Command{
Use: "setup",
Expand Down Expand Up @@ -1395,3 +1469,53 @@ func filterEmptyStrings(slice []string) []string {
return s == ""
})
}

// validInterceptMode reports whether the given value is a recognized --intercept-mode.
// This is the single source of truth for mode validation — used by the early start
// command check, the runtime validation in prog.go, and onlyInterceptFlags below.
// Add new modes here to have them recognized everywhere.
func validInterceptMode(mode string) bool {
switch mode {
case "off", "dns", "hard":
return true
}
return false
}

// onlyInterceptFlags reports whether args contain only intercept mode
// flags (--intercept-mode <value>) and flags that are auto-added by the
// start command alias (--iface). This is used to detect "ctrld start --intercept-mode dns"
// (or "off" to disable) on an existing installation, where the intent is to modify the
// intercept flag on the existing service without replacing other arguments.
//
// Note: the startCmdAlias appends "--iface=auto" to os.Args when --iface isn't
// explicitly provided, so we must allow it here.
func onlyInterceptFlags(args []string) bool {
hasIntercept := false
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--intercept-mode":
// Next arg must be a valid mode value.
if i+1 < len(args) && validInterceptMode(args[i+1]) {
hasIntercept = true
i++ // skip the value
} else {
return false
}
case strings.HasPrefix(arg, "--intercept-mode="):
val := strings.TrimPrefix(arg, "--intercept-mode=")
if validInterceptMode(val) {
hasIntercept = true
} else {
return false
}
case arg == "--iface=auto" || arg == "--iface" || arg == "auto":
// Auto-added by startCmdAlias or its value; safe to ignore.
continue
default:
return false
}
}
return hasIntercept
}
Loading