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
118    /// Fetch DNS query logs
119    Logs {
120        /// Maximum number of log entries to return
121        #[arg(long, default_value_t = 50)]
122        lines: u32,
123        /// Start time: ISO 8601 (2024-01-01T10:00:00), relative duration (10m, 2h, 1d, 30s),
124        /// or time of day (14:30 → most recent occurrence)
125        #[arg(long)]
126        start: Option<String>,
127        /// End time: same format as --start
128        #[arg(long)]
129        end: Option<String>,
130        /// Minimum log level; omit to show all
131        #[arg(long, value_enum)]
132        level: Option<crate::core::dns::logs::LogLevel>,
133    },
134
135    /// Print a shell completion script to stdout.
136    ///
137    /// Redirect the output to a file in your shell's completions directory:
138    ///   dns completions fish > ~/.config/fish/completions/dns.fish
139    ///   dns completions bash > ~/.local/share/bash-completion/completions/dns
140    ///   dns completions zsh > ~/.zsh/completions/_dns
141    Completions { shell: Shell },
142
143    /// Print configured server IDs (used by shell completions)
144    #[command(name = "_servers", hide = true)]
145    ServerIds,
146}
147
148#[derive(Subcommand)]
149pub enum ConfigCmd {
150    /// Write the starter config file and exit
151    Init {
152        /// Overwrite an existing config file
153        #[arg(long)]
154        force: bool,
155    },
156
157    /// Print the config to stdout (existing config with tokens redacted, or the
158    /// starter template if no config file exists yet)
159    Print,
160
161    /// Add a server entry to the config file (creates the file if needed).
162    /// Run with no flags to enter interactive setup.
163    Add {
164        /// Unique ID for this server
165        #[arg(long)]
166        id: Option<String>,
167
168        /// DNS vendor backend
169        #[arg(long, default_value = "technitium")]
170        vendor: crate::control_plane::config::VendorKind,
171
172        /// Base URL of the DNS server API
173        #[arg(long)]
174        base_url: Option<String>,
175
176        /// Name of the environment variable that holds the base URL
177        #[arg(long)]
178        base_url_env: Option<String>,
179
180        /// Name of the environment variable that holds the API token (recommended)
181        #[arg(long)]
182        token_env: Option<String>,
183
184        /// API token literal — stored in plain text in the config file; prefer --token-env
185        #[arg(long)]
186        token: Option<String>,
187
188        /// Organisation ID (Pangolin only)
189        #[arg(long)]
190        org_id: Option<String>,
191
192        /// Whether the server is on a local network or an external/cloud service
193        /// (auto-detected from base_url when omitted)
194        #[arg(long)]
195        location: Option<crate::control_plane::config::ServerLocation>,
196
197        /// MCP allowed operations for this server (default: all)
198        #[arg(long, value_enum, value_delimiter = ',', num_args = 0.., default_values = &["read", "write", "delete"])]
199        access: Vec<PolicyRule>,
200
201        /// Restrict MCP zone-targeting tools to this zone (repeatable)
202        #[arg(long, value_name = "ZONE")]
203        allow_zone: Vec<String>,
204
205        /// Validation endpoint in name:transport:address format (repeatable; transport: dns, doh, dot)
206        #[arg(long = "validation-endpoint", value_name = "NAME:TRANSPORT:ADDRESS")]
207        validation_endpoints: Vec<crate::control_plane::config::ValidationEndpointConfig>,
208    },
209}
210
211// ─── Zone subcommands ────────────────────────────────────────────────────────
212
213#[derive(Subcommand)]
214pub enum ZoneCmd {
215    /// List all hosted zones
216    List {
217        #[arg(long, default_value_t = 1)]
218        page: u32,
219        #[arg(long, default_value_t = 50)]
220        per_page: u32,
221    },
222    /// Create a new zone
223    Create {
224        zone: String,
225        /// Zone type: Primary, Secondary, Stub, Forwarder
226        #[arg(long, default_value = "Primary")]
227        r#type: String,
228    },
229    /// Delete a zone
230    Delete { zone: String },
231    /// Enable a zone
232    Enable { zone: String },
233    /// Disable a zone
234    Disable { zone: String },
235    /// Import a zone file (RFC 1035 format) into an existing zone
236    Import {
237        zone: String,
238        /// Path to the zone file on disk
239        file: std::path::PathBuf,
240        #[command(flatten)]
241        options: crate::core::dns::zones::ZoneImportOptions,
242    },
243    /// Export a zone as a BIND-format (RFC 1035) zone file
244    Export {
245        zone: String,
246        /// Write zone file to this path instead of stdout
247        #[arg(long, short)]
248        output: Option<std::path::PathBuf>,
249    },
250    /// Copy a zone from one configured server to another
251    Transfer {
252        zone: String,
253        /// Source server ID (must be in config file)
254        #[arg(long)]
255        from: String,
256        /// Destination server ID (must be in config file)
257        #[arg(long)]
258        to: String,
259        /// Overwrite existing record sets in the destination for imported types (default: true)
260        #[arg(long, default_value_t = true)]
261        overwrite: bool,
262        /// Delete all existing records in the destination before importing (clean replace)
263        #[arg(long, default_value_t = false)]
264        overwrite_zone: bool,
265    },
266}
267
268// ─── Record subcommands ──────────────────────────────────────────────────────
269
270#[derive(Subcommand)]
271pub enum RecordCmd {
272    /// List DNS records, optionally filtered to a domain
273    List {
274        /// Domain to look up. Omitting it lists records for all hosted zones.
275        /// A bare label (e.g. `huly`) can be combined with --zone, or searched
276        /// across all zones when --zone is omitted.
277        domain: Option<String>,
278        /// Zone the domain belongs to.  When given, a bare domain label is automatically
279        /// qualified: `huly` + `--zone hankin.io` → `huly.hankin.io`.
280        #[arg(long)]
281        zone: Option<String>,
282        /// Also show records for every subdomain of the given domain
283        #[arg(long)]
284        all_subdomains: bool,
285        /// Server IDs to query (repeatable); ignored when --all is used
286        #[arg(long = "server", value_name = "ID")]
287        servers: Vec<String>,
288        /// Prefer a locally-resolved private IP over the provider's public A/AAAA value
289        #[arg(long)]
290        use_local_ip: bool,
291        /// Output raw JSON instead of a table
292        #[arg(long)]
293        json: bool,
294    },
295    /// Add a record — type is a subcommand with typed fields
296    Add {
297        #[arg(long)]
298        zone: String,
299        #[arg(long)]
300        domain: String,
301        #[arg(long, default_value_t = 3600)]
302        ttl: u32,
303        #[command(subcommand)]
304        record: crate::core::dns::records::RecordData,
305    },
306    /// Delete a record. Value fields are optional — omitting them deletes ALL
307    /// records of that type for the domain.
308    Delete {
309        #[arg(long)]
310        zone: String,
311        #[arg(long)]
312        domain: String,
313        #[command(subcommand)]
314        record: crate::core::dns::records::RecordSelector,
315    },
316}
317
318// ─── Cache subcommands ───────────────────────────────────────────────────────
319
320#[derive(Subcommand)]
321pub enum CacheCmd {
322    /// Browse the DNS cache for a domain
323    List {
324        #[arg(default_value = "")]
325        domain: String,
326    },
327    /// Evict a domain from cache
328    Delete { domain: String },
329    /// Flush the entire DNS cache
330    Flush,
331}
332
333// ─── Blocked subcommands ─────────────────────────────────────────────────────
334
335#[derive(Subcommand)]
336pub enum BlockedCmd {
337    /// List all blocked domains
338    List,
339    /// Block a domain
340    Add { domain: String },
341    /// Unblock a domain
342    Delete { domain: String },
343}
344
345// ─── Allowed subcommands ─────────────────────────────────────────────────────
346
347#[derive(Subcommand)]
348pub enum AllowedCmd {
349    /// List all whitelisted domains
350    List,
351    /// Whitelist a domain
352    Add { domain: String },
353    /// Remove a domain from the whitelist
354    Delete { domain: String },
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
362
363    #[test]
364    fn technitium_env_vars_do_not_populate_global_overrides() {
365        let _guard = ENV_LOCK.lock().unwrap();
366        // SAFETY: this test serializes access to these process-wide env vars.
367        unsafe {
368            std::env::set_var("TECHNITIUM_BASE_URL", "http://technitium.local:5380");
369            std::env::set_var("TECHNITIUM_API_TOKEN", "technitium-token");
370        }
371
372        let cli = Cli::try_parse_from(["dns", "mcp"]).unwrap();
373
374        assert!(cli.base_url.is_none());
375        assert!(cli.token.is_none());
376
377        // SAFETY: this test serializes access to these process-wide env vars.
378        unsafe {
379            std::env::remove_var("TECHNITIUM_BASE_URL");
380            std::env::remove_var("TECHNITIUM_API_TOKEN");
381        }
382    }
383}