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

nordisk/gunnar-ui

crates/gunnar-ui-server/src/program.rs

//! **The console's program**: runs the console's server, and enrolls its identity with
//! gunnar. A library module (lane GUN-PRODUCTS, 2026-10-30) so two programs run it: the
//! product `gunnar-ui` (`cargo install gunnar-ui`, the console bundle built in) and
//! `gunnar-ui-server`, the same program under the name the estate and the kits use.

use std::path::PathBuf;
use std::sync::Arc;

use anyhow::{bail, Context, Result};
use crate::app::App;
use crate::auth::{middleware, RelyingParty, Webauthn};
use crate::grpc::Gunnar;
use crate::signer::Signer;
use crate::sse::EventHub;
use crate::{api, pb, readme, serve, state};

const USAGE: &str = "\
gunnar-ui-server — the gunnar console

usage:
  gunnar-ui-server                       run the console
  gunnar-ui-server enroll [options]      register this console's identity with gunnar
  gunnar-ui-server identity              print the public key to register
  gunnar-ui-server reset-state <scope>   get back in when nobody can sign in
  gunnar-ui-server --version             crate version, git describe, source
                                         date, and which HALF this binary is —
                                         `metered` or `community`. The last word
                                         is a compiled-in fact, which is what
                                         makes it worth reading: a feature that
                                         silently did not apply looks exactly
                                         like one that did

the service, through skidbladnir (every service in the suite goes through it):
  gunnar-ui-server --env-template        print /etc/gunnar-ui.env for an operator
                                         to fill in. It writes nothing: the file
                                         holds GUNNAR_UI_ADMIN_TOKEN, and an
                                         install that can silently rewrite that
                                         is an install that can silently lock
                                         everyone out. Redirect it yourself, fill
                                         in every REPLACE-ME, then chmod 600
  gunnar-ui-server --check-env           say every reason an install would refuse
                                         this host's /etc/gunnar-ui.env, and
                                         change nothing. What a deploy asks
                                         BEFORE it swaps anything. One of those
                                         reasons is about THIS binary and not the
                                         file: a console built without `metered`
                                         has no /welcome and no /signup, so on a
                                         host whose env file turns it on would
                                         answer a returning visitor 401 — and it
                                         is refused here instead
  gunnar-ui-server --unit                render the systemd unit, writing nothing
  gunnar-ui-server --install             write + enable + start it (root). Reads
                                         /etc/gunnar-ui.env first and refuses,
                                         changing nothing, if it is missing, if a
                                         required key is unset, if a placeholder
                                         survived, if GUNNAR_UI_DIR does not go
                                         through /opt/gunnar-ui/current, or if
                                         the file enables what this binary lacks.
                                         It
                                         then REWRITES that file canonically:
                                         skidbladnir owns the EnvironmentFile=
                                         it is told about, so every key and
                                         value survives and the comments do not.
                                         The comments live in the declaration,
                                         which is where they can be kept true
  gunnar-ui-server --uninstall           stop + disable + erase releases/ and
                                         incoming/. NOT data/ (gunnar's own
                                         repositories), not the keys, not the
                                         passkey store
  gunnar-ui-server --start | --stop | --status

enroll options:
  --admin-key <path>   an SSH private key already holding a gunnar admin grant
  --name <name>        the principal to create (default: gunnar-ui)

reset-state scopes (one is required):
  --passkeys           forget the registered passkeys and void every live
                       session. The session key, the per-person revocations and
                       everybody's preferences are kept. This is the answer to
                       a passkey that stopped working: the break-glass
                       GUNNAR_UI_ADMIN_TOKEN then mints the first one again
  --all                empty the store. The console comes back up as if it had
                       never been configured

reset-state options:
  --yes                actually do it. WITHOUT this the command only PRINTS
                       what it would destroy and changes nothing
  --state <path>       the store to act on (default: $GUNNAR_UI_STATE, else
                       <data>/state.redb — the same one the console runs on)

  It will not run while the console is running: redb holds the file lock, and
  a reset the live process cannot see is a reset that appears to have done
  nothing until the next restart. Stop the unit first.

