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}
27
28/// Error response
29#[derive(Serialize)]
30pub struct ErrorResponse {
31    pub error: String,
32    pub details: Option<String>,
33}
34
35/// API error type
36#[derive(Debug, thiserror::Error)]
37pub enum ApiError {
38    #[error("Zone file error: {0}")]
39    ZoneFileError(String),
40
41    #[error("RNDC command failed: {0}")]
42    RndcError(String),
43
44    #[error("Invalid request: {0}")]
45    InvalidRequest(String),
46
47    #[error("Zone not found: {0}")]
48    ZoneNotFound(String),
49
50    #[error("Zone already exists: {0}")]
51    ZoneAlreadyExists(String),
52
53    #[error("Internal server error: {0}")]
54    InternalError(String),
55
56    #[error("Dynamic updates not enabled: {0}")]
57    DynamicUpdatesNotEnabled(String),
58
59    #[error("nsupdate command failed: {0}")]
60    NsupdateError(String),
61
62    #[error("Invalid record: {0}")]
63    InvalidRecord(String),
64}
65
66/// Generic, non-revealing message returned to clients for any 5xx error.
67///
68/// The detailed cause (raw `rndc`/`nsupdate` stderr, internal filesystem paths,
69/// kube API-server errors) is logged server-side instead. Returning it to the
70/// caller is an information-disclosure vector (A-3): BIND/nsupdate stderr can
71/// leak key names, zone-internal configuration, server addresses, and file
72/// contents useful for further attack.
73const GENERIC_SERVER_ERROR: &str = "Internal server error";
74
75impl IntoResponse for ApiError {
76    fn into_response(self) -> Response {
77        // 4xx variants describe a problem with the caller's own request (their
78        // input, a missing/duplicate zone) and are safe — and useful — to
79        // return verbatim. 5xx variants carry internal detail and are replaced
80        // with a generic message after the full error is logged server-side.
81        let (status, error_message) = match &self {
82            ApiError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
83            ApiError::ZoneNotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
84            ApiError::ZoneAlreadyExists(_) => (StatusCode::CONFLICT, self.to_string()),
85            ApiError::DynamicUpdatesNotEnabled(_) => (StatusCode::BAD_REQUEST, self.to_string()),
86            ApiError::InvalidRecord(_) => (StatusCode::BAD_REQUEST, self.to_string()),
87            ApiError::ZoneFileError(_)
88            | ApiError::RndcError(_)
89            | ApiError::InternalError(_)
90            | ApiError::NsupdateError(_) => {
91                error!("returning 500 to client; internal error: {}", self);
92                (
93                    StatusCode::INTERNAL_SERVER_ERROR,
94                    GENERIC_SERVER_ERROR.to_string(),
95                )
96            }
97        };
98
99        let body = Json(ErrorResponse {
100            error: error_message,
101            details: None,
102        });
103
104        (status, body).into_response()
105    }
106}