crates/cli/git-ents/src/commands/bootstrap.rs
bootstrap.rshistorycomment on this file
| 1 | //! `git ents bootstrap`: an operator's first-boot enrollment of a fresh |
| 2 | //! hosted root, run from a clone — never on the server. |
| 3 | //! |
| 4 | //! Order is the whole design: the operator's own key lands first, as the |
| 5 | //! self-admitting first push (`gate.bootstrap`), and the server's key is |
| 6 | //! then vouched for under the operator's signature (`roots.web-signing`) |
| 7 | //! so `serve --hosted`'s fail-closed web UI can boot. Enrolling |
| 8 | //! server-side instead would spend the self-admitting push on the |
| 9 | //! server's own key, making the machine the trust root rather than the |
| 10 | //! operator — the reason `docker/entrypoint.sh` retries and waits for |
| 11 | //! this command instead of enrolling itself. |
| 12 | //! |
| 13 | //! The server key to vouch for is discovered, not copied: the front |
| 14 | //! proxy serves the key's public half at `/.ents/server-key` (written by |
| 15 | //! `git ents setup --hosted`) precisely while the web UI waits for this |
| 16 | //! enrollment. `--server-pubkey` overrides discovery for a remote that |
| 17 | //! is not http(s). Only the public half is ever fetched; the private key |
| 18 | //! never leaves the server's volume, in either direction. |
| 19 | |
| 20 | use std::path::PathBuf; |
| 21 | use std::process::Command; |
| 22 | |
| 23 | use crate::commands::members; |
| 24 | use crate::error::{Error, Result}; |
| 25 | use crate::root::LocalRoot; |
| 26 | |
| 27 | /// Enroll `username` (the operator, signed by `key` or `user.signingkey`) |
| 28 | /// and then `server_name` holding `server_pubkey` — discovered from |
| 29 | /// `remote` when not given — pushing each enrollment to `remote` as it |
| 30 | /// lands. |
| 31 | /// |
| 32 | /// # Errors |
| 33 | /// |
| 34 | /// [`Error::NotFound`] if discovery is needed but `remote` is not an |
| 35 | /// http(s) URL or does not answer with a public key; see [`members::add`] |
| 36 | /// for an enrollment failure; [`Error::Push`] if a push is refused or the |
| 37 | /// transport fails. Discovery and the operator's push both precede the |
| 38 | /// server enrollment, so a failure stops the bootstrap with nothing |
| 39 | /// half-done on the remote. |
| 40 | pub fn run( |
| 41 | root: &LocalRoot, |
| 42 | username: &str, |
| 43 | server_pubkey: Option<String>, |
| 44 | server_name: &str, |
| 45 | remote: &str, |
| 46 | key: Option<PathBuf>, |
| 47 | out: &mut impl std::io::Write, |
| 48 | ) -> Result<()> { |
| 49 | let server_pubkey = match server_pubkey { |
| 50 | Some(given) => given, |
| 51 | None => { |
| 52 | let discovered = discover_server_pubkey(root, remote)?; |
| 53 | let _ = writeln!(out, "discovered server key: {discovered}"); |
| 54 | discovered |
| 55 | } |
| 56 | }; |
| 57 | // `push`'s `--signed=if-asked` needs git's own signing config to |
| 58 | // match the key this command signs the enrollment commits with — |
| 59 | // ambient global config may point at a different key, or none. |
| 60 | configure_push_signing(root, key.as_deref())?; |
| 61 | members::add(root, username, None, key.clone())?; |
| 62 | push(root, remote, username)?; |
| 63 | let _ = writeln!(out, "enrolled {username} (self-admitting first push)"); |
| 64 | members::add(root, server_name, Some(server_pubkey), key)?; |
| 65 | push(root, remote, server_name)?; |
| 66 | let _ = writeln!( |
| 67 | out, |
| 68 | "enrolled {server_name} (server key, vouched for by {username})" |
| 69 | ); |
| 70 | Ok(()) |
| 71 | } |
| 72 | |
| 73 | /// Set `root`'s own `user.signingkey`/`gpg.format=ssh` to the key this |
| 74 | /// command signs enrollment commits with, so [`push`]'s `--signed=if-asked` |
| 75 | /// produces a push certificate under the same key rather than whatever |
| 76 | /// (or nothing) the ambient global config names. |
| 77 | fn configure_push_signing(root: &LocalRoot, key: Option<&std::path::Path>) -> Result<()> { |
| 78 | let repo = gix::open(&root.path)?; |
| 79 | let resolved = crate::sign::resolve_key_path(&repo, key)?; |
| 80 | for (name, value) in [ |
| 81 | ("user.signingkey", resolved.to_string_lossy().into_owned()), |
| 82 | ("gpg.format", "ssh".to_owned()), |
| 83 | ] { |
| 84 | let output = Command::new("git") |
| 85 | .arg("-C") |
| 86 | .arg(&root.path) |
| 87 | .args(["config", "--local", name, &value]) |
| 88 | .output() |
| 89 | .map_err(|source| Error::Io { |
| 90 | path: root.path.clone(), |
| 91 | source, |
| 92 | })?; |
| 93 | if !output.status.success() { |
| 94 | return Err(Error::Io { |
| 95 | path: root.path.clone(), |
| 96 | source: std::io::Error::other(format!( |
| 97 | "git config --local {name} {value} failed: {}", |
| 98 | String::from_utf8_lossy(&output.stderr) |
| 99 | )), |
| 100 | }); |
| 101 | } |
| 102 | } |
| 103 | Ok(()) |
| 104 | } |
| 105 | |
| 106 | /// Push `username`'s member ref to `remote` via a real `git push`, so the |
| 107 | /// remote's own hooks gate the enrollment exactly as any other push. |
| 108 | /// |
| 109 | /// `--signed=if-asked` signs the push whenever `remote` advertises |
| 110 | /// `push-cert` (the single-node hosted root does, once `git ents setup |
| 111 | /// --hosted` has run) and pushes unsigned otherwise — an operator's own |
| 112 | /// unenrolled key has nothing to sign *as* yet on the very first push of |
| 113 | /// all, so this cannot unconditionally require `--signed`. |
| 114 | fn push(root: &LocalRoot, remote: &str, username: &str) -> Result<()> { |
| 115 | let refspec = format!("refs/meta/member/{username}"); |
| 116 | let output = Command::new("git") |
| 117 | .arg("-C") |
| 118 | .arg(&root.path) |
| 119 | .args(["push", "--signed=if-asked", remote, &refspec]) |
| 120 | .output() |
| 121 | .map_err(|source| Error::Io { |
| 122 | path: root.path.clone(), |
| 123 | source, |
| 124 | })?; |
| 125 | if !output.status.success() { |
| 126 | return Err(Error::Push { |
| 127 | refspec, |
| 128 | remote: remote.to_owned(), |
| 129 | stderr: String::from_utf8_lossy(&output.stderr).into_owned(), |
| 130 | }); |
| 131 | } |
| 132 | Ok(()) |
| 133 | } |
| 134 | |
| 135 | /// Fetch the server's public key from `remote`'s host at |
| 136 | /// `/.ents/server-key`, the path the hosted root's front proxy serves it |
| 137 | /// on while the web UI is fail-closed. |
| 138 | fn discover_server_pubkey(root: &LocalRoot, remote: &str) -> Result<String> { |
| 139 | let url = remote_url(root, remote)?; |
| 140 | let base = http_origin(&url).ok_or_else(|| Error::NotFound { |
| 141 | what: format!( |
| 142 | "an http(s) remote to discover the server key from ({remote} is {url}); \ |
| 143 | pass --server-pubkey instead" |
| 144 | ), |
| 145 | })?; |
| 146 | let endpoint = format!("{base}/.ents/server-key"); |
| 147 | let body = ureq::get(&endpoint) |
| 148 | .call() |
| 149 | .map_err(|source| Error::NotFound { |
| 150 | what: format!("the server key at {endpoint}: {source}"), |
| 151 | })? |
| 152 | .body_mut() |
| 153 | .read_to_string() |
| 154 | .map_err(|source| Error::NotFound { |
| 155 | what: format!("the server key at {endpoint}: {source}"), |
| 156 | })?; |
| 157 | let pubkey = body.trim(); |
| 158 | if !pubkey.starts_with("ssh-") { |
| 159 | return Err(Error::NotFound { |
| 160 | what: format!( |
| 161 | "an OpenSSH public key at {endpoint} — is this a git-ents hosted root awaiting \ |
| 162 | bootstrap?" |
| 163 | ), |
| 164 | }); |
| 165 | } |
| 166 | Ok(pubkey.to_owned()) |
| 167 | } |
| 168 | |
| 169 | /// `remote`'s configured URL, via `git remote get-url` so `insteadOf` |
| 170 | /// rewrites apply exactly as they would to the pushes that follow. |
| 171 | fn remote_url(root: &LocalRoot, remote: &str) -> Result<String> { |
| 172 | let output = Command::new("git") |
| 173 | .arg("-C") |
| 174 | .arg(&root.path) |
| 175 | .args(["remote", "get-url", remote]) |
| 176 | .output() |
| 177 | .map_err(|source| Error::Io { |
| 178 | path: root.path.clone(), |
| 179 | source, |
| 180 | })?; |
| 181 | if !output.status.success() { |
| 182 | return Err(Error::NotFound { |
| 183 | what: format!("a remote named {remote}"), |
| 184 | }); |
| 185 | } |
| 186 | Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) |
| 187 | } |
| 188 | |
| 189 | /// The `scheme://host[:port]` prefix of an http(s) URL, or `None` for any |
| 190 | /// other transport (ssh, scp-like, file) — discovery has nowhere to GET |
| 191 | /// from on those. |
| 192 | fn http_origin(url: &str) -> Option<String> { |
| 193 | let (scheme, rest) = url.split_once("://")?; |
| 194 | if scheme != "http" && scheme != "https" { |
| 195 | return None; |
| 196 | } |
| 197 | let host = rest.split('/').next()?; |
| 198 | (!host.is_empty()).then(|| format!("{scheme}://{host}")) |
| 199 | } |
| 200 | |
| 201 | #[cfg(test)] |
| 202 | mod tests { |
| 203 | #![allow(clippy::expect_used, clippy::unwrap_used, reason = "unit test")] |
| 204 | |
| 205 | use rstest::rstest; |
| 206 | |
| 207 | use super::*; |
| 208 | |
| 209 | #[rstest] |
| 210 | #[case::https("https://git.ents.cloud/repo.git", Some("https://git.ents.cloud"))] |
| 211 | #[case::port("http://127.0.0.1:8080/repo.git", Some("http://127.0.0.1:8080"))] |
| 212 | #[case::bare_host("https://git.ents.cloud", Some("https://git.ents.cloud"))] |
| 213 | #[case::ssh("ssh://git@ents.cloud/repo.git", None)] |
| 214 | #[case::scp_like("git@github.com:git-ents/git-ents.git", None)] |
| 215 | #[case::file_path("/data/repo.git", None)] |
| 216 | fn http_origin_accepts_only_http(#[case] url: &str, #[case] expected: Option<&str>) { |
| 217 | assert_eq!(http_origin(url).as_deref(), expected); |
| 218 | } |
| 219 | } |