OXYGENHUB
# e8d38e47e9d7 oxygen / crates / oxy-server / src / lib.rs
13.3 KiB · Rust · last changed Roadmap: phase B done 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
7aa2dc93 Andy McCall ↶ 63
    /// (accepted or refused), the states each accepted reference moved
64
    /// between, and the changes stored, for logs and cache invalidation.
76f75250 Andy McCall ↶ 65
    fn after_push(&self, _name: &str, _report: &oxy_core::repo::PushReport, _headers: &HeaderMap) {}
e02bf0cd Andy McCall ↶ 66
}
67
68
/// A protocol server over any [`Backend`], serialising the pushes to each
69
/// repository. Hosts route requests to [`Server::handle`].
70
pub struct Server {
71
    backend: Box<dyn Backend>,
750251b2 Andy McCall ↶ 72
    locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
73
}
74
e02bf0cd Andy McCall ↶ 75
/// An error response: a status and a plain-text body.
76
#[derive(Debug)]
77
pub struct Failure(pub StatusCode, pub String);
750251b2 Andy McCall ↶ 78
79
impl IntoResponse for Failure {
80
    fn into_response(self) -> Response {
81
        (self.0, self.1).into_response()
82
    }
83
}
84
85
impl From<oxy_core::repo::RepoError> for Failure {
86
    fn from(e: oxy_core::repo::RepoError) -> Failure {
87
        Failure(StatusCode::BAD_REQUEST, e.to_string())
88
    }
89
}
90
e02bf0cd Andy McCall ↶ 91
/// The wire protocol's endpoints (spec §16.1), named as in the URL.
92
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93
pub enum Endpoint {
94
    Capabilities,
95
    Refs,
96
    Negotiate,
97
    Fetch,
98
    Objects,
99
    Checkpoint,
100
    Shallow,
101
    Push,
102
}
103
104
impl Endpoint {
105
    pub fn parse(name: &str) -> Option<Endpoint> {
106
        Some(match name {
107
            "capabilities" => Endpoint::Capabilities,
108
            "refs" => Endpoint::Refs,
109
            "negotiate" => Endpoint::Negotiate,
110
            "fetch" => Endpoint::Fetch,
111
            "objects" => Endpoint::Objects,
112
            "checkpoint" => Endpoint::Checkpoint,
113
            "shallow" => Endpoint::Shallow,
114
            "push" => Endpoint::Push,
115
            _ => return None,
116
        })
117
    }
118
119
    pub fn name(self) -> &'static str {
120
        match self {
121
            Endpoint::Capabilities => "capabilities",
122
            Endpoint::Refs => "refs",
123
            Endpoint::Negotiate => "negotiate",
124
            Endpoint::Fetch => "fetch",
125
            Endpoint::Objects => "objects",
126
            Endpoint::Checkpoint => "checkpoint",
127
            Endpoint::Shallow => "shallow",
128
            Endpoint::Push => "push",
129
        }
130
    }
131
132
    /// The HTTP method the endpoint answers to.
133
    pub fn method(self) -> Method {
134
        match self {
135
            Endpoint::Capabilities | Endpoint::Refs => Method::GET,
136
            _ => Method::POST,
137
        }
138
    }
139
140
    fn access(self) -> Access {
141
        match self {
142
            Endpoint::Push => Access::Push,
143
            _ => Access::Read,
144
        }
145
    }
