manages a two-node dnsmasq cluster from a single place:
- add, toggle and delete hosts
- relocate VM records between subnets
- edit dnsmasq options through a per-directive Configuration page
- see exactly what differs between nodes, then sync the configuration to all nodes
- restart the service
- watch both servers' query logs live with per-server analytics
- get live in-app notifications when the nodes drift out of sync or a restart is pending.
The web frontend drives a single privileged CLI backend (dcm-cli) over sudo.
Early development. dcm is under active, heavy development and far from feature-complete. Several planned features — DHCP and PXE/TFTP boot, ad-blocking lists, multi-network / split-horizon DNS, and real authentication — are not implemented yet, and existing behaviour may still change.
Two (or more) dnsmasq nodes run an identical configuration except for listen.conf — each node listens on its own address. dcm-cli keeps the nodes in sync over rsync + SSH and is the single privileged entry point; the PHP frontend only ever calls dcm-cli through sudo.
dnsmasq is configured entirely through drop-in files in its config directory (/etc/dnsmasq.d/); there is no monolithic config file. Each setting on the Configuration page is one <directive>.conf drop-in — present means the option is active, absent means dnsmasq's built-in default.
dcm does not duplicate dnsmasq's settings — it reads every path from dnsmasq's own config (single source of truth), merging the drop-ins the same way dnsmasq does:
| dcm needs | read from |
|---|---|
| config dir | CONFIG_DIR in /etc/default/dnsmasq |
| hosts dir | addn-hosts in the drop-ins |
| log file | log-facility in the drop-ins |
The only dcm-owned paths are the binary (/usr/local/sbin/dcm-cli) and the node list (/etc/dcm/nodes).
Every page carries a bell that polls dcm-cli health: it stays muted while all is well and glows when a node's configuration differs (compared by content, so a mere timestamp change does not count) or a drop-in is newer than the running dnsmasq — i.e. a sync or restart is pending. The Dashboard's What differs? button (dcm-cli diff) then lists the exact paths.
See docs/architecture.md for diagrams and the full design.
- Two or more Debian/Ubuntu nodes running
dnsmasq. - On the node that serves the UI: a web server (Apache2 in the example below) and PHP-FPM (8.x).
rsyncand passwordless root SSH from the UI node to every other node.addn-hostsset to a directory (not a single file) — dcm keeps its host files there. The setup below creates this drop-in; query logging is switched on later in the UI.
Pick one node to host the web UI — the one that runs the web server and PHP-FPM. With a single exception you do the entire setup on that node; the first sudo dcm-cli sync then replicates everything — the /etc/default/dnsmasq launch config, the drop-ins, the host files, the node list and the dcm-cli binary — to every other node.
The exception is step 1 (freeing port 53): it changes each node's own OS (systemd-resolved and /etc/resolv.conf), is not synced, and must be done on every node.
In the steps below node-a is the UI node and node-b a second node (placeholders for the short hostnames, hostname -s). Run every step as root.
Every other node needs only: dnsmasq installed and enabled, step 1 done locally, and passwordless root SSH reachable from the UI node (step 7). Its /etc/default/dnsmasq, drop-ins and the dcm-cli binary all arrive on the first sync. All remaining steps run on the UI node.
On systemd-based distros systemd-resolved occupies port 53 on 127.0.0.53, so dnsmasq cannot bind it. Disable only the stub listener — systemd-resolved keeps running as the upstream / per-link DNS manager.
/etc/systemd/resolved.conf:
[Resolve]
DNSStubListener=nosudo systemctl restart systemd-resolvedPoint the node's own resolver at dnsmasq:
rm -f /etc/resolv.conf
echo 'nameserver 127.0.0.1' > /etc/resolv.confAlways required, regardless of upstream encryption. A DoH/DoT/DoQ proxy (dnscrypt-proxy & co.) listens on a different port as dnsmasq's upstream and never touches port 53 — it does not replace this step. Until port 53 is free, dnsmasq cannot bind it and the service fails to start (
failed to create listening socket for port 53: Address already in use).
Why not just keep systemd-resolved and bind dnsmasq to its own addresses instead?
-
You could:
bind-interfaces/bind-dynamicmakes dnsmasq bind127.0.0.1and the LAN IP individually instead of the default wildcard0.0.0.0:53, so it no longer collides with the stub on127.0.0.53:53. But running both resolvers at once is messy and fragile:- Two sources of truth. Anything that still reaches
127.0.0.53— apps using the NSSresolvemodule, or aresolv.confthat got repointed — bypasses dnsmasq entirely. Yourhosts/block/ split-horizon rules and dnsmasq's query log do not apply, so dcm's live log and analytics silently miss those lookups. - resolv.conf churn.
systemd-resolved(together with NetworkManager / netplan) may rewrite/etc/resolv.confback to127.0.0.53on a network change or reboot, switching the box off dnsmasq without warning. - More moving parts.
bind-interfacesonly binds interfaces that exist at startup; addresses that appear later (DHCP, VPN tunnels) needbind-dynamic. The default wildcard bind avoids that — but the wildcard is exactly what collides with the stub.
Freeing port 53 once (
DNSStubListener=no) avoids all of it, and you do not need the stub: dnsmasq is your resolver.Aside:
listen-addressalone does not prevent the collision — by default it is only a software filter over the wildcard0.0.0.0:53socket, not an address-specific bind. - Two sources of truth. Anything that still reaches
dnsmasq is configured entirely through drop-ins in /etc/dnsmasq.d/; there is no monolithic config file. Point dnsmasq at an empty config file so it reads only the drop-in directory.
/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--conf-file=/dev/null overrides the compiled-in default (/etc/dnsmasq.conf) with an empty file, so dnsmasq's entire configuration comes from CONFIG_DIR (passed to dnsmasq as --conf-dir). dcm needs a hosts directory and a log file; create those as drop-ins:
mkdir -p /etc/dnsmasq.d
printf 'addn-hosts = /etc/dnsmasq.d/hosts\n' > /etc/dnsmasq.d/addn-hosts.conf
printf 'log-facility = /var/log/dnsmasq/dnsmasq.log\n' > /etc/dnsmasq.d/log-facility.confOnly the log file is defined here. Query logging itself is switched on later on the Configuration page — that also drives the Live Log and Analytics pages, which stay hidden until logging is on. Defining the file but enabling logging in the UI keeps a fresh install fail-safe.
Create the hosts directory and its three files:
mkdir -p /etc/dnsmasq.d/hosts
touch /etc/dnsmasq.d/hosts/{local,vms,block}These are ordinary dnsmasq hosts files (loaded via addn-hosts), each managed by its own UI page:
local— your LAN hosts, including one entry per cluster node (required — see below).vms— virtual-machine records, with one-click subnet relocation in the UI. (Interim solution: a future version will drop manual relocation and auto-detect the network, so a VM is always reachable by name no matter which connected network it is started in.)block— software phone-home endpoints (e.g. the license-check servers of Acronis, Adobe, …) pinned to127.0.0.1so those lookups fail silently. This is not an ad-blocking list; ad-list blocking is a separate, still-planned feature.
hosts/local must contain one line per node mapping its hostname to its IP — dcm-cli reads these to generate each node's listen.conf:
192.168.2.10 node-a
192.168.2.11 node-b
install -o root -g root -m 755 sbin/dcm-cli /usr/local/sbin/dcm-cli
mkdir -p /etc/dcm
printf '%s\n' node-a node-b > /etc/dcm/nodes # short hostnames, one per lineList all nodes (including this one) in /etc/dcm/nodes. The binary and this file are pushed to the other nodes on the first sync, so you install them here only.
echo 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/dcm-cli *' > /etc/sudoers.d/dcm-cli
chmod 440 /etc/sudoers.d/dcm-cli
visudo -c # validate before relying on itPHP-FPM ships with ProtectSystem=full, which makes /etc read-only for the service and everything it spawns (including sudo dcm-cli). Grant write access with a drop-in override — never edit the packaged unit file directly:
sudo systemctl edit php8.x-fpmAdd only the section and line you need:
[Service]
ReadWritePaths=/etc/dnsmasq.d /etc/dcmsystemd saves this to /etc/systemd/system/php8.x-fpm.service.d/override.conf (which survives package updates), then reload and restart:
sudo systemctl daemon-reload
sudo systemctl restart php8.x-fpmWhy a drop-in — and how to verify or revert it
-
Do not edit
/lib/systemd/system/php8.x-fpm.servicedirectly: that file is owned by thephp8.x-fpmpackage and is silently overwritten on the next update (e.g. from deb.sury.org), breaking your setup again without warning. A drop-in override under/etc/systemd/system/is the supported way to customise a package-provided unit and survives updates.Verify the merged result at any time:
sudo systemctl cat php8.x-fpm
Revert to the package defaults:
sudo systemctl revert php8.x-fpm sudo systemctl daemon-reload
List every customised, overridden or masked unit on the system:
sudo systemd-delta
mkdir -p /var/www/dcm
cp -r www/* /var/www/dcm/
chown -R www-data:www-data /var/www/dcmThe UI writes dnsmasq files directly (as www-data): the hosts and upstream files, and — on the Configuration page — one <directive>.conf drop-in per setting in /etc/dnsmasq.d/. Creating and removing those drop-ins needs write access to the directory itself, so make it group-writable by www-data (setgid, so new drop-ins inherit the group); give www-data the files it edits and keep the rest root-owned:
# the Configuration page creates/removes <directive>.conf drop-ins here
chown root:www-data /etc/dnsmasq.d && chmod 2775 /etc/dnsmasq.d
# UI-edited drop-ins (per-directive config + upstream.conf) -> owned by www-data
chown www-data:www-data /etc/dnsmasq.d/*.conf
chown www-data:www-data /etc/dnsmasq.d/hosts/local /etc/dnsmasq.d/hosts/vms
# generated per node / read-only in the UI by design -> stay root-owned
chown root:root /etc/dnsmasq.d/listen.conf /etc/dnsmasq.d/hosts/block(www-data already runs dcm-cli as root via sudo, so making the drop-in directory group-writable is not an additional exposure.)
Apache vhost (/etc/apache2/sites-available/dcm.conf) — adjust the domain and TLS to your environment:
<VirtualHost *:443>
ServerName dns.example.net
ServerAlias dcm.example.net
DocumentRoot /var/www/dcm
DirectoryIndex index.php
SSLEngine on
SSLCertificateFile /path/to/fullchain.pem
SSLCertificateKeyFile /path/to/privkey.pem
<Directory /var/www/dcm>
Options -Indexes
AllowOverride None
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/dcm_error.log
CustomLog ${APACHE_LOG_DIR}/dcm_access.log combined
</VirtualHost>a2ensite dcm
systemctl reload apache2Use a wildcard TLS certificate. The UI node must never be exposed to the internet, so it cannot answer an ACME HTTP-01 challenge. Obtain a wildcard certificate via a DNS-01 challenge (or copy one from a host that already manages it) and point
SSLCertificateFile/SSLCertificateKeyFileat it.
Make the UI reachable by name. Once dcm is the resolver, the vhost's
ServerName/ServerAliasresolve only if dnsmasq knows them. Add an entry mapping each UI hostname to the UI node's IP on the Hosts page (or inhosts/local).
Authentication is intentionally left out —
inc/auth.phpis a no-op stub. Until real auth is added, keep the vhost behind HTTP Basic auth, a VPN, or a trusted network.
dcm-cli sync runs rsync and ssh as root, connecting to root@<other-node>. So root on the UI node needs key-based access to root on every other node.
This may already be in place — test it first:
sudo ssh -o BatchMode=yes root@node-b true && echo OKIf it prints OK, you are done. Otherwise set it up:
Set up root SSH keys from scratch
-
Become root on the UI node and create a key if root does not have one yet (empty passphrase, so the unattended sync can use it):
sudo -i [ -f ~/.ssh/id_ed25519 ] || ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519
Install root@node-a's public key on each other node — this prompts once for that node's root password and appends the key to its
/root/.ssh/authorized_keys:ssh-copy-id root@node-b
Verify, then drop back to your normal user:
ssh -o BatchMode=yes root@node-b true && echo OK exit
If
ssh-copy-idis refused because the other node forbids root login, temporarily setPermitRootLogin yesin its/etc/ssh/sshd_config,systemctl restart ssh, copy the key, then tighten to key-only:PermitRootLogin prohibit-password
sudo dcm-cli sync
sudo dcm-cli restart allsync writes the local listen.conf, then rsyncs the config, the node list and the dcm-cli binary to every other node and regenerates their listen.conf (the one file never copied between nodes). restart all then restarts dnsmasq on every node so the freshly synced config takes effect.
| Path | Owner / mode | Notes |
|---|---|---|
/usr/local/sbin/dcm-cli |
root 755 |
CLI backend, identical on every node |
/etc/dcm/nodes |
root | node short-hostnames, one per line |
/etc/sudoers.d/dcm-cli |
root 440 |
lets www-data run dcm-cli as root |
/var/www/dcm |
www-data | web frontend |
/etc/dnsmasq.d |
root:www-data 2775 |
drop-in dir; UI creates/removes <directive>.conf here |
/etc/dnsmasq.d/*.conf |
www-data | per-directive drop-ins + upstream.conf, edited in the UI |
/etc/dnsmasq.d/hosts/{local,vms} |
www-data | host records, edited in the UI |
/etc/dnsmasq.d/hosts/block |
root | phone-home endpoints → 127.0.0.1, read-only in the UI |
/etc/dnsmasq.d/listen.conf |
root | per-node, generated, never synced |
dcm-cli sync sync config + binary to all other nodes
dcm-cli restart local|remote|all systemctl restart dnsmasq
dcm-cli status local|remote systemctl status dnsmasq
dcm-cli logs [N] last N log lines (default 200)
dcm-cli tail-f local|remote stream the log (used by the live view)
dcm-cli stats local|remote [period] log analytics (all|today|1h|24h|7d)
dcm-cli health live sync/restart state as key=value (used by the UI bell)
dcm-cli diff list what a sync would change on each remote node
dcm-cli restart-needed print 'needed' or 'ok' for this node (used by health)
GPL-3.0-or-later © 2026 [ernolf] Raphael Gradenwitz