Skip to content

Architecture

ernolf edited this page Sep 26, 2026 · 4 revisions

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.

Network overview

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
Loading

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.


dnsmasq configuration

Both servers run identical configuration except for listen.conf (server-specific listen address).

/etc/default/dnsmasq

ENABLED=1
DNSMASQ_OPTS="--conf-file=/dev/null"
CONFIG_DIR=/etc/dnsmasq.d,.dpkg-dist,.dpkg-old,.dpkg-new
IGNORE_RESOLVCONF=yes

Drop-in configuration

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

/etc/dnsmasq.d/ structure

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)

DNS query routing logic

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)"]
Loading

The dcm-cli tool

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 *).

Key design decisions

  • Single source of truth for listen IP: reads node IPs from hosts/local, never hardcodes them
  • Single source of truth for paths: reads CONFIG_DIR from /etc/default/dnsmasq, then addn-hosts / log-facility from the merged drop-ins
  • Single source of truth for swarm members: /etc/dcm/nodes (hostname list only)
  • listen.conf is never synced: regenerated from hosts/local after every sync
  • Content-based drift detection: health and diff dry-run rsync --checksum, so a node counts as out of sync only when file content differs — a mere mtime change is ignored — and listen.conf is excluded (it is intentionally per-node)
  • LC_ALL=C for all date calls: 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

Commands

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

Sync flow

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}
Loading

Cluster build detection

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.


Web frontend

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.

Security architecture

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"]
Loading

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/dcm

This 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.


Live log (SSE architecture)

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
Loading

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.


Analytics pipeline

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"]
Loading

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.


See also

  • 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

dcm

Getting started

Managing the cluster

Under the hood

What comes next

Clone this wiki locally