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