Skip to content

resolver

resolver

DNS resolution with timestamp-based caching.

Resolves allowlist domains via dig (getent fallback) and caches the IPs so containers do not block on DNS at every start.

Two cache layers:

  • per-container file (cache_path): the view the nft ruleset reads. Scoped to one container, so a fresh container never reuses another's.
  • host cache (host_cache_dir): shared across containers, keyed by the allowlist's content hash. The first task resolves; the rest read.

Domains resolve concurrently with a per-lookup timeout, so a batch costs about one lookup and one dead domain cannot stall startup. A batch that does run says so on stderr, before and after: the launcher is waiting on it, and a silent wait reads as a hang.

Every tier but dnsmasq-live uses this module at launch. On that tier domains resolve on-demand at runtime via --nftset; this module then handles raw IPs only.

logger = logging.getLogger(__name__) module-attribute

RESOLVE_TIMEOUT = 2 module-attribute

Per-subprocess DNS budget in seconds.

Best-effort: an answer slower than this counts as no answer. The old 10s budget let one dead domain (times the getent retry) stall a start by ~20s.

MAX_RESOLVE_WORKERS = 16 module-attribute

Upper bound on concurrent resolver subprocesses per batch.

DnsResolver(*, runner, host_cache_dir=None)

Stateless DNS resolver — all persistence lives in the cache files.

Depends on a CommandRunner for lookup-tool (dig/drill) and getent subprocess calls and a host-level cache directory shared across containers.

Inject the command runner and the shared cache location.

Parameters:

Name Type Description Default
runner CommandRunner

Command runner used for all DNS subprocess calls.

required
host_cache_dir Path | None

Cross-container cache directory; None selects dns_cache_dir.

None
Source code in src/terok_shield/dns/resolver.py
def __init__(self, *, runner: CommandRunner, host_cache_dir: Path | None = None) -> None:
    """Inject the command runner and the shared cache location.

    Args:
        runner: Command runner used for all DNS subprocess calls.
        host_cache_dir: Cross-container cache directory; ``None`` selects
            [`dns_cache_dir`][terok_shield.paths.dns_cache_dir].
    """
    self._runner = runner
    self._host_cache_dir = host_cache_dir or dns_cache_dir()

resolve_and_cache(entries, cache_path, *, force=False, source_mtime=0.0)

Resolve profile entries and cache the result.

Reads the per-container file first, then the shared host cache (materializing a hit into the per-container file), and only resolves when both miss.

Parameters:

Name Type Description Default
entries list[str]

Domain names and/or raw IPs from composed profiles.

required
cache_path Path

Per-container file the nft ruleset reads.

required
force bool

Re-resolve even when a cache is younger than the one-hour freshness window.

False
source_mtime float

mtime of the authored policy; a per-container cache older than it is re-resolved even within the freshness window, so an edited allowlist takes effect on the next task start. The host cache ignores this — its content-hash key already makes it edit-aware.

0.0

Returns:

Type Description
list[str]

Resolved IPv4/IPv6 addresses combined with raw IPs/CIDRs.

Source code in src/terok_shield/dns/resolver.py
def resolve_and_cache(
    self,
    entries: list[str],
    cache_path: Path,
    *,
    force: bool = False,
    source_mtime: float = 0.0,
) -> list[str]:
    """Resolve profile entries and cache the result.

    Reads the per-container file first, then the shared host cache
    (materializing a hit into the per-container file), and only resolves
    when both miss.

    Args:
        entries: Domain names and/or raw IPs from composed profiles.
        cache_path: Per-container file the nft ruleset reads.
        force: Re-resolve even when a cache is younger than the one-hour
            freshness window.
        source_mtime: mtime of the authored policy; a per-container cache
            older than it is re-resolved even within the freshness window,
            so an edited allowlist takes effect on the next task start. The
            host cache ignores this — its content-hash key already makes it
            edit-aware.

    Returns:
        Resolved IPv4/IPv6 addresses combined with raw IPs/CIDRs.
    """
    max_age = 0 if force else _CACHE_MAX_AGE
    if self._cache_fresh(cache_path, max_age, source_mtime):
        return self._read_cache(cache_path)

    host_path = self._host_cache_path(entries)
    if self._cache_fresh(host_path, max_age):
        ips = self._read_cache(host_path)
        self._write_cache(cache_path, ips)
        return ips

    domains, raw_ips = self._split_entries(entries)
    resolved = self._resolve_announced(domains)
    all_ips = raw_ips + resolved

    self._write_cache(cache_path, all_ips)
    # An all-domains-failed resolve (DNS outage, broken resolver) is fine
    # for one container but must not poison every task on the host for
    # max_age: share only when at least one domain resolved (or there were
    # none to resolve).
    if resolved or not domains:
        self._write_cache(host_path, all_ips)
    return all_ips

resolve_domains(domains)

Resolve domain names to IPs (A + AAAA), best-effort and concurrent.

Probes for a lookup tool once, then resolves every domain on a small thread pool — a batch costs about its slowest single lookup. Unresolvable domains are skipped with a warning; results are deduplicated in first-seen (input) order.

Source code in src/terok_shield/dns/resolver.py
def resolve_domains(self, domains: list[str]) -> list[str]:
    """Resolve domain names to IPs (A + AAAA), best-effort and concurrent.

    Probes for a lookup tool once, then resolves every domain on a small thread
    pool — a batch costs about its slowest single lookup. Unresolvable
    domains are skipped with a warning; results are deduplicated in
    first-seen (input) order.
    """
    if not domains:
        return []
    if self._runner.has("dig") or self._runner.has("drill"):
        resolve = self._resolve_via_lookup
    else:
        logger.warning("neither dig nor drill found — using getent for DNS resolution")
        resolve = self._resolve_via_getent
    with ThreadPoolExecutor(max_workers=min(len(domains), MAX_RESOLVE_WORKERS)) as pool:
        per_domain = pool.map(resolve, domains)
    return list(dict.fromkeys(ip for ips in per_domain for ip in ips))