//! Reference HTTP server for the Oxygen wire protocol (spec §15.1, §16). //! //! Serves every repository directory under a root at `/{repo}/oxy/v1/...`, //! over TLS when a certificate and key are given, else plain HTTP for use //! behind a TLS-terminating proxy or on a loopback interface (spec §15.1). //! //! Authentication: when bearer tokens are configured, `push` requires one; //! reads are open. With no tokens configured everything is open (development). //! //! Hosts embed the protocol by implementing [`Backend`] (name resolution //! and authorization) and routing to [`Server::handle`]; `router` is the //! standalone server built on [`DirBackend`]. pub mod witness; use axum::body::Bytes; use axum::extract::{Path, State}; use axum::http::{header, HeaderMap, Method, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::any; use axum::Router; use oxy_core::protocol::{wire, CONTENT_TYPE}; use oxy_core::repo::Repo; use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; #[derive(Clone, Debug)] pub struct ServerConfig { /// Directory containing one repository per subdirectory. pub root: PathBuf, /// Bearer tokens allowed to push; empty means no authentication. pub tokens: Vec, /// Serve HTTPS with this certificate chain and private key (PEM files). pub tls: Option, } #[derive(Clone, Debug)] pub struct TlsFiles { pub cert: PathBuf, pub key: PathBuf, } /// Which kind of access a request needs (spec §15.1: reads may be open; /// a push must be authenticated when any credential is configured). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Access { Read, Push, } /// What a host provides to the protocol server: how a repository name maps /// to a repository on disk and who may do what. `oxy serve` uses /// [`DirBackend`]; a hub supplies its own with accounts and roles. pub trait Backend: Send + Sync + 'static { /// Open the repository called `name`, or fail with the status to /// answer. An unknown name is `404`; a name the caller may not see /// SHOULD also be `404`, so private repositories are not enumerable. fn open(&self, name: &str) -> Result; /// Decide whether this request may perform `access` on `name`. fn authorize(&self, name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure>; /// Called after a push was processed, with its per-reference results /// (accepted or refused), the states each accepted reference moved /// between, and the changes stored, for logs and cache invalidation. fn after_push(&self, _name: &str, _report: &oxy_core::repo::PushReport, _headers: &HeaderMap) {} /// Notes for the pusher on references this push moved: each /// `(reference, note)` is added to that accepted reference's result, /// which a client shows beside it (`Pushed origin/x - note`). For a /// push that did what the pusher may not expect, say moving the /// bookmark of a proposal that has landed (oxygenhub#329, #148). Called /// before [`Backend::after_push`], which sees the notes. fn push_notes( &self, _name: &str, _report: &oxy_core::repo::PushReport, _headers: &HeaderMap, ) -> Vec<(String, String)> { Vec::new() } /// Vet a push by what it adds before anything of it is stored or any /// reference moves (spec §16.1): `bundle` is the push as decoded, its /// changes with their lines, `updates` the references it would move. /// A host refuses the whole push by returning a [`Failure`], whose /// text reaches the client as the push's error, as it is, several /// lines included; it should answer neither 401, 403 nor 404, which a /// client takes for a credential it should change. Called for every /// part of a push sent in parts (each part's changes are its own), /// outside the repository's push lock, so a slow vet holds up no /// other push. The default accepts everything at no cost. fn vet_push( &self, _name: &str, _repo: &Repo, _bundle: &oxy_core::protocol::Bundle, _updates: &[oxy_core::protocol::RefUpdate], _headers: &HeaderMap, ) -> Result<(), Failure> { Ok(()) } /// Add the host's transparency log to a refs answer (spec §14.8): its /// checkpoint, and each attestation's entry and inclusion proof. A host /// without a log leaves it as it is. fn add_log(&self, _name: &str, _refs: &mut oxy_core::protocol::RefsResponse) {} /// Tailor a refs answer to the client asking, by its request headers /// (its `User-Agent` and the features it declares in `Oxy-Features`), /// after [`Backend::add_log`]. The default leaves out the tags a client /// that does not declare `certified-tags` would refuse /// ([`Repo::refs_for_client`], oxygen#131); a host that overrides it /// should apply that too ([`tailor_refs`]). fn adjust_refs( &self, _name: &str, repo: &Repo, refs: &mut oxy_core::protocol::RefsResponse, headers: &HeaderMap, ) { tailor_refs(repo, refs, headers); } /// Whether tag `tag` of `name` may be withdrawn (oxygenhub#301): called /// for each withdrawal a push carries, before the repository sees it. /// A host refuses one it depends on (a forge's release names the tag) /// with the reason, which is that withdrawal's result. The default /// allows every one; the policy is checked after, as for any push. fn vet_tag_withdrawal(&self, _name: &str, _tag: &str) -> Result<(), String> { Ok(()) } /// The bookmark of `name` that pushes may never delete (its default), /// when the host keeps one apart from the repository's own config. fn default_bookmark(&self, _name: &str) -> Option { None } } /// A protocol server over any [`Backend`], serialising the pushes to each /// repository. Hosts route requests to [`Server::handle`]. pub struct Server { backend: Box, locks: Mutex>>>, } /// An error response: a status and a plain-text body. #[derive(Debug)] pub struct Failure(pub StatusCode, pub String); impl IntoResponse for Failure { fn into_response(self) -> Response { (self.0, self.1).into_response() } } impl From for Failure { fn from(e: oxy_core::repo::RepoError) -> Failure { use oxy_core::repo::RepoError; // The server's own storage failing is not the client's fault. let status = match &e { RepoError::Io(_) | RepoError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::BAD_REQUEST, }; Failure(status, e.to_string()) } } /// The wire protocol's endpoints (spec §16.1), named as in the URL. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Endpoint { Capabilities, Refs, Negotiate, Fetch, Objects, Checkpoint, CheckpointIndex, Shallow, Push, } impl Endpoint { pub fn parse(name: &str) -> Option { Some(match name { "capabilities" => Endpoint::Capabilities, "refs" => Endpoint::Refs, "negotiate" => Endpoint::Negotiate, "fetch" => Endpoint::Fetch, "objects" => Endpoint::Objects, "checkpoint" => Endpoint::Checkpoint, "checkpoint-index" => Endpoint::CheckpointIndex, "shallow" => Endpoint::Shallow, "push" => Endpoint::Push, _ => return None, }) } pub fn name(self) -> &'static str { match self { Endpoint::Capabilities => "capabilities", Endpoint::Refs => "refs", Endpoint::Negotiate => "negotiate", Endpoint::Fetch => "fetch", Endpoint::Objects => "objects", Endpoint::Checkpoint => "checkpoint", Endpoint::CheckpointIndex => "checkpoint-index", Endpoint::Shallow => "shallow", Endpoint::Push => "push", } } /// The HTTP method the endpoint answers to. pub fn method(self) -> Method { match self { Endpoint::Capabilities | Endpoint::Refs => Method::GET, _ => Method::POST, } } fn access(self) -> Access { match self { Endpoint::Push => Access::Push, _ => Access::Read, } } } fn ok(body: Vec) -> Response { ([(header::CONTENT_TYPE, CONTENT_TYPE)], body).into_response() } pub fn valid_repo_name(name: &str) -> bool { !name.is_empty() && !name.starts_with('.') && name .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) } impl Server { pub fn new(backend: impl Backend) -> Arc { Arc::new(Server { backend: Box::new(backend), locks: Mutex::new(HashMap::new()), }) } pub fn backend(&self) -> &dyn Backend { self.backend.as_ref() } fn lock_for(&self, name: &str) -> Arc> { self.locks .lock() .unwrap() .entry(name.to_string()) .or_default() .clone() } /// Answer one protocol request for repository `name`. `method` is /// checked against the endpoint; the body is the raw CBOR request. pub async fn handle( self: &Arc, name: String, endpoint: Endpoint, method: &Method, headers: &HeaderMap, body: Bytes, ) -> Response { if *method != endpoint.method() { return Failure( StatusCode::METHOD_NOT_ALLOWED, format!("{} is {}", endpoint.name(), endpoint.method()), ) .into_response(); } if let Err(f) = self.backend.authorize(&name, endpoint.access(), headers) { return f.into_response(); } let claimed = headers .get(oxy_core::protocol::REPO_ID_HEADER) .and_then(|v| v.to_str().ok()) .and_then(hex_id); let server = Arc::clone(self); let request_headers = headers.clone(); let repo = name.clone(); let result = tokio::task::spawn_blocking(move || { server.respond(&repo, endpoint, body, claimed, &request_headers) }) .await; let failure = match result { Ok(Ok(r)) => return r, Ok(Err(f)) => f, Err(e) => Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), }; // Name the operation and repository, in the log and for the client. let f = Failure( failure.0, format!("{} of {name}: {}", endpoint.name(), failure.1), ); eprintln!("oxy-server: {}: {}", f.0.as_u16(), f.1); f.into_response() } fn respond( &self, name: &str, endpoint: Endpoint, body: Bytes, claimed: Option<[u8; 16]>, headers: &HeaderMap, ) -> Result { let r = self.backend.open(name)?; Ok(match endpoint { Endpoint::Capabilities => ok(wire::encode_capabilities(r.id()?)), Endpoint::Refs => { let mut refs = r.remote_refs()?; if let Some(d) = self.backend.default_bookmark(name) { refs.default_bookmark = Some(d); } self.backend.add_log(name, &mut refs); self.backend.adjust_refs(name, &r, &mut refs, headers); ok(wire::encode_refs(r.id()?, &refs)) } Endpoint::Negotiate => { let (have, want) = wire::decode_negotiate(&body)?; ok(wire::encode_hashes(&r.negotiate(&have, &want)?)) } Endpoint::Fetch => { let (changes, max_blob) = wire::decode_fetch(&body)?; ok(wire::encode_bundle( &r.bundle_for_filtered(&changes, max_blob)?, )) } Endpoint::Objects => { let refs = wire::decode_hashes(&body)?; let bundle = oxy_core::protocol::Bundle { objects: r.objects_for(&refs)?, ..Default::default() }; ok(wire::encode_bundle(&bundle)) } Endpoint::Checkpoint => { let heads = wire::decode_hashes(&body)?; let lock = self.lock_for(name); let _guard = lock.lock().unwrap(); let bundle = r.checkpoint_bundle_for(&heads)?; let _t = oxy_core::timing::phase("serve: encode the checkpoint"); ok(wire::encode_bundle(&bundle)) } Endpoint::CheckpointIndex => { let heads = wire::decode_hashes(&body)?; let lock = self.lock_for(name); let _guard = lock.lock().unwrap(); ok(wire::encode_bundle(&r.checkpoint_index_for(&heads)?)) } Endpoint::Shallow => { let (heads, depth) = wire::decode_shallow(&body)?; ok(wire::encode_hashes(&r.shallow_changes(&heads, depth)?)) } Endpoint::Push => { let (bundle, updates, policy, tags) = wire::decode_push(&body)?; // The host vets what the push adds before anything is stored // or bound: a refused push leaves the repository as it was. self.backend .vet_push(name, &r, &bundle, &updates, headers)?; let lock = self.lock_for(name); let _guard = lock.lock().unwrap(); if r.is_unbound() { // The first push binds the repository to the pusher's id (spec §16.1). match claimed { Some(id) => r.bind_id(id)?, None => { return Err(Failure( StatusCode::BAD_REQUEST, format!( "this repository is unbound; push with a client that sends {}", oxy_core::protocol::REPO_ID_HEADER ), )) } } } let protected = self.backend.default_bookmark(name); let (refused, updates): (Vec<_>, Vec<_>) = updates.into_iter().partition(|u| { u.new_state.is_zero() && protected.as_deref() == Some(u.name.as_str()) }); let mut kept_tags = Vec::new(); let mut withdrawals_refused = Vec::new(); for t in tags { match t .is_withdrawal() .then(|| self.backend.vet_tag_withdrawal(name, &t.name)) { Some(Err(reason)) => withdrawals_refused.push((t.name, reason)), _ => kept_tags.push(t), } } let mut report = r.receive_push_report(bundle, updates, policy, kept_tags)?; // The request named references, all refused here: it was no // push of objects alone, whatever the repository saw. if !refused.is_empty() || !withdrawals_refused.is_empty() { report .results .retain(|r| r.name != oxy_core::repo::OBJECTS_RESULT); } for u in refused { report.results.push(oxy_core::protocol::RefResult { name: u.name.clone(), ok: false, reason: format!( "{:?} is this repository's default bookmark and cannot be deleted", u.name ), }); } for (tag, reason) in withdrawals_refused { report.results.push(oxy_core::protocol::RefResult { name: format!("tags/{tag}"), ok: false, reason, }); } for r in report.results.iter().filter(|r| !r.ok) { eprintln!( "oxy-server: push to {name}: {} refused: {}", r.name, r.reason ); } for (reference, note) in self.backend.push_notes(name, &report, headers) { if let Some(r) = report .results .iter_mut() .find(|r| r.ok && r.name == reference) { r.reason = if r.reason.is_empty() { note } else { format!("{}; {note}", r.reason) }; } } self.backend.after_push(name, &report, headers); ok(wire::encode_push_response(&report.results)) } }) } } /// The backend of `oxy serve`: one repository per subdirectory of a root, /// open reads, and pushes gated by a list of bearer tokens. pub struct DirBackend { pub root: PathBuf, pub tokens: Vec, } impl Backend for DirBackend { fn open(&self, name: &str) -> Result { if !valid_repo_name(name) { return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into())); } let dir = self.root.join(name); if !dir.join(".oxy").is_dir() { return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into())); } Repo::open(&dir).map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) } fn default_bookmark(&self, name: &str) -> Option { self.open(name).ok().map(|r| r.config.bookmark.clone()) } fn authorize(&self, _name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure> { if access == Access::Read || self.tokens.is_empty() { return Ok(()); } match bearer_token(headers) { Some(t) if self.tokens.iter().any(|x| x == t) => Ok(()), _ => Err(Failure( StatusCode::UNAUTHORIZED, "a valid bearer token is required to push".into(), )), } } } fn hex_id(h: &str) -> Option<[u8; 16]> { let h = h.trim(); if h.len() != 32 { return None; } let mut out = [0u8; 16]; for (i, byte) in out.iter_mut().enumerate() { *byte = u8::from_str_radix(&h[2 * i..2 * i + 2], 16).ok()?; } Some(out) } /// The features a request declares in its `Oxy-Features` header, as the /// header's text; `None` when it sent none (a client of the 0.5.0 era). pub fn client_features(headers: &HeaderMap) -> Option<&str> { headers .get(oxy_core::protocol::FEATURES_HEADER) .and_then(|v| v.to_str().ok()) } /// Tailor `refs` of `repo` to the client whose request carried `headers`: /// the shared rule of [`Repo::refs_for_client`], which every host applies. pub fn tailor_refs(repo: &Repo, refs: &mut oxy_core::protocol::RefsResponse, headers: &HeaderMap) { repo.refs_for_client(refs, client_features(headers)); } /// The token of an `Authorization: Bearer` header, if any. pub fn bearer_token(headers: &HeaderMap) -> Option<&str> { headers .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .map(str::trim) } async fn dispatch( State(server): State>, Path((repo, endpoint)): Path<(String, String)>, method: Method, headers: HeaderMap, body: Bytes, ) -> Response { match Endpoint::parse(&endpoint) { Some(e) => server.handle(repo, e, &method, &headers, body).await, None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(), } } /// The largest request `oxy serve` takes: a push part is about /// `push.batch-bytes` (64 MiB), but one change can weigh more; OxygenHub's /// default `push_body_limit` is the same. pub const BODY_LIMIT: usize = if usize::BITS > 32 { 4 << 30 } else { usize::MAX }; /// The router of `oxy serve`: `/{repo}/oxy/v1/{endpoint}` over a [`DirBackend`]. pub fn router(cfg: ServerConfig) -> Router { let server = Server::new(DirBackend { root: cfg.root, tokens: cfg.tokens, }); // Without a limit of its own a request takes axum's 2 MB, and a larger // push was cut off with no word in the log ("Broken pipe" at the client). Router::new() .route("/{repo}/oxy/v1/{endpoint}", any(dispatch)) .layer(axum::extract::DefaultBodyLimit::max(BODY_LIMIT)) .with_state(server) } /// Serve on an already-bound listener until the process ends; HTTPS when /// `cfg.tls` is set. pub async fn serve(listener: std::net::TcpListener, cfg: ServerConfig) -> std::io::Result<()> { listener.set_nonblocking(true)?; match cfg.tls.clone() { Some(tls) => { let _ = rustls::crypto::ring::default_provider().install_default(); let rustls_cfg = axum_server::tls_rustls::RustlsConfig::from_pem_file(&tls.cert, &tls.key) .await .map_err(|e| std::io::Error::other(format!("loading TLS files: {e}")))?; axum_server::from_tcp_rustls(listener, rustls_cfg)? .serve(router(cfg).into_make_service()) .await } None => { let listener = tokio::net::TcpListener::from_std(listener)?; axum::serve(listener, router(cfg)).await } } } /// Build a runtime and serve; for the CLI and for tests on a background thread. pub fn run_blocking(listener: std::net::TcpListener, cfg: ServerConfig) -> std::io::Result<()> { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; rt.block_on(serve(listener, cfg)) }