-
-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
How dcm is built and why. This is the reference for anyone reading or extending the code; for what the pages do, see The web interface.
Names, addresses and sites throughout this page are the fictional example cluster described in Network. Five Fritzbox networks share the same two dnsmasq servers as their primary DNS; clients get that server address via DHCP from their local Fritzbox, which forwards queries to the cluster.
graph TD
FB0["Fritzbox 7580\n192.168.188.1\nFrankfurt — gateway"]
FB1["Fritzbox 7690\n192.168.78.1\nBerlin 1"]
FB2["Fritzbox 5530\n192.168.118.1\nZürich"]
FB3["Fritzbox 7430\n10.1.10.1\nBerlin 2"]
FB4["Fritzbox 6820 LTE\n192.168.178.1\nMobile"]
DNS1["castor\n192.168.189.1\nDNS master\nApache2 + PHP-FPM + web UI"]
DNS2["pollux\n192.168.189.101\nDNS replica"]
GG["8.8.8.8 / 8.8.4.4\nGoogle DNS (upstream)"]
FB0 <-->|VPN tunnel| FB1
FB0 <-->|VPN tunnel| FB2
FB0 <-->|VPN tunnel| FB3
FB1 <-->|VPN tunnel| FB2
FB1 <-->|VPN tunnel| FB3
FB2 <-->|VPN tunnel| FB3
FB4 -->|"VPN dial-in (one way)"| FB0
FB0 -->|DNS queries| DNS1
FB0 -->|DNS queries| DNS2
FB1 -->|DNS queries| DNS1
FB1 -->|DNS queries| DNS2
FB2 -->|DNS queries| DNS1
FB2 -->|DNS queries| DNS2
FB3 -->|DNS queries| DNS1
FB3 -->|DNS queries| DNS2
FB4 -->|DNS queries| DNS1
FB4 -->|DNS queries| DNS2
DNS1 <-->|"dcm-cli sync (rsync+SSH)"| DNS2
DNS1 -->|"domain-specific upstream"| GG
DNS2 -->|"domain-specific upstream"| GG
The dnsmasq servers live in the gateway network (192.168.189.x), which is a /23 subnet of the Frankfurt Fritzbox. All five Fritzboxen forward DNS queries to both servers. Clients always query through their local Fritzbox, so a client that moves between sites keeps the same resolver.
Both servers run identical configuration except for listen.conf (server-specific listen address).
ENABLED=1
DNSMASQ_OPTS="--conf-file=/dev/null"
CONFIG_DIR=/etc/dnsmasq.d,.dpkg-dist,.dpkg-old,.dpkg-new
IGNORE_RESOLVCONF=yes
There is no monolithic config file — --conf-file=/dev/null makes dnsmasq read only the drop-ins in /etc/dnsmasq.d/. Each setting is its own <directive>.conf (present = active, absent = dnsmasq's default), written by the Configuration page. Key drop-ins:
-
domain-needed.conf/bogus-priv.conf— security -
resolv-file.conf— upstream from systemd-resolved (resolv-file = /run/systemd/resolve/resolv.conf) -
addn-hosts.conf(addn-hosts = /etc/dnsmasq.d/hosts) — loads the entire hosts directory -
log-queries.conf+log-facility.conf— full query logging
| File | Synced | Purpose |
|---|---|---|
<directive>.conf |
Yes | one drop-in per dnsmasq option (Configuration page) |
listen.conf |
No |
listen-address = 127.0.0.1 + own IP — generated per node |
upstream.conf |
Yes | All server = directives |
address.conf |
Yes | All address = directives (Fixed Addresses page) |
hosts/local |
Yes | LAN hosts, swarm nodes, Fritzboxen |
hosts/vms |
Yes | VM entries — IPs change per connected network |
hosts/isolated |
Yes | Phone-home domains → 127.0.0.1 and ::1 (Acronis, Adobe, Piriform) |
flowchart TD
Client["Client device"] --> FB["Local Fritzbox\n(whichever site the client is on)"]
FB --> DNS1["castor\n192.168.189.1"]
FB --> DNS2["pollux\n192.168.189.101"]
DNS1 --> Cache{"Cached?"}
DNS2 --> Cache
Cache -->|Yes| CacheReply["Return from cache"]
Cache -->|No| Hosts{"In hosts/local\nor hosts/vms?"}
Hosts -->|Yes| LocalReply["Return configured IP"]
Hosts -->|No| Isolated{"In hosts/isolated?"}
Isolated -->|Yes| Quarantined["Return 127.0.0.1 or ::1 — silent drop"]
Isolated -->|No| Domain{"Domain-specific upstream?"}
Domain -->|"Google, YouTube etc."| Google["8.8.8.8 / 8.8.4.4"]
Domain -->|"Reverse DNS 192.168.188-189.x"| Fritz["Fritzbox 192.168.188.1"]
Domain -->|"All other"| Default["Default upstream\n(systemd-resolved)"]
Location: /usr/local/sbin/dcm-cli — present on both nodes, synced automatically.
PHP calls it via sudo (sudoers: www-data ALL=(root) NOPASSWD: /usr/local/sbin/dcm-cli *).
-
Single source of truth for listen IP: reads node IPs from
hosts/local, never hardcodes them -
Single source of truth for paths: reads
CONFIG_DIRfrom/etc/default/dnsmasq, thenaddn-hosts/log-facilityfrom the merged drop-ins -
Single source of truth for swarm members:
/etc/dcm/nodes(hostname list only) -
listen.confis never synced: regenerated fromhosts/localafter every sync -
Content-based drift detection:
healthanddiffdry-runrsync --checksum, so a node counts as out of sync only when file content differs — a mere mtime change is ignored — andlisten.confis excluded (it is intentionally per-node) -
LC_ALL=Cfor alldatecalls: dnsmasq logs in English (May 31), system locale is German (Mai 31) - No dnsmasq version is hardcoded: every node reports its own build (see below), and the UI offers what the weakest node understands
sync Sync all config files + dcm-cli binary to all remote nodes
restart local|remote|all systemctl restart dnsmasq
status local|remote systemctl status dnsmasq
logs [N] tail -n N of log file
tail-f local|remote tail -F — streaming, used by SSE live log endpoint
stats local|remote [period] single-pass awk analytics (all|today|1h|24h|7d)
health Live sync/restart/build state as key=value, polled by the UI bell
diff Human-readable list of what a sync would change per remote node
node-report This node's restart state and dnsmasq build; used by health
sequenceDiagram
participant UI as Web UI (browser)
participant PHP as action.php (www-data)
participant CLI as dcm-cli (root)
participant L as castor
participant R as pollux
UI->>PHP: POST action=sync
PHP->>CLI: sudo dcm-cli sync
CLI->>L: write listen.conf (127.0.0.1 + 192.168.189.1)
CLI->>R: rsync /etc/default/dnsmasq
CLI->>R: rsync /etc/dnsmasq.d/ (--exclude listen.conf)
CLI->>R: rsync /etc/dcm/nodes
CLI->>R: rsync /usr/local/sbin/dcm-cli
CLI->>R: write listen.conf (127.0.0.1 + 192.168.189.101)
CLI-->>PHP: output text
PHP-->>UI: JSON {ok, output}
dcm replicates one identical configuration to every node, so the cluster has to agree on what that configuration may contain. Running the same dnsmasq version on all nodes is a hard requirement, for two reasons:
- dnsmasq aborts at startup on an option it does not know. A directive only the newer build understands takes the older node down on its next restart — after a sync, not at the moment the setting is saved.
- Semantics change between releases. Since 2.86,
address=/dom/<ip>no longer suppresses other query types on its own; the same line therefore answers differently on 2.85 and on 2.91.
Nothing is hardcoded about a particular release. Each node reports its own build to health, which asks node-report locally and over SSH:
| Source | What it yields |
|---|---|
LC_ALL=C dnsmasq --version |
version, plus the compile time options (ipset, no-nftset, DNSSEC, …) |
LC_ALL=C dnsmasq --help |
every long option this build accepts |
LC_ALL=C is mandatory — the system locale is German and dnsmasq translates both outputs. --help is not filtered by the compile time options (a no-nftset build still lists --nftset), so the two lists answer different questions and both are kept.
health also checks the configured drop-ins against each node's option list and reports every directive a node would choke on (build-unknown), which is the case that actually takes a node down: the config is written on the UI node, and the older node only fails when it restarts with it. It writes one TSV record per node (node, version, features, options) to /var/lib/dcm/cluster-build. Records of nodes that were unreachable are carried over, so a node being down cannot silently widen what the UI offers. The web side reads that cache — no SSH per page view — and inc/dnsmasq_build.php reduces it to the cluster floor: the oldest version, and only those features and options that every node has. A directive outside that floor is rendered read-only with the reason, and dropin_editable() is the single gate for rendering, saving and writing, so a blocked directive is neither written nor silently deleted.
The same idea applies to the directive help texts: inc/manpage.php parses the dnsmasq(8) manual page installed next to the running binary, so the help describes the behaviour this node actually has. inc/dnsmasq_manpage.php is a generated copy of that text (tools/gen-manpage.php) and is used only where manual pages are stripped.
URL: https://dns.example.net/ (Apache2 on castor, port 443, wildcard TLS cert)
Also: https://adblock.example.net/ (ad-server sink — served by same Apache, returns blocked page)
The page map lives in The web interface.
graph LR
Browser -->|HTTPS 443| Apache
Apache -->|FastCGI| PHPFPM["PHP-FPM (www-data)\nProtectSystem=full +\nReadWritePaths override"]
PHPFPM -->|"sudo (NOPASSWD)"| CLI["/usr/local/sbin/dcm-cli (root)"]
CLI -->|"rsync + SSH"| Remote["pollux (root)"]
CLI -->|"direct write"| EtcDnsmasq["/etc/dnsmasq.d/listen.conf\n/etc/dcm/nodes\n/var/lib/dcm/cluster-build"]
PHPFPM -->|"direct write (www-data owns)"| HostsFiles["/etc/dnsmasq.d/*.conf\n/etc/dnsmasq.d/hosts/local\n/etc/dnsmasq.d/hosts/vms"]
PHP-FPM runs with ProtectSystem=full (systemd sandboxing makes /etc read-only). Override in /etc/systemd/system/php8.4-fpm.service.d/override.conf:
[Service]
ReadWritePaths=/etc/dnsmasq.d /etc/dcmThis applies to all child processes including sudo dcm-cli. The Configuration page additionally needs /etc/dnsmasq.d group-writable by www-data (chown root:www-data + chmod 2775) so it can create and remove <directive>.conf drop-ins.
sequenceDiagram
participant B as Browser
participant LS as live_stream.php (www-data)
participant CLI as dcm-cli (root)
participant TailL as tail -F (local)
participant TailR as ssh root@pollux tail -F (remote)
B->>LS: GET live_stream.php?server=local (EventSource)
LS->>CLI: sudo dcm-cli tail-f local
CLI->>TailL: exec tail -F /var/log/dnsmasq/dnsmasq.log
B->>LS: GET live_stream.php?server=remote (EventSource)
LS->>CLI: sudo dcm-cli tail-f remote
CLI->>TailR: exec ssh root@pollux tail -F /var/log/...
loop Until browser disconnects
TailL-->>LS: new line
LS-->>B: data: "May 31 ..."\n\n
TailR-->>LS: new line
LS-->>B: data: "May 31 ..."\n\n
end
The browser opens two separate EventSource connections (one per server). Lines are color-coded:
- Blue:
query[A]· Purple:query[AAAA]· Teal:query[HTTPS]· Light blue: other query types - Yellow:
forwarded· Green:cached - Red:
NXDOMAIN· Orange:NODATA· Dark red:SERVFAIL/REFUSED - Grey:
config/ hosts file responses
Layout toggles between side-by-side and stacked. Sidebar collapsible for full-width view.
flowchart LR
LogFile["/var/log/dnsmasq/dnsmasq.log\n+ dnsmasq.log.1"] -->|"grep with LC_ALL=C date filter"| Filtered["Lines for selected period"]
Filtered -->|"single awk pass"| Stats["key=value scalars\n+ TSV arrays"]
Stats -->|"sudo dcm-cli stats"| PHP["analytics.php parse_stats()"]
PHP --> UI["HTML: cards + bar chart\n+ top-N tables"]
Single awk pass collects: query types (A/AAAA/HTTPS/PTR/…), cache hits, forwarded, locally resolved, blocked, NXDOMAIN/NODATA/SERVFAIL/REFUSED/CNAME, per-hour counts, top 15 upstreams, top 20 domains, top 15 clients.
Filter periods: 1h · today · 24h · 7d · all — filter selection persisted via 30-day cookie.
- Hosts files — the three host files and the VM subnet relocation
- Directive catalog — every directive the Configuration page exposes
- Roadmap — what is planned and what is deliberately not there yet
© 2026 [ernolf] Raphael Gradenwitz · GPL-3.0-or-later · Report an issue
Getting started
Managing the cluster
Under the hood
What comes next