roots: mount the hosted web UI and add git ents login
commit 969cfab
roots: mount the hosted web UI and add git ents login
git ents serve --hosted (roots.single-node-hosted): the HostedRoot’s own
seams behind ents-web with SignInRequired, the server’s enrolled member
key as signing identity (boot fails closed with the enroll command when
it is not a member), still loopback-only — nginx stays the sole external
listener, routing *.git to git-http-backend and everything else to the
web process; the bash entrypoint supervises both with wait -n. git ents
login <url> <code> proves membership over ureq/rustls-ring (musl
cross-build safe; ISC/BSD-3-Clause/CDLA licenses allowlisted), rebuilding
the challenge payload locally so a server never chooses the signed bytes.
members::find_by_key generalizes off LocalRoot so both roots share the
one key-match loop. Fly suspends instead of stopping so memory-only
sessions survive idle.
Assisted-by: Claude:claude-fable-5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cargo.toml
@@ -105,6 +105,10 @@
target-lexicon = "0.13"
tempfile = "3"
thiserror = "2"
+# The CLI's one HTTP client (`git ents login`): synchronous, so no tokio
+# runtime for two requests, and rustls with the ring provider, so the
+# musl static release cross-build (`cargo zigbuild`) stays pure Rust.
+ureq = { version = "3", default-features = false, features = ["rustls"] }
tokio = { version = "1", features = [
"rt-multi-thread",
"macros",
.config/deny.toml
@@ -4,4 +4,10 @@
"MIT",
"Apache-2.0",
"Unicode-3.0",
+ # ureq's rustls/ring TLS stack (`git ents login`): ISC covers ring and
+ # untrusted; the others are the webpki verifier and Mozilla's root
+ # store. All permissive.
+ "ISC",
+ "BSD-3-Clause",
+ "CDLA-Permissive-2.0",
]
.config/fly.toml
@@ -17,6 +17,9 @@
[env]
PORT = '8080'
+ # The canonical host sign-in challenges bind to (`roots.web-signin`);
+ # the entrypoint passes it to `git ents serve --hosted --public-host`.
+ PUBLIC_HOST = 'git.ents.cloud'
[mounts]
source = 'git_ents_hosted'
@@ -25,7 +28,10 @@
[http_service]
internal_port = 8080
force_https = true
- auto_stop_machines = 'stop'
+ # Suspend, not stop: web sessions and sign-in challenges are memory-only
+ # by spec (`roots.web-session`), and suspend preserves RAM across idle
+ # periods — a deploy or crash still drops them, which the spec accepts.
+ auto_stop_machines = 'suspend'
auto_start_machines = true
min_machines_running = 0
processes = ['app']
docker/entrypoint.sh
@@ -1,10 +1,18 @@
-#!/bin/sh
+#!/bin/bash
# Bootstraps the single-node hosted root (`roots.single-node-hosted`) on
-# first boot, then serves it over stock git's smart-HTTP transport.
+# first boot, then serves it two ways behind one nginx: stock git's
+# smart-HTTP transport on the *.git paths, and the hosted web UI
+# (`git ents serve --hosted`) on everything else.
+#
+# bash, not sh: `wait -n` below is the whole process supervisor — first
+# long-running process to exit takes the machine down nonzero, and Fly
+# restarts it. Crude but honest for a single-node root; runit is the
+# upgrade path if either process starts crash-looping.
set -eu
repo=/data/repo.git
key=/data/hosted_signing_key
+public_host="${PUBLIC_HOST:-git.ents.cloud}"
if [ ! -d "$repo" ]; then
git init --quiet --bare "$repo"
@@ -19,4 +27,15 @@
mkdir -p /run
spawn-fcgi -s /run/fcgiwrap.sock -M 766 -- /usr/sbin/fcgiwrap
-exec nginx -c /etc/git-ents/nginx.conf -g "daemon off;"
+
+# The web UI refuses to boot until $key's public half is enrolled as a
+# member (`roots.web-signing`) — on a fresh volume, enroll it before the
+# first deploy that ships this entrypoint, or the machine crash-loops
+# with the exact command to run in its logs.
+git-ents serve --hosted --key "$key" --public-host "$public_host" --port 4880 "$repo" &
+nginx -c /etc/git-ents/nginx.conf -g "daemon off;" &
+
+wait -n
+code=$?
+kill 0 2>/dev/null || true
+exit "$code"
docker/nginx.conf
@@ -12,7 +12,10 @@
server {
listen 8080;
- location / {
+ # git smart-HTTP: any *.git path goes to stock git's own
+ # http-backend (`roots.single-node-hosted`: stock git stays the
+ # one git transport; the web process adds none of its own).
+ location ~ ^/[^/]+\.git(/.*)?$ {
# git push can be large; never buffer it into a temp file.
client_max_body_size 0;
gzip off;
@@ -29,5 +32,15 @@
# minimal environment with no PATH at all.
fastcgi_param PATH "/usr/local/bin:/usr/bin:/bin";
}
+
+ # Everything else: the hosted web UI (`git ents serve --hosted`),
+ # loopback-only inside this machine — nginx is the sole external
+ # listener.
+ location / {
+ proxy_pass http://127.0.0.1:4880;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_http_version 1.1;
+ }
}
}
crates/cli/git-ents/src/cli.rs
@@ -129,13 +129,31 @@
#[facet(args::subcommand)]
action: HookAction,
},
+ /// Prove membership to a hosted web session (`roots.web-signin`):
+ /// fetch the one-time challenge the hosted `/login` page displayed,
+ /// sign it with your member key under the `git-ents-login` SSHSIG
+ /// namespace — locally, the key never leaves this machine — and post
+ /// the signature back, signing that browser session in.
+ Login {
+ /// The hosted root's base URL, e.g. `https://git.ents.cloud`.
+ #[facet(args::positional)]
+ url: String,
+ /// The one-time code the `/login` page displays (`XXXX-XXXX`).
+ #[facet(args::positional)]
+ code: String,
+ /// Key to prove membership with; defaults to `user.signingkey`,
+ /// else `~/.ssh/id_ed25519`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
/// Start the local web UI (`roots.local`): reuses this repository's
/// existing local composition root (the same loose-ref `RefStore`,
/// odb, null `EventSink`, and advisory gate `git ents members`,
/// `git ents comment`, and every other porcelain command already use)
/// and adds only the `ents-web` HTTP frontend, bound to loopback —
/// never git's own smart-HTTP transport, which this command does not
- /// expose in any form.
+ /// expose in any form. With `--hosted`, serves the single-node hosted
+ /// root's web UI instead (`roots.single-node-hosted`).
Serve {
/// Port to bind on loopback (`127.0.0.1`); `0` picks any free
/// port. Defaults to 4880.
@@ -144,6 +162,22 @@
/// Key to sign web edits with; defaults to `user.signingkey`.
#[facet(args::named)]
key: Option<PathBuf>,
+ /// Serve the single-node hosted root's web UI instead
+ /// (`roots.single-node-hosted`): mandatory gate, sign-in
+ /// required, member-attributed edits, the server's own key as
+ /// signing identity. Still binds loopback — the front proxy is
+ /// the only external listener.
+ #[facet(args::named, default)]
+ hosted: bool,
+ /// The canonical public host bound into sign-in challenges with
+ /// `--hosted` (`roots.web-signin`), e.g. `git.ents.cloud`.
+ /// Required with `--hosted`; ignored without it.
+ #[facet(args::named)]
+ public_host: Option<String>,
+ /// The bare repository to serve with `--hosted`; defaults to the
+ /// current directory. Ignored without `--hosted`.
+ #[facet(args::positional, default)]
+ path: Option<PathBuf>,
},
/// Serve the editor lens (`lens.serve`): a Language Server Protocol
/// server over stdin/stdout that projects this repository's comments
crates/cli/git-ents/src/exe.rs
@@ -54,10 +54,29 @@
Top::Inbox { action } => run_inbox(action, out),
Top::Redact { action } => run_redact(action, out),
Top::Hook { action } => run_hook(action, out),
- Top::Serve { port, key } => {
+ Top::Login { url, code, key } => commands::login::run(&url, &code, key, out),
+ Top::Serve {
+ port,
+ key,
+ hosted: false,
+ ..
+ } => {
let root = LocalRoot::discover(".")?;
commands::serve::run(root, port, key, out)
}
+ // Root choice off the flag belongs exactly here, in the
+ // composition root's dispatch (`arch.no-hosted-branch` bans it in
+ // library code, not in `exe`).
+ Top::Serve {
+ port,
+ key,
+ hosted: true,
+ public_host,
+ path,
+ } => {
+ let root = HostedRoot::open(path.unwrap_or_else(|| ".".into()))?;
+ commands::serve::run_hosted(root, port, key, public_host, out)
+ }
Top::Lsp { key } => {
// The lens speaks LSP over stdin/stdout, so nothing may be
// written to `out` (the process's stdout) here — that stream is
@@ -73,7 +92,7 @@
let root = LocalRoot::discover(".")?;
match action {
MembersAction::List => {
- for (username, member) in commands::members::list(&root)? {
+ for (username, member) in commands::members::list(&root.refs, &root.objects)? {
let _ = writeln!(
out,
"{username}\t{:?}\t{:?}",
crates/cli/git-ents/src/sign.rs
@@ -99,13 +99,27 @@
/// payload cannot fail for the algorithms this module accepts.
#[must_use]
pub fn sign(&self, payload: &[u8]) -> String {
+ self.sign_in_namespace(GIT_SIGN_NAMESPACE, payload)
+ }
+
+ /// Sign `payload` under an explicit SSHSIG `namespace` — what
+ /// `git ents login` uses with `ents_web::auth::LOGIN_NAMESPACE`
+ /// (`roots.web-signin`): a sign-in signature deliberately lives in a
+ /// namespace distinct from [`Self::sign`]'s `git`, so neither can
+ /// ever double as the other.
+ ///
+ /// # Panics
+ ///
+ /// Never for a well-formed loaded key; see [`Self::sign`].
+ #[must_use]
+ pub fn sign_in_namespace(&self, namespace: &str, payload: &[u8]) -> String {
#[expect(
clippy::expect_used,
reason = "signing and PEM-rendering an ed25519 signature over any byte payload is \
infallible; mirrors `ents_testutil::Keypair::sign`'s identical, unguarded call"
)]
self.private
- .sign(GIT_SIGN_NAMESPACE, HashAlg::Sha512, payload)
+ .sign(namespace, HashAlg::Sha512, payload)
.expect("signing is infallible for a loaded, unencrypted key")
.to_pem(LineEnding::LF)
.expect("an SSHSIG always renders as PEM")
crates/cli/git-ents/tests/hosted_root.rs
@@ -167,6 +167,7 @@
offset: 0,
},
},
+ author: None,
sign: &|payload| admin_signer.sign(payload),
};
let config_ref: gix::refs::FullName =
@@ -221,3 +222,49 @@
"a refused pre-receive push must leave no trace on the hosted root"
);
}
+
+/// `serve --hosted` fails closed on an unenrolled server key
+/// (`roots.web-signing`: the signing key must itself be an enrolled
+/// member) and boots once the key is enrolled, with the enrolled
+/// username as the serving identity's label.
+// @relation(roots.web-signing, roots.single-node-hosted, scope=function, role=Verifies)
+#[test]
+fn hosted_serve_boots_only_with_an_enrolled_server_key() {
+ use ssh_key::private::{Ed25519Keypair, KeypairData};
+
+ let dir = tempfile::tempdir().expect("tempdir");
+ let bare = dir.path().join("repo.git");
+ let output = Command::new("git")
+ .args(["init", "--bare"])
+ .arg(&bare)
+ .output()
+ .expect("git runs");
+ assert!(output.status.success(), "{output:?}");
+
+ let key_path = dir.path().join("hosted_signing_key");
+ let pair = Ed25519Keypair::from_seed(&[42; 32]);
+ let key = ssh_key::PrivateKey::new(KeypairData::from(pair), "server").expect("well-formed");
+ key.write_openssh_file(&key_path, ssh_key::LineEnding::LF)
+ .expect("writes");
+
+ let root = git_ents::root::HostedRoot::open(&bare).expect("opens");
+ let refused = git_ents::commands::serve::build_hosted_state(
+ root,
+ key_path.clone(),
+ "ents.test".to_owned(),
+ );
+ assert!(
+ refused.is_err(),
+ "an unenrolled server key must refuse to boot"
+ );
+
+ let local = LocalRoot::open(&bare).expect("opens");
+ git_ents::commands::members::add(&local, "server", None, Some(key_path.clone()))
+ .expect("enrolls the server key");
+
+ let root = git_ents::root::HostedRoot::open(&bare).expect("opens");
+ let state =
+ git_ents::commands::serve::build_hosted_state(root, key_path, "ents.test".to_owned())
+ .expect("boots once enrolled");
+ assert_eq!(state.identity.label(), "server");
+}
crates/verify/ents-verify/src/durability.rs
@@ -14,7 +14,10 @@
//! invariant under study is unchanged: no ref points outside the
//! durable object set.
-#![expect(clippy::todo, reason = "Phase 5 skeleton — filling this in is the human exercise, not this scaffold's job")]
+#![expect(
+ clippy::todo,
+ reason = "Phase 5 skeleton — filling this in is the human exercise, not this scaffold's job"
+)]
use stateright::{Model, Property};
crates/verify/ents-verify/src/effects.rs
@@ -13,7 +13,10 @@
//! cross-transaction dedup obligation as a deliberate gap — this model
//! is where that obligation lives.
-#![expect(clippy::todo, reason = "Phase 4 skeleton — filling this in is the human exercise, not this scaffold's job")]
+#![expect(
+ clippy::todo,
+ reason = "Phase 4 skeleton — filling this in is the human exercise, not this scaffold's job"
+)]
use stateright::{Model, Property};
@@ -68,7 +71,9 @@
}
fn actions(&self, _state: &Self::State, _actions: &mut Vec<Self::Action>) {
- todo!("exercise: enumerate RefAdvance/TriggerEval/Enqueue/Execute/ResultPush per verify/exercise.md Phase 4")
+ todo!(
+ "exercise: enumerate RefAdvance/TriggerEval/Enqueue/Execute/ResultPush per verify/exercise.md Phase 4"
+ )
}
fn next_state(&self, _last_state: &Self::State, _action: Self::Action) -> Option<Self::State> {
@@ -80,16 +85,25 @@
// Obligation 1: the dedup key (effect, refname, new_oid)
// yields result-ref idempotency under duplicate delivery
// and executor crash-restart.
- Property::always("exactly_once_observable_effect", |_, _| true /* TODO(exercise) */),
+ Property::always(
+ "exactly_once_observable_effect",
+ |_, _| true, /* TODO(exercise) */
+ ),
// Obligation 2: a ref deleted and re-pushed to the same oid
// — does the commit re-enter the trigger set? Defines
// whether triggers are monotone.
- Property::always("trigger_set_monotone", |_, _| true /* TODO(exercise) */),
+ Property::always(
+ "trigger_set_monotone",
+ |_, _| true, /* TODO(exercise) */
+ ),
// Obligation 3: no sequence lets a non-admin cause execution
// of content they authored as an effect (composes with
// crate::search's binding_refname_recomputed property —
// this was the original cross-ref replay scenario).
- Property::always("authorization_asymmetry", |_, _| true /* TODO(exercise) */),
+ Property::always(
+ "authorization_asymmetry",
+ |_, _| true, /* TODO(exercise) */
+ ),
]
}
}
crates/verify/ents-verify/src/receive.rs
@@ -13,7 +13,10 @@
//! invariant, adoption, revocation), §4 (anti-replay);
//! `docs/spec/receive.adoc`; `docs/spec/gate.adoc` epoch bootstrap.
-#![expect(clippy::todo, reason = "Phase 3 skeleton — filling this in is the human exercise, not this scaffold's job")]
+#![expect(
+ clippy::todo,
+ reason = "Phase 3 skeleton — filling this in is the human exercise, not this scaffold's job"
+)]
use ents_gate_rules::{Facts, gate};
use stateright::{Model, Property};
@@ -85,11 +88,15 @@
}
fn actions(&self, _state: &Self::State, _actions: &mut Vec<Self::Action>) {
- todo!("exercise: enumerate Propose/GateCheck/Cas/AdoptMerge/SelfMerge per verify/exercise.md Phase 3")
+ todo!(
+ "exercise: enumerate Propose/GateCheck/Cas/AdoptMerge/SelfMerge per verify/exercise.md Phase 3"
+ )
}
fn next_state(&self, _last_state: &Self::State, _action: Self::Action) -> Option<Self::State> {
- todo!("exercise: Phase 3's transition relation, using gate_admits as GateCheck's enabling condition")
+ todo!(
+ "exercise: Phase 3's transition relation, using gate_admits as GateCheck's enabling condition"
+ )
}
fn properties(&self) -> Vec<Property<Self>> {
@@ -99,11 +106,17 @@
// action — pay attention to SelfMerge (is the merge commit
// itself signed in the implementation? see ents-sync/src
// and ents-receive/src/reconcile.rs).
- Property::always("tip_invariant_inductive", |_, _| true /* TODO(exercise) */),
+ Property::always(
+ "tip_invariant_inductive",
+ |_, _| true, /* TODO(exercise) */
+ ),
// Obligation 2: adoption preserves the tip invariant, even
// when the contributor's commit is itself a merge of
// unauthorized commits.
- Property::always("adoption_preserves_tip_invariant", |_, _| true /* TODO(exercise) */),
+ Property::always(
+ "adoption_preserves_tip_invariant",
+ |_, _| true, /* TODO(exercise) */
+ ),
// Obligation 3: a replayed genesis against a not-yet-created
// ref is safe only in conjunction with Phase 2's binding
// totality — state the exact conjunction.
crates/verify/ents-verify/src/search.rs
@@ -61,7 +61,12 @@
}
/// All four transaction shapes, in a fixed enumeration order.
-pub const SHAPES: [Shape; 4] = [Shape::Genesis, Shape::FastForward, Shape::NonFf, Shape::SecondRoot];
+pub const SHAPES: [Shape; 4] = [
+ Shape::Genesis,
+ Shape::FastForward,
+ Shape::NonFf,
+ Shape::SecondRoot,
+];
/// Who signs every commit the transaction introduces — one signer
/// applies uniformly to keep the state small; per-commit signer
@@ -95,8 +100,12 @@
}
/// All four retention choices, in a fixed enumeration order.
-pub const RETENTIONS: [Retention; 4] =
- [Retention::Absent, Retention::Resolving, Retention::DanglingAnchor, Retention::DanglingContext];
+pub const RETENTIONS: [Retention; 4] = [
+ Retention::Absent,
+ Retention::Resolving,
+ Retention::DanglingAnchor,
+ Retention::DanglingContext,
+];
/// All three [`Kind`] choices, in a fixed enumeration order.
pub const KINDS: [Kind; 3] = [Kind::Comment, Kind::Issue, Kind::Effect];
@@ -200,17 +209,29 @@
}
}
Shape::FastForward => {
- f.ref_update = vec![(self.ref_name.to_string(), Some("g".to_string()), "c1".to_string())];
+ f.ref_update = vec![(
+ self.ref_name.to_string(),
+ Some("g".to_string()),
+ "c1".to_string(),
+ )];
f.parent = vec![("c1".to_string(), "g".to_string())];
sign(&mut f, "g");
sign(&mut f, "c1");
}
Shape::NonFf => {
- f.ref_update = vec![(self.ref_name.to_string(), Some("g".to_string()), "x".to_string())];
+ f.ref_update = vec![(
+ self.ref_name.to_string(),
+ Some("g".to_string()),
+ "x".to_string(),
+ )];
sign(&mut f, "x");
}
Shape::SecondRoot => {
- f.ref_update = vec![(self.ref_name.to_string(), Some("g".to_string()), "m".to_string())];
+ f.ref_update = vec![(
+ self.ref_name.to_string(),
+ Some("g".to_string()),
+ "m".to_string(),
+ )];
f.parent = vec![
("c1".to_string(), "g".to_string()),
("m".to_string(), "c1".to_string()),
@@ -258,7 +279,10 @@
if !matches!(self.shape, Shape::Genesis) {
return true;
}
- !matches!(self.retention, Retention::DanglingAnchor | Retention::DanglingContext)
+ !matches!(
+ self.retention,
+ Retention::DanglingAnchor | Retention::DanglingContext
+ )
}
/// abstractions.adoc §6 / effect.admin-only: an admitted write to
@@ -327,12 +351,18 @@
let mut state = last_state.clone();
match action {
Action::ChooseRef(r) if state.ref_name.is_none() => state.ref_name = Some(r),
- Action::ChooseShape(s) if state.ref_name.is_some() && state.shape.is_none() => state.shape = Some(s),
- Action::ChooseSigner(s) if state.shape.is_some() && state.signer.is_none() => state.signer = Some(s),
+ Action::ChooseShape(s) if state.ref_name.is_some() && state.shape.is_none() => {
+ state.shape = Some(s)
+ }
+ Action::ChooseSigner(s) if state.shape.is_some() && state.signer.is_none() => {
+ state.signer = Some(s)
+ }
Action::ChooseRetention(r) if state.signer.is_some() && state.retention.is_none() => {
state.retention = Some(r);
}
- Action::ChooseKind(k) if state.retention.is_some() && state.kind.is_none() => state.kind = Some(k),
+ Action::ChooseKind(k) if state.retention.is_some() && state.kind.is_none() => {
+ state.kind = Some(k)
+ }
_ => return None,
}
Some(state)
@@ -349,17 +379,23 @@
vec![
Property::always("ff_only_advance", |_, s| implication(s, Complete::ff_holds)),
- Property::always("single_root_identity", |_, s| implication(s, Complete::single_root_holds)),
+ Property::always("single_root_identity", |_, s| {
+ implication(s, Complete::single_root_holds)
+ }),
Property::always("introduced_commits_member_signed", |_, s| {
implication(s, Complete::tip_signed_holds)
}),
- Property::always("anchor_retention_resolves", |_, s| implication(s, Complete::retention_holds)),
+ Property::always("anchor_retention_resolves", |_, s| {
+ implication(s, Complete::retention_holds)
+ }),
Property::always("effects_writes_admin_signed", |_, s| {
implication(s, Complete::effect_admin_holds)
}),
// The one property expected to have a discovery: the known,
// ledger-recorded DIVERGED gap. See tests::rediscovers_cross_ref_replay_by_search.
- Property::always("binding_refname_recomputed", |_, s| implication(s, Complete::binding_holds)),
+ Property::always("binding_refname_recomputed", |_, s| {
+ implication(s, Complete::binding_holds)
+ }),
]
}
}
@@ -382,7 +418,10 @@
let path = checker.assert_any_discovery("binding_refname_recomputed");
// Printed so a run's witness can be copied into the PR
// description as confirmation the harness has teeth.
- println!("binding_refname_recomputed witness: {:?}", path.into_actions());
+ println!(
+ "binding_refname_recomputed witness: {:?}",
+ path.into_actions()
+ );
checker.assert_no_discovery("ff_only_advance");
checker.assert_no_discovery("single_root_identity");
crates/cli/git-ents/src/commands/members.rs
@@ -3,6 +3,7 @@
use ents_model::{Member, MemberId, MemberState, Provenance, namespace};
use ents_receive::{Identity, propose_delete, propose_entity};
+use gix_object::Find;
use gix_ref_store::RefStoreRead;
use super::{actor, signer};
@@ -12,18 +13,22 @@
/// `git ents members list`: every member ref and its current state.
///
+/// Takes the ref and object seams rather than a concrete root so the same
+/// read path serves both composition roots — the local CLI and the hosted
+/// web mount both need "who are the members," never a root-specific rescan.
+///
/// # Errors
///
/// Propagates a ref-store or object read failure.
-pub fn list(root: &LocalRoot) -> Result<Vec<(String, Member)>> {
+pub fn list(refs: &impl RefStoreRead, objects: &impl Find) -> Result<Vec<(String, Member)>> {
let mut out = Vec::new();
- for entry in root.refs.iter_prefix("refs/meta/member/")? {
+ for entry in refs.iter_prefix("refs/meta/member/")? {
let (name, tip) = entry?;
let path = name.as_bstr().to_string();
let Some(username) = path.strip_prefix("refs/meta/member/") else {
continue;
};
- if let Some(member) = read_member(root, tip)? {
+ if let Some(member) = read_member(objects, tip)? {
out.push((username.to_owned(), member));
}
}
@@ -49,6 +54,7 @@
let name = namespace::member_ref(&MemberId::new(username))?;
let identity = Identity {
actor: actor(&signer),
+ author: None,
sign: &|payload| signer.sign(payload),
};
let outcome = propose_entity(
@@ -99,7 +105,7 @@
what: format!("member {username}"),
});
};
- let mut member = read_member(root, tip)?.ok_or_else(|| Error::NotFound {
+ let mut member = read_member(&root.objects, tip)?.ok_or_else(|| Error::NotFound {
what: format!("member {username}"),
})?;
member.state = if revoked {
@@ -109,6 +115,7 @@
};
let identity = Identity {
actor: actor(&signer),
+ author: None,
sign: &|payload| signer.sign(payload),
};
let verb = if revoked { "Revoke" } else { "Unrevoke" };
@@ -137,20 +144,25 @@
key: Option<std::path::PathBuf>,
) -> Result<Option<(String, MemberState)>> {
let signer = signer(root, key)?;
- find_by_key(root, &signer.public_openssh())
+ find_by_key(&root.refs, &root.objects, &signer.public_openssh())
}
/// Resolve `pubkey` to the enrolled member whose stored key matches it, if
-/// any -- the shared match loop behind [`check`] and `git ents serve`'s own
+/// any -- the shared match loop behind [`check`], `git ents serve`'s own
/// identity-chip label (`crate::commands::serve::build_state`,
-/// `roots.web-signing`): both need "which member owns this key," never a
-/// bespoke re-scan of `list`'s own rows.
+/// `roots.web-signing`), and the hosted mount's server-key enrollment
+/// check: all need "which member owns this key," never a bespoke re-scan
+/// of `list`'s own rows.
///
/// # Errors
///
/// Propagates a ref-store or object read failure.
-pub fn find_by_key(root: &LocalRoot, pubkey: &str) -> Result<Option<(String, MemberState)>> {
- for (username, member) in list(root)? {
+pub fn find_by_key(
+ refs: &impl RefStoreRead,
+ objects: &impl Find,
+ pubkey: &str,
+) -> Result<Option<(String, MemberState)>> {
+ for (username, member) in list(refs, objects)? {
if member.key == pubkey {
return Ok(Some((username, member.state)));
}
@@ -158,7 +170,7 @@
Ok(None)
}
-fn read_member(root: &LocalRoot, tip: gix_hash::ObjectId) -> Result<Option<Member>> {
- let tree = crate::commands::commit_tree(&root.objects, tip)?;
- Ok(facet_git_tree::deserialize::<Member>(&tree, &root.objects).ok())
+fn read_member(objects: &impl Find, tip: gix_hash::ObjectId) -> Result<Option<Member>> {
+ let tree = crate::commands::commit_tree(objects, tip)?;
+ Ok(facet_git_tree::deserialize::<Member>(&tree, objects).ok())
}
crates/cli/git-ents/src/commands/mod.rs
@@ -13,6 +13,7 @@
pub mod effect;
pub mod inbox;
pub mod issue;
+pub mod login;
pub mod lsp;
pub mod members;
pub mod redact;
crates/cli/git-ents/src/commands/serve.rs
@@ -130,7 +130,7 @@
// than re-scanning `refs/meta/member/*` by hand, falling back to the
// signer's own short fingerprint when no enrolled member's key matches
// (an unenrolled local key, still allowed to browse and sign).
- let label = super::members::find_by_key(&root, &pubkey)?
+ let label = super::members::find_by_key(&root.refs, &root.objects, &pubkey)?
.map(|(username, _state)| username)
.unwrap_or_else(|| super::short_fingerprint(&signer));
let identity = LocalIdentity {
@@ -156,24 +156,114 @@
)))
}
-/// Run `git ents serve`: bind loopback and block, serving the web UI
-/// until the process is killed.
+/// The hosted half of `roots.web-signing`'s indirection: the server's
+/// own member key (persisted by `setup --hosted`) signs every web edit,
+/// while the signed-in member is carried as the commit's author
+/// (`receive.attributed-author`). Same shape as [`LocalIdentity`]; a
+/// distinct type rather than a flag, per `arch.no-hosted-branch`'s
+/// spirit.
+// @relation(roots.web-signing, roots.single-node-hosted, scope=type)
+struct HostedIdentity {
+ signer: Signer,
+ actor: gix::actor::Signature,
+ /// The server key's own enrolled member username — [`build_hosted_state`]
+ /// refuses to start when none matches, so this is never a fallback
+ /// fingerprint.
+ label: String,
+}
+
+impl SigningIdentity for HostedIdentity {
+ fn actor(&self) -> gix::actor::Signature {
+ self.actor.clone()
+ }
+
+ fn sign(&self, payload: &[u8]) -> String {
+ self.signer.sign(payload)
+ }
+
+ fn public_openssh(&self) -> String {
+ self.signer.public_openssh()
+ }
+
+ fn label(&self) -> String {
+ self.label.clone()
+ }
+}
+
+/// Build the [`AppState`] `git ents serve --hosted` runs, from an
+/// already-open [`crate::root::HostedRoot`]: the same seams the
+/// `pre-receive`/`post-receive` hooks wire (`roots.single-node-hosted`),
+/// plus the web UI's own policy — sign-in required against
+/// `public_host` (`roots.web-signin`) and the server key as signing
+/// identity.
///
/// # Errors
///
-/// Propagates [`build_state`]'s own errors, or an [`Error::Io`] binding
-/// the loopback socket or constructing the async runtime.
-// @relation(roots.local, scope=function)
-pub fn run(
- root: LocalRoot,
- port: Option<u16>,
- key: Option<PathBuf>,
- mut report: impl std::io::Write,
-) -> Result<()> {
- let label = host_label(&root.path);
- let state = build_state(root, key)?;
- let addr = loopback_addr(port.unwrap_or(4880));
+/// [`Error::BadSigningKey`] if `key` cannot be loaded;
+/// [`Error::NotFound`] if the server key is not an enrolled member —
+/// `roots.web-signing` requires the signing key itself be enrolled, so
+/// an unenrolled key is a boot failure with a bootstrap instruction, not
+/// a warning: nothing it signed would be admitted anyway.
+// @relation(roots.single-node-hosted, roots.web-signin, roots.composition, scope=function)
+pub fn build_hosted_state(
+ root: crate::root::HostedRoot,
+ key: PathBuf,
+ public_host: String,
+) -> Result<Arc<AppState<crate::root::QuarantineObjects>>> {
+ let signer = Signer::load(&key)?;
+ let pubkey = signer.public_openssh();
+ let label = super::members::find_by_key(&root.refs, &root.objects, &pubkey)?
+ .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}\"",
+ key.display()
+ ),
+ })?;
+ let identity = HostedIdentity {
+ actor: actor(&signer),
+ label,
+ signer,
+ };
+ let mode = root.mode();
+ let crate::root::HostedRoot {
+ path,
+ refs,
+ objects,
+ events,
+ executor: _,
+ } = root;
+ Ok(Arc::new(
+ AppState::new(
+ Box::new(refs),
+ objects,
+ Box::new(events),
+ mode,
+ Box::new(identity),
+ path,
+ )
+ .with_access(ents_web::state::AccessPolicy::SignInRequired(
+ ents_web::state::Realm {
+ host: public_host,
+ challenges: ents_web::auth::ChallengeStore::default(),
+ },
+ )),
+ ))
+}
+/// Bind loopback, print `banner`, and serve `state` until killed — the
+/// runtime/bind/serve tail [`run`] and [`run_hosted`] share, generic over
+/// the object store exactly as `ents_web::serve_on` is.
+fn serve_state<O>(
+ state: Arc<AppState<O>>,
+ addr: SocketAddr,
+ banner: impl Fn(SocketAddr) -> String,
+ mut report: impl std::io::Write,
+) -> Result<()>
+where
+ O: gix_object::Find + gix_object::Write + Send + 'static,
+{
let runtime = tokio::runtime::Runtime::new().map_err(|source| Error::Io {
path: PathBuf::from("<tokio runtime>"),
source,
@@ -187,11 +277,7 @@
path: PathBuf::from(addr.to_string()),
source,
})?;
- let _ = writeln!(
- report,
- "listening on http://{label}.localhost:{port} (http://{bound})",
- port = bound.port()
- );
+ let _ = writeln!(report, "{}", banner(bound));
ents_web::serve_on(listener, state)
.await
.map_err(|source| Error::Io {
@@ -201,6 +287,74 @@
})
}
+/// Run `git ents serve`: bind loopback and block, serving the web UI
+/// until the process is killed.
+///
+/// # Errors
+///
+/// Propagates [`build_state`]'s own errors, or an [`Error::Io`] binding
+/// the loopback socket or constructing the async runtime.
+// @relation(roots.local, scope=function)
+pub fn run(
+ root: LocalRoot,
+ port: Option<u16>,
+ key: Option<PathBuf>,
+ report: impl std::io::Write,
+) -> Result<()> {
+ let label = host_label(&root.path);
+ let state = build_state(root, key)?;
+ let addr = loopback_addr(port.unwrap_or(4880));
+ serve_state(
+ state,
+ addr,
+ move |bound| {
+ format!(
+ "listening on http://{label}.localhost:{port} (http://{bound})",
+ port = bound.port()
+ )
+ },
+ report,
+ )
+}
+
+/// Run `git ents serve --hosted`: the single-node hosted root's web UI
+/// (`roots.single-node-hosted`), still bound to loopback — inside the
+/// hosted container the front proxy (nginx) is the only external
+/// listener, so no `--host` flag exists here either and
+/// [`loopback_addr`]'s guarantee stands unchanged.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] when `--public-host` is absent, plus
+/// [`build_hosted_state`]'s own errors and [`serve_state`]'s IO errors.
+// @relation(roots.single-node-hosted, roots.web-signin, scope=function)
+pub fn run_hosted(
+ root: crate::root::HostedRoot,
+ port: Option<u16>,
+ key: Option<PathBuf>,
+ public_host: Option<String>,
+ report: impl std::io::Write,
+) -> Result<()> {
+ let key = key.ok_or_else(|| Error::NotFound {
+ what: "--key: the hosted root's persisted signing key (setup --hosted writes it)"
+ .to_owned(),
+ })?;
+ let public_host = public_host.ok_or_else(|| Error::NotFound {
+ what: "--public-host: the canonical host sign-in challenges bind to (roots.web-signin)"
+ .to_owned(),
+ })?;
+ let state = build_hosted_state(root, key, public_host.clone())?;
+ let addr = loopback_addr(port.unwrap_or(4880));
+ serve_state(
+ state,
+ addr,
+ move |bound| {
+ format!("hosted web UI for https://{public_host} on http://{bound} (proxy-only)")
+ },
+ report,
+ )
+}
+
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, reason = "unit test")]
crates/cli/git-ents/src/commands/login.rs
@@ -1,0 +1,195 @@
+//! `git ents login <url> <code>`: prove membership to a hosted web
+//! session (`roots.web-signin`) — the automated replacement for the
+//! pre-redo forge's paste-the-signature sign-in.
+//!
+//! The browser's `/login` page on the hosted root displays a one-time
+//! code; this command fetches that code's challenge, **rebuilds the
+//! signed payload locally** from the host the member typed — never from
+//! bytes the server supplies, so a malicious or misconfigured server
+//! cannot get an arbitrary blob signed — signs it with the member's own
+//! key under `ents-web`'s login namespace (distinct from git's commit
+//! namespace by construction), and posts the signature back. On success
+//! the browser session that displayed the code is signed in; nothing
+//! secret ever leaves this machine.
+//!
+//! The HTTP client is `ureq`: synchronous (two requests need no runtime)
+//! and rustls/ring, so the hosted root's musl static release cross-build
+//! keeps working.
+// @relation(roots.web-signin, scope=file)
+
+use std::path::PathBuf;
+
+use ents_web::auth;
+
+use crate::error::{Error, Result};
+use crate::root::LocalRoot;
+use crate::sign::Signer;
+
+/// Run the sign-in: resolve the member key exactly as every mutation
+/// command does when run inside a repository (`--key`, else
+/// `user.signingkey`, else `~/.ssh/id_ed25519`), falling back to the
+/// same non-repository chain when run from anywhere else — a login
+/// targets a *hosted* root, so requiring a local clone would be
+/// arbitrary.
+///
+/// # Errors
+///
+/// [`Error::NoSigningKey`]/[`Error::BadSigningKey`] resolving the key;
+/// [`Error::NotFound`] for an unknown or expired code, a host mismatch
+/// between `url` and the server's own answer, or a server that refuses
+/// the signature (each with the server's own explanation).
+// @relation(roots.web-signin, scope=function)
+pub fn run(
+ url: &str,
+ code: &str,
+ key: Option<PathBuf>,
+ mut out: impl std::io::Write,
+) -> Result<()> {
+ let signer = resolve_signer(key)?;
+ let url = url.trim_end_matches('/');
+ let host = host_of(url)?;
+ let code = auth::normalize_code(code);
+
+ let agent = ureq::agent();
+ let challenge = agent
+ .get(format!("{url}/login/challenge/{code}"))
+ .call()
+ .map_err(|source| http_error(url, &source))?
+ .body_mut()
+ .read_to_string()
+ .map_err(|source| http_error(url, &source))?;
+ let served_host = line_value(&challenge, "host");
+ let nonce = line_value(&challenge, "nonce");
+ let (Some(served_host), Some(nonce)) = (served_host, nonce) else {
+ return Err(Error::NotFound {
+ what: format!("a challenge in {url}'s answer — is this a git-ents hosted root?"),
+ });
+ };
+ // The typed URL is the trust anchor (`roots.web-signin`): a server
+ // answering for a different host gets nothing signed.
+ if served_host != host {
+ return Err(Error::NotFound {
+ what: format!(
+ "host agreement: you addressed {host}, the server answers for {served_host}"
+ ),
+ });
+ }
+
+ let payload = auth::challenge_payload(&host, &code, nonce);
+ let signature = signer.sign_in_namespace(auth::LOGIN_NAMESPACE, payload.as_bytes());
+ let public_key = signer.public_openssh();
+ let _ = writeln!(out, "proving membership to {url} as {public_key}");
+
+ let response = agent
+ .post(format!("{url}/login/challenge/{code}"))
+ .send_form([
+ ("public_key", public_key.as_str()),
+ ("signature", signature.as_str()),
+ ]);
+ match response {
+ Ok(mut response) => {
+ let body = response.body_mut().read_to_string().unwrap_or_default();
+ let member = line_value(&body, "member").unwrap_or("<unknown>");
+ let _ = writeln!(
+ out,
+ "signed in: the browser session is now authenticated as {member}"
+ );
+ Ok(())
+ }
+ Err(ureq::Error::StatusCode(status)) => Err(Error::NotFound {
+ what: match status {
+ 401 => format!(
+ "membership: {url} refused the signature — is {public_key} enrolled and \
+ active there?"
+ ),
+ 404 | 410 => format!(
+ "a live sign-in code: {code} is unknown, expired, or already used; reload \
+ the sign-in page for a fresh one"
+ ),
+ other => format!("a sign-in answer from {url} (HTTP {other})"),
+ },
+ }),
+ Err(source) => Err(http_error(url, &source)),
+ }
+}
+
+/// Resolve the signing key with [`crate::commands::signer`]'s chain when
+/// inside a repository, else the same chain minus the repository config.
+fn resolve_signer(key: Option<PathBuf>) -> Result<Signer> {
+ match LocalRoot::discover(".") {
+ Ok(root) => crate::commands::signer(&root, key),
+ Err(_not_a_repo) => {
+ if let Some(path) = key {
+ return Signer::load(&path);
+ }
+ let home = std::env::var_os("HOME").map(PathBuf::from);
+ let default = home
+ .map(|home| home.join(".ssh").join("id_ed25519"))
+ .filter(|path| path.exists());
+ match default {
+ Some(path) => Signer::load(&path),
+ None => Err(Error::NoSigningKey),
+ }
+ }
+ }
+}
+
+/// The host (and non-default port) component of an `https://` or
+/// `http://` base URL — the exact string bound into the signed payload.
+fn host_of(url: &str) -> Result<String> {
+ let rest = url
+ .strip_prefix("https://")
+ .or_else(|| url.strip_prefix("http://"))
+ .ok_or_else(|| Error::NotFound {
+ what: format!("an http(s):// URL (got {url})"),
+ })?;
+ let host = rest.split('/').next().unwrap_or_default();
+ if host.is_empty() {
+ return Err(Error::NotFound {
+ what: format!("a host in {url}"),
+ });
+ }
+ Ok(host.to_owned())
+}
+
+/// The value of a `key=value` line in the server's plain-text answers.
+fn line_value<'a>(body: &'a str, key: &str) -> Option<&'a str> {
+ body.lines()
+ .find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
+}
+
+fn http_error(url: &str, source: &dyn std::fmt::Display) -> Error {
+ Error::NotFound {
+ what: format!("a reachable hosted root at {url}: {source}"),
+ }
+}
+
+#[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", "git.ents.cloud")]
+ #[case::trailing_path("https://git.ents.cloud/x", "git.ents.cloud")]
+ #[case::port("http://127.0.0.1:4880", "127.0.0.1:4880")]
+ fn host_of_extracts_the_authority(#[case] url: &str, #[case] expected: &str) {
+ assert_eq!(host_of(url).expect("parses"), expected);
+ }
+
+ #[rstest]
+ fn host_of_refuses_a_bare_name() {
+ host_of("git.ents.cloud").unwrap_err();
+ }
+
+ #[rstest]
+ fn line_value_reads_the_servers_plain_answers() {
+ let body = "host=ents.test\ncode=ABCD2345\nnonce=00ff\n";
+ assert_eq!(line_value(body, "host"), Some("ents.test"));
+ assert_eq!(line_value(body, "nonce"), Some("00ff"));
+ assert_eq!(line_value(body, "member"), None);
+ }
+}
crates/cli/git-ents/tests/login.rs
@@ -1,0 +1,117 @@
+//! End-to-end coverage of `git ents login` (`roots.web-signin`) against
+//! the real hosted-shaped web state served on a real loopback socket:
+//! the same `ents_web::router` the Fly deployment proxies to, the same
+//! `commands::login::run` a member's own machine executes. This doubles
+//! as the hosted-mount integration test — `build_hosted_state` wired and
+//! served end to end, no container needed.
+#![allow(clippy::expect_used, reason = "integration test")]
+
+use std::path::Path;
+use std::process::Command;
+
+use git_ents::root::LocalRoot;
+
+/// A bare repository with `username`'s freshly-written key enrolled,
+/// returning the key path.
+fn enrolled_bare(dir: &Path, username: &str, seed: u8) -> std::path::PathBuf {
+ let bare = dir.join("repo.git");
+ let output = Command::new("git")
+ .args(["init", "--bare"])
+ .arg(&bare)
+ .output()
+ .expect("git runs");
+ assert!(output.status.success(), "{output:?}");
+
+ use ssh_key::private::{Ed25519Keypair, KeypairData};
+ let key_path = dir.join(format!("key_{username}"));
+ let pair = Ed25519Keypair::from_seed(&[seed; 32]);
+ let key = ssh_key::PrivateKey::new(KeypairData::from(pair), username).expect("well-formed");
+ key.write_openssh_file(&key_path, ssh_key::LineEnding::LF)
+ .expect("writes");
+
+ let local = LocalRoot::open(&bare).expect("opens");
+ git_ents::commands::members::add(&local, username, None, Some(key_path.clone()))
+ .expect("enrolls");
+ key_path
+}
+
+// @relation(roots.web-signin, roots.single-node-hosted, scope=function, role=Verifies)
+#[tokio::test(flavor = "multi_thread")]
+async fn git_ents_login_signs_a_hosted_browser_session_in() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let server_key = enrolled_bare(dir.path(), "server", 42);
+ // Enroll the human member too, with their own distinct key.
+ use ssh_key::private::{Ed25519Keypair, KeypairData};
+ let member_key = dir.path().join("key_joey");
+ let pair = Ed25519Keypair::from_seed(&[7; 32]);
+ let key = ssh_key::PrivateKey::new(KeypairData::from(pair), "joey").expect("well-formed");
+ key.write_openssh_file(&member_key, ssh_key::LineEnding::LF)
+ .expect("writes");
+ let bare = dir.path().join("repo.git");
+ let local = LocalRoot::open(&bare).expect("opens");
+ git_ents::commands::members::add(&local, "joey", None, Some(member_key.clone()))
+ .expect("enrolls");
+
+ // Bind first so the realm's host names the real ephemeral port —
+ // `git ents login` refuses a host disagreement by design.
+ let listener = ents_web::bind("127.0.0.1:0".parse().expect("addr"))
+ .await
+ .expect("binds");
+ let host = listener.local_addr().expect("bound").to_string();
+ let root = git_ents::root::HostedRoot::open(&bare).expect("opens");
+ let state = git_ents::commands::serve::build_hosted_state(root, server_key, host.clone())
+ .expect("boots");
+ tokio::spawn(ents_web::serve_on(listener, state));
+
+ let url = format!("http://{host}");
+ let outcome = tokio::task::spawn_blocking(move || {
+ // The browser half: GET /login mints a session and displays the
+ // one-time code.
+ let agent = ureq::agent();
+ let mut page = agent
+ .get(format!("{url}/login"))
+ .call()
+ .expect("login page");
+ let cookie = page
+ .headers()
+ .get("set-cookie")
+ .expect("a fresh session cookie")
+ .to_str()
+ .expect("ascii")
+ .split(';')
+ .next()
+ .expect("cookie pair")
+ .to_owned();
+ let body = page.body_mut().read_to_string().expect("html");
+ let code: String = body
+ .split(&format!("{host} "))
+ .nth(1)
+ .expect("the page displays the login command")
+ .chars()
+ .take(9)
+ .collect();
+
+ // The CLI half: the real command, against the real socket.
+ let mut out = Vec::new();
+ git_ents::commands::login::run(&url, &code, Some(member_key), &mut out).expect("signs in");
+ let printed = String::from_utf8(out).expect("utf8");
+ assert!(
+ printed.contains("authenticated as joey"),
+ "reports the member: {printed}"
+ );
+
+ // The browser half again: the same session now reads signed in.
+ let mut page = agent
+ .get(format!("{url}/login"))
+ .header("cookie", &cookie)
+ .call()
+ .expect("login page");
+ let body = page.body_mut().read_to_string().expect("html");
+ assert!(
+ body.contains("Signed in as") && body.contains("joey"),
+ "the browser session is authenticated: {body}"
+ );
+ })
+ .await;
+ outcome.expect("blocking half succeeds");
+}