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)
}