Skip to main content

dnslib/control_plane/
config.rs

1use std::{
2    collections::{BTreeMap, HashSet},
3    env,
4    net::IpAddr,
5    path::{Path, PathBuf},
6};
7
8use hickory_resolver::Resolver;
9use serde::{Deserialize, Serialize};
10
11use crate::control_plane::policy::PolicyRule;
12use crate::core::error::{Error, Result};
13use crate::core::secret::ApiToken;
14
15pub const TECHNITIUM_DEFAULT_BASE_URL: &str = "http://localhost:5380";
16pub const PANGOLIN_DEFAULT_BASE_URL: &str = "https://api.pangolin.net/v1";
17pub const CLOUDFLARE_DEFAULT_BASE_URL: &str = "https://api.cloudflare.com/client/v4";
18pub const UNIFI_DEFAULT_BASE_URL: &str = "https://192.168.1.1/proxy/network/integration/v1";
19pub const PIHOLE_DEFAULT_BASE_URL: &str = "http://pi.hole";
20
21const CLOUDFLARE_RESOLVER_IP: &str = "1.1.1.1";
22const CLOUDFLARE_RESOLVER_NAME: &str = "cloudflare-dns.com";
23const CLOUDFLARE_DOH_URL: &str = "https://cloudflare-dns.com/dns-query";
24
25/// Supported DNS vendor backends.
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
27#[serde(rename_all = "lowercase")]
28pub enum VendorKind {
29    #[default]
30    Technitium,
31    Pangolin,
32    Cloudflare,
33    Unifi,
34    Pihole,
35}
36
37/// Whether the DNS server is on a local network or an external/cloud service.
38///
39/// When omitted from config, the value is inferred from the base URL:
40/// `localhost` and private-range IPs → `local`; everything else → `external`.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
42#[serde(rename_all = "lowercase")]
43pub enum ServerLocation {
44    Local,
45    External,
46}
47
48/// Transport used to query a DNS endpoint.
49///
50/// `Doq` is always available as a tag for `[servers.doq]` blocks and as a
51/// CLI flag for the `dns query` subcommand, but the resolver path is
52/// gated behind the `doq` Cargo feature; without it, queries over DoQ
53/// return `ValidationFailureKind::UnsupportedTransport`.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
55#[serde(rename_all = "lowercase")]
56pub enum ValidationTransport {
57    Dns,
58    Doh,
59    Dot,
60    Doq,
61}
62
63/// Configured role for writes across a logical cluster.
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum ClusterWritePolicy {
67    #[default]
68    PrimaryOnly,
69}
70
71/// Plain DNS query endpoint for a configured server.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct DnsTransportConfig {
75    #[serde(default = "default_true")]
76    pub enabled: bool,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub addr: Option<String>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub timeout_ms: Option<u64>,
81}
82
83/// DNS-over-TLS query endpoint for a configured server.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct DotTransportConfig {
87    #[serde(default = "default_true")]
88    pub enabled: bool,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub addr: Option<String>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub server_name: Option<String>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub timeout_ms: Option<u64>,
95}
96
97/// DNS-over-HTTPS query endpoint for a configured server.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct DohTransportConfig {
101    #[serde(default = "default_true")]
102    pub enabled: bool,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub url: Option<String>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub addr: Option<String>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub server_name: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub timeout_ms: Option<u64>,
111}
112
113/// DNS-over-QUIC query endpoint for a configured server.
114///
115/// Parsed and round-tripped on every build so configs are portable.
116/// The actual resolver wiring is gated behind the `doq` Cargo feature;
117/// without it, attempts to query this endpoint return
118/// `ValidationFailureKind::UnsupportedTransport`.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(deny_unknown_fields)]
121pub struct DoqTransportConfig {
122    #[serde(default = "default_true")]
123    pub enabled: bool,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub addr: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub server_name: Option<String>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub timeout_ms: Option<u64>,
130}
131
132/// Logical cluster policy shared by member servers.
133///
134/// `primary` and `preferred_writer` accept either a configured DNS server id or
135/// the special value `auto`, matched case-insensitively.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct ClusterConfig {
139    #[serde(default)]
140    pub vendor: VendorKind,
141    #[serde(default)]
142    pub members: Vec<String>,
143    #[serde(default)]
144    pub write_policy: ClusterWritePolicy,
145    /// Primary server id, or `auto` to discover it dynamically.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub primary: Option<String>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub catalog_zone: Option<String>,
150    /// Preferred writer server id, or `auto` to discover it dynamically.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub preferred_writer: Option<String>,
153}
154
155/// DNS endpoint used to validate imported or listed records.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(deny_unknown_fields)]
158pub struct ValidationEndpointConfig {
159    pub name: String,
160
161    pub transport: ValidationTransport,
162
163    #[serde(default)]
164    pub address: String,
165
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub port: Option<u16>,
168
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub url: Option<String>,
171
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub tls_server_name: Option<String>,
174
175    #[serde(default = "default_true")]
176    pub enabled: bool,
177
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub timeout_ms: Option<u64>,
180}
181
182impl std::str::FromStr for ValidationEndpointConfig {
183    type Err = String;
184
185    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
186        let mut parts = value.splitn(3, ':');
187        let name = parts
188            .next()
189            .filter(|part| !part.trim().is_empty())
190            .ok_or_else(|| "validation endpoint must use name:transport:address".to_string())?;
191        let transport = match parts.next().map(str::to_ascii_lowercase).as_deref() {
192            Some("dns") => ValidationTransport::Dns,
193            Some("doh") => ValidationTransport::Doh,
194            Some("dot") => ValidationTransport::Dot,
195            Some("doq") => ValidationTransport::Doq,
196            Some(other) => {
197                return Err(format!(
198                    "unsupported validation endpoint transport '{other}'; expected dns, doh, dot, or doq"
199                ));
200            }
201            None => return Err("validation endpoint must use name:transport:address".to_string()),
202        };
203        let target = parts
204            .next()
205            .filter(|part| !part.trim().is_empty())
206            .ok_or_else(|| "validation endpoint must use name:transport:address".to_string())?;
207
208        Ok(ValidationEndpointConfig {
209            name: name.to_string(),
210            transport,
211            address: if matches!(transport, ValidationTransport::Doh) {
212                String::new()
213            } else {
214                target.to_string()
215            },
216            port: None,
217            url: if matches!(transport, ValidationTransport::Doh) {
218                Some(target.to_string())
219            } else {
220                None
221            },
222            tls_server_name: None,
223            enabled: true,
224            timeout_ms: None,
225        })
226    }
227}
228
229#[derive(Debug, Clone, Default, Serialize, Deserialize)]
230#[serde(deny_unknown_fields)]
231pub struct AppConfig {
232    #[serde(default)]
233    pub servers: Vec<DnsServerConfig>,
234    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
235    pub clusters: BTreeMap<String, ClusterConfig>,
236
237    /// Named record-sync profiles (see `dns sync`).
238    #[serde(default, skip_serializing_if = "Vec::is_empty")]
239    pub sync: Vec<SyncProfile>,
240}
241
242/// A named record-sync profile: copy records from one configured server to
243/// another, optionally rewriting IP addresses on A/AAAA records.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct SyncProfile {
247    /// Unique profile name, invoked as `dns sync <name>`.
248    pub name: String,
249
250    /// Source server id — must match a `[[servers]]` entry.
251    pub from: String,
252
253    /// Destination server id — must match a `[[servers]]` entry.
254    pub to: String,
255
256    /// Zones to sync. Empty means every zone found on the source server.
257    #[serde(default)]
258    pub zones: Vec<String>,
259
260    /// Explicit `source = destination` IP rewrites applied to A/AAAA records.
261    #[serde(default)]
262    pub ip_map: std::collections::BTreeMap<String, String>,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266#[serde(from = "DnsServerConfigRaw")]
267pub struct DnsServerConfig {
268    pub id: String,
269
270    #[serde(default)]
271    pub vendor: VendorKind,
272
273    /// Whether this server is on a local network or an external/cloud service.
274    /// Inferred from the base URL when omitted.
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub location: Option<ServerLocation>,
277
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub base_url: Option<String>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub base_url_env: Option<String>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub token: Option<String>,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub token_env: Option<String>,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub org_id: Option<String>,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub cluster: Option<String>,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub dns: Option<DnsTransportConfig>,
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub dot: Option<DotTransportConfig>,
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub doh: Option<DohTransportConfig>,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub doq: Option<DoqTransportConfig>,
298
299    #[serde(default, skip_serializing_if = "McpPermissions::is_default")]
300    pub mcp: McpPermissions,
301
302    #[serde(default, skip_serializing_if = "Vec::is_empty")]
303    pub validation_endpoints: Vec<ValidationEndpointConfig>,
304}
305
306/// Intermediate struct used only for TOML deserialization.
307///
308/// Accepts `mcp_readonly` and `mcp_allowed_zones` directly on the server entry
309/// (flat format) in addition to the nested `[servers.mcp]` table, then
310/// merges them into `McpPermissions` via the `From` impl.
311#[derive(Deserialize)]
312#[serde(deny_unknown_fields)]
313struct DnsServerConfigRaw {
314    id: String,
315    #[serde(default)]
316    vendor: VendorKind,
317    #[serde(default)]
318    location: Option<ServerLocation>,
319    #[serde(default)]
320    base_url: Option<String>,
321    #[serde(default)]
322    base_url_env: Option<String>,
323    #[serde(default)]
324    token: Option<String>,
325    #[serde(default)]
326    token_env: Option<String>,
327    #[serde(default)]
328    org_id: Option<String>,
329    #[serde(default)]
330    cluster: Option<String>,
331    #[serde(default)]
332    dns: Option<DnsTransportConfig>,
333    #[serde(default)]
334    dot: Option<DotTransportConfig>,
335    #[serde(default)]
336    doh: Option<DohTransportConfig>,
337    #[serde(default)]
338    doq: Option<DoqTransportConfig>,
339    #[serde(default)]
340    mcp: McpPermissions,
341    #[serde(default)]
342    validation_endpoints: Vec<ValidationEndpointConfig>,
343    // Flat shorthands — merged into `mcp` on conversion.
344    /// Flat shorthand: `mcp_access = ["read", "write", "delete"]`.
345    #[serde(default)]
346    mcp_access: Option<Vec<PolicyRule>>,
347    /// Deprecated flat shorthand kept for backward compatibility; prefer `mcp_access = ["read"]`.
348    #[serde(default)]
349    mcp_readonly: bool,
350    #[serde(default)]
351    mcp_allowed_zones: Vec<String>,
352}
353
354impl From<DnsServerConfigRaw> for DnsServerConfig {
355    fn from(raw: DnsServerConfigRaw) -> Self {
356        let mut zones = raw.mcp.allowed_zones;
357        for z in raw.mcp_allowed_zones {
358            if !zones.contains(&z) {
359                zones.push(z);
360            }
361        }
362        // Flat shorthand resolution: mcp_access wins over deprecated mcp_readonly;
363        // intersect the flat shorthand with the nested mcp.access set.
364        let config_set: HashSet<PolicyRule> = raw.mcp.access.iter().cloned().collect();
365        let access = if let Some(flat) = raw.mcp_access {
366            let flat_set: HashSet<PolicyRule> = flat.into_iter().collect();
367            flat_set
368                .intersection(&config_set)
369                .cloned()
370                .collect::<Vec<_>>()
371        } else if raw.mcp_readonly {
372            let flat_set: HashSet<PolicyRule> = [PolicyRule::Read].into_iter().collect();
373            flat_set
374                .intersection(&config_set)
375                .cloned()
376                .collect::<Vec<_>>()
377        } else {
378            raw.mcp.access
379        };
380
381        let mut server = DnsServerConfig {
382            id: raw.id,
383            vendor: raw.vendor,
384            location: raw.location,
385            base_url: raw.base_url,
386            base_url_env: raw.base_url_env,
387            token: raw.token,
388            token_env: raw.token_env,
389            org_id: raw.org_id,
390            cluster: raw.cluster,
391            dns: raw.dns,
392            dot: raw.dot,
393            doh: raw.doh,
394            doq: raw.doq,
395            mcp: McpPermissions {
396                access,
397                allowed_zones: zones,
398                show_settings_secrets: raw.mcp.show_settings_secrets,
399            },
400            validation_endpoints: raw.validation_endpoints,
401        };
402        apply_provider_transport_defaults(&mut server);
403        server
404    }
405}
406
407fn apply_provider_transport_defaults(server: &mut DnsServerConfig) {
408    if server.location == Some(ServerLocation::Local) {
409        return;
410    }
411
412    match server.vendor {
413        VendorKind::Cloudflare => apply_cloudflare_transport_defaults(server),
414        VendorKind::Technitium | VendorKind::Pangolin | VendorKind::Unifi | VendorKind::Pihole => {}
415    }
416}
417
418fn apply_cloudflare_transport_defaults(server: &mut DnsServerConfig) {
419    server.dns.get_or_insert_with(|| DnsTransportConfig {
420        enabled: true,
421        addr: Some(format!("{CLOUDFLARE_RESOLVER_IP}:53")),
422        timeout_ms: None,
423    });
424    server.dot.get_or_insert_with(|| DotTransportConfig {
425        enabled: true,
426        addr: Some(format!("{CLOUDFLARE_RESOLVER_IP}:853")),
427        server_name: Some(CLOUDFLARE_RESOLVER_NAME.to_string()),
428        timeout_ms: None,
429    });
430    server.doh.get_or_insert_with(|| DohTransportConfig {
431        enabled: true,
432        url: Some(CLOUDFLARE_DOH_URL.to_string()),
433        addr: Some(format!("{CLOUDFLARE_RESOLVER_IP}:443")),
434        server_name: Some(CLOUDFLARE_RESOLVER_NAME.to_string()),
435        timeout_ms: None,
436    });
437    server.doq.get_or_insert_with(|| DoqTransportConfig {
438        enabled: true,
439        addr: Some(format!("{CLOUDFLARE_RESOLVER_IP}:853")),
440        server_name: Some(CLOUDFLARE_RESOLVER_NAME.to_string()),
441        timeout_ms: None,
442    });
443}
444
445fn default_true() -> bool {
446    true
447}
448
449fn default_access() -> Vec<PolicyRule> {
450    vec![PolicyRule::Read, PolicyRule::Write, PolicyRule::Delete]
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
454#[serde(deny_unknown_fields)]
455pub struct McpPermissions {
456    /// Permitted operation classes (default: all).
457    #[serde(default = "default_access")]
458    pub access: Vec<PolicyRule>,
459
460    #[serde(default)]
461    pub allowed_zones: Vec<String>,
462
463    #[serde(default)]
464    pub show_settings_secrets: bool,
465}
466
467impl Default for McpPermissions {
468    fn default() -> Self {
469        Self {
470            access: default_access(),
471            allowed_zones: Vec::new(),
472            show_settings_secrets: false,
473        }
474    }
475}
476
477impl McpPermissions {
478    fn is_default(&self) -> bool {
479        let full: HashSet<PolicyRule> = [PolicyRule::Read, PolicyRule::Write, PolicyRule::Delete]
480            .into_iter()
481            .collect();
482        let current: HashSet<PolicyRule> = self.access.iter().cloned().collect();
483        current == full && self.allowed_zones.is_empty() && !self.show_settings_secrets
484    }
485}
486
487impl AppConfig {
488    pub fn starter() -> Self {
489        AppConfig {
490            servers: vec![DnsServerConfig {
491                id: "default".to_string(),
492                vendor: VendorKind::Technitium,
493                location: None,
494                base_url: Some(TECHNITIUM_DEFAULT_BASE_URL.to_string()),
495                base_url_env: None,
496                token: None,
497                token_env: Some("DNSYNC_TECHNITIUM_API_TOKEN".to_string()),
498                org_id: None,
499                cluster: None,
500                dns: None,
501                dot: None,
502                doh: None,
503                doq: None,
504                mcp: McpPermissions::default(),
505                validation_endpoints: Vec::new(),
506            }],
507            clusters: BTreeMap::new(),
508            sync: Vec::new(),
509        }
510    }
511
512    pub fn render_starter_toml() -> Result<String> {
513        Self::starter().render_toml()
514    }
515
516    pub fn render_toml(&self) -> Result<String> {
517        let mut doc = toml_edit::DocumentMut::new();
518        for server in &self.servers {
519            append_server_entry(&mut doc, server);
520        }
521        append_cluster_entries(&mut doc, &self.clusters);
522        for profile in &self.sync {
523            append_sync_entry(&mut doc, profile);
524        }
525        Ok(doc.to_string())
526    }
527
528    /// Returns a copy of the config with every literal `token` value replaced
529    /// by `"[redacted]"`. `token_env` values (env var names) are not secrets
530    /// and are left as-is.
531    pub fn redact(&self) -> Self {
532        AppConfig {
533            servers: self
534                .servers
535                .iter()
536                .map(|s| DnsServerConfig {
537                    token: s.token.as_ref().map(|_| "[redacted]".to_string()),
538                    ..s.clone()
539                })
540                .collect(),
541            clusters: self.clusters.clone(),
542            sync: self.sync.clone(),
543        }
544    }
545
546    /// Load the config file if it already exists; return `Ok(None)` if it does
547    /// not. Unlike `load`, this never creates the file.
548    pub fn load_if_exists(path: Option<PathBuf>) -> Result<Option<Self>> {
549        let Some(path) = path.or_else(default_config_path) else {
550            return Ok(None);
551        };
552        if !path.exists() {
553            return Ok(None);
554        }
555        load_from_path(&path).map(Some)
556    }
557
558    /// Load the config file, creating it with starter defaults if it does not
559    /// exist yet.
560    pub fn load(path: Option<PathBuf>) -> Result<Option<Self>> {
561        let Some(path) = path.or_else(default_config_path) else {
562            return Ok(None);
563        };
564
565        if !path.exists() {
566            write_default_config(&path, false)?;
567        }
568
569        load_from_path(&path).map(Some)
570    }
571
572    pub fn selected_server(&self, selected_id: Option<&str>) -> Result<&DnsServerConfig> {
573        if let Some(id) = selected_id {
574            return self
575                .servers
576                .iter()
577                .find(|server| server.id.eq_ignore_ascii_case(id))
578                .ok_or_else(|| {
579                    Error::config(format!("config does not define a DNS server named '{id}'"))
580                });
581        }
582
583        match self.servers.as_slice() {
584            [server] => Ok(server),
585            [] => Err(Error::config("config file does not define any DNS servers")),
586            _ => Err(Error::config(
587                "config file defines multiple DNS servers; select one with --server or DNSYNC_SERVER",
588            )),
589        }
590    }
591
592    fn validate(&self) -> Result<()> {
593        let mut ids = std::collections::HashSet::new();
594        for server in &self.servers {
595            if server.id.trim().is_empty() {
596                return Err(Error::config(
597                    "config contains a DNS server with an empty id",
598                ));
599            }
600            if !ids.insert(server.id.to_lowercase()) {
601                return Err(Error::config(format!(
602                    "config contains duplicate DNS server id '{}'",
603                    server.id
604                )));
605            }
606            if let Some(cluster_id) = &server.cluster
607                && !self.clusters.contains_key(cluster_id)
608            {
609                return Err(Error::config(format!(
610                    "DNS server '{}' references unknown cluster '{}'",
611                    server.id, cluster_id
612                )));
613            }
614            validate_server_transports(server)?;
615            validate_validation_endpoints(server)?;
616        }
617        validate_clusters(&self.clusters, &ids)?;
618
619        let mut sync_names = std::collections::HashSet::new();
620        for profile in &self.sync {
621            if profile.name.trim().is_empty() {
622                return Err(Error::config(
623                    "config contains a sync profile with an empty name",
624                ));
625            }
626            if !sync_names.insert(profile.name.to_lowercase()) {
627                return Err(Error::config(format!(
628                    "config contains duplicate sync profile name '{}'",
629                    profile.name
630                )));
631            }
632            if !ids.contains(&profile.from.to_lowercase()) {
633                return Err(Error::config(format!(
634                    "sync profile '{}' references unknown source server '{}'",
635                    profile.name, profile.from
636                )));
637            }
638            if !ids.contains(&profile.to.to_lowercase()) {
639                return Err(Error::config(format!(
640                    "sync profile '{}' references unknown destination server '{}'",
641                    profile.name, profile.to
642                )));
643            }
644            if profile.from.to_lowercase() == profile.to.to_lowercase() {
645                return Err(Error::config(format!(
646                    "sync profile '{}' has identical source and destination server '{}'",
647                    profile.name, profile.from
648                )));
649            }
650            for (src, dst) in &profile.ip_map {
651                validate_ip_pair(&profile.name, src, dst)?;
652            }
653        }
654
655        Ok(())
656    }
657}
658
659fn validate_validation_endpoints(server: &DnsServerConfig) -> Result<()> {
660    for endpoint in &server.validation_endpoints {
661        if endpoint.name.trim().is_empty() {
662            return Err(Error::config(format!(
663                "DNS server '{}' contains a validation endpoint with an empty name",
664                server.id
665            )));
666        }
667
668        match endpoint.transport {
669            ValidationTransport::Dns | ValidationTransport::Dot | ValidationTransport::Doq
670                if endpoint.address.trim().is_empty() =>
671            {
672                return Err(Error::config(format!(
673                    "validation endpoint '{}' on DNS server '{}' requires address for {:?} transport",
674                    endpoint.name, server.id, endpoint.transport
675                )));
676            }
677            ValidationTransport::Doh
678                if endpoint
679                    .url
680                    .as_deref()
681                    .is_none_or(|url| url.trim().is_empty()) =>
682            {
683                return Err(Error::config(format!(
684                    "validation endpoint '{}' on DNS server '{}' requires url for doh transport",
685                    endpoint.name, server.id
686                )));
687            }
688            _ => {}
689        }
690    }
691
692    Ok(())
693}
694
695fn validate_server_transports(server: &DnsServerConfig) -> Result<()> {
696    if let Some(dns) = &server.dns
697        && dns.enabled
698        && dns
699            .addr
700            .as_deref()
701            .is_none_or(|addr| addr.trim().is_empty())
702    {
703        return Err(Error::config(format!(
704            "DNS server '{}' has enabled dns transport without addr",
705            server.id
706        )));
707    }
708
709    if let Some(dot) = &server.dot
710        && dot.enabled
711        && dot
712            .addr
713            .as_deref()
714            .is_none_or(|addr| addr.trim().is_empty())
715    {
716        return Err(Error::config(format!(
717            "DNS server '{}' has enabled dot transport without addr",
718            server.id
719        )));
720    }
721
722    if let Some(doh) = &server.doh
723        && doh.enabled
724        && doh.url.as_deref().is_none_or(|url| url.trim().is_empty())
725    {
726        return Err(Error::config(format!(
727            "DNS server '{}' has enabled doh transport without url",
728            server.id
729        )));
730    }
731
732    if let Some(doq) = &server.doq
733        && doq.enabled
734        && doq
735            .addr
736            .as_deref()
737            .is_none_or(|addr| addr.trim().is_empty())
738    {
739        return Err(Error::config(format!(
740            "DNS server '{}' has enabled doq transport without addr",
741            server.id
742        )));
743    }
744
745    Ok(())
746}
747
748fn validate_clusters(
749    clusters: &BTreeMap<String, ClusterConfig>,
750    server_ids: &HashSet<String>,
751) -> Result<()> {
752    for (id, cluster) in clusters {
753        if id.trim().is_empty() {
754            return Err(Error::config("config contains a cluster with an empty id"));
755        }
756
757        for member in &cluster.members {
758            if !server_ids.contains(&member.to_lowercase()) {
759                return Err(Error::config(format!(
760                    "cluster '{id}' references unknown DNS server '{member}'"
761                )));
762            }
763        }
764
765        for field in [cluster.primary.as_ref(), cluster.preferred_writer.as_ref()]
766            .into_iter()
767            .flatten()
768        {
769            if !field.eq_ignore_ascii_case("auto") && !server_ids.contains(&field.to_lowercase()) {
770                return Err(Error::config(format!(
771                    "cluster '{id}' references unknown DNS server '{field}'"
772                )));
773            }
774        }
775    }
776
777    Ok(())
778}
779
780pub fn init_config(path: Option<PathBuf>, force: bool) -> Result<PathBuf> {
781    let Some(path) = path.or_else(default_config_path) else {
782        return Err(Error::config(
783            "could not determine a default config path; pass --config <path>",
784        ));
785    };
786
787    write_default_config(&path, force)?;
788    Ok(path)
789}
790
791/// Append a new server entry to the config file. Creates the file if it does
792/// not exist yet. Existing file content — including comments and formatting —
793/// is preserved; only the new `[[servers]]` block is appended.
794pub fn add_server(path: Option<PathBuf>, server: DnsServerConfig) -> Result<PathBuf> {
795    let Some(path) = path.or_else(default_config_path) else {
796        return Err(Error::config(
797            "could not determine a default config path; pass --config <path>",
798        ));
799    };
800
801    // Validate via the serde types: check for duplicate IDs etc.
802    let mut config = if path.exists() {
803        load_from_path(&path)?
804    } else {
805        AppConfig::default()
806    };
807    config.servers.push(server.clone());
808    config.validate()?;
809
810    // Read the raw file so toml_edit can preserve comments and formatting.
811    let raw = if path.exists() {
812        std::fs::read_to_string(&path)
813            .map_err(|e| Error::io(format!("reading config file '{}'", path.display()), e))?
814    } else {
815        String::new()
816    };
817
818    let mut doc: toml_edit::DocumentMut = raw.parse().map_err(|e| {
819        Error::config(format!(
820            "could not parse config file '{}': {e}",
821            path.display()
822        ))
823    })?;
824
825    append_server_entry(&mut doc, &server);
826
827    ensure_config_dir(&path)?;
828    write_private_file(&path, &doc.to_string())?;
829    Ok(path)
830}
831
832#[derive(Debug, Clone, PartialEq, Eq)]
833pub struct UpdateDefaultsReport {
834    pub path: PathBuf,
835    pub updated_servers: usize,
836    pub added_values: usize,
837}
838
839/// Add currently-known default values to existing server entries without
840/// overwriting any field or sub-table already present in the config file.
841pub fn update_defaults(path: Option<PathBuf>) -> Result<UpdateDefaultsReport> {
842    let Some(path) = path.or_else(default_config_path) else {
843        return Err(Error::config(
844            "could not determine a default config path; pass --config <path>",
845        ));
846    };
847
848    let config = load_from_path(&path)?;
849    let raw = std::fs::read_to_string(&path)
850        .map_err(|e| Error::io(format!("reading config file '{}'", path.display()), e))?;
851    let mut doc: toml_edit::DocumentMut = raw.parse().map_err(|e| {
852        Error::config(format!(
853            "could not parse config file '{}': {e}",
854            path.display()
855        ))
856    })?;
857
858    let servers = doc
859        .get_mut("servers")
860        .and_then(|v| v.as_array_of_tables_mut())
861        .ok_or_else(|| Error::config("config file has no [[servers]] entries"))?;
862
863    let mut updated_servers = 0usize;
864    let mut added_values = 0usize;
865
866    for server_tbl in servers.iter_mut() {
867        let Some(id) = server_tbl
868            .get("id")
869            .and_then(|v| v.as_str())
870            .map(str::to_string)
871        else {
872            continue;
873        };
874        let Some(server) = config
875            .servers
876            .iter()
877            .find(|server| server.id.eq_ignore_ascii_case(&id))
878        else {
879            continue;
880        };
881
882        let before = added_values;
883        added_values += add_missing_server_defaults(server_tbl, server);
884        if added_values > before {
885            updated_servers += 1;
886        }
887    }
888
889    if added_values > 0 {
890        let updated: AppConfig = toml::from_str(&doc.to_string())
891            .map_err(|e| Error::config(format!("updated config would be invalid: {e}")))?;
892        updated.validate()?;
893        write_private_file(&path, &doc.to_string())?;
894    }
895
896    Ok(UpdateDefaultsReport {
897        path,
898        updated_servers,
899        added_values,
900    })
901}
902
903/// Specifies which transport endpoint on a server to create, replace, or remove.
904///
905/// `None` removes the transport block entirely. `Some(config)` creates or replaces it.
906pub enum EndpointUpdate {
907    Dns(Option<DnsTransportConfig>),
908    Dot(Option<DotTransportConfig>),
909    Doh(Option<DohTransportConfig>),
910    Doq(Option<DoqTransportConfig>),
911}
912
913fn add_missing_server_defaults(
914    server_tbl: &mut toml_edit::Table,
915    server: &DnsServerConfig,
916) -> usize {
917    use toml_edit::{Array, Item, value};
918
919    let mut added = 0usize;
920
921    if !server_tbl.contains_key("vendor") {
922        server_tbl["vendor"] = value(vendor_name(server.vendor));
923        added += 1;
924    }
925
926    if !server_tbl.contains_key("base_url") && !server_tbl.contains_key("base_url_env") {
927        server_tbl["base_url"] = value(default_base_url(server.vendor));
928        added += 1;
929    }
930
931    if !server_tbl.contains_key("mcp_access") && !server_tbl.contains_key("mcp") {
932        let mut access = Array::new();
933        for rule in &server.mcp.access {
934            access.push(policy_rule_name(*rule));
935        }
936        server_tbl["mcp_access"] = value(access);
937        added += 1;
938    } else if let Some(mcp) = server_tbl
939        .get_mut("mcp")
940        .and_then(|item| item.as_table_mut())
941        && !mcp.contains_key("access")
942    {
943        let mut access = Array::new();
944        for rule in &server.mcp.access {
945            access.push(policy_rule_name(*rule));
946        }
947        mcp["access"] = value(access);
948        added += 1;
949    }
950
951    if let Some(mcp) = server_tbl
952        .get_mut("mcp")
953        .and_then(|item| item.as_table_mut())
954        && !mcp.contains_key("show_settings_secrets")
955    {
956        mcp["show_settings_secrets"] = value(server.mcp.show_settings_secrets);
957        added += 1;
958    }
959
960    if !server_tbl.contains_key("dns")
961        && let Some(ref dns) = server.dns
962    {
963        server_tbl["dns"] = Item::Table(dns_transport_table(dns));
964        added += 1;
965    }
966    if !server_tbl.contains_key("dot")
967        && let Some(ref dot) = server.dot
968    {
969        server_tbl["dot"] = Item::Table(dot_transport_table(dot));
970        added += 1;
971    }
972    if !server_tbl.contains_key("doh")
973        && let Some(ref doh) = server.doh
974    {
975        server_tbl["doh"] = Item::Table(doh_transport_table(doh));
976        added += 1;
977    }
978    if !server_tbl.contains_key("doq")
979        && let Some(ref doq) = server.doq
980    {
981        server_tbl["doq"] = Item::Table(doq_transport_table(doq));
982        added += 1;
983    }
984
985    added
986}
987
988fn default_base_url(vendor: VendorKind) -> &'static str {
989    match vendor {
990        VendorKind::Technitium => TECHNITIUM_DEFAULT_BASE_URL,
991        VendorKind::Pangolin => PANGOLIN_DEFAULT_BASE_URL,
992        VendorKind::Cloudflare => CLOUDFLARE_DEFAULT_BASE_URL,
993        VendorKind::Unifi => UNIFI_DEFAULT_BASE_URL,
994        VendorKind::Pihole => PIHOLE_DEFAULT_BASE_URL,
995    }
996}
997
998fn vendor_name(vendor: VendorKind) -> &'static str {
999    match vendor {
1000        VendorKind::Technitium => "technitium",
1001        VendorKind::Pangolin => "pangolin",
1002        VendorKind::Cloudflare => "cloudflare",
1003        VendorKind::Unifi => "unifi",
1004        VendorKind::Pihole => "pihole",
1005    }
1006}
1007
1008fn policy_rule_name(rule: PolicyRule) -> &'static str {
1009    match rule {
1010        PolicyRule::Read => "read",
1011        PolicyRule::Write => "write",
1012        PolicyRule::Delete => "delete",
1013    }
1014}
1015
1016fn dns_transport_table(cfg: &DnsTransportConfig) -> toml_edit::Table {
1017    use toml_edit::{Table, value};
1018
1019    let mut tbl = Table::new();
1020    tbl["enabled"] = value(cfg.enabled);
1021    if let Some(ref addr) = cfg.addr {
1022        tbl["addr"] = value(addr.as_str());
1023    }
1024    if let Some(ms) = cfg.timeout_ms {
1025        tbl["timeout_ms"] = value(ms as i64);
1026    }
1027    tbl
1028}
1029
1030fn dot_transport_table(cfg: &DotTransportConfig) -> toml_edit::Table {
1031    use toml_edit::{Table, value};
1032
1033    let mut tbl = Table::new();
1034    tbl["enabled"] = value(cfg.enabled);
1035    if let Some(ref addr) = cfg.addr {
1036        tbl["addr"] = value(addr.as_str());
1037    }
1038    if let Some(ref sn) = cfg.server_name {
1039        tbl["server_name"] = value(sn.as_str());
1040    }
1041    if let Some(ms) = cfg.timeout_ms {
1042        tbl["timeout_ms"] = value(ms as i64);
1043    }
1044    tbl
1045}
1046
1047fn doh_transport_table(cfg: &DohTransportConfig) -> toml_edit::Table {
1048    use toml_edit::{Table, value};
1049
1050    let mut tbl = Table::new();
1051    tbl["enabled"] = value(cfg.enabled);
1052    if let Some(ref url) = cfg.url {
1053        tbl["url"] = value(url.as_str());
1054    }
1055    if let Some(ref addr) = cfg.addr {
1056        tbl["addr"] = value(addr.as_str());
1057    }
1058    if let Some(ref sn) = cfg.server_name {
1059        tbl["server_name"] = value(sn.as_str());
1060    }
1061    if let Some(ms) = cfg.timeout_ms {
1062        tbl["timeout_ms"] = value(ms as i64);
1063    }
1064    tbl
1065}
1066
1067fn doq_transport_table(cfg: &DoqTransportConfig) -> toml_edit::Table {
1068    use toml_edit::{Table, value};
1069
1070    let mut tbl = Table::new();
1071    tbl["enabled"] = value(cfg.enabled);
1072    if let Some(ref addr) = cfg.addr {
1073        tbl["addr"] = value(addr.as_str());
1074    }
1075    if let Some(ref sn) = cfg.server_name {
1076        tbl["server_name"] = value(sn.as_str());
1077    }
1078    if let Some(ms) = cfg.timeout_ms {
1079        tbl["timeout_ms"] = value(ms as i64);
1080    }
1081    tbl
1082}
1083
1084/// Update a single transport endpoint on an existing server entry in the config file.
1085///
1086/// The server is matched by ID (case-insensitive). Existing file content — including
1087/// comments and formatting — is preserved; only the targeted transport sub-table is
1088/// added, replaced, or removed.
1089pub fn update_server_endpoint(
1090    path: Option<PathBuf>,
1091    server_id: &str,
1092    update: EndpointUpdate,
1093) -> Result<PathBuf> {
1094    let Some(path) = path.or_else(default_config_path) else {
1095        return Err(Error::config(
1096            "could not determine a default config path; pass --config <path>",
1097        ));
1098    };
1099
1100    // Validate via the serde types first so we catch bad values early.
1101    let mut config = load_from_path(&path)?;
1102    let pos = config
1103        .servers
1104        .iter()
1105        .position(|s| s.id.eq_ignore_ascii_case(server_id))
1106        .ok_or_else(|| {
1107            Error::config(format!(
1108                "config does not define a DNS server named '{server_id}'"
1109            ))
1110        })?;
1111    match &update {
1112        EndpointUpdate::Dns(cfg) => config.servers[pos].dns = cfg.clone(),
1113        EndpointUpdate::Dot(cfg) => config.servers[pos].dot = cfg.clone(),
1114        EndpointUpdate::Doh(cfg) => config.servers[pos].doh = cfg.clone(),
1115        EndpointUpdate::Doq(cfg) => config.servers[pos].doq = cfg.clone(),
1116    }
1117    config.validate()?;
1118
1119    // Read the raw file so toml_edit can preserve comments and formatting.
1120    let raw = std::fs::read_to_string(&path)
1121        .map_err(|e| Error::io(format!("reading config file '{}'", path.display()), e))?;
1122    let mut doc: toml_edit::DocumentMut = raw.parse().map_err(|e| {
1123        Error::config(format!(
1124            "could not parse config file '{}': {e}",
1125            path.display()
1126        ))
1127    })?;
1128
1129    let servers = doc
1130        .get_mut("servers")
1131        .and_then(|v| v.as_array_of_tables_mut())
1132        .ok_or_else(|| Error::config("config file has no [[servers]] entries"))?;
1133
1134    let server_tbl = servers
1135        .iter_mut()
1136        .find(|tbl| {
1137            tbl.get("id")
1138                .and_then(|v| v.as_str())
1139                .is_some_and(|id| id.eq_ignore_ascii_case(server_id))
1140        })
1141        .ok_or_else(|| {
1142            Error::config(format!(
1143                "config does not define a DNS server named '{server_id}'"
1144            ))
1145        })?;
1146
1147    use toml_edit::{Item, Table, value};
1148
1149    match update {
1150        EndpointUpdate::Dns(None) => {
1151            server_tbl.remove("dns");
1152        }
1153        EndpointUpdate::Dns(Some(cfg)) => {
1154            let mut tbl = Table::new();
1155            tbl["enabled"] = value(cfg.enabled);
1156            if let Some(ref addr) = cfg.addr {
1157                tbl["addr"] = value(addr.as_str());
1158            }
1159            if let Some(ms) = cfg.timeout_ms {
1160                tbl["timeout_ms"] = value(ms as i64);
1161            }
1162            server_tbl["dns"] = Item::Table(tbl);
1163        }
1164        EndpointUpdate::Dot(None) => {
1165            server_tbl.remove("dot");
1166        }
1167        EndpointUpdate::Dot(Some(cfg)) => {
1168            let mut tbl = Table::new();
1169            tbl["enabled"] = value(cfg.enabled);
1170            if let Some(ref addr) = cfg.addr {
1171                tbl["addr"] = value(addr.as_str());
1172            }
1173            if let Some(ref sn) = cfg.server_name {
1174                tbl["server_name"] = value(sn.as_str());
1175            }
1176            if let Some(ms) = cfg.timeout_ms {
1177                tbl["timeout_ms"] = value(ms as i64);
1178            }
1179            server_tbl["dot"] = Item::Table(tbl);
1180        }
1181        EndpointUpdate::Doh(None) => {
1182            server_tbl.remove("doh");
1183        }
1184        EndpointUpdate::Doh(Some(cfg)) => {
1185            let mut tbl = Table::new();
1186            tbl["enabled"] = value(cfg.enabled);
1187            if let Some(ref url) = cfg.url {
1188                tbl["url"] = value(url.as_str());
1189            }
1190            if let Some(ref addr) = cfg.addr {
1191                tbl["addr"] = value(addr.as_str());
1192            }
1193            if let Some(ref sn) = cfg.server_name {
1194                tbl["server_name"] = value(sn.as_str());
1195            }
1196            if let Some(ms) = cfg.timeout_ms {
1197                tbl["timeout_ms"] = value(ms as i64);
1198            }
1199            server_tbl["doh"] = Item::Table(tbl);
1200        }
1201        EndpointUpdate::Doq(None) => {
1202            server_tbl.remove("doq");
1203        }
1204        EndpointUpdate::Doq(Some(cfg)) => {
1205            let mut tbl = Table::new();
1206            tbl["enabled"] = value(cfg.enabled);
1207            if let Some(ref addr) = cfg.addr {
1208                tbl["addr"] = value(addr.as_str());
1209            }
1210            if let Some(ref sn) = cfg.server_name {
1211                tbl["server_name"] = value(sn.as_str());
1212            }
1213            if let Some(ms) = cfg.timeout_ms {
1214                tbl["timeout_ms"] = value(ms as i64);
1215            }
1216            server_tbl["doq"] = Item::Table(tbl);
1217        }
1218    }
1219
1220    write_private_file(&path, &doc.to_string())?;
1221    Ok(path)
1222}
1223
1224/// Append a `[[servers]]` entry to a toml_edit document without touching
1225/// any existing content.
1226fn append_server_entry(doc: &mut toml_edit::DocumentMut, server: &DnsServerConfig) {
1227    use toml_edit::{Array, ArrayOfTables, Item, Table, value};
1228
1229    let mut tbl = Table::new();
1230    // Blank line before each [[servers]] header for readability.
1231    tbl.decor_mut().set_prefix("\n");
1232
1233    tbl["id"] = value(server.id.as_str());
1234    tbl["vendor"] = value(match server.vendor {
1235        VendorKind::Technitium => "technitium",
1236        VendorKind::Pangolin => "pangolin",
1237        VendorKind::Cloudflare => "cloudflare",
1238        VendorKind::Unifi => "unifi",
1239        VendorKind::Pihole => "pihole",
1240    });
1241    if let Some(loc) = server.location {
1242        tbl["location"] = value(match loc {
1243            ServerLocation::Local => "local",
1244            ServerLocation::External => "external",
1245        });
1246    }
1247    if let Some(ref v) = server.base_url {
1248        tbl["base_url"] = value(v.as_str());
1249    }
1250    if let Some(ref v) = server.base_url_env {
1251        tbl["base_url_env"] = value(v.as_str());
1252    }
1253    if let Some(ref v) = server.token_env {
1254        tbl["token_env"] = value(v.as_str());
1255    }
1256    match server.token.as_deref() {
1257        Some(t) => tbl["token"] = value(t),
1258        // Write an empty placeholder so the field is visible in the config file.
1259        None if server.token_env.is_none() => tbl["token"] = value(""),
1260        None => {}
1261    }
1262    if let Some(ref v) = server.org_id {
1263        tbl["org_id"] = value(v.as_str());
1264    }
1265
1266    let mut access_arr = Array::new();
1267    for rule in &server.mcp.access {
1268        access_arr.push(match rule {
1269            PolicyRule::Read => "read",
1270            PolicyRule::Write => "write",
1271            PolicyRule::Delete => "delete",
1272        });
1273    }
1274    tbl["mcp_access"] = value(access_arr);
1275    let mut zones = Array::new();
1276    for zone in &server.mcp.allowed_zones {
1277        zones.push(zone.as_str());
1278    }
1279    tbl["mcp_allowed_zones"] = value(zones);
1280
1281    if !server.validation_endpoints.is_empty() {
1282        let mut endpoints = ArrayOfTables::new();
1283        for endpoint in &server.validation_endpoints {
1284            let mut endpoint_tbl = Table::new();
1285            endpoint_tbl["name"] = value(endpoint.name.as_str());
1286            endpoint_tbl["transport"] = value(match endpoint.transport {
1287                ValidationTransport::Dns => "dns",
1288                ValidationTransport::Doh => "doh",
1289                ValidationTransport::Dot => "dot",
1290                ValidationTransport::Doq => "doq",
1291            });
1292            if !endpoint.address.is_empty() {
1293                endpoint_tbl["address"] = value(endpoint.address.as_str());
1294            }
1295            if let Some(port) = endpoint.port {
1296                endpoint_tbl["port"] = value(i64::from(port));
1297            }
1298            if let Some(ref url) = endpoint.url {
1299                endpoint_tbl["url"] = value(url.as_str());
1300            }
1301            if let Some(ref tls_server_name) = endpoint.tls_server_name {
1302                endpoint_tbl["tls_server_name"] = value(tls_server_name.as_str());
1303            }
1304            endpoint_tbl["enabled"] = value(endpoint.enabled);
1305            if let Some(timeout_ms) = endpoint.timeout_ms {
1306                endpoint_tbl["timeout_ms"] = value(timeout_ms as i64);
1307            }
1308            endpoints.push(endpoint_tbl);
1309        }
1310        tbl["validation_endpoints"] = Item::ArrayOfTables(endpoints);
1311    }
1312
1313    if let Some(ref cluster) = server.cluster {
1314        tbl["cluster"] = value(cluster.as_str());
1315    }
1316    if let Some(ref dns) = server.dns {
1317        let mut dns_tbl = Table::new();
1318        dns_tbl["enabled"] = value(dns.enabled);
1319        if let Some(ref addr) = dns.addr {
1320            dns_tbl["addr"] = value(addr.as_str());
1321        }
1322        if let Some(timeout_ms) = dns.timeout_ms {
1323            dns_tbl["timeout_ms"] = value(timeout_ms as i64);
1324        }
1325        tbl["dns"] = Item::Table(dns_tbl);
1326    }
1327    if let Some(ref dot) = server.dot {
1328        let mut dot_tbl = Table::new();
1329        dot_tbl["enabled"] = value(dot.enabled);
1330        if let Some(ref addr) = dot.addr {
1331            dot_tbl["addr"] = value(addr.as_str());
1332        }
1333        if let Some(ref server_name) = dot.server_name {
1334            dot_tbl["server_name"] = value(server_name.as_str());
1335        }
1336        if let Some(timeout_ms) = dot.timeout_ms {
1337            dot_tbl["timeout_ms"] = value(timeout_ms as i64);
1338        }
1339        tbl["dot"] = Item::Table(dot_tbl);
1340    }
1341    if let Some(ref doh) = server.doh {
1342        let mut doh_tbl = Table::new();
1343        doh_tbl["enabled"] = value(doh.enabled);
1344        if let Some(ref url) = doh.url {
1345            doh_tbl["url"] = value(url.as_str());
1346        }
1347        if let Some(ref addr) = doh.addr {
1348            doh_tbl["addr"] = value(addr.as_str());
1349        }
1350        if let Some(ref server_name) = doh.server_name {
1351            doh_tbl["server_name"] = value(server_name.as_str());
1352        }
1353        if let Some(timeout_ms) = doh.timeout_ms {
1354            doh_tbl["timeout_ms"] = value(timeout_ms as i64);
1355        }
1356        tbl["doh"] = Item::Table(doh_tbl);
1357    }
1358    if let Some(ref doq) = server.doq {
1359        let mut doq_tbl = Table::new();
1360        doq_tbl["enabled"] = value(doq.enabled);
1361        if let Some(ref addr) = doq.addr {
1362            doq_tbl["addr"] = value(addr.as_str());
1363        }
1364        if let Some(ref server_name) = doq.server_name {
1365            doq_tbl["server_name"] = value(server_name.as_str());
1366        }
1367        if let Some(timeout_ms) = doq.timeout_ms {
1368            doq_tbl["timeout_ms"] = value(timeout_ms as i64);
1369        }
1370        tbl["doq"] = Item::Table(doq_tbl);
1371    }
1372
1373    match doc.entry("servers") {
1374        toml_edit::Entry::Occupied(mut e) => {
1375            if let Some(aot) = e.get_mut().as_array_of_tables_mut() {
1376                aot.push(tbl);
1377            }
1378        }
1379        toml_edit::Entry::Vacant(e) => {
1380            let mut aot = ArrayOfTables::new();
1381            aot.push(tbl);
1382            e.insert(Item::ArrayOfTables(aot));
1383        }
1384    }
1385}
1386
1387/// Append a `[[sync]]` profile entry to a toml_edit document without touching
1388/// any existing content.
1389fn append_sync_entry(doc: &mut toml_edit::DocumentMut, profile: &SyncProfile) {
1390    use toml_edit::{Array, ArrayOfTables, Item, Table, value};
1391
1392    let mut tbl = Table::new();
1393    // Blank line before each [[sync]] header for readability.
1394    tbl.decor_mut().set_prefix("\n");
1395
1396    tbl["name"] = value(profile.name.as_str());
1397    tbl["from"] = value(profile.from.as_str());
1398    tbl["to"] = value(profile.to.as_str());
1399
1400    let mut zones = Array::new();
1401    for zone in &profile.zones {
1402        zones.push(zone.as_str());
1403    }
1404    tbl["zones"] = value(zones);
1405
1406    if !profile.ip_map.is_empty() {
1407        let mut map_tbl = Table::new();
1408        for (src, dst) in &profile.ip_map {
1409            map_tbl[src.as_str()] = value(dst.as_str());
1410        }
1411        tbl["ip_map"] = Item::Table(map_tbl);
1412    }
1413
1414    match doc.entry("sync") {
1415        toml_edit::Entry::Occupied(mut e) => {
1416            if let Some(aot) = e.get_mut().as_array_of_tables_mut() {
1417                aot.push(tbl);
1418            }
1419        }
1420        toml_edit::Entry::Vacant(e) => {
1421            let mut aot = ArrayOfTables::new();
1422            aot.push(tbl);
1423            e.insert(Item::ArrayOfTables(aot));
1424        }
1425    }
1426}
1427
1428/// Validate a single `ip_map` entry: both sides must parse as IP addresses of
1429/// the same family.
1430fn validate_ip_pair(profile: &str, src: &str, dst: &str) -> Result<()> {
1431    let source: IpAddr = src.parse().map_err(|_| {
1432        Error::config(format!(
1433            "sync profile '{profile}': '{src}' is not a valid IP address"
1434        ))
1435    })?;
1436    let dest: IpAddr = dst.parse().map_err(|_| {
1437        Error::config(format!(
1438            "sync profile '{profile}': '{dst}' is not a valid IP address"
1439        ))
1440    })?;
1441    if source.is_ipv4() != dest.is_ipv4() {
1442        return Err(Error::config(format!(
1443            "sync profile '{profile}': IP mapping '{src}' = '{dst}' mixes IPv4 and IPv6"
1444        )));
1445    }
1446    Ok(())
1447}
1448
1449fn write_default_config(path: &Path, force: bool) -> Result<()> {
1450    if path.exists() && !force {
1451        return Err(Error::config(format!(
1452            "config file '{}' already exists; pass --force to overwrite it",
1453            path.display()
1454        )));
1455    }
1456
1457    ensure_config_dir(path)?;
1458    let contents = AppConfig::render_starter_toml()?;
1459    write_private_file(path, &contents)
1460}
1461
1462fn ensure_config_dir(path: &Path) -> Result<()> {
1463    if let Some(parent) = path.parent() {
1464        std::fs::create_dir_all(parent).map_err(|e| {
1465            Error::io(
1466                format!("creating config directory '{}'", parent.display()),
1467                e,
1468            )
1469        })?;
1470        restrict_dir_permissions(parent)?;
1471    }
1472    Ok(())
1473}
1474
1475fn load_from_path(path: &Path) -> Result<AppConfig> {
1476    check_config_permissions(path)?;
1477    let contents = std::fs::read_to_string(path)
1478        .map_err(|e| Error::io(format!("reading config file '{}'", path.display()), e))?;
1479    let config: AppConfig = toml::from_str(&contents).map_err(|e| {
1480        Error::config(format!(
1481            "could not parse config file '{}': {e}",
1482            path.display()
1483        ))
1484    })?;
1485    config.validate()?;
1486    Ok(config)
1487}
1488
1489fn append_cluster_entries(
1490    doc: &mut toml_edit::DocumentMut,
1491    clusters: &BTreeMap<String, ClusterConfig>,
1492) {
1493    use toml_edit::{Array, Item, Table, value};
1494
1495    if clusters.is_empty() {
1496        return;
1497    }
1498
1499    let mut clusters_tbl = Table::new();
1500    clusters_tbl.decor_mut().set_prefix("\n");
1501
1502    for (id, cluster) in clusters {
1503        let mut tbl = Table::new();
1504        tbl["vendor"] = value(match cluster.vendor {
1505            VendorKind::Technitium => "technitium",
1506            VendorKind::Pangolin => "pangolin",
1507            VendorKind::Cloudflare => "cloudflare",
1508            VendorKind::Unifi => "unifi",
1509            VendorKind::Pihole => "pihole",
1510        });
1511        let mut members = Array::new();
1512        for member in &cluster.members {
1513            members.push(member.as_str());
1514        }
1515        tbl["members"] = value(members);
1516        tbl["write_policy"] = value(match cluster.write_policy {
1517            ClusterWritePolicy::PrimaryOnly => "primary_only",
1518        });
1519        if let Some(ref primary) = cluster.primary {
1520            tbl["primary"] = value(primary.as_str());
1521        }
1522        if let Some(ref catalog_zone) = cluster.catalog_zone {
1523            tbl["catalog_zone"] = value(catalog_zone.as_str());
1524        }
1525        if let Some(ref preferred_writer) = cluster.preferred_writer {
1526            tbl["preferred_writer"] = value(preferred_writer.as_str());
1527        }
1528        clusters_tbl[id] = Item::Table(tbl);
1529    }
1530
1531    doc["clusters"] = Item::Table(clusters_tbl);
1532}
1533
1534/// Write `contents` to `path` with owner-only permissions (0o600 on Unix).
1535/// Uses `OpenOptions::mode` so the file is never created world-readable,
1536/// then explicitly sets permissions to handle the overwrite (force) case.
1537#[cfg(unix)]
1538fn write_private_file(path: &Path, contents: &str) -> Result<()> {
1539    use std::io::Write as _;
1540    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
1541
1542    let mut file = std::fs::OpenOptions::new()
1543        .write(true)
1544        .create(true)
1545        .truncate(true)
1546        .mode(0o600)
1547        .open(path)
1548        .map_err(|e| Error::io(format!("creating config file '{}'", path.display()), e))?;
1549
1550    file.write_all(contents.as_bytes())
1551        .map_err(|e| Error::io(format!("writing config file '{}'", path.display()), e))?;
1552
1553    // mode() only applies when the file is newly created; set explicitly for overwrites.
1554    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
1555        .map_err(|e| Error::io(format!("setting permissions on '{}'", path.display()), e))
1556}
1557
1558#[cfg(not(unix))]
1559fn write_private_file(path: &Path, contents: &str) -> Result<()> {
1560    std::fs::write(path, contents)
1561        .map_err(|e| Error::io(format!("creating config file '{}'", path.display()), e))
1562}
1563
1564/// Restrict the config directory to owner-only access (0o700 on Unix).
1565#[cfg(unix)]
1566fn restrict_dir_permissions(path: &Path) -> Result<()> {
1567    use std::os::unix::fs::PermissionsExt;
1568    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
1569        .map_err(|e| Error::io(format!("setting permissions on '{}'", path.display()), e))
1570}
1571
1572#[cfg(not(unix))]
1573fn restrict_dir_permissions(_path: &Path) -> Result<()> {
1574    Ok(())
1575}
1576
1577/// Error if the config file is readable by anyone other than the owner.
1578#[cfg(unix)]
1579fn check_config_permissions(path: &Path) -> Result<()> {
1580    use std::os::unix::fs::MetadataExt;
1581    let meta = std::fs::metadata(path)
1582        .map_err(|e| Error::io(format!("reading metadata for '{}'", path.display()), e))?;
1583    let mode = meta.mode() & 0o777;
1584    if mode & 0o077 != 0 {
1585        return Err(Error::config(format!(
1586            "config file '{}' has permissions {:04o} — group or world can read it.\n\
1587             API tokens must be owner-readable only. Fix with:\n\
1588             \n    chmod 600 {}",
1589            path.display(),
1590            mode,
1591            path.display(),
1592        )));
1593    }
1594    Ok(())
1595}
1596
1597#[cfg(not(unix))]
1598fn check_config_permissions(_path: &Path) -> Result<()> {
1599    Ok(())
1600}
1601
1602impl DnsServerConfig {
1603    /// Returns whether this server is local or external.
1604    ///
1605    /// Uses the explicit `location` config field when set; otherwise resolves
1606    /// the effective base URL's hostname via hickory — private/loopback IPs
1607    /// and `localhost` are `Local`, everything else is `External`.
1608    pub async fn resolved_location(&self) -> ServerLocation {
1609        if let Some(loc) = self.location {
1610            return loc;
1611        }
1612        let url = self.base_url.as_deref().unwrap_or(match self.vendor {
1613            VendorKind::Technitium => TECHNITIUM_DEFAULT_BASE_URL,
1614            VendorKind::Pangolin => PANGOLIN_DEFAULT_BASE_URL,
1615            VendorKind::Cloudflare => CLOUDFLARE_DEFAULT_BASE_URL,
1616            VendorKind::Unifi => UNIFI_DEFAULT_BASE_URL,
1617            VendorKind::Pihole => PIHOLE_DEFAULT_BASE_URL,
1618        });
1619        if url_is_local(url).await {
1620            ServerLocation::Local
1621        } else {
1622            ServerLocation::External
1623        }
1624    }
1625
1626    pub fn resolved_base_url(&self, override_url: Option<&str>) -> String {
1627        override_url
1628            .map(ToOwned::to_owned)
1629            .or_else(|| self.base_url_env.as_ref().and_then(|k| env::var(k).ok()))
1630            .or_else(|| self.base_url.clone())
1631            .unwrap_or_else(|| match self.vendor {
1632                VendorKind::Technitium => TECHNITIUM_DEFAULT_BASE_URL.to_string(),
1633                VendorKind::Pangolin => PANGOLIN_DEFAULT_BASE_URL.to_string(),
1634                VendorKind::Cloudflare => CLOUDFLARE_DEFAULT_BASE_URL.to_string(),
1635                VendorKind::Unifi => UNIFI_DEFAULT_BASE_URL.to_string(),
1636                VendorKind::Pihole => PIHOLE_DEFAULT_BASE_URL.to_string(),
1637            })
1638    }
1639
1640    pub fn resolved_token(&self, override_token: Option<&str>) -> Result<ApiToken> {
1641        if let Some(token) = override_token {
1642            return Ok(ApiToken::new(token));
1643        }
1644
1645        if let Some(ref env_name) = self.token_env {
1646            return env::var(env_name).map(ApiToken::new).map_err(|_| {
1647                Error::config(format!(
1648                    "DNS server '{}' requires token env var '{env_name}' to be set",
1649                    self.id
1650                ))
1651            });
1652        }
1653
1654        // Treat an empty string the same as absent — it's an unfilled placeholder.
1655        self.token
1656            .as_deref()
1657            .filter(|t| !t.is_empty())
1658            .map(ApiToken::new)
1659            .ok_or_else(|| {
1660                Error::config(format!(
1661                    "DNS server '{}' has no token configured; set token or token_env in config, or pass --token",
1662                    self.id
1663                ))
1664            })
1665    }
1666}
1667
1668/// Extracts the host portion (no port, no brackets around IPv6 literals) from a URL.
1669fn url_host(url: &str) -> &str {
1670    let without_scheme = url
1671        .trim_start_matches("https://")
1672        .trim_start_matches("http://");
1673    let authority = without_scheme.split('/').next().unwrap_or(without_scheme);
1674
1675    if authority.starts_with('[') {
1676        // IPv6 literal — strip brackets; ignore the trailing `]:port` part.
1677        authority
1678            .trim_start_matches('[')
1679            .split(']')
1680            .next()
1681            .unwrap_or(authority)
1682    } else {
1683        // Strip port if present (e.g. "192.168.1.1:5380" → "192.168.1.1").
1684        authority.rsplit(':').nth(1).unwrap_or(authority)
1685    }
1686}
1687
1688fn is_local_ip(ip: IpAddr) -> bool {
1689    match ip {
1690        IpAddr::V4(v4) => v4.is_private() || v4.is_loopback(),
1691        IpAddr::V6(v6) => v6.is_loopback(),
1692    }
1693}
1694
1695/// Returns true when the URL resolves to a local/private address.
1696///
1697/// Literal IPs and `localhost` are checked directly. For any other hostname
1698/// hickory resolves it to an IP first — if any resolved address is
1699/// private/loopback the URL is considered local.
1700async fn url_is_local(url: &str) -> bool {
1701    let host = url_host(url);
1702
1703    if host == "localhost" || host == "127.0.0.1" || host == "::1" {
1704        return true;
1705    }
1706
1707    if let Ok(ip) = host.parse::<IpAddr>() {
1708        return is_local_ip(ip);
1709    }
1710
1711    // Hostname — resolve via hickory and check the resulting addresses.
1712    let resolver = match Resolver::builder_tokio() {
1713        Ok(builder) => match builder.build() {
1714            Ok(r) => r,
1715            Err(e) => {
1716                tracing::debug!(%e, "could not build resolver for location check");
1717                return false;
1718            }
1719        },
1720        Err(e) => {
1721            tracing::debug!(%e, "could not load resolver config for location check");
1722            return false;
1723        }
1724    };
1725
1726    match resolver.lookup_ip(host).await {
1727        Ok(lookup) => lookup.iter().any(is_local_ip),
1728        Err(e) => {
1729            tracing::debug!(%e, host, "hostname resolution failed during location check");
1730            false
1731        }
1732    }
1733}
1734
1735pub fn default_config_path() -> Option<PathBuf> {
1736    #[cfg(debug_assertions)]
1737    {
1738        Some(
1739            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1740                .join(".config")
1741                .join("dnsync")
1742                .join("config.toml"),
1743        )
1744    }
1745
1746    #[cfg(not(debug_assertions))]
1747    env::var_os("XDG_CONFIG_HOME")
1748        .map(PathBuf::from)
1749        .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
1750        .map(|dir| dir.join("dnsync").join("config.toml"))
1751}
1752
1753#[cfg(test)]
1754mod tests {
1755    use super::*;
1756    use std::time::{SystemTime, UNIX_EPOCH};
1757
1758    fn temp_config_path(name: &str) -> PathBuf {
1759        let nonce = SystemTime::now()
1760            .duration_since(UNIX_EPOCH)
1761            .expect("system clock should be after unix epoch")
1762            .as_nanos();
1763
1764        env::temp_dir()
1765            .join("dnsync-config-tests")
1766            .join(format!("{name}-{}-{nonce}", std::process::id()))
1767            .join("config.toml")
1768    }
1769
1770    fn config() -> AppConfig {
1771        toml::from_str(
1772            r#"
1773                [[servers]]
1774                id = "home"
1775                vendor = "technitium"
1776                base_url = "http://home.local:5380"
1777                token = "home-token"
1778
1779                [servers.mcp]
1780                access = ["read"]
1781                allowed_zones = ["example.com", "internal.lan"]
1782                show_settings_secrets = true
1783
1784                [[servers]]
1785                id = "lab"
1786                vendor = "technitium"
1787                base_url = "http://lab.local:5380"
1788                token_env = "LAB_TOKEN"
1789            "#,
1790        )
1791        .expect("config should parse")
1792    }
1793
1794    #[test]
1795    fn parses_per_server_mcp_permissions() {
1796        let config = config();
1797        let home = config.selected_server(Some("home")).unwrap();
1798
1799        assert_eq!(home.id, "home");
1800        assert_eq!(home.vendor, VendorKind::Technitium);
1801        assert_eq!(home.base_url.as_deref(), Some("http://home.local:5380"));
1802        assert_eq!(home.mcp.access, vec![PolicyRule::Read]);
1803        assert_eq!(home.mcp.allowed_zones, ["example.com", "internal.lan"]);
1804        assert!(home.mcp.show_settings_secrets);
1805
1806        let lab = config.selected_server(Some("lab")).unwrap();
1807        assert!(!lab.mcp.show_settings_secrets);
1808    }
1809
1810    #[test]
1811    fn requires_server_selection_when_multiple_servers_exist() {
1812        let err = config().selected_server(None).unwrap_err();
1813
1814        assert!(err.to_string().contains("multiple DNS servers"));
1815    }
1816
1817    #[test]
1818    fn rejects_duplicate_server_ids_case_insensitively() {
1819        let config: AppConfig = toml::from_str(
1820            r#"
1821                [[servers]]
1822                id = "home"
1823
1824                [[servers]]
1825                id = "HOME"
1826            "#,
1827        )
1828        .expect("config should parse before validation");
1829
1830        let err = config.validate().unwrap_err();
1831
1832        assert!(err.to_string().contains("duplicate DNS server id"));
1833    }
1834
1835    #[test]
1836    fn rejects_unknown_mcp_permission_fields() {
1837        let err = toml::from_str::<AppConfig>(
1838            r#"
1839                [[servers]]
1840                id = "home"
1841
1842                [servers.mcp]
1843                read_only = true
1844            "#,
1845        )
1846        .unwrap_err();
1847
1848        assert!(err.to_string().contains("unknown field"));
1849    }
1850
1851    #[test]
1852    fn selected_server_matches_case_insensitively() {
1853        let config = config();
1854
1855        assert_eq!(config.selected_server(Some("HOME")).unwrap().id, "home");
1856    }
1857
1858    #[test]
1859    fn load_creates_missing_config_with_defaults() {
1860        let path = temp_config_path("missing-default");
1861
1862        let config = AppConfig::load(Some(path.clone()))
1863            .expect("missing config should be created and loaded")
1864            .expect("created config should load");
1865
1866        let server = config.selected_server(None).unwrap();
1867        assert_eq!(server.id, "default");
1868        assert_eq!(server.vendor, VendorKind::Technitium);
1869        assert_eq!(server.base_url.as_deref(), Some("http://localhost:5380"));
1870        assert_eq!(
1871            server.token_env.as_deref(),
1872            Some("DNSYNC_TECHNITIUM_API_TOKEN")
1873        );
1874        assert!(server.token.is_none());
1875        {
1876            use std::collections::HashSet;
1877            let full: HashSet<PolicyRule> =
1878                [PolicyRule::Read, PolicyRule::Write, PolicyRule::Delete]
1879                    .into_iter()
1880                    .collect();
1881            let actual: HashSet<PolicyRule> = server.mcp.access.iter().cloned().collect();
1882            assert_eq!(actual, full);
1883        }
1884        assert!(server.mcp.allowed_zones.is_empty());
1885
1886        // Verify the written file round-trips and uses token_env, not token
1887        let written = std::fs::read_to_string(&path).unwrap();
1888        let reparsed: AppConfig =
1889            toml::from_str(&written).expect("written config should be valid TOML");
1890        let reparsed_server = reparsed.selected_server(None).unwrap();
1891        assert_eq!(
1892            reparsed_server.token_env.as_deref(),
1893            Some("DNSYNC_TECHNITIUM_API_TOKEN")
1894        );
1895        assert!(reparsed_server.token.is_none());
1896
1897        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
1898    }
1899
1900    #[test]
1901    fn load_does_not_overwrite_existing_config() {
1902        let path = temp_config_path("existing-config");
1903        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1904        std::fs::write(
1905            &path,
1906            r#"
1907                [[servers]]
1908                id = "custom"
1909                token = "custom-token"
1910            "#,
1911        )
1912        .unwrap();
1913        // match the permissions the production code sets so the load check passes
1914        #[cfg(unix)]
1915        {
1916            use std::os::unix::fs::PermissionsExt;
1917            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1918        }
1919
1920        let config = AppConfig::load(Some(path.clone()))
1921            .expect("existing config should load")
1922            .expect("config should be present");
1923
1924        assert_eq!(config.selected_server(None).unwrap().id, "custom");
1925        assert!(
1926            std::fs::read_to_string(&path)
1927                .unwrap()
1928                .contains("custom-token")
1929        );
1930
1931        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
1932    }
1933
1934    #[test]
1935    fn init_config_refuses_to_overwrite_existing_config() {
1936        let path = temp_config_path("init-existing-config");
1937        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1938        std::fs::write(&path, "existing = true\n").unwrap();
1939
1940        let err = init_config(Some(path.clone()), false).unwrap_err();
1941
1942        assert!(err.to_string().contains("already exists"));
1943        assert_eq!(std::fs::read_to_string(&path).unwrap(), "existing = true\n");
1944
1945        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
1946    }
1947
1948    #[test]
1949    fn init_config_force_overwrites_existing_config() {
1950        let path = temp_config_path("init-force-config");
1951        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1952        std::fs::write(&path, "existing = true\n").unwrap();
1953
1954        let written_path = init_config(Some(path.clone()), true).unwrap();
1955
1956        assert_eq!(written_path, path);
1957
1958        let written = std::fs::read_to_string(&written_path).unwrap();
1959        let config: AppConfig =
1960            toml::from_str(&written).expect("written config should be valid TOML");
1961        let server = config.selected_server(None).unwrap();
1962        assert_eq!(server.id, "default");
1963        assert_eq!(
1964            server.token_env.as_deref(),
1965            Some("DNSYNC_TECHNITIUM_API_TOKEN")
1966        );
1967        assert!(server.token.is_none());
1968
1969        std::fs::remove_dir_all(written_path.parent().unwrap()).unwrap();
1970    }
1971
1972    #[test]
1973    fn cli_base_url_override_wins_over_config() {
1974        let server = config().selected_server(Some("home")).unwrap().clone();
1975
1976        assert_eq!(
1977            server.resolved_base_url(Some("http://override.local:5380")),
1978            "http://override.local:5380"
1979        );
1980    }
1981
1982    #[test]
1983    fn technitium_base_url_defaults_to_localhost() {
1984        let server = DnsServerConfig {
1985            id: "home".to_string(),
1986            vendor: VendorKind::Technitium,
1987            location: None,
1988            base_url: None,
1989            base_url_env: None,
1990            token: None,
1991            token_env: None,
1992            org_id: None,
1993            cluster: None,
1994            dns: None,
1995            dot: None,
1996            doh: None,
1997            doq: None,
1998            mcp: McpPermissions::default(),
1999            validation_endpoints: Vec::new(),
2000        };
2001
2002        assert_eq!(server.resolved_base_url(None), TECHNITIUM_DEFAULT_BASE_URL);
2003    }
2004
2005    #[test]
2006    fn pangolin_base_url_defaults_to_cloud_api() {
2007        let server = DnsServerConfig {
2008            id: "cloud".to_string(),
2009            vendor: VendorKind::Pangolin,
2010            location: None,
2011            base_url: None,
2012            base_url_env: None,
2013            token: None,
2014            token_env: None,
2015            org_id: None,
2016            cluster: None,
2017            dns: None,
2018            dot: None,
2019            doh: None,
2020            doq: None,
2021            mcp: McpPermissions::default(),
2022            validation_endpoints: Vec::new(),
2023        };
2024
2025        assert_eq!(server.resolved_base_url(None), PANGOLIN_DEFAULT_BASE_URL);
2026    }
2027
2028    #[test]
2029    fn cli_token_override_wins_over_config() {
2030        let server = config().selected_server(Some("home")).unwrap().clone();
2031
2032        assert_eq!(
2033            server
2034                .resolved_token(Some("override-token"))
2035                .unwrap()
2036                .expose_for_auth(),
2037            "override-token"
2038        );
2039    }
2040
2041    #[test]
2042    fn debug_default_config_path_uses_repo_root() {
2043        let path = default_config_path().expect("debug builds should have a default config path");
2044
2045        assert_eq!(
2046            path,
2047            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2048                .join(".config")
2049                .join("dnsync")
2050                .join("config.toml")
2051        );
2052    }
2053
2054    #[test]
2055    fn starter_config_contains_token_env() {
2056        let toml = AppConfig::render_starter_toml().unwrap();
2057        assert!(
2058            toml.contains(r#"token_env = "DNSYNC_TECHNITIUM_API_TOKEN""#),
2059            "starter TOML should contain token_env assignment"
2060        );
2061    }
2062
2063    #[test]
2064    fn starter_config_does_not_contain_literal_token() {
2065        let toml = AppConfig::render_starter_toml().unwrap();
2066        assert!(
2067            !toml.lines().any(|l| l.trim_start().starts_with("token =")),
2068            "starter TOML must not contain a bare `token = ...` key"
2069        );
2070    }
2071
2072    #[test]
2073    fn starter_config_round_trips() {
2074        let toml = AppConfig::render_starter_toml().unwrap();
2075        let reparsed: AppConfig = toml::from_str(&toml).expect("starter TOML should parse back");
2076        let server = reparsed.selected_server(None).unwrap();
2077        assert_eq!(server.id, "default");
2078        assert_eq!(server.vendor, VendorKind::Technitium);
2079        assert_eq!(server.base_url.as_deref(), Some("http://localhost:5380"));
2080        assert_eq!(
2081            server.token_env.as_deref(),
2082            Some("DNSYNC_TECHNITIUM_API_TOKEN")
2083        );
2084        assert!(server.token.is_none());
2085        {
2086            use std::collections::HashSet;
2087            let full: HashSet<PolicyRule> =
2088                [PolicyRule::Read, PolicyRule::Write, PolicyRule::Delete]
2089                    .into_iter()
2090                    .collect();
2091            let actual: HashSet<PolicyRule> = server.mcp.access.iter().cloned().collect();
2092            assert_eq!(actual, full);
2093        }
2094        assert!(server.mcp.allowed_zones.is_empty());
2095    }
2096
2097    #[test]
2098    fn starter_config_validates() {
2099        AppConfig::starter()
2100            .validate()
2101            .expect("starter config should pass validation");
2102    }
2103
2104    #[cfg(unix)]
2105    #[test]
2106    fn written_config_file_has_owner_only_permissions() {
2107        use std::os::unix::fs::PermissionsExt;
2108        let path = temp_config_path("perms-file");
2109
2110        init_config(Some(path.clone()), false).unwrap();
2111
2112        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2113        assert_eq!(
2114            mode, 0o600,
2115            "config file should be owner read/write only (0600)"
2116        );
2117
2118        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
2119    }
2120
2121    #[cfg(unix)]
2122    #[test]
2123    fn written_config_dir_has_owner_only_permissions() {
2124        use std::os::unix::fs::PermissionsExt;
2125        let path = temp_config_path("perms-dir");
2126
2127        init_config(Some(path.clone()), false).unwrap();
2128
2129        let dir = path.parent().unwrap();
2130        let mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777;
2131        assert_eq!(mode, 0o700, "config directory should be owner-only (0700)");
2132
2133        std::fs::remove_dir_all(dir).unwrap();
2134    }
2135
2136    #[test]
2137    fn redact_replaces_token_but_preserves_token_env() {
2138        let cfg: AppConfig = toml::from_str(
2139            r#"
2140                [[servers]]
2141                id = "home"
2142                token = "secret"
2143                token_env = "MY_TOKEN_VAR"
2144            "#,
2145        )
2146        .unwrap();
2147
2148        let redacted = cfg.redact();
2149        let server = redacted.selected_server(None).unwrap();
2150        assert_eq!(server.token.as_deref(), Some("[redacted]"));
2151        assert_eq!(server.token_env.as_deref(), Some("MY_TOKEN_VAR"));
2152    }
2153
2154    #[test]
2155    fn redact_leaves_none_token_as_none() {
2156        let cfg: AppConfig = toml::from_str(
2157            r#"
2158                [[servers]]
2159                id = "home"
2160                token_env = "MY_TOKEN_VAR"
2161            "#,
2162        )
2163        .unwrap();
2164
2165        let redacted = cfg.redact();
2166        assert!(redacted.selected_server(None).unwrap().token.is_none());
2167    }
2168
2169    #[test]
2170    fn config_validation_endpoint_roundtrip() {
2171        let cfg: AppConfig = toml::from_str(
2172            r#"
2173                [[servers]]
2174                id = "home"
2175                token_env = "MY_TOKEN_VAR"
2176
2177                [[servers.validation_endpoints]]
2178                name = "router"
2179                transport = "dns"
2180                address = "192.168.1.1"
2181                port = 53
2182                enabled = true
2183                timeout_ms = 1500
2184
2185                [[servers.validation_endpoints]]
2186                name = "cloudflare-doh"
2187                transport = "doh"
2188                url = "https://cloudflare-dns.com/dns-query"
2189                enabled = true
2190
2191                [[servers.validation_endpoints]]
2192                name = "quad9-dot"
2193                transport = "dot"
2194                address = "9.9.9.9"
2195                port = 853
2196                tls_server_name = "dns.quad9.net"
2197            "#,
2198        )
2199        .unwrap();
2200
2201        cfg.validate().unwrap();
2202        let rendered = cfg.render_toml().unwrap();
2203        let reparsed: AppConfig = toml::from_str(&rendered).unwrap();
2204        let endpoints = &reparsed.selected_server(None).unwrap().validation_endpoints;
2205
2206        assert_eq!(endpoints.len(), 3);
2207        assert_eq!(endpoints[0].name, "router");
2208        assert_eq!(endpoints[0].transport, ValidationTransport::Dns);
2209        assert_eq!(
2210            endpoints[1].url.as_deref(),
2211            Some("https://cloudflare-dns.com/dns-query")
2212        );
2213        assert_eq!(
2214            endpoints[2].tls_server_name.as_deref(),
2215            Some("dns.quad9.net")
2216        );
2217        assert!(rendered.contains("[[servers.validation_endpoints]]"));
2218    }
2219
2220    #[test]
2221    fn server_transport_blocks_roundtrip() {
2222        let cfg: AppConfig = toml::from_str(
2223            r#"
2224                [[servers]]
2225                id = "dns1"
2226                vendor = "technitium"
2227                cluster = "home-dns"
2228
2229                [servers.dns]
2230                enabled = true
2231                addr = "10.5.0.53:53"
2232                timeout_ms = 1500
2233
2234                [servers.dot]
2235                enabled = true
2236                addr = "10.5.0.53:853"
2237                server_name = "dns1.hankin.io"
2238
2239                [servers.doh]
2240                enabled = true
2241                url = "https://dns1.hankin.io/dns-query"
2242                addr = "10.5.0.53:443"
2243                server_name = "dns1.hankin.io"
2244
2245                [servers.doq]
2246                enabled = true
2247                addr = "10.5.0.53:853"
2248                server_name = "dns1.hankin.io"
2249                timeout_ms = 2000
2250
2251                [clusters.home-dns]
2252                members = ["dns1"]
2253            "#,
2254        )
2255        .unwrap();
2256
2257        cfg.validate().unwrap();
2258        let rendered = cfg.render_toml().unwrap();
2259        let reparsed: AppConfig = toml::from_str(&rendered).unwrap();
2260        let server = reparsed.selected_server(None).unwrap();
2261
2262        assert_eq!(server.cluster.as_deref(), Some("home-dns"));
2263        assert_eq!(
2264            server.dns.as_ref().unwrap().addr.as_deref(),
2265            Some("10.5.0.53:53")
2266        );
2267        assert_eq!(
2268            server.dot.as_ref().unwrap().server_name.as_deref(),
2269            Some("dns1.hankin.io")
2270        );
2271        assert_eq!(
2272            server.doh.as_ref().unwrap().url.as_deref(),
2273            Some("https://dns1.hankin.io/dns-query")
2274        );
2275        let doq = server.doq.as_ref().unwrap();
2276        assert!(doq.enabled);
2277        assert_eq!(doq.addr.as_deref(), Some("10.5.0.53:853"));
2278        assert_eq!(doq.server_name.as_deref(), Some("dns1.hankin.io"));
2279        assert_eq!(doq.timeout_ms, Some(2000));
2280        assert!(rendered.contains("[servers.dns]"));
2281        assert!(rendered.contains("[servers.dot]"));
2282        assert!(rendered.contains("[servers.doh]"));
2283        assert!(rendered.contains("[servers.doq]"));
2284    }
2285
2286    #[test]
2287    fn cloudflare_external_server_gets_provider_transport_defaults() {
2288        let cfg: AppConfig = toml::from_str(
2289            r#"
2290                [[servers]]
2291                id = "cf"
2292                vendor = "cloudflare"
2293                token_env = "DNSYNC_CLOUDFLARE_API_TOKEN"
2294            "#,
2295        )
2296        .unwrap();
2297
2298        cfg.validate().unwrap();
2299        let server = cfg.selected_server(None).unwrap();
2300
2301        assert_eq!(
2302            server.dns.as_ref().unwrap().addr.as_deref(),
2303            Some("1.1.1.1:53")
2304        );
2305        assert_eq!(
2306            server.dot.as_ref().unwrap().server_name.as_deref(),
2307            Some("cloudflare-dns.com")
2308        );
2309        let doh = server.doh.as_ref().unwrap();
2310        assert_eq!(
2311            doh.url.as_deref(),
2312            Some("https://cloudflare-dns.com/dns-query")
2313        );
2314        assert_eq!(doh.addr.as_deref(), Some("1.1.1.1:443"));
2315        assert_eq!(
2316            server.doq.as_ref().unwrap().server_name.as_deref(),
2317            Some("cloudflare-dns.com")
2318        );
2319    }
2320
2321    #[test]
2322    fn cloudflare_transport_blocks_override_provider_defaults() {
2323        let cfg: AppConfig = toml::from_str(
2324            r#"
2325                [[servers]]
2326                id = "cf"
2327                vendor = "cloudflare"
2328                token_env = "DNSYNC_CLOUDFLARE_API_TOKEN"
2329
2330                [servers.dns]
2331                enabled = false
2332
2333                [servers.doh]
2334                enabled = true
2335                url = "https://security.cloudflare-dns.com/dns-query"
2336                addr = "1.1.1.2:443"
2337                server_name = "security.cloudflare-dns.com"
2338                timeout_ms = 2500
2339            "#,
2340        )
2341        .unwrap();
2342
2343        cfg.validate().unwrap();
2344        let server = cfg.selected_server(None).unwrap();
2345
2346        let dns = server.dns.as_ref().unwrap();
2347        assert!(!dns.enabled);
2348        assert_eq!(dns.addr, None);
2349
2350        let doh = server.doh.as_ref().unwrap();
2351        assert_eq!(
2352            doh.url.as_deref(),
2353            Some("https://security.cloudflare-dns.com/dns-query")
2354        );
2355        assert_eq!(doh.addr.as_deref(), Some("1.1.1.2:443"));
2356        assert_eq!(
2357            doh.server_name.as_deref(),
2358            Some("security.cloudflare-dns.com")
2359        );
2360        assert_eq!(doh.timeout_ms, Some(2500));
2361
2362        assert_eq!(
2363            server.dot.as_ref().unwrap().addr.as_deref(),
2364            Some("1.1.1.1:853")
2365        );
2366        assert_eq!(
2367            server.doq.as_ref().unwrap().addr.as_deref(),
2368            Some("1.1.1.1:853")
2369        );
2370    }
2371
2372    #[test]
2373    fn cloudflare_local_server_does_not_get_provider_transport_defaults() {
2374        let cfg: AppConfig = toml::from_str(
2375            r#"
2376                [[servers]]
2377                id = "cf-local"
2378                vendor = "cloudflare"
2379                location = "local"
2380                token_env = "DNSYNC_CLOUDFLARE_API_TOKEN"
2381            "#,
2382        )
2383        .unwrap();
2384
2385        cfg.validate().unwrap();
2386        let server = cfg.selected_server(None).unwrap();
2387
2388        assert!(server.dns.is_none());
2389        assert!(server.dot.is_none());
2390        assert!(server.doh.is_none());
2391        assert!(server.doq.is_none());
2392    }
2393
2394    #[test]
2395    fn validate_rejects_doq_without_addr() {
2396        let cfg: AppConfig = toml::from_str(
2397            r#"
2398                [[servers]]
2399                id = "dns1"
2400
2401                [servers.doq]
2402                enabled = true
2403            "#,
2404        )
2405        .unwrap();
2406
2407        let err = cfg.validate().unwrap_err();
2408        assert!(
2409            err.to_string()
2410                .contains("enabled doq transport without addr"),
2411            "unexpected error: {err}",
2412        );
2413    }
2414
2415    #[test]
2416    fn disabled_doq_block_does_not_require_addr() {
2417        let cfg: AppConfig = toml::from_str(
2418            r#"
2419                [[servers]]
2420                id = "dns1"
2421
2422                [servers.doq]
2423                enabled = false
2424            "#,
2425        )
2426        .unwrap();
2427
2428        cfg.validate().unwrap();
2429    }
2430
2431    #[test]
2432    fn disabled_transport_blocks_can_omit_endpoints() {
2433        let cfg: AppConfig = toml::from_str(
2434            r#"
2435                [[servers]]
2436                id = "dns1"
2437
2438                [servers.dns]
2439                enabled = false
2440
2441                [servers.dot]
2442                enabled = false
2443
2444                [servers.doh]
2445                enabled = false
2446            "#,
2447        )
2448        .unwrap();
2449
2450        cfg.validate().unwrap();
2451        let rendered = cfg.render_toml().unwrap();
2452
2453        assert!(rendered.contains("[servers.dns]"));
2454        assert!(rendered.contains("enabled = false"));
2455        assert!(!rendered.contains("addr = \"\""));
2456        assert!(!rendered.contains("url = \"\""));
2457    }
2458
2459    #[test]
2460    fn cluster_config_roundtrip() {
2461        let cfg: AppConfig = toml::from_str(
2462            r#"
2463                [[servers]]
2464                id = "dns1"
2465                vendor = "technitium"
2466                cluster = "home-dns"
2467
2468                [[servers]]
2469                id = "dns2"
2470                vendor = "technitium"
2471                cluster = "home-dns"
2472
2473                [clusters.home-dns]
2474                vendor = "technitium"
2475                members = ["dns1", "dns2"]
2476                write_policy = "primary_only"
2477                primary = "auto"
2478                catalog_zone = "auto"
2479                preferred_writer = "dns1"
2480            "#,
2481        )
2482        .unwrap();
2483
2484        cfg.validate().unwrap();
2485        let rendered = cfg.render_toml().unwrap();
2486        let reparsed: AppConfig = toml::from_str(&rendered).unwrap();
2487        let cluster = reparsed.clusters.get("home-dns").unwrap();
2488
2489        assert_eq!(cluster.members, ["dns1", "dns2"]);
2490        assert_eq!(cluster.write_policy, ClusterWritePolicy::PrimaryOnly);
2491        assert_eq!(cluster.primary.as_deref(), Some("auto"));
2492        assert_eq!(cluster.catalog_zone.as_deref(), Some("auto"));
2493        assert_eq!(cluster.preferred_writer.as_deref(), Some("dns1"));
2494        assert!(rendered.contains("[clusters.home-dns]"));
2495    }
2496
2497    #[test]
2498    fn cluster_rejects_unknown_members() {
2499        let cfg: AppConfig = toml::from_str(
2500            r#"
2501                [[servers]]
2502                id = "dns1"
2503
2504                [clusters.home-dns]
2505                members = ["dns1", "dns2"]
2506            "#,
2507        )
2508        .unwrap();
2509
2510        let err = cfg.validate().unwrap_err();
2511        assert!(err.to_string().contains("unknown DNS server 'dns2'"));
2512    }
2513
2514    #[test]
2515    fn server_rejects_unknown_cluster_reference() {
2516        let cfg: AppConfig = toml::from_str(
2517            r#"
2518                [[servers]]
2519                id = "dns1"
2520                cluster = "missing"
2521            "#,
2522        )
2523        .unwrap();
2524
2525        let err = cfg.validate().unwrap_err();
2526        assert!(
2527            err.to_string()
2528                .contains("DNS server 'dns1' references unknown cluster 'missing'")
2529        );
2530    }
2531
2532    #[test]
2533    fn config_rejects_invalid_validation_endpoint() {
2534        let cfg: AppConfig = toml::from_str(
2535            r#"
2536                [[servers]]
2537                id = "home"
2538                token_env = "MY_TOKEN_VAR"
2539
2540                [[servers.validation_endpoints]]
2541                name = ""
2542                transport = "dns"
2543                address = "192.168.1.1"
2544            "#,
2545        )
2546        .unwrap();
2547
2548        let err = cfg.validate().unwrap_err();
2549        assert!(err.to_string().contains("empty name"));
2550
2551        let cfg: AppConfig = toml::from_str(
2552            r#"
2553                [[servers]]
2554                id = "home"
2555                token_env = "MY_TOKEN_VAR"
2556
2557                [[servers.validation_endpoints]]
2558                name = "missing-url"
2559                transport = "doh"
2560            "#,
2561        )
2562        .unwrap();
2563
2564        let err = cfg.validate().unwrap_err();
2565        assert!(err.to_string().contains("requires url"));
2566    }
2567
2568    #[test]
2569    fn config_print_redacts_tokens_but_keeps_validation_endpoints() {
2570        let cfg: AppConfig = toml::from_str(
2571            r#"
2572                [[servers]]
2573                id = "home"
2574                token = "secret"
2575
2576                [[servers.validation_endpoints]]
2577                name = "router"
2578                transport = "dns"
2579                address = "192.168.1.1"
2580            "#,
2581        )
2582        .unwrap();
2583
2584        let redacted = cfg.redact();
2585        let server = redacted.selected_server(None).unwrap();
2586
2587        assert_eq!(server.token.as_deref(), Some("[redacted]"));
2588        assert_eq!(
2589            server.validation_endpoints,
2590            cfg.servers[0].validation_endpoints
2591        );
2592    }
2593
2594    #[test]
2595    fn load_if_exists_returns_none_when_no_file() {
2596        let path = temp_config_path("load-if-exists-missing");
2597        assert!(!path.exists());
2598
2599        let result = AppConfig::load_if_exists(Some(path)).unwrap();
2600        assert!(result.is_none());
2601    }
2602
2603    #[test]
2604    fn load_if_exists_returns_config_when_file_present() {
2605        let path = temp_config_path("load-if-exists-present");
2606        // Use init_config so the file is created with correct permissions
2607        init_config(Some(path.clone()), false).unwrap();
2608
2609        let config = AppConfig::load_if_exists(Some(path.clone()))
2610            .expect("should load")
2611            .expect("should be Some");
2612        assert_eq!(config.selected_server(None).unwrap().id, "default");
2613
2614        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
2615    }
2616
2617    #[test]
2618    fn add_server_creates_config_with_single_server() {
2619        let path = temp_config_path("add-server-new");
2620        let server = DnsServerConfig {
2621            id: "myserver".to_string(),
2622            vendor: VendorKind::Technitium,
2623            location: None,
2624            base_url: Some("http://192.168.1.10:5380".to_string()),
2625            base_url_env: None,
2626            token: None,
2627            token_env: Some("MY_API_TOKEN".to_string()),
2628            org_id: None,
2629            cluster: None,
2630            dns: None,
2631            dot: None,
2632            doh: None,
2633            doq: None,
2634            mcp: McpPermissions::default(),
2635            validation_endpoints: Vec::new(),
2636        };
2637
2638        let written = add_server(Some(path.clone()), server).unwrap();
2639        assert_eq!(written, path);
2640
2641        let config = AppConfig::load(Some(path.clone())).unwrap().unwrap();
2642        let s = config.selected_server(None).unwrap();
2643        assert_eq!(s.id, "myserver");
2644        assert_eq!(s.token_env.as_deref(), Some("MY_API_TOKEN"));
2645
2646        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
2647    }
2648
2649    #[test]
2650    fn add_server_appends_to_existing_config() {
2651        let path = temp_config_path("add-server-existing");
2652        init_config(Some(path.clone()), false).unwrap();
2653
2654        let server = DnsServerConfig {
2655            id: "lab".to_string(),
2656            vendor: VendorKind::Technitium,
2657            location: None,
2658            base_url: Some("http://192.168.1.20:5380".to_string()),
2659            base_url_env: None,
2660            token: None,
2661            token_env: Some("LAB_TOKEN".to_string()),
2662            org_id: None,
2663            cluster: None,
2664            dns: None,
2665            dot: None,
2666            doh: None,
2667            doq: None,
2668            mcp: McpPermissions::default(),
2669            validation_endpoints: Vec::new(),
2670        };
2671
2672        add_server(Some(path.clone()), server).unwrap();
2673
2674        let config = AppConfig::load(Some(path.clone())).unwrap().unwrap();
2675        assert_eq!(config.servers.len(), 2);
2676        assert!(config.selected_server(Some("default")).is_ok());
2677        assert!(config.selected_server(Some("lab")).is_ok());
2678
2679        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
2680    }
2681
2682    #[test]
2683    fn add_server_preserves_comments_in_existing_config() {
2684        let path = temp_config_path("add-server-comments");
2685        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2686        let original = concat!(
2687            "# My DNS servers\n",
2688            "[[servers]]\n",
2689            "id = \"home\"\n",
2690            "# Home server uses its own env var\n",
2691            "token_env = \"HOME_TOKEN\"\n",
2692        );
2693        std::fs::write(&path, original).unwrap();
2694        #[cfg(unix)]
2695        {
2696            use std::os::unix::fs::PermissionsExt;
2697            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2698        }
2699
2700        let server = DnsServerConfig {
2701            id: "lab".to_string(),
2702            vendor: VendorKind::Technitium,
2703            location: None,
2704            base_url: None,
2705            base_url_env: None,
2706            token: None,
2707            token_env: Some("LAB_TOKEN".to_string()),
2708            org_id: None,
2709            cluster: None,
2710            dns: None,
2711            dot: None,
2712            doh: None,
2713            doq: None,
2714            mcp: McpPermissions::default(),
2715            validation_endpoints: Vec::new(),
2716        };
2717        add_server(Some(path.clone()), server).unwrap();
2718
2719        let written = std::fs::read_to_string(&path).unwrap();
2720        assert!(
2721            written.contains("# My DNS servers"),
2722            "top-level comment should be preserved"
2723        );
2724        assert!(
2725            written.contains("# Home server uses its own env var"),
2726            "inline comment should be preserved"
2727        );
2728        assert!(
2729            written.contains("id = \"lab\""),
2730            "new server should be appended"
2731        );
2732
2733        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
2734    }
2735
2736    #[test]
2737    fn update_defaults_adds_missing_values_without_overwriting_existing_ones() {
2738        let path = temp_config_path("update-defaults");
2739        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2740        let original = concat!(
2741            "# Existing config\n",
2742            "[[servers]]\n",
2743            "id = \"cf\"\n",
2744            "vendor = \"cloudflare\"\n",
2745            "token_env = \"CF_TOKEN\"\n",
2746            "\n",
2747            "[servers.dns]\n",
2748            "enabled = false\n",
2749            "\n",
2750            "[[servers]]\n",
2751            "id = \"home\"\n",
2752            "base_url_env = \"HOME_URL\"\n",
2753            "token_env = \"HOME_TOKEN\"\n",
2754        );
2755        write_private_file(&path, original).unwrap();
2756
2757        let report = update_defaults(Some(path.clone())).unwrap();
2758
2759        assert_eq!(report.updated_servers, 2);
2760        assert!(report.added_values >= 1);
2761
2762        let updated = std::fs::read_to_string(&path).unwrap();
2763        assert!(updated.contains("# Existing config"));
2764        assert!(updated.contains("base_url = \"https://api.cloudflare.com/client/v4\""));
2765        assert!(updated.contains("[servers.dot]"));
2766        assert!(updated.contains("server_name = \"cloudflare-dns.com\""));
2767        assert!(updated.contains("[servers.doh]"));
2768        assert!(updated.contains("[servers.doq]"));
2769        assert!(updated.contains("base_url_env = \"HOME_URL\""));
2770        assert!(!updated.contains("base_url = \"http://localhost:5380\""));
2771
2772        let parsed = AppConfig::load(Some(path.clone())).unwrap().unwrap();
2773        let cf = parsed.selected_server(Some("cf")).unwrap();
2774        assert_eq!(cf.dns.as_ref().unwrap().enabled, false);
2775        assert_eq!(cf.dns.as_ref().unwrap().addr, None);
2776
2777        let second = update_defaults(Some(path.clone())).unwrap();
2778        assert_eq!(second.updated_servers, 0);
2779        assert_eq!(second.added_values, 0);
2780
2781        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
2782    }
2783
2784    #[test]
2785    fn add_server_rejects_duplicate_id() {
2786        let path = temp_config_path("add-server-duplicate");
2787        init_config(Some(path.clone()), false).unwrap();
2788
2789        let server = DnsServerConfig {
2790            id: "default".to_string(), // already exists
2791            vendor: VendorKind::Technitium,
2792            location: None,
2793            base_url: None,
2794            base_url_env: None,
2795            token: None,
2796            token_env: None,
2797            org_id: None,
2798            cluster: None,
2799            dns: None,
2800            dot: None,
2801            doh: None,
2802            doq: None,
2803            mcp: McpPermissions::default(),
2804            validation_endpoints: Vec::new(),
2805        };
2806
2807        let err = add_server(Some(path.clone()), server).unwrap_err();
2808        assert!(err.to_string().contains("duplicate DNS server id"));
2809
2810        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
2811    }
2812
2813    #[cfg(unix)]
2814    #[test]
2815    fn load_errors_if_config_is_world_readable() {
2816        use std::os::unix::fs::PermissionsExt;
2817        let path = temp_config_path("world-readable");
2818        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2819        std::fs::write(&path, AppConfig::render_starter_toml().unwrap()).unwrap();
2820        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2821
2822        let err = AppConfig::load(Some(path.clone())).unwrap_err();
2823
2824        assert!(
2825            err.to_string().contains("chmod 600"),
2826            "error should include remediation command"
2827        );
2828
2829        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
2830    }
2831
2832    // ── resolved_location ─────────────────────────────────────────────────────
2833
2834    fn server_with_url(url: &str) -> DnsServerConfig {
2835        DnsServerConfig {
2836            id: "test".to_string(),
2837            vendor: VendorKind::Technitium,
2838            location: None,
2839            base_url: Some(url.to_string()),
2840            base_url_env: None,
2841            token: None,
2842            token_env: None,
2843            org_id: None,
2844            cluster: None,
2845            dns: None,
2846            dot: None,
2847            doh: None,
2848            doq: None,
2849            mcp: McpPermissions::default(),
2850            validation_endpoints: Vec::new(),
2851        }
2852    }
2853
2854    #[tokio::test]
2855    async fn localhost_url_is_local() {
2856        assert_eq!(
2857            server_with_url("http://localhost:5380")
2858                .resolved_location()
2859                .await,
2860            ServerLocation::Local
2861        );
2862    }
2863
2864    #[tokio::test]
2865    async fn loopback_ip_is_local() {
2866        assert_eq!(
2867            server_with_url("http://127.0.0.1:5380")
2868                .resolved_location()
2869                .await,
2870            ServerLocation::Local
2871        );
2872    }
2873
2874    #[tokio::test]
2875    async fn private_ip_is_local() {
2876        assert_eq!(
2877            server_with_url("http://192.168.1.10:5380")
2878                .resolved_location()
2879                .await,
2880            ServerLocation::Local
2881        );
2882        assert_eq!(
2883            server_with_url("http://10.0.0.1:8080")
2884                .resolved_location()
2885                .await,
2886            ServerLocation::Local
2887        );
2888    }
2889
2890    #[tokio::test]
2891    async fn public_ip_is_external() {
2892        assert_eq!(
2893            server_with_url("https://1.2.3.4:5380")
2894                .resolved_location()
2895                .await,
2896            ServerLocation::External
2897        );
2898    }
2899
2900    #[tokio::test]
2901    async fn cloud_domain_is_external() {
2902        assert_eq!(
2903            server_with_url("https://api.pangolin.net/v1")
2904                .resolved_location()
2905                .await,
2906            ServerLocation::External
2907        );
2908    }
2909
2910    #[tokio::test]
2911    async fn technitium_default_url_is_local() {
2912        let server = DnsServerConfig {
2913            id: "test".to_string(),
2914            vendor: VendorKind::Technitium,
2915            location: None,
2916            base_url: None,
2917            base_url_env: None,
2918            token: None,
2919            token_env: None,
2920            org_id: None,
2921            cluster: None,
2922            dns: None,
2923            dot: None,
2924            doh: None,
2925            doq: None,
2926            mcp: McpPermissions::default(),
2927            validation_endpoints: Vec::new(),
2928        };
2929        assert_eq!(server.resolved_location().await, ServerLocation::Local);
2930    }
2931
2932    #[tokio::test]
2933    async fn pangolin_default_url_is_external() {
2934        let server = DnsServerConfig {
2935            id: "test".to_string(),
2936            vendor: VendorKind::Pangolin,
2937            location: None,
2938            base_url: None,
2939            base_url_env: None,
2940            token: None,
2941            token_env: None,
2942            org_id: None,
2943            cluster: None,
2944            dns: None,
2945            dot: None,
2946            doh: None,
2947            doq: None,
2948            mcp: McpPermissions::default(),
2949            validation_endpoints: Vec::new(),
2950        };
2951        assert_eq!(server.resolved_location().await, ServerLocation::External);
2952    }
2953
2954    #[tokio::test]
2955    async fn explicit_location_overrides_auto_detection() {
2956        let mut server = server_with_url("https://api.pangolin.net");
2957        server.location = Some(ServerLocation::Local);
2958        assert_eq!(server.resolved_location().await, ServerLocation::Local);
2959
2960        server.location = Some(ServerLocation::External);
2961        assert_eq!(server.resolved_location().await, ServerLocation::External);
2962    }
2963
2964    // ── url_host extraction ───────────────────────────────────────────────────
2965
2966    #[test]
2967    fn url_host_strips_scheme_and_port() {
2968        assert_eq!(url_host("http://localhost:5380"), "localhost");
2969        assert_eq!(url_host("https://192.168.1.1:443"), "192.168.1.1");
2970        assert_eq!(url_host("https://api.pangolin.net/v1"), "api.pangolin.net");
2971    }
2972
2973    #[test]
2974    fn url_host_handles_ipv6_literals() {
2975        assert_eq!(url_host("http://[::1]:5380"), "::1");
2976    }
2977
2978    #[test]
2979    fn url_host_no_port() {
2980        assert_eq!(url_host("http://myserver"), "myserver");
2981    }
2982
2983    // ── location field TOML round-trip ────────────────────────────────────────
2984
2985    #[test]
2986    fn location_field_round_trips_in_toml() {
2987        let toml = r#"
2988            [[servers]]
2989            id = "home"
2990            vendor = "technitium"
2991            location = "external"
2992            token = "tok"
2993        "#;
2994        let config: AppConfig = toml::from_str(toml).expect("should parse");
2995        let server = config.selected_server(None).unwrap();
2996        assert_eq!(server.location, Some(ServerLocation::External));
2997    }
2998
2999    // ── sync profiles ─────────────────────────────────────────────────────────
3000
3001    fn sync_config() -> &'static str {
3002        r#"
3003            [[servers]]
3004            id = "cf"
3005            token = "tok"
3006
3007            [[servers]]
3008            id = "home"
3009            token = "tok"
3010
3011            [[sync]]
3012            name = "split"
3013            from = "cf"
3014            to = "home"
3015            zones = ["example.com"]
3016
3017            [sync.ip_map]
3018            "203.0.113.10" = "192.168.1.10"
3019        "#
3020    }
3021
3022    #[test]
3023    fn parses_and_validates_sync_profile() {
3024        let config: AppConfig = toml::from_str(sync_config()).expect("should parse");
3025        config.validate().expect("sync profile should validate");
3026
3027        assert_eq!(config.sync.len(), 1);
3028        let profile = &config.sync[0];
3029        assert_eq!(profile.name, "split");
3030        assert_eq!(profile.from, "cf");
3031        assert_eq!(profile.to, "home");
3032        assert_eq!(profile.zones, ["example.com"]);
3033        assert_eq!(
3034            profile.ip_map.get("203.0.113.10").map(String::as_str),
3035            Some("192.168.1.10")
3036        );
3037    }
3038
3039    #[test]
3040    fn sync_profile_round_trips_through_render_toml() {
3041        let config: AppConfig = toml::from_str(sync_config()).expect("should parse");
3042        let rendered = config.render_toml().expect("should render");
3043        let reparsed: AppConfig =
3044            toml::from_str(&rendered).expect("rendered sync config should parse back");
3045        reparsed
3046            .validate()
3047            .expect("reparsed config should validate");
3048        assert_eq!(reparsed.sync.len(), 1);
3049        assert_eq!(reparsed.sync[0].name, "split");
3050        assert_eq!(
3051            reparsed.sync[0]
3052                .ip_map
3053                .get("203.0.113.10")
3054                .map(String::as_str),
3055            Some("192.168.1.10")
3056        );
3057    }
3058
3059    #[test]
3060    fn rejects_sync_profile_with_unknown_server() {
3061        let config: AppConfig = toml::from_str(
3062            r#"
3063                [[servers]]
3064                id = "cf"
3065                token = "tok"
3066
3067                [[sync]]
3068                name = "bad"
3069                from = "cf"
3070                to = "missing"
3071            "#,
3072        )
3073        .expect("should parse before validation");
3074
3075        let err = config.validate().unwrap_err();
3076        assert!(err.to_string().contains("unknown destination server"));
3077    }
3078
3079    #[test]
3080    fn rejects_sync_profile_with_family_mismatched_ip_map() {
3081        let config: AppConfig = toml::from_str(
3082            r#"
3083                [[servers]]
3084                id = "cf"
3085                token = "tok"
3086
3087                [[servers]]
3088                id = "home"
3089                token = "tok"
3090
3091                [[sync]]
3092                name = "bad"
3093                from = "cf"
3094                to = "home"
3095
3096                [sync.ip_map]
3097                "203.0.113.10" = "fd00::1"
3098            "#,
3099        )
3100        .expect("should parse before validation");
3101
3102        let err = config.validate().unwrap_err();
3103        assert!(err.to_string().contains("IPv4 and IPv6"));
3104    }
3105
3106    #[test]
3107    fn rejects_duplicate_sync_profile_names() {
3108        let config: AppConfig = toml::from_str(
3109            r#"
3110                [[servers]]
3111                id = "cf"
3112                token = "tok"
3113
3114                [[servers]]
3115                id = "home"
3116                token = "tok"
3117
3118                [[sync]]
3119                name = "dup"
3120                from = "cf"
3121                to = "home"
3122
3123                [[sync]]
3124                name = "DUP"
3125                from = "home"
3126                to = "cf"
3127            "#,
3128        )
3129        .expect("should parse before validation");
3130
3131        let err = config.validate().unwrap_err();
3132        assert!(err.to_string().contains("duplicate sync profile name"));
3133    }
3134}