roots: add git ents bootstrap with server-key discovery
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.
No reviews of this commit yet — record a verdict below.
Start a review
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/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);
+ }
+}