1use axum::{
15 extract::{Path, State},
16 http::StatusCode,
17 Json,
18};
19use std::path::PathBuf;
20use tracing::{debug, error, info, warn};
21
22use crate::{
23 metrics,
24 types::{ApiError, AppState},
25};
26
27pub use crate::zones_types::{
31 CheckdsRequest, CreateZoneRequest, DnsRecord, DsRecordView, DsSetResponse, ModifyZoneRequest,
32 ServerStatusResponse, SoaRecord, ZoneConfig, ZoneInfo, ZoneListResponse, ZoneResponse,
33 ZoneStatusResponse, ZONE_TYPE_PRIMARY, ZONE_TYPE_SECONDARY,
34};
35
36const MAX_ZONE_NAME_LEN: usize = 253;
38
39const MAX_RNDC_IDENTIFIER_LEN: usize = 253;
41
42pub(crate) fn validate_zone_name(zone_name: &str) -> Result<(), ApiError> {
55 if zone_name.is_empty() {
56 return Err(ApiError::InvalidRequest(
57 "Zone name cannot be empty".to_string(),
58 ));
59 }
60
61 if zone_name.len() > MAX_ZONE_NAME_LEN {
62 return Err(ApiError::InvalidRequest(format!(
63 "Zone name exceeds maximum length of {} characters",
64 MAX_ZONE_NAME_LEN
65 )));
66 }
67
68 if zone_name.contains("..") {
71 return Err(ApiError::InvalidRequest(
72 "Zone name must not contain '..'".to_string(),
73 ));
74 }
75
76 let starts_alphanumeric = zone_name
79 .chars()
80 .next()
81 .is_some_and(|c| c.is_ascii_alphanumeric());
82 if !starts_alphanumeric {
83 return Err(ApiError::InvalidRequest(
84 "Zone name must start with an alphanumeric character".to_string(),
85 ));
86 }
87
88 for c in zone_name.chars() {
91 if !(c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') {
92 return Err(ApiError::InvalidRequest(format!(
93 "Zone name contains invalid character: {:?}",
94 c
95 )));
96 }
97 }
98
99 Ok(())
100}
101
102pub(crate) fn validate_rndc_identifier(field: &str, value: &str) -> Result<(), ApiError> {
119 if value.is_empty() {
120 return Err(ApiError::InvalidRequest(format!(
121 "{} cannot be empty",
122 field
123 )));
124 }
125
126 if value.len() > MAX_RNDC_IDENTIFIER_LEN {
127 return Err(ApiError::InvalidRequest(format!(
128 "{} exceeds maximum length of {} characters",
129 field, MAX_RNDC_IDENTIFIER_LEN
130 )));
131 }
132
133 for c in value.chars() {
134 if !(c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') {
135 return Err(ApiError::InvalidRequest(format!(
136 "{} contains invalid character: {:?}",
137 field, c
138 )));
139 }
140 }
141
142 Ok(())
143}
144
145pub(crate) fn validate_ip_list(field: &str, ips: &[String]) -> Result<(), ApiError> {
161 for ip in ips {
162 if ip.parse::<std::net::IpAddr>().is_err() {
163 return Err(ApiError::InvalidRequest(format!(
164 "{} contains an invalid IP address: {:?}",
165 field, ip
166 )));
167 }
168 }
169
170 Ok(())
171}
172
173pub(crate) fn parse_ip_port_entry(s: &str) -> Option<(&str, Option<u16>)> {
184 if let Some(inner) = s.strip_prefix('[') {
186 let (addr, rest) = inner.split_once(']')?;
187 addr.parse::<std::net::Ipv6Addr>().ok()?;
188 let port = match rest.strip_prefix(':') {
189 Some(p) => Some(p.parse::<u16>().ok()?),
190 None if rest.is_empty() => None,
191 _ => return None,
192 };
193 return Some((addr, port));
194 }
195
196 let mut parts = s.rsplitn(2, ':');
198 let last = parts.next().unwrap();
199 let prefix = parts.next();
200
201 if let Some(prefix) = prefix {
202 if let Ok(port) = last.parse::<u16>() {
203 if prefix.parse::<std::net::Ipv4Addr>().is_ok() {
204 return Some((prefix, Some(port)));
205 }
206 }
207 }
208
209 s.parse::<std::net::IpAddr>().ok()?;
211 Some((s, None))
212}
213
214pub(crate) fn render_ip_port_entry(s: &str) -> String {
216 if let Some((addr, Some(port))) = parse_ip_port_entry(s) {
217 format!("{} port {}; ", addr, port)
218 } else {
219 format!("{}; ", s)
220 }
221}
222
223pub(crate) fn validate_ip_port_list(field: &str, entries: &[String]) -> Result<(), ApiError> {
233 for entry in entries {
234 if parse_ip_port_entry(entry).is_none() {
235 return Err(ApiError::InvalidRequest(format!(
236 "{} contains an invalid entry: {:?} (expected \"<ip>\" or \"<ip>:<port>\")",
237 field, entry
238 )));
239 }
240 }
241
242 Ok(())
243}
244
245fn validate_zone_file_hostname(field: &str, value: &str) -> Result<(), ApiError> {
262 if value.is_empty() {
263 return Err(ApiError::InvalidRequest(format!(
264 "{} cannot be empty",
265 field
266 )));
267 }
268
269 if let Some(bad) = value
270 .chars()
271 .find(|&c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')))
272 {
273 return Err(ApiError::InvalidRequest(format!(
274 "{} contains an illegal character: {:?} (allowed: A-Z a-z 0-9 . - _)",
275 field, bad
276 )));
277 }
278
279 Ok(())
280}
281
282pub(crate) fn validate_zone_config_content(config: &ZoneConfig) -> Result<(), ApiError> {
306 validate_zone_file_hostname("soa.primaryNs", &config.soa.primary_ns)?;
307 validate_zone_file_hostname("soa.adminEmail", &config.soa.admin_email)?;
308
309 for ns in &config.name_servers {
310 validate_zone_file_hostname("nameServers entry", ns)?;
311 }
312
313 for (host, ip) in &config.name_server_ips {
314 validate_zone_file_hostname("nameServerIps key", host)?;
315 if ip.parse::<std::net::IpAddr>().is_err() {
316 return Err(ApiError::InvalidRequest(format!(
317 "nameServerIps[{:?}] is not a valid IP address: {:?}",
318 host, ip
319 )));
320 }
321 }
322
323 for record in &config.records {
324 crate::records::validate_record_type(&record.record_type)?;
325 crate::records::validate_record_name(&record.name)?;
326 crate::records::validate_record_value(&record.record_type, &record.value)?;
327 }
328
329 Ok(())
330}
331
332pub fn resolve_zone_dir(raw_dir: &str) -> Result<String, ApiError> {
358 let canonical = std::fs::canonicalize(raw_dir).map_err(|e| {
359 ApiError::InternalError(format!(
360 "zone directory {:?} could not be resolved: {}",
361 raw_dir, e
362 ))
363 })?;
364
365 if !canonical.is_dir() {
366 return Err(ApiError::InternalError(format!(
367 "zone directory {:?} does not resolve to a directory",
368 raw_dir
369 )));
370 }
371
372 canonical.into_os_string().into_string().map_err(|_| {
373 ApiError::InternalError(format!(
374 "zone directory {:?} resolves to a non-UTF-8 path",
375 raw_dir
376 ))
377 })
378}
379
380pub fn is_normalized_zone_dir(path: &str) -> bool {
425 use std::path::{Component, Path};
426
427 let path = Path::new(path);
428 if !path.is_absolute() {
429 return false;
430 }
431
432 !path
433 .components()
434 .any(|component| matches!(component, Component::ParentDir | Component::CurDir))
435}
436
437#[utoipa::path(
444 post,
445 path = "/api/v1/zones",
446 request_body = CreateZoneRequest,
447 responses(
448 (status = 201, description = "Zone created successfully", body = ZoneResponse),
449 (status = 400, description = "Invalid request"),
450 (status = 409, description = "Zone already exists"),
451 (status = 500, description = "RNDC command failed"),
452 (status = 500, description = "Internal server error")
453 ),
454 tag = "zones"
455)]
456pub async fn create_zone(
457 State(state): State<AppState>,
458 Json(request): Json<CreateZoneRequest>,
459) -> Result<(StatusCode, Json<ZoneResponse>), ApiError> {
460 info!("Creating zone: {}", request.zone_name);
461
462 if let Ok(json_payload) = serde_json::to_string_pretty(&request) {
464 debug!("POST /api/v1/zones payload: {}", json_payload);
465 }
466
467 if let Err(e) = validate_zone_name(&request.zone_name) {
470 metrics::record_zone_operation("create", false);
471 return Err(e);
472 }
473
474 if request.zone_type != ZONE_TYPE_PRIMARY && request.zone_type != ZONE_TYPE_SECONDARY {
476 metrics::record_zone_operation("create", false);
477 return Err(ApiError::InvalidRequest(format!(
478 "Invalid zone type: {}. Must be '{}' or '{}'",
479 request.zone_type, ZONE_TYPE_PRIMARY, ZONE_TYPE_SECONDARY
480 )));
481 }
482
483 if request.zone_type == ZONE_TYPE_SECONDARY
485 && request
486 .zone_config
487 .primaries
488 .as_ref()
489 .is_none_or(|p| p.is_empty())
490 {
491 metrics::record_zone_operation("create", false);
492 return Err(ApiError::InvalidRequest(
493 "Secondary zones require at least one primary server in 'primaries' field".to_string(),
494 ));
495 }
496
497 if let Some(key_name) = &request.update_key_name {
500 if let Err(e) = validate_rndc_identifier("updateKeyName", key_name) {
501 metrics::record_zone_operation("create", false);
502 return Err(e);
503 }
504 }
505 if let Some(dnssec_policy) = &request.zone_config.dnssec_policy {
506 if let Err(e) = validate_rndc_identifier("dnssecPolicy", dnssec_policy) {
507 metrics::record_zone_operation("create", false);
508 return Err(e);
509 }
510 }
511
512 if let Some(primaries) = &request.zone_config.primaries {
513 if let Err(e) = validate_ip_port_list("primaries", primaries) {
514 metrics::record_zone_operation("create", false);
515 return Err(e);
516 }
517 }
518 if let Some(also_notify) = &request.zone_config.also_notify {
519 if let Err(e) = validate_ip_port_list("also-notify", also_notify) {
520 metrics::record_zone_operation("create", false);
521 return Err(e);
522 }
523 }
524 if let Some(allow_transfer) = &request.zone_config.allow_transfer {
525 if let Err(e) = validate_ip_list("allow-transfer", allow_transfer) {
526 metrics::record_zone_operation("create", false);
527 return Err(e);
528 }
529 }
530
531 if request.zone_type == ZONE_TYPE_PRIMARY {
536 if let Err(e) = validate_zone_config_content(&request.zone_config) {
537 metrics::record_zone_operation("create", false);
538 return Err(e);
539 }
540 }
541
542 let zone_content = if request.zone_type == ZONE_TYPE_PRIMARY {
544 request.zone_config.to_zone_file()
545 } else {
546 String::new() };
548
549 let zone_file_name = format!("{}.zone", request.zone_name);
551 let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
552
553 if request.zone_type == ZONE_TYPE_PRIMARY {
554 info!(
555 "Generated zone file content for {}: {} bytes",
556 request.zone_name,
557 zone_content.len()
558 );
559
560 let journal_file_name = format!("{}.zone.jnl", request.zone_name);
562 let journal_file_path = PathBuf::from(&state.zone_dir).join(&journal_file_name);
563 if journal_file_path.exists() {
564 if let Err(e) = tokio::fs::remove_file(&journal_file_path).await {
565 error!(
566 "Failed to remove old journal file {}: {}",
567 journal_file_path.display(),
568 e
569 );
570 } else {
571 info!("Removed old journal file: {}", journal_file_path.display());
572 }
573 }
574
575 tokio::fs::write(&zone_file_path, &zone_content)
576 .await
577 .map_err(|e| {
578 error!(
579 "Failed to write zone file {}: {}",
580 zone_file_path.display(),
581 e
582 );
583 metrics::record_zone_operation("create", false);
584 ApiError::ZoneFileError(format!("Failed to write zone file: {}", e))
585 })?;
586
587 info!("Wrote zone file: {}", zone_file_path.display());
588 }
589
590 let mut config_parts = vec![format!(r#"type {}"#, request.zone_type)];
592
593 if request.zone_type == ZONE_TYPE_PRIMARY {
595 let zone_file_full_path = format!("{}/{}", state.zone_dir, zone_file_name);
596 config_parts.push(format!(r#"file "{}""#, zone_file_full_path));
597 }
598
599 if request.zone_type == ZONE_TYPE_SECONDARY {
601 if let Some(primaries) = &request.zone_config.primaries {
602 if !primaries.is_empty() {
603 let primaries_list = primaries
604 .iter()
605 .map(|s| render_ip_port_entry(s))
606 .collect::<String>();
607 config_parts.push(format!(r#"primaries {{ {} }}"#, primaries_list));
608 }
609 }
610 }
611
612 if let Some(key_name) = &request.update_key_name {
614 config_parts.push(format!(r#"allow-update {{ key "{}"; }}"#, key_name));
615 }
616
617 if let Some(also_notify) = &request.zone_config.also_notify {
619 if !also_notify.is_empty() {
620 let notify_list = also_notify
621 .iter()
622 .map(|s| render_ip_port_entry(s))
623 .collect::<String>();
624 config_parts.push(format!(r#"also-notify {{ {} }}"#, notify_list));
625 }
626 }
627
628 if let Some(allow_transfer) = &request.zone_config.allow_transfer {
630 if !allow_transfer.is_empty() {
631 let transfer_list = allow_transfer
632 .iter()
633 .map(|ip| format!("{}; ", ip))
634 .collect::<String>();
635 config_parts.push(format!(r#"allow-transfer {{ {} }}"#, transfer_list));
636 }
637 }
638
639 if let Some(dnssec_policy) = &request.zone_config.dnssec_policy {
641 config_parts.push(format!(r#"dnssec-policy "{}""#, dnssec_policy));
642 }
643
644 if let Some(inline_signing) = request.zone_config.inline_signing {
646 config_parts.push(format!(
647 r#"inline-signing {}"#,
648 if inline_signing { "yes" } else { "no" }
649 ));
650 }
651
652 let zone_config = format!("{{ {}; }};", config_parts.join("; "));
654
655 let output = state
657 .rndc
658 .addzone(&request.zone_name, &zone_config)
659 .await
660 .map_err(|e| {
661 error!("RNDC addzone failed for {}: {}", request.zone_name, e);
662 metrics::record_zone_operation("create", false);
663
664 let error_msg = e.to_string();
666 if error_msg.contains("already exists") {
667 ApiError::ZoneAlreadyExists(request.zone_name.clone())
668 } else {
669 ApiError::RndcError(error_msg)
670 }
671 })?;
672
673 info!("Zone {} created successfully", request.zone_name);
674 metrics::record_zone_operation("create", true);
675
676 Ok((
677 StatusCode::CREATED,
678 Json(ZoneResponse {
679 success: true,
680 message: format!("Zone {} created successfully", request.zone_name),
681 details: Some(output),
682 }),
683 ))
684}
685
686pub(crate) fn zone_command_error(zone_name: &str, err: &impl std::fmt::Display) -> ApiError {
694 let message = err.to_string();
695 if message.contains("not found") {
696 ApiError::ZoneNotFound(zone_name.to_string())
697 } else {
698 ApiError::RndcError(message)
699 }
700}
701
702#[utoipa::path(
703 delete,
704 path = "/api/v1/zones/{name}",
705 params(
706 ("name" = String, Path, description = "Zone name to delete")
707 ),
708 responses(
709 (status = 200, description = "Zone deleted successfully", body = ZoneResponse),
710 (status = 404, description = "Zone not found"),
711 (status = 500, description = "RNDC command failed")
712 ),
713 tag = "zones"
714)]
715pub async fn delete_zone(
716 State(state): State<AppState>,
717 Path(zone_name): Path<String>,
718) -> Result<Json<ZoneResponse>, ApiError> {
719 info!("Deleting zone: {}", zone_name);
720
721 if let Err(e) = validate_zone_name(&zone_name) {
724 metrics::record_zone_operation("delete", false);
725 return Err(e);
726 }
727
728 let output = state.rndc.delzone(&zone_name).await.map_err(|e| {
730 error!("RNDC delzone failed for {}: {}", zone_name, e);
731 metrics::record_zone_operation("delete", false);
732 zone_command_error(&zone_name, &e)
733 })?;
734
735 let zone_file_name = format!("{}.zone", zone_name);
737 let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
738
739 if zone_file_path.exists() {
740 if let Err(e) = tokio::fs::remove_file(&zone_file_path).await {
741 error!(
742 "Failed to delete zone file {}: {}",
743 zone_file_path.display(),
744 e
745 );
746 } else {
748 info!("Deleted zone file: {}", zone_file_path.display());
749 }
750 }
751
752 let journal_file_name = format!("{}.zone.jnl", zone_name);
754 let journal_file_path = PathBuf::from(&state.zone_dir).join(&journal_file_name);
755
756 if journal_file_path.exists() {
757 if let Err(e) = tokio::fs::remove_file(&journal_file_path).await {
758 error!(
759 "Failed to delete journal file {}: {}",
760 journal_file_path.display(),
761 e
762 );
763 } else {
765 info!("Deleted journal file: {}", journal_file_path.display());
766 }
767 }
768
769 info!("Zone {} deleted successfully", zone_name);
770 metrics::record_zone_operation("delete", true);
771
772 Ok(Json(ZoneResponse {
773 success: true,
774 message: format!("Zone {} deleted successfully", zone_name),
775 details: Some(output),
776 }))
777}
778
779#[utoipa::path(
781 post,
782 path = "/api/v1/zones/{name}/reload",
783 params(
784 ("name" = String, Path, description = "Zone name to reload")
785 ),
786 responses(
787 (status = 200, description = "Zone reloaded successfully", body = ZoneResponse),
788 (status = 404, description = "Zone not found"),
789 (status = 500, description = "RNDC command failed")
790 ),
791 tag = "zones"
792)]
793pub async fn reload_zone(
794 State(state): State<AppState>,
795 Path(zone_name): Path<String>,
796) -> Result<Json<ZoneResponse>, ApiError> {
797 info!("Reloading zone: {}", zone_name);
798
799 if let Err(e) = validate_zone_name(&zone_name) {
802 metrics::record_zone_operation("reload", false);
803 return Err(e);
804 }
805
806 let output = state.rndc.reload(&zone_name).await.map_err(|e| {
807 error!("RNDC reload failed for {}: {}", zone_name, e);
808 metrics::record_zone_operation("reload", false);
809 zone_command_error(&zone_name, &e)
810 })?;
811
812 info!("Zone {} reloaded successfully", zone_name);
813 metrics::record_zone_operation("reload", true);
814
815 Ok(Json(ZoneResponse {
816 success: true,
817 message: format!("Zone {} reloaded successfully", zone_name),
818 details: Some(output),
819 }))
820}
821
822#[utoipa::path(
830 get,
831 path = "/api/v1/zones/{name}/status",
832 params(
833 ("name" = String, Path, description = "Zone name")
834 ),
835 responses(
836 (status = 200, description = "Zone status retrieved", body = ZoneStatusResponse),
837 (status = 404, description = "Zone not found"),
838 (status = 500, description = "RNDC command failed")
839 ),
840 tag = "zones"
841)]
842pub async fn zone_status(
843 State(state): State<AppState>,
844 Path(zone_name): Path<String>,
845) -> Result<Json<ZoneStatusResponse>, ApiError> {
846 info!("Getting status for zone: {}", zone_name);
847
848 validate_zone_name(&zone_name)?;
851
852 let output = state.rndc.zonestatus(&zone_name).await.map_err(|e| {
853 error!("RNDC zonestatus failed for {}: {}", zone_name, e);
854 zone_command_error(&zone_name, &e)
855 })?;
856
857 let dnssec = match state.rndc.dnssec_status(&zone_name).await {
861 Ok(status_output) => Some(crate::dnssec::parse_dnssec_status(&status_output)),
862 Err(e) => {
863 warn!("RNDC dnssec -status failed for {}: {}", zone_name, e);
864 None
865 }
866 };
867
868 Ok(Json(ZoneStatusResponse {
869 success: true,
870 message: format!("Zone {} status retrieved", zone_name),
871 details: Some(output),
872 dnssec,
873 }))
874}
875
876#[utoipa::path(
883 get,
884 path = "/api/v1/zones/{name}/ds",
885 params(
886 ("name" = String, Path, description = "Zone name")
887 ),
888 responses(
889 (status = 200, description = "DS records computed", body = DsSetResponse),
890 (status = 404, description = "Zone not found or not signed"),
891 (status = 501, description = "Key directory not configured"),
892 (status = 500, description = "RNDC command failed")
893 ),
894 tag = "zones"
895)]
896pub async fn get_zone_ds(
897 State(state): State<AppState>,
898 Path(zone_name): Path<String>,
899) -> Result<Json<DsSetResponse>, ApiError> {
900 info!("Computing DS records for zone: {}", zone_name);
901
902 validate_zone_name(&zone_name)?;
903
904 let Some(key_dir) = state.key_dir.as_deref() else {
905 return Err(ApiError::NotConfigured(
906 "DS retrieval requires the DNSSEC key directory to be mounted read-only \
907 and configured via BIND_KEY_DIR"
908 .to_string(),
909 ));
910 };
911
912 if !is_normalized_zone_dir(key_dir) || key_dir.contains("..") {
915 error!("key directory {:?} failed the normalization guard", key_dir);
916 return Err(ApiError::InternalError(
917 "key directory misconfigured".to_string(),
918 ));
919 }
920
921 let status_output = state.rndc.dnssec_status(&zone_name).await.map_err(|e| {
922 error!("RNDC dnssec -status failed for {}: {}", zone_name, e);
923 zone_command_error(&zone_name, &e)
924 })?;
925 let status = crate::dnssec::parse_dnssec_status(&status_output);
926
927 if status.policy.is_none() {
928 return Err(ApiError::DsNotAvailable(format!(
929 "zone {} has no dnssec-policy; there is nothing to delegate",
930 zone_name
931 )));
932 }
933
934 let ksk_tags = status.ksk_tags();
935 if ksk_tags.is_empty() {
936 return Err(ApiError::DsNotAvailable(format!(
937 "zone {} reports no key-signing keys",
938 zone_name
939 )));
940 }
941
942 let file_prefix = format!("K{}.+", zone_name);
946 let mut ds_records: Vec<DsRecordView> = Vec::new();
947 let mut entries = tokio::fs::read_dir(key_dir).await.map_err(|e| {
948 error!("failed to read key directory {:?}: {}", key_dir, e);
949 ApiError::InternalError("key directory unreadable".to_string())
950 })?;
951 while let Some(entry) = entries.next_entry().await.map_err(|e| {
952 error!("failed to iterate key directory {:?}: {}", key_dir, e);
953 ApiError::InternalError("key directory unreadable".to_string())
954 })? {
955 let file_name = entry.file_name();
956 let Some(name) = file_name.to_str() else {
957 continue;
958 };
959 if !name.starts_with(&file_prefix) || !name.ends_with(".key") {
960 continue;
961 }
962 let contents = match tokio::fs::read_to_string(entry.path()).await {
963 Ok(contents) => contents,
964 Err(e) => {
965 warn!("skipping unreadable key file {:?}: {}", name, e);
966 continue;
967 }
968 };
969 let dnskey = match crate::dnssec::parse_key_file(&contents) {
970 Ok(dnskey) => dnskey,
971 Err(e) => {
972 warn!("skipping unparseable key file {:?}: {}", name, e);
973 continue;
974 }
975 };
976 if !ksk_tags.contains(&dnskey.key_tag()) {
977 continue;
978 }
979 let ds = dnskey.ds_record().map_err(|e| {
980 error!("DS computation failed for key file {:?}: {}", name, e);
981 ApiError::InternalError("DS computation failed".to_string())
982 })?;
983 let rr = format!("{} IN DS {}", dnskey.owner, ds.rdata());
984 ds_records.push(DsRecordView {
985 key_tag: ds.key_tag,
986 algorithm: ds.algorithm,
987 digest_type: ds.digest_type,
988 digest: ds.digest,
989 rr,
990 });
991 }
992
993 if ds_records.is_empty() {
994 return Err(ApiError::DsNotAvailable(format!(
995 "no public key files for the key-signing keys of zone {} were found; \
996 is the key directory mounted?",
997 zone_name
998 )));
999 }
1000 ds_records.sort_by_key(|r| r.key_tag);
1001
1002 Ok(Json(DsSetResponse {
1003 success: true,
1004 zone: zone_name,
1005 ds_records,
1006 }))
1007}
1008
1009#[utoipa::path(
1015 post,
1016 path = "/api/v1/zones/{name}/dnssec/checkds",
1017 request_body = CheckdsRequest,
1018 params(
1019 ("name" = String, Path, description = "Zone name")
1020 ),
1021 responses(
1022 (status = 200, description = "DS state recorded", body = ZoneResponse),
1023 (status = 404, description = "Zone not found"),
1024 (status = 500, description = "RNDC command failed")
1025 ),
1026 tag = "zones"
1027)]
1028pub async fn checkds_zone(
1029 State(state): State<AppState>,
1030 Path(zone_name): Path<String>,
1031 Json(request): Json<CheckdsRequest>,
1032) -> Result<Json<ZoneResponse>, ApiError> {
1033 info!(
1034 "Recording DS {} for zone {} key tag {}",
1035 request.ds.as_str(),
1036 zone_name,
1037 request.key_tag
1038 );
1039
1040 validate_zone_name(&zone_name)?;
1041
1042 let output = state
1043 .rndc
1044 .dnssec_checkds(&zone_name, request.key_tag, request.ds)
1045 .await
1046 .map_err(|e| {
1047 error!("RNDC dnssec -checkds failed for {}: {}", zone_name, e);
1048 zone_command_error(&zone_name, &e)
1049 })?;
1050
1051 Ok(Json(ZoneResponse {
1052 success: true,
1053 message: format!(
1054 "DS {} recorded for zone {} key tag {}",
1055 request.ds.as_str(),
1056 zone_name,
1057 request.key_tag
1058 ),
1059 details: Some(output),
1060 }))
1061}
1062
1063#[utoipa::path(
1065 post,
1066 path = "/api/v1/zones/{name}/freeze",
1067 params(
1068 ("name" = String, Path, description = "Zone name to freeze")
1069 ),
1070 responses(
1071 (status = 200, description = "Zone frozen successfully", body = ZoneResponse),
1072 (status = 404, description = "Zone not found"),
1073 (status = 500, description = "RNDC command failed")
1074 ),
1075 tag = "zones"
1076)]
1077pub async fn freeze_zone(
1078 State(state): State<AppState>,
1079 Path(zone_name): Path<String>,
1080) -> Result<Json<ZoneResponse>, ApiError> {
1081 info!("Freezing zone: {}", zone_name);
1082
1083 if let Err(e) = validate_zone_name(&zone_name) {
1086 metrics::record_zone_operation("freeze", false);
1087 return Err(e);
1088 }
1089
1090 let output = state.rndc.freeze(&zone_name).await.map_err(|e| {
1091 error!("RNDC freeze failed for {}: {}", zone_name, e);
1092 metrics::record_zone_operation("freeze", false);
1093 zone_command_error(&zone_name, &e)
1094 })?;
1095
1096 info!("Zone {} frozen successfully", zone_name);
1097 metrics::record_zone_operation("freeze", true);
1098
1099 Ok(Json(ZoneResponse {
1100 success: true,
1101 message: format!("Zone {} frozen successfully", zone_name),
1102 details: Some(output),
1103 }))
1104}
1105
1106#[utoipa::path(
1108 post,
1109 path = "/api/v1/zones/{name}/thaw",
1110 params(
1111 ("name" = String, Path, description = "Zone name to thaw")
1112 ),
1113 responses(
1114 (status = 200, description = "Zone thawed successfully", body = ZoneResponse),
1115 (status = 404, description = "Zone not found"),
1116 (status = 500, description = "RNDC command failed")
1117 ),
1118 tag = "zones"
1119)]
1120pub async fn thaw_zone(
1121 State(state): State<AppState>,
1122 Path(zone_name): Path<String>,
1123) -> Result<Json<ZoneResponse>, ApiError> {
1124 info!("Thawing zone: {}", zone_name);
1125
1126 if let Err(e) = validate_zone_name(&zone_name) {
1129 metrics::record_zone_operation("thaw", false);
1130 return Err(e);
1131 }
1132
1133 let output = state.rndc.thaw(&zone_name).await.map_err(|e| {
1134 error!("RNDC thaw failed for {}: {}", zone_name, e);
1135 metrics::record_zone_operation("thaw", false);
1136 zone_command_error(&zone_name, &e)
1137 })?;
1138
1139 info!("Zone {} thawed successfully", zone_name);
1140 metrics::record_zone_operation("thaw", true);
1141
1142 Ok(Json(ZoneResponse {
1143 success: true,
1144 message: format!("Zone {} thawed successfully", zone_name),
1145 details: Some(output),
1146 }))
1147}
1148
1149#[utoipa::path(
1151 post,
1152 path = "/api/v1/zones/{name}/notify",
1153 params(
1154 ("name" = String, Path, description = "Zone name")
1155 ),
1156 responses(
1157 (status = 200, description = "Notify sent successfully", body = ZoneResponse),
1158 (status = 404, description = "Zone not found"),
1159 (status = 500, description = "RNDC command failed")
1160 ),
1161 tag = "zones"
1162)]
1163pub async fn notify_zone(
1164 State(state): State<AppState>,
1165 Path(zone_name): Path<String>,
1166) -> Result<Json<ZoneResponse>, ApiError> {
1167 info!("Notifying secondaries for zone: {}", zone_name);
1168
1169 if let Err(e) = validate_zone_name(&zone_name) {
1172 metrics::record_zone_operation("notify", false);
1173 return Err(e);
1174 }
1175
1176 let output = state.rndc.notify(&zone_name).await.map_err(|e| {
1177 error!("RNDC notify failed for {}: {}", zone_name, e);
1178 metrics::record_zone_operation("notify", false);
1179 zone_command_error(&zone_name, &e)
1180 })?;
1181
1182 info!("Zone {} notify sent successfully", zone_name);
1183 metrics::record_zone_operation("notify", true);
1184
1185 Ok(Json(ZoneResponse {
1186 success: true,
1187 message: format!("Notify sent for zone {}", zone_name),
1188 details: Some(output),
1189 }))
1190}
1191
1192#[utoipa::path(
1194 post,
1195 path = "/api/v1/zones/{name}/retransfer",
1196 params(
1197 ("name" = String, Path, description = "Zone name to retransfer")
1198 ),
1199 responses(
1200 (status = 200, description = "Zone retransfer initiated", body = ZoneResponse),
1201 (status = 404, description = "Zone not found"),
1202 (status = 500, description = "RNDC command failed")
1203 ),
1204 tag = "zones"
1205)]
1206pub async fn retransfer_zone(
1207 State(state): State<AppState>,
1208 Path(zone_name): Path<String>,
1209) -> Result<Json<ZoneResponse>, ApiError> {
1210 info!("Retransferring zone: {}", zone_name);
1211
1212 if let Err(e) = validate_zone_name(&zone_name) {
1215 metrics::record_zone_operation("retransfer", false);
1216 return Err(e);
1217 }
1218
1219 let output = state.rndc.retransfer(&zone_name).await.map_err(|e| {
1220 error!("RNDC retransfer failed for {}: {}", zone_name, e);
1221 metrics::record_zone_operation("retransfer", false);
1222 zone_command_error(&zone_name, &e)
1223 })?;
1224
1225 info!("Zone {} retransfer initiated successfully", zone_name);
1226 metrics::record_zone_operation("retransfer", true);
1227
1228 Ok(Json(ZoneResponse {
1229 success: true,
1230 message: format!("Retransfer initiated for zone {}", zone_name),
1231 details: Some(output),
1232 }))
1233}
1234
1235#[utoipa::path(
1237 get,
1238 path = "/api/v1/server/status",
1239 responses(
1240 (status = 200, description = "Server status retrieved", body = ServerStatusResponse),
1241 (status = 500, description = "RNDC command failed")
1242 ),
1243 tag = "server"
1244)]
1245pub async fn server_status(
1246 State(state): State<AppState>,
1247) -> Result<Json<ServerStatusResponse>, ApiError> {
1248 info!("Getting server status");
1249
1250 let output = state.rndc.status().await.map_err(|e| {
1251 error!("RNDC status failed: {}", e);
1252 ApiError::RndcError(e.to_string())
1253 })?;
1254
1255 Ok(Json(ServerStatusResponse { status: output }))
1256}
1257
1258#[utoipa::path(
1260 get,
1261 path = "/api/v1/zones",
1262 responses(
1263 (status = 200, description = "List of zones", body = ZoneListResponse),
1264 (status = 500, description = "Failed to read zone directory")
1265 ),
1266 tag = "zones"
1267)]
1268pub async fn list_zones(State(state): State<AppState>) -> Result<Json<ZoneListResponse>, ApiError> {
1269 info!("Listing all zones");
1270
1271 if !is_normalized_zone_dir(&state.zone_dir) || state.zone_dir.contains("..") {
1278 error!(
1279 "zone directory {:?} is not a normalized absolute path; refusing to list it",
1280 state.zone_dir
1281 );
1282 return Err(ApiError::InternalError(
1283 "zone directory is not a normalized absolute path".to_string(),
1284 ));
1285 }
1286
1287 let mut zones = Vec::new();
1289 let mut entries = tokio::fs::read_dir(&state.zone_dir).await.map_err(|e| {
1290 error!("Failed to read zone directory: {}", e);
1291 ApiError::InternalError(format!("Failed to read zone directory: {}", e))
1292 })?;
1293
1294 while let Ok(Some(entry)) = entries.next_entry().await {
1295 if let Ok(file_name) = entry.file_name().into_string() {
1296 if file_name.ends_with(".zone") {
1297 if let Some(zone_name) = file_name.strip_suffix(".zone") {
1299 zones.push(zone_name.to_string());
1300 }
1301 }
1302 }
1303 }
1304
1305 zones.sort();
1306 let count = zones.len();
1307
1308 info!("Found {} zones", count);
1309 metrics::update_zones_count(count as i64);
1310
1311 Ok(Json(ZoneListResponse { zones, count }))
1312}
1313
1314#[utoipa::path(
1316 get,
1317 path = "/api/v1/zones/{name}",
1318 params(
1319 ("name" = String, Path, description = "Zone name")
1320 ),
1321 responses(
1322 (status = 200, description = "Zone information", body = ZoneInfo),
1323 (status = 404, description = "Zone not found"),
1324 (status = 500, description = "RNDC command failed")
1325 ),
1326 tag = "zones"
1327)]
1328pub async fn get_zone(
1329 State(state): State<AppState>,
1330 Path(zone_name): Path<String>,
1331) -> Result<Json<ZoneInfo>, ApiError> {
1332 info!("Getting zone: {}", zone_name);
1333
1334 validate_zone_name(&zone_name)?;
1339
1340 let zone_file_name = format!("{}.zone", zone_name);
1342 let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
1343
1344 if !zone_file_path.exists() {
1345 return Err(ApiError::ZoneNotFound(zone_name.clone()));
1346 }
1347
1348 let status_output = state.rndc.zonestatus(&zone_name).await.map_err(|e| {
1350 error!("RNDC zonestatus failed for {}: {}", zone_name, e);
1351 zone_command_error(&zone_name, &e)
1352 })?;
1353
1354 let mut zone_type = "unknown".to_string();
1356 let mut serial = None;
1357
1358 for line in status_output.lines() {
1359 if let Some(type_str) = line.strip_prefix("type:").or_else(|| {
1360 line.contains("type:")
1361 .then(|| line.split("type:").nth(1))
1362 .flatten()
1363 }) {
1364 zone_type = type_str.trim().to_string();
1365 }
1366
1367 if let Some(serial_str) = line.strip_prefix("serial:").or_else(|| {
1368 line.contains("serial:")
1369 .then(|| line.split("serial:").nth(1))
1370 .flatten()
1371 }) {
1372 if let Ok(s) = serial_str.trim().parse::<u32>() {
1373 serial = Some(s);
1374 }
1375 }
1376 }
1377
1378 Ok(Json(ZoneInfo {
1379 name: zone_name,
1380 zone_type,
1381 serial,
1382 file_path: Some(zone_file_path.display().to_string()),
1383 }))
1384}
1385
1386pub const DNSSEC_POLICY_INSECURE: &str = "insecure";
1388
1389pub const DNSSEC_POLICY_NONE: &str = "none";
1391
1392pub fn apply_dnssec_request(
1421 zone_config: &mut crate::rndc_types::ZoneConfig,
1422 requested_policy: Option<&str>,
1423 requested_inline_signing: Option<bool>,
1424 zone_serves_dnskey: bool,
1425) -> Result<bool, ApiError> {
1426 let mut changed = false;
1427
1428 if let Some(policy) = requested_policy {
1429 if policy.eq_ignore_ascii_case(DNSSEC_POLICY_NONE) {
1430 if zone_serves_dnskey {
1431 return Err(ApiError::UnsafeDnssecTransition(format!(
1432 "zone still serves DNSKEY records; removing dnssec-policy now would \
1433 take the zone dark for validating resolvers. Transition to the \
1434 built-in \"{}\" policy first, confirm DS withdrawal at the parent \
1435 (POST .../dnssec/checkds), and remove the policy once the zone no \
1436 longer serves DNSKEY/RRSIG records",
1437 DNSSEC_POLICY_INSECURE
1438 )));
1439 }
1440 zone_config.dnssec_policy = None;
1441 } else {
1442 let zone_is_dynamic = zone_config.allow_update.is_some()
1443 || zone_config.allow_update_raw.is_some()
1444 || zone_config.update_policy.is_some();
1445 zone_config.dnssec_policy = Some(policy.to_string());
1446 if requested_inline_signing.is_none()
1448 && zone_config.inline_signing.is_none()
1449 && !zone_is_dynamic
1450 {
1451 zone_config.inline_signing = Some(true);
1452 }
1453 }
1454 changed = true;
1455 }
1456
1457 if let Some(inline_signing) = requested_inline_signing {
1458 zone_config.inline_signing = Some(inline_signing);
1459 changed = true;
1460 }
1461
1462 Ok(changed)
1463}
1464
1465#[utoipa::path(
1474 patch,
1475 path = "/api/v1/zones/{name}",
1476 request_body = ModifyZoneRequest,
1477 params(
1478 ("name" = String, Path, description = "Zone name to modify")
1479 ),
1480 responses(
1481 (status = 200, description = "Zone modified successfully", body = ZoneResponse),
1482 (status = 400, description = "Invalid request"),
1483 (status = 404, description = "Zone not found"),
1484 (status = 500, description = "RNDC command failed"),
1485 (status = 500, description = "Internal server error")
1486 ),
1487 tag = "zones"
1488)]
1489pub async fn modify_zone(
1490 State(state): State<AppState>,
1491 Path(zone_name): Path<String>,
1492 Json(request): Json<ModifyZoneRequest>,
1493) -> Result<Json<ZoneResponse>, ApiError> {
1494 info!("Modifying zone: {}", zone_name);
1495
1496 if let Err(e) = validate_zone_name(&zone_name) {
1499 metrics::record_zone_operation("modify", false);
1500 return Err(e);
1501 }
1502
1503 if let Ok(json_payload) = serde_json::to_string_pretty(&request) {
1505 debug!(
1506 "PATCH /api/v1/zones/{} payload: {}",
1507 zone_name, json_payload
1508 );
1509 }
1510
1511 if request.also_notify.is_none()
1513 && request.allow_transfer.is_none()
1514 && request.allow_update.is_none()
1515 && request.dnssec_policy.is_none()
1516 && request.inline_signing.is_none()
1517 {
1518 metrics::record_zone_operation("modify", false);
1519 return Err(ApiError::InvalidRequest(
1520 "At least one field (alsoNotify, allowTransfer, allowUpdate, dnssecPolicy, \
1521 or inlineSigning) must be provided"
1522 .to_string(),
1523 ));
1524 }
1525
1526 if let Some(dnssec_policy) = &request.dnssec_policy {
1529 if let Err(e) = validate_rndc_identifier("dnssecPolicy", dnssec_policy) {
1530 metrics::record_zone_operation("modify", false);
1531 return Err(e);
1532 }
1533 }
1534
1535 let zone_file_name = format!("{}.zone", zone_name);
1537 let zone_file_path = PathBuf::from(&state.zone_dir).join(&zone_file_name);
1538
1539 let zone_exists = if zone_file_path.exists() {
1541 true
1542 } else {
1543 match state.rndc.zonestatus(&zone_name).await {
1545 Ok(_) => true,
1546 Err(e) => {
1547 if e.to_string().contains("not found") {
1548 false
1549 } else {
1550 true
1552 }
1553 }
1554 }
1555 };
1556
1557 if !zone_exists {
1558 metrics::record_zone_operation("modify", false);
1559 return Err(ApiError::ZoneNotFound(zone_name.clone()));
1560 }
1561
1562 let showzone_output = state.rndc.showzone(&zone_name).await.map_err(|e| {
1564 error!("Failed to get zone configuration for {}: {}", zone_name, e);
1565 zone_command_error(&zone_name, &e)
1566 })?;
1567
1568 let mut zone_config = crate::rndc_parser::parse_showzone(&showzone_output).map_err(|e| {
1570 error!(
1571 "Failed to parse zone configuration for {}: {}",
1572 zone_name, e
1573 );
1574 ApiError::RndcError(format!("Failed to parse zone configuration: {}", e))
1575 })?;
1576
1577 info!(
1578 "Zone {} has type: {}",
1579 zone_name,
1580 zone_config.zone_type.as_str()
1581 );
1582
1583 if let Some(also_notify) = &request.also_notify {
1585 let ip_addrs: Result<Vec<std::net::IpAddr>, _> =
1587 also_notify.iter().map(|s| s.parse()).collect();
1588
1589 match ip_addrs {
1590 Ok(addrs) => {
1591 zone_config.also_notify = if addrs.is_empty() { None } else { Some(addrs) };
1592 }
1593 Err(e) => {
1594 metrics::record_zone_operation("modify", false);
1595 return Err(ApiError::InvalidRequest(format!(
1596 "Invalid IP address in also-notify: {}",
1597 e
1598 )));
1599 }
1600 }
1601 }
1602
1603 if let Some(allow_transfer) = &request.allow_transfer {
1604 let ip_addrs: Result<Vec<std::net::IpAddr>, _> =
1606 allow_transfer.iter().map(|s| s.parse()).collect();
1607
1608 match ip_addrs {
1609 Ok(addrs) => {
1610 zone_config.allow_transfer = if addrs.is_empty() { None } else { Some(addrs) };
1611 }
1612 Err(e) => {
1613 metrics::record_zone_operation("modify", false);
1614 return Err(ApiError::InvalidRequest(format!(
1615 "Invalid IP address in allow-transfer: {}",
1616 e
1617 )));
1618 }
1619 }
1620 }
1621
1622 if let Some(allow_update) = &request.allow_update {
1623 let ip_addrs: Result<Vec<std::net::IpAddr>, _> =
1625 allow_update.iter().map(|s| s.parse()).collect();
1626
1627 match ip_addrs {
1628 Ok(addrs) => {
1629 zone_config.allow_update = if addrs.is_empty() { None } else { Some(addrs) };
1630 zone_config.allow_update_raw = None;
1632 }
1633 Err(e) => {
1634 metrics::record_zone_operation("modify", false);
1635 return Err(ApiError::InvalidRequest(format!(
1636 "Invalid IP address in allow-update: {}",
1637 e
1638 )));
1639 }
1640 }
1641 }
1642
1643 let removal_requested = request
1647 .dnssec_policy
1648 .as_deref()
1649 .is_some_and(|p| p.eq_ignore_ascii_case(DNSSEC_POLICY_NONE));
1650 let zone_serves_dnskey = if removal_requested {
1651 let status_output = state.rndc.dnssec_status(&zone_name).await.map_err(|e| {
1652 error!("RNDC dnssec -status failed for {}: {}", zone_name, e);
1653 metrics::record_zone_operation("modify", false);
1654 ApiError::RndcError(e.to_string())
1655 })?;
1656 crate::dnssec::parse_dnssec_status(&status_output).signed
1657 } else {
1658 false
1659 };
1660
1661 let dnssec_changed = apply_dnssec_request(
1662 &mut zone_config,
1663 request.dnssec_policy.as_deref(),
1664 request.inline_signing,
1665 zone_serves_dnskey,
1666 )
1667 .inspect_err(|_| {
1668 metrics::record_zone_operation("modify", false);
1669 })?;
1670
1671 let rndc_config_block = zone_config.to_rndc_block();
1673
1674 info!(
1675 "Modifying zone {} with config: {}",
1676 zone_name, rndc_config_block
1677 );
1678
1679 let output = state
1681 .rndc
1682 .modzone(&zone_name, &rndc_config_block)
1683 .await
1684 .map_err(|e| {
1685 error!("RNDC modzone failed for {}: {}", zone_name, e);
1686 metrics::record_zone_operation("modify", false);
1687 ApiError::RndcError(e.to_string())
1688 })?;
1689
1690 if dnssec_changed {
1695 if let Err(e) = state.rndc.reconfig().await {
1696 error!(
1697 "RNDC reconfig failed after modzone for {}: {}",
1698 zone_name, e
1699 );
1700 metrics::record_zone_operation("modify", true);
1701 return Ok(Json(ZoneResponse {
1702 success: true,
1703 message: format!(
1704 "Zone {} configuration stored, but reconfig failed: the DNSSEC \
1705 change is persisted and will activate on the next successful \
1706 reconfig or server restart",
1707 zone_name
1708 ),
1709 details: Some(output),
1710 }));
1711 }
1712 info!(
1713 "Zone {} DNSSEC configuration activated via reconfig",
1714 zone_name
1715 );
1716 }
1717
1718 info!("Zone {} modified successfully", zone_name);
1719 metrics::record_zone_operation("modify", true);
1720
1721 Ok(Json(ZoneResponse {
1722 success: true,
1723 message: format!("Zone {} modified successfully", zone_name),
1724 details: Some(output),
1725 }))
1726}