feat: sign in through the CLI and debug a repo's checks Sprite over it
commit 26a888c
feat: sign in through the CLI and debug a repo's checks Sprite over it
Auth proofs that used to require a browser now work from git ents
directly: the CLI signs the server’s one-time challenge with the same
key a signed push uses, so it can complete the exact login the web UI
does without ever pasting anything by hand. That session then
authorizes a second command - an interactive, read-write shell in the
repository’s persistent checks Sprite - brokered over a WebSocket so
the member never needs their own Fly credential; the server holds the
one SPRITES_TOKEN and relays bytes while sprite exec --tty handles
the remote pseudo-TTY.
feat: add plain-text /login/cli challenge and verify endpoints for CLI sign-in
feat: add git ents login to sign in via a CLI-signed challenge
feat: add /_debug/<repo> WebSocket broker for an interactive checks-Sprite shell
feat: add git ents checks debug to open a raw-mode shell over it
Assisted-by: Claude:claude-sonnet-5
crates/git-ents-server/src/checks.rs
@@ -314,7 +314,10 @@
/// A Sprite name derived from the repository directory, kept to the
/// `[a-z0-9-]` a Sprite name allows so the same repo reuses the same sandbox.
-fn sprite_name(repo: &Path) -> String {
+///
+/// Shared with [`crate::web`]'s debug-session broker, which targets the same
+/// persistent per-repo Sprite a check run used.
+pub(crate) fn sprite_name(repo: &Path) -> String {
let stem = repo
.file_name()
.map(|name| name.to_string_lossy())
@@ -341,7 +344,7 @@
/// token per call, so without this it reports "no organizations configured"
/// even with the token in the environment. `auth setup` is idempotent, so it is
/// run on every push to keep the steady state self-healing.
-fn ensure_auth() -> Result<(), String> {
+pub(crate) fn ensure_auth() -> Result<(), String> {
let token = std::env::var("SPRITES_TOKEN")
.ok()
.ok_or("SPRITES_TOKEN is not set in the hook environment")?;
@@ -363,7 +366,7 @@
/// fails when the Sprite is already there, which is the steady state once the
/// first push has run, so its failure is tolerated and surfaces only later if
/// the Sprite turns out to be unreachable.
-fn ensure_sprite(sprite: &str) -> Result<(), String> {
+pub(crate) fn ensure_sprite(sprite: &str) -> Result<(), String> {
let _existing = Command::new("sprite")
.args(["create", "--skip-console", sprite])
.output()
crates/git-ents/src/main.rs
@@ -8,6 +8,8 @@
//! `refs/meta/member/*` refs into the local repository, editing them through
//! [`git_ents::members`], and pushing them back.
+mod debug_session;
+
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode, Stdio};
@@ -42,6 +44,17 @@
#[command(subcommand)]
action: ChecksAction,
},
+ /// Sign in to a remote's server the same way the web UI does — sign a
+ /// server-issued challenge with your key — so this machine can also open a
+ /// debug session (`checks debug`).
+ Login {
+ /// Remote whose server to sign in to.
+ #[arg(default_value = "origin")]
+ remote: String,
+ /// Key to sign in with; defaults to `user.signingkey`.
+ #[arg(long)]
+ key: Option<PathBuf>,
+ },
}
#[derive(Subcommand)]
@@ -171,6 +184,14 @@
#[arg(default_value = "origin")]
remote: String,
},
+ /// Open an interactive, read-write shell in `remote`'s persistent checks
+ /// Sprite — the same sandbox its check runs execute in. Requires
+ /// `git ents login <remote>` first.
+ Debug {
+ /// Remote whose checks Sprite to open a shell in.
+ #[arg(default_value = "origin")]
+ remote: String,
+ },
}
fn main() -> ExitCode {
@@ -179,6 +200,7 @@
Top::Members { action } => run_members(action),
Top::Account { action } => run_account(action),
Top::Checks { action } => run_checks(action),
+ Top::Login { remote, key } => login(&remote, key.as_deref()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -242,6 +264,7 @@
remote,
} => add_check(&name, &command, &remote),
ChecksAction::Remove { name, remote } => remove::<Checks>(&name, &remote),
+ ChecksAction::Debug { remote } => checks_debug(&remote),
}
}
@@ -712,6 +735,209 @@
Ok(())
}
+/// The SSHSIG namespace a sign-in signature is made under; must match the
+/// server's `git-ents-server::web::write::LOGIN_NAMESPACE`.
+const LOGIN_NAMESPACE: &str = "git-ents-login";
+
+/// Sign in to `remote`'s server: fetch its one-time challenge, sign it locally
+/// with `key` (never handing the private key anywhere), and post the
+/// signature back — the same proof the browser login page collects by hand.
+/// The returned session token is stored locally so `checks_debug` can reuse
+/// it.
+fn login(remote: &str, key: Option<&Path>) -> Result<(), String> {
+ let (base, _repo_path) = remote_http_base(remote)?;
+ let private_key = signing_key_file(key)?;
+ let public_key = public_key(key)?;
+
+ let nonce = http_get(&format!("{base}/login/cli"))?;
+ let signature = sign_challenge(&private_key, &nonce)?;
+ let body = form_urlencoded::Serializer::new(String::new())
+ .append_pair("public_key", &public_key)
+ .append_pair("signature", &signature)
+ .append_pair("nonce", &nonce)
+ .finish();
+ let token = http_post_form(&format!("{base}/login/cli"), &body)?;
+
+ store_session(&host_of(&base)?, &token)?;
+ println!("signed in to {remote}");
+ Ok(())
+}
+
+/// Open an interactive, read-write shell in `remote`'s persistent checks
+/// Sprite, brokered by the server over a WebSocket using the session
+/// `login` stored.
+fn checks_debug(remote: &str) -> Result<(), String> {
+ let (base, repo_path) = remote_http_base(remote)?;
+ let host = host_of(&base)?;
+ let token = load_session(&host)?
+ .ok_or_else(|| format!("not signed in to {remote}; run `git ents login {remote}` first"))?;
+ let ws_url = format!("{}/_debug/{repo_path}", to_ws(&base));
+
+ let runtime = tokio::runtime::Runtime::new()
+ .map_err(|error| format!("could not start the async runtime: {error}"))?;
+ runtime.block_on(crate::debug_session::run(&ws_url, &token))
+}
+
+/// The path to the private half of the signing key to use: `key` verbatim, or
+/// the path behind `user.signingkey`, resolved the same way `setup` does.
+fn signing_key_file(key: Option<&Path>) -> Result<PathBuf, String> {
+ match key {
+ Some(path) => Ok(key_paths(path).0),
+ None => {
+ let configured = config_get("user.signingkey")
+ .ok_or("no --key given and user.signingkey is unset")?;
+ Ok(key_paths(&signing_key_path(&configured)).0)
+ }
+ }
+}
+
+/// Sign `nonce` under [`LOGIN_NAMESPACE`] with the private key at `path`,
+/// returning the armored SSH signature. `ssh-keygen -Y sign` only writes a
+/// signature next to a file it read, so the nonce is staged there first.
+fn sign_challenge(private_key: &Path, nonce: &str) -> Result<String, String> {
+ let dir = tempfile::tempdir().map_err(|error| format!("could not create temp dir: {error}"))?;
+ let data = dir.path().join("nonce");
+ std::fs::write(&data, nonce).map_err(|error| format!("could not write challenge: {error}"))?;
+ let status = Command::new("ssh-keygen")
+ .args(["-Y", "sign", "-f"])
+ .arg(private_key)
+ .args(["-n", LOGIN_NAMESPACE])
+ .arg(&data)
+ .status()
+ .map_err(|error| format!("could not run ssh-keygen: {error}"))?;
+ if !status.success() {
+ return Err("ssh-keygen could not sign the challenge".to_owned());
+ }
+ std::fs::read_to_string(dir.path().join("nonce.sig"))
+ .map_err(|error| format!("could not read the signature: {error}"))
+}
+
+/// The server's http(s) base URL and repository path (without `.git`) for
+/// `remote`'s configured URL, e.g. `https://ents.example.com` and `org/repo`.
+fn remote_http_base(remote: &str) -> Result<(String, String), String> {
+ let url = git_capture(&["remote", "get-url", remote])?;
+ let url = url.trim();
+ let (scheme, rest) = url
+ .split_once("://")
+ .ok_or_else(|| format!("{remote} is not an http(s) remote; login and debug need one"))?;
+ if scheme != "http" && scheme != "https" {
+ return Err(format!(
+ "{remote} is not an http(s) remote; login and debug need one"
+ ));
+ }
+ let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
+ let repo_path = path.strip_suffix(".git").unwrap_or(path).trim_matches('/');
+ Ok((format!("{scheme}://{host}"), repo_path.to_owned()))
+}
+
+/// The `host[:port]` portion of an `http(s)://host[:port]` base URL.
+fn host_of(base: &str) -> Result<String, String> {
+ base.split_once("://")
+ .map(|(_scheme, host)| host.to_owned())
+ .ok_or_else(|| "malformed server URL".to_owned())
+}
+
+/// Rewrite an `http(s)://` base URL to its `ws(s)://` equivalent.
+fn to_ws(base: &str) -> String {
+ if let Some(rest) = base.strip_prefix("https://") {
+ format!("wss://{rest}")
+ } else if let Some(rest) = base.strip_prefix("http://") {
+ format!("ws://{rest}")
+ } else {
+ base.to_owned()
+ }
+}
+
+/// GET `url`, returning the response body, or its body text as the error on a
+/// non-2xx status.
+fn http_get(url: &str) -> Result<String, String> {
+ let mut response = ureq::get(url)
+ .config()
+ .http_status_as_error(false)
+ .build()
+ .call()
+ .map_err(|error| format!("GET {url} failed: {error}"))?;
+ let status = response.status();
+ let text = response
+ .body_mut()
+ .read_to_string()
+ .map_err(|error| format!("could not read the response: {error}"))?;
+ if status.is_success() {
+ Ok(text)
+ } else if text.is_empty() {
+ Err(format!("GET {url} returned {status}"))
+ } else {
+ Err(text)
+ }
+}
+
+/// POST an `application/x-www-form-urlencoded` `body` to `url`, returning the
+/// response body, or its body text as the error on a non-2xx status.
+fn http_post_form(url: &str, body: &str) -> Result<String, String> {
+ let mut response = ureq::post(url)
+ .config()
+ .http_status_as_error(false)
+ .build()
+ .header("Content-Type", "application/x-www-form-urlencoded")
+ .send(body)
+ .map_err(|error| format!("POST {url} failed: {error}"))?;
+ let status = response.status();
+ let text = response
+ .body_mut()
+ .read_to_string()
+ .map_err(|error| format!("could not read the response: {error}"))?;
+ if status.is_success() {
+ Ok(text)
+ } else if text.is_empty() {
+ Err(format!("POST {url} returned {status}"))
+ } else {
+ Err(text)
+ }
+}
+
+/// Where `login` stores the session token for `host`, one file per host.
+fn session_path(host: &str) -> Result<PathBuf, String> {
+ let home = std::env::var("HOME").map_err(|_unset| "HOME is not set".to_owned())?;
+ let sanitized: String = host
+ .chars()
+ .map(|c| {
+ if c.is_ascii_alphanumeric() || matches!(c, '.' | '-') {
+ c
+ } else {
+ '_'
+ }
+ })
+ .collect();
+ Ok(Path::new(&home)
+ .join(".config/git-ents/sessions")
+ .join(sanitized))
+}
+
+/// Persist the session `token` for `host`, restricted to the owner.
+fn store_session(host: &str, token: &str) -> Result<(), String> {
+ let path = session_path(host)?;
+ if let Some(dir) = path.parent() {
+ std::fs::create_dir_all(dir)
+ .map_err(|error| format!("could not create {}: {error}", dir.display()))?;
+ }
+ std::fs::write(&path, token).map_err(|error| format!("could not write session: {error}"))?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt as _;
+ let _permissions = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
+ }
+ Ok(())
+}
+
+/// The stored session token for `host`, if `login` has been run against it.
+fn load_session(host: &str) -> Result<Option<String>, String> {
+ match std::fs::read_to_string(session_path(host)?) {
+ Ok(token) => Ok(Some(token.trim().to_owned())),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
+ Err(error) => Err(format!("could not read the stored session: {error}")),
+ }
+}
+
/// This client's own signing-key fingerprint, best-effort — `None` when no key
/// is configured or it cannot be read.
fn own_fingerprint() -> Option<String> {
crates/git-ents-server/src/web/mod.rs
@@ -9,6 +9,7 @@
//! This file owns routing and the shared page shell.
mod assets;
+mod debug;
mod git;
mod icons;
mod pages;
@@ -26,6 +27,7 @@
use crate::AppState;
use crate::http::{MAX_REPO_DEPTH, is_bare_repo, valid_segment};
+pub(crate) use self::debug::handshake;
pub(crate) use self::write::{Challenges, Sessions, new_challenges, new_sessions};
/// Who is signed in for the current request, resolved per repository: a member's
@@ -65,6 +67,14 @@
};
return login_page(session.as_ref(), challenge.as_deref(), None).into_response();
}
+ // The CLI signs in the same way the browser form does, just without the
+ // HTML: a bare nonce to sign, and (via `handle_post`) a bare token back.
+ if segments == ["login", "cli"] {
+ return match write::issue_challenge(&state.challenges) {
+ Ok(nonce) => nonce.into_response(),
+ Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
+ };
+ }
if let Some((repo, rel, rest)) = resolve_repo(&state.data_dir, &segments) {
return route(&repo, &rel, rest, host, session, editing_enabled(state)).await;
@@ -123,6 +133,12 @@
}
};
}
+ if segments == ["login", "cli"] {
+ return match write::login(&state.sessions, &state.challenges, &body) {
+ Ok(token) => token.into_response(),
+ Err(error) => (StatusCode::UNAUTHORIZED, error).into_response(),
+ };
+ }
if segments == ["logout"] {
// A cross-site form cannot read the session's CSRF token, so an absent
// or wrong one means the request did not originate from our own page.
crates/git-ents-server/src/web/debug.rs
@@ -1,0 +1,123 @@
+//! Interactive debug sessions into a repository's checks Sprite.
+//!
+//! A member who can already sign in to the web UI (see [`super::write`]) can
+//! open a read-write shell in the same persistent Sprite a check run used,
+//! brokered over a WebSocket so the member never needs a Fly credential of
+//! their own: the server holds the one `SPRITES_TOKEN` and relays bytes.
+//! Reachable at the reserved top-level path `/_debug/<repo-path>` — a repo
+//! literally named `_debug` is shadowed, the same tradeoff `/login` already
+//! makes against a repo named `login`.
+
+use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
+use axum::extract::{Path, State};
+use axum::http::{HeaderMap, StatusCode};
+use axum::response::{IntoResponse, Response};
+use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
+use tokio::process::Command;
+
+use crate::AppState;
+
+/// Upgrade an authenticated member's request into an interactive shell in
+/// `repo_path`'s checks Sprite.
+pub(crate) async fn handshake(
+ State(state): State<AppState>,
+ Path(repo_path): Path<String>,
+ headers: HeaderMap,
+ ws: WebSocketUpgrade,
+) -> Response {
+ let segments: Vec<&str> = repo_path.split('/').filter(|s| !s.is_empty()).collect();
+ let Some((repo, _rel, rest)) = super::resolve_repo(&state.data_dir, &segments) else {
+ return (StatusCode::NOT_FOUND, "no such repository").into_response();
+ };
+ if !rest.is_empty() {
+ return (StatusCode::NOT_FOUND, "no such repository").into_response();
+ }
+
+ let cookie = headers
+ .get(axum::http::header::COOKIE)
+ .and_then(|value| value.to_str().ok());
+ let Some(session) = super::write::snapshot(&state.sessions, cookie) else {
+ return (StatusCode::UNAUTHORIZED, "sign in to open a debug session").into_response();
+ };
+ let store = match git_store::Store::open(&repo) {
+ Ok(store) => store,
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ format!("cannot open store: {e}"),
+ )
+ .into_response();
+ }
+ };
+ if super::write::member_for_public_key_with(&store, &session.public_key).is_none() {
+ return (
+ StatusCode::FORBIDDEN,
+ "your web key is not a member of this repository",
+ )
+ .into_response();
+ }
+
+ let sprite = crate::checks::sprite_name(&repo);
+ let ready = tokio::task::spawn_blocking({
+ let sprite = sprite.clone();
+ move || crate::checks::ensure_auth().and_then(|()| crate::checks::ensure_sprite(&sprite))
+ })
+ .await;
+ if !matches!(ready, Ok(Ok(()))) {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ "could not prepare the sprite",
+ )
+ .into_response();
+ }
+
+ ws.on_upgrade(move |socket| relay(socket, sprite))
+}
+
+/// Spawn an interactive shell in `sprite` and relay it over `socket` until
+/// either side closes: the Sprite CLI's own `--tty` handles the pseudo-TTY, so
+/// the broker only ever pumps bytes.
+async fn relay(mut socket: WebSocket, sprite: String) {
+ let child = Command::new("sprite")
+ .args(["exec", "--tty", "-s", &sprite, "--", "/bin/bash"])
+ .stdin(std::process::Stdio::piped())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped())
+ .spawn();
+ let mut child = match child {
+ Ok(child) => child,
+ Err(_could_not_spawn) => return,
+ };
+ let (Some(mut stdin), Some(mut stdout)) = (child.stdin.take(), child.stdout.take()) else {
+ return;
+ };
+
+ let mut buf = [0u8; 4096];
+ loop {
+ tokio::select! {
+ read = stdout.read(&mut buf) => {
+ match read {
+ Ok(0) | Err(_) => break,
+ Ok(n) => {
+ let Some(chunk) = buf.get(..n) else { break };
+ if socket.send(Message::Binary(chunk.to_vec().into())).await.is_err() {
+ break;
+ }
+ }
+ }
+ }
+ frame = socket.recv() => {
+ match frame {
+ Some(Ok(Message::Binary(data))) => {
+ if stdin.write_all(&data).await.is_err() {
+ break;
+ }
+ }
+ Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
+ _ => {}
+ }
+ }
+ }
+ }
+ let _killed = child.kill().await;
+}
crates/git-ents/src/debug_session.rs
@@ -1,0 +1,86 @@
+//! The CLI side of an interactive debug session: connect to the server's
+//! WebSocket broker (see `git-ents-server`'s `web::debug`), put this
+//! terminal into raw mode, and pump bytes between it and the remote shell
+//! until either side closes.
+
+use std::io::{Read as _, Write as _};
+
+use futures_util::{SinkExt as _, StreamExt as _};
+use tokio_tungstenite::tungstenite::Message;
+use tokio_tungstenite::tungstenite::client::IntoClientRequest;
+
+/// Open a debug session at `url`, authenticated with the session `token`
+/// stored by `git ents login`.
+pub(crate) async fn run(url: &str, token: &str) -> Result<(), String> {
+ let mut request = url
+ .into_client_request()
+ .map_err(|error| format!("bad debug session URL: {error}"))?;
+ let cookie = format!("ents_session={token}")
+ .parse()
+ .map_err(|_invalid| "the stored session token is not a valid cookie value".to_owned())?;
+ request.headers_mut().insert("Cookie", cookie);
+
+ let (stream, _response) = tokio_tungstenite::connect_async(request)
+ .await
+ .map_err(|error| format!("could not open the debug session: {error}"))?;
+ let (mut sink, mut source) = stream.split();
+
+ crossterm::terminal::enable_raw_mode()
+ .map_err(|error| format!("could not set the terminal to raw mode: {error}"))?;
+ let result = pump(&mut sink, &mut source).await;
+ let _restored = crossterm::terminal::disable_raw_mode();
+ result
+}
+
+/// Relay bytes both ways: a background thread feeds raw stdin bytes through
+/// `tx`, forwarded here to the sink, while frames from `source` are written
+/// straight to stdout. A dedicated thread reads stdin because raw terminal
+/// input has no natural way to interrupt a blocking read when the session
+/// ends from the other side.
+async fn pump<S, R>(sink: &mut S, source: &mut R) -> Result<(), String>
+where
+ S: futures_util::Sink<Message> + Unpin,
+ R: futures_util::Stream<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
+{
+ let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
+ std::thread::spawn(move || {
+ let mut buf = [0u8; 1024];
+ loop {
+ match std::io::stdin().read(&mut buf) {
+ Ok(0) | Err(_) => break,
+ Ok(n) => {
+ let Some(chunk) = buf.get(..n) else { break };
+ if tx.send(chunk.to_vec()).is_err() {
+ break;
+ }
+ }
+ }
+ }
+ });
+
+ loop {
+ tokio::select! {
+ input = rx.recv() => {
+ match input {
+ Some(bytes) => {
+ if sink.send(Message::Binary(bytes.into())).await.is_err() {
+ return Ok(());
+ }
+ }
+ None => return Ok(()),
+ }
+ }
+ frame = source.next() => {
+ match frame {
+ Some(Ok(Message::Binary(data))) => {
+ let _write = std::io::stdout().write_all(&data);
+ let _flush = std::io::stdout().flush();
+ }
+ Some(Ok(Message::Close(_))) | None => return Ok(()),
+ Some(Err(error)) => return Err(format!("debug session error: {error}")),
+ _ => {}
+ }
+ }
+ }
+ }
+}