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 against the strict DNS grammar. This also rejects
120    // empty names and any control characters that could inject extra nsupdate
121    // commands once the zone is assembled into a request.
122    crate::zones::validate_zone_name(zone_name)?;
123
124    // Get zone configuration via RNDC
125    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    // Parse zone configuration
134    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    // Zone must be primary type
139    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    // Zone must have allow-update configured
148    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
159/// Validate DNS record type
160pub(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
173/// Validate a record name against the strict DNS owner-name character set.
174///
175/// Record names flow into two sinks with different injection surfaces:
176/// * newline-delimited nsupdate command scripts (`add`/`update`/`remove`), where
177///   a `\n`/`\r`/NUL would inject additional update commands (B-2); and
178/// * the **start of a zone-file line** in [`ZoneConfig::to_zone_file`] for records
179///   embedded in a `create_zone` request, where a leading `$` plus whitespace/`;`
180///   would plant a master-file directive such as `$INCLUDE` (arbitrary file read
181///   into the zone) or `$GENERATE` (resource exhaustion) — even with no control
182///   character present (C-2 hardening).
183///
184/// Rejecting anything outside `[A-Za-z0-9._-]` plus the DNS name specials `@`
185/// (apex), `*` (wildcard), and `_` (underscore labels, e.g. `_dmarc`,
186/// `_sip._tcp`) closes both surfaces: no whitespace, `$`, `;`, quote, or paren
187/// can reach either sink. Master-file names that would legitimately need other
188/// bytes require backslash escaping, which this API does not emit, so they are
189/// intentionally unsupported.
190///
191/// # Errors
192/// Returns [`ApiError::InvalidRecord`] (HTTP 400) if the name is empty or contains
193/// any character outside the permitted set.
194pub(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
214/// Validate DNS record value based on type
215///
216/// # Errors
217/// Returns [`ApiError::InvalidRecord`] (HTTP 400) if the value is empty, contains
218/// a control character (which could inject nsupdate commands), or fails the
219/// per-type format check.
220pub(crate) fn validate_record_value(record_type: &str, value: &str) -> Result<(), ApiError> {
221    // Value cannot be empty
222    if value.is_empty() {
223        return Err(ApiError::InvalidRecord(
224            "Record value cannot be empty".to_string(),
225        ));
226    }
227
228    // Reject control characters (newline/CR/NUL) for every record type. These
229    // would otherwise be interpreted as nsupdate command separators and allow
230    // injection of arbitrary update commands (B-2). This replaces the previous
231    // "any non-empty string is valid" behaviour for TXT/CAA/SRV.
232    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            // Validate IPv4 address
242            value
243                .parse::<std::net::Ipv4Addr>()
244                .map_err(|_| ApiError::InvalidRecord(format!("Invalid IPv4 address: {}", value)))?;
245        }
246        "AAAA" => {
247            // Validate IPv6 address
248            value
249                .parse::<std::net::Ipv6Addr>()
250                .map_err(|_| ApiError::InvalidRecord(format!("Invalid IPv6 address: {}", value)))?;
251        }
252        "CNAME" | "NS" | "PTR" | "MX" => {
253            // Must be FQDN with trailing dot
254            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            // No further format constraint beyond the non-empty and
263            // control-character checks performed above.
264        }
265        _ => {}
266    }
267
268    Ok(())
269}
270
271/// Normalize record name to FQDN
272///
273/// # Arguments
274///
275/// * `name` - Record name (may be relative, @, or FQDN)
276/// * `zone` - Zone name
277///
278/// # Returns
279///
280/// Fully qualified domain name with trailing dot
281fn normalize_record_name(name: &str, zone: &str) -> String {
282    if name == "@" {
283        // Apex record - use zone name
284        format!("{}.", zone)
285    } else if name.ends_with('.') {
286        // Already a FQDN
287        name.to_string()
288    } else if name.contains('.') && name.ends_with(zone) {
289        // FQDN without trailing dot
290        format!("{}.", name)
291    } else {
292        // Relative name - append zone
293        format!("{}.{}.", name, zone)
294    }
295}
296
297/// Add a DNS record to an existing zone
298#[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    // Early return pattern: validate all prerequisites
324    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    // Normalize record name to FQDN
330    let fqdn = normalize_record_name(&request.name, &zone_name);
331
332    debug!("Normalized record name: {} -> {}", request.name, fqdn);
333
334    // For MX and SRV records, prepend priority to value
335    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    // Execute nsupdate
347    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/// Remove a DNS record from an existing zone
385#[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    // Early return pattern: validate all prerequisites
411    validate_zone_for_updates(&state, &zone_name).await?;
412    validate_record_type(&request.record_type)?;
413    validate_record_name(&request.name)?;
414
415    // Validate value if provided
416    if let Some(ref value) = request.value {
417        validate_record_value(&request.record_type, value)?;
418    }
419
420    // Normalize record name to FQDN
421    let fqdn = normalize_record_name(&request.name, &zone_name);
422
423    debug!("Normalized record name: {} -> {}", request.name, fqdn);
424
425    // Execute nsupdate
426    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/// Update a DNS record in an existing zone
455#[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    // Early return pattern: validate all prerequisites
486    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    // Normalize record name to FQDN
493    let fqdn = normalize_record_name(&request.name, &zone_name);
494
495    debug!("Normalized record name: {} -> {}", request.name, fqdn);
496
497    // For MX and SRV records, prepend priority to values
498    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    // Execute nsupdate
513    let _output = state
514        .nsupdate
515        .update_record(
516            &zone_name,
517            &fqdn,
518            request.ttl,
519            &request.record_type,
520            &current_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}