bindcar/
types.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Common types and errors used throughout the bindcar library
5
6use axum::{
7    http::StatusCode,
8    response::{IntoResponse, Response},
9    Json,
10};
11use serde::Serialize;
12use std::sync::Arc;
13use tracing::error;
14
15use crate::{nsupdate::NsupdateExecutor, rndc::RndcExecutor};
16
17/// Application state shared across handlers
18#[derive(Clone)]
19pub struct AppState {
20    /// RNDC command executor
21    pub rndc: Arc<RndcExecutor>,
22    /// nsupdate command executor
23    pub nsupdate: Arc<NsupdateExecutor>,
24    /// Zone file directory
25    pub zone_dir: String,
26    /// DNSSEC key directory (`BIND_KEY_DIR`), canonicalized at startup.
27    /// `None` when not configured; only the DS endpoint requires it
28    /// (ADR-0001) — everything else works without it.
29    pub key_dir: Option<String>,
30}
31
32/// Error response
33#[derive(Serialize)]
34pub struct ErrorResponse {
35    pub error: String,
36    pub details: Option<String>,
37}
38
39/// API error type
40#[derive(Debug, thiserror::Error)]
41pub enum ApiError {
42    #[error("Zone file error: {0}")]
43    ZoneFileError(String),
44
45    #[error("RNDC command failed: {0}")]
46    RndcError(String),
47
48    #[error("Invalid request: {0}")]
49    InvalidRequest(String),
50
51    #[error("Zone not found: {0}")]
52    ZoneNotFound(String),
53
54    #[error("Zone already exists: {0}")]
55    ZoneAlreadyExists(String),
56
57    #[error("Internal server error: {0}")]
58    InternalError(String),
59
60    #[error("Dynamic updates not enabled: {0}")]
61    DynamicUpdatesNotEnabled(String),
62
63    #[error("nsupdate command failed: {0}")]
64    NsupdateError(String),
65
66    #[error("Invalid record: {0}")]
67    InvalidRecord(String),
68
69    /// A DNSSEC transition that would take the zone dark for validating
70    /// resolvers (ADR-0001), e.g. removing `dnssec-policy` while DNSKEY
71    /// records are still served. 409: the request conflicts with the zone's
72    /// current signing state, not with its syntax.
73    #[error("Unsafe DNSSEC transition: {0}")]
74    UnsafeDnssecTransition(String),
75
76    /// The requested DNSSEC material does not exist for this zone (e.g. DS
77    /// records of an unsigned zone). 404: the sub-resource is absent.
78    #[error("DNSSEC material not available: {0}")]
79    DsNotAvailable(String),
80
81    /// The deployment lacks a prerequisite for this endpoint (e.g. the DS
82    /// endpoint without a `BIND_KEY_DIR` mount, ADR-0001). 501: the server
83    /// cannot fulfil the request until it is reconfigured.
84    #[error("Not configured on this server: {0}")]
85    NotConfigured(String),
86}
87
88/// Generic, non-revealing message returned to clients for any 5xx error.
89///
90/// The detailed cause (raw `rndc`/`nsupdate` stderr, internal filesystem paths,
91/// kube API-server errors) is logged server-side instead. Returning it to the
92/// caller is an information-disclosure vector (A-3): BIND/nsupdate stderr can
93/// leak key names, zone-internal configuration, server addresses, and file
94/// contents useful for further attack.
95const GENERIC_SERVER_ERROR: &str = "Internal server error";
96
97impl IntoResponse for ApiError {
98    fn into_response(self) -> Response {
99        // 4xx variants describe a problem with the caller's own request (their
100        // input, a missing/duplicate zone) and are safe — and useful — to
101        // return verbatim. 5xx variants carry internal detail and are replaced
102        // with a generic message after the full error is logged server-side.
103        let (status, error_message) = match &self {
104            ApiError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
105            ApiError::ZoneNotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
106            ApiError::ZoneAlreadyExists(_) => (StatusCode::CONFLICT, self.to_string()),
107            ApiError::DynamicUpdatesNotEnabled(_) => (StatusCode::BAD_REQUEST, self.to_string()),
108            ApiError::InvalidRecord(_) => (StatusCode::BAD_REQUEST, self.to_string()),
109            ApiError::UnsafeDnssecTransition(_) => (StatusCode::CONFLICT, self.to_string()),
110            ApiError::DsNotAvailable(_) => (StatusCode::NOT_FOUND, self.to_string()),
111            ApiError::NotConfigured(_) => (StatusCode::NOT_IMPLEMENTED, self.to_string()),
112            ApiError::ZoneFileError(_)
113            | ApiError::RndcError(_)
114            | ApiError::InternalError(_)
115            | ApiError::NsupdateError(_) => {
116                error!("returning 500 to client; internal error: {}", self);
117                (
118                    StatusCode::INTERNAL_SERVER_ERROR,
119                    GENERIC_SERVER_ERROR.to_string(),
120                )
121            }
122        };
123
124        let body = Json(ErrorResponse {
125            error: error_message,
126            details: None,
127        });
128
129        (status, body).into_response()
130    }
131}