1use std::io::Read;
11use std::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 let app = Router::new()
49 .route("/healthz", get(|| async { "ok" }))
50 .route("/readyz", get(|| async { "ok" }))
51 .route("/metrics", get(metrics_handler))
52 .fallback(handle_git)
53 .with_state(state);
54 if max_concurrent == 0 {
58 app
59 } else {
60 app.layer(GlobalConcurrencyLimitLayer::new(max_concurrent))
61 }
62}
63
64async fn metrics_handler(State(st): State<AppState>) -> Response {
65 Response::builder()
66 .header(header::CONTENT_TYPE, "text/plain; version=0.0.4")
67 .body(Body::from(st.metrics.gather()))
68 .expect("valid response")
69}
70
71async fn handle_git(State(st): State<AppState>, req: Request<Body>) -> Response {
72 let (parts, body) = req.into_parts();
73 let path = parts.uri.path().to_string();
74 let query = parts.uri.query().unwrap_or("").to_string();
75 let git_protocol = parts
76 .headers
77 .get("git-protocol")
78 .and_then(|v| v.to_str().ok())
79 .map(str::to_string);
80
81 if let Some(resp) = check_auth(&st, &parts.headers) {
82 st.metrics.record_request("auth", "unauthorized", "-");
83 return resp;
84 }
85
86 if path.ends_with(&format!("/{RECEIVE_PACK}"))
88 || query.contains(&format!("service={RECEIVE_PACK}"))
89 {
90 st.metrics.record_request("receive_pack", "rejected", "-");
91 return err(
92 StatusCode::FORBIDDEN,
93 "read-only proxy: pushes are not allowed",
94 );
95 }
96
97 if parts.method == Method::GET && path.ends_with("/info/refs") {
98 if !query.contains(&format!("service={UPLOAD_PACK}")) {
99 st.metrics.record_request("info_refs", "error", "-");
100 return err(
101 StatusCode::BAD_REQUEST,
102 "only smart-http git-upload-pack is supported",
103 );
104 }
105 return info_refs(st, &path, git_protocol.as_deref()).await;
106 }
107
108 if parts.method == Method::POST && path.ends_with(&format!("/{UPLOAD_PACK}")) {
109 let body = match axum::body::to_bytes(body, MAX_BODY).await {
110 Ok(b) => b,
111 Err(_) => return err(StatusCode::BAD_REQUEST, "failed to read request body"),
112 };
113 let content_encoding = parts
119 .headers
120 .get(header::CONTENT_ENCODING)
121 .and_then(|v| v.to_str().ok());
122 let body = match decode_body(content_encoding, body, st.max_decoded_body) {
123 Ok(b) => b,
124 Err(_) => return err(StatusCode::BAD_REQUEST, "failed to decode request body"),
125 };
126 return upload_pack(st, &path, git_protocol.as_deref(), body).await;
127 }
128
129 err(StatusCode::NOT_FOUND, "not a git smart-http endpoint")
130}
131
132async fn info_refs(st: AppState, path: &str, git_protocol: Option<&str>) -> Response {
133 let Some(name) = repo::repo_name_from_path(path, "/info/refs") else {
134 st.metrics.record_request("info_refs", "error", "-");
135 return err(StatusCode::NOT_FOUND, "bad path");
136 };
137 let repo = match repo::resolve(&name, &st.upstream_base, &st.cache_root) {
138 Ok(r) => r,
139 Err(e) => {
140 st.metrics.record_request("info_refs", "error", "-");
141 return err(StatusCode::BAD_REQUEST, &e.to_string());
142 }
143 };
144
145 if let Err(e) = st.cache.ensure_fresh(&repo, true).await {
150 st.metrics
151 .record_request("info_refs", "upstream_error", "-");
152 tracing::warn!(repo = %name, error = %e, "ensure_fresh failed");
153 return err(StatusCode::BAD_GATEWAY, "upstream fetch failed");
154 }
155
156 match st.cache.advertise_refs(&repo, git_protocol).await {
157 Ok(body) => {
158 st.metrics.record_request("info_refs", "ok", &name);
159 Response::builder()
160 .header(
161 header::CONTENT_TYPE,
162 "application/x-git-upload-pack-advertisement",
163 )
164 .header(header::CACHE_CONTROL, "no-cache")
165 .body(Body::from(body))
166 .expect("valid response")
167 }
168 Err(e) => {
169 st.metrics.record_request("info_refs", "error", "-");
170 tracing::warn!(repo = %name, error = %e, "advertise_refs failed");
171 err(StatusCode::INTERNAL_SERVER_ERROR, "advertise-refs failed")
172 }
173 }
174}
175
176async fn upload_pack(
177 st: AppState,
178 path: &str,
179 git_protocol: Option<&str>,
180 body: Bytes,
181) -> Response {
182 let Some(name) = repo::repo_name_from_path(path, &format!("/{UPLOAD_PACK}")) else {
183 st.metrics.record_request("upload_pack", "error", "-");
184 return err(StatusCode::NOT_FOUND, "bad path");
185 };
186 let repo = match repo::resolve(&name, &st.upstream_base, &st.cache_root) {
187 Ok(r) => r,
188 Err(e) => {
189 st.metrics.record_request("upload_pack", "error", "-");
190 return err(StatusCode::BAD_REQUEST, &e.to_string());
191 }
192 };
193
194 if let Err(e) = st.cache.ensure_fresh(&repo, false).await {
197 st.metrics
198 .record_request("upload_pack", "upstream_error", "-");
199 tracing::warn!(repo = %name, error = %e, "ensure mirror exists failed");
200 return err(StatusCode::BAD_GATEWAY, "upstream unavailable");
201 }
202
203 match st.cache.upload_pack_rpc(&repo, git_protocol, body).await {
204 Ok(stream) => {
205 st.metrics.record_request("upload_pack", "ok", &name);
206 Response::builder()
207 .header(header::CONTENT_TYPE, "application/x-git-upload-pack-result")
208 .header(header::CACHE_CONTROL, "no-cache")
209 .body(Body::from_stream(stream))
210 .expect("valid response")
211 }
212 Err(e) => {
213 st.metrics.record_request("upload_pack", "error", "-");
214 tracing::warn!(repo = %name, error = %e, "upload_pack_rpc failed");
215 err(StatusCode::INTERNAL_SERVER_ERROR, "upload-pack failed")
216 }
217 }
218}
219
220fn check_auth(st: &AppState, headers: &HeaderMap) -> Option<Response> {
223 let expected = st.serve_token.as_ref()?;
224 let provided = headers
225 .get(header::AUTHORIZATION)
226 .and_then(|v| v.to_str().ok())
227 .and_then(|v| v.strip_prefix("Bearer "));
228 if provided.is_some_and(|t| token_matches(t, expected)) {
229 None
230 } else {
231 Some(err(
232 StatusCode::UNAUTHORIZED,
233 "missing or invalid bearer token",
234 ))
235 }
236}
237
238fn token_matches(provided: &str, expected: &str) -> bool {
242 provided.as_bytes().ct_eq(expected.as_bytes()).into()
243}
244
245fn decode_body(
255 content_encoding: Option<&str>,
256 body: Bytes,
257 max_decoded: usize,
258) -> std::io::Result<Bytes> {
259 match content_encoding.map(str::trim) {
260 Some(enc) if enc.eq_ignore_ascii_case("gzip") || enc.eq_ignore_ascii_case("x-gzip") => {
261 let mut out = Vec::new();
262 let limit = max_decoded as u64 + 1;
263 flate2::read::GzDecoder::new(&body[..])
264 .take(limit)
265 .read_to_end(&mut out)?;
266 within_limit(Bytes::from(out), max_decoded)
267 }
268 None => within_limit(body, max_decoded),
272 Some(enc) if enc.is_empty() || enc.eq_ignore_ascii_case("identity") => {
273 within_limit(body, max_decoded)
274 }
275 Some(other) => Err(std::io::Error::new(
276 std::io::ErrorKind::InvalidData,
277 format!("unsupported content-encoding: {other}"),
278 )),
279 }
280}
281
282fn within_limit(body: Bytes, max_decoded: usize) -> std::io::Result<Bytes> {
285 if body.len() > max_decoded {
286 return Err(std::io::Error::new(
287 std::io::ErrorKind::InvalidData,
288 "decoded request body exceeds limit",
289 ));
290 }
291 Ok(body)
292}
293
294fn err(status: StatusCode, msg: &str) -> Response {
295 (status, format!("{msg}\n")).into_response()
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use std::io::Write;
302
303 fn gzip(bytes: &[u8]) -> Bytes {
304 let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
305 enc.write_all(bytes).unwrap();
306 Bytes::from(enc.finish().unwrap())
307 }
308
309 #[test]
310 fn identity_and_absent_encoding_pass_through() {
311 let raw = Bytes::from_static(b"want ...\n");
312 assert_eq!(decode_body(None, raw.clone(), 1024).unwrap(), raw);
313 assert_eq!(
314 decode_body(Some("identity"), raw.clone(), 1024).unwrap(),
315 raw
316 );
317 assert_eq!(decode_body(Some(""), raw.clone(), 1024).unwrap(), raw);
318 }
319
320 #[test]
321 fn identity_body_over_limit_is_rejected() {
322 let body = Bytes::from(vec![b'x'; 2048]);
325 for enc in [None, Some("identity"), Some("")] {
326 assert!(decode_body(enc, body.clone(), 2048).is_ok());
327 assert!(decode_body(enc, body.clone(), 2047).is_err());
328 }
329 }
330
331 #[test]
332 fn gzip_within_limit_decodes() {
333 let payload = b"command=ls-refs\n";
334 let decoded = decode_body(Some("gzip"), gzip(payload), 1024).unwrap();
335 assert_eq!(&decoded[..], payload);
336 assert_eq!(
338 &decode_body(Some("GZIP"), gzip(payload), 1024).unwrap()[..],
339 payload
340 );
341 assert_eq!(
342 &decode_body(Some("x-gzip"), gzip(payload), 1024).unwrap()[..],
343 payload
344 );
345 }
346
347 #[test]
348 fn gzip_decompression_bomb_is_rejected() {
349 let big = vec![0u8; 1024 * 1024];
352 assert!(decode_body(Some("gzip"), gzip(&big), 1024).is_err());
353 assert!(decode_body(Some("gzip"), gzip(&big), big.len()).is_ok());
354 assert!(decode_body(Some("gzip"), gzip(&big), big.len() - 1).is_err());
355 }
356
357 #[test]
358 fn unsupported_encoding_is_rejected() {
359 assert!(decode_body(Some("br"), Bytes::from_static(b"x"), 1024).is_err());
360 assert!(decode_body(Some("deflate"), Bytes::from_static(b"x"), 1024).is_err());
361 }
362
363 #[test]
364 fn token_matches_only_the_exact_token() {
365 assert!(token_matches("s3cret", "s3cret"));
366 assert!(!token_matches("s3creT", "s3cret")); assert!(!token_matches("s3cre", "s3cret")); assert!(!token_matches("s3cret-extra", "s3cret")); assert!(!token_matches("", "s3cret"));
370 assert!(token_matches("", "")); }
372}