use anyhow::Context;
use axum::{
extract::State,
http::StatusCode,
middleware as axum_middleware,
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use clap::Parser;
use serde::Serialize;
use std::net::SocketAddr;
use std::sync::Arc;
use tower_http::trace::TraceLayer;
use tracing::{debug, error, info, warn};
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use bindcar::{
auth::authenticate,
cli::{Cli, Commands},
metrics, middleware,
rate_limit::RateLimitConfig,
rndc::RndcExecutor,
types::{AppState, ErrorResponse},
zones,
};
use tower_governor::{
governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer,
};
#[derive(OpenApi)]
#[openapi(
paths(
zones::create_zone,
zones::delete_zone,
zones::modify_zone,
zones::reload_zone,
zones::zone_status,
zones::freeze_zone,
zones::thaw_zone,
zones::notify_zone,
zones::retransfer_zone,
zones::server_status,
zones::list_zones,
zones::get_zone,
bindcar::records::add_record,
bindcar::records::remove_record,
bindcar::records::update_record,
),
components(
schemas(
zones::CreateZoneRequest,
zones::ModifyZoneRequest,
zones::ZoneResponse,
zones::ServerStatusResponse,
zones::ZoneInfo,
zones::ZoneListResponse,
zones::ZoneConfig,
zones::SoaRecord,
zones::DnsRecord,
bindcar::records::AddRecordRequest,
bindcar::records::RemoveRecordRequest,
bindcar::records::UpdateRecordRequest,
bindcar::records::RecordResponse,
)
),
tags(
(name = "zones", description = "Zone management endpoints"),
(name = "records", description = "DNS record management endpoints"),
(name = "server", description = "Server status endpoints")
),
info(
title = "Bindcar API",
version = "0.1.0",
description = "HTTP REST API for managing BIND9 zones and DNS records via RNDC and nsupdate",
license(name = "MIT")
)
)]
struct ApiDoc;
const DEFAULT_BIND_ZONE_DIR: &str = "/var/cache/bind";
const DEFAULT_API_PORT: u16 = 8080;
#[derive(Serialize)]
struct HealthResponse {
status: String,
version: String,
}
#[derive(Serialize)]
struct ReadyResponse {
ready: bool,
checks: Vec<String>,
}
async fn health_check() -> Json<HealthResponse> {
Json(HealthResponse {
status: "healthy".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
})
}
async fn metrics_handler() -> Response {
match metrics::gather_metrics() {
Ok(metrics_text) => (
StatusCode::OK,
[("Content-Type", "text/plain; version=0.0.4")],
metrics_text,
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to gather metrics: {}", e),
details: None,
}),
)
.into_response(),
}
}
async fn ready_check(State(state): State<AppState>) -> Json<ReadyResponse> {
let mut checks = Vec::new();
let mut ready = true;
match tokio::fs::metadata(&state.zone_dir).await {
Ok(metadata) => {
if !metadata.is_dir() {
ready = false;
checks.push(format!("zone_dir_not_directory: {}", state.zone_dir));
} else {
checks.push(format!("zone_dir_accessible: {}", state.zone_dir));
}
}
Err(e) => {
ready = false;
checks.push(format!("zone_dir_error: {}", e));
}
}
if let Err(e) = state.rndc.status().await {
warn!("RNDC not ready: {}", e);
ready = false;
checks.push(format!("rndc_error: {}", e));
} else {
checks.push("rndc_available: true".to_string());
}
Json(ReadyResponse { ready, checks })
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
init_tracing(cli.debug);
start_server(cli.resolved_command()).await
}
fn init_tracing(debug: bool) {
let filter = if debug {
tracing_subscriber::EnvFilter::new("debug")
} else {
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))
};
tracing_subscriber::fmt()
.with_env_filter(filter)
.json()
.init();
}
async fn start_server(command: &Commands) -> anyhow::Result<()> {
match command {
Commands::Run => info!(
"starting bindcar v{} [sidecar mode]",
env!("CARGO_PKG_VERSION")
),
Commands::Drone => info!(
"starting bindcar v{} [drone mode] - standalone, managing remote BIND9",
env!("CARGO_PKG_VERSION")
),
}
metrics::init_metrics();
let zone_dir =
std::env::var("BIND_ZONE_DIR").unwrap_or_else(|_| DEFAULT_BIND_ZONE_DIR.to_string());
let api_port = std::env::var("API_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_API_PORT);
let disable_auth = std::env::var("DISABLE_AUTH")
.ok()
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(false);
info!("zone directory: {}", zone_dir);
info!("api port: {}", api_port);
if disable_auth {
warn!("⚠️ authentication is disabled - api endpoints are unprotected!");
warn!("⚠️ this should only be used in trusted environments (e.g., linkerd service mesh)");
} else {
info!("authentication is enabled");
#[cfg(feature = "k8s-token-review")]
{
use bindcar::auth::{detect_kube_auth_mode, KubeAuthMode};
match detect_kube_auth_mode() {
KubeAuthMode::Explicit { ref server, .. } => {
info!(
"kubernetes auth mode: explicit (KUBE_API_SERVER={})",
server
);
}
KubeAuthMode::Default => {
info!("kubernetes auth mode: try_default (KUBECONFIG / ~/.kube/config / in-cluster)");
}
}
}
}
let rate_limit_config = RateLimitConfig::from_env();
if let Err(e) = rate_limit_config.validate() {
error!("invalid rate limit configuration: {}", e);
return Err(anyhow::anyhow!("invalid rate limit configuration: {}", e));
}
if rate_limit_config.enabled {
info!(
"rate limiting enabled: {} requests per {} seconds (burst: {})",
rate_limit_config.requests_per_period,
rate_limit_config.period_secs,
rate_limit_config.burst_size
);
} else {
warn!("⚠️ rate limiting is disabled");
}
let config_paths = vec!["/etc/bind/rndc.conf", "/etc/rndc.conf"];
let mut parsed_config = None;
for path in &config_paths {
match bindcar::rndc::parse_rndc_conf(path) {
Ok(cfg) => {
info!("successfully parsed rndc configuration from {}", path);
parsed_config = Some(cfg);
break;
}
Err(e) => {
debug!("failed to parse {}: {}", path, e);
}
}
}
let rndc_server = if let Ok(server) = std::env::var("RNDC_SERVER") {
info!("using RNDC_SERVER from environment: {}", server);
server
} else if let Some(ref cfg) = parsed_config {
info!("using server from rndc.conf: {}", cfg.server);
cfg.server.clone()
} else {
let default = "127.0.0.1:953".to_string();
warn!("using default RNDC_SERVER: {}", default);
default
};
let rndc_algorithm = if let Ok(algorithm) = std::env::var("RNDC_ALGORITHM") {
info!("using RNDC_ALGORITHM from environment: {}", algorithm);
algorithm
} else if let Some(ref cfg) = parsed_config {
info!("using algorithm from rndc.conf: {}", cfg.algorithm);
cfg.algorithm.clone()
} else {
let default = "sha256".to_string();
warn!("using default RNDC_ALGORITHM: {}", default);
default
};
let rndc_secret = if let Ok(secret) = std::env::var("RNDC_SECRET") {
info!("using RNDC_SECRET from environment");
secret
} else if let Some(ref cfg) = parsed_config {
info!("using secret from rndc.conf");
cfg.secret.clone()
} else {
error!("rndc configuration not found!");
error!("either set RNDC_SECRET environment variable or ensure /etc/bind/rndc.conf exists");
return Err(anyhow::anyhow!(
"rndc configuration required: set RNDC_SECRET env var or create /etc/bind/rndc.conf"
));
};
if !tokio::fs::metadata(&zone_dir).await?.is_dir() {
error!("zone directory does not exist: {}", zone_dir);
return Err(anyhow::anyhow!("zone directory not found"));
}
let rndc = Arc::new(
RndcExecutor::new(
rndc_server.clone(),
rndc_algorithm.clone(),
rndc_secret.clone(),
)
.context("failed to create rndc client")?,
);
let nsupdate_key_name = std::env::var("NSUPDATE_KEY_NAME")
.ok()
.or_else(|| std::env::var("RNDC_KEY_NAME").ok())
.or(Some("rndc-key".to_string()));
let nsupdate_algorithm = std::env::var("NSUPDATE_ALGORITHM")
.ok()
.or(Some(rndc_algorithm.clone()));
let nsupdate_secret = std::env::var("NSUPDATE_SECRET")
.ok()
.or(Some(rndc_secret.clone()));
let nsupdate_server = std::env::var("NSUPDATE_SERVER")
.ok()
.unwrap_or_else(|| "127.0.0.1".to_string());
let nsupdate_port = std::env::var("NSUPDATE_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(53);
info!("nsupdate executor configuration:");
info!(" server: {}:{}", nsupdate_server, nsupdate_port);
info!(" TSIG key: {:?}", nsupdate_key_name);
let nsupdate = Arc::new(
bindcar::nsupdate::NsupdateExecutor::new(
nsupdate_server,
nsupdate_port,
nsupdate_key_name,
nsupdate_algorithm,
nsupdate_secret,
)
.context("failed to create nsupdate executor")?,
);
let state = AppState {
rndc,
nsupdate,
zone_dir: zone_dir.clone(),
};
let api_routes = Router::new()
.route("/zones", post(zones::create_zone).get(zones::list_zones))
.route(
"/zones/{name}",
get(zones::get_zone)
.delete(zones::delete_zone)
.patch(zones::modify_zone),
)
.route("/zones/{name}/reload", post(zones::reload_zone))
.route("/zones/{name}/status", get(zones::zone_status))
.route("/zones/{name}/freeze", post(zones::freeze_zone))
.route("/zones/{name}/thaw", post(zones::thaw_zone))
.route("/zones/{name}/notify", post(zones::notify_zone))
.route("/zones/{name}/retransfer", post(zones::retransfer_zone))
.route(
"/zones/{name}/records",
post(bindcar::records::add_record)
.delete(bindcar::records::remove_record)
.put(bindcar::records::update_record),
)
.route("/server/status", get(zones::server_status))
.with_state(state.clone());
let api_routes = if !disable_auth {
api_routes.layer(axum_middleware::from_fn(authenticate))
} else {
api_routes
};
let api_routes = if rate_limit_config.enabled {
let per_second =
rate_limit_config.requests_per_period / rate_limit_config.period_secs.max(1) as u32;
let per_second = per_second.max(1);
let governor_conf = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_second(per_second.into())
.burst_size(rate_limit_config.burst_size)
.finish()
.expect("Failed to create governor config"),
);
api_routes.layer(GovernorLayer::new(governor_conf))
} else {
api_routes
};
let app = Router::new()
.merge(SwaggerUi::new("/api/v1/docs").url("/api/v1/openapi.json", ApiDoc::openapi()))
.route("/api/v1/health", get(health_check))
.route("/api/v1/ready", get(ready_check))
.route("/metrics", get(metrics_handler))
.nest("/api/v1", api_routes)
.with_state(state)
.layer(axum_middleware::from_fn(middleware::track_metrics))
.layer(TraceLayer::new_for_http());
let addr = format!("0.0.0.0:{}", api_port);
info!("bind9 rndc api server listening on {}", addr);
info!("swagger ui available at http://{}/api/v1/docs", addr);
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.context("server error")?;
Ok(())
}