Cuong Manh Le 891d5d9821 cmd/cli: drop stale intercept enforcement at startup, before API bootstrap
A host carrying orphaned WFP filters from a ctrld build that predates
session-scoped ownership cannot recover on its own. In Firewall Mode those
filters block all non-allowlisted outbound traffic machine-wide, which denies
the replacement ctrld's own API bootstrap. API-managed startup then sits in the
resolver-config retry loop forever and never reaches startWFPFilters, where the
only stale-sublayer cleanup lived. Cleanup needs startup, startup needs the
network, the network needs cleanup - the host stays locked out until a reboot.

Add cleanupStaleDNSInterceptState() and call it early in run(), before the
network-up wait and before the API preflight. On Windows it deletes ctrld's WFP
sublayer, which takes its child filters with it, so a previous process's
enforcement is gone before this process makes its first connection. Objects
owned by a live session cannot be deleted, so a running ctrld is unaffected and
"nothing to clean up" stays at debug level; an actual removal logs a warning,
since it means a previous ctrld left machine-wide enforcement installed.

macOS and the other platforms get no-op implementations: pf enforcement does not
outlive the process, and startDNSIntercept already flushes the anchor and
removes a stale anchor file before loading rules.

The cleanup inside startWFPFilters stays as a second line of defense.

The cleanup session is deliberately NOT dynamic. FwpmSubLayerDeleteByKey0 is
documented to fail with FWP_E_DYNAMIC_SESSION_IN_PROGRESS when called from a
dynamic session for an object that was not added in one, and the only orphans
that can exist are exactly those: a ctrld predating session-scoped ownership
added its sublayer statically. Anything a newer ctrld leaves behind is removed by
the OS when its session ends. Opening this session dynamically would have made
the cleanup a no-op in the one case it exists for, while still logging "no stale
WFP state".

A concurrently running ctrld is protected by documented ownership rather than by
the child-filter question below: a session-scoped ctrld's sublayer belongs to a
different dynamic session, so the delete fails with FWP_E_WRONG_SESSION. That
matters because this call is made unconditionally at startup, in every intercept
mode, so an interactive "ctrld run" alongside a healthy service reaches it.

A ctrld predating session scoping has no such protection: it holds a non-dynamic
sublayer, which is exactly what this targets and is indistinguishable from an
orphan. Two guards cover that instead. Elevation, because opening a WFP engine
and deleting ctrld's sublayer must not be reachable from an unprivileged local
process - FwpmEngineOpen0 is expected to fail without elevation, but that is a
property of the API rather than something this code checked, and it was the only
barrier. And interactive invocation, since a service start is not interactive: the
deadlock case still gets cleaned, while a hand-run "ctrld run" beside a live
service does not strip its enforcement. That second guard requires positive
evidence of absence - ctrldServiceLiveness answers unknown for an unreachable SCM
or a service mid-stop, and unknown skips the cleanup exactly as running does,
because "could not be observed" is not "not there".

Both are asserted against the deletion itself, not only against the predicate: a
caller-level test substitutes the WFP delete and the guard inputs, so a future
change that stops consulting the guard, or consults it and deletes anyway, fails
rather than staying green.

Delete failures are no longer collapsed into "nothing to clean up". Only
FWP_E_SUBLAYER_NOT_FOUND means that; any other code means state exists under our
GUID that we could not remove, which is the lockout condition itself, so it is
logged as a warning naming the code.

Whether deleting the sublayer is sufficient is left explicitly UNRESOLVED rather
than asserted. It is sufficient only if the delete also removes the filters
inside it. FwpmSubLayerDeleteByKey0's Remarks say nothing about child filters
either way, while object management states that an object cannot be deleted until
everything referencing it has been - and FWP_E_IN_USE exists for that. Whether a
filter's subLayerKey counts as such a reference is not documented, and this code
cannot be exercised off-Windows, so the earlier claim that the delete "takes its
child filters with it" is removed from the comment here and from
docs/wfp-dns-intercept.md, which carried it from before this branch.

The behaviour is safe under both readings: the delete is attempted, and
FWP_E_IN_USE is reported rather than counted as success, so a support log
distinguishes "cleared it" from "could not clear it". If a live Windows check
shows FWP_E_IN_USE against orphaned filters, the cleanup must enumerate and
delete those filters first. That is deliberately not written blind:
FWPM_FILTER_ENUM_TEMPLATE0 has no sublayer field, so selecting ctrld's own
filters means reading subLayerKey at a computed offset in FWPM_FILTER0, and
getting that offset wrong would delete other software's filters - a worse failure
than not cleaning up.

Refs: https://learn.microsoft.com/en-us/windows/win32/api/fwpmu/nf-fwpmu-fwpmsublayerdeletebykey0
Refs: https://learn.microsoft.com/en-us/windows/win32/fwp/object-management
2026-08-14 15:29:06 +07:00
2026-04-30 19:19:19 +07:00
2026-05-29 13:36:54 +07:00
2023-04-04 21:55:04 +07:00
2023-04-04 21:55:04 +07:00
2026-07-23 19:16:41 +07:00
2026-07-23 19:16:41 +07:00
2022-12-13 12:04:01 -05:00

