nautalid 0.1.0

Scratch container substrate — TLS 1.3 HTTP/2+3 kernel, LID/AetherDB, optional filter bus (GPL-3.0-or-later).
//! Nautalid **secure mTLS tunnel** for distributed AetherDB wire (Hickory DNS + rustls-backed ZMQ).
//!
//! Scratch containers have no `/etc/ssl`; mount PEMs or set inline env vars and Nautalid
//! materializes client cert/key/CA for the AetherDB wire preset.
//!
//! | Variable | Role |
//! |----------|------|
//! | **`NAUTALID_AETHER_TUNNEL_CERT_PEM`** / **`_CERT_PATH`** | Client certificate |
//! | **`NAUTALID_AETHER_TUNNEL_KEY_PEM`** / **`_KEY_PATH`** | Client private key |
//! | **`NAUTALID_AETHER_TUNNEL_CA_PEM`** / **`_CA_PATH`** | CA to verify the AetherDB hub |
//! | **`NAUTALID_AETHER_TUNNEL_INSECURE`** | Dev only: plain `tcp://` (no TLS) |
//!
//! Legacy **`KWT_REPLAY_AETHERDB_TLS_*_PATH`** paths are still accepted.

use std::path::PathBuf;
use std::sync::OnceLock;

use aetherdb::ZmqTlsConfig;
use thiserror::Error;

/// mTLS client material for the AetherDB ZMQ wire preset.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WireTls {
    pub cert_file: PathBuf,
    pub key_file: PathBuf,
    pub ca_file: PathBuf,
}

#[derive(Debug, Error, Clone)]
pub enum TunnelError {
    #[error("AetherDB tunnel requires client cert (NAUTALID_AETHER_TUNNEL_CERT_PEM or _CERT_PATH)")]
    MissingCert,
    #[error("AetherDB tunnel requires client key (NAUTALID_AETHER_TUNNEL_KEY_PEM or _KEY_PATH)")]
    MissingKey,
    #[error("AetherDB tunnel requires CA (NAUTALID_AETHER_TUNNEL_CA_PEM or _CA_PATH)")]
    MissingCa,
    #[error("read {path}: {message}")]
    Io { path: String, message: String },
    #[error(
        "host wire TLS is enabled (AETHERDB_HOST_TLS_*) but client tunnel material is missing (NAUTALID_AETHER_TUNNEL_*)"
    )]
    WireClientTlsRequired,
    #[error(
        "remote Aether wire endpoint `{endpoint}` requires mTLS (set NAUTALID_AETHER_TUNNEL_* or dev-only NAUTALID_AETHER_TUNNEL_INSECURE=1)"
    )]
    RemoteWireTlsRequired { endpoint: String },
}

struct TunnelMaterial {
    _guard: TunnelGuard,
    tls: WireTls,
}

enum TunnelGuard {
    Mounted,
    Materialized { _dir: tempfile::TempDir },
}

static TUNNEL: OnceLock<Result<TunnelMaterial, TunnelError>> = OnceLock::new();

/// Whether any AetherDB tunnel env var is set.
#[must_use]
pub fn configured_from_env() -> bool {
    tunnel_env_touched()
}

/// Dev-only plain TCP for AetherDB wire (never use in production).
#[must_use]
pub fn insecure_dev_allowed() -> bool {
    matches!(
        std::env::var("NAUTALID_AETHER_TUNNEL_INSECURE")
            .ok()
            .as_deref(),
        Some("1") | Some("true") | Some("yes")
    ) || matches!(
        std::env::var("KWT_REPLAY_AETHERDB_TLS_INSECURE")
            .ok()
            .as_deref(),
        Some("1") | Some("true") | Some("yes")
    )
}

/// Eagerly load and materialize the tunnel (logged at startup warm-up).
pub fn warm_up() -> Result<(), TunnelError> {
    match load_tunnel()? {
        Some(_) => {
            tracing::info!("AetherDB wire tunnel ready (mTLS client)");
            Ok(())
        }
        None => Ok(()),
    }
}

/// Whether the in-process data-plane host advertises TLS on its ZMQ bind.
#[must_use]
pub fn host_bind_tls_configured() -> bool {
    host_tls_env_touched()
}

/// Map mounted/materialized tunnel PEMs to an AetherDB ZMQ TLS client profile.
#[must_use]
pub fn wire_tls_to_zmq(tls: &WireTls) -> ZmqTlsConfig {
    ZmqTlsConfig {
        cert_file: Some(tls.cert_file.display().to_string()),
        key_file: Some(tls.key_file.display().to_string()),
        ca_file: Some(tls.ca_file.display().to_string()),
        client_auth: true,
        require_explicit_ca: true,
        max_concurrent: None,
    }
}

