nordisk/gunnar
crates/gunnar-cli/src/control.rs
//! **`gunnar control`: the client half of the gRPC control plane.**
//!
//! The four steps of a first boot, and the whole of what this module is for:
//!
//! ```text
//! 1 gunnar control bootstrap alice NO auth. Creates the first user,
//! who is gunnar-admin. Closes the
//! window permanently.
//! 2 …and you are LOGGED OUT There was never a session. Step 1
//! holds no state a later call can
//! reuse; see below.
//! 3 gunnar control login Authenticates with the ssh key.
//! 4 gunnar control user add bob … Authenticated. May pass gunnar-admin on.
//! ```
//!
//! # Step 2 is enforced by not existing
//!
//! **This CLI has no session, no token cache and no state directory.** There is
//! nothing on disk for step 1 to leave behind and nothing for step 4 to pick
//! up. Every authenticated call in this file mints a *fresh, single-use*
//! `sshsig` credential from the private key on disk, signs the server's
//! challenge with it, and throws it away — the server's replay guard refuses a
//! second use of the same nonce anyway.
//!
//! That is a stronger promise than "the client remembers to log out": there is
//! no code path that could keep a privileged session, so step 4 with no key
//! fails for the same reason it would fail on a fresh machine.
//! `tests/control_cli.rs::the_bootstrap_verb_leaves_no_session_behind` asserts
//! it against the filesystem rather than against this paragraph.
//!
//! # The credential is the server's own, not a second one
//!
//! [`gunnar_grpc::sign_credential`] is the *same function* the server verifies
//! against, in the same file that defines the wire format. This module invents
//! no token type and holds no shared secret (one implementation).
//!
//! # Two failures that must never be confusable
//!
//! *"this server is already enrolled"* and *"this server could not be reached"*
//! look identical if both are reported as a bare error, and reading the second
//! as the first is how a closed door gets treated as an open one. They are
//! separated here by construction: a transport failure never reaches the RPC
//! result, and [`Reach`] turns each into its own sentence.
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use gunnar_proto::access_client::AccessClient;
use gunnar_proto::accounts_client::AccountsClient;
use gunnar_proto::admin_client::AdminClient;
use gunnar_proto::bootstrap_client::BootstrapClient;
use gunnar_proto::forge_client::ForgeClient;
use gunnar_proto::mirror_client::MirrorClient;
use gunnar_proto::policy_client::PolicyClient;
use gunnar_proto::pull_requests_client::PullRequestsClient;
use gunnar_proto::refs_client::RefsClient;
use gunnar_proto::repos_client::ReposClient;
use gunnar_proto::usage_client::UsageClient;
use gunnar_proto::webhooks_client::WebhooksClient;
use tonic::transport::Channel;
use crate::cli::{
ControlAccountCommand, ControlCommand, ControlGrantCommand, ControlGroupCommand,
ControlGroupMemberCommand, ControlGrowCommand, ControlNamespaceCommand, ControlPolicyCommand,
ControlReplicationCommand, ControlRepoCommand, ControlUserCommand, ControlWebhookCommand,
};
/// `gunnar control clan …` (2026-10-05).
mod clan;
/// The forge's online verbs: `whoami`, `pr`, `issue`, `review`, `events`.
mod forge;
/// The pull mirror, and the forge listing that feeds it.
///
/// Its own module rather than a twenty-first `fn` in this file, and the reason
/// is not length: `control mirror import` is the ONE control verb that links
/// [`gunnar_forge`] — an outbound-HTTPS REST client holding somebody's
/// personal access token — and putting it behind its own `mod` keeps the one
/// `use` of that crate in one file, where a reviewer asking *"what in
/// gunnar-cli can reach github.com"* gets a one-file answer. Everything else
/// it needs (`connect`, `authed`, `private_key`, `explain`, `repo_path`) is
/// this file's, reused rather than re-derived (one implementation), which is
/// why those are `pub(crate)`.
pub mod mirror;
/// The WireGuard tunnel address, handed over at bootstrap.
mod tunnel;
/// The twin's verbs: follow, restore, status.
mod twin;
/// Where the control plane listens unless told otherwise.
///
/// The definition moved to [`gunnar_common::DEFAULT_CONTROL_ADDR`] with `cli.rs`
/// — the shared command line names it as the default of every `--at` — and is
/// re-exported here so `crate::control::DEFAULT_CONTROL_ADDR` still resolves.
pub use gunnar_common::DEFAULT_CONTROL_ADDR;
/// One tokio runtime for one command. `gunnar` is not an async program; this is
/// the only place it needs a reactor.
pub(crate) fn runtime() -> Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("building a runtime for the control-plane client")
}
/// Connect, and say **unreachable** when it is unreachable.
///
/// Never folded into the RPC's own error: a control plane that is not there and
/// a control plane that refuses are different facts, and collapsing them is the
/// confusion this whole design is written against.
/// The client half of the control plane's mutual TLS.
///
/// Read from the environment rather than from a flag on each subcommand, for
/// the same reason `--at` carries `env = "GUNNAR_CONTROL_ADDR"`: this is
/// per-*deployment* configuration, not per-command, and repeating three paths
/// on nineteen subcommands is how one of them ends up spelled differently.
///
/// All three or none. Two out of three is a misconfiguration that would
/// otherwise surface as a handshake failure with no hint about which piece is
/// missing, so it is refused by name here.
struct ClientTrust {
ca: Vec<u8>,
cert: Vec<u8>,
key: Vec<u8>,
}
/// The environment variables `gunnar control bootstrap` tells an operator to set.
const CLIENT_CERT_ENV: &str = "GUNNAR_CLIENT_CERT";
const CLIENT_KEY_ENV: &str = "GUNNAR_CLIENT_KEY";
const CLIENT_CA_ENV: &str = "GUNNAR_CA";
impl ClientTrust {
fn from_env() -> Result<Option<ClientTrust>> {
let read = |name: &str| -> Option<std::ffi::OsString> {
std::env::var_os(name).filter(|value| !value.is_empty())
};
let (cert, key, ca) = (
read(CLIENT_CERT_ENV),
read(CLIENT_KEY_ENV),
read(CLIENT_CA_ENV),
);
if cert.is_none() && key.is_none() && ca.is_none() {
return Ok(None);
}
let want = |value: Option<std::ffi::OsString>, name: &str| -> Result<Vec<u8>> {
let path = value.ok_or_else(|| {
anyhow::anyhow!(
"mutual TLS to the control plane needs all three of {CLIENT_CERT_ENV}, \
{CLIENT_KEY_ENV} and {CLIENT_CA_ENV}; {name} is not set. \
`gunnar control bootstrap` writes all three files."
)
})?;
let path = PathBuf::from(path);
std::fs::read(&path).with_context(|| format!("reading {}", path.display()))
};
Ok(Some(ClientTrust {
cert: want(cert, CLIENT_CERT_ENV)?,
key: want(key, CLIENT_KEY_ENV)?,
ca: want(ca, CLIENT_CA_ENV)?,
}))
}
}
/// **A site's own CA** (a PEM file), trusted INSTEAD of the public roots for an
/// `https://` address with no client certificate: the airgap install whose
/// front serves a certificate its own CA issued, a bench's.
pub(crate) const SITE_CA_ENV: &str = "GUNNAR_SITE_CA";
/// **TLS to a site's front** (no client certificate): its own CA when one is
/// given, else the compiled-in Mozilla roots (webpki-roots; the system store is
/// not read, so every machine checks a site alike).
pub(crate) fn site_tls(site_ca: Option<&[u8]>) -> tonic::transport::ClientTlsConfig {
let tls = tonic::transport::ClientTlsConfig::new();
match site_ca {
Some(pem) => tls.ca_certificate(tonic::transport::Certificate::from_pem(pem)),
None => tls.with_webpki_roots(),
}
}
/// **The control-plane channel, with the credential signer on it.** The
/// signer itself is `gunnar_grpc::client_signer` since 2026-09-15 — the twin
/// signs from inside `gunnar-server` with the same one (one implementation).
pub(crate) use gunnar_grpc::client_signer::{ControlChannel, CredentialSigner};
pub(crate) fn connect(rt: &tokio::runtime::Runtime, at: &str) -> Result<ControlChannel> {
let trust = ClientTrust::from_env()?;
// The scheme follows the material, not the other way round: an operator who
// exported the three variables meant to use them, and dialling `http://`
// with a certificate in hand would silently drop it.
let url = if at.contains("://") {
at.to_owned()
} else if trust.is_some() {
format!("https://{at}")
} else {
format!("http://{at}")
};
let endpoint = Channel::from_shared(url.clone())
.with_context(|| format!("{at:?} is not an address the control plane can be reached at"))?;
let endpoint = match &trust {
None if url.starts_with("https://") => {
// **Through a site's front** (GRAND-PLAN-V2 §10, the hole): `--at
// https://<site>` with no client certificate is the site's public :443,
// which carries gRPC to the main's control plane. TLS checks the SITE
// (the compiled-in Mozilla roots, or the site's own CA in
// GUNNAR_SITE_CA); the call is authenticated by this key's signature,
// which the front carries untouched.
gunnar_grpc::ensure_crypto_provider();
let site_ca =
match std::env::var_os(SITE_CA_ENV).filter(|v| !v.is_empty()) {
Some(path) => {
let path = PathBuf::from(path);
Some(std::fs::read(&path).with_context(|| {
format!("reading {SITE_CA_ENV} at {}", path.display())
})?)
}
None => None,
};
endpoint
.tls_config(site_tls(site_ca.as_deref()))
.with_context(|| format!("configuring TLS to the site at {at}"))?
}
None => endpoint,
Some(trust) => {
// The client half needs the provider pinned for the same reason
// the listener does: this binary links russh's aws-lc-rs too.
gunnar_grpc::ensure_crypto_provider();
endpoint
.tls_config(
tonic::transport::ClientTlsConfig::new()
// Pin the server's own issuer. The system trust store is
// deliberately not consulted: this CA is gunnar's, and a
// control plane that would also accept a certificate from
// a public CA is not pinned to anything.
.ca_certificate(tonic::transport::Certificate::from_pem(&trust.ca))
.identity(tonic::transport::Identity::from_pem(
&trust.cert,
&trust.key,
)),
)
.context("configuring mutual TLS for the control-plane client")?
}
};
let channel = rt.block_on(endpoint.connect()).map_err(|err| {
anyhow::anyhow!(
"the gunnar control plane at {at} could not be reached: {err}.\n\
\n\
This is NOT the same as \"this server has not been enrolled\". Check that `gunnar \
serve` is running on that host, that it was not started with --no-grpc, and that \
you are pointing at its gRPC port (it binds {DEFAULT_CONTROL_ADDR} by default, on \
LOOPBACK, so a remote one is normally reached through an ssh tunnel: \
`ssh -L 50051:127.0.0.1:50051 <host>`)."
)
})?;
Ok(tonic::service::interceptor::InterceptedService::new(
channel,
CredentialSigner,
))
}
// ───────────────────────────────────────────────────────────────────────────
// Keys
// ───────────────────────────────────────────────────────────────────────────
/// Where a private key is looked for when `-i` is not given.
///
/// The same shape as the client half's `identity()` (`client.rs`), and the same
/// order: ed25519 first, because that is what gunnar mints and what every
/// modern OpenSSH writes. A FIDO2 security key (`id_ed25519_sk`,
/// `id_ecdsa_sk`) comes last: it signs through the ssh-agent with a touch
/// (`gunnar_grpc::security_key`), so a plain key on the same machine wins.
pub fn candidate_identities() -> Vec<PathBuf> {
match std::env::var_os("HOME") {
Some(home) if !home.is_empty() => identities_in(Path::new(&home)),
_ => Vec::new(),
}
}
fn identities_in(home: &Path) -> Vec<PathBuf> {
[
"id_ed25519",
"id_ecdsa",
"id_rsa",
"id_ed25519_sk",
"id_ecdsa_sk",
]
.iter()
.map(|name| home.join(".ssh").join(name))
.collect()
}
/// The private key an authenticated call signs with.
///
/// **It says which key, on stderr, every time — and that line is not cosmetic.**
///
/// `gunnar control` has always fallen back to `~/.ssh/id_ed25519` when `-i` is
/// absent and said nothing about it. On 2026-08-31 that cost a night: probes run
/// from a workstation against the live reference appliance — `repo list`,
/// `user list`, and a `repo create` that succeeded — were reported, in good
/// faith and twice by different hands, as having been made with NO CREDENTIAL.
/// They had been signed with that workstation's default key, which is enrolled on that box
/// as gunnar-admin. The conclusion drawn was that the control plane
/// authenticated nobody; it was written into three source files as a measured
/// fact, a firewall was raised against it, and a fleet went looking for a hole
/// that did not exist. The gate had been holding the whole time (re-measured
/// with a raw gRPC request carrying no metadata: `UNAUTHENTICATED` from every
/// gated service).
///
/// A silent default is the whole mechanism. An operator cannot verify a negative
/// about authentication with a client that quietly authenticates, so this states
/// the identity it used and lets the reader see it. `--as-nobody` is the other
/// half: it is how an uncredentialled call is actually spelled.
///
/// One writer, so every control verb inherits it (one implementation) —
/// announcing it at each of the twenty-odd call sites is how half of them
/// would come to be missing it.
pub(crate) fn private_key(explicit: Option<&Path>) -> Result<ssh_key::PrivateKey> {
let path = match explicit {
Some(path) => path.to_path_buf(),
None => {
let candidates = candidate_identities();
candidates
.iter()
.find(|path| path.exists())
.cloned()
.ok_or_else(|| {
anyhow::anyhow!(
"no ssh private key found, so this call cannot be authenticated. Pass \
-i <path>; looked at: {}",
candidates
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)
})?
}
};
let key = ssh_key::PrivateKey::read_openssh_file(&path)
.with_context(|| format!("reading the ssh private key at {}", path.display()))?;
if key.is_encrypted() {
bail!(
"the private key at {} is passphrase-encrypted. gunnar signs in-process and does \
not prompt; use an unencrypted key, or decrypt one to a file this command can \
read.",
path.display()
);
}
// stderr, so nothing that parses the verb's stdout changes shape.
eprintln!(
"gunnar: signing as {} ({})",
path.display(),
key.public_key().fingerprint(ssh_key::HashAlg::Sha256)
);
Ok(key)
}
/// The public key line to enrol or register.
///
/// `--key` wins; otherwise the public half is **derived from the private key**
/// rather than read from a second file, so `gunnar control bootstrap alice`
/// with no flags at all does the right thing and cannot pair a private key with
/// somebody else's `.pub`.
/// The attestation `user attest-key` printed, as the wire carries it: the
/// JSON text itself or `@<path>` to a file holding it.
///
/// Parsed here into the core record first, so a truncated paste is a local
/// error naming what is wrong rather than an INVALID_ARGUMENT from the far
/// side of a tunnel — the same reason `public_key_line` parses the key.
fn read_attestation(spec: &str) -> Result<gunnar_proto::SignedKeyAttestation> {
let text = match spec.strip_prefix('@') {
Some(path) => std::fs::read_to_string(path)
.with_context(|| format!("reading the attestation from {path}"))?,
None => spec.to_owned(),
};
let signed: gunnar_core::SignedKeyAttestation = serde_json::from_str(text.trim())
.context("the attestation is not what `gunnar control user attest-key` prints")?;
Ok(gunnar_proto::SignedKeyAttestation {
attestation: Some(gunnar_proto::KeyAttestation {
principal_id: signed.attestation.principal_id,
fingerprint: signed.attestation.fingerprint,
at_unix_ms: signed.attestation.at_unix_ms,
}),
key_fingerprint: signed.key_fingerprint,
signature: signed.signature,
})
}
fn public_key_line(explicit: Option<&str>, identity: Option<&Path>) -> Result<String> {
if let Some(spec) = explicit {
let text = match spec.strip_prefix('@') {
Some(path) => std::fs::read_to_string(path)
.with_context(|| format!("reading the public key from {path}"))?,
None => spec.to_owned(),
};
let line = text.trim().to_owned();
if line.is_empty() {
bail!("the public key given is empty");
}
// Parsed here rather than at the server, so a typo is a local error
// with the file name in it instead of an INVALID_ARGUMENT from a box
// that may be on the other side of a tunnel.
// An `age1pq1…` line is a quantum-safe encryption key for encrypted
// repositories (`gunnar crypt keygen`), registered beside SSH keys.
if line.starts_with("age1pq1") {
gunnar_core::auth::pq_recipient_fingerprint(&line)
.with_context(|| format!("{line:?} is not a well-formed age1pq1… key"))?;
return Ok(line);
}
ssh_key::PublicKey::from_openssh(&line)
.with_context(|| format!("{line:?} is not an authorized_keys line"))?;
return Ok(line);
}
let key = private_key(identity)?;
key.public_key()
.to_openssh()
.context("rendering the public half of the private key")
}
// ───────────────────────────────────────────────────────────────────────────
// The credential
// ───────────────────────────────────────────────────────────────────────────
/// Mark a request as one that must carry a fresh, single-use credential.
///
/// **It no longer signs.** [`CredentialSigner`] does, one layer down, where
/// `tonic::GrpcMethod` says which method this request is about to become — and
/// a credential that does not name its method can be lifted off the wire and
/// re-sent at `Access.AddKey` (GUN-001). All this does is leave the key where
/// the signer will find it, which keeps the change to one function instead of
/// to the hundred-odd call sites that would otherwise each have to repeat a
/// method name the generated client already knows.
///
/// A new nonce every time, still: the server remembers accepted
/// `(fingerprint, nonce)` pairs and refuses a repeat, so reusing one would lock
/// this caller out rather than let it through — which is why nothing caches
/// anything.
pub(crate) fn authed<T>(key: &ssh_key::PrivateKey, message: T) -> Result<tonic::Request<T>> {
Ok(gunnar_grpc::client_signer::authed(key, message))
}
/// What a control-plane call said, in words an operator can act on.
///
/// The point of the type is that every arm names a **different** cause. A
/// caller that printed `{err}` would render "not enrolled", "not authorised"
/// and "not reachable" as three lines of the same shape.
///
/// # The request id
///
/// Every arm gets `request-id <id>` appended, from
/// [`gunnar_grpc::REQUEST_ID_METADATA_KEY`] on the status's response metadata.
/// That is the value the server logged the call under, so the sentence the
/// operator is reading names the line in the server's log — which is the whole
/// of the request id reaching a person rather than a file.
///
/// **Appended here and nowhere else.** `explain` is the one writer for all
/// twenty-five `map_err` sites in this file (one implementation); adding it at the call
/// sites would be twenty-five chances to forget one, and the one forgotten
/// would be the refusal somebody is debugging.
///
/// A status with no id is rendered **without the clause**, not with an empty
/// one: talking to a server too old to stamp it is a different situation from
/// one that stamped nothing, and a trailing `request-id ` says neither.
pub(crate) fn explain(status: &tonic::Status) -> String {
with_request_id(explain_code(status), status)
}
/// Append the refused call's id to a message this file wrote itself.
///
/// [`explain`] is the one writer for the twenty-five `map_err` sites, but it is
/// not the only place a `Status` becomes a sentence: an arm that special-cases
/// one code and writes its own prose bypasses it entirely. `bootstrap`'s
/// `FailedPrecondition` arm did exactly that, and it is the refusal a
/// first-time user is most likely to hit — the one call where "which line in
/// the server's log was this" is hardest to answer any other way, because the
/// caller has no credential and cannot ask the server anything else.
///
/// So the id is appended by a function either path can reach, rather than by
/// the one path that happens to go through [`explain_code`] (one
/// implementation). A status with no id is rendered **without the clause**,
/// not with an empty one: talking to a server too old to stamp it is a
/// different situation from one that stamped nothing, and a trailing
/// `request-id ` says neither.
fn with_request_id(mut body: String, status: &tonic::Status) -> String {
if let Some(id) = status
.metadata()
.get(gunnar_grpc::REQUEST_ID_METADATA_KEY)
.and_then(|value| value.to_str().ok())
{
body.push_str(&format!(
"\n request-id: {id} (grep the server's log for `req={id}`)"
));
}
body
}
/// [`explain`] without the request id, so the two concerns are separable and
/// the id cannot be lost inside a `match` arm somebody adds later.
fn explain_code(status: &tonic::Status) -> String {
match status.code() {
tonic::Code::Unauthenticated => format!(
"this server did not accept your key. It is not registered in gunnar's key table, \
or you signed with a different one. Ask a gunnar-admin to run `gunnar control user \
add <you> --key <your .pub>`.\n server said: {}",
status.message()
),
tonic::Code::PermissionDenied => format!(
"your key authenticated, but you are not allowed to do this. The server's own \
sentence below says which grant was missing; creating users and handing out \
gunnar-admin both need gunnar-admin, and everything else needs a grant on the \
namespace or the repository.\n server said: {}",
status.message()
),
tonic::Code::FailedPrecondition => format!(
"the server refused because of the state it is in.\n server said: {}",
status.message()
),
tonic::Code::Unavailable => format!(
"the server could not read its own state, so it answered neither yes nor no. This is \
NOT \"not enrolled\".\n server said: {}",
status.message()
),
_ => format!("{}: {}", status.code(), status.message()),
}
}
// ───────────────────────────────────────────────────────────────────────────
// The verbs
// ───────────────────────────────────────────────────────────────────────────
/// `gunnar control …`
pub fn run(command: ControlCommand) -> Result<()> {
match command {
ControlCommand::Status { at } => status(&at),
ControlCommand::Bootstrap {
at,
name,
key,
identity,
out,
} => bootstrap(
&at,
&name,
key.as_deref(),
identity.as_deref(),
out.as_deref(),
),
ControlCommand::Login { at, identity } => login(&at, identity.as_deref()),
ControlCommand::User(command) => user(command),
ControlCommand::Group(command) => group_command(command),
ControlCommand::Repo(command) => repo(command),
ControlCommand::Audit {
at_seq,
identity,
at,
} => audit_trail(&at, identity.as_deref(), at_seq.unwrap_or(0)),
ControlCommand::Namespace(command) => namespace(command),
ControlCommand::Account(command) => account(command),
ControlCommand::Clan(command) => clan::run(command),
ControlCommand::Mirror(command) => mirror::run(command),
ControlCommand::Replication(command) => replication(command),
ControlCommand::Twin(command) => twin::run(command),
ControlCommand::Tunnel(command) => tunnel::run(command),
ControlCommand::Webhook(command) => webhook(command),
ControlCommand::Policy(command) => policy(command),
ControlCommand::Grant {
command:
Some(ControlGrantCommand::List {
principal,
identity,
at,
}),
..
} => grant_list(&at, identity.as_deref(), principal.as_deref()),
ControlCommand::Grant {
command: None,
at,
identity,
principal,
level,
server,
namespace,
repo,
} => {
// The positionals are `Option` only because `grant list` is a
// subcommand of the same word; giving access still requires both.
let (Some(principal), Some(level)) = (principal, level) else {
bail!(
"`gunnar control grant` takes <principal> <level> (or the subcommand \
`list`): `gunnar control grant bob read --repo alice/linux`"
);
};
grant(
&at,
identity.as_deref(),
&principal,
level,
server,
namespace.as_deref(),
repo.as_deref(),
)
}
ControlCommand::Revoke {
at,
identity,
principal,
server,
namespace,
repo,
} => revoke(
&at,
identity.as_deref(),
&principal,
server,
namespace.as_deref(),
repo.as_deref(),
),
ControlCommand::Health { at, identity } => health(&at, identity.as_deref()),
#[cfg(feature = "metered")]
ControlCommand::ApplianceIdentity { at, identity } => {
appliance_identity(&at, identity.as_deref())
}
#[cfg(feature = "metered")]
ControlCommand::Entitlement(command) => entitlement(command),
ControlCommand::Stats { at, identity } => stats(&at, identity.as_deref()),
ControlCommand::Capacity {
at,
identity,
bytes,
namespace,
} => capacity(&at, identity.as_deref(), bytes, namespace.as_deref()),
ControlCommand::Grow(command) => grow(command),
ControlCommand::Storage { at, identity, repo } => {
storage(&at, identity.as_deref(), repo.as_deref())
}
ControlCommand::Topology { at, identity } => topology(&at, identity.as_deref()),
ControlCommand::Usage {
at,
identity,
namespace,
} => usage(&at, identity.as_deref(), namespace.as_deref()),
ControlCommand::Activity {
at,
identity,
namespace,
days,
} => activity(&at, identity.as_deref(), &namespace, days),
ControlCommand::Admit {
command:
gunnar_common::cli::AdmitCommand::Show {
namespace,
identity,
at,
},
} => admit_plan(&at, identity.as_deref(), &namespace),
ControlCommand::Blame {
repo,
path,
rev,
identity,
at,
} => blame(&at, identity.as_deref(), &repo, &path, &rev),
ControlCommand::Search {
repo,
pattern,
rev,
max_file_bytes,
max_matches,
identity,
at,
} => search(
&at,
identity.as_deref(),
&repo,
&pattern,
&rev,
max_file_bytes,
max_matches,
),
ControlCommand::Show {
repo,
pull,
identity,
at,
} => show(&at, identity.as_deref(), &repo, pull),
ControlCommand::Merge {
repo,
pull,
identity,
at,
} => merge(&at, identity.as_deref(), &repo, pull),
ControlCommand::MergeKey { identity, at } => merge_key(&at, identity.as_deref()),
ControlCommand::Whoami { identity, at } => forge::whoami(&at, identity.as_deref()),
ControlCommand::Pr(command) => forge::pr(command),
ControlCommand::Issue(command) => forge::issue(command),
ControlCommand::Review(command) => forge::review(command),
ControlCommand::Events(command) => forge::events(command),
ControlCommand::Invite(command) => forge::invite(command),
}
}
/// `gunnar control show <ns>/<repo> <pull>` — a pull request's review against a
/// RUNNING server, over the control plane. The store-local `gunnar forge show`
/// cannot run while a serve holds the root; on a sealed appliance this is the
/// only door to the review.
pub(crate) fn show(at: &str, identity: Option<&Path>, repo: &str, pull: u64) -> Result<()> {
// Validated here so a malformed `<ns>/<repo>` is named before a round trip;
// the wire carries the string the forge log is keyed by, not a `RepoPath`.
let repo = repo_path(repo)?;
let repo = format!("{}/{}", repo.namespace, repo.repo);
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = ForgeClient::new(channel);
let response = rt
.block_on(client.get_review(authed(
&signer,
gunnar_proto::GetReviewRequest {
repo: repo.clone(),
pull,
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
// The REVIEW only, in the same shape `gunnar forge show` prints, so an
// operator reads one output whichever door they came through. Whether the
// pull request is open and what it merged at are `PullRequests`' answers.
let state = response
.state
.ok_or_else(|| anyhow::anyhow!("the server returned a review with no state"))?;
println!("{repo}#{pull} review");
println!("comments {}", state.comments);
if state.approvals.is_empty() {
println!("approvals none");
}
for approval in &state.approvals {
let short = &approval.commit[..approval.commit.len().min(12)];
// The verdict is the server's re-check against the identity key table right
// now — the same fact `PullRequests.Merge` gates on — not what the
// event said when it was written.
println!(
"approval {} → {short} [{}]",
approval.actor, approval.verdict
);
}
Ok(())
}
/// `gunnar control merge <ns>/<repo> <pull>` — merge a reviewed pull request
/// against a RUNNING server. The gate is the server's: it refuses unless a
/// verified approval names the head commit it merges (a fast-forward, or a merge
/// commit when the branch fell behind). This is only the verb that reaches `PullRequests.Merge`; on a shell-less
/// appliance there is no other way to move the ref.
fn merge(at: &str, identity: Option<&Path>, repo: &str, pull: u64) -> Result<()> {
let repo = repo_path(repo)?;
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = PullRequestsClient::new(channel);
let response = rt
.block_on(client.merge(authed(
&signer,
gunnar_proto::MergePullRequestRequest {
repo: Some(repo),
number: pull,
// The server picks: a fast-forward where it can, else a merge commit.
..Default::default()
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
let merged = response
.pull
.ok_or_else(|| anyhow::anyhow!("the server merged but returned no pull request"))?;
let scope = merged
.repo
.as_ref()
.map(|r| format!("{}/{}", r.namespace, r.repo))
.unwrap_or_default();
// The oid the target ref actually moved to — the fact the merge exists to
// produce, printed so an operator can check it against the branch.
let how = match response
.plan
.as_ref()
.and_then(|plan| gunnar_proto::MergeStrategy::try_from(plan.strategy).ok())
{
Some(gunnar_proto::MergeStrategy::FastForward) => " (fast-forward)",
Some(gunnar_proto::MergeStrategy::MergeCommit) => " (merge commit)",
Some(gunnar_proto::MergeStrategy::Squash) => " (squash)",
_ => "",
};
println!(
"merged {scope}#{} {} -> {}{how}",
merged.number, merged.target_ref, merged.merge_oid
);
Ok(())
}
/// `gunnar control merge-key` — the public half of the key gunnar signs its merge
/// commits with, and the allowed signers line git verifies them by (GUN-MERGE-SIGN).
fn merge_key(at: &str, identity: Option<&Path>) -> Result<()> {
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = PullRequestsClient::new(channel);
let key = rt
.block_on(
client.merge_signing_key(authed(&signer, gunnar_proto::MergeSigningKeyRequest {})?),
)
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
println!("{}", key.openssh);
println!("fingerprint {}", key.fingerprint_sha256);
println!("allowed signers {}", key.allowed_signers);
Ok(())
}
/// `gunnar control blame <ns>/<repo> <path> [--rev]` — blame against a RUNNING
/// server, over the control plane. The store-local `gunnar blame` cannot run
/// while a serve holds the root; this is the only way on a sealed appliance.
fn blame(at: &str, identity: Option<&Path>, repo: &str, path: &str, rev: &str) -> Result<()> {
let repo = repo_path(repo)?;
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = RefsClient::new(channel);
let response = rt
.block_on(client.blame(authed(
&signer,
gunnar_proto::BlameRequest {
repo: Some(repo),
rev: rev.to_owned(),
path: path.to_owned(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
// `<short-oid> <author>\t<line>`, the same shape the store-local verb
// prints, so an operator reads one output whichever door they came through.
for line in &response.lines {
let short = &line.commit[..line.commit.len().min(12)];
println!(
"{short} {}\t{}",
line.author,
String::from_utf8_lossy(&line.line)
);
}
Ok(())
}
/// `gunnar control search <ns>/<repo> <pattern> [--rev] [--max-*]` — a literal
/// string across one revision, against a RUNNING server.
fn search(
at: &str,
identity: Option<&Path>,
repo: &str,
pattern: &str,
rev: &str,
max_file_bytes: u64,
max_matches: u32,
) -> Result<()> {
let repo = repo_path(repo)?;
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = RefsClient::new(channel);
let response = rt
.block_on(client.search(authed(
&signer,
gunnar_proto::SearchRequest {
repo: Some(repo),
rev: rev.to_owned(),
pattern: pattern.to_owned(),
max_file_bytes,
max_matches,
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
for hit in &response.matches {
println!(
"{}:{}:{}",
hit.path,
hit.line_number,
String::from_utf8_lossy(&hit.line)
);
}
Ok(())
}
/// **The `bootstrap` line(s) of `gunnar control status`, from the ROLE first
/// and the window second.**
///
/// "Not enrolled yet" and "can never be enrolled" must not print the same
/// words. A twin originates no transactions (`txlog: this box is a twin (or
/// restoring); it originates no transactions`), so `Bootstrap.Enroll` on it is
/// refused whatever its identity table holds — and that table is the
/// primary's, replicated, not a door of its own. MEASURED 2026-09-23 on the
/// estate's twin: `role twin` on one line and, two lines under it,
/// `bootstrap OPEN — no identity has ever been enrolled here … Anyone who can
/// reach this port can claim it`. An invitation to a door that does not open,
/// printed by the one command an operator runs to learn what a box is.
///
/// So the role is read before the window. On a twin the window's value is
/// reported as what it is — the replicated table being empty or not — and the
/// claim instruction is never printed. A server too old to report a role keeps
/// the old reading: it predates twins that answer `Bootstrap.Status` at all.
pub(crate) fn bootstrap_lines(
state: &gunnar_proto::BootstrapStatusResponse,
at: &str,
) -> Vec<String> {
if state.role == "twin" {
let table = if state.window_open {
"the replicated identity table is EMPTY — the primary has enrolled nobody yet, or nothing has replicated".to_string()
} else if state.bootstrapped {
format!(
"the replicated identity table was enrolled {} by \"{}\" — on the PRIMARY",
state.bootstrapped_at_unix_ms, state.bootstrapped_principal
)
} else {
"the replicated identity table predates the bootstrap record".to_string()
};
return vec![
"bootstrap NOT POSSIBLE HERE — this box is a twin: it originates no transactions, so nothing \
can be enrolled on it. Enroll on the primary; the twin replicates the result."
.to_string(),
format!(" {table}"),
];
}
if state.window_open {
vec![
"bootstrap OPEN — no identity has ever been enrolled here".to_string(),
String::new(),
" Anyone who can reach this port can claim it, with no credential:".to_string(),
format!(" gunnar control bootstrap <your-name> --at {at}"),
]
} else if state.bootstrapped {
vec![format!(
"bootstrap closed — enrolled {} by \"{}\"",
state.bootstrapped_at_unix_ms, state.bootstrapped_principal
)]
} else {
vec![
"bootstrap closed — this identity table predates the bootstrap record".to_string(),
]
}
}
/// `gunnar control status` — unauthenticated, on purpose.
///
/// It is the one question an operator can ask a server they have no key for,
/// and on an unenrolled server there is by definition no key.
fn status(at: &str) -> Result<()> {
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = BootstrapClient::new(channel);
let state = rt
.block_on(client.status(gunnar_proto::BootstrapStatusRequest {}))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
println!("control plane {at}");
// WHO is answering — read by the installer door's `--grpc-only` and the
// re-image's `--verify-control`, for a box (a twin) that has no banner.
// `unreported` is a server older than the fields, never a guess.
println!(
"build {}",
if state.build.is_empty() {
"unreported (a server older than the build stamp on Bootstrap.Status)"
} else {
state.build.as_str()
}
);
println!(
"role {}",
if state.role.is_empty() {
"unreported"
} else {
state.role.as_str()
}
);
for line in bootstrap_lines(&state, at) {
println!("{line}");
}
if let Some(p) = state.primary.as_ref() {
print_heartbeat(&[], Some(p), state.server_unix_ms);
}
println!("principals {}", state.principals);
println!("keys {}", state.keys);
// **THE GATE, MEASURED RATHER THAN READ.**
//
// This line exists because on 2026-08-31 the question "is this control
// plane authenticating anybody?" had no answer any single command could
// give, so it was answered by inference — a `repo list` that returned
// everything was read as an anonymous read, when in fact `gunnar control`
// had silently signed it with `~/.ssh/id_ed25519` (see `private_key`). The
// wrong answer stood for hours and was committed into three source files.
//
// `Admin.Health` is a GATED service and this call carries no credential, so
// the refusal is the applied output: the gate ran and turned a caller away.
// `Admin.Health` and not `Repos.List` deliberately — it reads nothing and
// names no repository, so an operator running this against someone else's
// server is asking about the door and not about what is behind it.
println!(
"authentication {}",
match rt
.block_on(AdminClient::new(connect(&rt, at)?).health(gunnar_proto::HealthRequest {}))
{
Err(status) if status.code() == tonic::Code::Unauthenticated =>
"REQUIRED — an uncredentialled call to a gated service was refused".to_owned(),
Ok(_) => "⚠ NOT REQUIRED — this server served an uncredentialled Admin.Health. \
Every principal, key, grant and repository is reachable by anyone who \
can route to this port."
.to_owned(),
// Anything else is the gate declining to say. A `PERMISSION_DENIED`
// would mean it authenticated us, which it cannot have: nothing was
// signed. Report the code rather than guessing at a verdict.
Err(status) => format!("UNDETERMINED — {} ({:?})", status.message(), status.code()),
}
);
// READINESS. The control plane answering does not mean git is being
// served: after a restart the two are ~0.6-0.7 s apart, and a script that
// reads "the port answered" as "the repository is there" is exactly what
// reported 64 acked pushes missing on the first power-cut door.
//
// `NOT SERVING` and not `STARTING`: there are now two ways for this to be
// false and only one of them ends. A boot window closes in 0.6 s; a TWIN's
// git listener is on loopback for the life of the box, deliberately, and
// telling an operator that it is "starting" invites them to wait for
// something that is never going to happen. The server's own sentence says
// which it is — it is the one that knows — so the word here is the one that
// is true of both.
println!(
"git {}",
if state.serving_git {
"serving".to_owned()
} else {
format!("NOT SERVING — {}", state.readiness)
}
);
print_clock(
state.server_unix_ms,
state.clock_last.as_ref(),
"clock sync",
);
if state.clock_skew_syncs_suppressed > 0 {
println!(
" rate-limited {} skew-refusal sync(s) dropped since boot (one a minute at most)",
state.clock_skew_syncs_suppressed
);
}
// The machine, while the box is FRESH (T17): what the twin election ranks by, with no
// credential. Absent once enrolled; the sentence says where the numbers are then.
if !state.facts_detail.is_empty() {
println!("machine {}", state.facts_detail);
}
if let Some(f) = state.facts.as_ref() {
println!();
print_box_facts(f);
}
Ok(())
}
/// **Step 1.** `gunnar control bootstrap <name>` — no credential, once, ever.
fn bootstrap(
at: &str,
name: &str,
key: Option<&str>,
identity: Option<&Path>,
out: Option<&Path>,
) -> Result<()> {
let openssh = public_key_line(key, identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = BootstrapClient::new(channel);
let response = match rt.block_on(client.enroll(gunnar_proto::EnrollRequest {
name: name.to_owned(),
openssh_public_key: openssh,
})) {
Ok(response) => response.into_inner(),
Err(status) if status.code() == tonic::Code::FailedPrecondition => {
// The one refusal a first-time user is most likely to hit, and the
// one that must not read as a bug in the tool.
bail!(
"{}",
with_request_id(
format!(
"this server has already been bootstrapped, so there is no \
unauthenticated way in and there never will be again.\n server said: \
{}\n\
\n\
What to do instead: ask whoever holds gunnar-admin to run\n\
\n gunnar control user add <you> --key <your .pub> --at {at}\n\
\n\
Deleting the identities does NOT reopen this. `gunnar control status \
--at {at}` says who enrolled it and when.",
status.message()
),
&status,
)
)
}
Err(status) => bail!("{}", explain(&status)),
};
let principal = response
.principal
.ok_or_else(|| anyhow::anyhow!("the server enrolled nobody but reported success"))?;
let key = response
.key
.ok_or_else(|| anyhow::anyhow!("the server registered no key but reported success"))?;
println!("enrolled {}", principal.id);
println!(
"account {} (you are admin of it, always)",
response.account
);
println!("key {}", key.fingerprint);
println!("role gunnar-admin");
// ── the mTLS credential, when the server minted one ─────────────────────
//
// Written to files rather than printed: a private key on a terminal ends
// up in scrollback and shell history's neighbourhood, and the very next
// thing a user does with these is point a flag at a path. 0600 on the key.
//
// And written into a directory of the user's own, never the current one.
// Until 2026-09-10 these landed in `.`, which for an operator trying the
// tool out is a git checkout: the repository's own `.gitignore` grew
// three rules to hide the keys the CLI and its tests kept dropping there,
// and one real enrolment on builder put `alice.key.pem` in the gunnar
// checkout root. A private key in a checkout is one `git add` from a
// remote; a directory nobody commits from is the fix by construction.
if !response.client_cert_pem.is_empty() {
let dir = match out {
Some(dir) => dir.to_path_buf(),
None => credential_dir(at)?,
};
create_private_dir(&dir)
.with_context(|| format!("creating {} for the credential", dir.display()))?;
let cert_path = dir.join(format!("{name}.cert.pem"));
let key_path = dir.join(format!("{name}.key.pem"));
let issuer_path = dir.join("gunnar-issuer.pem");
// **The private key first**, because it is the one write that can
// refuse (see `write_private_pem`), and a refusal that happens after
// the certificate has landed leaves a directory holding half a
// credential and an error message about the other half.
write_private_pem(&key_path, &response.client_key_pem)?;
std::fs::write(&cert_path, &response.client_cert_pem)
.with_context(|| format!("writing {}", cert_path.display()))?;
std::fs::write(&issuer_path, &response.issuer_cert_pem)
.with_context(|| format!("writing {}", issuer_path.display()))?;
println!();
println!("The server issued you a client certificate and registered it:");
println!(" certificate {}", cert_path.display());
println!(
" private key {} (0600; the server kept no copy)",
key_path.display()
);
println!(
" issuer CA {} (what your tools pin the server's issuer by)",
issuer_path.display()
);
}
println!();
println!("The unauthenticated door is now CLOSED, permanently. You are logged out:");
println!("this command kept no session, and nothing on this machine holds one.");
println!();
println!(" gunnar control login --at {at}");
println!(" gunnar control user add <name> --key <their .pub> --at {at}");
Ok(())
}
/// Where a bootstrap credential for the server at `at` lives when `--out` is
/// not given: `$XDG_CONFIG_HOME/gunnar/<server>/`, falling back to
/// `~/.config/gunnar/<server>/`. `<server>` is `at` with every character
/// that is not a letter, digit, dot or dash turned into `_`, so
/// `100.115.167.95:50061` becomes `100.115.167.95_50061` and two servers
/// never share an `gunnar-issuer.pem`. No HOME at all is an error, not a
/// fallback to `.`: the fallback is the bug this replaces.
pub(crate) fn credential_dir(at: &str) -> Result<PathBuf> {
let base = match std::env::var_os("XDG_CONFIG_HOME") {
Some(xdg) if !xdg.is_empty() => PathBuf::from(xdg),
_ => match std::env::var_os("HOME") {
Some(home) if !home.is_empty() => PathBuf::from(home).join(".config"),
_ => bail!(
"no HOME and no XDG_CONFIG_HOME, so there is no directory of your own to put \
the credential in. Pass --out <DIR>."
),
},
};
let server: String = at
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
c
} else {
'_'
}
})
.collect();
Ok(base.join("gunnar").join(server))
}
/// `mkdir -p`, with every directory this call creates at 0700. An existing
/// directory is left as it is: its mode is the owner's decision.
fn create_private_dir(dir: &Path) -> Result<()> {
if dir.is_dir() {
return Ok(());
}
if let Some(parent) = dir.parent() {
if !parent.as_os_str().is_empty() {
create_private_dir(parent)?;
}
}
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt as _;
builder.mode(0o700);
}
match builder.create(dir) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(err) => Err(err.into()),
}
}
/// **Write a private key PEM to a path nothing was at**, owner-only.
///
/// Two properties, and the second was missing until 2026-09-11.
///
/// **0600 before a byte lands in it**, so there is no window where the key is
/// world-readable. That half was always here.
///
/// **`O_EXCL`**, so the path must not already exist. `create(true)./// truncate(true)` opens whatever is there, and `mode(0o600)` applies **only to
/// a file this call creates** — so an existing world-readable file at the path
/// was truncated, filled with a private key, and left at the mode it already
/// had, with the sentence "(0600; the server kept no copy)" printed underneath
/// it. Worse, `open` follows symlinks: a link planted at
/// `~/.config/gunnar/<server>/<name>.key.pem` — a path an attacker can compute
/// from a name they can see — made this an arbitrary write, to any file the
/// user can write, of content the attacker chose the shape of. `O_EXCL` refuses
/// to follow a symlink at the final component at all, which closes both.
///
/// And it makes a second bootstrap say so instead of silently replacing a key
/// somebody may be depending on. The refusal names the path, because "file
/// exists" with no path is the least actionable error in this file.
fn write_private_pem(path: &Path, pem: &str) -> Result<()> {
use std::io::Write as _;
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = match options.open(path) {
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => bail!(
"{} already exists, and this would have written a private key over it.\n\
\n\
Nothing was written. If that file is a credential you still use, keep it and \
pass --out <DIR> for the new one; if it is not, move it aside and run this \
again.",
path.display()
),
Err(err) => return Err(err).with_context(|| format!("opening {}", path.display())),
};
file.write_all(pem.as_bytes())?;
Ok(())
}
/// **Step 3.** `gunnar control login` — prove the key works. Stores nothing.
///
/// Four outcomes, four different sentences. `PERMISSION_DENIED` is a *success*
/// of authentication and is reported as one: a user who is not gunnar-admin has
/// still logged in.
fn login(at: &str, identity: Option<&Path>) -> Result<()> {
let key = private_key(identity)?;
let fingerprint = key
.public_key()
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string();
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = AdminClient::new(channel);
let request = authed(&key, gunnar_proto::HealthRequest {})?;
match rt.block_on(client.health(request)) {
Ok(response) => {
let health = response.into_inner();
println!("logged in {fingerprint}");
println!("role gunnar-admin (server-wide read or better)");
println!("server {} on {}", health.agent, health.grpc_bind);
println!("ssh {}", health.ssh_bind);
}
Err(status) if status.code() == tonic::Code::PermissionDenied => {
// Authenticated. The gate let it through; the ACL said no to this
// particular question. Reporting this as a login failure would send
// an ordinary user looking for a key problem they do not have.
println!("logged in {fingerprint}");
println!("role ordinary user — not gunnar-admin");
println!(" you are still admin of your own account, always.");
}
Err(status) => bail!("{}", explain(&status)),
}
println!();
println!("Nothing was written. gunnar keeps no session: every call signs a fresh");
println!("single-use credential with this key.");
Ok(())
}
/// **Step 4.** `gunnar control user …` — ordinary authenticated administration.
fn user(command: ControlUserCommand) -> Result<()> {
match command {
ControlUserCommand::Add {
at,
identity,
name,
key,
id,
gunnar_admin,
kind,
} => {
let openssh = public_key_line(Some(&key), identity.as_deref())?;
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = AccessClient::new(channel);
let principal = rt
.block_on(client.add_principal(authed(
&signer,
gunnar_proto::AddPrincipalRequest {
name: name.clone(),
// Empty means "the name", on the server's side: the
// one rule, spelled once, in the proto.
id: id.clone().unwrap_or_default(),
// The identity table's three kinds. `machine` is the one that matters for
// replication: a standby's freeze lets a `Machine` push
// through and refuses a `User`/`Deploy` client write.
kind: match kind {
gunnar_common::cli::PrincipalKindArg::User => {
gunnar_proto::PrincipalKind::User
}
gunnar_common::cli::PrincipalKindArg::Machine => {
gunnar_proto::PrincipalKind::Machine
}
gunnar_common::cli::PrincipalKindArg::Deploy => {
gunnar_proto::PrincipalKind::Deploy
}
gunnar_common::cli::PrincipalKindArg::Group => {
gunnar_proto::PrincipalKind::Group
}
} as i32,
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.principal
.ok_or_else(|| anyhow::anyhow!("the server created nobody but reported success"))?;
let key = rt
.block_on(client.add_key(authed(
&signer,
gunnar_proto::AddKeyRequest {
principal_id: principal.id.clone(),
openssh_public_key: openssh,
comment: String::new(),
expires_at_unix_ms: None,
attestation: None,
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.key
.ok_or_else(|| anyhow::anyhow!("the server stored no key but reported success"))?;
println!("created {}", principal.id);
if principal.name != principal.id {
println!("name {}", principal.name);
}
println!(
"account {} (they are admin of it, always — it is not a grant)",
principal.id
);
println!("key {}", key.fingerprint);
if gunnar_admin {
rt.block_on(client.grant(authed(
&signer,
gunnar_proto::GrantRequest {
principal_id: principal.id.clone(),
namespace: String::new(),
repo: String::new(),
level: gunnar_proto::GrantLevel::Admin as i32,
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?;
println!("role gunnar-admin — they can now create users too");
} else {
println!("role ordinary user");
}
Ok(())
}
ControlUserCommand::AttestKey {
identity,
name,
key,
} => {
let openssh = public_key_line(Some(&key), identity.as_deref())?;
let new = ssh_key::PublicKey::from_openssh(&openssh)
.context("the key being added is not an authorized_keys line")?;
let signer = private_key(identity.as_deref())?;
let signed = gunnar_core::key_attestation::sign_attestation(
&signer,
gunnar_core::KeyAttestation {
principal_id: name.clone(),
fingerprint: new.fingerprint(ssh_key::HashAlg::Sha256).to_string(),
at_unix_ms: gunnar_core::now_ms(),
},
)
.context("signing the attestation")?;
// One JSON object on one line: what `--attestation` takes back,
// verbatim or through `@<path>`. The core struct's own serde form,
// so there is one spelling of the record on both ends.
println!("{}", serde_json::to_string(&signed)?);
eprintln!(
"attested: key {} may act as {name}, signed by {}",
signed.attestation.fingerprint, signed.key_fingerprint
);
Ok(())
}
ControlUserCommand::AddKey {
at,
identity,
name,
key,
attestation,
} => {
// `Some(&key)` deliberately: the key being enrolled is another
// MACHINE's, so it cannot be derived from the private key signing
// this call the way `user add` derives it. Reading it from a
// `@<path>` is the normal case, and `public_key_line` parses it
// here so a truncated paste is a local error naming the file
// rather than an INVALID_ARGUMENT from the far side of a tunnel.
let openssh = public_key_line(Some(&key), identity.as_deref())?;
let attestation = attestation.as_deref().map(read_attestation).transpose()?;
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = AccessClient::new(channel);
let added = rt
.block_on(client.add_key(authed(
&signer,
gunnar_proto::AddKeyRequest {
principal_id: name.clone(),
openssh_public_key: openssh,
comment: String::new(),
expires_at_unix_ms: None,
attestation,
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.key
.ok_or_else(|| anyhow::anyhow!("the server stored no key but reported success"))?;
// Read back, and print the WHOLE set. "one key added" is a state
// round-trip of the request; the set is the applied output, and it
// is also the answer to the question this verb exists for — how
// many machines is this one human on.
let keys = rt
.block_on(client.list_keys(authed(
&signer,
gunnar_proto::ListKeysRequest {
principal_id: name.clone(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.keys;
println!("added {} to {name}", added.fingerprint);
println!("{name} now holds {} key(s):", keys.len());
for key in &keys {
println!(" {}\t{}", key.fingerprint, key.algo);
}
println!();
println!("Grants belong to {name}, not to the key, so this machine inherits");
println!("every grant {name} already has. Nothing else had to be repeated.");
Ok(())
}
ControlUserCommand::RevokeKey {
at,
identity,
fingerprint,
force,
} => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = AccessClient::new(channel.clone());
// Read the key table BEFORE revoking, for two separate reasons and
// not one: a fingerprint that is not there is a typo, and saying so
// here beats a NOT_FOUND from the far side of a tunnel; and the
// principal it belongs to is not in the argument, so the only way
// to report *whose* key went is to look it up while it exists.
let all_keys = rt
.block_on(client.list_keys(authed(
&signer,
gunnar_proto::ListKeysRequest {
principal_id: String::new(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.keys;
let target = all_keys
.iter()
.find(|key| key.fingerprint == fingerprint)
.ok_or_else(|| {
anyhow::anyhow!(
"no key on this server has the fingerprint {fingerprint}; nothing was \
revoked.\n `gunnar control user keys` lists every one, in the form this \
verb takes (`SHA256:…`, as `ssh-keygen -lf` prints it)."
)
})?;
let owner = target.principal_id.clone();
// **Whose key it is, is deliberately NOT checked.** The console's
// `DELETE /api/account/keys` refuses a fingerprint that is not the
// caller's, and it must: it is reachable by a *viewer*, the RPC
// takes a fingerprint and nothing else, and every fingerprint on
// the box is readable from `GET /api/keys` — so without that check
// a viewer could lock any operator out of git. Here the caller has
// already passed `Access.RevokeKey`'s server-scope Admin rule.
// Somebody who holds gunnar-admin can already run
// `user remove <anyone>`, which takes that person's keys AND their
// grants; refusing them the smaller act while allowing the larger
// one would protect nothing and would make this verb useless for
// the case it was written for — rotating a MACHINE's key, which is
// by definition never the caller's own.
//
// The one guard that IS here is the lockout, below.
if !force {
if let Some(refusal) =
last_admin_key_refusal(&rt, &channel, &signer, &all_keys, &fingerprint, &owner)?
{
bail!("{refusal}");
}
}
rt.block_on(client.revoke_key(authed(
&signer,
gunnar_proto::RevokeKeyRequest {
fingerprint: fingerprint.clone(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?;
// The fact, printed before anything that could fail. The revoke has
// happened; a later error must not read as though it had not.
println!("revoked {fingerprint} from {owner}");
// Read back and print the applied set, the same convention
// `user add-key` follows: "one key revoked" is the request echoed,
// the remaining set is the state, and it is also the answer to the
// question an operator is about to ask — can that machine still get
// in.
//
// **Not fatal, and MEASURED why**: with `--force` on the last admin
// key the caller has just revoked the credential it is reading
// with, so this call comes back `Unauthenticated` — correctly. The
// first cut propagated that, and the verb exited non-zero after a
// revoke that had already been applied, which is the one failure
// mode worse than refusing: an operator retries a thing that is
// done.
let left = match rt.block_on(client.list_keys(authed(
&signer,
gunnar_proto::ListKeysRequest {
principal_id: owner.clone(),
},
)?)) {
Ok(response) => Some(response.into_inner().keys),
Err(status) => {
eprintln!(
"gunnar: the revoke was applied; reading {owner}'s remaining keys back \
failed ({}). If you just forced the last admin key, this is that \
lockout, exactly as the guard described it.",
explain(&status)
);
None
}
};
let Some(left) = left else {
return Ok(());
};
println!("{owner} now holds {} key(s):", left.len());
for key in &left {
println!(" {}\t{}", key.fingerprint, key.algo);
}
println!();
if left.is_empty() {
// The re-imaged-machine case, spelled out, because this is the
// exact point where 2026-09-19's six-step detour started: the
// operator reached for `user remove` next, and that is the step
// that threw the grants away.
println!("{owner} still exists, with every grant, membership and namespace");
println!("it had. Enrol the new key with");
println!(" gunnar control user add-key {owner} --key @<new>.pub");
println!("which needs no attestation now that {owner} holds none.");
} else {
println!("Grants belong to {owner}, not to the key, so nothing else changed.");
}
Ok(())
}
ControlUserCommand::Keys { at, identity, name } => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = AccessClient::new(channel);
let keys = rt
.block_on(client.list_keys(authed(
&signer,
gunnar_proto::ListKeysRequest {
// Empty means every key on the server; that is the
// proto's own rule, not a sentinel invented here.
principal_id: name.clone().unwrap_or_default(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.keys;
if keys.is_empty() {
match &name {
Some(name) => println!("{name} holds no keys"),
None => println!("no keys on this server"),
}
}
for key in keys {
println!("{}\t{}\t{}", key.principal_id, key.fingerprint, key.algo);
}
Ok(())
}
ControlUserCommand::Credentials { at, identity, name } => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = AccessClient::new(channel);
let credentials = rt
.block_on(client.list_credentials(authed(
&signer,
gunnar_proto::ListCredentialsRequest {
principal_id: name.clone().unwrap_or_default(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.credentials;
if credentials.is_empty() {
match &name {
Some(name) => println!("{name} holds no passkeys"),
None => println!("no passkeys on this server"),
}
}
for credential in credentials {
println!(
"{}\t{}\t{}\t{}",
credential.principal_id,
gunnar_grpc::webauthn::b64url(&credential.credential_id),
credential.label,
match credential.last_used_unix_ms {
Some(at) => format!("last used at {at} (unix ms)"),
None => "never used".to_owned(),
}
);
}
Ok(())
}
ControlUserCommand::RevokeCredential { at, identity, id } => {
let raw = gunnar_grpc::webauthn::from_b64url(&id).ok_or_else(|| {
anyhow::anyhow!(
"{id:?} is not a credential id: base64url, as `user credentials` prints it"
)
})?;
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = AccessClient::new(channel);
rt.block_on(client.revoke_credential(authed(
&signer,
gunnar_proto::RevokeCredentialRequest { credential_id: raw },
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?;
println!("revoked passkey {id}");
Ok(())
}
ControlUserCommand::List { at, identity } => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = AccessClient::new(channel);
let principals = rt
.block_on(client.list_principals(authed(
&signer,
gunnar_proto::ListPrincipalsRequest {
page_size: 0,
page_token: String::new(),
name: String::new(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.principals;
if principals.is_empty() {
println!("no principals");
}
for principal in principals {
// id, name, created — the id is what every other verb takes;
// the created stamp is what tells a re-created id from the row
// an operator saw before (0 for a row older than the field).
println!(
"{}\t{}\t{}",
principal.id, principal.name, principal.created_at_unix_ms
);
}
Ok(())
}
ControlUserCommand::Rename {
at,
identity,
id,
name,
} => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = AccessClient::new(channel);
let renamed = rt
.block_on(client.rename_principal(authed(
&signer,
gunnar_proto::RenamePrincipalRequest {
principal_id: id.clone(),
name: name.clone(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.principal
.ok_or_else(|| anyhow::anyhow!("the server renamed nobody but reported success"))?;
// The applied row, not the request: the id must have stayed.
println!(
"id {} (unchanged: keys, grants, account stay with it)",
renamed.id
);
println!("name {}", renamed.name);
Ok(())
}
ControlUserCommand::Remove { at, identity, name } => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = AccessClient::new(channel);
let removed = rt
.block_on(client.remove_principal(authed(
&signer,
gunnar_proto::RemovePrincipalRequest {
principal_id: name.clone(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
println!(
"removed {name}: {} keys, {} grants",
removed.keys_revoked, removed.grants_revoked
);
println!("the enrolment window stays shut; removing users never reopens it.");
Ok(())
}
}
}
/// **Would this revoke take the last key that can still administer the box?**
///
/// `Some(sentence)` refuses; `None` lets it through.
///
/// # Why this guard exists here and not on the server
///
/// gunnar-ui refuses to revoke the final *enabled admin passkey*
/// (`auth::WebauthnState::revoke_credential`, `enabled_admins <= 1`) for a
/// reason that is even sharper on an appliance: there is no way back. The
/// offline verb that could re-key a server with no admin key —
/// `gunnar key add` — writes the control repository directly and therefore
/// wants the server stopped, and the appliance is PID 1 with no shell. A box
/// that loses its last gunnar-admin key cannot be given one. It is not a
/// lockout you wait out; it is a re-image.
///
/// So yes, this verb needs the guard, and `--force` is the way past it for the
/// operator who knows the box will be re-imaged anyway.
///
/// **It is a guardrail, not a boundary.** It runs in the client, so anything
/// speaking `Access.RevokeKey` straight — the console, a script, an older
/// `gunnar` — is unaffected, exactly as gunnar-ui's own check is. That is
/// acceptable because it protects against a slip, not against an adversary:
/// the caller already holds gunnar-admin and can do far worse on purpose.
///
/// # What counts as "a key that can still administer the box"
///
/// `Access.RevokeKey` is authorised at [`Scope::Server`] with `Admin`, so a key
/// counts when all of these hold, and each exclusion is in the safe direction —
/// mistake it and the verb refuses a revoke that was fine, rather than allowing
/// one that bricks the box:
///
/// * its principal holds a server-scope `Admin` grant — `namespace` and `repo`
/// both empty, which is how [`Scope::Server`] is carried on the wire — either
/// directly or through a group, **including nested groups**, since a member
/// inherits every grant the group holds;
/// * that principal is not gated: a gate is total in both directions, so a
/// gated admin's key opens nothing (a purge lease that has run out is not a
/// gate any more, and is not counted as one);
/// * the key has not expired.
///
/// Four reads for one revoke, which is four more than the verb strictly needs.
/// A revoke-key is a deliberate, rare act — the measured cost of getting it
/// wrong on 2026-09-19 was a hand-driven six-step recovery, and that was the
/// *recoverable* failure.
fn last_admin_key_refusal(
rt: &tokio::runtime::Runtime,
channel: &ControlChannel,
signer: &ssh_key::PrivateKey,
all_keys: &[gunnar_proto::PublicKey],
fingerprint: &str,
owner: &str,
) -> Result<Option<String>> {
use std::collections::BTreeSet;
let mut access = AccessClient::new(channel.clone());
let grants = rt
.block_on(access.list_grants(authed(
signer,
gunnar_proto::ListGrantsRequest {
// No filter can ask for the server scope: an empty `namespace`
// means "do not filter" on the wire, and the server scope IS
// the empty namespace. So the whole table comes back and the
// scope is matched here.
principal_id: String::new(),
namespace: String::new(),
repo: String::new(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.grants;
let mut admins: BTreeSet<String> = grants
.iter()
.filter(|grant| grant.namespace.is_empty() && grant.repo.is_empty())
.filter(|grant| grant.level == gunnar_proto::GrantLevel::Admin as i32)
.map(|grant| grant.principal_id.clone())
.collect();
let principals = rt
.block_on(access.list_principals(authed(
signer,
gunnar_proto::ListPrincipalsRequest {
page_size: 0,
page_token: String::new(),
name: String::new(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.principals;
let is_group = |id: &str| {
principals
.iter()
.any(|p| p.id == id && p.kind == gunnar_proto::PrincipalKind::Group as i32)
};
// Groups nest, so this is a walk and not a single expansion. `admins`
// doubles as the visited set: a cycle inserts nothing new and the queue
// drains.
let mut pending: Vec<String> = admins.iter().cloned().collect();
while let Some(id) = pending.pop() {
if !is_group(&id) {
continue;
}
let members = rt
.block_on(access.list_group_members(authed(
signer,
gunnar_proto::ListGroupMembersRequest {
group_id: id.clone(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.members;
for member in members {
if admins.insert(member.id.clone()) {
pending.push(member.id);
}
}
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_millis() as i64)
.unwrap_or(i64::MAX);
let gates = rt
.block_on(
AccountsClient::new(channel.clone())
.list_account_gates(authed(signer, gunnar_proto::ListAccountGatesRequest {})?),
)
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.gates;
for gate in gates {
// `expires_unix_ms == 0` is an administrative gate with no deadline;
// non-zero is a purge's lease, and a lease that has run out is not
// holding anybody out any more.
if gate.expires_unix_ms == 0 || gate.expires_unix_ms > now {
admins.remove(&gate.account);
}
}
let admin_keys: Vec<&gunnar_proto::PublicKey> = all_keys
.iter()
.filter(|key| admins.contains(&key.principal_id))
.filter(|key| key.expires_at_unix_ms.is_none_or(|at| at > now))
.collect();
if admin_keys.is_empty() {
// This cannot be true — the call that read these tables was itself
// authorised at server scope with Admin, so at least the caller's key
// qualifies. An empty set means this walk did not model some path to
// gunnar-admin, and silently treating that as "nothing to protect"
// would turn the guard off without saying so.
eprintln!(
"gunnar: warning — no gunnar-admin key could be identified, which cannot be right \
for a call that was just authorised as one. The last-admin guard did not run."
);
return Ok(None);
}
if admin_keys.len() == 1 && admin_keys[0].fingerprint == fingerprint {
return Ok(Some(format!(
"{fingerprint} is the LAST gunnar-admin key on this server ({owner} holds it), and \
revoking it locks everybody out of the control plane for good.\n \
There is no way back: the verb that could re-key a server with no admin key is \
`gunnar key add`, which writes the control repository directly and so needs the \
server stopped — and an appliance is PID 1 with no shell.\n \
Enrol the replacement FIRST (`gunnar control user add-key {owner} --key @<new>.pub`, \
or `user add` for a second admin), then revoke this one.\n \
Pass --force if this box is being re-imaged and you mean it."
)));
}
Ok(None)
}
/// `gunnar control group …` — groups in the ACL (punkt 9).
///
/// A group is a principal that holds no key: `group add` creates it with
/// `AddPrincipal`, membership rides on the `Access` service's group RPCs, and a
/// grant to a group is an ordinary `gunnar control grant <group> …`. Everything
/// here is gunnar-admin-gated on the server, the same rule as a grant.
fn group_command(command: ControlGroupCommand) -> Result<()> {
match command {
ControlGroupCommand::Add { at, identity, name } => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = AccessClient::new(channel);
let group = rt
.block_on(client.add_principal(authed(
&signer,
gunnar_proto::AddPrincipalRequest {
name: name.clone(),
kind: gunnar_proto::PrincipalKind::Group as i32,
id: String::new(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.principal
.ok_or_else(|| {
anyhow::anyhow!("the server created no group but reported success")
})?;
println!("created group {}", group.id);
println!(
"grant it gunnar control grant {} read --repo <ns>/<repo>",
group.id
);
println!(
"add members gunnar control group member add {} <principal>",
group.id
);
Ok(())
}
ControlGroupCommand::Member(member) => group_member(member),
ControlGroupCommand::List { at, identity } => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = AccessClient::new(channel);
let principals = rt
.block_on(client.list_principals(authed(
&signer,
gunnar_proto::ListPrincipalsRequest {
page_size: 0,
page_token: String::new(),
name: String::new(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.principals;
let groups: Vec<_> = principals
.into_iter()
.filter(|p| p.kind == gunnar_proto::PrincipalKind::Group as i32)
.collect();
if groups.is_empty() {
println!("no groups");
}
for group in groups {
println!("{}\t{}", group.id, group.name);
}
Ok(())
}
ControlGroupCommand::Members {
group,
at,
identity,
} => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = AccessClient::new(channel);
let members = rt
.block_on(client.list_group_members(authed(
&signer,
gunnar_proto::ListGroupMembersRequest {
group_id: group.clone(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.members;
if members.is_empty() {
println!("group {group} has no members");
}
for member in members {
println!("{}\t{}", member.id, member.name);
}
Ok(())
}
ControlGroupCommand::History {
group,
at,
identity,
} => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = AccessClient::new(channel);
let events = rt
.block_on(client.list_group_membership_events(authed(
&signer,
gunnar_proto::ListGroupMembershipEventsRequest {
group_id: group.clone().unwrap_or_default(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.events;
if events.is_empty() {
match &group {
Some(g) => println!("no signed membership changes recorded for {g}"),
None => println!("no signed membership changes recorded"),
}
}
for signed in events {
let Some(event) = signed.event else { continue };
let action = match gunnar_proto::MembershipAction::try_from(event.action) {
Ok(gunnar_proto::MembershipAction::Add) => "add",
Ok(gunnar_proto::MembershipAction::Remove) => "remove",
_ => "?",
};
// The fingerprint is what a verifier checks the signature
// against with `ssh-keygen -Y verify`, so it is printed: this
// is evidence, not a log line.
println!(
"{}\t{}\t{}\t{} <- {}\tby {} ({})",
event.at_unix_ms,
action,
event.group,
event.member,
event.group,
event.actor,
signed.key_fingerprint,
);
}
Ok(())
}
}
}
/// **The principal id the signing key belongs to**, resolved from the server's
/// identity key table.
///
/// A membership event names the actor, the actor is bound into the signature,
/// and the server refuses an event filed in a name other than the caller's — so
/// the CLI has to sign with the right one. Rather than make the operator repeat
/// their own id on the command line, it is looked up: the key table maps this
/// key's fingerprint to its principal, which is the same mapping the server
/// uses to authenticate the call. Admin sees every key (that is the gate on
/// `list_keys`), so the caller — who is about to do an Admin-only membership
/// change — can always resolve their own.
fn resolve_actor(
rt: &tokio::runtime::Runtime,
client: &mut AccessClient<ControlChannel>,
signer: &ssh_key::PrivateKey,
) -> Result<String> {
let fingerprint = signer
.public_key()
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string();
let keys = rt
.block_on(client.list_keys(authed(
signer,
gunnar_proto::ListKeysRequest {
principal_id: String::new(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.keys;
keys.into_iter()
.find(|key| key.fingerprint == fingerprint)
.map(|key| key.principal_id)
.ok_or_else(|| {
anyhow::anyhow!(
"your key {fingerprint} is not registered on this server, so a membership \
change it signed could not be attributed to anyone. Enrol it first."
)
})
}
/// The wire form of a signed membership event.
fn signed_membership_proto(
signed: gunnar_core::SignedMembershipEvent,
) -> gunnar_proto::SignedMembershipEvent {
let action = match signed.event.action {
gunnar_core::MembershipAction::Add => gunnar_proto::MembershipAction::Add,
gunnar_core::MembershipAction::Remove => gunnar_proto::MembershipAction::Remove,
};
gunnar_proto::SignedMembershipEvent {
event: Some(gunnar_proto::MembershipEvent {
group: signed.event.group,
member: signed.event.member,
action: action as i32,
actor: signed.event.actor,
at_unix_ms: signed.event.at_unix_ms,
}),
key_fingerprint: signed.key_fingerprint,
signature: signed.signature,
}
}
/// Sign one membership change with the acting admin's own key.
fn sign_membership(
signer: &ssh_key::PrivateKey,
actor: String,
group: String,
member: String,
action: gunnar_core::MembershipAction,
) -> Result<gunnar_proto::SignedMembershipEvent> {
let at_unix_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.context("the system clock is before 1970")?
.as_millis() as i64;
let event = gunnar_core::MembershipEvent {
group,
member,
action,
actor,
at_unix_ms,
};
let signed = gunnar_core::membership::sign_event(signer, event)
.map_err(|err| anyhow::anyhow!("signing the membership event: {err}"))?;
Ok(signed_membership_proto(signed))
}
/// `gunnar control group member add|remove`.
fn group_member(command: ControlGroupMemberCommand) -> Result<()> {
match command {
ControlGroupMemberCommand::Add {
group,
principal,
at,
identity,
} => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = AccessClient::new(channel);
let actor = resolve_actor(&rt, &mut client, &signer)?;
let signed = sign_membership(
&signer,
actor,
group.clone(),
principal.clone(),
gunnar_core::MembershipAction::Add,
)?;
rt.block_on(client.add_group_member(authed(
&signer,
gunnar_proto::AddGroupMemberRequest {
group_id: group.clone(),
member_id: principal.clone(),
signed: Some(signed),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?;
println!("added {principal} to {group}; they inherit its grants now");
println!("this change is signed into the membership log as evidence");
Ok(())
}
ControlGroupMemberCommand::Remove {
group,
principal,
at,
identity,
} => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = AccessClient::new(channel);
let actor = resolve_actor(&rt, &mut client, &signer)?;
let signed = sign_membership(
&signer,
actor,
group.clone(),
principal.clone(),
gunnar_core::MembershipAction::Remove,
)?;
let was_member = rt
.block_on(client.remove_group_member(authed(
&signer,
gunnar_proto::RemoveGroupMemberRequest {
group_id: group.clone(),
member_id: principal.clone(),
signed: Some(signed),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.was_member;
if was_member {
println!("removed {principal} from {group}; the inherited access is gone");
} else {
println!("{principal} was not a member of {group}; nothing changed");
}
println!("this change is signed into the membership log as evidence");
Ok(())
}
}
}
/// The `--server` / `--namespace <n>` / `--repo <ns>/<r>` flags, as the
/// `(namespace, repo)` pair the `Access` wire format spells a scope with.
///
/// One parser for `grant` and `revoke` (one implementation): the revoke that removes a
/// grant must name scopes exactly the way the grant that created it did, or a
/// grant could be given under one spelling and be irrevocable under the other.
fn scope_flags(
server: bool,
namespace: Option<&str>,
repo: Option<&str>,
) -> Result<(String, String)> {
match (server, namespace, repo) {
(true, _, _) => Ok((String::new(), String::new())),
(false, Some(name), None) => Ok((name.to_owned(), String::new())),
(false, _, Some(path)) => {
let (ns, name) = path
.split_once('/')
.ok_or_else(|| anyhow::anyhow!("--repo takes <namespace>/<repo>, got {path:?}"))?;
Ok((ns.to_owned(), name.to_owned()))
}
(false, None, None) => bail!("one of --server, --namespace or --repo is required"),
}
}
/// A scope, the way `grant list` prints it and a human reads it:
/// `server`, `namespace:<name>` or `repo:<namespace>/<repo>`.
fn scope_str(namespace: &str, repo: &str) -> String {
match (namespace.is_empty(), repo.is_empty()) {
(true, _) => "server".to_owned(),
(false, true) => format!("namespace:{namespace}"),
(false, false) => format!("repo:{namespace}/{repo}"),
}
}
/// A grant level as one lowercase word, matching what the `grant` verb takes.
fn level_str(level: i32) -> String {
match gunnar_proto::GrantLevel::try_from(level) {
Ok(gunnar_proto::GrantLevel::Read) => "read".to_owned(),
Ok(gunnar_proto::GrantLevel::Write) => "write".to_owned(),
Ok(gunnar_proto::GrantLevel::Admin) => "admin".to_owned(),
// A server newer than this CLI. Print the number rather than lie.
_ => format!("level({level})"),
}
}
/// `gunnar control grant` — the online twin of the offline `gunnar grant`.
#[allow(clippy::too_many_arguments)]
fn grant(
at: &str,
identity: Option<&Path>,
principal: &str,
level: crate::cli::LevelArg,
server: bool,
namespace: Option<&str>,
repo: Option<&str>,
) -> Result<()> {
let (namespace, repo) = scope_flags(server, namespace, repo)?;
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = AccessClient::new(channel);
rt.block_on(client.grant(authed(
&signer,
gunnar_proto::GrantRequest {
principal_id: principal.to_owned(),
namespace: namespace.clone(),
repo: repo.clone(),
level: level.to_proto() as i32,
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?;
let scope = match (namespace.is_empty(), repo.is_empty()) {
(true, _) => "the whole server (gunnar-admin)".to_owned(),
(false, true) => format!("namespace {namespace}"),
(false, false) => format!("repo {namespace}/{repo}"),
};
println!("granted {principal} {level:?} on {scope}");
Ok(())
}
/// `gunnar control grant list` — the answer to "what can this principal see?".
///
/// `Access.ListGrants` has been served since the control plane existed and the
/// console has read it all along; until this verb the CLI could GIVE access
/// and never read the table back, so the only listing an operator had was the
/// console. Grants are grouped per principal because "what does this person
/// hold" is the question the listing answers; the flat wire order would
/// interleave principals and answer nothing at a glance.
fn grant_list(at: &str, identity: Option<&Path>, principal: Option<&str>) -> Result<()> {
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = AccessClient::new(channel);
let grants = rt
.block_on(client.list_grants(authed(
&signer,
gunnar_proto::ListGrantsRequest {
// Empty means unfiltered; that is the proto's own rule.
principal_id: principal.unwrap_or_default().to_owned(),
namespace: String::new(),
repo: String::new(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.grants;
if grants.is_empty() {
match principal {
Some(name) => println!(
"{name} holds no grants (their own account `{name}/…` is not a grant and is \
always theirs)"
),
None => println!("no grants on this server"),
}
return Ok(());
}
// Group per principal. A BTreeMap so the order is stable and two runs of
// the same table print the same page.
let mut held: std::collections::BTreeMap<&str, Vec<&gunnar_proto::Grant>> = Default::default();
for grant in &grants {
held.entry(grant.principal_id.as_str())
.or_default()
.push(grant);
}
for (principal, rows) in held {
println!("{principal}");
for grant in rows {
println!(
" {}\t{}",
scope_str(&grant.namespace, &grant.repo),
level_str(grant.level)
);
}
}
Ok(())
}
/// `gunnar control revoke` — take back what `grant` gave.
///
/// The refusal distinguishes the two ways this fails, because they demand
/// opposite reactions: "no such grant" means the access you are worried about
/// does not exist (check the spelling of the scope against `grant list`);
/// "not allowed" means it may well exist and YOU cannot remove it.
fn revoke(
at: &str,
identity: Option<&Path>,
principal: &str,
server: bool,
namespace: Option<&str>,
repo: Option<&str>,
) -> Result<()> {
let (namespace, repo) = scope_flags(server, namespace, repo)?;
let scope = scope_str(&namespace, &repo);
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = AccessClient::new(channel);
match rt.block_on(client.revoke(authed(
&signer,
gunnar_proto::RevokeRequest {
principal_id: principal.to_owned(),
namespace: namespace.clone(),
repo: repo.clone(),
},
)?)) {
Ok(_) => {}
// A grant that is not there is its own sentence, not the generic
// `NotFound: …`: the operator's next question is "then what DO they
// hold", and the verb that answers it is named right here.
Err(status) if status.code() == tonic::Code::NotFound => {
bail!(
"{}",
with_request_id(
format!(
"no such grant: {principal} holds nothing on {scope}. Nothing was \
revoked. `gunnar control grant list --principal {principal}` shows \
what they do hold — a grant on another scope is a different grant.\n \
server said: {}",
status.message()
),
&status,
)
);
}
Err(status) => bail!("{}", explain(&status)),
}
println!("revoked {principal} on {scope}");
// The applied output: what the principal STILL holds, read back rather
// than assumed — a revoke that leaves a wider grant standing (say, the
// namespace grant above a repo grant) has not taken the access away, and
// this is where an operator sees that. `ListGrants` needs server-wide
// Admin while the revoke above may have needed only the namespace's, so a
// tenant admin's successful revoke must not turn into a failure here.
if let Ok(response) = rt.block_on(client.list_grants(authed(
&signer,
gunnar_proto::ListGrantsRequest {
principal_id: principal.to_owned(),
namespace: String::new(),
repo: String::new(),
},
)?)) {
let remaining = response.into_inner().grants;
if remaining.is_empty() {
println!("{principal} now holds no grants");
} else {
println!("{principal} still holds:");
for grant in &remaining {
println!(
" {}\t{}",
scope_str(&grant.namespace, &grant.repo),
level_str(grant.level)
);
}
}
}
Ok(())
}
/// `gunnar control health` — the authenticated "how are you".
///
/// `status` asks the unauthenticated questions (is this box enrolled, does the
/// gate turn strangers away); this asks the server about itself with a key,
/// which is what an operator of a headless appliance does instead of a VNC
/// console. `Admin.Health` has been served all along; only the console called
/// it.
/// **`gunnar issuer show --at <addr>`** — which CA does the box over there
/// mint with?
///
/// `pub` because it is dispatched from `gunnar_cli::run`'s top-level
/// `Command::Issuer` arm rather than from `control::run`: it is the same verb
/// as the offline `gunnar issuer show`, asked in a different place, and
/// putting it under `gunnar control issuer` would have made it a second verb
/// for one question.
///
/// It prints through [`gunnar_proto::IssuerInfoResponse::render`], which is
/// also what the offline half prints through, so the two outputs are the same
/// bytes for the same document.
pub fn issuer_show(at: &str, identity: Option<&Path>, pem: bool) -> Result<()> {
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = AdminClient::new(channel);
let info = rt
.block_on(client.issuer_info(authed(&signer, gunnar_proto::IssuerInfoRequest {})?))
// **The two failures this file's header says must never be
// confusable**, in the one direction this verb adds. `Admin.IssuerInfo`
// is additive: a server built before it has no such route, and tonic's
// router answers UNIMPLEMENTED. Rendered as a bare status that reads
// like a gunnar refusal — and the next thought is "so this box has no
// issuer", which is the one conclusion that must not be drawn from it.
// A server with no issuer ANSWERS, with `has_issuer: false`.
.map_err(|status| match status.code() {
tonic::Code::Unimplemented => anyhow::anyhow!(
"{at} does not serve Admin.IssuerInfo: it is running a gunnar from before the verb existed. That is NOT an answer about its issuer — it has one or it does not, and this call could not ask. Upgrade it, or read the certificate from a client that was enrolled against it ({})",
status.message()
),
_ => anyhow::anyhow!("{}", explain(&status)),
})?
.into_inner();
print!("{}", info.render(pem));
Ok(())
}
/// **`gunnar control audit`** — is the record of what happened on this box
/// still complete, and is it still the same record?
///
/// The trail has been written since GUN-044 and could be read only as root on
/// the box, which an appliance does not have. That made it unreadable exactly
/// where it matters, and it made the one thing a restore has to prove —
/// *did the history come back* — unprovable from outside.
///
/// Every line is a fact with a name, and the two that are not facts about the
/// chain's contents are said plainly: `replicated` is whether the twin holds
/// it, and a `no` there is the defect D6, not a preference.
fn audit_trail(at: &str, identity: Option<&Path>, at_seq: u64) -> Result<()> {
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = AdminClient::new(channel);
let info = rt
.block_on(client.audit_trail(authed(&signer, gunnar_proto::AuditTrailRequest { at_seq })?))
// The same trap `issuer_show` names: `Admin.AuditTrail` is additive,
// an older server has no such route, and tonic answers UNIMPLEMENTED
// — which must never be read as "this box keeps no audit trail".
.map_err(|status| match status.code() {
tonic::Code::Unimplemented => anyhow::anyhow!(
"{at} does not serve Admin.AuditTrail: it is running a gunnar from before the \
verb existed. That is NOT an answer about its audit trail — it has one or it \
does not, and this call could not ask ({})",
status.message()
),
_ => anyhow::anyhow!("{}", explain(&status)),
})?
.into_inner();
if !info.present {
println!("audit trail: NONE — this box is serving without a durable trail");
if !info.why.is_empty() {
println!(" why: {}", info.why);
}
return Ok(());
}
println!("audit trail: {} entries", info.entries);
println!(
" tip: {}",
if info.tip.is_empty() {
"(empty chain)"
} else {
&info.tip
}
);
println!(" tip seq: {}", info.tip_seq);
println!(
" verifies: {}",
if info.verified {
"yes"
} else {
"NO — the file has been edited or truncated since gunnar wrote it"
}
);
println!(
" replicated: {}",
if info.replicated {
"yes — every entry is a transaction; the twin holds it and a restore brings it back"
} else {
"no — this box's trail is written here only (a twin, a box being restored, or the D6 defect)"
}
);
if at_seq != 0 {
println!(
" hash at seq {at_seq}: {}",
if info.hash_at_seq.is_empty() {
"(not held)"
} else {
&info.hash_at_seq
}
);
}
if !info.why.is_empty() {
println!(" note: {}", info.why);
}
Ok(())
}
/// **The key a monetize needs before a tenant can renew their own
/// subscription**, printed for pasting.
///
/// `Billing.ApplianceIdentity` has served this since it was written — its own
/// proto says "so an operator can configure their monetize with it" — and until
/// 2026-09-19 no verb asked. MEASURED on gunnar.rs that day: every tenant's own
/// renewal answered `501 unimplemented`, because monetize's `[products.gunnar]`
/// carried no `appliance_key` and the plugin refuses a ticket it cannot verify
/// rather than trusting one. An RPC nothing can call is a feature nobody has.
///
/// Hex, lowercase, 64 characters, exactly as the far side's config takes it —
/// `--monetize-pubkey` is the same form in the other direction. The line is
/// printed alone on stdout so it can be piped; everything else goes to stderr.
///
/// A community appliance answers `Unimplemented` here, and that is the honest
/// answer: it carries no billing service because it sells nothing.
#[cfg(feature = "metered")]
fn appliance_identity(at: &str, identity: Option<&Path>) -> Result<()> {
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = gunnar_proto::billing_client::BillingClient::new(channel);
let answer = rt
.block_on(
client.appliance_identity(authed(&signer, gunnar_proto::ApplianceIdentityRequest {})?),
)
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
eprintln!("gunnar: issuer {}", answer.issuer);
eprintln!(
"gunnar: paste this into your monetize as [products.{}] appliance_key",
answer.issuer
);
println!("{}", answer.public_key_hex);
Ok(())
}
/// `gunnar control entitlement …`. See [`ControlCommand::Entitlement`].
#[cfg(feature = "metered")]
fn entitlement(command: crate::cli::ControlEntitlementCommand) -> Result<()> {
use crate::cli::ControlEntitlementCommand as C;
use gunnar_proto::entitlement_client::EntitlementClient;
match command {
C::Set { fact, identity, at } => {
let fact = read_signed_fact(&fact)?;
let namespace = fact.namespace.clone();
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let answer = rt
.block_on(EntitlementClient::new(channel).set(authed(
&signer,
gunnar_proto::SetEntitlementRequest { fact: Some(fact) },
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
println!(
"{namespace} {} (attestation line {})",
state_word(answer.state),
answer.attestation_seq
);
Ok(())
}
C::Get {
namespace,
identity,
at,
} => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let answer = rt
.block_on(EntitlementClient::new(channel).get(authed(
&signer,
gunnar_proto::GetEntitlementRequest {
namespace: namespace.clone(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
match answer.fact {
Some(fact) => println!("{}", fact_line(&fact)),
// Absent is Free — and, since 2026-09-21, ALSO a stored fact
// this box refused at boot. The box's own journal line
// `entitlement.boot.mismatch` is how the two are told apart.
None => println!("{namespace} free (no fact enforced)"),
}
Ok(())
}
C::List { identity, at } => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let answer = rt
.block_on(
EntitlementClient::new(channel)
.list(authed(&signer, gunnar_proto::ListEntitlementsRequest {})?),
)
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
for fact in &answer.facts {
println!("{}", fact_line(fact));
}
// On STDOUT, in the same listing, because an empty list is ALSO what a
// box with no customers answers: this line is what tells them apart.
if let Some(r) = &answer.refused_at_boot {
println!(
"REFUSED AT BOOT {} fact(s) held and NOT enforced (served Free) since unix ms {}: {} — {}",
r.count,
r.at_unix_ms,
r.namespaces.join(", "),
r.reason
);
}
eprintln!(
"gunnar: {} namespace(s) with an enforced fact; every other is free",
answer.facts.len()
);
Ok(())
}
}
}
/// monetize's own `EntitlementFact`, JSON, off a file or stdin (`-`), moved
/// field for field onto the wire. Nothing is derived and nothing is signed:
/// the signatures are the ones monetize made, over this exact value.
#[cfg(feature = "metered")]
fn read_signed_fact(path: &Path) -> Result<gunnar_proto::EntitlementFact> {
use monetize_product::State;
let text = if path == Path::new("-") {
let mut text = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut text)
.context("reading the fact from stdin")?;
text
} else {
std::fs::read_to_string(path)
.with_context(|| format!("reading the fact at {}", path.display()))?
};
let fact: monetize_product::EntitlementFact = serde_json::from_str(&text)
.with_context(|| format!("{} is not a monetize EntitlementFact", path.display()))?;
if fact.signature.is_empty() {
bail!(
"the fact for {} carries no signature; this verb signs nothing, and the box would refuse it",
fact.tenant.0
);
}
use gunnar_proto::EntitlementState as P;
Ok(gunnar_proto::EntitlementFact {
namespace: fact.tenant.0,
plan: fact.plan,
state: match fact.state {
State::Free => P::Free,
State::Paid => P::Paid,
State::Grace => P::Grace,
State::Suspended => P::Suspended,
State::Retention => P::Retention,
} as i32,
paid_until_unix_ms: fact.paid_until_unix_ms,
caps: fact.caps,
source: fact.source,
signature: fact.signature,
issued_unix_ms: fact.issued_unix_ms,
issued_signature: fact.issued_signature,
})
}
#[cfg(feature = "metered")]
fn state_word(state: i32) -> String {
match gunnar_proto::EntitlementState::try_from(state) {
Ok(gunnar_proto::EntitlementState::Free) => "free".into(),
Ok(gunnar_proto::EntitlementState::Paid) => "paid".into(),
Ok(gunnar_proto::EntitlementState::Grace) => "grace".into(),
Ok(gunnar_proto::EntitlementState::Suspended) => "suspended".into(),
Ok(gunnar_proto::EntitlementState::Retention) => "retention".into(),
Ok(gunnar_proto::EntitlementState::Unspecified) => "unspecified".into(),
Err(_) => format!("state#{state}"),
}
}
#[cfg(feature = "metered")]
fn fact_line(fact: &gunnar_proto::EntitlementFact) -> String {
let until = fact
.paid_until_unix_ms
.map(|ms| format!("paid_until_ms={ms}"))
.unwrap_or_else(|| "paid_until=none".into());
let caps = fact
.caps
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(",");
format!(
"{} {} {until} caps=[{caps}] source={}",
fact.namespace,
state_word(fact.state),
fact.source
)
}
fn health(at: &str, identity: Option<&Path>) -> Result<()> {
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = AdminClient::new(channel);
let health = rt
.block_on(client.health(authed(&signer, gunnar_proto::HealthRequest {})?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
println!(
"serving {}",
if health.serving { "yes" } else { "NO" }
);
println!("agent {}", health.agent);
println!("version {}", health.version);
// **What is actually running here.** `version` is the CRATE version and
// two boxes hundreds of commits apart report the same `1.5.0` — the
// confusion is on record (a console header read `1.5.0` on an appliance
// 228 commits past the tag). `build_ref` has been on `HealthResponse`
// since 2026-09-16 and was never printed by this door, so the one
// question that dates a MEDIUM — "does this box carry the commit that
// does X?" — had no answer here. An unstamped build says so; empty is
// never "the same as version".
println!(
"build {}",
if health.build_ref.is_empty() {
"unstamped (this build had no git to ask)"
} else {
health.build_ref.as_str()
}
);
println!("uptime {}", human_secs(health.uptime_seconds));
if !health.status.is_empty() {
println!("status {}", health.status);
}
// Right under the uptime, where a glance lands: a state the box is in and
// that its console — absent on the UpCloud machine — was the only place for.
for a in &health.alarms {
println!(
"ALARM {} ×{} since unix ms {}: {}",
a.event, a.count, a.since_unix_ms, a.detail
);
}
print_clock(
health.server_unix_ms,
health.clock_last.as_ref(),
"clock last",
);
if health.clock_skew_syncs_suppressed > 0 {
println!(
" rate-limited {} skew-refusal sync(s) dropped since boot (one a minute at most)",
health.clock_skew_syncs_suppressed
);
}
if let Some(boot) = health
.clock_boot
.as_ref()
.filter(|b| Some(*b) != health.clock_last.as_ref())
{
print_clock(0, Some(boot), "clock boot");
}
println!(
"authentication {}",
if health.grpc_authentication_enabled {
"required (sshsig + the identity ACL)"
} else {
"⚠ NOT REQUIRED — this listener was built with the development escape hatch on"
}
);
println!("grpc bind {}", health.grpc_bind);
println!("ssh bind {}", health.ssh_bind);
// The data set's grow journal, as the boot stack left it — absent on
// a boot stack without the engine, and said so rather than skipped.
match &health.grow_state {
Some(g) => {
println!(
"data grow {} — {} ({})",
g.phase.to_uppercase(),
g.detail,
g.journal_path
);
// **The last epoch's records, on a line of their own, IN THE
// JOURNAL'S ORDER.** They are inside `detail` too, but there they
// sit in a sentence that already has parentheses in it (the
// journal path closes the line), and a reader that took the last
// bracketed group off `data grow` would read the PATH as the
// steps. This list is what F7 of the live suite judges — the
// flush after the gate closed and before the set went away — and
// an ordering read out of an ambiguous string is not an ordering.
//
// The order is POSITION and not `at=`: the journal's timestamps
// are whole seconds and a runtime growth is under half of one, so
// DRAIN, FLUSH and UNMOUNT routinely carry the same number. This
// line must never be sorted, deduped or set-ified.
if !g.steps.is_empty() {
println!("grow steps {}", g.steps.join(" "));
}
}
None => println!("data grow no journal: this boot stack does not grow the data set"),
}
print_heartbeat(
&health.twins,
health.primary.as_ref(),
health.server_unix_ms,
);
println!(
"control tls {}",
if health.grpc_mutual_tls {
"mutual — a client certificate this server's issuer signed is required"
} else {
"plaintext — the credential in metadata is the only identity"
}
);
// ── who still signs v1 ─────────────────────────────────────────────────
//
// A 5.3.3 server refuses every v1 credential and reports REQUIRED; an
// older one reports whether it still serves them, which is the reading to
// take before upgrading it.
//
// Printed together, always, because the number is only worth anything
// beside the uptime: "0 seen" on a server up for ten seconds is not
// evidence that nothing sends v1, and an operator reading the two on
// separate screens would not notice.
println!(
"bound creds {}",
if health.grpc_require_bound_credentials {
"REQUIRED — the method-blind gunnar-sshsig-v1 credential is refused"
} else {
"accepted — this listener still serves the method-blind gunnar-sshsig-v1 credential"
}
);
if health.unbound_credentials_accepted == 0 && health.unbound_credentials_refused == 0 {
println!(
" v1 seen none in {} of uptime",
human_secs(health.uptime_seconds)
);
if !health.grpc_require_bound_credentials {
println!(" once that uptime exceeds the longest interval any client of");
println!(
" this server calls at, upgrading it to 5.3.3 (which refuses v1) is safe."
);
}
} else {
println!(
" v1 seen {} served, {} refused, in {} of uptime",
health.unbound_credentials_accepted,
health.unbound_credentials_refused,
human_secs(health.uptime_seconds)
);
for caller in &health.unbound_credential_callers {
println!(
" {} {} {} x{}{}",
caller.fingerprint,
if caller.principal_id.is_empty() {
"(unnamed)"
} else {
&caller.principal_id
},
caller.method,
caller.count,
if caller.refused { " REFUSED" } else { "" }
);
}
if health.unbound_credential_callers_dropped > 0 {
println!(
" …and {} more distinct callers the roster could not hold",
health.unbound_credential_callers_dropped
);
}
if !health.grpc_require_bound_credentials && health.unbound_credentials_accepted > 0 {
println!(
" upgrading this server to 5.3.3 would REFUSE the callers above."
);
}
}
Ok(())
}
/// `gunnar control stats` — the server's counters, on a terminal.
///
/// Every number here is `Admin.Stats`, which the console has drawn all along
/// — nothing is computed client-side, so this page and the console band
/// cannot disagree.
fn stats(at: &str, identity: Option<&Path>) -> Result<()> {
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = AdminClient::new(channel);
let stats = rt
.block_on(client.stats(authed(&signer, gunnar_proto::StatsRequest {})?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
println!("namespaces {}", stats.namespaces);
println!("repos {}", stats.repos);
println!("principals {}", stats.principals);
println!("keys {}", stats.keys);
println!("grants {}", stats.grants);
println!(
"events published {} dropped {} subscribers {} buffer {}",
stats.events_published,
stats.events_dropped,
stats.event_subscribers,
stats.event_buffer_capacity
);
if stats.tombstoned_repos > 0 {
println!(
"tombstoned {} repo(s) holding {} — deleted, retained, still charged",
stats.tombstoned_repos,
human_bytes(stats.tombstoned_bytes)
);
}
// The tenants row, exactly as the console band prints it. Only when the
// server holds any fact: a community server counts nothing, and a line of
// zeros there would read as "everyone is Free" on a box that cannot know.
#[cfg(feature = "metered")]
if stats.tenants_paid + stats.tenants_grace + stats.tenants_suspended > 0 {
println!(
"tenants paid {} grace {} suspended {}",
stats.tenants_paid, stats.tenants_grace, stats.tenants_suspended
);
}
if stats.replication_configured {
println!(
"replication synced {} failed {} enqueued {} bus gaps {}",
stats.replication_synced,
stats.replication_failed,
stats.replication_enqueued,
stats.replication_bus_gaps
);
if stats.replication_refs_total > 0 {
println!(
" {} ref(s) here, {} on NO standby",
stats.replication_refs_total, stats.replication_refs_unmirrored
);
}
for standby in &stats.standbys {
println!(
" standby {} pending {} {}",
standby.name,
standby.pending,
if standby.verdict.is_empty() {
"(verdict unknown — server predates it)"
} else {
&standby.verdict
}
);
}
println!(" (`gunnar control replication list` has the whole story)");
} else {
println!("replication not configured — every byte on this server lives here alone");
}
for row in &stats.index {
let path = row
.repo
.as_ref()
.map(|p| format!("{}/{}", p.namespace, p.repo))
.unwrap_or_else(|| "<no path>".to_owned());
println!(
"index {path} {}% {}/{} {}{}",
row.percent,
row.objects_done,
row.objects_total,
row.phase,
if row.resumed { " (resumed)" } else { "" }
);
}
Ok(())
}
/// `gunnar control capacity --bytes N [--namespace NS]` — **"if a tenant's cap
/// rose to N bytes, could you serve it?"**
///
/// Two lines carry the answer and neither is optional. `verdict` is whether
/// this request fits in what exists; `growth` is whether this box can ever
/// hold more than it does. They are printed together because a caller that
/// read only the first would answer `NO` with "buy another disk", and on the
/// appliance that disk is billed and invisible.
///
/// Every byte is printed raw AND human-readable, the same rule
/// [`usage`] follows: the raw figure is what a script compares, the human one
/// is what an operator reads.
///
/// Below the disk, the box: cores and RAM (product scope — the ceilings a
/// seller may sell up to), printed as `unmeasured` rather than `0` when the
/// server did not fill them, because a zero here would read as a box with
/// nothing in it.
fn capacity(at: &str, identity: Option<&Path>, bytes: u64, namespace: Option<&str>) -> Result<()> {
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = AdminClient::new(channel);
let a = rt
.block_on(client.capacity(authed(
&signer,
gunnar_proto::CapacityRequest {
requested_bytes: bytes,
namespace: namespace.unwrap_or_default().to_owned(),
disks: false,
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
let verdict = match gunnar_proto::CapacityVerdict::try_from(a.verdict) {
Ok(gunnar_proto::CapacityVerdict::CapacityAvailable) => "YES it can be served",
Ok(gunnar_proto::CapacityVerdict::CapacityExhausted) => "NO not enough can be served",
Ok(gunnar_proto::CapacityVerdict::CapacityUnmeasured) => {
"UNMEASURED the filesystem could not be read, which is NOT a yes"
}
_ => "? this server did not fill the verdict in",
};
// Never the verdict's twin: a set that cannot grow is a different fact
// from a set that is full, and an operator must be able to see both at
// once.
let growth = match gunnar_proto::SetGrowth::try_from(a.growth) {
Ok(gunnar_proto::SetGrowth::GrowthSealed) => {
"SEALED this set cannot grow while gunnar runs; an attached disk is wasted"
}
Ok(gunnar_proto::SetGrowth::GrowthOperatorOnly) => {
"OPERATOR ONLY gunnar will not grow it; a shell on this box can, out of band"
}
Ok(gunnar_proto::SetGrowth::GrowthUnknown) => {
"UNKNOWN gunnar cannot tell what the data root sits on"
}
Ok(gunnar_proto::SetGrowth::GrowthAtRuntime) => {
"AT RUNTIME GrowStart grows it while gunnar runs: detach, resize and re-attach ALL members; no restart"
}
Ok(gunnar_proto::SetGrowth::GrowthAtRestart) => {
"AT RESTART the boot stack grows it at the next boot once ALL members are enlarged equally; an attached disk is still wasted"
}
_ => "? this server did not fill the growth answer in",
};
println!("verdict {verdict}");
println!("growth {growth}");
println!(
"hotplug {}",
if a.hotplug {
"yes the running kernel sees a member detached and re-attached"
} else {
"no a detached member is invisible until a restart"
}
);
if let Some(g) = &a.grow_state {
println!("grow {:<14} {}", g.phase.to_uppercase(), g.detail);
}
let set = if a.data_set.is_empty() {
String::new()
} else {
format!(" ({})", a.data_set)
};
println!("root {}{set}", a.root);
let row = |label: &str, n: u64, note: &str| {
println!("{label:<12}{n:>20} {:>10}{note}", human_bytes(n));
};
row("total", a.total_bytes, "");
row("free", a.free_bytes, "");
row("floor", a.floor_bytes, " reserved; never handed out");
row("servable", a.servable_bytes, " free minus the floor");
row("requested", a.requested_bytes, "");
if let Some(ns) = namespace {
row("held", a.held_bytes, &format!(" {ns} already holds this"));
}
row(
"additional",
a.additional_bytes,
" the NEW bytes this needs",
);
if a.shortfall_bytes > 0 {
row("short", a.shortfall_bytes, " what is missing");
}
// The box: product scope, and ABSENT is printed as the word, never as a
// 0 a script would sum. Cores and busy are millicores, not bytes, so they
// do not go through `row`.
println!();
let unmeasured = " unmeasured see the reason below";
match a.cpu_millicores_total {
Some(m) => println!(
"cores {m:>20} millicores gunnar may run on {} CPUs",
m / 1000
),
None => println!("cores{unmeasured}"),
}
match a.cpu_millicores_busy {
Some(m) => println!("busy {m:>20} millicores 1-min load x 1000, an approximation"),
None => println!("busy {unmeasured}"),
}
match a.ram_bytes_total {
Some(n) => row("ram total", n, ""),
None => println!("ram total{unmeasured}"),
}
match a.ram_bytes_available {
Some(n) => row("ram avail", n, " MemAvailable"),
None => println!("ram avail{unmeasured}"),
}
// The disk, as it was measured at start-up on an idle box. The STATE line
// comes first and is never optional: a reader that sees only speeds
// cannot tell a slow disk from one that was never measured, and the twin
// election turns on exactly that difference.
println!();
print_disk(
a.disk_test,
a.disk_seq_write_bps,
a.disk_seq_read_bps,
&a.disk_read_how,
a.disk_sync_milliops_per_s,
);
println!();
println!("{}", a.reason);
if !a.machine_reason.is_empty() {
println!("{}", a.machine_reason);
}
if !a.disk_reason.is_empty() {
println!("{}", a.disk_reason);
}
Ok(())
}
/// `gunnar control storage` — what is on disk, and behind which backend.
fn or_dash(s: &str) -> &str {
if s.is_empty() {
"-"
} else {
s
}
}
/// `gunnar control topology` — `Admin.Topology`, one line per box: the name,
/// the role, gunnar's state word, the address, and the sentence that says how
/// gunnar knows. Printed as it came; nothing here decides a state.
fn topology(at: &str, identity: Option<&Path>) -> Result<()> {
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = AdminClient::new(channel);
let t = rt
.block_on(client.topology(authed(&signer, gunnar_proto::TopologyRequest {})?))
.map_err(|s| anyhow::anyhow!("Admin.Topology: {}", s.message()))?
.into_inner();
println!(
"estate {}",
if t.estate.is_empty() {
"(not paired)"
} else {
&t.estate
}
);
if t.recorded {
println!(
"recorded yes — the installer's estate record ({})",
if t.variant.is_empty() {
"no variant named"
} else {
&t.variant
}
);
for v in &t.versions {
println!(" laid from {v}");
}
} else {
println!("recorded no — no estate record on this box; the boxes below are what the evidence alone shows");
}
println!("boxes {}", t.boxes.len());
for b in &t.boxes {
let disk = if b.disk_measured {
format!(
" disk {} of {}",
human_bytes(b.disk_used_bytes),
human_bytes(b.disk_total_bytes)
)
} else {
String::new()
};
println!(
"{}{:<16} {:<9} {:<8} {}{}",
if b.this_box { "* " } else { " " },
b.name,
b.role,
b.state.to_uppercase(),
if b.address.is_empty() {
"-"
} else {
&b.address
},
disk
);
if !b.headline.is_empty() {
println!(" {}", b.headline);
}
println!(" {}", b.why);
if !b.laid_as.is_empty() {
let mut laid = vec![format!("laid as {}", b.laid_as)];
if !b.hostname.is_empty() {
laid.push(b.hostname.clone());
}
if !b.public_address.is_empty() || !b.utility_address.is_empty() {
laid.push(format!(
"public {} utility {}",
or_dash(&b.public_address),
or_dash(&b.utility_address)
));
}
if !b.provider_size.is_empty() || b.declared_vcpu > 0 {
laid.push(format!(
"{} ({} vCPU, {} MB declared)",
or_dash(&b.provider_size),
b.declared_vcpu,
b.declared_ram_mb
));
}
if b.declared_system_disk_gb > 0 {
laid.push(format!("system disk {} GB", b.declared_system_disk_gb));
}
if !b.declared_data.is_empty() {
// As LAID: after a grow the disk above (measured) is what is true.
laid.push(format!("data as laid {}", b.declared_data));
}
println!(" {}", laid.join(" · "));
}
}
Ok(())
}
fn storage(at: &str, identity: Option<&Path>, repo: Option<&str>) -> Result<()> {
let repo = repo
.map(|path| {
let (ns, name) = path
.split_once('/')
.ok_or_else(|| anyhow::anyhow!("--repo takes <namespace>/<repo>, got {path:?}"))?;
Ok::<_, anyhow::Error>(gunnar_proto::RepoPath {
namespace: ns.to_owned(),
repo: name.to_owned(),
})
})
.transpose()?;
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = AdminClient::new(channel);
let info = rt
.block_on(client.storage_info(authed(&signer, gunnar_proto::StorageInfoRequest { repo })?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
println!("objects {}", info.object_backend);
println!("refs {}", info.ref_backend);
println!(
"hot {} (every push lands here)",
human_bytes(info.total_hot_bytes)
);
println!(
"cold {} (one archive per repo, written by repack)",
human_bytes(info.total_cold_bytes)
);
if info.total_explode_bytes > 0 || info.total_explode_budget_bytes > 0 {
println!(
"explode cache {} of {} budget (re-derivable, not storage)",
human_bytes(info.total_explode_bytes),
human_bytes(info.total_explode_budget_bytes)
);
}
if info.total_tombstoned_bytes > 0 {
println!(
"tombstoned {} held by deleted repositories, outside the totals above",
human_bytes(info.total_tombstoned_bytes)
);
}
for row in &info.repos {
let path = row
.repo
.as_ref()
.map(|p| format!("{}/{}", p.namespace, p.repo))
.unwrap_or_else(|| "<no path>".to_owned());
println!(
" {path}\thot {}\tcold {} ({} objects)\t{} ref(s)",
human_bytes(row.hot_bytes),
human_bytes(row.cold_bytes),
row.cold_object_count,
row.ref_count
);
}
if let Some(reality) = info.reality.as_ref() {
print_storage_reality(reality);
}
Ok(())
}
/// Pure: **gunnar's at-a-glance lines as the CLI prints them** — `STATE line`, the state upper-cased
/// in eight columns, the sentence exactly as gunnar wrote it (the console's Machine card draws the same
/// `StorageReality.glance`; gunnar-ui's UI row the-glance-lines-are-gunnars-own compares the two).
fn glance_lines(glance: &[gunnar_proto::GlanceLine]) -> Vec<String> {
glance
.iter()
.map(|g| format!("{:<8} {}", g.state.to_uppercase(), g.line))
.collect()
}
/// **The storage reality of the machine**, printed in the three layers the
/// server keeps apart.
///
/// The appliance has no shell: this text is the only way anyone will ever see
/// which disks became `/data`, which were rejected and on what grounds, or
/// whether the metadata mirror is genuinely on another spindle. So it prints
/// what was READ and nothing else — an absent install record prints as absent,
/// with the sentence saying where gunnar looked, rather than as a plausible
/// summary reconstructed from the mount table.
fn print_storage_reality(r: &gunnar_proto::StorageReality) {
// ── at a glance: gunnar's own verdicts, the lines the console's Machine
// card draws (StorageReality.glance, 2026-10-04) ──
if !r.glance.is_empty() {
println!();
println!("── at a glance ──────────────────────────────────────────────");
for l in glance_lines(&r.glance) {
println!("{l}");
}
}
println!();
println!("── what the installer decided ───────────────────────────────");
if r.record_present {
println!(
"config {} (format {})",
r.record_path, r.record_version
);
// **Three states, and the third is the estate's.** `INTEGRITY ONLY` is
// the installer's own wording for "the blake3 matches and NOTHING else
// is claimed", which is where every appliance stands while no
// KORP_ROOT_SIGNING_KEY is enrolled. An unsigned config is USED and is
// never called verified.
println!("authenticity {}", r.record_signature);
if !r.record_signature_note.is_empty() {
println!(" {}", r.record_signature_note);
}
for field in &r.record_fields {
println!(" {:<34} {}", field.key, field.value);
}
} else {
println!("config ABSENT — {}", r.record_absent_reason);
println!(
" Nothing below is inferred from it: an appliance installed before\n\
\x20 the config existed has none, and guessing the installer's\n\
\x20 decisions from the current mount table is how \"why is this disk\n\
\x20 unused?\" gets answered wrongly."
);
}
println!();
println!("── what is true now ─────────────────────────────────────────");
for fs in &r.filesystems {
if fs.measured {
println!(
"{:<14} {} {} free of {}",
fs.what,
fs.path,
human_bytes(fs.free_bytes),
human_bytes(fs.total_bytes)
);
} else {
// No number at all, rather than a zero somebody would read as full.
println!("{:<14} {} NOT MEASURED: {}", fs.what, fs.path, fs.error);
}
}
println!();
println!("── what gunnar is configured to do ──────────────────────────");
println!("data root {}", r.data_root);
// `/data`'s OWN shape, from the installer's `data.*` keys — never the
// `serialdat.*` ones below, which a console once drew under this line.
match (r.data_shape.as_str(), r.data_devices.is_empty()) {
("", _) => println!("data shape nothing said"),
(shape, true) => println!("data shape {shape}"),
(shape, false) => println!("data shape {shape} over {}", r.data_devices.join(", ")),
}
if r.slow_root.is_empty() {
println!("serialdat not stated ({})", r.slow_root_source);
} else {
println!("serialdat {} ({})", r.slow_root, r.slow_root_source);
}
// A class that was not read is not reported as one.
// These are `/serialdat`'s disk, not `/data`'s — hence the label.
match (r.backing_class.as_str(), r.backing_device.as_str()) {
("", _) => println!("serialdat on nothing said"),
(class, "") => println!("serialdat on {class}"),
(class, dev) => println!("serialdat on {dev} ({class})"),
}
// The one verdict, in the three honest states. `same-device` is NEVER a
// green tick: a mirror that dies with its original is worse than no mirror,
// because somebody will plan a recovery around it.
println!("off-stripe {}", r.off_stripe.to_uppercase());
for line in &r.off_stripe_health {
println!(" {line}");
}
println!();
for row in &r.placement {
let state = if row.requires_off_stripe {
format!(" [needs off-stripe: {}]", row.off_stripe)
} else {
String::new()
};
let wired = if row.wired { "" } else { " NOT WIRED" };
println!(" {:<52} {}{state}{wired}", row.what, row.path);
if !row.wired && !row.note.is_empty() {
println!(" {}", row.note);
}
}
}
/// `gunnar control usage [ns]` — the meters, one row per namespace.
///
/// Every number is printed as raw bytes AND human-readable: the raw figure is
/// what an invoice or a script compares, the human one is what an operator
/// reads. Column names are the meter names `usage.proto` declares, so the row
/// can be grepped by the same word monetize bills by.
fn usage(at: &str, identity: Option<&Path>, namespace: Option<&str>) -> Result<()> {
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = UsageClient::new(channel);
let rows = match namespace {
Some(ns) => {
let answer = rt
.block_on(client.read(authed(
&signer,
gunnar_proto::ReadUsageRequest {
namespace: ns.to_owned(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
answer.usage.into_iter().collect::<Vec<_>>()
}
None => {
rt.block_on(client.read_all(authed(&signer, gunnar_proto::ReadAllUsageRequest {})?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner()
.usages
}
};
if rows.is_empty() {
println!("no namespaces");
return Ok(());
}
for row in &rows {
println!(
"{}\trepos {}\tpack {} ({})\tcache {} ({})\tlfs {} ({})\ttombstoned {} ({})\tobjects {}\tcold {}",
row.namespace,
row.repos,
row.pack_bytes,
human_bytes(row.pack_bytes),
row.cache_bytes,
human_bytes(row.cache_bytes),
row.lfs_bytes,
human_bytes(row.lfs_bytes),
row.tombstoned_bytes,
human_bytes(row.tombstoned_bytes),
row.objects,
row.cold_bytes,
);
// **The FLOW half, on its own line.** Not more columns on the row
// above: those are bytes on a disk and these are counts of events, and
// a single row that mixed them would invite exactly the reading this
// whole ledger exists to prevent — that a big `pack` means a busy
// tenant. The words are the meter names `usage.proto` declares, so the
// line can be grepped by the same word monetize bills by.
println!(
"\ttoday\tin {} ({})\tout {} ({})\tpushes {}\tfetches {}\trefused {}\tlast push {}\tlast fetch {}",
row.bytes_in_today,
human_bytes(row.bytes_in_today),
row.bytes_out_today,
human_bytes(row.bytes_out_today),
row.pushes_today,
row.fetches_today,
row.refused_today,
stamp(row.last_push_unix_ms),
stamp(row.last_fetch_unix_ms),
);
}
Ok(())
}
/// **`never`, and not `1970-01-01`.**
///
/// proto3 cannot tell "unset" from "zero", so a namespace nothing has ever
/// pushed to answers 0 — and printing that as a date would put the epoch on
/// screen beside real timestamps, which reads as a very old push rather than as
/// no push at all.
fn stamp(unix_ms: i64) -> String {
if unix_ms <= 0 {
return "never".to_owned();
}
gunnar_core::iso8601_utc(unix_ms)
}
/// `gunnar control activity <ns> [--days N]` — what MOVED, one row per UTC day.
///
/// Raw numbers AND human-readable, for [`usage`]'s reason: the raw figure is
/// what a script diffs and the rounded one is what an operator reads.
///
/// **A day with no traffic has no row**, and that is deliberate rather than a
/// gap to fill in: a day that was never written and a day on which nothing
/// happened are the same fact, and printing a row of zeroes for every quiet
/// Sunday would bury the days that mean something. The header says how many
/// days the server keeps, so an empty answer can be read as *nothing happened*
/// rather than as *this box no longer remembers*.
fn activity(at: &str, identity: Option<&Path>, namespace: &str, days: u32) -> Result<()> {
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = UsageClient::new(channel);
let now = gunnar_core::now_ms();
let answer = rt
.block_on(client.activity(authed(
&signer,
gunnar_proto::ActivityRequest {
namespace: namespace.to_owned(),
since_unix_ms: now - i64::from(days) * gunnar_core::DAY_MS,
until_unix_ms: now,
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
if answer.days.is_empty() {
println!(
"{namespace}\tnothing moved in the last {days} day(s); this server keeps {} days",
answer.retained_days
);
return Ok(());
}
println!(
"{namespace}\t{} day(s) with traffic\tkept {} days",
answer.days.len(),
answer.retained_days
);
for day in &answer.days {
let reasons: Vec<String> = {
let mut by_reason: Vec<(&String, &u64)> = day.refused_by_reason.iter().collect();
by_reason.sort();
by_reason
.iter()
.map(|(why, n)| format!("{why} {n}"))
.collect()
};
println!(
"{}\tin {} ({})\tout {} ({})\tpushes {}\tfetches {}\tlfs in {}\tlfs out {}\trefused {}{}\tauth failures {}\tkeys {}{}",
day_stamp(day.day_unix_ms),
day.bytes_in,
human_bytes(day.bytes_in),
day.bytes_out,
human_bytes(day.bytes_out),
day.pushes,
day.fetches,
day.lfs_bytes_in,
day.lfs_bytes_out,
day.refused,
if reasons.is_empty() { String::new() } else { format!(" ({})", reasons.join(", ")) },
day.auth_failures,
day.distinct_principals,
// A count that stopped growing must never be read as a total.
if day.distinct_principals_capped { "+" } else { "" },
);
}
Ok(())
}
/// `gunnar control admit show <ns>` — `Usage.AdmitPlan`.
///
/// Raw numbers AND human-readable, for [`usage`]'s reason; the per-kind line
/// ends with the server's own sentence, verbatim, so the words an operator
/// reads here are the words the gate logs.
fn admit_plan(at: &str, identity: Option<&Path>, namespace: &str) -> Result<()> {
let signer = private_key(identity)?;
let rt = runtime()?;
let channel = connect(&rt, at)?;
let mut client = UsageClient::new(channel);
let plan = rt
.block_on(client.admit_plan(authed(
&signer,
gunnar_proto::AdmitPlanRequest {
namespace: namespace.to_owned(),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
let budget = match (plan.limit_bytes, plan.budget_left_bytes) {
(Some(limit), Some(left)) => format!(
"budget {limit} ({}) per {}d\tleft {left} ({})",
human_bytes(limit),
plan.window_days,
human_bytes(left)
),
_ => "no budget".to_owned(),
};
println!(
"{namespace}\tpolicy {}\tused {} ({}) over {}d\t{budget}",
plan.policy,
plan.used_bytes,
human_bytes(plan.used_bytes),
plan.window_days,
);
for kind in &plan.kinds {
println!(
"{}\t{}\testimate {} ({})\tsamples {}\t{}",
kind.kind,
kind.admit_class,
kind.estimate_bytes,
human_bytes(kind.estimate_bytes),
kind.samples,
kind.why,
);
}
Ok(())
}
/// `YYYY-MM-DD` of a day's midnight — the key of an activity row. The time of
/// day is always midnight UTC here, so printing it would be nine characters
/// that are the same on every line.
fn day_stamp(unix_ms: i64) -> String {
gunnar_core::iso8601_utc(unix_ms)
.split('T')
.next()
.unwrap_or("?")
.to_owned()
}
/// `1d 2h 3m 4s` from seconds, omitting leading zero units. Zero is `0s`.
fn human_secs(secs: u64) -> String {
let (d, h, m, s) = (
secs / 86400,
(secs % 86400) / 3600,
(secs % 3600) / 60,
secs % 60,
);
let mut out = String::new();
for (value, unit) in [(d, "d"), (h, "h"), (m, "m"), (s, "s")] {
if value > 0 || (!out.is_empty()) || unit == "s" {
out.push_str(&format!("{value}{unit} "));
}
}
out.trim_end().to_owned()
}
/// Bytes with the raw number kept: `1234567 (1.2 MiB)`. The raw number stays
/// because it is the one a later reader can diff, sum or grep; the rounded
/// form is for the glance.
pub(crate) fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 4] = ["KiB", "MiB", "GiB", "TiB"];
if bytes < 1024 {
return format!("{bytes} B");
}
let mut value = bytes as f64;
let mut unit = "B";
for next in UNITS {
if value < 1024.0 {
break;
}
value /= 1024.0;
unit = next;
}
format!("{bytes} ({value:.1} {unit})")
}
// ───────────────────────────────────────────────────────────────────────────
// Replication — the only way to name a standby on a box with no shell
// ───────────────────────────────────────────────────────────────────────────
/// `gunnar control replication …`
///
/// Every one of these is `Admin` on the whole server, including `list`: a
/// replication target is a URL every repository is copied to plus the path of
/// the key it authenticates with, so reading the roster tells you where a
/// second copy of everything lives.
fn replication(command: ControlReplicationCommand) -> Result<()> {
match command {
ControlReplicationCommand::Add {
name,
remote,
namespace,
repo,
identity_path,
known_hosts_path,
strict_host_key,
scope,
control,
identity,
at,
} => {
use gunnar_proto::replication_scope::Of;
let of = match (namespace, repo) {
(None, None) => Of::Server(true),
(Some(ns), None) => Of::Namespace(ns),
(_, Some(path)) => {
let (ns, name) = path.split_once('/').ok_or_else(|| {
anyhow::anyhow!("--repo takes <namespace>/<repo>, got {path:?}")
})?;
Of::Repo(gunnar_proto::RepoPath {
namespace: ns.to_owned(),
repo: name.to_owned(),
})
}
};
let target = gunnar_proto::ReplicationTarget {
name: name.clone(),
scope: Some(gunnar_proto::ReplicationScope { of: Some(of) }),
remote: remote.clone(),
identity_path: identity_path.unwrap_or_default(),
known_hosts_path: known_hosts_path.unwrap_or_default(),
strict_host_key,
public_only: scope == gunnar_common::cli::ReplicationScopeArg::PublicOnly,
control: control.unwrap_or_default(),
};
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = MirrorClient::new(channel);
let out = rt
.block_on(client.set_replication(authed(
&signer,
gunnar_proto::SetReplicationRequest {
target: Some(target),
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
println!("standby {name} -> {remote}");
if !out.identity_public_key.is_empty() {
println!();
println!(" This box pushes with a key it minted itself, and the private half");
println!(" never leaves it. AUTHORISE THIS LINE ON THE STANDBY or every push");
println!(" to it fails on authentication, forever:");
println!();
println!(" {}", out.identity_public_key.trim());
println!();
println!(" On a gunnar standby that is TWO verbs, and the second one is the");
println!(" one that is easy to get wrong — a `write` grant is NOT enough,");
println!(" because the standby has to CREATE each repository:");
println!();
println!(
" gunnar-cli control user add primary --key '{}' \\",
out.identity_public_key.trim()
);
println!(" --at <standby>:50051");
println!(" gunnar-cli control grant primary admin --namespace <ns> \\");
println!(" --at <standby>:50051");
println!();
println!(" Then BACKFILL it — adding a standby copies nothing that is");
println!(" already here, only what is pushed from now on:");
println!();
println!(" gunnar-cli control replication resync {name}");
}
print_replication(&out);
Ok(())
}
ControlReplicationCommand::Remove { name, identity, at } => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = MirrorClient::new(channel);
let out = rt
.block_on(client.remove_replication(authed(
&signer,
gunnar_proto::RemoveReplicationRequest { name: name.clone() },
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
println!("standby {name} removed");
print_replication(&out);
Ok(())
}
ControlReplicationCommand::Demote { identity, at } => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = MirrorClient::new(channel);
// Count what we are about to clear, so the operator sees whether the
// recovered box really was still a primary (targets > 0) or had
// already come back empty (the torn-write path).
let before = rt
.block_on(client.replication_status(authed(
&signer,
gunnar_proto::ReplicationStatusRequest {
include_refs: false,
},
)?))
.map(|r| r.into_inner().targets.len())
.unwrap_or(0);
let out = rt
.block_on(client.demote_replication(authed(
&signer,
gunnar_proto::DemoteReplicationRequest {},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
println!("{}", demote_report(before, out.role));
print_replication(&out);
Ok(())
}
ControlReplicationCommand::Abdicate {
promote,
identity,
at,
} => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = MirrorClient::new(channel);
// The whole hand-off on the master, in order, in one RPC: freeze
// (stop taking client writes) → flush (resync + drain) → demote
// (clear the roster). See `Mirror.AbdicateReplication`.
let out = rt
.block_on(client.abdicate_replication(authed(
&signer,
gunnar_proto::AbdicateReplicationRequest {},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
println!(
"abdicated — this box is now a read-only standby with an empty roster; \
it takes no client writes and can never clobber the new primary"
);
print_replication(&out);
match promote {
// The takeover, as abdication's last act: tell the standby it is
// the primary now. A SEPARATE control call, to the standby's own
// address, because promotion is the standby flipping ITS role.
Some(addr) => {
let channel = connect(&rt, &addr)?;
let mut standby = MirrorClient::new(channel);
let promoted = rt
.block_on(standby.set_role(authed(
&signer,
gunnar_proto::SetRoleRequest {
role: gunnar_proto::ReplicationRole::Primary as i32,
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
println!("promoted {addr} — it is now the writable primary and takes writes");
print_replication(&promoted);
println!();
println!(" ONE infra step remains, and it is not gunnar's: repoint the front");
println!(" (DNS / the UpCloud DNAT) at the new primary so clients reach it.");
}
None => {
println!();
println!(" Now PROMOTE the new primary — run this against ITS control plane:");
println!();
println!(" gunnar control replication promote --at <new-primary>");
println!();
println!(" then repoint the front (DNS / DNAT) at it — that step is infra.");
}
}
Ok(())
}
ControlReplicationCommand::Promote { identity, at } => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = MirrorClient::new(channel);
let out = rt
.block_on(client.set_role(authed(
&signer,
gunnar_proto::SetRoleRequest {
role: gunnar_proto::ReplicationRole::Primary as i32,
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
println!("promoted — this box is now a writable primary and takes client writes");
print_replication(&out);
Ok(())
}
ControlReplicationCommand::Browse { identity, at } => {
let signer = private_key(identity.as_deref())?;
let rt = runtime()?;
let channel = connect(&rt, &at)?;
let mut client = MirrorClient::new(channel);
let out = rt
.block_on(client.set_role(authed(
&signer,
gunnar_proto::SetRoleRequest {
role: gunnar_proto::ReplicationRole::Browse as i32,
},
)?))
.map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
.into_inner();
println!(
"browse — this box is now a browse replica: read-only to clients, anonymous \
reads of public repositories served, every control-plane change refused, \
and it cannot be promoted. `replication standby` is the way back"
);
print_replication(&out);
Ok(())
The file goes on; the console shows all of it.