Outcall
SpecificationsS007 · DNS Filter

S007 · DNS Filter

Specification module 007-dns-filter

S007: DNS Filter

FieldValue
SpecS007
FeatureDNS Filter
Date2026-04-21
StatusDraft
Author@marktopper

Overview

The DNS filter is an application-layer name resolution gateway that prevents agent containers from resolving hostnames that policy does not permit. It runs as a Tokio task inside outcalld, listening for DNS queries on the bridge IP (e.g., 10.200.0.1:53). Agent containers have their /etc/resolv.conf configured to use this address as their sole nameserver.

When a query arrives, the filter extracts the hostname and query type, builds a DnsContext (S003), and evaluates it against the rule engine. Allowed queries are forwarded to an upstream resolver and the response is returned to the agent. Blocked queries receive an NXDOMAIN response -- the agent never learns the real IP address.

This is a complementary layer to the nftables bridge rules (S001). nftables blocks at L3/L4 by dropping packets to disallowed IPs and ports. The DNS filter blocks at the application layer by preventing name resolution entirely. Together they form defense in depth: even if an agent hardcodes an IP address, nftables catches it; even if nftables rules are broad, DNS filtering narrows what the agent can discover.

How it works

  1. outcalld starts a DNS server on the bridge gateway IP, port 53 (UDP and TCP)
  2. Agent containers have /etc/resolv.conf pointing at this DNS server
  3. Agent sends a DNS query (e.g., A api.github.com)
  4. outcalld extracts dns.query and dns.record_type from the query
  5. The rule engine (S003) evaluates the request
  6. ALLOW -- forward the query to the configured upstream resolver, return the response
    • If the matched rule sets egress.mode: direct_ip, outcalld also inserts scoped nftables allow rules for resolved IPv4 targets and configured ports.
    • If the matched rule sets egress.mode: proxy, no L3/L4 holes are opened; access is expected through the L7 proxy path.
  7. BLOCK -- return NXDOMAIN with SOA in the authority section

Upstream resolution

outcalld supports one or more upstream DNS resolvers. The default upstream is the host's /etc/resolv.conf nameservers, but operators can override this via --dns-upstream. When multiple upstreams are configured, outcalld tries them in order, falling back to the next on timeout or error.

Caching

The DNS filter implements a response cache for allowed queries. Cached entries respect the minimum TTL from the upstream response, capped at a configurable maximum. The cache is keyed on (hostname, record_type). Cache entries are invalidated on rule reload (S003) to ensure policy changes take effect immediately.

User Scenarios

S007-US-001 [P1] As a host operator, I want all DNS queries from agent containers to pass through outcalld so that name resolution is governed by policy.

S007-US-002 [P1] As a host operator, I want blocked hostnames to return NXDOMAIN so that agents cannot discover IP addresses for disallowed services.

S007-US-003 [P1] As a host operator, I want allowed DNS queries forwarded to upstream resolvers so that agents can reach permitted services.

S007-US-004 [P2] As a host operator, I want to configure upstream DNS resolvers so that I control where queries are forwarded.

S007-US-005 [P2] As a host operator, I want DNS responses cached so that repeated lookups for allowed hostnames are fast and reduce upstream load.

S007-US-006 [P1] As a host operator, I want DNS query decisions logged so that I can audit what agents attempted to resolve.

S007-US-007 [P2] As a host operator, I want to check DNS filter status so that I can verify it is running and see its configuration.

Requirements Summary

