git-ents.gitmain
⌘K
foforge
bootstrap.rs219 lines · 8.3 KB · rusthistorycomment 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
20use std::path::PathBuf;
21use std::process::Command;
22
23use crate::commands::members;
24use crate::error::{Error, Result};
25use 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.
40pub 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.
77fn 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`.
114fn 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.
138fn 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.
171fn 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.
192fn 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)]
202mod 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}