146
}
147
750251b2 Andy McCall ↶ 148
fn ok(body: Vec<u8>) -> Response {
149
    ([(header::CONTENT_TYPE, CONTENT_TYPE)], body).into_response()
150
}
151
e02bf0cd Andy McCall ↶ 152
pub fn valid_repo_name(name: &str) -> bool {
750251b2 Andy McCall ↶ 153
    !name.is_empty()
154
        && !name.starts_with('.')
75167672 Andy McCall 155
        && name
156
            .chars()
157
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
750251b2 Andy McCall ↶ 158
}
159
e02bf0cd Andy McCall ↶ 160
impl Server {
161
    pub fn new(backend: impl Backend) -> Arc<Server> {
75167672 Andy McCall 162
        Arc::new(Server {
163
            backend: Box::new(backend),
164
            locks: Mutex::new(HashMap::new()),
165
        })
e02bf0cd Andy McCall ↶ 166
    }
167
168
    pub fn backend(&self) -> &dyn Backend {
169
        self.backend.as_ref()
170
    }
171
172
    fn lock_for(&self, name: &str) -> Arc<Mutex<()>> {
75167672 Andy McCall 173
        self.locks
174
            .lock()
175
            .unwrap()
176
            .entry(name.to_string())
177
            .or_default()
178
            .clone()
e02bf0cd Andy McCall ↶ 179
    }
180
181
    /// Answer one protocol request for repository `name`. `method` is
182
    /// checked against the endpoint; the body is the raw CBOR request.
75167672 Andy McCall 183
    pub async fn handle(
184
        self: &Arc<Self>,
185
        name: String,
186
        endpoint: Endpoint,
187
        method: &Method,
188
        headers: &HeaderMap,
189
        body: Bytes,
190
    ) -> Response {
e02bf0cd Andy McCall ↶ 191
        if *method != endpoint.method() {
75167672 Andy McCall 192
            return Failure(
193
                StatusCode::METHOD_NOT_ALLOWED,
194
                format!("{} is {}", endpoint.name(), endpoint.method()),
195
            )
196
            .into_response();
e02bf0cd Andy McCall ↶ 197
        }
198
        if let Err(f) = self.backend.authorize(&name, endpoint.access(), headers) {
199
            return f.into_response();
200
        }
75167672 Andy McCall 201
        let claimed = headers
202
            .get(oxy_core::protocol::REPO_ID_HEADER)
203
            .and_then(|v| v.to_str().ok())
204
            .and_then(hex_id);
e02bf0cd Andy McCall ↶ 205
        let server = Arc::clone(self);
76f75250 Andy McCall ↶ 206
        let request_headers = headers.clone();
be0161a8 Andy McCall ↶ 207
        let result = tokio::task::spawn_blocking(move || {
208
            server.respond(&name, endpoint, body, claimed, &request_headers)
209
        })
210
        .await;
e02bf0cd Andy McCall ↶ 211
        match result {
212
            Ok(Ok(r)) => r,
213
            Ok(Err(f)) => f.into_response(),
214
            Err(e) => Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
215
        }
216
    }
217
05e71ed6 Andy McCall ↶ 218
    fn respond(
219
        &self,
220
        name: &str,
221
        endpoint: Endpoint,
222
        body: Bytes,
223
        claimed: Option<[u8; 16]>,
76f75250 Andy McCall ↶ 224
        headers: &HeaderMap,
05e71ed6 Andy McCall ↶ 225
    ) -> Result<Response, Failure> {
e02bf0cd Andy McCall ↶ 226
        let r = self.backend.open(name)?;
227
        Ok(match endpoint {
228
            Endpoint::Capabilities => ok(wire::encode_capabilities(r.id()?)),
229
            Endpoint::Refs => ok(wire::encode_refs(r.id()?, &r.remote_refs()?)),
230
            Endpoint::Negotiate => {
231
                let (have, want) = wire::decode_negotiate(&body)?;
232
                ok(wire::encode_hashes(&r.negotiate(&have, &want)?))
233
            }
234
            Endpoint::Fetch => {
235
                let (changes, max_blob) = wire::decode_fetch(&body)?;
75167672 Andy McCall 236
                ok(wire::encode_bundle(
237
                    &r.bundle_for_filtered(&changes, max_blob)?,
238
                ))
e02bf0cd Andy McCall ↶ 239
            }
240
            Endpoint::Objects => {
241
                let refs = wire::decode_hashes(&body)?;
75167672 Andy McCall 242
                let bundle = oxy_core::protocol::Bundle {
243
                    objects: r.objects_for(&refs)?,
244
                    ..Default::default()
245
                };
e02bf0cd Andy McCall ↶ 246
                ok(wire::encode_bundle(&bundle))
247
            }
248
            Endpoint::Checkpoint => {
249
                let heads = wire::decode_hashes(&body)?;
250
                let lock = self.lock_for(name);
251
                let _guard = lock.lock().unwrap();
252
                ok(wire::encode_bundle(&r.checkpoint_bundle_for(&heads)?))
253
            }
254
            Endpoint::Shallow => {
255
                let (heads, depth) = wire::decode_shallow(&body)?;
256
                ok(wire::encode_hashes(&r.shallow_changes(&heads, depth)?))
257
            }
258
            Endpoint::Push => {
259
                let (bundle, updates, policy, tags) = wire::decode_push(&body)?;
260
                let lock = self.lock_for(name);
261
                let _guard = lock.lock().unwrap();
75167672 Andy McCall 262
                if r.is_unbound() {
263
                    // The first push binds the repository to the pusher's id (spec §16.1).
264
                    match claimed {
265
                        Some(id) => r.bind_id(id)?,
266
                        None => {
267
                            return Err(Failure(
268
                                StatusCode::BAD_REQUEST,
05e71ed6 Andy McCall ↶ 269
                                format!(
270
                                    "this repository is unbound; push with a client that sends {}",
271
                                    oxy_core::protocol::REPO_ID_HEADER
272
                                ),
75167672 Andy McCall 273
                            ))
274
                        }
275
                    }
276
                }
7aa2dc93 Andy McCall ↶ 277
                let report = r.receive_push_report(bundle, updates, policy, tags)?;
76f75250 Andy McCall ↶ 278
                self.backend.after_push(name, &report, headers);
7aa2dc93 Andy McCall ↶ 279
                ok(wire::encode_push_response(&report.results))
e02bf0cd Andy McCall ↶ 280
            }
281
        })
282
    }
283
}
284
285
/// The backend of `oxy serve`: one repository per subdirectory of a root,
286
/// open reads, and pushes gated by a list of bearer tokens.
287
pub struct DirBackend {
288
    pub root: PathBuf,
289
    pub tokens: Vec<String>,
290
}
291
292
impl Backend for DirBackend {
750251b2 Andy McCall ↶ 293
    fn open(&self, name: &str) -> Result<Repo, Failure> {
294
        if !valid_repo_name(name) {
295
            return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
296
        }
e02bf0cd Andy McCall ↶ 297
        let dir = self.root.join(name);
750251b2 Andy McCall ↶ 298
        if !dir.join(".oxy").is_dir() {
299
            return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
300
        }
301
        Repo::open(&dir).map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
302
    }
303
e02bf0cd Andy McCall ↶ 304
    fn authorize(&self, _name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure> {
305
        if access == Access::Read || self.tokens.is_empty() {
750251b2 Andy McCall ↶ 306
            return Ok(());
307
        }
e02bf0cd Andy McCall ↶ 308
        match bearer_token(headers) {
309
            Some(t) if self.tokens.iter().any(|x| x == t) => Ok(()),
75167672 Andy McCall 310
            _ => Err(Failure(
311
                StatusCode::UNAUTHORIZED,
312
                "a valid bearer token is required to push".into(),
313
            )),
750251b2 Andy McCall ↶ 314
        }
315
    }
316
}
317
75167672 Andy McCall 318
fn hex_id(h: &str) -> Option<[u8; 16]> {
319
    let h = h.trim();
320
    if h.len() != 32 {
321
        return None;
322
    }
323
    let mut out = [0u8; 16];
324
    for (i, byte) in out.iter_mut().enumerate() {
325
        *byte = u8::from_str_radix(&h[2 * i..2 * i + 2], 16).ok()?;
326
    }
327
    Some(out)
328
}
329
e02bf0cd Andy McCall ↶ 330
/// The token of an `Authorization: Bearer` header, if any.
331
pub fn bearer_token(headers: &HeaderMap) -> Option<&str> {
75167672 Andy McCall 332
    headers
333
        .get(header::AUTHORIZATION)
334
        .and_then(|v| v.to_str().ok())
335
        .and_then(|v| v.strip_prefix("Bearer "))
336
        .map(str::trim)
b3c5a59a Andy McCall ↶ 337
}
338
75167672 Andy McCall 339
async fn dispatch(
340
    State(server): State<Arc<Server>>,
341
    Path((repo, endpoint)): Path<(String, String)>,
342
    method: Method,
343
    headers: HeaderMap,
344
    body: Bytes,
345
) -> Response {
e02bf0cd Andy McCall ↶ 346
    match Endpoint::parse(&endpoint) {
347
        Some(e) => server.handle(repo, e, &method, &headers, body).await,
348
        None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(),
349
    }
750251b2 Andy McCall ↶ 350
}
351
e02bf0cd Andy McCall ↶ 352
/// The router of `oxy serve`: `/{repo}/oxy/v1/{endpoint}` over a [`DirBackend`].
750251b2 Andy McCall ↶ 353
pub fn router(cfg: ServerConfig) -> Router {
75167672 Andy McCall 354
    let server = Server::new(DirBackend {
355
        root: cfg.root,
356
        tokens: cfg.tokens,
357
    });
358
    Router::new()
359
        .route("/{repo}/oxy/v1/{endpoint}", any(dispatch))
360
        .with_state(server)
750251b2 Andy McCall ↶ 361
}
362
2b5ee371 Andy McCall ↶ 363
/// Serve on an already-bound listener until the process ends; HTTPS when
364
/// `cfg.tls` is set.
750251b2 Andy McCall ↶ 365
pub async fn serve(listener: std::net::TcpListener, cfg: ServerConfig) -> std::io::Result<()> {
366
    listener.set_nonblocking(true)?;
2b5ee371 Andy McCall ↶ 367
    match cfg.tls.clone() {
368
        Some(tls) => {
369
            let _ = rustls::crypto::ring::default_provider().install_default();
75167672 Andy McCall 370
            let rustls_cfg =
371
                axum_server::tls_rustls::RustlsConfig::from_pem_file(&tls.cert, &tls.key)
372
                    .await
373
                    .map_err(|e| std::io::Error::other(format!("loading TLS files: {e}")))?;
374
            axum_server::from_tcp_rustls(listener, rustls_cfg)?
375
                .serve(router(cfg).into_make_service())
2b5ee371 Andy McCall ↶ 376
                .await
377
        }
378
        None => {
379
            let listener = tokio::net::TcpListener::from_std(listener)?;
380
            axum::serve(listener, router(cfg)).await
381
        }
382
    }
750251b2 Andy McCall ↶ 383
}
384
385
/// Build a runtime and serve; for the CLI and for tests on a background thread.
386
pub fn run_blocking(listener: std::net::TcpListener, cfg: ServerConfig) -> std::io::Result<()> {
75167672 Andy McCall 387
    let rt = tokio::runtime::Builder::new_multi_thread()
388
        .enable_all()
389
        .build()?;
750251b2 Andy McCall ↶ 390
    rt.block_on(serve(listener, cfg))
391
}