milestone 11: systemd and docker packaging, operator and architecture docs, config and api reference, docs drift guards
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
# Configuration reference
|
||||
|
||||
Every section, field and collection nxdns accepts, with its type, default,
|
||||
unit, validation rule and the subsystem that consumes it. Source of truth:
|
||||
`src/config/model.zig` (the model and defaults), `src/config/validate.zig`
|
||||
(the rules), `src/config/{bootstrap,import,export}.zig` (the lifecycle).
|
||||
|
||||
## How configuration works
|
||||
|
||||
The database is the truth; the file is a seed.
|
||||
|
||||
- On `nxdns run`, the configuration file (default `/etc/nxdns/config.zon`,
|
||||
overridable with `--config`) is imported into `config.db` **once**: only
|
||||
when the file exists and the database has never been configured. On every
|
||||
later start the file is ignored and the database is used as it is
|
||||
(`src/config/bootstrap.zig`). A file that exists but is unreadable,
|
||||
unparseable or invalid fails the start — nxdns never falls back to silent
|
||||
defaults over a file the operator wrote.
|
||||
- After the seed, changes are made through the web API (or `nxdns import
|
||||
--force`), never by editing the file. Editing the file after first boot has
|
||||
no effect.
|
||||
- `nxdns export` renders the database back as canonical ZON: fixed two-line
|
||||
header, every default emitted, deterministic ordering, no timestamps. The
|
||||
round trip `export` → `import` → `export` is byte-identical. With `--out
|
||||
FILE` the write is atomic and the file is created mode 0600, because the
|
||||
export carries `web.password_hash`.
|
||||
- `nxdns import FILE` replaces the whole database content in one transaction.
|
||||
Without `--force` it refuses a database that already has content
|
||||
(`error.DatabaseNotEmpty`); a failed import leaves the database untouched.
|
||||
- `nxdns check` validates without writing. Source selection order: an
|
||||
explicit `--config FILE` wins; otherwise `config.db` in the data directory
|
||||
if it exists; otherwise the default config file path if it exists;
|
||||
otherwise "nothing to check" (exit 2). `check` also verifies TLS
|
||||
certificate/key readability and, from the command line, probes each enabled
|
||||
upstream with a real query.
|
||||
|
||||
Absent fields keep their defaults — in the file and in the database. A
|
||||
settings key stored in the database that the running binary does not know is
|
||||
warned about and ignored, never an error, so a downgrade cannot brick a
|
||||
config database.
|
||||
|
||||
## What is not in the file
|
||||
|
||||
Storage paths are process arguments, not configuration:
|
||||
|
||||
- `--data-dir DIR` (default `/var/lib/nxdns`) holds `config.db` and
|
||||
`querylog.db`. The directory is created mode 0700; both databases and their
|
||||
WAL sidecars are forced to mode 0600.
|
||||
- `--config FILE` (default `/etc/nxdns/config.zon`) names the seed file.
|
||||
- `--web-dev DIR` (`run` only) serves the web interface from a directory
|
||||
instead of the embedded assets.
|
||||
|
||||
## File format
|
||||
|
||||
The file is ZON: a top-level anonymous struct whose fields are the sections
|
||||
and collections below. Enum values are written as ZON enum literals
|
||||
(`.level = .err`, `.response = .nxdomain`). Strings are double-quoted. The
|
||||
file may be at most 4 MiB (`ConfigTooLarge` beyond that). A syntax error is
|
||||
reported with its line and column.
|
||||
|
||||
One serialization quirk: the log level `error` is the Zig keyword `error`,
|
||||
so the ZON/model tag is `.err` while the database stores the operator-facing
|
||||
word `"error"`. `err` is not accepted as database text, and `error` is not a
|
||||
ZON tag — the file says `.err`, the settings API says `"error"`.
|
||||
|
||||
## Scalar sections
|
||||
|
||||
The "Key" column is the settings key as stored in the database
|
||||
(`section.field`); in the file the same field lives inside its section block,
|
||||
e.g. `.dns = .{ .port = 53 }`.
|
||||
|
||||
### upstream
|
||||
|
||||
Timeouts for talking to upstream resolvers.
|
||||
|
||||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||||
|---|---|---|---|---|---|
|
||||
| `upstream.read_timeout_ms` | u32 | 3000 | ms | 100–120000 | read deadline on conditional-forward-zone exchanges (`src/server/handler.zig` via `app.zig`) |
|
||||
| `upstream.total_timeout_ms` | u32 | 5000 | ms | 100–120000, and at least `read_timeout_ms` | per-query budget of the upstream pool (`src/upstream/pool.zig`) — the whole attempt including the connect; also the `nxdns check` probe deadline |
|
||||
|
||||
### dns
|
||||
|
||||
The plain DNS listener (UDP + TCP).
|
||||
|
||||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||||
|---|---|---|---|---|---|
|
||||
| `dns.bind_ipv4` | string | `"0.0.0.0"` | IP address | must parse as an IPv4 address | UDP/TCP listener bind (`src/app.zig`) |
|
||||
| `dns.bind_ipv6` | string | `"::"` | IP address | must parse as an IPv6 address | UDP/TCP listener bind (`src/app.zig`) |
|
||||
| `dns.port` | u16 | 53 | port | 1–65535 | UDP/TCP listener port |
|
||||
| `dns.rate_limit` | u32 | 1000 | queries per window | at least 1 | per-client DNS rate limiter (`src/server/rate_limiter.zig`) |
|
||||
| `dns.rate_window_seconds` | u32 | 60 | seconds | 1–3600 | window of the same limiter |
|
||||
|
||||
### blocking
|
||||
|
||||
What a blocked query gets back.
|
||||
|
||||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||||
|---|---|---|---|---|---|
|
||||
| `blocking.response` | enum `.zero` \| `.nxdomain` | `.zero` | — | one of the two tags | blocked-response synthesis (`src/filter/response.zig`): `.zero` answers 0.0.0.0 / `::`, `.nxdomain` answers NXDOMAIN |
|
||||
| `blocking.ttl` | u32 | 5 | seconds | at most 86400 (0 allowed) | TTL on the synthesized block answer |
|
||||
|
||||
### cache
|
||||
|
||||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||||
|---|---|---|---|---|---|
|
||||
| `cache.size` | u32 | 10000 | entries | none; `0` disables caching (put short-circuits — `src/cache/dns_cache.zig` test "a cache of zero entries stores nothing") | DNS answer cache capacity (`src/cache/dns_cache.zig`) |
|
||||
| `cache.negative_ttl_max` | u32 | 3600 | seconds | at most 86400 | cap on cached negative answers; 0 disables negative caching |
|
||||
|
||||
### web
|
||||
|
||||
The web interface and REST API.
|
||||
|
||||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||||
|---|---|---|---|---|---|
|
||||
| `web.enabled` | bool | true | — | — | gates the whole web stack: server, sessions, SSE hub, API limiter (`src/app.zig`) |
|
||||
| `web.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address | web listener bind (`src/web/server.zig`) |
|
||||
| `web.port` | u16 | 8080 | port | 1–65535 | web listener port |
|
||||
| `web.password` | string | `""` | — | must not be set together with `web.password_hash` | operator input only — see "Authentication" below; never stored, never a settings key |
|
||||
| `web.password_hash` | string | `""` | — | — | argon2id PHC string verified at login (`src/web/auth.zig`); `""` disables authentication |
|
||||
| `web.session_ttl_hours` | u16 | 24 | hours | at least 1 | session expiry and cookie Max-Age (`src/web/auth.zig`) |
|
||||
| `web.api_rate_limit_per_min` | u32 | 300 | requests per minute | at least 1 | API token-bucket limiter (`src/web/api_limiter.zig`) |
|
||||
| `web.api_localhost_exempt` | bool | true | — | — | loopback requests skip the API limiter |
|
||||
| `web.sse_max_connections_per_ip` | u16 | 3 | connections | at least 1 | cap on concurrent SSE streams per client IP |
|
||||
|
||||
### doh_server
|
||||
|
||||
The DNS-over-HTTPS listener (server side, for clients on the LAN).
|
||||
|
||||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||||
|---|---|---|---|---|---|
|
||||
| `doh_server.enabled` | bool | false | — | — | gates the DoH listener (`src/server/doh_server.zig`) |
|
||||
| `doh_server.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address | DoH listener bind |
|
||||
| `doh_server.port` | u16 | 443 | port | 1–65535 | DoH listener port |
|
||||
| `doh_server.cert_path` | string | `"/etc/nxdns/cert.pem"` | path | non-empty when enabled | certificate loaded into the hot-reloading `CertStore`; readability is checked by `nxdns check`, not the validator |
|
||||
| `doh_server.key_path` | string | `"/etc/nxdns/key.pem"` | path | non-empty when enabled | private key for the same; `nxdns check` warns when it is readable beyond its owner |
|
||||
|
||||
### dot_server
|
||||
|
||||
The DNS-over-TLS listener. Same shape as `doh_server`; only the default port
|
||||
differs.
|
||||
|
||||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||||
|---|---|---|---|---|---|
|
||||
| `dot_server.enabled` | bool | false | — | — | gates the DoT listener (`src/server/dot_server.zig`) |
|
||||
| `dot_server.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address | DoT listener bind |
|
||||
| `dot_server.port` | u16 | 853 | port | 1–65535 | DoT listener port |
|
||||
| `dot_server.cert_path` | string | `"/etc/nxdns/cert.pem"` | path | non-empty when enabled | certificate, shared `CertStore` with hot reload |
|
||||
| `dot_server.key_path` | string | `"/etc/nxdns/key.pem"` | path | non-empty when enabled | private key for the same |
|
||||
|
||||
### edns
|
||||
|
||||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||||
|---|---|---|---|---|---|
|
||||
| `edns.ecs_mode` | enum `.strip` \| `.forward` | `.strip` | — | one of the two tags | EDNS Client Subnet handling in the query path (`src/server/handler.zig`, `src/dns/edns.zig`): `.strip` removes the client subnet before forwarding, `.forward` passes it through |
|
||||
|
||||
### logging
|
||||
|
||||
Process log and query log behavior.
|
||||
|
||||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||||
|---|---|---|---|---|---|
|
||||
| `logging.level` | enum `.err` \| `.warn` \| `.info` \| `.debug` | `.info` | — | one of the four tags; stored as `"error"`/`"warn"`/`"info"`/`"debug"` | log threshold (`src/platform/logging.zig`) |
|
||||
| `logging.retention_days` | u16 | 30 | days | at least 1 | query-log pruning cutoff (`src/storage/retention.zig`) and the client tracker's last-seen cutoff (`src/server/clients.zig`) |
|
||||
| `logging.query_log_buffer_max` | u32 | 10000 | entries | at least 1 | in-memory query-log ring size and backpressure cap (`src/storage/logger.zig`) |
|
||||
| `logging.hide_domains` | bool | false | — | — | query log stores a hidden marker instead of the domain |
|
||||
| `logging.hide_client_ips` | bool | false | — | — | query log stores a hidden marker instead of the client IP |
|
||||
| `logging.output` | enum `.stderr` \| `.syslog` \| `.file` | `.stderr` | — | one of the three tags | log sink selection (`src/platform/logging.zig`); `.stderr` and `.syslog` both write to stderr (journald captures it), `.file` rotates |
|
||||
| `logging.file_path` | string | `"/var/log/nxdns/nxdns.log"` | path | when `output` is `.file`: non-empty absolute path | rotating log file; its directory also feeds the disk monitor. The binary does not create the directory |
|
||||
| `logging.max_size_mb` | u32 | 50 | MiB | at least 1 | rotation trigger for the log file |
|
||||
| `logging.max_files` | u8 | 5 | files | at least 1 | rotated generations kept |
|
||||
|
||||
### disk
|
||||
|
||||
Free-space thresholds for the data directory. When free space falls below
|
||||
them, the query-log writer, client tracker and blocklist scheduler are
|
||||
throttled (`src/storage/disk_monitor.zig`).
|
||||
|
||||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||||
|---|---|---|---|---|---|
|
||||
| `disk.min_free_mb` | u32 | 200 | MiB | at least 1, and not above `warn_free_mb` | `critical` threshold |
|
||||
| `disk.warn_free_mb` | u32 | 500 | MiB | at least 1 | `warn` threshold |
|
||||
|
||||
### blocklist_update
|
||||
|
||||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||||
|---|---|---|---|---|---|
|
||||
| `blocklist_update.enabled` | bool | true | — | — | blocklist refresh scheduler (`src/filter/manager.zig`); when false only the startup pass runs |
|
||||
| `blocklist_update.interval_hours` | u16 | 24 | hours | at least 1 | sleep between refresh passes and the per-source staleness test |
|
||||
|
||||
## Collections
|
||||
|
||||
Collections are ZON lists of structs. Fields without a default are required.
|
||||
Runtime columns (first/last seen timestamps, per-source download counters)
|
||||
are deliberately not part of the model: import sets timestamps to the import
|
||||
time, and export omits them, which is what keeps the round trip byte-stable.
|
||||
|
||||
### groups
|
||||
|
||||
Client groups. A group named `default` is **required**; every client not
|
||||
assigned elsewhere lands in it, and import guarantees it keeps database id 1.
|
||||
|
||||
| Field | Type | Default | Validation |
|
||||
|---|---|---|---|
|
||||
| `name` | string | required | non-empty, unique |
|
||||
| `safe_search` | bool | false | — |
|
||||
|
||||
Consumed by the filter engine (`src/filter/matcher.zig`); `safe_search`
|
||||
triggers the safe-search rewrite in the query path.
|
||||
|
||||
### upstreams
|
||||
|
||||
Upstream resolvers. **At least one enabled upstream is required.**
|
||||
|
||||
| Field | Type | Default | Validation |
|
||||
|---|---|---|---|
|
||||
| `url` | string | required | `https://` (DoH) or `tls://` (DoT) endpoint; unique |
|
||||
| `priority` | i32 | 100 | — (lower is tried first) |
|
||||
| `enabled` | bool | true | — |
|
||||
| `tls_name` | string | `""` | DoT only — a `tls_name` on an `https://` upstream is an error; must be a valid domain name |
|
||||
|
||||
Consumed by the upstream pool (`src/upstream/pool.zig`): entries are sorted
|
||||
by ascending priority and tried in order with failover. `tls_name` sets SNI
|
||||
and the certificate verification name for a `tls://` upstream written as an
|
||||
IP literal; empty means "verify by the URL host"
|
||||
(`src/upstream/dot_client.zig`).
|
||||
|
||||
### clients
|
||||
|
||||
Known clients with a fixed group assignment.
|
||||
|
||||
| Field | Type | Default | Validation |
|
||||
|---|---|---|---|
|
||||
| `ip` | string | required | IP address; unique after canonicalization (`FD00::1` and `fd00:0:0:0:0:0:0:1` collide) |
|
||||
| `name` | string | `""` | — (display only, never read by the resolver) |
|
||||
| `group` | string | `"default"` | must name a declared group |
|
||||
|
||||
Consumed by the filter engine's exact IP → group lookup
|
||||
(`src/filter/matcher.zig`).
|
||||
|
||||
### client_prefixes
|
||||
|
||||
Group assignment by CIDR prefix, for clients without an exact entry.
|
||||
|
||||
| Field | Type | Default | Validation |
|
||||
|---|---|---|---|
|
||||
| `prefix` | string | required | CIDR prefix (`192.168.2.0/24`, `fd00:abcd::/48`); unique after canonicalization |
|
||||
| `group` | string | `"default"` | must name a declared group |
|
||||
| `priority` | i32 | 100 | — (ties on match are broken by lower priority) |
|
||||
|
||||
Consumed by the filter engine's longest-prefix match
|
||||
(`src/filter/matcher.zig`).
|
||||
|
||||
### blocklist_sources
|
||||
|
||||
Downloadable blocklists.
|
||||
|
||||
| Field | Type | Default | Validation |
|
||||
|---|---|---|---|
|
||||
| `url` | string | required | `http://` or `https://` URL with a host; unique |
|
||||
| `name` | string | required | non-empty |
|
||||
| `enabled` | bool | true | — |
|
||||
| `is_suggested` | bool | false | — (web UI hint only, never read by the resolver) |
|
||||
|
||||
Consumed by the blocklist manager (`src/filter/manager.zig`): downloaded by
|
||||
the fetcher, compiled into domain sets; a disabled source is neither
|
||||
downloaded nor loaded.
|
||||
|
||||
### group_sources
|
||||
|
||||
Which groups consult which blocklist sources.
|
||||
|
||||
| Field | Type | Default | Validation |
|
||||
|---|---|---|---|
|
||||
| `group` | string | required | must name a declared group |
|
||||
| `source_url` | string | required | must name a declared blocklist source's `url`; the (group, source_url) pair is unique |
|
||||
|
||||
Consumed by the filter engine when assembling each group's compiled domain
|
||||
sets (`src/filter/matcher.zig`). A link to a disabled source is silently
|
||||
skipped.
|
||||
|
||||
### rules
|
||||
|
||||
Per-group allow/block overrides, checked before the blocklists.
|
||||
|
||||
| Field | Type | Default | Validation |
|
||||
|---|---|---|---|
|
||||
| `group` | string | required | must name a declared group |
|
||||
| `pattern` | string | required | see below |
|
||||
| `kind` | enum `.exact` \| `.wildcard` | required | — |
|
||||
| `action` | enum `.allow` \| `.block` | required | — |
|
||||
|
||||
Pattern rules: an `.exact` pattern is a plain domain name and may not contain
|
||||
`*`; a `.wildcard` pattern must contain at least one label that is exactly
|
||||
`*` (`*.tracker.example`, or `*` alone), and every other label must be a
|
||||
legal DNS label. `ads*.example` is not a valid wildcard.
|
||||
|
||||
Consumed by the filter engine's rule sets (`src/filter/rules.zig`).
|
||||
|
||||
### local_records
|
||||
|
||||
Local DNS answers, served without touching any upstream.
|
||||
|
||||
| Field | Type | Default | Validation |
|
||||
|---|---|---|---|
|
||||
| `name` | string | required | valid domain name |
|
||||
| `rtype` | enum `.a` \| `.aaaa` \| `.cname` | required | stored as `A`/`AAAA`/`CNAME` |
|
||||
| `value` | string | required | IPv4 address for `.a`, IPv6 for `.aaaa`, domain name for `.cname` |
|
||||
| `ttl` | u32 | 300 | 1–604800 seconds |
|
||||
|
||||
The (name, rtype, value) triple is unique. Consumed by the local records
|
||||
table in the query path (`src/local/records.zig`).
|
||||
|
||||
### forward_zones
|
||||
|
||||
Zones resolved by a specific resolver instead of the configured upstreams —
|
||||
for LAN or corporate domains.
|
||||
|
||||
| Field | Type | Default | Validation |
|
||||
|---|---|---|---|
|
||||
| `zone` | string | required | valid domain name; unique |
|
||||
| `resolver` | string | required | `udp://IP:port` or `tcp://IP:port`; the host must be an IP literal and the port is mandatory |
|
||||
|
||||
The resolver host must be an IP literal because resolving the resolver's own
|
||||
name would be a bootstrap problem. Matching is longest suffix
|
||||
(`src/local/forward_zones.zig`); the exchange is UDP-then-TCP
|
||||
(`src/local/forward_client.zig`) with `upstream.read_timeout_ms` as the read
|
||||
deadline.
|
||||
|
||||
## Authentication: web.password vs web.password_hash
|
||||
|
||||
Exactly one of the two may be set; setting both is refused
|
||||
(`PasswordAndHashBothSet` — ambiguity in a security setting).
|
||||
|
||||
- `web.password` is operator input only. At import time it is hashed with
|
||||
argon2id (OWASP parameters: t=2, m=19 MiB, p=1, PHC encoding) into
|
||||
`web.password_hash` and discarded. It is never stored — there is no
|
||||
`web.password` settings row, and `nxdns export` always writes
|
||||
`.password = ""`.
|
||||
- `web.password_hash` is the stored argon2id PHC string. Supplying it
|
||||
directly (for example from a previous export) is how a backup restores
|
||||
authentication without knowing the password.
|
||||
- Both empty disables web authentication entirely.
|
||||
|
||||
Because the export carries the hash and re-importing an exported file takes
|
||||
the "password is empty" branch, the export/import round trip preserves the
|
||||
hash byte-for-byte.
|
||||
|
||||
## Minimal working example
|
||||
|
||||
The smallest file that passes validation: a `default` group and one enabled
|
||||
upstream. Everything else keeps its default.
|
||||
|
||||
```zon
|
||||
.{
|
||||
.groups = .{ .{ .name = "default" } },
|
||||
.upstreams = .{ .{ .url = "https://dns.quad9.net/dns-query" } },
|
||||
}
|
||||
```
|
||||
|
||||
## Fuller annotated example
|
||||
|
||||
```zon
|
||||
.{
|
||||
// Plain DNS on the standard port, rate-limited per client.
|
||||
.dns = .{
|
||||
.bind_ipv4 = "0.0.0.0",
|
||||
.bind_ipv6 = "::",
|
||||
.port = 53,
|
||||
.rate_limit = 1000,
|
||||
.rate_window_seconds = 60,
|
||||
},
|
||||
|
||||
// Blocked queries answer 0.0.0.0 / :: with a 5 second TTL.
|
||||
.blocking = .{ .response = .zero, .ttl = 5 },
|
||||
|
||||
.cache = .{ .size = 10000, .negative_ttl_max = 3600 },
|
||||
|
||||
// Web UI on 8080. The password is hashed at import and never stored;
|
||||
// leave .password_hash out when setting .password (they are exclusive).
|
||||
.web = .{
|
||||
.enabled = true,
|
||||
.port = 8080,
|
||||
.password = "correct horse battery staple",
|
||||
.session_ttl_hours = 24,
|
||||
},
|
||||
|
||||
// Serve DoT to the LAN. The key file should be mode 0600.
|
||||
.dot_server = .{
|
||||
.enabled = true,
|
||||
.port = 853,
|
||||
.cert_path = "/etc/nxdns/cert.pem",
|
||||
.key_path = "/etc/nxdns/key.pem",
|
||||
},
|
||||
|
||||
// Strip EDNS Client Subnet before forwarding (the default).
|
||||
.edns = .{ .ecs_mode = .strip },
|
||||
|
||||
// ".err" in the file; the settings API shows it as "error".
|
||||
.logging = .{ .level = .err, .retention_days = 14 },
|
||||
|
||||
.blocklist_update = .{ .enabled = true, .interval_hours = 24 },
|
||||
|
||||
// "default" is mandatory. Additional groups get their own rules,
|
||||
// blocklists and safe-search flag.
|
||||
.groups = .{
|
||||
.{ .name = "default" },
|
||||
.{ .name = "kids", .safe_search = true },
|
||||
},
|
||||
|
||||
// Lower priority is tried first; the second entry is a failover.
|
||||
// tls_name is needed when a tls:// upstream is written as an IP
|
||||
// literal, so certificate verification has a DNS name to match.
|
||||
.upstreams = .{
|
||||
.{ .url = "https://dns.quad9.net/dns-query", .priority = 10 },
|
||||
.{ .url = "tls://9.9.9.9:853", .priority = 20, .tls_name = "dns.quad9.net" },
|
||||
},
|
||||
|
||||
// Exact client assignments win over prefixes.
|
||||
.clients = .{
|
||||
.{ .ip = "192.168.1.20", .name = "tablet", .group = "kids" },
|
||||
},
|
||||
.client_prefixes = .{
|
||||
.{ .prefix = "192.168.2.0/24", .group = "kids" },
|
||||
},
|
||||
|
||||
.blocklist_sources = .{
|
||||
.{ .url = "https://lists.example/ads.txt", .name = "ads" },
|
||||
},
|
||||
.group_sources = .{
|
||||
.{ .group = "kids", .source_url = "https://lists.example/ads.txt" },
|
||||
},
|
||||
|
||||
// Overrides beat blocklists. Wildcards need a label that is exactly "*".
|
||||
.rules = .{
|
||||
.{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
|
||||
.{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
|
||||
},
|
||||
|
||||
// Local names, answered without any upstream.
|
||||
.local_records = .{
|
||||
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" },
|
||||
.{ .name = "www.lan", .rtype = .cname, .value = "nas.lan" },
|
||||
},
|
||||
|
||||
// Everything under corp.lan goes to the LAN resolver directly.
|
||||
.forward_zones = .{
|
||||
.{ .zone = "corp.lan", .resolver = "udp://192.168.1.1:53" },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user