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

nordisk/gunnar

crates/gunnar-cli/src/lib.rs

//! **`gunnar`, the binary that ties the crates together.**
//!
//! Everything below this crate is already built and tested. `gunnar-cli` is
//! wiring, and the wiring is the point: until this crate existed, the SSH
//! server ([`gunnar_ssh`]) and the protocol state machines ([`gunnar_wire`])
//! had each been proven on their own and never joined. `gunnar-ssh`'s tests
//! drove a `BannerTransport`; `gunnar-wire`'s tests drove a pipe. Here they
//! meet, and `tests/ssh_end_to_end.rs` runs stock `git` against the join.
//!
//! ```text
//!   gunnar serve
//!     ├── SSH  :2222        russh ── auth by key ── sandbox ── grant
//!     │                       └── GitTransport ──▶ gunnar-wire upload/receive-pack
//!     │                              receive-pack ── PushGate ──▶ gunnar-policy
//!     └── gRPC 127.0.0.1:50051   tonic ── ControlPlane ──▶ gunnar-core
//! ```
//!
//! # Two listeners, never one
//!
//! Git traffic never passes through gRPC and there is no bridge. **Both**
//! listeners authenticate against the same identity key table: SSH by publickey,
//! gRPC by an `sshsig` credential signed with that same key (the gRPC
//! allow-all hole was closed 2026-08-03), and both authorise through the same
//! `AuthTable`. gRPC still defaults to **loopback** (closing the hole is not a
//! licence to widen the bind), and `tests/serve_wiring.rs` goes red if that
//! default moves.
//!
//! # Two halves, two features
//!
//! `server` and `client` are both on by default. An appliance image builds
//! `--no-default-features --features server`, and links **no** client code:
//! `gunnar-client`, and with it gix's worktree/status porcelain, is an
//! optional dependency of the `client` feature alone. `tests/feature_split.rs`
//! asserts that from a resolved `cargo tree`, not from the declaration. A
//! declaration cannot see feature unification.

#![warn(clippy::all)]
#![doc(
    html_logo_url = "https://gunnar.rs/download/gunnar.webp",
    html_favicon_url = "https://gunnar.rs/download/favicon.svg"
)]

// `cli` and `logging` moved to `gunnar-common` so `gunnar-server` can share
// them without depending on this crate. Re-exported here so every existing path
// — `gunnar_cli::cli::…`, `gunnar_cli::logging::…`, and the `crate::cli` /
// `crate::logging` forms the modules below use — keeps resolving unchanged.
pub use gunnar_common::{cli, logging};

#[cfg(feature = "client")]
pub mod client;
#[cfg(feature = "control")]
pub mod control;
#[cfg(feature = "client")]
pub mod crypt;
#[cfg(feature = "client")]
pub mod crypt_key;
#[cfg(feature = "client")]
pub mod fido2;
#[cfg(feature = "client")]
pub mod repo_settings;
// `gunnar-cli login` asks the console over HTTPS: the named extra `online` (GUN-PRODUCTS).
#[cfg(feature = "online")]
pub mod login;
// The server half lives in its own crate (`gunnar-server`) now. Re-exported so
// every existing path — `gunnar_cli::server::…` (used by ~15 tests and by
// gunnar-znippy) and the `crate::server::…` dispatch below — resolves unchanged.
#[cfg(feature = "server")]
pub use gunnar_server as server;
#[cfg(feature = "systemd")]
pub mod service;

use anyhow::Result;
use clap::Parser;

use crate::cli::{Cli, Command};