IDTypePriorityTitleStatus
S007-FR-001FunctionalP1DNS server on bridge IPDraft
S007-FR-002FunctionalP1UDP and TCP listenersDraft
S007-FR-003FunctionalP1Tokio task lifecycleDraft
S007-FR-004FunctionalP1Bridge-up prerequisiteDraft
S007-FR-005FunctionalP1Graceful shutdownDraft
S007-FR-006FunctionalP1Container resolv.conf injectionDraft
S007-FR-007FunctionalP1Query interception and parsingDraft
S007-FR-008FunctionalP1Rule engine evaluationDraft
S007-FR-009FunctionalP1CEL context variablesDraft
S007-FR-010FunctionalP1NXDOMAIN for blocked queriesDraft
S007-FR-011FunctionalP1Upstream forwarding for allowed queriesDraft
S007-FR-012FunctionalP1Upstream resolver configurationDraft
S007-FR-013FunctionalP1Default upstream from host resolv.confDraft
S007-FR-014FunctionalP2Multiple upstream resolversDraft
S007-FR-015FunctionalP2Upstream failoverDraft
S007-FR-016FunctionalP2Response cachingDraft
S007-FR-017FunctionalP2Cache TTL handlingDraft
S007-FR-018FunctionalP2Cache invalidation on rule reloadDraft
S007-FR-019FunctionalP2Cache size limitDraft
S007-FR-020FunctionalP1DNS over TCP fallbackDraft
S007-FR-021FunctionalP2DNSSEC pass-throughDraft
S007-FR-022FunctionalP1Query loggingDraft
S007-FR-023FunctionalP1Performance latency budgetDraft
S007-FR-024FunctionalP1hickory-dns implementationDraft
S007-FR-025FunctionalP1Structured loggingDraft
S007-FR-026FunctionalP1Typed errorsDraft
S007-FR-027FunctionalP1Host API endpointsDraft
S007-FR-028FunctionalP1CLI subcommandsDraft
S007-FR-029FunctionalP2Configurable listen portDraft
S007-FR-030FunctionalP2Cache statistics endpointDraft
S007-FR-031FunctionalP3DNS query metricsDraft
S007-FR-032FunctionalP1Fail-closed on rule engine errorDraft
S007-FR-033FunctionalP2mDNS (.local) blockedDraft
S007-FR-034FunctionalP2DNS rebinding protectionDraft
S007-AS-001AcceptanceP1Allowed query resolvedDraft
S007-AS-002AcceptanceP1Blocked query returns NXDOMAINDraft
S007-AS-003AcceptanceP1No matching rule defaults to blockDraft
S007-AS-004AcceptanceP1DNS server starts with bridgeDraft
S007-AS-005AcceptanceP1Graceful shutdown drains queriesDraft
S007-AS-006AcceptanceP1Container resolv.conf points at outcalldDraft
S007-AS-007AcceptanceP2Upstream failover on timeoutDraft
S007-AS-008AcceptanceP2Cached response servedDraft
S007-AS-009AcceptanceP2Cache invalidated on rule reloadDraft
S007-AS-010AcceptanceP1TCP fallback for large responsesDraft
S007-AS-011AcceptanceP1CLI DNS filter statusDraft
S007-AS-012AcceptanceP1CLI DNS filter test queryDraft
S007-AS-013AcceptanceP1Daemon not running (CLI)Draft
S007-AS-014AcceptanceP2DNSSEC records passed throughDraft
S007-AS-015AcceptanceP1Multiple query types handledDraft
S007-IF-001InterfaceP1GET /api/v1/dnsDraft
S007-IF-002InterfaceP2GET /api/v1/dns/cacheDraft
S007-IF-003InterfaceP2POST /api/v1/dns/cache/flushDraft
S007-IF-004InterfaceP1CLI commandsDraft
S007-IF-005InterfaceP1CLI output formatDraft
S007-IF-006InterfaceP1DNS wire protocol (RFC 1035)Draft
S007-IF-007InterfaceP1resolv.conf formatDraft
S007-EC-001Edge CaseP1Bridge not upDraft
S007-EC-002Edge CaseP1All upstreams unreachableDraft
S007-EC-003Edge CaseP1Upstream timeoutDraft
S007-EC-004Edge CaseP2Malformed DNS queryDraft
S007-EC-005Edge CaseP1Rule engine unavailableDraft
S007-EC-006Edge CaseP2Query for outcalld's own addressDraft
S007-EC-007Edge CaseP2Cache fullDraft
S007-EC-008Edge CaseP1Port 53 already in useDraft
S007-EC-009Edge CaseP2Extremely long hostnameDraft
S007-EC-010Edge CaseP2Rapid duplicate queriesDraft
S007-EC-011Edge CaseP2EDNS0 optionsDraft
S007-EC-012Edge CaseP1Daemon shutdown mid-queryDraft
S007-EC-013Edge CaseP2Upstream returns SERVFAILDraft
S007-EC-014Edge CaseP2PTR / reverse DNS queriesDraft
S007-EC-015Edge CaseP3DNS amplification preventionDraft
S007-SC-001SuccessP1DNS server binds on bridge IPDraft
S007-SC-002SuccessP1Allowed query returns upstream answerDraft
S007-SC-003SuccessP1Blocked query returns NXDOMAINDraft
S007-SC-004SuccessP1Default block on no matching ruleDraft
S007-SC-005SuccessP1Latency under budgetDraft
S007-SC-006SuccessP1Container resolv.conf verifiedDraft
S007-SC-007SuccessP2Cache hit avoids upstream callDraft
S007-SC-008SuccessP1Audit log entries for all queriesDraft
S007-SC-009SuccessP1Clean shutdown (no leaked sockets)Draft
S007-SC-010SuccessP1Existing bridge/network/rule commands unaffectedDraft

