diff options
Diffstat (limited to 'crates/gilti/src/main.rs')
| -rw-r--r-- | crates/gilti/src/main.rs | 591 | +73 −518 |
1 files changed, 73 insertions, 518 deletions
diff --git a/crates/gilti/src/main.rs b/crates/gilti/src/main.rs index 70a0b3d..aa36590 100644 --- a/crates/gilti/src/main.rs +++ b/crates/gilti/src/main.rs @@ -2,159 +2,95 @@ // SPDX-License-Identifier: AGPL-3.0-or-later mod cgi; -mod lfs; -mod models; -pub mod router; -mod views; const DEFAULT_LISTEN_ADDR: &str = "0.0.0.0:8080"; -const DEFAULT_ROOT_TITLE: &str = "Gilti"; -const DEFAULT_ROOT_DESCRIPTION: &str = "A tiny Git server"; -const GIT_HTTP_BACKENDS: &[&str] = &[ - "/usr/libexec/git-core/git-http-backend", - "/usr/lib/git-core/git-http-backend", -]; -const GIT: &str = "/usr/bin/git"; + +const CGIT: &str = "/usr/share/webapps/cgit/cgit.cgi"; +const CGIT_CONFIG: &str = "/etc/cgitrc"; const GIT_HOME: &str = "/var/lib/gilti/git"; -const REPOSITORIES: &str = "/var/lib/gilti/git/repositories"; -const ARCHIVE_COMPRESSORS: &[&str] = &[ - "/usr/bin/bzip2", - "/usr/bin/lzip", - "/usr/bin/xz", - "/usr/bin/zstd", -]; +const RUN_DIR: &str = "/run/gilti"; -const ASSET_CSS: &str = "/usr/share/gilti/gilti.css"; -const ASSET_JS: &str = "/usr/share/gilti/gilti.js"; -const ASSET_LOGO: &str = "/usr/share/gilti/gilti.png"; -const ASSET_FAVICON: &str = "/usr/share/gilti/favicon.ico"; +const CGIT_CSS: &str = "/usr/share/webapps/cgit/cgit.css"; +const CGIT_LOGO: &str = "/usr/share/webapps/cgit/cgit.png"; +const CGIT_FAVICON: &str = "/usr/share/webapps/cgit/favicon.ico"; #[derive(Clone)] -struct RepositoryService { - git: cgi::Cgi, - views: views::shared::Context, - write_enabled: bool, +struct AppState { + cgit: cgi::Cgi, } -struct Config { - listen_addr: std::net::SocketAddr, - root_title: String, - root_description: String, - clone_prefix: String, - http_write: bool, +struct CgitConfig { + path: std::path::PathBuf, } -impl Config { - fn from_environment() -> std::io::Result<Self> { - let listen_addr = environment("GILTI_HTTP_ADDR", DEFAULT_LISTEN_ADDR)? - .parse() - .map_err(|_| invalid_config("GILTI_HTTP_ADDR must be a socket address"))?; - Ok(Self { - listen_addr, - root_title: environment("GILTI_ROOT_TITLE", DEFAULT_ROOT_TITLE)?, - root_description: environment("GILTI_ROOT_DESCRIPTION", DEFAULT_ROOT_DESCRIPTION)?, - clone_prefix: environment("GILTI_CLONE_PREFIX", "")?, - http_write: parse_bool("GILTI_HTTP_WRITE", &environment("GILTI_HTTP_WRITE", "0")?)?, - }) +impl CgitConfig { + fn create() -> std::io::Result<Self> { + let contents = std::fs::read(CGIT_CONFIG)?; + let path = std::path::PathBuf::from(format!("{RUN_DIR}/cgitrc.{}", std::process::id())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let mut file = options.open(&path)?; + let config = Self { path }; + std::io::Write::write_all(&mut file, &contents)?; + Ok(config) } } -fn environment(name: &str, default: &str) -> std::io::Result<String> { - match std::env::var(name) { - Ok(value) => Ok(value), - Err(std::env::VarError::NotPresent) => Ok(default.to_owned()), - Err(std::env::VarError::NotUnicode(_)) => { - Err(invalid_config(format!("{name} must be valid UTF-8"))) +impl Drop for CgitConfig { + fn drop(&mut self) { + match std::fs::remove_file(&self.path) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => { + eprintln!( + "gilti-httpd: cannot remove {}: {error}", + self.path.display() + ); + } + _ => {} } } } -fn parse_bool(name: &str, value: &str) -> std::io::Result<bool> { - match value { - "0" => Ok(false), - "1" => Ok(true), - _ => Err(invalid_config(format!("{name} must be 0 or 1"))), - } -} - -fn invalid_config(message: impl Into<String>) -> std::io::Error { - std::io::Error::new(std::io::ErrorKind::InvalidData, message.into()) -} - #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { - let config = Config::from_environment()?; - let listen_addr = config.listen_addr; - let git_http_backend = git_http_backend()?; - check_files(&git_http_backend)?; + let listen_addr = std::env::var("GILTI_HTTP_ADDR") + .unwrap_or_else(|_| DEFAULT_LISTEN_ADDR.to_owned()) + .parse::<std::net::SocketAddr>()?; + check_files()?; if std::env::args().nth(1).as_deref() == Some("--check") { return Ok(()); } - let views = views::shared::Context { - repositories: REPOSITORIES, - root_title: std::sync::Arc::from(config.root_title.clone()), - root_description: std::sync::Arc::from(config.root_description.clone()), - clone_prefix: std::sync::Arc::from(config.clone_prefix.clone()), - }; - let git = cgi::Cgi::new(git_http_backend, GIT_HOME, config.listen_addr) - .env("GIT_PROJECT_ROOT", REPOSITORIES) - .env("GIT_HTTP_EXPORT_ALL", "1") - .env("HOME", GIT_HOME) - .env("USER", "git") - .env("LOGNAME", "git") - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_NOSYSTEM", "1") - .env("PATH", "/usr/bin:/bin"); - let repositories = RepositoryService { - git, - views, - write_enabled: config.http_write, + let cgit_config = CgitConfig::create()?; + let state = AppState { + cgit: cgi::Cgi::new(CGIT, GIT_HOME, listen_addr) + .env("CGIT_CONFIG", cgit_config.path.as_os_str()) + .env("HOME", GIT_HOME) + .env("PATH", "/usr/bin:/bin"), }; let app = axum::Router::new() .route( - "/-/health", - axum::routing::get(async || { - response( - axum::http::StatusCode::OK, - "application/json", - b"{\"status\":\"ok\"}\n".to_vec(), - ) - }), - ) - .route( - "/-/about", - axum::routing::get(async || { - plain_response(axum::http::StatusCode::OK, "Gilti Git server\n") - }), - ) - .route( - "/-/terms", - axum::routing::get(async || { - plain_response(axum::http::StatusCode::OK, "No additional terms of use.\n") - }), - ) - .route( - "/-/assets/gilti.css", - axum::routing::get(async || static_file(ASSET_CSS, "text/css")), + "/healthz", + axum::routing::get(async || plain_response(axum::http::StatusCode::OK, "ok\n")), ) .route( - "/-/assets/gilti.js", - axum::routing::get(async || static_file(ASSET_JS, "text/javascript")), + "/cgit.css", + axum::routing::get(async || static_file(CGIT_CSS, "text/css")), ) .route( - "/-/assets/gilti.png", - axum::routing::get(async || static_file(ASSET_LOGO, "image/png")), + "/cgit.png", + axum::routing::get(async || static_file(CGIT_LOGO, "image/png")), ) .route( - "/-/assets/favicon.ico", - axum::routing::get(async || static_file(ASSET_FAVICON, "image/x-icon")), + "/favicon.ico", + axum::routing::get(async || static_file(CGIT_FAVICON, "image/x-icon")), ) - .fallback_service(repositories); + .fallback(proxy_to_cgit) + .with_state(state); let listener = tokio::net::TcpListener::bind(listen_addr).await?; - eprintln!("gilti: listening on {listen_addr}"); + eprintln!("gilti-httpd: listening on {listen_addr}"); axum::serve( listener, @@ -166,32 +102,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { Ok(()) } -fn git_http_backend() -> std::io::Result<std::path::PathBuf> { - GIT_HTTP_BACKENDS - .iter() - .map(std::path::PathBuf::from) - .find(|path| path.is_file()) - .ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "git-http-backend not found") - }) -} - -fn check_files(git_http_backend: &std::path::Path) -> std::io::Result<()> { - for path in std::iter::once(git_http_backend) - .chain(std::iter::once(std::path::Path::new(GIT))) - .chain(ARCHIVE_COMPRESSORS.iter().map(std::path::Path::new)) +fn check_files() -> std::io::Result<()> { + let metadata = std::fs::metadata(CGIT)?; + if !metadata.is_file() + || std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o111 == 0 { - let metadata = std::fs::metadata(path)?; - if !metadata.is_file() - || std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o111 == 0 - { - return Err(std::io::Error::other(format!( - "{} is not executable", - path.display() - ))); - } + return Err(std::io::Error::other(format!("{CGIT} is not executable"))); } - for path in [ASSET_CSS, ASSET_JS, ASSET_LOGO, ASSET_FAVICON] { + for path in [CGIT_CONFIG, CGIT_CSS, CGIT_LOGO, CGIT_FAVICON] { if !std::fs::metadata(path)?.is_file() { return Err(std::io::Error::other(format!( "{path} is not a regular file" @@ -216,382 +134,29 @@ fn static_file(path: &str, content_type: &'static str) -> axum::response::Respon match std::fs::read(path) { Ok(bytes) => response(axum::http::StatusCode::OK, content_type, bytes), Err(error) => { - eprintln!("gilti: cannot read {path}: {error}"); + eprintln!("gilti-httpd: cannot read {path}: {error}"); plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n") } } } -impl tower::Service<axum::extract::Request> for RepositoryService { - type Response = axum::response::Response; - type Error = std::convert::Infallible; - type Future = std::pin::Pin< - Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>, - >; - - fn poll_ready( - &mut self, - _context: &mut std::task::Context<'_>, - ) -> std::task::Poll<Result<(), Self::Error>> { - std::task::Poll::Ready(Ok(())) - } - - fn call(&mut self, request: axum::extract::Request) -> Self::Future { - let service = self.clone(); - Box::pin(async move { Ok(service.execute(request).await) }) - } -} - -impl RepositoryService { - async fn execute(&self, mut request: axum::extract::Request) -> axum::response::Response { - let route = match router::parse(request.uri().path()) { - Ok(route) => route, - Err(_) => return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"), - }; - if let Some(axum::extract::ConnectInfo(remote)) = request - .extensions() - .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>() - .copied() - { - request.extensions_mut().insert(cgi::RemoteAddr(remote)); - } - let browser_view = matches!( - &route, - router::Route::Repositories - | router::Route::Overview(_) - | router::Route::About(_) - | router::Route::Stats(_) - | router::Route::Object(_) - | router::Route::Refs(_) - | router::Route::Revision(_) - | router::Route::Tree(_) - | router::Route::Blame(_) - | router::Route::Archive(_) - | router::Route::ArchiveSignature(_) - | router::Route::Diff(_) - | router::Route::Patch(_) - | router::Route::Log(_) - | router::Route::AtomFeed(_) - ); - let query = if browser_view { - let query = match request_query(request.uri().query()) { - Ok(query) => query, - Err(()) => { - return plain_response(axum::http::StatusCode::BAD_REQUEST, "bad query\n"); - } - }; - if !valid_format(&route, query.format.as_deref()) { - return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"); - } - Some(query) - } else { - None - }; - let format = query.as_ref().and_then(|query| query.format.as_deref()); - - match route { - router::Route::Repositories => { - views::repositories::serve( - &self.views, - views::repositories::Query::from_request(query.as_ref().expect("query parsed")), - request.method().clone(), - ) - .await - } - router::Route::Summary(route) => redirect(&route.repo), - router::Route::GitClone(route) => redirect(&route.repo), - router::Route::Overview(route) => { - views::overview::serve( - &self.views, - route, - request.headers(), - request.method().clone(), - ) - .await - } - router::Route::About(route) => { - views::about::serve(&self.views, route, request.method().clone()).await - } - router::Route::Stats(route) => { - let query = match views::stats::Query::from_request( - query.as_ref().expect("query parsed"), - ) { - Ok(query) => query, - Err(views::stats::QueryError::BadRequest) => { - return views::bad_request("bad query\n"); - } - Err(views::stats::QueryError::NotFound) => { - return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"); - } - }; - views::stats::serve(&self.views, route, query, request.method().clone()).await - } - router::Route::Object(route) => { - views::object::serve(REPOSITORIES, route, request.method().clone()).await - } - router::Route::Refs(route) => { - views::refs::serve(&self.views, route, request.method().clone()).await - } - router::Route::Tree(route) => { - views::tree::serve(&self.views, route, format, request.method().clone()).await - } - router::Route::Blame(route) => { - views::blame::serve(&self.views, route, request.method().clone()).await - } - router::Route::Archive(route) => { - let Some(format) = views::archive::Format::parse(format) else { - return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"); - }; - views::archive::serve(&self.views, route, format, request.method().clone()).await - } - router::Route::ArchiveSignature(route) => { - views::archive_signature::serve( - &self.views, - route, - format, - request.method().clone(), - ) - .await - } - router::Route::Revision(route) - if matches!( - &route.params, - router::Revision::Ref(reference) if reference.starts_with("refs/tags/") - ) => - { - views::tag::serve(&self.views, route, request.method().clone()).await - } - router::Route::Revision(route) => { - let query = - match views::diff::Query::from_request(query.as_ref().expect("query parsed")) { - Ok(query) => query, - Err(()) => return views::bad_request("bad query\n"), - }; - views::revision::serve(&self.views, route, query, request.method().clone()).await - } - router::Route::Diff(route) => { - let query = - match views::diff::Query::from_request(query.as_ref().expect("query parsed")) { - Ok(query) => query, - Err(()) => return views::bad_request("bad query\n"), - }; - views::diff::serve( - &self.views, - route, - query, - format == Some("raw"), - request.method().clone(), - ) - .await - } - router::Route::Patch(route) => { - views::patch::serve(&self.views, route, request.method().clone()).await - } - router::Route::Log(route) => { - let query = - match views::log::Query::from_request(query.as_ref().expect("query parsed")) { - Ok(query) => query, - Err(()) => return views::bad_request("bad query\n"), - }; - views::log::serve(&self.views, route, query, request.method().clone()).await - } - router::Route::AtomFeed(route) => { - views::atom::serve( - &self.views, - route, - request.headers().get(axum::http::header::HOST), - request.method().clone(), - ) - .await - } - router::Route::GitLfs(route) => { - lfs::serve( - std::path::Path::new(REPOSITORIES), - &route.repo, - &route.params, - self.write_enabled, - request, - ) - .await - } - router::Route::GitInfoRefs(route) => { - self.git(request, route.repo, "info/refs", self.write_enabled) - .await - } - router::Route::GitUploadPack(route) => { - self.git(request, route.repo, "git-upload-pack", false) - .await - } - router::Route::GitReceivePack(route) if self.write_enabled => { - self.git(request, route.repo, "git-receive-pack", true) - .await - } - router::Route::GitReceivePack(_) => { - plain_response(axum::http::StatusCode::FORBIDDEN, "HTTP push is disabled\n") - } - router::Route::GitHead(route) => self.git(request, route.repo, "HEAD", false).await, - router::Route::GitObjects(route) => { - self.git( - request, - route.repo, - &format!("objects/{}", route.params), - false, - ) - .await - } - } - } - - async fn git( - &self, - mut request: axum::extract::Request, - repo: String, - endpoint: &str, - authenticated: bool, - ) -> axum::response::Response { - if !safe_repository(&repo) { - return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"); - } - let mut environment = vec![ - ("PATH_INFO".into(), format!("/{repo}.git/{endpoint}").into()), - ("GIT_PROJECT_ROOT".into(), REPOSITORIES.into()), - ]; - if authenticated { - environment.push(("REMOTE_USER".into(), "gilti".into())); - } - request - .extensions_mut() - .insert(cgi::Environment(environment)); - match tower::ServiceExt::oneshot(self.git.clone(), request).await { - Ok(response) => response, - Err(error) => internal_error("git-http-backend", error), - } - } -} - -#[derive(Default)] -struct RequestQuery { - format: Option<String>, - environment: Vec<(std::ffi::OsString, std::ffi::OsString)>, -} - -impl RequestQuery { - fn value(&self, name: &str) -> Option<&str> { - self.environment - .iter() - .find(|(key, _)| key == std::ffi::OsStr::new(name)) - .and_then(|(_, value)| value.to_str()) - } -} - -fn request_query(query: Option<&str>) -> Result<RequestQuery, ()> { - let mut result = RequestQuery::default(); - for pair in query - .unwrap_or("") - .split('&') - .filter(|pair| !pair.is_empty()) - { - let (name, value) = pair.split_once('=').unwrap_or((pair, "")); - let name = decode_query(name)?; - let value = decode_query(value)?; - if name == "format" { - if result.format.replace(value).is_some() { - return Err(()); - } - continue; - } - let environment = match name.as_str() { - "q" => "GILTI_QUERY_SEARCH", - "qt" => "GILTI_QUERY_GREP", - "ofs" => "GILTI_QUERY_OFFSET", - "s" => "GILTI_QUERY_SORT", - "showmsg" => "GILTI_QUERY_SHOWMSG", - "period" => "GILTI_QUERY_PERIOD", - "dt" => "GILTI_QUERY_DIFFTYPE", - "context" => "GILTI_QUERY_CONTEXT", - "ignorews" => "GILTI_QUERY_IGNOREWS", - "follow" => "GILTI_QUERY_FOLLOW", - "view" => "GILTI_QUERY_VIEW", - _ => return Err(()), - }; - if result - .environment - .iter() - .any(|(existing, _)| existing == std::ffi::OsStr::new(environment)) - { - return Err(()); - } - result.environment.push((environment.into(), value.into())); - } - Ok(result) -} - -fn decode_query(value: &str) -> Result<String, ()> { - let value = value.replace('+', " "); - let value = percent_encoding::percent_decode_str(&value) - .decode_utf8() - .map_err(|_| ())? - .into_owned(); - (!value.contains('\0')).then_some(value).ok_or(()) -} - -fn valid_format(route: &router::Route, format: Option<&str>) -> bool { - let Some(format) = format else { - return true; - }; - match route { - router::Route::Tree(_) | router::Route::Diff(_) => matches!(format, "html" | "raw"), - router::Route::Object(_) => format == "raw", - router::Route::Patch(_) => matches!(format, "patch" | "raw"), - router::Route::Archive(_) | router::Route::ArchiveSignature(_) => matches!( - format, - "tar" | "tar.gz" | "tar.bz2" | "tar.lz" | "tar.xz" | "tar.zst" | "zip" - ), - router::Route::AtomFeed(_) => format == "atom", - _ => format == "html", +async fn proxy_to_cgit( + axum::extract::State(state): axum::extract::State<AppState>, + axum::extract::ConnectInfo(remote): axum::extract::ConnectInfo<std::net::SocketAddr>, + mut request: axum::extract::Request, +) -> axum::response::Response { + if request.method() != axum::http::Method::GET && request.method() != axum::http::Method::HEAD { + return plain_response(axum::http::StatusCode::FORBIDDEN, "forbidden\n"); } -} - -fn safe_repository(repo: &str) -> bool { - !repo.is_empty() - && !repo.starts_with('/') - && !repo.chars().any(char::is_control) - && !repo.contains('\\') - && repo - .split('/') - .all(|component| !component.is_empty() && !matches!(component, "." | "..")) -} - -fn redirect(repo: &str) -> axum::response::Response { - axum::http::Response::builder() - .status(axum::http::StatusCode::PERMANENT_REDIRECT) - .header( - axum::http::header::LOCATION, - format!("/{}", encode_path(repo)), - ) - .body(axum::body::Body::empty()) - .expect("valid redirect") -} + request.extensions_mut().insert(cgi::RemoteAddr(remote)); -fn encode_path(value: &str) -> String { - let mut encoded = String::new(); - for byte in value.bytes() { - if byte.is_ascii_alphanumeric() || byte == b'/' || byte == b'_' { - encoded.push(char::from(byte)); - } else { - use std::fmt::Write; - write!(encoded, "%{byte:02X}").expect("writing to String cannot fail"); + match tower::ServiceExt::oneshot(state.cgit.clone(), request).await { + Ok(response) => response, + Err(error) => { + eprintln!("gilti-httpd: cgit request failed: {error}"); + plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n") } } - encoded -} - -fn internal_error(context: &str, error: std::io::Error) -> axum::response::Response { - eprintln!("gilti: {context} request failed: {error}"); - plain_response( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "internal server error\n", - ) } fn response( @@ -612,13 +177,3 @@ fn plain_response( ) -> axum::response::Response { response(status, "text/plain", message.as_bytes().to_vec()) } - -#[cfg(test)] -mod tests { - #[test] - fn structural_query_parameters_are_rejected() { - assert!(super::request_query(Some("id=HEAD")).is_err()); - assert!(super::request_query(Some("path=README.md")).is_err()); - assert!(super::request_query(Some("format=raw&format=html")).is_err()); - } -} |
