1use std::io::Read;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use axum::Router;
15use axum::body::{Body, Bytes};
16use axum::extract::State;
17use axum::http::{HeaderMap, Method, Request, StatusCode, header};
18use axum::response::{IntoResponse, Response};
19use axum::routing::get;
20use subtle::ConstantTimeEq;
21use tower::limit::GlobalConcurrencyLimitLayer;
22
23use crate::git::GitCache;
24use crate::lfs::{Lfs, Outcome};
25use crate::metrics::{LfsResult, Metrics, RequestKind, Status};
26use crate::repo;
27
28const MAX_BODY: usize = 64 * 1024 * 1024;
29const UPLOAD_PACK: &str = "git-upload-pack";
30const RECEIVE_PACK: &str = "git-receive-pack";
31const LFS_CONTENT_TYPE: &str = "application/vnd.git-lfs+json";
32const LFS_BATCH_SUFFIX: &str = "/info/lfs/objects/batch";
33
34#[derive(Clone)]
35pub struct AppState {
36 pub cache: Arc<GitCache>,
37 pub lfs: Arc<Lfs>,
38 pub upstream_base: String,
39 pub cache_root: PathBuf,
40 pub serve_token: Option<String>,
41 pub max_decoded_body: usize,
44 pub max_concurrent: usize,
47 pub metrics: Arc<Metrics>,
48}
49
50pub fn router(state: AppState) -> Router {
51 let max_concurrent = state.max_concurrent;
52
53 let observability = Router::new()
58 .route("/healthz", get(|| async { "ok" }))
59 .route("/readyz", get(readyz))
60 .route("/metrics", get(metrics_handler))
61 .with_state(state.clone());
62
63 let mut git = Router::new().fallback(handle_git).with_state(state);
71 if max_concurrent != 0 {
72 git = git.layer(GlobalConcurrencyLimitLayer::new(max_concurrent));
73 }
74
75 observability.merge(git)
76}
77
78async fn metrics_handler(State(st): State<AppState>) -> Response {
79 Response::builder()
80 .header(header::CONTENT_TYPE, "text/plain; version=0.0.4")
81 .body(Body::from(st.metrics.gather()))
82 .expect("valid response")
83}
84
85async fn readyz(State(st): State<AppState>) -> Response {
91 match cache_writable(&st.cache_root).await {
92 Ok(()) => (StatusCode::OK, "ok").into_response(),
93 Err(e) => {
94 tracing::warn!(
95 cache_root = %st.cache_root.display(),
96 error = %e,
97 "readiness check failed"
98 );
99 err(StatusCode::SERVICE_UNAVAILABLE, "cache root not writable")
100 }
101 }
102}
103
104async fn cache_writable(cache_root: &Path) -> std::io::Result<()> {
110 tokio::fs::create_dir_all(cache_root).await?;
111 let probe = cache_root.join(".readyz-probe");
112 tokio::fs::write(&probe, b"").await?;
113 let _ = tokio::fs::remove_file(&probe).await;
114 Ok(())
115}
116
117async fn handle_git(State(st): State<AppState>, req: Request<Body>) -> Response {
118 let (parts, body) = req.into_parts();
119 let path = parts.uri.path().to_string();
120 let query = parts.uri.query().unwrap_or("").to_string();
121 let git_protocol = parts
122 .headers
123 .get("git-protocol")
124 .and_then(|v| v.to_str().ok())
125 .map(str::to_string);
126
127 if let Some(resp) = check_auth(&st, &parts.headers) {
128 st.metrics
129 .record_request(RequestKind::Auth, Status::Unauthorized, "-");
130 return resp;
131 }
132
133 if path.ends_with(&format!("/{RECEIVE_PACK}"))
135 || query.contains(&format!("service={RECEIVE_PACK}"))
136 {
137 st.metrics
138 .record_request(RequestKind::ReceivePack, Status::Rejected, "-");
139 return err(
140 StatusCode::FORBIDDEN,
141 "read-only proxy: pushes are not allowed",
142 );
143 }
144
145 if parts.method == Method::GET && path.ends_with("/info/refs") {
146 if !query.contains(&format!("service={UPLOAD_PACK}")) {
147 st.metrics
148 .record_request(RequestKind::InfoRefs, Status::Error, "-");
149 return err(
150 StatusCode::BAD_REQUEST,
151 "only smart-http git-upload-pack is supported",
152 );
153 }
154 return info_refs(st, &path, git_protocol.as_deref()).await;
155 }
156
157 if parts.method == Method::POST && path.ends_with(&format!("/{UPLOAD_PACK}")) {
158 let body = match axum::body::to_bytes(body, MAX_BODY).await {
159 Ok(b) => b,
160 Err(_) => return err(StatusCode::BAD_REQUEST, "failed to read request body"),
161 };
162 let content_encoding = parts
168 .headers
169 .get(header::CONTENT_ENCODING)
170 .and_then(|v| v.to_str().ok());
171 let body = match decode_body(content_encoding, body, st.max_decoded_body) {
172 Ok(b) => b,
173 Err(_) => return err(StatusCode::BAD_REQUEST, "failed to decode request body"),
174 };
175 return upload_pack(st, &path, git_protocol.as_deref(), body).await;
176 }
177
178 if parts.method == Method::POST && path.ends_with(LFS_BATCH_SUFFIX) {
180 let body = match axum::body::to_bytes(body, MAX_BODY).await {
181 Ok(b) => b,
182 Err(_) => return err(StatusCode::BAD_REQUEST, "failed to read request body"),
183 };
184 return lfs_batch(st, &path, &parts.headers, body).await;
185 }
186 if parts.method == Method::GET
187 && let Some((repo_name, oid)) = repo::lfs_object_from_path(&path)
188 {
189 return lfs_object(st, repo_name, oid, &query).await;
190 }
191
192 err(StatusCode::NOT_FOUND, "not a git smart-http endpoint")
193}
194
195async fn lfs_batch(st: AppState, path: &str, headers: &HeaderMap, body: Bytes) -> Response {
199 let Some(name) = repo::lfs_batch_repo(path) else {
200 st.metrics
201 .record_request(RequestKind::LfsBatch, Status::Error, "-");
202 return err(StatusCode::NOT_FOUND, "bad lfs batch path");
203 };
204 if is_lfs_upload(&body) {
205 st.metrics
206 .record_request(RequestKind::LfsBatch, Status::Rejected, "-");
207 return err(
208 StatusCode::FORBIDDEN,
209 "read-only proxy: lfs upload is not allowed",
210 );
211 }
212 let advertise = advertise_base(headers);
213 match st.lfs.batch(&name, &body, &advertise).await {
214 Ok(json) => {
215 st.metrics
216 .record_request(RequestKind::LfsBatch, Status::Ok, &name);
217 Response::builder()
218 .header(header::CONTENT_TYPE, LFS_CONTENT_TYPE)
219 .header(header::CACHE_CONTROL, "no-cache")
220 .body(Body::from(json))
221 .expect("valid response")
222 }
223 Err(e) => {
224 st.metrics
225 .record_request(RequestKind::LfsBatch, Status::UpstreamError, "-");
226 tracing::warn!(repo = %name, error = %e, "lfs batch failed");
227 err(StatusCode::BAD_GATEWAY, "upstream lfs batch failed")
228 }
229 }
230}
231
232async fn lfs_object(st: AppState, repo_name: String, oid: String, query: &str) -> Response {
236 let size = size_from_query(query);
237 match st.lfs.ensure_object(&repo_name, &oid, size).await {
238 Ok((path, outcome)) => {
239 st.metrics.record_lfs(match outcome {
240 Outcome::Hit => LfsResult::Hit,
241 Outcome::Miss => LfsResult::Miss,
242 });
243 match lfs_file_response(&path).await {
244 Ok(resp) => {
245 st.metrics
246 .record_request(RequestKind::LfsObject, Status::Ok, "-");
247 resp
248 }
249 Err(e) => {
250 st.metrics
251 .record_request(RequestKind::LfsObject, Status::Error, "-");
252 tracing::warn!(oid = %oid, error = %e, "serve cached lfs object failed");
253 err(StatusCode::INTERNAL_SERVER_ERROR, "serve lfs object failed")
254 }
255 }
256 }
257 Err(e) => {
258 st.metrics.record_lfs(LfsResult::Error);
259 st.metrics
260 .record_request(RequestKind::LfsObject, Status::UpstreamError, "-");
261 tracing::warn!(oid = %oid, error = %e, "lfs object fetch failed");
262 err(StatusCode::BAD_GATEWAY, "upstream lfs object fetch failed")
263 }
264 }
265}
266
267async fn lfs_file_response(path: &Path) -> std::io::Result<Response> {
269 let file = tokio::fs::File::open(path).await?;
270 let len = file.metadata().await?.len();
271 let stream = tokio_util::io::ReaderStream::new(file);
272 Ok(Response::builder()
273 .header(header::CONTENT_TYPE, "application/octet-stream")
274 .header(header::CONTENT_LENGTH, len)
275 .body(Body::from_stream(stream))
276 .expect("valid response"))
277}
278
279fn is_lfs_upload(body: &[u8]) -> bool {
282 let Ok(v) = serde_json::from_slice::<serde_json::Value>(body) else {
283 return false;
284 };
285 v.get("operation").and_then(serde_json::Value::as_str) == Some("upload")
286}
287
288fn size_from_query(query: &str) -> Option<u64> {
291 query
292 .split('&')
293 .find_map(|kv| kv.strip_prefix("size="))
294 .and_then(|v| v.parse().ok())
295}
296
297fn advertise_base(headers: &HeaderMap) -> String {
302 let first = |v: &axum::http::HeaderValue| {
303 v.to_str()
304 .ok()
305 .map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
306 };
307 let scheme = headers
308 .get("x-forwarded-proto")
309 .and_then(first)
310 .filter(|s| !s.is_empty())
311 .unwrap_or_else(|| "http".to_string());
312 let host = headers
313 .get("x-forwarded-host")
314 .or_else(|| headers.get(header::HOST))
315 .and_then(first)
316 .unwrap_or_default();
317 format!("{scheme}://{host}")
318}
319
320async fn info_refs(st: AppState, path: &str, git_protocol: Option<&str>) -> Response {
321 let Some(name) = repo::repo_name_from_path(path, "/info/refs") else {
322 st.metrics
323 .record_request(RequestKind::InfoRefs, Status::Error, "-");
324 return err(StatusCode::NOT_FOUND, "bad path");
325 };
326 let repo = match repo::resolve(&name, &st.upstream_base, &st.cache_root) {
327 Ok(r) => r,
328 Err(e) => {
329 st.metrics
330 .record_request(RequestKind::InfoRefs, Status::Error, "-");
331 return err(StatusCode::BAD_REQUEST, &e.to_string());
332 }
333 };
334
335 if let Err(e) = st.cache.ensure_fresh(&repo, true).await {
340 st.metrics
341 .record_request(RequestKind::InfoRefs, Status::UpstreamError, "-");
342 tracing::warn!(repo = %name, error = %e, "ensure_fresh failed");
343 return err(StatusCode::BAD_GATEWAY, "upstream fetch failed");
344 }
345
346 match st.cache.advertise_refs(&repo, git_protocol).await {
347 Ok(body) => {
348 st.metrics
349 .record_request(RequestKind::InfoRefs, Status::Ok, &name);
350 Response::builder()
351 .header(
352 header::CONTENT_TYPE,
353 "application/x-git-upload-pack-advertisement",
354 )
355 .header(header::CACHE_CONTROL, "no-cache")
356 .body(Body::from(body))
357 .expect("valid response")
358 }
359 Err(e) => {
360 st.metrics
361 .record_request(RequestKind::InfoRefs, Status::Error, "-");
362 tracing::warn!(repo = %name, error = %e, "advertise_refs failed");
363 err(StatusCode::INTERNAL_SERVER_ERROR, "advertise-refs failed")
364 }
365 }
366}
367
368async fn upload_pack(
369 st: AppState,
370 path: &str,
371 git_protocol: Option<&str>,
372 body: Bytes,
373) -> Response {
374 let Some(name) = repo::repo_name_from_path(path, &format!("/{UPLOAD_PACK}")) else {
375 st.metrics
376 .record_request(RequestKind::UploadPack, Status::Error, "-");
377 return err(StatusCode::NOT_FOUND, "bad path");
378 };
379 let repo = match repo::resolve(&name, &st.upstream_base, &st.cache_root) {
380 Ok(r) => r,
381 Err(e) => {
382 st.metrics
383 .record_request(RequestKind::UploadPack, Status::Error, "-");
384 return err(StatusCode::BAD_REQUEST, &e.to_string());
385 }
386 };
387
388 if let Err(e) = st.cache.ensure_fresh(&repo, false).await {
391 st.metrics
392 .record_request(RequestKind::UploadPack, Status::UpstreamError, "-");
393 tracing::warn!(repo = %name, error = %e, "ensure mirror exists failed");
394 return err(StatusCode::BAD_GATEWAY, "upstream unavailable");
395 }
396
397 match st.cache.upload_pack_rpc(&repo, git_protocol, body).await {
398 Ok(stream) => {
399 st.metrics
400 .record_request(RequestKind::UploadPack, Status::Ok, &name);
401 Response::builder()
402 .header(header::CONTENT_TYPE, "application/x-git-upload-pack-result")
403 .header(header::CACHE_CONTROL, "no-cache")
404 .body(Body::from_stream(stream))
405 .expect("valid response")
406 }
407 Err(e) => {
408 st.metrics
409 .record_request(RequestKind::UploadPack, Status::Error, "-");
410 tracing::warn!(repo = %name, error = %e, "upload_pack_rpc failed");
411 err(StatusCode::INTERNAL_SERVER_ERROR, "upload-pack failed")
412 }
413 }
414}
415
416fn check_auth(st: &AppState, headers: &HeaderMap) -> Option<Response> {
419 let expected = st.serve_token.as_ref()?;
420 let provided = headers
421 .get(header::AUTHORIZATION)
422 .and_then(|v| v.to_str().ok())
423 .and_then(|v| v.strip_prefix("Bearer "));
424 if provided.is_some_and(|t| token_matches(t, expected)) {
425 None
426 } else {
427 Some(err(
428 StatusCode::UNAUTHORIZED,
429 "missing or invalid bearer token",
430 ))
431 }
432}
433
434fn token_matches(provided: &str, expected: &str) -> bool {
438 provided.as_bytes().ct_eq(expected.as_bytes()).into()
439}
440
441fn decode_body(
451 content_encoding: Option<&str>,
452 body: Bytes,
453 max_decoded: usize,
454) -> std::io::Result<Bytes> {
455 match content_encoding.map(str::trim) {
456 Some(enc) if enc.eq_ignore_ascii_case("gzip") || enc.eq_ignore_ascii_case("x-gzip") => {
457 let mut out = Vec::new();
458 let limit = max_decoded as u64 + 1;
459 flate2::read::GzDecoder::new(&body[..])
460 .take(limit)
461 .read_to_end(&mut out)?;
462 within_limit(Bytes::from(out), max_decoded)
463 }
464 None => within_limit(body, max_decoded),
468 Some(enc) if enc.is_empty() || enc.eq_ignore_ascii_case("identity") => {
469 within_limit(body, max_decoded)
470 }
471 Some(other) => Err(std::io::Error::new(
472 std::io::ErrorKind::InvalidData,
473 format!("unsupported content-encoding: {other}"),
474 )),
475 }
476}
477
478fn within_limit(body: Bytes, max_decoded: usize) -> std::io::Result<Bytes> {
481 if body.len() > max_decoded {
482 return Err(std::io::Error::new(
483 std::io::ErrorKind::InvalidData,
484 "decoded request body exceeds limit",
485 ));
486 }
487 Ok(body)
488}
489
490fn err(status: StatusCode, msg: &str) -> Response {
491 (status, format!("{msg}\n")).into_response()
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use std::io::Write;
498
499 fn gzip(bytes: &[u8]) -> Bytes {
500 let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
501 enc.write_all(bytes).unwrap();
502 Bytes::from(enc.finish().unwrap())
503 }
504
505 #[test]
506 fn identity_and_absent_encoding_pass_through() {
507 let raw = Bytes::from_static(b"want ...\n");
508 assert_eq!(decode_body(None, raw.clone(), 1024).unwrap(), raw);
509 assert_eq!(
510 decode_body(Some("identity"), raw.clone(), 1024).unwrap(),
511 raw
512 );
513 assert_eq!(decode_body(Some(""), raw.clone(), 1024).unwrap(), raw);
514 }
515
516 #[test]
517 fn identity_body_over_limit_is_rejected() {
518 let body = Bytes::from(vec![b'x'; 2048]);
521 for enc in [None, Some("identity"), Some("")] {
522 assert!(decode_body(enc, body.clone(), 2048).is_ok());
523 assert!(decode_body(enc, body.clone(), 2047).is_err());
524 }
525 }
526
527 #[test]
528 fn gzip_within_limit_decodes() {
529 let payload = b"command=ls-refs\n";
530 let decoded = decode_body(Some("gzip"), gzip(payload), 1024).unwrap();
531 assert_eq!(&decoded[..], payload);
532 assert_eq!(
534 &decode_body(Some("GZIP"), gzip(payload), 1024).unwrap()[..],
535 payload
536 );
537 assert_eq!(
538 &decode_body(Some("x-gzip"), gzip(payload), 1024).unwrap()[..],
539 payload
540 );
541 }
542
543 #[test]
544 fn gzip_decompression_bomb_is_rejected() {
545 let big = vec![0u8; 1024 * 1024];
548 assert!(decode_body(Some("gzip"), gzip(&big), 1024).is_err());
549 assert!(decode_body(Some("gzip"), gzip(&big), big.len()).is_ok());
550 assert!(decode_body(Some("gzip"), gzip(&big), big.len() - 1).is_err());
551 }
552
553 #[test]
554 fn unsupported_encoding_is_rejected() {
555 assert!(decode_body(Some("br"), Bytes::from_static(b"x"), 1024).is_err());
556 assert!(decode_body(Some("deflate"), Bytes::from_static(b"x"), 1024).is_err());
557 }
558
559 #[test]
560 fn token_matches_only_the_exact_token() {
561 assert!(token_matches("s3cret", "s3cret"));
562 assert!(!token_matches("s3creT", "s3cret")); assert!(!token_matches("s3cre", "s3cret")); assert!(!token_matches("s3cret-extra", "s3cret")); assert!(!token_matches("", "s3cret"));
566 assert!(token_matches("", "")); }
568
569 #[test]
570 fn advertise_base_uses_forwarded_headers_then_host() {
571 use axum::http::HeaderValue;
572
573 let mut h = HeaderMap::new();
574 h.insert(header::HOST, HeaderValue::from_static("svc.local:8080"));
575 assert_eq!(advertise_base(&h), "http://svc.local:8080");
576 h.insert("x-forwarded-proto", HeaderValue::from_static("https"));
578 h.insert(
579 "x-forwarded-host",
580 HeaderValue::from_static("proxy.example"),
581 );
582 assert_eq!(advertise_base(&h), "https://proxy.example");
583 h.insert("x-forwarded-proto", HeaderValue::from_static("https, http"));
585 assert_eq!(advertise_base(&h), "https://proxy.example");
586 }
587
588 #[test]
589 fn size_from_query_parses_only_a_valid_size() {
590 assert_eq!(size_from_query("size=42"), Some(42));
591 assert_eq!(size_from_query("a=1&size=7&b=2"), Some(7));
592 assert_eq!(size_from_query(""), None);
593 assert_eq!(size_from_query("size=notanumber"), None);
594 }
595
596 #[test]
597 fn is_lfs_upload_detects_the_operation() {
598 assert!(is_lfs_upload(br#"{"operation":"upload","objects":[]}"#));
599 assert!(!is_lfs_upload(br#"{"operation":"download"}"#));
600 assert!(!is_lfs_upload(b"not json")); assert!(!is_lfs_upload(b"{}"));
602 }
603}