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

nordisk/edda

crates/estate-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.
//!
//! * [`certs`] — which certificate is served: one the installer FEEDS
//!   (`tls.<host>.cert`/`.key`, how a rebuild carries the previous front's
//!   certificate over), the one kept on the data disk, or a new one;
//! * [`acme`] — the new one, from Let's Encrypt in this process (HTTP-01 on
//!   :80), asked only when nothing valid has more than thirty days left;
//! * [`serve`] — the two listeners; the certificate and the site are both
//!   swapped live, never by a restart.
//!
//! **What the installer may feed** (all optional), on the FRONT VM's own feed
//! (edda nordisk-pairing 0.5.0 `PlanVm::feed`) — the `tls.*` keys are refused in
//! the shared feed, so main and twin never receive them. At the end of the
//! install the front hands its kept certificates back ([`certs::handover`],
//! the installer's secret offer) for the next front:
//!
//! | feed key | value | default |
//! |---|---|---|
//! | `web_hosts` | the names served, comma-separated; the first names the certificate | the estate (`example.test`) |
//! | `tls.<host>.cert`, `tls.<host>.key` | a PEM chain and its key for the first host | — (ACME) |
//! | `acme_directory` | the ACME directory | Let's Encrypt production |
//! | `acme_contact` | an e-mail for the ACME account | none |
//! | `web_callback_only` | hosts of `web_hosts` that answer `/callback/<vendor>[/<instance>]` only; every other path there is a 308 to the same path on the first host | none |
//!
//! A malformed fed certificate is refused by name and not taken; the
//! certificate served before keeps serving.
//!
//! **The site is `www.<site>`** (WWW-CANON, owner 2026-10-30, reversing WWW-APEX): when a
//! served `<h>` has `www.<h>` served too, every page request to `<h>` — on :443 over its own
//! certificate, or on :80 — is a 301 to `https://www.<h>` with the same path and query (308 for
//! a method other than GET and HEAD), and nothing else is served there; a gRPC call and a
//! vendor's `/callback/…` are not redirected ([`serve::to_www`]). The passkeys' relying party
//! stays `<h>` (the certificate's name, [`WebConfig::name`]); the console's origin is
//! [`WebConfig::canonical`]. The ONE box's DNS lay keeps naming both.

pub mod acme;
pub mod certs;
pub mod dns;
pub mod serve;
pub mod status;

use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime};

/// The feed key of the served names.
pub const FEED_WEB_HOSTS: &str = "web_hosts";
/// The feed key of the ACME directory.
pub const FEED_ACME_DIRECTORY: &str = "acme_directory";
/// The feed key of the ACME account's contact.
pub const FEED_ACME_CONTACT: &str = "acme_contact";
/// The feed key of the hosts that answer callbacks only (a payment vendor's
/// webhook name, e.g. the Stripe account's): `/callback/<vendor>[/<instance>]` is served
/// there, every other path is redirected to the first host.
pub const FEED_WEB_CALLBACK_ONLY: &str = "web_callback_only";
/// The feed key of **monetize's own host** (`monetize.<apex>`, owner
/// 2026-09-28): every request there is carried to the product's monetize (the
/// [`serve::App::set_callbacks`] target) — its control API (gRPC, h2c) and its
/// `/callback/<vendor>` (HTTP/1.1). The ONE operator console outside the
/// estates dials it, every call signed (SSHSIG); a bearer from outside is
/// stripped there. A served name, never the first; never narrowed to callbacks.
pub const FEED_MONETIZE_CONTROL_HOST: &str = "monetize_control_host";

/// The public web ports.
pub const HTTP_PORT: u16 = 80;
pub const HTTPS_PORT: u16 = 443;

/// How often the certificate is looked at again (a new feed, the thirty days,
/// a backoff that ran out).
pub const LOOK_EVERY: Duration = Duration::from_secs(60);