Out of Scope

  • DNS-over-HTTPS (DoH) or DNS-over-TLS (DoT) -- agents query plain DNS on port 53; encrypted DNS from agents would bypass the filter and is blocked by nftables
  • Recursive resolution -- outcalld forwards to upstream resolvers, it does not perform iterative resolution
  • Zone hosting / authoritative DNS -- outcalld is not a DNS server for any zone; it is a filtering forwarder
  • DNSSEC validation -- outcalld passes DNSSEC records through but does not validate signatures itself
  • IPv6 AAAA filtering as separate policy -- AAAA queries are evaluated by the same rule engine as A queries
  • Per-container DNS policy -- all containers on the bridge share the same rule set
  • Custom DNS records / split-horizon -- outcalld does not synthesize records beyond NXDOMAIN
  • GUI for DNS management -- CLI and API only

Cross-Spec Dependencies

  • Depends on: S001 (bridge must be up; DNS server binds to bridge gateway IP -- S007-FR-004)
  • Depends on: S002 (network provides the bridge gateway IP that the DNS server listens on; container resolv.conf points here)
  • Depends on: S003 (rule engine evaluates dns.query and dns.record_type context variables -- S007-FR-008)
  • Complements: S006 (HTTP proxy handles L7 HTTP inspection; DNS filter prevents resolution of blocked hostnames before HTTP is even attempted)

Security Guidance

  • Recommended (best approach): use egress.mode: proxy for internet hostnames, especially CDN/shared-IP endpoints. This keeps policy at hostname/SNI granularity and avoids broad IP-level access.
  • Use with caution: egress.mode: direct_ip opens L3/L4 rules to resolved IPs, which can allow unrelated tenants on shared IP infrastructure.

Shared Types (outcall-api)

Constants

pub const DNS_DEFAULT_PORT: u16 = 53;
pub const DNS_UPSTREAM_TIMEOUT_MS: u64 = 5000;
pub const DNS_CACHE_MAX_ENTRIES: usize = 10_000;
pub const DNS_CACHE_MAX_TTL_SECS: u32 = 300;
pub const DNS_QUERY_LATENCY_BUDGET_MS: u64 = 10;
pub const DNS_TCP_TIMEOUT_MS: u64 = 10_000;

Types

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnsFilterStatus {
    pub running: bool,
    pub listen_address: String,
    pub listen_port: u16,
    pub upstreams: Vec<String>,
    pub cache_entries: usize,
    pub queries_total: u64,
    pub queries_allowed: u64,
    pub queries_blocked: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnsCacheStats {
    pub entries: usize,
    pub max_entries: usize,
    pub hits: u64,
    pub misses: u64,
    pub evictions: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnsCacheEntry {
    pub hostname: String,
    pub record_type: String,
    pub ttl_remaining_secs: u32,
    pub cached_at: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnsCacheFlushResult {
    pub entries_flushed: usize,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnsQueryLog {
    pub timestamp: String,
    pub source_ip: String,
    pub hostname: String,
    pub record_type: String,
    pub decision: String,
    pub matched_rule: Option<String>,
    pub upstream_ms: Option<u64>,
    pub cached: bool,
}

On this page