runbound 0.6.6

A DNS server. Just for fun.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2024-2026 RedLemonBe — https://github.com/redlemonbe/Runbound
use anyhow::{Context, Result};
use tracing::warn;

#[derive(Debug, Clone)]
pub struct LocalZone {
    pub name: String,
    pub zone_type: String, // "refuse", "always_nxdomain", "static", etc.
}

#[derive(Debug, Clone)]
pub struct LocalData {
    pub rr: String, // raw RR string, e.g. "host.local. A 192.168.1.1"
}

#[derive(Debug, Clone)]
pub struct ForwardZone {
    pub name: String,
    pub addrs: Vec<String>,
    /// Send queries over DNS-over-TLS (port 853) instead of plain UDP/TCP.
    pub tls: bool,
}

#[derive(Debug, Clone, Default)]
pub struct TlsConfig {
    pub cert_path: Option<String>,
    pub key_path: Option<String>,
    /// DNS-over-TLS port (RFC 7858) — default 853
    pub dot_port: Option<u16>,
    /// DNS-over-HTTPS port (RFC 8484) — default 443
    pub doh_port: Option<u16>,
    /// DNS-over-QUIC port (RFC 9250) — default 853 UDP
    pub doq_port: Option<u16>,
    /// Hostname sent in TLS SNI / DoH path
    pub hostname: Option<String>,
    /// Path to CA cert PEM for DoT mutual TLS client authentication (HIGH-08).
    /// When set, DoT clients must present a certificate signed by this CA.
    /// DoH and DoQ are unaffected (they authenticate via the application layer).
    pub dot_client_auth_ca: Option<String>,
}

#[derive(Debug, Clone, Default)]
pub struct UnboundConfig {
    pub interfaces: Vec<String>,
    pub port: u16,
    pub access_control: Vec<String>,
    pub local_zones: Vec<LocalZone>,
    pub local_data: Vec<LocalData>,
    pub forward_zones: Vec<ForwardZone>,
    pub verbosity: u8,
    pub logfile: Option<String>,
    pub pidfile: Option<String>,
    pub do_ipv4: bool,
    pub do_ipv6: bool,
    pub do_udp: bool,
    pub do_tcp: bool,
    pub tls: TlsConfig,
    /// DNS rate limit in queries/second per source IP.
    /// Overrides the compiled default (200). Set to 5000+ for shared resolvers.
    pub rate_limit: Option<u64>,
    /// REST API key. Overridden by RUNBOUND_API_KEY env var if both are set.
    pub api_key: Option<String>,
    /// REST API port. Default: 8081.
    pub api_port: Option<u16>,
    /// Maximum TTL cap for cached records (seconds). Default: 86400 (24 h).
    pub cache_max_ttl: Option<u32>,
    /// Minimum number of cache entries during memory pressure halvings. Default: 2048.
    /// The cache halving mechanism will never reduce the cache below this value.
    pub cache_min_entries: usize,
    /// CIDR ranges that must never appear in resolver responses (DNS rebinding guard).
    pub private_addresses: Vec<String>,
    /// Enable DNSSEC validation. Default: false (forwarder mode — trust upstream AD bit).
    /// Set to `yes` for recursive/authoritative deployments with full RRSIG chains.
    pub dnssec_validation: bool,
    /// Log WARN for every DNSSEC-bogus query when dnssec-validation is enabled.
    pub dnssec_log_bogus: bool,

    // ── GDPR / privacy controls ────────────────────────────────────────────
    /// Max entries in the in-RAM query log ring buffer. Default: 1000. 0 = disabled.
    /// Reduce or set to 0 if your data-retention policy requires it.
    pub log_retention: usize,
    /// Include client IPs in /logs and logfile. Default: true.
    /// Set to `no` to replace IPs with "[redacted]" (does not apply to audit log).
    pub log_client_ip: bool,