/// How often the feed is read to see whether it CHANGED — a new feed is looked
/// at within this, not up to [`LOOK_EVERY`] later.
///
/// MEASURED 2026-09-26 on loki (mock bench run 8): the one look per minute
/// happened at boot, BEFORE the installer's first feed; the feed with the
/// bench certificate arrived 2 s later and the install closed ~50 s after
/// that, so the fed certificate was never kept and the hand-over the
/// installer asks for at the close was empty (`[proof-no-handover]`).
pub const FEED_TICK: Duration = Duration::from_secs(2);

/// **Whether to look now**, pure: never looked, the feed changed, or
/// [`LOOK_EVERY`] passed since the last look.
pub fn look_now(last: Option<(&BTreeMap<String, String>, Duration)>, feed: &BTreeMap<String, String>) -> bool {
    look_now_every(last, feed, LOOK_EVERY)
}

/// **Whether ACME may be asked**, pure: only when the look found it due AND the
/// installer has fed this front at least once. Before the first feed the front
/// knows neither the directory (the bench feeds a dead one) nor the names
/// (`web_hosts`), so an order then goes to Let's Encrypt production for the
/// bare estate name. MEASURED 2026-09-26 (loki, bench run 8): a mock front
/// asked Let's Encrypt for `gunnar` (its estate name) one second after boot, before its feed.
///
/// `switch_on`: the product's ACME switch ([`Hooks::acme_on`]; AIRGAP B1): off, ACME is never
/// asked and the front serves what is fed or kept.
pub fn acme_may_ask(due: bool, feed: &BTreeMap<String, String>, switch_on: bool) -> bool {
    due && switch_on && !feed.is_empty()
}

/// What [`Hooks::on_look`] calls with every look's config.
pub type OnLook = Arc<dyn Fn(&WebConfig) + Send + Sync>;

/// **What a product's front adds to the shared one** (A32-11: Gunnar's front runs on this crate,
/// not a fork of it).
#[derive(Clone)]
pub struct Hooks {
    /// **The ACME switch** (AIRGAP B1): ACME is asked only when this is on. Default: on in a
    /// build with `acme`, off without (a front built without the client never asks).
    pub acme_on: bool,
    /// Called on every look with the config read off the feed (Gunnar: its login names and the
    /// CLI hole's names).
    pub on_look: Option<OnLook>,
    /// The TLS provider :443 is served with; `None`: rustls' ring provider (Gunnar: its post-quantum
    /// key exchange first).
    pub tls: Option<Arc<rustls::crypto::CryptoProvider>>,
    /// The product's outer layer on :443 ([`serve::Outer`]), on before the first connection is
    /// accepted: the ONE way a product installs it (RA4-22; [`start_with`], [`start_on_hooked`],
    /// or [`Web::with_hooks`] for a web half that is not started).
    pub outer: Option<serve::Outer>,
}

impl Default for Hooks {
    fn default() -> Hooks {
        Hooks { acme_on: cfg!(feature = "acme"), on_look: None, tls: None, outer: None }
    }
}

/// **What the web half runs with**, read from the estate and the feed — pure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebConfig {
    /// The first names the certificate; all are in it.
    pub hosts: Vec<String>,
    pub directory: String,
    pub contact: Option<String>,
    /// Of `hosts`, the ones that answer `/callback/<vendor>[/<instance>]` only
    /// ([`FEED_WEB_CALLBACK_ONLY`]); never the first host.
    pub callback_only: Vec<String>,
    /// The host whose every request is monetize's ([`FEED_MONETIZE_CONTROL_HOST`]).
    pub control_host: Option<String>,
}

