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;
#[cfg(feature = "tls")]
use std::sync::Arc as StdArc;
use std::sync::Arc;
use tower_http::trace::TraceLayer;
use tracing::{debug, error, info, warn};
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
#[cfg(feature = "tls")]
use axum::extract::ConnectInfo;
#[cfg(feature = "tls")]
use bindcar::tls::TlsReloader;
use bindcar::tls::{scheme_for, TlsSettings, DEFAULT_RELOAD_INTERVAL_SECS};
use bindcar::{
auth::authenticate,
cli::{Cli, Commands},
metrics, middleware,
rate_limit::RateLimitConfig,
rndc::RndcExecutor,
types::{AppState, ErrorResponse},
zones,
};
#[cfg(feature = "tls")]
use tower::Service as _;
use tower_governor::{
governor::GovernorConfigBuilder, key_extractor::PeerIpKeyExtractor, GovernorLayer,
};
const VERSION: &str = env!("CARGO_PKG_VERSION");
const TLS_SUPPORTED: bool = cfg!(feature = "tls");
fn check_tls_support(tls_requested: bool, tls_supported: bool) -> anyhow::Result<()> {
if tls_requested && !tls_supported {
anyhow::bail!(
"TLS options were supplied but this bindcar binary was built without the \
`tls` feature, so it can only serve plaintext. Rebuild with `--features tls` \
(it is enabled by default), or remove the TLS options to serve plaintext deliberately."
);
}
Ok(())
}
#[derive(OpenApi)]
#[openapi(
paths(
zones::create_zone,
zones::delete_zone,
zones::modify_zone,
zones::reload_zone,
zones::zone_status,
zones::get_zone_ds,
zones::checkds_zone,
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::ZoneStatusResponse,
zones::DsSetResponse,
zones::DsRecordView,
zones::CheckdsRequest,
bindcar::dnssec::DnssecStatus,
bindcar::dnssec::DnssecKeyStatus,
bindcar::dnssec::CheckdsState,
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 = VERSION,
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;
const DEFAULT_BIND_API_ADDRESS: &str = "0.0.0.0";
#[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: VERSION.to_string(),
})
}
fn ready_check_label(name: &str, ok: bool) -> String {
format!("{}: {}", name, if ok { "ok" } else { "error" })
}
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) => {
error!("failed to gather metrics: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to gather metrics".to_string(),
details: None,
}),
)
.into_response()
}
}
}
async fn probe_zone_dir(zone_dir: &str) -> bool {
if !zones::is_normalized_zone_dir(zone_dir) || zone_dir.contains("..") {
warn!(
"zone directory {:?} is not a normalized absolute path; refusing to probe it",
zone_dir
);
return false;
}
let metadata = match tokio::fs::metadata(zone_dir).await {
Ok(metadata) => metadata,
Err(e) => {
warn!("zone directory {:?} not accessible: {}", zone_dir, e);
return false;
}
};
if !metadata.is_dir() {
warn!("zone directory {:?} is not a directory", zone_dir);
return false;
}
true
}
async fn ready_check(State(state): State<AppState>) -> Json<ReadyResponse> {
let mut checks = Vec::new();
let mut ready = true;
let zone_dir_ok = probe_zone_dir(&state.zone_dir).await;
ready &= zone_dir_ok;
checks.push(ready_check_label("zone_dir", zone_dir_ok));
let rndc_ok = match state.rndc.status().await {
Ok(_) => true,
Err(e) => {
warn!("RNDC not ready: {}", e);
false
}
};
ready &= rndc_ok;
checks.push(ready_check_label("rndc", rndc_ok));
Json(ReadyResponse { ready, checks })
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
init_tracing(cli.debug);
let insecure_override = cli.i_know_this_is_insecure
|| std::env::var("BINDCAR_ALLOW_INSECURE_AUTH")
.ok()
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(false);
let tls_requested =
cli.tls_cert.is_some() || cli.tls_key.is_some() || cli.tls_client_ca.is_some();
check_tls_support(tls_requested, TLS_SUPPORTED)?;
let tls_settings = bindcar::tls::resolve_tls_settings(
cli.tls_cert.clone(),
cli.tls_key.clone(),
cli.tls_client_ca.clone(),
)
.context("invalid TLS configuration")?;
let reload_interval = cli
.tls_reload_interval
.unwrap_or(DEFAULT_RELOAD_INTERVAL_SECS);
start_server(
cli.resolved_command(),
insecure_override,
tls_settings,
reload_interval,
)
.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,
insecure_override: bool,
tls_settings: Option<TlsSettings>,
tls_reload_interval: u64,
) -> anyhow::Result<()> {
match command {
Commands::Run => info!("starting bindcar v{} [sidecar mode]", VERSION),
Commands::Drone => info!(
"starting bindcar v{} [drone mode] - standalone, managing remote BIND9",
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 bind_host =
std::env::var("BIND_API_ADDRESS").unwrap_or_else(|_| DEFAULT_BIND_API_ADDRESS.to_string());
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 address: {}:{}", bind_host, api_port);
if let Err(e) = bindcar::auth::check_startup_auth_posture(
!disable_auth,
bindcar::auth::has_real_auth(),
&bind_host,
insecure_override,
) {
error!("{}", e);
return Err(anyhow::anyhow!(e));
}
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");
if std::env::var(bindcar::auth::BIND_API_TOKEN_ENV)
.map(|v| !v.is_empty())
.unwrap_or(false)
{
info!("shared-secret API token authentication is active (BIND_API_TOKEN)");
}
#[cfg(feature = "k8s-token-review")]
{
use bindcar::auth::{
check_authorization_posture, detect_kube_auth_mode, shared_secret_configured,
KubeAuthMode, TokenReviewConfig, ALLOW_ANY_SERVICE_ACCOUNT_ENV,
};
if shared_secret_configured() {
info!("auth mode: shared-secret (BIND_API_TOKEN) — Kubernetes TokenReview is not active");
} else {
let tr_config = TokenReviewConfig::from_env();
let allow_any = std::env::var(ALLOW_ANY_SERVICE_ACCOUNT_ENV)
.ok()
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(false);
if let Err(e) =
check_authorization_posture(tr_config.is_authorization_restricted(), allow_any)
{
error!("{}", e);
return Err(anyhow::anyhow!(e));
}
if allow_any && !tr_config.is_authorization_restricted() {
warn!("⚠️ BIND_ALLOW_ANY_SERVICEACCOUNT is set: every authenticated ServiceAccount in the cluster is authorized");
}
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"
));
};
let zone_dir = zones::resolve_zone_dir(&zone_dir)
.map_err(|e| anyhow::anyhow!("zone directory not usable: {}", e))?;
info!("resolved zone directory: {}", zone_dir);
let key_dir = match std::env::var("BIND_KEY_DIR") {
Ok(raw) => {
let resolved = zones::resolve_zone_dir(&raw)
.map_err(|e| anyhow::anyhow!("key directory not usable: {}", e))?;
info!("resolved DNSSEC key directory: {}", resolved);
Some(resolved)
}
Err(_) => {
info!("BIND_KEY_DIR not set; the DS endpoint will return 501");
None
}
};
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(),
key_dir,
};
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}/ds", get(zones::get_zone_ds))
.route("/zones/{name}/dnssec/checkds", post(zones::checkds_zone))
.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 replenish_period = rate_limit_config.replenish_period();
let governor_conf = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(PeerIpKeyExtractor)
.period(replenish_period)
.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 enable_docs = std::env::var("BIND_ENABLE_DOCS")
.ok()
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(false);
let mut app = Router::new();
if enable_docs {
info!("serving unauthenticated API docs at /api/v1/docs (BIND_ENABLE_DOCS=true)");
app = app
.merge(SwaggerUi::new("/api/v1/docs").url("/api/v1/openapi.json", ApiDoc::openapi()));
}
let app = app
.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!("{}:{}", bind_host, api_port);
let scheme = scheme_for(tls_settings.as_ref());
info!(
"bind9 rndc api server listening on {} ({})",
addr,
if tls_settings.is_some() {
"TLS"
} else {
"plaintext"
}
);
if enable_docs {
info!("swagger ui available at {}://{}/api/v1/docs", scheme, addr);
}
let listener = tokio::net::TcpListener::bind(&addr).await?;
#[cfg(not(feature = "tls"))]
{
let _ = (tls_settings, tls_reload_interval);
warn_plaintext_transport(&bind_host);
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.context("server error")
}
#[cfg(feature = "tls")]
{
let Some(tls_settings) = tls_settings else {
warn_plaintext_transport(&bind_host);
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.context("server error")?;
return Ok(());
};
if tls_settings.requires_client_auth() {
info!(
"mutual TLS enabled; clients must present a certificate trusted by the configured CA"
);
}
let reloader =
StdArc::new(TlsReloader::new(tls_settings).context("failed to build TLS config")?);
if TlsReloader::reloading_enabled(tls_reload_interval) {
info!(
"watching TLS certificate material for renewals every {}s (BIND_TLS_RELOAD_INTERVAL=0 to disable)",
tls_reload_interval
);
spawn_tls_reload_task(StdArc::clone(&reloader), tls_reload_interval);
} else {
info!("TLS certificate reloading is disabled; a renewal requires a restart");
}
serve_tls(listener, app, reloader).await
}
}
#[cfg(feature = "tls")]
fn spawn_tls_reload_task(reloader: StdArc<TlsReloader>, interval_secs: u64) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
ticker.tick().await;
#[cfg(unix)]
let mut sighup = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())
{
Ok(s) => Some(s),
Err(e) => {
warn!("could not install SIGHUP handler for TLS reload: {}", e);
None
}
};
loop {
#[cfg(unix)]
{
match sighup.as_mut() {
Some(hup) => {
tokio::select! {
_ = ticker.tick() => {}
_ = hup.recv() => {
info!("SIGHUP received; checking TLS material now");
}
}
}
None => {
ticker.tick().await;
}
}
}
#[cfg(not(unix))]
{
ticker.tick().await;
}
reloader.reload_if_changed();
}
});
}
fn warn_plaintext_transport(bind_host: &str) {
if bind_host.starts_with("127.") || bind_host == "localhost" || bind_host == "::1" {
return;
}
warn!(
"serving the API over plaintext HTTP on a non-loopback address; API credentials \
cross the network in the clear. Set --tls-cert/--tls-key (BIND_TLS_CERT/BIND_TLS_KEY) to enable TLS."
);
}
#[cfg(feature = "tls")]
async fn serve_tls(
listener: tokio::net::TcpListener,
app: Router,
reloader: StdArc<TlsReloader>,
) -> anyhow::Result<()> {
loop {
let (stream, peer) = listener.accept().await.context("accept failed")?;
let acceptor = tokio_rustls::TlsAcceptor::from(reloader.current());
let app = app.clone();
tokio::spawn(async move {
let tls_stream = match acceptor.accept(stream).await {
Ok(s) => s,
Err(e) => {
debug!("TLS handshake with {} failed: {}", peer, e);
return;
}
};
let service = hyper::service::service_fn(
move |mut req: hyper::Request<hyper::body::Incoming>| {
req.extensions_mut().insert(ConnectInfo(peer));
app.clone().call(req)
},
);
if let Err(e) =
hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new())
.serve_connection_with_upgrades(
hyper_util::rt::TokioIo::new(tls_stream),
service,
)
.await
{
debug!("connection from {} ended: {}", peer, e);
}
});
}
}
#[cfg(test)]
mod main_test;