    // ── Audit log ─────────────────────────────────────────────────────────────
    /// Enable immutable HMAC-chained audit log. Default: false.
    pub audit_log: bool,
    /// Path to audit log file. Default: base_dir/audit.log.
    pub audit_log_path: Option<String>,
    /// HMAC-SHA256 key (hex or raw). Auto-generated if empty.
    pub audit_log_hmac_key: Option<String>,

    // ── Slave/master sync (Runbound extensions) ────────────────────────────
    /// Node role: "master" (default) or "slave".
    pub mode: String,
    /// Master only: port for the HTTPS sync server (e.g. 8082). Disabled if absent.
    pub sync_port: Option<u16>,
    /// Slave only: master IP:port (e.g. "192.168.1.10:8082").
    pub sync_master: Option<String>,
    /// Slave only: Bearer token for authenticating to the master sync API.
    pub sync_key: Option<String>,
    /// Slave only: sync interval in seconds. Default: 30.
    pub sync_interval: u64,

    // ── ACME (Let's Encrypt) ───────────────────────────────────────────────
    /// Contact email for Let's Encrypt account.
    pub acme_email: Option<String>,
    /// Domain names to include in the cert (can appear multiple times).
    pub acme_domains: Vec<String>,
    /// Directory to store ACME account credentials and temp files.
    pub acme_cache_dir: Option<String>,
    /// Use Let's Encrypt Staging API (for testing). Default: false.
    pub acme_staging: bool,
    /// Port for the HTTP-01 challenge server. Default: 80.
    pub acme_challenge_port: Option<u16>,

    // ── HSM (Hardware Security Module) via PKCS#11 ────────────────────────
    /// Path to the PKCS#11 shared library (.so). HSM is disabled when absent.
    /// Example: /usr/lib/softhsm/libsofthsm2.so
    pub hsm_pkcs11_lib: Option<String>,
    /// PKCS#11 slot index (0-based). Default: 0.
    pub hsm_slot: u64,
    /// PKCS#11 PIN. Prefer the HSM_PIN environment variable (chmod 640).
    pub hsm_pin: Option<String>,
    /// Label of the CKO_SECRET_KEY object used as the REST API Bearer token.
    pub hsm_api_key_label: Option<String>,
    /// Label of the CKO_SECRET_KEY object used as the JSON store HMAC key.
    pub hsm_store_key_label: Option<String>,

    // ── Performance ───────────────────────────────────────────────────────────
    /// Pin each tokio worker thread to a distinct physical core (HT excluded).
    /// Default: true. Set to `no` to disable (e.g. in containers without CAP_SYS_NICE).
    pub cpu_affinity: bool,
    /// Enable AF/XDP kernel-bypass fast path. Default: true (when compiled with xdp feature).
    /// Set to `no` in unbound.conf, or pass `--no-xdp` on the command line, to disable.
    pub xdp: bool,

    // ── DNS prefetching ───────────────────────────────────────────────────────
    /// Pre-resolve popular domains before their cache entry expires. Default: false.
    pub prefetch: bool,
    /// Minimum forwarded-query count per window to qualify for prefetch. Default: 5.
    pub prefetch_threshold: u32,

    // ── API safety ────────────────────────────────────────────────────────────
    /// Minimum seconds between two consecutive POST /api/cache/flush calls.
    /// 0 disables the cooldown entirely. Default: 60.
    pub cache_flush_cooldown: u64,
}

impl UnboundConfig {
    pub fn defaults() -> Self {
        Self {
            interfaces:    vec![],   // empty = bind 0.0.0.0 in server.rs
            port:          53,
            verbosity:     1,        // WARN — per-query logs off by default
            do_ipv4:       true,
            do_ipv6:       true,
            do_udp:        true,
            do_tcp:        true,
            mode:          "master".to_string(),
            sync_interval: 30,
            log_retention: 1000,
            log_client_ip: true,
            cpu_affinity:       true,
            xdp:                true,
            cache_min_entries:  2048,
            prefetch:             false,
            prefetch_threshold:   5,
            cache_flush_cooldown: 60,
            ..Default::default()
        }
    }

