Foghorn is a versatile DNS server designed for flexibility and performance. Built on a robust caching forwarder or recursive resolver foundation, it seamlessly integrates with YAML-based configuration and modular plugins to transform into a powerful ad-blocker, local hosts service, kid-safe filter. Fine-tune its capabilities with a Redis backend, InfluxDB logging, and customizable function-cache sizes tailored to your specific needs.
With built-in admin and API server support, Foghorn empowers you to monitor and manage its operations efficiently. Plugins extend its functionality by providing their own status pages, seamlessly integrated into the admin dashboard. Newer releases add DNSSEC signing helpers, zone transfers (AXFR/IXFR), RFC 8914 Extended DNS Errors (EDE), and SSH host key utilities so you can treat DNS as a first-class security and operations tool.
- Foghorn is a DNS server that lets you do almost anything.
- Nearly everything is configurable, tunable, and observable via the admin Web UI, even down to Python function cache settings.
- Plugins and the resolver do most of the heavy lifting.
- Out of the box, with no plugins enabled, it behaves like a standard DNS server with:
- UDP / TCP
- DoT (DNS over TLS)
- DoH (DNS over HTTPS)
- DNSSEC support for secure DNS
- DNS
- Enabling TLS is straightforward. The
Makefileincludes targets to generate a CA and sign keys.
- Queries flow through a pipeline of plugins. A plugin can appear multiple times with different configurations.
- Plugins execute in priority order. The first plugin that produces a final answer immediately short-circuits the pipeline.
- If no pre-resolve plugin responds, the configured resolver, either forwarder or recursive, runs.
- The result then flows through a post-resolve pipeline, if configured, sent to the client, then added to the logging queue.
Key plugins include:
- ACL: Access control
- EtcHosts: Serve a hosts-file-style set of A/AAAA records via DNS. Ideal for small, simple setups.
- FileDownloader + Filter: Download blocklists and filter queries, similar to Pi-hole. Can return:
- An IP
REFUSEDSERVFAIL- Or silently drop the connection
- ZoneRecords: Load BIND9 zone files and/or define arbitrary records without creating a full zone. Supports combining multiple files and enabling DNSSEC.
- UpstreamRouter: Route queries to different upstreams based on name, for example forwarding
.corpto a VPN resolver. - Additional plugins for:
- Rate limiting - Static or Dynamic
- Docker host discovery - Add containers to DNS
- Zeroconf / mDNS / Bonjour - Forward mDNS over DNS, also the admin UI offers observability.
- Simulating unreliable upstreams for development, including on-the-wire fuzzing. Seedable for testing purposes.
Some plugins ship with configuration profiles (preset bundles) stored as YAML in
src/foghorn/plugins/resolve/*_profiles.yaml (for example rate_limit_profiles.yaml).
"Targets" let you limit the scope of the plugin to given client IPs, domain names, query types, listener type, and more.
Creating new plugins is simple. You can implement custom DNS logic without writing an entire DNS server. For example, the “finger-over-dns” example plugin can be built in under an hour.
- Fine-grained controls let you tune behavior precisely.
- Resize and configure the Python function cache to match your workload.
- Variables allow multiple servers to share the same configuration with small differences, such as listen address.
- Environment variables make it easy to adjust behavior in CI/CD environments.
- Flexible caching backends:
- In-memory
- SQL databases
- Valkey or Redis
- MongoDB
- Logging integrates with your existing systems, can log to multiple targets.:
- File-based logging
- SQL databases
- InfluxDB
- MQTT
- 0. Thanks
- 1. Quick Start
- 2. Configuration layout overview
- 3. Listeners and upstreams by example
- 4. Plugin cookbook
- 4.1 Access control (acl)
- 4.2 Docker containers (docker)
- 4.3 Hosts files (hosts)
- 4.4 List downloader (lists)
- 4.5 Domain filter / adblock (filter)
- 4.6 Flaky upstream simulator (flaky)
- 4.7 mDNS / Bonjour bridge (mdns)
- 4.8 Rate limiting (rate)
- 4.9 Per-domain upstream routing (router)
- 4.10 Inline and file-based records (zone)
- 5. Example Plugins
- 6. Variables
- 7. Sample configurations
- Developer notes and contribution guide
- Makefile targets and build helpers
- Pi-hole replacement example configuration
- Query-log hardening and sampling
- OpenSSL make targets (certificate helpers)
- DNS RFC compliance, EDNS/EDE, and AXFR/IXFR notes
- SSH host keys, SSHFP records, and DNSSEC integration
With special thanks to Fiona Weatherwax for their contributions and inspiration, to the dnslib team for the low level / on wire primitives, and to dnspython for the DNSSEC implementation. Additional shout outs to the whole python community, and the teams of fastapi, pydantic, black, ruff, pytest, and every other giant on whose shoulders I stand.
Also thanks to my junior developers, AI from both local and remote models, some via warp.dev, who keeps my docstrings and unit tests up to date, creates good commit messages, and other janitorial tasks. Also a lot of help with the all the HTML/JS. Because I'm just not good at it.
Foghorn can be installed a few different ways, depending on how you prefer to run services:
• From PyPI (recommended for most users)
Install the latest released version into your Python environment:
pip install foghorn
This gives you the foghorn CLI and library directly on your host system.Foghorn can run as a small UDP/TCP DNS server with lightweight plugins (for example
hosts / EtcHosts) without enabling DNSSEC validation, DoH, or the admin HTTP UI.
However, some features depend on optional third-party packages. If you remove those packages from your image/environment:
- DNSSEC local validation (
server.dnssec.mode: validatewithserver.dnssec.validation: local|local_extended) requires dnspython and cryptography. If enabled but missing, Foghorn will exit with an error. - Admin HTTP UI (
server.http) and DoH listener (server.listen.doh.enabled: true) require fastapi (and the DoH path uses uvicorn). If enabled but missing, Foghorn will exit with an error. - Plugins may require extra dependencies. During startup, plugins that fail to
import due to missing dependencies are skipped by default.
To make a plugin import failure fatal, set
abort_on_failure: trueinside that plugin'sconfigblock.
Example (require ssh_keys plugin deps at startup):
plugins:
- type: ssh_keys
config:
abort_on_failure: trueDeveloper strict mode: to make plugin discovery itself strict (raise on ImportError
while scanning plugin modules), set FOGHORN_STRICT_PLUGIN_DISCOVERY=1.
• From source (GitHub) If you want to track development, hack on plugins, or run a specific commit/branch, clone the repository and install it in editable mode:
git clone https://github.com/zallison/foghorn.git
cd foghorn
pip install -e .This keeps your local checkout and installed code in sync as you make changes.
• Prebuilt Docker images (amd64 and armhf) If you prefer to run Foghorn in a container, prebuilt images for both amd64 and armhf are available on Docker Hub at https://hub.docker.com/r/zallison/foghorn. Pull the image for your architecture and run it with your configuration mounted as /foghorn/config.yaml, along with any port mappings you need for DNS, DoT/DoH, and the admin web UI.
docker run --name foghorn -v ./config/:/foghorn/config/ -p 53:53/udp -p 53:53/tcp -p 5380:5380 --privileged zallison/foghorn:latestThis example listens on all interfaces for UDP/TCP DNS and forwards to a public DoT resolver. It also enables a simple in-memory cache.
# config/config.yaml
vars:
ENV: prod
server:
listen:
dns:
udp:
enabled: true
host: 0.0.0.0
port: 53
tcp:
enabled: true
host: 0.0.0.0
port: 53
cache:
module: memory # memory | sqlite | redis | memcached | mysql | mariadb | postgres | mongodb | none
upstreams:
strategy: failover # failover | round_robin | random
max_concurrent: 1 # 1 | 2 | 4 ...
endpoints:
- host: 1.1.1.1
port: 853
transport: dot # udp | tcp | dot
tls:
server_name: cloudflare-dns.com
plugins: []You can start Foghorn with:
foghorn --config config/config.yamlFrom here you layer in plugins to get adblocking, hosts files, per-user allowlists, and more.
For local development there is a Makefile with a few convenience targets:
make run– create a venv if needed and start Foghorn withconfig/config.yaml.make env/make env-dev– create the virtualenv in./venvand install dependencies (with dev extras forenv-dev).make build– prepare the development environment (keeps the JSON schema up to date).make schema– regenerateassets/config-schema.jsonfrom the Python code.make ui-bundle– build a single JavaScript admin UI bundle with embedded HTML/CSS/JS atdist/foghorn-admin-ui.cdn.js.make ui-bundle-runtime– build a runtime-only JavaScript admin UI bundle atdist/foghorn-admin-ui.cdn.js.make test– run the test suite with coverage.make dnssec-sign-zone– sign a BIND-style zone file with DNSSEC using the bundled helper script, writing a signed zone that can be served by the ZoneRecords plugin.make clean– remove the venv, build artefacts, and temporary files.make docker,make docker-build,make docker-run,make docker-logs,make docker-clean,make docker-ship– build and run Docker images/containers.make package-build/make package-publish/make package-publish-dev– build and (optionally) publish Python packages.make ssl-cert– generate a self-signed TLS key and certificate under./varusingopenssl req -x509.
If you have a diagram.dot and want to render it to diagram.png:
dot -Tpng diagram.dot -o diagram.png- OpenSSL make targets (made easy)
- DNS RFC compliance and protocol notes (including EDE and AXFR)
- SSH host keys, SSHFP records, and DNSSEC integration
At the top level the schema defines these keys:
vars: key/value variables for interpolation inside the rest of the file.server: listener, DNSSEC, resolver, cache, and admin HTTP settings.upstreams: how outbound DNS queries are sent.logging: global logging level and outputs.stats: runtime statistics and query-log persistence.plugins: the ordered list of plugins that wrap each query.
Conceptually, a request flows like this:
client ---> UDP/TCP/DoH listener
---> DNS cache (memory, redis, etc) (optional)
---> plugins (pre_resolve chain)
---> [maybe upstream DNS calls or recursive resolving]
---> plugins (post_resolve chain)
---> response or deny
Note: when a pre_resolve plugin returns an override decision the generated
response is sent immediately and the post_resolve chain is skipped entirely; a
post_resolve override short-circuits any later post_resolve plugins for that
query.
Key parts of server:
server.listendns.udp/dns.tcp: classic DNS listeners.dns.dot: DNS-over-TLS listener.doh: DNS-over-HTTPS listener.
server.cachemodule: which cache plugin to use.config: plugin-specific cache settings.- For the in-memory DNS cache (
module: memory), the underlyingFoghornTTLCachesupports optional capacity and eviction controls when instantiated from Python code:maxsize(positive integer, unbounded whenNoneor non-positive) andeviction_policy(one ofnone,lru,lfu,fifo,random, oralmost_expired).
- For the in-memory DNS cache (
modify/decorated_overrides/func_caches: optional overrides for internal helper caches (functions decorated withregistered_cached,registered_lru_cache,registered_foghorn_ttl, orregistered_sqlite_ttl). These let you tune TTL and maxsize for specific helpers without code changes.- Valid
backendvalues forfunc_cachesentries are:ttlcache(cachetools.TTLCache)lfu_cache(cachetools.LFUCache)rr_cache(cachetools.RRCache)fifo_cache(cachetools.FIFOCache)lru_cache(cachetools.LRUCache)foghorn_ttl(FoghornTTLCache-based helpers via registered_foghorn_ttl)sqlite_ttl(SQLite3TTLCache-based helpers via registered_sqlite_ttl)
- Valid
Example: increase the TTL and maxsize for a DNSSEC helper cached via
cachetools.TTLCache:
server:
cache:
module: memory
config:
min_cache_ttl: 60
func_caches:
- module: foghorn.dnssec.dnssec_validate
name: _find_zone_apex_cached
backend: ttlcache # ttlcache | lru_cache | foghorn_ttl | sqlite_ttl | lfu_cache | rr_cache
ttl: 300 # seconds; applies to TTL-style backends
maxsize: 2048 # logical max size for this helper cache
reset_on_ttl_change: true # clear TTLCache once when ttl changesNote: the backend field here selects the desired cache backend type for
that helper (matching the "Backend" column in the admin "Decorated caches"
table). For cachetools-backed helpers wrapped with registered_cached, Foghorn
rebuilds the function's cache at startup so you can switch between
ttlcache, lfu_cache, rr_cache, fifo_cache, and lru_cache purely from
configuration.
server.dnssec- Mode and DNSSEC validation knobs (e.g., UDP payload size).
On Linux systems that use glibc, applications (including ssh when using SSHFP records) only see the DNSSEC AD bit when the resolver is explicitly configured to trust it. If you run Foghorn (or another validating resolver that honors the upstream AD bit) and want glibc clients to accept that AD as trustworthy, point /etc/resolv.conf at Foghorn and add trust-ad to the options line:
nameserver 127.0.0.1
options edns0 trust-ad
Without trust-ad, glibc clears the AD flag before handing answers to applications, so tools like OpenSSH will ignore SSHFP records even when Foghorn has validated them.
server.enable_ede- Optional toggle for RFC 8914 Extended DNS Errors; when true and the client advertises EDNS(0), Foghorn can attach EDE options to certain policy or upstream-failure responses and surface per-code stats in the admin UI.
server.resolver- Timeouts, recursion depth, and resolver mode:
forward(default): forward to configuredupstreams.recursive: walk from root servers.master/none: authoritative-only (no forwarding; cache miss -> REFUSED).
- Timeouts, recursion depth, and resolver mode:
server.http- Admin web UI listener configuration.
upstreams describes how Foghorn talks to other DNS servers:
strategy:failover(try in order),round_robin, orrandom.max_concurrent: maximum simultaneous outstanding upstream queries.endpoints: list ofupstream_hostdefinitions.
Notes:
- If an upstream returns
SERVFAIL, malformed DNS bytes, or a mismatched response (TXID/question), Foghorn treats that upstream as failed for that query and continues failover. - Skip/failover events are logged at DEBUG, but de-duplicated per upstream until that upstream succeeds again (to avoid log spam).
- To surface these events, set
logging.python.level: debug.
An upstream_host entry:
upstreams:
endpoints:
- host: 9.9.9.9
port: 53 # 853 for DoT
transport: udp # udp | tcp | dot
tls:
server_name: dns.quad9.net
verify: true # true | falseAn upstream_host entry: using DNS-over-HTTP(s)
upstreams:
endpoints:
- transport: doh
url: https://dns.example.com/dns-query
method: POST # POST | GET
tls:
verify: true # true | false
ca_file: /etc/ssl/certs/ca-certificates.crtlogging controls both the process-wide Python logger and the
statistics/query-log backends.
Python logging (global defaults):
logging:
python:
level: info # debug | info | warn | error | critical
stderr: true # true | false
file: ./var/foghorn.log
syslog: false # false | true | {address, facility, tag}Plugins can also override logging per-instance via their own logging block on
the plugin entry, using the same shape as logging.python.
logging.backends describes where persistent stats/query-log data is written.
Each entry maps to a statistics backend such as SQLite or MQTT logging:
logging:
async: true # default async behaviour for stats backends
query_log_only: false # false | true
backends:
- id: local-log
backend: sqlite
config:
db_path: ./config/var/stats.db
batch_writes: true
- id: backup-mqtt
backend: mqtt_logging
config:
host: mqtt.internal
port: 1883
topic: foghorn/query_logThe stats section controls runtime statistics behaviour and selects which
logging backend to read from. When logging.async is true, writes to
stats/query-log backends are performed by a background worker so request
handling stays fast; setting it to false forces synchronous writes.
Important fields include:
enabled: master on/off switch for statistics.source_backend: whichlogging.backends[*].id(or backend alias) to treat as the primary read backend.
Example:
logging:
async: true
query_log_only: false # false | true
backends:
- id: local-log
backend: sqlite
config:
db_path: ./config/var/stats.db
batch_writes: true
stats:
enabled: true
source_backend: local-log
interval_seconds: 300
ignore:
include_in_stats: true # Just don't display them
ignore_single_host: false
top_domains_mode: suffix # exact | suffix
top_domains:
- example.comIf query logging is enabled on an exposed resolver, set explicit limits so flood traffic cannot grow persistence usage without bounds.
Recommended controls:
logging.query_log_retentionglobal defaults:max_records: cap row count.days: cap age.max_bytes: cap estimated backend storage size.prune_interval_seconds: avoid pruning on every insert.prune_every_n_inserts: run prune on an insert cadence.
logging.max_logging_queue: keep async queue bounded (default is bounded; do not set<= 0unless you explicitly want unbounded memory growth).logging.query_log_only: when true, skip mirroring aggregate counters to the persistence backend and keep only raw query-log rows.logging.query_log_sampling.enabled: when false, suppress persistent query-log writes from the stats collector.logging.query_log_sampling.sample_rate: keep only a fraction of query-log rows (for example0.1for ~10%).logging.query_log_sampling.rate: compatibility alias forsample_rate.logging.query_log_sample_rate: legacy compatibility alias.logging.query_log_dedupe.window_seconds: suppress repeated identical query-log rows inside a short window.- Full hardening profile:
example_configs/logging/query_log_hardening.yaml. - Detailed guide:
docs/query-log-hardening.md.
Rate-limit integration:
rateplugindeny_log_first_nlogs only the first N denies for each active blocked episode.- Remaining deny events set
PluginDecision.suppress_query_logand are skipped in persistent query logs until the episode cools down.
Example hardening profile:
logging:
async: true
max_logging_queue: 4096
query_log_only: false
query_log_retention:
max_records: 500000
days: 7
max_bytes: 2147483648
prune_interval_seconds: 30
prune_every_n_inserts: 200
query_log_sampling:
sample_rate: 0.25
query_log_dedupe:
window_seconds: 2
max_entries: 50000
backends:
- id: local-log
backend: sqlite
config:
db_path: ./config/var/stats.db
# Per-backend overrides (optional):
# retention_max_records: 250000
# retention_days: 3
# retention_max_bytes: 1073741824
# retention_prune_interval_seconds: 15
# retention_prune_every_n_inserts: 100Backend-specific optional maintenance controls:
- SQLite:
retention_vacuum_on_prune,retention_vacuum_interval_seconds,sqlite_auto_vacuum(none,full,incremental). - MongoDB:
retention_native_ttl(enables a TTL index whenretention_daysis set). - MySQL/MariaDB:
retention_optimize_on_prune,retention_optimize_interval_seconds. - PostgreSQL:
retention_vacuum_on_prune,retention_vacuum_interval_seconds.
In the plugins list each entry is a PluginInstance:
plugins:
- type: filter
id: main-filter
enabled: true
setup:
abort_on_failure: true
hooks:
pre_resolve:
enabled: true
priority: 50
logging:
level: info
config:
# plugin-specific config hereYou normally care about:
type: short alias for the plugin.id: optional stable identifier for this plugin instance (surfaced in stats, logs, and admin UI).enabled: whether it runs.hooks: per-hook enable/priority overrides (optional).setup: one-time setup behaviour;abort_on_failurecontrols whether a failing setup() aborts startup.logging: per-plugin logging overrides (level, file, stderr, syslog) using the same shape as the globalloggingblock.config: the actual configuration for that plugin.
Common plugin‑wide config options:
Targeting (preferred shape uses a nested targets block):
config.targets(object or legacy list/string)- Preferred object keys:
ips: list/string of CIDR/IPs to target.ignore_ips: list/string of CIDR/IPs to exclude.listeners: list/string of listeners (udp,tcp,dot,doh) or aliases:secure→dot+dohunsecure/insecure→udp+tcpany/*/null→ no restriction
domains: list/string of domain names to match.domains_mode:exactorsuffix(defaults tosuffixwhendomainsis set).qtypes: list/string of qtype names or'*'for all types (e.g.['A', 'AAAA']).opcodes: list/string of DNS opcodes (e.g.['QUERY']).rcodes: list/string of response codes for post‑resolve plugins (e.g.['NOERROR', 'NXDOMAIN']).
- Legacy form: if
targetsis a list/string, it is treated astargets.ips.
- Preferred object keys:
Logging
- Per-plugin logging:
- The
loggingstanza on the plugin instance lets you bump log level or direct output differently from the global logger.
- The
Example with all common knobs:
plugins:
- type: some-plugin
id: example
enabled: true
logging:
level: debug
config:
targets:
ips:
- 192.168.0.0/16
- 10.10.10.0/24
ignore_ips: [ "192.168.0.10" ]
listeners: [ dot, doh ]
domains: [ corp.example ]
domains_mode: suffix # exact | suffix
qtypes: [ 'A', 'AAAA' ]
opcodes: [ 'QUERY' ]
rcodes: [ 'NOERROR', 'NXDOMAIN' ]A typical server.listen.dns configuration:
server:
listen:
dns:
udp:
enabled: true
host: 0.0.0.0
port: 53
tcp:
enabled: true
host: 0.0.0.0
port: 53Turn TCP off if you never want to accept TCP DNS:
tcp:
enabled: false # true | falseTo talk to an upstream DoT resolver:
upstreams:
strategy: round_robin # failover | round_robin | random
endpoints:
- host: 1.1.1.1
port: 853
transport: dot
tls:
server_name: cloudflare-dns.com
verify: trueYou can mix DoT and plain UDP endpoints in the same list; the strategy decides how they are chosen.
To expose a DoH listener directly from Foghorn (for example on port 8053 with TLS termination):
server:
listen:
# ... dns.udp / dns.tcp here ...
doh:
enabled: true
host: 0.0.0.0
port: 8053
cert_file: /etc/foghorn/tls/server.crt
key_file: /etc/foghorn/tls/server.keyWhen Foghorn itself is running behind an HTTP reverse proxy (for example, nginx or Envoy), you typically terminate TLS at the proxy and run the DoH listener as plain HTTP on localhost. The proxy handles https:// and forwards /dns-query to Foghorn:
server:
listen:
# ... dns.udp / dns.tcp here ...
doh:
enabled: true
host: 127.0.0.1
port: 8443
# No cert_file/key_file here; TLS is terminated at the reverse proxy.Your reverse proxy is then configured to listen on 443 with TLS and proxy requests such as https://dns.example.com/dns-query to http://127.0.0.1:8443/dns-query.
For quick local testing, the Makefile includes convenience targets that generate a small CA and server key material under ./keys:
make ssl-ca– createfoghorn_ca.keyand a self-signedfoghorn_ca.crtwith CA key usage.make ssl-ca-pem– export the CA certificate asfoghorn_ca.pemfor use as a trust anchor (e.g.upstreams.*.tls.ca_file).make ssl-cert CNAME=myserver– create a server key and certificate signed by the local CA, namedfoghorn_${CNAME}.key/.crt.make ssl-server-pem CNAME=myserver– build a combinedfoghorn_${CNAME}.pemcontaining the server certificate and key.
Use ca.pem when Foghorn is a TLS client and needs to trust an internal CA (for example for DoT/DoH upstreams via tls.ca_file). Use server.pem when Foghorn is acting as a TLS server and you need a single file containing both cert and key for a listener.
These are intended for development and lab environments only; for production, use your normal PKI or certificate management.
Below are the built‑in plugins, with short descriptions and minimal configs. All examples assume they live in the shared plugins: list.
IP-based allow/deny control at the edge.
plugins:
- type: acl
config:
default: allow # allow | deny
allow:
- 192.168.1.1 # Overrides the deny below.
- 10.0.0.0/8
deny:
- 192.168.0.0/16
- 172.16.0.0/12Expose Docker container names as DNS answers.
plugins:
- type: docker
config:
endpoints:
- url: unix:///var/run/docker.sock
- url: tcp://my.server.lan:2375
ttl: 60
health: ['healthy', 'running']
discovery: true # false | trueServe additional records from one or more hosts-style files.
plugins:
- type: hosts
config:
file_paths:
- /etc/hosts
- ./config/hosts.
ttl: 300
watchdog_enabled: true # null | true | falseFetches remote blocklists/allowlists on a schedule and stores them as files for other plugins.
plugins:
- type: lists
hooks:
setup: { priority: 10 } # Setup early so other plugins have their files available.
config:
download_path: ./config/var/lists
interval_days: 1
hash_filenames: true # false | true - Multiple "hosts.txt" files easily handled by hashing the URL
urls:
- https://example.com/ads.txt
- https://example.com/hosts.txt
- https://serverA/hosts.txt
- https://serverB/hosts.txtFlexible domain/IP/pattern filter used to build adblockers and kid-safe DNS.
plugins:
- type: filter
hooks:
priority: 25 # Applies to pre_resolve + post_resolve + setup unless overridden.
config:
default: allow # deny | allow
targets:
- 10.0.1.0/24 # Kids subnet
ttl: 300
# When a pre_resolve deny happens, synthesize an IP response pointing at a sinkhole address
deny_response: ip # nxdomain | refused | servfail | ip | noerror_empty
deny_response_ip4: 0.0.0.0
# Post-resolve IP filtering rules (answer inspection)
blocked_ips:
- ip: 203.0.113.10 # Replace a specific IP with a safer landing page
action: replace
replace_with: 0.0.0.0
- ip: 203.0.113.0/24 # Strip an entire subnet from answers
action: remove
- ip: 198.51.100.42 # Block a single IP entirely (maps to deny)
action: deny
blocked_domains_files:
- ./config/var/lists/*.txt
allowed_domains:
- homework.example.org
blocked_domains:
- how-to-cheat.org
- current-game-obession.io
Injects DNS errors and timeouts for testing client behaviour.
plugins:
- type: flaky
id: dev-servfail-5-A-AAAA
config:
servfail_percent: 5
nxdomain_percent: 0
timeout_percent: 0
truncate_percent: 0
noerror_empty_percent: 0
apply_to_qtypes: ['A', 'AAAA']Expose mDNS / DNS-SD services as normal DNS records.
Note For mDNS / DNS-SD / Bonjour / Zeroconf / Avahi you must be on the same L2 network in order to receive (e.g. in Docker you might need to run with --net=host or --net=macvlan)
plugins:
- type: mdns
config:
domain: '.'
ttl: 120
include_ipv4: true # true | false
include_ipv6: true # true | false
network_enabled: trueFoghorn includes several built-in security protections to mitigate DoS/DDoS attacks and DNS amplification risks:
- DoH parameter size validation: Oversized base64-encoded DNS parameters are rejected (HTTP 413) before decoding, preventing processing of megabyte-scale payloads.
- Recursive resolver depth limits: Default
max_depthis 12 (configurable viaserver.resolver.max_depth) to limit recursion depth and prevent abuse through deep delegation chains. - Upstream health cleanup: The
DNSUDPHandler._cleanup_upstream_health()method periodically removes stale healthy entries from theupstream_healthtracking dictionary to prevent unbounded memory growth. - Rate limiting and concurrency controls: The
rateplugin provides per-client or per-(client,domain) rate limiting (see below). Combined with listener connection limits (max_connections,max_connections_per_ip) and per-connection query caps (max_queries_per_connection), this provides defense at multiple layers. - DNS response size limits: UDP responses are capped at 1232 bytes to minimize amplification potential. DoH response sizes are also limited to large payloads.
When deploying Foghorn as an authoritative or recursive resolver on exposed interfaces, consider enabling these protections and monitoring the metrics exposed via the admin UI for query patterns and error rates.
Adaptive rate limiting per client or per (client,domain).
Note: listener/transport protections (for example UDP inflight shedding and
TCP/DoT connection limits) can trigger before plugin hooks run, so under heavy
load some traffic may be dropped/refused before the rate plugin evaluates it.
plugins:
- type: rate
config:
mode: per_client # per_client | per_client_domain | per_domain
window_seconds: 10
warmup_windows: 6
burst_factor: 3.0
burst_windows: 6
stats_log_interval_seconds: 900
min_enforce_rps: 50.0
deny_response: nxdomain # nxdomain | refused | servfail | noerror_empty | ip
deny_response_ip4: 0.0.0.0
db_path: ./config/var/dbs/rate_limit.dbSend different domains to different upstreams.
upstreams:
strategy: failover
endpoints:
- host: 9.9.9.9
port: 53
transport: udp
plugins:
- type: router
config:
routes:
- domain: internal.example
upstreams:
- host: 10.0.0.53
port: 53
- suffix: corp.
upstreams:
- host: 192.168.1.1
port: 53The ZoneRecords ("zonerecords") plugin (type: zone) is for custom DNS answers. If you only need a handful of local overrides, you do not need to create and maintain an entire RFC-1035 zonefile — just use inline records or a simple file_paths records file.
Define custom records either:
- Inline using the
recordslist and the pipe-delimited format<domain>|<qtype>|<ttl>|<value>. - From one or more custom records files using
file_paths(same pipe-delimited format as above). - From one or more RFC‑1035 style BIND zonefiles using
bind_paths(parsed via dnslib; supports$ORIGIN,$TTL, and normal RR syntax). Each bind_paths entry can be either a string path, or an object withpath, plus optionalorigin/ttloverrides. - Optional
path_allowlistcan restrictfile_pathsandbind_pathsto a set of allowed directory prefixes.- Paths containing explicit
..segments are rejected.
- Paths containing explicit
All sources are merged into a single internal view per (name, qtype):
- The TTL for each (name, qtype) pair comes from the first occurrence of that pair across all sources in configuration order.
- Values are kept in first-seen order with duplicates dropped.
- An SOA record at a name marks the apex of an authoritative zone; inside such a zone the plugin behaves like an authoritative server, including correct NXDOMAIN/NODATA and ANY semantics with SOA in the authority section.
- For zones with an SOA apex and a TCP/DoT listener enabled, Foghorn also answers AXFR/IXFR for that zone using the same ZoneRecords data.
- IXFR server side currently implemented as a full AXFR-style transfer.
- IXFR client side is not yet supported.
Wildcard notes:
- ZoneRecords treats a leading
*label as matching one or more labels, which differs from RFC 4592. For example,*.example.orgmatches botha.example.organda.b.example.org.
Operational guidance:
- Keep authoritative zone apex counts to less than 1,000 zones per instance unless you have benchmarked higher counts.
Optional merge controls:
load_mode:merge(default) preserves any existing in-memory records and overlays new data.replacerebuilds the mapping on each load/reload.firstuses the first configured source group in this order: file_paths → bind_paths → axfr_zones → records (inline), and ignores the others.
merge_policy:add(default) appends distinct values into an RRset;overwritereplaces an RRset when a later source defines the same(domain, qtype).nxdomain_zones: optional list of zone suffixes where, if a name does not exist in ZoneRecords, the plugin returns NXDOMAIN/NODATA instead of falling through to upstream resolution.max_file_size_bytes: max allowed bytes per source file infile_pathsandbind_paths.max_records: max accepted record values per load cycle.max_record_value_length: max per-record value length in characters.auto_ptr_enabled/max_auto_ptr_records: enable and bound auto-generated PTR values.soa_synthesis_enabled: controls inferred SOA fallback when no SOA exists.
plugins:
- type: zone
config:
# Optional: pipe-delimited records files
file_paths:
- ./config/zones.d/internal.records
# Optional: native BIND zonefiles
bind_paths:
- ./config/zones.d/internal.zone
# Optional: inline records
records:
- 'printer.lan|A|300|192.168.1.50'
- 'files.lan|AAAA|300|2001:db8::50'
# Optional: load/reload behaviour
load_mode: merge # replace | merge | first
merge_policy: add # add | overwrite
ttl: 300Some resolver filters are shipped as examples only and now live under
foghorn.plugins.resolve.examples (for example the DNS prefetch, example
rewrites, greylist, new-domain WHOIS filter, file-over-DNS helper, and
finger-style user lookup over DNS).
These plugins are functional, but they are intended as reference implementations
for advanced users. They are not wired into a running server by default; a
config that references these example types will not work unless you first move
or copy the corresponding module into foghorn.plugins.resolve (or build your
own plugin based on them).
Treat them as starting points: read the code end‑to‑end, decide whether the behaviour and trade‑offs match your environment, and in most cases prefer creating your own plugin that uses these as an example rather than dropping them directly into production.
Prefetches the most popular names from statistics to keep the cache warm. Not very efficient yet, but it shows the concept.
plugins:
- type: prefetch
config:
interval_seconds: 60
prefetch_top_n: 100
max_consecutive_misses: 5
qtypes: ['A', 'AAAA']A playground plugin that can rewrite responses or demonstrate behaviours.
plugins:
- type: examples
config:
base_labels: 2
max_subdomains: 5
apply_to_qtypes: ['A']Introduces a delay window before new names are allowed. The origin of the project! Started as a greylist for security researchers working on phishing protection.
plugins:
- type: greylist_example
config:
db_path: ./config/var/greylist.db
cache_ttl_seconds: 300
duration_hours: 24Blocks domains that appear too new according to WHOIS data.
plugins:
- type: new_domain
config:
threshold_days: 7
whois_db_path: ./config/var/whois_cache.db
whois_cache_ttl_seconds: 3600Serves byte ranges from configured files over TXT DNS queries using a pattern
like <name>.<start>.<end>.<rest-of-domain>.
plugins:
- type: file_over_dns
config:
files:
- name: readme
file_path: ./README.md
ttl: 300
max_chunk_bytes: 1024
format: base64 # base64 | rawExposes a finger-style view of user information over TXT records for
<user>.<domain> when enabled, reading from per-user files such as
$HOME/.finger or a configured path template.
Exposes a finger-style view of user information over TXT records for
<user>.<domain> when enabled, reading from per-user files such as
$HOME/.finger or a configured path template.
Foghorn supports a simple variable system so you can avoid repetition, keep secrets out of committed configs, and adapt the same config to multiple environments (local dev, CI/CD, containers, different sites, and so on).
Variables are resolved in this order (highest priority last):
- Command-line
--var/--varsflags. - Environment variables.
- The
vars:section at the top of the config file.
Later sources override earlier ones for the same variable name.
At the top of your config:
vars:
PROFILE: lan
MAIN_SUBNET: 192.168.0.0/16
TEST_SUBNET: 192.168.50.0/24You can then reference these variables elsewhere using ${NAME} syntax:
server:
listen:
dns:
udp: { enabled: true, host: 0.0.0.0, port: 53 }
plugins:
- type: filter
id: main-filter
config:
targets: ${MAIN_SUBNET}
- type: filter
id: test-filter
config:
targets: ${TEST_SUBNET}This keeps common values (like subnets) in one place so they are easy to update and reuse.
vars:
LAN: 192.168.0.0/16
ADULTS: 192.168.1.0/24
KIDS: 192.168.2.0/24
server:
listen:
dns:
udp: { enabled: true, host: 0.0.0.0, port: 53 }
plugins:
- type: filter
id: adblock
config:
targets: ${LAN}
default: allow
# ... normal adblock rules ...
- type: filter
id: kids-filter
config:
targets: ${KIDS}
default: deny
# ... strict allowlist rules for kids ...- Less repetition: Define shared values (subnets, hostnames, DNS names) once and reuse them throughout the config.
- Safer secrets: Keep API tokens, passwords, and other sensitive data out of the main config by passing them via environment variables or command-line overrides.
- Environment-specific overrides: In CI/CD or container setups you can keep
a single base config file and override only the variables per environment.
- In local dev, you might rely on the
vars:section. - In staging/prod, environment variables or
--varflags can redefine a few keys (for example, stats database DSN, upstreams, or listen addresses).
- In local dev, you might rely on the
- Caveat for IDEs: Some editors/IDEs run JSON/YAML schema validation on the
raw file and do not interpolate
${VAR}placeholders first. In that case the config may show spurious validation errors even though Foghorn will load it correctly at runtime.
These sketches show how all the pieces fit together. Adjust paths and IPs to match your environment.
Goals:
- Cache locally for speed.
- Forward to a public DoT resolver.
- No plugins yet, no logging, no
vars:
PROFILE: local
server:
listen:
dns:
udp: {enabled: "true", host: "127.0.0.1", port: 5353}
cache:
module: memory # Default
upstreams:
strategy: failover
endpoints:
- host: 1.1.1.1
port: 853
transport: dot
tls: {server_name: "cloudflare-dns.com", verify: true}
logging:
python:
level: info
plugins: []Goals:
- Listen on all interfaces.
- Use filter+lists to block ads.
- Use a second filter instance as a stricter allowlist for kids.
- Route internal corp domains to a separate upstream.
vars:
PROFILE: lan
LAN: 192.168.0.0/16
KIDS: 192.168.2.0/24
server:
listen:
dns:
udp: {enabled: true, host: 0.0.0.0, port: 53}
cache:
module: sqlite
config:
db_path: ./config/var/dns_cache.db
upstreams:
strategy: round_robin
endpoints:
- host: 9.9.9.9
port: 53
transport: udp
- host: 1.1.1.1
port: 53
transport: udp
logging:
python:
level: info
async: true
query_log_only: false
backends:
- id: lan-log
backend: sqlite
config:
db_path: ./config/var/stats_lan.db
stats:
enabled: true
source_backend: lan-log
plugins:
- type: lists
id: blocklists
config:
pre_priority: 20
download_path: ./config/var/lists
interval_days: 1
urls:
- https://example.com/ads.txt
- type: filter
id: adblock # Ad block for everyone
config:
pre_priority: 40
default: allow
blocked_domains_files:
- ./config/var/lists/ads.txt
- type: filter
id: kids # Filter just for the kids.
config:
pre_priority: 50
targets: ${KIDS}
default: deny
allowed_domains:
- homework.example.org
- library.example.org
deny_response: ip
deny_response_ip4: 0.0.0.0
- type: router
id: corp-router
config:
pre_priority: 80
routes:
- suffix: corp
upstreams:
- host: 192.168.100.53
port: 53Goals:
- Persistent DNS cache.
- Local LAN overrides via hosts, mDNS bridge, and zone records.
- Simple access control and rate limiting.
- Query-log persistence in MariaDB/MySQL (configured via logging.backends).
vars:
PROFILE: smb
LISTEN: "0.0.0.0"
LAN: 192.168.0.0/16
FLOOR1: 192.168.10.50
FLOOR2: 192.168.20.50
FLOOR1_NET: 192.168.10.0/24
FLOOR2_NET: 192.168.20.0/24
server:
listen:
dns:
udp: {enabled: true, host: ${LISTEN}, port: 53}
tcp: {enabled: true, host: ${LISTEN}, port: 53}
cache:
module: sqlite
config:
db_path: ./config/var/dns_cache.db
upstreams:
strategy: failover
endpoints:
- host: dot1.myisp.example
port: 853
transport: dot
tls: {server_name: dot1.myisp.example, verify: true}
- host: dot2.myisp.example
port: 853
transport: dot
tls: {server_name: dot2.myisp.example, verify: true}
logging:
python:
level: info
async: true
query_log_only: false
backends:
- id: mariadb
backend: mariadb
config:
host: db.internal
port: 3306
user: foghorn
database: foghorn_stats
# Optional: control which Python DB driver is used.
driver: auto # auto | mariadb | mysql-connector-python | mysql
driver_fallback: auto # auto | none | <driver> | [<driver>, ...]
stats:
enabled: true
source_backend: mariadb
plugins:
- type: acl
id: lan-only
config:
pre_priority: 10
default: deny
allow:
- ${LAN}
- type: hosts
id: office-hosts
config:
pre_priority: 20
file_paths:
- /etc/hosts
- ./config/hosts.office
- type: mdns
id: office-mdns
config:
pre_priority: 30
domain: 'devices.mycorp'
ttl: 120
- type: zone
id: printers-floor1
config:
pre_priority: 40
targets: ${FLOOR1_NET}
records:
- 'printer.corp|A|300|${FLOOR1}'
- type: zone
id: printers-floor2
config:
pre_priority: 41
targets: ${FLOOR2_NET}
records:
- 'printer.corp|A|300|${FLOOR2}'
- type: docker
id: lan-docker
config:
pre_priority: 50
targets: ${LAN}
endpoints:
- url: unix:///var/run/docker.sock
ttl: 60
- type: rate
id: smb-rate
config:
pre_priority: 5 # Run first thing
mode: per_client
window_seconds: 10
min_enforce_rps: 20.0
deny_response: refusedGoals:
- Redis or Memcached cache for large edge deployments.
- Multiple PostgreSQL statistics backends plus Influx logging.
- Heavy use of plugins (router, filter, docker, mdns, zone).
- Fine-grained client targeting and per-plugin priorities.
vars:
PROFILE: enterprise
LISTEN: 0.0.0.0
LAN: 10.0.0.0/16
OFFICE: 10.10.0.0/16
OFFICE_REMOTE: 10.20.0.0/16
server:
listen:
dns:
udp: {enabled: true, host: ${LISTEN}, port: 53}
tcp: {enabled: true, host: ${LISTEN}, port: 53}
dot:
enabled: true
host: ${LISTEN}
port: 853
cert_file: /etc/foghorn/tls/server.crt
key_file: /etc/foghorn/tls/server.key
cache:
module: redis # redis | memcached | sqlite | memory | mongodb | none
config:
url: redis://redis-cache.internal:6379/0
namespace: foghorn:dns_cache:
upstreams:
strategy: round_robin
max_concurrent: 2
endpoints:
- host: 10.0.0.53
port: 53
transport: udp
pool:
max_connections: 64
idle_timeout_ms: 30000
- host: 10.0.1.53
port: 53
transport: udp
pool:
max_connections: 64
- host: 10.0.2.53
port: 853
transport: dot
tls: {server_name: dns.corp.example, verify: true}
pool:
max_connections: 32
logging:
python:
level: info
async: true
backends:
- id: pg_primary
backend: postgr
- id: pg_reporting
backend: postgres
config:
host: pg-reporting.internal
port: 5432
user: foghorn_ro
database: foghorn_stats
- id: influx-logging
backend: influx
config:
write_url: http://metrics.internal:8086/api/v2/write
bucket: dns
org: infra
stats:
enabled: true
source_backend: pg_primary
plugins:
- type: acl
id: lan-only
hooks:
pre_resolve: 10
config:
default: deny
allow:
- ${LAN}
- type: docker
id: lan-docker
hooks:
pre_resolve: 20
config:
targets: ${LAN}
endpoints:
- url: unix:///var/run/docker.sock
ttl: 60
- type: mdns
id: enterprise-mdns
hooks:
pre_resolve: 30
domain: 'devices.lan'
ttl: 120
- type: zone
id: zone-1-office
hooks:
pre_resolve: 40
config:
pre_priority: 40
targets: ${OFFICE}
file_paths:
- ./config/zones.d/zone-1.zone
- type: zone
id: zone-2-remote
hooks:
pre_resolve:
priority: 41
config:
targets: ${OFFICE_REMOTE}
file_paths:
- ./config/zones.d/zone-2.zone
- type: router
id: corp-router
hooks:
pre_resolve: 60
config:
routes:
- suffix: corp.example
upstreams:
- host: 10.1.0.53
port: 53
- type: filter
id: global-filter
hooks:
pre_resolve: 80
config:
default: allow
blocked_domains_files:
- ./config/var/lists/global_block.txtFrom here you can mix and match plugins, caches, and stats backends to shape Foghorn into exactly the DNS service you need.



