irongit

Git hosting and a container registry in one Rust binary (axum + Astro)

Merge branch 'worktree-agent-ad4dacdd8848fc6a4'

huncholanehuncholaneauthored
parent 4f57a2cparent a3fe629commit 3ee1f1669807432f541a78ad232c5ded5860a0bfBrowse files

9 files changed, +1880 -11

+374-5backend/src/backup.rs
@@ -1,10 +1,379 @@
1-//! Nightly backups of repositories and Postgres to R2. Stub.
1+//! Backups of every repository and of Postgres to R2.
2+//!
3+//! Each run writes under `backups/<stamp>/`:
4+//! repos/<id>-<owner>-<name>.bundle `git bundle create --all`, restorable
5+//! with `git clone <bundle>`
6+//! postgres.sql.gz plain `pg_dump`, when pg_dump exists
7+//! manifest.json what the run contains, with ref shas
8+//!
9+//! Runs every BACKUP_INTERVAL_HOURS (first one ten minutes after start), or
10+//! on demand from the admin panel. The newest seven runs are kept.
211
3-use crate::state::AppState;
12+use std::{
13+ collections::BTreeSet,
14+ path::{Path, PathBuf},
15+ process::Stdio,
16+ time::Duration,
17+};
418
5-pub fn spawn(_state: AppState) {}
19+use anyhow::Context;
20+use chrono::Utc;
21+use serde_json::json;
22+use tokio::sync::Mutex;
23+
24+use crate::{git::Git, models, state::AppState, storage::keys, web::ui};
25+
26+const KEEP_RUNS: usize = 7;
27+const FIRST_RUN_DELAY: Duration = Duration::from_secs(10 * 60);
28+
29+/// One backup at a time, whether scheduled or requested.
30+static RUNNING: Mutex<()> = Mutex::const_new(());
31+
32+pub fn spawn(state: AppState) {
33+ tokio::spawn(async move {
34+ // A crash mid-run leaves a row stuck in 'running'.
35+ if let Err(error) = sqlx::query(
36+ "update backup_runs set status = 'failed', finished_at = now(), error = 'interrupted (server restarted)' where status = 'running'",
37+ )
38+ .execute(&state.db)
39+ .await
40+ {
41+ tracing::warn!(%error, "could not close interrupted backup runs");
42+ }
43+ let interval = Duration::from_secs(state.config.backup_interval_hours.max(1) * 3600);
44+ tokio::time::sleep(FIRST_RUN_DELAY).await;
45+ loop {
46+ match run_now(&state).await {
47+ Ok(summary) => tracing::info!(%summary, "scheduled backup finished"),
48+ Err(error) => tracing::error!(error = ?error, "scheduled backup failed"),
49+ }
50+ tokio::time::sleep(interval).await;
51+ }
52+ });
53+}
654
755 /// Runs one backup immediately (admin "Back up now"). Returns a summary.
8-pub async fn run_now(_state: &AppState) -> anyhow::Result<String> {
9- anyhow::bail!("backups are not implemented yet")
56+pub async fn run_now(state: &AppState) -> anyhow::Result<String> {
57+ let Ok(_guard) = RUNNING.try_lock() else {
58+ anyhow::bail!("a backup is already running");
59+ };
60+ let run_id: i64 = sqlx::query_scalar("insert into backup_runs default values returning id").fetch_one(&state.db).await?;
61+ let stamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
62+ let work_dir = state.config.data_dir.join("backup-tmp").join(&stamp);
63+ tracing::info!(run_id, %stamp, "backup started");
64+
65+ let result = run(state, &stamp, &work_dir).await;
66+ let _ = tokio::fs::remove_dir_all(&work_dir).await;
67+
68+ match result {
69+ Ok(report) => {
70+ let status = if report.failures.is_empty() { "ok" } else { "failed" };
71+ let error = (!report.failures.is_empty()).then(|| report.failures.join("; "));
72+ sqlx::query("update backup_runs set finished_at = now(), status = $2, repo_count = $3, bytes = $4, error = $5 where id = $1")
73+ .bind(run_id)
74+ .bind(status)
75+ .bind(report.repos as i32)
76+ .bind(report.bytes as i64)
77+ .bind(&error)
78+ .execute(&state.db)
79+ .await?;
80+ let summary = report.summary(&stamp);
81+ if report.failures.is_empty() {
82+ tracing::info!(run_id, %summary, "backup finished");
83+ } else {
84+ tracing::warn!(run_id, %summary, "backup finished with failures");
85+ }
86+ Ok(summary)
87+ }
88+ Err(error) => {
89+ sqlx::query("update backup_runs set finished_at = now(), status = 'failed', error = $2 where id = $1")
90+ .bind(run_id)
91+ .bind(format!("{error:#}"))
92+ .execute(&state.db)
93+ .await?;
94+ Err(error)
95+ }
96+ }
97+}
98+
99+#[derive(Default)]
100+struct Report {
101+ repos: usize,
102+ skipped_empty: usize,
103+ bytes: u64,
104+ postgres: Option<u64>,
105+ notes: Vec<String>,
106+ failures: Vec<String>,
107+ pruned: usize,
108+}
109+
110+impl Report {
111+ fn summary(&self, stamp: &str) -> String {
112+ let mut parts = vec![format!(
113+ "backup {stamp}: {} repositories ({} empty skipped), {} uploaded",
114+ self.repos,
115+ self.skipped_empty,
116+ ui::bytes(self.bytes)
117+ )];
118+ match self.postgres {
119+ Some(size) => parts.push(format!("postgres dump {}", ui::bytes(size))),
120+ None => parts.push("no postgres dump".into()),
121+ }
122+ if self.pruned > 0 {
123+ parts.push(format!("pruned {} old backups", self.pruned));
124+ }
125+ parts.extend(self.notes.iter().cloned());
126+ if !self.failures.is_empty() {
127+ parts.push(format!("{} failures: {}", self.failures.len(), self.failures.join("; ")));
128+ }
129+ parts.join(". ")
130+ }
131+}
132+
133+#[derive(sqlx::FromRow)]
134+struct RepoRow {
135+ id: i64,
136+ owner_name: String,
137+ name: String,
138+ is_empty: bool,
139+}
140+
141+async fn run(state: &AppState, stamp: &str, work_dir: &Path) -> anyhow::Result<Report> {
142+ tokio::fs::create_dir_all(work_dir).await?;
143+ let mut report = Report::default();
144+ let repos: Vec<RepoRow> = sqlx::query_as(
145+ "select r.id, a.name::text as owner_name, r.name::text as name, r.is_empty from repos r join accounts a on a.id = r.owner_id order by r.id",
146+ )
147+ .fetch_all(&state.db)
148+ .await?;
149+
150+ let mut manifest_repos = Vec::new();
151+ for repo in &repos {
152+ if repo.is_empty {
153+ report.skipped_empty += 1;
154+ continue;
155+ }
156+ let path = models::repo_disk_path(&state.config, repo.id);
157+ let name = format!("repos/{}-{}-{}.bundle", repo.id, repo.owner_name, repo.name);
158+ match backup_repo(state, &path, &work_dir.join(format!("{}.bundle", repo.id)), &keys::backup(stamp, &name)).await {
159+ Ok(Some((size, refs))) => {
160+ report.repos += 1;
161+ report.bytes += size;
162+ manifest_repos.push(json!({
163+ "id": repo.id, "owner": repo.owner_name, "name": repo.name,
164+ "key": keys::backup(stamp, &name), "size": size, "refs": refs,
165+ }));
166+ }
167+ Ok(None) => report.skipped_empty += 1,
168+ Err(error) => {
169+ tracing::error!(repo = %format!("{}/{}", repo.owner_name, repo.name), error = ?error, "repository backup failed");
170+ report.failures.push(format!("{}/{}: {error:#}", repo.owner_name, repo.name));
171+ }
172+ }
173+ }
174+
175+ let dump_path = work_dir.join("postgres.sql.gz");
176+ match dump_postgres(&state.config.database_url, &work_dir.join("postgres.sql"), &dump_path).await {
177+ Ok(Some(_)) => {
178+ let key = keys::backup(stamp, "postgres.sql.gz");
179+ let size = state.storage.put_file(&key, &dump_path).await.context("uploading postgres dump")?;
180+ report.bytes += size;
181+ report.postgres = Some(size);
182+ }
183+ Ok(None) => report.notes.push("pg_dump is not installed on the server, so Postgres was not dumped".into()),
184+ Err(error) => {
185+ tracing::error!(error = ?error, "postgres dump failed");
186+ report.notes.push(format!("Postgres dump failed: {error:#}"));
187+ }
188+ }
189+
190+ let manifest = json!({
191+ "stamp": stamp,
192+ "created_at": Utc::now().to_rfc3339(),
193+ "bucket": state.storage.bucket_name(),
194+ "repos": manifest_repos,
195+ "postgres": report.postgres.map(|size| json!({ "key": keys::backup(stamp, "postgres.sql.gz"), "size": size })),
196+ "failures": report.failures,
197+ "restore": "git clone <bundle> <dir>; gunzip -c postgres.sql.gz | psql <database>",
198+ });
199+ state
200+ .storage
201+ .put_bytes(&keys::backup(stamp, "manifest.json"), serde_json::to_vec_pretty(&manifest)?.into(), "application/json")
202+ .await
203+ .context("uploading manifest")?;
204+
205+ match prune(state, stamp).await {
206+ Ok(pruned) => report.pruned = pruned,
207+ Err(error) => report.notes.push(format!("pruning old backups failed: {error:#}")),
208+ }
209+ Ok(report)
210+}
211+
212+/// Bundles one repository and uploads it. None when it has no refs.
213+async fn backup_repo(state: &AppState, repo_path: &Path, bundle: &Path, key: &str) -> anyhow::Result<Option<(u64, serde_json::Value)>> {
214+ let git = Git::new(repo_path);
215+ let refs = git.ref_snapshot().await?;
216+ if refs.is_empty() {
217+ return Ok(None);
218+ }
219+ let output = git
220+ .command()
221+ .args(["bundle", "create", "--quiet"])
222+ .arg(bundle)
223+ .arg("--all")
224+ .stdout(Stdio::null())
225+ .stderr(Stdio::piped())
226+ .output()
227+ .await?;
228+ if !output.status.success() {
229+ anyhow::bail!("git bundle failed: {}", String::from_utf8_lossy(&output.stderr).trim());
230+ }
231+ let size = state.storage.put_file(key, bundle).await.context("uploading bundle")?;
232+ let _ = tokio::fs::remove_file(bundle).await;
233+ Ok(Some((size, json!(refs))))
234+}
235+
236+/// `pg_dump` to a plain SQL file, then gzip it. None when pg_dump is absent.
237+async fn dump_postgres(database_url: &str, plain: &Path, gzipped: &Path) -> anyhow::Result<Option<PathBuf>> {
238+ let probe = tokio::process::Command::new("pg_dump").arg("--version").stdout(Stdio::null()).stderr(Stdio::null()).status().await;
239+ if probe.is_err() {
240+ return Ok(None);
241+ }
242+ let output = tokio::process::Command::new("pg_dump")
243+ .args(["--no-owner", "--no-privileges", "--format=plain", "--file"])
244+ .arg(plain)
245+ .arg("--dbname")
246+ .arg(database_url)
247+ .stdin(Stdio::null())
248+ .stdout(Stdio::null())
249+ .stderr(Stdio::piped())
250+ .kill_on_drop(true)
251+ .output()
252+ .await
253+ .context("running pg_dump")?;
254+ if !output.status.success() {
255+ // stderr may echo the connection string; keep only the first line and
256+ // strip anything that looks like credentials.
257+ let message = String::from_utf8_lossy(&output.stderr).lines().next().unwrap_or("").replace(database_url, "<DATABASE_URL>");
258+ anyhow::bail!("pg_dump exited with {}: {message}", output.status);
259+ }
260+ let (plain, gzipped, out) = (plain.to_path_buf(), gzipped.to_path_buf(), gzipped.to_path_buf());
261+ tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
262+ let mut input = std::fs::File::open(&plain)?;
263+ let mut encoder = flate2::write::GzEncoder::new(std::fs::File::create(&gzipped)?, flate2::Compression::default());
264+ std::io::copy(&mut input, &mut encoder)?;
265+ encoder.finish()?;
266+ std::fs::remove_file(&plain)?;
267+ Ok(())
268+ })
269+ .await??;
270+ Ok(Some(out))
271+}
272+
273+/// Deletes every backup stamp but the newest `KEEP_RUNS`.
274+async fn prune(state: &AppState, current: &str) -> anyhow::Result<usize> {
275+ let objects = state.storage.list("backups/").await?;
276+ let keys: Vec<&str> = objects.iter().map(|o| o.key.as_str()).collect();
277+ let (doomed, stamps) = keys_to_prune(&keys, current, KEEP_RUNS);
278+ for key in &doomed {
279+ state.storage.delete(key).await?;
280+ }
281+ if !stamps.is_empty() {
282+ tracing::info!(pruned = ?stamps, objects = doomed.len(), "old backups deleted");
283+ }
284+ Ok(stamps.len())
285+}
286+
287+/// Object keys (and their stamps) outside the newest `keep` runs. Stamps
288+/// sort chronologically as strings; `current` is never pruned.
289+fn keys_to_prune<'a>(keys: &[&'a str], current: &str, keep: usize) -> (Vec<&'a str>, BTreeSet<String>) {
290+ let stamp_of = |key: &'a str| key.strip_prefix("backups/").and_then(|k| k.split('/').next()).filter(|s| !s.is_empty());
291+ let stamps: BTreeSet<&str> = keys.iter().filter_map(|k| stamp_of(k)).collect();
292+ let kept: BTreeSet<&str> = stamps.iter().rev().take(keep).copied().chain(std::iter::once(current)).collect();
293+ let mut doomed = Vec::new();
294+ let mut doomed_stamps = BTreeSet::new();
295+ for key in keys {
296+ if let Some(stamp) = stamp_of(key).filter(|s| !kept.contains(s)) {
297+ doomed.push(*key);
298+ doomed_stamps.insert(stamp.to_string());
299+ }
300+ }
301+ (doomed, doomed_stamps)
302+}
303+
304+#[cfg(test)]
305+mod tests {
306+ use std::sync::Arc;
307+
308+ use super::*;
309+
310+ #[test]
311+ fn prunes_all_but_newest_runs() {
312+ let keys = [
313+ "backups/20260101T000000Z/manifest.json",
314+ "backups/20260101T000000Z/repos/1-a-b.bundle",
315+ "backups/20260102T000000Z/manifest.json",
316+ "backups/20260103T000000Z/manifest.json",
317+ "backups/20260104T000000Z/postgres.sql.gz",
318+ "backups/",
319+ ];
320+ let (doomed, stamps) = keys_to_prune(&keys, "20260104T000000Z", 2);
321+ assert_eq!(doomed, vec!["backups/20260101T000000Z/manifest.json", "backups/20260101T000000Z/repos/1-a-b.bundle", "backups/20260102T000000Z/manifest.json"]);
322+ assert_eq!(stamps.into_iter().collect::<Vec<_>>(), vec!["20260101T000000Z", "20260102T000000Z"]);
323+ // The current run survives even if it would sort below the cutoff.
324+ let (doomed, _) = keys_to_prune(&keys, "20260101T000000Z", 1);
325+ assert!(!doomed.iter().any(|k| k.contains("20260101")));
326+ assert!(doomed.iter().any(|k| k.contains("20260102")));
327+ let (doomed, _) = keys_to_prune(&keys, "x", 10);
328+ assert!(doomed.is_empty());
329+ }
330+
331+ /// Runs one real backup against the configured database and bucket and
332+ /// prints the summary and stamp. Needs .env:
333+ /// `cargo test -p irongit backup::tests::backup_now -- --ignored --nocapture`
334+ #[tokio::test]
335+ #[ignore]
336+ async fn backup_now() {
337+ // Relative paths in .env (DATA_DIR) are relative to the workspace root.
338+ std::env::set_current_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/..")).unwrap();
339+ let _ = dotenvy::dotenv();
340+ let config = crate::config::Config::from_env().unwrap();
341+ let db = sqlx::PgPool::connect(&config.database_url).await.unwrap();
342+ let (reload_tx, _) = tokio::sync::broadcast::channel(1);
343+ let state = AppState {
344+ storage: crate::storage::Storage::new(&config.r2).unwrap(),
345+ http: reqwest::Client::new(),
346+ config: Arc::new(config),
347+ db,
348+ reload_tx,
349+ };
350+ let summary = run_now(&state).await.unwrap();
351+ println!("SUMMARY: {summary}");
352+ let (status, repos, bytes): (String, i32, i64) =
353+ sqlx::query_as("select status, repo_count, bytes from backup_runs order by id desc limit 1").fetch_one(&state.db).await.unwrap();
354+ println!("RUN: status={status} repos={repos} bytes={bytes}");
355+ assert_eq!(status, "ok");
356+
357+ // Pull the run back out of R2 so the caller can restore from it.
358+ let stamp = summary.strip_prefix("backup ").and_then(|s| s.split(':').next()).unwrap().to_string();
359+ let manifest = state.storage.get_bytes(&keys::backup(&stamp, "manifest.json")).await.unwrap().expect("manifest uploaded");
360+ let manifest: serde_json::Value = serde_json::from_slice(&manifest).unwrap();
361+ assert!(state.storage.head(&keys::backup(&stamp, "postgres.sql.gz")).await.unwrap().is_some_and(|s| s > 0));
362+ if let Ok(dir) = std::env::var("E2E_RESTORE_DIR") {
363+ std::fs::create_dir_all(&dir).unwrap();
364+ let dump = state.storage.get_bytes(&keys::backup(&stamp, "postgres.sql.gz")).await.unwrap().unwrap();
365+ std::fs::write(Path::new(&dir).join("postgres.sql.gz"), dump).unwrap();
366+ for repo in manifest["repos"].as_array().unwrap() {
367+ let key = repo["key"].as_str().unwrap();
368+ let bytes = state.storage.get_bytes(key).await.unwrap().expect("bundle uploaded");
369+ let file = Path::new(&dir).join(format!("{}-{}.bundle", repo["owner"].as_str().unwrap(), repo["name"].as_str().unwrap()));
370+ std::fs::write(&file, bytes).unwrap();
371+ println!("BUNDLE {} {}", file.display(), repo["refs"]);
372+ }
373+ }
374+ let kept = state.storage.list("backups/").await.unwrap();
375+ let stamps: BTreeSet<&str> = kept.iter().filter_map(|o| o.key.strip_prefix("backups/")?.split('/').next()).collect();
376+ println!("STAMPS IN R2: {stamps:?}");
377+ assert!(stamps.len() <= KEEP_RUNS);
378+ }
10379 }
+465-3backend/src/lfs.rs
@@ -1,9 +1,471 @@
1-//! Git LFS batch API backed by R2. Stub.
1+//! Git LFS batch API backed by R2.
2+//!
3+//! POST /{owner}/{repo}/info/lfs/objects/batch
4+//! POST /{owner}/{repo}/info/lfs/verify
5+//! POST /{owner}/{repo}/info/lfs/locks/verify
6+//! GET /{owner}/{repo}/info/lfs/locks
7+//!
8+//! Object bytes never pass through this server: the batch response hands
9+//! out presigned R2 URLs. Objects are stored once under `lfs/<oid>` and
10+//! linked per repo; downloads require a link to the repo being read.
11+//!
12+//! Uploads are presigned with `x-amz-checksum-sha256` set to the oid, so R2
13+//! rejects any body whose SHA-256 is not the oid. That makes a shared,
14+//! content-addressed key impossible to overwrite with other bytes, and it
15+//! is why an object stored by another repo is uploaded again rather than
16+//! linked: re-uploading is the proof that the pusher actually has it.
217
3-use axum::Router;
18+use std::time::Duration;
419
5-use crate::state::AppState;
20+use axum::{
21+ Router,
22+ body::Bytes,
23+ extract::{Path, State},
24+ http::{HeaderMap, HeaderValue, StatusCode, header},
25+ response::{IntoResponse, Response},
26+ routing::{get, post},
27+};
28+use base64::Engine;
29+use serde::{Deserialize, Serialize};
30+use serde_json::{Value, json};
31+
32+use crate::{
33+ analytics,
34+ auth::{self, HeaderAuth, Viewer},
35+ models::Repo,
36+ perm::{self, Access, Area},
37+ signed,
38+ state::AppState,
39+ storage::keys,
40+};
41+
42+const CONTENT_TYPE: &str = "application/vnd.git-lfs+json";
43+const TOKEN_PURPOSE: &str = "lfs";
44+/// Lifetime of tokens minted over SSH and of presigned URLs.
45+pub const TOKEN_TTL_SECS: i64 = 3600;
46+const URL_TTL: Duration = Duration::from_secs(TOKEN_TTL_SECS as u64);
47+const MAX_OBJECTS_PER_BATCH: usize = 1000;
648
749 pub fn router() -> Router<AppState> {
850 Router::new()
51+ .route("/{owner}/{repo}/info/lfs/objects/batch", post(batch))
52+ .route("/{owner}/{repo}/info/lfs/verify", post(verify))
53+ .route("/{owner}/{repo}/info/lfs/locks/verify", post(locks_verify))
54+ .route("/{owner}/{repo}/info/lfs/locks", get(locks_list).post(locks_create))
55+}
56+
57+/// Bearer token handed out by `git-lfs-authenticate` over SSH, and attached
58+/// to verify actions so git-lfs never has to ask for credentials again.
59+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
60+pub struct LfsToken {
61+ pub uid: i64,
62+ pub repo: i64,
63+ /// "upload" or "download".
64+ pub op: String,
65+ /// Set only on verify tokens: the value the batch's presigned uploads
66+ /// stamp into the object's metadata. See `verify`.
67+ #[serde(default, skip_serializing_if = "Option::is_none")]
68+ pub nonce: Option<String>,
69+}
70+
71+pub fn mint_token(state: &AppState, user_id: i64, repo_id: i64, op: &str) -> String {
72+ let claims = LfsToken { uid: user_id, repo: repo_id, op: op.to_string(), nonce: None };
73+ signed::sign(&state.config.secret_key, TOKEN_PURPOSE, &claims, TOKEN_TTL_SECS)
74+}
75+
76+fn mint_verify_token(state: &AppState, user_id: i64, repo_id: i64, nonce: &str) -> String {
77+ let claims = LfsToken { uid: user_id, repo: repo_id, op: "upload".into(), nonce: Some(nonce.to_string()) };
78+ signed::sign(&state.config.secret_key, TOKEN_PURPOSE, &claims, TOKEN_TTL_SECS)
79+}
80+
81+/// Object metadata key (x-amz-meta-irongit-upload) proving which batch
82+/// uploaded the bytes.
83+const UPLOAD_META: &str = "irongit-upload";
84+
85+/// LFS oids are lowercase hex SHA-256.
86+pub fn valid_oid(oid: &str) -> bool {
87+ oid.len() == 64 && oid.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
88+}
89+
90+/// The oid as the base64 SHA-256 digest R2 checks uploads against.
91+fn oid_checksum(oid: &str) -> Option<String> {
92+ let raw = hex::decode(oid).ok()?;
93+ Some(base64::engine::general_purpose::STANDARD.encode(raw))
94+}
95+
96+fn lfs_json(status: StatusCode, body: Value) -> Response {
97+ (status, [(header::CONTENT_TYPE, CONTENT_TYPE)], body.to_string()).into_response()
98+}
99+
100+fn lfs_error(status: StatusCode, message: &str) -> Response {
101+ let mut response = lfs_json(status, json!({ "message": message }));
102+ if status == StatusCode::UNAUTHORIZED {
103+ response.headers_mut().insert("LFS-Authenticate", HeaderValue::from_static("Basic realm=\"irongit\""));
104+ response.headers_mut().insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Basic realm=\"irongit\""));
105+ }
106+ response
107+}
108+
109+fn internal(error: impl std::fmt::Debug) -> Response {
110+ tracing::error!(?error, "lfs error");
111+ lfs_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
112+}
113+
114+/// Who is calling and, for SSH-minted tokens, which operation they may do.
115+struct Caller {
116+ viewer: Option<Viewer>,
117+ /// Some("download") for a download-only token.
118+ only: Option<String>,
119+ /// Upload nonce carried by a verify token.
120+ nonce: Option<String>,
121+}
122+
123+/// An authorized LFS request against a repo.
124+struct Authorized {
125+ repo: Repo,
126+ viewer: Option<Viewer>,
127+ nonce: Option<String>,
128+}
129+
130+async fn caller(state: &AppState, headers: &HeaderMap, repo: Option<&Repo>) -> Result<Caller, Response> {
131+ let bearer = headers
132+ .get(header::AUTHORIZATION)
133+ .and_then(|v| v.to_str().ok())
134+ .and_then(|v| v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")))
135+ .map(str::trim)
136+ .filter(|t| !t.starts_with(auth::TOKEN_PREFIX));
137+ if let Some(token) = bearer {
138+ let Some(claims) = signed::verify::<LfsToken>(&state.config.secret_key, TOKEN_PURPOSE, token) else {
139+ return Err(lfs_error(StatusCode::UNAUTHORIZED, "LFS token is invalid or expired"));
140+ };
141+ if repo.is_some_and(|r| r.id != claims.repo) {
142+ return Err(lfs_error(StatusCode::FORBIDDEN, "LFS token was issued for a different repository"));
143+ }
144+ let viewer = crate::ssh::viewer_by_id(&state.db, claims.uid).await.map_err(internal)?;
145+ let Some(viewer) = viewer else {
146+ return Err(lfs_error(StatusCode::UNAUTHORIZED, "LFS token owner no longer exists"));
147+ };
148+ return Ok(Caller { viewer: Some(viewer), only: Some(claims.op), nonce: claims.nonce });
149+ }
150+ match auth::authenticate_header(&state.db, headers).await.map_err(internal)? {
151+ HeaderAuth::Viewer(viewer) => Ok(Caller { viewer: Some(viewer), only: None, nonce: None }),
152+ HeaderAuth::Anonymous => Ok(Caller { viewer: None, only: None, nonce: None }),
153+ HeaderAuth::Invalid => Err(lfs_error(StatusCode::UNAUTHORIZED, "Invalid credentials. Use a personal access token as the password.")),
154+ }
155+}
156+
157+/// Resolves the repo and checks `needed` access, answering 401 whenever
158+/// credentials might help so private repos look like missing ones.
159+async fn authorize(state: &AppState, headers: &HeaderMap, owner: &str, name: &str, operation: &str) -> Result<Authorized, Response> {
160+ let repo = Repo::by_path(&state.db, owner, name).await.map_err(internal)?;
161+ let caller = caller(state, headers, repo.as_ref()).await?;
162+ if let Some(only) = &caller.only {
163+ if only != operation {
164+ return Err(lfs_error(StatusCode::FORBIDDEN, &format!("this LFS token only allows {only}")));
165+ }
166+ }
167+ let access = match &repo {
168+ Some(repo) => perm::repo_access(&state.db, caller.viewer.as_ref(), repo, Area::Repo).await.map_err(internal)?,
169+ None => Access::None,
170+ };
171+ let needed = if operation == "upload" { Access::Write } else { Access::Read };
172+ if access >= needed {
173+ return Ok(Authorized { repo: repo.expect("access implies repo"), viewer: caller.viewer, nonce: caller.nonce });
174+ }
175+ if caller.viewer.is_none() {
176+ return Err(lfs_error(StatusCode::UNAUTHORIZED, "Authentication required"));
177+ }
178+ match (&repo, access.can_read()) {
179+ (Some(repo), true) if repo.archived => Err(lfs_error(StatusCode::FORBIDDEN, "This repository is archived and read-only")),
180+ (Some(_), true) => Err(lfs_error(StatusCode::FORBIDDEN, "You have read access only; uploading needs write access")),
181+ _ => Err(lfs_error(StatusCode::NOT_FOUND, "Repository not found")),
182+ }
183+}
184+
185+#[derive(Deserialize)]
186+struct BatchRequest {
187+ operation: String,
188+ #[serde(default)]
189+ transfers: Vec<String>,
190+ #[serde(default)]
191+ objects: Vec<ObjectSpec>,
192+ hash_algo: Option<String>,
193+}
194+
195+#[derive(Deserialize, Clone)]
196+struct ObjectSpec {
197+ oid: String,
198+ size: i64,
199+}
200+
201+async fn batch(State(state): State<AppState>, Path((owner, name)): Path<(String, String)>, headers: HeaderMap, body: Bytes) -> Response {
202+ let Ok(request) = serde_json::from_slice::<BatchRequest>(&body) else {
203+ return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "Malformed batch request");
204+ };
205+ if !matches!(request.operation.as_str(), "upload" | "download") {
206+ return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "operation must be upload or download");
207+ }
208+ if !request.transfers.is_empty() && !request.transfers.iter().any(|t| t == "basic") {
209+ return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "Only the basic transfer adapter is supported");
210+ }
211+ if request.hash_algo.as_deref().is_some_and(|h| h != "sha256") {
212+ return lfs_error(StatusCode::CONFLICT, "Only sha256 is supported");
213+ }
214+ if request.objects.len() > MAX_OBJECTS_PER_BATCH {
215+ return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "Too many objects in one batch");
216+ }
217+ let Authorized { repo, viewer, .. } = match authorize(&state, &headers, &owner, &name, &request.operation).await {
218+ Ok(found) => found,
219+ Err(response) => return response,
220+ };
221+
222+ let oids: Vec<String> = request.objects.iter().filter(|o| valid_oid(&o.oid)).map(|o| o.oid.clone()).collect();
223+ let linked: std::collections::HashMap<String, i64> = match sqlx::query_as::<_, (String, i64)>(
224+ "select o.oid, o.size from repo_lfs_objects r join lfs_objects o on o.oid = r.oid where r.repo_id = $1 and r.oid = any($2)",
225+ )
226+ .bind(repo.id)
227+ .bind(&oids)
228+ .fetch_all(&state.db)
229+ .await
230+ {
231+ Ok(rows) => rows.into_iter().collect(),
232+ Err(error) => return internal(error),
233+ };
234+
235+ let upload = request.operation == "upload";
236+ let max = state.config.limits.max_lfs_object_bytes as i64;
237+ let base = format!("{}/{}/{}.git/info/lfs", state.config.base_url(), repo.owner_name, repo.name);
238+ // Every upload in this batch stamps `nonce` into the object's metadata,
239+ // and the verify token carries it: verify only links an object whose
240+ // stored bytes were written by this batch, so calling verify for an oid
241+ // someone else uploaded proves nothing. The token also spares git-lfs a
242+ // second credential prompt.
243+ let nonce = auth::random_token(12);
244+ let verify_token = viewer.as_ref().map(|v| mint_verify_token(&state, v.id, repo.id, &nonce));
245+ let mut actions_issued = 0usize;
246+ let objects: Vec<Value> = request
247+ .objects
248+ .iter()
249+ .map(|object| {
250+ if !valid_oid(&object.oid) || object.size < 0 {
251+ return json!({ "oid": object.oid, "size": object.size, "error": { "code": 422, "message": "Invalid object id or size" } });
252+ }
253+ let key = keys::lfs(&object.oid);
254+ if upload {
255+ if object.size > max {
256+ return json!({ "oid": object.oid, "size": object.size, "error": { "code": 422, "message": format!("Object is larger than the {} LFS limit", crate::web::ui::bytes(max as u64)) } });
257+ }
258+ if linked.contains_key(&object.oid) {
259+ // Already stored for this repo: no actions means "skip".
260+ return json!({ "oid": object.oid, "size": object.size });
261+ }
262+ let checksum = oid_checksum(&object.oid).expect("valid oid is hex");
263+ actions_issued += 1;
264+ let mut verify = json!({ "href": format!("{base}/verify"), "expires_in": TOKEN_TTL_SECS });
265+ if let Some(token) = &verify_token {
266+ verify["header"] = json!({ "Authorization": format!("Bearer {token}") });
267+ }
268+ json!({
269+ "oid": object.oid,
270+ "size": object.size,
271+ "authenticated": true,
272+ "actions": {
273+ "upload": {
274+ "href": state.storage.presign_put_sha256(&key, URL_TTL, &checksum, &[(UPLOAD_META, &nonce)]).to_string(),
275+ "header": {
276+ "x-amz-checksum-sha256": checksum,
277+ format!("x-amz-meta-{UPLOAD_META}"): nonce,
278+ },
279+ "expires_in": TOKEN_TTL_SECS,
280+ },
281+ "verify": verify,
282+ }
283+ })
284+ } else {
285+ match linked.get(&object.oid) {
286+ Some(size) => {
287+ actions_issued += 1;
288+ json!({
289+ "oid": object.oid,
290+ "size": size,
291+ "authenticated": true,
292+ "actions": {
293+ "download": {
294+ "href": state.storage.presign_get(&key, URL_TTL, None).to_string(),
295+ "expires_in": TOKEN_TTL_SECS,
296+ }
297+ }
298+ })
299+ }
300+ None => json!({ "oid": object.oid, "size": object.size, "error": { "code": 404, "message": "Object does not exist" } }),
301+ }
302+ }
303+ })
304+ .collect();
305+
306+ tracing::info!(
307+ repo = %repo.full_name(),
308+ user = viewer.as_ref().map(|v| v.name.as_str()).unwrap_or("-"),
309+ operation = %request.operation,
310+ objects = request.objects.len(),
311+ actions = actions_issued,
312+ "lfs batch"
313+ );
314+ analytics::track(
315+ &state,
316+ if upload { "lfs_upload_batch" } else { "lfs_download_batch" },
317+ viewer.as_ref().map(|v| v.name.as_str()),
318+ &repo.url(),
319+ json!({ "objects": request.objects.len(), "transfers": actions_issued }),
320+ );
321+ lfs_json(StatusCode::OK, json!({ "transfer": "basic", "objects": objects, "hash_algo": "sha256" }))
322+}
323+
324+/// Called by git-lfs after an upload: confirms the object reached R2 with the
325+/// declared size, then records it and links it to the repo.
326+async fn verify(State(state): State<AppState>, Path((owner, name)): Path<(String, String)>, headers: HeaderMap, body: Bytes) -> Response {
327+ let Ok(object) = serde_json::from_slice::<ObjectSpec>(&body) else {
328+ return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "Malformed verify request");
329+ };
330+ if !valid_oid(&object.oid) {
331+ return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "Invalid object id");
332+ }
333+ let Authorized { repo, viewer, nonce } = match authorize(&state, &headers, &owner, &name, "upload").await {
334+ Ok(found) => found,
335+ Err(response) => return response,
336+ };
337+ // Only the token from a batch response carries a nonce; a PAT or an SSH
338+ // token alone cannot verify, or anyone could link objects they never sent.
339+ let Some(nonce) = nonce else {
340+ return lfs_error(StatusCode::FORBIDDEN, "Verify with the token from the batch response");
341+ };
342+ let stored = match state.storage.head_metadata(&keys::lfs(&object.oid)).await {
343+ Ok(stored) => stored,
344+ Err(error) => return internal(error),
345+ };
346+ match stored {
347+ None => return lfs_error(StatusCode::NOT_FOUND, "Object was not uploaded"),
348+ Some((size, _)) if size as i64 != object.size => {
349+ return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, &format!("Object size is {size}, expected {}", object.size));
350+ }
351+ Some((_, metadata)) if !metadata.iter().any(|(k, v)| k == UPLOAD_META && *v == nonce) => {
352+ tracing::warn!(repo = %repo.full_name(), oid = %object.oid, "lfs verify without a matching upload");
353+ return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "Object was not uploaded by this batch; upload it again");
354+ }
355+ Some(_) => {}
356+ }
357+ let result = async {
358+ let mut tx = state.db.begin().await?;
359+ sqlx::query("insert into lfs_objects (oid, size) values ($1, $2) on conflict (oid) do nothing")
360+ .bind(&object.oid)
361+ .bind(object.size)
362+ .execute(&mut *tx)
363+ .await?;
364+ sqlx::query("insert into repo_lfs_objects (repo_id, oid) values ($1, $2) on conflict do nothing")
365+ .bind(repo.id)
366+ .bind(&object.oid)
367+ .execute(&mut *tx)
368+ .await?;
369+ tx.commit().await
370+ }
371+ .await;
372+ if let Err(error) = result {
373+ return internal(error);
374+ }
375+ tracing::info!(repo = %repo.full_name(), user = viewer.as_ref().map(|v| v.name.as_str()).unwrap_or("-"), oid = %object.oid, size = object.size, "lfs object verified");
376+ lfs_json(StatusCode::OK, json!({}))
377+}
378+
379+/// File locking is not supported; answering "no locks" keeps pushes quiet.
380+async fn locks_verify(State(state): State<AppState>, Path((owner, name)): Path<(String, String)>, headers: HeaderMap) -> Response {
381+ match authorize(&state, &headers, &owner, &name, "upload").await {
382+ Ok(_) => lfs_json(StatusCode::OK, json!({ "ours": [], "theirs": [], "next_cursor": "" })),
383+ Err(response) => response,
384+ }
385+}
386+
387+async fn locks_list(State(state): State<AppState>, Path((owner, name)): Path<(String, String)>, headers: HeaderMap) -> Response {
388+ match authorize(&state, &headers, &owner, &name, "download").await {
389+ Ok(_) => lfs_json(StatusCode::OK, json!({ "locks": [], "next_cursor": "" })),
390+ Err(response) => response,
391+ }
392+}
393+
394+async fn locks_create() -> Response {
395+ lfs_error(StatusCode::NOT_IMPLEMENTED, "File locking is not supported on irongit")
396+}
397+
398+#[cfg(test)]
399+mod tests {
400+ use super::*;
401+
402+ #[test]
403+ fn oid_validation() {
404+ let good = "a".repeat(64);
405+ assert!(valid_oid(&good));
406+ assert!(valid_oid("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"));
407+ assert!(!valid_oid(&"A".repeat(64)));
408+ assert!(!valid_oid(&"a".repeat(63)));
409+ assert!(!valid_oid(&"a".repeat(65)));
410+ assert!(!valid_oid(&format!("{}g", "a".repeat(63))));
411+ assert!(!valid_oid("../../etc/passwd"));
412+ assert!(!valid_oid(""));
413+ }
414+
415+ #[test]
416+ fn checksum_is_base64_digest() {
417+ // sha256("") in hex and base64.
418+ let oid = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
419+ assert_eq!(oid_checksum(oid).unwrap(), "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=");
420+ }
421+
422+ #[test]
423+ fn token_roundtrip() {
424+ let key = [3u8; 32];
425+ let original = LfsToken { uid: 1, repo: 2, op: "upload".into(), nonce: Some("n".into()) };
426+ let token = signed::sign(&key, TOKEN_PURPOSE, &original, 60);
427+ let claims: LfsToken = signed::verify(&key, TOKEN_PURPOSE, &token).unwrap();
428+ assert_eq!(claims, original);
429+ // Tokens minted over SSH carry no nonce and still parse.
430+ let plain = signed::sign(&key, TOKEN_PURPOSE, &serde_json::json!({ "uid": 1, "repo": 2, "op": "download" }), 60);
431+ assert_eq!(signed::verify::<LfsToken>(&key, TOKEN_PURPOSE, &plain).unwrap().nonce, None);
432+ assert!(signed::verify::<LfsToken>(&key, "registry", &token).is_none());
433+ }
434+
435+ /// Confirms R2 enforces x-amz-checksum-sha256 on presigned PUTs, which
436+ /// the upload design relies on. Needs .env with R2 credentials:
437+ /// `cargo test -p irongit lfs::tests::r2_enforces_checksum -- --ignored`
438+ #[tokio::test]
439+ #[ignore]
440+ async fn r2_enforces_checksum() {
441+ // Relative paths in .env (DATA_DIR) are relative to the workspace root.
442+ std::env::set_current_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/..")).unwrap();
443+ let _ = dotenvy::dotenv();
444+ let config = crate::config::Config::from_env().unwrap();
445+ let storage = crate::storage::Storage::new(&config.r2).unwrap();
446+ let body = b"irongit lfs checksum test".to_vec();
447+ let oid = auth::sha256_hex(&body);
448+ let checksum = oid_checksum(&oid).unwrap();
449+ let key = format!("healthcheck/lfs-{oid}");
450+ let url = storage.presign_put_sha256(&key, Duration::from_secs(60), &checksum, &[(UPLOAD_META, "nonce123")]);
451+ let http = reqwest::Client::new();
452+
453+ let meta = format!("x-amz-meta-{UPLOAD_META}");
454+ let wrong = http.put(url.clone()).header("x-amz-checksum-sha256", &checksum).header(&meta, "nonce123").body(b"tampered bytes".to_vec()).send().await.unwrap();
455+ assert!(!wrong.status().is_success(), "R2 accepted a body that does not match the checksum");
456+ assert_eq!(storage.head(&key).await.unwrap(), None);
457+
458+ let missing = http.put(url.clone()).header(&meta, "nonce123").body(body.clone()).send().await.unwrap();
459+ assert!(!missing.status().is_success(), "R2 accepted an upload without the signed checksum header");
460+
461+ let other_nonce = http.put(url.clone()).header("x-amz-checksum-sha256", &checksum).header(&meta, "forged").body(body.clone()).send().await.unwrap();
462+ assert!(!other_nonce.status().is_success(), "R2 accepted a different metadata value than was signed");
463+
464+ let right = http.put(url).header("x-amz-checksum-sha256", &checksum).header(&meta, "nonce123").body(body.clone()).send().await.unwrap();
465+ assert!(right.status().is_success(), "matching upload failed: {}", right.status());
466+ let (size, metadata) = storage.head_metadata(&key).await.unwrap().unwrap();
467+ assert_eq!(size, body.len() as u64);
468+ assert!(metadata.iter().any(|(k, v)| k == UPLOAD_META && v == "nonce123"), "metadata missing: {metadata:?}");
469+ storage.delete(&key).await.unwrap();
470+ }
9471 }
+576-3backend/src/ssh.rs
@@ -1,5 +1,578 @@
1-//! Built-in SSH server for git. Stub.
1+//! Built-in SSH server for git, so the host's own sshd is never touched.
2+//!
3+//! Public-key auth only: the offered key's SHA256 fingerprint is looked up in
4+//! `ssh_keys`. Any username works (`git@host` by convention). Each session
5+//! channel accepts one of:
6+//!
7+//! git-upload-pack 'owner/repo.git' clone and fetch
8+//! git-receive-pack 'owner/repo.git' push
9+//! git-lfs-authenticate owner/repo.git upload|download
10+//!
11+//! Git commands are spawned through `transport::service_command`, so SSH
12+//! gets the same hooks, size limits and quotas as smart HTTP.
213
3-use crate::state::AppState;
14+use std::{net::SocketAddr, path::Path, process::Stdio, sync::Arc, time::Duration};
415
5-pub fn spawn(_state: AppState) {}
16+use anyhow::Context;
17+use russh::{
18+ Channel, ChannelMsg, MethodKind, MethodSet,
19+ keys::{Algorithm, HashAlg, PrivateKey, PublicKey},
20+ server::{self, Auth, ChannelOpenHandle, Handle, Msg, Server as _, Session},
21+};
22+use serde_json::json;
23+use tokio::{io::AsyncWriteExt, net::TcpListener};
24+
25+use crate::{
26+ analytics,
27+ auth::{AuthVia, Scopes, Viewer},
28+ git::Git,
29+ lfs,
30+ models::Repo,
31+ perm::{self, Access, Area},
32+ push,
33+ state::AppState,
34+ transport::{self, Service, ServiceOptions},
35+};
36+
37+/// Starts the SSH server in the background. A bind failure is logged, not
38+/// fatal: the web UI and HTTP git keep working without it.
39+pub fn spawn(state: AppState) {
40+ tokio::spawn(async move {
41+ if let Err(error) = run(state).await {
42+ tracing::error!(error = ?error, "ssh server stopped");
43+ }
44+ });
45+}
46+
47+async fn run(state: AppState) -> anyhow::Result<()> {
48+ let key = load_or_create_host_key(&state.config.data_dir.join("ssh_host_ed25519_key"))?;
49+ let config = server::Config {
50+ server_id: russh::SshId::Standard("SSH-2.0-irongit".into()),
51+ methods: MethodSet::from(&[MethodKind::PublicKey][..]),
52+ auth_rejection_time: Duration::from_millis(500),
53+ auth_rejection_time_initial: Some(Duration::ZERO),
54+ inactivity_timeout: Some(Duration::from_secs(600)),
55+ keepalive_interval: Some(Duration::from_secs(30)),
56+ keepalive_max: 6,
57+ keys: vec![key],
58+ nodelay: true,
59+ ..Default::default()
60+ };
61+ let address = format!("{}:{}", state.config.host, state.config.ssh_port);
62+ let listener = TcpListener::bind(&address).await.with_context(|| format!("binding ssh on {address}"))?;
63+ tracing::info!(address = %listener.local_addr()?, "ssh listening");
64+ let mut server = SshServer { state };
65+ server.run_on_socket(Arc::new(config), &listener).await?;
66+ Ok(())
67+}
68+
69+/// The ed25519 host key, generated on first start and kept in DATA_DIR so
70+/// clients' known_hosts entries stay valid across restarts.
71+fn load_or_create_host_key(path: &Path) -> anyhow::Result<PrivateKey> {
72+ if let Ok(text) = std::fs::read_to_string(path) {
73+ return PrivateKey::from_openssh(&text).with_context(|| format!("reading ssh host key {}", path.display()));
74+ }
75+ let key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).map_err(|e| anyhow::anyhow!("generating host key: {e}"))?;
76+ let text = key.to_openssh(russh::keys::ssh_key::LineEnding::LF).map_err(|e| anyhow::anyhow!("encoding host key: {e}"))?;
77+ {
78+ use std::os::unix::fs::OpenOptionsExt;
79+ let mut file = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)?;
80+ std::io::Write::write_all(&mut file, text.as_bytes())?;
81+ }
82+ tracing::info!(path = %path.display(), fingerprint = %key.public_key().fingerprint(HashAlg::Sha256), "generated ssh host key");
83+ Ok(key)
84+}
85+
86+#[derive(Clone)]
87+struct SshServer {
88+ state: AppState,
89+}
90+
91+impl server::Server for SshServer {
92+ type Handler = SshSession;
93+
94+ fn new_client(&mut self, peer: Option<SocketAddr>) -> SshSession {
95+ tracing::debug!(peer = ?peer, "ssh connection");
96+ SshSession { state: self.state.clone(), peer, viewer: None }
97+ }
98+
99+ fn handle_session_error(&mut self, error: russh::Error) {
100+ tracing::debug!(%error, "ssh session ended with an error");
101+ }
102+}
103+
104+struct SshSession {
105+ state: AppState,
106+ peer: Option<SocketAddr>,
107+ viewer: Option<Viewer>,
108+}
109+
110+/// The user owning an SSH key, by SHA256 fingerprint.
111+async fn viewer_for_key(state: &AppState, key: &PublicKey) -> anyhow::Result<Option<(Viewer, i64)>> {
112+ let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
113+ let row: Option<(i64, i64, String, bool, Option<String>)> = sqlx::query_as(
114+ "select k.id, a.id, a.name::text, u.is_admin, a.avatar_key
115+ from ssh_keys k join users u on u.account_id = k.user_id join accounts a on a.id = u.account_id
116+ where k.fingerprint = $1 and u.suspended_at is null",
117+ )
118+ .bind(&fingerprint)
119+ .fetch_optional(&state.db)
120+ .await?;
121+ Ok(row.map(|(key_id, id, name, is_admin, avatar_key)| {
122+ (Viewer { id, name, is_admin, avatar_key, scopes: Scopes::ALL, via: AuthVia::Session }, key_id)
123+ }))
124+}
125+
126+/// A signed-in user loaded by id (LFS tokens minted over SSH carry the id).
127+pub async fn viewer_by_id(db: &sqlx::PgPool, user_id: i64) -> anyhow::Result<Option<Viewer>> {
128+ let row: Option<(i64, String, bool, Option<String>)> = sqlx::query_as(
129+ "select a.id, a.name::text, u.is_admin, a.avatar_key
130+ from users u join accounts a on a.id = u.account_id
131+ where u.account_id = $1 and u.suspended_at is null",
132+ )
133+ .bind(user_id)
134+ .fetch_optional(db)
135+ .await?;
136+ Ok(row.map(|(id, name, is_admin, avatar_key)| Viewer { id, name, is_admin, avatar_key, scopes: Scopes::ALL, via: AuthVia::Session }))
137+}
138+
139+impl server::Handler for SshSession {
140+ type Error = russh::Error;
141+
142+ /// Cheap pre-check so unknown keys are refused before any signing work.
143+ async fn auth_publickey_offered(&mut self, _user: &str, key: &PublicKey) -> Result<Auth, Self::Error> {
144+ match viewer_for_key(&self.state, key).await {
145+ Ok(Some(_)) => Ok(Auth::Accept),
146+ Ok(None) => {
147+ tracing::info!(peer = ?self.peer, fingerprint = %key.fingerprint(HashAlg::Sha256), "ssh key offered but not registered");
148+ Ok(Auth::reject())
149+ }
150+ Err(error) => {
151+ tracing::error!(error = ?error, "ssh key lookup failed");
152+ Ok(Auth::reject())
153+ }
154+ }
155+ }
156+
157+ async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Result<Auth, Self::Error> {
158+ let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
159+ match viewer_for_key(&self.state, key).await {
160+ Ok(Some((viewer, key_id))) => {
161+ tracing::info!(peer = ?self.peer, ssh_user = user, user = %viewer.name, %fingerprint, "ssh auth accepted");
162+ let _ = sqlx::query("update ssh_keys set last_used_at = now() where id = $1").bind(key_id).execute(&self.state.db).await;
163+ self.viewer = Some(viewer);
164+ Ok(Auth::Accept)
165+ }
166+ Ok(None) => {
167+ tracing::info!(peer = ?self.peer, ssh_user = user, %fingerprint, "ssh auth rejected: unknown key");
168+ Ok(Auth::reject())
169+ }
170+ Err(error) => {
171+ tracing::error!(error = ?error, "ssh key lookup failed");
172+ Ok(Auth::reject())
173+ }
174+ }
175+ }
176+
177+ async fn channel_open_session(&mut self, channel: Channel<Msg>, reply: ChannelOpenHandle, session: &mut Session) -> Result<(), Self::Error> {
178+ let Some(viewer) = self.viewer.clone() else {
179+ return Ok(()); // dropping the reply rejects the channel
180+ };
181+ reply.accept().await;
182+ let state = self.state.clone();
183+ let handle = session.handle();
184+ let peer = self.peer;
185+ tokio::spawn(async move {
186+ serve_channel(state, viewer, channel, handle, peer).await;
187+ });
188+ Ok(())
189+ }
190+}
191+
192+// ---------------------------------------------------------------------------
193+// Channels
194+
195+/// Drives one session channel: collects env, then runs the exec request.
196+async fn serve_channel(state: AppState, viewer: Viewer, channel: Channel<Msg>, handle: Handle, peer: Option<SocketAddr>) {
197+ let id = channel.id();
198+ let (mut reader, writer) = channel.split();
199+ let mut protocol: Option<String> = None;
200+ while let Some(message) = reader.wait().await {
201+ match message {
202+ ChannelMsg::SetEnv { variable_name, variable_value, want_reply } => {
203+ if variable_name == "GIT_PROTOCOL" && variable_value.len() < 200 {
204+ protocol = Some(variable_value);
205+ }
206+ if want_reply {
207+ let _ = handle.channel_success(id).await;
208+ }
209+ }
210+ ChannelMsg::RequestPty { want_reply, .. } => {
211+ if want_reply {
212+ let _ = handle.channel_success(id).await;
213+ }
214+ }
215+ ChannelMsg::RequestShell { want_reply } => {
216+ if want_reply {
217+ let _ = handle.channel_success(id).await;
218+ }
219+ tracing::info!(user = %viewer.name, peer = ?peer, "ssh shell requested; refused");
220+ let message = format!(
221+ "Hi {}! You've successfully authenticated, but irongit does not provide shell access.\r\n",
222+ viewer.name
223+ );
224+ finish(&writer, Some(message.as_bytes()), None, 1).await;
225+ return;
226+ }
227+ ChannelMsg::Exec { want_reply, command } => {
228+ if want_reply {
229+ let _ = handle.channel_success(id).await;
230+ }
231+ let command = String::from_utf8_lossy(&command).into_owned();
232+ tracing::info!(user = %viewer.name, peer = ?peer, %command, "ssh exec");
233+ let status = match parse_command(&command) {
234+ Ok(parsed) => run_command(&state, &viewer, parsed, protocol.as_deref(), &mut reader, &writer).await,
235+ Err(message) => {
236+ let _ = writer.make_writer_ext(Some(1)).write_all(format!("irongit: {message}\n").as_bytes()).await;
237+ 1
238+ }
239+ };
240+ finish(&writer, None, None, status).await;
241+ return;
242+ }
243+ ChannelMsg::RequestSubsystem { want_reply, name } => {
244+ tracing::info!(user = %viewer.name, %name, "ssh subsystem refused");
245+ if want_reply {
246+ let _ = handle.channel_failure(id).await;
247+ }
248+ }
249+ ChannelMsg::Eof | ChannelMsg::Close => break,
250+ _ => {}
251+ }
252+ }
253+ let _ = writer.close().await;
254+}
255+
256+/// Writes any final output, the exit status, then EOF and close.
257+async fn finish(writer: &russh::ChannelWriteHalf<Msg>, stdout: Option<&[u8]>, stderr: Option<&[u8]>, status: u32) {
258+ if let Some(data) = stdout {
259+ let _ = writer.make_writer().write_all(data).await;
260+ }
261+ if let Some(data) = stderr {
262+ let _ = writer.make_writer_ext(Some(1)).write_all(data).await;
263+ }
264+ let _ = writer.exit_status(status).await;
265+ let _ = writer.eof().await;
266+ let _ = writer.close().await;
267+}
268+
269+#[derive(Debug, Clone, PartialEq, Eq)]
270+pub enum LfsOperation {
271+ Upload,
272+ Download,
273+}
274+
275+impl LfsOperation {
276+ pub fn as_str(&self) -> &'static str {
277+ match self {
278+ Self::Upload => "upload",
279+ Self::Download => "download",
280+ }
281+ }
282+}
283+
284+#[derive(Debug, Clone, PartialEq, Eq)]
285+pub enum SshCommand {
286+ Git { service: Service, owner: String, repo: String },
287+ LfsAuthenticate { owner: String, repo: String, operation: LfsOperation },
288+}
289+
290+/// Splits a command line the way a minimal shell would: whitespace separates
291+/// words, single and double quotes group them. Backslashes are literal.
292+fn shell_words(line: &str) -> Result<Vec<String>, String> {
293+ let mut words = Vec::new();
294+ let mut current = String::new();
295+ let mut in_word = false;
296+ let mut quote: Option<char> = None;
297+ for c in line.chars() {
298+ match quote {
299+ Some(q) if c == q => quote = None,
300+ Some(_) => current.push(c),
301+ None if c == '\'' || c == '"' => {
302+ quote = Some(c);
303+ in_word = true;
304+ }
305+ None if c.is_whitespace() => {
306+ if in_word {
307+ words.push(std::mem::take(&mut current));
308+ in_word = false;
309+ }
310+ }
311+ None => {
312+ current.push(c);
313+ in_word = true;
314+ }
315+ }
316+ }
317+ if quote.is_some() {
318+ return Err("unterminated quote in command".into());
319+ }
320+ if in_word {
321+ words.push(current);
322+ }
323+ Ok(words)
324+}
325+
326+/// "owner/repo", "/owner/repo.git", "~/owner/repo/" -> ("owner", "repo").
327+fn parse_repo_path(path: &str) -> Result<(String, String), String> {
328+ let trimmed = path.trim_start_matches('~').trim_matches('/');
329+ let trimmed = trimmed.strip_suffix(".git").unwrap_or(trimmed);
330+ let mut parts = trimmed.split('/');
331+ match (parts.next(), parts.next(), parts.next()) {
332+ (Some(owner), Some(repo), None)
333+ if !owner.is_empty()
334+ && !repo.is_empty()
335+ && owner.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
336+ && repo.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
337+ && !repo.starts_with('.') =>
338+ {
339+ Ok((owner.to_ascii_lowercase(), repo.to_string()))
340+ }
341+ _ => Err(format!("'{path}' is not a repository path; use owner/repo.git")),
342+ }
343+}
344+
345+pub fn parse_command(line: &str) -> Result<SshCommand, String> {
346+ let words = shell_words(line.trim())?;
347+ let mut words = words.iter().map(String::as_str);
348+ let first = words.next().ok_or("no command given; irongit only serves git over SSH")?;
349+ // `git upload-pack <path>` is accepted as well as `git-upload-pack <path>`.
350+ let program = if first == "git" {
351+ match words.next() {
352+ Some(sub) => format!("git-{sub}"),
353+ None => return Err("no git command given".into()),
354+ }
355+ } else {
356+ first.to_string()
357+ };
358+ match program.as_str() {
359+ "git-upload-pack" | "git-receive-pack" => {
360+ let path = words.next().ok_or("missing repository path")?;
361+ if words.next().is_some() {
362+ return Err("unexpected arguments".into());
363+ }
364+ let (owner, repo) = parse_repo_path(path)?;
365+ let service = Service::from_name(&program).expect("matched above");
366+ Ok(SshCommand::Git { service, owner, repo })
367+ }
368+ "git-lfs-authenticate" => {
369+ let path = words.next().ok_or("missing repository path")?;
370+ let operation = match words.next() {
371+ Some("upload") => LfsOperation::Upload,
372+ Some("download") => LfsOperation::Download,
373+ _ => return Err("git-lfs-authenticate needs 'upload' or 'download'".into()),
374+ };
375+ let (owner, repo) = parse_repo_path(path)?;
376+ Ok(SshCommand::LfsAuthenticate { owner, repo, operation })
377+ }
378+ "git-upload-archive" => Err("git archive over SSH is not supported; download archives from the web UI".into()),
379+ // git-lfs tries this first and falls back to git-lfs-authenticate.
380+ "git-lfs-transfer" => Err("git-lfs-transfer is not supported; git-lfs falls back to git-lfs-authenticate".into()),
381+ other => Err(format!("'{other}' is not allowed; irongit only serves git over SSH")),
382+ }
383+}
384+
385+/// Runs a parsed command and returns its exit status.
386+async fn run_command(
387+ state: &AppState,
388+ viewer: &Viewer,
389+ command: SshCommand,
390+ protocol: Option<&str>,
391+ reader: &mut russh::ChannelReadHalf,
392+ writer: &russh::ChannelWriteHalf<Msg>,
393+) -> u32 {
394+ let mut stderr = writer.make_writer_ext(Some(1));
395+ let (owner, name) = match &command {
396+ SshCommand::Git { owner, repo, .. } | SshCommand::LfsAuthenticate { owner, repo, .. } => (owner.clone(), repo.clone()),
397+ };
398+ let repo = match Repo::by_path(&state.db, &owner, &name).await {
399+ Ok(repo) => repo,
400+ Err(error) => {
401+ tracing::error!(error = ?error, "ssh repo lookup failed");
402+ let _ = stderr.write_all(b"irongit: internal error\n").await;
403+ return 1;
404+ }
405+ };
406+ let access = match &repo {
407+ Some(repo) => perm::repo_access(&state.db, Some(viewer), repo, Area::Repo).await.unwrap_or(Access::None),
408+ None => Access::None,
409+ };
410+ let Some(repo) = repo.filter(|_| access.can_read()) else {
411+ let _ = stderr.write_all(format!("irongit: repository '{owner}/{name}' not found\n").as_bytes()).await;
412+ return 1;
413+ };
414+
415+ match command {
416+ SshCommand::LfsAuthenticate { operation, .. } => {
417+ if operation == LfsOperation::Upload && !access.can_write() {
418+ let message = if repo.archived { "this repository is archived and read-only" } else { "you have read access only" };
419+ let _ = stderr.write_all(format!("irongit: {message}\n").as_bytes()).await;
420+ return 1;
421+ }
422+ let token = lfs::mint_token(state, viewer.id, repo.id, operation.as_str());
423+ let body = json!({
424+ "href": format!("{}/{}/{}.git/info/lfs", state.config.base_url(), repo.owner_name, repo.name),
425+ "header": { "Authorization": format!("Bearer {token}") },
426+ "expires_in": lfs::TOKEN_TTL_SECS,
427+ });
428+ tracing::info!(user = %viewer.name, repo = %repo.full_name(), operation = operation.as_str(), "lfs token issued over ssh");
429+ let _ = writer.make_writer().write_all(body.to_string().as_bytes()).await;
430+ 0
431+ }
432+ SshCommand::Git { service, .. } => {
433+ if service == Service::ReceivePack && !access.can_write() {
434+ let message = if repo.archived { "this repository is archived and read-only" } else { "you have read access only; pushing needs write access" };
435+ let _ = stderr.write_all(format!("irongit: {message}\n").as_bytes()).await;
436+ return 1;
437+ }
438+ run_git(state, viewer, &repo, service, protocol, reader, writer).await
439+ }
440+ }
441+}
442+
443+async fn run_git(
444+ state: &AppState,
445+ viewer: &Viewer,
446+ repo: &Repo,
447+ service: Service,
448+ protocol: Option<&str>,
449+ reader: &mut russh::ChannelReadHalf,
450+ writer: &russh::ChannelWriteHalf<Msg>,
451+) -> u32 {
452+ let before = if service == Service::ReceivePack {
453+ match Git::new(repo.disk_path(&state.config)).ref_snapshot().await {
454+ Ok(refs) => Some(refs),
455+ Err(error) => {
456+ tracing::error!(error = ?error, repo = %repo.full_name(), "ref snapshot failed");
457+ return 1;
458+ }
459+ }
460+ } else {
461+ None
462+ };
463+ let options = ServiceOptions { stateless_rpc: false, advertise_refs: false, protocol };
464+ let spawned = transport::service_command(state, repo, service, Some(viewer), options)
465+ .await
466+ .and_then(|mut command| Ok(command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?));
467+ let mut child = match spawned {
468+ Ok(child) => child,
469+ Err(error) => {
470+ tracing::error!(error = ?error, repo = %repo.full_name(), "spawning git failed");
471+ let _ = writer.make_writer_ext(Some(1)).write_all(b"irongit: internal error\n").await;
472+ return 1;
473+ }
474+ };
475+
476+ let mut stdout = child.stdout.take().expect("piped");
477+ let mut stderr = child.stderr.take().expect("piped");
478+ let mut out_writer = writer.make_writer();
479+ let mut err_writer = writer.make_writer_ext(Some(1));
480+ let pump_out = tokio::spawn(async move {
481+ let result = tokio::io::copy(&mut stdout, &mut out_writer).await;
482+ let _ = out_writer.flush().await;
483+ result
484+ });
485+ let pump_err = tokio::spawn(async move {
486+ let result = tokio::io::copy(&mut stderr, &mut err_writer).await;
487+ let _ = err_writer.flush().await;
488+ result
489+ });
490+
491+ // Client -> git stdin until EOF, while git runs.
492+ let mut stdin = child.stdin.take();
493+ let status = loop {
494+ tokio::select! {
495+ message = reader.wait(), if stdin.is_some() => match message {
496+ Some(ChannelMsg::Data { data }) => {
497+ if let Some(pipe) = stdin.as_mut() {
498+ if pipe.write_all(&data).await.is_err() {
499+ stdin = None;
500+ }
501+ }
502+ }
503+ Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => {
504+ if let Some(mut pipe) = stdin.take() {
505+ let _ = pipe.shutdown().await;
506+ }
507+ }
508+ Some(_) => {}
509+ },
510+ status = child.wait() => break status,
511+ }
512+ };
513+ let _ = pump_out.await;
514+ let _ = pump_err.await;
515+
516+ let code = match status {
517+ Ok(status) => status.code().unwrap_or(1).clamp(0, 255) as u32,
518+ Err(error) => {
519+ tracing::error!(%error, repo = %repo.full_name(), "waiting for git failed");
520+ 1
521+ }
522+ };
523+ if code == 0 {
524+ match before {
525+ Some(before) => push::spawn_after_push(state.clone(), repo.clone(), Some(viewer.clone()), before, "ssh"),
526+ None => analytics::track(state, "git_fetch", Some(&viewer.name), &repo.url(), json!({ "via": "ssh", "visibility": repo.visibility })),
527+ }
528+ } else {
529+ tracing::info!(repo = %repo.full_name(), service = service.name(), code, "git over ssh exited unsuccessfully");
530+ }
531+ code
532+}
533+
534+#[cfg(test)]
535+mod tests {
536+ use super::*;
537+
538+ fn git(service: Service, owner: &str, repo: &str) -> SshCommand {
539+ SshCommand::Git { service, owner: owner.into(), repo: repo.into() }
540+ }
541+
542+ #[test]
543+ fn parses_git_commands() {
544+ assert_eq!(parse_command("git-upload-pack 'alice/proj.git'"), Ok(git(Service::UploadPack, "alice", "proj")));
545+ assert_eq!(parse_command("git-receive-pack '/alice/proj.git'"), Ok(git(Service::ReceivePack, "alice", "proj")));
546+ assert_eq!(parse_command("git-upload-pack alice/proj"), Ok(git(Service::UploadPack, "alice", "proj")));
547+ assert_eq!(parse_command("git upload-pack \"/alice/proj/\""), Ok(git(Service::UploadPack, "alice", "proj")));
548+ assert_eq!(parse_command("git-upload-pack '~/alice/my.proj.git'"), Ok(git(Service::UploadPack, "alice", "my.proj")));
549+ }
550+
551+ #[test]
552+ fn parses_lfs_authenticate() {
553+ assert_eq!(
554+ parse_command("git-lfs-authenticate 'alice/proj.git' upload"),
555+ Ok(SshCommand::LfsAuthenticate { owner: "alice".into(), repo: "proj".into(), operation: LfsOperation::Upload })
556+ );
557+ assert_eq!(
558+ parse_command("git-lfs-authenticate alice/proj download"),
559+ Ok(SshCommand::LfsAuthenticate { owner: "alice".into(), repo: "proj".into(), operation: LfsOperation::Download })
560+ );
561+ assert!(parse_command("git-lfs-authenticate alice/proj delete").is_err());
562+ }
563+
564+ #[test]
565+ fn rejects_everything_else() {
566+ assert!(parse_command("").is_err());
567+ assert!(parse_command("ls -la").is_err());
568+ assert!(parse_command("git-upload-pack").is_err());
569+ assert!(parse_command("git-upload-pack 'alice'").is_err());
570+ assert!(parse_command("git-upload-pack 'a/b/c'").is_err());
571+ assert!(parse_command("git-upload-pack '../../etc/passwd'").is_err());
572+ assert!(parse_command("git-upload-pack 'alice/proj' extra").is_err());
573+ assert!(parse_command("git-upload-pack 'alice/proj").is_err());
574+ assert!(parse_command("git-upload-pack '--upload-pack=x/y'").is_err());
575+ assert!(parse_command("git-upload-archive 'alice/proj'").is_err());
576+ assert!(parse_command("git-upload-pack 'alice/.hidden'").is_err());
577+ }
578+}
+40-0backend/src/storage.rs
@@ -68,6 +68,46 @@ impl Storage {
6868 self.bucket.put_object(Some(&self.credentials), key).sign(ttl)
6969 }
7070
71+ /// A presigned PUT that only succeeds when the body's SHA-256 matches.
72+ /// The client must send `x-amz-checksum-sha256: <sha256_base64>` and each
73+ /// `x-amz-meta-<name>: <value>` in `metadata` exactly; R2 rejects any other
74+ /// content, so a content-addressed key can never be overwritten with
75+ /// different bytes.
76+ pub fn presign_put_sha256(&self, key: &str, ttl: Duration, sha256_base64: &str, metadata: &[(&str, &str)]) -> Url {
77+ let mut action = self.bucket.put_object(Some(&self.credentials), key);
78+ action.headers_mut().insert("x-amz-checksum-sha256", sha256_base64.to_string());
79+ for (name, value) in metadata {
80+ action.headers_mut().insert(format!("x-amz-meta-{name}"), value.to_string());
81+ }
82+ action.sign(ttl)
83+ }
84+
85+ /// Size and user metadata (`x-amz-meta-*`, names without the prefix) of
86+ /// an object, or None when it does not exist.
87+ pub async fn head_metadata(&self, key: &str) -> anyhow::Result<Option<(u64, Vec<(String, String)>)>> {
88+ let url = self.bucket.head_object(Some(&self.credentials), key).sign(SELF_TTL);
89+ let response = self.http.head(url).send().await?;
90+ match response.status() {
91+ StatusCode::NOT_FOUND => Ok(None),
92+ status if status.is_success() => {
93+ let headers = response.headers();
94+ let size = headers
95+ .get(reqwest::header::CONTENT_LENGTH)
96+ .and_then(|v| v.to_str().ok()?.parse().ok())
97+ .unwrap_or(0);
98+ let metadata = headers
99+ .iter()
100+ .filter_map(|(name, value)| {
101+ let name = name.as_str().strip_prefix("x-amz-meta-")?;
102+ Some((name.to_string(), value.to_str().ok()?.to_string()))
103+ })
104+ .collect();
105+ Ok(Some((size, metadata)))
106+ }
107+ status => bail!("R2 HEAD {key} failed: {status}"),
108+ }
109+ }
110+
71111 /// Size of an object, or None when it does not exist.
72112 pub async fn head(&self, key: &str) -> anyhow::Result<Option<u64>> {
73113 let url = self.bucket.head_object(Some(&self.credentials), key).sign(SELF_TTL);
+144-0frontend/src/pages/docs/git.astro
@@ -0,0 +1,144 @@
1+---
2+import Layout from "../../layouts/Layout.astro";
3+
4+const sections = [
5+ ["https", "Clone over HTTPS"],
6+ ["ssh", "Clone over SSH"],
7+ ["limits", "File size limit"],
8+ ["lfs", "Large files with Git LFS"],
9+ ["quotas", "Storage quotas"],
10+ ["trouble", "Troubleshooting"],
11+];
12+---
13+
14+<Layout
15+ title="Git over HTTPS and SSH · irongit docs"
16+ description="Clone and push with personal access tokens or SSH keys, store large files with Git LFS, and understand size limits and quotas."
17+>
18+ <div class="grid gap-6 md:grid-cols-[180px_1fr]">
19+ <nav class="text-[13px] md:sticky md:top-4 md:self-start">
20+ <a href="/docs" class="text-ink-dim">Docs</a>
21+ <p class="mt-2 mb-1 text-xs font-semibold text-ink-faint uppercase">On this page</p>
22+ <ul class="space-y-1">
23+ {sections.map(([id, label]) => <li><a href={`#${id}`} class="text-ink-dim hover:text-ink">{label}</a></li>)}
24+ </ul>
25+ </nav>
26+
27+ <article class="markdown max-w-[760px]">
28+ <h1>Git over HTTPS and SSH</h1>
29+ <p>
30+ Every repository can be cloned and pushed over HTTPS or SSH with any git client. Both paths enforce the same
31+ permissions, file size limit and storage quota.
32+ </p>
33+
34+ <h2 id="https">Clone over HTTPS</h2>
35+ <p>
36+ HTTPS uses a <strong>personal access token</strong> as the password; account passwords are never accepted by git.
37+ The <a href="/docs/cli">ig CLI</a> sets this up for you:
38+ </p>
39+ <pre><code>ig login
40+ig repo clone owner/repo</code></pre>
41+ <p>
42+ <code>ig login</code> stores a token and registers <code>ig</code> as git's credential helper for this host, so plain
43+ <code>git clone</code>, <code>git pull</code> and <code>git push</code> work without prompts.
44+ </p>
45+ <p>Without the CLI, create a token under <a href="/settings/tokens">Settings, Tokens</a> and use it when git asks:</p>
46+ <pre><code>git clone <span class="ig-origin">https://this-site</span>/owner/repo.git
47+Username: your-username
48+Password: igp_... (the token)</code></pre>
49+ <p>
50+ Tokens need the <code>repo</code> scope for git. Store them with a credential helper (for example
51+ <code>git config --global credential.helper store</code>) rather than in the remote URL.
52+ </p>
53+
54+ <h2 id="ssh">Clone over SSH</h2>
55+ <p>
56+ irongit runs its own SSH server for git, separate from the machine's login SSH. It listens on port
57+ <strong>2222</strong> unless the administrator changed it, and only accepts public keys. Any username works;
58+ <code>git</code> is the convention.
59+ </p>
60+ <ol>
61+ <li>
62+ Create a key if you do not have one: <code>ssh-keygen -t ed25519 -C "you@example.com"</code>
63+ </li>
64+ <li>
65+ Add the <strong>public</strong> half (<code>~/.ssh/id_ed25519.pub</code>) under
66+ <a href="/settings/keys">Settings, SSH keys</a>, or run <code>ig ssh-key add ~/.ssh/id_ed25519.pub</code>.
67+ </li>
68+ <li>Clone with an <code>ssh://</code> URL that carries the port:</li>
69+ </ol>
70+ <pre><code>git clone ssh://git@<span class="ig-host">this-site</span>:2222/owner/repo.git</code></pre>
71+ <p>To use the short <code>host:owner/repo</code> form, add a host entry to <code>~/.ssh/config</code>:</p>
72+ <pre><code>Host <span class="ig-host">this-site</span>
73+ Port 2222
74+ User git
75+ IdentityFile ~/.ssh/id_ed25519
76+ IdentitiesOnly yes</code></pre>
77+ <pre><code>git clone <span class="ig-host">this-site</span>:owner/repo.git</code></pre>
78+ <p>Check that your key is recognised:</p>
79+ <pre><code>ssh -T -p 2222 git@<span class="ig-host">this-site</span>
80+Hi you! You've successfully authenticated, but irongit does not provide shell access.</code></pre>
81+
82+ <h2 id="limits">File size limit</h2>
83+ <p>
84+ A push is refused if it adds any single file larger than <strong>50 MB</strong> (the default; administrators can
85+ change it). The rejection lists each oversized file:
86+ </p>
87+ <pre><code>remote: irongit: push rejected: files larger than 50 MB must use Git LFS.
88+remote: irongit: assets/video.mp4 (212 MB)</code></pre>
89+ <p>
90+ The check covers every commit in the push, not just the last one, so deleting the file in a later commit is not
91+ enough. Move the file to LFS and rewrite the commits that added it (next section).
92+ </p>
93+
94+ <h2 id="lfs">Large files with Git LFS</h2>
95+ <p>
96+ Git LFS keeps large files out of the repository: git stores a small pointer and the file itself goes to object
97+ storage. Uploads and downloads go straight between your machine and storage, so large files are fast and never
98+ count against the git file size limit. Individual LFS objects can be up to <strong>2 GB</strong> by default.
99+ </p>
100+ <p>Install <a href="https://git-lfs.com">git-lfs</a> once, then in a repository:</p>
101+ <pre><code>git lfs install
102+git lfs track "*.psd" "*.mp4" "models/**"
103+git add .gitattributes
104+git add design.psd
105+git commit -m "Add design files"
106+git push</code></pre>
107+ <p>LFS works over both HTTPS and SSH remotes with the same credentials as git. To move files that are already in history into LFS:</p>
108+ <pre><code>git lfs migrate import --include="*.mp4" --everything
109+git push --force-with-lease</code></pre>
110+ <p>
111+ Rewriting history changes commit ids, so coordinate with anyone else working on the branch. Clones fetch LFS files
112+ automatically; <code>GIT_LFS_SKIP_SMUDGE=1 git clone ...</code> skips them and <code>git lfs pull</code> fetches them later.
113+ File locking (<code>git lfs lock</code>) is not supported.
114+ </p>
115+
116+ <h2 id="quotas">Storage quotas</h2>
117+ <p>
118+ Each account (personal or organization) has a storage quota for git data across all its repositories,
119+ <strong>5 GB</strong> by default. A push that would exceed it is refused with the space remaining. Repository sizes
120+ are shown on each repository and in your settings. LFS objects and container images are stored separately and do
121+ not count against the git quota.
122+ </p>
123+
124+ <h2 id="trouble">Troubleshooting</h2>
125+ <table>
126+ <thead><tr><th>Message</th><th>Meaning</th></tr></thead>
127+ <tbody>
128+ <tr><td><code>Authentication failed</code> (HTTPS)</td><td>Use a personal access token as the password, not your account password. Check it has the <code>repo</code> scope and has not expired.</td></tr>
129+ <tr><td><code>Permission denied (publickey)</code></td><td>The key you offered is not registered. Add its <code>.pub</code> file in settings and make sure ssh uses it (<code>IdentityFile</code>, <code>IdentitiesOnly yes</code>).</td></tr>
130+ <tr><td><code>repository 'owner/repo' not found</code></td><td>The repository does not exist or you do not have access. Private repositories look missing to people without access.</td></tr>
131+ <tr><td><code>you have read access only</code></td><td>Ask a repository admin for write access, or push to your own fork.</td></tr>
132+ <tr><td><code>this repository is archived</code></td><td>Archived repositories are read-only until an admin unarchives them.</td></tr>
133+ <tr><td><code>files larger than 50 MB must use Git LFS</code></td><td>See <a href="#lfs">Git LFS</a>; rewrite the commits that added the files.</td></tr>
134+ <tr><td>Connection refused on port 22</td><td>The git SSH server uses port 2222. Use an <code>ssh://</code> URL with the port or an <code>~/.ssh/config</code> entry.</td></tr>
135+ </tbody>
136+ </table>
137+ </article>
138+ </div>
139+</Layout>
140+
141+<script>
142+ document.querySelectorAll(".ig-host").forEach((el) => (el.textContent = location.hostname));
143+ document.querySelectorAll(".ig-origin").forEach((el) => (el.textContent = location.origin));
144+</script>
+51-0scripts/e2e/backup.sh
@@ -0,0 +1,51 @@
1+#!/bin/bash
2+# Runs one real backup to R2 (via the ignored cargo test), downloads the
3+# bundles and the Postgres dump, and restores every bundle with git clone,
4+# comparing refs against the live repositories. Uses .env, so it backs up
5+# that database and data/repos into that bucket (and prunes old runs there).
6+set -uo pipefail
7+
8+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
9+E2E="$ROOT/data/e2e"
10+mkdir -p "$E2E/work"
11+DB="$(sed -n 's/^DATABASE_URL=//p' "$ROOT/.env")"
12+RESTORE="$E2E/work/restore"
13+rm -rf "$RESTORE"
14+export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null
15+export PATH="$E2E/bin:$PATH"
16+
17+cd "$ROOT"
18+E2E_RESTORE_DIR="$RESTORE" cargo test -q -p irongit backup::tests::backup_now -- --ignored --nocapture > "$E2E/work/backup-test.log" 2>&1
19+rc=$?
20+grep -E 'SUMMARY|RUN:|BUNDLE|STAMPS|test result|panicked' "$E2E/work/backup-test.log" | cut -c1-300
21+[ "$rc" -eq 0 ] || { echo "FAIL backup test (see $E2E/work/backup-test.log)"; exit 1; }
22+
23+PASS=0
24+FAIL=0
25+for bundle in "$RESTORE"/*.bundle; do
26+ base=$(basename "$bundle" .bundle)
27+ owner=${base%%-*}
28+ name=${base#*-}
29+ id=$(psql "$DB" -tAc "select r.id from repos r join accounts a on a.id = r.owner_id where a.name = '$owner' and r.name = '$name'")
30+ live=$(git --git-dir="$ROOT/data/repos/$id.git" for-each-ref --format='%(refname) %(objectname)' refs/heads refs/tags | sort)
31+ git clone -q --mirror "$bundle" "$RESTORE/$base.git" 2>"$RESTORE/$base.err"
32+ restored=$(git --git-dir="$RESTORE/$base.git" for-each-ref --format='%(refname) %(objectname)' refs/heads refs/tags | sort)
33+ if [ -n "$live" ] && [ "$live" = "$restored" ] && git --git-dir="$RESTORE/$base.git" fsck --no-dangling --no-progress >/dev/null 2>&1; then
34+ echo "PASS restore $owner/$name ($(printf '%s\n' "$restored" | wc -l) refs, fsck clean)"
35+ PASS=$((PASS + 1))
36+ else
37+ echo "FAIL restore $owner/$name"
38+ FAIL=$((FAIL + 1))
39+ fi
40+done
41+if gunzip -t "$RESTORE/postgres.sql.gz" && [ "$(gunzip -c "$RESTORE/postgres.sql.gz" | grep -c 'CREATE TABLE public.repos ')" = 1 ]; then
42+ echo "PASS postgres dump is valid gzip with the schema"
43+ PASS=$((PASS + 1))
44+else
45+ echo "FAIL postgres dump"
46+ FAIL=$((FAIL + 1))
47+fi
48+echo "latest runs:"
49+psql "$DB" -c "select id, status, repo_count, bytes, error, finished_at - started_at as took from backup_runs order by id desc limit 3"
50+echo "passed $PASS, failed $FAIL"
51+[ "$FAIL" -eq 0 ]
+22-0scripts/e2e/fetch-lfs.py
@@ -0,0 +1,22 @@
1+#!/usr/bin/env python3
2+"""Downloads the git-lfs linux-amd64 release binary into data/e2e/bin (tests only)."""
3+import io
4+import json
5+import os
6+import tarfile
7+import urllib.request
8+
9+here = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "data", "e2e")
10+release = json.load(urllib.request.urlopen("https://api.github.com/repos/git-lfs/git-lfs/releases/latest"))
11+url = next(a["browser_download_url"] for a in release["assets"]
12+ if a["name"].startswith("git-lfs-linux-amd64-") and a["name"].endswith(".tar.gz"))
13+print(url)
14+data = urllib.request.urlopen(url).read()
15+os.makedirs(os.path.join(here, "bin"), exist_ok=True)
16+with tarfile.open(fileobj=io.BytesIO(data)) as tar:
17+ member = next(m for m in tar.getmembers() if m.name.endswith("/git-lfs") or m.name == "git-lfs")
18+ target = os.path.join(here, "bin", "git-lfs")
19+ with open(target, "wb") as out:
20+ out.write(tar.extractfile(member).read())
21+ os.chmod(target, 0o755)
22+print("ok", target)
+186-0scripts/e2e/ssh-lfs.sh
@@ -0,0 +1,186 @@
1+#!/bin/bash
2+# End-to-end test of SSH git, LFS (HTTP and SSH) and LFS access control
3+# against a running server. Point it at a throwaway database: it creates and
4+# deletes the users 'alice' and 'bob'. Uses a throwaway HOME, so the real
5+# ~/.ssh and ~/.gitconfig are never touched.
6+#
7+# python3 scripts/e2e/fetch-lfs.py # once: git-lfs into data/e2e/bin
8+# E2E_HTTP=http://localhost:7883 E2E_SSH_PORT=2283 scripts/e2e/ssh-lfs.sh
9+#
10+# Reads DATABASE_URL from .env and initializes bare repos under data/repos,
11+# so run it from the checkout whose server is under test.
12+set -uo pipefail
13+
14+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
15+E2E="$ROOT/data/e2e"
16+DB="$(sed -n 's/^DATABASE_URL=//p' "$ROOT/.env")"
17+HTTP="${E2E_HTTP:-http://localhost:7883}"
18+SSHP="${E2E_SSH_PORT:-2283}"
19+HOSTPORT="${HTTP#http://}"
20+W="$E2E/work"
21+rm -rf "$W"
22+mkdir -p "$W/home"
23+export HOME="$W/home"
24+export PATH="$E2E/bin:$PATH"
25+unset SSH_AUTH_SOCK
26+export GIT_CONFIG_NOSYSTEM=1 GIT_TERMINAL_PROMPT=0
27+git config --global user.name Alice
28+git config --global user.email alice@example.com
29+git config --global init.defaultBranch main
30+git config --global credential.helper "store --file=$W/creds"
31+git lfs install --skip-repo >/dev/null
32+
33+PASS=0
34+FAIL=0
35+ok() { echo "PASS $1"; PASS=$((PASS + 1)); }
36+bad() { echo "FAIL $1"; FAIL=$((FAIL + 1)); }
37+expect() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (expected '$3', got '$2')"; fi; }
38+contains() { if printf "%s" "$2" | grep -qi -- "$3"; then ok "$1"; else bad "$1 (missing '$3' in: $(printf '%s' "$2" | head -c 300))"; fi; }
39+sql() { psql "$DB" -tAc "$1"; }
40+
41+# --- Users, keys, tokens, repos ---------------------------------------------
42+for who in alice bob stranger; do ssh-keygen -q -t ed25519 -N '' -C "$who" -f "$W/${who}_key"; done
43+fp() { ssh-keygen -lf "$1.pub" -E sha256 | awk '{print $2}'; }
44+TA=igp_e2eAlice00000000000000000000000000000000
45+TB=igp_e2eBob0000000000000000000000000000000000
46+hash() { printf %s "$1" | sha256sum | cut -d' ' -f1; }
47+psql "$DB" -q -v ON_ERROR_STOP=1 <<SQL
48+delete from accounts where name in ('alice', 'bob');
49+insert into accounts (kind, name) values ('user', 'alice'), ('user', 'bob');
50+insert into users (account_id) select id from accounts where name in ('alice', 'bob');
51+insert into emails (user_id, email, is_primary, verified_at) select id, name || '@example.com', true, now() from accounts where name in ('alice', 'bob');
52+insert into access_tokens (user_id, name, token_hash, token_prefix) select id, 'e2e', '$(hash $TA)', 'igp_e2eA' from accounts where name = 'alice';
53+insert into access_tokens (user_id, name, token_hash, token_prefix) select id, 'e2e', '$(hash $TB)', 'igp_e2eB' from accounts where name = 'bob';
54+insert into ssh_keys (user_id, title, public_key, fingerprint) select id, 'e2e', '$(cut -d' ' -f1,2 "$W/alice_key.pub")', '$(fp "$W/alice_key")' from accounts where name = 'alice';
55+insert into ssh_keys (user_id, title, public_key, fingerprint) select id, 'e2e', '$(cut -d' ' -f1,2 "$W/bob_key.pub")', '$(fp "$W/bob_key")' from accounts where name = 'bob';
56+insert into repos (owner_id, name, visibility) select id, unnest(array['ssh-repo', 'lfs-http', 'lfs-ssh']), 'private' from accounts where name = 'alice';
57+insert into repos (owner_id, name, visibility) select id, 'bob-repo', 'private' from accounts where name = 'bob';
58+insert into repo_collaborators (repo_id, user_id, permission)
59+ select r.id, b.id, 'read' from repos r join accounts a on a.id = r.owner_id, accounts b where a.name = 'alice' and r.name = 'ssh-repo' and b.name = 'bob';
60+SQL
61+repo_id() { sql "select r.id from repos r join accounts a on a.id = r.owner_id where a.name = '$1' and r.name = '$2'"; }
62+for spec in alice/ssh-repo alice/lfs-http alice/lfs-ssh bob/bob-repo; do
63+ id=$(repo_id "${spec%/*}" "${spec#*/}")
64+ git init --bare -q --initial-branch=main "$ROOT/data/repos/$id.git"
65+ rm -rf "$ROOT/data/repos/$id.git/hooks"
66+done
67+printf 'http://alice:%s@%s\n' "$TA" "$HOSTPORT" > "$W/creds"
68+
69+SSH_OPTS="-o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
70+as_alice() { GIT_SSH_COMMAND="ssh -i $W/alice_key $SSH_OPTS" "$@"; }
71+as_bob() { GIT_SSH_COMMAND="ssh -i $W/bob_key $SSH_OPTS" "$@"; }
72+
73+# --- 1. Git over SSH ----------------------------------------------------------
74+echo "== git over ssh"
75+mkdir "$W/src1" && cd "$W/src1" && git init -q
76+echo hello > README.md && git add . && git commit -qm "first"
77+echo two > two.txt && git add . && git commit -qm "second"
78+out=$(as_alice git push ssh://git@localhost:$SSHP/alice/ssh-repo.git main 2>&1); rc=$?
79+expect "push over ssh succeeds" "$rc" 0
80+out=$(as_alice git clone -q ssh://git@localhost:$SSHP/alice/ssh-repo.git "$W/clone1" 2>&1); rc=$?
81+expect "clone over ssh succeeds" "$rc" 0
82+expect "clone has both commits" "$(git -C "$W/clone1" rev-list --count HEAD 2>/dev/null)" 2
83+out=$(GIT_SSH_COMMAND="ssh -i $W/alice_key $SSH_OPTS -o SendEnv=GIT_PROTOCOL" git -c protocol.version=0 clone -q ssh://git@localhost:$SSHP/alice/ssh-repo "$W/clone1b" 2>&1); rc=$?
84+expect "clone over ssh with protocol v0 and no .git suffix" "$rc" 0
85+sleep 2
86+RID=$(repo_id alice ssh-repo)
87+expect "push event recorded via ssh" "$(sql "select count(*) from push_events where repo_id = $RID and via = 'ssh'")" 1
88+expect "contributions recorded for ssh push" "$(sql "select count(*) from contribution_commits where repo_id = $RID")" 2
89+expect "repo marked non-empty with size" "$(sql "select (not is_empty and size_bytes > 0)::text from repos where id = $RID")" true
90+
91+out=$(as_bob git clone -q ssh://git@localhost:$SSHP/alice/ssh-repo.git "$W/clone-bob" 2>&1); rc=$?
92+expect "read collaborator can clone over ssh" "$rc" 0
93+cd "$W/clone-bob" && echo bob > bob.txt && git add . && git -c user.email=bob@example.com commit -qm "bob"
94+out=$(as_bob git push origin main 2>&1); rc=$?
95+[ "$rc" -ne 0 ] && ok "read collaborator cannot push over ssh" || bad "read collaborator pushed"
96+contains "push refusal explains read access" "$out" "read access only"
97+out=$(as_bob git clone -q ssh://git@localhost:$SSHP/alice/lfs-http.git "$W/clone-bob2" 2>&1); rc=$?
98+[ "$rc" -ne 0 ] && ok "no-access user cannot clone private repo" || bad "no-access user cloned"
99+contains "no-access clone says not found" "$out" "not found"
100+out=$(GIT_SSH_COMMAND="ssh -i $W/stranger_key $SSH_OPTS" git clone -q ssh://git@localhost:$SSHP/alice/ssh-repo.git "$W/clone-x" 2>&1); rc=$?
101+[ "$rc" -ne 0 ] && ok "unknown key is refused" || bad "unknown key accepted"
102+contains "unknown key gets permission denied" "$out" "Permission denied"
103+out=$(ssh -i "$W/alice_key" $SSH_OPTS -p $SSHP -T git@localhost 2>&1); rc=$?
104+expect "shell request exits 1" "$rc" 1
105+contains "shell request greets the user" "$out" "Hi alice"
106+out=$(ssh -i "$W/alice_key" $SSH_OPTS -p $SSHP git@localhost "cat /etc/passwd" 2>&1); rc=$?
107+[ "$rc" -ne 0 ] && ok "arbitrary command refused" || bad "arbitrary command ran"
108+contains "arbitrary command message" "$out" "not allowed"
109+
110+# --- 2. LFS over HTTP ---------------------------------------------------------
111+echo "== lfs over http"
112+mkdir "$W/src2" && cd "$W/src2" && git init -q
113+git lfs track "*.bin" >/dev/null
114+head -c 62914560 /dev/urandom > big.bin
115+BIG_SHA=$(sha256sum big.bin | cut -d' ' -f1)
116+git add .gitattributes big.bin && git commit -qm "big file"
117+git remote add origin $HTTP/alice/lfs-http.git
118+out=$(git push origin main 2>&1); rc=$?
119+expect "lfs push over http succeeds" "$rc" 0
120+LID=$(repo_id alice lfs-http)
121+expect "lfs object linked to repo" "$(sql "select count(*) from repo_lfs_objects where repo_id = $LID and oid = '$BIG_SHA'")" 1
122+expect "lfs object recorded with size" "$(sql "select size from lfs_objects where oid = '$BIG_SHA'")" 62914560
123+out=$(git clone -q $HTTP/alice/lfs-http.git "$W/clone2" 2>&1); rc=$?
124+expect "fresh http clone succeeds" "$rc" 0
125+expect "fresh http clone has real lfs content" "$(sha256sum "$W/clone2/big.bin" 2>/dev/null | cut -d' ' -f1)" "$BIG_SHA"
126+batch() { curl -s -o "$W/batch.json" -w '%{http_code}' -X POST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' "$@"; }
127+code=$(batch -u "alice:$TA" -d "{\"operation\":\"download\",\"objects\":[{\"oid\":\"$BIG_SHA\",\"size\":62914560}]}" $HTTP/alice/lfs-http.git/info/lfs/objects/batch)
128+href=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["objects"][0]["actions"]["download"]["href"])' "$W/batch.json")
129+contains "download href points at R2 lfs/ key" "$href" "/lfs/${BIG_SHA:0:2}/${BIG_SHA:2:2}/$BIG_SHA"
130+expect "R2 object has the full size" "$(curl -s -o /dev/null -w '%{size_download}' "$href")" 62914560
131+
132+# --- 3. LFS over SSH (git-lfs-authenticate) ----------------------------------
133+echo "== lfs over ssh"
134+mkdir "$W/src3" && cd "$W/src3" && git init -q
135+git lfs track "*.bin" >/dev/null
136+cp "$W/src2/big.bin" big.bin
137+head -c 5242880 /dev/urandom > small.bin
138+SMALL_SHA=$(sha256sum small.bin | cut -d' ' -f1)
139+git add .gitattributes big.bin small.bin && git commit -qm "lfs over ssh"
140+git remote add origin ssh://git@localhost:$SSHP/alice/lfs-ssh.git
141+out=$(as_alice git push origin main 2>&1); rc=$?
142+expect "lfs push over ssh succeeds" "$rc" 0
143+SID=$(repo_id alice lfs-ssh)
144+expect "both objects linked to the ssh repo" "$(sql "select count(*) from repo_lfs_objects where repo_id = $SID")" 2
145+out=$(as_alice git clone -q ssh://git@localhost:$SSHP/alice/lfs-ssh.git "$W/clone3" 2>&1); rc=$?
146+expect "fresh ssh clone succeeds" "$rc" 0
147+expect "ssh clone big file content" "$(sha256sum "$W/clone3/big.bin" 2>/dev/null | cut -d' ' -f1)" "$BIG_SHA"
148+expect "ssh clone small file content" "$(sha256sum "$W/clone3/small.bin" 2>/dev/null | cut -d' ' -f1)" "$SMALL_SHA"
149+auth=$(ssh -i "$W/alice_key" $SSH_OPTS -p $SSHP git@localhost git-lfs-authenticate alice/lfs-http.git download 2>&1)
150+contains "git-lfs-authenticate returns an href" "$auth" "\"href\":\"$HTTP/alice/lfs-http.git/info/lfs\""
151+DL_TOKEN=$(printf '%s' "$auth" | python3 -c 'import json,sys; print(json.load(sys.stdin)["header"]["Authorization"])')
152+code=$(batch -H "Authorization: $DL_TOKEN" -d "{\"operation\":\"upload\",\"objects\":[{\"oid\":\"$SMALL_SHA\",\"size\":5242880}]}" $HTTP/alice/lfs-http.git/info/lfs/objects/batch)
153+expect "download-only ssh token cannot upload" "$code" 403
154+code=$(batch -H "Authorization: $DL_TOKEN" -d "{\"operation\":\"download\",\"objects\":[{\"oid\":\"$BIG_SHA\",\"size\":62914560}]}" $HTTP/alice/lfs-ssh.git/info/lfs/objects/batch)
155+expect "ssh token for one repo is refused on another" "$code" 403
156+out=$(ssh -i "$W/bob_key" $SSH_OPTS -p $SSHP git@localhost git-lfs-authenticate alice/lfs-http.git download 2>&1); rc=$?
157+[ "$rc" -ne 0 ] && ok "no-access user gets no lfs token" || bad "no-access user got lfs token: $out"
158+
159+# --- 4. LFS access control ----------------------------------------------------
160+echo "== lfs access control"
161+code=$(batch -d "{\"operation\":\"download\",\"objects\":[{\"oid\":\"$BIG_SHA\",\"size\":62914560}]}" -D "$W/anon.headers" $HTTP/alice/lfs-http.git/info/lfs/objects/batch)
162+expect "anonymous batch on private repo is 401" "$code" 401
163+contains "401 carries LFS-Authenticate" "$(cat "$W/anon.headers")" "LFS-Authenticate: Basic"
164+code=$(batch -u "bob:$TB" -d "{\"operation\":\"download\",\"objects\":[{\"oid\":\"$BIG_SHA\",\"size\":62914560}]}" $HTTP/alice/lfs-http.git/info/lfs/objects/batch)
165+expect "other user gets 404 on private repo" "$code" 404
166+code=$(batch -u "bob:$TB" -d "{\"operation\":\"download\",\"objects\":[{\"oid\":\"$BIG_SHA\",\"size\":62914560}]}" $HTTP/bob/bob-repo.git/info/lfs/objects/batch)
167+expect "batch on own repo answers 200" "$code" 200
168+contains "foreign oid is not downloadable from own repo" "$(cat "$W/batch.json")" '"code":404'
169+code=$(batch -u "bob:$TB" -d "{\"operation\":\"upload\",\"objects\":[{\"oid\":\"$BIG_SHA\",\"size\":62914560}]}" $HTTP/bob/bob-repo.git/info/lfs/objects/batch)
170+expect "upload batch for a stored oid answers 200" "$code" 200
171+contains "stored oid still requires an upload" "$(cat "$W/batch.json")" '"upload"'
172+VERIFY_AUTH=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["objects"][0]["actions"]["verify"]["header"]["Authorization"])' "$W/batch.json")
173+code=$(curl -s -o "$W/verify.json" -w '%{http_code}' -X POST -H "Authorization: $VERIFY_AUTH" -H 'Content-Type: application/vnd.git-lfs+json' -d "{\"oid\":\"$BIG_SHA\",\"size\":62914560}" $HTTP/bob/bob-repo.git/info/lfs/verify)
174+expect "verify without uploading is rejected" "$code" 422
175+code=$(curl -s -o "$W/verify.json" -w '%{http_code}' -X POST -u "bob:$TB" -H 'Content-Type: application/vnd.git-lfs+json' -d "{\"oid\":\"$BIG_SHA\",\"size\":62914560}" $HTTP/bob/bob-repo.git/info/lfs/verify)
176+expect "verify with a plain token is rejected" "$code" 403
177+BID=$(repo_id bob bob-repo)
178+expect "foreign object never linked to bob's repo" "$(sql "select count(*) from repo_lfs_objects where repo_id = $BID")" 0
179+code=$(batch -u "alice:$TA" -d '{"operation":"download","objects":[{"oid":"../../etc/passwd","size":1}]}' $HTTP/alice/lfs-http.git/info/lfs/objects/batch)
180+contains "invalid oid rejected per object" "$(cat "$W/batch.json")" '"code":422'
181+code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -u "alice:$TA" -H 'Content-Type: application/vnd.git-lfs+json' -d '{}' $HTTP/alice/lfs-http.git/info/lfs/locks/verify)
182+expect "locks/verify answers 200" "$code" 200
183+
184+echo
185+echo "passed $PASS, failed $FAIL"
186+[ "$FAIL" -eq 0 ]
+22-0scripts/e2e/stop-server.py
@@ -0,0 +1,22 @@
1+#!/usr/bin/env python3
2+"""List (or with --kill, stop) debug servers built from this checkout, leaving
3+servers from other checkouts and worktrees alone."""
4+import os
5+import signal
6+import sys
7+
8+worktree = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) # checkout root
9+kill = "--kill" in sys.argv
10+for pid in os.listdir("/proc"):
11+ if not pid.isdigit():
12+ continue
13+ try:
14+ exe = os.readlink(f"/proc/{pid}/exe").removesuffix(" (deleted)")
15+ cwd = os.readlink(f"/proc/{pid}/cwd")
16+ except OSError:
17+ continue
18+ if exe.startswith(worktree + "/target/") and exe.endswith("/debug/irongit"):
19+ print(pid, exe, cwd)
20+ if kill:
21+ os.kill(int(pid), signal.SIGTERM)
22+ print("killed", pid)