nautalid 0.1.0

Scratch container substrate — TLS 1.3 HTTP/2+3 kernel, LID/AetherDB, optional filter bus (GPL-3.0-or-later).
//! Optional server surfaces — static files, reverse proxy, WebSocket, gRPC.

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;

/// Reject env vars that would enable HTTP/1.x (forbidden in Nautalid).
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(())
}

/// HTTP/1.x is not supported anywhere in Nautalid.
#[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,
}

/// Merge enabled optional surfaces onto the public router tree.
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
}

/// Spawn optional gRPC listener when enabled (requires **`grpc-surface`** feature).
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)"
        );
    }
}

/// Log listen + surface toggles at startup.
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");
        }
    }
}