/// **Every verb, dispatched.** `pub` so a second binary — `crates/gunnar-znippy`,
/// which installs a cold tier and then runs exactly this — is a `main` of four
/// lines rather than a copy of this `match` (one implementation).
pub fn run() -> Result<()> {
    let cli = Cli::parse();
    crate::init_tracing(cli.verbose);

    match cli.command {
        // **`gunnar issuer show --at <addr>`: the same verb, asked over the
        // wire.** Intercepted before the server arm below because the wire
        // half needs the CONTROL client and not a data root — a thin client
        // with no storage engine can ask it, which is the point: the appliance
        // it asks about has no shell to run the offline half on.
        //
        // One verb, not two. `gunnar control issuer show` would have been a
        // second spelling of one question and a second place for the rendering
        // to drift; the offline and the remote halves print through the same
        // `IssuerInfoResponse::render`.
        #[cfg(feature = "control")]
        Command::Issuer(cli::IssuerCommand::Show {
            pem,
            at: Some(at),
            identity,
        }) => crate::control::issuer_show(&at, identity.as_deref(), pem),

        // A CONTROL-only binary carries no data root reader, so `issuer show`
        // with no `--at` has nothing to read. Said out loud rather than left
        // to clap: the offline form is the one every runbook prints, and an
        // operator who typed it on a thin client deserves the one word that
        // fixes it.
        #[cfg(all(feature = "control", not(feature = "server")))]
        Command::Issuer(cli::IssuerCommand::Show { at: None, .. }) => anyhow::bail!(
            "`gunnar issuer show` with no `--at` reads the data root under its feet, and this              binary carries no storage half (built without the `server` feature). Name the              server to ask: `gunnar issuer show --at <addr> -i <key>`"
        ),

        // Every server verb routes into `gunnar-server`, where the dispatch
        // and all its wiring live exactly once (one implementation). The
        // `gunnar-server` binary calls the same `gunnar_server::run`, so there
        // is one server match.
        #[cfg(feature = "server")]
        server_command @ (Command::Serve(_)
        | Command::Namespace(_)
        | Command::Repo(_)
        | Command::Principal(_)
        | Command::Issuer(_)
        | Command::Key(_)
        | Command::Token(_)
        | Command::Attest(_)
        | Command::Verify(_)
        | Command::Blame(_)
        | Command::Search(_)
        | Command::Forge(_)
        | Command::Grant(_)
        | Command::Revoke(_)
        | Command::Gc(_)
        | Command::Reflog(_)
        | Command::Wg(_)) => gunnar_server::run(cli.data_root, server_command),

        #[cfg(feature = "client")]
        Command::Clone(args) => crate::client::clone(args),
        #[cfg(feature = "client")]
        Command::Fetch(args) => crate::client::fetch(args),
        #[cfg(feature = "client")]
        Command::LsRemote(args) => crate::client::ls_remote(args),
        #[cfg(feature = "client")]
        Command::Push(args) => crate::client::push(args),
        #[cfg(feature = "client")]
        Command::Mirror(command) => crate::client::mirror(command),
        #[cfg(feature = "client")]
        Command::Crypt(command) => crate::crypt::run(command),
        #[cfg(feature = "client")]
        Command::Settings(args) => crate::repo_settings::run(args),
        #[cfg(feature = "client")]
        Command::Create(args) => crate::repo_settings::create(args),
        #[cfg(feature = "client")]
        Command::Log(args) => crate::client::log(args),
        #[cfg(feature = "client")]
        Command::Status(args) => crate::client::status(args),
        #[cfg(feature = "client")]
        Command::Commit(args) => crate::client::commit(args),
        #[cfg(feature = "client")]
        Command::Branch(args) => crate::client::branch(args),
        #[cfg(feature = "client")]
        Command::Tag(args) => crate::client::tag(args),

        #[cfg(feature = "control")]
        Command::Control(command) => crate::control::run(command),
        #[cfg(feature = "online")]
        Command::Login(args) => crate::login::run(args),
        #[cfg(all(feature = "control", not(feature = "online")))]
        Command::Login(_) => anyhow::bail!(
            "this gunnar-cli has no `login`: it asks the console over HTTPS, the named extra `online` \
             (`cargo install gunnar-cli --features online`); `-i <key>` signs in with an SSH key"
        ),

        #[cfg(feature = "systemd")]
        Command::Service(command) => crate::service::run(command),

        // A verb `gunnar-common` parses but this build does not carry: only when
        // `gunnar-common` is built with a half this crate is not (feature
        // unification — the front's test links the control verbs beside a
        // server-featured `gunnar-common`). Said, never a compile failure.
        #[allow(unreachable_patterns)]
        _ => anyhow::bail!("this build of gunnar does not carry that verb"),
    }
}

// The build identity, the SSH banner and `init_tracing` moved to `gunnar-common`
// when the server became its own crate. Re-exported so every existing path —
// `gunnar_cli::AGENT`, `gunnar_cli::ssh_server_id`, `gunnar_cli::init_tracing`,
// `crate::AGENT`, `crate::ssh_server_id()`, `crate::init_tracing(..)` — resolves
// unchanged.
pub use gunnar_common::{init_tracing, ssh_server_id, AGENT, BUILD_REF};