fabrica

hanna/fabrica

13115 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The smart-HTTP git transport (read-only).
6//!
7//! Only clone/fetch (`upload-pack`) is served; push (`receive-pack`) returns a
8//! deliberate `403` pointing at the SSH URL (§3.3). Authorization happens **before**
9//! the `git` subprocess is spawned. The refs advertisement is small and buffered;
10//! the pack response streams straight from the child's stdout so it is never held
11//! in memory. Dumb HTTP is not implemented.
12
13use axum::body::Body;
14use axum::extract::{Path, State};
15use axum::http::{HeaderMap, StatusCode, header};
16use axum::response::{IntoResponse, Response};
17use axum_extra::extract::cookie::CookieJar;
18use base64::Engine as _;
19use model::{Repo, User};
20use tokio::io::AsyncWriteExt as _;
21use tokio_util::io::ReaderStream;
22
23use crate::AppState;
24
25/// Split `"repo/path.git/service..."` into the repo path and the trailing service
26/// path (`info/refs`, `git-upload-pack`, `git-receive-pack`). Also accepts the
27/// forms without `.git` is *not* supported here — callers strip that themselves.
28#[must_use]
29pub fn split_git(rest: &str) -> Option<(&str, &str)> {
30 let idx = rest.find(".git/")?;
31 Some((&rest[..idx], &rest[idx + ".git/".len()..]))
32}
33
34/// Handle a GET under a `.git/` path — only `info/refs?service=git-upload-pack`.
35pub async fn http_get(
36 state: &AppState,
37 jar: &CookieJar,
38 headers: &HeaderMap,
39 owner: &str,
40 repo_path: &str,
41 tail: &str,
42 service: Option<&str>,
43) -> Response {
44 if tail != "info/refs" {
45 return StatusCode::NOT_FOUND.into_response();
46 }
47 match service {
48 Some("git-upload-pack") => {}
49 Some("git-receive-pack") => return push_disabled(state, owner, repo_path),
50 _ => return StatusCode::FORBIDDEN.into_response(),
51 }
52
53 let repo = match authorize(state, jar, headers, owner, repo_path, auth::Access::Read).await {
54 Ok(repo) => repo,
55 Err(response) => return response,
56 };
57 info_refs(state, &repo, headers).await
58}
59
60/// Handle a POST under a `.git/` path — `git-upload-pack` (served) or
61/// `git-receive-pack` (403).
62pub async fn pack_rpc(
63 State(state): State<AppState>,
64 jar: CookieJar,
65 headers: HeaderMap,
66 Path((owner, rest)): Path<(String, String)>,
67 body: Body,
68) -> Response {
69 // LFS batch/verify share the POST catch-all; route them before pack RPC.
70 if let Some((repo_path, tail)) = crate::lfs::split_lfs(&rest) {
71 let repo_path = repo_path.to_string();
72 return match tail {
73 "objects/batch" => crate::lfs::batch(state, jar, headers, owner, repo_path, body).await,
74 t if t.starts_with("objects/") && t.ends_with("/verify") => {
75 crate::lfs::verify(state, jar, headers, owner, repo_path, body).await
76 }
77 _ => StatusCode::NOT_FOUND.into_response(),
78 };
79 }
80 let Some((repo_path, tail)) = split_git(&rest) else {
81 return StatusCode::NOT_FOUND.into_response();
82 };
83 match tail {
84 "git-upload-pack" => {}
85 "git-receive-pack" => return push_disabled(&state, &owner, repo_path),
86 _ => return StatusCode::NOT_FOUND.into_response(),
87 }
88
89 let repo = match authorize(
90 &state,
91 &jar,
92 &headers,
93 &owner,
94 repo_path,
95 auth::Access::Read,
96 )
97 .await
98 {
99 Ok(repo) => repo,
100 Err(response) => return response,
101 };
102 upload_pack_rpc(&state, &repo, &headers, body).await
103}
104
105/// Spawn `upload-pack --advertise-refs`, frame the output as a smart-HTTP service
106/// advertisement, and return it.
107async fn info_refs(state: &AppState, repo: &Repo, headers: &HeaderMap) -> Response {
108 let repo_dir = match git::repo_path(&state.config.storage.repo_dir, &repo.id) {
109 Ok(p) => p,
110 Err(e) => return internal(&e.to_string()),
111 };
112 let env = pack_env(state, headers, repo);
113 let child = git::pack::spawn(
114 &state.config.git.binary,
115 git::pack::Service::UploadPack,
116 &["--stateless-rpc", "--advertise-refs"],
117 &repo_dir,
118 &env,
119 );
120 let output = match child {
121 Ok(child) => match child.wait_with_output().await {
122 Ok(out) => out,
123 Err(e) => return internal(&e.to_string()),
124 },
125 Err(e) => return internal(&e.to_string()),
126 };
127
128 let mut framed = git::pack::pkt_line("# service=git-upload-pack\n").into_bytes();
129 framed.extend_from_slice(git::pack::FLUSH_PKT.as_bytes());
130 framed.extend_from_slice(&output.stdout);
131
132 (
133 [
134 (
135 header::CONTENT_TYPE,
136 "application/x-git-upload-pack-advertisement",
137 ),
138 (header::CACHE_CONTROL, "no-cache"),
139 ],
140 framed,
141 )
142 .into_response()
143}
144
145/// Spawn `upload-pack --stateless-rpc`, feed it the (buffered, possibly gunzipped)
146/// request, and stream its stdout back as the response body.
147async fn upload_pack_rpc(
148 state: &AppState,
149 repo: &Repo,
150 headers: &HeaderMap,
151 body: Body,
152) -> Response {
153 let repo_dir = match git::repo_path(&state.config.storage.repo_dir, &repo.id) {
154 Ok(p) => p,
155 Err(e) => return internal(&e.to_string()),
156 };
157
158 // The upload-pack request (the client's "wants") is small; buffer it, and
159 // decompress if the client gzipped it. The large pack is the *response*, which
160 // is streamed below.
161 let Ok(raw) = axum::body::to_bytes(body, 16 * 1024 * 1024).await else {
162 return (StatusCode::BAD_REQUEST, "request body too large").into_response();
163 };
164 let request = if header_eq(headers, header::CONTENT_ENCODING, "gzip") {
165 match gunzip(&raw) {
166 Ok(data) => data,
167 Err(_) => return (StatusCode::BAD_REQUEST, "invalid gzip body").into_response(),
168 }
169 } else {
170 raw.to_vec()
171 };
172
173 let env = pack_env(state, headers, repo);
174 let mut child = match git::pack::spawn(
175 &state.config.git.binary,
176 git::pack::Service::UploadPack,
177 &["--stateless-rpc"],
178 &repo_dir,
179 &env,
180 ) {
181 Ok(child) => child,
182 Err(e) => return internal(&e.to_string()),
183 };
184
185 let (Some(mut stdin), Some(stdout)) = (child.stdin.take(), child.stdout.take()) else {
186 return internal("failed to capture pack subprocess stdio");
187 };
188
189 // Feed the request, then own the child until it exits (or the client
190 // disconnects, closing stdout and making upload-pack exit on write).
191 tokio::spawn(async move {
192 let _ = stdin.write_all(&request).await;
193 let _ = stdin.shutdown().await;
194 drop(stdin);
195 let _ = child.wait().await;
196 });
197
198 let stream = ReaderStream::new(stdout);
199 (
200 [(header::CONTENT_TYPE, "application/x-git-upload-pack-result")],
201 Body::from_stream(stream),
202 )
203 .into_response()
204}
205
206/// Resolve and authorize a repo for pack access. On failure returns the response
207/// to send: `401` (anonymous, credentials needed) or `404` (missing, or an
208/// authenticated stranger — no existence leak).
209#[allow(clippy::result_large_err)] // the Err is a ready-to-send Response by design.
210pub(crate) async fn authorize(
211 state: &AppState,
212 jar: &CookieJar,
213 headers: &HeaderMap,
214 owner: &str,
215 repo_path: &str,
216 required: auth::Access,
217) -> Result<Repo, Response> {
218 let owner_user = state.store.user_by_username(owner).await.ok().flatten();
219 let repo = match owner_user {
220 Some(ref u) => state
221 .store
222 .repo_by_owner_path(&u.id, repo_path)
223 .await
224 .ok()
225 .flatten(),
226 None => None,
227 };
228 let Some(repo) = repo else {
229 return Err(StatusCode::NOT_FOUND.into_response());
230 };
231
232 let viewer = authenticate(state, jar, headers).await;
233 let viewer_id = viewer.as_ref().map(|u| auth::Viewer {
234 id: u.id.clone(),
235 is_admin: u.is_admin,
236 });
237 let collaborator = match &viewer {
238 Some(u) => state
239 .store
240 .effective_permission(&repo.id, &u.id)
241 .await
242 .ok()
243 .flatten()
244 .and_then(|p| auth::Permission::parse(&p)),
245 None => None,
246 };
247 let access = auth::access(
248 viewer_id.as_ref(),
249 &repo,
250 collaborator,
251 state.allow_anonymous(),
252 );
253 if access >= required {
254 Ok(repo)
255 } else if viewer.is_none() {
256 Err(unauthorized())
257 } else {
258 // An authenticated stranger: 404, not 403, so existence never leaks.
259 Err(StatusCode::NOT_FOUND.into_response())
260 }
261}
262
263/// Authenticate a request via the session cookie, an LFS bearer token (minted by
264/// SSH `git-lfs-authenticate`), or HTTP Basic (username + password). Returns the
265/// user, or `None` for anonymous.
266pub(crate) async fn authenticate(
267 state: &AppState,
268 jar: &CookieJar,
269 headers: &HeaderMap,
270) -> Option<User> {
271 if let Some(cookie) = jar.get(&state.config.auth.cookie_name)
272 && let Ok(Some(user)) = state
273 .store
274 .user_for_session(&auth::session_id(cookie.value()))
275 .await
276 {
277 return Some(user);
278 }
279 if let Some(user) = bearer_user(state, headers).await {
280 return Some(user);
281 }
282 let (username, password) = parse_basic(headers)?;
283 let user = state
284 .store
285 .user_by_username(&username)
286 .await
287 .ok()
288 .flatten()?;
289 if user.disabled_at.is_none()
290 && user.password_hash.is_some()
291 && auth::verify_password(&password, user.password_hash.as_deref())
292 {
293 Some(user)
294 } else {
295 None
296 }
297}
298
299/// Resolve an `Authorization: Bearer <jwt>` header to its user. The token is an
300/// LFS access token minted by SSH `git-lfs-authenticate`; only its signature and
301/// expiry are proven here (access is re-checked per repo by [`authorize`]).
302async fn bearer_user(state: &AppState, headers: &HeaderMap) -> Option<User> {
303 let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
304 let token = value.strip_prefix("Bearer ")?.trim();
305 let now = crate::now_ms() / 1000;
306 let claims = auth::verify_jwt(state.secret.as_str(), token, now).ok()?;
307 state
308 .store
309 .user_by_id(&claims.sub)
310 .await
311 .ok()
312 .flatten()
313 .filter(|u| u.disabled_at.is_none())
314}
315
316/// Parse an HTTP Basic `Authorization` header into `(username, password)`.
317fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> {
318 let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
319 let encoded = value.strip_prefix("Basic ")?;
320 let decoded = base64::engine::general_purpose::STANDARD
321 .decode(encoded.trim())
322 .ok()?;
323 let text = String::from_utf8(decoded).ok()?;
324 let (user, pass) = text.split_once(':')?;
325 Some((user.to_string(), pass.to_string()))
326}
327
328/// Build the subprocess environment for a pack spawn.
329fn pack_env(state: &AppState, headers: &HeaderMap, repo: &Repo) -> git::pack::PackEnv {
330 git::pack::PackEnv {
331 home: state.config.storage.data_dir.join("tmp"),
332 protocol: headers
333 .get("git-protocol")
334 .and_then(|v| v.to_str().ok())
335 .map(str::to_string),
336 repo_id: Some(repo.id.clone()),
337 ..git::pack::PackEnv::default()
338 }
339}
340
341/// The `403` push-over-HTTPS-disabled response, naming the SSH remote.
342fn push_disabled(state: &AppState, owner: &str, repo_path: &str) -> Response {
343 let host = &state.config.ssh.clone_host;
344 let port = state.config.ssh.clone_port;
345 let ssh_url = if port == 22 {
346 format!("git@{host}:{owner}/{repo_path}.git")
347 } else {
348 format!("ssh://git@{host}:{port}/{owner}/{repo_path}.git")
349 };
350 (
351 StatusCode::FORBIDDEN,
352 format!("Pushing over HTTPS is disabled. Push over SSH instead:\n\n {ssh_url}\n"),
353 )
354 .into_response()
355}
356
357/// The `401` challenge for anonymous access to a private repo.
358fn unauthorized() -> Response {
359 (
360 StatusCode::UNAUTHORIZED,
361 [(header::WWW_AUTHENTICATE, "Basic realm=\"fabrica\"")],
362 "Authentication required.\n",
363 )
364 .into_response()
365}
366
367/// A `500` for an internal transport failure (detail logged, not shown).
368fn internal(detail: &str) -> Response {
369 tracing::error!(error.detail = %detail, "pack transport error");
370 (StatusCode::INTERNAL_SERVER_ERROR, "internal error\n").into_response()
371}
372
373/// Whether a header equals a value (ASCII case-insensitive).
374fn header_eq(headers: &HeaderMap, name: header::HeaderName, value: &str) -> bool {
375 headers
376 .get(name)
377 .and_then(|v| v.to_str().ok())
378 .is_some_and(|v| v.eq_ignore_ascii_case(value))
379}
380
381/// Decompress a gzip body.
382fn gunzip(data: &[u8]) -> std::io::Result<Vec<u8>> {
383 use std::io::Read as _;
384 let mut decoder = flate2::read::GzDecoder::new(data);
385 let mut out = Vec::new();
386 decoder.read_to_end(&mut out)?;
387 Ok(out)
388}