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 tracing::{debug, error, info};
19
20use crate::{
21    metrics, rndc_parser, rndc_types,
22    types::{ApiError, AppState},
23};
24
25// Pure record data types live in `records_types` so a library consumer can take
26// them without the HTTP stack (roadmap 02, phase 1). Re-exported unchanged so
27// `bindcar::records::AddRecordRequest` and friends keep resolving.
28pub use crate::records_types::{
29    AddRecordRequest, RecordResponse, RemoveRecordRequest, UpdateRecordRequest,
30};
31
32/// Supported DNS record types
33const VALID_RECORD_TYPES: &[&str] = &["A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR", "SRV", "CAA"];
34
35/// Validate that a zone exists and supports dynamic updates
36///
37/// # Arguments
38///
39/// * `state` - Application state
40/// * `zone_name` - Zone name to validate
41///
42/// # Returns
43///
44/// Ok if zone exists and has allow-update configured, Err otherwise
45async fn validate_zone_for_updates(state: &AppState, zone_name: &str) -> Result<(), ApiError> {
46    // Validate zone name against the strict DNS grammar. This also rejects
47    // empty names and any control characters that could inject extra nsupdate
48    // commands once the zone is assembled into a request.
49    crate::zones::validate_zone_name(zone_name)?;
50
51    // Get zone configuration via RNDC
52    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    // Parse zone configuration
61    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    // Zone must be primary type
66    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    // Zone must have allow-update configured
75    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
86/// Validate DNS record type
87pub(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
100/// Validate a record name against the strict DNS owner-name character set.
101///
102/// Record names flow into two sinks with different injection surfaces:
103/// * newline-delimited nsupdate command scripts (`add`/`update`/`remove`), where
104///   a `\n`/`\r`/NUL would inject additional update commands (B-2); and
105/// * the **start of a zone-file line** in [`ZoneConfig::to_zone_file`] for records
106///   embedded in a `create_zone` request, where a leading `$` plus whitespace/`;`
107///   would plant a master-file directive such as `$INCLUDE` (arbitrary file read
108///   into the zone) or `$GENERATE` (resource exhaustion) — even with no control
109///   character present (C-2 hardening).
110///
111/// Rejecting anything outside `[A-Za-z0-9._-]` plus the DNS name specials `@`
112/// (apex), `*` (wildcard), and `_` (underscore labels, e.g. `_dmarc`,
113/// `_sip._tcp`) closes both surfaces: no whitespace, `$`, `;`, quote, or paren
114/// can reach either sink. Master-file names that would legitimately need other
115/// bytes require backslash escaping, which this API does not emit, so they are
116/// intentionally unsupported.
117///
118/// # Errors
119/// Returns [`ApiError::InvalidRecord`] (HTTP 400) if the name is empty or contains
120/// any character outside the permitted set.
121pub(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
141/// Validate DNS record value based on type
142///
143/// # Errors
144/// Returns [`ApiError::InvalidRecord`] (HTTP 400) if the value is empty, contains
145/// a control character (which could inject nsupdate commands), or fails the
146/// per-type format check.
147pub(crate) fn validate_record_value(record_type: &str, value: &str) -> Result<(), ApiError> {
148    // Value cannot be empty
149    if value.is_empty() {
150        return Err(ApiError::InvalidRecord(
151            "Record value cannot be empty".to_string(),
152        ));
153    }
154
155    // Reject control characters (newline/CR/NUL) for every record type. These
156    // would otherwise be interpreted as nsupdate command separators and allow
157    // injection of arbitrary update commands (B-2). This replaces the previous
158    // "any non-empty string is valid" behaviour for TXT/CAA/SRV.
159    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            // Validate IPv4 address
169            value
170                .parse::<std::net::Ipv4Addr>()
171                .map_err(|_| ApiError::InvalidRecord(format!("Invalid IPv4 address: {}", value)))?;
172        }
173        "AAAA" => {
174            // Validate IPv6 address
175            value
176                .parse::<std::net::Ipv6Addr>()
177                .map_err(|_| ApiError::InvalidRecord(format!("Invalid IPv6 address: {}", value)))?;
178        }
179        "CNAME" | "NS" | "PTR" | "MX" => {
180            // Must be FQDN with trailing dot
181            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            // No further format constraint beyond the non-empty and
190            // control-character checks performed above.
191        }
192        _ => {}
193    }
194
195    Ok(())
196}
197
198/// Normalize record name to FQDN
199///
200/// # Arguments
201///
202/// * `name` - Record name (may be relative, @, or FQDN)
203/// * `zone` - Zone name
204///
205/// # Returns
206///
207/// Fully qualified domain name with trailing dot
208fn normalize_record_name(name: &str, zone: &str) -> String {
209    if name == "@" {
210        // Apex record - use zone name
211        format!("{}.", zone)
212    } else if name.ends_with('.') {
213        // Already a FQDN
214        name.to_string()
215    } else if name.contains('.') && name.ends_with(zone) {
216        // FQDN without trailing dot
217        format!("{}.", name)
218    } else {
219        // Relative name - append zone
220        format!("{}.{}.", name, zone)
221    }
222}
223
224/// Add a DNS record to an existing zone
225#[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    // Early return pattern: validate all prerequisites
251    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    // Normalize record name to FQDN
257    let fqdn = normalize_record_name(&request.name, &zone_name);
258
259    debug!("Normalized record name: {} -> {}", request.name, fqdn);
260
261    // For MX and SRV records, prepend priority to value
262    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    // Execute nsupdate
274    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/// Remove a DNS record from an existing zone
312#[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    // Early return pattern: validate all prerequisites
338    validate_zone_for_updates(&state, &zone_name).await?;
339    validate_record_type(&request.record_type)?;
340    validate_record_name(&request.name)?;
341
342    // Validate value if provided
343    if let Some(ref value) = request.value {
344        validate_record_value(&request.record_type, value)?;
345    }
346
347    // Normalize record name to FQDN
348    let fqdn = normalize_record_name(&request.name, &zone_name);
349
350    debug!("Normalized record name: {} -> {}", request.name, fqdn);
351
352    // Execute nsupdate
353    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/// Update a DNS record in an existing zone
382#[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    // Early return pattern: validate all prerequisites
413    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    // Normalize record name to FQDN
420    let fqdn = normalize_record_name(&request.name, &zone_name);
421
422    debug!("Normalized record name: {} -> {}", request.name, fqdn);
423
424    // For MX and SRV records, prepend priority to values
425    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    // Execute nsupdate
440    let _output = state
441        .nsupdate
442        .update_record(
443            &zone_name,
444            &fqdn,
445            request.ttl,
446            &request.record_type,
447            &current_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}