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#[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#[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#[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#[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#[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#[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#[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#[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 #[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 #[serde(skip_serializing_if = "Option::is_none")]
123 pub preferred_writer: Option<String>,
124}
125
126#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
209 pub sync: Vec<SyncProfile>,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct SyncProfile {
217 pub name: String,
219
220 pub from: String,
222
223 pub to: String,
225
226 #[serde(default)]
228 pub zones: Vec<String>,
229
230 #[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 #[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#[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 #[serde(default)]
312 mcp_access: Option<Vec<PolicyRule>>,
313 #[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 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 #[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 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 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 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
697pub 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 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 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
738fn 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 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 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
886fn 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 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
927fn 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#[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 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#[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#[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 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 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
1167fn 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 authority
1177 .trim_start_matches('[')
1178 .split(']')
1179 .next()
1180 .unwrap_or(authority)
1181 } else {
1182 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
1194async 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 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 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 #[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 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(), 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 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 #[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 #[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 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}