//! `sorceryd`: serves generated sites and git data from behind nginx. use std::fs::{self, Permissions}; use std::os::unix::fs::{FileTypeExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result, bail}; use arc_swap::ArcSwap; use axum::Router; use axum::http::{HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::{any, get, post}; use tokio::net::UnixListener; use tokio::sync::{Mutex, Notify, OwnedSemaphorePermit, RwLock, Semaphore, watch}; use crate::catalog::Repository; use crate::highlight::Cache as GrammarCache; use cache::{BuildQueue, RepoEntry, Snapshot}; mod cache; mod control; mod git; mod objects; mod site; #[derive(Clone)] pub struct Config { pub repositories: PathBuf, pub cache: PathBuf, pub socket: PathBuf, pub instance_name: String, pub internal_prefix: String, /// Internal location rooted at the *repositories* dir, for `.git` serving. pub git_internal_prefix: String, pub check_interval: Duration, pub max_git_processes: usize, /// Local socket accepting refresh requests; see `control`. pub control_socket: Option, /// Public base URL (e.g. `https://git.example.org`); enables the clone /// command on repository pages. pub clone_url_base: Option, } impl Config { fn repo_cache(&self, repository: &Repository) -> PathBuf { self.cache.join(&repository.user).join(&repository.name) } fn site_url(&self, repository: &Repository) -> Option { let base = self.clone_url_base.as_ref()?.trim_end_matches('/'); Some(format!("{base}/{}/{}", repository.user, repository.name)) } } type AppState = Arc; struct Inner { config: Config, /// Immutable view of the repository catalog, replaced wholesale by the /// background scanner. Request handlers only ever read it. snapshot: ArcSwap, /// Builds hold this shared; garbage collection needs it exclusively. cache_lock: RwLock<()>, background: watch::Sender>, build_queue: BuildQueue, rescan_requested: Notify, rescan_gate: Mutex<()>, git_processes: Arc, highlights: Arc, } type WebError = (StatusCode, String); type WebResult = Result; pub async fn run(config: Config) -> Result<()> { if config.instance_name.trim().is_empty() { bail!("--instance-name must not be empty"); } if !config.internal_prefix.starts_with('/') { bail!("--internal-prefix must start with /"); } if config.max_git_processes == 0 { bail!("--max-git-processes must be greater than zero"); } fs::create_dir_all(&config.cache)?; site::stage_assets(&config.cache)?; let listener = bind_unix(&config.socket)?; let control = config .control_socket .as_deref() .map(bind_unix) .transpose()?; if let Some(path) = &config.control_socket { fs::set_permissions(path, Permissions::from_mode(0o600))?; } let highlights = Arc::new(GrammarCache::new(config.cache.join(".arborium"))); let state = Inner::new(config, highlights); tokio::spawn(cache::background_worker( state.clone(), state.background.subscribe(), )); state.rescan().await.context("initial repository scan")?; tokio::spawn(cache::scan_periodically(state.clone())); if let Some(control) = control { tokio::spawn(control::serve(state.clone(), control)); } let app = Router::new() .route("/", get(site::index)) .route("/css/style.css", get(site::stylesheet)) .route("/js/{*path}", get(site::js_asset)) .route("/fonts/{*path}", get(site::font_asset)) .route("/{user}", get(site::user_redirect)) .route("/{user}/", get(site::user_root)) .route("/{user}/{repo}", get(site::add_trailing_slash)) .route("/{user}/{repo}/", get(site::repo_root)) .route("/{user}/{repo}/.git/{*path}", get(git::git_dir)) .route("/{user}/{repo}/info/refs", get(git::info_refs)) .route("/{user}/{repo}/git-upload-pack", post(git::upload_pack)) .route("/{user}/{repo}/obj", any(objects::query)) .route("/{user}/{repo}/raw/{oid}/{*path}", get(git::raw_blob)) .route("/{user}/{repo}/{*path}", get(site::repo_path)) .with_state(state.clone()); let result = axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await; let sockets = [ Some(&state.config.socket), state.config.control_socket.as_ref(), ]; for socket in sockets.into_iter().flatten() { if socket.exists() { fs::remove_file(socket)?; } } result?; Ok(()) } /// Bind `path`, replacing a stale socket left behind by an earlier run. fn bind_unix(path: &Path) -> Result { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } if let Ok(metadata) = fs::symlink_metadata(path) { if !metadata.file_type().is_socket() { bail!("refusing to replace non-socket {}", path.display()); } fs::remove_file(path)?; } UnixListener::bind(path).with_context(|| format!("binding {}", path.display())) } async fn shutdown_signal() { let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) .expect("installing SIGTERM handler"); tokio::select! { _ = tokio::signal::ctrl_c() => {} _ = terminate.recv() => {} } } impl Inner { fn new(config: Config, highlights: Arc) -> AppState { Arc::new(Inner { snapshot: ArcSwap::from_pointee(Snapshot::default()), cache_lock: RwLock::new(()), background: watch::channel(Vec::new()).0, build_queue: BuildQueue::new(), rescan_requested: Notify::new(), rescan_gate: Mutex::new(()), git_processes: Arc::new(Semaphore::new(config.max_git_processes)), highlights, config, }) } /// Look up a repository in the current snapshot, tolerating a `.git` /// suffix on the request (so `git clone .../user/repo.git` works too). fn resolve(&self, user: &str, name: &str) -> WebResult { let name = name.strip_suffix(".git").unwrap_or(name); self.snapshot .load() .repos .get(&format!("{user}/{name}")) .cloned() .ok_or_else(|| (StatusCode::NOT_FOUND, "repository not found".into())) } #[cfg(test)] fn for_tests(root: &Path) -> AppState { Inner::new( Config { repositories: root.join("repos"), cache: root.join("cache"), socket: PathBuf::from("/unused"), instance_name: "test".into(), internal_prefix: "/_sorcery".into(), git_internal_prefix: "/_sorcery-git".into(), check_interval: Duration::from_secs(3600), max_git_processes: 1, control_socket: None, clone_url_base: Some("https://git.example/".into()), }, crate::testutil::grammar_cache(), ) } fn git_permit(&self) -> WebResult { self.git_processes.clone().try_acquire_owned().map_err(|_| { ( StatusCode::SERVICE_UNAVAILABLE, "git service is busy".into(), ) }) } } /// Hand the (already percent-encoded) path beneath `prefix` to nginx. fn x_accel(prefix: &str, encoded_path: &str) -> WebResult { let location = format!("{}/{encoded_path}", prefix.trim_end_matches('/')); let location = HeaderValue::from_str(&location).map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, "invalid internal path".into(), ) })?; Ok([("x-accel-redirect", location)].into_response()) } fn internal(error: impl std::fmt::Display) -> WebError { eprintln!("sorceryd: {error}"); ( StatusCode::INTERNAL_SERVER_ERROR, "internal server error".into(), ) }