/// Resolve ZMQ client TLS for wire pools (`WireRoutePool`, `WireSessionPool`, WT wire backend).
///
/// Honors dev bypass (`NAUTALID_AETHER_TUNNEL_INSECURE`), tunnel env, and host TLS requirements.
pub fn wire_client_zmq_tls() -> Result<Option<ZmqTlsConfig>, TunnelError> {
    if insecure_dev_allowed() {
        return Ok(None);
    }
    if let Some(tls) = load_tunnel()? {
        return Ok(Some(wire_tls_to_zmq(&tls)));
    }
    if host_bind_tls_configured() {
        return Err(TunnelError::WireClientTlsRequired);
    }
    Ok(None)
}

/// Same as [`wire_client_zmq_tls`] but enforces mTLS for non-loopback `tcp://` peers.
pub fn wire_client_zmq_tls_for_endpoint(endpoint: &str) -> Result<Option<ZmqTlsConfig>, TunnelError> {
    let tls = wire_client_zmq_tls()?;
    if tls.is_none() && remote_wire_endpoint(endpoint) {
        return Err(TunnelError::RemoteWireTlsRequired {
            endpoint: endpoint.to_string(),
        });
    }
    Ok(tls)
}

fn remote_wire_endpoint(endpoint: &str) -> bool {
    let connect = endpoint.strip_prefix("tcp://").unwrap_or(endpoint);
    let host = connect.split(':').next().unwrap_or(connect);
    !matches!(host, "127.0.0.1" | "localhost" | "0.0.0.0" | "::1")
}

/// Load mTLS material for AetherDB wire, or **`None`** when tunnel env is unset.
pub fn load_tunnel() -> Result<Option<WireTls>, TunnelError> {
    if !tunnel_env_touched() {
        return Ok(None);
    }
    match TUNNEL.get_or_init(build_tunnel) {
        Ok(material) => Ok(Some(material.tls.clone())),
        Err(err) => Err(err.clone()),
    }
}

fn build_tunnel() -> Result<TunnelMaterial, TunnelError> {
    let cert = load_component(
        "NAUTALID_AETHER_TUNNEL_CERT_PEM",
        "NAUTALID_AETHER_TUNNEL_CERT_PATH",
        "KWT_REPLAY_AETHERDB_TLS_CERT_PATH",
        TunnelError::MissingCert,
    )?;
    let key = load_component(
        "NAUTALID_AETHER_TUNNEL_KEY_PEM",
        "NAUTALID_AETHER_TUNNEL_KEY_PATH",
        "KWT_REPLAY_AETHERDB_TLS_KEY_PATH",
        TunnelError::MissingKey,
    )?;
    let ca = load_component(
        "NAUTALID_AETHER_TUNNEL_CA_PEM",
        "NAUTALID_AETHER_TUNNEL_CA_PATH",
        "KWT_REPLAY_AETHERDB_TLS_CA_PATH",
        TunnelError::MissingCa,
    )?;

    let needs_materialize = cert.inline.is_some() || key.inline.is_some() || ca.inline.is_some();
    if needs_materialize {
        let dir = tempfile::tempdir().map_err(|e| TunnelError::Io {
            path: "aether tunnel tempdir".into(),
            message: e.to_string(),
        })?;
        let tls = WireTls {
            cert_file: write_pem(&dir, "client.pem", cert.bytes()?)?,
            key_file: write_pem(&dir, "client-key.pem", key.bytes()?)?,
            ca_file: write_pem(&dir, "ca.pem", ca.bytes()?)?,
        };
        return Ok(TunnelMaterial {
            _guard: TunnelGuard::Materialized { _dir: dir },
            tls,
        });
    }

    Ok(TunnelMaterial {
        _guard: TunnelGuard::Mounted,
        tls: WireTls {
            cert_file: cert.path.ok_or(TunnelError::MissingCert)?,
            key_file: key.path.ok_or(TunnelError::MissingKey)?,
            ca_file: ca.path.ok_or(TunnelError::MissingCa)?,
        },
    })
}

struct Component {
    inline: Option<String>,
    path: Option<PathBuf>,
}

impl Component {
    fn bytes(&self) -> Result<Vec<u8>, TunnelError> {
        if let Some(pem) = &self.inline {
            return Ok(pem.as_bytes().to_vec());
        }
        let path = self.path.as_ref().ok_or(TunnelError::MissingCert)?;
        std::fs::read(path).map_err(|e| TunnelError::Io {
            path: path.display().to_string(),
            message: e.to_string(),
        })
    }
}

fn load_component(
    pem_var: &str,
    path_var: &str,
    legacy_path_var: &str,
    missing: TunnelError,
) -> Result<Component, TunnelError> {
    if let Ok(pem) = std::env::var(pem_var) {
        if !pem.trim().is_empty() {
            return Ok(Component {
                inline: Some(pem),
                path: None,
            });
        }
    }
    for name in [path_var, legacy_path_var] {
        if let Ok(path) = std::env::var(name) {
            let path = path.trim();
            if path.is_empty() {
                continue;
            }
            let pb = PathBuf::from(path);
            if !pb.is_file() {
                return Err(TunnelError::Io {
                    path: path.to_string(),
                    message: "tunnel material not a file".into(),
                });
            }
            return Ok(Component {
                inline: None,
                path: Some(pb),
            });
        }
    }
    Err(missing)
}

