crates/gunnar-front/src/console.rs
//! **The web face of the estate — the gunnar console, served by vm1 itself.**
//!
//! The console is two halves of one Rust program (gunnar-ui): the BFF,
//! `gunnar-ui-server`, linked here as a library, and the browser half,
//! gunnar-ui-web's built `dist/` (Leptos → wasm), embedded in this binary at
//! build time (`build.rs`) and unpacked to `<data>/console/dist` at boot. Its
//! router is mounted on the front's own :443 ([`crate::web`]).
//!
//! **What the console makes itself and OFFERS the installer** (every visit):
//!
//! | offer | value | who takes it |
//! |---|---|---|
//! | `console_pubkey` | the console's own key (OpenSSH line) — it signs gunnar calls for logged-in people | the primary: principal `gunnar-ui`, machine, gunnar-admin |
//! | `guest_pubkey` | the console's GUEST key — anonymous visitors' reads | the primary: principal `gunnar-guest`, machine, NO grant |
//! | `operator_console_pubkey` | the console's credential for the estate's OPERATOR — his reads and the attestation of every SSH key he adds | the primary: the operator's account (fed `operator_name`), its FIRST key |
//!
//! The keys are minted here at first boot (`<data>/console/id`, `…/guest`,
//! `…/operator`, 0600) and kept across installations, like the clock key. The
//! operator's is also laid as the console's tenant SEED for that account
//! (`<data>/console/seeds/<operator>`, [`lay_operator_seed`]) once the feed
//! names him, so the console adopts the key the install enrolled instead of
//! minting one gunnar would want an attestation for (2026-10-04).
//!
//! **What it takes from the feed**: where the primary's control plane is
//! (`primary_utility`, else the fed peer of role primary, port 50051) and the
//! twin's (for the one-step restore); the web names ([`crate::web::WebConfig`],
//! the passkey relying party is the first); and optionally
//! `console_admin_token`, the break-glass bearer that mints the first
//! passkey — refused by name if it is guessable. When the primary moves (a
//! new feed names another), the console is rebuilt against it and swapped in
//! LIVE; the passkey store is opened once and kept.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use nordisk_pairing::join::JoinReply;
use nordisk_pairing::Role;
mod embedded {
include!(concat!(env!("OUT_DIR"), "/console_dist.rs"));
}
pub use embedded::{DIST_SOURCE, FILES};
/// Under the data root: the console's own key (a gunnar admin, via the
/// `gunnar-ui` principal).
pub const IDENTITY_REL: &str = "console/id";
/// Under the data root: the guest key (no grant at all).
pub const GUEST_REL: &str = "console/guest";
/// Under the data root: the console's credential for the estate's OPERATOR
/// (offered as [`OFFER_OPERATOR_CONSOLE_PUBKEY`], enrolled at install on his
/// account as its first key).
pub const OPERATOR_REL: &str = "console/operator";
/// Under the data root: the console's tenant seeds, one private key per
/// principal id (`gunnar_ui_server::tenant::TenantKeys::with_seeds`) — the
/// operator's, laid from [`OPERATOR_REL`] by [`lay_operator_seed`].
pub const SEEDS_REL: &str = "console/seeds";
/// Under the data root: the passkey/session store.
pub const STATE_REL: &str = "console/state.redb";
/// Under the data root: where the embedded `dist/` is unpacked.
pub const DIST_REL: &str = "console/dist";
/// The offer key of the console's own key.
pub const OFFER_CONSOLE_PUBKEY: &str = gunnar_server::fed_principals::OFFER_CONSOLE_PUBKEY;
/// The offer key of the guest key.
pub const OFFER_GUEST_PUBKEY: &str = gunnar_server::fed_principals::OFFER_GUEST_PUBKEY;
/// The offer key of the console's credential for the operator.
pub const OFFER_OPERATOR_CONSOLE_PUBKEY: &str =
gunnar_server::fed_principals::OFFER_OPERATOR_CONSOLE_PUBKEY;
/// The feed key of the break-glass bearer.
pub const FEED_ADMIN_TOKEN: &str = "console_admin_token";
/// **The operator's own address for an order's receipt** (g-receipts): fed by
/// `estate install`, handed to the console's monetize handle, named as the
/// payer of every order the OPERATOR places (`Monetize::payer_for`) so Stripe
/// mails its receipt. A tenant's order names nothing and monetize names the
/// address they signed up with.
pub const FEED_RECEIPTS_TO: &str = "console_receipts_to";
/// **The monetize product this console sells** (G15, 5.4): fed by `estate
/// install` (`console_product`), handed to the console as
/// [`ENV_CONSOLE_PRODUCT`] — `gunnar` on every estate (gunnar.rs and test2 alike):
/// the ONE monetize tells the sites apart by each estate's own bearer (UW-1).
pub const FEED_CONSOLE_PRODUCT: &str = "console_product";
/// What gunnar-ui reads its product id from (`gunnar_ui_server::monetize::ENV_PRODUCT`).
pub const ENV_CONSOLE_PRODUCT: &str = "GUNNAR_UI_PRODUCT";
/// The product of a console the feed names none for: gunnar.rs's own.
pub const CONSOLE_PRODUCT_DEFAULT: &str = "gunnar";
/// **A SOLD private Gunnar's console knows what it is** (private ownership
/// (b), 2026-10-07): the kit feeds vm1 the estate's name and its seller's
/// site; the console reads them from its environment ([`ENV_INSTANCE`],
/// [`ENV_PROVIDER`]) — it says "<company>'s Gunnar", and its "new instance"
/// goes to the seller's `/signup?plan=private` (its own monetize sells none).
pub const FEED_CONSOLE_INSTANCE: &str = "console_instance";
/// The seller's site, `https://<seller apex>` ([`FEED_CONSOLE_INSTANCE`]).
pub const FEED_CONSOLE_PROVIDER: &str = "console_provider";
/// **The public site's switch, as the ONE box feeds it** (TASKS-12, owner
/// 2026-10-25): `on` only for an operator site whose declaration says
/// `[estates.<label>] public_site = true` (gunnar.rs). Everything else, every
/// sold estate first, leaves the console's public site OFF (the airgap default).
pub const FEED_PUBLIC_SITE: &str = "console_public_site";
/// gunnar-ui's switch (`gunnar_public_site::ENV_SWITCH`): OFF unless `on`.
pub const ENV_PUBLIC_SITE: &str = "GUNNAR_UI_PUBLIC_SITE";
/// Pure: whether the feed turns the console's public site on.
pub fn public_site_of(feed: &BTreeMap<String, String>) -> bool {
feed.get(FEED_PUBLIC_SITE)
.is_some_and(|v| v.trim().eq_ignore_ascii_case("on"))
}
/// The console's own name for the estate (gunnar-ui `GUNNAR_UI_INSTANCE`).
pub const ENV_INSTANCE: &str = "GUNNAR_UI_INSTANCE";
/// The console's own name for its seller (gunnar-ui `GUNNAR_UI_PROVIDER`).
pub const ENV_PROVIDER: &str = "GUNNAR_UI_PROVIDER";
/// Pure: **the console's environment for a sold estate**, from the feed —
/// `(ENV_INSTANCE, <company>)` and `(ENV_PROVIDER, https://<seller>)`; empty
/// for the kit's own estates. The company is the kit's instance rule (a
/// lowercase letter, then letters and digits, at most 16); the seller an
/// `https://` site with no path. Anything else is refused by name.
pub fn sold_by(feed: &BTreeMap<String, String>) -> Result<Vec<(&'static str, String)>, String> {
let get = |k: &str| {
feed.get(k)
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
};
let (instance, seller) = match (get(FEED_CONSOLE_INSTANCE), get(FEED_CONSOLE_PROVIDER)) {
(None, None) => return Ok(Vec::new()),
(Some(i), Some(s)) => (i, s),
_ => return Err(format!("REFUSED [console-sold-by] `{FEED_CONSOLE_INSTANCE}` and `{FEED_CONSOLE_PROVIDER}` come together: a sold estate names both")),
};
let named = instance.len() <= 16
&& instance.starts_with(|c: char| c.is_ascii_lowercase())
&& instance
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
if !named {
return Err(format!("REFUSED [console-sold-by] the fed `{FEED_CONSOLE_INSTANCE}` {instance:?} is not an instance's name (a lowercase letter, then letters and digits, at most 16)"));
}
let site = seller.trim_end_matches('/');
let host = site.strip_prefix("https://").unwrap_or("");
if host.is_empty()
|| !host
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == ':')
{
return Err(format!("REFUSED [console-sold-by] the fed `{FEED_CONSOLE_PROVIDER}` {seller:?} is not a seller's site (https://<host>, no path)"));
}
Ok(vec![
(ENV_INSTANCE, instance),
(ENV_PROVIDER, site.to_string()),
])
}
/// **What the console is told it is, given the feed and what it was told
/// before** ([`sold_by`]; wiring tracer B 2026-10-08). `before` is the last
/// pair applied (or remembered on the data disk across a restart).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SoldDecision {
/// Set exactly these (empty: the kit's own estate, nothing set).
Apply(Vec<(&'static str, String)>),
/// Keep what is applied: the feed lost or garbled a SOLD estate's pair.
Keep(String),
/// Build no console: a sold estate's pair is unreadable and nothing good
/// was ever applied, so a console now would sell over a free monetize.
Refuse(String),
}
/// Pure: [`SoldDecision`]. **Fails closed**: a SOLD estate's monetize is
/// free (`plugin.gunnar.free`), so a console that forgot it is sold would
/// offer purchases a free monetize grants for nothing.
pub fn sold_decision(
feed: &BTreeMap<String, String>,
before: Option<&[(&'static str, String)]>,
) -> SoldDecision {
let was_sold = before.is_some_and(|b| !b.is_empty());
match sold_by(feed) {
// The feed says nothing of a seller, but this console was SOLD: a sold
// estate never becomes a seller (an upgrade or a recharge run without
// the order lost the pair) — keep it.
Ok(now) if now.is_empty() && was_sold => SoldDecision::Keep(format!(
"REFUSED [console-sold-by-dropped] this front's feed no longer names `{FEED_CONSOLE_INSTANCE}` / `{FEED_CONSOLE_PROVIDER}`, but its console was a SOLD estate's: it stays sold (an estate the shop laid never sells); feed the pair again"
)),
Ok(now) => SoldDecision::Apply(now),
Err(why) if was_sold => SoldDecision::Keep(format!("{why}; the console keeps the pair it was told before")),
Err(why) => SoldDecision::Refuse(why),
}
}
/// **Hand the console what the feed says it is** ([`sold_decision`]) — its
/// environment, read by gunnar-ui before it first answers. Set (or cleared)
/// only when it changed. `false`: the console must not be built (a sold
/// estate's pair is unreadable and nothing good was applied before).
fn apply_sold_by(
root: &Path,
feed: &BTreeMap<String, String>,
applied: &mut Option<Vec<(&'static str, String)>>,
) -> bool {
if applied.is_none() {
if let Some(remembered) = remembered_sold_by(root) {
*applied = Some(remembered);
}
}
let now = match sold_decision(feed, applied.as_deref()) {
SoldDecision::Apply(now) => now,
SoldDecision::Keep(why) => {
tracing::error!("{why}");
match applied.clone() {
Some(kept) => kept,
None => return false,
}
}
SoldDecision::Refuse(why) => {
tracing::error!("{why}: no console is built until the feed names a sold estate's pair it can read (a console now would sell over a free monetize)");
return false;
}
};
let changed = applied.as_ref() != Some(&now);
for k in [ENV_INSTANCE, ENV_PROVIDER] {
match now.iter().find(|(n, _)| *n == k) {
Some((_, v)) => std::env::set_var(k, v),
None => std::env::remove_var(k),
}
}
if changed {
if let Some((_, i)) = now.iter().find(|(n, _)| *n == ENV_INSTANCE) {
tracing::info!(instance = %i, seller = %now.iter().find(|(n, _)| *n == ENV_PROVIDER).map(|(_, v)| v.as_str()).unwrap_or(""), "console: a sold private Gunnar — the console is told its name and its seller");
if let Err(e) = remember_sold_by(root, &now) {
tracing::error!(error = %format!("{e:#}"), "console: the sold-by pair cannot be remembered on the data disk");
}
}
}
*applied = Some(now);
true
}
/// Under the data root: the last sold-by pair applied (wiring tracer B 2026-10-08).
pub const SOLD_BY_REL: &str = "console/sold-by";
/// **The sold-by pair this front applied last**, from the data disk (two
/// lines: the instance, the seller); `None` when none was ever applied, or
/// what is there is not a pair [`sold_by`] takes.
pub fn remembered_sold_by(root: &Path) -> Option<Vec<(&'static str, String)>> {
let text = std::fs::read_to_string(root.join(SOLD_BY_REL)).ok()?;
let mut lines = text.lines().map(str::trim);
let feed: BTreeMap<String, String> = [
(FEED_CONSOLE_INSTANCE.to_string(), lines.next()?.to_string()),
(FEED_CONSOLE_PROVIDER.to_string(), lines.next()?.to_string()),
]
.into_iter()
.collect();
sold_by(&feed).ok().filter(|p| !p.is_empty())
}
/// Remember the applied sold-by pair on the data disk (an upgrade in place
/// keeps /data), so a restart with a feed that lost it stays sold.
pub fn remember_sold_by(root: &Path, applied: &[(&'static str, String)]) -> anyhow::Result<()> {
let get = |k: &str| {
applied
.iter()
.find(|(n, _)| *n == k)
.map(|(_, v)| v.as_str())
.unwrap_or("")
};
let path = root.join(SOLD_BY_REL);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(
&path,
format!("{}\n{}\n", get(ENV_INSTANCE), get(ENV_PROVIDER)),
)?;
Ok(())
}
/// **The estate's own bearer at the ONE monetize** — a sealed secret slot (`secret.*`, vm1's
/// alone, sealed at rest like every fed secret): what the console presents on its calls to the
/// ONE box's edge (Signup, EstateOrder, Purchase, Products), so the box knows which SITE a
/// signup comes from and makes the account on THIS estate. The kit feeds the bearer the
/// estate's attach enrolment was handed; the door it is presented at is [`FEED_FLEET`].
pub const FEED_MONETIZE_BEARER: &str = "secret.monetize_bearer";
/// The ONE monetize's door (`https://monetize.ignalina.dk`): gunnar-server's `fleet` feed key.
pub const FEED_FLEET: &str = gunnar_server::feed::FEED_FLEET;
/// **Where the console sells, and with what**: the ONE monetize's door and the estate's own
/// bearer there. Never rendered with the bearer.
#[derive(Clone, PartialEq, Eq)]
pub struct MonetizeDoor {
pub addr: String,
pub bearer: String,
/// The mock's seam (`fleet_at`, a bench `ip:port`): where the door is dialled, its name
/// still what its certificate is checked under. `None`: the door itself.
pub at: Option<String>,
/// The bench's CA for the door's certificate (`fleet_ca`), beside `at` only.
pub ca_pem: Option<String>,
}
impl std::fmt::Debug for MonetizeDoor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MonetizeDoor")
.field("addr", &self.addr)
.finish_non_exhaustive()
}
}
/// Pure: **the console's monetize door, from the feed** — [`FEED_FLEET`] (plain, or the first
/// VM's offer) and [`FEED_MONETIZE_BEARER`]. No bearer: `None`, the console sells nothing (a
/// sold estate, the open build). A bearer with no door to present it at is refused by name.
pub fn monetize_door(feed: &BTreeMap<String, String>) -> Result<Option<MonetizeDoor>, String> {
let Some(bearer) = feed
.get(FEED_MONETIZE_BEARER)
.map(|b| b.trim().to_string())
.filter(|b| !b.is_empty())
else {
return Ok(None);
};
let fed = gunnar_server::feed::Fed::from_feed(feed);
if let Some(why) =
gunnar_server::feed::seam_refusal(fed.fleet_at.as_deref(), fed.fleet_ca.is_some())
{
return Err(why);
}
match fed.fleet {
Some(addr) => Ok(Some(MonetizeDoor { addr, bearer, at: fed.fleet_at, ca_pem: fed.fleet_ca })),
None => Err(format!(
"REFUSED [console-monetize-no-door] the feed carries `{FEED_MONETIZE_BEARER}` and no `{FEED_FLEET}`: the estate's bearer has no ONE monetize to be presented at; the console sells nothing"
)),
}
}
/// The fed receipt address, if any — pure.
pub fn receipts_to(feed: &BTreeMap<String, String>) -> Option<String> {
feed.get(FEED_RECEIPTS_TO)
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
}
/// The fed product id of this console — pure. Unfed or blank is
/// [`CONSOLE_PRODUCT_DEFAULT`]; anything else must be a monetize product id
/// (`^[a-z][a-z0-9-]{0,62}$`, gunnar-ui's own rule), else `Err` naming the key.
pub fn console_product(feed: &BTreeMap<String, String>) -> Result<String, String> {
let id = feed
.get(FEED_CONSOLE_PRODUCT)
.map(|t| t.trim())
.unwrap_or_default();
if id.is_empty() {
return Ok(CONSOLE_PRODUCT_DEFAULT.to_string());
}
let mut c = id.chars();
let first = c.next().is_some_and(|c| c.is_ascii_lowercase());
if !first
|| id.len() > 63
|| !c.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(format!(
"REFUSED [console-product] the feed's {FEED_CONSOLE_PRODUCT} = {id:?} is not a monetize product id (a-z first, then a-z 0-9 -, at most 63)"
));
}
Ok(id.to_string())
}
/// gunnar's control plane, on every box's utility address.
pub const CONTROL_PORT: u16 = 50051;
/// How often the feed is looked at for a moved primary.
const POLL: Duration = Duration::from_secs(2);
/// **An ed25519 key under `rel`**, minted (0600) if absent; answers its
/// public OpenSSH line with `comment`.
pub fn key_line(root: &Path, rel: &str, comment: &str) -> anyhow::Result<String> {
// ONE writer, and the key ON DISK is the answer (release-3: the certify
// key's mint race, crate::keys::certify_key — the same shape here).
static MINT: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _one = MINT.lock().unwrap_or_else(|p| p.into_inner());
let path = root.join(rel);
if !std::fs::read(&path).map(|b| !b.is_empty()).unwrap_or(false) {
let pem = gunnar_core::mint_ed25519()?;
gunnar_server::pairing::write_secret(root, rel, std::str::from_utf8(&pem)?)?;
}
let pem = std::fs::read(&path).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
.map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
let mut line = key
.public_key()
.to_openssh()
.map_err(|e| anyhow::anyhow!("{e}"))?;
line.push(' ');
line.push_str(comment);
Ok(line)
}
/// **What the console offers**: its own key, its guest key and its credential
/// for the operator, minted at the first ask. Public values only.
pub fn offers(root: &Path) -> BTreeMap<String, String> {
let mut o = BTreeMap::new();
for (key, rel, comment) in [
(OFFER_CONSOLE_PUBKEY, IDENTITY_REL, "gunnar-ui"),
(OFFER_GUEST_PUBKEY, GUEST_REL, "gunnar-guest"),
(
OFFER_OPERATOR_CONSOLE_PUBKEY,
OPERATOR_REL,
gunnar_server::fed_principals::OPERATOR_CONSOLE_COMMENT,
),
] {
match key_line(root, rel, comment) {
Ok(line) => {
o.insert(key.to_string(), line);
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), key, "console: a key cannot be minted — not offered")
}
}
}
o
}
/// ★ **Lay the operator's tenant seed** (2026-10-04): when the feed names the
/// estate's operator (`operator_name`), the console's credential for him
/// ([`OPERATOR_REL`], the key offered as [`OFFER_OPERATOR_CONSOLE_PUBKEY`]) is
/// written as `<data>/console/seeds/<operator>` (0600), the file the console's
/// `TenantKeys` looks a principal's seed up by. `Ok(None)` when nothing names an
/// operator.
///
/// A name that cannot be a plain file name is refused (`TenantKeys` would never
/// look it up anyway). A seed laid for an operator the feed no longer names is
/// removed: the console must not hold a credential for an account nobody named.
pub fn lay_operator_seed(
root: &Path,
feed: &BTreeMap<String, String>,
) -> anyhow::Result<Option<String>> {
let Some(operator) = gunnar_server::fed_principals::operator_of(feed) else {
return Ok(None);
};
if operator.starts_with('.')
|| !operator
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
{
anyhow::bail!("REFUSED [console-operator-name] the fed `{}` {operator:?} is not a plain account name; no seed is laid for it", gunnar_server::fed_principals::FEED_OPERATOR);
}
key_line(
root,
OPERATOR_REL,
gunnar_server::fed_principals::OPERATOR_CONSOLE_COMMENT,
)?;
let pem = std::fs::read_to_string(root.join(OPERATOR_REL))
.map_err(|e| anyhow::anyhow!("{}: {e}", root.join(OPERATOR_REL).display()))?;
let rel = format!("{SEEDS_REL}/{operator}");
if std::fs::read_to_string(root.join(&rel)).ok().as_deref() != Some(pem.as_str()) {
gunnar_server::pairing::write_secret(root, &rel, &pem)?;
tracing::info!(operator = %operator, "console: the credential for the operator is laid as his tenant seed — the console adopts the key the install enrolled");
}
if let Ok(dir) = std::fs::read_dir(root.join(SEEDS_REL)) {
for entry in dir.flatten() {
if entry.file_name().to_str() != Some(operator.as_str()) && entry.path().is_file() {
let _ = std::fs::remove_file(entry.path());
tracing::info!(seed = ?entry.file_name(), "console: a seed for an account the feed no longer names as the operator is removed");
}
}
}
{
use std::os::unix::fs::PermissionsExt as _;
let _ =
std::fs::set_permissions(root.join(SEEDS_REL), std::fs::Permissions::from_mode(0o700));
}
Ok(Some(operator))
}
/// **Where the console dials**, from the fed reply — pure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
/// The primary's control plane, `<overlay>:50051` (over the tunnel).
pub control: String,
/// The twin's, when a twin with a utility address is fed.
pub twin: Option<String>,
/// The elected primary's NAME (the verdict, as the peers carry it) — whose
/// `offer.<name>.*` is the primary's own (the ticket key).
pub primary: Option<String>,
}
/// What the console last served: target, (web name = the relying party, canonical name = its
/// origin; WWW-CANON), admin token, receipts address, product, the ONE monetize's door with the
/// estate's bearer, and its public site's switch.
type Served = (
Target,
(String, String),
Option<String>,
Option<String>,
String,
Option<MonetizeDoor>,
bool,
);
pub fn target(reply: &JoinReply) -> Option<Target> {
// Over the tunnel (WireGuard-only): the boxes' overlay addresses.
let primary = crate::primary_overlay(reply)?;
let twin = crate::twin_overlay(reply).map(|ip| format!("{ip}:{CONTROL_PORT}"));
let name = reply
.peers
.iter()
.find(|p| p.role == Role::Primary)
.map(|p| p.name.clone());
Some(Target {
control: format!("{primary}:{CONTROL_PORT}"),
twin,
primary: name,
})
}
/// The fed break-glass bearer, if any — refused by name when it is guessable.
pub fn admin_token(feed: &BTreeMap<String, String>) -> Result<Option<String>, String> {
let Some(t) = feed
.get(FEED_ADMIN_TOKEN)
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
else {
return Ok(None);
};
match gunnar_ui_server::auth::middleware::weak_admin_token(&t) {
Some(why) => Err(format!("REFUSED [console-admin-token-weak] the fed `{FEED_ADMIN_TOKEN}` {why}; the console starts without a break-glass bearer")),
None => Ok(Some(t)),
}
}
/// **Unpack the embedded `dist/`** to `<data>/console/dist` (a file is only
/// rewritten when it differs; a file the build no longer carries is removed,
/// so `index.html` never names a stylesheet of another build). `None` when
/// this binary carries no console.
pub fn unpack(root: &Path) -> anyhow::Result<Option<PathBuf>> {
if FILES.is_empty() {
return Ok(None);
}
let dir = root.join(DIST_REL);
std::fs::create_dir_all(&dir)?;
let mut wanted = std::collections::BTreeSet::new();
for (rel, bytes) in FILES {
let p = dir.join(rel);
wanted.insert(p.clone());
if std::fs::read(&p).map(|b| b == *bytes).unwrap_or(false) {
continue;
}
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = p.with_extension("unpacking");
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &p)?;
}
for e in std::fs::read_dir(&dir)?.flatten() {
if e.path().is_file() && !wanted.contains(&e.path()) {
let _ = std::fs::remove_file(e.path());
}
}
Ok(Some(dir))
}
/// **The console, opened once**: its keys, its passkey store and its files.
pub struct Console {
root: PathBuf,
store: gunnar_ui_server::state::StateStore,
dist: Option<PathBuf>,
}
impl Console {
pub fn open(root: &Path) -> anyhow::Result<Console> {
let _ = offers(root);
let state = root.join(STATE_REL);
if let Some(parent) = state.parent() {
gunnar_ui_server::state::create_private_dir(parent)?;
}
let store = gunnar_ui_server::state::StateStore::open(&state)
.map_err(|e| anyhow::anyhow!("{}: {e}", state.display()))?;
Ok(Console {
root: root.to_path_buf(),
store,
dist: unpack(root)?,
})
}
/// **The console's router against `target`**, its passkeys bound to
/// `https://<host>`. Must run inside the tokio runtime (the event feed
/// subscribes at once).
pub fn router(
&self,
target: &Target,
host: &str,
admin_token: Option<String>,
receipts_to: Option<&str>,
) -> anyhow::Result<axum::Router> {
self.serving(target, host, admin_token, receipts_to)
.map(|(r, _)| r)
}
/// [`Console::router`], and the event hub its pump feeds — what
/// [`start`] stops when it replaces this console.
pub fn serving(
&self,
target: &Target,
host: &str,
admin_token: Option<String>,
receipts_to: Option<&str>,
) -> anyhow::Result<(axum::Router, gunnar_ui_server::sse::EventHub)> {
self.serving_selling(
target,
host,
admin_token,
receipts_to,
None,
CONSOLE_PRODUCT_DEFAULT,
)
}
/// [`Console::serving`], selling `product` through the ONE monetize at `door` with the
/// estate's own bearer ([`monetize_door`]); `None` sells nothing.
pub fn serving_selling(
&self,
target: &Target,
host: &str,
admin_token: Option<String>,
receipts_to: Option<&str>,
door: Option<&MonetizeDoor>,
product: &str,
) -> anyhow::Result<(axum::Router, gunnar_ui_server::sse::EventHub)> {
self.serving_selling_at(target, host, host, admin_token, receipts_to, door, product)
}
/// [`Console::serving_selling`] with its passkeys' relying party `rp_id` (the bare site) asked
/// at `https://<origin_host>` (WWW-CANON: the canonical `www.<site>`, [`relying_party`]).
#[allow(clippy::too_many_arguments)]
pub fn serving_selling_at(
&self,
target: &Target,
rp_id: &str,
origin_host: &str,
admin_token: Option<String>,
receipts_to: Option<&str>,
door: Option<&MonetizeDoor>,
product: &str,
) -> anyhow::Result<(axum::Router, gunnar_ui_server::sse::EventHub)> {
// The paid console's money half talks to the ONE monetize, not to one on this box —
// with the estate's OWN bearer there, so the box knows which site a signup is from.
#[cfg(feature = "metered")]
let monetize = match door {
Some(d) => Some(
gunnar_ui_server::monetize::Monetize::connect_lazy_via(
&d.addr,
&d.bearer,
product,
d.at.as_deref().map(|at| (at, d.ca_pem.as_deref())),
)
.map_err(anyhow::Error::msg)?
.with_receipts_to(receipts_to),
),
None => None,
};
#[cfg(not(feature = "metered"))]
let _ = (receipts_to, door, product);
use gunnar_ui_server::{app::App, auth, grpc::Gunnar, signer::Signer};
let signer = Arc::new(Signer::load(self.root.join(IDENTITY_REL))?);
let gunnar = Gunnar::connect_lazy(&target.control, signer).map_err(anyhow::Error::msg)?;
let guest = Gunnar::connect_lazy(
&target.control,
Arc::new(Signer::load(self.root.join(GUEST_REL))?),
)
.map_err(anyhow::Error::msg)?;
let twin = match &target.twin {
Some(t) => Some(
gunnar_ui_server::api::twin::Twin::connect(&gunnar, t)
.map_err(anyhow::Error::msg)?,
),
None => None,
};
let rp = relying_party(rp_id, origin_host);
let webauthn =
Arc::new(auth::Webauthn::new(rp, self.store.clone()).map_err(anyhow::Error::msg)?);
// A32-14, stays a task: the console's event hub, for the console's life, ended with its runtime.
let events = gunnar_ui_server::sse::EventHub::spawn(gunnar.clone());
let app = App {
webauthn: Some(webauthn),
admin_token,
events: events.clone(),
#[cfg(feature = "metered")]
monetize,
reviewers: Arc::new(gunnar_ui_server::reviewer::ReviewerKeys::new(
target.control.clone(),
self.store.clone(),
)),
// The operator's seed (2026-10-04): the key the install enrolled on his
// account, adopted at his first passkey instead of a fresh mint.
tenants: Arc::new(
gunnar_ui_server::tenant::TenantKeys::new(
target.control.clone(),
self.store.clone(),
)
.with_seeds(Some(self.root.join(SEEDS_REL))),
),
gunnar,
twin,
guest: Some(guest),
forges: Arc::new(gunnar_ui_server::forges::ForgeSessions::new()),
forge_apps: Arc::new(gunnar_ui_server::forges::ForgeApps::from_env()),
// gunnar-ui 053ecff (tenant import): the per-person import/sync limits, as the
// standalone console takes them (gunnar-ui-server main.rs).
imports: Arc::new(gunnar_ui_server::api::mirror_gate::Imports::from_env()),
ui_dir: self.dist.clone(),
caches: Default::default(),
objects: Default::default(),
readmes: Arc::new(gunnar_ui_server::readme::Cache::new(Some(
self.store.clone(),
))),
};
Ok((gunnar_ui_server::serve::router(Arc::new(app)), events))
}
}
/// Pure (WWW-CANON, owner 2026-10-30): **the console's relying party** — the RP ID `rp_id` (the
/// bare site, the registrable domain the passkeys were made for) and the one origin the console
/// asks them at and its origin guard takes, `https://<origin_host>` (the canonical `www.<site>`;
/// an origin of `rp_id`). Its invite links are built from that origin.
pub fn relying_party(rp_id: &str, origin_host: &str) -> gunnar_ui_server::auth::RelyingParty {
gunnar_ui_server::auth::RelyingParty {
rp_id: rp_id.to_string(),
origin: format!("https://{origin_host}"),
rp_name: "gunnar".to_string(),
}
}
/// **The console now serving's event pump replaces the last one's.** `false`
/// when there was none. The replaced console's pump is to stop subscribing
/// to the main it was built against.
pub fn replace_pump(
slot: &mut Option<gunnar_ui_server::sse::EventHub>,
now: gunnar_ui_server::sse::EventHub,
) -> bool {
match slot.replace(now) {
Some(old) => {
old.stop();
true
}
None => false,
}
}
/// **Run the console on `web` for the life of the process**: open it, and
/// every time the fed primary (or the names, or the bearer) changes, build it
/// against that and swap it in. Must run inside the tokio runtime.
/// **This front's certificates on the Administration page** (owner 2026-10-25): the
/// report and try now are `web::status`'s, read from this front's data disk.
struct FrontCertsOf(PathBuf);
impl gunnar_ui_server::front_certs::FrontCerts for FrontCertsOf {
fn report(&self) -> serde_json::Value {
crate::web::status::report_json(&self.0, std::time::SystemTime::now())
}
fn try_now(&self, host: &str) -> Result<String, String> {
let words = crate::web::status::try_now(&self.0, host, std::time::SystemTime::now())?;
tracing::warn!("console: an admin asked {host}'s certificate to be tried now");
Ok(words)
}
}
pub fn start(web: crate::web::Web, vm: Arc<crate::vm::FrontVm>, estate: String) {
gunnar_ui_server::front_certs::install(Arc::new(FrontCertsOf(web.root.clone())));
let console = match Console::open(web.root.as_path()) {
Ok(c) => c,
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "console: cannot open — the web face serves a refusal");
web.app.set(crate::web::serve::placeholder("REFUSED [console-unopenable] the console's store or keys cannot be opened on this front's data disk"));
return;
}
};
if console.dist.is_none() {
web.app.set(crate::web::serve::placeholder(
"REFUSED [console-not-on-medium] this front medium was composed without the console's built files (gunnar-ui-web dist); compose it again with them",
));
tracing::error!("console: this binary carries no built console (DIST_SOURCE = None)");
return;
}
tracing::info!(dist = ?DIST_SOURCE, "console: the built console is on this medium");
web.app.set(crate::web::serve::placeholder(
"the console waits for the installer: no primary is known yet",
));
// A32-14, stays a task: the console's follow loop on the front's runtime, async, ends with it.
tokio::spawn(async move {
let mut last: Option<Served> = None;
let mut pump: Option<gunnar_ui_server::sse::EventHub> = None;
let mut seeded: Option<Option<String>> = None;
let mut sold: Option<Vec<(&'static str, String)>> = None;
loop {
if let Some(reply) = vm.latest() {
// Before the console is built against this feed: what it is.
// A SOLD estate's pair it cannot read builds no console (fails closed).
if !apply_sold_by(&web.root, &reply.feed, &mut sold) {
web.app.set(crate::web::serve::placeholder(
"this Gunnar's console is not ready yet: its setup is incomplete, and the operator has been told",
));
last = None;
tokio::time::sleep(POLL).await;
continue;
}
// The product it sells (G15): a bad id builds no console (fails closed).
let product = match console_product(&reply.feed) {
Ok(p) => p,
Err(e) => {
tracing::error!("{e}");
web.app.set(crate::web::serve::placeholder(
"this Gunnar's console is not ready yet: its setup is incomplete, and the operator has been told",
));
last = None;
tokio::time::sleep(POLL).await;
continue;
}
};
// The operator's tenant seed, laid once per name the feed gives.
let named = gunnar_server::fed_principals::operator_of(&reply.feed);
if seeded.as_ref() != Some(&named) {
// Said once per name: a refusal is not retried every poll.
if let Err(e) = lay_operator_seed(&web.root, &reply.feed) {
tracing::error!(error = %format!("{e:#}"), "console: the operator's tenant seed cannot be laid — his first login mints the console's credential instead");
}
seeded = Some(named);
}
let cfg = crate::web::WebConfig::from(&estate, &reply.feed);
let token = match admin_token(&reply.feed) {
Ok(t) => t,
Err(e) => {
tracing::error!("{e}");
None
}
};
let receipts = receipts_to(&reply.feed);
// Where it sells, with the estate's own bearer; a bearer with no door sells nothing.
let door = match monetize_door(&reply.feed) {
Ok(d) => d,
Err(e) => {
tracing::error!("{e}");
None
}
};
let public = public_site_of(&reply.feed);
if let Some(t) = target(&reply) {
let now = (
t.clone(),
(cfg.name().to_string(), cfg.canonical()),
token.clone(),
receipts.clone(),
product.clone(),
door.clone(),
public,
);
if last.as_ref() != Some(&now) {
// gunnar-ui reads it when the console's monetize handle is built.
std::env::set_var(ENV_CONSOLE_PRODUCT, &product);
// ...and its public site's switch when the console is built: on
// only where the ONE box fed it (gunnar.rs), else unset = off.
if public {
std::env::set_var(ENV_PUBLIC_SITE, "on");
} else {
std::env::remove_var(ENV_PUBLIC_SITE);
}
// WWW-CANON: the relying party is the first host, the origin the canonical www.
match console.serving_selling_at(
&t,
cfg.name(),
&cfg.canonical(),
token,
receipts.as_deref(),
door.as_ref(),
&product,
) {
Ok((r, events)) => {
// `/download/…` (the signed gunnar CLI, E4) ahead of the console.
web.app.set(crate::downloads::serving(r));
let replaced = replace_pump(&mut pump, events);
tracing::info!(control = %t.control, twin = ?t.twin, host = cfg.name(), origin = %cfg.canonical(), replaced, sells_at = ?door.as_ref().map(|d| d.addr.as_str()), "console: serving against the primary — swapped in live");
last = Some(now);
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "console: cannot be built against {}", t.control)
}
}
}
}
}
tokio::time::sleep(POLL).await;
}
});
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ **The public site is ON only where the site's own declaration says so**
/// (owner 2026-10-25: gunnar.rs; a sold private estate is the airgap default
/// and is never indexed): the ONE box feeds an operator site whose
/// `[estates.<label>] public_site = true` the key `console_public_site = on`,
/// and only that turns the console's `GUNNAR_UI_PUBLIC_SITE` on.
#[test]
fn the_public_site_is_on_only_where_the_feed_says_on() {
let feed = |pairs: &[(&str, &str)]| -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
assert_eq!(FEED_PUBLIC_SITE, "console_public_site");
assert_eq!(ENV_PUBLIC_SITE, "GUNNAR_UI_PUBLIC_SITE");
assert!(public_site_of(&feed(&[(FEED_PUBLIC_SITE, "on")])));
assert!(public_site_of(&feed(&[(FEED_PUBLIC_SITE, " ON ")])));
for off in [
feed(&[]),
feed(&[(FEED_PUBLIC_SITE, "off")]),
feed(&[(FEED_PUBLIC_SITE, "")]),
feed(&[(FEED_PUBLIC_SITE, "yes please")]),
] {
assert!(!public_site_of(&off), "{off:?}");
}
// A sold estate's feed never carries it, and its console stays off.
let sold = feed(&[
(FEED_CONSOLE_INSTANCE, "acme"),
(FEED_CONSOLE_PROVIDER, "https://gunnar.rs"),
]);
assert!(!public_site_of(&sold));
}
/// ★ **A sold estate's console is told its name and its seller** (private
/// ownership (b)): as `GUNNAR_UI_INSTANCE` / `GUNNAR_UI_PROVIDER`, from the
/// kit's `console_instance` / `console_provider`; nothing for the kit's own
/// estates; a malformed one refused by name (the console then says
/// nothing of either).
#[test]
fn a_sold_estates_console_is_told_its_name_and_its_seller() {
let feed = |pairs: &[(&str, &str)]| -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
let sold = feed(&[
(FEED_CONSOLE_INSTANCE, " acme "),
(FEED_CONSOLE_PROVIDER, "https://gunnar.rs/"),
]);
assert_eq!(
sold_by(&sold).unwrap(),
vec![
(ENV_INSTANCE, "acme".to_string()),
(ENV_PROVIDER, "https://gunnar.rs".to_string())
]
);
assert!(
sold_by(&BTreeMap::new()).unwrap().is_empty(),
"the kit's own estates: nothing"
);
for bad in [
feed(&[
(FEED_CONSOLE_INSTANCE, "Acme"),
(FEED_CONSOLE_PROVIDER, "https://gunnar.rs"),
]),
feed(&[
(FEED_CONSOLE_INSTANCE, "a-b"),
(FEED_CONSOLE_PROVIDER, "https://gunnar.rs"),
]),
feed(&[
(FEED_CONSOLE_INSTANCE, "acme"),
(FEED_CONSOLE_PROVIDER, "http://gunnar.rs"),
]),
feed(&[
(FEED_CONSOLE_INSTANCE, "acme"),
(FEED_CONSOLE_PROVIDER, "https://gunnar.rs/signup"),
]),
feed(&[(FEED_CONSOLE_INSTANCE, "acme")]),
feed(&[(FEED_CONSOLE_PROVIDER, "https://gunnar.rs")]),
] {
assert!(
sold_by(&bad).unwrap_err().contains("[console-sold-by]"),
"{bad:?}"
);
}
}
/// ★ **A sold console never starts selling** (wiring tracer B 2026-10-08):
/// a SOLD estate's monetize is free (`plugin.gunnar.free`), so a console
/// that forgot it is sold offers purchases a free monetize grants for
/// nothing. A malformed pair after a good one keeps the good one; a pair
/// that drops out of the feed after a good one keeps it too (a sold
/// estate never becomes a seller); a malformed pair with nothing good
/// before builds no console at all. The kit's own estates are untouched.
#[test]
fn a_sold_console_never_starts_selling() {
let feed = |pairs: &[(&str, &str)]| -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
let good = feed(&[
(FEED_CONSOLE_INSTANCE, "acme"),
(FEED_CONSOLE_PROVIDER, "https://gunnar.rs"),
]);
let garbled = feed(&[
(FEED_CONSOLE_INSTANCE, "Acme"),
(FEED_CONSOLE_PROVIDER, "https://gunnar.rs"),
]);
let half = feed(&[(FEED_CONSOLE_INSTANCE, "acme")]);
let applied = vec![
(ENV_INSTANCE, "acme".to_string()),
(ENV_PROVIDER, "https://gunnar.rs".to_string()),
];
assert_eq!(
sold_decision(&good, None),
SoldDecision::Apply(applied.clone())
);
assert_eq!(
sold_decision(&BTreeMap::new(), None),
SoldDecision::Apply(Vec::new()),
"the kit's own estate"
);
for bad in [&garbled, &half] {
assert!(
matches!(sold_decision(bad, None), SoldDecision::Refuse(ref why) if why.contains("[console-sold-by]")),
"a garbled pair with nothing before builds no console: {bad:?}"
);
assert!(
matches!(sold_decision(bad, Some(&applied)), SoldDecision::Keep(_)),
"a garbled pair after a good one keeps the good one: {bad:?}"
);
}
assert!(
matches!(
sold_decision(&BTreeMap::new(), Some(&applied)),
SoldDecision::Keep(_)
),
"a sold estate whose feed lost the pair (an upgrade without --laid) stays sold"
);
assert_eq!(
sold_decision(&good, Some(&applied)),
SoldDecision::Apply(applied.clone())
);
}
/// The last good pair survives a restart of the front (an upgrade in
/// place keeps /data): remembered under the data root, read back whole.
#[test]
fn the_sold_pair_is_remembered_on_the_data_disk() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
remembered_sold_by(dir.path()),
None,
"nothing remembered on a fresh disk"
);
let applied = vec![
(ENV_INSTANCE, "acme".to_string()),
(ENV_PROVIDER, "https://gunnar.rs".to_string()),
];
remember_sold_by(dir.path(), &applied).unwrap();
assert_eq!(remembered_sold_by(dir.path()), Some(applied));
}
use nordisk_pairing::join::{Assigned, Peer};
use std::net::Ipv4Addr;
fn peer(name: &str, role: Role, ip: u8) -> Peer {
Peer {
name: name.into(),
role,
tunnel_pubkey: String::new(),
overlay: Ipv4Addr::new(10, 44, 0, ip),
endpoint: None,
utility: Some(Ipv4Addr::new(10, 13, 8, ip)),
}
}
fn reply(primary_utility: Option<Ipv4Addr>, peers: Vec<Peer>) -> JoinReply {
JoinReply {
estate: "gunnar.rs".into(),
me: Assigned {
name: "vm1".into(),
overlay: nordisk_pairing::OVERLAY_FRONT,
},
cert_pem: None,
role: Some(Role::Front),
primary_overlay: None,
primary_utility,
peers,
feed: BTreeMap::new(),
}
}
/// **WireGuard-only** (owner 2026-10-04): the console — and monetize and
/// the operator door, which dial its target — reach the primary and the
/// twin on their OVERLAY addresses, through the front's own tun.
#[test]
fn the_console_dials_the_fed_primary_and_the_twin_over_the_tunnel() {
let peers = vec![
peer("vm1", Role::Front, 1),
peer("vm2", Role::Primary, 2),
peer("vm3", Role::Twin, 3),
];
assert_eq!(
target(&reply(None, peers.clone())),
Some(Target {
control: "10.44.0.2:50051".into(),
twin: Some("10.44.0.3:50051".into()),
primary: Some("vm2".into())
})
);
// The door's own statement of the primary's overlay wins over the list;
// its utility address is never dialled.
let said = JoinReply {
primary_overlay: Some(Ipv4Addr::new(10, 44, 0, 9)),
..reply(Some(Ipv4Addr::new(10, 13, 8, 9)), peers)
};
assert_eq!(target(&said).unwrap().control, "10.44.0.9:50051");
assert_eq!(
target(&reply(None, vec![peer("vm1", Role::Front, 1)])),
None,
"no primary yet: nothing to dial"
);
}
/// The two keys are minted once (0600), offered as OpenSSH lines, never
/// the private half, and they are exactly what the primary enrols.
#[test]
fn the_console_offers_its_own_key_and_its_guest_key_and_the_primary_enrols_both() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let o = offers(dir.path());
assert!(
o[OFFER_CONSOLE_PUBKEY].starts_with("ssh-ed25519 ")
&& o[OFFER_CONSOLE_PUBKEY].ends_with(" gunnar-ui")
);
assert!(
o[OFFER_GUEST_PUBKEY].starts_with("ssh-ed25519 ")
&& o[OFFER_GUEST_PUBKEY].ends_with(" gunnar-guest")
);
assert_ne!(
o[OFFER_CONSOLE_PUBKEY], o[OFFER_GUEST_PUBKEY],
"the guest key must never be the admin key"
);
assert_eq!(offers(dir.path()), o, "minted once");
assert!(o.values().all(|v| !v.contains("PRIVATE")));
for rel in [IDENTITY_REL, GUEST_REL] {
assert_eq!(
std::fs::metadata(dir.path().join(rel))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600,
"{rel}"
);
}
// The console loads what was minted.
gunnar_ui_server::signer::Signer::load(dir.path().join(IDENTITY_REL)).unwrap();
// What the primary installs from vm1's offer (gunnar-server fed_principals).
let feed: BTreeMap<String, String> = o
.iter()
.map(|(k, v)| (format!("offer.vm1.{k}"), v.clone()))
.collect();
let p = gunnar_server::fed_principals::parse(&feed).unwrap();
let ui = p
.iter()
.find(|p| p.id == "gunnar-ui")
.expect("gunnar-ui enrolled from the offer");
assert!(ui.admin && ui.kind == gunnar_proto::PrincipalKind::Machine);
let guest = p
.iter()
.find(|p| p.id == "gunnar-guest")
.expect("gunnar-guest enrolled from the offer");
assert!(!guest.admin, "the guest has NO grant");
}
/// ★ **The console's credential for the OPERATOR is minted here, offered,
/// laid as his tenant seed, and enrolled by the primary as his account's
/// FIRST key** (2026-10-04, test2: the owner's first passkey bound to
/// nothing, and an account with a key of its own would have refused the
/// console's at the first read — "awaiting attestation"). One key, three
/// places: the offer (public), the seed the console adopts (private, 0600),
/// and the operator's key table on the primary (public) — the same key.
#[test]
fn the_operators_console_credential_is_offered_laid_as_his_seed_and_enrolled_at_install() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let o = offers(dir.path());
let line = o
.get(OFFER_OPERATOR_CONSOLE_PUBKEY)
.cloned()
.expect("the console offers its credential for the operator");
assert!(
line.starts_with("ssh-ed25519 ")
&& line.ends_with(&format!(
" {}",
gunnar_server::fed_principals::OPERATOR_CONSOLE_COMMENT
)),
"{line}"
);
assert!(!line.contains("PRIVATE"));
assert_ne!(
line, o[OFFER_CONSOLE_PUBKEY],
"never the console's own (server-wide) key"
);
assert_ne!(line, o[OFFER_GUEST_PUBKEY], "never the guest key");
assert_eq!(offers(dir.path()), o, "minted once, kept");
assert_eq!(
std::fs::metadata(dir.path().join(OPERATOR_REL))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
// The feed names the operator: his seed is laid, 0600, the SAME key.
let mut feed: BTreeMap<String, String> = BTreeMap::from([(
gunnar_server::fed_principals::FEED_OPERATOR.to_string(),
"rickard".to_string(),
)]);
assert_eq!(
lay_operator_seed(dir.path(), &feed).unwrap().as_deref(),
Some("rickard")
);
let seed = dir.path().join(SEEDS_REL).join("rickard");
assert_eq!(
std::fs::metadata(&seed).unwrap().permissions().mode() & 0o777,
0o600
);
let laid =
gunnar_ui_server::signer::Signer::from_openssh(std::fs::read_to_string(&seed).unwrap())
.expect("the console can load the seed");
assert_eq!(
laid.public_key_openssh().split_whitespace().nth(1),
line.split_whitespace().nth(1),
"the seed is the offered key"
);
// Again: unchanged. Another operator: his seed, and the old one gone.
assert_eq!(
lay_operator_seed(dir.path(), &feed).unwrap().as_deref(),
Some("rickard")
);
feed.insert(
gunnar_server::fed_principals::FEED_OPERATOR.to_string(),
"henrik".to_string(),
);
assert_eq!(
lay_operator_seed(dir.path(), &feed).unwrap().as_deref(),
Some("henrik")
);
assert!(dir.path().join(SEEDS_REL).join("henrik").is_file());
assert!(
!seed.exists(),
"a seed for an operator the feed no longer names is not left behind"
);
// Not a name a file can carry: refused, nothing laid.
feed.insert(
gunnar_server::fed_principals::FEED_OPERATOR.to_string(),
"../console/id".to_string(),
);
assert!(lay_operator_seed(dir.path(), &feed).is_err());
// Nothing named: nothing laid, nothing refused.
assert_eq!(
lay_operator_seed(dir.path(), &BTreeMap::new()).unwrap(),
None
);
// What the primary does with the relayed offer: the operator's FIRST key.
let data = tempfile::tempdir().unwrap();
let stores =
std::sync::Arc::new(gunnar_server::stores::ServerStores::open(data.path()).unwrap());
let admin =
gunnar_core::ssh_key::PrivateKey::from_openssh(gunnar_core::mint_ed25519().unwrap())
.unwrap()
.public_key()
.to_openssh()
.unwrap();
stores.auth().bootstrap_admin(&admin).unwrap();
let mut fed: BTreeMap<String, String> = o
.iter()
.map(|(k, v)| (format!("offer.vm1.{k}"), v.clone()))
.collect();
fed.insert("admin_pubkey".into(), admin);
fed.insert(
gunnar_server::fed_principals::FEED_OPERATOR.into(),
"rickard".into(),
);
gunnar_server::fed_principals::install(&stores, &fed).unwrap();
let keys = stores.auth().keys_of("rickard").unwrap();
assert_eq!(
keys.len(),
1,
"the operator's only key is the console's credential: {keys:?}"
);
assert_eq!(
keys[0].fingerprint_sha256,
laid.fingerprint(),
"the key the primary enrolled is the seed the console adopts"
);
}
/// ★ **g-receipts: the operator's receipt address is read off the feed**
/// (`console_receipts_to`, fed by `estate install`), and handed to the
/// console's monetize handle so the operator's own orders name it as their
/// payer — MEASURED 2026-09-29: every order asked `receipt_email = None`.
#[test]
fn the_operators_receipt_address_is_read_off_the_feed() {
let f = |t: &str| BTreeMap::from([(FEED_RECEIPTS_TO.to_string(), t.to_string())]);
assert_eq!(FEED_RECEIPTS_TO, "console_receipts_to");
assert_eq!(
receipts_to(&f(" operator@example.com ")),
Some("operator@example.com".to_string())
);
assert_eq!(receipts_to(&f("")), None);
assert_eq!(
receipts_to(&BTreeMap::new()),
None,
"not fed: the console names no payer"
);
}
/// ★ **G15: the console's product id is read off the feed** — gunnar.rs and
/// test2.gunnar.rs both sold `gunnar` (a const in gunnar-ui); now the kit
/// feeds `console_product` and a bad id refuses instead of selling another
/// product's plans.
#[test]
fn the_consoles_product_is_read_off_the_feed() {
let f = |t: &str| BTreeMap::from([(FEED_CONSOLE_PRODUCT.to_string(), t.to_string())]);
assert_eq!(FEED_CONSOLE_PRODUCT, "console_product");
assert_eq!(ENV_CONSOLE_PRODUCT, "GUNNAR_UI_PRODUCT");
assert_eq!(
console_product(&f("gunnar-test2")),
Ok("gunnar-test2".to_string())
);
assert_eq!(console_product(&f(" gunnar ")), Ok("gunnar".to_string()));
assert_eq!(
console_product(&f("")),
Ok("gunnar".to_string()),
"blank: gunnar.rs"
);
assert_eq!(
console_product(&BTreeMap::new()),
Ok("gunnar".to_string()),
"unfed: gunnar.rs"
);
for bad in [
"Gunnar",
"gunnar test2",
"-gunnar",
"9gunnar",
"gunnar_test2",
&"g".repeat(64),
] {
let e = console_product(&f(bad)).expect_err(bad);
assert!(
e.contains("console_product") && e.contains(bad.trim()),
"{e}"
);
}
assert!(
console_product(&f(&"g".repeat(63))).is_ok(),
"63 chars is an id"
);
}
#[test]
fn a_guessable_break_glass_token_is_refused_by_name() {
let f = |t: &str| BTreeMap::from([(FEED_ADMIN_TOKEN.to_string(), t.to_string())]);
assert_eq!(admin_token(&BTreeMap::new()), Ok(None));
assert!(admin_token(&f("hunter2"))
.unwrap_err()
.contains("[console-admin-token-weak]"));
let strong = "4f1c9a7e-2b3d-4e5f-8a9b-0c1d2e3f4a5b-77aa";
assert_eq!(admin_token(&f(strong)), Ok(Some(strong.to_string())));
}
/// ★ **The console sells through the ONE box with the estate's OWN bearer** (owner
/// 2026-10-11: gunnar.rs and test2.gunnar.rs on one monetize, each its own Stripe — the
/// bearer is how the box knows the site): read off the feed's sealed secret slot and the
/// `fleet` door; no bearer sells nothing; a bearer with no door is refused by name.
#[test]
fn the_estates_own_bearer_at_the_one_box_is_read_off_the_feed() {
let feed = |kv: &[(&str, &str)]| {
kv.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect::<BTreeMap<_, _>>()
};
let door = |a: &str, b: &str| {
Some(MonetizeDoor {
addr: a.into(),
bearer: b.into(),
at: None,
ca_pem: None,
})
};
assert!(
FEED_MONETIZE_BEARER.starts_with("secret."),
"a sealed secret slot, vm1's alone"
);
assert_eq!(monetize_door(&feed(&[])), Ok(None));
assert_eq!(
monetize_door(&feed(&[(FEED_FLEET, "https://monetize.ignalina.dk")])),
Ok(None),
"no bearer: sells nothing"
);
assert_eq!(
monetize_door(&feed(&[
(FEED_FLEET, "https://monetize.ignalina.dk"),
(FEED_MONETIZE_BEARER, " b0 \n")
])),
Ok(door("https://monetize.ignalina.dk", "b0"))
);
assert_eq!(
monetize_door(&feed(&[
("offer.vm1.fleet", "https://monetize.ignalina.dk"),
(FEED_MONETIZE_BEARER, "b0")
])),
Ok(door("https://monetize.ignalina.dk", "b0")),
"the front's offer of the door"
);
let e = monetize_door(&feed(&[(FEED_MONETIZE_BEARER, "b0")])).unwrap_err();
assert!(e.contains(FEED_FLEET) && e.contains("REFUSED"), "{e}");
assert!(
!format!("{:?}", door("https://x", "sekret-bearer")).contains("sekret"),
"never rendered"
);
}
/// ★ i54a run29 A2: on the mock the console dialled the ONE box by NAME (`dns error`, the
/// paid signup 502), where the boxes dial the fed seam. The console takes the same seam
/// (`fleet_at` + `fleet_ca`), on a bench address only — anything else refused by name.
#[test]
fn the_console_dials_the_one_box_where_the_mock_feeds_it() {
let feed = |kv: &[(&str, &str)]| {
kv.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect::<BTreeMap<_, _>>()
};
let pem = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n";
let base = [
(FEED_FLEET, "https://monetize-test.ignalina.dk"),
(FEED_MONETIZE_BEARER, "b0"),
];
let mut kv = base.to_vec();
kv.extend([
(gunnar_server::feed::FEED_FLEET_AT, "10.0.2.2:35219"),
(gunnar_server::feed::FEED_FLEET_CA, pem),
]);
let d = monetize_door(&feed(&kv)).unwrap().expect("a door");
assert_eq!(
(d.addr.as_str(), d.at.as_deref(), d.ca_pem.as_deref()),
(
"https://monetize-test.ignalina.dk",
Some("10.0.2.2:35219"),
Some(pem.trim())
),
"as fed, trimmed"
);
assert!(!format!("{d:?}").contains("BEGIN CERTIFICATE"));
let d = monetize_door(&feed(&base)).unwrap().expect("a door");
assert_eq!(
(d.at, d.ca_pem),
(None, None),
"no seam fed: the door itself"
);
let mut public = base.to_vec();
public.push((gunnar_server::feed::FEED_FLEET_AT, "203.0.113.5:443"));
assert!(
monetize_door(&feed(&public))
.unwrap_err()
.contains("fleet-seam-bench-only"),
"a public address is no seam"
);
}
/// ★ **With the door, the console's money half is ON** (it was `monetize: None` on every
/// estate: nothing fed it a credential) — the vendor's return page `/welcome` is served;
/// without one it is not.
#[cfg(feature = "metered")]
#[tokio::test]
async fn a_console_fed_its_door_sells_and_one_without_does_not() {
use tower::ServiceExt as _;
let dir = tempfile::tempdir().unwrap();
let c = Console::open(dir.path()).unwrap();
let t = Target {
control: "127.0.0.1:1".into(),
twin: None,
primary: None,
};
let welcome = |r: axum::Router| async move {
let req = http::Request::get("/welcome?session=cs_test_a1b2c3d4e5")
.header("host", "gunnar.test")
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
[127, 0, 0, 1],
9,
))))
.body(axum::body::Body::empty())
.unwrap();
r.oneshot(req).await.unwrap().status()
};
let door = MonetizeDoor {
addr: "https://monetize.example".into(),
bearer: "estate-bearer-0123456789".into(),
at: None,
ca_pem: None,
};
let (selling, _) = c
.serving_selling(&t, "gunnar.test", None, None, Some(&door), "gunnar")
.unwrap();
assert_eq!(
welcome(selling).await,
http::StatusCode::OK,
"the return page of a console that sells"
);
let (bare, _) = c
.serving_selling(&t, "gunnar.test", None, None, None, "gunnar")
.unwrap();
assert_ne!(
welcome(bare).await,
http::StatusCode::OK,
"no door: no signup door either"
);
}
/// ★ WWW-CANON (owner 2026-10-30: the site is `www.<site>`): **the console's passkeys keep the
/// bare site as their relying party and are asked at `https://www.<site>`** — the passkeys
/// made on the bare host (RP ID `gunnar.rs`) keep working, and the origin guard takes a
/// state change from www and refuses one from the bare origin or another site.
#[tokio::test]
async fn the_consoles_origin_is_www_and_its_relying_party_the_bare_site() {
use tower::ServiceExt as _;
let rp = relying_party("gunnar.rs", "www.gunnar.rs");
assert_eq!(
(rp.rp_id.as_str(), rp.origin.as_str()),
("gunnar.rs", "https://www.gunnar.rs")
);
let dir = tempfile::tempdir().unwrap();
let c = Console::open(dir.path()).unwrap();
let t = Target {
control: "127.0.0.1:1".into(),
twin: None,
primary: None,
};
let (r, _) = c
.serving_selling_at(
&t,
"gunnar.test",
"www.gunnar.test",
None,
None,
None,
"gunnar",
)
.unwrap();
let logout = |origin: &'static str| {
let req = http::Request::post("/auth/logout")
.header("host", "www.gunnar.test")
.header("origin", origin)
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
[127, 0, 0, 1],
9,
))))
.body(axum::body::Body::empty())
.unwrap();
r.clone().oneshot(req)
};
assert_ne!(
logout("https://www.gunnar.test").await.unwrap().status(),
http::StatusCode::FORBIDDEN,
"www is the console's origin"
);
assert_eq!(
logout("https://gunnar.test").await.unwrap().status(),
http::StatusCode::FORBIDDEN,
"the bare host serves no page that could send it"
);
assert_eq!(
logout("https://evil.example").await.unwrap().status(),
http::StatusCode::FORBIDDEN
);
}
/// ★ GUN-SEO-GUEST (owner 2026-10-31; WWW-CANON): **behind this front, every bare-name
/// address a crawler or a search result holds answers 301 to the same path and query on
/// `https://www.<site>`**: a repository page, a file at depth, an issue search, the sitemap,
/// llms.txt, robots.txt. www itself is the console (no redirect), and a CLI's gRPC call to
/// the bare name is not redirected. The console is served through estate-front's
/// `App::router_now` with the names the ONE box feeds (`<site>`, `www.<site>`, `git.<site>`),
/// the path a live front takes.
#[tokio::test]
async fn a_bare_name_address_is_a_301_to_the_same_path_on_www() {
use tower::ServiceExt as _;
let dir = tempfile::tempdir().unwrap();
let c = Console::open(dir.path()).unwrap();
let t = Target {
control: "127.0.0.1:1".into(),
twin: None,
primary: None,
};
let (r, _) = c
.serving_selling_at(
&t,
"gunnar.test",
"www.gunnar.test",
None,
None,
None,
"gunnar",
)
.unwrap();
let app = estate_front::web::serve::App::new(r);
app.set_hosts(&[
"gunnar.test".to_owned(),
"www.gunnar.test".to_owned(),
"git.gunnar.test".to_owned(),
]);
let ask = |host: &'static str, path: &'static str| {
let req = http::Request::get(path)
.header("host", host)
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
[127, 0, 0, 1],
9,
))))
.body(axum::body::Body::empty())
.unwrap();
app.router_now().oneshot(req)
};
for path in [
"/",
"/nordisk/gunnar",
"/nordisk/gunnar/blob/docs/v2/README.md",
"/nordisk/gunnar/issues?q=open%20bug",
"/sitemap.xml",
"/llms.txt",
"/robots.txt",
] {
let a = ask("gunnar.test", path).await.unwrap();
assert_eq!(
a.status(),
http::StatusCode::MOVED_PERMANENTLY,
"{path} on the bare name"
);
assert_eq!(
a.headers()[http::header::LOCATION].to_str().unwrap(),
format!("https://www.gunnar.test{path}"),
"{path}: the same path and query on www"
);
let w = ask("www.gunnar.test", path).await.unwrap();
assert!(
!w.status().is_redirection(),
"{path} on www is the console, not a redirect: {}",
w.status()
);
}
let grpc = http::Request::post("/gunnar.v1.Repos/List")
.header("host", "gunnar.test")
.header(http::header::CONTENT_TYPE, "application/grpc")
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
[127, 0, 0, 1],
9,
))))
.body(axum::body::Body::empty())
.unwrap();
assert!(
!app.router_now()
.oneshot(grpc)
.await
.unwrap()
.status()
.is_redirection(),
"a CLI's gRPC call to the bare name stays there"
);
}
/// With the console embedded, it unpacks, and a stale file of another
/// build is removed; the router builds against a (lazy) primary.
#[tokio::test]
async fn the_embedded_console_unpacks_and_its_router_builds_against_the_fed_primary() {
let dir = tempfile::tempdir().unwrap();
if FILES.is_empty() {
assert!(
unpack(dir.path()).unwrap().is_none(),
"no console embedded: nothing unpacked"
);
} else {
std::fs::create_dir_all(dir.path().join(DIST_REL)).unwrap();
std::fs::write(dir.path().join(DIST_REL).join("console-old.css"), b"x").unwrap();
let d = unpack(dir.path()).unwrap().unwrap();
assert!(d.join("index.html").is_file());
assert!(
!d.join("console-old.css").exists(),
"a file of another build is gone"
);
}
let c = Console::open(dir.path()).unwrap();
let t = Target {
control: "127.0.0.1:1".into(),
twin: None,
primary: None,
};
let _ = c.router(&t, "gunnar.test", None, None).unwrap();
// Rebuilt against another primary on the SAME store (no second redb lock).
let _ = c
.router(
&Target {
control: "127.0.0.1:2".into(),
twin: Some("127.0.0.1:3".into()),
primary: None,
},
"gunnar.test",
None,
None,
)
.unwrap();
// ★ The console replaced by another stops pumping the main it was
// built against (follow-up of recharge-main-front-cut-off): before, every
// replaced console's pump re-subscribed to its OLD main for the life of
// the process.
let mut slot = None;
let (_, first) = c.serving(&t, "gunnar.test", None, None).unwrap();
assert!(
!replace_pump(&mut slot, first.clone()),
"nothing to replace yet"
);
let (_, second) = c
.serving(
&Target {
control: "127.0.0.1:4".into(),
twin: None,
primary: None,
},
"gunnar.test",
None,
None,
)
.unwrap();
assert!(replace_pump(&mut slot, second.clone()));
assert!(
!first.pumping(),
"the replaced console still pumps its old main's events"
);
assert!(second.pumping(), "the console serving now pumps");
if !FILES.is_empty() {
use tower::ServiceExt as _;
let r = c.router(&t, "gunnar.test", None, None).unwrap();
let css = FILES
.iter()
.find(|(p, _)| p.ends_with(".css"))
.map(|(p, _)| format!("/{p}"))
.unwrap();
let req = http::Request::get(css.as_str())
.header("host", "gunnar.test")
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
[127, 0, 0, 1],
9,
))))
.body(axum::body::Body::empty())
.unwrap();
let rsp = r.oneshot(req).await.unwrap();
assert_eq!(
rsp.status(),
http::StatusCode::OK,
"the embedded console's stylesheet {css} is served"
);
}
}
}