nautalid 0.1.0

Scratch container substrate — TLS 1.3 HTTP/2+3 kernel, LID/AetherDB, optional filter bus (GPL-3.0-or-later).
//! ACME (Let's Encrypt) certificate automation — **TLS-ALPN-01 only** (no HTTP/1.x).

use std::sync::Arc;

use futures_util::StreamExt;
use rustls::ServerConfig;
use rustls_acme::caches::DirCache;
use rustls_acme::{AcmeConfig, AcmeState, ResolvesServerCertAcme, UseChallenge};
use rustls_acme::acme::{LETS_ENCRYPT_PRODUCTION_DIRECTORY, LETS_ENCRYPT_STAGING_DIRECTORY};
use tracing::{info, warn};

use super::material::crypto_provider;
use super::TlsConfigError;

/// Live ACME sidecar (driver task + TLS-ALPN-01 challenge config).
pub struct AcmeHandle {
    pub resolver: Arc<ResolvesServerCertAcme>,
    challenge_config: Arc<ServerConfig>,
    _driver: tokio::task::JoinHandle<()>,
}

impl AcmeHandle {
    #[must_use]
    pub fn resolver(&self) -> Arc<ResolvesServerCertAcme> {
        Arc::clone(&self.resolver)
    }

    #[must_use]
    pub fn challenge_config(&self) -> Arc<ServerConfig> {
        Arc::clone(&self.challenge_config)
    }
}

/// Whether **`NAUTALID_TLS_ACME=1`**.
#[must_use]
pub fn acme_enabled_from_env() -> bool {
    crate::surfaces::env_enabled("NAUTALID_TLS_ACME")
}

/// Start ACME state machine and return a handle for resolvers and TLS-ALPN-01 challenges.
pub fn start_from_env() -> Result<AcmeHandle, TlsConfigError> {
    reject_http01_challenge_env()?;
    let domains = domains_from_env()?;
    let cache_path = std::env::var("NAUTALID_TLS_ACME_CACHE_PATH")
        .unwrap_or_else(|_| "/var/cache/nautalid/acme".into());

    let mut config = AcmeConfig::new(domains)
        .cache(DirCache::new(cache_path))
        .challenge_type(UseChallenge::TlsAlpn01);

    if let Ok(email) = std::env::var("NAUTALID_TLS_ACME_EMAIL") {
        let email = email.trim();
        if !email.is_empty() {
            let contact = if email.contains('@') {
                format!("mailto:{email}")
            } else {
                email.to_owned()
            };
            config = config.contact([contact]);
        }
    }

    let directory = std::env::var("NAUTALID_TLS_ACME_DIRECTORY")
        .ok()
        .map(|raw| raw.trim().to_ascii_lowercase());
    config = match directory.as_deref() {
        Some("staging") | Some("letsencrypt-staging") => {
            config.directory(LETS_ENCRYPT_STAGING_DIRECTORY)
        }
        Some("production") | Some("letsencrypt") | None => {
            config.directory(LETS_ENCRYPT_PRODUCTION_DIRECTORY)
        }
        Some(url) if url.starts_with("https://") => config.directory(url),
        Some(other) => {
            return Err(TlsConfigError::Rustls(format!(
                "unknown NAUTALID_TLS_ACME_DIRECTORY: {other}"
            )));
        }
    };

    let mut state: AcmeState<std::io::Error> = config.state();
    let resolver = state.resolver();
    let challenge_config = state.challenge_rustls_config_with_provider(crypto_provider()?);

    let driver = tokio::spawn(async move {
        while let Some(event) = state.next().await {
            match event {
                Ok(ok) => info!(?ok, "acme event"),
                Err(err) => warn!(%err, "acme error"),
            }
        }
        warn!("acme driver exited");
    });

    Ok(AcmeHandle {
        resolver,
        challenge_config,
        _driver: driver,
    })
}

fn domains_from_env() -> Result<Vec<String>, TlsConfigError> {
    let raw = std::env::var("NAUTALID_TLS_ACME_DOMAINS").map_err(|_| {
        TlsConfigError::Rustls("NAUTALID_TLS_ACME=1 requires NAUTALID_TLS_ACME_DOMAINS".into())
    })?;
    let domains: Vec<String> = raw
        .split(',')
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(str::to_owned)
        .collect();
    if domains.is_empty() {
        return Err(TlsConfigError::Rustls(
            "NAUTALID_TLS_ACME_DOMAINS is empty".into(),
        ));
    }
    Ok(domains)
}

fn reject_http01_challenge_env() -> Result<(), TlsConfigError> {
    match std::env::var("NAUTALID_TLS_ACME_CHALLENGE")
        .ok()
        .map(|raw| raw.trim().to_ascii_lowercase())
        .as_deref()
    {
        Some("http-01") | Some("http01") => Err(TlsConfigError::Rustls(
            "HTTP/1.x is not supported: NAUTALID_TLS_ACME_CHALLENGE=http-01 is forbidden; use TLS-ALPN-01 (default) or omit NAUTALID_TLS_ACME_CHALLENGE".into(),
        )),
        _ => Ok(()),
    }
}