1use axum::{
14 extract::{Path, State},
15 http::StatusCode,
16 Json,
17};
18use serde::{Deserialize, Serialize};
19use tracing::{debug, error, info};
20use utoipa::ToSchema;
21
22use crate::{
23 metrics, rndc_parser, rndc_types,
24 types::{ApiError, AppState},
25};
26
27#[derive(Debug, Serialize, Deserialize, ToSchema)]
29#[serde(rename_all = "camelCase")]
30pub struct AddRecordRequest {
31 pub name: String,
33
34 #[serde(rename = "type")]
36 pub record_type: String,
37
38 pub value: String,
40
41 #[serde(default = "default_ttl")]
43 pub ttl: u32,
44
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub priority: Option<u16>,
48}
49
50#[derive(Debug, Serialize, Deserialize, ToSchema)]
52#[serde(rename_all = "camelCase")]
53pub struct RemoveRecordRequest {
54 pub name: String,
56
57 #[serde(rename = "type")]
59 pub record_type: String,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub value: Option<String>,
64}
65
66#[derive(Debug, Serialize, Deserialize, ToSchema)]
68#[serde(rename_all = "camelCase")]
69pub struct UpdateRecordRequest {
70 pub name: String,
72
73 #[serde(rename = "type")]
75 pub record_type: String,
76
77 pub current_value: String,
79
80 pub new_value: String,
82
83 #[serde(default = "default_ttl")]
85 pub ttl: u32,
86
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub priority: Option<u16>,
90}
91
92#[derive(Debug, Serialize, Deserialize, ToSchema)]
94pub struct RecordResponse {
95 pub success: bool,
96 pub message: String,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub details: Option<serde_json::Value>,
99}
100
101fn default_ttl() -> u32 {
102 3600
103}
104
105const VALID_RECORD_TYPES: &[&str] = &["A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR", "SRV", "CAA"];
107
108async fn validate_zone_for_updates(state: &AppState, zone_name: &str) -> Result<(), ApiError> {
119 crate::zones::validate_zone_name(zone_name)?;
123
124 let zone_config_output = state.rndc.showzone(zone_name).await.map_err(|e| {
126 if e.to_string().contains("not found") {
127 ApiError::ZoneNotFound(zone_name.to_string())
128 } else {
129 ApiError::RndcError(e.to_string())
130 }
131 })?;
132
133 let zone_config = rndc_parser::parse_showzone(&zone_config_output).map_err(|e| {
135 ApiError::InternalError(format!("Failed to parse zone configuration: {}", e))
136 })?;
137
138 if zone_config.zone_type != rndc_types::ZoneType::Primary {
140 return Err(ApiError::DynamicUpdatesNotEnabled(format!(
141 "Zone {} is {} type. Dynamic updates only supported on primary zones",
142 zone_name,
143 zone_config.zone_type.as_str()
144 )));
145 }
146
147 if zone_config.allow_update.is_none() && zone_config.allow_update_raw.is_none() {
149 return Err(ApiError::DynamicUpdatesNotEnabled(format!(
150 "Zone {} does not have allow-update configured. \
151 Create zone with updateKeyName or modify zone to enable dynamic updates",
152 zone_name
153 )));
154 }
155
156 Ok(())
157}
158
159pub(crate) fn validate_record_type(record_type: &str) -> Result<(), ApiError> {
161 let upper = record_type.to_uppercase();
162
163 if !VALID_RECORD_TYPES.contains(&upper.as_str()) {
164 return Err(ApiError::InvalidRecord(format!(
165 "Invalid record type: {}. Supported types: {:?}",
166 record_type, VALID_RECORD_TYPES
167 )));
168 }
169
170 Ok(())
171}
172
173pub(crate) fn validate_record_name(name: &str) -> Result<(), ApiError> {
195 if name.is_empty() {
196 return Err(ApiError::InvalidRecord(
197 "Record name cannot be empty".to_string(),
198 ));
199 }
200
201 if let Some(bad) = name
202 .chars()
203 .find(|&c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '@' | '*')))
204 {
205 return Err(ApiError::InvalidRecord(format!(
206 "Record name contains illegal character: {:?} (allowed: A-Z a-z 0-9 . - _ @ *)",
207 bad
208 )));
209 }
210
211 Ok(())
212}
213
214pub(crate) fn validate_record_value(record_type: &str, value: &str) -> Result<(), ApiError> {
221 if value.is_empty() {
223 return Err(ApiError::InvalidRecord(
224 "Record value cannot be empty".to_string(),
225 ));
226 }
227
228 if let Some(bad) = value.chars().find(|c| c.is_control()) {
233 return Err(ApiError::InvalidRecord(format!(
234 "Record value contains illegal control character: {:?}",
235 bad
236 )));
237 }
238
239 match record_type.to_uppercase().as_str() {
240 "A" => {
241 value
243 .parse::<std::net::Ipv4Addr>()
244 .map_err(|_| ApiError::InvalidRecord(format!("Invalid IPv4 address: {}", value)))?;
245 }
246 "AAAA" => {
247 value
249 .parse::<std::net::Ipv6Addr>()
250 .map_err(|_| ApiError::InvalidRecord(format!("Invalid IPv6 address: {}", value)))?;
251 }
252 "CNAME" | "NS" | "PTR" | "MX" => {
253 if !value.ends_with('.') {
255 return Err(ApiError::InvalidRecord(format!(
256 "{} record value must be a fully qualified domain name ending with '.': {}",
257 record_type, value
258 )));
259 }
260 }
261 "TXT" | "CAA" | "SRV" => {
262 }
265 _ => {}
266 }
267
268 Ok(())
269}
270
271fn normalize_record_name(name: &str, zone: &str) -> String {
282 if name == "@" {
283 format!("{}.", zone)
285 } else if name.ends_with('.') {
286 name.to_string()
288 } else if name.contains('.') && name.ends_with(zone) {
289 format!("{}.", name)
291 } else {
292 format!("{}.{}.", name, zone)
294 }
295}
296
297#[utoipa::path(
299 post,
300 path = "/api/v1/zones/{zone_name}/records",
301 request_body = AddRecordRequest,
302 params(
303 ("zone_name" = String, Path, description = "Zone name")
304 ),
305 responses(
306 (status = 201, description = "Record added successfully", body = RecordResponse),
307 (status = 400, description = "Invalid request or zone not configured for updates"),
308 (status = 404, description = "Zone not found"),
309 (status = 500, description = "Update failed"),
310 ),
311 tag = "records"
312)]
313pub async fn add_record(
314 State(state): State<AppState>,
315 Path(zone_name): Path<String>,
316 Json(request): Json<AddRecordRequest>,
317) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
318 info!(
319 "Adding record to zone {}: {} {} {} (TTL: {})",
320 zone_name, request.name, request.record_type, request.value, request.ttl
321 );
322
323 validate_zone_for_updates(&state, &zone_name).await?;
325 validate_record_type(&request.record_type)?;
326 validate_record_name(&request.name)?;
327 validate_record_value(&request.record_type, &request.value)?;
328
329 let fqdn = normalize_record_name(&request.name, &zone_name);
331
332 debug!("Normalized record name: {} -> {}", request.name, fqdn);
333
334 let value_with_priority = if let Some(priority) = request.priority {
336 if request.record_type.to_uppercase() == "MX" || request.record_type.to_uppercase() == "SRV"
337 {
338 format!("{} {}", priority, request.value)
339 } else {
340 request.value.clone()
341 }
342 } else {
343 request.value.clone()
344 };
345
346 let _output = state
348 .nsupdate
349 .add_record(
350 &zone_name,
351 &fqdn,
352 request.ttl,
353 &request.record_type,
354 &value_with_priority,
355 )
356 .await
357 .map_err(|e| {
358 error!("nsupdate add failed: {}", e);
359 metrics::record_record_operation("add", false);
360 ApiError::NsupdateError(format!("Failed to add record: {}", e))
361 })?;
362
363 info!("Record added successfully to zone {}", zone_name);
364 metrics::record_record_operation("add", true);
365
366 Ok((
367 StatusCode::CREATED,
368 Json(RecordResponse {
369 success: true,
370 message: format!("Record added to zone {}", zone_name),
371 details: Some(serde_json::json!({
372 "zone": zone_name,
373 "record": {
374 "name": request.name,
375 "type": request.record_type,
376 "value": request.value,
377 "ttl": request.ttl,
378 }
379 })),
380 }),
381 ))
382}
383
384#[utoipa::path(
386 delete,
387 path = "/api/v1/zones/{zone_name}/records",
388 request_body = RemoveRecordRequest,
389 params(
390 ("zone_name" = String, Path, description = "Zone name")
391 ),
392 responses(
393 (status = 200, description = "Record removed successfully", body = RecordResponse),
394 (status = 400, description = "Invalid request or zone not configured for updates"),
395 (status = 404, description = "Zone not found"),
396 (status = 500, description = "Update failed"),
397 ),
398 tag = "records"
399)]
400pub async fn remove_record(
401 State(state): State<AppState>,
402 Path(zone_name): Path<String>,
403 Json(request): Json<RemoveRecordRequest>,
404) -> Result<Json<RecordResponse>, ApiError> {
405 info!(
406 "Removing record from zone {}: {} {} {:?}",
407 zone_name, request.name, request.record_type, request.value
408 );
409
410 validate_zone_for_updates(&state, &zone_name).await?;
412 validate_record_type(&request.record_type)?;
413 validate_record_name(&request.name)?;
414
415 if let Some(ref value) = request.value {
417 validate_record_value(&request.record_type, value)?;
418 }
419
420 let fqdn = normalize_record_name(&request.name, &zone_name);
422
423 debug!("Normalized record name: {} -> {}", request.name, fqdn);
424
425 let value_str = request.value.as_deref().unwrap_or("");
427 let _output = state
428 .nsupdate
429 .remove_record(&zone_name, &fqdn, &request.record_type, value_str)
430 .await
431 .map_err(|e| {
432 error!("nsupdate remove failed: {}", e);
433 metrics::record_record_operation("remove", false);
434 ApiError::NsupdateError(format!("Failed to remove record: {}", e))
435 })?;
436
437 info!("Record removed successfully from zone {}", zone_name);
438 metrics::record_record_operation("remove", true);
439
440 Ok(Json(RecordResponse {
441 success: true,
442 message: format!("Record removed from zone {}", zone_name),
443 details: Some(serde_json::json!({
444 "zone": zone_name,
445 "record": {
446 "name": request.name,
447 "type": request.record_type,
448 "value": request.value,
449 }
450 })),
451 }))
452}
453
454#[utoipa::path(
456 put,
457 path = "/api/v1/zones/{zone_name}/records",
458 request_body = UpdateRecordRequest,
459 params(
460 ("zone_name" = String, Path, description = "Zone name")
461 ),
462 responses(
463 (status = 200, description = "Record updated successfully", body = RecordResponse),
464 (status = 400, description = "Invalid request or zone not configured for updates"),
465 (status = 404, description = "Zone not found"),
466 (status = 500, description = "Update failed"),
467 ),
468 tag = "records"
469)]
470pub async fn update_record(
471 State(state): State<AppState>,
472 Path(zone_name): Path<String>,
473 Json(request): Json<UpdateRecordRequest>,
474) -> Result<Json<RecordResponse>, ApiError> {
475 info!(
476 "Updating record in zone {}: {} {} from {} to {} (TTL: {})",
477 zone_name,
478 request.name,
479 request.record_type,
480 request.current_value,
481 request.new_value,
482 request.ttl
483 );
484
485 validate_zone_for_updates(&state, &zone_name).await?;
487 validate_record_type(&request.record_type)?;
488 validate_record_name(&request.name)?;
489 validate_record_value(&request.record_type, &request.current_value)?;
490 validate_record_value(&request.record_type, &request.new_value)?;
491
492 let fqdn = normalize_record_name(&request.name, &zone_name);
494
495 debug!("Normalized record name: {} -> {}", request.name, fqdn);
496
497 let (current_with_priority, new_with_priority) = if let Some(priority) = request.priority {
499 if request.record_type.to_uppercase() == "MX" || request.record_type.to_uppercase() == "SRV"
500 {
501 (
502 format!("{} {}", priority, request.current_value),
503 format!("{} {}", priority, request.new_value),
504 )
505 } else {
506 (request.current_value.clone(), request.new_value.clone())
507 }
508 } else {
509 (request.current_value.clone(), request.new_value.clone())
510 };
511
512 let _output = state
514 .nsupdate
515 .update_record(
516 &zone_name,
517 &fqdn,
518 request.ttl,
519 &request.record_type,
520 ¤t_with_priority,
521 &new_with_priority,
522 )
523 .await
524 .map_err(|e| {
525 error!("nsupdate update failed: {}", e);
526 metrics::record_record_operation("update", false);
527 ApiError::NsupdateError(format!("Failed to update record: {}", e))
528 })?;
529
530 info!("Record updated successfully in zone {}", zone_name);
531 metrics::record_record_operation("update", true);
532
533 Ok(Json(RecordResponse {
534 success: true,
535 message: format!("Record updated in zone {}", zone_name),
536 details: Some(serde_json::json!({
537 "zone": zone_name,
538 "record": {
539 "name": request.name,
540 "type": request.record_type,
541 "currentValue": request.current_value,
542 "newValue": request.new_value,
543 "ttl": request.ttl,
544 }
545 })),
546 }))
547}