crates/cli/git-ents/src/commands/serve.rs
serve.rshistorycomment on this file
| 1 | //! `git ents serve`: reuse [`LocalRoot`]'s existing wiring and add only |
| 2 | //! the `ents-web` HTTP frontend, bound to loopback (`roots.local`). |
| 3 | //! |
| 4 | //! `roots.local` is explicit that this command MUST reuse the local |
| 5 | //! root's own seams rather than construct a second one, and MUST NOT |
| 6 | //! expose git's smart-HTTP transport in any form. This module upholds |
| 7 | //! both: [`build_state`] is handed an already-open [`LocalRoot`] (never |
| 8 | //! opens its own), and adds nothing but `ents_web::router()`'s own route |
| 9 | //! table -- which carries no `/info/refs` or `git-upload-pack` surface at |
| 10 | //! all (see `ents-web`'s own test coverage for that half). |
| 11 | //! |
| 12 | //! # Signing identity (`roots.web-signing`) |
| 13 | //! |
| 14 | //! `LocalIdentity` is the one place this crate bridges its own |
| 15 | //! [`Signer`] to [`ents_web::identity::SigningIdentity`]: the local root |
| 16 | //! signs every web edit with the user's own member key, resolved exactly |
| 17 | //! as every other mutation command resolves it (`--key`, else |
| 18 | //! `user.signingkey`, else the default `~/.ssh/id_ed25519`) — no |
| 19 | //! server-key indirection exists anywhere in this module, which is |
| 20 | //! exactly what keeps `roots.web-signing`'s hosted-only indirection from |
| 21 | //! leaking into the local root. `LocalIdentity::label` additionally |
| 22 | //! resolves the signer's own enrolled member (reusing |
| 23 | //! `crate::commands::members::find_by_key`, the same key-match loop |
| 24 | //! `git ents members check` runs), so the web shell's identity chip shows |
| 25 | //! a username instead of [`actor`]'s fixed `"git-ents"` commit-author |
| 26 | //! wordmark. |
| 27 | |
| 28 | use std::net::{IpAddr, Ipv4Addr, SocketAddr}; |
| 29 | use std::path::PathBuf; |
| 30 | use std::sync::Arc; |
| 31 | |
| 32 | use ents_web::identity::SigningIdentity; |
| 33 | use ents_web::state::AppState; |
| 34 | |
| 35 | use super::actor; |
| 36 | use crate::error::{Error, Result}; |
| 37 | use crate::root::LocalRoot; |
| 38 | use crate::sign::Signer; |
| 39 | |
| 40 | /// Bridges [`Signer`] to [`SigningIdentity`]: the local root's half of |
| 41 | /// `roots.web-signing`'s indirection (the user's own key, captured once |
| 42 | /// at `serve` startup rather than re-resolved per request). |
| 43 | // @relation(roots.web-signing, scope=file) |
| 44 | struct LocalIdentity { |
| 45 | signer: Signer, |
| 46 | actor: gix::actor::Signature, |
| 47 | /// The web shell's identity-chip label (see [`SigningIdentity::label`]'s |
| 48 | /// own doc): the signer's enrolled member username when one matches, |
| 49 | /// its short key fingerprint otherwise — resolved once in |
| 50 | /// [`build_state`], not per request. |
| 51 | label: String, |
| 52 | } |
| 53 | |
| 54 | impl SigningIdentity for LocalIdentity { |
| 55 | fn actor(&self) -> gix::actor::Signature { |
| 56 | self.actor.clone() |
| 57 | } |
| 58 | |
| 59 | fn sign(&self, payload: &[u8]) -> String { |
| 60 | self.signer.sign(payload) |
| 61 | } |
| 62 | |
| 63 | fn public_openssh(&self) -> String { |
| 64 | self.signer.public_openssh() |
| 65 | } |
| 66 | |
| 67 | fn label(&self) -> String { |
| 68 | self.label.clone() |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | /// The loopback address `git ents serve` binds -- `roots.local` forbids |
| 73 | /// this command from exposing anything but loopback, so there is no |
| 74 | /// `--host` flag anywhere in [`crate::cli`] to override it. |
| 75 | // @relation(roots.local, scope=function) |
| 76 | fn loopback_addr(port: u16) -> SocketAddr { |
| 77 | SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port) |
| 78 | } |
| 79 | |
| 80 | /// The `<label>.localhost` hostname printed for the served repository: |
| 81 | /// its directory name lowercased with every character outside |
| 82 | /// `[a-z0-9]` folded to `-` (a DNS label), `repo` when nothing |
| 83 | /// survives. `*.localhost` names resolve to loopback inside Firefox |
| 84 | /// and Chrome themselves (RFC 6761) and count as a secure context, so |
| 85 | /// the printed URL carries the repo's name instead of a bare |
| 86 | /// `127.0.0.1` — same socket, nicer address bar. Safari delegates to |
| 87 | /// the system resolver and needs an `/etc/hosts` line, which is why |
| 88 | /// the raw bound address is still printed alongside. |
| 89 | fn host_label(path: &std::path::Path) -> String { |
| 90 | // `LocalRoot::discover(".")` hands this a relative path whose |
| 91 | // file_name is `.`; canonicalize first so the label is the repo |
| 92 | // directory's real name, not the fallback. |
| 93 | let path = path.canonicalize().unwrap_or_else(|_io| path.to_path_buf()); |
| 94 | let name = path |
| 95 | .file_name() |
| 96 | .map(|n| n.to_string_lossy().to_lowercase()) |
| 97 | .unwrap_or_default(); |
| 98 | let label: String = name |
| 99 | .chars() |
| 100 | .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) |
| 101 | .collect(); |
| 102 | let label = label.trim_matches('-'); |
| 103 | if label.is_empty() { |
| 104 | "repo".to_owned() |
| 105 | } else { |
| 106 | label.to_owned() |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// Build the [`AppState`] `git ents serve` runs, from an already-open |
| 111 | /// [`LocalRoot`] -- the one seam-wiring step `roots.composition` allows, |
| 112 | /// and the only place this command touches `root`'s fields at all. |
| 113 | /// |
| 114 | /// Split out from [`run`] so tests can drive the resulting state through |
| 115 | /// `ents_web::router()` directly (via `tower::ServiceExt::oneshot`, per |
| 116 | /// `roots.web-agnostic`) without binding a socket or blocking. |
| 117 | /// |
| 118 | /// # Errors |
| 119 | /// |
| 120 | /// Propagates a signing-key resolution failure ([`crate::sign::Signer`]). |
| 121 | // @relation(roots.local, roots.composition, scope=function) |
| 122 | pub fn build_state( |
| 123 | root: LocalRoot, |
| 124 | key: Option<PathBuf>, |
| 125 | ) -> Result<Arc<AppState<crate::root::Objects>>> { |
| 126 | let signer = super::signer(&root, key)?; |
| 127 | let pubkey = signer.public_openssh(); |
| 128 | // The identity chip's label (`roots.web-signing`): reuse the same |
| 129 | // key-match loop `git ents members check` runs (`find_by_key`) rather |
| 130 | // than re-scanning `refs/meta/member/*` by hand, falling back to the |
| 131 | // signer's own short fingerprint when no enrolled member's key matches |
| 132 | // (an unenrolled local key, still allowed to browse and sign). |
| 133 | let label = super::members::find_by_key(&root.refs, &root.objects, &pubkey)? |
| 134 | .map(|(username, _state)| username) |
| 135 | .unwrap_or_else(|| super::short_fingerprint(&signer)); |
| 136 | let identity = LocalIdentity { |
| 137 | actor: actor(&signer), |
| 138 | label, |
| 139 | signer, |
| 140 | }; |
| 141 | let mode = root.mode(); |
| 142 | let LocalRoot { |
| 143 | path, |
| 144 | refs, |
| 145 | objects, |
| 146 | events, |
| 147 | executor: _, |
| 148 | } = root; |
| 149 | Ok(Arc::new(AppState::new( |
| 150 | Box::new(refs), |
| 151 | objects, |
| 152 | Box::new(events), |
| 153 | mode, |
| 154 | Box::new(identity), |
| 155 | path, |
| 156 | ))) |
| 157 | } |
| 158 | |
| 159 | /// The hosted half of `roots.web-signing`'s indirection: the server's |
| 160 | /// own member key (persisted by `setup --hosted`) signs every web edit, |
| 161 | /// while the signed-in member is carried as the commit's author |
| 162 | /// (`receive.attributed-author`). Same shape as [`LocalIdentity`]; a |
| 163 | /// distinct type rather than a flag, per `arch.no-hosted-branch`'s |
| 164 | /// spirit. |
| 165 | // @relation(roots.web-signing, roots.single-node-hosted, scope=type) |
| 166 | struct HostedIdentity { |
| 167 | signer: Signer, |
| 168 | actor: gix::actor::Signature, |
| 169 | /// The server key's own enrolled member username — [`build_hosted_state`] |
| 170 | /// refuses to start when none matches, so this is never a fallback |
| 171 | /// fingerprint. |
| 172 | label: String, |
| 173 | } |
| 174 | |
| 175 | impl SigningIdentity for HostedIdentity { |
| 176 | fn actor(&self) -> gix::actor::Signature { |
| 177 | self.actor.clone() |
| 178 | } |
| 179 | |
| 180 | fn sign(&self, payload: &[u8]) -> String { |
| 181 | self.signer.sign(payload) |
| 182 | } |
| 183 | |
| 184 | fn public_openssh(&self) -> String { |
| 185 | self.signer.public_openssh() |
| 186 | } |
| 187 | |
| 188 | fn label(&self) -> String { |
| 189 | self.label.clone() |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | /// Build the [`AppState`] `git ents serve --hosted` runs, from an |
| 194 | /// already-open [`crate::root::HostedRoot`]: the same seams the |
| 195 | /// `pre-receive`/`post-receive` hooks wire (`roots.single-node-hosted`), |
| 196 | /// plus the web UI's own policy — sign-in required against |
| 197 | /// `public_host` (`roots.web-signin`) and the server key as signing |
| 198 | /// identity. |
| 199 | /// |
| 200 | /// # Errors |
| 201 | /// |
| 202 | /// [`Error::BadSigningKey`] if `key` cannot be loaded; |
| 203 | /// [`Error::NotFound`] if the server key is not an enrolled member — |
| 204 | /// `roots.web-signing` requires the signing key itself be enrolled, so |
| 205 | /// an unenrolled key is a boot failure with a bootstrap instruction, not |
| 206 | /// a warning: nothing it signed would be admitted anyway. |
| 207 | // @relation(roots.single-node-hosted, roots.web-signin, roots.composition, scope=function) |
| 208 | pub fn build_hosted_state( |
| 209 | root: crate::root::HostedRoot, |
| 210 | key: PathBuf, |
| 211 | public_host: String, |
| 212 | ) -> Result<Arc<AppState<crate::root::QuarantineObjects>>> { |
| 213 | let signer = Signer::load(&key)?; |
| 214 | let pubkey = signer.public_openssh(); |
| 215 | let label = super::members::find_by_key(&root.refs, &root.objects, &pubkey)? |
| 216 | .map(|(username, _state)| username) |
| 217 | .ok_or_else(|| Error::NotFound { |
| 218 | what: format!( |
| 219 | "an enrolled member holding the server key {} — bootstrap from a clone: \ |
| 220 | git ents bootstrap <you> --server-pubkey \"{pubkey}\"", |
| 221 | key.display() |
| 222 | ), |
| 223 | })?; |
| 224 | let identity = HostedIdentity { |
| 225 | actor: actor(&signer), |
| 226 | label, |
| 227 | signer, |
| 228 | }; |
| 229 | let mode = root.mode(); |
| 230 | let crate::root::HostedRoot { |
| 231 | path, |
| 232 | refs, |
| 233 | objects, |
| 234 | events, |
| 235 | executor: _, |
| 236 | } = root; |
| 237 | Ok(Arc::new( |
| 238 | AppState::new( |
| 239 | Box::new(refs), |
| 240 | objects, |
| 241 | Box::new(events), |
| 242 | mode, |
| 243 | Box::new(identity), |
| 244 | path, |
| 245 | ) |
| 246 | .with_access(ents_web::state::AccessPolicy::SignInRequired( |
| 247 | ents_web::state::Realm { |
| 248 | host: public_host, |
| 249 | challenges: ents_web::auth::ChallengeStore::default(), |
| 250 | }, |
| 251 | )), |
| 252 | )) |
| 253 | } |
| 254 | |
| 255 | /// Bind loopback, print `banner`, and serve `state` until killed — the |
| 256 | /// runtime/bind/serve tail [`run`] and [`run_hosted`] share, generic over |
| 257 | /// the object store exactly as `ents_web::serve_on` is. |
| 258 | fn serve_state<O>( |
| 259 | state: Arc<AppState<O>>, |
| 260 | addr: SocketAddr, |
| 261 | banner: impl Fn(SocketAddr) -> String, |
| 262 | mut report: impl std::io::Write, |
| 263 | ) -> Result<()> |
| 264 | where |
| 265 | O: gix_object::Find + gix_object::Write + Send + 'static, |
| 266 | { |
| 267 | let runtime = tokio::runtime::Runtime::new().map_err(|source| Error::Io { |
| 268 | path: PathBuf::from("<tokio runtime>"), |
| 269 | source, |
| 270 | })?; |
| 271 | runtime.block_on(async move { |
| 272 | let listener = ents_web::bind(addr).await.map_err(|source| Error::Io { |
| 273 | path: PathBuf::from(addr.to_string()), |
| 274 | source, |
| 275 | })?; |
| 276 | let bound = listener.local_addr().map_err(|source| Error::Io { |
| 277 | path: PathBuf::from(addr.to_string()), |
| 278 | source, |
| 279 | })?; |
| 280 | let _ = writeln!(report, "{}", banner(bound)); |
| 281 | ents_web::serve_on(listener, state) |
| 282 | .await |
| 283 | .map_err(|source| Error::Io { |
| 284 | path: PathBuf::from(addr.to_string()), |
| 285 | source, |
| 286 | }) |
| 287 | }) |
| 288 | } |
| 289 | |
| 290 | /// Run `git ents serve`: bind loopback and block, serving the web UI |
| 291 | /// until the process is killed. |
| 292 | /// |
| 293 | /// # Errors |
| 294 | /// |
| 295 | /// Propagates [`build_state`]'s own errors, or an [`Error::Io`] binding |
| 296 | /// the loopback socket or constructing the async runtime. |
| 297 | // @relation(roots.local, scope=function) |
| 298 | pub fn run( |
| 299 | root: LocalRoot, |
| 300 | port: Option<u16>, |
| 301 | key: Option<PathBuf>, |
| 302 | report: impl std::io::Write, |
| 303 | ) -> Result<()> { |
| 304 | let label = host_label(&root.path); |
| 305 | let state = build_state(root, key)?; |
| 306 | let addr = loopback_addr(port.unwrap_or(4880)); |
| 307 | serve_state( |
| 308 | state, |
| 309 | addr, |
| 310 | move |bound| { |
| 311 | format!( |
| 312 | "listening on http://{label}.localhost:{port} (http://{bound})", |
| 313 | port = bound.port() |
| 314 | ) |
| 315 | }, |
| 316 | report, |
| 317 | ) |
| 318 | } |
| 319 | |
| 320 | /// Run `git ents serve --hosted`: the single-node hosted root's web UI |
| 321 | /// (`roots.single-node-hosted`), still bound to loopback — inside the |
| 322 | /// hosted container the front proxy (nginx) is the only external |
| 323 | /// listener, so no `--host` flag exists here either and |
| 324 | /// [`loopback_addr`]'s guarantee stands unchanged. |
| 325 | /// |
| 326 | /// # Errors |
| 327 | /// |
| 328 | /// [`Error::NotFound`] when `--public-host` is absent, plus |
| 329 | /// [`build_hosted_state`]'s own errors and [`serve_state`]'s IO errors. |
| 330 | // @relation(roots.single-node-hosted, roots.web-signin, scope=function) |
| 331 | pub fn run_hosted( |
| 332 | root: crate::root::HostedRoot, |
| 333 | port: Option<u16>, |
| 334 | key: Option<PathBuf>, |
| 335 | public_host: Option<String>, |
| 336 | report: impl std::io::Write, |
| 337 | ) -> Result<()> { |
| 338 | let key = key.ok_or_else(|| Error::NotFound { |
| 339 | what: "--key: the hosted root's persisted signing key (setup --hosted writes it)" |
| 340 | .to_owned(), |
| 341 | })?; |
| 342 | let public_host = public_host.ok_or_else(|| Error::NotFound { |
| 343 | what: "--public-host: the canonical host sign-in challenges bind to (roots.web-signin)" |
| 344 | .to_owned(), |
| 345 | })?; |
| 346 | let state = build_hosted_state(root, key, public_host.clone())?; |
| 347 | let addr = loopback_addr(port.unwrap_or(4880)); |
| 348 | serve_state( |
| 349 | state, |
| 350 | addr, |
| 351 | move |bound| { |
| 352 | format!("hosted web UI for https://{public_host} on http://{bound} (proxy-only)") |
| 353 | }, |
| 354 | report, |
| 355 | ) |
| 356 | } |
| 357 | |
| 358 | #[cfg(test)] |
| 359 | mod tests { |
| 360 | #![allow(clippy::expect_used, reason = "unit test")] |
| 361 | |
| 362 | use rstest::rstest; |
| 363 | |
| 364 | use super::*; |
| 365 | |
| 366 | #[rstest] |
| 367 | #[case::plain_repo_name("git-ents", "git-ents")] |
| 368 | #[case::uppercase_and_dots("My_Repo.git", "my-repo-git")] |
| 369 | #[case::nothing_survives("...", "repo")] |
| 370 | fn host_label_folds_to_a_dns_label(#[case] dir: &str, #[case] expected: &str) { |
| 371 | assert_eq!(host_label(std::path::Path::new(dir)), expected); |
| 372 | } |
| 373 | |
| 374 | /// `discover(".")` hands serve a relative path; the label must be the |
| 375 | /// directory's real name, never the `repo` fallback `.`'s empty |
| 376 | /// file_name would fold to. |
| 377 | #[rstest] |
| 378 | fn host_label_canonicalizes_a_relative_path() { |
| 379 | assert_ne!(host_label(std::path::Path::new(".")), "repo"); |
| 380 | } |
| 381 | |
| 382 | #[rstest] |
| 383 | // @relation(roots.local, scope=function, role=Verifies) |
| 384 | fn serve_only_ever_binds_loopback() { |
| 385 | assert_eq!(loopback_addr(4880).ip(), IpAddr::V4(Ipv4Addr::LOCALHOST)); |
| 386 | assert_eq!(loopback_addr(0).ip(), IpAddr::V4(Ipv4Addr::LOCALHOST)); |
| 387 | } |
| 388 | } |