OXYGENHUB
◆ carried-not-removed oxygen / crates / oxy-server / src / lib.rs
16.0 KiB · Rust · last changed oxy witness serve: a transparency-log witness anyone can run 18 days ago source blame history raw
1
//! Reference HTTP server for the Oxygen wire protocol (spec §15.1, §16).
2
//!
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).
6
//!
7
//! Authentication: when bearer tokens are configured, `push` requires one;
8
//! reads are open. With no tokens configured everything is open (development).
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`].
13
14
pub mod witness;
15
16
use axum::body::Bytes;
17
use axum::extract::{Path, State};
18
use axum::http::{header, HeaderMap, Method, StatusCode};
19
use axum::response::{IntoResponse, Response};
20
use axum::routing::any;
21
use axum::Router;
22
use oxy_core::protocol::{wire, CONTENT_TYPE};
23
use oxy_core::repo::Repo;
24
use std::collections::HashMap;
25
use std::path::PathBuf;
26
use std::sync::{Arc, Mutex};
27
28
#[derive(Clone, Debug)]
29
pub struct ServerConfig {
30
    /// Directory containing one repository per subdirectory.
31
    pub root: PathBuf,
32
    /// Bearer tokens allowed to push; empty means no authentication.
33
    pub tokens: Vec<String>,
34
    /// Serve HTTPS with this certificate chain and private key (PEM files).
35
    pub tls: Option<TlsFiles>,
36
}
37
38
#[derive(Clone, Debug)]
39
pub struct TlsFiles {
40
    pub cert: PathBuf,
41
    pub key: PathBuf,
42
}
43
44
/// Which kind of access a request needs (spec §15.1: reads may be open;
45
/// a push must be authenticated when any credential is configured).
46
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47
pub enum Access {
48
    Read,
49
    Push,
50
}
51
52
/// What a host provides to the protocol server: how a repository name maps
53
/// to a repository on disk and who may do what. `oxy serve` uses
54
/// [`DirBackend`]; a hub supplies its own with accounts and roles.
55
pub trait Backend: Send + Sync + 'static {
56
    /// Open the repository called `name`, or fail with the status to
57
    /// answer. An unknown name is `404`; a name the caller may not see
58
    /// SHOULD also be `404`, so private repositories are not enumerable.
59
    fn open(&self, name: &str) -> Result<Repo, Failure>;
60
61
    /// Decide whether this request may perform `access` on `name`.
62
    fn authorize(&self, name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure>;
63
64
    /// Called after a push was processed, with its per-reference results
65
    /// (accepted or refused), the states each accepted reference moved
66
    /// between, and the changes stored, for logs and cache invalidation.
67
    fn after_push(&self, _name: &str, _report: &oxy_core::repo::PushReport, _headers: &HeaderMap) {}
68
69
    /// Add the host's transparency log to a refs answer (spec §14.8): its
70
    /// checkpoint, and each attestation's entry and inclusion proof. A host
71
    /// without a log leaves it as it is.
72
    fn add_log(&self, _name: &str, _refs: &mut oxy_core::protocol::RefsResponse) {}
73
74
    /// The bookmark of `name` that pushes may never delete (its default),
75
    /// when the host keeps one apart from the repository's own config.
76
    fn default_bookmark(&self, _name: &str) -> Option<String> {
77
        None
78
    }
79
}
80
81
/// A protocol server over any [`Backend`], serialising the pushes to each
82
/// repository. Hosts route requests to [`Server::handle`].
83
pub struct Server {
84
    backend: Box<dyn Backend>,
85
    locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
86
}
87
88
/// An error response: a status and a plain-text body.
89
#[derive(Debug)]
90
pub struct Failure(pub StatusCode, pub String);
91
92
impl IntoResponse for Failure {
93
    fn into_response(self) -> Response {
94
        (self.0, self.1).into_response()
95
    }
96
}
97
98
impl From<oxy_core::repo::RepoError> for Failure {
99
    fn from(e: oxy_core::repo::RepoError) -> Failure {
100
        use oxy_core::repo::RepoError;
101
        // The server's own storage failing is not the client's fault.
102
        let status = match &e {
103
            RepoError::Io(_) | RepoError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
104
            _ => StatusCode::BAD_REQUEST,
105
        };
106
        Failure(status, e.to_string())
107
    }
108
}
109
110
/// The wire protocol's endpoints (spec §16.1), named as in the URL.
111
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
112
pub enum Endpoint {
113
    Capabilities,
114
    Refs,
115
    Negotiate,
116
    Fetch,
117
    Objects,
118
    Checkpoint,
119
    Shallow,
120
    Push,
121
}
122
123
impl Endpoint {
124
    pub fn parse(name: &str) -> Option<Endpoint> {
125
        Some(match name {
126
            "capabilities" => Endpoint::Capabilities,
127
            "refs" => Endpoint::Refs,
128
            "negotiate" => Endpoint::Negotiate,
129
            "fetch" => Endpoint::Fetch,
130
            "objects" => Endpoint::Objects,
131
            "checkpoint" => Endpoint::Checkpoint,
132
            "shallow" => Endpoint::Shallow,
133
            "push" => Endpoint::Push,
134
            _ => return None,
135
        })
136
    }
137
138
    pub fn name(self) -> &'static str {
139
        match self {
140
            Endpoint::Capabilities => "capabilities",
141
            Endpoint::Refs => "refs",
142
            Endpoint::Negotiate => "negotiate",
143
            Endpoint::Fetch => "fetch",
144
            Endpoint::Objects => "objects",
145
            Endpoint::Checkpoint => "checkpoint",
146
            Endpoint::Shallow => "shallow",
147
            Endpoint::Push => "push",
148
        }
149
    }
150
151
    /// The HTTP method the endpoint answers to.
152
    pub fn method(self) -> Method {
153
        match self {
154
            Endpoint::Capabilities | Endpoint::Refs => Method::GET,
155
            _ => Method::POST,
156
        }
157
    }
158
159
    fn access(self) -> Access {
160
        match self {
161
            Endpoint::Push => Access::Push,
162
            _ => Access::Read,
163
        }
164
    }
165
}
166
167
fn ok(body: Vec<u8>) -> Response {
168
    ([(header::CONTENT_TYPE, CONTENT_TYPE)], body).into_response()
169
}
170
171
pub fn valid_repo_name(name: &str) -> bool {
172
    !name.is_empty()
173
        && !name.starts_with('.')
174
        && name
175
            .chars()
176
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
177
}
178
179
impl Server {
180
    pub fn new(backend: impl Backend) -> Arc<Server> {
181
        Arc::new(Server {
182
            backend: Box::new(backend),
183
            locks: Mutex::new(HashMap::new()),
184
        })
185
    }
186
187
    pub fn backend(&self) -> &dyn Backend {
188
        self.backend.as_ref()
189
    }
190
191
    fn lock_for(&self, name: &str) -> Arc<Mutex<()>> {
192
        self.locks
193
            .lock()
194
            .unwrap()
195
            .entry(name.to_string())
196
            .or_default()
197
            .clone()
198
    }
199
200
    /// Answer one protocol request for repository `name`. `method` is
201
    /// checked against the endpoint; the body is the raw CBOR request.
202
    pub async fn handle(
203
        self: &Arc<Self>,
204
        name: String,
205
        endpoint: Endpoint,
206
        method: &Method,
207
        headers: &HeaderMap,
208
        body: Bytes,
209
    ) -> Response {
210
        if *method != endpoint.method() {
211
            return Failure(
212
                StatusCode::METHOD_NOT_ALLOWED,
213
                format!("{} is {}", endpoint.name(), endpoint.method()),
214
            )
215
            .into_response();
216
        }
217
        if let Err(f) = self.backend.authorize(&name, endpoint.access(), headers) {
218
            return f.into_response();
219
        }
220
        let claimed = headers
221
            .get(oxy_core::protocol::REPO_ID_HEADER)
222
            .and_then(|v| v.to_str().ok())
223
            .and_then(hex_id);
224
        let server = Arc::clone(self);
225
        let request_headers = headers.clone();
226
        let repo = name.clone();
227
        let result = tokio::task::spawn_blocking(move || {
228
            server.respond(&repo, endpoint, body, claimed, &request_headers)
229
        })
230
        .await;
231
        let failure = match result {
232
            Ok(Ok(r)) => return r,
233
            Ok(Err(f)) => f,
234
            Err(e) => Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
235
        };
236
        // Name the operation and repository, in the log and for the client.
237
        let f = Failure(
238
            failure.0,
239
            format!("{} of {name}: {}", endpoint.name(), failure.1),
240
        );
241
        eprintln!("oxy-server: {}: {}", f.0.as_u16(), f.1);
242
        f.into_response()
243
    }
244
245
    fn respond(
246
        &self,
247
        name: &str,
248
        endpoint: Endpoint,
249
        body: Bytes,
250
        claimed: Option<[u8; 16]>,
251
        headers: &HeaderMap,
252
    ) -> Result<Response, Failure> {
253
        let r = self.backend.open(name)?;
254
        Ok(match endpoint {
255
            Endpoint::Capabilities => ok(wire::encode_capabilities(r.id()?)),
256
            Endpoint::Refs => {
257
                let mut refs = r.remote_refs()?;
258
                if let Some(d) = self.backend.default_bookmark(name) {
259
                    refs.default_bookmark = Some(d);
260
                }
261
                self.backend.add_log(name, &mut refs);
262
                ok(wire::encode_refs(r.id()?, &refs))
263
            }
264
            Endpoint::Negotiate => {
265
                let (have, want) = wire::decode_negotiate(&body)?;
266
                ok(wire::encode_hashes(&r.negotiate(&have, &want)?))
267
            }
268
            Endpoint::Fetch => {
269
                let (changes, max_blob) = wire::decode_fetch(&body)?;
270
                ok(wire::encode_bundle(
271
                    &r.bundle_for_filtered(&changes, max_blob)?,
272
                ))
273
            }
274
            Endpoint::Objects => {
275
                let refs = wire::decode_hashes(&body)?;
276
                let bundle = oxy_core::protocol::Bundle {
277
                    objects: r.objects_for(&refs)?,
278
                    ..Default::default()
279
                };
280
                ok(wire::encode_bundle(&bundle))
281
            }
282
            Endpoint::Checkpoint => {
283
                let heads = wire::decode_hashes(&body)?;
284
                let lock = self.lock_for(name);
285
                let _guard = lock.lock().unwrap();
286
                ok(wire::encode_bundle(&r.checkpoint_bundle_for(&heads)?))
287
            }
288
            Endpoint::Shallow => {
289
                let (heads, depth) = wire::decode_shallow(&body)?;
290
                ok(wire::encode_hashes(&r.shallow_changes(&heads, depth)?))
291
            }
292
            Endpoint::Push => {
293
                let (bundle, updates, policy, tags) = wire::decode_push(&body)?;
294
                let lock = self.lock_for(name);
295
                let _guard = lock.lock().unwrap();
296
                if r.is_unbound() {
297
                    // The first push binds the repository to the pusher's id (spec §16.1).
298
                    match claimed {
299
                        Some(id) => r.bind_id(id)?,
300
                        None => {
301
                            return Err(Failure(
302
                                StatusCode::BAD_REQUEST,
303
                                format!(
304
                                    "this repository is unbound; push with a client that sends {}",
305
                                    oxy_core::protocol::REPO_ID_HEADER
306
                                ),
307
                            ))
308
                        }
309
                    }
310
                }
311
                let protected = self.backend.default_bookmark(name);
312
                let (refused, updates): (Vec<_>, Vec<_>) = updates.into_iter().partition(|u| {
313
                    u.new_state.is_zero() && protected.as_deref() == Some(u.name.as_str())
314
                });
315
                let mut report = r.receive_push_report(bundle, updates, policy, tags)?;
316
                // The request named references, all refused here: it was no
317
                // push of objects alone, whatever the repository saw.
318
                if !refused.is_empty() {
319
                    report
320
                        .results
321
                        .retain(|r| r.name != oxy_core::repo::OBJECTS_RESULT);
322
                }
323
                for u in refused {
324
                    report.results.push(oxy_core::protocol::RefResult {
325
                        name: u.name.clone(),
326
                        ok: false,
327
                        reason: format!(
328
                            "{:?} is this repository's default bookmark and cannot be deleted",
329
                            u.name
330
                        ),
331
                    });
332
                }
333
                for r in report.results.iter().filter(|r| !r.ok) {
334
                    eprintln!(
335
                        "oxy-server: push to {name}: {} refused: {}",
336
                        r.name, r.reason
337
                    );
338
                }
339
                self.backend.after_push(name, &report, headers);
340
                ok(wire::encode_push_response(&report.results))
341
            }
342
        })
343
    }
344
}
345
346
/// The backend of `oxy serve`: one repository per subdirectory of a root,
347
/// open reads, and pushes gated by a list of bearer tokens.
348
pub struct DirBackend {
349
    pub root: PathBuf,
350
    pub tokens: Vec<String>,
351
}
352
353
impl Backend for DirBackend {
354
    fn open(&self, name: &str) -> Result<Repo, Failure> {
355
        if !valid_repo_name(name) {
356
            return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
357
        }
358
        let dir = self.root.join(name);
359
        if !dir.join(".oxy").is_dir() {
360
            return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
361
        }
362
        Repo::open(&dir).map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
363
    }
364
365
    fn default_bookmark(&self, name: &str) -> Option<String> {
366
        self.open(name).ok().map(|r| r.config.bookmark.clone())
367
    }
368
369
    fn authorize(&self, _name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure> {
370
        if access == Access::Read || self.tokens.is_empty() {
371
            return Ok(());
372
        }
373
        match bearer_token(headers) {
374
            Some(t) if self.tokens.iter().any(|x| x == t) => Ok(()),
375
            _ => Err(Failure(
376
                StatusCode::UNAUTHORIZED,
377
                "a valid bearer token is required to push".into(),
378
            )),
379
        }
380
    }
381
}
382
383
fn hex_id(h: &str) -> Option<[u8; 16]> {
384
    let h = h.trim();
385
    if h.len() != 32 {
386
        return None;
387
    }
388
    let mut out = [0u8; 16];
389
    for (i, byte) in out.iter_mut().enumerate() {
390
        *byte = u8::from_str_radix(&h[2 * i..2 * i + 2], 16).ok()?;
391
    }
392
    Some(out)
393
}
394
395
/// The token of an `Authorization: Bearer` header, if any.
396
pub fn bearer_token(headers: &HeaderMap) -> Option<&str> {
397
    headers
398
        .get(header::AUTHORIZATION)
399
        .and_then(|v| v.to_str().ok())
400
        .and_then(|v| v.strip_prefix("Bearer "))
401
        .map(str::trim)
402
}
403
404
async fn dispatch(
405
    State(server): State<Arc<Server>>,
406
    Path((repo, endpoint)): Path<(String, String)>,
407
    method: Method,
408
    headers: HeaderMap,
409
    body: Bytes,
410
) -> Response {
411
    match Endpoint::parse(&endpoint) {
412
        Some(e) => server.handle(repo, e, &method, &headers, body).await,
413
        None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(),
414
    }
415
}
416
417
/// The router of `oxy serve`: `/{repo}/oxy/v1/{endpoint}` over a [`DirBackend`].
418
pub fn router(cfg: ServerConfig) -> Router {
419
    let server = Server::new(DirBackend {
420
        root: cfg.root,
421
        tokens: cfg.tokens,
422
    });
423
    Router::new()
424
        .route("/{repo}/oxy/v1/{endpoint}", any(dispatch))
425
        .with_state(server)
426
}
427
428
/// Serve on an already-bound listener until the process ends; HTTPS when
429
/// `cfg.tls` is set.
430
pub async fn serve(listener: std::net::TcpListener, cfg: ServerConfig) -> std::io::Result<()> {
431
    listener.set_nonblocking(true)?;
432
    match cfg.tls.clone() {
433
        Some(tls) => {
434
            let _ = rustls::crypto::ring::default_provider().install_default();
435
            let rustls_cfg =
436
                axum_server::tls_rustls::RustlsConfig::from_pem_file(&tls.cert, &tls.key)
437
                    .await
438
                    .map_err(|e| std::io::Error::other(format!("loading TLS files: {e}")))?;
439
            axum_server::from_tcp_rustls(listener, rustls_cfg)?
440
                .serve(router(cfg).into_make_service())
441
                .await
442
        }
443
        None => {
444
            let listener = tokio::net::TcpListener::from_std(listener)?;
445
            axum::serve(listener, router(cfg)).await
446
        }
447
    }
448
}
449
450
/// Build a runtime and serve; for the CLI and for tests on a background thread.
451
pub fn run_blocking(listener: std::net::TcpListener, cfg: ServerConfig) -> std::io::Result<()> {
452
    let rt = tokio::runtime::Builder::new_multi_thread()
453
        .enable_all()
454
        .build()?;
455
    rt.block_on(serve(listener, cfg))
456
}