impl WebConfig {
    pub fn from(estate: &str, feed: &BTreeMap<String, String>) -> WebConfig {
        let hosts: Vec<String> = feed
            .get(FEED_WEB_HOSTS)
            .map(|v| v.split(',').map(|h| h.trim().to_ascii_lowercase()).filter(|h| !h.is_empty()).collect())
            .filter(|v: &Vec<String>| !v.is_empty())
            .unwrap_or_else(|| vec![estate.to_ascii_lowercase()]);
        let directory = feed.get(FEED_ACME_DIRECTORY).map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).unwrap_or_else(|| acme::LETS_ENCRYPT.to_string());
        let contact = feed.get(FEED_ACME_CONTACT).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
        // Only names that ARE served, and never the first: the first is where
        // every other path is sent, and a redirect to itself is a loop.
        let callback_only: Vec<String> = feed
            .get(FEED_WEB_CALLBACK_ONLY)
            .map(|v| v.split(',').map(|h| h.trim().to_ascii_lowercase()).filter(|h| !h.is_empty() && hosts.contains(h) && Some(h) != hosts.first()).collect())
            .unwrap_or_default();
        let control_host = feed.get(FEED_MONETIZE_CONTROL_HOST).map(|h| h.trim().to_ascii_lowercase()).filter(|h| !h.is_empty() && hosts.contains(h) && Some(h) != hosts.first());
        // monetize's own host is monetize's whole: a callback-only rule on the
        // same name would send its control API to the apex.
        let callback_only = callback_only.into_iter().filter(|h| Some(h) != control_host.as_ref()).collect();
        WebConfig { hosts, directory, contact, callback_only, control_host }
    }

    /// The name the certificate is kept and fed under (and the passkeys' relying party).
    pub fn name(&self) -> &str {
        &self.hosts[0]
    }

    /// **The canonical name** (WWW-CANON): `www.<first>` when it is served, else the first
    /// host. What a product builds its own URLs and its console's origin from.
    pub fn canonical(&self) -> String {
        serve::www_of(self.name(), &self.hosts).unwrap_or_else(|| self.name().to_string())
    }
}

/// **The web half, running** — the certificate the handshakes get, the site
/// 443 serves, the challenges 80 answers.
#[derive(Clone)]
pub struct Web {
    pub root: PathBuf,
    pub resolver: Arc<certs::Resolver>,
    pub app: serve::App,
    pub challenges: Arc<acme::Challenges>,
}

impl Web {
    /// A web half with no product hooks ([`Hooks::default`]).
    pub fn new(root: &Path) -> Web {
        Web::with_hooks(root, &Hooks::default())
    }

    /// **A web half with the product's [`Hooks`]**: its outer layer ([`Hooks::outer`]) is on
    /// before anything is served (RA4-22: the one way; a web half that is started takes it from
    /// the same hooks).
    pub fn with_hooks(root: &Path, hooks: &Hooks) -> Web {
        let web = Web {
            root: root.to_path_buf(),
            resolver: Arc::new(certs::Resolver::default()),
            app: serve::App::new(serve::placeholder("the web face of this estate is starting")),
            challenges: Arc::new(acme::Challenges::default()),
        };
        web.app.set_outer(hooks.outer.clone());
        web
    }

    /// Serve `cert` for every host it names that `cfg` serves.
    fn serve_cert(&self, cfg: &WebConfig, cert: &certs::Cert) {
        for h in &cfg.hosts {
            if cert.names.iter().any(|n| n.eq_ignore_ascii_case(h) || n.starts_with("*.")) {
                self.resolver.set(h, Arc::clone(&cert.key));
            }
        }
    }

    /// **One look**: take the best of the fed and the kept certificate, keep
    /// a fed one that wins, and say whether ACME is NEEDED (nothing valid, the
    /// thirty days, a name not covered), backoff aside. Answers the lines to log.
    pub fn look_needs(&self, cfg: &WebConfig, feed: &BTreeMap<String, String>, now: SystemTime) -> (bool, Vec<String>) {
        let host = cfg.name();
        let mut lines = Vec::new();
        let mut take = |r: Option<Result<certs::Cert, String>>| match r {
            Some(Ok(c)) => Some(c),
            Some(Err(e)) => {
                lines.push(e);
                None
            }
            None => None,
        };
        let fed = take(certs::fed(feed, host, now));
        let kept = take(certs::kept(&self.root, host, now));
        let d = certs::decide_for(fed.as_ref(), kept.as_ref(), &cfg.hosts, now);
        match d.serve {
            Some(certs::Source::Fed) => {
                let c = fed.as_ref().expect("decided on the fed certificate");
                if kept.as_ref().map(|k| k.chain_pem != c.chain_pem).unwrap_or(true) {
                    match certs::keep(&self.root, host, c) {
                        Ok(()) => lines.push(format!("tls: the certificate the installer fed for {host} is kept on the data disk (valid {} more days)", c.left(now).as_secs() / 86400)),
                        Err(e) => lines.push(format!("tls: the fed certificate for {host} is served but could not be kept: {e}")),
                    }
                }
                self.serve_cert(cfg, c);
            }
            Some(certs::Source::Kept) => self.serve_cert(cfg, kept.as_ref().expect("decided on the kept certificate")),
            None => {}
        }
        (d.acme, lines)
    }

