diff --git a/radicle-httpd/src/git.rs b/radicle-httpd/src/git.rs new file mode 100644 index 00000000..cf4c3784 --- /dev/null +++ b/radicle-httpd/src/git.rs @@ -0,0 +1,194 @@ +use std::collections::HashMap; +use std::io::prelude::*; +use std::net::SocketAddr; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::{io, net, str}; + +use axum::body::Bytes; +use axum::extract::{ConnectInfo, Path as AxumPath, RawQuery, State}; +use axum::http::header::HeaderName; +use axum::http::{HeaderMap, Method, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::any; +use axum::Router; +use flate2::write::GzDecoder; +use hyper::body::Buf as _; + +use radicle::identity::Id; +use radicle::profile::Profile; + +use crate::error::Error; + +pub fn router(profile: Arc) -> Router { + Router::new() + .route("/:project/*request", any(git_handler)) + .with_state(profile) +} + +async fn git_handler( + State(profile): State>, + AxumPath((project, request)): AxumPath<(String, String)>, + method: Method, + headers: HeaderMap, + ConnectInfo(remote): ConnectInfo, + query: RawQuery, + body: Bytes, +) -> impl IntoResponse { + let query = query.0.unwrap_or_default(); + let id: Id = project.strip_suffix(".git").unwrap_or(&project).parse()?; + + let (status, headers, body) = + git_http_backend(&profile, method, headers, body, remote, id, &request, query).await?; + + let mut response_headers = HeaderMap::new(); + for (name, vec) in headers.iter() { + for value in vec { + let header: HeaderName = name.try_into()?; + response_headers.insert(header, value.parse()?); + } + } + + Ok::<_, Error>((status, response_headers, body)) +} + +async fn git_http_backend( + profile: &Profile, + method: Method, + headers: HeaderMap, + mut body: Bytes, + remote: net::SocketAddr, + id: Id, + path: &str, + query: String, +) -> Result<(StatusCode, HashMap>, Vec), Error> { + let git_dir = radicle::storage::git::paths::repository(&profile.storage, &id); + let content_type = + if let Some(Ok(content_type)) = headers.get("Content-Type").map(|h| h.to_str()) { + content_type + } else { + "" + }; + + // Reject push requests. + match (path, query.as_str()) { + ("git-receive-pack", _) | (_, "service=git-receive-pack") => { + return Err(Error::ServiceUnavailable("git-receive-pack")); + } + _ => {} + }; + + tracing::debug!("id: {:?}", id); + tracing::debug!("headers: {:?}", headers); + tracing::debug!("path: {:?}", path); + tracing::debug!("method: {:?}", method.as_str()); + tracing::debug!("remote: {:?}", remote.to_string()); + + let mut cmd = Command::new("git"); + let mut child = cmd + .arg("http-backend") + .env("REQUEST_METHOD", method.as_str()) + .env("GIT_PROJECT_ROOT", git_dir) + // "The GIT_HTTP_EXPORT_ALL environmental variable may be passed to git-http-backend to bypass + // the check for the "git-daemon-export-ok" file in each repository before allowing export of + // that repository." + .env("GIT_HTTP_EXPORT_ALL", String::default()) + .env("PATH_INFO", Path::new("/").join(path)) + .env("CONTENT_TYPE", content_type) + .env("QUERY_STRING", query) + .stderr(Stdio::piped()) + .stdout(Stdio::piped()) + .stdin(Stdio::piped()) + .spawn()?; + + // Whether the request body is compressed. + let gzip = matches!( + headers.get("Content-Encoding").map(|h| h.to_str()), + Some(Ok("gzip")) + ); + + { + // This is safe because we captured the child's stdin. + let mut stdin = child.stdin.take().unwrap(); + + // Copy the request body to git-http-backend's stdin. + if gzip { + let mut decoder = GzDecoder::new(&mut stdin); + let mut reader = body.reader(); + + io::copy(&mut reader, &mut decoder)?; + decoder.finish()?; + } else { + while body.has_remaining() { + let mut chunk = body.chunk(); + let count = chunk.len(); + + io::copy(&mut chunk, &mut stdin)?; + body.advance(count); + } + } + } + + match child.wait_with_output() { + Ok(output) if output.status.success() => { + tracing::info!("git-http-backend: exited successfully for {}", id); + + let mut reader = std::io::Cursor::new(output.stdout); + let mut headers = HashMap::new(); + + // Parse headers returned by git so that we can use them in the client response. + for line in io::Read::by_ref(&mut reader).lines() { + let line = line?; + + if line.is_empty() || line == "\r" { + break; + } + + let mut parts = line.splitn(2, ':'); + let key = parts.next(); + let value = parts.next(); + + if let (Some(key), Some(value)) = (key, value) { + let value = &value[1..]; + + headers + .entry(key.to_string()) + .or_insert_with(Vec::new) + .push(value.to_string()); + } else { + return Err(Error::Backend); + } + } + + let status = { + tracing::debug!("git-http-backend: {:?}", &headers); + + let line = headers.remove("Status").unwrap_or_default(); + let line = line.into_iter().next().unwrap_or_default(); + let mut parts = line.split(' '); + + parts + .next() + .and_then(|p| p.parse().ok()) + .unwrap_or(StatusCode::OK) + }; + + let position = reader.position() as usize; + let body = reader.into_inner().split_off(position); + + Ok((status, headers, body)) + } + Ok(output) => { + tracing::error!("git-http-backend: exited with code {}", output.status); + + if let Ok(output) = std::str::from_utf8(&output.stderr) { + tracing::error!("git-http-backend: stderr: {}", output.trim_end()); + } + Err(Error::Backend) + } + Err(err) => { + panic!("failed to wait for git-http-backend: {err}"); + } + } +} diff --git a/radicle-httpd/src/lib.rs b/radicle-httpd/src/lib.rs index be643f56..6110d14d 100644 --- a/radicle-httpd/src/lib.rs +++ b/radicle-httpd/src/lib.rs @@ -2,41 +2,25 @@ #![allow(clippy::too_many_arguments)] pub mod error; -use std::collections::HashMap; -use std::io::prelude::*; use std::net::SocketAddr; -use std::path::Path; -use std::process::{Command, Stdio}; +use std::process::Command; +use std::str; use std::sync::Arc; use std::time::Duration; -use std::{io, net, str}; use anyhow::Context as _; -use axum::body::Body; -use axum::body::{BoxBody, Bytes}; -use axum::extract::{ConnectInfo, Path as AxumPath, RawQuery}; -use axum::http::header::HeaderName; -use axum::http::{HeaderMap, Method, Request, Response, StatusCode}; -use axum::response::IntoResponse; -use axum::routing::any; -use axum::{Extension, Router}; -use flate2::write::GzDecoder; -use hyper::body::Buf as _; +use axum::body::{Body, BoxBody}; +use axum::http::{Request, Response}; +use axum::Router; use tower_http::trace::TraceLayer; use tracing::Span; -use radicle::identity::Id; -use radicle::profile::Profile; - -use error::Error; - mod api; - -pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +mod git; #[derive(Debug, Clone)] pub struct Options { - pub listen: net::SocketAddr, + pub listen: SocketAddr, } /// Run the Server. @@ -51,12 +35,9 @@ pub async fn run(options: Options) -> anyhow::Result<()> { let profile = Arc::new(radicle::Profile::load()?); tracing::info!("using radicle home at {}", profile.home().display()); - let git_router = Router::new() - .route("/:project/*request", any(git_handler)) - .layer(Extension(profile.clone())); - - let ctx = api::Context::new(profile); + let ctx = api::Context::new(profile.clone()); let api_router = api::router(ctx); + let git_router = git::router(profile); tracing::info!("listening on http://{}", options.listen); @@ -90,169 +71,3 @@ pub async fn run(options: Options) -> anyhow::Result<()> { .await .map_err(anyhow::Error::from) } - -async fn git_handler( - Extension(profile): Extension>, - AxumPath((project, request)): AxumPath<(String, String)>, - method: Method, - headers: HeaderMap, - ConnectInfo(remote): ConnectInfo, - query: RawQuery, - body: Bytes, -) -> impl IntoResponse { - let query = query.0.unwrap_or_default(); - let id: Id = project.strip_suffix(".git").unwrap_or(&project).parse()?; - - let (status, headers, body) = - git_http_backend(&profile, method, headers, body, remote, id, &request, query).await?; - - let mut response_headers = HeaderMap::new(); - for (name, vec) in headers.iter() { - for value in vec { - let header: HeaderName = name.try_into()?; - response_headers.insert(header, value.parse()?); - } - } - - Ok::<_, Error>((status, response_headers, body)) -} - -async fn git_http_backend( - profile: &Profile, - method: Method, - headers: HeaderMap, - mut body: Bytes, - remote: net::SocketAddr, - id: Id, - path: &str, - query: String, -) -> Result<(StatusCode, HashMap>, Vec), Error> { - let git_dir = radicle::storage::git::paths::repository(&profile.storage, &id); - let content_type = - if let Some(Ok(content_type)) = headers.get("Content-Type").map(|h| h.to_str()) { - content_type - } else { - "" - }; - - // Reject push requests. - match (path, query.as_str()) { - ("git-receive-pack", _) | (_, "service=git-receive-pack") => { - return Err(Error::ServiceUnavailable("git-receive-pack")); - } - _ => {} - }; - - tracing::debug!("id: {:?}", id); - tracing::debug!("headers: {:?}", headers); - tracing::debug!("path: {:?}", path); - tracing::debug!("method: {:?}", method.as_str()); - tracing::debug!("remote: {:?}", remote.to_string()); - - let mut cmd = Command::new("git"); - let mut child = cmd - .arg("http-backend") - .env("REQUEST_METHOD", method.as_str()) - .env("GIT_PROJECT_ROOT", git_dir) - // "The GIT_HTTP_EXPORT_ALL environmental variable may be passed to git-http-backend to bypass - // the check for the "git-daemon-export-ok" file in each repository before allowing export of - // that repository." - .env("GIT_HTTP_EXPORT_ALL", String::default()) - .env("PATH_INFO", Path::new("/").join(path)) - .env("CONTENT_TYPE", content_type) - .env("QUERY_STRING", query) - .stderr(Stdio::piped()) - .stdout(Stdio::piped()) - .stdin(Stdio::piped()) - .spawn()?; - - // Whether the request body is compressed. - let gzip = matches!( - headers.get("Content-Encoding").map(|h| h.to_str()), - Some(Ok("gzip")) - ); - - { - // This is safe because we captured the child's stdin. - let mut stdin = child.stdin.take().unwrap(); - - // Copy the request body to git-http-backend's stdin. - if gzip { - let mut decoder = GzDecoder::new(&mut stdin); - let mut reader = body.reader(); - - io::copy(&mut reader, &mut decoder)?; - decoder.finish()?; - } else { - while body.has_remaining() { - let mut chunk = body.chunk(); - let count = chunk.len(); - - io::copy(&mut chunk, &mut stdin)?; - body.advance(count); - } - } - } - - match child.wait_with_output() { - Ok(output) if output.status.success() => { - tracing::info!("git-http-backend: exited successfully for {}", id); - - let mut reader = std::io::Cursor::new(output.stdout); - let mut headers = HashMap::new(); - - // Parse headers returned by git so that we can use them in the client response. - for line in io::Read::by_ref(&mut reader).lines() { - let line = line?; - - if line.is_empty() || line == "\r" { - break; - } - - let mut parts = line.splitn(2, ':'); - let key = parts.next(); - let value = parts.next(); - - if let (Some(key), Some(value)) = (key, value) { - let value = &value[1..]; - - headers - .entry(key.to_string()) - .or_insert_with(Vec::new) - .push(value.to_string()); - } else { - return Err(Error::Backend); - } - } - - let status = { - tracing::debug!("git-http-backend: {:?}", &headers); - - let line = headers.remove("Status").unwrap_or_default(); - let line = line.into_iter().next().unwrap_or_default(); - let mut parts = line.split(' '); - - parts - .next() - .and_then(|p| p.parse().ok()) - .unwrap_or(StatusCode::OK) - }; - - let position = reader.position() as usize; - let body = reader.into_inner().split_off(position); - - Ok((status, headers, body)) - } - Ok(output) => { - tracing::error!("git-http-backend: exited with code {}", output.status); - - if let Ok(output) = std::str::from_utf8(&output.stderr) { - tracing::error!("git-http-backend: stderr: {}", output.trim_end()); - } - Err(Error::Backend) - } - Err(err) => { - panic!("failed to wait for git-http-backend: {err}"); - } - } -}