OXYGENHUB
# 1f53d5b91eb5 oxygen / crates / oxy-server / src / lib.rs
6.5 KiB · Rust · last changed Trust policy: policy objects, init/grant/revoke, enforcement on push and fetch, propagation with downgrade protection (spec 0.6) 1 month 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
use axum::body::Bytes;
11
use axum::extract::{Path, State};
12
use axum::http::{header, HeaderMap, StatusCode};
13
use axum::response::{IntoResponse, Response};
14
use axum::routing::{get, post};
15
use axum::Router;
16
use oxy_core::protocol::{wire, CONTENT_TYPE};
17
use oxy_core::repo::Repo;
18
use std::collections::HashMap;
19
use std::path::PathBuf;
20
use std::sync::{Arc, Mutex};
21
22
#[derive(Clone, Debug)]
23
pub struct ServerConfig {
24
    /// Directory containing one repository per subdirectory.
25
    pub root: PathBuf,
26
    /// Bearer tokens allowed to push; empty means no authentication.
27
    pub tokens: Vec<String>,
28
    /// Serve HTTPS with this certificate chain and private key (PEM files).
29
    pub tls: Option<TlsFiles>,
30
}
31
32
#[derive(Clone, Debug)]
33
pub struct TlsFiles {
34
    pub cert: PathBuf,
35
    pub key: PathBuf,
36
}
37
38
struct App {
39
    cfg: ServerConfig,
40
    /// One lock per repository so concurrent pushes serialize.
41
    locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
42
}
43
44
type Shared = Arc<App>;
45
46
struct Failure(StatusCode, String);
47
48
impl IntoResponse for Failure {
49
    fn into_response(self) -> Response {
50
        (self.0, self.1).into_response()
51
    }
52
}
53
54
impl From<oxy_core::repo::RepoError> for Failure {
55
    fn from(e: oxy_core::repo::RepoError) -> Failure {
56
        Failure(StatusCode::BAD_REQUEST, e.to_string())
57
    }
58
}
59
60
fn ok(body: Vec<u8>) -> Response {
61
    ([(header::CONTENT_TYPE, CONTENT_TYPE)], body).into_response()
62
}
63
64
fn valid_repo_name(name: &str) -> bool {
65
    !name.is_empty()
66
        && !name.starts_with('.')
67
        && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
68
}
69
70
impl App {
71
    fn open(&self, name: &str) -> Result<Repo, Failure> {
72
        if !valid_repo_name(name) {
73
            return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
74
        }
75
        let dir = self.cfg.root.join(name);
76
        if !dir.join(".oxy").is_dir() {
77
            return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
78
        }
79
        Repo::open(&dir).map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
80
    }
81
82
    fn lock_for(&self, name: &str) -> Arc<Mutex<()>> {
83
        self.locks.lock().unwrap().entry(name.to_string()).or_default().clone()
84
    }
85
86
    fn authorize_push(&self, headers: &HeaderMap) -> Result<(), Failure> {
87
        if self.cfg.tokens.is_empty() {
88
            return Ok(());
89
        }
90
        let presented = headers
91
            .get(header::AUTHORIZATION)
92
            .and_then(|v| v.to_str().ok())
93
            .and_then(|v| v.strip_prefix("Bearer "))
94
            .map(str::trim);
95
        match presented {
96
            Some(t) if self.cfg.tokens.iter().any(|x| x == t) => Ok(()),
97
            _ => Err(Failure(StatusCode::UNAUTHORIZED, "a valid bearer token is required to push".into())),
98
        }
99
    }
100
}
101
102
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> Result<T, Failure> + Send + 'static) -> Result<T, Failure> {
103
    tokio::task::spawn_blocking(f).await.map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
104
}
105
106
async fn capabilities(State(app): State<Shared>, Path(repo): Path<String>) -> Result<Response, Failure> {
107
    blocking(move || {
108
        let r = app.open(&repo)?;
109
        Ok(ok(wire::encode_capabilities(r.id()?)))
110
    })
111
    .await
112
}
113
114
async fn refs(State(app): State<Shared>, Path(repo): Path<String>) -> Result<Response, Failure> {
115
    blocking(move || {
116
        let r = app.open(&repo)?;
117
        Ok(ok(wire::encode_refs(r.id()?, &r.remote_refs()?)))
118
    })
119
    .await
120
}
121
122
async fn negotiate(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> {
123
    blocking(move || {
124
        let r = app.open(&repo)?;
125
        let (have, want) = wire::decode_negotiate(&body)?;
126
        Ok(ok(wire::encode_hashes(&r.negotiate(&have, &want)?)))
127
    })
128
    .await
129
}
130
131
async fn fetch(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> {
132
    blocking(move || {
133
        let r = app.open(&repo)?;
134
        let changes = wire::decode_hashes(&body)?;
135
        Ok(ok(wire::encode_bundle(&r.bundle_for(&changes)?)))
136
    })
137
    .await
138
}
139
140
async fn push(State(app): State<Shared>, Path(repo): Path<String>, headers: HeaderMap, body: Bytes) -> Result<Response, Failure> {
141
    app.authorize_push(&headers)?;
142
    blocking(move || {
143
        let r = app.open(&repo)?;
144
        let (bundle, updates, policy) = wire::decode_push(&body)?;
145
        let lock = app.lock_for(&repo);
146
        let _guard = lock.lock().unwrap();
147
        Ok(ok(wire::encode_push_response(&r.receive_push(bundle, updates, policy)?)))
148
    })
149
    .await
150
}
151
152
pub fn router(cfg: ServerConfig) -> Router {
153
    let app: Shared = Arc::new(App { cfg, locks: Mutex::new(HashMap::new()) });
154
    Router::new()
155
        .route("/{repo}/oxy/v1/capabilities", get(capabilities))
156
        .route("/{repo}/oxy/v1/refs", get(refs))
157
        .route("/{repo}/oxy/v1/negotiate", post(negotiate))
158
        .route("/{repo}/oxy/v1/fetch", post(fetch))
159
        .route("/{repo}/oxy/v1/push", post(push))
160
        .with_state(app)
161
}
162
163
/// Serve on an already-bound listener until the process ends; HTTPS when
164
/// `cfg.tls` is set.
165
pub async fn serve(listener: std::net::TcpListener, cfg: ServerConfig) -> std::io::Result<()> {
166
    listener.set_nonblocking(true)?;
167
    match cfg.tls.clone() {
168
        Some(tls) => {
169
            let _ = rustls::crypto::ring::default_provider().install_default();
170
            let rustls_cfg = axum_server::tls_rustls::RustlsConfig::from_pem_file(&tls.cert, &tls.key)
171
                .await
172
                .map_err(|e| std::io::Error::other(format!("loading TLS files: {e}")))?;
173
            axum_server::from_tcp_rustls(listener, rustls_cfg)?.serve(router(cfg).into_make_service()).await
174
        }
175
        None => {
176
            let listener = tokio::net::TcpListener::from_std(listener)?;
177
            axum::serve(listener, router(cfg)).await
178
        }
179
    }
180
}
181
182
/// Build a runtime and serve; for the CLI and for tests on a background thread.
183
pub fn run_blocking(listener: std::net::TcpListener, cfg: ServerConfig) -> std::io::Result<()> {
184
    let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
185
    rt.block_on(serve(listener, cfg))
186
}