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/// SOA (Start of Authority) record configuration
34///
35/// # Default Values
36///
37/// - `serial`: Automatically generated in YYYYMMDD01 format (e.g., 2025120601) if not provided
38/// - `refresh`: 3600 seconds
39/// - `retry`: 600 seconds
40/// - `expire`: 604800 seconds (7 days)
41/// - `negative_ttl`: 86400 seconds (1 day)
42#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
43#[serde(rename_all = "camelCase")]
44pub struct SoaRecord {
45    /// Primary nameserver (e.g., "ns1.example.com.")
46    pub primary_ns: String,
47
48    /// Admin email (e.g., "admin.example.com.")
49    pub admin_email: String,
50
51    /// Serial number (e.g., 2025120601) - defaults to current date in YYYYMMDD01 format
52    #[serde(default = "default_serial")]
53    pub serial: u32,
54
55    /// Refresh interval in seconds (default: 3600)
56    #[serde(default = "default_refresh")]
57    pub refresh: u32,
58
59    /// Retry interval in seconds (default: 600)
60    #[serde(default = "default_retry")]
61    pub retry: u32,
62
63    /// Expire time in seconds (default: 604800)
64    #[serde(default = "default_expire")]
65    pub expire: u32,
66
67    /// Negative TTL in seconds (default: 86400)
68    #[serde(default = "default_negative_ttl")]
69    pub negative_ttl: u32,
70}
71
72fn default_serial() -> u32 {
73    // Generate serial as YYYYMMDD01
74    let now = chrono::Utc::now();
75    let date_part = now.format("%Y%m%d").to_string();
76    format!("{}01", date_part).parse().unwrap_or(2025120601)
77}
78
79fn default_refresh() -> u32 {
80    3600
81}
82
83fn default_retry() -> u32 {
84    600
85}
86
87fn default_expire() -> u32 {
88    604_800
89}
90
91fn default_negative_ttl() -> u32 {
92    86400
93}
94
95/// DNS record entry
96#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
97#[serde(rename_all = "camelCase")]
98pub struct DnsRecord {
99    /// Record name (e.g., "www", "@")
100    pub name: String,
101
102    /// Record type (e.g., "A", "AAAA", "CNAME", "MX", "TXT")
103    #[serde(rename = "type")]
104    pub record_type: String,
105
106    /// Record value (e.g., "192.0.2.1", "example.com.")
107    pub value: String,
108
109    /// Optional TTL (uses zone default if not specified)
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub ttl: Option<u32>,
112
113    /// Optional priority (for MX, SRV records)
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub priority: Option<u16>,
116}
117
118/// Structured zone configuration
119#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
120#[serde(rename_all = "camelCase")]
121pub struct ZoneConfig {
122    /// Default TTL for the zone (e.g., 3600)
123    pub ttl: u32,
124
125    /// SOA record
126    pub soa: SoaRecord,
127
128    /// Name servers for the zone
129    pub name_servers: Vec<String>,
130
131    /// A records for nameservers (glue records)
132    /// Maps nameserver hostname to IP address (e.g., "ns1.example.com." -> "192.0.2.1")
133    pub name_server_ips: std::collections::HashMap<String, String>,
134
135    /// DNS records in the zone
136    #[serde(default)]
137    pub records: Vec<DnsRecord>,
138
139    /// IP addresses of secondary servers to notify when zone changes (BIND9 also-notify)
140    /// Example: ["10.244.2.101", "10.244.2.102"]
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub also_notify: Option<Vec<String>>,
143
144    /// IP addresses allowed to transfer the zone (BIND9 allow-transfer)
145    /// Example: ["10.244.2.101", "10.244.2.102"]
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub allow_transfer: Option<Vec<String>>,
148
149    /// IP addresses of primary servers for secondary zones (BIND9 primaries/masters)
150    /// Example: ["192.0.2.1", "192.0.2.2"]
151    /// Required for secondary zone types
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub primaries: Option<Vec<String>>,
154
155    /// DNSSEC policy name to apply to this zone (BIND9 9.16+)
156    ///
157    /// Specifies the name of a `dnssec-policy` block defined in `named.conf.options`.
158    /// When set, BIND9 will automatically sign the zone using the specified policy.
159    ///
160    /// Example: `"default"`, `"high-security"`
161    ///
162    /// Requires `inline_signing` to be enabled for DNSSEC to function.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub dnssec_policy: Option<String>,
165
166    /// Enable inline signing for DNSSEC (BIND9 inline-signing)
167    ///
168    /// When `true`, BIND9 will sign the zone inline rather than requiring pre-signed zone files.
169    /// This is required for DNSSEC with dynamic zones and modern BIND9 configurations.
170    ///
171    /// Should be set to `true` when `dnssec_policy` is specified.
172    ///
173    /// Default: `false`
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub inline_signing: Option<bool>,
176}
177
178impl ZoneConfig {
179    /// Generate BIND9 zone file content from structured configuration
180    pub fn to_zone_file(&self) -> String {
181        let mut zone_file = String::new();
182
183        // TTL directive
184        zone_file.push_str(&format!("$TTL {}\n\n", self.ttl));
185
186        // SOA record
187        zone_file.push_str(&format!(
188            "@ IN SOA {} {} (\n",
189            self.soa.primary_ns, self.soa.admin_email
190        ));
191        zone_file.push_str(&format!("    {}  ; Serial\n", self.soa.serial));
192        zone_file.push_str(&format!("    {}  ; Refresh\n", self.soa.refresh));
193        zone_file.push_str(&format!("    {}  ; Retry\n", self.soa.retry));
194        zone_file.push_str(&format!("    {}  ; Expire\n", self.soa.expire));
195        zone_file.push_str(&format!(
196            "    {} ); Negative TTL\n\n",
197            self.soa.negative_ttl
198        ));
199
200        // Name servers
201        for ns in &self.name_servers {
202            zone_file.push_str(&format!("@ IN NS {}\n", ns));
203        }
204
205        if !self.name_servers.is_empty() {
206            zone_file.push('\n');
207        }
208
209        // Glue records (A records for nameservers)
210        for (ns_name, ip) in &self.name_server_ips {
211            // Use FQDN with trailing dot to prevent BIND9 from appending zone name
212            zone_file.push_str(&format!("{} IN A {}\n", ns_name, ip));
213        }
214        if !self.name_server_ips.is_empty() {
215            zone_file.push('\n');
216        }
217
218        // DNS records
219        for record in &self.records {
220            let ttl_str = if let Some(ttl) = record.ttl {
221                format!("{} ", ttl)
222            } else {
223                String::new()
224            };
225
226            let priority_str = if let Some(priority) = record.priority {
227                format!("{} ", priority)
228            } else {
229                String::new()
230            };
231
232            zone_file.push_str(&format!(
233                "{} {}IN {} {}{}\n",
234                record.name, ttl_str, record.record_type, priority_str, record.value
235            ));
236        }
237
238        zone_file
239    }
240}
241
242/// Request to create a new zone
243#[derive(Debug, Serialize, Deserialize, ToSchema)]
244#[serde(rename_all = "camelCase")]
245pub struct CreateZoneRequest {
246    /// Zone name (e.g., "example.com")
247    pub zone_name: String,
248
249    /// Zone type ("primary" or "secondary")
250    pub zone_type: String,
251
252    /// Structured zone configuration
253    pub zone_config: ZoneConfig,
254
255    /// Optional: TSIG key name for allow-update
256    pub update_key_name: Option<String>,
257}
258
259/// Request to modify a zone configuration
260#[derive(Debug, Serialize, Deserialize, ToSchema)]
261#[serde(rename_all = "camelCase")]
262pub struct ModifyZoneRequest {
263    /// IP addresses of secondary servers to notify when zone changes (BIND9 also-notify)
264    /// Example: ["10.244.2.101", "10.244.2.102"]
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub also_notify: Option<Vec<String>>,
267
268    /// IP addresses allowed to transfer the zone (BIND9 allow-transfer)
269    /// Example: ["10.244.2.101", "10.244.2.102"]
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub allow_transfer: Option<Vec<String>>,
272
273    /// IP addresses allowed to update the zone dynamically (BIND9 allow-update)
274    /// Example: ["10.244.2.101", "10.244.2.102"]
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub allow_update: Option<Vec<String>>,
277}
278
279/// Response from zone operations
280#[derive(Debug, Serialize, Deserialize, ToSchema)]
281pub struct ZoneResponse {
282    pub success: bool,
283    pub message: String,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub details: Option<String>,
286}
287
288/// Server status response
289#[derive(Debug, Serialize, Deserialize, ToSchema)]
290pub struct ServerStatusResponse {
291    pub status: String,
292}
293
294/// Zone information
295#[derive(Debug, Serialize, Deserialize, ToSchema)]
296#[serde(rename_all = "camelCase")]
297pub struct ZoneInfo {
298    pub name: String,
299    pub zone_type: String,
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub serial: Option<u32>,
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub file_path: Option<String>,
304}
305
306/// List of zones response
307#[derive(Debug, Serialize, Deserialize, ToSchema)]
308pub struct ZoneListResponse {
309    pub zones: Vec<String>,
310    pub count: usize,
311}
312
313/// Create a new zone
314///
315/// This endpoint:
316/// 1. Generates zone file from structured configuration
317/// 2. Writes the zone file to disk
318/// 3. Executes `rndc addzone` to add the zone to BIND9
319#[utoipa::path(
320    post,
321    path = "/api/v1/zones",
322    request_body = CreateZoneRequest,
323    responses(
324        (status = 201, description = "Zone created successfully", body = ZoneResponse),
325        (status = 400, description = "Invalid request"),
326        (status = 409, description = "Zone already exists"),
327        (status = 500, description = "RNDC command failed"),
328        (status = 500, description = "Internal server error")
329    ),
330    tag = "zones"
331)]
332pub async fn create_zone(
333    State(state): State<AppState>,
334    Json(request): Json<CreateZoneRequest>,
335) -> Result<(StatusCode, Json<ZoneResponse>), ApiError> {
336    info!("Creating zone: {}", request.zone_name);
337
338    // Debug log the full request payload
339    if let Ok(json_payload) = serde_json::to_string_pretty(&request) {
340        debug!("POST /api/v1/zones payload: {}", json_payload);
341    }
342
343    // Validate zone name
344    if request.zone_name.is_empty() {
345        metrics::record_zone_operation("create", false);
346        return Err(ApiError::InvalidRequest(
347            "Zone name cannot be empty".to_string(),
348        ));
349    }
350
351    // Validate zone type
352    if request.zone_type != ZONE_TYPE_PRIMARY && request.zone_type != ZONE_TYPE_SECONDARY {
353        metrics::record_zone_operation("create", false);
354        return Err(ApiError::InvalidRequest(format!(
355            "Invalid zone type: {}. Must be '{}' or '{}'",
356            request.zone_type, ZONE_TYPE_PRIMARY, ZONE_TYPE_SECONDARY
357        )));
358    }
359
360    // Validate secondary zone requirements
361    if request.zone_type == ZONE_TYPE_SECONDARY
362        && request
363            .zone_config
364            .primaries
365            .as_ref()
366            .map_or(true, |p| p.is_empty())
367    {
368        metrics::record_zone_operation("create", false);
369        return Err(ApiError::InvalidRequest(
370            "Secondary zones require at least one primary server in 'primaries' field".to_string(),
371        ));
372    }
373
374    // Generate zone file content from structured configuration (only for primary zones)
375    let zone_content = if request.zone_type == ZONE_TYPE_PRIMARY {
376        request.zone_config.to_zone_file()
377    } else {
378        String::new() // Secondary zones don't need zone files
379    };
380
381    // Only write zone file for primary zones
382    let zone_file_name = format!("{}.zone", request.zone_name);
383    let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
384
385    if request.zone_type == ZONE_TYPE_PRIMARY {
386        info!(
387            "Generated zone file content for {}: {} bytes",
388            request.zone_name,
389            zone_content.len()
390        );
391
392        // Clean up any existing journal file to prevent sync issues
393        let journal_file_name = format!("{}.zone.jnl", request.zone_name);
394        let journal_file_path = PathBuf::from(&state.zone_dir).join(&journal_file_name);
395        if journal_file_path.exists() {
396            if let Err(e) = tokio::fs::remove_file(&journal_file_path).await {
397                error!(
398                    "Failed to remove old journal file {}: {}",
399                    journal_file_path.display(),
400                    e
401                );
402            } else {
403                info!("Removed old journal file: {}", journal_file_path.display());
404            }
405        }
406
407        tokio::fs::write(&zone_file_path, &zone_content)
408            .await
409            .map_err(|e| {
410                error!(
411                    "Failed to write zone file {}: {}",
412                    zone_file_path.display(),
413                    e
414                );
415                metrics::record_zone_operation("create", false);
416                ApiError::ZoneFileError(format!("Failed to write zone file: {}", e))
417            })?;
418
419        info!("Wrote zone file: {}", zone_file_path.display());
420    }
421
422    // Build zone configuration for rndc addzone
423    let mut config_parts = vec![format!(r#"type {}"#, request.zone_type)];
424
425    // Add file path for primary zones
426    if request.zone_type == ZONE_TYPE_PRIMARY {
427        let zone_file_full_path = format!("{}/{}", state.zone_dir, zone_file_name);
428        config_parts.push(format!(r#"file "{}""#, zone_file_full_path));
429    }
430
431    // Add primaries for secondary zones
432    if request.zone_type == ZONE_TYPE_SECONDARY {
433        if let Some(primaries) = &request.zone_config.primaries {
434            if !primaries.is_empty() {
435                let primaries_list = primaries
436                    .iter()
437                    .map(|ip| format!("{}; ", ip))
438                    .collect::<String>();
439                config_parts.push(format!(r#"primaries {{ {} }}"#, primaries_list));
440            }
441        }
442    }
443
444    // Add allow-update if TSIG key is provided
445    if let Some(key_name) = &request.update_key_name {
446        config_parts.push(format!(r#"allow-update {{ key "{}"; }}"#, key_name));
447    }
448
449    // Add also-notify if secondary IPs are provided
450    if let Some(also_notify) = &request.zone_config.also_notify {
451        if !also_notify.is_empty() {
452            let notify_list = also_notify
453                .iter()
454                .map(|ip| format!("{}; ", ip))
455                .collect::<String>();
456            config_parts.push(format!(r#"also-notify {{ {} }}"#, notify_list));
457        }
458    }
459
460    // Add allow-transfer if secondary IPs are provided
461    if let Some(allow_transfer) = &request.zone_config.allow_transfer {
462        if !allow_transfer.is_empty() {
463            let transfer_list = allow_transfer
464                .iter()
465                .map(|ip| format!("{}; ", ip))
466                .collect::<String>();
467            config_parts.push(format!(r#"allow-transfer {{ {} }}"#, transfer_list));
468        }
469    }
470
471    // Add DNSSEC policy if specified (BIND9 9.16+)
472    if let Some(dnssec_policy) = &request.zone_config.dnssec_policy {
473        config_parts.push(format!(r#"dnssec-policy "{}""#, dnssec_policy));
474    }
475
476    // Add inline-signing if specified (required for DNSSEC with dynamic zones)
477    if let Some(inline_signing) = request.zone_config.inline_signing {
478        config_parts.push(format!(
479            r#"inline-signing {}"#,
480            if inline_signing { "yes" } else { "no" }
481        ));
482    }
483
484    // Join all parts into final configuration
485    let zone_config = format!("{{ {}; }};", config_parts.join("; "));
486
487    // Execute rndc addzone
488    let output = state
489        .rndc
490        .addzone(&request.zone_name, &zone_config)
491        .await
492        .map_err(|e| {
493            error!("RNDC addzone failed for {}: {}", request.zone_name, e);
494            metrics::record_zone_operation("create", false);
495
496            // Check if zone already exists
497            let error_msg = e.to_string();
498            if error_msg.contains("already exists") {
499                ApiError::ZoneAlreadyExists(request.zone_name.clone())
500            } else {
501                ApiError::RndcError(error_msg)
502            }
503        })?;
504
505    info!("Zone {} created successfully", request.zone_name);
506    metrics::record_zone_operation("create", true);
507
508    Ok((
509        StatusCode::CREATED,
510        Json(ZoneResponse {
511            success: true,
512            message: format!("Zone {} created successfully", request.zone_name),
513            details: Some(output),
514        }),
515    ))
516}
517
518/// Delete a zone
519#[utoipa::path(
520    delete,
521    path = "/api/v1/zones/{name}",
522    params(
523        ("name" = String, Path, description = "Zone name to delete")
524    ),
525    responses(
526        (status = 200, description = "Zone deleted successfully", body = ZoneResponse),
527        (status = 500, description = "RNDC command failed")
528    ),
529    tag = "zones"
530)]
531pub async fn delete_zone(
532    State(state): State<AppState>,
533    Path(zone_name): Path<String>,
534) -> Result<Json<ZoneResponse>, ApiError> {
535    info!("Deleting zone: {}", zone_name);
536
537    // Execute rndc delzone
538    let output = state.rndc.delzone(&zone_name).await.map_err(|e| {
539        error!("RNDC delzone failed for {}: {}", zone_name, e);
540        metrics::record_zone_operation("delete", false);
541        ApiError::RndcError(e.to_string())
542    })?;
543
544    // Delete zone file
545    let zone_file_name = format!("{}.zone", zone_name);
546    let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
547
548    if zone_file_path.exists() {
549        if let Err(e) = tokio::fs::remove_file(&zone_file_path).await {
550            error!(
551                "Failed to delete zone file {}: {}",
552                zone_file_path.display(),
553                e
554            );
555            // Don't fail the request if file deletion fails - zone is already removed from BIND9
556        } else {
557            info!("Deleted zone file: {}", zone_file_path.display());
558        }
559    }
560
561    // Delete journal file if it exists
562    let journal_file_name = format!("{}.zone.jnl", zone_name);
563    let journal_file_path = PathBuf::from(&state.zone_dir).join(&journal_file_name);
564
565    if journal_file_path.exists() {
566        if let Err(e) = tokio::fs::remove_file(&journal_file_path).await {
567            error!(
568                "Failed to delete journal file {}: {}",
569                journal_file_path.display(),
570                e
571            );
572            // Don't fail the request if file deletion fails
573        } else {
574            info!("Deleted journal file: {}", journal_file_path.display());
575        }
576    }
577
578    info!("Zone {} deleted successfully", zone_name);
579    metrics::record_zone_operation("delete", true);
580
581    Ok(Json(ZoneResponse {
582        success: true,
583        message: format!("Zone {} deleted successfully", zone_name),
584        details: Some(output),
585    }))
586}
587
588/// Reload a zone
589#[utoipa::path(
590    post,
591    path = "/api/v1/zones/{name}/reload",
592    params(
593        ("name" = String, Path, description = "Zone name to reload")
594    ),
595    responses(
596        (status = 200, description = "Zone reloaded successfully", body = ZoneResponse),
597        (status = 500, description = "RNDC command failed")
598    ),
599    tag = "zones"
600)]
601pub async fn reload_zone(
602    State(state): State<AppState>,
603    Path(zone_name): Path<String>,
604) -> Result<Json<ZoneResponse>, ApiError> {
605    info!("Reloading zone: {}", zone_name);
606
607    let output = state.rndc.reload(&zone_name).await.map_err(|e| {
608        error!("RNDC reload failed for {}: {}", zone_name, e);
609        metrics::record_zone_operation("reload", false);
610        ApiError::RndcError(e.to_string())
611    })?;
612
613    info!("Zone {} reloaded successfully", zone_name);
614    metrics::record_zone_operation("reload", true);
615
616    Ok(Json(ZoneResponse {
617        success: true,
618        message: format!("Zone {} reloaded successfully", zone_name),
619        details: Some(output),
620    }))
621}
622
623/// Get zone status
624#[utoipa::path(
625    get,
626    path = "/api/v1/zones/{name}/status",
627    params(
628        ("name" = String, Path, description = "Zone name")
629    ),
630    responses(
631        (status = 200, description = "Zone status retrieved", body = ZoneResponse),
632        (status = 404, description = "Zone not found"),
633        (status = 500, description = "RNDC command failed")
634    ),
635    tag = "zones"
636)]
637pub async fn zone_status(
638    State(state): State<AppState>,
639    Path(zone_name): Path<String>,
640) -> Result<Json<ZoneResponse>, ApiError> {
641    info!("Getting status for zone: {}", zone_name);
642
643    let output = state.rndc.zonestatus(&zone_name).await.map_err(|e| {
644        error!("RNDC zonestatus failed for {}: {}", zone_name, e);
645        if e.to_string().contains("not found") {
646            ApiError::ZoneNotFound(zone_name.clone())
647        } else {
648            ApiError::RndcError(e.to_string())
649        }
650    })?;
651
652    Ok(Json(ZoneResponse {
653        success: true,
654        message: format!("Zone {} status retrieved", zone_name),
655        details: Some(output),
656    }))
657}
658
659/// Freeze a zone (disable dynamic updates)
660#[utoipa::path(
661    post,
662    path = "/api/v1/zones/{name}/freeze",
663    params(
664        ("name" = String, Path, description = "Zone name to freeze")
665    ),
666    responses(
667        (status = 200, description = "Zone frozen successfully", body = ZoneResponse),
668        (status = 500, description = "RNDC command failed")
669    ),
670    tag = "zones"
671)]
672pub async fn freeze_zone(
673    State(state): State<AppState>,
674    Path(zone_name): Path<String>,
675) -> Result<Json<ZoneResponse>, ApiError> {
676    info!("Freezing zone: {}", zone_name);
677
678    let output = state.rndc.freeze(&zone_name).await.map_err(|e| {
679        error!("RNDC freeze failed for {}: {}", zone_name, e);
680        metrics::record_zone_operation("freeze", false);
681        ApiError::RndcError(e.to_string())
682    })?;
683
684    info!("Zone {} frozen successfully", zone_name);
685    metrics::record_zone_operation("freeze", true);
686
687    Ok(Json(ZoneResponse {
688        success: true,
689        message: format!("Zone {} frozen successfully", zone_name),
690        details: Some(output),
691    }))
692}
693
694/// Thaw a zone (enable dynamic updates)
695#[utoipa::path(
696    post,
697    path = "/api/v1/zones/{name}/thaw",
698    params(
699        ("name" = String, Path, description = "Zone name to thaw")
700    ),
701    responses(
702        (status = 200, description = "Zone thawed successfully", body = ZoneResponse),
703        (status = 500, description = "RNDC command failed")
704    ),
705    tag = "zones"
706)]
707pub async fn thaw_zone(
708    State(state): State<AppState>,
709    Path(zone_name): Path<String>,
710) -> Result<Json<ZoneResponse>, ApiError> {
711    info!("Thawing zone: {}", zone_name);
712
713    let output = state.rndc.thaw(&zone_name).await.map_err(|e| {
714        error!("RNDC thaw failed for {}: {}", zone_name, e);
715        metrics::record_zone_operation("thaw", false);
716        ApiError::RndcError(e.to_string())
717    })?;
718
719    info!("Zone {} thawed successfully", zone_name);
720    metrics::record_zone_operation("thaw", true);
721
722    Ok(Json(ZoneResponse {
723        success: true,
724        message: format!("Zone {} thawed successfully", zone_name),
725        details: Some(output),
726    }))
727}
728
729/// Notify secondaries about zone changes
730#[utoipa::path(
731    post,
732    path = "/api/v1/zones/{name}/notify",
733    params(
734        ("name" = String, Path, description = "Zone name")
735    ),
736    responses(
737        (status = 200, description = "Notify sent successfully", body = ZoneResponse),
738        (status = 500, description = "RNDC command failed")
739    ),
740    tag = "zones"
741)]
742pub async fn notify_zone(
743    State(state): State<AppState>,
744    Path(zone_name): Path<String>,
745) -> Result<Json<ZoneResponse>, ApiError> {
746    info!("Notifying secondaries for zone: {}", zone_name);
747
748    let output = state.rndc.notify(&zone_name).await.map_err(|e| {
749        error!("RNDC notify failed for {}: {}", zone_name, e);
750        metrics::record_zone_operation("notify", false);
751        ApiError::RndcError(e.to_string())
752    })?;
753
754    info!("Zone {} notify sent successfully", zone_name);
755    metrics::record_zone_operation("notify", true);
756
757    Ok(Json(ZoneResponse {
758        success: true,
759        message: format!("Notify sent for zone {}", zone_name),
760        details: Some(output),
761    }))
762}
763
764/// Force a zone retransfer from primary
765#[utoipa::path(
766    post,
767    path = "/api/v1/zones/{name}/retransfer",
768    params(
769        ("name" = String, Path, description = "Zone name to retransfer")
770    ),
771    responses(
772        (status = 200, description = "Zone retransfer initiated", body = ZoneResponse),
773        (status = 500, description = "RNDC command failed")
774    ),
775    tag = "zones"
776)]
777pub async fn retransfer_zone(
778    State(state): State<AppState>,
779    Path(zone_name): Path<String>,
780) -> Result<Json<ZoneResponse>, ApiError> {
781    info!("Retransferring zone: {}", zone_name);
782
783    let output = state.rndc.retransfer(&zone_name).await.map_err(|e| {
784        error!("RNDC retransfer failed for {}: {}", zone_name, e);
785        metrics::record_zone_operation("retransfer", false);
786        ApiError::RndcError(e.to_string())
787    })?;
788
789    info!("Zone {} retransfer initiated successfully", zone_name);
790    metrics::record_zone_operation("retransfer", true);
791
792    Ok(Json(ZoneResponse {
793        success: true,
794        message: format!("Retransfer initiated for zone {}", zone_name),
795        details: Some(output),
796    }))
797}
798
799/// Get server status
800#[utoipa::path(
801    get,
802    path = "/api/v1/server/status",
803    responses(
804        (status = 200, description = "Server status retrieved", body = ServerStatusResponse),
805        (status = 500, description = "RNDC command failed")
806    ),
807    tag = "server"
808)]
809pub async fn server_status(
810    State(state): State<AppState>,
811) -> Result<Json<ServerStatusResponse>, ApiError> {
812    info!("Getting server status");
813
814    let output = state.rndc.status().await.map_err(|e| {
815        error!("RNDC status failed: {}", e);
816        ApiError::RndcError(e.to_string())
817    })?;
818
819    Ok(Json(ServerStatusResponse { status: output }))
820}
821
822/// List all zones
823#[utoipa::path(
824    get,
825    path = "/api/v1/zones",
826    responses(
827        (status = 200, description = "List of zones", body = ZoneListResponse),
828        (status = 500, description = "Failed to read zone directory")
829    ),
830    tag = "zones"
831)]
832pub async fn list_zones(State(state): State<AppState>) -> Result<Json<ZoneListResponse>, ApiError> {
833    info!("Listing all zones");
834
835    // Get zone files from directory
836    let mut zones = Vec::new();
837    let mut entries = tokio::fs::read_dir(&state.zone_dir).await.map_err(|e| {
838        error!("Failed to read zone directory: {}", e);
839        ApiError::InternalError(format!("Failed to read zone directory: {}", e))
840    })?;
841
842    while let Ok(Some(entry)) = entries.next_entry().await {
843        if let Ok(file_name) = entry.file_name().into_string() {
844            if file_name.ends_with(".zone") {
845                // Extract zone name from filename (remove .zone extension)
846                if let Some(zone_name) = file_name.strip_suffix(".zone") {
847                    zones.push(zone_name.to_string());
848                }
849            }
850        }
851    }
852
853    zones.sort();
854    let count = zones.len();
855
856    info!("Found {} zones", count);
857    metrics::update_zones_count(count as i64);
858
859    Ok(Json(ZoneListResponse { zones, count }))
860}
861
862/// Get a specific zone
863#[utoipa::path(
864    get,
865    path = "/api/v1/zones/{name}",
866    params(
867        ("name" = String, Path, description = "Zone name")
868    ),
869    responses(
870        (status = 200, description = "Zone information", body = ZoneInfo),
871        (status = 404, description = "Zone not found"),
872        (status = 500, description = "RNDC command failed")
873    ),
874    tag = "zones"
875)]
876pub async fn get_zone(
877    State(state): State<AppState>,
878    Path(zone_name): Path<String>,
879) -> Result<Json<ZoneInfo>, ApiError> {
880    info!("Getting zone: {}", zone_name);
881
882    // Check if zone file exists
883    let zone_file_name = format!("{}.zone", zone_name);
884    let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
885
886    if !zone_file_path.exists() {
887        return Err(ApiError::ZoneNotFound(zone_name.clone()));
888    }
889
890    // Get zone status from BIND9
891    let status_output = state.rndc.zonestatus(&zone_name).await.map_err(|e| {
892        error!("RNDC zonestatus failed for {}: {}", zone_name, e);
893        if e.to_string().contains("not found") {
894            ApiError::ZoneNotFound(zone_name.clone())
895        } else {
896            ApiError::RndcError(e.to_string())
897        }
898    })?;
899
900    // Parse zone type and serial from status output
901    let mut zone_type = "unknown".to_string();
902    let mut serial = None;
903
904    for line in status_output.lines() {
905        if let Some(type_str) = line.strip_prefix("type:").or_else(|| {
906            line.contains("type:")
907                .then(|| line.split("type:").nth(1))
908                .flatten()
909        }) {
910            zone_type = type_str.trim().to_string();
911        }
912
913        if let Some(serial_str) = line.strip_prefix("serial:").or_else(|| {
914            line.contains("serial:")
915                .then(|| line.split("serial:").nth(1))
916                .flatten()
917        }) {
918            if let Ok(s) = serial_str.trim().parse::<u32>() {
919                serial = Some(s);
920            }
921        }
922    }
923
924    Ok(Json(ZoneInfo {
925        name: zone_name,
926        zone_type,
927        serial,
928        file_path: Some(zone_file_path.display().to_string()),
929    }))
930}
931
932/// Modify a zone configuration
933///
934/// This endpoint allows updating zone configuration parameters such as
935/// also-notify and allow-transfer IP addresses without recreating the zone.
936/// It uses the `rndc modzone` command to dynamically update the zone configuration.
937#[utoipa::path(
938    patch,
939    path = "/api/v1/zones/{name}",
940    request_body = ModifyZoneRequest,
941    params(
942        ("name" = String, Path, description = "Zone name to modify")
943    ),
944    responses(
945        (status = 200, description = "Zone modified successfully", body = ZoneResponse),
946        (status = 400, description = "Invalid request"),
947        (status = 404, description = "Zone not found"),
948        (status = 500, description = "RNDC command failed"),
949        (status = 500, description = "Internal server error")
950    ),
951    tag = "zones"
952)]
953pub async fn modify_zone(
954    State(state): State<AppState>,
955    Path(zone_name): Path<String>,
956    Json(request): Json<ModifyZoneRequest>,
957) -> Result<Json<ZoneResponse>, ApiError> {
958    info!("Modifying zone: {}", zone_name);
959
960    // Debug log the full request payload
961    if let Ok(json_payload) = serde_json::to_string_pretty(&request) {
962        debug!(
963            "PATCH /api/v1/zones/{} payload: {}",
964            zone_name, json_payload
965        );
966    }
967
968    // Validate that at least one field is being updated
969    if request.also_notify.is_none()
970        && request.allow_transfer.is_none()
971        && request.allow_update.is_none()
972    {
973        metrics::record_zone_operation("modify", false);
974        return Err(ApiError::InvalidRequest(
975            "At least one field (alsoNotify, allowTransfer, or allowUpdate) must be provided"
976                .to_string(),
977        ));
978    }
979
980    // Check if zone exists by checking for zone file or querying status
981    let zone_file_name = format!("{}.zone", zone_name);
982    let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
983
984    // For secondary zones, there may not be a zone file, so we should check status instead
985    let zone_exists = if zone_file_path.exists() {
986        true
987    } else {
988        // Try to get zone status to see if it exists
989        match state.rndc.zonestatus(&zone_name).await {
990            Ok(_) => true,
991            Err(e) => {
992                if e.to_string().contains("not found") {
993                    false
994                } else {
995                    // Some other error, but zone might exist
996                    true
997                }
998            }
999        }
1000    };
1001
1002    if !zone_exists {
1003        metrics::record_zone_operation("modify", false);
1004        return Err(ApiError::ZoneNotFound(zone_name.clone()));
1005    }
1006
1007    // Get current zone configuration from BIND9
1008    let showzone_output = state.rndc.showzone(&zone_name).await.map_err(|e| {
1009        error!("Failed to get zone configuration for {}: {}", zone_name, e);
1010        if e.to_string().contains("not found") {
1011            ApiError::ZoneNotFound(zone_name.clone())
1012        } else {
1013            ApiError::RndcError(e.to_string())
1014        }
1015    })?;
1016
1017    // Parse the zone configuration
1018    let mut zone_config = crate::rndc_parser::parse_showzone(&showzone_output).map_err(|e| {
1019        error!(
1020            "Failed to parse zone configuration for {}: {}",
1021            zone_name, e
1022        );
1023        ApiError::RndcError(format!("Failed to parse zone configuration: {}", e))
1024    })?;
1025
1026    info!(
1027        "Zone {} has type: {}",
1028        zone_name,
1029        zone_config.zone_type.as_str()
1030    );
1031
1032    // Update the configuration with new values from the request
1033    if let Some(also_notify) = &request.also_notify {
1034        // Convert string IPs to IpAddr
1035        let ip_addrs: Result<Vec<std::net::IpAddr>, _> =
1036            also_notify.iter().map(|s| s.parse()).collect();
1037
1038        match ip_addrs {
1039            Ok(addrs) => {
1040                zone_config.also_notify = if addrs.is_empty() { None } else { Some(addrs) };
1041            }
1042            Err(e) => {
1043                metrics::record_zone_operation("modify", false);
1044                return Err(ApiError::InvalidRequest(format!(
1045                    "Invalid IP address in also-notify: {}",
1046                    e
1047                )));
1048            }
1049        }
1050    }
1051
1052    if let Some(allow_transfer) = &request.allow_transfer {
1053        // Convert string IPs to IpAddr
1054        let ip_addrs: Result<Vec<std::net::IpAddr>, _> =
1055            allow_transfer.iter().map(|s| s.parse()).collect();
1056
1057        match ip_addrs {
1058            Ok(addrs) => {
1059                zone_config.allow_transfer = if addrs.is_empty() { None } else { Some(addrs) };
1060            }
1061            Err(e) => {
1062                metrics::record_zone_operation("modify", false);
1063                return Err(ApiError::InvalidRequest(format!(
1064                    "Invalid IP address in allow-transfer: {}",
1065                    e
1066                )));
1067            }
1068        }
1069    }
1070
1071    if let Some(allow_update) = &request.allow_update {
1072        // Convert string IPs to IpAddr
1073        let ip_addrs: Result<Vec<std::net::IpAddr>, _> =
1074            allow_update.iter().map(|s| s.parse()).collect();
1075
1076        match ip_addrs {
1077            Ok(addrs) => {
1078                zone_config.allow_update = if addrs.is_empty() { None } else { Some(addrs) };
1079                // Clear the raw directive since we're setting explicit IPs
1080                zone_config.allow_update_raw = None;
1081            }
1082            Err(e) => {
1083                metrics::record_zone_operation("modify", false);
1084                return Err(ApiError::InvalidRequest(format!(
1085                    "Invalid IP address in allow-update: {}",
1086                    e
1087                )));
1088            }
1089        }
1090    }
1091
1092    // Serialize the updated configuration back to RNDC format
1093    let rndc_config_block = zone_config.to_rndc_block();
1094
1095    info!(
1096        "Modifying zone {} with config: {}",
1097        zone_name, rndc_config_block
1098    );
1099
1100    // Execute rndc modzone
1101    let output = state
1102        .rndc
1103        .modzone(&zone_name, &rndc_config_block)
1104        .await
1105        .map_err(|e| {
1106            error!("RNDC modzone failed for {}: {}", zone_name, e);
1107            metrics::record_zone_operation("modify", false);
1108            ApiError::RndcError(e.to_string())
1109        })?;
1110
1111    info!("Zone {} modified successfully", zone_name);
1112    metrics::record_zone_operation("modify", true);
1113
1114    Ok(Json(ZoneResponse {
1115        success: true,
1116        message: format!("Zone {} modified successfully", zone_name),
1117        details: Some(output),
1118    }))
1119}