Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-front/src/web/mod.rs

//! **The front's own HTTPS** — vm1 serves the web face of the estate itself, on tcp/80 and
//! tcp/443 of its public address. There is no Caddy on an OS-less front.
//!
//! The web half is edda's **estate-front** (`estate_front::web`), the one Holger's front and
//! the ONE box run: the fed, kept or ACME certificate ([`certs`], [`acme`], [`dns`],
//! [`status`]), the two listeners, the callback-only names, the hand-over. Until 2026-10-26
//! (A32-11) this module was a hand-ported fork of it, so every front fix landed twice.
//!
//! What is Gunnar's, on top ([`serve`], [`hole`]):
//!
//! | piece | where |
//! |---|---|
//! | the ACME switch `GUNNAR_FRONT_ACME` (AIRGAP B1) | [`acme_switch`], [`ACME_ENV`] |
//! | the login pages of every other served name go to the canonical host (`www.<site>`, the console's origin; WWW-CANON) | [`serve::login_on_first`], [`login_elsewhere`] |
//! | git and Git LFS over HTTPS are answered with the SSH URL | [`serve::git_paths_here`] |
//! | the CLI's gRPC hole on :443 (A6) | [`hole`] |
//! | one redacted access line per request | [`serve::access_logged`] |
//! | post-quantum TLS first (X25519MLKEM768) | [`tls`] (`nordisk_pairing::pqtls`) |
//!
//! **What the installer may feed** is estate-front's (`web_hosts`, `tls.<host>.cert`/`.key`,
//! `acme_directory`, `acme_contact`, `web_callback_only`); every served name but the canonical one
//! and a callback-only one sends its LOGIN pages to the canonical host (private ownership,
//! 2026-10-07; WWW-CANON 2026-10-30: `www.<first>` when served — the bare first host stays the
//! passkeys' relying party, and estate-front sends its every page to www).

use std::net::SocketAddr;
use std::path::Path;
use std::sync::{Arc, RwLock};

pub use estate_front::web::{
    acme, certs, dns, look_now, look_now_every, status, Feed, Probe, WebConfig, FEED_ACME_CONTACT,
    FEED_ACME_DIRECTORY, FEED_TICK, FEED_WEB_CALLBACK_ONLY, FEED_WEB_HOSTS, HTTPS_PORT, HTTP_PORT,
    LOOK_EVERY,
};

#[cfg(feature = "console")]
pub mod hole;
pub mod serve;
#[cfg(test)]
mod tests;

/// **The ACME switch** (AIRGAP B1, owner 2026-10-25): ACME dials an outside directory, so a
/// front asks it only when it is built with `acme` AND this variable says `on`. Every front
/// medium sets it (`korp.env.GUNNAR_FRONT_ACME=on`, gunnar-recipe); a front without it serves
/// what is fed or kept, and says ACME is switched off.
pub const ACME_ENV: &str = "GUNNAR_FRONT_ACME";

/// Pure: does the switch's value say `on`, in a build that has ACME at all?
pub fn acme_switch(value: Option<&str>) -> bool {
    cfg!(feature = "acme") && value.is_some_and(|v| v.trim().eq_ignore_ascii_case("on"))
}

/// Pure: **the served names whose login pages are the canonical host's** ([`serve::LoginOn`]):
/// every name but the canonical one ([`WebConfig::canonical`], `www.<first>` when served) and a
/// callback-only one. The console's passkeys are asked at the canonical origin; their relying
/// party is the first host, which `https://www.<first>` is an origin of.
pub fn login_elsewhere(cfg: &WebConfig) -> Vec<String> {
    let canonical = cfg.canonical();
    cfg.hosts
        .iter()
        .filter(|h| **h != canonical && !cfg.callback_only.contains(*h))
        .cloned()
        .collect()
}

/// Pure: **the console's own names**: every served name but a callback-only one (a vendor's
/// webhook name is a machine's door, not the console's). The CLI's gRPC hole answers on these.
pub fn console_names(cfg: &WebConfig) -> Vec<String> {
    cfg.hosts
        .iter()
        .filter(|h| !cfg.callback_only.contains(*h))
        .cloned()
        .collect()
}

/// **Gunnar's TLS on :443**: ring with X25519MLKEM768 offered FIRST (`nordisk_pairing::pqtls`),
/// the classical groups after it (the PQ transport lane, 2026-10-06).
pub fn tls() -> Arc<rustls::crypto::CryptoProvider> {
    Arc::new(nordisk_pairing::pqtls::provider())
}

/// **What Gunnar adds on :443**, held for the life of the process: the login names and the CLI
/// hole. Read per accepted connection by the outer layer ([`App::outer`]).
#[derive(Clone)]
pub struct Layers {
    login_on: Arc<RwLock<Option<serve::LoginOn>>>,
    #[cfg(feature = "console")]
    hole: hole::Hole,
}

/// **The face of :443 and Gunnar's layers on it**: estate-front's [`estate_front::web::serve::App`]
/// with Gunnar's [`Layers`].
#[derive(Clone)]
pub struct App {
    shared: estate_front::web::serve::App,
    layers: Layers,
}

impl App {
    fn over(shared: estate_front::web::serve::App, layers: Layers) -> App {
        App { shared, layers }
    }
    /// From the next accepted connection on, serve `router`.
    pub fn set(&self, router: axum::Router) {
        self.shared.set(router);
    }
    pub fn current(&self) -> axum::Router {
        self.shared.current()
    }
    /// The router one accepted connection is served by (estate-front's, Gunnar's layers around it).
    pub fn router_now(&self) -> axum::Router {
        self.shared.router_now()
    }
    /// **The CLI's gRPC hole** on this :443 ([`hole`]): one for the life of the process.
    #[cfg(feature = "console")]
    pub fn hole(&self) -> hole::Hole {
        self.layers.hole.clone()
    }
    /// From the next accepted connection on, a login page on `hosts` is sent to `to`
    /// ([`serve::LoginOn`]). No hosts: nothing is sent anywhere.
    pub fn set_login_on(&self, hosts: &[String], to: &str) {
        self.layers.set_login_on(hosts, to);
    }
    pub fn login_on(&self) -> Option<serve::LoginOn> {
        self.layers.login_on()
    }
}

