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::metrics::Metrics;
25use crate::repo;
26
27const MAX_BODY: usize = 64 * 1024 * 1024;
28const UPLOAD_PACK: &str = "git-upload-pack";
29const RECEIVE_PACK: &str = "git-receive-pack";
30
31#[derive(Clone)]
32pub struct AppState {
33 pub cache: Arc<GitCache>,
34 pub upstream_base: String,
35 pub cache_root: PathBuf,
36 pub serve_token: Option<String>,
37 pub max_decoded_body: usize,
40 pub max_concurrent: usize,
43 pub metrics: Arc<Metrics>,
44}
45
46pub fn router(state: AppState) -> Router {
47 let max_concurrent = state.max_concurrent;
48
49 let observability = Router::new()
54 .route("/healthz", get(|| async { "ok" }))
55 .route("/readyz", get(readyz))
56 .route("/metrics", get(metrics_handler))
57 .with_state(state.clone());
58
59 let mut git = Router::new().fallback(handle_git).with_state(state);
67 if max_concurrent != 0 {
68 git = git.layer(GlobalConcurrencyLimitLayer::new(max_concurrent));
69 }
70
71 observability.merge(git)
72}
73
74async fn metrics_handler(State(st): State<AppState>) -> Response {
75 Response::builder()
76 .header(header::CONTENT_TYPE, "text/plain; version=0.0.4")
77 .body(Body::from(st.metrics.gather()))
78 .expect("valid response")
79}
80
81async fn readyz(State(st): State<AppState>) -> Response {
87 match cache_writable(&st.cache_root).await {
88 Ok(()) => (StatusCode::OK, "ok").into_response(),
89 Err(e) => {
90 tracing::warn!(
91 cache_root = %st.cache_root.display(),
92 error = %e,
93 "readiness check failed"
94 );
95 err(StatusCode::SERVICE_UNAVAILABLE, "cache root not writable")
96 }
97 }
98}
99
100async fn cache_writable(cache_root: &Path) -> std::io::Result<()> {
106 tokio::fs::create_dir_all(cache_root).await?;
107 let probe = cache_root.join(".readyz-probe");
108 tokio::fs::write(&probe, b"").await?;
109 let _ = tokio::fs::remove_file(&probe).await;
110 Ok(())
111}
112
113async fn handle_git(State(st): State<AppState>, req: Request<Body>) -> Response {
114 let (parts, body) = req.into_parts();
115 let path = parts.uri.path().to_string();
116 let query = parts.uri.query().unwrap_or("").to_string();
117 let git_protocol = parts
118 .headers
119 .get("git-protocol")
120 .and_then(|v| v.to_str().ok())
121 .map(str::to_string);
122
123 if let Some(resp) = check_auth(&st, &parts.headers) {
124 st.metrics.record_request("auth", "unauthorized", "-");
125 return resp;
126 }
127
128 if path.ends_with(&format!("/{RECEIVE_PACK}"))
130 || query.contains(&format!("service={RECEIVE_PACK}"))
131 {
132 st.metrics.record_request("receive_pack", "rejected", "-");
133 return err(
134 StatusCode::FORBIDDEN,
135 "read-only proxy: pushes are not allowed",
136 );
137 }
138
139 if parts.method == Method::GET && path.ends_with("/info/refs") {
140 if !query.contains(&format!("service={UPLOAD_PACK}")) {
141 st.metrics.record_request("info_refs", "error", "-");
142 return err(
143 StatusCode::BAD_REQUEST,
144 "only smart-http git-upload-pack is supported",
145 );
146 }
147 return info_refs(st, &path, git_protocol.as_deref()).await;
148 }
149
150 if parts.method == Method::POST && path.ends_with(&format!("/{UPLOAD_PACK}")) {
151 let body = match axum::body::to_bytes(body, MAX_BODY).await {
152 Ok(b) => b,
153 Err(_) => return err(StatusCode::BAD_REQUEST, "failed to read request body"),
154 };
155 let content_encoding = parts
161 .headers
162 .get(header::CONTENT_ENCODING)
163 .and_then(|v| v.to_str().ok());
164 let body = match decode_body(content_encoding, body, st.max_decoded_body) {
165 Ok(b) => b,
166 Err(_) => return err(StatusCode::BAD_REQUEST, "failed to decode request body"),
167 };
168 return upload_pack(st, &path, git_protocol.as_deref(), body).await;
169 }
170
171 err(StatusCode::NOT_FOUND, "not a git smart-http endpoint")
172}
173
174async fn info_refs(st: AppState, path: &str, git_protocol: Option<&str>) -> Response {
175 let Some(name) = repo::repo_name_from_path(path, "/info/refs") else {
176 st.metrics.record_request("info_refs", "error", "-");
177 return err(StatusCode::NOT_FOUND, "bad path");
178 };
179 let repo = match repo::resolve(&name, &st.upstream_base, &st.cache_root) {
180 Ok(r) => r,
181 Err(e) => {
182 st.metrics.record_request("info_refs", "error", "-");
183 return err(StatusCode::BAD_REQUEST, &e.to_string());
184 }
185 };
186
187 if let Err(e) = st.cache.ensure_fresh(&repo, true).await {
192 st.metrics
193 .record_request("info_refs", "upstream_error", "-");
194 tracing::warn!(repo = %name, error = %e, "ensure_fresh failed");
195 return err(StatusCode::BAD_GATEWAY, "upstream fetch failed");
196 }
197
198 match st.cache.advertise_refs(&repo, git_protocol).await {
199 Ok(body) => {
200 st.metrics.record_request("info_refs", "ok", &name);
201 Response::builder()
202 .header(
203 header::CONTENT_TYPE,
204 "application/x-git-upload-pack-advertisement",
205 )
206 .header(header::CACHE_CONTROL, "no-cache")
207 .body(Body::from(body))
208 .expect("valid response")
209 }
210 Err(e) => {
211 st.metrics.record_request("info_refs", "error", "-");
212 tracing::warn!(repo = %name, error = %e, "advertise_refs failed");
213 err(StatusCode::INTERNAL_SERVER_ERROR, "advertise-refs failed")
214 }
215 }
216}
217
218async fn upload_pack(
219 st: AppState,
220 path: &str,
221 git_protocol: Option<&str>,
222 body: Bytes,
223) -> Response {
224 let Some(name) = repo::repo_name_from_path(path, &format!("/{UPLOAD_PACK}")) else {
225 st.metrics.record_request("upload_pack", "error", "-");
226 return err(StatusCode::NOT_FOUND, "bad path");
227 };
228 let repo = match repo::resolve(&name, &st.upstream_base, &st.cache_root) {
229 Ok(r) => r,
230 Err(e) => {
231 st.metrics.record_request("upload_pack", "error", "-");
232 return err(StatusCode::BAD_REQUEST, &e.to_string());
233 }
234 };
235
236 if let Err(e) = st.cache.ensure_fresh(&repo, false).await {
239 st.metrics
240 .record_request("upload_pack", "upstream_error", "-");
241 tracing::warn!(repo = %name, error = %e, "ensure mirror exists failed");
242 return err(StatusCode::BAD_GATEWAY, "upstream unavailable");
243 }
244
245 match st.cache.upload_pack_rpc(&repo, git_protocol, body).await {
246 Ok(stream) => {
247 st.metrics.record_request("upload_pack", "ok", &name);
248 Response::builder()
249 .header(header::CONTENT_TYPE, "application/x-git-upload-pack-result")
250 .header(header::CACHE_CONTROL, "no-cache")
251 .body(Body::from_stream(stream))
252 .expect("valid response")
253 }
254 Err(e) => {
255 st.metrics.record_request("upload_pack", "error", "-");
256 tracing::warn!(repo = %name, error = %e, "upload_pack_rpc failed");
257 err(StatusCode::INTERNAL_SERVER_ERROR, "upload-pack failed")
258 }
259 }
260}
261
262fn check_auth(st: &AppState, headers: &HeaderMap) -> Option<Response> {
265 let expected = st.serve_token.as_ref()?;
266 let provided = headers
267 .get(header::AUTHORIZATION)
268 .and_then(|v| v.to_str().ok())
269 .and_then(|v| v.strip_prefix("Bearer "));
270 if provided.is_some_and(|t| token_matches(t, expected)) {
271 None
272 } else {
273 Some(err(
274 StatusCode::UNAUTHORIZED,
275 "missing or invalid bearer token",
276 ))
277 }
278}
279
280fn token_matches(provided: &str, expected: &str) -> bool {
284 provided.as_bytes().ct_eq(expected.as_bytes()).into()
285}
286
287fn decode_body(
297 content_encoding: Option<&str>,
298 body: Bytes,
299 max_decoded: usize,
300) -> std::io::Result<Bytes> {
301 match content_encoding.map(str::trim) {
302 Some(enc) if enc.eq_ignore_ascii_case("gzip") || enc.eq_ignore_ascii_case("x-gzip") => {
303 let mut out = Vec::new();
304 let limit = max_decoded as u64 + 1;
305 flate2::read::GzDecoder::new(&body[..])
306 .take(limit)
307 .read_to_end(&mut out)?;
308 within_limit(Bytes::from(out), max_decoded)
309 }
310 None => within_limit(body, max_decoded),
314 Some(enc) if enc.is_empty() || enc.eq_ignore_ascii_case("identity") => {
315 within_limit(body, max_decoded)
316 }
317 Some(other) => Err(std::io::Error::new(
318 std::io::ErrorKind::InvalidData,
319 format!("unsupported content-encoding: {other}"),
320 )),
321 }
322}
323
324fn within_limit(body: Bytes, max_decoded: usize) -> std::io::Result<Bytes> {
327 if body.len() > max_decoded {
328 return Err(std::io::Error::new(
329 std::io::ErrorKind::InvalidData,
330 "decoded request body exceeds limit",
331 ));
332 }
333 Ok(body)
334}
335
336fn err(status: StatusCode, msg: &str) -> Response {
337 (status, format!("{msg}\n")).into_response()
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use std::io::Write;
344
345 fn gzip(bytes: &[u8]) -> Bytes {
346 let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
347 enc.write_all(bytes).unwrap();
348 Bytes::from(enc.finish().unwrap())
349 }
350
351 #[test]
352 fn identity_and_absent_encoding_pass_through() {
353 let raw = Bytes::from_static(b"want ...\n");
354 assert_eq!(decode_body(None, raw.clone(), 1024).unwrap(), raw);
355 assert_eq!(
356 decode_body(Some("identity"), raw.clone(), 1024).unwrap(),
357 raw
358 );
359 assert_eq!(decode_body(Some(""), raw.clone(), 1024).unwrap(), raw);
360 }
361
362 #[test]
363 fn identity_body_over_limit_is_rejected() {
364 let body = Bytes::from(vec![b'x'; 2048]);
367 for enc in [None, Some("identity"), Some("")] {
368 assert!(decode_body(enc, body.clone(), 2048).is_ok());
369 assert!(decode_body(enc, body.clone(), 2047).is_err());
370 }
371 }
372
373 #[test]
374 fn gzip_within_limit_decodes() {
375 let payload = b"command=ls-refs\n";
376 let decoded = decode_body(Some("gzip"), gzip(payload), 1024).unwrap();
377 assert_eq!(&decoded[..], payload);
378 assert_eq!(
380 &decode_body(Some("GZIP"), gzip(payload), 1024).unwrap()[..],
381 payload
382 );
383 assert_eq!(
384 &decode_body(Some("x-gzip"), gzip(payload), 1024).unwrap()[..],
385 payload
386 );
387 }
388
389 #[test]
390 fn gzip_decompression_bomb_is_rejected() {
391 let big = vec![0u8; 1024 * 1024];
394 assert!(decode_body(Some("gzip"), gzip(&big), 1024).is_err());
395 assert!(decode_body(Some("gzip"), gzip(&big), big.len()).is_ok());
396 assert!(decode_body(Some("gzip"), gzip(&big), big.len() - 1).is_err());
397 }
398
399 #[test]
400 fn unsupported_encoding_is_rejected() {
401 assert!(decode_body(Some("br"), Bytes::from_static(b"x"), 1024).is_err());
402 assert!(decode_body(Some("deflate"), Bytes::from_static(b"x"), 1024).is_err());
403 }
404
405 #[test]
406 fn token_matches_only_the_exact_token() {
407 assert!(token_matches("s3cret", "s3cret"));
408 assert!(!token_matches("s3creT", "s3cret")); assert!(!token_matches("s3cre", "s3cret")); assert!(!token_matches("s3cret-extra", "s3cret")); assert!(!token_matches("", "s3cret"));
412 assert!(token_matches("", "")); }
414}