aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to 'crates/gilti/src/main.rs')
-rw-r--r--crates/gilti/src/main.rs591+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());
- }
-}