Cuong Manh Le 171dd0a7e6 fix(firewall): support VM/container egress under macOS Firewall Mode
macOS Firewall Mode blocked forwarded/NATed VM/container egress: a guest
resolves DNS through a path host ctrld does not observe, so the guest-resolved
public IP never enters <ctrld_allowed> and the blanket outbound block drops the
guest's TCP/443.

Make VM/container guests first-class Firewall Mode clients by forcing their DNS
through ctrld. The trusted source subnets are the UNION of:

  - Auto-detected VM/NAT networks (default, no config): interfaces that are up,
    carry an RFC1918 IPv4 address, and whose VM ownership can be proven either
    by a vendor-specific name (vnic/vboxnet/vmnet) or by being a bridge* whose
    member list contains a vendor VM interface (typically vmenet*). Each keeps
    its ingress interface, and its pf rules are scoped "on <iface>" so an
    unrelated interface on the same private range is never affected.
  - service.firewall_forwarded_sources (opt-in): explicit IPv4 CIDRs, matched on
    the source CIDR alone, for stacks whose ownership cannot be proven. Config
    adds only; an invalid or non-IPv4 entry is dropped with a warning.

Checking bridge membership is what makes the common case work without config.
Every vmnet.framework stack - UTM and other Virtualization.framework guests,
Docker Desktop, Multipass, Fusion 12.1+ NAT - puts the RFC1918 gateway address
on a bridge10x interface and attaches the vendor-named vmenet* interface as an
address-less member, so matching on interface name alone never sees them.
Membership is the ownership proof a bridge name lacks: macOS shares that
namespace with Thunderbolt/aggregated links (ctrld's own tunnel-change code
treats bridge0 as physical), and such a bridge has en* members, so it stays
untrusted however private its address. Members are read with ifconfig, and only
for a bridge that already carries an RFC1918 IPv4 address, so a host with no VM
running executes no subprocess.

Per source subnet, plaintext DNS (53) is force-routed through ctrld (route-to
lo0 -> existing rdr-on-lo0) so guest resolutions are policy-enforced and
populate the allowlist; guest egress to allowed IPs is then permitted by the
existing <ctrld_allowed> rule. DoT (853) is blocked so guests cannot swap in an
alternate resolver. DoH/443 is a documented limitation.

Rules are emitted strictly per address family. Sources are IPv4 (interception
targets ctrld's IPv4 listener), so only inet rules are generated: pf rejects an
entire anchor over a single "inet6 ... from 192.168.x.0/24" mismatch, which
would take DNS interception down with it. Guest IPv6 DoT is covered by the
blanket IPv6 block instead, since such a resolver never enters <ctrld_allowed>.

firewall_forwarded_sources is deliberately not validated with `cidr`. Entries
are checked where they are parsed and a bad one is dropped while the rest of the
set still applies. A hard validator would make one typo in an MDM-pushed subnet
fatal at startup - validateConfig exits the process - taking DNS service down
for the whole host over a line that only ever widened a firewall allowance. The
warning fires when the set of unusable entries changes rather than on every
parse, since config is re-read on every anchor build and every watchdog tick.

Reconcile the trust set at runtime, since VM interfaces appear and disappear
while ctrld runs and no existing path rebuilds an intact anchor for that
(ensurePFAnchorActive returns early, checkTunnelInterfaceChanges tracks only
tunnels, pfInterceptMonitor rebuilds only after a failed host probe). On a
change, rebuild the anchor and drop the pf states of the affected subnets
(targeted pfctl -k, not a global state flush), because rules govern only new
states: a stopped guest would otherwise keep using states created while it was
trusted, and a newly trusted one would keep bypassing interception until its
states expired. Reconciliation runs on interface appear/disappear, on network
changes, on the delayed post-change re-checks (a VM network often gets its
address after its interface appears), and on the pf watchdog tick, which bounds
guest start/stop convergence to one interval even with no network event.

Convergence is not latched on failure: the applied set advances, and states are
killed, only after pf has accepted the new anchor. reloadForwardedSourceAnchor
reports write/pfctl failures to the caller, which then keeps the previous set
recorded and logs a warning, so the next reconcile retries the same transition
instead of going quiet with the old anchor still installed. The whole
compare-reload-record sequence is serialized so a watchdog tick and a network
change cannot both rebuild or interleave snapshots.

Every anchor rebuild records the forwarded-source set it installed, and takes
that set as a parameter rather than re-detecting internally. Otherwise a rebuild
triggered by something else (tunnel change, watchdog restore, VPN DNS
exemptions, forced reload, startup) leaves the snapshot at the older set and the
next reconcile "discovers" the same change again: another rebuild, another round
of killed guest states, and a transition logged for something already in effect.
Passing the set in also means what pf loaded is exactly what gets recorded, so
an interface appearing mid-reconcile cannot leave the snapshot describing a set
that was never installed.

The anchor file is replaced atomically (temp file plus rename). Seven paths
rebuild it from timers and network-change callbacks in their own goroutines with
no lock between them, and os.WriteFile truncates before writing, so a pfctl -f
racing that window could read a partial ruleset and reject the anchor - taking
DNS interception down until the next watchdog restore.

Report the effective trust set at startup and on every change, naming each
subnet's origin ("192.168.64.0/24 (configured)" vs "(auto-detected on
bridge100)"), and say so explicitly when the set is empty, including what
auto-detection requires. Configured entries previously produced no log output at
all, so an admin who set firewall_forwarded_sources could not confirm it had
taken effect without reading pf rules. Per-source detection logging is at debug,
since detection re-runs on every anchor build.

Explicit, per-subnet trust boundary (RFC1918-only auto-detect, proven VM
ownership, interface-scoped rules, config adds only), not an interface-wide
permit: direct public IPs the guest never resolved through ctrld stay blocked.

Tests cover rule generation and its interface scoping, the no-blanket-permit
boundary, single-family emission (no inet6 rule for an IPv4 source, IPv6 sources
skipped), a real pfctl -n -f parse of both the forwarded-source rules alone and
the full anchor (group-scoped rules stripped, since _ctrld exists only where the
service is installed), invalid/duplicate/non-IPv4 config entries, that a bad
entry does not fail config validation, the union set and its signature, a
deterministic guest start/stop lifecycle asserting both the rebuild points and
which subnets' states must be dropped, anchor-reload failure followed by a
successful retry, that reconcile is inert outside firewall mode, ifconfig member
parsing against real bridge output, and the trust decision per interface -
including everything that must NOT qualify: a Thunderbolt bridge, a
public-range VM bridge, an IPv6-only bridge, an address-less vendor interface, a
physical uplink and a VPN tunnel. docs/firewall-mode.md documents the boundary,
what auto-detection can and cannot prove, the address-family constraint, the
lifecycle/retry behavior, how to confirm the trust set from the log, and that a
resolver running inside the guest is not supported (its encrypted upstream
leaves the host nothing to allowlist).
2026-08-04 14:12:15 +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%