ctrld

Test Go Reference Go Report Card

ctrld splash image

A highly configurable DNS forwarding proxy with support for:

  • Multiple listeners for incoming queries
  • Multiple upstreams with fallbacks
  • Multiple network policy driven DNS query steering (via network cidr, MAC address or FQDN)
  • Policy driven domain based "split horizon" DNS with wildcard support
  • LAN client discovery via DHCP, mDNS, ARP, NDP, hosts file parsing
  • Prometheus metrics exporter

TLDR

Proxy legacy DNS traffic to secure DNS upstreams in highly configurable ways.

All DNS protocols are supported, including:

  • UDP 53
  • DNS-over-HTTPS
  • DNS-over-TLS
  • DNS-over-HTTP/3 (DOH3)
  • DNS-over-QUIC

Use Cases

  1. Use secure DNS protocols on networks and devices that don't natively support them (legacy OSes, TVs, smart toasters).
  2. Create source IP based DNS routing policies with variable secure DNS upstreams. Subnet 1 (admin) uses upstream resolver A, while Subnet 2 (employee) uses upstream resolver B.
  3. Create destination IP based DNS routing policies with variable secure DNS upstreams. Listener 1 uses upstream resolver C, while Listener 2 uses upstream resolver D.
  4. Create domain level "split horizon" DNS routing policies to send internal domains (*.company.int) to a local DNS server, while everything else goes to another upstream.

OS Support

  • Windows Desktop (386, amd64, arm64)
  • MacOS (amd64, arm64)
  • Linux (386, amd64, arm, arm64, mips, mipsle, mips64)
  • FreeBSD (386, amd64, arm, arm64)

Install

There are several ways to download and install ctrld.

Quick Install

The simplest way to download and install ctrld is to use the following installer command on any UNIX-like platform:

sh -c 'sh -c "$(curl -sL https://api.controld.com/dl?version=2)"'