    /// **One look** ([`Web::look_needs`]), and whether ACME is due now: needed, and not
    /// inside a backoff.
    pub fn look(&self, cfg: &WebConfig, feed: &BTreeMap<String, String>, now: SystemTime) -> (bool, Vec<String>) {
        let (needs, lines) = self.look_needs(cfg, feed, now);
        let (next, _) = acme::next_attempt(&self.root, cfg.name());
        (needs && now >= next, lines)
    }

    /// **Ask ACME** for `cfg.hosts`, keep and serve what it issues; a failure
    /// starts (or lengthens) the backoff.
    pub async fn renew(&self, cfg: &WebConfig) -> Result<String, String> {
        self.renew_at(cfg, SystemTime::now()).await.map_err(|f| f.text)
    }

    /// [`Web::renew`], the failure told by kind; a failure is dated `now`.
    pub async fn renew_at(&self, cfg: &WebConfig, now: SystemTime) -> Result<String, acme::Failure> {
        let host = cfg.name().to_string();
        match acme::issue_as(&self.root, &cfg.hosts, &cfg.directory, cfg.contact.as_deref(), &self.challenges).await {
            Ok((chain, key)) => {
                let c = certs::Cert::parse(&host, &chain, &key, SystemTime::now()).map_err(|e| acme::Failure { kind: acme::FailKind::Acme, text: format!("ACME issued a certificate this front refuses: {e}") })?;
                certs::keep(&self.root, &host, &c).map_err(|text| acme::Failure { kind: acme::FailKind::Acme, text })?;
                self.serve_cert(cfg, &c);
                acme::succeeded(&self.root, &host);
                Ok(format!("tls: ACME issued a certificate for {:?} (valid {} days) — served from the next handshake", cfg.hosts, c.left(SystemTime::now()).as_secs() / 86400))
            }
            Err(f) => {
                let wait = acme::failed_as(&self.root, &host, now, f.kind);
                Err(acme::Failure { kind: f.kind, text: format!("{} — asked again in {} min", f.text, wait.as_secs() / 60) })
            }
        }
    }
}

/// **Start the web half** on `http`/`https` (0.0.0.0:80 / :443 on the medium):
/// both listeners, and the certificate loop reading `feed()` (what the
/// installer fed last) for the life of the process. Returns the running half
/// so the site can be set on it. Must run inside the tokio runtime.
pub async fn start(root: &Path, estate: String, feed: Feed, http: SocketAddr, https: SocketAddr) -> Result<Web, String> {
    let l80 = tokio::net::TcpListener::bind(http).await.map_err(|e| format!("web: cannot listen on {http}: {e}"))?;
    let l443 = tokio::net::TcpListener::bind(https).await.map_err(|e| format!("web: cannot listen on {https}: {e}"))?;
    Ok(start_on(root, estate, feed, l80, l443))
}

/// What the installer fed last, asked for on every look.
pub type Feed = Arc<dyn Fn() -> BTreeMap<String, String> + Send + Sync>;

/// **What the certificate loop asks the world**: the resolver and this front's own
/// addresses ([`dns::Dns`]), the clock, and how often it looks. Injected so tests never ask
/// live DNS or wait a real minute.
#[derive(Clone)]
pub struct Probe {
    pub dns: Arc<dyn dns::Dns>,
    pub clock: Arc<dyn Fn() -> SystemTime + Send + Sync>,
    /// How often the certificate is looked at again, and a name that does not point here
    /// resolved again ([`LOOK_EVERY`], [`dns::DNS_EVERY`]).
    pub look_every: Duration,
    /// How often the feed is read ([`FEED_TICK`]).
    pub tick: Duration,
}

