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