OXYGENHUB
# 78b49d4d01f0 oxygen / crates / oxy-server / src / lib.rs
20.2 KiB · Rust · last changed oxy-server: a host can add a note to a reference a push moved (closes #148) 9 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
use axum::body::Bytes;
15
use axum::extract::{Path, State};
16
use axum::http::{header, HeaderMap, Method, StatusCode};
17
use axum::response::{IntoResponse, Response};
18
use axum::routing::any;
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>,
32
    /// Serve HTTPS with this certificate chain and private key (PEM files).
33
    pub tls: Option<TlsFiles>,
34
}
35
36
#[derive(Clone, Debug)]
37
pub struct TlsFiles {
38
    pub cert: PathBuf,
39
    pub key: PathBuf,
40
}
41
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), the states each accepted reference moved
64
    /// between, and the changes stored, for logs and cache invalidation.
65
    fn after_push(&self, _name: &str, _report: &oxy_core::repo::PushReport, _headers: &HeaderMap) {}
66
67
    /// Notes for the pusher on references this push moved: each
68
    /// `(reference, note)` is added to that accepted reference's result,
69
    /// which a client shows beside it (`Pushed origin/x - note`). For a
70
    /// push that did what the pusher may not expect, say moving the
71
    /// bookmark of a proposal that has landed (oxygenhub#329, #148). Called
72
    /// before [`Backend::after_push`], which sees the notes.
73
    fn push_notes(
74
        &self,
75
        _name: &str,
76
        _report: &oxy_core::repo::PushReport,
77
        _headers: &HeaderMap,
78
    ) -> Vec<(String, String)> {
79
        Vec::new()
80
    }
81
82
    /// Vet a push by what it adds before anything of it is stored or any
83
    /// reference moves (spec §16.1): `bundle` is the push as decoded, its
84
    /// changes with their lines, `updates` the references it would move.
85
    /// A host refuses the whole push by returning a [`Failure`], whose
86
    /// text reaches the client as the push's error, as it is, several
87
    /// lines included; it should answer neither 401, 403 nor 404, which a
88
    /// client takes for a credential it should change. Called for every
89
    /// part of a push sent in parts (each part's changes are its own),
90
    /// outside the repository's push lock, so a slow vet holds up no
91
    /// other push. The default accepts everything at no cost.
92
    fn vet_push(
93
        &self,
94
        _name: &str,
95
        _repo: &Repo,
96
        _bundle: &oxy_core::protocol::Bundle,
97
        _updates: &[oxy_core::protocol::RefUpdate],
98
        _headers: &HeaderMap,
99
    ) -> Result<(), Failure> {
100
        Ok(())
101
    }
102
103
    /// Add the host's transparency log to a refs answer (spec §14.8): its
104
    /// checkpoint, and each attestation's entry and inclusion proof. A host
105
    /// without a log leaves it as it is.
106
    fn add_log(&self, _name: &str, _refs: &mut oxy_core::protocol::RefsResponse) {}
107
108
    /// Tailor a refs answer to the client asking, by its request headers
109
    /// (its `User-Agent` and the features it declares in `Oxy-Features`),
110
    /// after [`Backend::add_log`]. The default leaves out the tags a client
111
    /// that does not declare `certified-tags` would refuse
112
    /// ([`Repo::refs_for_client`], oxygen#131); a host that overrides it
113
    /// should apply that too ([`tailor_refs`]).
114
    fn adjust_refs(
115
        &self,
116
        _name: &str,
117
        repo: &Repo,
118
        refs: &mut oxy_core::protocol::RefsResponse,
119
        headers: &HeaderMap,
120
    ) {
121
        tailor_refs(repo, refs, headers);
122
    }
123
124
    /// The bookmark of `name` that pushes may never delete (its default),
125
    /// when the host keeps one apart from the repository's own config.
126
    fn default_bookmark(&self, _name: &str) -> Option<String> {
127
        None
128
    }