impl Probe {
    /// The live one: libc's resolver, the system clock, a look a minute.
    pub fn system() -> Probe {
        Probe { dns: Arc::new(dns::SystemDns), clock: Arc::new(SystemTime::now), look_every: LOOK_EVERY.min(dns::DNS_EVERY), tick: FEED_TICK }
    }
}

/// [`start`] on listeners already bound (tests bind `127.0.0.1:0`).
pub fn start_on(root: &Path, estate: String, feed: Feed, l80: tokio::net::TcpListener, l443: tokio::net::TcpListener) -> Web {
    start_on_probed(root, estate, feed, l80, l443, Probe::system())
}

/// [`start_on`] 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, probe: Probe) -> Web {
    start_on_hooked(root, estate, feed, l80, l443, Hooks::default(), probe)
}

/// [`start`] with the product's [`Hooks`].
pub async fn start_with(root: &Path, estate: String, feed: Feed, http: SocketAddr, https: SocketAddr, hooks: Hooks) -> Result<Web, String> {
    let l80 = tokio::net::TcpListener::bind(http).await.map_err(|e| format!("web: cannot listen on {http}: {e}"))?;
    let l443 = tokio::net::TcpListener::bind(https).await.map_err(|e| format!("web: cannot listen on {https}: {e}"))?;
    Ok(start_on_hooked(root, estate, feed, l80, l443, hooks, Probe::system()))
}

/// [`start_on_probed`] with the product's [`Hooks`].
pub fn start_on_hooked(root: &Path, estate: String, feed: Feed, l80: tokio::net::TcpListener, l443: tokio::net::TcpListener, hooks: Hooks, probe: Probe) -> Web {
    let web = Web::with_hooks(root, &hooks);
    // A32-14, stays a task: the :80 accept loop (ACME challenges and the https redirect), for the life of the web.
    // WWW-CANON: it reads the served names off the app, so the bare `<site>` goes straight to `www.<site>`.
    tokio::spawn(serve::serve_http_for(l80, Arc::clone(&web.challenges), web.app.clone()));
    // A32-14, stays a task: the :443 accept loop, for the life of the web; its connections are capped (MAX_TLS_CONNS).
    tokio::spawn(serve::serve_https_with(l443, Arc::clone(&web.resolver), web.app.clone(), hooks.tls.clone()));
    let w = web.clone();
    let acme_on = hooks.acme_on;
    // A32-14, stays a task: the certificate and config follow loop, for the life of the web (RA4-11 left: a jera duty with a stop).
    tokio::spawn(async move {
        let mut said = SaidOnce::default();
        let mut last: Option<(BTreeMap<String, String>, std::time::Instant)> = None;
        loop {
            let f = feed();
            let cfg = WebConfig::from(&estate, &f);
            if status::take_ask(&w.root, cfg.name()) {
                last = None;
            }
            if !look_now_every(last.as_ref().map(|(seen, at)| (seen, at.elapsed())), &f, probe.look_every) {
                tokio::time::sleep(probe.tick).await;
                continue;
            }
            w.app.set_hosts(&cfg.hosts);
            w.app.set_callback_only(&cfg.callback_only, cfg.name());
            w.app.set_control_host(cfg.control_host.as_deref());
            if let Some(on_look) = &hooks.on_look {
                on_look(&cfg);
            }
            let now = (probe.clock)();
            let (needs, mut lines) = w.look_needs(&cfg, &f, now);
            let ask = acme_may_ask(needs, &f, acme_on);
            let mut rec = status::Record::load(&w.root, cfg.name());
            let was = rec.clone();
            rec.hosts = cfg.hosts.clone();
            if !needs {
                rec.said(None, None);
            }
            if needs && !acme_on {
                let l = "tls: nothing valid to serve yet, and ACME is switched off (the product's switch is off, or this front is built without `acme`) — feed a certificate".to_string();
                rec.said(Some(status::KIND_OFF), Some(l.clone()));
                lines.push(l);
            } else if needs && !ask {
                let l = "tls: nothing valid to serve yet, and the installer has fed nothing — ACME is not asked before the first feed (it names the directory and the hosts)".to_string();
                rec.said(Some(status::KIND_UNFED), Some(l.clone()));
                lines.push(l);
            }
            let mut go = false;
            if ask {
                // DNS first: a name that does not point here is not an ACME failure.
                let points = points(&probe, &cfg, &f).await;
                match points.reason() {
                    Some(reason) => {
                        lines.push(format!("tls: {reason} — Let's Encrypt is not asked, and asked within {} s of it pointing here", probe.look_every.as_secs().max(1)));
                        rec.said(Some(status::KIND_DNS), Some(reason));
                    }
                    None => {
                        let wait = acme::wait(&w.root, cfg.name());
                        go = acme::may_attempt(wait.as_ref(), now, points == dns::Points::Here);
                        if let (false, Some(wt)) = (go, wait) {
                            lines.push(format!("tls: ACME for {} waits after {} failed attempt(s) in a row, until unix {}", cfg.name(), wt.failures, wt.at.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()));
                        }
                    }
                }
            }
            for l in lines {
                if said.first_time(&l) {
                    tracing::info!("{l}");
                }
            }
            last = Some((f, std::time::Instant::now()));
            if go {
                match w.renew_at(&cfg, (probe.clock)()).await {
                    Ok(l) => {
                        tracing::info!("{l}");
                        rec.said(None, None);
                    }
                    Err(e) => {
                        tracing::error!("tls: {e}");
                        rec.said(Some(e.kind.as_str()), Some(e.text));
                    }
                }
            }
            let now = (probe.clock)();
            let valid = matches!(certs::kept(&w.root, cfg.name(), now), Some(Ok(_)));
            if let Some(alert) = rec.served(cfg.name(), valid, now) {
                tracing::error!("{alert}");
            }
            if rec != was {
                if let Err(e) = rec.save(&w.root, cfg.name()) {
                    tracing::warn!("tls: the certificate status of {} could not be kept: {e}", cfg.name());
                }
            }
            tokio::time::sleep(probe.tick).await;
        }
    });
    web
}

