dnsmasq
dnsmasq
¶
Per-container dnsmasq config generation, reload, and domain management.
dnsmasq runs inside the container's network namespace (via nsenter)
on a runtime-dependent listen address — 127.0.0.1:53 for ordinary
runtimes that share the netns loopback, a link-local address under
krun whose guest can't reach netns 127.0.0.1. A build with nftset
support populates the nft allow sets on every DNS resolution, which
follows IP rotation that static pre-start resolution cannot; a build
without it still serves the query log and the deny sinkholes.
This module is the single package-side owner of dnsmasq config format
and CLI args; the per-container start/stop dance is owned by the OCI
hook resource (resources/nft_hook.py). Both sides locate and
match the dnsmasq process through resources/_oci_state.
logger = logging.getLogger(__name__)
module-attribute
¶
locate(explicit, runner)
¶
The dnsmasq binary shield runs: explicit when set, else the host's own.
Returns an empty string when the host has none. An explicit path is the operator's word, so a path that is not an executable file is refused, never silently replaced by a PATH lookup.
Raises:
| Type | Description |
|---|---|
ShieldNeedsSetup
|
When explicit is not an executable file. |
Source code in src/terok_shield/dns/dnsmasq.py
reload(state_dir, upstream_dns, domains, *, deny_domains=(), override_domains=(), container, runner)
¶
Regenerate the dnsmasq config and restart dnsmasq so it takes effect.
dnsmasq does NOT re-read its main config file on SIGHUP (only hosts /
--addn-hosts / --hostsdir and its cache), so a config change —
the nftset= line for a newly allowed domain, or the local=
NXDOMAIN sinkhole for a denied one — only lands on a fresh start. So we
restart in-netns: regenerate the conf, stop the old process, and relaunch
it reading the new conf. No-op if dnsmasq was never started (PID file
absent — a tier without dnsmasq).
There is a sub-second window with no in-container DNS between stop and relaunch. Runtime domain allow/deny is operator-initiated and rare, so that is preferred over the previous SIGHUP, which loaded nothing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state_dir
|
Path
|
Per-container state directory. |
required |
upstream_dns
|
str
|
Upstream DNS forwarder address. |
required |
domains
|
list[str]
|
Updated domain names for nftset auto-population. |
required |
deny_domains
|
Sequence[str]
|
Denied domain names for DNS-plane NXDOMAIN sinkholes. |
()
|
override_domains
|
Sequence[str]
|
t10 override domains — sinkhole punch-throughs
(see |
()
|
container
|
str
|
Container name — used to enter its netns for the relaunch. |
required |
runner
|
CommandRunner
|
Command runner that performs the in-netns relaunch. |
required |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
On a stale/foreign PID file, or if dnsmasq does not come back after the restart — the container's DNS is broken and the task should be re-created. |
Source code in src/terok_shield/dns/dnsmasq.py
read_merged_domains(state_dir)
¶
Effective dnsmasq nftset domains: admitted (+) minus denied (-).
Composed from the tiered policy/ bundle (project/provider/live), so
runtime shield allow/deny of a domain takes effect on the next
dnsmasq reload. Returns a deduplicated, stable-order list.
Source code in src/terok_shield/dns/dnsmasq.py
read_denied_domains(state_dir)
¶
Denied (-) domains from the composed policy bundle.
Fed to generate_config as
DNS-plane sinkholes, so a denied name stops resolving at all instead of
resolving and then timing out against the packet filter.
Source code in src/terok_shield/dns/dnsmasq.py
read_override_domains(state_dir)
¶
Break-glass (t10) override domains from the composed policy bundle.
Fed to generate_config as
sinkhole punch-throughs: an override host is usually also denied by
t20, and without the punch-through it would NXDOMAIN before its
statically seeded t10 set ever saw a packet.
Source code in src/terok_shield/dns/dnsmasq.py
generate_config(upstream_dns, domains, pid_path, *, listen_address, log_path=None, deny_domains=(), override_domains=(), populate=True)
¶
Generate a complete dnsmasq configuration.
cache-size=0 is deliberate: dnsmasq only performs the --nftset
add while processing an upstream reply, so a cached answer would hand
the workload an IP without re-arming its (timeout-carrying) allow-set
element. With caching off, every query re-arms the element right before
the connection that needs it; the upstream forwarder sits one hop away
and caches on the host side.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
upstream_dns
|
str
|
Upstream DNS forwarder (pasta or slirp4netns address). |
required |
domains
|
list[str]
|
Domain names for |
required |
pid_path
|
Path
|
Path for the dnsmasq PID file. |
required |
listen_address
|
str
|
Address dnsmasq binds to inside the netns. See
|
required |
log_path
|
Path | None
|
If set, enable query logging to this file (for |
None
|
deny_domains
|
Sequence[str]
|
Denied domains, sinkholed in the DNS plane (NXDOMAIN) so they fail fast and observably instead of resolving and then timing out against the packet filter. |
()
|
override_domains
|
Sequence[str]
|
t10 break-glass override domains — treated as
allowed by the sinkhole generator (exact-name denies emit no
sinkhole, subdomain-of-denied-ancestor gets a punch-through)
without joining the |
()
|
populate
|
bool
|
Emit the |
True
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If upstream_dns or listen_address is not a valid IP address. |
Source code in src/terok_shield/dns/dnsmasq.py
deny_config_lines(allow_domains, deny_domains, upstream_dns)
¶
DNS-plane deny: NXDOMAIN sinkholes for denied domains, with punch-throughs.
Emits local=/dom/ (never forwarded, answered NXDOMAIN) for each
denied domain. dnsmasq matches domain directives by longest suffix, so
an allowed strict subdomain of a denied ancestor gets an explicit
server=/sub/upstream punch-through — mirroring the policy engine,
where the more specific allow entry survives the ancestor deny.
Two deliberate asymmetries with the packet filter:
- A deny at exactly an allowed domain's own name emits no sinkhole (a same-specificity directive conflict has no defined winner in dnsmasq); the IP tiers still govern actual connectivity.
- The sinkhole stops the name, not the address — an IP learned via a legitimately allowed co-hosted domain remains reachable. That gap is intrinsic to L3/L4 enforcement; the DNS plane just fails the common case fast and visibly.
Invalid entries are skipped with a warning, matching the nftset path.
Source code in src/terok_shield/dns/dnsmasq.py
nftset_entry(domain)
¶
Generate a dnsmasq nftset config line for a domain.
Maps A records to the IPv4 project-allow set and AAAA records to the IPv6 project-allow set (tier 40). dnsmasq automatically matches the domain and all its subdomains.
Example::
nftset=/github.com/4#inet#terok_shield#t40_project_allow_v4,6#inet#terok_shield#t40_project_allow_v6
Source code in src/terok_shield/dns/dnsmasq.py
has_nftset_support(runner, binary)
¶
Return True if the dnsmasq at binary supports --nftset.
Parses dnsmasq --version compile-time options for the nftset
feature flag. A build without it prints no-nftset.