1use inquire::validator::Validation;
2use inquire::{Confirm, InquireError, MultiSelect, Select, Text};
3
4use crate::control_plane::config::{
5 CLOUDFLARE_DEFAULT_BASE_URL, DnsServerConfig, DnsTransportConfig, DohTransportConfig,
6 DoqTransportConfig, DotTransportConfig, EndpointUpdate, McpPermissions,
7 PANGOLIN_DEFAULT_BASE_URL, PIHOLE_DEFAULT_BASE_URL, ServerLocation,
8 TECHNITIUM_DEFAULT_BASE_URL, UNIFI_DEFAULT_BASE_URL, ValidationEndpointConfig, VendorKind,
9};
10use crate::control_plane::policy::PolicyRule;
11use crate::core::error::{Error, Result};
12
13pub fn run_add_wizard(existing_ids: &[String]) -> Result<DnsServerConfig> {
14 let existing: Vec<String> = existing_ids.iter().map(|s| s.to_lowercase()).collect();
15 let id = Text::new("Server ID:")
16 .with_help_message("Unique identifier for this server entry")
17 .with_validator(move |input: &str| {
18 if existing.iter().any(|id| id == &input.to_lowercase()) {
19 Ok(Validation::Invalid(
20 format!("a server with id '{input}' already exists").into(),
21 ))
22 } else {
23 Ok(Validation::Valid)
24 }
25 })
26 .prompt()
27 .map_err(wizard_err)?;
28
29 let vendor = {
30 let choices = vec![
31 VendorChoice {
32 kind: VendorKind::Technitium,
33 label: "technitium",
34 },
35 VendorChoice {
36 kind: VendorKind::Pangolin,
37 label: "pangolin",
38 },
39 VendorChoice {
40 kind: VendorKind::Cloudflare,
41 label: "cloudflare",
42 },
43 VendorChoice {
44 kind: VendorKind::Unifi,
45 label: "unifi",
46 },
47 VendorChoice {
48 kind: VendorKind::Pihole,
49 label: "pihole",
50 },
51 ];
52 Select::new("Vendor:", choices)
53 .prompt()
54 .map_err(wizard_err)?
55 .kind
56 };
57
58 let default_url = match vendor {
59 VendorKind::Technitium => TECHNITIUM_DEFAULT_BASE_URL,
60 VendorKind::Pangolin => PANGOLIN_DEFAULT_BASE_URL,
61 VendorKind::Cloudflare => CLOUDFLARE_DEFAULT_BASE_URL,
62 VendorKind::Unifi => UNIFI_DEFAULT_BASE_URL,
63 VendorKind::Pihole => PIHOLE_DEFAULT_BASE_URL,
64 };
65
66 let base_url = optional_text(
67 "Base URL:",
68 &format!("Press Enter for default ({default_url}), or type a custom URL"),
69 Some(default_url),
70 )?;
71
72 let token_env = optional_text(
73 "Token environment variable:",
74 "Name of the env var holding the API token (recommended). Leave empty to skip.",
75 None,
76 )?;
77
78 let token = if token_env.is_none() {
79 optional_text(
80 "API token (stored in plain text — prefer token env var above):",
81 "Leave empty to skip",
82 None,
83 )?
84 } else {
85 None
86 };
87
88 let org_id = match vendor {
89 VendorKind::Pangolin => {
90 optional_text("Organisation ID (Pangolin):", "Leave empty to skip", None)?
91 }
92 VendorKind::Unifi => Some(
93 Text::new("Site name (UniFi):")
94 .with_help_message(
95 "Human-readable site name (e.g. \"Default\") or site UUID; stored in org_id. \
96 Run `dns settings` after saving to list valid site names.",
97 )
98 .with_validator(|input: &str| {
99 if input.trim().is_empty() {
100 Ok(Validation::Invalid("site is required for UniFi".into()))
101 } else {
102 Ok(Validation::Valid)
103 }
104 })
105 .prompt()
106 .map_err(wizard_err)?,
107 ),
108 _ => None,
109 };
110
111 let location = {
112 let choices = vec![
113 LocationChoice {
114 value: None,
115 label: "auto-detect",
116 },
117 LocationChoice {
118 value: Some(ServerLocation::Local),
119 label: "local",
120 },
121 LocationChoice {
122 value: Some(ServerLocation::External),
123 label: "external",
124 },
125 ];
126 Select::new("Location:", choices)
127 .with_help_message(
128 "auto-detect infers from the base URL (localhost/private IP → local)",
129 )
130 .prompt()
131 .map_err(wizard_err)?
132 .value
133 };
134
135 let access: Vec<PolicyRule> = {
136 let choices = vec![
137 AccessChoice {
138 rule: PolicyRule::Read,
139 label: "read (list/export/stats/settings)",
140 },
141 AccessChoice {
142 rule: PolicyRule::Write,
143 label: "write (create/update/import/flush)",
144 },
145 AccessChoice {
146 rule: PolicyRule::Delete,
147 label: "delete (delete zones/records/cache)",
148 },
149 ];
150 let defaults: Vec<usize> = (0..choices.len()).collect();
151 let chosen = MultiSelect::new("MCP allowed operations:", choices)
152 .with_default(&defaults)
153 .with_help_message("Select which operations are permitted for MCP tools on this server")
154 .prompt()
155 .map_err(wizard_err)?;
156 chosen.into_iter().map(|c| c.rule).collect()
157 };
158
159 let mut allowed_zones: Vec<String> = Vec::new();
160 loop {
161 let help = if allowed_zones.is_empty() {
162 "Restrict zone-targeting tools to specific zones; subdomains are also permitted. Leave empty to skip.".to_string()
163 } else {
164 format!(
165 "Added: {} — enter another, or leave empty to finish",
166 allowed_zones.join(", ")
167 )
168 };
169 let zone = match Text::new("Allowed zone:").with_help_message(&help).prompt() {
170 Ok(z) => z,
171 Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
172 return Err(Error::cancelled());
173 }
174 Err(e) => return Err(wizard_err(e)),
175 };
176 if zone.is_empty() {
177 break;
178 }
179 allowed_zones.push(zone);
180 }
181
182 let mut validation_endpoints: Vec<ValidationEndpointConfig> = Vec::new();
183 loop {
184 let help = if validation_endpoints.is_empty() {
185 "Optional DNS validation endpoints as name:transport:address (transport: dns, doh, dot). Leave empty to skip.".to_string()
186 } else {
187 format!(
188 "Added: {} — enter another, or leave empty to finish",
189 validation_endpoints
190 .iter()
191 .map(|endpoint| endpoint.name.as_str())
192 .collect::<Vec<_>>()
193 .join(", ")
194 )
195 };
196 let endpoint = match Text::new("Validation endpoint:")
197 .with_help_message(&help)
198 .prompt()
199 {
200 Ok(endpoint) => endpoint,
201 Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
202 return Err(Error::cancelled());
203 }
204 Err(e) => return Err(wizard_err(e)),
205 };
206 if endpoint.is_empty() {
207 break;
208 }
209 validation_endpoints.push(
210 endpoint
211 .parse::<ValidationEndpointConfig>()
212 .map_err(Error::parse)?,
213 );
214 }
215
216 let (dns, dot, doh, doq) = prompt_transport_endpoints_for_add()?;
217
218 Ok(DnsServerConfig {
219 id,
220 vendor,
221 location,
222 base_url,
223 base_url_env: None,
224 token,
225 token_env,
226 org_id,
227 cluster: None,
228 dns,
229 dot,
230 doh,
231 doq,
232 mcp: McpPermissions {
233 access,
234 allowed_zones,
235 show_settings_secrets: false,
236 },
237 validation_endpoints,
238 })
239}
240
241pub fn run_server_wizard(server: &DnsServerConfig) -> Result<EndpointUpdate> {
246 let choices = vec![
247 EndpointChoice {
248 protocol: EndpointProtocol::Dns,
249 label: format_endpoint_label(
250 "dns",
251 "plain DNS, port 53",
252 endpoint_addr_status(server.dns.as_ref(), false),
253 ),
254 },
255 EndpointChoice {
256 protocol: EndpointProtocol::Dot,
257 label: format_endpoint_label(
258 "dot",
259 "DNS-over-TLS, port 853",
260 endpoint_addr_status(server.dot.as_ref(), false),
261 ),
262 },
263 EndpointChoice {
264 protocol: EndpointProtocol::Doh,
265 label: format_endpoint_label(
266 "doh",
267 "DNS-over-HTTPS",
268 endpoint_addr_status(server.doh.as_ref(), true),
269 ),
270 },
271 EndpointChoice {
272 protocol: EndpointProtocol::Doq,
273 label: format_endpoint_label(
274 "doq",
275 "DNS-over-QUIC",
276 endpoint_addr_status(server.doq.as_ref(), false),
277 ),
278 },
279 ];
280
281 let chosen = Select::new("Select endpoint to configure:", choices)
282 .with_help_message("Use arrow keys to select; current status shown on the right")
283 .prompt()
284 .map_err(wizard_err)?;
285
286 match chosen.protocol {
287 EndpointProtocol::Dns => {
288 let cfg = configure_or_remove("DNS", server.dns.as_ref(), |existing| {
289 prompt_dns_config(existing)
290 })?;
291 Ok(EndpointUpdate::Dns(cfg))
292 }
293 EndpointProtocol::Dot => {
294 let cfg = configure_or_remove("DoT", server.dot.as_ref(), |existing| {
295 prompt_dot_config(existing)
296 })?;
297 Ok(EndpointUpdate::Dot(cfg))
298 }
299 EndpointProtocol::Doh => {
300 let cfg = configure_or_remove("DoH", server.doh.as_ref(), |existing| {
301 prompt_doh_config(existing)
302 })?;
303 Ok(EndpointUpdate::Doh(cfg))
304 }
305 EndpointProtocol::Doq => {
306 let cfg = configure_or_remove("DoQ", server.doq.as_ref(), |existing| {
307 prompt_doq_config(existing)
308 })?;
309 Ok(EndpointUpdate::Doq(cfg))
310 }
311 }
312}
313
314pub fn run_server_picker(servers: &[DnsServerConfig]) -> Result<String> {
316 let choices: Vec<ServerChoice> = servers
317 .iter()
318 .map(|s| ServerChoice {
319 id: s.id.clone(),
320 label: format_server_summary(s),
321 })
322 .collect();
323
324 let chosen = Select::new("Select server to update:", choices)
325 .prompt()
326 .map_err(wizard_err)?;
327
328 Ok(chosen.id)
329}
330
331fn prompt_transport_endpoints_for_add() -> Result<(
334 Option<DnsTransportConfig>,
335 Option<DotTransportConfig>,
336 Option<DohTransportConfig>,
337 Option<DoqTransportConfig>,
338)> {
339 let configure = Confirm::new("Configure DNS transport endpoints (dns/dot/doh/doq)?")
340 .with_default(false)
341 .with_help_message(
342 "Set up direct DNS query endpoints for validation and resolution. \
343 You can always add these later with `dns config server <id> <protocol>`.",
344 )
345 .prompt()
346 .map_err(wizard_err)?;
347
348 if !configure {
349 return Ok((None, None, None, None));
350 }
351
352 let choices = vec![
353 ProtocolChoice {
354 id: 0,
355 label: "dns (plain DNS, port 53)",
356 },
357 ProtocolChoice {
358 id: 1,
359 label: "dot (DNS-over-TLS, port 853)",
360 },
361 ProtocolChoice {
362 id: 2,
363 label: "doh (DNS-over-HTTPS)",
364 },
365 ProtocolChoice {
366 id: 3,
367 label: "doq (DNS-over-QUIC)",
368 },
369 ];
370
371 let selected = MultiSelect::new("Select protocols to configure:", choices)
372 .with_help_message("Space to toggle, Enter to confirm")
373 .prompt()
374 .map_err(wizard_err)?;
375
376 let mut dns = None;
377 let mut dot = None;
378 let mut doh = None;
379 let mut doq = None;
380
381 for choice in selected {
382 match choice.id {
383 0 => dns = Some(prompt_dns_config(None)?),
384 1 => dot = Some(prompt_dot_config(None)?),
385 2 => doh = Some(prompt_doh_config(None)?),
386 3 => doq = Some(prompt_doq_config(None)?),
387 _ => unreachable!(),
388 }
389 }
390
391 Ok((dns, dot, doh, doq))
392}
393
394fn prompt_dns_config(existing: Option<&DnsTransportConfig>) -> Result<DnsTransportConfig> {
395 let addr = Text::new("Address (host:port):")
396 .with_help_message("e.g. 10.0.0.1:53 or dns.example.com:53")
397 .with_default(existing.and_then(|e| e.addr.as_deref()).unwrap_or(""))
398 .prompt()
399 .map_err(wizard_err)?;
400
401 let timeout_ms = optional_u64(
402 "Timeout (ms):",
403 "Query timeout in milliseconds, e.g. 2000. Leave empty to use the default.",
404 existing.and_then(|e| e.timeout_ms),
405 )?;
406
407 let enabled = Confirm::new("Enable this endpoint?")
408 .with_default(existing.map_or(true, |e| e.enabled))
409 .prompt()
410 .map_err(wizard_err)?;
411
412 let addr = addr.trim().to_string();
413 Ok(DnsTransportConfig {
414 enabled,
415 addr: Some(addr).filter(|a| !a.is_empty()),
416 timeout_ms,
417 })
418}
419
420fn prompt_dot_config(existing: Option<&DotTransportConfig>) -> Result<DotTransportConfig> {
421 let addr = Text::new("Address (host:port):")
422 .with_help_message("e.g. 10.0.0.1:853 or dns.example.com:853")
423 .with_default(existing.and_then(|e| e.addr.as_deref()).unwrap_or(""))
424 .prompt()
425 .map_err(wizard_err)?;
426
427 let server_name = optional_text(
428 "TLS server name (SNI):",
429 "Hostname for TLS certificate validation. Leave empty to use the hostname from address.",
430 existing.and_then(|e| e.server_name.as_deref()),
431 )?;
432
433 let timeout_ms = optional_u64(
434 "Timeout (ms):",
435 "Query timeout in milliseconds, e.g. 2000. Leave empty to use the default.",
436 existing.and_then(|e| e.timeout_ms),
437 )?;
438
439 let enabled = Confirm::new("Enable this endpoint?")
440 .with_default(existing.map_or(true, |e| e.enabled))
441 .prompt()
442 .map_err(wizard_err)?;
443
444 let addr = addr.trim().to_string();
445 Ok(DotTransportConfig {
446 enabled,
447 addr: Some(addr).filter(|a| !a.is_empty()),
448 server_name,
449 timeout_ms,
450 })
451}
452
453fn prompt_doh_config(existing: Option<&DohTransportConfig>) -> Result<DohTransportConfig> {
454 let url = optional_text(
455 "URL:",
456 "Full HTTPS URL, e.g. https://dns.example.com/dns-query",
457 existing.and_then(|e| e.url.as_deref()),
458 )?;
459
460 let addr = optional_text(
461 "Address override (host:port):",
462 "Override the TCP address resolved from the URL, e.g. 10.0.0.1:443. Leave empty to use DNS.",
463 existing.and_then(|e| e.addr.as_deref()),
464 )?;
465
466 let server_name = optional_text(
467 "TLS server name (SNI):",
468 "Hostname for TLS certificate validation. Leave empty to use the hostname from the URL.",
469 existing.and_then(|e| e.server_name.as_deref()),
470 )?;
471
472 let timeout_ms = optional_u64(
473 "Timeout (ms):",
474 "Query timeout in milliseconds, e.g. 2000. Leave empty to use the default.",
475 existing.and_then(|e| e.timeout_ms),
476 )?;
477
478 let enabled = Confirm::new("Enable this endpoint?")
479 .with_default(existing.map_or(true, |e| e.enabled))
480 .prompt()
481 .map_err(wizard_err)?;
482
483 Ok(DohTransportConfig {
484 enabled,
485 url,
486 addr,
487 server_name,
488 timeout_ms,
489 })
490}
491
492fn prompt_doq_config(existing: Option<&DoqTransportConfig>) -> Result<DoqTransportConfig> {
493 let addr = Text::new("Address (host:port):")
494 .with_help_message("e.g. 10.0.0.1:853 or dns.example.com:853")
495 .with_default(existing.and_then(|e| e.addr.as_deref()).unwrap_or(""))
496 .prompt()
497 .map_err(wizard_err)?;
498
499 let server_name = optional_text(
500 "TLS server name (SNI):",
501 "Hostname for TLS certificate validation. Leave empty to use the hostname from address.",
502 existing.and_then(|e| e.server_name.as_deref()),
503 )?;
504
505 let timeout_ms = optional_u64(
506 "Timeout (ms):",
507 "Query timeout in milliseconds, e.g. 2000. Leave empty to use the default.",
508 existing.and_then(|e| e.timeout_ms),
509 )?;
510
511 let enabled = Confirm::new("Enable this endpoint?")
512 .with_default(existing.map_or(true, |e| e.enabled))
513 .prompt()
514 .map_err(wizard_err)?;
515
516 let addr = addr.trim().to_string();
517 Ok(DoqTransportConfig {
518 enabled,
519 addr: Some(addr).filter(|a| !a.is_empty()),
520 server_name,
521 timeout_ms,
522 })
523}
524
525fn configure_or_remove<T, F>(
528 protocol: &str,
529 existing: Option<&T>,
530 configure: F,
531) -> Result<Option<T>>
532where
533 F: FnOnce(Option<&T>) -> Result<T>,
534{
535 if existing.is_some() {
536 let choices = vec![
537 ActionChoice {
538 action: EndpointAction::Configure,
539 label: "configure / update",
540 },
541 ActionChoice {
542 action: EndpointAction::Remove,
543 label: "remove endpoint",
544 },
545 ];
546 let chosen = Select::new(&format!("{protocol} endpoint:"), choices)
547 .prompt()
548 .map_err(wizard_err)?;
549
550 if matches!(chosen.action, EndpointAction::Remove) {
551 return Ok(None);
552 }
553 }
554
555 configure(existing).map(Some)
556}
557
558fn format_endpoint_label(protocol: &str, description: &str, status: String) -> String {
561 format!("{protocol:<4} {description:<30} {status}")
562}
563
564fn endpoint_addr_status<T: EndpointInfo>(endpoint: Option<&T>, is_url: bool) -> String {
567 match endpoint {
568 None => "not configured".to_string(),
569 Some(ep) => {
570 let target = if is_url {
571 ep.url_str().unwrap_or_else(|| ep.addr_str().unwrap_or("?"))
572 } else {
573 ep.addr_str().unwrap_or("?")
574 };
575 let state = if ep.is_enabled() {
576 "enabled"
577 } else {
578 "disabled"
579 };
580 format!("{state} → {target}")
581 }
582 }
583}
584
585fn format_server_summary(server: &DnsServerConfig) -> String {
586 let vendor = match server.vendor {
587 crate::control_plane::config::VendorKind::Technitium => "technitium",
588 crate::control_plane::config::VendorKind::Pangolin => "pangolin",
589 crate::control_plane::config::VendorKind::Cloudflare => "cloudflare",
590 crate::control_plane::config::VendorKind::Unifi => "unifi",
591 crate::control_plane::config::VendorKind::Pihole => "pihole",
592 };
593 let url = server.base_url.as_deref().unwrap_or("(default)");
594 format!("{} [{vendor}] {url}", server.id)
595}
596
597trait EndpointInfo {
600 fn is_enabled(&self) -> bool;
601 fn addr_str(&self) -> Option<&str>;
602 fn url_str(&self) -> Option<&str> {
603 None
604 }
605}
606
607impl EndpointInfo for DnsTransportConfig {
608 fn is_enabled(&self) -> bool {
609 self.enabled
610 }
611 fn addr_str(&self) -> Option<&str> {
612 self.addr.as_deref()
613 }
614}
615
616impl EndpointInfo for DotTransportConfig {
617 fn is_enabled(&self) -> bool {
618 self.enabled
619 }
620 fn addr_str(&self) -> Option<&str> {
621 self.addr.as_deref()
622 }
623}
624
625impl EndpointInfo for DohTransportConfig {
626 fn is_enabled(&self) -> bool {
627 self.enabled
628 }
629 fn addr_str(&self) -> Option<&str> {
630 self.addr.as_deref()
631 }
632 fn url_str(&self) -> Option<&str> {
633 self.url.as_deref()
634 }
635}
636
637impl EndpointInfo for DoqTransportConfig {
638 fn is_enabled(&self) -> bool {
639 self.enabled
640 }
641 fn addr_str(&self) -> Option<&str> {
642 self.addr.as_deref()
643 }
644}
645
646fn optional_text(label: &str, help: &str, default: Option<&str>) -> Result<Option<String>> {
649 let mut builder = Text::new(label).with_help_message(help);
650 if let Some(d) = default {
651 builder = builder.with_default(d);
652 }
653 let val = builder.prompt().map_err(wizard_err)?;
654 let val = val.trim();
655 Ok(if val.is_empty() {
656 None
657 } else {
658 Some(val.to_string())
659 })
660}
661
662fn optional_u64(label: &str, help: &str, current: Option<u64>) -> Result<Option<u64>> {
663 let default = current.map(|n| n.to_string());
664 let mut builder = Text::new(label)
665 .with_help_message(help)
666 .with_validator(|input: &str| {
667 if input.is_empty() {
668 return Ok(Validation::Valid);
669 }
670 if input.parse::<u64>().is_ok() {
671 Ok(Validation::Valid)
672 } else {
673 Ok(Validation::Invalid("must be a non-negative integer".into()))
674 }
675 });
676 if let Some(ref d) = default {
677 builder = builder.with_default(d.as_str());
678 }
679 let val = builder.prompt().map_err(wizard_err)?;
680 if val.is_empty() {
681 Ok(None)
682 } else {
683 val.parse::<u64>()
684 .map(Some)
685 .map_err(|_| Error::parse(format!("'{val}' is not a valid integer")))
686 }
687}
688
689fn wizard_err(e: inquire::InquireError) -> Error {
690 match e {
691 InquireError::OperationCanceled | InquireError::OperationInterrupted => Error::cancelled(),
692 other => Error::io(
693 format!("interactive prompt failed: {other}"),
694 std::io::Error::other(other.to_string()),
695 ),
696 }
697}
698
699struct VendorChoice {
702 kind: VendorKind,
703 label: &'static str,
704}
705
706impl std::fmt::Display for VendorChoice {
707 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
708 f.write_str(self.label)
709 }
710}
711
712struct LocationChoice {
713 value: Option<ServerLocation>,
714 label: &'static str,
715}
716
717impl std::fmt::Display for LocationChoice {
718 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
719 f.write_str(self.label)
720 }
721}
722
723struct AccessChoice {
724 rule: PolicyRule,
725 label: &'static str,
726}
727
728impl std::fmt::Display for AccessChoice {
729 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
730 f.write_str(self.label)
731 }
732}
733
734struct ProtocolChoice {
735 id: u8,
736 label: &'static str,
737}
738
739impl std::fmt::Display for ProtocolChoice {
740 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
741 f.write_str(self.label)
742 }
743}
744
745enum EndpointProtocol {
746 Dns,
747 Dot,
748 Doh,
749 Doq,
750}
751
752struct EndpointChoice {
753 protocol: EndpointProtocol,
754 label: String,
755}
756
757impl std::fmt::Display for EndpointChoice {
758 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
759 f.write_str(&self.label)
760 }
761}
762
763enum EndpointAction {
764 Configure,
765 Remove,
766}
767
768struct ActionChoice {
769 action: EndpointAction,
770 label: &'static str,
771}
772
773impl std::fmt::Display for ActionChoice {
774 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
775 f.write_str(self.label)
776 }
777}
778
779struct ServerChoice {
780 id: String,
781 label: String,
782}
783
784impl std::fmt::Display for ServerChoice {
785 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
786 f.write_str(&self.label)
787 }
788}