1use axum::{
14 extract::{Path, State},
15 http::StatusCode,
16 Json,
17};
18use tracing::{debug, error, info};
19
20use crate::{
21 metrics, rndc_parser, rndc_types,
22 types::{ApiError, AppState},
23};
24
25pub use crate::records_types::{
29 AddRecordRequest, RecordResponse, RemoveRecordRequest, UpdateRecordRequest,
30};
31
32const VALID_RECORD_TYPES: &[&str] = &["A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR", "SRV", "CAA"];
34
35async fn validate_zone_for_updates(state: &AppState, zone_name: &str) -> Result<(), ApiError> {
46 crate::zones::validate_zone_name(zone_name)?;
50
51 let zone_config_output = state.rndc.showzone(zone_name).await.map_err(|e| {
53 if e.to_string().contains("not found") {
54 ApiError::ZoneNotFound(zone_name.to_string())
55 } else {
56 ApiError::RndcError(e.to_string())
57 }
58 })?;
59
60 let zone_config = rndc_parser::parse_showzone(&zone_config_output).map_err(|e| {
62 ApiError::InternalError(format!("Failed to parse zone configuration: {}", e))
63 })?;
64
65 if zone_config.zone_type != rndc_types::ZoneType::Primary {
67 return Err(ApiError::DynamicUpdatesNotEnabled(format!(
68 "Zone {} is {} type. Dynamic updates only supported on primary zones",
69 zone_name,
70 zone_config.zone_type.as_str()
71 )));
72 }
73
74 if zone_config.allow_update.is_none() && zone_config.allow_update_raw.is_none() {
76 return Err(ApiError::DynamicUpdatesNotEnabled(format!(
77 "Zone {} does not have allow-update configured. \
78 Create zone with updateKeyName or modify zone to enable dynamic updates",
79 zone_name
80 )));
81 }
82
83 Ok(())
84}
85
86pub(crate) fn validate_record_type(record_type: &str) -> Result<(), ApiError> {
88 let upper = record_type.to_uppercase();
89
90 if !VALID_RECORD_TYPES.contains(&upper.as_str()) {
91 return Err(ApiError::InvalidRecord(format!(
92 "Invalid record type: {}. Supported types: {:?}",
93 record_type, VALID_RECORD_TYPES
94 )));
95 }
96
97 Ok(())
98}
99
100pub(crate) fn validate_record_name(name: &str) -> Result<(), ApiError> {
122 if name.is_empty() {
123 return Err(ApiError::InvalidRecord(
124 "Record name cannot be empty".to_string(),
125 ));
126 }
127
128 if let Some(bad) = name
129 .chars()
130 .find(|&c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '@' | '*')))
131 {
132 return Err(ApiError::InvalidRecord(format!(
133 "Record name contains illegal character: {:?} (allowed: A-Z a-z 0-9 . - _ @ *)",
134 bad
135 )));
136 }
137
138 Ok(())
139}
140
141pub(crate) fn validate_record_value(record_type: &str, value: &str) -> Result<(), ApiError> {
148 if value.is_empty() {
150 return Err(ApiError::InvalidRecord(
151 "Record value cannot be empty".to_string(),
152 ));
153 }
154
155 if let Some(bad) = value.chars().find(|c| c.is_control()) {
160 return Err(ApiError::InvalidRecord(format!(
161 "Record value contains illegal control character: {:?}",
162 bad
163 )));
164 }
165
166 match record_type.to_uppercase().as_str() {
167 "A" => {
168 value
170 .parse::<std::net::Ipv4Addr>()
171 .map_err(|_| ApiError::InvalidRecord(format!("Invalid IPv4 address: {}", value)))?;
172 }
173 "AAAA" => {
174 value
176 .parse::<std::net::Ipv6Addr>()
177 .map_err(|_| ApiError::InvalidRecord(format!("Invalid IPv6 address: {}", value)))?;
178 }
179 "CNAME" | "NS" | "PTR" | "MX" => {
180 if !value.ends_with('.') {
182 return Err(ApiError::InvalidRecord(format!(
183 "{} record value must be a fully qualified domain name ending with '.': {}",
184 record_type, value
185 )));
186 }
187 }
188 "TXT" | "CAA" | "SRV" => {
189 }
192 _ => {}
193 }
194
195 Ok(())
196}
197
198fn normalize_record_name(name: &str, zone: &str) -> String {
209 if name == "@" {
210 format!("{}.", zone)
212 } else if name.ends_with('.') {
213 name.to_string()
215 } else if name.contains('.') && name.ends_with(zone) {
216 format!("{}.", name)
218 } else {
219 format!("{}.{}.", name, zone)
221 }
222}
223
224#[utoipa::path(
226 post,
227 path = "/api/v1/zones/{zone_name}/records",
228 request_body = AddRecordRequest,
229 params(
230 ("zone_name" = String, Path, description = "Zone name")
231 ),
232 responses(
233 (status = 201, description = "Record added successfully", body = RecordResponse),
234 (status = 400, description = "Invalid request or zone not configured for updates"),
235 (status = 404, description = "Zone not found"),
236 (status = 500, description = "Update failed"),
237 ),
238 tag = "records"
239)]
240pub async fn add_record(
241 State(state): State<AppState>,
242 Path(zone_name): Path<String>,
243 Json(request): Json<AddRecordRequest>,
244) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
245 info!(
246 "Adding record to zone {}: {} {} {} (TTL: {})",
247 zone_name, request.name, request.record_type, request.value, request.ttl
248 );
249
250 validate_zone_for_updates(&state, &zone_name).await?;
252 validate_record_type(&request.record_type)?;
253 validate_record_name(&request.name)?;
254 validate_record_value(&request.record_type, &request.value)?;
255
256 let fqdn = normalize_record_name(&request.name, &zone_name);
258
259 debug!("Normalized record name: {} -> {}", request.name, fqdn);
260
261 let value_with_priority = if let Some(priority) = request.priority {
263 if request.record_type.to_uppercase() == "MX" || request.record_type.to_uppercase() == "SRV"
264 {
265 format!("{} {}", priority, request.value)
266 } else {
267 request.value.clone()
268 }
269 } else {
270 request.value.clone()
271 };
272
273 let _output = state
275 .nsupdate
276 .add_record(
277 &zone_name,
278 &fqdn,
279 request.ttl,
280 &request.record_type,
281 &value_with_priority,
282 )
283 .await
284 .map_err(|e| {
285 error!("nsupdate add failed: {}", e);
286 metrics::record_record_operation("add", false);
287 ApiError::NsupdateError(format!("Failed to add record: {}", e))
288 })?;
289
290 info!("Record added successfully to zone {}", zone_name);
291 metrics::record_record_operation("add", true);
292
293 Ok((
294 StatusCode::CREATED,
295 Json(RecordResponse {
296 success: true,
297 message: format!("Record added to zone {}", zone_name),
298 details: Some(serde_json::json!({
299 "zone": zone_name,
300 "record": {
301 "name": request.name,
302 "type": request.record_type,
303 "value": request.value,
304 "ttl": request.ttl,
305 }
306 })),
307 }),
308 ))
309}
310
311#[utoipa::path(
313 delete,
314 path = "/api/v1/zones/{zone_name}/records",
315 request_body = RemoveRecordRequest,
316 params(
317 ("zone_name" = String, Path, description = "Zone name")
318 ),
319 responses(
320 (status = 200, description = "Record removed successfully", body = RecordResponse),
321 (status = 400, description = "Invalid request or zone not configured for updates"),
322 (status = 404, description = "Zone not found"),
323 (status = 500, description = "Update failed"),
324 ),
325 tag = "records"
326)]
327pub async fn remove_record(
328 State(state): State<AppState>,
329 Path(zone_name): Path<String>,
330 Json(request): Json<RemoveRecordRequest>,
331) -> Result<Json<RecordResponse>, ApiError> {
332 info!(
333 "Removing record from zone {}: {} {} {:?}",
334 zone_name, request.name, request.record_type, request.value
335 );
336
337 validate_zone_for_updates(&state, &zone_name).await?;
339 validate_record_type(&request.record_type)?;
340 validate_record_name(&request.name)?;
341
342 if let Some(ref value) = request.value {
344 validate_record_value(&request.record_type, value)?;
345 }
346
347 let fqdn = normalize_record_name(&request.name, &zone_name);
349
350 debug!("Normalized record name: {} -> {}", request.name, fqdn);
351
352 let value_str = request.value.as_deref().unwrap_or("");
354 let _output = state
355 .nsupdate
356 .remove_record(&zone_name, &fqdn, &request.record_type, value_str)
357 .await
358 .map_err(|e| {
359 error!("nsupdate remove failed: {}", e);
360 metrics::record_record_operation("remove", false);
361 ApiError::NsupdateError(format!("Failed to remove record: {}", e))
362 })?;
363
364 info!("Record removed successfully from zone {}", zone_name);
365 metrics::record_record_operation("remove", true);
366
367 Ok(Json(RecordResponse {
368 success: true,
369 message: format!("Record removed from zone {}", zone_name),
370 details: Some(serde_json::json!({
371 "zone": zone_name,
372 "record": {
373 "name": request.name,
374 "type": request.record_type,
375 "value": request.value,
376 }
377 })),
378 }))
379}
380
381#[utoipa::path(
383 put,
384 path = "/api/v1/zones/{zone_name}/records",
385 request_body = UpdateRecordRequest,
386 params(
387 ("zone_name" = String, Path, description = "Zone name")
388 ),
389 responses(
390 (status = 200, description = "Record updated successfully", body = RecordResponse),
391 (status = 400, description = "Invalid request or zone not configured for updates"),
392 (status = 404, description = "Zone not found"),
393 (status = 500, description = "Update failed"),
394 ),
395 tag = "records"
396)]
397pub async fn update_record(
398 State(state): State<AppState>,
399 Path(zone_name): Path<String>,
400 Json(request): Json<UpdateRecordRequest>,
401) -> Result<Json<RecordResponse>, ApiError> {
402 info!(
403 "Updating record in zone {}: {} {} from {} to {} (TTL: {})",
404 zone_name,
405 request.name,
406 request.record_type,
407 request.current_value,
408 request.new_value,
409 request.ttl
410 );
411
412 validate_zone_for_updates(&state, &zone_name).await?;
414 validate_record_type(&request.record_type)?;
415 validate_record_name(&request.name)?;
416 validate_record_value(&request.record_type, &request.current_value)?;
417 validate_record_value(&request.record_type, &request.new_value)?;
418
419 let fqdn = normalize_record_name(&request.name, &zone_name);
421
422 debug!("Normalized record name: {} -> {}", request.name, fqdn);
423
424 let (current_with_priority, new_with_priority) = if let Some(priority) = request.priority {
426 if request.record_type.to_uppercase() == "MX" || request.record_type.to_uppercase() == "SRV"
427 {
428 (
429 format!("{} {}", priority, request.current_value),
430 format!("{} {}", priority, request.new_value),
431 )
432 } else {
433 (request.current_value.clone(), request.new_value.clone())
434 }
435 } else {
436 (request.current_value.clone(), request.new_value.clone())
437 };
438
439 let _output = state
441 .nsupdate
442 .update_record(
443 &zone_name,
444 &fqdn,
445 request.ttl,
446 &request.record_type,
447 ¤t_with_priority,
448 &new_with_priority,
449 )
450 .await
451 .map_err(|e| {
452 error!("nsupdate update failed: {}", e);
453 metrics::record_record_operation("update", false);
454 ApiError::NsupdateError(format!("Failed to update record: {}", e))
455 })?;
456
457 info!("Record updated successfully in zone {}", zone_name);
458 metrics::record_record_operation("update", true);
459
460 Ok(Json(RecordResponse {
461 success: true,
462 message: format!("Record updated in zone {}", zone_name),
463 details: Some(serde_json::json!({
464 "zone": zone_name,
465 "record": {
466 "name": request.name,
467 "type": request.record_type,
468 "currentValue": request.current_value,
469 "newValue": request.new_value,
470 "ttl": request.ttl,
471 }
472 })),
473 }))
474}