git-ents.gitmain
⌘K
foforge
serve.rs388 lines · 13.6 KB · rusthistorycomment 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
28use std::net::{IpAddr, Ipv4Addr, SocketAddr};
29use std::path::PathBuf;
30use std::sync::Arc;
31
32use ents_web::identity::SigningIdentity;
33use ents_web::state::AppState;
34
35use super::actor;
36use crate::error::{Error, Result};
37use crate::root::LocalRoot;
38use 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)
44struct 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
54impl 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)
76fn 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.
89fn 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)
122pub 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)
166struct 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
175impl 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)
208pub 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.
258fn 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<()>
264where
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)
298pub 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)
331pub 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)]
359mod 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}