bindcar/
zones.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Zone management API handlers
5//!
6//! This module implements HTTP handlers for all zone-related operations:
7//! - Creating zones (with zone file creation)
8//! - Deleting zones
9//! - Reloading zones
10//! - Getting zone status
11//! - Freezing/thawing zones
12//! - Notifying secondaries
13
14use axum::{
15    extract::{Path, State},
16    http::StatusCode,
17    Json,
18};
19use serde::{Deserialize, Serialize};
20use std::path::PathBuf;
21use tracing::{debug, error, info};
22use utoipa::ToSchema;
23
24use crate::{
25    metrics,
26    types::{ApiError, AppState},
27};
28
29/// Zone type constants
30pub const ZONE_TYPE_PRIMARY: &str = "primary";
31pub const ZONE_TYPE_SECONDARY: &str = "secondary";
32
33/// Maximum length of a DNS zone name (RFC 1035 total name length).
34const MAX_ZONE_NAME_LEN: usize = 253;
35
36/// Maximum length of an RNDC configuration identifier (TSIG key / policy name).
37const MAX_RNDC_IDENTIFIER_LEN: usize = 253;
38
39/// Validate a zone name against a strict DNS-name grammar.
40///
41/// The zone name is interpolated into both a filesystem path (`<zone>.zone`) and
42/// RNDC `addzone`/`delzone` commands, so it must be tightly constrained to prevent
43/// path traversal (B-1) and command injection. Only the DNS character set
44/// `[A-Za-z0-9._-]` is permitted, the name must start with an alphanumeric
45/// character, and parent-directory references (`..`), path separators, whitespace,
46/// NUL, and other control characters are rejected.
47///
48/// # Errors
49/// Returns [`ApiError::InvalidRequest`] (HTTP 400) when the name is empty, too
50/// long, or contains a disallowed character or sequence.
51pub(crate) fn validate_zone_name(zone_name: &str) -> Result<(), ApiError> {
52    if zone_name.is_empty() {
53        return Err(ApiError::InvalidRequest(
54            "Zone name cannot be empty".to_string(),
55        ));
56    }
57
58    if zone_name.len() > MAX_ZONE_NAME_LEN {
59        return Err(ApiError::InvalidRequest(format!(
60            "Zone name exceeds maximum length of {} characters",
61            MAX_ZONE_NAME_LEN
62        )));
63    }
64
65    // Reject parent-directory references outright (defense-in-depth against
66    // path traversal even though '/' is already rejected below).
67    if zone_name.contains("..") {
68        return Err(ApiError::InvalidRequest(
69            "Zone name must not contain '..'".to_string(),
70        ));
71    }
72
73    // The first character must be alphanumeric (no leading dot, hyphen, or
74    // separator that could anchor a traversal or empty label).
75    let starts_alphanumeric = zone_name
76        .chars()
77        .next()
78        .is_some_and(|c| c.is_ascii_alphanumeric());
79    if !starts_alphanumeric {
80        return Err(ApiError::InvalidRequest(
81            "Zone name must start with an alphanumeric character".to_string(),
82        ));
83    }
84
85    // Every character must be in the permitted DNS set. This rejects '/', '\\',
86    // whitespace, NUL, quotes, semicolons, braces, and all control characters.
87    for c in zone_name.chars() {
88        if !(c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') {
89            return Err(ApiError::InvalidRequest(format!(
90                "Zone name contains invalid character: {:?}",
91                c
92            )));
93        }
94    }
95
96    Ok(())
97}
98
99/// Validate an RNDC configuration identifier such as a TSIG key name or a
100/// `dnssec-policy` name.
101///
102/// These values are interpolated into quoted RNDC `addzone` config literals
103/// (e.g. `allow-update {{ key "<name>"; }}`), so any `"`, `;`, `{`, `}`,
104/// whitespace, or control character could break out of the quoted context and
105/// inject arbitrary BIND configuration (B-3). Only the safe identifier set
106/// `[A-Za-z0-9._-]` is permitted.
107///
108/// # Arguments
109/// * `field` - Human-readable field name used in error messages.
110/// * `value` - The identifier value to validate.
111///
112/// # Errors
113/// Returns [`ApiError::InvalidRequest`] (HTTP 400) when the identifier is empty,
114/// too long, or contains a disallowed character.
115pub(crate) fn validate_rndc_identifier(field: &str, value: &str) -> Result<(), ApiError> {
116    if value.is_empty() {
117        return Err(ApiError::InvalidRequest(format!(
118            "{} cannot be empty",
119            field
120        )));
121    }
122
123    if value.len() > MAX_RNDC_IDENTIFIER_LEN {
124        return Err(ApiError::InvalidRequest(format!(
125            "{} exceeds maximum length of {} characters",
126            field, MAX_RNDC_IDENTIFIER_LEN
127        )));
128    }
129
130    for c in value.chars() {
131        if !(c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') {
132            return Err(ApiError::InvalidRequest(format!(
133                "{} contains invalid character: {:?}",
134                field, c
135            )));
136        }
137    }
138
139    Ok(())
140}
141
142/// Validate that every entry in an IP-address list parses as an [`IpAddr`].
143///
144/// `create_zone` interpolates these values directly into the `rndc addzone`
145/// configuration literal — `primaries {{ .. }}`, `also-notify {{ .. }}`,
146/// `allow-transfer {{ .. }}`. Without this check an entry such as
147/// `"1.2.3.4; }; zone \"x\" { type primary; ..."` would close the brace block
148/// and inject arbitrary configuration into the running `named` (B-8 / C-1).
149/// Requiring a strict `IpAddr` parse rejects every metacharacter (`}`, `{`,
150/// `;`, `"`, whitespace) since none can appear in a valid IPv4/IPv6 literal.
151///
152/// [`IpAddr`]: std::net::IpAddr
153///
154/// # Errors
155/// Returns [`ApiError::InvalidRequest`] (HTTP 400) if any entry is not a valid
156/// IP address.
157pub(crate) fn validate_ip_list(field: &str, ips: &[String]) -> Result<(), ApiError> {
158    for ip in ips {
159        if ip.parse::<std::net::IpAddr>().is_err() {
160            return Err(ApiError::InvalidRequest(format!(
161                "{} contains an invalid IP address: {:?}",
162                field, ip
163            )));
164        }
165    }
166
167    Ok(())
168}
169
170/// Parse an IP address entry with optional port.
171///
172/// Accepts:
173/// - Bare IPv4: `192.0.2.1`
174/// - Bare IPv6: `2001:db8::1`
175/// - IPv4 with port: `192.0.2.2:5353`
176/// - IPv6 with port (bracketed): `[2001:db8::1]:5353`
177///
178/// Returns `(address, port)` where port is `None` when not specified.
179/// Returns `None` for invalid input.
180pub(crate) fn parse_ip_port_entry(s: &str) -> Option<(&str, Option<u16>)> {
181    // Bracketed IPv6: [::1] or [::1]:5353
182    if let Some(inner) = s.strip_prefix('[') {
183        let (addr, rest) = inner.split_once(']')?;
184        addr.parse::<std::net::Ipv6Addr>().ok()?;
185        let port = match rest.strip_prefix(':') {
186            Some(p) => Some(p.parse::<u16>().ok()?),
187            None if rest.is_empty() => None,
188            _ => return None,
189        };
190        return Some((addr, port));
191    }
192
193    // Try last-colon split for ipv4:port
194    let mut parts = s.rsplitn(2, ':');
195    let last = parts.next().unwrap();
196    let prefix = parts.next();
197
198    if let Some(prefix) = prefix {
199        if let Ok(port) = last.parse::<u16>() {
200            if prefix.parse::<std::net::Ipv4Addr>().is_ok() {
201                return Some((prefix, Some(port)));
202            }
203        }
204    }
205
206    // Bare IP (v4 or v6)
207    s.parse::<std::net::IpAddr>().ok()?;
208    Some((s, None))
209}
210
211/// Render an IP:port entry into BIND config syntax (`ip port N`).
212pub(crate) fn render_ip_port_entry(s: &str) -> String {
213    if let Some((addr, Some(port))) = parse_ip_port_entry(s) {
214        format!("{} port {}; ", addr, port)
215    } else {
216        format!("{}; ", s)
217    }
218}
219
220/// Validate transfer endpoints rendered into BIND `primaries` / `also-notify` blocks.
221///
222/// Accepts either a bare IP address (`192.0.2.1`) or the compact `ip:port`
223/// syntax (`192.0.2.2:5353`, `[2001:db8::1]:5353`). This keeps bindcar backward
224/// compatible with existing bare-IP requests while allowing callers to run
225/// `named` on an unprivileged transfer port.
226///
227/// Entries are rendered to BIND's `port` keyword syntax internally.
228/// `allow-transfer` does not support per-endpoint ports and must use bare IPs.
229pub(crate) fn validate_ip_port_list(field: &str, entries: &[String]) -> Result<(), ApiError> {
230    for entry in entries {
231        if parse_ip_port_entry(entry).is_none() {
232            return Err(ApiError::InvalidRequest(format!(
233                "{} contains an invalid entry: {:?} (expected \"<ip>\" or \"<ip>:<port>\")",
234                field, entry
235            )));
236        }
237    }
238
239    Ok(())
240}
241
242/// Validate a zone-file hostname field against the strict DNS name character set.
243///
244/// [`ZoneConfig::to_zone_file`] interpolates these fields directly into the zone
245/// file — and glue hostnames (`nameServerIps` keys) are rendered at the **start
246/// of a line**, exactly where BIND master-file directives (`$INCLUDE`,
247/// `$GENERATE`, `$ORIGIN`, `$TTL`) are recognized. A control-char-only check is
248/// therefore insufficient: a value like `"$INCLUDE /etc/bind/rndc.key ;"`
249/// contains no control character yet plants a directive that reads an arbitrary
250/// file into the zone (disclosure via AXFR/query) or exhausts resources (B-8 /
251/// C-2). Restricting to `[A-Za-z0-9._-]` rejects whitespace, `$`, `;`, quotes,
252/// and parens, closing the directive-injection surface while still accepting
253/// every legitimate hostname/SOA-email form (FQDNs with a trailing dot).
254///
255/// # Errors
256/// Returns [`ApiError::InvalidRequest`] (HTTP 400) if `value` is empty or contains
257/// any character outside the permitted set.
258fn validate_zone_file_hostname(field: &str, value: &str) -> Result<(), ApiError> {
259    if value.is_empty() {
260        return Err(ApiError::InvalidRequest(format!(
261            "{} cannot be empty",
262            field
263        )));
264    }
265
266    if let Some(bad) = value
267        .chars()
268        .find(|&c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')))
269    {
270        return Err(ApiError::InvalidRequest(format!(
271            "{} contains an illegal character: {:?} (allowed: A-Z a-z 0-9 . - _)",
272            field, bad
273        )));
274    }
275
276    Ok(())
277}
278
279/// Validate the structured zone configuration before it is rendered into a zone
280/// file by [`ZoneConfig::to_zone_file`].
281///
282/// Every field that `to_zone_file` interpolates is checked here so that no
283/// user-controlled value can inject extra zone-file lines or directives (B-8 /
284/// C-2). This closes the gap where records embedded in a `create_zone` request
285/// reached the zone file without passing the per-request validation applied by
286/// the add/update/remove-record endpoints.
287///
288/// - SOA `primary_ns` / `admin_email`, name-server names, and glue hostnames must
289///   match the strict DNS name charset `[A-Za-z0-9._-]`
290///   ([`validate_zone_file_hostname`]) — rejecting whitespace/`$`/`;` so a value
291///   rendered at the start of a zone-file line cannot plant a `$INCLUDE` /
292///   `$GENERATE` master-file directive.
293/// - Glue record IPs must parse as an [`IpAddr`](std::net::IpAddr).
294/// - Each embedded record is held to the same rules as the add-record endpoint
295///   ([`validate_record_type`](crate::records::validate_record_type),
296///   [`validate_record_name`](crate::records::validate_record_name),
297///   [`validate_record_value`](crate::records::validate_record_value)).
298///
299/// # Errors
300/// Returns [`ApiError::InvalidRequest`] or [`ApiError::InvalidRecord`] (both
301/// HTTP 400) for the first field that fails validation.
302pub(crate) fn validate_zone_config_content(config: &ZoneConfig) -> Result<(), ApiError> {
303    validate_zone_file_hostname("soa.primaryNs", &config.soa.primary_ns)?;
304    validate_zone_file_hostname("soa.adminEmail", &config.soa.admin_email)?;
305
306    for ns in &config.name_servers {
307        validate_zone_file_hostname("nameServers entry", ns)?;
308    }
309
310    for (host, ip) in &config.name_server_ips {
311        validate_zone_file_hostname("nameServerIps key", host)?;
312        if ip.parse::<std::net::IpAddr>().is_err() {
313            return Err(ApiError::InvalidRequest(format!(
314                "nameServerIps[{:?}] is not a valid IP address: {:?}",
315                host, ip
316            )));
317        }
318    }
319
320    for record in &config.records {
321        crate::records::validate_record_type(&record.record_type)?;
322        crate::records::validate_record_name(&record.name)?;
323        crate::records::validate_record_value(&record.record_type, &record.value)?;
324    }
325
326    Ok(())
327}
328
329/// Resolve and validate the configured zone directory at startup.
330///
331/// The zone directory comes from the `BIND_ZONE_DIR` environment variable, which
332/// static analysis (CodeQL `rust/path-injection`) treats as untrusted input.
333/// Canonicalizing it once at startup both hardens the server and removes that
334/// taint before the path ever reaches a filesystem sink (`read_dir`/`metadata`):
335///
336/// * Symlinks and `..` segments are resolved against the real filesystem, so the
337///   value stored in [`AppState`] is an absolute, fully-normalized path.
338/// * A missing path or a path that does not resolve to a directory is rejected
339///   up front, turning a late runtime failure into a clear startup error.
340///
341/// This is the configuration-time counterpart to [`validate_zone_name`], which
342/// guards the per-request zone names that are joined onto this directory (B-1).
343///
344/// # Arguments
345/// * `raw_dir` - The configured zone directory path (e.g. from `BIND_ZONE_DIR`).
346///
347/// # Returns
348/// The canonicalized directory path as a UTF-8 `String`.
349///
350/// # Errors
351/// Returns [`ApiError::InternalError`] if the path cannot be canonicalized (for
352/// example it does not exist), does not resolve to a directory, or is not valid
353/// UTF-8.
354pub fn resolve_zone_dir(raw_dir: &str) -> Result<String, ApiError> {
355    let canonical = std::fs::canonicalize(raw_dir).map_err(|e| {
356        ApiError::InternalError(format!(
357            "zone directory {:?} could not be resolved: {}",
358            raw_dir, e
359        ))
360    })?;
361
362    if !canonical.is_dir() {
363        return Err(ApiError::InternalError(format!(
364            "zone directory {:?} does not resolve to a directory",
365            raw_dir
366        )));
367    }
368
369    canonical.into_os_string().into_string().map_err(|_| {
370        ApiError::InternalError(format!(
371            "zone directory {:?} resolves to a non-UTF-8 path",
372            raw_dir
373        ))
374    })
375}
376
377/// Returns `true` if `path` is an absolute, fully-normalized directory path.
378///
379/// "Fully-normalized" means the path is absolute and contains no `..`
380/// (parent-directory) or `.` (current-directory) components — exactly the shape
381/// that [`resolve_zone_dir`] guarantees at startup.
382///
383/// # Why this exists
384///
385/// The configured zone directory is canonicalized once at startup, but it is
386/// read back inside HTTP handlers through the axum `State` extractor. CodeQL
387/// (`rust/path-injection`) models any value reaching a handler via `State` as
388/// untrusted, so it re-taints the already-safe path where it feeds a filesystem
389/// sink (e.g. `tokio::fs::metadata` in the readiness probe). Calling this guard
390/// immediately before such a sink is a defense-in-depth barrier: it re-asserts
391/// the [`resolve_zone_dir`] invariant at the point of use and rejects any path
392/// that is unexpectedly relative or contains traversal components, rather than
393/// touching an unintended location on the filesystem.
394///
395/// # Arguments
396/// * `path` - The configured zone directory path to check.
397///
398/// # Returns
399/// `true` if the path is absolute and free of `..`/`.` components, else `false`.
400pub fn is_normalized_zone_dir(path: &str) -> bool {
401    use std::path::{Component, Path};
402
403    let path = Path::new(path);
404    if !path.is_absolute() {
405        return false;
406    }
407
408    !path
409        .components()
410        .any(|component| matches!(component, Component::ParentDir | Component::CurDir))
411}
412
413/// SOA (Start of Authority) record configuration
414///
415/// # Default Values
416///
417/// - `serial`: Automatically generated in YYYYMMDD01 format (e.g., 2025120601) if not provided
418/// - `refresh`: 3600 seconds
419/// - `retry`: 600 seconds
420/// - `expire`: 604800 seconds (7 days)
421/// - `negative_ttl`: 86400 seconds (1 day)
422#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
423#[serde(rename_all = "camelCase")]
424pub struct SoaRecord {
425    /// Primary nameserver (e.g., "ns1.example.com.")
426    pub primary_ns: String,
427
428    /// Admin email (e.g., "admin.example.com.")
429    pub admin_email: String,
430
431    /// Serial number (e.g., 2025120601) - defaults to current date in YYYYMMDD01 format
432    #[serde(default = "default_serial")]
433    pub serial: u32,
434
435    /// Refresh interval in seconds (default: 3600)
436    #[serde(default = "default_refresh")]
437    pub refresh: u32,
438
439    /// Retry interval in seconds (default: 600)
440    #[serde(default = "default_retry")]
441    pub retry: u32,
442
443    /// Expire time in seconds (default: 604800)
444    #[serde(default = "default_expire")]
445    pub expire: u32,
446
447    /// Negative TTL in seconds (default: 86400)
448    #[serde(default = "default_negative_ttl")]
449    pub negative_ttl: u32,
450}
451
452fn default_serial() -> u32 {
453    // Generate serial as YYYYMMDD01
454    let now = chrono::Utc::now();
455    let date_part = now.format("%Y%m%d").to_string();
456    format!("{}01", date_part).parse().unwrap_or(2025120601)
457}
458
459fn default_refresh() -> u32 {
460    3600
461}
462
463fn default_retry() -> u32 {
464    600
465}
466
467fn default_expire() -> u32 {
468    604_800
469}
470
471fn default_negative_ttl() -> u32 {
472    86400
473}
474
475/// DNS record entry
476#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
477#[serde(rename_all = "camelCase")]
478pub struct DnsRecord {
479    /// Record name (e.g., "www", "@")
480    pub name: String,
481
482    /// Record type (e.g., "A", "AAAA", "CNAME", "MX", "TXT")
483    #[serde(rename = "type")]
484    pub record_type: String,
485
486    /// Record value (e.g., "192.0.2.1", "example.com.")
487    pub value: String,
488
489    /// Optional TTL (uses zone default if not specified)
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub ttl: Option<u32>,
492
493    /// Optional priority (for MX, SRV records)
494    #[serde(skip_serializing_if = "Option::is_none")]
495    pub priority: Option<u16>,
496}
497
498/// Structured zone configuration
499#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
500#[serde(rename_all = "camelCase")]
501pub struct ZoneConfig {
502    /// Default TTL for the zone (e.g., 3600)
503    pub ttl: u32,
504
505    /// SOA record
506    pub soa: SoaRecord,
507
508    /// Name servers for the zone
509    pub name_servers: Vec<String>,
510
511    /// A records for nameservers (glue records)
512    /// Maps nameserver hostname to IP address (e.g., "ns1.example.com." -> "192.0.2.1")
513    pub name_server_ips: std::collections::HashMap<String, String>,
514
515    /// DNS records in the zone
516    #[serde(default)]
517    pub records: Vec<DnsRecord>,
518
519    /// IP addresses of secondary servers to notify when zone changes (BIND9 also-notify)
520    /// Example: ["10.244.2.101", "10.244.2.102"]
521    #[serde(skip_serializing_if = "Option::is_none")]
522    pub also_notify: Option<Vec<String>>,
523
524    /// IP addresses allowed to transfer the zone (BIND9 allow-transfer)
525    /// Example: ["10.244.2.101", "10.244.2.102"]
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub allow_transfer: Option<Vec<String>>,
528
529    /// IP addresses of primary servers for secondary zones (BIND9 primaries/masters)
530    /// Example: ["192.0.2.1", "192.0.2.2"]
531    /// Required for secondary zone types
532    #[serde(skip_serializing_if = "Option::is_none")]
533    pub primaries: Option<Vec<String>>,
534
535    /// DNSSEC policy name to apply to this zone (BIND9 9.16+)
536    ///
537    /// Specifies the name of a `dnssec-policy` block defined in `named.conf.options`.
538    /// When set, BIND9 will automatically sign the zone using the specified policy.
539    ///
540    /// Example: `"default"`, `"high-security"`
541    ///
542    /// Requires `inline_signing` to be enabled for DNSSEC to function.
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub dnssec_policy: Option<String>,
545
546    /// Enable inline signing for DNSSEC (BIND9 inline-signing)
547    ///
548    /// When `true`, BIND9 will sign the zone inline rather than requiring pre-signed zone files.
549    /// This is required for DNSSEC with dynamic zones and modern BIND9 configurations.
550    ///
551    /// Should be set to `true` when `dnssec_policy` is specified.
552    ///
553    /// Default: `false`
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub inline_signing: Option<bool>,
556}
557
558impl ZoneConfig {
559    /// Generate BIND9 zone file content from structured configuration
560    pub fn to_zone_file(&self) -> String {
561        let mut zone_file = String::new();
562
563        // TTL directive
564        zone_file.push_str(&format!("$TTL {}\n\n", self.ttl));
565
566        // SOA record
567        zone_file.push_str(&format!(
568            "@ IN SOA {} {} (\n",
569            self.soa.primary_ns, self.soa.admin_email
570        ));
571        zone_file.push_str(&format!("    {}  ; Serial\n", self.soa.serial));
572        zone_file.push_str(&format!("    {}  ; Refresh\n", self.soa.refresh));
573        zone_file.push_str(&format!("    {}  ; Retry\n", self.soa.retry));
574        zone_file.push_str(&format!("    {}  ; Expire\n", self.soa.expire));
575        zone_file.push_str(&format!(
576            "    {} ); Negative TTL\n\n",
577            self.soa.negative_ttl
578        ));
579
580        // Name servers
581        for ns in &self.name_servers {
582            zone_file.push_str(&format!("@ IN NS {}\n", ns));
583        }
584
585        if !self.name_servers.is_empty() {
586            zone_file.push('\n');
587        }
588
589        // Glue records (A records for nameservers)
590        for (ns_name, ip) in &self.name_server_ips {
591            // Use FQDN with trailing dot to prevent BIND9 from appending zone name
592            zone_file.push_str(&format!("{} IN A {}\n", ns_name, ip));
593        }
594        if !self.name_server_ips.is_empty() {
595            zone_file.push('\n');
596        }
597
598        // DNS records
599        for record in &self.records {
600            let ttl_str = if let Some(ttl) = record.ttl {
601                format!("{} ", ttl)
602            } else {
603                String::new()
604            };
605
606            let priority_str = if let Some(priority) = record.priority {
607                format!("{} ", priority)
608            } else {
609                String::new()
610            };
611
612            zone_file.push_str(&format!(
613                "{} {}IN {} {}{}\n",
614                record.name, ttl_str, record.record_type, priority_str, record.value
615            ));
616        }
617
618        zone_file
619    }
620}
621
622/// Request to create a new zone
623#[derive(Debug, Serialize, Deserialize, ToSchema)]
624#[serde(rename_all = "camelCase")]
625pub struct CreateZoneRequest {
626    /// Zone name (e.g., "example.com")
627    pub zone_name: String,
628
629    /// Zone type ("primary" or "secondary")
630    pub zone_type: String,
631
632    /// Structured zone configuration
633    pub zone_config: ZoneConfig,
634
635    /// Optional: TSIG key name for allow-update
636    pub update_key_name: Option<String>,
637}
638
639/// Request to modify a zone configuration
640#[derive(Debug, Serialize, Deserialize, ToSchema)]
641#[serde(rename_all = "camelCase")]
642pub struct ModifyZoneRequest {
643    /// IP addresses of secondary servers to notify when zone changes (BIND9 also-notify)
644    /// Example: ["10.244.2.101", "10.244.2.102"]
645    #[serde(skip_serializing_if = "Option::is_none")]
646    pub also_notify: Option<Vec<String>>,
647
648    /// IP addresses allowed to transfer the zone (BIND9 allow-transfer)
649    /// Example: ["10.244.2.101", "10.244.2.102"]
650    #[serde(skip_serializing_if = "Option::is_none")]
651    pub allow_transfer: Option<Vec<String>>,
652
653    /// IP addresses allowed to update the zone dynamically (BIND9 allow-update)
654    /// Example: ["10.244.2.101", "10.244.2.102"]
655    #[serde(skip_serializing_if = "Option::is_none")]
656    pub allow_update: Option<Vec<String>>,
657}
658
659/// Response from zone operations
660#[derive(Debug, Serialize, Deserialize, ToSchema)]
661pub struct ZoneResponse {
662    pub success: bool,
663    pub message: String,
664    #[serde(skip_serializing_if = "Option::is_none")]
665    pub details: Option<String>,
666}
667
668/// Server status response
669#[derive(Debug, Serialize, Deserialize, ToSchema)]
670pub struct ServerStatusResponse {
671    pub status: String,
672}
673
674/// Zone information
675#[derive(Debug, Serialize, Deserialize, ToSchema)]
676#[serde(rename_all = "camelCase")]
677pub struct ZoneInfo {
678    pub name: String,
679    pub zone_type: String,
680    #[serde(skip_serializing_if = "Option::is_none")]
681    pub serial: Option<u32>,
682    #[serde(skip_serializing_if = "Option::is_none")]
683    pub file_path: Option<String>,
684}
685
686/// List of zones response
687#[derive(Debug, Serialize, Deserialize, ToSchema)]
688pub struct ZoneListResponse {
689    pub zones: Vec<String>,
690    pub count: usize,
691}
692
693/// Create a new zone
694///
695/// This endpoint:
696/// 1. Generates zone file from structured configuration
697/// 2. Writes the zone file to disk
698/// 3. Executes `rndc addzone` to add the zone to BIND9
699#[utoipa::path(
700    post,
701    path = "/api/v1/zones",
702    request_body = CreateZoneRequest,
703    responses(
704        (status = 201, description = "Zone created successfully", body = ZoneResponse),
705        (status = 400, description = "Invalid request"),
706        (status = 409, description = "Zone already exists"),
707        (status = 500, description = "RNDC command failed"),
708        (status = 500, description = "Internal server error")
709    ),
710    tag = "zones"
711)]
712pub async fn create_zone(
713    State(state): State<AppState>,
714    Json(request): Json<CreateZoneRequest>,
715) -> Result<(StatusCode, Json<ZoneResponse>), ApiError> {
716    info!("Creating zone: {}", request.zone_name);
717
718    // Debug log the full request payload
719    if let Ok(json_payload) = serde_json::to_string_pretty(&request) {
720        debug!("POST /api/v1/zones payload: {}", json_payload);
721    }
722
723    // Validate zone name (strict DNS grammar; prevents path traversal into the
724    // zone directory and command injection into rndc addzone).
725    if let Err(e) = validate_zone_name(&request.zone_name) {
726        metrics::record_zone_operation("create", false);
727        return Err(e);
728    }
729
730    // Validate zone type
731    if request.zone_type != ZONE_TYPE_PRIMARY && request.zone_type != ZONE_TYPE_SECONDARY {
732        metrics::record_zone_operation("create", false);
733        return Err(ApiError::InvalidRequest(format!(
734            "Invalid zone type: {}. Must be '{}' or '{}'",
735            request.zone_type, ZONE_TYPE_PRIMARY, ZONE_TYPE_SECONDARY
736        )));
737    }
738
739    // Validate secondary zone requirements
740    if request.zone_type == ZONE_TYPE_SECONDARY
741        && request
742            .zone_config
743            .primaries
744            .as_ref()
745            .is_none_or(|p| p.is_empty())
746    {
747        metrics::record_zone_operation("create", false);
748        return Err(ApiError::InvalidRequest(
749            "Secondary zones require at least one primary server in 'primaries' field".to_string(),
750        ));
751    }
752
753    // Validate optional RNDC identifiers up front, before any filesystem writes,
754    // so an injection attempt never reaches the rndc addzone config literal.
755    if let Some(key_name) = &request.update_key_name {
756        if let Err(e) = validate_rndc_identifier("updateKeyName", key_name) {
757            metrics::record_zone_operation("create", false);
758            return Err(e);
759        }
760    }
761    if let Some(dnssec_policy) = &request.zone_config.dnssec_policy {
762        if let Err(e) = validate_rndc_identifier("dnssecPolicy", dnssec_policy) {
763            metrics::record_zone_operation("create", false);
764            return Err(e);
765        }
766    }
767
768    if let Some(primaries) = &request.zone_config.primaries {
769        if let Err(e) = validate_ip_port_list("primaries", primaries) {
770            metrics::record_zone_operation("create", false);
771            return Err(e);
772        }
773    }
774    if let Some(also_notify) = &request.zone_config.also_notify {
775        if let Err(e) = validate_ip_port_list("also-notify", also_notify) {
776            metrics::record_zone_operation("create", false);
777            return Err(e);
778        }
779    }
780    if let Some(allow_transfer) = &request.zone_config.allow_transfer {
781        if let Err(e) = validate_ip_list("allow-transfer", allow_transfer) {
782            metrics::record_zone_operation("create", false);
783            return Err(e);
784        }
785    }
786
787    // Validate the zone-file content fields before rendering (C-2). Records
788    // embedded in the create request never passed the per-request validation
789    // applied by the add-record endpoint, so a control character in any field
790    // could inject extra zone-file lines / directives ($INCLUDE, $GENERATE).
791    if request.zone_type == ZONE_TYPE_PRIMARY {
792        if let Err(e) = validate_zone_config_content(&request.zone_config) {
793            metrics::record_zone_operation("create", false);
794            return Err(e);
795        }
796    }
797
798    // Generate zone file content from structured configuration (only for primary zones)
799    let zone_content = if request.zone_type == ZONE_TYPE_PRIMARY {
800        request.zone_config.to_zone_file()
801    } else {
802        String::new() // Secondary zones don't need zone files
803    };
804
805    // Only write zone file for primary zones
806    let zone_file_name = format!("{}.zone", request.zone_name);
807    let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
808
809    if request.zone_type == ZONE_TYPE_PRIMARY {
810        info!(
811            "Generated zone file content for {}: {} bytes",
812            request.zone_name,
813            zone_content.len()
814        );
815
816        // Clean up any existing journal file to prevent sync issues
817        let journal_file_name = format!("{}.zone.jnl", request.zone_name);
818        let journal_file_path = PathBuf::from(&state.zone_dir).join(&journal_file_name);
819        if journal_file_path.exists() {
820            if let Err(e) = tokio::fs::remove_file(&journal_file_path).await {
821                error!(
822                    "Failed to remove old journal file {}: {}",
823                    journal_file_path.display(),
824                    e
825                );
826            } else {
827                info!("Removed old journal file: {}", journal_file_path.display());
828            }
829        }
830
831        tokio::fs::write(&zone_file_path, &zone_content)
832            .await
833            .map_err(|e| {
834                error!(
835                    "Failed to write zone file {}: {}",
836                    zone_file_path.display(),
837                    e
838                );
839                metrics::record_zone_operation("create", false);
840                ApiError::ZoneFileError(format!("Failed to write zone file: {}", e))
841            })?;
842
843        info!("Wrote zone file: {}", zone_file_path.display());
844    }
845
846    // Build zone configuration for rndc addzone
847    let mut config_parts = vec![format!(r#"type {}"#, request.zone_type)];
848
849    // Add file path for primary zones
850    if request.zone_type == ZONE_TYPE_PRIMARY {
851        let zone_file_full_path = format!("{}/{}", state.zone_dir, zone_file_name);
852        config_parts.push(format!(r#"file "{}""#, zone_file_full_path));
853    }
854
855    // Add primaries for secondary zones
856    if request.zone_type == ZONE_TYPE_SECONDARY {
857        if let Some(primaries) = &request.zone_config.primaries {
858            if !primaries.is_empty() {
859                let primaries_list = primaries
860                    .iter()
861                    .map(|s| render_ip_port_entry(s))
862                    .collect::<String>();
863                config_parts.push(format!(r#"primaries {{ {} }}"#, primaries_list));
864            }
865        }
866    }
867
868    // Add allow-update if TSIG key is provided
869    if let Some(key_name) = &request.update_key_name {
870        config_parts.push(format!(r#"allow-update {{ key "{}"; }}"#, key_name));
871    }
872
873    // Add also-notify if secondary IPs are provided
874    if let Some(also_notify) = &request.zone_config.also_notify {
875        if !also_notify.is_empty() {
876            let notify_list = also_notify
877                .iter()
878                .map(|s| render_ip_port_entry(s))
879                .collect::<String>();
880            config_parts.push(format!(r#"also-notify {{ {} }}"#, notify_list));
881        }
882    }
883
884    // Add allow-transfer if secondary IPs are provided
885    if let Some(allow_transfer) = &request.zone_config.allow_transfer {
886        if !allow_transfer.is_empty() {
887            let transfer_list = allow_transfer
888                .iter()
889                .map(|ip| format!("{}; ", ip))
890                .collect::<String>();
891            config_parts.push(format!(r#"allow-transfer {{ {} }}"#, transfer_list));
892        }
893    }
894
895    // Add DNSSEC policy if specified (BIND9 9.16+)
896    if let Some(dnssec_policy) = &request.zone_config.dnssec_policy {
897        config_parts.push(format!(r#"dnssec-policy "{}""#, dnssec_policy));
898    }
899
900    // Add inline-signing if specified (required for DNSSEC with dynamic zones)
901    if let Some(inline_signing) = request.zone_config.inline_signing {
902        config_parts.push(format!(
903            r#"inline-signing {}"#,
904            if inline_signing { "yes" } else { "no" }
905        ));
906    }
907
908    // Join all parts into final configuration
909    let zone_config = format!("{{ {}; }};", config_parts.join("; "));
910
911    // Execute rndc addzone
912    let output = state
913        .rndc
914        .addzone(&request.zone_name, &zone_config)
915        .await
916        .map_err(|e| {
917            error!("RNDC addzone failed for {}: {}", request.zone_name, e);
918            metrics::record_zone_operation("create", false);
919
920            // Check if zone already exists
921            let error_msg = e.to_string();
922            if error_msg.contains("already exists") {
923                ApiError::ZoneAlreadyExists(request.zone_name.clone())
924            } else {
925                ApiError::RndcError(error_msg)
926            }
927        })?;
928
929    info!("Zone {} created successfully", request.zone_name);
930    metrics::record_zone_operation("create", true);
931
932    Ok((
933        StatusCode::CREATED,
934        Json(ZoneResponse {
935            success: true,
936            message: format!("Zone {} created successfully", request.zone_name),
937            details: Some(output),
938        }),
939    ))
940}
941
942/// Delete a zone
943#[utoipa::path(
944    delete,
945    path = "/api/v1/zones/{name}",
946    params(
947        ("name" = String, Path, description = "Zone name to delete")
948    ),
949    responses(
950        (status = 200, description = "Zone deleted successfully", body = ZoneResponse),
951        (status = 500, description = "RNDC command failed")
952    ),
953    tag = "zones"
954)]
955pub async fn delete_zone(
956    State(state): State<AppState>,
957    Path(zone_name): Path<String>,
958) -> Result<Json<ZoneResponse>, ApiError> {
959    info!("Deleting zone: {}", zone_name);
960
961    // Validate zone name before it reaches rndc delzone or any filesystem path
962    // (prevents path traversal deleting arbitrary *.zone files).
963    if let Err(e) = validate_zone_name(&zone_name) {
964        metrics::record_zone_operation("delete", false);
965        return Err(e);
966    }
967
968    // Execute rndc delzone
969    let output = state.rndc.delzone(&zone_name).await.map_err(|e| {
970        error!("RNDC delzone failed for {}: {}", zone_name, e);
971        metrics::record_zone_operation("delete", false);
972        ApiError::RndcError(e.to_string())
973    })?;
974
975    // Delete zone file
976    let zone_file_name = format!("{}.zone", zone_name);
977    let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
978
979    if zone_file_path.exists() {
980        if let Err(e) = tokio::fs::remove_file(&zone_file_path).await {
981            error!(
982                "Failed to delete zone file {}: {}",
983                zone_file_path.display(),
984                e
985            );
986            // Don't fail the request if file deletion fails - zone is already removed from BIND9
987        } else {
988            info!("Deleted zone file: {}", zone_file_path.display());
989        }
990    }
991
992    // Delete journal file if it exists
993    let journal_file_name = format!("{}.zone.jnl", zone_name);
994    let journal_file_path = PathBuf::from(&state.zone_dir).join(&journal_file_name);
995
996    if journal_file_path.exists() {
997        if let Err(e) = tokio::fs::remove_file(&journal_file_path).await {
998            error!(
999                "Failed to delete journal file {}: {}",
1000                journal_file_path.display(),
1001                e
1002            );
1003            // Don't fail the request if file deletion fails
1004        } else {
1005            info!("Deleted journal file: {}", journal_file_path.display());
1006        }
1007    }
1008
1009    info!("Zone {} deleted successfully", zone_name);
1010    metrics::record_zone_operation("delete", true);
1011
1012    Ok(Json(ZoneResponse {
1013        success: true,
1014        message: format!("Zone {} deleted successfully", zone_name),
1015        details: Some(output),
1016    }))
1017}
1018
1019/// Reload a zone
1020#[utoipa::path(
1021    post,
1022    path = "/api/v1/zones/{name}/reload",
1023    params(
1024        ("name" = String, Path, description = "Zone name to reload")
1025    ),
1026    responses(
1027        (status = 200, description = "Zone reloaded successfully", body = ZoneResponse),
1028        (status = 500, description = "RNDC command failed")
1029    ),
1030    tag = "zones"
1031)]
1032pub async fn reload_zone(
1033    State(state): State<AppState>,
1034    Path(zone_name): Path<String>,
1035) -> Result<Json<ZoneResponse>, ApiError> {
1036    info!("Reloading zone: {}", zone_name);
1037
1038    // Validate the caller-supplied zone name before it reaches rndc (defense
1039    // against rndc command / path injection via the {name} path parameter).
1040    if let Err(e) = validate_zone_name(&zone_name) {
1041        metrics::record_zone_operation("reload", false);
1042        return Err(e);
1043    }
1044
1045    let output = state.rndc.reload(&zone_name).await.map_err(|e| {
1046        error!("RNDC reload failed for {}: {}", zone_name, e);
1047        metrics::record_zone_operation("reload", false);
1048        ApiError::RndcError(e.to_string())
1049    })?;
1050
1051    info!("Zone {} reloaded successfully", zone_name);
1052    metrics::record_zone_operation("reload", true);
1053
1054    Ok(Json(ZoneResponse {
1055        success: true,
1056        message: format!("Zone {} reloaded successfully", zone_name),
1057        details: Some(output),
1058    }))
1059}
1060
1061/// Get zone status
1062#[utoipa::path(
1063    get,
1064    path = "/api/v1/zones/{name}/status",
1065    params(
1066        ("name" = String, Path, description = "Zone name")
1067    ),
1068    responses(
1069        (status = 200, description = "Zone status retrieved", body = ZoneResponse),
1070        (status = 404, description = "Zone not found"),
1071        (status = 500, description = "RNDC command failed")
1072    ),
1073    tag = "zones"
1074)]
1075pub async fn zone_status(
1076    State(state): State<AppState>,
1077    Path(zone_name): Path<String>,
1078) -> Result<Json<ZoneResponse>, ApiError> {
1079    info!("Getting status for zone: {}", zone_name);
1080
1081    // Validate the caller-supplied zone name before it reaches rndc (defense
1082    // against rndc command / path injection via the {name} path parameter).
1083    validate_zone_name(&zone_name)?;
1084
1085    let output = state.rndc.zonestatus(&zone_name).await.map_err(|e| {
1086        error!("RNDC zonestatus failed for {}: {}", zone_name, e);
1087        if e.to_string().contains("not found") {
1088            ApiError::ZoneNotFound(zone_name.clone())
1089        } else {
1090            ApiError::RndcError(e.to_string())
1091        }
1092    })?;
1093
1094    Ok(Json(ZoneResponse {
1095        success: true,
1096        message: format!("Zone {} status retrieved", zone_name),
1097        details: Some(output),
1098    }))
1099}
1100
1101/// Freeze a zone (disable dynamic updates)
1102#[utoipa::path(
1103    post,
1104    path = "/api/v1/zones/{name}/freeze",
1105    params(
1106        ("name" = String, Path, description = "Zone name to freeze")
1107    ),
1108    responses(
1109        (status = 200, description = "Zone frozen successfully", body = ZoneResponse),
1110        (status = 500, description = "RNDC command failed")
1111    ),
1112    tag = "zones"
1113)]
1114pub async fn freeze_zone(
1115    State(state): State<AppState>,
1116    Path(zone_name): Path<String>,
1117) -> Result<Json<ZoneResponse>, ApiError> {
1118    info!("Freezing zone: {}", zone_name);
1119
1120    // Validate the caller-supplied zone name before it reaches rndc (defense
1121    // against rndc command / path injection via the {name} path parameter).
1122    if let Err(e) = validate_zone_name(&zone_name) {
1123        metrics::record_zone_operation("freeze", false);
1124        return Err(e);
1125    }
1126
1127    let output = state.rndc.freeze(&zone_name).await.map_err(|e| {
1128        error!("RNDC freeze failed for {}: {}", zone_name, e);
1129        metrics::record_zone_operation("freeze", false);
1130        ApiError::RndcError(e.to_string())
1131    })?;
1132
1133    info!("Zone {} frozen successfully", zone_name);
1134    metrics::record_zone_operation("freeze", true);
1135
1136    Ok(Json(ZoneResponse {
1137        success: true,
1138        message: format!("Zone {} frozen successfully", zone_name),
1139        details: Some(output),
1140    }))
1141}
1142
1143/// Thaw a zone (enable dynamic updates)
1144#[utoipa::path(
1145    post,
1146    path = "/api/v1/zones/{name}/thaw",
1147    params(
1148        ("name" = String, Path, description = "Zone name to thaw")
1149    ),
1150    responses(
1151        (status = 200, description = "Zone thawed successfully", body = ZoneResponse),
1152        (status = 500, description = "RNDC command failed")
1153    ),
1154    tag = "zones"
1155)]
1156pub async fn thaw_zone(
1157    State(state): State<AppState>,
1158    Path(zone_name): Path<String>,
1159) -> Result<Json<ZoneResponse>, ApiError> {
1160    info!("Thawing zone: {}", zone_name);
1161
1162    // Validate the caller-supplied zone name before it reaches rndc (defense
1163    // against rndc command / path injection via the {name} path parameter).
1164    if let Err(e) = validate_zone_name(&zone_name) {
1165        metrics::record_zone_operation("thaw", false);
1166        return Err(e);
1167    }
1168
1169    let output = state.rndc.thaw(&zone_name).await.map_err(|e| {
1170        error!("RNDC thaw failed for {}: {}", zone_name, e);
1171        metrics::record_zone_operation("thaw", false);
1172        ApiError::RndcError(e.to_string())
1173    })?;
1174
1175    info!("Zone {} thawed successfully", zone_name);
1176    metrics::record_zone_operation("thaw", true);
1177
1178    Ok(Json(ZoneResponse {
1179        success: true,
1180        message: format!("Zone {} thawed successfully", zone_name),
1181        details: Some(output),
1182    }))
1183}
1184
1185/// Notify secondaries about zone changes
1186#[utoipa::path(
1187    post,
1188    path = "/api/v1/zones/{name}/notify",
1189    params(
1190        ("name" = String, Path, description = "Zone name")
1191    ),
1192    responses(
1193        (status = 200, description = "Notify sent successfully", body = ZoneResponse),
1194        (status = 500, description = "RNDC command failed")
1195    ),
1196    tag = "zones"
1197)]
1198pub async fn notify_zone(
1199    State(state): State<AppState>,
1200    Path(zone_name): Path<String>,
1201) -> Result<Json<ZoneResponse>, ApiError> {
1202    info!("Notifying secondaries for zone: {}", zone_name);
1203
1204    // Validate the caller-supplied zone name before it reaches rndc (defense
1205    // against rndc command / path injection via the {name} path parameter).
1206    if let Err(e) = validate_zone_name(&zone_name) {
1207        metrics::record_zone_operation("notify", false);
1208        return Err(e);
1209    }
1210
1211    let output = state.rndc.notify(&zone_name).await.map_err(|e| {
1212        error!("RNDC notify failed for {}: {}", zone_name, e);
1213        metrics::record_zone_operation("notify", false);
1214        ApiError::RndcError(e.to_string())
1215    })?;
1216
1217    info!("Zone {} notify sent successfully", zone_name);
1218    metrics::record_zone_operation("notify", true);
1219
1220    Ok(Json(ZoneResponse {
1221        success: true,
1222        message: format!("Notify sent for zone {}", zone_name),
1223        details: Some(output),
1224    }))
1225}
1226
1227/// Force a zone retransfer from primary
1228#[utoipa::path(
1229    post,
1230    path = "/api/v1/zones/{name}/retransfer",
1231    params(
1232        ("name" = String, Path, description = "Zone name to retransfer")
1233    ),
1234    responses(
1235        (status = 200, description = "Zone retransfer initiated", body = ZoneResponse),
1236        (status = 500, description = "RNDC command failed")
1237    ),
1238    tag = "zones"
1239)]
1240pub async fn retransfer_zone(
1241    State(state): State<AppState>,
1242    Path(zone_name): Path<String>,
1243) -> Result<Json<ZoneResponse>, ApiError> {
1244    info!("Retransferring zone: {}", zone_name);
1245
1246    // Validate the caller-supplied zone name before it reaches rndc (defense
1247    // against rndc command / path injection via the {name} path parameter).
1248    if let Err(e) = validate_zone_name(&zone_name) {
1249        metrics::record_zone_operation("retransfer", false);
1250        return Err(e);
1251    }
1252
1253    let output = state.rndc.retransfer(&zone_name).await.map_err(|e| {
1254        error!("RNDC retransfer failed for {}: {}", zone_name, e);
1255        metrics::record_zone_operation("retransfer", false);
1256        ApiError::RndcError(e.to_string())
1257    })?;
1258
1259    info!("Zone {} retransfer initiated successfully", zone_name);
1260    metrics::record_zone_operation("retransfer", true);
1261
1262    Ok(Json(ZoneResponse {
1263        success: true,
1264        message: format!("Retransfer initiated for zone {}", zone_name),
1265        details: Some(output),
1266    }))
1267}
1268
1269/// Get server status
1270#[utoipa::path(
1271    get,
1272    path = "/api/v1/server/status",
1273    responses(
1274        (status = 200, description = "Server status retrieved", body = ServerStatusResponse),
1275        (status = 500, description = "RNDC command failed")
1276    ),
1277    tag = "server"
1278)]
1279pub async fn server_status(
1280    State(state): State<AppState>,
1281) -> Result<Json<ServerStatusResponse>, ApiError> {
1282    info!("Getting server status");
1283
1284    let output = state.rndc.status().await.map_err(|e| {
1285        error!("RNDC status failed: {}", e);
1286        ApiError::RndcError(e.to_string())
1287    })?;
1288
1289    Ok(Json(ServerStatusResponse { status: output }))
1290}
1291
1292/// List all zones
1293#[utoipa::path(
1294    get,
1295    path = "/api/v1/zones",
1296    responses(
1297        (status = 200, description = "List of zones", body = ZoneListResponse),
1298        (status = 500, description = "Failed to read zone directory")
1299    ),
1300    tag = "zones"
1301)]
1302pub async fn list_zones(State(state): State<AppState>) -> Result<Json<ZoneListResponse>, ApiError> {
1303    info!("Listing all zones");
1304
1305    // Get zone files from directory
1306    let mut zones = Vec::new();
1307    let mut entries = tokio::fs::read_dir(&state.zone_dir).await.map_err(|e| {
1308        error!("Failed to read zone directory: {}", e);
1309        ApiError::InternalError(format!("Failed to read zone directory: {}", e))
1310    })?;
1311
1312    while let Ok(Some(entry)) = entries.next_entry().await {
1313        if let Ok(file_name) = entry.file_name().into_string() {
1314            if file_name.ends_with(".zone") {
1315                // Extract zone name from filename (remove .zone extension)
1316                if let Some(zone_name) = file_name.strip_suffix(".zone") {
1317                    zones.push(zone_name.to_string());
1318                }
1319            }
1320        }
1321    }
1322
1323    zones.sort();
1324    let count = zones.len();
1325
1326    info!("Found {} zones", count);
1327    metrics::update_zones_count(count as i64);
1328
1329    Ok(Json(ZoneListResponse { zones, count }))
1330}
1331
1332/// Get a specific zone
1333#[utoipa::path(
1334    get,
1335    path = "/api/v1/zones/{name}",
1336    params(
1337        ("name" = String, Path, description = "Zone name")
1338    ),
1339    responses(
1340        (status = 200, description = "Zone information", body = ZoneInfo),
1341        (status = 404, description = "Zone not found"),
1342        (status = 500, description = "RNDC command failed")
1343    ),
1344    tag = "zones"
1345)]
1346pub async fn get_zone(
1347    State(state): State<AppState>,
1348    Path(zone_name): Path<String>,
1349) -> Result<Json<ZoneInfo>, ApiError> {
1350    info!("Getting zone: {}", zone_name);
1351
1352    // Validate the caller-supplied zone name before it is joined into a
1353    // filesystem path (prevents path traversal: a name like "../../etc/passwd"
1354    // would otherwise turn into an arbitrary-file existence oracle) or forwarded
1355    // to rndc.
1356    validate_zone_name(&zone_name)?;
1357
1358    // Check if zone file exists
1359    let zone_file_name = format!("{}.zone", zone_name);
1360    let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
1361
1362    if !zone_file_path.exists() {
1363        return Err(ApiError::ZoneNotFound(zone_name.clone()));
1364    }
1365
1366    // Get zone status from BIND9
1367    let status_output = state.rndc.zonestatus(&zone_name).await.map_err(|e| {
1368        error!("RNDC zonestatus failed for {}: {}", zone_name, e);
1369        if e.to_string().contains("not found") {
1370            ApiError::ZoneNotFound(zone_name.clone())
1371        } else {
1372            ApiError::RndcError(e.to_string())
1373        }
1374    })?;
1375
1376    // Parse zone type and serial from status output
1377    let mut zone_type = "unknown".to_string();
1378    let mut serial = None;
1379
1380    for line in status_output.lines() {
1381        if let Some(type_str) = line.strip_prefix("type:").or_else(|| {
1382            line.contains("type:")
1383                .then(|| line.split("type:").nth(1))
1384                .flatten()
1385        }) {
1386            zone_type = type_str.trim().to_string();
1387        }
1388
1389        if let Some(serial_str) = line.strip_prefix("serial:").or_else(|| {
1390            line.contains("serial:")
1391                .then(|| line.split("serial:").nth(1))
1392                .flatten()
1393        }) {
1394            if let Ok(s) = serial_str.trim().parse::<u32>() {
1395                serial = Some(s);
1396            }
1397        }
1398    }
1399
1400    Ok(Json(ZoneInfo {
1401        name: zone_name,
1402        zone_type,
1403        serial,
1404        file_path: Some(zone_file_path.display().to_string()),
1405    }))
1406}
1407
1408/// Modify a zone configuration
1409///
1410/// This endpoint allows updating zone configuration parameters such as
1411/// also-notify and allow-transfer IP addresses without recreating the zone.
1412/// It uses the `rndc modzone` command to dynamically update the zone configuration.
1413#[utoipa::path(
1414    patch,
1415    path = "/api/v1/zones/{name}",
1416    request_body = ModifyZoneRequest,
1417    params(
1418        ("name" = String, Path, description = "Zone name to modify")
1419    ),
1420    responses(
1421        (status = 200, description = "Zone modified successfully", body = ZoneResponse),
1422        (status = 400, description = "Invalid request"),
1423        (status = 404, description = "Zone not found"),
1424        (status = 500, description = "RNDC command failed"),
1425        (status = 500, description = "Internal server error")
1426    ),
1427    tag = "zones"
1428)]
1429pub async fn modify_zone(
1430    State(state): State<AppState>,
1431    Path(zone_name): Path<String>,
1432    Json(request): Json<ModifyZoneRequest>,
1433) -> Result<Json<ZoneResponse>, ApiError> {
1434    info!("Modifying zone: {}", zone_name);
1435
1436    // Validate the caller-supplied zone name before it is joined into a
1437    // filesystem path or forwarded to rndc modzone/zonestatus.
1438    if let Err(e) = validate_zone_name(&zone_name) {
1439        metrics::record_zone_operation("modify", false);
1440        return Err(e);
1441    }
1442
1443    // Debug log the full request payload
1444    if let Ok(json_payload) = serde_json::to_string_pretty(&request) {
1445        debug!(
1446            "PATCH /api/v1/zones/{} payload: {}",
1447            zone_name, json_payload
1448        );
1449    }
1450
1451    // Validate that at least one field is being updated
1452    if request.also_notify.is_none()
1453        && request.allow_transfer.is_none()
1454        && request.allow_update.is_none()
1455    {
1456        metrics::record_zone_operation("modify", false);
1457        return Err(ApiError::InvalidRequest(
1458            "At least one field (alsoNotify, allowTransfer, or allowUpdate) must be provided"
1459                .to_string(),
1460        ));
1461    }
1462
1463    // Check if zone exists by checking for zone file or querying status
1464    let zone_file_name = format!("{}.zone", zone_name);
1465    let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
1466
1467    // For secondary zones, there may not be a zone file, so we should check status instead
1468    let zone_exists = if zone_file_path.exists() {
1469        true
1470    } else {
1471        // Try to get zone status to see if it exists
1472        match state.rndc.zonestatus(&zone_name).await {
1473            Ok(_) => true,
1474            Err(e) => {
1475                if e.to_string().contains("not found") {
1476                    false
1477                } else {
1478                    // Some other error, but zone might exist
1479                    true
1480                }
1481            }
1482        }
1483    };
1484
1485    if !zone_exists {
1486        metrics::record_zone_operation("modify", false);
1487        return Err(ApiError::ZoneNotFound(zone_name.clone()));
1488    }
1489
1490    // Get current zone configuration from BIND9
1491    let showzone_output = state.rndc.showzone(&zone_name).await.map_err(|e| {
1492        error!("Failed to get zone configuration for {}: {}", zone_name, e);
1493        if e.to_string().contains("not found") {
1494            ApiError::ZoneNotFound(zone_name.clone())
1495        } else {
1496            ApiError::RndcError(e.to_string())
1497        }
1498    })?;
1499
1500    // Parse the zone configuration
1501    let mut zone_config = crate::rndc_parser::parse_showzone(&showzone_output).map_err(|e| {
1502        error!(
1503            "Failed to parse zone configuration for {}: {}",
1504            zone_name, e
1505        );
1506        ApiError::RndcError(format!("Failed to parse zone configuration: {}", e))
1507    })?;
1508
1509    info!(
1510        "Zone {} has type: {}",
1511        zone_name,
1512        zone_config.zone_type.as_str()
1513    );
1514
1515    // Update the configuration with new values from the request
1516    if let Some(also_notify) = &request.also_notify {
1517        // Convert string IPs to IpAddr
1518        let ip_addrs: Result<Vec<std::net::IpAddr>, _> =
1519            also_notify.iter().map(|s| s.parse()).collect();
1520
1521        match ip_addrs {
1522            Ok(addrs) => {
1523                zone_config.also_notify = if addrs.is_empty() { None } else { Some(addrs) };
1524            }
1525            Err(e) => {
1526                metrics::record_zone_operation("modify", false);
1527                return Err(ApiError::InvalidRequest(format!(
1528                    "Invalid IP address in also-notify: {}",
1529                    e
1530                )));
1531            }
1532        }
1533    }
1534
1535    if let Some(allow_transfer) = &request.allow_transfer {
1536        // Convert string IPs to IpAddr
1537        let ip_addrs: Result<Vec<std::net::IpAddr>, _> =
1538            allow_transfer.iter().map(|s| s.parse()).collect();
1539
1540        match ip_addrs {
1541            Ok(addrs) => {
1542                zone_config.allow_transfer = if addrs.is_empty() { None } else { Some(addrs) };
1543            }
1544            Err(e) => {
1545                metrics::record_zone_operation("modify", false);
1546                return Err(ApiError::InvalidRequest(format!(
1547                    "Invalid IP address in allow-transfer: {}",
1548                    e
1549                )));
1550            }
1551        }
1552    }
1553
1554    if let Some(allow_update) = &request.allow_update {
1555        // Convert string IPs to IpAddr
1556        let ip_addrs: Result<Vec<std::net::IpAddr>, _> =
1557            allow_update.iter().map(|s| s.parse()).collect();
1558
1559        match ip_addrs {
1560            Ok(addrs) => {
1561                zone_config.allow_update = if addrs.is_empty() { None } else { Some(addrs) };
1562                // Clear the raw directive since we're setting explicit IPs
1563                zone_config.allow_update_raw = None;
1564            }
1565            Err(e) => {
1566                metrics::record_zone_operation("modify", false);
1567                return Err(ApiError::InvalidRequest(format!(
1568                    "Invalid IP address in allow-update: {}",
1569                    e
1570                )));
1571            }
1572        }
1573    }
1574
1575    // Serialize the updated configuration back to RNDC format
1576    let rndc_config_block = zone_config.to_rndc_block();
1577
1578    info!(
1579        "Modifying zone {} with config: {}",
1580        zone_name, rndc_config_block
1581    );
1582
1583    // Execute rndc modzone
1584    let output = state
1585        .rndc
1586        .modzone(&zone_name, &rndc_config_block)
1587        .await
1588        .map_err(|e| {
1589            error!("RNDC modzone failed for {}: {}", zone_name, e);
1590            metrics::record_zone_operation("modify", false);
1591            ApiError::RndcError(e.to_string())
1592        })?;
1593
1594    info!("Zone {} modified successfully", zone_name);
1595    metrics::record_zone_operation("modify", true);
1596
1597    Ok(Json(ZoneResponse {
1598        success: true,
1599        message: format!("Zone {} modified successfully", zone_name),
1600        details: Some(output),
1601    }))
1602}