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 if zone_name.is_empty() {
121 return Err(ApiError::InvalidRequest(
122 "Zone name cannot be empty".to_string(),
123 ));
124 }
125
126 let zone_config_output = state.rndc.showzone(zone_name).await.map_err(|e| {
128 if e.to_string().contains("not found") {
129 ApiError::ZoneNotFound(zone_name.to_string())
130 } else {
131 ApiError::RndcError(e.to_string())
132 }
133 })?;
134
135 let zone_config = rndc_parser::parse_showzone(&zone_config_output).map_err(|e| {
137 ApiError::InternalError(format!("Failed to parse zone configuration: {}", e))
138 })?;
139
140 if zone_config.zone_type != rndc_types::ZoneType::Primary {
142 return Err(ApiError::DynamicUpdatesNotEnabled(format!(
143 "Zone {} is {} type. Dynamic updates only supported on primary zones",
144 zone_name,
145 zone_config.zone_type.as_str()
146 )));
147 }
148
149 if zone_config.allow_update.is_none() && zone_config.allow_update_raw.is_none() {
151 return Err(ApiError::DynamicUpdatesNotEnabled(format!(
152 "Zone {} does not have allow-update configured. \
153 Create zone with updateKeyName or modify zone to enable dynamic updates",
154 zone_name
155 )));
156 }
157
158 Ok(())
159}
160
161fn validate_record_type(record_type: &str) -> Result<(), ApiError> {
163 let upper = record_type.to_uppercase();
164
165 if !VALID_RECORD_TYPES.contains(&upper.as_str()) {
166 return Err(ApiError::InvalidRecord(format!(
167 "Invalid record type: {}. Supported types: {:?}",
168 record_type, VALID_RECORD_TYPES
169 )));
170 }
171
172 Ok(())
173}
174
175fn validate_record_value(record_type: &str, value: &str) -> Result<(), ApiError> {
177 if value.is_empty() {
179 return Err(ApiError::InvalidRecord(
180 "Record value cannot be empty".to_string(),
181 ));
182 }
183
184 match record_type.to_uppercase().as_str() {
185 "A" => {
186 value
188 .parse::<std::net::Ipv4Addr>()
189 .map_err(|_| ApiError::InvalidRecord(format!("Invalid IPv4 address: {}", value)))?;
190 }
191 "AAAA" => {
192 value
194 .parse::<std::net::Ipv6Addr>()
195 .map_err(|_| ApiError::InvalidRecord(format!("Invalid IPv6 address: {}", value)))?;
196 }
197 "CNAME" | "NS" | "PTR" | "MX" => {
198 if !value.ends_with('.') {
200 return Err(ApiError::InvalidRecord(format!(
201 "{} record value must be a fully qualified domain name ending with '.': {}",
202 record_type, value
203 )));
204 }
205 }
206 "TXT" | "CAA" | "SRV" => {
207 }
209 _ => {}
210 }
211
212 Ok(())
213}
214
215fn normalize_record_name(name: &str, zone: &str) -> String {
226 if name == "@" {
227 format!("{}.", zone)
229 } else if name.ends_with('.') {
230 name.to_string()
232 } else if name.contains('.') && name.ends_with(zone) {
233 format!("{}.", name)
235 } else {
236 format!("{}.{}.", name, zone)
238 }
239}
240
241#[utoipa::path(
243 post,
244 path = "/api/v1/zones/{zone_name}/records",
245 request_body = AddRecordRequest,
246 params(
247 ("zone_name" = String, Path, description = "Zone name")
248 ),
249 responses(
250 (status = 201, description = "Record added successfully", body = RecordResponse),
251 (status = 400, description = "Invalid request or zone not configured for updates"),
252 (status = 404, description = "Zone not found"),
253 (status = 500, description = "Update failed"),
254 ),
255 tag = "records"
256)]
257pub async fn add_record(
258 State(state): State<AppState>,
259 Path(zone_name): Path<String>,
260 Json(request): Json<AddRecordRequest>,
261) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
262 info!(
263 "Adding record to zone {}: {} {} {} (TTL: {})",
264 zone_name, request.name, request.record_type, request.value, request.ttl
265 );
266
267 validate_zone_for_updates(&state, &zone_name).await?;
269 validate_record_type(&request.record_type)?;
270 validate_record_value(&request.record_type, &request.value)?;
271
272 let fqdn = normalize_record_name(&request.name, &zone_name);
274
275 debug!("Normalized record name: {} -> {}", request.name, fqdn);
276
277 let value_with_priority = if let Some(priority) = request.priority {
279 if request.record_type.to_uppercase() == "MX" || request.record_type.to_uppercase() == "SRV"
280 {
281 format!("{} {}", priority, request.value)
282 } else {
283 request.value.clone()
284 }
285 } else {
286 request.value.clone()
287 };
288
289 let _output = state
291 .nsupdate
292 .add_record(
293 &zone_name,
294 &fqdn,
295 request.ttl,
296 &request.record_type,
297 &value_with_priority,
298 )
299 .await
300 .map_err(|e| {
301 error!("nsupdate add failed: {}", e);
302 metrics::record_record_operation("add", false);
303 ApiError::NsupdateError(format!("Failed to add record: {}", e))
304 })?;
305
306 info!("Record added successfully to zone {}", zone_name);
307 metrics::record_record_operation("add", true);
308
309 Ok((
310 StatusCode::CREATED,
311 Json(RecordResponse {
312 success: true,
313 message: format!("Record added to zone {}", zone_name),
314 details: Some(serde_json::json!({
315 "zone": zone_name,
316 "record": {
317 "name": request.name,
318 "type": request.record_type,
319 "value": request.value,
320 "ttl": request.ttl,
321 }
322 })),
323 }),
324 ))
325}
326
327#[utoipa::path(
329 delete,
330 path = "/api/v1/zones/{zone_name}/records",
331 request_body = RemoveRecordRequest,
332 params(
333 ("zone_name" = String, Path, description = "Zone name")
334 ),
335 responses(
336 (status = 200, description = "Record removed successfully", body = RecordResponse),
337 (status = 400, description = "Invalid request or zone not configured for updates"),
338 (status = 404, description = "Zone not found"),
339 (status = 500, description = "Update failed"),
340 ),
341 tag = "records"
342)]
343pub async fn remove_record(
344 State(state): State<AppState>,
345 Path(zone_name): Path<String>,
346 Json(request): Json<RemoveRecordRequest>,
347) -> Result<Json<RecordResponse>, ApiError> {
348 info!(
349 "Removing record from zone {}: {} {} {:?}",
350 zone_name, request.name, request.record_type, request.value
351 );
352
353 validate_zone_for_updates(&state, &zone_name).await?;
355 validate_record_type(&request.record_type)?;
356
357 if let Some(ref value) = request.value {
359 validate_record_value(&request.record_type, value)?;
360 }
361
362 let fqdn = normalize_record_name(&request.name, &zone_name);
364
365 debug!("Normalized record name: {} -> {}", request.name, fqdn);
366
367 let value_str = request.value.as_deref().unwrap_or("");
369 let _output = state
370 .nsupdate
371 .remove_record(&zone_name, &fqdn, &request.record_type, value_str)
372 .await
373 .map_err(|e| {
374 error!("nsupdate remove failed: {}", e);
375 metrics::record_record_operation("remove", false);
376 ApiError::NsupdateError(format!("Failed to remove record: {}", e))
377 })?;
378
379 info!("Record removed successfully from zone {}", zone_name);
380 metrics::record_record_operation("remove", true);
381
382 Ok(Json(RecordResponse {
383 success: true,
384 message: format!("Record removed from zone {}", zone_name),
385 details: Some(serde_json::json!({
386 "zone": zone_name,
387 "record": {
388 "name": request.name,
389 "type": request.record_type,
390 "value": request.value,
391 }
392 })),
393 }))
394}
395
396#[utoipa::path(
398 put,
399 path = "/api/v1/zones/{zone_name}/records",
400 request_body = UpdateRecordRequest,
401 params(
402 ("zone_name" = String, Path, description = "Zone name")
403 ),
404 responses(
405 (status = 200, description = "Record updated successfully", body = RecordResponse),
406 (status = 400, description = "Invalid request or zone not configured for updates"),
407 (status = 404, description = "Zone not found"),
408 (status = 500, description = "Update failed"),
409 ),
410 tag = "records"
411)]
412pub async fn update_record(
413 State(state): State<AppState>,
414 Path(zone_name): Path<String>,
415 Json(request): Json<UpdateRecordRequest>,
416) -> Result<Json<RecordResponse>, ApiError> {
417 info!(
418 "Updating record in zone {}: {} {} from {} to {} (TTL: {})",
419 zone_name,
420 request.name,
421 request.record_type,
422 request.current_value,
423 request.new_value,
424 request.ttl
425 );
426
427 validate_zone_for_updates(&state, &zone_name).await?;
429 validate_record_type(&request.record_type)?;
430 validate_record_value(&request.record_type, &request.current_value)?;
431 validate_record_value(&request.record_type, &request.new_value)?;
432
433 let fqdn = normalize_record_name(&request.name, &zone_name);
435
436 debug!("Normalized record name: {} -> {}", request.name, fqdn);
437
438 let (current_with_priority, new_with_priority) = if let Some(priority) = request.priority {
440 if request.record_type.to_uppercase() == "MX" || request.record_type.to_uppercase() == "SRV"
441 {
442 (
443 format!("{} {}", priority, request.current_value),
444 format!("{} {}", priority, request.new_value),
445 )
446 } else {
447 (request.current_value.clone(), request.new_value.clone())
448 }
449 } else {
450 (request.current_value.clone(), request.new_value.clone())
451 };
452
453 let _output = state
455 .nsupdate
456 .update_record(
457 &zone_name,
458 &fqdn,
459 request.ttl,
460 &request.record_type,
461 ¤t_with_priority,
462 &new_with_priority,
463 )
464 .await
465 .map_err(|e| {
466 error!("nsupdate update failed: {}", e);
467 metrics::record_record_operation("update", false);
468 ApiError::NsupdateError(format!("Failed to update record: {}", e))
469 })?;
470
471 info!("Record updated successfully in zone {}", zone_name);
472 metrics::record_record_operation("update", true);
473
474 Ok(Json(RecordResponse {
475 success: true,
476 message: format!("Record updated in zone {}", zone_name),
477 details: Some(serde_json::json!({
478 "zone": zone_name,
479 "record": {
480 "name": request.name,
481 "type": request.record_type,
482 "currentValue": request.current_value,
483 "newValue": request.new_value,
484 "ttl": request.ttl,
485 }
486 })),
487 }))
488}