git-ents.gitmain
⌘K
foforge
commit 0a7ddab
roots: add git ents bootstrap with server-key discovery

The single-node hosted root’s first boot deliberately fails closed until the server’s signing key is enrolled (roots.web-signing), but the operator-side runbook was four manual commands plus a pubkey copied out of the logs. One porcelain now does it, from a clone, never on the server: enroll the operator as the self-admitting first push (gate.bootstrap), then vouch for the server key under the operator’s own signature — auto-enrolling server-side would spend the self-admitting push on the machine itself.

The key to vouch for is discovered, not copied: setup --hosted writes the key’s public half to <key>.pub, nginx serves it at /.ents/server-key during exactly the window the web UI is still down, and bootstrap fetches it from the remote it is about to push to (--server-pubkey overrides for non-http remotes). Only the public half ever crosses the wire; the private key never leaves the volume.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Joseph D. Carpinelli · 29 days ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

docker/entrypoint.sh @@ -34,6 +34,16 @@ # and the ssh path an operator needs to *do* the enrolling — down in a # crash loop. The web surface stays fail-closed (nothing listens on 4880 # until enrollment succeeds); the enroll command prints every attempt. +# +# Fresh-volume runbook: enrollment is deliberately NOT automated here. +# Auto-enrolling would spend the self-admitting first push +# (`gate.bootstrap`) on the server's own key, making the machine the +# trust root instead of the operator. From a local clone, one command — +# it enrolls the operator (signed by `user.signingkey`), then vouches +# for the server key, discovered from nginx's /.ents/server-key (its +# private half never leaves this volume): +# git ents bootstrap <you> +# The retry below then admits the web UI within one 15s cycle. ( until git-ents serve --hosted --key "$key" --public-host "$public_host" \ --port 4880 "$repo"; do
docker/nginx.conf @@ -33,6 +33,16 @@ fastcgi_param PATH "/usr/local/bin:/usr/bin:/bin"; } + # The server key's public half (`git ents setup --hosted` writes + # `<key>.pub`): served by nginx itself, not the web process, so + # `git ents bootstrap` can discover the identity to vouch for + # during the exact window the web UI is still fail-closed + # awaiting that enrollment (`roots.web-signing`). + location = /.ents/server-key { + default_type text/plain; + alias /data/hosted_signing_key.pub; + } + # Everything else: the hosted web UI (`git ents serve --hosted`), # loopback-only inside this machine — nginx is the sole external # listener.
crates/cli/git-ents/src/cli.rs @@ -60,6 +60,35 @@ #[facet(args::positional, default)] path: Option<PathBuf>, }, + /// Bootstrap a fresh hosted root from a clone of it: enroll yourself + /// as the self-admitting first member (`gate.bootstrap`), then vouch + /// for the server's own key (`roots.web-signing`) so its fail-closed + /// web UI can boot, pushing both enrollments to the remote. Run from + /// your clone, never on the server — enrolling server-side would + /// make the machine the trust root instead of the operator. + Bootstrap { + /// Your username to enroll (`refs/meta/member/<username>`). + #[facet(args::positional)] + username: String, + /// The server's public key to vouch for; defaults to fetching + /// `/.ents/server-key` from the remote's host — the hosted + /// root's front proxy publishes the key's public half there + /// while the web UI awaits this enrollment. Required when the + /// remote is not http(s). + #[facet(args::named)] + server_pubkey: Option<String>, + /// The username the server key is enrolled under; defaults to + /// `forge`. + #[facet(args::named)] + server_name: Option<String>, + /// The remote to push both enrollments to; defaults to `origin`. + #[facet(args::named)] + remote: Option<String>, + /// Key to sign both enrollments with; defaults to + /// `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, /// Manage the repository members at `refs/meta/member/<username>`. Members { /// The member action to run.
crates/cli/git-ents/src/error.rs @@ -77,6 +77,19 @@ source: std::io::Error, }, + /// A `git push` run on the operator's behalf failed: the remote's + /// gate refused the enrollment, or the transport itself failed. + #[error("push of {refspec} to {remote} failed:\n{stderr}")] + Push { + /// The refspec being pushed. + refspec: String, + /// The remote pushed to. + remote: String, + /// git's own stderr, which carries the gate's refusal + /// (`gate.verdict-reason`) when there is one. + stderr: String, + }, + /// A malformed command-line argument that passed `figue`'s own parsing /// but fails a semantic check this crate makes (an invalid line range, /// an unparsable oid, ...).
crates/cli/git-ents/src/exe.rs @@ -44,6 +44,24 @@ let _ = writeln!(out, "signing key: {}", key_path.display()); Ok(()) } + Top::Bootstrap { + username, + server_pubkey, + server_name, + remote, + key, + } => { + let root = LocalRoot::discover(".")?; + commands::bootstrap::run( + &root, + &username, + server_pubkey, + server_name.as_deref().unwrap_or("forge"), + remote.as_deref().unwrap_or("origin"), + key, + out, + ) + } Top::Members { action } => run_members(action, out), Top::Account { action } => run_account(action, out), Top::Effect { action } => run_effect(action, out),
crates/cli/git-ents/tests/hosted_root.rs @@ -43,6 +43,17 @@ .expect("git-ents runs"); assert!(output.status.success(), "{output:?}"); + // The public half published for `git ents bootstrap`'s discovery — + // written next to the key `setup --hosted` resolved (here, the one it + // generated under the scratch HOME). + let pub_path = scratch_home.path().join(".ssh").join("id_ed25519.pub"); + assert!( + pub_path.exists(), + "setup --hosted must write the key's public half" + ); + let pubkey = std::fs::read_to_string(&pub_path).expect("readable"); + assert!(pubkey.starts_with("ssh-"), "{pubkey:?}"); + for hook in ["pre-receive", "post-receive"] { let path = bare.join("hooks").join(hook); assert!(path.exists(), "setup --hosted must install {hook}"); @@ -124,6 +135,58 @@ ); } +/// The operator bootstrap porcelain (`git ents bootstrap`) against a +/// fresh hosted root: one command from a clone enrolls the operator +/// under the self-admitting window (`gate.bootstrap`), then the server +/// key under the operator's own signature (`roots.web-signing`), landing +/// both refs on the bare repository over real pushes — the whole +/// first-boot runbook `docker/entrypoint.sh` waits on. +// @relation(gate.bootstrap, roots.web-signing, scope=function, role=Verifies) +#[test] +fn bootstrap_command_enrolls_operator_then_server_key() { + let bare = common::Fixture::new_bare(30); + setup_hosted(bare.path()); + + let clone_dir = tempfile::tempdir().expect("tempdir"); + let clone_output = git( + clone_dir.path(), + &["clone", "--quiet", bare.path().to_str().expect("utf8"), "."], + ); + assert!(clone_output.status.success(), "{clone_output:?}"); + + let operator_key = common::write_key_in(clone_dir.path(), 31); + let server_key = clone_dir.path().join(".server_key"); + common::write_key(&server_key, 32); + let server_pubkey = git_ents::sign::Signer::load(&server_key) + .expect("loads server key") + .public_openssh(); + + let output = Command::new(common::bin_path()) + .args(["bootstrap", "jdc", "--server-pubkey"]) + .arg(&server_pubkey) + .arg("--key") + .arg(&operator_key) + .current_dir(clone_dir.path()) + .env("GIT_AUTHOR_NAME", "test") + .env("GIT_AUTHOR_EMAIL", "test@ents.test") + .env("GIT_COMMITTER_NAME", "test") + .env("GIT_COMMITTER_EMAIL", "test@ents.test") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .env_remove("HOME") + .output() + .expect("git-ents runs"); + assert!(output.status.success(), "{output:?}"); + + for member in ["jdc", "forge"] { + let show = git(bare.path(), &["show-ref", &format!("refs/meta/member/{member}")]); + assert!( + show.status.success(), + "refs/meta/member/{member} must land on the hosted root: {show:?}" + ); + } +} + /// A second push, from a *different, unenrolled* signer, straight onto a /// canonical meta-ref with no admin standing, must be refused by the /// mandatory gate — and because `pre-receive` rejects the whole batch
crates/cli/git-ents/src/commands/mod.rs @@ -9,6 +9,7 @@ )] pub mod account; +pub mod bootstrap; pub mod comment; pub mod effect; pub mod inbox;
crates/cli/git-ents/src/commands/serve.rs @@ -216,8 +216,8 @@ .map(|(username, _state)| username) .ok_or_else(|| Error::NotFound { what: format!( - "an enrolled member holding the server key {} — enroll it first: \ - git ents members add <name> --pubkey \"{pubkey}\"", + "an enrolled member holding the server key {} — bootstrap from a clone: \ + git ents bootstrap <you> --server-pubkey \"{pubkey}\"", key.display() ), })?;
crates/cli/git-ents/src/commands/setup.rs @@ -77,10 +77,25 @@ ] { set_local_config(path, key, value)?; } + write_pubkey(&resolved)?; install_hooks(path)?; Ok(resolved) } +/// Write the key's public half to `<key>.pub` — the front proxy publishes +/// it at a well-known path so `git ents bootstrap` can discover the server +/// identity to vouch for (`roots.web-signing`) without the operator +/// copying it out of the logs. Runs every boot, so a volume whose key +/// predates this file gains it on the next deploy. +fn write_pubkey(key: &Path) -> Result<()> { + let pubkey = Signer::load(key)?.public_openssh(); + let pub_path = PathBuf::from(format!("{}.pub", key.display())); + std::fs::write(&pub_path, format!("{pubkey}\n")).map_err(|source| Error::Io { + path: pub_path, + source, + }) +} + /// Resolve `key` (or `path`'s `user.signingkey`, or a default /// `~/.ssh/id_ed25519`), generating a fresh key if nothing resolves to an /// existing file, and confirm the result actually loads.
crates/cli/git-ents/src/commands/bootstrap.rs @@ -1,0 +1,176 @@ +//! `git ents bootstrap`: an operator's first-boot enrollment of a fresh +//! hosted root, run from a clone — never on the server. +//! +//! Order is the whole design: the operator's own key lands first, as the +//! self-admitting first push (`gate.bootstrap`), and the server's key is +//! then vouched for under the operator's signature (`roots.web-signing`) +//! so `serve --hosted`'s fail-closed web UI can boot. Enrolling +//! server-side instead would spend the self-admitting push on the +//! server's own key, making the machine the trust root rather than the +//! operator — the reason `docker/entrypoint.sh` retries and waits for +//! this command instead of enrolling itself. +//! +//! The server key to vouch for is discovered, not copied: the front +//! proxy serves the key's public half at `/.ents/server-key` (written by +//! `git ents setup --hosted`) precisely while the web UI waits for this +//! enrollment. `--server-pubkey` overrides discovery for a remote that +//! is not http(s). Only the public half is ever fetched; the private key +//! never leaves the server's volume, in either direction. + +use std::path::PathBuf; +use std::process::Command; + +use crate::commands::members; +use crate::error::{Error, Result}; +use crate::root::LocalRoot; + +/// Enroll `username` (the operator, signed by `key` or `user.signingkey`) +/// and then `server_name` holding `server_pubkey` — discovered from +/// `remote` when not given — pushing each enrollment to `remote` as it +/// lands. +/// +/// # Errors +/// +/// [`Error::NotFound`] if discovery is needed but `remote` is not an +/// http(s) URL or does not answer with a public key; see [`members::add`] +/// for an enrollment failure; [`Error::Push`] if a push is refused or the +/// transport fails. Discovery and the operator's push both precede the +/// server enrollment, so a failure stops the bootstrap with nothing +/// half-done on the remote. +pub fn run( + root: &LocalRoot, + username: &str, + server_pubkey: Option<String>, + server_name: &str, + remote: &str, + key: Option<PathBuf>, + out: &mut impl std::io::Write, +) -> Result<()> { + let server_pubkey = match server_pubkey { + Some(given) => given, + None => { + let discovered = discover_server_pubkey(root, remote)?; + let _ = writeln!(out, "discovered server key: {discovered}"); + discovered + } + }; + members::add(root, username, None, key.clone())?; + push(root, remote, username)?; + let _ = writeln!(out, "enrolled {username} (self-admitting first push)"); + members::add(root, server_name, Some(server_pubkey), key)?; + push(root, remote, server_name)?; + let _ = writeln!( + out, + "enrolled {server_name} (server key, vouched for by {username})" + ); + Ok(()) +} + +/// Push `username`'s member ref to `remote` via a real `git push`, so the +/// remote's own hooks gate the enrollment exactly as any other push. +fn push(root: &LocalRoot, remote: &str, username: &str) -> Result<()> { + let refspec = format!("refs/meta/member/{username}"); + let output = Command::new("git") + .arg("-C") + .arg(&root.path) + .args(["push", remote, &refspec]) + .output() + .map_err(|source| Error::Io { + path: root.path.clone(), + source, + })?; + if !output.status.success() { + return Err(Error::Push { + refspec, + remote: remote.to_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + Ok(()) +} + +/// Fetch the server's public key from `remote`'s host at +/// `/.ents/server-key`, the path the hosted root's front proxy serves it +/// on while the web UI is fail-closed. +fn discover_server_pubkey(root: &LocalRoot, remote: &str) -> Result<String> { + let url = remote_url(root, remote)?; + let base = http_origin(&url).ok_or_else(|| Error::NotFound { + what: format!( + "an http(s) remote to discover the server key from ({remote} is {url}); \ + pass --server-pubkey instead" + ), + })?; + let endpoint = format!("{base}/.ents/server-key"); + let body = ureq::get(&endpoint) + .call() + .map_err(|source| Error::NotFound { + what: format!("the server key at {endpoint}: {source}"), + })? + .body_mut() + .read_to_string() + .map_err(|source| Error::NotFound { + what: format!("the server key at {endpoint}: {source}"), + })?; + let pubkey = body.trim(); + if !pubkey.starts_with("ssh-") { + return Err(Error::NotFound { + what: format!( + "an OpenSSH public key at {endpoint} — is this a git-ents hosted root awaiting \ + bootstrap?" + ), + }); + } + Ok(pubkey.to_owned()) +} + +/// `remote`'s configured URL, via `git remote get-url` so `insteadOf` +/// rewrites apply exactly as they would to the pushes that follow. +fn remote_url(root: &LocalRoot, remote: &str) -> Result<String> { + let output = Command::new("git") + .arg("-C") + .arg(&root.path) + .args(["remote", "get-url", remote]) + .output() + .map_err(|source| Error::Io { + path: root.path.clone(), + source, + })?; + if !output.status.success() { + return Err(Error::NotFound { + what: format!("a remote named {remote}"), + }); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) +} + +/// The `scheme://host[:port]` prefix of an http(s) URL, or `None` for any +/// other transport (ssh, scp-like, file) — discovery has nowhere to GET +/// from on those. +fn http_origin(url: &str) -> Option<String> { + let (scheme, rest) = url.split_once("://")?; + if scheme != "http" && scheme != "https" { + return None; + } + let host = rest.split('/').next()?; + (!host.is_empty()).then(|| format!("{scheme}://{host}")) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used, reason = "unit test")] + + use rstest::rstest; + + use super::*; + + #[rstest] + #[case::https("https://git.ents.cloud/repo.git", Some("https://git.ents.cloud"))] + #[case::port("http://127.0.0.1:8080/repo.git", Some("http://127.0.0.1:8080"))] + #[case::bare_host("https://git.ents.cloud", Some("https://git.ents.cloud"))] + #[case::ssh("ssh://git@ents.cloud/repo.git", None)] + #[case::scp_like("git@github.com:git-ents/git-ents.git", None)] + #[case::file_path("/data/repo.git", None)] + fn http_origin_accepts_only_http(#[case] url: &str, #[case] expected: Option<&str>) { + assert_eq!(http_origin(url).as_deref(), expected); + } +}