129
}
130
131
/// A protocol server over any [`Backend`], serialising the pushes to each
132
/// repository. Hosts route requests to [`Server::handle`].
133
pub struct Server {
134
    backend: Box<dyn Backend>,
135
    locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
136
}
137
138
/// An error response: a status and a plain-text body.
139
#[derive(Debug)]
140
pub struct Failure(pub StatusCode, pub String);
141
142
impl IntoResponse for Failure {
143
    fn into_response(self) -> Response {
144
        (self.0, self.1).into_response()
145
    }
146
}
147
148
impl From<oxy_core::repo::RepoError> for Failure {
149
    fn from(e: oxy_core::repo::RepoError) -> Failure {
150
        use oxy_core::repo::RepoError;
151
        // The server's own storage failing is not the client's fault.
152
        let status = match &e {
153
            RepoError::Io(_) | RepoError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
154
            _ => StatusCode::BAD_REQUEST,
155
        };
156
        Failure(status, e.to_string())
157
    }
158
}
159
160
/// The wire protocol's endpoints (spec §16.1), named as in the URL.
161
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
162
pub enum Endpoint {
163
    Capabilities,
164
    Refs,
165
    Negotiate,
166
    Fetch,
167
    Objects,
168
    Checkpoint,
169
    CheckpointIndex,
170
    Shallow,
171
    Push,
172
}
173
174
impl Endpoint {
175
    pub fn parse(name: &str) -> Option<Endpoint> {
176
        Some(match name {
177
            "capabilities" => Endpoint::Capabilities,
178
            "refs" => Endpoint::Refs,
179
            "negotiate" => Endpoint::Negotiate,
180
            "fetch" => Endpoint::Fetch,
181
            "objects" => Endpoint::Objects,
182
            "checkpoint" => Endpoint::Checkpoint,
183
            "checkpoint-index" => Endpoint::CheckpointIndex,
184
            "shallow" => Endpoint::Shallow,
185
            "push" => Endpoint::Push,
186
            _ => return None,
187
        })
188
    }
189
190
    pub fn name(self) -> &'static str {
191
        match self {
192
            Endpoint::Capabilities => "capabilities",
193
            Endpoint::Refs => "refs",
194
            Endpoint::Negotiate => "negotiate",
195
            Endpoint::Fetch => "fetch",
196
            Endpoint::Objects => "objects",
197
            Endpoint::Checkpoint => "checkpoint",
198
            Endpoint::CheckpointIndex => "checkpoint-index",
199
            Endpoint::Shallow => "shallow",
200
            Endpoint::Push => "push",
201
        }
202
    }
203
204
    /// The HTTP method the endpoint answers to.
205
    pub fn method(self) -> Method {
206
        match self {
207
            Endpoint::Capabilities | Endpoint::Refs => Method::GET,
208
            _ => Method::POST,
209
        }
210
    }
211
212
    fn access(self) -> Access {
213
        match self {
214
            Endpoint::Push => Access::Push,
215
            _ => Access::Read,
216
        }
217
    }
