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
33const MAX_ZONE_NAME_LEN: usize = 253;
35
36const MAX_RNDC_IDENTIFIER_LEN: usize = 253;
38
39pub(crate) fn validate_zone_name(zone_name: &str) -> Result<(), ApiError> {
52 if zone_name.is_empty() {
53 return Err(ApiError::InvalidRequest(
54 "Zone name cannot be empty".to_string(),
55 ));
56 }
57
58 if zone_name.len() > MAX_ZONE_NAME_LEN {
59 return Err(ApiError::InvalidRequest(format!(
60 "Zone name exceeds maximum length of {} characters",
61 MAX_ZONE_NAME_LEN
62 )));
63 }
64
65 if zone_name.contains("..") {
68 return Err(ApiError::InvalidRequest(
69 "Zone name must not contain '..'".to_string(),
70 ));
71 }
72
73 let starts_alphanumeric = zone_name
76 .chars()
77 .next()
78 .is_some_and(|c| c.is_ascii_alphanumeric());
79 if !starts_alphanumeric {
80 return Err(ApiError::InvalidRequest(
81 "Zone name must start with an alphanumeric character".to_string(),
82 ));
83 }
84
85 for c in zone_name.chars() {
88 if !(c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') {
89 return Err(ApiError::InvalidRequest(format!(
90 "Zone name contains invalid character: {:?}",
91 c
92 )));
93 }
94 }
95
96 Ok(())
97}
98
99pub(crate) fn validate_rndc_identifier(field: &str, value: &str) -> Result<(), ApiError> {
116 if value.is_empty() {
117 return Err(ApiError::InvalidRequest(format!(
118 "{} cannot be empty",
119 field
120 )));
121 }
122
123 if value.len() > MAX_RNDC_IDENTIFIER_LEN {
124 return Err(ApiError::InvalidRequest(format!(
125 "{} exceeds maximum length of {} characters",
126 field, MAX_RNDC_IDENTIFIER_LEN
127 )));
128 }
129
130 for c in value.chars() {
131 if !(c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') {
132 return Err(ApiError::InvalidRequest(format!(
133 "{} contains invalid character: {:?}",
134 field, c
135 )));
136 }
137 }
138
139 Ok(())
140}
141
142pub(crate) fn validate_ip_list(field: &str, ips: &[String]) -> Result<(), ApiError> {
158 for ip in ips {
159 if ip.parse::<std::net::IpAddr>().is_err() {
160 return Err(ApiError::InvalidRequest(format!(
161 "{} contains an invalid IP address: {:?}",
162 field, ip
163 )));
164 }
165 }
166
167 Ok(())
168}
169
170pub(crate) fn parse_ip_port_entry(s: &str) -> Option<(&str, Option<u16>)> {
181 if let Some(inner) = s.strip_prefix('[') {
183 let (addr, rest) = inner.split_once(']')?;
184 addr.parse::<std::net::Ipv6Addr>().ok()?;
185 let port = match rest.strip_prefix(':') {
186 Some(p) => Some(p.parse::<u16>().ok()?),
187 None if rest.is_empty() => None,
188 _ => return None,
189 };
190 return Some((addr, port));
191 }
192
193 let mut parts = s.rsplitn(2, ':');
195 let last = parts.next().unwrap();
196 let prefix = parts.next();
197
198 if let Some(prefix) = prefix {
199 if let Ok(port) = last.parse::<u16>() {
200 if prefix.parse::<std::net::Ipv4Addr>().is_ok() {
201 return Some((prefix, Some(port)));
202 }
203 }
204 }
205
206 s.parse::<std::net::IpAddr>().ok()?;
208 Some((s, None))
209}
210
211pub(crate) fn render_ip_port_entry(s: &str) -> String {
213 if let Some((addr, Some(port))) = parse_ip_port_entry(s) {
214 format!("{} port {}; ", addr, port)
215 } else {
216 format!("{}; ", s)
217 }
218}
219
220pub(crate) fn validate_ip_port_list(field: &str, entries: &[String]) -> Result<(), ApiError> {
230 for entry in entries {
231 if parse_ip_port_entry(entry).is_none() {
232 return Err(ApiError::InvalidRequest(format!(
233 "{} contains an invalid entry: {:?} (expected \"<ip>\" or \"<ip>:<port>\")",
234 field, entry
235 )));
236 }
237 }
238
239 Ok(())
240}
241
242fn validate_zone_file_hostname(field: &str, value: &str) -> Result<(), ApiError> {
259 if value.is_empty() {
260 return Err(ApiError::InvalidRequest(format!(
261 "{} cannot be empty",
262 field
263 )));
264 }
265
266 if let Some(bad) = value
267 .chars()
268 .find(|&c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')))
269 {
270 return Err(ApiError::InvalidRequest(format!(
271 "{} contains an illegal character: {:?} (allowed: A-Z a-z 0-9 . - _)",
272 field, bad
273 )));
274 }
275
276 Ok(())
277}
278
279pub(crate) fn validate_zone_config_content(config: &ZoneConfig) -> Result<(), ApiError> {
303 validate_zone_file_hostname("soa.primaryNs", &config.soa.primary_ns)?;
304 validate_zone_file_hostname("soa.adminEmail", &config.soa.admin_email)?;
305
306 for ns in &config.name_servers {
307 validate_zone_file_hostname("nameServers entry", ns)?;
308 }
309
310 for (host, ip) in &config.name_server_ips {
311 validate_zone_file_hostname("nameServerIps key", host)?;
312 if ip.parse::<std::net::IpAddr>().is_err() {
313 return Err(ApiError::InvalidRequest(format!(
314 "nameServerIps[{:?}] is not a valid IP address: {:?}",
315 host, ip
316 )));
317 }
318 }
319
320 for record in &config.records {
321 crate::records::validate_record_type(&record.record_type)?;
322 crate::records::validate_record_name(&record.name)?;
323 crate::records::validate_record_value(&record.record_type, &record.value)?;
324 }
325
326 Ok(())
327}
328
329pub fn resolve_zone_dir(raw_dir: &str) -> Result<String, ApiError> {
355 let canonical = std::fs::canonicalize(raw_dir).map_err(|e| {
356 ApiError::InternalError(format!(
357 "zone directory {:?} could not be resolved: {}",
358 raw_dir, e
359 ))
360 })?;
361
362 if !canonical.is_dir() {
363 return Err(ApiError::InternalError(format!(
364 "zone directory {:?} does not resolve to a directory",
365 raw_dir
366 )));
367 }
368
369 canonical.into_os_string().into_string().map_err(|_| {
370 ApiError::InternalError(format!(
371 "zone directory {:?} resolves to a non-UTF-8 path",
372 raw_dir
373 ))
374 })
375}
376
377pub fn is_normalized_zone_dir(path: &str) -> bool {
401 use std::path::{Component, Path};
402
403 let path = Path::new(path);
404 if !path.is_absolute() {
405 return false;
406 }
407
408 !path
409 .components()
410 .any(|component| matches!(component, Component::ParentDir | Component::CurDir))
411}
412
413#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
423#[serde(rename_all = "camelCase")]
424pub struct SoaRecord {
425 pub primary_ns: String,
427
428 pub admin_email: String,
430
431 #[serde(default = "default_serial")]
433 pub serial: u32,
434
435 #[serde(default = "default_refresh")]
437 pub refresh: u32,
438
439 #[serde(default = "default_retry")]
441 pub retry: u32,
442
443 #[serde(default = "default_expire")]
445 pub expire: u32,
446
447 #[serde(default = "default_negative_ttl")]
449 pub negative_ttl: u32,
450}
451
452fn default_serial() -> u32 {
453 let now = chrono::Utc::now();
455 let date_part = now.format("%Y%m%d").to_string();
456 format!("{}01", date_part).parse().unwrap_or(2025120601)
457}
458
459fn default_refresh() -> u32 {
460 3600
461}
462
463fn default_retry() -> u32 {
464 600
465}
466
467fn default_expire() -> u32 {
468 604_800
469}
470
471fn default_negative_ttl() -> u32 {
472 86400
473}
474
475#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
477#[serde(rename_all = "camelCase")]
478pub struct DnsRecord {
479 pub name: String,
481
482 #[serde(rename = "type")]
484 pub record_type: String,
485
486 pub value: String,
488
489 #[serde(skip_serializing_if = "Option::is_none")]
491 pub ttl: Option<u32>,
492
493 #[serde(skip_serializing_if = "Option::is_none")]
495 pub priority: Option<u16>,
496}
497
498#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
500#[serde(rename_all = "camelCase")]
501pub struct ZoneConfig {
502 pub ttl: u32,
504
505 pub soa: SoaRecord,
507
508 pub name_servers: Vec<String>,
510
511 pub name_server_ips: std::collections::HashMap<String, String>,
514
515 #[serde(default)]
517 pub records: Vec<DnsRecord>,
518
519 #[serde(skip_serializing_if = "Option::is_none")]
522 pub also_notify: Option<Vec<String>>,
523
524 #[serde(skip_serializing_if = "Option::is_none")]
527 pub allow_transfer: Option<Vec<String>>,
528
529 #[serde(skip_serializing_if = "Option::is_none")]
533 pub primaries: Option<Vec<String>>,
534
535 #[serde(skip_serializing_if = "Option::is_none")]
544 pub dnssec_policy: Option<String>,
545
546 #[serde(skip_serializing_if = "Option::is_none")]
555 pub inline_signing: Option<bool>,
556}
557
558impl ZoneConfig {
559 pub fn to_zone_file(&self) -> String {
561 let mut zone_file = String::new();
562
563 zone_file.push_str(&format!("$TTL {}\n\n", self.ttl));
565
566 zone_file.push_str(&format!(
568 "@ IN SOA {} {} (\n",
569 self.soa.primary_ns, self.soa.admin_email
570 ));
571 zone_file.push_str(&format!(" {} ; Serial\n", self.soa.serial));
572 zone_file.push_str(&format!(" {} ; Refresh\n", self.soa.refresh));
573 zone_file.push_str(&format!(" {} ; Retry\n", self.soa.retry));
574 zone_file.push_str(&format!(" {} ; Expire\n", self.soa.expire));
575 zone_file.push_str(&format!(
576 " {} ); Negative TTL\n\n",
577 self.soa.negative_ttl
578 ));
579
580 for ns in &self.name_servers {
582 zone_file.push_str(&format!("@ IN NS {}\n", ns));
583 }
584
585 if !self.name_servers.is_empty() {
586 zone_file.push('\n');
587 }
588
589 for (ns_name, ip) in &self.name_server_ips {
591 zone_file.push_str(&format!("{} IN A {}\n", ns_name, ip));
593 }
594 if !self.name_server_ips.is_empty() {
595 zone_file.push('\n');
596 }
597
598 for record in &self.records {
600 let ttl_str = if let Some(ttl) = record.ttl {
601 format!("{} ", ttl)
602 } else {
603 String::new()
604 };
605
606 let priority_str = if let Some(priority) = record.priority {
607 format!("{} ", priority)
608 } else {
609 String::new()
610 };
611
612 zone_file.push_str(&format!(
613 "{} {}IN {} {}{}\n",
614 record.name, ttl_str, record.record_type, priority_str, record.value
615 ));
616 }
617
618 zone_file
619 }
620}
621
622#[derive(Debug, Serialize, Deserialize, ToSchema)]
624#[serde(rename_all = "camelCase")]
625pub struct CreateZoneRequest {
626 pub zone_name: String,
628
629 pub zone_type: String,
631
632 pub zone_config: ZoneConfig,
634
635 pub update_key_name: Option<String>,
637}
638
639#[derive(Debug, Serialize, Deserialize, ToSchema)]
641#[serde(rename_all = "camelCase")]
642pub struct ModifyZoneRequest {
643 #[serde(skip_serializing_if = "Option::is_none")]
646 pub also_notify: Option<Vec<String>>,
647
648 #[serde(skip_serializing_if = "Option::is_none")]
651 pub allow_transfer: Option<Vec<String>>,
652
653 #[serde(skip_serializing_if = "Option::is_none")]
656 pub allow_update: Option<Vec<String>>,
657}
658
659#[derive(Debug, Serialize, Deserialize, ToSchema)]
661pub struct ZoneResponse {
662 pub success: bool,
663 pub message: String,
664 #[serde(skip_serializing_if = "Option::is_none")]
665 pub details: Option<String>,
666}
667
668#[derive(Debug, Serialize, Deserialize, ToSchema)]
670pub struct ServerStatusResponse {
671 pub status: String,
672}
673
674#[derive(Debug, Serialize, Deserialize, ToSchema)]
676#[serde(rename_all = "camelCase")]
677pub struct ZoneInfo {
678 pub name: String,
679 pub zone_type: String,
680 #[serde(skip_serializing_if = "Option::is_none")]
681 pub serial: Option<u32>,
682 #[serde(skip_serializing_if = "Option::is_none")]
683 pub file_path: Option<String>,
684}
685
686#[derive(Debug, Serialize, Deserialize, ToSchema)]
688pub struct ZoneListResponse {
689 pub zones: Vec<String>,
690 pub count: usize,
691}
692
693#[utoipa::path(
700 post,
701 path = "/api/v1/zones",
702 request_body = CreateZoneRequest,
703 responses(
704 (status = 201, description = "Zone created successfully", body = ZoneResponse),
705 (status = 400, description = "Invalid request"),
706 (status = 409, description = "Zone already exists"),
707 (status = 500, description = "RNDC command failed"),
708 (status = 500, description = "Internal server error")
709 ),
710 tag = "zones"
711)]
712pub async fn create_zone(
713 State(state): State<AppState>,
714 Json(request): Json<CreateZoneRequest>,
715) -> Result<(StatusCode, Json<ZoneResponse>), ApiError> {
716 info!("Creating zone: {}", request.zone_name);
717
718 if let Ok(json_payload) = serde_json::to_string_pretty(&request) {
720 debug!("POST /api/v1/zones payload: {}", json_payload);
721 }
722
723 if let Err(e) = validate_zone_name(&request.zone_name) {
726 metrics::record_zone_operation("create", false);
727 return Err(e);
728 }
729
730 if request.zone_type != ZONE_TYPE_PRIMARY && request.zone_type != ZONE_TYPE_SECONDARY {
732 metrics::record_zone_operation("create", false);
733 return Err(ApiError::InvalidRequest(format!(
734 "Invalid zone type: {}. Must be '{}' or '{}'",
735 request.zone_type, ZONE_TYPE_PRIMARY, ZONE_TYPE_SECONDARY
736 )));
737 }
738
739 if request.zone_type == ZONE_TYPE_SECONDARY
741 && request
742 .zone_config
743 .primaries
744 .as_ref()
745 .is_none_or(|p| p.is_empty())
746 {
747 metrics::record_zone_operation("create", false);
748 return Err(ApiError::InvalidRequest(
749 "Secondary zones require at least one primary server in 'primaries' field".to_string(),
750 ));
751 }
752
753 if let Some(key_name) = &request.update_key_name {
756 if let Err(e) = validate_rndc_identifier("updateKeyName", key_name) {
757 metrics::record_zone_operation("create", false);
758 return Err(e);
759 }
760 }
761 if let Some(dnssec_policy) = &request.zone_config.dnssec_policy {
762 if let Err(e) = validate_rndc_identifier("dnssecPolicy", dnssec_policy) {
763 metrics::record_zone_operation("create", false);
764 return Err(e);
765 }
766 }
767
768 if let Some(primaries) = &request.zone_config.primaries {
769 if let Err(e) = validate_ip_port_list("primaries", primaries) {
770 metrics::record_zone_operation("create", false);
771 return Err(e);
772 }
773 }
774 if let Some(also_notify) = &request.zone_config.also_notify {
775 if let Err(e) = validate_ip_port_list("also-notify", also_notify) {
776 metrics::record_zone_operation("create", false);
777 return Err(e);
778 }
779 }
780 if let Some(allow_transfer) = &request.zone_config.allow_transfer {
781 if let Err(e) = validate_ip_list("allow-transfer", allow_transfer) {
782 metrics::record_zone_operation("create", false);
783 return Err(e);
784 }
785 }
786
787 if request.zone_type == ZONE_TYPE_PRIMARY {
792 if let Err(e) = validate_zone_config_content(&request.zone_config) {
793 metrics::record_zone_operation("create", false);
794 return Err(e);
795 }
796 }
797
798 let zone_content = if request.zone_type == ZONE_TYPE_PRIMARY {
800 request.zone_config.to_zone_file()
801 } else {
802 String::new() };
804
805 let zone_file_name = format!("{}.zone", request.zone_name);
807 let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
808
809 if request.zone_type == ZONE_TYPE_PRIMARY {
810 info!(
811 "Generated zone file content for {}: {} bytes",
812 request.zone_name,
813 zone_content.len()
814 );
815
816 let journal_file_name = format!("{}.zone.jnl", request.zone_name);
818 let journal_file_path = PathBuf::from(&state.zone_dir).join(&journal_file_name);
819 if journal_file_path.exists() {
820 if let Err(e) = tokio::fs::remove_file(&journal_file_path).await {
821 error!(
822 "Failed to remove old journal file {}: {}",
823 journal_file_path.display(),
824 e
825 );
826 } else {
827 info!("Removed old journal file: {}", journal_file_path.display());
828 }
829 }
830
831 tokio::fs::write(&zone_file_path, &zone_content)
832 .await
833 .map_err(|e| {
834 error!(
835 "Failed to write zone file {}: {}",
836 zone_file_path.display(),
837 e
838 );
839 metrics::record_zone_operation("create", false);
840 ApiError::ZoneFileError(format!("Failed to write zone file: {}", e))
841 })?;
842
843 info!("Wrote zone file: {}", zone_file_path.display());
844 }
845
846 let mut config_parts = vec![format!(r#"type {}"#, request.zone_type)];
848
849 if request.zone_type == ZONE_TYPE_PRIMARY {
851 let zone_file_full_path = format!("{}/{}", state.zone_dir, zone_file_name);
852 config_parts.push(format!(r#"file "{}""#, zone_file_full_path));
853 }
854
855 if request.zone_type == ZONE_TYPE_SECONDARY {
857 if let Some(primaries) = &request.zone_config.primaries {
858 if !primaries.is_empty() {
859 let primaries_list = primaries
860 .iter()
861 .map(|s| render_ip_port_entry(s))
862 .collect::<String>();
863 config_parts.push(format!(r#"primaries {{ {} }}"#, primaries_list));
864 }
865 }
866 }
867
868 if let Some(key_name) = &request.update_key_name {
870 config_parts.push(format!(r#"allow-update {{ key "{}"; }}"#, key_name));
871 }
872
873 if let Some(also_notify) = &request.zone_config.also_notify {
875 if !also_notify.is_empty() {
876 let notify_list = also_notify
877 .iter()
878 .map(|s| render_ip_port_entry(s))
879 .collect::<String>();
880 config_parts.push(format!(r#"also-notify {{ {} }}"#, notify_list));
881 }
882 }
883
884 if let Some(allow_transfer) = &request.zone_config.allow_transfer {
886 if !allow_transfer.is_empty() {
887 let transfer_list = allow_transfer
888 .iter()
889 .map(|ip| format!("{}; ", ip))
890 .collect::<String>();
891 config_parts.push(format!(r#"allow-transfer {{ {} }}"#, transfer_list));
892 }
893 }
894
895 if let Some(dnssec_policy) = &request.zone_config.dnssec_policy {
897 config_parts.push(format!(r#"dnssec-policy "{}""#, dnssec_policy));
898 }
899
900 if let Some(inline_signing) = request.zone_config.inline_signing {
902 config_parts.push(format!(
903 r#"inline-signing {}"#,
904 if inline_signing { "yes" } else { "no" }
905 ));
906 }
907
908 let zone_config = format!("{{ {}; }};", config_parts.join("; "));
910
911 let output = state
913 .rndc
914 .addzone(&request.zone_name, &zone_config)
915 .await
916 .map_err(|e| {
917 error!("RNDC addzone failed for {}: {}", request.zone_name, e);
918 metrics::record_zone_operation("create", false);
919
920 let error_msg = e.to_string();
922 if error_msg.contains("already exists") {
923 ApiError::ZoneAlreadyExists(request.zone_name.clone())
924 } else {
925 ApiError::RndcError(error_msg)
926 }
927 })?;
928
929 info!("Zone {} created successfully", request.zone_name);
930 metrics::record_zone_operation("create", true);
931
932 Ok((
933 StatusCode::CREATED,
934 Json(ZoneResponse {
935 success: true,
936 message: format!("Zone {} created successfully", request.zone_name),
937 details: Some(output),
938 }),
939 ))
940}
941
942#[utoipa::path(
944 delete,
945 path = "/api/v1/zones/{name}",
946 params(
947 ("name" = String, Path, description = "Zone name to delete")
948 ),
949 responses(
950 (status = 200, description = "Zone deleted successfully", body = ZoneResponse),
951 (status = 500, description = "RNDC command failed")
952 ),
953 tag = "zones"
954)]
955pub async fn delete_zone(
956 State(state): State<AppState>,
957 Path(zone_name): Path<String>,
958) -> Result<Json<ZoneResponse>, ApiError> {
959 info!("Deleting zone: {}", zone_name);
960
961 if let Err(e) = validate_zone_name(&zone_name) {
964 metrics::record_zone_operation("delete", false);
965 return Err(e);
966 }
967
968 let output = state.rndc.delzone(&zone_name).await.map_err(|e| {
970 error!("RNDC delzone failed for {}: {}", zone_name, e);
971 metrics::record_zone_operation("delete", false);
972 ApiError::RndcError(e.to_string())
973 })?;
974
975 let zone_file_name = format!("{}.zone", zone_name);
977 let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
978
979 if zone_file_path.exists() {
980 if let Err(e) = tokio::fs::remove_file(&zone_file_path).await {
981 error!(
982 "Failed to delete zone file {}: {}",
983 zone_file_path.display(),
984 e
985 );
986 } else {
988 info!("Deleted zone file: {}", zone_file_path.display());
989 }
990 }
991
992 let journal_file_name = format!("{}.zone.jnl", zone_name);
994 let journal_file_path = PathBuf::from(&state.zone_dir).join(&journal_file_name);
995
996 if journal_file_path.exists() {
997 if let Err(e) = tokio::fs::remove_file(&journal_file_path).await {
998 error!(
999 "Failed to delete journal file {}: {}",
1000 journal_file_path.display(),
1001 e
1002 );
1003 } else {
1005 info!("Deleted journal file: {}", journal_file_path.display());
1006 }
1007 }
1008
1009 info!("Zone {} deleted successfully", zone_name);
1010 metrics::record_zone_operation("delete", true);
1011
1012 Ok(Json(ZoneResponse {
1013 success: true,
1014 message: format!("Zone {} deleted successfully", zone_name),
1015 details: Some(output),
1016 }))
1017}
1018
1019#[utoipa::path(
1021 post,
1022 path = "/api/v1/zones/{name}/reload",
1023 params(
1024 ("name" = String, Path, description = "Zone name to reload")
1025 ),
1026 responses(
1027 (status = 200, description = "Zone reloaded successfully", body = ZoneResponse),
1028 (status = 500, description = "RNDC command failed")
1029 ),
1030 tag = "zones"
1031)]
1032pub async fn reload_zone(
1033 State(state): State<AppState>,
1034 Path(zone_name): Path<String>,
1035) -> Result<Json<ZoneResponse>, ApiError> {
1036 info!("Reloading zone: {}", zone_name);
1037
1038 if let Err(e) = validate_zone_name(&zone_name) {
1041 metrics::record_zone_operation("reload", false);
1042 return Err(e);
1043 }
1044
1045 let output = state.rndc.reload(&zone_name).await.map_err(|e| {
1046 error!("RNDC reload failed for {}: {}", zone_name, e);
1047 metrics::record_zone_operation("reload", false);
1048 ApiError::RndcError(e.to_string())
1049 })?;
1050
1051 info!("Zone {} reloaded successfully", zone_name);
1052 metrics::record_zone_operation("reload", true);
1053
1054 Ok(Json(ZoneResponse {
1055 success: true,
1056 message: format!("Zone {} reloaded successfully", zone_name),
1057 details: Some(output),
1058 }))
1059}
1060
1061#[utoipa::path(
1063 get,
1064 path = "/api/v1/zones/{name}/status",
1065 params(
1066 ("name" = String, Path, description = "Zone name")
1067 ),
1068 responses(
1069 (status = 200, description = "Zone status retrieved", body = ZoneResponse),
1070 (status = 404, description = "Zone not found"),
1071 (status = 500, description = "RNDC command failed")
1072 ),
1073 tag = "zones"
1074)]
1075pub async fn zone_status(
1076 State(state): State<AppState>,
1077 Path(zone_name): Path<String>,
1078) -> Result<Json<ZoneResponse>, ApiError> {
1079 info!("Getting status for zone: {}", zone_name);
1080
1081 validate_zone_name(&zone_name)?;
1084
1085 let output = state.rndc.zonestatus(&zone_name).await.map_err(|e| {
1086 error!("RNDC zonestatus failed for {}: {}", zone_name, e);
1087 if e.to_string().contains("not found") {
1088 ApiError::ZoneNotFound(zone_name.clone())
1089 } else {
1090 ApiError::RndcError(e.to_string())
1091 }
1092 })?;
1093
1094 Ok(Json(ZoneResponse {
1095 success: true,
1096 message: format!("Zone {} status retrieved", zone_name),
1097 details: Some(output),
1098 }))
1099}
1100
1101#[utoipa::path(
1103 post,
1104 path = "/api/v1/zones/{name}/freeze",
1105 params(
1106 ("name" = String, Path, description = "Zone name to freeze")
1107 ),
1108 responses(
1109 (status = 200, description = "Zone frozen successfully", body = ZoneResponse),
1110 (status = 500, description = "RNDC command failed")
1111 ),
1112 tag = "zones"
1113)]
1114pub async fn freeze_zone(
1115 State(state): State<AppState>,
1116 Path(zone_name): Path<String>,
1117) -> Result<Json<ZoneResponse>, ApiError> {
1118 info!("Freezing zone: {}", zone_name);
1119
1120 if let Err(e) = validate_zone_name(&zone_name) {
1123 metrics::record_zone_operation("freeze", false);
1124 return Err(e);
1125 }
1126
1127 let output = state.rndc.freeze(&zone_name).await.map_err(|e| {
1128 error!("RNDC freeze failed for {}: {}", zone_name, e);
1129 metrics::record_zone_operation("freeze", false);
1130 ApiError::RndcError(e.to_string())
1131 })?;
1132
1133 info!("Zone {} frozen successfully", zone_name);
1134 metrics::record_zone_operation("freeze", true);
1135
1136 Ok(Json(ZoneResponse {
1137 success: true,
1138 message: format!("Zone {} frozen successfully", zone_name),
1139 details: Some(output),
1140 }))
1141}
1142
1143#[utoipa::path(
1145 post,
1146 path = "/api/v1/zones/{name}/thaw",
1147 params(
1148 ("name" = String, Path, description = "Zone name to thaw")
1149 ),
1150 responses(
1151 (status = 200, description = "Zone thawed successfully", body = ZoneResponse),
1152 (status = 500, description = "RNDC command failed")
1153 ),
1154 tag = "zones"
1155)]
1156pub async fn thaw_zone(
1157 State(state): State<AppState>,
1158 Path(zone_name): Path<String>,
1159) -> Result<Json<ZoneResponse>, ApiError> {
1160 info!("Thawing zone: {}", zone_name);
1161
1162 if let Err(e) = validate_zone_name(&zone_name) {
1165 metrics::record_zone_operation("thaw", false);
1166 return Err(e);
1167 }
1168
1169 let output = state.rndc.thaw(&zone_name).await.map_err(|e| {
1170 error!("RNDC thaw failed for {}: {}", zone_name, e);
1171 metrics::record_zone_operation("thaw", false);
1172 ApiError::RndcError(e.to_string())
1173 })?;
1174
1175 info!("Zone {} thawed successfully", zone_name);
1176 metrics::record_zone_operation("thaw", true);
1177
1178 Ok(Json(ZoneResponse {
1179 success: true,
1180 message: format!("Zone {} thawed successfully", zone_name),
1181 details: Some(output),
1182 }))
1183}
1184
1185#[utoipa::path(
1187 post,
1188 path = "/api/v1/zones/{name}/notify",
1189 params(
1190 ("name" = String, Path, description = "Zone name")
1191 ),
1192 responses(
1193 (status = 200, description = "Notify sent successfully", body = ZoneResponse),
1194 (status = 500, description = "RNDC command failed")
1195 ),
1196 tag = "zones"
1197)]
1198pub async fn notify_zone(
1199 State(state): State<AppState>,
1200 Path(zone_name): Path<String>,
1201) -> Result<Json<ZoneResponse>, ApiError> {
1202 info!("Notifying secondaries for zone: {}", zone_name);
1203
1204 if let Err(e) = validate_zone_name(&zone_name) {
1207 metrics::record_zone_operation("notify", false);
1208 return Err(e);
1209 }
1210
1211 let output = state.rndc.notify(&zone_name).await.map_err(|e| {
1212 error!("RNDC notify failed for {}: {}", zone_name, e);
1213 metrics::record_zone_operation("notify", false);
1214 ApiError::RndcError(e.to_string())
1215 })?;
1216
1217 info!("Zone {} notify sent successfully", zone_name);
1218 metrics::record_zone_operation("notify", true);
1219
1220 Ok(Json(ZoneResponse {
1221 success: true,
1222 message: format!("Notify sent for zone {}", zone_name),
1223 details: Some(output),
1224 }))
1225}
1226
1227#[utoipa::path(
1229 post,
1230 path = "/api/v1/zones/{name}/retransfer",
1231 params(
1232 ("name" = String, Path, description = "Zone name to retransfer")
1233 ),
1234 responses(
1235 (status = 200, description = "Zone retransfer initiated", body = ZoneResponse),
1236 (status = 500, description = "RNDC command failed")
1237 ),
1238 tag = "zones"
1239)]
1240pub async fn retransfer_zone(
1241 State(state): State<AppState>,
1242 Path(zone_name): Path<String>,
1243) -> Result<Json<ZoneResponse>, ApiError> {
1244 info!("Retransferring zone: {}", zone_name);
1245
1246 if let Err(e) = validate_zone_name(&zone_name) {
1249 metrics::record_zone_operation("retransfer", false);
1250 return Err(e);
1251 }
1252
1253 let output = state.rndc.retransfer(&zone_name).await.map_err(|e| {
1254 error!("RNDC retransfer failed for {}: {}", zone_name, e);
1255 metrics::record_zone_operation("retransfer", false);
1256 ApiError::RndcError(e.to_string())
1257 })?;
1258
1259 info!("Zone {} retransfer initiated successfully", zone_name);
1260 metrics::record_zone_operation("retransfer", true);
1261
1262 Ok(Json(ZoneResponse {
1263 success: true,
1264 message: format!("Retransfer initiated for zone {}", zone_name),
1265 details: Some(output),
1266 }))
1267}
1268
1269#[utoipa::path(
1271 get,
1272 path = "/api/v1/server/status",
1273 responses(
1274 (status = 200, description = "Server status retrieved", body = ServerStatusResponse),
1275 (status = 500, description = "RNDC command failed")
1276 ),
1277 tag = "server"
1278)]
1279pub async fn server_status(
1280 State(state): State<AppState>,
1281) -> Result<Json<ServerStatusResponse>, ApiError> {
1282 info!("Getting server status");
1283
1284 let output = state.rndc.status().await.map_err(|e| {
1285 error!("RNDC status failed: {}", e);
1286 ApiError::RndcError(e.to_string())
1287 })?;
1288
1289 Ok(Json(ServerStatusResponse { status: output }))
1290}
1291
1292#[utoipa::path(
1294 get,
1295 path = "/api/v1/zones",
1296 responses(
1297 (status = 200, description = "List of zones", body = ZoneListResponse),
1298 (status = 500, description = "Failed to read zone directory")
1299 ),
1300 tag = "zones"
1301)]
1302pub async fn list_zones(State(state): State<AppState>) -> Result<Json<ZoneListResponse>, ApiError> {
1303 info!("Listing all zones");
1304
1305 let mut zones = Vec::new();
1307 let mut entries = tokio::fs::read_dir(&state.zone_dir).await.map_err(|e| {
1308 error!("Failed to read zone directory: {}", e);
1309 ApiError::InternalError(format!("Failed to read zone directory: {}", e))
1310 })?;
1311
1312 while let Ok(Some(entry)) = entries.next_entry().await {
1313 if let Ok(file_name) = entry.file_name().into_string() {
1314 if file_name.ends_with(".zone") {
1315 if let Some(zone_name) = file_name.strip_suffix(".zone") {
1317 zones.push(zone_name.to_string());
1318 }
1319 }
1320 }
1321 }
1322
1323 zones.sort();
1324 let count = zones.len();
1325
1326 info!("Found {} zones", count);
1327 metrics::update_zones_count(count as i64);
1328
1329 Ok(Json(ZoneListResponse { zones, count }))
1330}
1331
1332#[utoipa::path(
1334 get,
1335 path = "/api/v1/zones/{name}",
1336 params(
1337 ("name" = String, Path, description = "Zone name")
1338 ),
1339 responses(
1340 (status = 200, description = "Zone information", body = ZoneInfo),
1341 (status = 404, description = "Zone not found"),
1342 (status = 500, description = "RNDC command failed")
1343 ),
1344 tag = "zones"
1345)]
1346pub async fn get_zone(
1347 State(state): State<AppState>,
1348 Path(zone_name): Path<String>,
1349) -> Result<Json<ZoneInfo>, ApiError> {
1350 info!("Getting zone: {}", zone_name);
1351
1352 validate_zone_name(&zone_name)?;
1357
1358 let zone_file_name = format!("{}.zone", zone_name);
1360 let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
1361
1362 if !zone_file_path.exists() {
1363 return Err(ApiError::ZoneNotFound(zone_name.clone()));
1364 }
1365
1366 let status_output = state.rndc.zonestatus(&zone_name).await.map_err(|e| {
1368 error!("RNDC zonestatus failed for {}: {}", zone_name, e);
1369 if e.to_string().contains("not found") {
1370 ApiError::ZoneNotFound(zone_name.clone())
1371 } else {
1372 ApiError::RndcError(e.to_string())
1373 }
1374 })?;
1375
1376 let mut zone_type = "unknown".to_string();
1378 let mut serial = None;
1379
1380 for line in status_output.lines() {
1381 if let Some(type_str) = line.strip_prefix("type:").or_else(|| {
1382 line.contains("type:")
1383 .then(|| line.split("type:").nth(1))
1384 .flatten()
1385 }) {
1386 zone_type = type_str.trim().to_string();
1387 }
1388
1389 if let Some(serial_str) = line.strip_prefix("serial:").or_else(|| {
1390 line.contains("serial:")
1391 .then(|| line.split("serial:").nth(1))
1392 .flatten()
1393 }) {
1394 if let Ok(s) = serial_str.trim().parse::<u32>() {
1395 serial = Some(s);
1396 }
1397 }
1398 }
1399
1400 Ok(Json(ZoneInfo {
1401 name: zone_name,
1402 zone_type,
1403 serial,
1404 file_path: Some(zone_file_path.display().to_string()),
1405 }))
1406}
1407
1408#[utoipa::path(
1414 patch,
1415 path = "/api/v1/zones/{name}",
1416 request_body = ModifyZoneRequest,
1417 params(
1418 ("name" = String, Path, description = "Zone name to modify")
1419 ),
1420 responses(
1421 (status = 200, description = "Zone modified successfully", body = ZoneResponse),
1422 (status = 400, description = "Invalid request"),
1423 (status = 404, description = "Zone not found"),
1424 (status = 500, description = "RNDC command failed"),
1425 (status = 500, description = "Internal server error")
1426 ),
1427 tag = "zones"
1428)]
1429pub async fn modify_zone(
1430 State(state): State<AppState>,
1431 Path(zone_name): Path<String>,
1432 Json(request): Json<ModifyZoneRequest>,
1433) -> Result<Json<ZoneResponse>, ApiError> {
1434 info!("Modifying zone: {}", zone_name);
1435
1436 if let Err(e) = validate_zone_name(&zone_name) {
1439 metrics::record_zone_operation("modify", false);
1440 return Err(e);
1441 }
1442
1443 if let Ok(json_payload) = serde_json::to_string_pretty(&request) {
1445 debug!(
1446 "PATCH /api/v1/zones/{} payload: {}",
1447 zone_name, json_payload
1448 );
1449 }
1450
1451 if request.also_notify.is_none()
1453 && request.allow_transfer.is_none()
1454 && request.allow_update.is_none()
1455 {
1456 metrics::record_zone_operation("modify", false);
1457 return Err(ApiError::InvalidRequest(
1458 "At least one field (alsoNotify, allowTransfer, or allowUpdate) must be provided"
1459 .to_string(),
1460 ));
1461 }
1462
1463 let zone_file_name = format!("{}.zone", zone_name);
1465 let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
1466
1467 let zone_exists = if zone_file_path.exists() {
1469 true
1470 } else {
1471 match state.rndc.zonestatus(&zone_name).await {
1473 Ok(_) => true,
1474 Err(e) => {
1475 if e.to_string().contains("not found") {
1476 false
1477 } else {
1478 true
1480 }
1481 }
1482 }
1483 };
1484
1485 if !zone_exists {
1486 metrics::record_zone_operation("modify", false);
1487 return Err(ApiError::ZoneNotFound(zone_name.clone()));
1488 }
1489
1490 let showzone_output = state.rndc.showzone(&zone_name).await.map_err(|e| {
1492 error!("Failed to get zone configuration for {}: {}", zone_name, e);
1493 if e.to_string().contains("not found") {
1494 ApiError::ZoneNotFound(zone_name.clone())
1495 } else {
1496 ApiError::RndcError(e.to_string())
1497 }
1498 })?;
1499
1500 let mut zone_config = crate::rndc_parser::parse_showzone(&showzone_output).map_err(|e| {
1502 error!(
1503 "Failed to parse zone configuration for {}: {}",
1504 zone_name, e
1505 );
1506 ApiError::RndcError(format!("Failed to parse zone configuration: {}", e))
1507 })?;
1508
1509 info!(
1510 "Zone {} has type: {}",
1511 zone_name,
1512 zone_config.zone_type.as_str()
1513 );
1514
1515 if let Some(also_notify) = &request.also_notify {
1517 let ip_addrs: Result<Vec<std::net::IpAddr>, _> =
1519 also_notify.iter().map(|s| s.parse()).collect();
1520
1521 match ip_addrs {
1522 Ok(addrs) => {
1523 zone_config.also_notify = if addrs.is_empty() { None } else { Some(addrs) };
1524 }
1525 Err(e) => {
1526 metrics::record_zone_operation("modify", false);
1527 return Err(ApiError::InvalidRequest(format!(
1528 "Invalid IP address in also-notify: {}",
1529 e
1530 )));
1531 }
1532 }
1533 }
1534
1535 if let Some(allow_transfer) = &request.allow_transfer {
1536 let ip_addrs: Result<Vec<std::net::IpAddr>, _> =
1538 allow_transfer.iter().map(|s| s.parse()).collect();
1539
1540 match ip_addrs {
1541 Ok(addrs) => {
1542 zone_config.allow_transfer = if addrs.is_empty() { None } else { Some(addrs) };
1543 }
1544 Err(e) => {
1545 metrics::record_zone_operation("modify", false);
1546 return Err(ApiError::InvalidRequest(format!(
1547 "Invalid IP address in allow-transfer: {}",
1548 e
1549 )));
1550 }
1551 }
1552 }
1553
1554 if let Some(allow_update) = &request.allow_update {
1555 let ip_addrs: Result<Vec<std::net::IpAddr>, _> =
1557 allow_update.iter().map(|s| s.parse()).collect();
1558
1559 match ip_addrs {
1560 Ok(addrs) => {
1561 zone_config.allow_update = if addrs.is_empty() { None } else { Some(addrs) };
1562 zone_config.allow_update_raw = None;
1564 }
1565 Err(e) => {
1566 metrics::record_zone_operation("modify", false);
1567 return Err(ApiError::InvalidRequest(format!(
1568 "Invalid IP address in allow-update: {}",
1569 e
1570 )));
1571 }
1572 }
1573 }
1574
1575 let rndc_config_block = zone_config.to_rndc_block();
1577
1578 info!(
1579 "Modifying zone {} with config: {}",
1580 zone_name, rndc_config_block
1581 );
1582
1583 let output = state
1585 .rndc
1586 .modzone(&zone_name, &rndc_config_block)
1587 .await
1588 .map_err(|e| {
1589 error!("RNDC modzone failed for {}: {}", zone_name, e);
1590 metrics::record_zone_operation("modify", false);
1591 ApiError::RndcError(e.to_string())
1592 })?;
1593
1594 info!("Zone {} modified successfully", zone_name);
1595 metrics::record_zone_operation("modify", true);
1596
1597 Ok(Json(ZoneResponse {
1598 success: true,
1599 message: format!("Zone {} modified successfully", zone_name),
1600 details: Some(output),
1601 }))
1602}