Windows user and prefer Powershell (who doesn't)? No problem, execute this command instead in administrative PowerShell:

(Invoke-WebRequest -Uri 'https://api.controld.com/dl/ps1?version=2' -UseBasicParsing).Content | Set-Content "$env:TEMP\ctrld_install.ps1"; Invoke-Expression "& '$env:TEMP\ctrld_install.ps1'"

Or you can pull and run a Docker container from Docker Hub

docker run -d --name=ctrld -p 127.0.0.1:53:53/tcp -p 127.0.0.1:53:53/udp controldns/ctrld:latest

Download Manually

Alternatively, if you know what you're doing you can download pre-compiled binaries from the Releases section for the appropriate platform.

Build

Lastly, you can build ctrld from source which requires go1.24+:

go build ./cmd/ctrld

or

go install github.com/Control-D-Inc/ctrld/cmd/ctrld@latest

or

docker build -t controldns/ctrld . -f docker/Dockerfile

Usage

The cli is self documenting, so feel free to run --help on any sub-command to get specific usages.

Arguments

        __         .__       .___
  _____/  |________|  |    __| _/
_/ ___\   __\_  __ \  |   / __ |
\  \___|  |  |  | \/  |__/ /_/ |
 \___  >__|  |__|  |____/\____ |
     \/ dns forwarding proxy  \/

Usage:
  ctrld [command]

Available Commands:
  run         Run the DNS proxy server
  start       Quick start service and configure DNS on interface
  stop        Quick stop service and remove DNS from interface
  restart     Restart the ctrld service
  reload      Reload the ctrld service
  status      Show status of the ctrld service
  uninstall   Stop and uninstall the ctrld service
  service     Manage ctrld service
  clients     Manage clients
  upgrade     Upgrading ctrld to latest version
  log         Manage runtime debug logs

Flags:
  -h, --help            help for ctrld
  -s, --silent          do not write any log output
  -v, --verbose count   verbose log output, "-v" basic logging, "-vv" debug logging
      --version         version for ctrld

Use "ctrld [command] --help" for more information about a command.

Basic Run Mode

This is the most basic way to run ctrld, in foreground mode. Unless you already have a config file, a default one will be generated.

Command

Windows (Admin Shell)

ctrld.exe run

Linux or Macos

sudo ctrld run

You can then run a test query using a DNS client, for example, dig:

$ dig verify.controld.com @127.0.0.1 +short
api.controld.com.
147.185.34.1

If verify.controld.com resolves, you're successfully using the default Control D upstream. From here, you can start editing the config file that was generated. To enforce a new config, restart the server.

Service Mode

This mode will run the application as a background system service on any Windows, MacOS, Linux or FreeBSD distribution. This will create a generic ctrld.toml file in the C:\ControlD directory (on Windows) or /etc/controld/ (almost everywhere else), start the system service, and configure the listener on all physical network interface. Service will start on OS boot.

Command

Windows (Admin Shell)

ctrld.exe start

Linux or Macos

sudo ctrld start

If ctrld is not in your system path (you installed it manually), you will need to run the above commands from the directory where you installed ctrld.

In order to stop the service, and restore your DNS to original state, simply run ctrld stop. If you wish to stop and uninstall the service permanently, run ctrld uninstall.

Unmanaged Service Mode

This mode functions similarly to the "Service Mode" above except it will simply start a system service and the config defined listeners, but will not make any changes to any network interfaces. You can then set the ctrld listener(s) IP on the desired network interfaces manually.

Command

Windows (Admin Shell)

ctrld.exe service start

Linux or Macos

sudo ctrld service start

Configuration

ctrld can be configured in variety of different ways, which include: API, local config file or via cli launch args.

API Based Auto Configuration

Application can be started with a specific Control D resolver config, instead of the default one. Simply supply your Resolver ID with a --cd flag, when using the start (service) mode. This mode is used when the 1 liner installer command from the Control D onboarding guide is executed.

The following command will use your own personal Control D Device resolver, and start the application in service mode. Your resolver ID is displayed on the "Show Resolvers" screen for the relevant Control D Endpoint.

Windows (Admin Shell)

ctrld.exe start --cd abcd1234

Linux or Macos

sudo ctrld start --cd abcd1234

Once you run the above command, the following things will happen:

  • You resolver configuration will be fetched from the API, and config file templated with the resolver data
  • Application will start as a service, and keep running (even after reboot) until you run the stop or uninstall sub-commands
  • All physical network interface will be updated to use the listener started by the service
  • All DNS queries will be sent to the listener

Manual Configuration

ctrld is entirely config driven and can be configured in many different ways, please see Configuration Docs.

Example

[listener]

  [listener.0]
    ip = '0.0.0.0'
    port = 53

[network]

  [network.0]
    cidrs = ["0.0.0.0/0"]
    name = "Network 0"

[upstream]

  [upstream.0]
    bootstrap_ip = "76.76.2.11"
    endpoint = "https://freedns.controld.com/p1"
    name = "Control D - Anti-Malware"
    timeout = 5000
    type = "doh"

The above basic config will:

  • Start listener on 0.0.0.0:53
  • Accept queries from any source address
  • Send all queries to https://freedns.controld.com/p1 using DoH protocol

CLI Args

If you're unable to use a config file, ctrld can be be supplied with basic configuration via launch arguments, in Ephemeral Mode.

Example

ctrld run --listen=127.0.0.1:53 --primary_upstream=https://freedns.controld.com/p2 --secondary_upstream=10.0.10.1:53 --domains=*.company.int,very-secure.local --log /path/to/log.log

The above will start a foreground process and:

  • Listen on 127.0.0.1:53 for DNS queries
  • Forward all queries to https://freedns.controld.com/p2 using DoH protocol, while...
  • Excluding *.company.int and very-secure.local matching queries, that are forwarded to 10.0.10.1:53
  • Write a debug log to /path/to/log.log

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.

When to Use

Enable DNS Intercept Mode if you:

  • Use corporate VPN software (F5, Cisco AnyConnect, Palo Alto GlobalProtect, Zscaler)
  • Run overlay networks like Tailscale or WireGuard
  • Experience random DNS failures when VPN connects/disconnects
  • See gaps in your Control D analytics when VPN is active
  • Have endpoint security software that also manages DNS

Command

Windows (Admin Shell)

ctrld.exe start --intercept-mode dns --cd RESOLVER_ID_HERE

macOS

sudo ctrld 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.

To disable intercept mode on a service that already has it enabled:

Windows (Admin Shell)

ctrld.exe start --intercept-mode off

macOS

sudo ctrld start --intercept-mode off

This removes the intercept rules and reverts to standard interface-based DNS configuration.

Platform Support

Platform Supported Mechanism
Windows NRPT (Name Resolution Policy Table)
macOS pf (packet filter) redirect
Linux Not currently supported

Features

  • VPN split routing — VPN-specific domains are automatically detected and forwarded to the VPN's DNS server
  • Captive portal recovery — Wi-Fi login pages (hotels, airports, coffee shops) work automatically
  • No network adapter changes — DNS settings stay untouched, eliminating conflicts entirely
  • Automatic port 53 conflict resolution — if another process (e.g., mDNSResponder on macOS) is already using port 53, ctrld automatically listens on a different port. OS-level packet interception redirects all DNS traffic to ctrld transparently, so no manual configuration is needed. This only applies to intercept mode.

Tested VPN Software

  • F5 BIG-IP APM
  • Cisco AnyConnect
  • Palo Alto GlobalProtect
  • Tailscale (including Exit Nodes)
  • Windscribe
  • WireGuard

For more details, see the DNS Intercept Mode documentation.

Contributing

See Contribution Guideline

S
Description
No description provided
Readme MIT
18 MiB
Languages
Go 98.9%
Shell 1%