OXYGENHUB
# e02bf0cd0533 oxygen / crates / oxy-server / src / lib.rs
11.3 KiB · Rust · last changed Bare repositories and an embeddable protocol server for Oxygenhub 1 month ago source blame history raw
750251b2 Andy McCall ↶ 1
//! Reference HTTP server for the Oxygen wire protocol (spec §15.1, §16).
2
//!
2b5ee371 Andy McCall ↶ 3
//! Serves every repository directory under a root at `/{repo}/oxy/v1/...`,
4
//! over TLS when a certificate and key are given, else plain HTTP for use
5
//! behind a TLS-terminating proxy or on a loopback interface (spec §15.1).
750251b2 Andy McCall ↶ 6
//!
7
//! Authentication: when bearer tokens are configured, `push` requires one;
8
//! reads are open. With no tokens configured everything is open (development).
e02bf0cd Andy McCall ↶ 9
//!
10
//! Hosts embed the protocol by implementing [`Backend`] (name resolution
11
//! and authorization) and routing to [`Server::handle`]; `router` is the
12
//! standalone server built on [`DirBackend`].
750251b2 Andy McCall ↶ 13
14
use axum::body::Bytes;
15
use axum::extract::{Path, State};
e02bf0cd Andy McCall ↶ 16
use axum::http::{header, HeaderMap, Method, StatusCode};
750251b2 Andy McCall ↶ 17
use axum::response::{IntoResponse, Response};
e02bf0cd Andy McCall ↶ 18
use axum::routing::any;
750251b2 Andy McCall ↶ 19
use axum::Router;
20
use oxy_core::protocol::{wire, CONTENT_TYPE};
21
use oxy_core::repo::Repo;
22
use std::collections::HashMap;
23
use std::path::PathBuf;
24
use std::sync::{Arc, Mutex};
25
26
#[derive(Clone, Debug)]
27
pub struct ServerConfig {
28
    /// Directory containing one repository per subdirectory.
29
    pub root: PathBuf,
30
    /// Bearer tokens allowed to push; empty means no authentication.
31
    pub tokens: Vec<String>,
2b5ee371 Andy McCall ↶ 32
    /// Serve HTTPS with this certificate chain and private key (PEM files).
33
    pub tls: Option<TlsFiles>,
750251b2 Andy McCall ↶ 34
}
35
2b5ee371 Andy McCall ↶ 36
#[derive(Clone, Debug)]
37
pub struct TlsFiles {
38
    pub cert: PathBuf,
39
    pub key: PathBuf,
40
}
41
e02bf0cd Andy McCall ↶ 42
/// Which kind of access a request needs (spec §15.1: reads may be open;
43
/// a push must be authenticated when any credential is configured).
44
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45
pub enum Access {
46
    Read,
47
    Push,
48
}
49
50
/// What a host provides to the protocol server: how a repository name maps
51
/// to a repository on disk and who may do what. `oxy serve` uses
52
/// [`DirBackend`]; a hub supplies its own with accounts and roles.
53
pub trait Backend: Send + Sync + 'static {
54
    /// Open the repository called `name`, or fail with the status to
55
    /// answer. An unknown name is `404`; a name the caller may not see
56
    /// SHOULD also be `404`, so private repositories are not enumerable.
57
    fn open(&self, name: &str) -> Result<Repo, Failure>;
58
59
    /// Decide whether this request may perform `access` on `name`.
60
    fn authorize(&self, name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure>;
61
62
    /// Called after a push was processed, with its per-reference results
63
    /// (accepted or refused), for logs and cache invalidation.
64
    fn after_push(&self, _name: &str, _results: &[oxy_core::protocol::RefResult]) {}
65
}
66
67
/// A protocol server over any [`Backend`], serialising the pushes to each
68
/// repository. Hosts route requests to [`Server::handle`].
69
pub struct Server {
70
    backend: Box<dyn Backend>,
750251b2 Andy McCall ↶ 71
    locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
72
}
73
e02bf0cd Andy McCall ↶ 74
/// An error response: a status and a plain-text body.
75
#[derive(Debug)]
76
pub struct Failure(pub StatusCode, pub String);
750251b2 Andy McCall ↶ 77
78
impl IntoResponse for Failure {
79
    fn into_response(self) -> Response {
80
        (self.0, self.1).into_response()
81
    }
82
}
83
84
impl From<oxy_core::repo::RepoError> for Failure {
85
    fn from(e: oxy_core::repo::RepoError) -> Failure {
86
        Failure(StatusCode::BAD_REQUEST, e.to_string())
87
    }
88
}
89
e02bf0cd Andy McCall ↶ 90
/// The wire protocol's endpoints (spec §16.1), named as in the URL.
91
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92
pub enum Endpoint {
93
    Capabilities,
94
    Refs,
95
    Negotiate,
96
    Fetch,
97
    Objects,
98
    Checkpoint,
99
    Shallow,
100
    Push,
101
}
102
103
impl Endpoint {
104
    pub fn parse(name: &str) -> Option<Endpoint> {
105
        Some(match name {
106
            "capabilities" => Endpoint::Capabilities,
107
            "refs" => Endpoint::Refs,
108
            "negotiate" => Endpoint::Negotiate,
109
            "fetch" => Endpoint::Fetch,
110
            "objects" => Endpoint::Objects,
111
            "checkpoint" => Endpoint::Checkpoint,
112
            "shallow" => Endpoint::Shallow,
113
            "push" => Endpoint::Push,
114
            _ => return None,
115
        })
116
    }
117
118
    pub fn name(self) -> &'static str {
119
        match self {
120
            Endpoint::Capabilities => "capabilities",
121
            Endpoint::Refs => "refs",
122
            Endpoint::Negotiate => "negotiate",
123
            Endpoint::Fetch => "fetch",
124
            Endpoint::Objects => "objects",
125
            Endpoint::Checkpoint => "checkpoint",
126
            Endpoint::Shallow => "shallow",
127
            Endpoint::Push => "push",
128
        }
129
    }
