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