Skip to main content

dnslib/cli/
mod.rs

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