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}