/// [`look_now`] with the cadence given.
pub fn look_now_every(last: Option<(&BTreeMap<String, String>, Duration)>, feed: &BTreeMap<String, String>, every: Duration) -> bool {
    match last {
        None => true,
        Some((seen, since)) => seen != feed || since >= every,
    }
}

/// **Does every name of `cfg` point here?** ([`dns::check`], off the async runtime: libc's
/// resolver blocks.)
async fn points(probe: &Probe, cfg: &WebConfig, feed: &BTreeMap<String, String>) -> dns::Points {
    let d = Arc::clone(&probe.dns);
    let hosts = cfg.hosts.clone();
    let fed = dns::fed_public(feed.get(dns::FEED_PUBLIC_ADDRESS).map(String::as_str));
    tokio::task::spawn_blocking(move || dns::check(&*d, &hosts, &fed)).await.unwrap_or(dns::Points::CannotTell)
}

/// How many distinct lines [`SaidOnce`] remembers.
pub(crate) const SAID_CAP: usize = 256;

/// **The certificate loop's lines, each said once** — in a bounded memory (Gunnar's stinky row
/// 25, 2026-10-10): a line that names a changing number (days left, a retry time) is a new line
/// each look, and a list grew for as long as the front ran. The oldest line is forgotten first;
/// at worst it is said once more.
#[derive(Default)]
pub(crate) struct SaidOnce {
    order: std::collections::VecDeque<String>,
    said: std::collections::HashSet<String>,
}

impl SaidOnce {
    /// `true` the first time `line` is offered.
    pub(crate) fn first_time(&mut self, line: &str) -> bool {
        if self.said.contains(line) {
            return false;
        }
        if self.order.len() >= SAID_CAP {
            if let Some(oldest) = self.order.pop_front() {
                self.said.remove(&oldest);
            }
        }
        self.order.push_back(line.to_owned());
        self.said.insert(line.to_owned());
        true
    }

    /// How many lines are remembered.
    #[cfg(test)]
    pub(crate) fn len(&self) -> usize {
        self.order.len()
    }
}

#[cfg(test)]
mod tests;