# ferrite configuration example # Copy to ~/.config/ferrite/config.toml (macOS and Linux) # or /etc/ferrite/config.toml (system-wide, needs root) # # Quick start for local networks: # # ferrite setup # # This command detects your gateway, subnet, and local search domain, then # writes the correct [[zones]] entries automatically. Edit the rest as needed. # Override where static web UI files are served from (dev only). # This is a top-level setting, not part of [api]. # web_dir = "/home/user/ferrite-ui/dist" # ── DNS server ──────────────────────────────────────────────────────────────── [dns] # Interface and port to listen on (UDP + TCP). # Needs root / CAP_NET_BIND_SERVICE for port 53. bind_addr = "0.0.0.0:53" # DNS response cache. cache_size = 10000 # Clamp upstream TTLs to this range (seconds). min_ttl = 60 max_ttl = 3600 # Optimistic cache: answer from an expired entry at once (with a 30 s TTL) and # refresh it in the background, so a name a device asked for before never waits # on the upstream — and keeps resolving while the upstream is down, for up to # stale_max_age seconds. Off by default. # serve_stale = true # stale_max_age = 43200 # Who may use this resolver, and how fast. A client over rate_limit (queries # per minute; a burst of the whole minute is fine) is answered REFUSED until it # slows down; 0 = no limit. allowed_clients / denied_clients take IPs and CIDR # ranges; a client outside them gets no answer at all. Loopback always passes. # rate_limit = 1000 # allowed_clients = ["192.168.1.0/24", "fd00::/8"] # denied_clients = ["192.168.1.66"] # Keeping devices on this resolver. All three apply without a restart. # # Firefox asks use-application-dns.net before switching itself to DNS over # HTTPS (and past every filter on the network); NXDOMAIN keeps it here. DoH a # user turned on by hand stays on. On by default. # firefox_canary = true # # Apple devices ask mask.icloud.com before using iCloud Private Relay, which # takes Safari's lookups around ferrite. NXDOMAIN turns Private Relay off on # this network. Off by default: it is the device owner's privacy choice. # block_private_relay = false # # Strip Encrypted Client Hello (ECH) from HTTPS/SVCB answers, so browsers keep # the hostname readable in the TLS handshake. Off by default. (Not strip_ecs.) # Blocking DoH servers themselves is a job for a list such as HaGeZi's # doh-vpn-proxy-bypass. # strip_ech = false # Domains to suppress from the query log entirely (still resolved normally). # Exact names or wildcard suffixes. Useful for noisy local/private zones. # `fe.te` is ferrite's built-in panel domain and is ignored by default. # log_ignore = ["fe.te", "*.test", "*.local", "*.lan"] # ── Default upstream resolvers ──────────────────────────────────────────────── # Queries not matched by any [[zones]] entry go here. # ferrite tries upstreams round-robin, failing over on error. # Plain UDP/TCP (fastest, no encryption) [[upstream]] type = "plain" address = "1.1.1.1" port = 53 [[upstream]] type = "plain" address = "1.0.0.1" port = 53 # DNS-over-HTTPS (recommended — encrypted, no ISP snooping) # [[upstream]] # type = "https" # url = "https://cloudflare-dns.com/dns-query" # bootstrap_ip = "1.1.1.1" # skip DNS for the resolver's own hostname # DNS-over-TLS # [[upstream]] # type = "tls" # address = "1.1.1.1" # port = 853 # tls_name = "cloudflare-dns.com" # DNS-over-QUIC # [[upstream]] # type = "quic" # address = "94.140.14.14" # port = 853 # tls_name = "dns.adguard-dns.com" # ── Zone routing (conditional forwarding) ───────────────────────────────────── # Route specific DNS zones to a dedicated upstream instead of the default pool. # # ferrite setup ← auto-detects and writes these for you # # Zones are matched by suffix; the longest (most specific) match wins. # Zone upstreams are plain UDP/TCP only. # # Why this matters: # PTR queries for 192.168.x.x never resolve on public DNS — only your # router knows those names. Without zones, the client list in the UI # shows raw IP addresses instead of hostnames. # # Typical home setup — adjust addresses to match your network: # [[zones]] # name = "1.168.192.in-addr.arpa" # PTR for 192.168.1.0/24 # upstream = "192.168.1.1:53" # [[zones]] # name = "localdomain" # hostname.localdomain # upstream = "192.168.1.1:53" # [[zones]] # name = "lan" # hostname.lan # upstream = "192.168.1.1:53" # IPv6 ULA reverse zone (replace with your actual prefix): # [[zones]] # name = "b.a.1.1.b.0.0.0.0.4.f.2.0.1.a.2.ip6.arpa" # upstream = "192.168.1.1:53" # Other subnets: # [[zones]] # name = "0.10.in-addr.arpa" # PTR for 10.0.0.0/16 # upstream = "10.0.0.1:53" # ── Storage ─────────────────────────────────────────────────────────────────── # # The database holds only what ferrite *observed*: the query log, its rollups, # and the device inventory learned from PTR/mDNS and the neighbour tables. # Everything you author — settings, subscriptions, manual block/allow entries, # custom records, client aliases, tunnels — lives in this file and only here. # So back up this file and you have backed up the configuration; delete the # database and you lose history, never setup. # # Changes you make in the web UI are written straight back here. The file is # regenerated on each save, so hand-written comments in the sections the UI # manages will not survive; the values will. [storage] # Where the query log, its rollups and the learned device inventory live. # # "sqlite" (default) — a database file; history survives a restart. # "memory" — RAM only. No database file, no write-ahead log, and no # disk write per query; history is lost on restart. # (The compiled blocklist cache still lives on disk — it # is built from downloaded lists, not from your traffic, # and is written on a refresh, not per query. Move it # with `blocklist_cache_dir` below.) # # Pick "memory" when the box boots off something you'd rather not spend writes # on (an SD card), or when the root filesystem is read-only, or simply when you # don't want what was resolved to land on a disk at all. The Queries page, the # dashboard and the per-device stats all keep working either way — the # in-memory store keeps the same rollups, it just forgets them on restart. # # Bounded by construction: the in-memory log is a ring that drops the oldest # entry rather than growing. `log_max_mb` below sizes it; left at 0 it holds # about 128k entries (~32 MB). # # Changing this needs a restart. Your configuration is in this file either way # and is never affected. # backend = "sqlite" # Where the database file lives. Default: ~/.local/share/ferrite/ferrite.db # (unused by the "memory" backend). # # Settable from the panel too, which lists what this box offers — including any # RAM-backed mounts (/dev/shm, /run, /tmp on systemd) with their free space. A # database on one of those costs the disk no writes at all and is cleared by a # reboot; `memory` above is the same trade without a file in the way, and the # difference is that a path on tmpfs survives a restart of ferrite itself. # # Changing this needs a restart. The path is checked before it is saved — it # must be absolute, must not be a directory, and ferrite must be able to create # files along it. # path = "/var/lib/ferrite/ferrite.db" # Where the compiled blocklist lives: the merged and per-profile FSTs the # resolver maps, and the per-list caches a refresh builds them from (`lists/`, # `allowlists/`). Default: the data directory, ~/.local/share/ferrite. # # None of it is your data — it is all rebuilt from the list servers — and it is # written on a refresh, not per query; with conditional fetches, only when a # list actually changed. A RAM-backed mount spends no flash on it at all. What # that costs: the files then occupy RAM that cannot be paged out (roughly the # size of the lists, tens of MB for a few big ones), and after a reboot every # list is downloaded again before blocking is back to full strength. # # Changing this needs a restart. The old directory is left as it was — delete # it by hand if you want the space back. Checked before it is saved, like `path`. # blocklist_cache_dir = "/dev/shm/ferrite" # Automatically delete query log entries older than this many days. # Default: 30. A busy household writes on the order of a gigabyte of query log a # quarter, and the database usually sits on an SD card; a month is longer than # anyone looks back for. Set it to 0 to keep every entry forever — ferrite then # warns once at startup if the database has grown large. # log_retention_days = 30 # Cap the query log at this many megabytes, oldest entries first. # Default: 0 (no size limit — the day count above decides on its own). # # Days are the right knob on a disk. Bytes are the right knob when the database # sits somewhere that cannot absorb a surprise: a RAM-backed path, or a flash # card with little room left. Set both and whichever trims first wins. # # Checked every 60 seconds rather than daily — a size cap exists to stop growth # before it matters. Only the query log and its rollups are trimmed; the learned # device inventory is tiny and is left alone. # # The first time a cap is switched on, an existing database is rebuilt once so # it can release space back to the filesystem (SQLite otherwise keeps freed # pages). That takes a moment and is logged; databases created from here on are # already set up for it. Allow a few MB of headroom for the write-ahead log, # which sits beside the database and is not counted in this figure. # log_max_mb = 0 # Privacy switches — both default to true, both apply without a restart. # # Both work the same way: they stop new data being recorded AND stop the old # data being served. What was written before the switch stays on disk, but # ferrite will not hand it back while you have asked it not to be keeping any — # switch the setting on again to read it, or delete it for good with the Clear # button in the UI (DELETE /api/queries). # # log_queries = false stops the per-query log: nothing lands in the `queries` # table and the Queries page stays empty. Filtering and routing are unaffected. # # keep_stats = false stops the aggregates: no top domains, no top clients, no # 24-hour graph, no per-device counters, and no host names on the Tunnels page # (per-tunnel byte and connection totals keep counting — they name no host). # The Clients page goes empty too: it *is* the per-device counters. The # dashboard falls back to the process-lifetime totals, which carry no domain or # device identity. Those totals are anonymous, not ephemeral — they are kept in # the restart snapshot so the dashboard still means something after a reboot. # # With both off ferrite records nothing about what was resolved — including the # warm-restart snapshot, which stops storing cached domains (so a restart starts # with a cold cache), and the learned device inventory: no IP->MAC bindings and # no device names are written or read back, so the database stops describing who # is on the network as well as what they looked up. Switching both off also # drops the inventory already stored. Manual client aliases are configuration, # not a record, and are kept. Other existing data is not deleted: use the Clear # button in the UI, or DELETE /api/queries, which also vacuums the database. # log_queries = true # keep_stats = true # ── API / web UI ────────────────────────────────────────────────────────────── [api] bind_addr = "127.0.0.1:8080" # Optional API key — all /api/* requests must include: # Authorization: Bearer or X-Api-Key: # Leave unset (or set to null via PATCH /api/settings) to disable key auth. # api_key = "change-me" # Web UI password (set via `ferrite passwd`, stored as Argon2id hash). # Leave unset (or set to null via PATCH /api/settings) to disable password auth. # password_hash = "..." # ── Blocklist ───────────────────────────────────────────────────────────────── [blocklist] # Recent block/allow decisions kept in RAM. Lower this on very small devices; # higher values reduce FST lookups on noisy networks. decision_cache_size = 50000 # NOTE: `whitelist` used to live here; it is deprecated in favour of the # [allowlist] section below. Old configs still load — entries are migrated # automatically and rewritten in the new shape on the next config save. # Manual block entries — exact domains and wildcards alike. An exact domain # also blocks its subdomains; wildcards like "*.doubleclick.net" match all # subdomains (not the apex itself). Mirrors [allowlist] domains below. # This is where the UI writes; also manageable via POST /api/blocklist/blacklist. # # NOTE: `wildcard_block` was the old name and held wildcards only. Old configs # still load — entries are migrated into `domains` automatically and rewritten # in the new shape on the next config save. # A regex between slashes matches anywhere in the name, case-insensitively — # write it in single quotes so TOML leaves the backslashes alone. domains = [ # "*.doubleclick.net", # "ads.example.com", # '/^ads?[0-9]+\./', ] # Services from the built-in catalog, blocked for every device: "youtube", # "tiktok", "steam", … (the full list: GET /api/services, or the Blocklist # page). Each service is a set of domains matched like the entries above, so # an [allowlist] entry still exempts one. Device profiles take their own # `services = [...]`, and a tunnel rule routes a whole service with # `service = "youtube"` in place of `pattern`. services = [ # "tiktok", ] # SafeSearch for every device: Google, Bing and DuckDuckGo resolve to the # engines' safe hosts, so results are filtered whatever the browser says. # YouTube Restricted Mode: "off", "moderate" or "strict". A device profile has # the same two settings and can only make them stricter for its devices. # safe_search = true # youtube_restricted = "moderate" # What a blocked name is answered with: # "nxdomain" (default) no such name — apps give up at once # "nodata" the name exists, without this record type # "null_ip" 0.0.0.0 for A, :: for AAAA # "custom_ip" block_ipv4 / block_ipv6 — a block page on your network # "refused" the server won't answer # Other record types get no records in the address modes. # block_response = "null_ip" # block_ipv4 = "192.168.1.5" # block_ipv6 = "fd00::5" # Remote blocklist subscriptions (hosts or Adblock format, auto-detected). [[blocklist.lists]] name = "StevenBlack" url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" enabled = true # [[blocklist.lists]] # name = "AdGuard Base" # url = "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt" # enabled = true # [[blocklist.lists]] # name = "Local custom" # url = "file:///etc/ferrite/custom.hosts" # enabled = true # ── Allowlist ───────────────────────────────────────────────────────────────── # Domains never blocked, even when a blocklist contains them. Mirrors the # [blocklist] structure: manual entries plus remote subscriptions. Only # consulted while blocking is enabled — an allowlist doesn't resolve anything # by itself, it only exempts domains from blocking. [allowlist] # Manual entries. An exact domain also exempts its subdomains; wildcards like # "*.example.com" match all subdomains (not the apex itself). # Also manageable at runtime via POST /api/blocklist/whitelist. domains = [ # "safe.example.com", # "*.internal.corp", ] # Remote allowlist subscriptions — refreshed together with the blocklists. # Formats: hosts, plain domain-per-line, or Adblock (there the "@@||domain^" # exception rules are the allow entries). Useful for community-maintained # false-positive lists. Also manageable at runtime via the /api/allowlists # endpoints. # [[allowlist.lists]] # name = "anudeepND Whitelist" # url = "https://raw.githubusercontent.com/anudeepND/whitelist/master/domains/whitelist.txt" # enabled = true # ── Custom DNS records ──────────────────────────────────────────────────────── # Local A/AAAA/CNAME overrides — bypass blocklist and upstream entirely. # Also manageable at runtime via POST /api/custom-records. # [[custom_records]] # domain = "router.lan" # type = "A" # value = "192.168.1.1" # ttl = 300 # [[custom_records]] # domain = "nas.lan" # type = "AAAA" # value = "fd00::1" # ttl = 300 # [[custom_records]] # domain = "*.home.lan" # type = "A" # value = "192.168.1.100" # ttl = 300 # ── Client aliases ──────────────────────────────────────────────────────────── # What you decided to call a device, overriding the hostname ferrite learns for # it. Give exactly one of `mac` or `ip`: a MAC follows the device across DHCP # leases and address rotation, so prefer it when you know it. # # These are configuration, not a record — they are kept even with both privacy # switches off, and they are unaffected by clearing the query log. The names # ferrite *learns* (PTR/mDNS) are the record, and those live in the database. # Also manageable at runtime via POST /api/clients/aliases. # [[client_aliases]] # mac = "aa:bb:cc:dd:ee:ff" # name = "Kitchen TV" # [[client_aliases]] # ip = "192.168.1.5" # name = "NAS" # ── Notifications ───────────────────────────────────────────────────────────── # One webhook, sent an event for each kind switched on. `format` shapes the # body for the receiver: "json" (the default — the event's fields, for Home # Assistant or a script), "ntfy", "discord", "slack" (also Mattermost and # Rocket.Chat), "telegram" or "gotify". Configurable from Settings → # Notifications, which can also send a test event. # # [notifications] # webhook = "https://ntfy.sh/my-ferrite" # format = "ntfy" # tunnel_alerts = true # a tunnel down for over a minute, and its recovery # new_device = true # a device the box has never seen joins the network # # Telegram: the bot's sendMessage URL and the chat to post to. # webhook = "https://api.telegram.org/bot/sendMessage" # format = "telegram" # chat_id = "123456789" # ── Following a primary (two resolvers that answer alike) ──────────────────── # This box takes the chosen parts of another ferrite's configuration every # `interval` seconds, with that box's API key, and restarts for about a second # when something changed. Sections: filtering, records, devices, dns, tunnels # (tunnels only if this box routes too — one WireGuard key on two boxes makes # both tunnels flap). Edit on the primary; changes made here to those sections # are replaced on the next sync. # # [sync] # primary = "http://192.168.1.10" # api_key = "" # interval = 300 # sections = ["filtering", "records", "devices", "dns"] # ── MCP (AI assistants) ─────────────────────────────────────────────────────── # /api/mcp lets an assistant such as Claude Code read what ferrite is doing, # using the API key. Off by default; allow_changes also lets it block, allow # and pause. # # [mcp] # enabled = true # allow_changes = false # ── DHCP (hand out addresses, with ferrite as everyone's DNS) ──────────────── # For routers that won't let you change the DNS they hand out. A network takes # one DHCP server: switch off the router's when you switch this on. Blank # values are detected — this box's address, the router, the subnet. Every # lease names its device in ferrite and answers as .. Applies # without a restart; never synced to a secondary. # # [dhcp] # enabled = true # pool_start = "192.168.1.100" # pool_end = "192.168.1.199" # subnet_mask = "255.255.255.0" # gateway = "192.168.1.1" # blank: the router this box uses # dns = [] # blank: this box # domain = "lan" # lease_secs = 86400 # # [[dhcp.reservations]] # mac = "aa:bb:cc:dd:ee:ff" # ip = "192.168.1.20" # name = "nas" # ── IPv6 DNS announcements ──────────────────────────────────────────────────── # Router advertisements naming this box the DNS server, so IPv6 devices don't # go around it through the router's IPv6 DNS. DNS only: ferrite never offers # itself as the router. Needs CAP_NET_RAW. Blank values are detected. # # [ra] # enabled = true # interface = "eth0" # rdnss = ["fd00::10"] # rdnss_lifetime = 1800 # interval_secs = 200 # ── Encrypted DNS for devices (DNS over TLS / DNS over HTTPS) ───────────────── # Android's "Private DNS" speaks DNS over TLS on 853; iOS profiles and # browsers speak DNS over HTTPS. A device can name itself with a client ID — # pixel.dns.example.com (TLS) or https://dns.example.com/dns-query/pixel # (HTTPS) — and is then that device for profiles, pauses and the log, from # wherever it connects. At home, ferrite answers hostname and *.hostname with # its own address, so devices reach it directly. Applies without a restart. # # The certificate must cover hostname and *.hostname (for IDs over TLS). # With certificate = "letsencrypt" ferrite gets it and renews it itself, # proving the name through Cloudflare's API — no port has to be open for # that. The token needs the "Edit zone DNS" permission for the zone. # With certificate = "files" it reads cert_file/key_file and picks renewed # files up within ten minutes. # With certificate = "relay" there's nothing to own: the box is named # . under its relay's zone (see [relay]), which fills in hostname, # and gets its Let's Encrypt certificate with the relay answering the # challenge. The key never leaves the box. # # DNS over HTTPS can share 443 with Tunnels: connections asking for # hostname are answered here, everything else is routed as before. # # [secure_dns] # enabled = true # hostname = "dns.example.com" # certificate = "letsencrypt" # or "files", or "relay" # dot_port = 853 # doh_port = 443 # 0 = off # http3 = true # DoH over HTTP/3 too, on UDP doh_port # devices = ["pixel", "iphone"] # answered away from home # A reverse proxy passing DNS over HTTPS on (Nginx Proxy Manager…): its # address, so the client it names in X-Real-IP counts, not the proxy. # DNS over TLS has to reach ferrite directly. # proxies = ["192.168.1.5"] # Who may set a device up from http:///setup, at home and without the # password: "off", "named" (devices you've named in Clients; the default) or # "anyone". The device is added to `devices` and the webhook is told. # self_setup = "named" # # ── Relay: devices away from home reach this box through a relay ────────────── # The box keeps a QUIC connection out to a relay (ferrite-relay), which passes # on devices' encrypted connections to the box's names, never opening them: # no port forwarded on the router. The relay is known by its key. # [relay] # enabled = true # server = "relay.example.net:4433" # pin = "sha256:…" # `ferrite-relay pin` prints it # # [secure_dns.acme] # cloudflare_token = "…" # email = "you@example.com" # optional # staging = false # true = test certificates # # or, with certificate = "files": # cert_file = "/etc/letsencrypt/live/dns.example.com/fullchain.pem" # key_file = "/etc/letsencrypt/live/dns.example.com/privkey.pem"