130
131
    /// The HTTP method the endpoint answers to.
132
    pub fn method(self) -> Method {
133
        match self {
134
            Endpoint::Capabilities | Endpoint::Refs => Method::GET,
135
            _ => Method::POST,
136
        }
137
    }
138
139
    fn access(self) -> Access {
140
        match self {
141
            Endpoint::Push => Access::Push,
142
            _ => Access::Read,
143
        }
144
    }
145
}
146
750251b2 Andy McCall ↶ 147
fn ok(body: Vec<u8>) -> Response {
148
    ([(header::CONTENT_TYPE, CONTENT_TYPE)], body).into_response()
149
}
150
e02bf0cd Andy McCall ↶ 151
pub fn valid_repo_name(name: &str) -> bool {
750251b2 Andy McCall ↶ 152
    !name.is_empty()
153
        && !name.starts_with('.')
154
        && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
155
}
156
e02bf0cd Andy McCall ↶ 157
impl Server {
158
    pub fn new(backend: impl Backend) -> Arc<Server> {
159
        Arc::new(Server { backend: Box::new(backend), locks: Mutex::new(HashMap::new()) })
160
    }
161
162
    pub fn backend(&self) -> &dyn Backend {
163
        self.backend.as_ref()
164
    }
165
166
    fn lock_for(&self, name: &str) -> Arc<Mutex<()>> {
167
        self.locks.lock().unwrap().entry(name.to_string()).or_default().clone()
168
    }
169
170
    /// Answer one protocol request for repository `name`. `method` is
171
    /// checked against the endpoint; the body is the raw CBOR request.
172
    pub async fn handle(self: &Arc<Self>, name: String, endpoint: Endpoint, method: &Method, headers: &HeaderMap, body: Bytes) -> Response {
173
        if *method != endpoint.method() {
174
            return Failure(StatusCode::METHOD_NOT_ALLOWED, format!("{} is {}", endpoint.name(), endpoint.method())).into_response();
175
        }
176
        if let Err(f) = self.backend.authorize(&name, endpoint.access(), headers) {
177
            return f.into_response();
178
        }
179
        let server = Arc::clone(self);
180
        let result = tokio::task::spawn_blocking(move || server.respond(&name, endpoint, body)).await;
181
        match result {
182
            Ok(Ok(r)) => r,
183
            Ok(Err(f)) => f.into_response(),
184
            Err(e) => Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
185
        }
186
    }
187
188
    fn respond(&self, name: &str, endpoint: Endpoint, body: Bytes) -> Result<Response, Failure> {
189
        let r = self.backend.open(name)?;
190
        Ok(match endpoint {
191
            Endpoint::Capabilities => ok(wire::encode_capabilities(r.id()?)),
192
            Endpoint::Refs => ok(wire::encode_refs(r.id()?, &r.remote_refs()?)),
193
            Endpoint::Negotiate => {
194
                let (have, want) = wire::decode_negotiate(&body)?;
195
                ok(wire::encode_hashes(&r.negotiate(&have, &want)?))
196
            }
197
            Endpoint::Fetch => {
198
                let (changes, max_blob) = wire::decode_fetch(&body)?;
199
                ok(wire::encode_bundle(&r.bundle_for_filtered(&changes, max_blob)?))
200
            }
201
            Endpoint::Objects => {
202
                let refs = wire::decode_hashes(&body)?;
203
                let bundle = oxy_core::protocol::Bundle { objects: r.objects_for(&refs)?, ..Default::default() };
204
                ok(wire::encode_bundle(&bundle))
205
            }
206
            Endpoint::Checkpoint => {
207
                let heads = wire::decode_hashes(&body)?;
208
                let lock = self.lock_for(name);
209
                let _guard = lock.lock().unwrap();
210
                ok(wire::encode_bundle(&r.checkpoint_bundle_for(&heads)?))
211
            }
212
            Endpoint::Shallow => {
213
                let (heads, depth) = wire::decode_shallow(&body)?;
214
                ok(wire::encode_hashes(&r.shallow_changes(&heads, depth)?))
215
            }
216
            Endpoint::Push => {
217
                let (bundle, updates, policy, tags) = wire::decode_push(&body)?;
218
                let lock = self.lock_for(name);
219
                let _guard = lock.lock().unwrap();
220
                let results = r.receive_push(bundle, updates, policy, tags)?;
221
                self.backend.after_push(name, &results);
222
                ok(wire::encode_push_response(&results))
223
            }
224
        })
225
    }
226
}
227
228
/// The backend of `oxy serve`: one repository per subdirectory of a root,
229
/// open reads, and pushes gated by a list of bearer tokens.
230
pub struct DirBackend {
231
    pub root: PathBuf,
232
    pub tokens: Vec<String>,
233
}
234
235
impl Backend for DirBackend {
750251b2 Andy McCall ↶ 236
    fn open(&self, name: &str) -> Result<Repo, Failure> {
237
        if !valid_repo_name(name) {
238
            return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
239
        }
e02bf0cd Andy McCall ↶ 240
        let dir = self.root.join(name);
750251b2 Andy McCall ↶ 241
        if !dir.join(".oxy").is_dir() {
242
            return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
243
        }
244
        Repo::open(&dir).map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
245
    }
246
e02bf0cd Andy McCall ↶ 247
    fn authorize(&self, _name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure> {
248
        if access == Access::Read || self.tokens.is_empty() {
750251b2 Andy McCall ↶ 249
            return Ok(());
250
        }
e02bf0cd Andy McCall ↶ 251
        match bearer_token(headers) {
252
            Some(t) if self.tokens.iter().any(|x| x == t) => Ok(()),
750251b2 Andy McCall ↶ 253
            _ => Err(Failure(StatusCode::UNAUTHORIZED, "a valid bearer token is required to push".into())),
254
        }
255
    }
256
}
257
e02bf0cd Andy McCall ↶ 258
/// The token of an `Authorization: Bearer` header, if any.
259
pub fn bearer_token(headers: &HeaderMap) -> Option<&str> {
260
    headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()).and_then(|v| v.strip_prefix("Bearer ")).map(str::trim)
b3c5a59a Andy McCall ↶ 261
}
262
e02bf0cd Andy McCall ↶ 263
async fn dispatch(State(server): State<Arc<Server>>, Path((repo, endpoint)): Path<(String, String)>, method: Method, headers: HeaderMap, body: Bytes) -> Response {
264
    match Endpoint::parse(&endpoint) {
265
        Some(e) => server.handle(repo, e, &method, &headers, body).await,
266
        None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(),
267
    }
750251b2 Andy McCall ↶ 268
}
269
e02bf0cd Andy McCall ↶ 270
/// The router of `oxy serve`: `/{repo}/oxy/v1/{endpoint}` over a [`DirBackend`].
750251b2 Andy McCall ↶ 271
pub fn router(cfg: ServerConfig) -> Router {
e02bf0cd Andy McCall ↶ 272
    let server = Server::new(DirBackend { root: cfg.root, tokens: cfg.tokens });
273
    Router::new().route("/{repo}/oxy/v1/{endpoint}", any(dispatch)).with_state(server)
750251b2 Andy McCall ↶ 274
}
275
2b5ee371 Andy McCall ↶ 276
/// Serve on an already-bound listener until the process ends; HTTPS when
277
/// `cfg.tls` is set.
750251b2 Andy McCall ↶ 278
pub async fn serve(listener: std::net::TcpListener, cfg: ServerConfig) -> std::io::Result<()> {
279
    listener.set_nonblocking(true)?;
2b5ee371 Andy McCall ↶ 280
    match cfg.tls.clone() {
281
        Some(tls) => {
282
            let _ = rustls::crypto::ring::default_provider().install_default();
283
            let rustls_cfg = axum_server::tls_rustls::RustlsConfig::from_pem_file(&tls.cert, &tls.key)
284
                .await
285
                .map_err(|e| std::io::Error::other(format!("loading TLS files: {e}")))?;
286
            axum_server::from_tcp_rustls(listener, rustls_cfg)?.serve(router(cfg).into_make_service()).await
287
        }
288
        None => {
289
            let listener = tokio::net::TcpListener::from_std(listener)?;
290
            axum::serve(listener, router(cfg)).await
291
        }
292
    }
750251b2 Andy McCall ↶ 293
}
294
295
/// Build a runtime and serve; for the CLI and for tests on a background thread.
296
pub fn run_blocking(listener: std::net::TcpListener, cfg: ServerConfig) -> std::io::Result<()> {
297
    let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
298
    rt.block_on(serve(listener, cfg))
299
}