Skip to main content

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}