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
21/// Supported DNS vendor backends.
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
23#[serde(rename_all = "lowercase")]
24pub enum VendorKind {
25    #[default]
26    Technitium,
27    Pangolin,
28    Cloudflare,
29    Unifi,
30    Pihole,
31}
32
33/// Whether the DNS server is on a local network or an external/cloud service.
34///
35/// When omitted from config, the value is inferred from the base URL:
36/// `localhost` and private-range IPs → `local`; everything else → `external`.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
38#[serde(rename_all = "lowercase")]
39pub enum ServerLocation {
40    Local,
41    External,
42}
43
44/// Transport used to query a validation endpoint.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
46#[serde(rename_all = "lowercase")]
47pub enum ValidationTransport {
48    Dns,
49    Doh,
50    Dot,
51}
52
53/// Configured role for writes across a logical cluster.
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ClusterWritePolicy {
57    #[default]
58    PrimaryOnly,
59}
60
61/// Plain DNS query endpoint for a configured server.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct DnsTransportConfig {
65    #[serde(default = "default_true")]
66    pub enabled: bool,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub addr: Option<String>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub timeout_ms: Option<u64>,
71}
72
73/// DNS-over-TLS query endpoint for a configured server.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct DotTransportConfig {
77    #[serde(default = "default_true")]
78    pub enabled: bool,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub addr: Option<String>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub server_name: Option<String>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub timeout_ms: Option<u64>,
85}
86
87/// DNS-over-HTTPS query endpoint for a configured server.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct DohTransportConfig {
91    #[serde(default = "default_true")]
92    pub enabled: bool,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub url: Option<String>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub addr: Option<String>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub server_name: Option<String>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub timeout_ms: Option<u64>,
101}
102
103/// Logical cluster policy shared by member servers.
104///
105/// `primary` and `preferred_writer` accept either a configured DNS server id or
106/// the special value `auto`, matched case-insensitively.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct ClusterConfig {
110    #[serde(default)]
111    pub vendor: VendorKind,
112    #[serde(default)]
113    pub members: Vec<String>,
114    #[serde(default)]
115    pub write_policy: ClusterWritePolicy,
116    /// Primary server id, or `auto` to discover it dynamically.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub primary: Option<String>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub catalog_zone: Option<String>,
121    /// Preferred writer server id, or `auto` to discover it dynamically.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub preferred_writer: Option<String>,
124}
125
126/// DNS endpoint used to validate imported or listed records.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct ValidationEndpointConfig {
130    pub name: String,
131
132    pub transport: ValidationTransport,
133
134    #[serde(default)]
135    pub address: String,
136
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub port: Option<u16>,
139
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub url: Option<String>,
142
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub tls_server_name: Option<String>,
145
146    #[serde(default = "default_true")]
147    pub enabled: bool,
148
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub timeout_ms: Option<u64>,
151}
152
153impl std::str::FromStr for ValidationEndpointConfig {
154    type Err = String;
155
156    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
157        let mut parts = value.splitn(3, ':');
158        let name = parts
159            .next()
160            .filter(|part| !part.trim().is_empty())
161            .ok_or_else(|| "validation endpoint must use name:transport:address".to_string())?;
162        let transport = match parts.next().map(str::to_ascii_lowercase).as_deref() {
163            Some("dns") => ValidationTransport::Dns,
164            Some("doh") => ValidationTransport::Doh,
165            Some("dot") => ValidationTransport::Dot,
166            Some(other) => {
167                return Err(format!(
168                    "unsupported validation endpoint transport '{other}'; expected dns, doh, or dot"
169                ));
170            }
171            None => return Err("validation endpoint must use name:transport:address".to_string()),
172        };
173        let target = parts
174            .next()
175            .filter(|part| !part.trim().is_empty())
176            .ok_or_else(|| "validation endpoint must use name:transport:address".to_string())?;
177
178        Ok(ValidationEndpointConfig {
179            name: name.to_string(),
180            transport,
181            address: if matches!(transport, ValidationTransport::Doh) {
182                String::new()
183            } else {
184                target.to_string()
185            },
186            port: None,
187            url: if matches!(transport, ValidationTransport::Doh) {
188                Some(target.to_string())
189            } else {
190                None
191            },
192            tls_server_name: None,
193            enabled: true,
194            timeout_ms: None,
195        })
196    }
197}
198
199#[derive(Debug, Clone, Default, Serialize, Deserialize)]
200#[serde(deny_unknown_fields)]
201pub struct AppConfig {
202    #[serde(default)]
203    pub servers: Vec<DnsServerConfig>,
204    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
205    pub clusters: BTreeMap<String, ClusterConfig>,
206
207    /// Named record-sync profiles (see `dns sync`).
208    #[serde(default, skip_serializing_if = "Vec::is_empty")]
209    pub sync: Vec<SyncProfile>,
210}
211
212/// A named record-sync profile: copy records from one configured server to
213/// another, optionally rewriting IP addresses on A/AAAA records.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct SyncProfile {
217    /// Unique profile name, invoked as `dns sync <name>`.
218    pub name: String,
219
220    /// Source server id — must match a `[[servers]]` entry.
221    pub from: String,
222
223    /// Destination server id — must match a `[[servers]]` entry.
224    pub to: String,
225
226    /// Zones to sync. Empty means every zone found on the source server.
227    #[serde(default)]
228    pub zones: Vec<String>,
229
230    /// Explicit `source = destination` IP rewrites applied to A/AAAA records.
231    #[serde(default)]
232    pub ip_map: std::collections::BTreeMap<String, String>,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(from = "DnsServerConfigRaw")]
237pub struct DnsServerConfig {
238    pub id: String,
239
240    #[serde(default)]
241    pub vendor: VendorKind,
242
243    /// Whether this server is on a local network or an external/cloud service.
244    /// Inferred from the base URL when omitted.
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub location: Option<ServerLocation>,
247
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub base_url: Option<String>,
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub base_url_env: Option<String>,
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub token: Option<String>,
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub token_env: Option<String>,
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub org_id: Option<String>,
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub cluster: Option<String>,
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub dns: Option<DnsTransportConfig>,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub dot: Option<DotTransportConfig>,
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub doh: Option<DohTransportConfig>,
266
267    #[serde(default, skip_serializing_if = "McpPermissions::is_default")]
268    pub mcp: McpPermissions,
269
270    #[serde(default, skip_serializing_if = "Vec::is_empty")]
271    pub validation_endpoints: Vec<ValidationEndpointConfig>,
272}
273
274/// Intermediate struct used only for TOML deserialization.
275///
276/// Accepts `mcp_readonly` and `mcp_allowed_zones` directly on the server entry
277/// (flat format) in addition to the nested `[servers.mcp]` table, then
278/// merges them into `McpPermissions` via the `From` impl.
279#[derive(Deserialize)]
280#[serde(deny_unknown_fields)]
281struct DnsServerConfigRaw {
282    id: String,
283    #[serde(default)]
284    vendor: VendorKind,
285    #[serde(default)]
286    location: Option<ServerLocation>,
287    #[serde(default)]
288    base_url: Option<String>,
289    #[serde(default)]
290    base_url_env: Option<String>,
291    #[serde(default)]
292    token: Option<String>,
293    #[serde(default)]
294    token_env: Option<String>,
295    #[serde(default)]
296    org_id: Option<String>,
297    #[serde(default)]
298    cluster: Option<String>,
299    #[serde(default)]
300    dns: Option<DnsTransportConfig>,
301    #[serde(default)]
302    dot: Option<DotTransportConfig>,
303    #[serde(default)]
304    doh: Option<DohTransportConfig>,
305    #[serde(default)]
306    mcp: McpPermissions,
307    #[serde(default)]
308    validation_endpoints: Vec<ValidationEndpointConfig>,
309    // Flat shorthands — merged into `mcp` on conversion.
310    /// Flat shorthand: `mcp_access = ["read", "write", "delete"]`.
311    #[serde(default)]
312    mcp_access: Option<Vec<PolicyRule>>,
313    /// Deprecated flat shorthand kept for backward compatibility; prefer `mcp_access = ["read"]`.
314    #[serde(default)]
315    mcp_readonly: bool,
316    #[serde(default)]
317    mcp_allowed_zones: Vec<String>,
318}
319
320impl From<DnsServerConfigRaw> for DnsServerConfig {
321    fn from(raw: DnsServerConfigRaw) -> Self {
322        let mut zones = raw.mcp.allowed_zones;
323        for z in raw.mcp_allowed_zones {
324            if !zones.contains(&z) {
325                zones.push(z);
326            }
327        }
328        // Flat shorthand resolution: mcp_access wins over deprecated mcp_readonly;
329        // intersect the flat shorthand with the nested mcp.access set.
330        let config_set: HashSet<PolicyRule> = raw.mcp.access.iter().cloned().collect();
331        let access = if let Some(flat) = raw.mcp_access {
332            let flat_set: HashSet<PolicyRule> = flat.into_iter().collect();
333            flat_set
334                .intersection(&config_set)
335                .cloned()
336                .collect::<Vec<_>>()
337        } else if raw.mcp_readonly {
338            let flat_set: HashSet<PolicyRule> = [PolicyRule::Read].into_iter().collect();
339            flat_set
340                .intersection(&config_set)
341                .cloned()
342                .collect::<Vec<_>>()
343        } else {
344            raw.mcp.access
345        };
346
347        DnsServerConfig {
348            id: raw.id,
349            vendor: raw.vendor,
350            location: raw.location,
351            base_url: raw.base_url,
352            base_url_env: raw.base_url_env,
353            token: raw.token,
354            token_env: raw.token_env,
355            org_id: raw.org_id,
356            cluster: raw.cluster,
357            dns: raw.dns,
358            dot: raw.dot,
359            doh: raw.doh,
360            mcp: McpPermissions {
361                access,
362                allowed_zones: zones,
363            },
364            validation_endpoints: raw.validation_endpoints,
365        }
366    }
367}
368
369fn default_true() -> bool {
370    true
371}
372
373fn default_access() -> Vec<PolicyRule> {
374    vec![PolicyRule::Read, PolicyRule::Write, PolicyRule::Delete]
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
378#[serde(deny_unknown_fields)]
379pub struct McpPermissions {
380    /// Permitted operation classes (default: all).
381    #[serde(default = "default_access")]
382    pub access: Vec<PolicyRule>,
383
384    #[serde(default)]
385    pub allowed_zones: Vec<String>,
386}
387
388impl Default for McpPermissions {
389    fn default() -> Self {
390        Self {
391            access: default_access(),
392            allowed_zones: Vec::new(),
393        }
394    }
395}
396
397impl McpPermissions {
398    fn is_default(&self) -> bool {
399        let full: HashSet<PolicyRule> = [PolicyRule::Read, PolicyRule::Write, PolicyRule::Delete]
400            .into_iter()
401            .collect();
402        let current: HashSet<PolicyRule> = self.access.iter().cloned().collect();
403        current == full && self.allowed_zones.is_empty()
404    }
405}
406
407impl AppConfig {
408    pub fn starter() -> Self {
409        AppConfig {
410            servers: vec![DnsServerConfig {
411                id: "default".to_string(),
412                vendor: VendorKind::Technitium,
413                location: None,
414                base_url: Some(TECHNITIUM_DEFAULT_BASE_URL.to_string()),
415                base_url_env: None,
416                token: None,
417                token_env: Some("DNSYNC_TECHNITIUM_API_TOKEN".to_string()),
418                org_id: None,
419                cluster: None,
420                dns: None,
421                dot: None,
422                doh: None,
423                mcp: McpPermissions::default(),
424                validation_endpoints: Vec::new(),
425            }],
426            clusters: BTreeMap::new(),
427            sync: Vec::new(),
428        }
429    }
430
431    pub fn render_starter_toml() -> Result<String> {
432        Self::starter().render_toml()
433    }
434
435    pub fn render_toml(&self) -> Result<String> {
436        let mut doc = toml_edit::DocumentMut::new();
437        for server in &self.servers {
438            append_server_entry(&mut doc, server);
439        }
440        append_cluster_entries(&mut doc, &self.clusters);
441        for profile in &self.sync {
442            append_sync_entry(&mut doc, profile);
443        }
444        Ok(doc.to_string())
445    }
446
447    /// Returns a copy of the config with every literal `token` value replaced
448    /// by `"[redacted]"`. `token_env` values (env var names) are not secrets
449    /// and are left as-is.
450    pub fn redact(&self) -> Self {
451        AppConfig {
452            servers: self
453                .servers
454                .iter()
455                .map(|s| DnsServerConfig {
456                    token: s.token.as_ref().map(|_| "[redacted]".to_string()),
457                    ..s.clone()
458                })
459                .collect(),
460            clusters: self.clusters.clone(),
461            sync: self.sync.clone(),
462        }
463    }
464
465    /// Load the config file if it already exists; return `Ok(None)` if it does
466    /// not. Unlike `load`, this never creates the file.
467    pub fn load_if_exists(path: Option<PathBuf>) -> Result<Option<Self>> {
468        let Some(path) = path.or_else(default_config_path) else {
469            return Ok(None);
470        };
471        if !path.exists() {
472            return Ok(None);
473        }
474        load_from_path(&path).map(Some)
475    }
476
477    /// Load the config file, creating it with starter defaults if it does not
478    /// exist yet.
479    pub fn load(path: Option<PathBuf>) -> Result<Option<Self>> {
480        let Some(path) = path.or_else(default_config_path) else {
481            return Ok(None);
482        };
483
484        if !path.exists() {
485            write_default_config(&path, false)?;
486        }
487
488        load_from_path(&path).map(Some)
489    }
490
491    pub fn selected_server(&self, selected_id: Option<&str>) -> Result<&DnsServerConfig> {
492        if let Some(id) = selected_id {
493            return self
494                .servers
495                .iter()
496                .find(|server| server.id.eq_ignore_ascii_case(id))
497                .ok_or_else(|| {
498                    Error::config(format!("config does not define a DNS server named '{id}'"))
499                });
500        }
501
502        match self.servers.as_slice() {
503            [server] => Ok(server),
504            [] => Err(Error::config("config file does not define any DNS servers")),
505            _ => Err(Error::config(
506                "config file defines multiple DNS servers; select one with --server or DNSYNC_SERVER",
507            )),
508        }
509    }
510
511    fn validate(&self) -> Result<()> {
512        let mut ids = std::collections::HashSet::new();
513        for server in &self.servers {
514            if server.id.trim().is_empty() {
515                return Err(Error::config(
516                    "config contains a DNS server with an empty id",
517                ));
518            }
519            if !ids.insert(server.id.to_lowercase()) {
520                return Err(Error::config(format!(
521                    "config contains duplicate DNS server id '{}'",
522                    server.id
523                )));
524            }
525            if let Some(cluster_id) = &server.cluster
526                && !self.clusters.contains_key(cluster_id)
527            {
528                return Err(Error::config(format!(
529                    "DNS server '{}' references unknown cluster '{}'",
530                    server.id, cluster_id
531                )));
532            }
533            validate_server_transports(server)?;
534            validate_validation_endpoints(server)?;
535        }
536        validate_clusters(&self.clusters, &ids)?;
537
538        let mut sync_names = std::collections::HashSet::new();
539        for profile in &self.sync {
540            if profile.name.trim().is_empty() {
541                return Err(Error::config(
542                    "config contains a sync profile with an empty name",
543                ));
544            }
545            if !sync_names.insert(profile.name.to_lowercase()) {
546                return Err(Error::config(format!(
547                    "config contains duplicate sync profile name '{}'",
548                    profile.name
549                )));
550            }
551            if !ids.contains(&profile.from.to_lowercase()) {
552                return Err(Error::config(format!(
553                    "sync profile '{}' references unknown source server '{}'",
554                    profile.name, profile.from
555                )));
556            }
557            if !ids.contains(&profile.to.to_lowercase()) {
558                return Err(Error::config(format!(
559                    "sync profile '{}' references unknown destination server '{}'",
560                    profile.name, profile.to
561                )));
562            }
563            if profile.from.to_lowercase() == profile.to.to_lowercase() {
564                return Err(Error::config(format!(
565                    "sync profile '{}' has identical source and destination server '{}'",
566                    profile.name, profile.from
567                )));
568            }
569            for (src, dst) in &profile.ip_map {
570                validate_ip_pair(&profile.name, src, dst)?;
571            }
572        }
573
574        Ok(())
575    }
576}
577
578fn validate_validation_endpoints(server: &DnsServerConfig) -> Result<()> {
579    for endpoint in &server.validation_endpoints {
580        if endpoint.name.trim().is_empty() {
581            return Err(Error::config(format!(
582                "DNS server '{}' contains a validation endpoint with an empty name",
583                server.id
584            )));
585        }
586
587        match endpoint.transport {
588            ValidationTransport::Dns | ValidationTransport::Dot
589                if endpoint.address.trim().is_empty() =>
590            {
591                return Err(Error::config(format!(
592                    "validation endpoint '{}' on DNS server '{}' requires address for {:?} transport",
593                    endpoint.name, server.id, endpoint.transport
594                )));
595            }
596            ValidationTransport::Doh
597                if endpoint
598                    .url
599                    .as_deref()
600                    .is_none_or(|url| url.trim().is_empty()) =>
601            {
602                return Err(Error::config(format!(
603                    "validation endpoint '{}' on DNS server '{}' requires url for doh transport",
604                    endpoint.name, server.id
605                )));
606            }
607            _ => {}
608        }
609    }
610
611    Ok(())
612}
613
614fn validate_server_transports(server: &DnsServerConfig) -> Result<()> {
615    if let Some(dns) = &server.dns
616        && dns.enabled
617        && dns
618            .addr
619            .as_deref()
620            .is_none_or(|addr| addr.trim().is_empty())
621    {
622        return Err(Error::config(format!(
623            "DNS server '{}' has enabled dns transport without addr",
624            server.id
625        )));
626    }
627
628    if let Some(dot) = &server.dot
629        && dot.enabled
630        && dot
631            .addr
632            .as_deref()
633            .is_none_or(|addr| addr.trim().is_empty())
634    {
635        return Err(Error::config(format!(
636            "DNS server '{}' has enabled dot transport without addr",
637            server.id
638        )));
639    }
640
641    if let Some(doh) = &server.doh
642        && doh.enabled
643        && doh.url.as_deref().is_none_or(|url| url.trim().is_empty())
644    {
645        return Err(Error::config(format!(
646            "DNS server '{}' has enabled doh transport without url",
647            server.id
648        )));
649    }
650
651    Ok(())
652}
653
654fn validate_clusters(
655    clusters: &BTreeMap<String, ClusterConfig>,
656    server_ids: &HashSet<String>,
657) -> Result<()> {
658    for (id, cluster) in clusters {
659        if id.trim().is_empty() {
660            return Err(Error::config("config contains a cluster with an empty id"));
661        }
662
663        for member in &cluster.members {
664            if !server_ids.contains(&member.to_lowercase()) {
665                return Err(Error::config(format!(
666                    "cluster '{id}' references unknown DNS server '{member}'"
667                )));
668            }
669        }
670
671        for field in [cluster.primary.as_ref(), cluster.preferred_writer.as_ref()]
672            .into_iter()
673            .flatten()
674        {
675            if !field.eq_ignore_ascii_case("auto") && !server_ids.contains(&field.to_lowercase()) {
676                return Err(Error::config(format!(
677                    "cluster '{id}' references unknown DNS server '{field}'"
678                )));
679            }
680        }
681    }
682
683    Ok(())
684}
685
686pub fn init_config(path: Option<PathBuf>, force: bool) -> Result<PathBuf> {
687    let Some(path) = path.or_else(default_config_path) else {
688        return Err(Error::config(
689            "could not determine a default config path; pass --config <path>",
690        ));
691    };
692
693    write_default_config(&path, force)?;
694    Ok(path)
695}
696
697/// Append a new server entry to the config file. Creates the file if it does
698/// not exist yet. Existing file content — including comments and formatting —
699/// is preserved; only the new `[[servers]]` block is appended.
700pub fn add_server(path: Option<PathBuf>, server: DnsServerConfig) -> Result<PathBuf> {
701    let Some(path) = path.or_else(default_config_path) else {
702        return Err(Error::config(
703            "could not determine a default config path; pass --config <path>",
704        ));
705    };
706
707    // Validate via the serde types: check for duplicate IDs etc.
708    let mut config = if path.exists() {
709        load_from_path(&path)?
710    } else {
711        AppConfig::default()
712    };
713    config.servers.push(server.clone());
714    config.validate()?;
715
716    // Read the raw file so toml_edit can preserve comments and formatting.
717    let raw = if path.exists() {
718        std::fs::read_to_string(&path)
719            .map_err(|e| Error::io(format!("reading config file '{}'", path.display()), e))?
720    } else {
721        String::new()
722    };
723
724    let mut doc: toml_edit::DocumentMut = raw.parse().map_err(|e| {
725        Error::config(format!(
726            "could not parse config file '{}': {e}",
727            path.display()
728        ))
729    })?;
730
731    append_server_entry(&mut doc, &server);
732
733    ensure_config_dir(&path)?;
734    write_private_file(&path, &doc.to_string())?;
735    Ok(path)
736}
737
738/// Append a `[[servers]]` entry to a toml_edit document without touching
739/// any existing content.
740fn append_server_entry(doc: &mut toml_edit::DocumentMut, server: &DnsServerConfig) {
741    use toml_edit::{Array, ArrayOfTables, Item, Table, value};
742
743    let mut tbl = Table::new();
744    // Blank line before each [[servers]] header for readability.
745    tbl.decor_mut().set_prefix("\n");
746
747    tbl["id"] = value(server.id.as_str());
748    tbl["vendor"] = value(match server.vendor {
749        VendorKind::Technitium => "technitium",
750        VendorKind::Pangolin => "pangolin",
751        VendorKind::Cloudflare => "cloudflare",
752        VendorKind::Unifi => "unifi",
753        VendorKind::Pihole => "pihole",
754    });
755    if let Some(loc) = server.location {
756        tbl["location"] = value(match loc {
757            ServerLocation::Local => "local",
758            ServerLocation::External => "external",
759        });
760    }
761    if let Some(ref v) = server.base_url {
762        tbl["base_url"] = value(v.as_str());
763    }
764    if let Some(ref v) = server.base_url_env {
765        tbl["base_url_env"] = value(v.as_str());
766    }
767    if let Some(ref v) = server.token_env {
768        tbl["token_env"] = value(v.as_str());
769    }
770    match server.token.as_deref() {
771        Some(t) => tbl["token"] = value(t),
772        // Write an empty placeholder so the field is visible in the config file.
773        None if server.token_env.is_none() => tbl["token"] = value(""),
774        None => {}
775    }
776    if let Some(ref v) = server.org_id {
777        tbl["org_id"] = value(v.as_str());
778    }
779
780    let mut access_arr = Array::new();
781    for rule in &server.mcp.access {
782        access_arr.push(match rule {
783            PolicyRule::Read => "read",
784            PolicyRule::Write => "write",
785            PolicyRule::Delete => "delete",
786        });
787    }
788    tbl["mcp_access"] = value(access_arr);
789    let mut zones = Array::new();
790    for zone in &server.mcp.allowed_zones {
791        zones.push(zone.as_str());
792    }
793    tbl["mcp_allowed_zones"] = value(zones);
794
795    if !server.validation_endpoints.is_empty() {
796        let mut endpoints = ArrayOfTables::new();
797        for endpoint in &server.validation_endpoints {
798            let mut endpoint_tbl = Table::new();
799            endpoint_tbl["name"] = value(endpoint.name.as_str());
800            endpoint_tbl["transport"] = value(match endpoint.transport {
801                ValidationTransport::Dns => "dns",
802                ValidationTransport::Doh => "doh",
803                ValidationTransport::Dot => "dot",
804            });
805            if !endpoint.address.is_empty() {
806                endpoint_tbl["address"] = value(endpoint.address.as_str());
807            }
808            if let Some(port) = endpoint.port {
809                endpoint_tbl["port"] = value(i64::from(port));
810            }
811            if let Some(ref url) = endpoint.url {
812                endpoint_tbl["url"] = value(url.as_str());
813            }
814            if let Some(ref tls_server_name) = endpoint.tls_server_name {
815                endpoint_tbl["tls_server_name"] = value(tls_server_name.as_str());
816            }
817            endpoint_tbl["enabled"] = value(endpoint.enabled);
818            if let Some(timeout_ms) = endpoint.timeout_ms {
819                endpoint_tbl["timeout_ms"] = value(timeout_ms as i64);
820            }
821            endpoints.push(endpoint_tbl);
822        }
823        tbl["validation_endpoints"] = Item::ArrayOfTables(endpoints);
824    }
825
826    if let Some(ref cluster) = server.cluster {
827        tbl["cluster"] = value(cluster.as_str());
828    }
829    if let Some(ref dns) = server.dns {
830        let mut dns_tbl = Table::new();
831        dns_tbl["enabled"] = value(dns.enabled);
832        if let Some(ref addr) = dns.addr {
833            dns_tbl["addr"] = value(addr.as_str());
834        }
835        if let Some(timeout_ms) = dns.timeout_ms {
836            dns_tbl["timeout_ms"] = value(timeout_ms as i64);
837        }
838        tbl["dns"] = Item::Table(dns_tbl);
839    }
840    if let Some(ref dot) = server.dot {
841        let mut dot_tbl = Table::new();
842        dot_tbl["enabled"] = value(dot.enabled);
843        if let Some(ref addr) = dot.addr {
844            dot_tbl["addr"] = value(addr.as_str());
845        }
846        if let Some(ref server_name) = dot.server_name {
847            dot_tbl["server_name"] = value(server_name.as_str());
848        }
849        if let Some(timeout_ms) = dot.timeout_ms {
850            dot_tbl["timeout_ms"] = value(timeout_ms as i64);
851        }
852        tbl["dot"] = Item::Table(dot_tbl);
853    }
854    if let Some(ref doh) = server.doh {
855        let mut doh_tbl = Table::new();
856        doh_tbl["enabled"] = value(doh.enabled);
857        if let Some(ref url) = doh.url {
858            doh_tbl["url"] = value(url.as_str());
859        }
860        if let Some(ref addr) = doh.addr {
861            doh_tbl["addr"] = value(addr.as_str());
862        }
863        if let Some(ref server_name) = doh.server_name {
864            doh_tbl["server_name"] = value(server_name.as_str());
865        }
866        if let Some(timeout_ms) = doh.timeout_ms {
867            doh_tbl["timeout_ms"] = value(timeout_ms as i64);
868        }
869        tbl["doh"] = Item::Table(doh_tbl);
870    }
871
872    match doc.entry("servers") {
873        toml_edit::Entry::Occupied(mut e) => {
874            if let Some(aot) = e.get_mut().as_array_of_tables_mut() {
875                aot.push(tbl);
876            }
877        }
878        toml_edit::Entry::Vacant(e) => {
879            let mut aot = ArrayOfTables::new();
880            aot.push(tbl);
881            e.insert(Item::ArrayOfTables(aot));
882        }
883    }
884}
885
886/// Append a `[[sync]]` profile entry to a toml_edit document without touching
887/// any existing content.
888fn append_sync_entry(doc: &mut toml_edit::DocumentMut, profile: &SyncProfile) {
889    use toml_edit::{Array, ArrayOfTables, Item, Table, value};
890
891    let mut tbl = Table::new();
892    // Blank line before each [[sync]] header for readability.
893    tbl.decor_mut().set_prefix("\n");
894
895    tbl["name"] = value(profile.name.as_str());
896    tbl["from"] = value(profile.from.as_str());
897    tbl["to"] = value(profile.to.as_str());
898
899    let mut zones = Array::new();
900    for zone in &profile.zones {
901        zones.push(zone.as_str());
902    }
903    tbl["zones"] = value(zones);
904
905    if !profile.ip_map.is_empty() {
906        let mut map_tbl = Table::new();
907        for (src, dst) in &profile.ip_map {
908            map_tbl[src.as_str()] = value(dst.as_str());
909        }
910        tbl["ip_map"] = Item::Table(map_tbl);
911    }
912
913    match doc.entry("sync") {
914        toml_edit::Entry::Occupied(mut e) => {
915            if let Some(aot) = e.get_mut().as_array_of_tables_mut() {
916                aot.push(tbl);
917            }
918        }
919        toml_edit::Entry::Vacant(e) => {
920            let mut aot = ArrayOfTables::new();
921            aot.push(tbl);
922            e.insert(Item::ArrayOfTables(aot));
923        }
924    }
925}
926
927/// Validate a single `ip_map` entry: both sides must parse as IP addresses of
928/// the same family.
929fn validate_ip_pair(profile: &str, src: &str, dst: &str) -> Result<()> {
930    let source: IpAddr = src.parse().map_err(|_| {
931        Error::config(format!(
932            "sync profile '{profile}': '{src}' is not a valid IP address"
933        ))
934    })?;
935    let dest: IpAddr = dst.parse().map_err(|_| {
936        Error::config(format!(
937            "sync profile '{profile}': '{dst}' is not a valid IP address"
938        ))
939    })?;
940    if source.is_ipv4() != dest.is_ipv4() {
941        return Err(Error::config(format!(
942            "sync profile '{profile}': IP mapping '{src}' = '{dst}' mixes IPv4 and IPv6"
943        )));
944    }
945    Ok(())
946}
947
948fn write_default_config(path: &Path, force: bool) -> Result<()> {
949    if path.exists() && !force {
950        return Err(Error::config(format!(
951            "config file '{}' already exists; pass --force to overwrite it",
952            path.display()
953        )));
954    }
955
956    ensure_config_dir(path)?;
957    let contents = AppConfig::render_starter_toml()?;
958    write_private_file(path, &contents)
959}
960
961fn ensure_config_dir(path: &Path) -> Result<()> {
962    if let Some(parent) = path.parent() {
963        std::fs::create_dir_all(parent).map_err(|e| {
964            Error::io(
965                format!("creating config directory '{}'", parent.display()),
966                e,
967            )
968        })?;
969        restrict_dir_permissions(parent)?;
970    }
971    Ok(())
972}
973
974fn load_from_path(path: &Path) -> Result<AppConfig> {
975    check_config_permissions(path)?;
976    let contents = std::fs::read_to_string(path)
977        .map_err(|e| Error::io(format!("reading config file '{}'", path.display()), e))?;
978    let config: AppConfig = toml::from_str(&contents).map_err(|e| {
979        Error::config(format!(
980            "could not parse config file '{}': {e}",
981            path.display()
982        ))
983    })?;
984    config.validate()?;
985    Ok(config)
986}
987
988fn append_cluster_entries(
989    doc: &mut toml_edit::DocumentMut,
990    clusters: &BTreeMap<String, ClusterConfig>,
991) {
992    use toml_edit::{Array, Item, Table, value};
993
994    if clusters.is_empty() {
995        return;
996    }
997
998    let mut clusters_tbl = Table::new();
999    clusters_tbl.decor_mut().set_prefix("\n");
1000
1001    for (id, cluster) in clusters {
1002        let mut tbl = Table::new();
1003        tbl["vendor"] = value(match cluster.vendor {
1004            VendorKind::Technitium => "technitium",
1005            VendorKind::Pangolin => "pangolin",
1006            VendorKind::Cloudflare => "cloudflare",
1007            VendorKind::Unifi => "unifi",
1008            VendorKind::Pihole => "pihole",
1009        });
1010        let mut members = Array::new();
1011        for member in &cluster.members {
1012            members.push(member.as_str());
1013        }
1014        tbl["members"] = value(members);
1015        tbl["write_policy"] = value(match cluster.write_policy {
1016            ClusterWritePolicy::PrimaryOnly => "primary_only",
1017        });
1018        if let Some(ref primary) = cluster.primary {
1019            tbl["primary"] = value(primary.as_str());
1020        }
1021        if let Some(ref catalog_zone) = cluster.catalog_zone {
1022            tbl["catalog_zone"] = value(catalog_zone.as_str());
1023        }
1024        if let Some(ref preferred_writer) = cluster.preferred_writer {
1025            tbl["preferred_writer"] = value(preferred_writer.as_str());
1026        }
1027        clusters_tbl[id] = Item::Table(tbl);
1028    }
1029
1030    doc["clusters"] = Item::Table(clusters_tbl);
1031}
1032
1033/// Write `contents` to `path` with owner-only permissions (0o600 on Unix).
1034/// Uses `OpenOptions::mode` so the file is never created world-readable,
1035/// then explicitly sets permissions to handle the overwrite (force) case.
1036#[cfg(unix)]
1037fn write_private_file(path: &Path, contents: &str) -> Result<()> {
1038    use std::io::Write as _;
1039    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
1040
1041    let mut file = std::fs::OpenOptions::new()
1042        .write(true)
1043        .create(true)
1044        .truncate(true)
1045        .mode(0o600)
1046        .open(path)
1047        .map_err(|e| Error::io(format!("creating config file '{}'", path.display()), e))?;
1048
1049    file.write_all(contents.as_bytes())
1050        .map_err(|e| Error::io(format!("writing config file '{}'", path.display()), e))?;
1051
1052    // mode() only applies when the file is newly created; set explicitly for overwrites.
1053    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
1054        .map_err(|e| Error::io(format!("setting permissions on '{}'", path.display()), e))
1055}
1056
1057#[cfg(not(unix))]
1058fn write_private_file(path: &Path, contents: &str) -> Result<()> {
1059    std::fs::write(path, contents)
1060        .map_err(|e| Error::io(format!("creating config file '{}'", path.display()), e))
1061}
1062
1063/// Restrict the config directory to owner-only access (0o700 on Unix).
1064#[cfg(unix)]
1065fn restrict_dir_permissions(path: &Path) -> Result<()> {
1066    use std::os::unix::fs::PermissionsExt;
1067    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
1068        .map_err(|e| Error::io(format!("setting permissions on '{}'", path.display()), e))
1069}
1070
1071#[cfg(not(unix))]
1072fn restrict_dir_permissions(_path: &Path) -> Result<()> {
1073    Ok(())
1074}
1075
1076/// Error if the config file is readable by anyone other than the owner.
1077#[cfg(unix)]
1078fn check_config_permissions(path: &Path) -> Result<()> {
1079    use std::os::unix::fs::MetadataExt;
1080    let meta = std::fs::metadata(path)
1081        .map_err(|e| Error::io(format!("reading metadata for '{}'", path.display()), e))?;
1082    let mode = meta.mode() & 0o777;
1083    if mode & 0o077 != 0 {
1084        return Err(Error::config(format!(
1085            "config file '{}' has permissions {:04o} — group or world can read it.\n\
1086             API tokens must be owner-readable only. Fix with:\n\
1087             \n    chmod 600 {}",
1088            path.display(),
1089            mode,
1090            path.display(),
1091        )));
1092    }
1093    Ok(())
1094}
1095
1096#[cfg(not(unix))]
1097fn check_config_permissions(_path: &Path) -> Result<()> {
1098    Ok(())
1099}
1100
1101impl DnsServerConfig {
1102    /// Returns whether this server is local or external.
1103    ///
1104    /// Uses the explicit `location` config field when set; otherwise resolves
1105    /// the effective base URL's hostname via hickory — private/loopback IPs
1106    /// and `localhost` are `Local`, everything else is `External`.
1107    pub async fn resolved_location(&self) -> ServerLocation {
1108        if let Some(loc) = self.location {
1109            return loc;
1110        }
1111        let url = self.base_url.as_deref().unwrap_or(match self.vendor {
1112            VendorKind::Technitium => TECHNITIUM_DEFAULT_BASE_URL,
1113            VendorKind::Pangolin => PANGOLIN_DEFAULT_BASE_URL,
1114            VendorKind::Cloudflare => CLOUDFLARE_DEFAULT_BASE_URL,
1115            VendorKind::Unifi => UNIFI_DEFAULT_BASE_URL,
1116            VendorKind::Pihole => PIHOLE_DEFAULT_BASE_URL,
1117        });
1118        if url_is_local(url).await {
1119            ServerLocation::Local
1120        } else {
1121            ServerLocation::External
1122        }
1123    }
1124
1125    pub fn resolved_base_url(&self, override_url: Option<&str>) -> String {
1126        override_url
1127            .map(ToOwned::to_owned)
1128            .or_else(|| self.base_url_env.as_ref().and_then(|k| env::var(k).ok()))
1129            .or_else(|| self.base_url.clone())
1130            .unwrap_or_else(|| match self.vendor {
1131                VendorKind::Technitium => TECHNITIUM_DEFAULT_BASE_URL.to_string(),
1132                VendorKind::Pangolin => PANGOLIN_DEFAULT_BASE_URL.to_string(),
1133                VendorKind::Cloudflare => CLOUDFLARE_DEFAULT_BASE_URL.to_string(),
1134                VendorKind::Unifi => UNIFI_DEFAULT_BASE_URL.to_string(),
1135                VendorKind::Pihole => PIHOLE_DEFAULT_BASE_URL.to_string(),
1136            })
1137    }
1138
1139    pub fn resolved_token(&self, override_token: Option<&str>) -> Result<ApiToken> {
1140        if let Some(token) = override_token {
1141            return Ok(ApiToken::new(token));
1142        }
1143
1144        if let Some(ref env_name) = self.token_env {
1145            return env::var(env_name).map(ApiToken::new).map_err(|_| {
1146                Error::config(format!(
1147                    "DNS server '{}' requires token env var '{env_name}' to be set",
1148                    self.id
1149                ))
1150            });
1151        }
1152
1153        // Treat an empty string the same as absent — it's an unfilled placeholder.
1154        self.token
1155            .as_deref()
1156            .filter(|t| !t.is_empty())
1157            .map(ApiToken::new)
1158            .ok_or_else(|| {
1159                Error::config(format!(
1160                    "DNS server '{}' has no token configured; set token or token_env in config, or pass --token",
1161                    self.id
1162                ))
1163            })
1164    }
1165}
1166
1167/// Extracts the host portion (no port, no brackets around IPv6 literals) from a URL.
1168fn url_host(url: &str) -> &str {
1169    let without_scheme = url
1170        .trim_start_matches("https://")
1171        .trim_start_matches("http://");
1172    let authority = without_scheme.split('/').next().unwrap_or(without_scheme);
1173
1174    if authority.starts_with('[') {
1175        // IPv6 literal — strip brackets; ignore the trailing `]:port` part.
1176        authority
1177            .trim_start_matches('[')
1178            .split(']')
1179            .next()
1180            .unwrap_or(authority)
1181    } else {
1182        // Strip port if present (e.g. "192.168.1.1:5380" → "192.168.1.1").
1183        authority.rsplit(':').nth(1).unwrap_or(authority)
1184    }
1185}
1186
1187fn is_local_ip(ip: IpAddr) -> bool {
1188    match ip {
1189        IpAddr::V4(v4) => v4.is_private() || v4.is_loopback(),
1190        IpAddr::V6(v6) => v6.is_loopback(),
1191    }
1192}
1193
1194/// Returns true when the URL resolves to a local/private address.
1195///
1196/// Literal IPs and `localhost` are checked directly. For any other hostname
1197/// hickory resolves it to an IP first — if any resolved address is
1198/// private/loopback the URL is considered local.
1199async fn url_is_local(url: &str) -> bool {
1200    let host = url_host(url);
1201
1202    if host == "localhost" || host == "127.0.0.1" || host == "::1" {
1203        return true;
1204    }
1205
1206    if let Ok(ip) = host.parse::<IpAddr>() {
1207        return is_local_ip(ip);
1208    }
1209
1210    // Hostname — resolve via hickory and check the resulting addresses.
1211    let resolver = match Resolver::builder_tokio() {
1212        Ok(builder) => match builder.build() {
1213            Ok(r) => r,
1214            Err(e) => {
1215                tracing::debug!(%e, "could not build resolver for location check");
1216                return false;
1217            }
1218        },
1219        Err(e) => {
1220            tracing::debug!(%e, "could not load resolver config for location check");
1221            return false;
1222        }
1223    };
1224
1225    match resolver.lookup_ip(host).await {
1226        Ok(lookup) => lookup.iter().any(is_local_ip),
1227        Err(e) => {
1228            tracing::debug!(%e, host, "hostname resolution failed during location check");
1229            false
1230        }
1231    }
1232}
1233
1234pub fn default_config_path() -> Option<PathBuf> {
1235    #[cfg(debug_assertions)]
1236    {
1237        Some(
1238            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1239                .join(".config")
1240                .join("dnsync")
1241                .join("config.toml"),
1242        )
1243    }
1244
1245    #[cfg(not(debug_assertions))]
1246    env::var_os("XDG_CONFIG_HOME")
1247        .map(PathBuf::from)
1248        .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
1249        .map(|dir| dir.join("dnsync").join("config.toml"))
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use super::*;
1255    use std::time::{SystemTime, UNIX_EPOCH};
1256
1257    fn temp_config_path(name: &str) -> PathBuf {
1258        let nonce = SystemTime::now()
1259            .duration_since(UNIX_EPOCH)
1260            .expect("system clock should be after unix epoch")
1261            .as_nanos();
1262
1263        env::temp_dir()
1264            .join("dnsync-config-tests")
1265            .join(format!("{name}-{}-{nonce}", std::process::id()))
1266            .join("config.toml")
1267    }
1268
1269    fn config() -> AppConfig {
1270        toml::from_str(
1271            r#"
1272                [[servers]]
1273                id = "home"
1274                vendor = "technitium"
1275                base_url = "http://home.local:5380"
1276                token = "home-token"
1277
1278                [servers.mcp]
1279                access = ["read"]
1280                allowed_zones = ["example.com", "internal.lan"]
1281
1282                [[servers]]
1283                id = "lab"
1284                vendor = "technitium"
1285                base_url = "http://lab.local:5380"
1286                token_env = "LAB_TOKEN"
1287            "#,
1288        )
1289        .expect("config should parse")
1290    }
1291
1292    #[test]
1293    fn parses_per_server_mcp_permissions() {
1294        let config = config();
1295        let home = config.selected_server(Some("home")).unwrap();
1296
1297        assert_eq!(home.id, "home");
1298        assert_eq!(home.vendor, VendorKind::Technitium);
1299        assert_eq!(home.base_url.as_deref(), Some("http://home.local:5380"));
1300        assert_eq!(home.mcp.access, vec![PolicyRule::Read]);
1301        assert_eq!(home.mcp.allowed_zones, ["example.com", "internal.lan"]);
1302    }
1303
1304    #[test]
1305    fn requires_server_selection_when_multiple_servers_exist() {
1306        let err = config().selected_server(None).unwrap_err();
1307
1308        assert!(err.to_string().contains("multiple DNS servers"));
1309    }
1310
1311    #[test]
1312    fn rejects_duplicate_server_ids_case_insensitively() {
1313        let config: AppConfig = toml::from_str(
1314            r#"
1315                [[servers]]
1316                id = "home"
1317
1318                [[servers]]
1319                id = "HOME"
1320            "#,
1321        )
1322        .expect("config should parse before validation");
1323
1324        let err = config.validate().unwrap_err();
1325
1326        assert!(err.to_string().contains("duplicate DNS server id"));
1327    }
1328
1329    #[test]
1330    fn rejects_unknown_mcp_permission_fields() {
1331        let err = toml::from_str::<AppConfig>(
1332            r#"
1333                [[servers]]
1334                id = "home"
1335
1336                [servers.mcp]
1337                read_only = true
1338            "#,
1339        )
1340        .unwrap_err();
1341
1342        assert!(err.to_string().contains("unknown field"));
1343    }
1344
1345    #[test]
1346    fn selected_server_matches_case_insensitively() {
1347        let config = config();
1348
1349        assert_eq!(config.selected_server(Some("HOME")).unwrap().id, "home");
1350    }
1351
1352    #[test]
1353    fn load_creates_missing_config_with_defaults() {
1354        let path = temp_config_path("missing-default");
1355
1356        let config = AppConfig::load(Some(path.clone()))
1357            .expect("missing config should be created and loaded")
1358            .expect("created config should load");
1359
1360        let server = config.selected_server(None).unwrap();
1361        assert_eq!(server.id, "default");
1362        assert_eq!(server.vendor, VendorKind::Technitium);
1363        assert_eq!(server.base_url.as_deref(), Some("http://localhost:5380"));
1364        assert_eq!(
1365            server.token_env.as_deref(),
1366            Some("DNSYNC_TECHNITIUM_API_TOKEN")
1367        );
1368        assert!(server.token.is_none());
1369        {
1370            use std::collections::HashSet;
1371            let full: HashSet<PolicyRule> =
1372                [PolicyRule::Read, PolicyRule::Write, PolicyRule::Delete]
1373                    .into_iter()
1374                    .collect();
1375            let actual: HashSet<PolicyRule> = server.mcp.access.iter().cloned().collect();
1376            assert_eq!(actual, full);
1377        }
1378        assert!(server.mcp.allowed_zones.is_empty());
1379
1380        // Verify the written file round-trips and uses token_env, not token
1381        let written = std::fs::read_to_string(&path).unwrap();
1382        let reparsed: AppConfig =
1383            toml::from_str(&written).expect("written config should be valid TOML");
1384        let reparsed_server = reparsed.selected_server(None).unwrap();
1385        assert_eq!(
1386            reparsed_server.token_env.as_deref(),
1387            Some("DNSYNC_TECHNITIUM_API_TOKEN")
1388        );
1389        assert!(reparsed_server.token.is_none());
1390
1391        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
1392    }
1393
1394    #[test]
1395    fn load_does_not_overwrite_existing_config() {
1396        let path = temp_config_path("existing-config");
1397        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1398        std::fs::write(
1399            &path,
1400            r#"
1401                [[servers]]
1402                id = "custom"
1403                token = "custom-token"
1404            "#,
1405        )
1406        .unwrap();
1407        // match the permissions the production code sets so the load check passes
1408        #[cfg(unix)]
1409        {
1410            use std::os::unix::fs::PermissionsExt;
1411            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1412        }
1413
1414        let config = AppConfig::load(Some(path.clone()))
1415            .expect("existing config should load")
1416            .expect("config should be present");
1417
1418        assert_eq!(config.selected_server(None).unwrap().id, "custom");
1419        assert!(
1420            std::fs::read_to_string(&path)
1421                .unwrap()
1422                .contains("custom-token")
1423        );
1424
1425        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
1426    }
1427
1428    #[test]
1429    fn init_config_refuses_to_overwrite_existing_config() {
1430        let path = temp_config_path("init-existing-config");
1431        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1432        std::fs::write(&path, "existing = true\n").unwrap();
1433
1434        let err = init_config(Some(path.clone()), false).unwrap_err();
1435
1436        assert!(err.to_string().contains("already exists"));
1437        assert_eq!(std::fs::read_to_string(&path).unwrap(), "existing = true\n");
1438
1439        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
1440    }
1441
1442    #[test]
1443    fn init_config_force_overwrites_existing_config() {
1444        let path = temp_config_path("init-force-config");
1445        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1446        std::fs::write(&path, "existing = true\n").unwrap();
1447
1448        let written_path = init_config(Some(path.clone()), true).unwrap();
1449
1450        assert_eq!(written_path, path);
1451
1452        let written = std::fs::read_to_string(&written_path).unwrap();
1453        let config: AppConfig =
1454            toml::from_str(&written).expect("written config should be valid TOML");
1455        let server = config.selected_server(None).unwrap();
1456        assert_eq!(server.id, "default");
1457        assert_eq!(
1458            server.token_env.as_deref(),
1459            Some("DNSYNC_TECHNITIUM_API_TOKEN")
1460        );
1461        assert!(server.token.is_none());
1462
1463        std::fs::remove_dir_all(written_path.parent().unwrap()).unwrap();
1464    }
1465
1466    #[test]
1467    fn cli_base_url_override_wins_over_config() {
1468        let server = config().selected_server(Some("home")).unwrap().clone();
1469
1470        assert_eq!(
1471            server.resolved_base_url(Some("http://override.local:5380")),
1472            "http://override.local:5380"
1473        );
1474    }
1475
1476    #[test]
1477    fn technitium_base_url_defaults_to_localhost() {
1478        let server = DnsServerConfig {
1479            id: "home".to_string(),
1480            vendor: VendorKind::Technitium,
1481            location: None,
1482            base_url: None,
1483            base_url_env: None,
1484            token: None,
1485            token_env: None,
1486            org_id: None,
1487            cluster: None,
1488            dns: None,
1489            dot: None,
1490            doh: None,
1491            mcp: McpPermissions::default(),
1492            validation_endpoints: Vec::new(),
1493        };
1494
1495        assert_eq!(server.resolved_base_url(None), TECHNITIUM_DEFAULT_BASE_URL);
1496    }
1497
1498    #[test]
1499    fn pangolin_base_url_defaults_to_cloud_api() {
1500        let server = DnsServerConfig {
1501            id: "cloud".to_string(),
1502            vendor: VendorKind::Pangolin,
1503            location: None,
1504            base_url: None,
1505            base_url_env: None,
1506            token: None,
1507            token_env: None,
1508            org_id: None,
1509            cluster: None,
1510            dns: None,
1511            dot: None,
1512            doh: None,
1513            mcp: McpPermissions::default(),
1514            validation_endpoints: Vec::new(),
1515        };
1516
1517        assert_eq!(server.resolved_base_url(None), PANGOLIN_DEFAULT_BASE_URL);
1518    }
1519
1520    #[test]
1521    fn cli_token_override_wins_over_config() {
1522        let server = config().selected_server(Some("home")).unwrap().clone();
1523
1524        assert_eq!(
1525            server
1526                .resolved_token(Some("override-token"))
1527                .unwrap()
1528                .expose_for_auth(),
1529            "override-token"
1530        );
1531    }
1532
1533    #[test]
1534    fn debug_default_config_path_uses_repo_root() {
1535        let path = default_config_path().expect("debug builds should have a default config path");
1536
1537        assert_eq!(
1538            path,
1539            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1540                .join(".config")
1541                .join("dnsync")
1542                .join("config.toml")
1543        );
1544    }
1545
1546    #[test]
1547    fn starter_config_contains_token_env() {
1548        let toml = AppConfig::render_starter_toml().unwrap();
1549        assert!(
1550            toml.contains(r#"token_env = "DNSYNC_TECHNITIUM_API_TOKEN""#),
1551            "starter TOML should contain token_env assignment"
1552        );
1553    }
1554
1555    #[test]
1556    fn starter_config_does_not_contain_literal_token() {
1557        let toml = AppConfig::render_starter_toml().unwrap();
1558        assert!(
1559            !toml.lines().any(|l| l.trim_start().starts_with("token =")),
1560            "starter TOML must not contain a bare `token = ...` key"
1561        );
1562    }
1563
1564    #[test]
1565    fn starter_config_round_trips() {
1566        let toml = AppConfig::render_starter_toml().unwrap();
1567        let reparsed: AppConfig = toml::from_str(&toml).expect("starter TOML should parse back");
1568        let server = reparsed.selected_server(None).unwrap();
1569        assert_eq!(server.id, "default");
1570        assert_eq!(server.vendor, VendorKind::Technitium);
1571        assert_eq!(server.base_url.as_deref(), Some("http://localhost:5380"));
1572        assert_eq!(
1573            server.token_env.as_deref(),
1574            Some("DNSYNC_TECHNITIUM_API_TOKEN")
1575        );
1576        assert!(server.token.is_none());
1577        {
1578            use std::collections::HashSet;
1579            let full: HashSet<PolicyRule> =
1580                [PolicyRule::Read, PolicyRule::Write, PolicyRule::Delete]
1581                    .into_iter()
1582                    .collect();
1583            let actual: HashSet<PolicyRule> = server.mcp.access.iter().cloned().collect();
1584            assert_eq!(actual, full);
1585        }
1586        assert!(server.mcp.allowed_zones.is_empty());
1587    }
1588
1589    #[test]
1590    fn starter_config_validates() {
1591        AppConfig::starter()
1592            .validate()
1593            .expect("starter config should pass validation");
1594    }
1595
1596    #[cfg(unix)]
1597    #[test]
1598    fn written_config_file_has_owner_only_permissions() {
1599        use std::os::unix::fs::PermissionsExt;
1600        let path = temp_config_path("perms-file");
1601
1602        init_config(Some(path.clone()), false).unwrap();
1603
1604        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1605        assert_eq!(
1606            mode, 0o600,
1607            "config file should be owner read/write only (0600)"
1608        );
1609
1610        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
1611    }
1612
1613    #[cfg(unix)]
1614    #[test]
1615    fn written_config_dir_has_owner_only_permissions() {
1616        use std::os::unix::fs::PermissionsExt;
1617        let path = temp_config_path("perms-dir");
1618
1619        init_config(Some(path.clone()), false).unwrap();
1620
1621        let dir = path.parent().unwrap();
1622        let mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777;
1623        assert_eq!(mode, 0o700, "config directory should be owner-only (0700)");
1624
1625        std::fs::remove_dir_all(dir).unwrap();
1626    }
1627
1628    #[test]
1629    fn redact_replaces_token_but_preserves_token_env() {
1630        let cfg: AppConfig = toml::from_str(
1631            r#"
1632                [[servers]]
1633                id = "home"
1634                token = "secret"
1635                token_env = "MY_TOKEN_VAR"
1636            "#,
1637        )
1638        .unwrap();
1639
1640        let redacted = cfg.redact();
1641        let server = redacted.selected_server(None).unwrap();
1642        assert_eq!(server.token.as_deref(), Some("[redacted]"));
1643        assert_eq!(server.token_env.as_deref(), Some("MY_TOKEN_VAR"));
1644    }
1645
1646    #[test]
1647    fn redact_leaves_none_token_as_none() {
1648        let cfg: AppConfig = toml::from_str(
1649            r#"
1650                [[servers]]
1651                id = "home"
1652                token_env = "MY_TOKEN_VAR"
1653            "#,
1654        )
1655        .unwrap();
1656
1657        let redacted = cfg.redact();
1658        assert!(redacted.selected_server(None).unwrap().token.is_none());
1659    }
1660
1661    #[test]
1662    fn config_validation_endpoint_roundtrip() {
1663        let cfg: AppConfig = toml::from_str(
1664            r#"
1665                [[servers]]
1666                id = "home"
1667                token_env = "MY_TOKEN_VAR"
1668
1669                [[servers.validation_endpoints]]
1670                name = "router"
1671                transport = "dns"
1672                address = "192.168.1.1"
1673                port = 53
1674                enabled = true
1675                timeout_ms = 1500
1676
1677                [[servers.validation_endpoints]]
1678                name = "cloudflare-doh"
1679                transport = "doh"
1680                url = "https://cloudflare-dns.com/dns-query"
1681                enabled = true
1682
1683                [[servers.validation_endpoints]]
1684                name = "quad9-dot"
1685                transport = "dot"
1686                address = "9.9.9.9"
1687                port = 853
1688                tls_server_name = "dns.quad9.net"
1689            "#,
1690        )
1691        .unwrap();
1692
1693        cfg.validate().unwrap();
1694        let rendered = cfg.render_toml().unwrap();
1695        let reparsed: AppConfig = toml::from_str(&rendered).unwrap();
1696        let endpoints = &reparsed.selected_server(None).unwrap().validation_endpoints;
1697
1698        assert_eq!(endpoints.len(), 3);
1699        assert_eq!(endpoints[0].name, "router");
1700        assert_eq!(endpoints[0].transport, ValidationTransport::Dns);
1701        assert_eq!(
1702            endpoints[1].url.as_deref(),
1703            Some("https://cloudflare-dns.com/dns-query")
1704        );
1705        assert_eq!(
1706            endpoints[2].tls_server_name.as_deref(),
1707            Some("dns.quad9.net")
1708        );
1709        assert!(rendered.contains("[[servers.validation_endpoints]]"));
1710    }
1711
1712    #[test]
1713    fn server_transport_blocks_roundtrip() {
1714        let cfg: AppConfig = toml::from_str(
1715            r#"
1716                [[servers]]
1717                id = "dns1"
1718                vendor = "technitium"
1719                cluster = "home-dns"
1720
1721                [servers.dns]
1722                enabled = true
1723                addr = "10.5.0.53:53"
1724                timeout_ms = 1500
1725
1726                [servers.dot]
1727                enabled = true
1728                addr = "10.5.0.53:853"
1729                server_name = "dns1.hankin.io"
1730
1731                [servers.doh]
1732                enabled = true
1733                url = "https://dns1.hankin.io/dns-query"
1734                addr = "10.5.0.53:443"
1735                server_name = "dns1.hankin.io"
1736
1737                [clusters.home-dns]
1738                members = ["dns1"]
1739            "#,
1740        )
1741        .unwrap();
1742
1743        cfg.validate().unwrap();
1744        let rendered = cfg.render_toml().unwrap();
1745        let reparsed: AppConfig = toml::from_str(&rendered).unwrap();
1746        let server = reparsed.selected_server(None).unwrap();
1747
1748        assert_eq!(server.cluster.as_deref(), Some("home-dns"));
1749        assert_eq!(
1750            server.dns.as_ref().unwrap().addr.as_deref(),
1751            Some("10.5.0.53:53")
1752        );
1753        assert_eq!(
1754            server.dot.as_ref().unwrap().server_name.as_deref(),
1755            Some("dns1.hankin.io")
1756        );
1757        assert_eq!(
1758            server.doh.as_ref().unwrap().url.as_deref(),
1759            Some("https://dns1.hankin.io/dns-query")
1760        );
1761        assert!(rendered.contains("[servers.dns]"));
1762        assert!(rendered.contains("[servers.dot]"));
1763        assert!(rendered.contains("[servers.doh]"));
1764    }
1765
1766    #[test]
1767    fn disabled_transport_blocks_can_omit_endpoints() {
1768        let cfg: AppConfig = toml::from_str(
1769            r#"
1770                [[servers]]
1771                id = "dns1"
1772
1773                [servers.dns]
1774                enabled = false
1775
1776                [servers.dot]
1777                enabled = false
1778
1779                [servers.doh]
1780                enabled = false
1781            "#,
1782        )
1783        .unwrap();
1784
1785        cfg.validate().unwrap();
1786        let rendered = cfg.render_toml().unwrap();
1787
1788        assert!(rendered.contains("[servers.dns]"));
1789        assert!(rendered.contains("enabled = false"));
1790        assert!(!rendered.contains("addr = \"\""));
1791        assert!(!rendered.contains("url = \"\""));
1792    }
1793
1794    #[test]
1795    fn cluster_config_roundtrip() {
1796        let cfg: AppConfig = toml::from_str(
1797            r#"
1798                [[servers]]
1799                id = "dns1"
1800                vendor = "technitium"
1801                cluster = "home-dns"
1802
1803                [[servers]]
1804                id = "dns2"
1805                vendor = "technitium"
1806                cluster = "home-dns"
1807
1808                [clusters.home-dns]
1809                vendor = "technitium"
1810                members = ["dns1", "dns2"]
1811                write_policy = "primary_only"
1812                primary = "auto"
1813                catalog_zone = "auto"
1814                preferred_writer = "dns1"
1815            "#,
1816        )
1817        .unwrap();
1818
1819        cfg.validate().unwrap();
1820        let rendered = cfg.render_toml().unwrap();
1821        let reparsed: AppConfig = toml::from_str(&rendered).unwrap();
1822        let cluster = reparsed.clusters.get("home-dns").unwrap();
1823
1824        assert_eq!(cluster.members, ["dns1", "dns2"]);
1825        assert_eq!(cluster.write_policy, ClusterWritePolicy::PrimaryOnly);
1826        assert_eq!(cluster.primary.as_deref(), Some("auto"));
1827        assert_eq!(cluster.catalog_zone.as_deref(), Some("auto"));
1828        assert_eq!(cluster.preferred_writer.as_deref(), Some("dns1"));
1829        assert!(rendered.contains("[clusters.home-dns]"));
1830    }
1831
1832    #[test]
1833    fn cluster_rejects_unknown_members() {
1834        let cfg: AppConfig = toml::from_str(
1835            r#"
1836                [[servers]]
1837                id = "dns1"
1838
1839                [clusters.home-dns]
1840                members = ["dns1", "dns2"]
1841            "#,
1842        )
1843        .unwrap();
1844
1845        let err = cfg.validate().unwrap_err();
1846        assert!(err.to_string().contains("unknown DNS server 'dns2'"));
1847    }
1848
1849    #[test]
1850    fn server_rejects_unknown_cluster_reference() {
1851        let cfg: AppConfig = toml::from_str(
1852            r#"
1853                [[servers]]
1854                id = "dns1"
1855                cluster = "missing"
1856            "#,
1857        )
1858        .unwrap();
1859
1860        let err = cfg.validate().unwrap_err();
1861        assert!(
1862            err.to_string()
1863                .contains("DNS server 'dns1' references unknown cluster 'missing'")
1864        );
1865    }
1866
1867    #[test]
1868    fn config_rejects_invalid_validation_endpoint() {
1869        let cfg: AppConfig = toml::from_str(
1870            r#"
1871                [[servers]]
1872                id = "home"
1873                token_env = "MY_TOKEN_VAR"
1874
1875                [[servers.validation_endpoints]]
1876                name = ""
1877                transport = "dns"
1878                address = "192.168.1.1"
1879            "#,
1880        )
1881        .unwrap();
1882
1883        let err = cfg.validate().unwrap_err();
1884        assert!(err.to_string().contains("empty name"));
1885
1886        let cfg: AppConfig = toml::from_str(
1887            r#"
1888                [[servers]]
1889                id = "home"
1890                token_env = "MY_TOKEN_VAR"
1891
1892                [[servers.validation_endpoints]]
1893                name = "missing-url"
1894                transport = "doh"
1895            "#,
1896        )
1897        .unwrap();
1898
1899        let err = cfg.validate().unwrap_err();
1900        assert!(err.to_string().contains("requires url"));
1901    }
1902
1903    #[test]
1904    fn config_print_redacts_tokens_but_keeps_validation_endpoints() {
1905        let cfg: AppConfig = toml::from_str(
1906            r#"
1907                [[servers]]
1908                id = "home"
1909                token = "secret"
1910
1911                [[servers.validation_endpoints]]
1912                name = "router"
1913                transport = "dns"
1914                address = "192.168.1.1"
1915            "#,
1916        )
1917        .unwrap();
1918
1919        let redacted = cfg.redact();
1920        let server = redacted.selected_server(None).unwrap();
1921
1922        assert_eq!(server.token.as_deref(), Some("[redacted]"));
1923        assert_eq!(
1924            server.validation_endpoints,
1925            cfg.servers[0].validation_endpoints
1926        );
1927    }
1928
1929    #[test]
1930    fn load_if_exists_returns_none_when_no_file() {
1931        let path = temp_config_path("load-if-exists-missing");
1932        assert!(!path.exists());
1933
1934        let result = AppConfig::load_if_exists(Some(path)).unwrap();
1935        assert!(result.is_none());
1936    }
1937
1938    #[test]
1939    fn load_if_exists_returns_config_when_file_present() {
1940        let path = temp_config_path("load-if-exists-present");
1941        // Use init_config so the file is created with correct permissions
1942        init_config(Some(path.clone()), false).unwrap();
1943
1944        let config = AppConfig::load_if_exists(Some(path.clone()))
1945            .expect("should load")
1946            .expect("should be Some");
1947        assert_eq!(config.selected_server(None).unwrap().id, "default");
1948
1949        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
1950    }
1951
1952    #[test]
1953    fn add_server_creates_config_with_single_server() {
1954        let path = temp_config_path("add-server-new");
1955        let server = DnsServerConfig {
1956            id: "myserver".to_string(),
1957            vendor: VendorKind::Technitium,
1958            location: None,
1959            base_url: Some("http://192.168.1.10:5380".to_string()),
1960            base_url_env: None,
1961            token: None,
1962            token_env: Some("MY_API_TOKEN".to_string()),
1963            org_id: None,
1964            cluster: None,
1965            dns: None,
1966            dot: None,
1967            doh: None,
1968            mcp: McpPermissions::default(),
1969            validation_endpoints: Vec::new(),
1970        };
1971
1972        let written = add_server(Some(path.clone()), server).unwrap();
1973        assert_eq!(written, path);
1974
1975        let config = AppConfig::load(Some(path.clone())).unwrap().unwrap();
1976        let s = config.selected_server(None).unwrap();
1977        assert_eq!(s.id, "myserver");
1978        assert_eq!(s.token_env.as_deref(), Some("MY_API_TOKEN"));
1979
1980        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
1981    }
1982
1983    #[test]
1984    fn add_server_appends_to_existing_config() {
1985        let path = temp_config_path("add-server-existing");
1986        init_config(Some(path.clone()), false).unwrap();
1987
1988        let server = DnsServerConfig {
1989            id: "lab".to_string(),
1990            vendor: VendorKind::Technitium,
1991            location: None,
1992            base_url: Some("http://192.168.1.20:5380".to_string()),
1993            base_url_env: None,
1994            token: None,
1995            token_env: Some("LAB_TOKEN".to_string()),
1996            org_id: None,
1997            cluster: None,
1998            dns: None,
1999            dot: None,
2000            doh: None,
2001            mcp: McpPermissions::default(),
2002            validation_endpoints: Vec::new(),
2003        };
2004
2005        add_server(Some(path.clone()), server).unwrap();
2006
2007        let config = AppConfig::load(Some(path.clone())).unwrap().unwrap();
2008        assert_eq!(config.servers.len(), 2);
2009        assert!(config.selected_server(Some("default")).is_ok());
2010        assert!(config.selected_server(Some("lab")).is_ok());
2011
2012        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
2013    }
2014
2015    #[test]
2016    fn add_server_preserves_comments_in_existing_config() {
2017        let path = temp_config_path("add-server-comments");
2018        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2019        let original = concat!(
2020            "# My DNS servers\n",
2021            "[[servers]]\n",
2022            "id = \"home\"\n",
2023            "# Home server uses its own env var\n",
2024            "token_env = \"HOME_TOKEN\"\n",
2025        );
2026        std::fs::write(&path, original).unwrap();
2027        #[cfg(unix)]
2028        {
2029            use std::os::unix::fs::PermissionsExt;
2030            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2031        }
2032
2033        let server = DnsServerConfig {
2034            id: "lab".to_string(),
2035            vendor: VendorKind::Technitium,
2036            location: None,
2037            base_url: None,
2038            base_url_env: None,
2039            token: None,
2040            token_env: Some("LAB_TOKEN".to_string()),
2041            org_id: None,
2042            cluster: None,
2043            dns: None,
2044            dot: None,
2045            doh: None,
2046            mcp: McpPermissions::default(),
2047            validation_endpoints: Vec::new(),
2048        };
2049        add_server(Some(path.clone()), server).unwrap();
2050
2051        let written = std::fs::read_to_string(&path).unwrap();
2052        assert!(
2053            written.contains("# My DNS servers"),
2054            "top-level comment should be preserved"
2055        );
2056        assert!(
2057            written.contains("# Home server uses its own env var"),
2058            "inline comment should be preserved"
2059        );
2060        assert!(
2061            written.contains("id = \"lab\""),
2062            "new server should be appended"
2063        );
2064
2065        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
2066    }
2067
2068    #[test]
2069    fn add_server_rejects_duplicate_id() {
2070        let path = temp_config_path("add-server-duplicate");
2071        init_config(Some(path.clone()), false).unwrap();
2072
2073        let server = DnsServerConfig {
2074            id: "default".to_string(), // already exists
2075            vendor: VendorKind::Technitium,
2076            location: None,
2077            base_url: None,
2078            base_url_env: None,
2079            token: None,
2080            token_env: None,
2081            org_id: None,
2082            cluster: None,
2083            dns: None,
2084            dot: None,
2085            doh: None,
2086            mcp: McpPermissions::default(),
2087            validation_endpoints: Vec::new(),
2088        };
2089
2090        let err = add_server(Some(path.clone()), server).unwrap_err();
2091        assert!(err.to_string().contains("duplicate DNS server id"));
2092
2093        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
2094    }
2095
2096    #[cfg(unix)]
2097    #[test]
2098    fn load_errors_if_config_is_world_readable() {
2099        use std::os::unix::fs::PermissionsExt;
2100        let path = temp_config_path("world-readable");
2101        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2102        std::fs::write(&path, AppConfig::render_starter_toml().unwrap()).unwrap();
2103        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2104
2105        let err = AppConfig::load(Some(path.clone())).unwrap_err();
2106
2107        assert!(
2108            err.to_string().contains("chmod 600"),
2109            "error should include remediation command"
2110        );
2111
2112        std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
2113    }
2114
2115    // ── resolved_location ─────────────────────────────────────────────────────
2116
2117    fn server_with_url(url: &str) -> DnsServerConfig {
2118        DnsServerConfig {
2119            id: "test".to_string(),
2120            vendor: VendorKind::Technitium,
2121            location: None,
2122            base_url: Some(url.to_string()),
2123            base_url_env: None,
2124            token: None,
2125            token_env: None,
2126            org_id: None,
2127            cluster: None,
2128            dns: None,
2129            dot: None,
2130            doh: None,
2131            mcp: McpPermissions::default(),
2132            validation_endpoints: Vec::new(),
2133        }
2134    }
2135
2136    #[tokio::test]
2137    async fn localhost_url_is_local() {
2138        assert_eq!(
2139            server_with_url("http://localhost:5380")
2140                .resolved_location()
2141                .await,
2142            ServerLocation::Local
2143        );
2144    }
2145
2146    #[tokio::test]
2147    async fn loopback_ip_is_local() {
2148        assert_eq!(
2149            server_with_url("http://127.0.0.1:5380")
2150                .resolved_location()
2151                .await,
2152            ServerLocation::Local
2153        );
2154    }
2155
2156    #[tokio::test]
2157    async fn private_ip_is_local() {
2158        assert_eq!(
2159            server_with_url("http://192.168.1.10:5380")
2160                .resolved_location()
2161                .await,
2162            ServerLocation::Local
2163        );
2164        assert_eq!(
2165            server_with_url("http://10.0.0.1:8080")
2166                .resolved_location()
2167                .await,
2168            ServerLocation::Local
2169        );
2170    }
2171
2172    #[tokio::test]
2173    async fn public_ip_is_external() {
2174        assert_eq!(
2175            server_with_url("https://1.2.3.4:5380")
2176                .resolved_location()
2177                .await,
2178            ServerLocation::External
2179        );
2180    }
2181
2182    #[tokio::test]
2183    async fn cloud_domain_is_external() {
2184        assert_eq!(
2185            server_with_url("https://api.pangolin.net/v1")
2186                .resolved_location()
2187                .await,
2188            ServerLocation::External
2189        );
2190    }
2191
2192    #[tokio::test]
2193    async fn technitium_default_url_is_local() {
2194        let server = DnsServerConfig {
2195            id: "test".to_string(),
2196            vendor: VendorKind::Technitium,
2197            location: None,
2198            base_url: None,
2199            base_url_env: None,
2200            token: None,
2201            token_env: None,
2202            org_id: None,
2203            cluster: None,
2204            dns: None,
2205            dot: None,
2206            doh: None,
2207            mcp: McpPermissions::default(),
2208            validation_endpoints: Vec::new(),
2209        };
2210        assert_eq!(server.resolved_location().await, ServerLocation::Local);
2211    }
2212
2213    #[tokio::test]
2214    async fn pangolin_default_url_is_external() {
2215        let server = DnsServerConfig {
2216            id: "test".to_string(),
2217            vendor: VendorKind::Pangolin,
2218            location: None,
2219            base_url: None,
2220            base_url_env: None,
2221            token: None,
2222            token_env: None,
2223            org_id: None,
2224            cluster: None,
2225            dns: None,
2226            dot: None,
2227            doh: None,
2228            mcp: McpPermissions::default(),
2229            validation_endpoints: Vec::new(),
2230        };
2231        assert_eq!(server.resolved_location().await, ServerLocation::External);
2232    }
2233
2234    #[tokio::test]
2235    async fn explicit_location_overrides_auto_detection() {
2236        let mut server = server_with_url("https://api.pangolin.net");
2237        server.location = Some(ServerLocation::Local);
2238        assert_eq!(server.resolved_location().await, ServerLocation::Local);
2239
2240        server.location = Some(ServerLocation::External);
2241        assert_eq!(server.resolved_location().await, ServerLocation::External);
2242    }
2243
2244    // ── url_host extraction ───────────────────────────────────────────────────
2245
2246    #[test]
2247    fn url_host_strips_scheme_and_port() {
2248        assert_eq!(url_host("http://localhost:5380"), "localhost");
2249        assert_eq!(url_host("https://192.168.1.1:443"), "192.168.1.1");
2250        assert_eq!(url_host("https://api.pangolin.net/v1"), "api.pangolin.net");
2251    }
2252
2253    #[test]
2254    fn url_host_handles_ipv6_literals() {
2255        assert_eq!(url_host("http://[::1]:5380"), "::1");
2256    }
2257
2258    #[test]
2259    fn url_host_no_port() {
2260        assert_eq!(url_host("http://myserver"), "myserver");
2261    }
2262
2263    // ── location field TOML round-trip ────────────────────────────────────────
2264
2265    #[test]
2266    fn location_field_round_trips_in_toml() {
2267        let toml = r#"
2268            [[servers]]
2269            id = "home"
2270            vendor = "technitium"
2271            location = "external"
2272            token = "tok"
2273        "#;
2274        let config: AppConfig = toml::from_str(toml).expect("should parse");
2275        let server = config.selected_server(None).unwrap();
2276        assert_eq!(server.location, Some(ServerLocation::External));
2277    }
2278
2279    // ── sync profiles ─────────────────────────────────────────────────────────
2280
2281    fn sync_config() -> &'static str {
2282        r#"
2283            [[servers]]
2284            id = "cf"
2285            token = "tok"
2286
2287            [[servers]]
2288            id = "home"
2289            token = "tok"
2290
2291            [[sync]]
2292            name = "split"
2293            from = "cf"
2294            to = "home"
2295            zones = ["example.com"]
2296
2297            [sync.ip_map]
2298            "203.0.113.10" = "192.168.1.10"
2299        "#
2300    }
2301
2302    #[test]
2303    fn parses_and_validates_sync_profile() {
2304        let config: AppConfig = toml::from_str(sync_config()).expect("should parse");
2305        config.validate().expect("sync profile should validate");
2306
2307        assert_eq!(config.sync.len(), 1);
2308        let profile = &config.sync[0];
2309        assert_eq!(profile.name, "split");
2310        assert_eq!(profile.from, "cf");
2311        assert_eq!(profile.to, "home");
2312        assert_eq!(profile.zones, ["example.com"]);
2313        assert_eq!(
2314            profile.ip_map.get("203.0.113.10").map(String::as_str),
2315            Some("192.168.1.10")
2316        );
2317    }
2318
2319    #[test]
2320    fn sync_profile_round_trips_through_render_toml() {
2321        let config: AppConfig = toml::from_str(sync_config()).expect("should parse");
2322        let rendered = config.render_toml().expect("should render");
2323        let reparsed: AppConfig =
2324            toml::from_str(&rendered).expect("rendered sync config should parse back");
2325        reparsed
2326            .validate()
2327            .expect("reparsed config should validate");
2328        assert_eq!(reparsed.sync.len(), 1);
2329        assert_eq!(reparsed.sync[0].name, "split");
2330        assert_eq!(
2331            reparsed.sync[0]
2332                .ip_map
2333                .get("203.0.113.10")
2334                .map(String::as_str),
2335            Some("192.168.1.10")
2336        );
2337    }
2338
2339    #[test]
2340    fn rejects_sync_profile_with_unknown_server() {
2341        let config: AppConfig = toml::from_str(
2342            r#"
2343                [[servers]]
2344                id = "cf"
2345                token = "tok"
2346
2347                [[sync]]
2348                name = "bad"
2349                from = "cf"
2350                to = "missing"
2351            "#,
2352        )
2353        .expect("should parse before validation");
2354
2355        let err = config.validate().unwrap_err();
2356        assert!(err.to_string().contains("unknown destination server"));
2357    }
2358
2359    #[test]
2360    fn rejects_sync_profile_with_family_mismatched_ip_map() {
2361        let config: AppConfig = toml::from_str(
2362            r#"
2363                [[servers]]
2364                id = "cf"
2365                token = "tok"
2366
2367                [[servers]]
2368                id = "home"
2369                token = "tok"
2370
2371                [[sync]]
2372                name = "bad"
2373                from = "cf"
2374                to = "home"
2375
2376                [sync.ip_map]
2377                "203.0.113.10" = "fd00::1"
2378            "#,
2379        )
2380        .expect("should parse before validation");
2381
2382        let err = config.validate().unwrap_err();
2383        assert!(err.to_string().contains("IPv4 and IPv6"));
2384    }
2385
2386    #[test]
2387    fn rejects_duplicate_sync_profile_names() {
2388        let config: AppConfig = toml::from_str(
2389            r#"
2390                [[servers]]
2391                id = "cf"
2392                token = "tok"
2393
2394                [[servers]]
2395                id = "home"
2396                token = "tok"
2397
2398                [[sync]]
2399                name = "dup"
2400                from = "cf"
2401                to = "home"
2402
2403                [[sync]]
2404                name = "DUP"
2405                from = "home"
2406                to = "cf"
2407            "#,
2408        )
2409        .expect("should parse before validation");
2410
2411        let err = config.validate().unwrap_err();
2412        assert!(err.to_string().contains("duplicate sync profile name"));
2413    }
2414}