fn write_pem(dir: &tempfile::TempDir, name: &str, bytes: Vec<u8>) -> Result<PathBuf, TunnelError> {
    let path = dir.path().join(name);
    std::fs::write(&path, &bytes).map_err(|e| TunnelError::Io {
        path: path.display().to_string(),
        message: e.to_string(),
    })?;
    Ok(path)
}

fn host_tls_env_touched() -> bool {
    [
        "AETHERDB_HOST_TLS_CERT_PATH",
        "AETHERDB_HUB_TLS_CERT_PATH",
        "AETHERDB_HOST_TLS_KEY_PATH",
        "AETHERDB_HUB_TLS_KEY_PATH",
        "AETHERDB_HOST_TLS_CA_PATH",
        "AETHERDB_HUB_TLS_CA_PATH",
    ]
    .into_iter()
    .any(|name| {
        std::env::var(name)
            .ok()
            .is_some_and(|s| !s.trim().is_empty())
    })
}

fn tunnel_env_touched() -> bool {
    [
        "NAUTALID_AETHER_TUNNEL_CERT_PEM",
        "NAUTALID_AETHER_TUNNEL_CERT_PATH",
        "NAUTALID_AETHER_TUNNEL_KEY_PEM",
        "NAUTALID_AETHER_TUNNEL_KEY_PATH",
        "NAUTALID_AETHER_TUNNEL_CA_PEM",
        "NAUTALID_AETHER_TUNNEL_CA_PATH",
        "KWT_REPLAY_AETHERDB_TLS_CERT_PATH",
        "KWT_REPLAY_AETHERDB_TLS_KEY_PATH",
        "KWT_REPLAY_AETHERDB_TLS_CA_PATH",
    ]
    .into_iter()
    .any(|name| {
        std::env::var(name)
            .ok()
            .is_some_and(|s| !s.trim().is_empty())
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn install_crypto() {
        let _ = crate::platform::install_defaults();
    }

    fn generated_pem_triple() -> (String, String, String) {
        install_crypto();
        let ca = rcgen::generate_simple_self_signed(vec!["aether-hub.test".into()]).unwrap();
        let ca_pem = ca.cert.pem();
        let client = rcgen::generate_simple_self_signed(vec!["nautalid-client".into()]).unwrap();
        (
            client.cert.pem(),
            client.signing_key.serialize_pem(),
            ca_pem,
        )
    }

    #[test]
    fn wire_tls_maps_to_zmq_config() {
        let (cert, key, ca) = generated_pem_triple();
        unsafe {
            std::env::set_var("NAUTALID_AETHER_TUNNEL_CERT_PEM", &cert);
            std::env::set_var("NAUTALID_AETHER_TUNNEL_KEY_PEM", &key);
            std::env::set_var("NAUTALID_AETHER_TUNNEL_CA_PEM", &ca);
        }
        let zmq = wire_client_zmq_tls().expect("tls").expect("configured");
        assert!(zmq.cert_file.is_some());
        assert!(zmq.client_auth);
        assert!(zmq.require_explicit_ca);
        unsafe {
            std::env::remove_var("NAUTALID_AETHER_TUNNEL_CERT_PEM");
            std::env::remove_var("NAUTALID_AETHER_TUNNEL_KEY_PEM");
            std::env::remove_var("NAUTALID_AETHER_TUNNEL_CA_PEM");
        }
    }

    #[test]
    fn remote_wire_requires_tunnel_material() {
        let err = wire_client_zmq_tls_for_endpoint("tcp://aetherdb-hub:5555")
            .expect_err("remote without tunnel");
        assert!(matches!(err, TunnelError::RemoteWireTlsRequired { .. }));
    }

    #[test]
    fn materializes_inline_pem_tunnel() {
        let (cert, key, ca) = generated_pem_triple();
        // SAFETY: test-only env mutation; serialized by test harness.
        unsafe {
            std::env::set_var("NAUTALID_AETHER_TUNNEL_CERT_PEM", &cert);
            std::env::set_var("NAUTALID_AETHER_TUNNEL_KEY_PEM", &key);
            std::env::set_var("NAUTALID_AETHER_TUNNEL_CA_PEM", &ca);
        }
        let tls = load_tunnel().expect("tunnel").expect("configured");
        assert!(tls.cert_file.is_file());
        assert!(tls.key_file.is_file());
        assert!(tls.ca_file.is_file());
        unsafe {
            std::env::remove_var("NAUTALID_AETHER_TUNNEL_CERT_PEM");
            std::env::remove_var("NAUTALID_AETHER_TUNNEL_KEY_PEM");
            std::env::remove_var("NAUTALID_AETHER_TUNNEL_CA_PEM");
        }
    }
}