bindcar/
records.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! DNS record management API handlers
5//!
6//! This module implements HTTP handlers for individual DNS record operations:
7//! - Adding records to existing zones
8//! - Removing records from existing zones
9//! - Updating existing records
10//!
11//! All operations use nsupdate for dynamic DNS updates with TSIG authentication.
12
13use 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/// Request to add a new DNS record
28#[derive(Debug, Serialize, Deserialize, ToSchema)]
29#[serde(rename_all = "camelCase")]
30pub struct AddRecordRequest {
31    /// Record name (e.g., "www", "@" for apex)
32    pub name: String,
33
34    /// Record type (e.g., "A", "AAAA", "CNAME", "MX", "TXT")
35    #[serde(rename = "type")]
36    pub record_type: String,
37
38    /// Record value (e.g., "192.0.2.1" for A record)
39    pub value: String,
40
41    /// TTL in seconds (default: 3600)
42    #[serde(default = "default_ttl")]
43    pub ttl: u32,
44
45    /// Priority (for MX and SRV records)
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub priority: Option<u16>,
48}
49
50/// Request to remove a DNS record
51#[derive(Debug, Serialize, Deserialize, ToSchema)]
52#[serde(rename_all = "camelCase")]
53pub struct RemoveRecordRequest {
54    /// Record name (e.g., "www", "@" for apex)
55    pub name: String,
56
57    /// Record type (e.g., "A", "AAAA", "CNAME")
58    #[serde(rename = "type")]
59    pub record_type: String,
60
61    /// Record value to remove (optional - if omitted, removes all records of this type)
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub value: Option<String>,
64}
65
66/// Request to update a DNS record
67#[derive(Debug, Serialize, Deserialize, ToSchema)]
68#[serde(rename_all = "camelCase")]
69pub struct UpdateRecordRequest {
70    /// Record name (e.g., "www", "@" for apex)
71    pub name: String,
72
73    /// Record type (e.g., "A", "AAAA", "CNAME")
74    #[serde(rename = "type")]
75    pub record_type: String,
76
77    /// Current record value
78    pub current_value: String,
79
80    /// New record value
81    pub new_value: String,
82
83    /// TTL in seconds (default: 3600)
84    #[serde(default = "default_ttl")]
85    pub ttl: u32,
86
87    /// Priority (for MX and SRV records)
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub priority: Option<u16>,
90}
91
92/// Response from record operations
93#[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
105/// Supported DNS record types
106const VALID_RECORD_TYPES: &[&str] = &["A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR", "SRV", "CAA"];
107
108/// Validate that a zone exists and supports dynamic updates
109///
110/// # Arguments
111///
112/// * `state` - Application state
113/// * `zone_name` - Zone name to validate
114///
115/// # Returns
116///
117/// Ok if zone exists and has allow-update configured, Err otherwise
118async fn validate_zone_for_updates(state: &AppState, zone_name: &str) -> Result<(), ApiError> {
119    // Validate zone name is not empty
120    if zone_name.is_empty() {
121        return Err(ApiError::InvalidRequest(
122            "Zone name cannot be empty".to_string(),
123        ));
124    }
125
126    // Get zone configuration via RNDC
127    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    // Parse zone configuration
136    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    // Zone must be primary type
141    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    // Zone must have allow-update configured
150    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
161/// Validate DNS record type
162fn 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
175/// Validate DNS record value based on type
176fn validate_record_value(record_type: &str, value: &str) -> Result<(), ApiError> {
177    // Value cannot be empty
178    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            // Validate IPv4 address
187            value
188                .parse::<std::net::Ipv4Addr>()
189                .map_err(|_| ApiError::InvalidRecord(format!("Invalid IPv4 address: {}", value)))?;
190        }
191        "AAAA" => {
192            // Validate IPv6 address
193            value
194                .parse::<std::net::Ipv6Addr>()
195                .map_err(|_| ApiError::InvalidRecord(format!("Invalid IPv6 address: {}", value)))?;
196        }
197        "CNAME" | "NS" | "PTR" | "MX" => {
198            // Must be FQDN with trailing dot
199            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            // Any non-empty string is valid
208        }
209        _ => {}
210    }
211
212    Ok(())
213}
214
215/// Normalize record name to FQDN
216///
217/// # Arguments
218///
219/// * `name` - Record name (may be relative, @, or FQDN)
220/// * `zone` - Zone name
221///
222/// # Returns
223///
224/// Fully qualified domain name with trailing dot
225fn normalize_record_name(name: &str, zone: &str) -> String {
226    if name == "@" {
227        // Apex record - use zone name
228        format!("{}.", zone)
229    } else if name.ends_with('.') {
230        // Already a FQDN
231        name.to_string()
232    } else if name.contains('.') && name.ends_with(zone) {
233        // FQDN without trailing dot
234        format!("{}.", name)
235    } else {
236        // Relative name - append zone
237        format!("{}.{}.", name, zone)
238    }
239}
240
241/// Add a DNS record to an existing zone
242#[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    // Early return pattern: validate all prerequisites
268    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    // Normalize record name to FQDN
273    let fqdn = normalize_record_name(&request.name, &zone_name);
274
275    debug!("Normalized record name: {} -> {}", request.name, fqdn);
276
277    // For MX and SRV records, prepend priority to value
278    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    // Execute nsupdate
290    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/// Remove a DNS record from an existing zone
328#[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    // Early return pattern: validate all prerequisites
354    validate_zone_for_updates(&state, &zone_name).await?;
355    validate_record_type(&request.record_type)?;
356
357    // Validate value if provided
358    if let Some(ref value) = request.value {
359        validate_record_value(&request.record_type, value)?;
360    }
361
362    // Normalize record name to FQDN
363    let fqdn = normalize_record_name(&request.name, &zone_name);
364
365    debug!("Normalized record name: {} -> {}", request.name, fqdn);
366
367    // Execute nsupdate
368    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/// Update a DNS record in an existing zone
397#[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    // Early return pattern: validate all prerequisites
428    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    // Normalize record name to FQDN
434    let fqdn = normalize_record_name(&request.name, &zone_name);
435
436    debug!("Normalized record name: {} -> {}", request.name, fqdn);
437
438    // For MX and SRV records, prepend priority to values
439    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    // Execute nsupdate
454    let _output = state
455        .nsupdate
456        .update_record(
457            &zone_name,
458            &fqdn,
459            request.ttl,
460            &request.record_type,
461            &current_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}