    pub fn is_slave(&self) -> bool { self.mode == "slave" }
    pub fn is_master(&self) -> bool { !self.is_slave() }
}

pub fn parse_file(path: &str) -> Result<UnboundConfig> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("Cannot read unbound config: {}", path))?;
    parse_str(&content)
}

pub fn parse_str(content: &str) -> Result<UnboundConfig> {
    let mut cfg = UnboundConfig::defaults();
    let mut current_forward: Option<ForwardZone> = None;
    let mut current_section = String::new();

    for (lineno, raw) in content.lines().enumerate() {
        let line = raw.split('#').next().unwrap_or("").trim();
        if line.is_empty() {
            continue;
        }

        // Section header
        if line.ends_with(':') && !line.contains(' ') {
            if let Some(fwd) = current_forward.take() {
                cfg.forward_zones.push(fwd);
            }
            current_section = line.trim_end_matches(':').to_string();
            continue;
        }

        let Some((key, val)) = line.split_once(':') else {
            warn!("Line {}: cannot parse '{}' — ignored", lineno + 1, line);
            continue;
        };
        let key = key.trim();
        // Do NOT strip quotes globally — directives like local-zone have complex quoted values.
        // Each handler strips its own quotes where needed.
        let val = val.trim();

        match current_section.as_str() {
            "server" => parse_server_directive(&mut cfg, key, val, lineno + 1),
            "forward-zone" => {
                let fwd = current_forward.get_or_insert_with(|| ForwardZone {
                    name: String::new(),
                    addrs: Vec::new(),
                    tls: false,
                });
                match key {
                    "name"           => fwd.name = val.trim_matches('"').to_string(),
                    "forward-addr"   => fwd.addrs.push(val.trim_matches('"').to_string()),
                    "forward-tls-upstream" => fwd.tls = val.trim() == "yes",
                    other => warn!("Line {}: unknown forward-zone directive '{}' — ignored", lineno + 1, other),
                }
            }
            other => {
                warn!("Line {}: unknown section '{}' — ignored", lineno + 1, other);
            }
        }
    }

    if let Some(fwd) = current_forward {
        cfg.forward_zones.push(fwd);
    }

    Ok(cfg)
}

/// LOW-03: cap on local-zone / local-data to prevent DoS via pathological configs.
const MAX_LOCAL_ZONES: usize = 1_000_000;
const MAX_LOCAL_DATA:  usize = 1_000_000;