218
}
219
220
fn ok(body: Vec<u8>) -> Response {
221
    ([(header::CONTENT_TYPE, CONTENT_TYPE)], body).into_response()
222
}
223
224
pub fn valid_repo_name(name: &str) -> bool {
225
    !name.is_empty()
226
        && !name.starts_with('.')
227
        && name
228
            .chars()
229
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
230
}
231
232
impl Server {
233
    pub fn new(backend: impl Backend) -> Arc<Server> {
234
        Arc::new(Server {
235
            backend: Box::new(backend),
236
            locks: Mutex::new(HashMap::new()),
237
        })
238
    }
239
240
    pub fn backend(&self) -> &dyn Backend {
241
        self.backend.as_ref()
242
    }
243
244
    fn lock_for(&self, name: &str) -> Arc<Mutex<()>> {
245
        self.locks
246
            .lock()
247
            .unwrap()
248
            .entry(name.to_string())
249
            .or_default()
250
            .clone()
251
    }
252
253
    /// Answer one protocol request for repository `name`. `method` is
254
    /// checked against the endpoint; the body is the raw CBOR request.
255
    pub async fn handle(
256
        self: &Arc<Self>,
257
        name: String,
258
        endpoint: Endpoint,
259
        method: &Method,
260
        headers: &HeaderMap,
261
        body: Bytes,
262
    ) -> Response {
263
        if *method != endpoint.method() {
264
            return Failure(
265
                StatusCode::METHOD_NOT_ALLOWED,
266
                format!("{} is {}", endpoint.name(), endpoint.method()),
267
            )
268
            .into_response();
269
        }
270
        if let Err(f) = self.backend.authorize(&name, endpoint.access(), headers) {
271
            return f.into_response();
272
        }
273
        let claimed = headers
274
            .get(oxy_core::protocol::REPO_ID_HEADER)
275
            .and_then(|v| v.to_str().ok())
276
            .and_then(hex_id);
277
        let server = Arc::clone(self);
278
        let request_headers = headers.clone();
279
        let repo = name.clone();
280
        let result = tokio::task::spawn_blocking(move || {
281
            server.respond(&repo, endpoint, body, claimed, &request_headers)
282
        })
283
        .await;
284
        let failure = match result {
285
            Ok(Ok(r)) => return r,
286
            Ok(Err(f)) => f,
287
            Err(e) => Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
288
        };
289
        // Name the operation and repository, in the log and for the client.
290
        let f = Failure(
291
            failure.0,
292
            format!("{} of {name}: {}", endpoint.name(), failure.1),
293
        );
294
        eprintln!("oxy-server: {}: {}", f.0.as_u16(), f.1);
295
        f.into_response()
296
    }
297
298
    fn respond(
299
        &self,
300
        name: &str,
301
        endpoint: Endpoint,
302
        body: Bytes,
303
        claimed: Option<[u8; 16]>,
304
        headers: &HeaderMap,
305
    ) -> Result<Response, Failure> {
306
        let r = self.backend.open(name)?;
307
        Ok(match endpoint {
308
            Endpoint::Capabilities => ok(wire::encode_capabilities(r.id()?)),
309
            Endpoint::Refs => {
310
                let mut refs = r.remote_refs()?;
311
                if let Some(d) = self.backend.default_bookmark(name) {
312
                    refs.default_bookmark = Some(d);
313
                }
314
                self.backend.add_log(name, &mut refs);
315
                self.backend.adjust_refs(name, &r, &mut refs, headers);
316
                ok(wire::encode_refs(r.id()?, &refs))
317
            }
318
            Endpoint::Negotiate => {
319
                let (have, want) = wire::decode_negotiate(&body)?;
320
                ok(wire::encode_hashes(&r.negotiate(&have, &want)?))
321
            }
322
            Endpoint::Fetch => {
323
                let (changes, max_blob) = wire::decode_fetch(&body)?;
324
                ok(wire::encode_bundle(
325
                    &r.bundle_for_filtered(&changes, max_blob)?,
326
                ))
327
            }
328
            Endpoint::Objects => {
329
                let refs = wire::decode_hashes(&body)?;
330
                let bundle = oxy_core::protocol::Bundle {
331
                    objects: r.objects_for(&refs)?,
332
                    ..Default::default()
333
                };
334
                ok(wire::encode_bundle(&bundle))
335
            }
336
            Endpoint::Checkpoint => {
337
                let heads = wire::decode_hashes(&body)?;
338
                let lock = self.lock_for(name);
339
                let _guard = lock.lock().unwrap();
340
                let bundle = r.checkpoint_bundle_for(&heads)?;
341
                let _t = oxy_core::timing::phase("serve: encode the checkpoint");
342
                ok(wire::encode_bundle(&bundle))
343
            }
344
            Endpoint::CheckpointIndex => {
345
                let heads = wire::decode_hashes(&body)?;
346
                let lock = self.lock_for(name);
347
                let _guard = lock.lock().unwrap();
348
                ok(wire::encode_bundle(&r.checkpoint_index_for(&heads)?))
349
            }
350
            Endpoint::Shallow => {
351
                let (heads, depth) = wire::decode_shallow(&body)?;
352
                ok(wire::encode_hashes(&r.shallow_changes(&heads, depth)?))
353
            }
354
            Endpoint::Push => {
355
                let (bundle, updates, policy, tags) = wire::decode_push(&body)?;
356
                // The host vets what the push adds before anything is stored
357
                // or bound: a refused push leaves the repository as it was.
358
                self.backend
359
                    .vet_push(name, &r, &bundle, &updates, headers)?;
360
                let lock = self.lock_for(name);
361
                let _guard = lock.lock().unwrap();
362
                if r.is_unbound() {
363
                    // The first push binds the repository to the pusher's id (spec §16.1).
364
                    match claimed {
365
                        Some(id) => r.bind_id(id)?,
366
                        None => {
367
                            return Err(Failure(
368
                                StatusCode::BAD_REQUEST,
369
                                format!(
370
                                    "this repository is unbound; push with a client that sends {}",
371
                                    oxy_core::protocol::REPO_ID_HEADER
372
                                ),
373
                            ))
374
                        }
375
                    }
376
                }
377
                let protected = self.backend.default_bookmark(name);
378
                let (refused, updates): (Vec<_>, Vec<_>) = updates.into_iter().partition(|u| {
379
                    u.new_state.is_zero() && protected.as_deref() == Some(u.name.as_str())
380
                });
381
                let mut report = r.receive_push_report(bundle, updates, policy, tags)?;
382
                // The request named references, all refused here: it was no
383
                // push of objects alone, whatever the repository saw.
384
                if !refused.is_empty() {
385
                    report
386
                        .results
387
                        .retain(|r| r.name != oxy_core::repo::OBJECTS_RESULT);
388
                }
389
                for u in refused {
390
                    report.results.push(oxy_core::protocol::RefResult {
391
                        name: u.name.clone(),
392
                        ok: false,
393
                        reason: format!(
394
                            "{:?} is this repository's default bookmark and cannot be deleted",
395
                            u.name
396
                        ),
397
                    });
398
                }
399
                for r in report.results.iter().filter(|r| !r.ok) {
400
                    eprintln!(
401
                        "oxy-server: push to {name}: {} refused: {}",
402
                        r.name, r.reason
403
                    );
404
                }
405
                for (reference, note) in self.backend.push_notes(name, &report, headers) {
406
                    if let Some(r) = report
407
                        .results
408
                        .iter_mut()
409
                        .find(|r| r.ok && r.name == reference)
410
                    {
411
                        r.reason = if r.reason.is_empty() {
412
                            note
413
                        } else {
414
                            format!("{}; {note}", r.reason)
415
                        };
416
                    }
417
                }
418
                self.backend.after_push(name, &report, headers);
419
                ok(wire::encode_push_response(&report.results))
420
            }
421
        })
422
    }
423
}
424
425
/// The backend of `oxy serve`: one repository per subdirectory of a root,
426
/// open reads, and pushes gated by a list of bearer tokens.
427
pub struct DirBackend {
428
    pub root: PathBuf,
429
    pub tokens: Vec<String>,
430
}
431
432
impl Backend for DirBackend {
433
    fn open(&self, name: &str) -> Result<Repo, Failure> {
434
        if !valid_repo_name(name) {
435
            return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
436
        }
437
        let dir = self.root.join(name);
438
        if !dir.join(".oxy").is_dir() {
439
            return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
440
        }
441
        Repo::open(&dir).map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
442
    }
443
444
    fn default_bookmark(&self, name: &str) -> Option<String> {
445
        self.open(name).ok().map(|r| r.config.bookmark.clone())
446
    }
447
448
    fn authorize(&self, _name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure> {
449
        if access == Access::Read || self.tokens.is_empty() {
450
            return Ok(());
451
        }
452
        match bearer_token(headers) {
453
            Some(t) if self.tokens.iter().any(|x| x == t) => Ok(()),
454
            _ => Err(Failure(
455
                StatusCode::UNAUTHORIZED,
456
                "a valid bearer token is required to push".into(),
457
            )),
458
        }
459
    }
460
}
461
462
fn hex_id(h: &str) -> Option<[u8; 16]> {
463
    let h = h.trim();
464
    if h.len() != 32 {
465
        return None;
466
    }
467
    let mut out = [0u8; 16];
468
    for (i, byte) in out.iter_mut().enumerate() {
469
        *byte = u8::from_str_radix(&h[2 * i..2 * i + 2], 16).ok()?;
470
    }
471
    Some(out)
472
}
473
474
/// The features a request declares in its `Oxy-Features` header, as the
475
/// header's text; `None` when it sent none (a client of the 0.5.0 era).
476
pub fn client_features(headers: &HeaderMap) -> Option<&str> {
477
    headers
478
        .get(oxy_core::protocol::FEATURES_HEADER)
479
        .and_then(|v| v.to_str().ok())
480
}
481
482
/// Tailor `refs` of `repo` to the client whose request carried `headers`:
483
/// the shared rule of [`Repo::refs_for_client`], which every host applies.
484
pub fn tailor_refs(repo: &Repo, refs: &mut oxy_core::protocol::RefsResponse, headers: &HeaderMap) {
485
    repo.refs_for_client(refs, client_features(headers));
486
}
487
488
/// The token of an `Authorization: Bearer` header, if any.
489
pub fn bearer_token(headers: &HeaderMap) -> Option<&str> {
490
    headers
491
        .get(header::AUTHORIZATION)
492
        .and_then(|v| v.to_str().ok())
493
        .and_then(|v| v.strip_prefix("Bearer "))
494
        .map(str::trim)
495
}
496
497
async fn dispatch(
498
    State(server): State<Arc<Server>>,
499
    Path((repo, endpoint)): Path<(String, String)>,
500
    method: Method,
501
    headers: HeaderMap,
502
    body: Bytes,
503
) -> Response {
504
    match Endpoint::parse(&endpoint) {
505
        Some(e) => server.handle(repo, e, &method, &headers, body).await,
506
        None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(),
507
    }
508
}
509
510
/// The router of `oxy serve`: `/{repo}/oxy/v1/{endpoint}` over a [`DirBackend`].
511
pub fn router(cfg: ServerConfig) -> Router {
512
    let server = Server::new(DirBackend {
513
        root: cfg.root,
514
        tokens: cfg.tokens,
515
    });
516
    Router::new()
517
        .route("/{repo}/oxy/v1/{endpoint}", any(dispatch))
518
        .with_state(server)
519
}
520
521
/// Serve on an already-bound listener until the process ends; HTTPS when
522
/// `cfg.tls` is set.
523
pub async fn serve(listener: std::net::TcpListener, cfg: ServerConfig) -> std::io::Result<()> {
524
    listener.set_nonblocking(true)?;
525
    match cfg.tls.clone() {
526
        Some(tls) => {
527
            let _ = rustls::crypto::ring::default_provider().install_default();
528
            let rustls_cfg =
529
                axum_server::tls_rustls::RustlsConfig::from_pem_file(&tls.cert, &tls.key)
530
                    .await
531
                    .map_err(|e| std::io::Error::other(format!("loading TLS files: {e}")))?;
532
            axum_server::from_tcp_rustls(listener, rustls_cfg)?
533
                .serve(router(cfg).into_make_service())
534
                .await
535
        }
536
        None => {
537
            let listener = tokio::net::TcpListener::from_std(listener)?;
538
            axum::serve(listener, router(cfg)).await
539
        }
540
    }
541
}
542
543
/// Build a runtime and serve; for the CLI and for tests on a background thread.
544
pub fn run_blocking(listener: std::net::TcpListener, cfg: ServerConfig) -> std::io::Result<()> {
545
    let rt = tokio::runtime::Builder::new_multi_thread()
546
        .enable_all()
547
        .build()?;
548
    rt.block_on(serve(listener, cfg))
549
}