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#[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#[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#[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#[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#[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#[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#[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#[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#[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 #[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 #[serde(skip_serializing_if = "Option::is_none")]
152 pub preferred_writer: Option<String>,
153}
154
155#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
239 pub sync: Vec<SyncProfile>,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct SyncProfile {
247 pub name: String,
249
250 pub from: String,
252
253 pub to: String,
255
256 #[serde(default)]
258 pub zones: Vec<String>,
259
260 #[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 #[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#[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 #[serde(default)]
346 mcp_access: Option<Vec<PolicyRule>>,
347 #[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 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 #[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 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 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 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
791pub 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 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 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
839pub 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
903pub 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
1084pub 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 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 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
1224fn 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 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 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
1387fn 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 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
1428fn 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#[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 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#[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#[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 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 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
1668fn 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 authority
1678 .trim_start_matches('[')
1679 .split(']')
1680 .next()
1681 .unwrap_or(authority)
1682 } else {
1683 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
1695async 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 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 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 #[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 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(), 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 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 #[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 #[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 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}