mod config;
#[cfg(feature = "grpc-surface")]
mod grpc;
mod kdl;
mod proxy;
mod static_files;
mod validate;
mod websocket;
pub use config::{
GrpcSurface, ListenEndpoints, ProxySurface, StaticSurface, SurfaceConfig,
WebSocketSurface, bind_addr_from_env, default_port, env_disabled, env_enabled,
};
pub use validate::{SurfaceConfigError, load_from_env};
use axum::Router;
use tracing::info;
use crate::state::AppState;
pub fn reject_http1_env() -> Result<(), Http1Forbidden> {
if std::env::var("NAUTALID_HTTP_UNIX")
.ok()
.is_some_and(|s| !s.trim().is_empty())
{
return Err(Http1Forbidden::UnixSocket);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Http1Forbidden {
#[error("HTTP/1.x is not supported: NAUTALID_HTTP_UNIX is forbidden (use the ZMTP bus on ipc:///run/nautalid/bus.sock for local control)")]
UnixSocket,
}
pub fn apply(router: Router<AppState>, state: &AppState, config: &SurfaceConfig) -> Router<AppState> {
let mut router = router;
if let Some(static_files) = &config.static_files {
info!(
root = %static_files.root.display(),
prefix = %static_files.prefix,
"static file surface enabled"
);
router = router.merge(static_files::router(state, static_files));
}
if let Some(proxy) = &config.proxy {
info!(
upstream = %proxy.upstream,
prefix = %proxy.prefix,
"reverse proxy surface enabled"
);
router = router.merge(proxy::router(state, proxy));
}
if let Some(ws) = &config.websocket {
info!(path = %ws.path, "websocket surface enabled");
router = router.merge(websocket::router(state, ws));
}
router
}
pub fn spawn_grpc(
config: &SurfaceConfig,
#[cfg(feature = "grpc-surface")] h2_config: std::sync::Arc<
tokio::sync::RwLock<std::sync::Arc<rustls::ServerConfig>>,
>,
#[cfg(feature = "grpc-surface")] acme_challenge: Option<std::sync::Arc<rustls::ServerConfig>>,
#[cfg(feature = "grpc-surface")] require_client_auth: bool,
) {
#[cfg(feature = "grpc-surface")]
if let Some(grpc) = &config.grpc {
let grpc = grpc.clone();
tokio::spawn(async move {
if let Err(err) = grpc::serve(&grpc, h2_config, acme_challenge, require_client_auth).await {
tracing::error!(%err, "gRPC surface failed");
}
});
}
#[cfg(not(feature = "grpc-surface"))]
if config.grpc.is_some() {
tracing::error!(
"NAUTALID_GRPC=1 requires building with feature grpc-surface (tonic-health)"
);
}
}
pub fn log_startup(config: &SurfaceConfig) {
let listen = config.listen;
if listen.h2_enabled {
info!(%listen.h2, "HTTP/2 listen address");
}
if listen.h3_enabled {
info!(%listen.h3, "HTTP/3 listen address");
}
if config.tls_reload {
info!("TLS certificate reload enabled (SIGHUP + bus tls reload)");
}
if config.probe_h3 {
info!("H3 probe enabled for --probe");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_unix_http_env() {
unsafe {
std::env::set_var("NAUTALID_HTTP_UNIX", "/tmp/nautalid.sock");
}
let err = reject_http1_env().expect_err("must reject HTTP/1.x env");
assert!(matches!(err, Http1Forbidden::UnixSocket));
unsafe {
std::env::remove_var("NAUTALID_HTTP_UNIX");
}
}
#[test]
fn static_enabled_without_root_fails_validation() {
unsafe {
std::env::set_var("NAUTALID_STATIC", "1");
std::env::remove_var("NAUTALID_STATIC_ROOT");
std::env::remove_var("NAUTALID_SURFACES_KDL_PATH");
}
let err = load_from_env().expect_err("missing root");
assert!(matches!(err, SurfaceConfigError::StaticNoRoot));
unsafe {
std::env::remove_var("NAUTALID_STATIC");
}
}
#[test]
fn proxy_enabled_without_upstream_fails_validation() {
unsafe {
std::env::set_var("NAUTALID_PROXY", "1");
std::env::remove_var("NAUTALID_PROXY_UPSTREAM");
std::env::remove_var("NAUTALID_SURFACES_KDL_PATH");
}
let err = load_from_env().expect_err("missing upstream");
assert!(matches!(err, SurfaceConfigError::ProxyNoUpstream));
unsafe {
std::env::remove_var("NAUTALID_PROXY");
}
}
}