1use 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
29pub const ZONE_TYPE_PRIMARY: &str = "primary";
31pub const ZONE_TYPE_SECONDARY: &str = "secondary";
32
33#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
43#[serde(rename_all = "camelCase")]
44pub struct SoaRecord {
45 pub primary_ns: String,
47
48 pub admin_email: String,
50
51 #[serde(default = "default_serial")]
53 pub serial: u32,
54
55 #[serde(default = "default_refresh")]
57 pub refresh: u32,
58
59 #[serde(default = "default_retry")]
61 pub retry: u32,
62
63 #[serde(default = "default_expire")]
65 pub expire: u32,
66
67 #[serde(default = "default_negative_ttl")]
69 pub negative_ttl: u32,
70}
71
72fn default_serial() -> u32 {
73 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#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
97#[serde(rename_all = "camelCase")]
98pub struct DnsRecord {
99 pub name: String,
101
102 #[serde(rename = "type")]
104 pub record_type: String,
105
106 pub value: String,
108
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub ttl: Option<u32>,
112
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub priority: Option<u16>,
116}
117
118#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
120#[serde(rename_all = "camelCase")]
121pub struct ZoneConfig {
122 pub ttl: u32,
124
125 pub soa: SoaRecord,
127
128 pub name_servers: Vec<String>,
130
131 pub name_server_ips: std::collections::HashMap<String, String>,
134
135 #[serde(default)]
137 pub records: Vec<DnsRecord>,
138
139 #[serde(skip_serializing_if = "Option::is_none")]
142 pub also_notify: Option<Vec<String>>,
143
144 #[serde(skip_serializing_if = "Option::is_none")]
147 pub allow_transfer: Option<Vec<String>>,
148
149 #[serde(skip_serializing_if = "Option::is_none")]
153 pub primaries: Option<Vec<String>>,
154
155 #[serde(skip_serializing_if = "Option::is_none")]
164 pub dnssec_policy: Option<String>,
165
166 #[serde(skip_serializing_if = "Option::is_none")]
175 pub inline_signing: Option<bool>,
176}
177
178impl ZoneConfig {
179 pub fn to_zone_file(&self) -> String {
181 let mut zone_file = String::new();
182
183 zone_file.push_str(&format!("$TTL {}\n\n", self.ttl));
185
186 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 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 for (ns_name, ip) in &self.name_server_ips {
211 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 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#[derive(Debug, Serialize, Deserialize, ToSchema)]
244#[serde(rename_all = "camelCase")]
245pub struct CreateZoneRequest {
246 pub zone_name: String,
248
249 pub zone_type: String,
251
252 pub zone_config: ZoneConfig,
254
255 pub update_key_name: Option<String>,
257}
258
259#[derive(Debug, Serialize, Deserialize, ToSchema)]
261#[serde(rename_all = "camelCase")]
262pub struct ModifyZoneRequest {
263 #[serde(skip_serializing_if = "Option::is_none")]
266 pub also_notify: Option<Vec<String>>,
267
268 #[serde(skip_serializing_if = "Option::is_none")]
271 pub allow_transfer: Option<Vec<String>>,
272
273 #[serde(skip_serializing_if = "Option::is_none")]
276 pub allow_update: Option<Vec<String>>,
277}
278
279#[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#[derive(Debug, Serialize, Deserialize, ToSchema)]
290pub struct ServerStatusResponse {
291 pub status: String,
292}
293
294#[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#[derive(Debug, Serialize, Deserialize, ToSchema)]
308pub struct ZoneListResponse {
309 pub zones: Vec<String>,
310 pub count: usize,
311}
312
313#[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 if let Ok(json_payload) = serde_json::to_string_pretty(&request) {
340 debug!("POST /api/v1/zones payload: {}", json_payload);
341 }
342
343 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 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 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 let zone_content = if request.zone_type == ZONE_TYPE_PRIMARY {
376 request.zone_config.to_zone_file()
377 } else {
378 String::new() };
380
381 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 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 let mut config_parts = vec![format!(r#"type {}"#, request.zone_type)];
424
425 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 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 if let Some(key_name) = &request.update_key_name {
446 config_parts.push(format!(r#"allow-update {{ key "{}"; }}"#, key_name));
447 }
448
449 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 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 if let Some(dnssec_policy) = &request.zone_config.dnssec_policy {
473 config_parts.push(format!(r#"dnssec-policy "{}""#, dnssec_policy));
474 }
475
476 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 let zone_config = format!("{{ {}; }};", config_parts.join("; "));
486
487 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 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#[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 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 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 } else {
557 info!("Deleted zone file: {}", zone_file_path.display());
558 }
559 }
560
561 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 } 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#[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#[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#[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#[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#[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#[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#[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#[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 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 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#[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 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 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 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#[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 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 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 let zone_file_name = format!("{}.zone", zone_name);
982 let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
983
984 let zone_exists = if zone_file_path.exists() {
986 true
987 } else {
988 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 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 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 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 if let Some(also_notify) = &request.also_notify {
1034 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 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 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 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 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 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}