dnslib/cli/mod.rs
1pub mod completions;
2pub mod interactive;
3pub mod query;
4pub mod records;
5pub mod runner;
6
7use clap::{Parser, Subcommand};
8use clap_complete::Shell;
9use std::path::PathBuf;
10
11use crate::control_plane::policy::PolicyRule;
12
13// ─── Top-level CLI ───────────────────────────────────────────────────────────
14
15#[derive(Parser)]
16#[command(name = "dns", about = "DNS Sync and Control with MCP", version)]
17pub struct Cli {
18 /// Config file path (defaults to $XDG_CONFIG_HOME/dnsync/config.toml or ~/.config/dnsync/config.toml)
19 #[arg(long, env = "DNSYNC_CONFIG")]
20 pub config: Option<PathBuf>,
21
22 /// DNS server ID from the config file (repeatable for record list)
23 #[arg(long = "server", env = "DNSYNC_SERVER")]
24 pub servers: Vec<String>,
25
26 /// Query all configured servers (record list only)
27 #[arg(long)]
28 pub all: bool,
29
30 /// API base URL override for the selected command only
31 #[arg(long)]
32 pub base_url: Option<String>,
33
34 /// API token override for the selected command only
35 #[arg(long)]
36 pub token: Option<String>,
37
38 /// MCP only: allowed operations (comma-separated: read,write,delete); defaults to all if omitted
39 #[arg(long, env = "DNS_ACCESS", value_enum, value_delimiter = ',', num_args = 0..)]
40 pub access: Vec<PolicyRule>,
41
42 /// MCP only: restrict access to this zone (repeatable); subdomains are also permitted
43 #[arg(long, env = "DNS_ALLOWED_ZONES", value_delimiter = ',')]
44 pub allow_zone: Vec<String>,
45
46 #[command(subcommand)]
47 pub command: Command,
48}
49
50#[derive(Subcommand)]
51pub enum Command {
52 /// Write a starter config file
53 #[command(subcommand)]
54 Config(ConfigCmd),
55
56 /// Start the MCP stdio server
57 Mcp,
58
59 /// Manage DNS zones
60 #[command(subcommand)]
61 Zone(ZoneCmd),
62
63 /// Manage DNS records
64 #[command(subcommand)]
65 Record(RecordCmd),
66
67 /// Resolve a name directly against the system, a configured server, or any
68 /// ad-hoc nameserver. Supports DNS, DoT, DoH, and (with `--features doq`)
69 /// DoQ transports.
70 #[command(alias = "q")]
71 Query(query::QueryArgs),
72
73 /// Sync records between two configured servers, optionally remapping IPs
74 Sync {
75 /// Named sync profile from the config file
76 profile: Option<String>,
77
78 /// Source server ID (overrides the profile's `from`)
79 #[arg(long)]
80 from: Option<String>,
81
82 /// Destination server ID (overrides the profile's `to`)
83 #[arg(long)]
84 to: Option<String>,
85
86 /// Zone to sync (repeatable; overrides the profile's zones)
87 #[arg(long = "zone", value_name = "ZONE")]
88 zone: Vec<String>,
89
90 /// IP rewrite for A/AAAA records, given as SRC=DST (repeatable)
91 #[arg(long = "map", value_name = "SRC=DST")]
92 map: Vec<String>,
93
94 /// Write the changes (without this flag, sync only previews them)
95 #[arg(long)]
96 apply: bool,
97
98 /// Output the sync plan as JSON
99 #[arg(long)]
100 json: bool,
101 },
102
103 /// Manage the DNS cache
104 #[command(subcommand)]
105 Cache(CacheCmd),
106
107 /// View server statistics
108 Stats {
109 /// Stats window: LastHour, LastDay, LastWeek, LastMonth, LastYear
110 #[arg(long, default_value = "LastDay")]
111 r#type: String,
112 },
113
114 /// Manage manually blocked domains
115 #[command(subcommand)]
116 Blocked(BlockedCmd),
117
118 /// Manage the allowed (whitelist) domains
119 #[command(subcommand)]
120 Allowed(AllowedCmd),
121
122 /// Show server settings
123 Settings {
124 /// Display sensitive settings values instead of redacting them
125 #[arg(long)]
126 show_secrets: bool,
127 },
128
129 /// Fetch DNS server logs
130 Logs {
131 /// Maximum number of log entries to return
132 #[arg(long, default_value_t = 50)]
133 lines: u32,
134 /// Start time: ISO 8601 (2024-01-01T10:00:00), relative duration (10m, 2h, 1d, 30s),
135 /// or time of day (14:30 → most recent occurrence)
136 #[arg(long)]
137 start: Option<String>,
138 /// End time: same format as --start
139 #[arg(long)]
140 end: Option<String>,
141 /// Minimum log level; omit to show all
142 #[arg(long, value_enum)]
143 level: Option<crate::core::dns::logs::LogLevel>,
144 },
145
146 /// Print a shell completion script to stdout.
147 ///
148 /// Redirect the output to a file in your shell's completions directory:
149 /// dns completions fish > ~/.config/fish/completions/dns.fish
150 /// dns completions bash > ~/.local/share/bash-completion/completions/dns
151 /// dns completions zsh > ~/.zsh/completions/_dns
152 Completions { shell: Shell },
153
154 /// Print configured server IDs (used by shell completions)
155 #[command(name = "_servers", hide = true)]
156 ServerIds,
157}
158
159#[derive(Subcommand)]
160pub enum ConfigCmd {
161 /// Write the starter config file and exit
162 Init {
163 /// Overwrite an existing config file
164 #[arg(long)]
165 force: bool,
166 },
167
168 /// Print the config to stdout (existing config with tokens redacted, or the
169 /// starter template if no config file exists yet)
170 Print,
171
172 /// Add newly-known default values to existing server entries without overwriting set values
173 Update,
174
175 /// Add a server entry to the config file (creates the file if needed).
176 /// Run with no flags to enter interactive setup.
177 Add {
178 /// Unique ID for this server
179 #[arg(long)]
180 id: Option<String>,
181
182 /// DNS vendor backend
183 #[arg(long, default_value = "technitium")]
184 vendor: crate::control_plane::config::VendorKind,
185
186 /// Base URL of the DNS server API
187 #[arg(long)]
188 base_url: Option<String>,
189
190 /// Name of the environment variable that holds the base URL
191 #[arg(long)]
192 base_url_env: Option<String>,
193
194 /// Name of the environment variable that holds the API token (recommended)
195 #[arg(long)]
196 token_env: Option<String>,
197
198 /// API token literal — stored in plain text in the config file; prefer --token-env
199 #[arg(long)]
200 token: Option<String>,
201
202 /// Organisation ID (Pangolin only)
203 #[arg(long)]
204 org_id: Option<String>,
205
206 /// Whether the server is on a local network or an external/cloud service
207 /// (auto-detected from base_url when omitted)
208 #[arg(long)]
209 location: Option<crate::control_plane::config::ServerLocation>,
210
211 /// MCP allowed operations for this server (default: all)
212 #[arg(long, value_enum, value_delimiter = ',', num_args = 0.., default_values = &["read", "write", "delete"])]
213 access: Vec<PolicyRule>,
214
215 /// Restrict MCP zone-targeting tools to this zone (repeatable)
216 #[arg(long, value_name = "ZONE")]
217 allow_zone: Vec<String>,
218
219 /// Validation endpoint in name:transport:address format (repeatable; transport: dns, doh, dot)
220 #[arg(long = "validation-endpoint", value_name = "NAME:TRANSPORT:ADDRESS")]
221 validation_endpoints: Vec<crate::control_plane::config::ValidationEndpointConfig>,
222 },
223
224 /// Set or clear a DNS query endpoint on an existing server entry.
225 ///
226 /// Run with no arguments to enter interactive setup.
227 /// Example (non-interactive): dns config server myserver dns --addr 10.0.0.1:53
228 Server {
229 /// ID of the server to update (case-insensitive).
230 /// Omit to be prompted interactively.
231 server_id: Option<String>,
232
233 /// Endpoint type and options.
234 /// Omit to be prompted interactively.
235 #[command(subcommand)]
236 endpoint: Option<ServerEndpointCmd>,
237 },
238}
239
240/// Transport endpoint subcommands for `config server`.
241#[derive(Subcommand)]
242pub enum ServerEndpointCmd {
243 /// Set or clear the plain DNS (port 53) endpoint
244 Dns {
245 /// Host:port for the DNS server (e.g. 10.0.0.1:53)
246 #[arg(long)]
247 addr: Option<String>,
248
249 /// Timeout for DNS queries in milliseconds
250 #[arg(long)]
251 timeout_ms: Option<u64>,
252
253 /// Mark the endpoint as disabled (default: enabled)
254 #[arg(long)]
255 disable: bool,
256
257 /// Remove the entire [servers.dns] block from the config
258 #[arg(long)]
259 clear: bool,
260 },
261
262 /// Set or clear the DNS-over-TLS (DoT, port 853) endpoint
263 Dot {
264 /// Host:port for the DoT server (e.g. 10.0.0.1:853)
265 #[arg(long)]
266 addr: Option<String>,
267
268 /// TLS SNI server name (defaults to the hostname in --addr)
269 #[arg(long)]
270 server_name: Option<String>,
271
272 /// Timeout for DoT queries in milliseconds
273 #[arg(long)]
274 timeout_ms: Option<u64>,
275
276 /// Mark the endpoint as disabled (default: enabled)
277 #[arg(long)]
278 disable: bool,
279
280 /// Remove the entire [servers.dot] block from the config
281 #[arg(long)]
282 clear: bool,
283 },
284
285 /// Set or clear the DNS-over-HTTPS (DoH) endpoint
286 Doh {
287 /// Full HTTPS URL for the DoH resolver (e.g. https://dns.example.com/dns-query)
288 #[arg(long)]
289 url: Option<String>,
290
291 /// Host:port to connect to (overrides the address resolved from --url)
292 #[arg(long)]
293 addr: Option<String>,
294
295 /// TLS SNI server name
296 #[arg(long)]
297 server_name: Option<String>,
298
299 /// Timeout for DoH queries in milliseconds
300 #[arg(long)]
301 timeout_ms: Option<u64>,
302
303 /// Mark the endpoint as disabled (default: enabled)
304 #[arg(long)]
305 disable: bool,
306
307 /// Remove the entire [servers.doh] block from the config
308 #[arg(long)]
309 clear: bool,
310 },
311
312 /// Set or clear the DNS-over-QUIC (DoQ) endpoint
313 Doq {
314 /// Host:port for the DoQ server (e.g. 10.0.0.1:853)
315 #[arg(long)]
316 addr: Option<String>,
317
318 /// TLS SNI server name (defaults to the hostname in --addr)
319 #[arg(long)]
320 server_name: Option<String>,
321
322 /// Timeout for DoQ queries in milliseconds
323 #[arg(long)]
324 timeout_ms: Option<u64>,
325
326 /// Mark the endpoint as disabled (default: enabled)
327 #[arg(long)]
328 disable: bool,
329
330 /// Remove the entire [servers.doq] block from the config
331 #[arg(long)]
332 clear: bool,
333 },
334}
335
336// ─── Zone subcommands ────────────────────────────────────────────────────────
337
338#[derive(Subcommand)]
339pub enum ZoneCmd {
340 /// List all hosted zones
341 List {
342 #[arg(long, default_value_t = 1)]
343 page: u32,
344 #[arg(long, default_value_t = 50)]
345 per_page: u32,
346 },
347 /// Create a new zone
348 Create {
349 zone: String,
350 /// Zone type: Primary, Secondary, Stub, Forwarder
351 #[arg(long, default_value = "Primary")]
352 r#type: String,
353 },
354 /// Delete a zone
355 Delete { zone: String },
356 /// Enable a zone
357 Enable { zone: String },
358 /// Disable a zone
359 Disable { zone: String },
360 /// Import a zone file (RFC 1035 format) into an existing zone
361 Import {
362 zone: String,
363 /// Path to the zone file on disk
364 file: std::path::PathBuf,
365 #[command(flatten)]
366 options: crate::core::dns::zones::ZoneImportOptions,
367 },
368 /// Export a zone as a BIND-format (RFC 1035) zone file
369 Export {
370 zone: String,
371 /// Write zone file to this path instead of stdout
372 #[arg(long, short)]
373 output: Option<std::path::PathBuf>,
374 },
375 /// Copy a zone from one configured server to another
376 Transfer {
377 zone: String,
378 /// Source server ID (must be in config file)
379 #[arg(long)]
380 from: String,
381 /// Destination server ID (must be in config file)
382 #[arg(long)]
383 to: String,
384 /// Overwrite existing record sets in the destination for imported types (default: true)
385 #[arg(long, default_value_t = true)]
386 overwrite: bool,
387 /// Delete all existing records in the destination before importing (clean replace)
388 #[arg(long, default_value_t = false)]
389 overwrite_zone: bool,
390 },
391}
392
393// ─── Record subcommands ──────────────────────────────────────────────────────
394
395#[derive(Subcommand)]
396pub enum RecordCmd {
397 /// List DNS records, optionally filtered to a domain
398 List {
399 /// Domain to look up. Omitting it lists records for all hosted zones.
400 /// A bare label (e.g. `huly`) can be combined with --zone, or searched
401 /// across all zones when --zone is omitted.
402 domain: Option<String>,
403 /// Zone the domain belongs to. When given, a bare domain label is automatically
404 /// qualified: `huly` + `--zone hankin.io` → `huly.hankin.io`.
405 #[arg(long)]
406 zone: Option<String>,
407 /// Also show records for every subdomain of the given domain
408 #[arg(long)]
409 all_subdomains: bool,
410 /// Server IDs to query (repeatable); ignored when --all is used
411 #[arg(long = "server", value_name = "ID")]
412 servers: Vec<String>,
413 /// Prefer a locally-resolved private IP over the provider's public A/AAAA value
414 #[arg(long)]
415 use_local_ip: bool,
416 /// Output raw JSON instead of a table
417 #[arg(long)]
418 json: bool,
419 },
420 /// Add a record — type is a subcommand with typed fields
421 Add {
422 #[arg(long)]
423 zone: String,
424 #[arg(long)]
425 domain: String,
426 #[arg(long, default_value_t = 3600)]
427 ttl: u32,
428 #[command(subcommand)]
429 record: crate::core::dns::records::RecordData,
430 },
431 /// Delete a record. Value fields are optional — omitting them deletes ALL
432 /// records of that type for the domain.
433 Delete {
434 #[arg(long)]
435 zone: String,
436 #[arg(long)]
437 domain: String,
438 #[command(subcommand)]
439 record: crate::core::dns::records::RecordSelector,
440 },
441}
442
443// ─── Cache subcommands ───────────────────────────────────────────────────────
444
445#[derive(Subcommand)]
446pub enum CacheCmd {
447 /// Browse the DNS cache for a domain
448 List {
449 #[arg(default_value = "")]
450 domain: String,
451 },
452 /// Evict a domain from cache
453 Delete { domain: String },
454 /// Flush the entire DNS cache
455 Flush,
456}
457
458// ─── Blocked subcommands ─────────────────────────────────────────────────────
459
460#[derive(Subcommand)]
461pub enum BlockedCmd {
462 /// List all blocked domains
463 List,
464 /// Block a domain
465 Add { domain: String },
466 /// Unblock a domain
467 Delete { domain: String },
468}
469
470// ─── Allowed subcommands ─────────────────────────────────────────────────────
471
472#[derive(Subcommand)]
473pub enum AllowedCmd {
474 /// List all whitelisted domains
475 List,
476 /// Whitelist a domain
477 Add { domain: String },
478 /// Remove a domain from the whitelist
479 Delete { domain: String },
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485
486 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
487
488 #[test]
489 fn technitium_env_vars_do_not_populate_global_overrides() {
490 let _guard = ENV_LOCK.lock().unwrap();
491 // SAFETY: this test serializes access to these process-wide env vars.
492 unsafe {
493 std::env::set_var("TECHNITIUM_BASE_URL", "http://technitium.local:5380");
494 std::env::set_var("TECHNITIUM_API_TOKEN", "technitium-token");
495 }
496
497 let cli = Cli::try_parse_from(["dns", "mcp"]).unwrap();
498
499 assert!(cli.base_url.is_none());
500 assert!(cli.token.is_none());
501
502 // SAFETY: this test serializes access to these process-wide env vars.
503 unsafe {
504 std::env::remove_var("TECHNITIUM_BASE_URL");
505 std::env::remove_var("TECHNITIUM_API_TOKEN");
506 }
507 }
508
509 #[test]
510 fn settings_accepts_show_secrets_flag() {
511 let cli = Cli::try_parse_from(["dns", "settings", "--show-secrets"]).unwrap();
512
513 assert!(matches!(
514 cli.command,
515 Command::Settings { show_secrets: true }
516 ));
517
518 let cli = Cli::try_parse_from(["dns", "settings"]).unwrap();
519
520 assert!(matches!(
521 cli.command,
522 Command::Settings {
523 show_secrets: false
524 }
525 ));
526 }
527}