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;