fn parse_server_directive(cfg: &mut UnboundConfig, key: &str, val: &str, lineno: usize) {
    // Mapping intentionnel 1:1 avec la syntaxe unbound.conf.
    // Volumineux par design — chaque directive Unbound correspond
    // à une assignation. Ne pas refactorer en table générique
    // pour conserver la lisibilité directive par directive.
    match key {
        "interface"      => cfg.interfaces.push(val.to_string()),
        "port"           => cfg.port = val.parse().unwrap_or(53),
        "access-control" => cfg.access_control.push(val.to_string()),
        "local-zone"     => {
            if cfg.local_zones.len() >= MAX_LOCAL_ZONES {
                warn!("Line {}: local-zone limit ({MAX_LOCAL_ZONES}) reached — entry ignored", lineno);
                return;
            }
            // Format: "name." type  OR  name. type  (with or without quotes)
            // Strip optional leading quote, then split name from type
            let raw = val.trim_start_matches('"');
            if let Some(pos) = raw.find("\" ").or_else(|| raw.find(' ')) {
                let (name, rest) = raw.split_at(pos);
                let name = name.trim_matches('"').trim();
                let zone_type = rest.trim_matches('"').trim();
                if !name.is_empty() && !zone_type.is_empty() {
                    cfg.local_zones.push(LocalZone {
                        name: name.to_string(),
                        zone_type: zone_type.to_string(),
                    });
                }
            }
        }
        "local-data"     => {
            if cfg.local_data.len() >= MAX_LOCAL_DATA {
                warn!("Line {}: local-data limit ({MAX_LOCAL_DATA}) reached — entry ignored", lineno);
                return;
            }
            // Format: "name. TYPE value"  (entire RR is quoted)
            let rr = val.trim_matches('"').trim().to_string();
            if !rr.is_empty() {
                cfg.local_data.push(LocalData { rr });
            }
        }
        "verbosity"      => cfg.verbosity = val.parse().unwrap_or(1),
        "logfile"        => cfg.logfile = Some(val.to_string()),
        "pidfile"        => cfg.pidfile = Some(val.to_string()),
        "do-ip4"         => cfg.do_ipv4 = val == "yes",
        "do-ip6"         => cfg.do_ipv6 = val == "yes",
        "do-udp"         => cfg.do_udp  = val == "yes",
        "do-tcp"         => cfg.do_tcp  = val == "yes",
        // TLS — DoT / DoH / DoQ (Runbound extensions, ignored by real Unbound)
        "tls-service-pem" | "tls-cert-bundle" => cfg.tls.cert_path = Some(val.trim_matches('"').to_string()),
        "tls-service-key"                      => cfg.tls.key_path  = Some(val.trim_matches('"').to_string()),
        "tls-port"   => cfg.tls.dot_port  = val.parse().ok(),
        "https-port" => cfg.tls.doh_port  = val.parse().ok(),
        "quic-port"  => cfg.tls.doq_port  = val.parse().ok(),
        "tls-cert-hostname" | "server-hostname" => cfg.tls.hostname = Some(val.trim_matches('"').to_string()),
        "dot-client-auth-ca" => cfg.tls.dot_client_auth_ca = Some(val.trim_matches('"').to_string()),
        // Runbound-specific extensions (not in stock Unbound)
        "rate-limit"    => cfg.rate_limit = val.parse::<u64>().ok()
                               .map(|v| v.min(1_000_000)), // cap at 1M rps — u64::MAX silently disables
        "api-key"       => {
            warn!(
                "api-key is set in the config file (plaintext). \
                 Prefer the RUNBOUND_API_KEY environment variable — \
                 set it in /etc/runbound/env (chmod 640) to keep the key \
                 out of config files and version control."
            );
            cfg.api_key = Some(val.trim_matches('"').to_string());
        }
        "api-port"      => cfg.api_port      = val.parse().ok(),
        "cache-max-ttl"      => cfg.cache_max_ttl      = val.parse().ok(),
        "cache-min-entries"  => cfg.cache_min_entries  = val.parse::<usize>().unwrap_or(2048).max(1),
        "private-address" => {
            let cidr = val.trim_matches('"').trim().to_string();
            if !cidr.is_empty() { cfg.private_addresses.push(cidr); }
        }
        "dnssec-validation" => cfg.dnssec_validation = val.trim_matches('"') == "yes",
        "dnssec-log-bogus"  => cfg.dnssec_log_bogus  = val.trim_matches('"') == "yes",
        "log-retention"     => cfg.log_retention      = val.parse().unwrap_or(1000),
        "log-client-ip"     => cfg.log_client_ip      = val.trim_matches('"') != "no",
        "audit-log"          => cfg.audit_log          = val.trim_matches('"') == "yes",
        "audit-log-path"     => cfg.audit_log_path     = Some(val.trim_matches('"').to_string()),
        "audit-log-hmac-key" => cfg.audit_log_hmac_key = Some(val.trim_matches('"').to_string()),
        // Slave/master sync directives
        "mode"          => cfg.mode          = val.trim_matches('"').to_string(),
        "sync-port"     => cfg.sync_port     = val.parse().ok(),
        "sync-master"   => cfg.sync_master   = Some(val.trim_matches('"').to_string()),
        "sync-key"      => cfg.sync_key      = Some(val.trim_matches('"').to_string()),
        "sync-interval" => cfg.sync_interval = val.parse().unwrap_or(30),
        // ACME / Let's Encrypt
        "acme-email"          => cfg.acme_email          = Some(val.trim_matches('"').to_string()),
        "acme-domain"         => cfg.acme_domains.push(val.trim_matches('"').to_string()),
        "acme-cache-dir"      => cfg.acme_cache_dir      = Some(val.trim_matches('"').to_string()),
        "acme-staging"        => cfg.acme_staging        = val.trim_matches('"') == "yes",
        "acme-challenge-port" => cfg.acme_challenge_port = val.parse().ok(),
        // HSM / PKCS#11
        "hsm-pkcs11-lib"      => cfg.hsm_pkcs11_lib  = Some(val.trim_matches('"').to_string()),
        "hsm-slot"            => cfg.hsm_slot         = val.parse().unwrap_or(0),
        "hsm-pin"             => {
            warn!(
                "hsm-pin is set in the config file (plaintext). \
                 Prefer the HSM_PIN environment variable — \
                 set it in /etc/runbound/env (chmod 640) to keep the PIN \
                 out of config files and version control."
            );
            cfg.hsm_pin = Some(val.trim_matches('"').to_string());
        }
        "hsm-api-key-label"   => cfg.hsm_api_key_label   = Some(val.trim_matches('"').to_string()),
        "hsm-store-key-label" => cfg.hsm_store_key_label = Some(val.trim_matches('"').to_string()),
        "cpu-affinity"        => cfg.cpu_affinity        = val.trim_matches('"') != "no",
        "xdp"                 => cfg.xdp                 = val.trim_matches('"') != "no",
        "prefetch"              => cfg.prefetch              = val.trim_matches('"') == "yes",
        "prefetch-threshold"    => cfg.prefetch_threshold    = val.parse().unwrap_or(5),
        "cache-flush-cooldown"  => cfg.cache_flush_cooldown  = val.parse().unwrap_or(60),
        // Accepted but unused — common Unbound tuning directives
        "num-threads" | "cache-size" | "msg-cache-size" | "rrset-cache-size"
        | "so-rcvbuf" | "so-sndbuf" | "outgoing-range" | "num-queries-per-thread"
        | "infra-cache-slabs" | "key-cache-slabs" | "msg-cache-slabs"
        | "rrset-cache-slabs" | "prefetch-key"
        | "use-syslog" | "log-queries" | "log-replies"
        | "hide-identity" | "hide-version" | "identity" | "version"
        | "username" | "chroot" | "directory"
        | "auto-trust-anchor-file" | "val-log-level"
        | "harden-glue" | "harden-dnssec-stripped"
        | "unwanted-reply-threshold" | "private-domain"
        => {} // silently accepted
        other => warn!("Line {}: unknown server directive '{}' — ignored", lineno, other),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── FEAT #16: prefetch config parsing ─────────────────────────────────

    #[test]
    fn prefetch_defaults_to_false() {
        let cfg = parse_str("server:\n").unwrap();
        assert!(!cfg.prefetch);
        assert_eq!(cfg.prefetch_threshold, 5);
    }

    #[test]
    fn prefetch_yes_enables_prefetch() {
        let cfg = parse_str("server:\n  prefetch: yes\n").unwrap();
        assert!(cfg.prefetch);
    }

    #[test]
    fn prefetch_no_disables_prefetch() {
        let cfg = parse_str("server:\n  prefetch: no\n").unwrap();
        assert!(!cfg.prefetch);
    }

    #[test]
    fn prefetch_threshold_parsed() {
        let cfg = parse_str("server:\n  prefetch-threshold: 10\n").unwrap();
        assert_eq!(cfg.prefetch_threshold, 10);
    }

    #[test]
    fn prefetch_threshold_invalid_falls_back_to_default() {
        let cfg = parse_str("server:\n  prefetch-threshold: notanumber\n").unwrap();
        assert_eq!(cfg.prefetch_threshold, 5);
    }
}