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}