environment:
  GUNNAR_UI_BIND               where to listen           (default 127.0.0.1:3210)
  GUNNAR_UI_CONTROL            gunnar's control plane    (default 127.0.0.1:50051)
  GUNNAR_UI_IDENTITY           this console's ed25519 private key
  GUNNAR_UI_GUEST_IDENTITY     a SECOND key, enrolled in gunnar with NO grants,
                               used only for visitors with no session. Unset
                               means nothing is served anonymously. It must not
                               be the key above: that one is a gunnar admin.
  GUNNAR_UI_STATE              the passkey/session store
  GUNNAR_UI_TENANT_SEEDS       a directory of <principal_id> ed25519 private keys
                               the estate ladder laid and enrolled at pairing;
                               a logged-in person whose principal has one reads
                               with it, and nobody pastes an attestation. Unset
                               means the console mints its own, as before.
  GUNNAR_UI_DIR                the built console (trunk's dist/)
  GUNNAR_UI_TRUSTED_PROXIES    addresses whose x-forwarded-for is believed,
                               comma separated. Loopback is always believed, so
                               this is only needed when the reverse proxy is a
                               SECOND machine. From any other peer the header is
                               ignored and the socket's address is used.
  GUNNAR_UI_TYPESETTERS        how many documents may be typeset at once
                               (default: the box's parallelism). Four more may
                               queue behind each; past that a render is refused
                               with a 503 rather than queued, and one that takes
                               longer than 30s answers 504. See readme::queue.
  GUNNAR_UI_ADMIN_TOKEN        break-glass bearer token, for the first passkey
  GUNNAR_UI_WEBAUTHN_RP_ID     the passkey relying party; unset disables login
  GUNNAR_UI_WEBAUTHN_ORIGIN    the exact browser origin
  GUNNAR_UI_WEBAUTHN_RP_NAME   what the authenticator shows
  GUNNAR_UI_MONETIZE           the ONE monetize (https://monetize.ignalina.dk);
                               off loopback it must be https://. No
                               default: REQUIRED once a bearer below is
                               found, else the console refuses to start.
  GUNNAR_UI_MONETIZE_TOKEN     the bearer monetize wants. Unset means this
                               monetize seam is off and the routes for it are
                               not registered. NEVER one of the keys above.
  GUNNAR_UI_MONETIZE_TOKEN_FILE  where to read that bearer from instead
                               (default <data>/monetize-token)
  GUNNAR_UI_PRODUCT            the product id of this estate, to monetize (the
                               kit sets it: `gunnar` on every estate; the
                               estate's bearer says which site). REQUIRED once a bearer
                               is found: unset, the console refuses to start.

forge connections (for /mirrors/import). Pasting a personal access token needs
NONE of these and works on every forge, self-hosted included; they add the
OAuth button, and a forge with no application configured has no button at all:
  GUNNAR_UI_FORGE_ORIGIN       the exact browser origin the callback comes back
                               to (defaults to GUNNAR_UI_WEBAUTHN_ORIGIN).
                               Without it no OAuth application can exist,
                               because there is no redirect URI to register.
  GUNNAR_UI_FORGE_GITHUB_CLIENT_ID / _CLIENT_SECRET
  GUNNAR_UI_FORGE_GITLAB_CLIENT_ID / _CLIENT_SECRET
  GUNNAR_UI_FORGE_CODEBERG_CLIENT_ID / _CLIENT_SECRET
  GUNNAR_UI_FORGE_GITLAB_BASE      a self-managed GitLab's site root
  GUNNAR_UI_FORGE_CODEBERG_BASE    a self-hosted Forgejo/Gitea's site root
  GUNNAR_UI_FORGE_ALLOW_PRIVATE_BASE
                               let an admin point /api/forges/connect at a
                               private, loopback or link-local address, and at
                               plain http. Needed when the forge really is on
                               this network; it turns a server-side request
                               forgery guard OFF, so set it only then.

  A forge token is NEVER read from the environment and never written to disk:
  it belongs to a person, arrives from a browser, lives in this process keyed
  to their session, and is handed to Mirror.SetCredential once.
";

/// The usage, naming the program it is printed by.
fn usage(program: &str) -> String {
    USAGE.replace("gunnar-ui-server", program)
}

/// What the console logs when `RUST_LOG` says nothing. The shared relay's audit line is a
/// metered build's: an open build links no relay and names none (`open_build_speaks_no_money`).
#[cfg(feature = "metered")]
const DEFAULT_LOG_FILTER: &str = "gunnar_ui_server=info,gunnar_ui::audit=info,monetize_site_buy::audit=info,tower_http=warn";
#[cfg(not(feature = "metered"))]
const DEFAULT_LOG_FILTER: &str = "gunnar_ui_server=info,gunnar_ui::audit=info,tower_http=warn";

/// **Run the program** as `program` (the name its usage and its errors print). What
/// both `main`s call; the argument list is the process's own.
pub async fn main(program: &str) -> Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| DEFAULT_LOG_FILTER.into()),
        )
        .init();

    let mut args = std::env::args().skip(1);
    match args.next().as_deref() {
        None => run().await,
        Some("enroll") => enroll(args.collect()).await,
        Some("identity") => identity(),
        Some("reset-state") => reset_state(args.collect()),
        Some(
            verb @ ("--env-template" | "--check-env" | "--unit" | "--install" | "--uninstall"
            | "--start" | "--stop" | "--status"),
        ) => service(verb),
        Some("-V" | "--version" | "version") => {
            // The line names the program that prints it (`gunnar-ui` or `gunnar-ui-server`).
            println!("{}", crate::VERSION_LINE.replacen("gunnar-ui-server", program, 1));
            Ok(())
        }
        Some("-h" | "--help" | "help") => {
            print!("{}", usage(program));
            Ok(())
        }
        Some(other) => {
            bail!("unknown command {other:?}\n\n{}", usage(program));
        }
    }
}

/// Where the console keeps its key and its state.
fn data_dir() -> PathBuf {
    std::env::var_os("GUNNAR_UI_DATA")
        .map(PathBuf::from)
        .or_else(|| {
            std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share/gunnar-ui"))
        })
        .unwrap_or_else(|| PathBuf::from("."))
}

fn env_path(key: &str, default: impl FnOnce() -> PathBuf) -> PathBuf {
    std::env::var_os(key)
        .map(PathBuf::from)
        .unwrap_or_else(default)
}

fn identity_path() -> PathBuf {
    env_path("GUNNAR_UI_IDENTITY", || data_dir().join("id"))
}

/// Where the **guest** key lives: the identity anonymous traffic is served as.
fn guest_identity_path() -> PathBuf {
    env_path("GUNNAR_UI_GUEST_IDENTITY", || data_dir().join("guest"))
}

/// **The monetize bearer, or nothing** — and nothing is a supported state.
///
/// The ladder is monetize's own, minus the flag this binary does not have:
/// `$GUNNAR_UI_MONETIZE_TOKEN` > `<data>/monetize-token` (trimmed; an empty
/// file falls through) > absent. It is the same shape `monetize-server` and
/// `monetize-ui-server` resolve theirs with, so an operator who has set one up
/// already knows where to put this.
///
/// **Absence is not an error**, exactly as a missing guest key is not: the
/// console starts, and the "more room" routes are not registered. What is NOT
/// supported is falling back to another credential. This process holds a gunnar
/// admin key and a grantless guest key, and neither is a monetize bearer;
/// presenting either would be the confused deputy that
/// [`crate::app::App::guest`] exists to describe, with money on the
/// other side of it.
#[cfg(feature = "metered")]
fn monetize_token() -> Option<String> {
    if let Some(from_env) = std::env::var("GUNNAR_UI_MONETIZE_TOKEN")
        .ok()
        .map(|t| t.trim().to_owned())
        .filter(|t| !t.is_empty())
    {
        return Some(from_env);
    }
    let path = env_path("GUNNAR_UI_MONETIZE_TOKEN_FILE", || {
        data_dir().join("monetize-token")
    });
    std::fs::read_to_string(path)
        .ok()
        .map(|t| t.trim().to_owned())
        .filter(|t| !t.is_empty())
}

/// Load the guest signer, or explain in one line why the console will serve
/// nothing anonymously.
///
/// **Absence is not an error.** A console with no guest key is the console
/// every deployment had until this feature existed, and it must still start:
/// the anonymous surface simply is not there, `/login/repos` answers `[]`, and
/// [`middleware::require_auth`] goes on redirecting a visitor to the login
/// page. Making this fatal would mean an upgrade that refuses to boot on every
/// existing installation in exchange for a feature nobody had asked that
/// installation to turn on.
///
/// **A key that is present but unreadable IS an error**, and deliberately so.
/// The two states are told apart because they mean opposite things: "no guest"
/// is a choice, and "a guest key that will not load" is a broken deployment
/// whose symptom would otherwise be a login page that silently lists nothing.
///
/// # The key this must NOT be
///
/// The console's own. It is a separate path so that it can be a separate
/// gunnar principal — one enrolled with `gunnar access add-principal` and given
/// **no grants at all**. gunnar then narrows everything it asks for to what a
/// stranger may see, and the console's admin credential never touches an
/// anonymous request. Pointing this at the console's own key would restore
/// exactly the confused deputy the split exists to prevent; `routes::public_repos`
/// carries a visibility filter as a second wall against that specific mistake.
fn load_guest_signer() -> Result<Option<Arc<Signer>>> {
    let path = guest_identity_path();
    if !path.exists() {
        tracing::info!(
            path = %path.display(),
            "no guest identity, so this console serves nothing to a visitor without a session. \
             To publish public repositories: make a key (`ssh-keygen -t ed25519 -N \"\" -f \
             <path>`), enrol it in gunnar as a principal with NO grants, and point \
             GUNNAR_UI_GUEST_IDENTITY at it."
        );
        return Ok(None);
    }
    let signer = Signer::load(&path)
        .with_context(|| format!("the guest identity at {} cannot be read", path.display()))?;
    Ok(Some(Arc::new(signer)))
}

fn load_signer() -> Result<Arc<Signer>> {
    let path = identity_path();
    let signer = Signer::load(&path).with_context(|| {
        format!(
            "the console needs an ed25519 identity at {}; make one with \
             `ssh-keygen -t ed25519 -N \"\" -f {}`",
            path.display(),
            path.display()
        )
    })?;
    Ok(Arc::new(signer))
}

/// `gunnar-ui-server identity`
fn identity() -> Result<()> {
    let signer = load_signer()?;
    println!("{}", signer.public_key_openssh());
    eprintln!("fingerprint: {}", signer.fingerprint());
    Ok(())
}

/// ★ **`gunnar-ui-server reset-state <scope> [--yes]`**
///
/// A console authenticates with passkeys, and a passkey is bound to the
/// relying party it was registered against. Get `GUNNAR_UI_WEBAUTHN_RP_ID`
/// wrong when the first one is registered, or lose the authenticator, and the
/// box is up, healthy, serving, and shut. The recovery was `rm state.redb`;
/// [`crate::reset`] says at length why that was worse than it
/// looked.
///
/// **It prints and stops unless it is given `--yes`.** A destructive verb on a
/// live box must not be reachable by half-typing it, and the dry run is also
/// the only way to see which scope is the right one — the plan lists the keys
/// it would KEEP beside the ones it would drop.
///
/// **It refuses to run while the console is running**, and that refusal comes
/// for free: redb takes a file lock, so `StateStore::open` fails. The message
/// says which of the two it is, because "cannot open the state store" on a box
/// where the console is obviously fine reads as a corrupt file.
fn reset_state(args: Vec<String>) -> Result<()> {
    use crate::reset::{self, Scope};

    let mut scope: Option<Scope> = None;
    let mut yes = false;
    let mut path: Option<PathBuf> = None;
    let mut rest = args.into_iter();
    while let Some(arg) = rest.next() {
        match arg.as_str() {
            "--passkeys" | "--all" => {
                let picked = if arg == "--passkeys" {
                    Scope::Passkeys
                } else {
                    Scope::Everything
                };
                // Two scopes on one command line is an operator who meant one
                // of them, and guessing which would be guessing about how much
                // to destroy.
                if scope.is_some_and(|had| had != picked) {
                    bail!("--passkeys and --all are two different resets; pick one");
                }
                scope = Some(picked);
            }
            "--yes" => yes = true,
            "--state" => {
                path = Some(PathBuf::from(rest.next().context("--state needs a path")?));
            }
            other => bail!("unknown argument {other:?}\n\n{USAGE}"),
        }
    }
    let Some(scope) = scope else {
        bail!(
            "reset-state needs a scope: --passkeys forgets the first-admin mark and the \
             console's own identity from before the identity core; --all empties the store.\n\n{USAGE}"
        );
    };

    let path = path.unwrap_or_else(|| env_path("GUNNAR_UI_STATE", || data_dir().join("state.redb")));
    if !path.exists() {
        bail!(
            "there is no state store at {}. Nothing has been changed — an absent store is \
             already reset, and creating one here would only make it look like something \
             happened.",
            path.display()
        );
    }
    let store = crate::state::StateStore::open(&path).with_context(|| {
        format!(
            "cannot open {}. If the console is running, stop it first: redb holds the file \
             lock, and a reset the live process cannot see is a reset that appears to have \
             done nothing until the next restart",
            path.display()
        )
    })?;

    let plan = reset::plan(&store, scope).map_err(anyhow::Error::msg)?;
    println!("store     {}", path.display());
    println!("scope     {}", scope.flag());
    println!(
        "passkeys  {}",
        match plan.passkeys {
            // Never "0" for a blob that did not parse: an operator reads a
            // zero as the reason they cannot get in, and would reset a store
            // whose real problem is a corrupt entry.
            None => "unreadable — the stored entry is not a list of credentials".to_owned(),
            Some(n) => n.to_string(),
        }
    );

    println!("\n  forgetting  {} key(s):", plan.dropping.len());
    for key in &plan.dropping {
        println!("    {key}");
    }
    if !plan.keeping.is_empty() {
        println!("\n  keeping     {} key(s):", plan.keeping.len());
        for key in &plan.keeping {
            let why = if reset::is_session_floor(key) {
                "  (a revocation somebody made on purpose)"
            } else {
                ""
            };
            println!("    {key}{why}");
        }
    }
    if plan.prefs > 0 || plan.cache > 0 {
        println!(
            "\n  and        {} remembered setting(s), {} cached render(s)",
            plan.prefs, plan.cache
        );
    }
    if matches!(scope, Scope::Passkeys) {
        println!("\n  the sessions and the passkeys are gunnar's identity core's: a reset here");
        println!("  ends none of them (gunnar's `Logout`/`EndAllSessions` do).");
    }

    if !yes {
        println!("\nNothing has been changed. Add --yes to do it.");
        return Ok(());
    }

    let done = reset::apply(&store, scope).map_err(anyhow::Error::msg)?;
    println!("\nforgot {} key(s)", done.dropped.len());
    if let Some(epoch) = done.epoch {
        println!("session epoch now {epoch}: every outstanding cookie is void");
    }
    if done.prefs > 0 || done.cache > 0 {
        println!(
            "emptied {} setting(s) and {} cached render(s)",
            done.prefs, done.cache
        );
    }
    println!(
        "\nRestart the console; GUNNAR_UI_ADMIN_TOKEN is offered again for the first \
         admin's passkey (gunnar's fed first-login link decides whether it still opens)."
    );
    Ok(())
}

/// **The service verbs**, every one of them through skidbladnir.
///
/// System scope only, and deliberately: this unit runs as root, reads a mode
/// 0600 file under `/etc`, and is reached through the TLS front. A `systemctl
/// --user` copy of it would be a different service wearing the same name.
fn service(verb: &str) -> Result<()> {
    use crate::{envfile, lifecycle};
    use skidbladnir::systemd::Scope;

    // Writes nothing, reads nothing, needs no root — so it is answered before
    // anything below goes looking at the host.
    if verb == "--env-template" {
        print!("{}", envfile::template());
        return Ok(());
    }

    // The host's own file, read once. The bind address the health probe dials
    // comes out of it — one number, not a second copy of it — and so does
    // everything `--install` writes back.
    let text = std::fs::read_to_string(envfile::PATH);
    let entries = text.as_deref().map(envfile::parse).unwrap_or_default();
    let settings = lifecycle::Settings::from_env_file(&entries);
    let scope = Scope::System;

    // **`ExecStart=` is the constant, never `current_exe()`.** monetize derives
    // it from the running binary; here that would be actively wrong. An install
    // run out of `releases/<tag>/` — which is where the deploy runs it from —
    // would write an `ExecStart` naming that release, bypassing `current`; the
    // next swap would then move the bundle and leave the old server serving it.
    // Both halves go through the symlink or they drift.
    let exec = std::path::Path::new(lifecycle::EXEC);
    // Only for the two verbs that put that path into a file. `--check-env` is
    // run from the staging directory on every single deploy, and a line that
    // prints every time is a line nobody reads the one time it matters.
    if matches!(verb, "--unit" | "--install") {
        if let Ok(running) = std::env::current_exe() {
            if std::fs::canonicalize(&running).ok() != std::fs::canonicalize(exec).ok() {
                eprintln!(
                    "gunnar-ui: this is {}, and the unit will run {}. The unit always names \
                     the symlink; this line only matters if the swap has not happened yet.",
                    running.display(),
                    exec.display()
                );
            }
        }
    }

    match verb {
        // Every reason an install would refuse, written out, having changed
        // nothing. This is what a deploy asks BEFORE it swaps anything: getting
        // the answer afterwards means the console is already down.
        "--check-env" => {
            // `crate::METERED` is the one input here that is not the
            // host's: it is what THIS binary was compiled as. A console that
            // cannot sell, standing on a host configured to sell, is the
            // 2026-09-12 failure, and the staged artifact answering for itself
            // is the only form of that check that cannot be wrong — see
            // `lifecycle::Refusal::ConfiguredToSellButCannot`.
            let bad = lifecycle::refusals(text.is_ok(), &entries, crate::METERED);
            for r in &bad {
                eprintln!("gunnar-ui: {r}");
            }
            if bad.is_empty() {
                println!("{} satisfies the declaration", envfile::PATH);
                Ok(())
            } else {
                bail!(
                    "{} is not installable ({} problem(s) above)",
                    envfile::PATH,
                    bad.len()
                )
            }
        }
        "--unit" => {
            print!("{}", lifecycle::unit_text(exec, &settings, scope, &entries));
            Ok(())
        }
        "--install" => {
            if text.is_err() {
                bail!(
                    "{}",
                    lifecycle::Refusal::NoEnvFile(envfile::PATH.to_string())
                );
            }
            lifecycle::install(exec, &settings, scope, &entries)?;
            println!(
                "installed {} — listening on {}, control plane {}",
                lifecycle::UNIT,
                settings.bind,
                entries
                    .iter()
                    .find(|(k, _)| k == "GUNNAR_UI_CONTROL")
                    .map(|(_, v)| v.as_str())
                    .unwrap_or("unset"),
            );
            Ok(())
        }
        "--uninstall" => {
            let report = lifecycle::lifecycle(exec, &settings, scope).uninstall()?;
            println!("{report:?}");
            Ok(())
        }
        "--start" => lifecycle::lifecycle(exec, &settings, scope).start(),
        "--stop" => lifecycle::lifecycle(exec, &settings, scope).stop(),
        "--status" => {
            let lc = lifecycle::lifecycle(exec, &settings, scope);
            println!("active={} healthy={}", lc.status()?, lc.health()?);
            Ok(())
        }
        other => bail!("unknown service verb {other:?}\n\n{USAGE}"),
    }
}

/// `gunnar-ui-server`
async fn run() -> Result<()> {
    let bind = std::env::var("GUNNAR_UI_BIND").unwrap_or_else(|_| "127.0.0.1:3210".to_owned());
    let control =
        std::env::var("GUNNAR_UI_CONTROL").unwrap_or_else(|_| "127.0.0.1:50051".to_owned());

    let signer = load_signer()?;
    let gunnar = Gunnar::connect_lazy(&control, signer.clone()).map_err(anyhow::Error::msg)?;

    // A SECOND channel to the same gunnar, signed by a different key. Not a
    // second connection pool for its own sake: the credential is what gunnar
    // authorises against, and there is no way to send one call as somebody else
    // on a channel whose interceptor holds one signer. Two identities means two
    // channels.
    let guest_signer = load_guest_signer()?;
    let guest = match guest_signer.clone() {
        Some(signer) => Some(Gunnar::connect_lazy(&control, signer).map_err(anyhow::Error::msg)?),
        None => None,
    };

    // The twin's control plane, for the one-step restore. Unset = no action.
    let twin = match std::env::var(api::twin::TWIN_ENV).map(|v| v.trim().to_owned()) {
        Ok(address) if !address.is_empty() => {
            Some(api::twin::Twin::connect(&gunnar, &address).map_err(anyhow::Error::msg)?)
        }
        _ => None,
    };

    let state_path = env_path("GUNNAR_UI_STATE", || data_dir().join("state.redb"));
    if let Some(parent) = state_path.parent() {
        // `create_dir_all` takes no mode, so this used to be the umask's — and
        // a directory anybody can walk is how a file with a good mode is found.
        // See `state::PRIVATE_DIR` for what is in the file.
        state::create_private_dir(parent)
            .with_context(|| format!("cannot create {}", parent.display()))?;
    }
    let store = state::StateStore::open(&state_path)
        .with_context(|| format!("cannot open {}", state_path.display()))?;

    let webauthn = match RelyingParty::from_env().map_err(anyhow::Error::msg)? {
        Some(rp) => Some(Arc::new(
            Webauthn::new(rp, store.clone()).map_err(anyhow::Error::msg)?,
        )),
        None => {
            tracing::warn!(
                "GUNNAR_UI_WEBAUTHN_RP_ID is unset, so passkey login is off and the console is \
                 open to anyone who can reach {bind}"
            );
            None
        }
    };
    let admin_token = std::env::var("GUNNAR_UI_ADMIN_TOKEN")
        .ok()
        .map(|t| t.trim().to_owned())
        .filter(|t| !t.is_empty());

    // **A guessable break-glass token is refused here, not warned about.**
    // Until the first admin passkey exists this value IS the console's admin,
    // it is offered as a bearer on every request, and nothing required it to be
    // more than a word in an environment file. Refused at start for the reason
    // `open_console_is_confined` refuses a wide bind: a warning is a log line
    // on a machine that came up fine. See `middleware::weak_admin_token` for
    // why it is a floor rather than a measurement.
    if let Some(weakness) = admin_token
        .as_deref()
        .and_then(middleware::weak_admin_token)
    {
        bail!(
            "GUNNAR_UI_ADMIN_TOKEN {weakness}. It is this console's admin credential until the \
             first admin passkey is enrolled, so make it 32 characters of randomness — a pair of \
             UUIDs, or 24 random bytes in base64 — or leave it unset and enroll the first \
             passkey through an invite from another console."
        );
    }

    // With neither of them, `require_auth` has nothing to check and skips the
    // role floor entirely: every request runs unauthenticated, which in a
    // console that holds a server-wide admin grant means every request runs as
    // admin. That is the development posture and it stays supported — on
    // loopback. Anywhere else it is refused here rather than warned about,
    // because the warning above is a log line on a machine that came up fine.
    if webauthn.is_none() && admin_token.is_none() && !middleware::open_console_is_confined(&bind) {
        bail!(
            "GUNNAR_UI_BIND is {bind:?} and neither GUNNAR_UI_WEBAUTHN_RP_ID nor \
             GUNNAR_UI_ADMIN_TOKEN is set, so this console would serve an unauthenticated admin \
             session to anyone who can reach that address. Set GUNNAR_UI_WEBAUTHN_RP_ID (and \
             GUNNAR_UI_WEBAUTHN_ORIGIN) to require a passkey, or bind 127.0.0.1 for development."
        );
    }

    // The third credential. `GUNNAR_UI_MONETIZE` names the ONE monetize
    // (OM-10): no estate runs a monetize of its own, so there is no loopback
    // to default to. It is only read when a TOKEN was found — a console that
    // sells and names no monetize refuses to start rather than dial nothing.
    #[cfg(feature = "metered")]
    let monetize = match monetize_token() {
        Some(token) => {
            let addr = crate::monetize::monetize_named(
                std::env::var(crate::monetize::ENV_MONETIZE).ok().as_deref(),
            )
            .map_err(anyhow::Error::msg)?;
            // OM-9: no default product — a console that sells names its own.
            let product = crate::monetize::product_named(
                std::env::var(crate::monetize::ENV_PRODUCT).ok().as_deref(),
            )
            .map_err(anyhow::Error::msg)
            .context("GUNNAR_UI_MONETIZE_TOKEN is set, so this console sells, but its product is not named")?;
            let handle =
                crate::monetize::Monetize::connect_lazy(&addr, &token, &product)
                    .map_err(anyhow::Error::msg)
                    .context(
                        "GUNNAR_UI_MONETIZE_TOKEN is set but the monetize channel cannot be built",
                    )?;
            tracing::info!(monetize = %addr, product = %handle.product(), "a tenant on this console can ask for more room");
            Some(handle)
        }
        None => {
            tracing::info!(
                "no monetize bearer, so this console sells nothing: set \
                 GUNNAR_UI_MONETIZE_TOKEN (or put the token in <data>/monetize-token) to turn \
                 the panel on. Without it the routes are not registered at all."
            );
            None
        }
    };

    // **The fourth credential's home, empty at start.** Nothing is read from
    // the environment here: a forge token belongs to a PERSON and arrives from
    // a browser — pasted, or fetched by the OAuth callback — so there is
    // nothing an operator could put in a file. It is in memory only, which
    // means a restart forgets every connection; that is the right trade for a
    // long-lived bearer credential to somebody's whole account, and
    // reconnecting costs one paste.
    let forges = Arc::new(crate::forges::ForgeSessions::new());
    // The OAuth applications, if any. Absent is the ordinary state: the token
    // door works on every forge, self-hosted included, and needs no
    // registration. A forge with no application here has no button at all.
    let forge_apps = Arc::new(crate::forges::ForgeApps::from_env());
    tracing::info!(
        oauth = ?forge_apps.doors(),
        "forge connections: a token can be pasted for github, codeberg (and any other \
         Forgejo/Gitea) and gitlab; the forges listed here also have an OAuth button"
    );

    let ui_dir = std::env::var_os("GUNNAR_UI_DIR").map(PathBuf::from);
    let reviewers = Arc::new(crate::reviewer::ReviewerKeys::new(
        control.clone(),
        store.clone(),
    ));
    // The same endpoint and the same store as the reviewer keys, and a
    // different principal: see `tenant` for why the two are not one type.
    let tenants = Arc::new(
        crate::tenant::TenantKeys::new(control.clone(), store.clone())
            // The keys the ladder laid at `pair`, one per operator principal,
            // already enrolled and vouched for — see `tenant::TenantKeys::seeds`.
            .with_seeds(std::env::var_os("GUNNAR_UI_TENANT_SEEDS").map(PathBuf::from)),
    );
    let state = Arc::new(App {
        webauthn,
        admin_token,
        events: EventHub::spawn(gunnar.clone()),
        #[cfg(feature = "metered")]
        monetize,
        gunnar,
        twin,
        guest,
        forges,
        forge_apps,
        imports: Arc::new(crate::api::mirror_gate::Imports::from_env()),
        reviewers,
        tenants,
        ui_dir,
        caches: api::Caches::default(),
        objects: Default::default(),
        // The one construction site that hands the README cache a store. Every
        // other one is a test that is not about caching, and a store-less cache
        // renders every time rather than pretending to be fast.
        readmes: Arc::new(readme::Cache::new(Some(store.clone()))),
    });

    tracing::info!(
        bind = %bind,
        control = %control,
        fingerprint = %signer.fingerprint(),
        passkey = state.passkey_enabled(),
        // Both identities on the start line, because "which key answered a
        // request" is the first question an operator asks when a public
        // repository does not show up — or when a private one does.
        anonymous = state.anonymous_enabled(),
        guest_fingerprint = guest_signer.as_ref().map(|s| s.fingerprint()).unwrap_or("(none)"),
        "the console is starting"
    );

    // **RA5-13**: the per-person keys this console no longer signs with (every
    // tenant key and ladder seed since the identity core) are revoked at gunnar
    // and forgotten; one gunnar would not revoke yet is tried again later.
    {
        let state = Arc::clone(&state);
        tokio::spawn(async move {
            for _ in 0..12 {
                if state.tenants.sweep(&state.gunnar).await.kept == 0 {
                    return;
                }
                tokio::time::sleep(std::time::Duration::from_secs(300)).await;
            }
        });
    }

    let listener = tokio::net::TcpListener::bind(&bind)
        .await
        .with_context(|| format!("cannot listen on {bind}"))?;
    // `into_make_service_with_connect_info` rather than the plain router: it is
    // what puts the peer address in the request extensions, and
    // `security::ClientIp` is what reads it. Without it the one unauthenticated
    // route this console has — the paid-signup status poll — would have every
    // caller in the world sharing a single rate-limit bucket. Behind a reverse
    // proxy the peer is the proxy, which is exactly why `ClientIp` prefers
    // `x-forwarded-for`; this is the answer where there is no proxy.
    axum::serve(
        console_listener(listener),
        serve::router(state).into_make_service_with_connect_info::<std::net::SocketAddr>(),
    )
    .with_graceful_shutdown(shutdown())
    .await
    .context("the server stopped")
}

/// **The console's listener, each accepted connection with `TCP_NODELAY`** (GUN-PERF,
/// 2026-10-31).
///
/// `axum::serve` over a plain `TcpListener` leaves Nagle on, and a response that hyper
/// writes in two pieces (the head, then the body) waits for the client's delayed ACK
/// before its second piece: about 40 ms. The prerendered repository page answered in
/// 41 ms at the median and 200 pages a second at c=8, for 0.1 ms of work.
pub(crate) fn console_listener(
    listener: tokio::net::TcpListener,
) -> axum::serve::TapIo<tokio::net::TcpListener, fn(&mut tokio::net::TcpStream)> {
    use axum::serve::ListenerExt as _;
    listener.tap_io(console_connection as fn(&mut tokio::net::TcpStream))
}

/// What the console does to each connection it accepts: `TCP_NODELAY`. A socket that
/// refuses the option still serves, only slower.
fn console_connection(tcp: &mut tokio::net::TcpStream) {
    if let Err(err) = tcp.set_nodelay(true) {
        tracing::debug!(%err, "set_nodelay failed on a console connection");
    }
}

async fn shutdown() {
    let _ = tokio::signal::ctrl_c().await;
    tracing::info!("stopping");
}

/// `gunnar-ui-server enroll`
///
/// Three authenticated calls made with a human's admin key. It has to be done
/// this way: `gunnar control user add` hardcodes the USER kind, and the offline
/// CLI that can set MACHINE writes the control repository directly and needs the
/// server stopped.
async fn enroll(args: Vec<String>) -> Result<()> {
    let mut admin_key = None;
    let mut name = "gunnar-ui".to_owned();
    let mut rest = args.into_iter();
    while let Some(arg) = rest.next() {
        match arg.as_str() {
            "--admin-key" => admin_key = rest.next(),
            "--name" => name = rest.next().context("--name needs a value")?,
            other => bail!("unknown option {other:?}\n\n{USAGE}"),
        }
    }
    let admin_key = admin_key.context(
        "--admin-key is required: enrolling needs a key that already holds a gunnar admin grant",
    )?;

    let console = load_signer()?;
    let admin = Arc::new(
        Signer::load(&admin_key)
            .with_context(|| format!("cannot use {admin_key} as an admin identity"))?,
    );
    let control =
        std::env::var("GUNNAR_UI_CONTROL").unwrap_or_else(|_| "127.0.0.1:50051".to_owned());
    let gunnar = Gunnar::connect_lazy(&control, admin).map_err(anyhow::Error::msg)?;

    // gunnar creates an account namespace named after the principal, and
    // refuses the whole call if that namespace exists — so say which of the two
    // it was rather than passing the raw refusal on.
    let principal = match gunnar
        .access()
        .add_principal(pb::AddPrincipalRequest {
            name: name.clone(),
            kind: pb::PrincipalKind::Machine as i32,
            // Empty: the account namespace is the name, which is the collision
            // the `AlreadyExists` arm below explains.
            id: String::new(),
        })
        .await
    {
        Ok(response) => response.into_inner().principal.unwrap_or_default(),
        Err(status) if status.code() == tonic::Code::AlreadyExists => {
            bail!(
                "gunnar already has a principal or namespace called {name:?}: {}\n\
                 Pass --name to pick another, or remove the existing one first.",
                status.message()
            );
        }
        Err(status) => bail!("gunnar refused to create the principal: {status}"),
    };

    gunnar
        .access()
        .add_key(pb::AddKeyRequest {
            principal_id: principal.id.clone(),
            openssh_public_key: console.public_key_openssh(),
            comment: "gunnar-ui console".to_owned(),
            expires_at_unix_ms: None,
            // The principal was created two statements ago and holds no key at
            // all, which is the creation case gunnar exempts from attestation.
            attestation: None,
        })
        .await
        .map_err(|s| anyhow::anyhow!("gunnar refused the console's key: {s}"))?;

    // Server-wide admin: both fields empty. The console reads and writes across
    // every namespace, and gunnar has no narrower scope that covers that.
    gunnar
        .access()
        .grant(pb::GrantRequest {
            principal_id: principal.id.clone(),
            namespace: String::new(),
            repo: String::new(),
            level: pb::GrantLevel::Admin as i32,
        })
        .await
        .map_err(|s| anyhow::anyhow!("gunnar refused the grant: {s}"))?;

    println!("enrolled {name} ({})", principal.id);
    println!("fingerprint {}", console.fingerprint());
    println!("server-wide admin granted; the console can now sign its own calls");
    Ok(())
}

#[cfg(test)]
mod console_listener_tests {
    /// GUN-PERF: a connection the console accepts has `TCP_NODELAY` set.
    #[tokio::test]
    async fn the_console_accepts_its_connections_with_nodelay() {
        use axum::serve::Listener as _;
        let bound = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
        let at = bound.local_addr().expect("addr");
        let mut listener = super::console_listener(bound);
        let _client = tokio::net::TcpStream::connect(at).await.expect("connect");
        let (accepted, _) = listener.accept().await;
        assert!(
            accepted.nodelay().expect("read TCP_NODELAY"),
            "the console accepted a connection without TCP_NODELAY"
        );
    }
}