impl Default for Layers {
    fn default() -> Layers {
        Layers {
            login_on: Arc::new(RwLock::new(None)),
            #[cfg(feature = "console")]
            hole: hole::Hole::new(),
        }
    }
}

impl Layers {
    fn set_login_on(&self, hosts: &[String], to: &str) {
        let rule = (!hosts.is_empty()).then(|| serve::LoginOn {
            hosts: hosts.to_vec(),
            to: to.to_string(),
        });
        *self.login_on.write().unwrap_or_else(|e| e.into_inner()) = rule;
    }
    fn login_on(&self) -> Option<serve::LoginOn> {
        self.login_on
            .read()
            .unwrap_or_else(|e| e.into_inner())
            .clone()
    }
    /// **Gunnar's outer layer** around everything estate-front serves: the login names, git's
    /// paths answered here, the CLI hole before every rule of the face, the access log outermost.
    fn outer(&self) -> estate_front::web::serve::Outer {
        let me = self.clone();
        Arc::new(move |router: axum::Router| {
            let router = serve::login_on_first(router, me.login_on());
            let router = serve::git_paths_here(router);
            #[cfg(feature = "console")]
            let router = hole::with_hole(router, me.hole.clone());
            serve::access_logged(router)
        })
    }
    /// Every look: the login names and the hole's names from the config the certificate loop read.
    fn on_look(&self) -> Arc<dyn Fn(&WebConfig) + Send + Sync> {
        let me = self.clone();
        Arc::new(move |cfg: &WebConfig| {
            me.set_login_on(&login_elsewhere(cfg), &cfg.canonical());
            #[cfg(feature = "console")]
            me.hole.set_names(&console_names(cfg));
        })
    }
    fn hooks(&self, acme_on: bool) -> estate_front::web::Hooks {
        estate_front::web::Hooks {
            acme_on,
            on_look: Some(self.on_look()),
            tls: Some(tls()),
            outer: Some(self.outer()),
        }
    }
}

/// **The running web half**: estate-front's, with Gunnar's [`App`] on it. Derefs to estate-front's
/// `Web` (its root, resolver and challenges, `look`, `renew`).
#[derive(Clone)]
pub struct Web {
    shared: estate_front::web::Web,
    pub app: App,
}

impl std::ops::Deref for Web {
    type Target = estate_front::web::Web;
    fn deref(&self) -> &estate_front::web::Web {
        &self.shared
    }
}

impl Web {
    /// A web half that serves nothing yet (the hand-over's successor in a test, say), Gunnar's
    /// outer layer on it through the same [`estate_front::web::Hooks`] a started one takes.
    pub fn new(root: &Path) -> Web {
        let layers = Layers::default();
        let hooks = estate_front::web::Hooks {
            outer: Some(layers.outer()),
            ..estate_front::web::Hooks::default()
        };
        Web::of(estate_front::web::Web::with_hooks(root, &hooks), layers)
    }
    /// Gunnar's [`App`] over `shared`, whose outer layer its hooks already installed (RA4-22: one way).
    fn of(shared: estate_front::web::Web, layers: Layers) -> Web {
        let app = App::over(shared.app.clone(), layers);
        Web { shared, app }
    }
}

/// **Start the web half** on `http`/`https` (0.0.0.0:80 / :443 on the medium) with ACME as the
/// medium's switch says ([`ACME_ENV`]).
pub async fn start(
    root: &Path,
    estate: String,
    feed: Feed,
    http: SocketAddr,
    https: SocketAddr,
) -> Result<Web, String> {
    let layers = Layers::default();
    let shared = estate_front::web::start_with(
        root,
        estate,
        feed,
        http,
        https,
        layers.hooks(acme_switch(std::env::var(ACME_ENV).ok().as_deref())),
    )
    .await?;
    Ok(Web::of(shared, layers))
}

/// [`start`] on listeners already bound (tests bind `127.0.0.1:0`), the switch as the medium says.
pub fn start_on(
    root: &Path,
    estate: String,
    feed: Feed,
    l80: tokio::net::TcpListener,
    l443: tokio::net::TcpListener,
) -> Web {
    start_on_with(
        root,
        estate,
        feed,
        l80,
        l443,
        acme_switch(std::env::var(ACME_ENV).ok().as_deref()),
    )
}

/// [`start_on`] with the ACME switch given (tests).
pub fn start_on_with(
    root: &Path,
    estate: String,
    feed: Feed,
    l80: tokio::net::TcpListener,
    l443: tokio::net::TcpListener,
    acme_on: bool,
) -> Web {
    start_on_probed(root, estate, feed, l80, l443, acme_on, Probe::system())
}

/// [`start_on_with`] with the resolver, the clock and the cadence given ([`Probe`]).
pub fn start_on_probed(
    root: &Path,
    estate: String,
    feed: Feed,
    l80: tokio::net::TcpListener,
    l443: tokio::net::TcpListener,
    acme_on: bool,
    probe: Probe,
) -> Web {
    let layers = Layers::default();
    let shared = estate_front::web::start_on_hooked(
        root,
        estate,
        feed,
        l80,
        l443,
        layers.hooks(acme_on),
        probe,
    );
    Web::of(shared, layers)
}