Skip to main content

bynk/registry/
serve.rs

1//! `bynk registry serve` and `bynk registry token` (packaging slice 4c, #2287,
2//! decisions A, D and E): a reference server that puts the publish API in
3//! front of a static registry directory.
4//!
5//! It serves the directory's files by `GET` and the API under `/api`:
6//!
7//! - `PUT /api/<org>/<package>/<version>` with a `bynk-tree-v1` archive body
8//!   admits the upload by the same rules as `bynk registry add`, signs its line
9//!   with the **server's** key, and answers `201` with the line (`200` with the
10//!   existing line when the same tree was already published, #2311);
11//! - `POST /api/<org>/<package>/<version>/yank` (or `/unyank`) flips `yanked`.
12//!
13//! A request carries `Authorization: Bearer <token>`; the tokens file holds
14//! only each token's sha256, the organisations it may act for, and what it
15//! `can` (`publish`, `yank`). Requests are handled one at a time, so writes to
16//! a registry are serialised. It speaks plain HTTP and binds loopback by
17//! default: put a TLS-terminating proxy in front to serve beyond this machine.
18
19use std::io::Read;
20use std::path::{Component, Path, PathBuf};
21
22use serde_json::json;
23use sha2::{Digest, Sha256};
24
25use super::commands::{
26    Refusal, admit, read_config, signing_key, write_private, write_replacing, yank_line,
27};
28use super::protocol::Config;
29
30/// The largest upload accepted.
31const MAX_UPLOAD: u64 = 64 * 1024 * 1024;
32
33/// The default address: loopback only.
34pub const DEFAULT_LISTEN: &str = "127.0.0.1:8730";
35
36/// One entry of the tokens file.
37#[derive(Debug, Clone, PartialEq, Eq)]
38struct TokenEntry {
39    sha256: String,
40    organisations: Vec<String>,
41    can: Vec<String>,
42}
43
44/// `bynk registry serve`: serve `dir` with the publish API, signing with
45/// `key_file` and authorising with `tokens_file`, on `listen`. `ready` is told
46/// the address actually bound, once listening. Runs until the process ends.
47pub fn serve(
48    dir: &Path,
49    key_file: &Path,
50    tokens_file: &Path,
51    listen: &str,
52    ready: impl FnOnce(&str),
53) -> Result<(), String> {
54    for (what, path) in [("signing key", key_file), ("tokens file", tokens_file)] {
55        if inside(path, dir) {
56            return Err(format!(
57                "the {what} `{}` is inside the registry directory, which is served — keep it elsewhere",
58                path.display()
59            ));
60        }
61    }
62    let mut config = read_config(dir)?;
63    let key = signing_key(key_file)?;
64    super::commands::current_key(&config, &key, key_file)?;
65    if config.api.is_none() {
66        config.api = Some("api".to_string());
67        write_replacing(&dir.join("config.json"), config.render().as_bytes())
68            .map_err(|e| format!("could not record the API in `config.json`: {e}"))?;
69    }
70    let tokens = read_tokens(tokens_file)?;
71    let server = tiny_http::Server::http(listen)
72        .map_err(|e| format!("could not listen on `{listen}`: {e}"))?;
73    let bound = server
74        .server_addr()
75        .to_ip()
76        .ok_or("the server isn't on an IP address")?;
77    if !bound.ip().is_loopback() {
78        eprintln!(
79            "bynk: warning: serving on {bound}, beyond this machine, over plain HTTP — tokens travel in clear unless a TLS-terminating proxy is in front"
80        );
81    }
82    ready(&format!("http://{bound}"));
83    for mut request in server.incoming_requests() {
84        let (status, body, kind) = handle(dir, &config, &key, &tokens, &mut request);
85        let response = tiny_http::Response::from_data(body)
86            .with_status_code(status)
87            .with_header(
88                tiny_http::Header::from_bytes(&b"Content-Type"[..], kind.as_bytes())
89                    .expect("a valid header"),
90            );
91        let _ = request.respond(response);
92    }
93    Ok(())
94}
95
96/// One request: `(status, body, content type)`.
97fn handle(
98    dir: &Path,
99    config: &Config,
100    key: &super::protocol::SigningKey,
101    tokens: &[TokenEntry],
102    request: &mut tiny_http::Request,
103) -> (u16, Vec<u8>, &'static str) {
104    let url = request
105        .url()
106        .split(['?', '#'])
107        .next()
108        .unwrap_or("/")
109        .to_string();
110    let method = request.method().clone();
111    if let Some(rest) = url.strip_prefix("/api/") {
112        let parts: Vec<&str> = rest.split('/').collect();
113        let error = |status: u16, code: &str, message: String| {
114            (
115                status,
116                json!({ "error": code, "message": message })
117                    .to_string()
118                    .into_bytes(),
119                "application/json",
120            )
121        };
122        let token = request
123            .headers()
124            .iter()
125            .find(|h| h.field.equiv("Authorization"))
126            .and_then(|h| h.value.as_str().strip_prefix("Bearer ").map(str::to_owned));
127        let authorise = |org: &str, cap: &str| -> Result<(), (u16, Vec<u8>, &'static str)> {
128            let Some(token) = &token else {
129                return Err(error(
130                    401,
131                    "unauthorised",
132                    "this registry needs a publish token".into(),
133                ));
134            };
135            let digest = hex(&Sha256::digest(token.as_bytes()));
136            let Some(entry) = tokens
137                .iter()
138                .find(|t| constant_time_eq(t.sha256.as_bytes(), digest.as_bytes()))
139            else {
140                return Err(error(
141                    401,
142                    "unauthorised",
143                    "the token isn't known to this registry".into(),
144                ));
145            };
146            if !entry.organisations.iter().any(|o| o == org) {
147                return Err(error(
148                    403,
149                    "forbidden",
150                    format!("the token can't act for the organisation `{org}`"),
151                ));
152            }
153            if !entry.can.iter().any(|c| c == cap) {
154                return Err(error(403, "forbidden", format!("the token can't `{cap}`")));
155            }
156            Ok(())
157        };
158        return match (method, parts.as_slice()) {
159            (tiny_http::Method::Put, [org, package, version]) => {
160                if let Err(e) = authorise(org, "publish") {
161                    return e;
162                }
163                if request.body_length().is_some_and(|n| n as u64 > MAX_UPLOAD) {
164                    return error(
165                        413,
166                        "too_large",
167                        format!("an upload is limited to {MAX_UPLOAD} bytes"),
168                    );
169                }
170                let mut upload = Vec::new();
171                if request
172                    .as_reader()
173                    .take(MAX_UPLOAD + 1)
174                    .read_to_end(&mut upload)
175                    .is_err()
176                {
177                    return error(400, "invalid_package", "the upload couldn't be read".into());
178                }
179                if upload.len() as u64 > MAX_UPLOAD {
180                    return error(
181                        413,
182                        "too_large",
183                        format!("an upload is limited to {MAX_UPLOAD} bytes"),
184                    );
185                }
186                let name = format!("{org}/{package}");
187                match admit(dir, config, key, &upload, Some((&name, version))) {
188                    // #2311 (D10): a retry of the same tree is `200` with
189                    // the existing line; a new line is `201`.
190                    Ok(a) => (
191                        if a.fresh { 201 } else { 200 },
192                        a.line.render().into_bytes(),
193                        "application/json",
194                    ),
195                    Err(r) => refusal(r),
196                }
197            }
198            (tiny_http::Method::Post, [org, package, version, verb @ ("yank" | "unyank")]) => {
199                if let Err(e) = authorise(org, "yank") {
200                    return e;
201                }
202                match yank_line(dir, &format!("{org}/{package}"), version, *verb == "unyank") {
203                    Ok(()) => (
204                        200,
205                        json!({ "ok": true }).to_string().into_bytes(),
206                        "application/json",
207                    ),
208                    // #2330 (E): un-yanking a withdrawn version conflicts with
209                    // its state (`409`); publishing it again is `410`.
210                    Err(r) if r.code == "withdrawn" => {
211                        let (_, body, kind) = refusal(r);
212                        (409, body, kind)
213                    }
214                    Err(r) => refusal(r),
215                }
216            }
217            _ => error(404, "not_found", format!("no API endpoint at `{url}`")),
218        };
219    }
220    if method != tiny_http::Method::Get {
221        return (405, Vec::new(), "text/plain");
222    }
223    match served_path(dir, &url).and_then(|p| std::fs::read(p).ok()) {
224        Some(bytes) => (200, bytes, "application/octet-stream"),
225        None => (404, Vec::new(), "text/plain"),
226    }
227}
228
229/// A refusal as an HTTP response.
230fn refusal(r: Refusal) -> (u16, Vec<u8>, &'static str) {
231    let status = match r.code {
232        "invalid_package" => 400,
233        "version_exists" => 409,
234        "withdrawn" => 410,
235        "not_found" => 404,
236        "too_large" => 413,
237        _ => 500,
238    };
239    (
240        status,
241        json!({ "error": r.code, "message": r.message })
242            .to_string()
243            .into_bytes(),
244        "application/json",
245    )
246}
247
248/// The file under `dir` a `GET` names, if it is a plain path inside it.
249fn served_path(dir: &Path, url: &str) -> Option<PathBuf> {
250    let rel = url.trim_start_matches('/');
251    if rel.is_empty() || rel.contains('\\') {
252        return None;
253    }
254    let path = Path::new(rel);
255    if !path.components().all(|c| matches!(c, Component::Normal(_))) {
256        return None;
257    }
258    let full = dir.join(path);
259    full.is_file().then_some(full)
260}
261
262/// `bynk registry token`: mint a token for `organisations` that `can` the
263/// given actions, append its hash to `tokens_file`, and return the token —
264/// shown once, never stored.
265pub fn mint(
266    tokens_file: &Path,
267    organisations: &[String],
268    can: &[String],
269) -> Result<String, String> {
270    if organisations.is_empty() {
271        return Err("name at least one organisation with `--org`".to_string());
272    }
273    for org in organisations {
274        if let Some(problem) = bynk_project::manifest::org_problem(org) {
275            return Err(format!("`{org}` isn't an organisation: {problem}"));
276        }
277    }
278    for c in can {
279        if c != "publish" && c != "yank" {
280            return Err(format!("`{c}` isn't a capability: use `publish` or `yank`"));
281        }
282    }
283    let mut raw = [0u8; 32];
284    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut raw)
285        .map_err(|_| "could not generate a token".to_string())?;
286    let token = format!("bynk_{}", hex(&raw));
287    let mut text = std::fs::read_to_string(tokens_file).unwrap_or_default();
288    if !text.is_empty() && !text.ends_with('\n') {
289        text.push('\n');
290    }
291    let list = |xs: &[String]| {
292        xs.iter()
293            .map(|x| format!("\"{x}\""))
294            .collect::<Vec<_>>()
295            .join(", ")
296    };
297    text.push_str(&format!(
298        "\n[[token]]\nsha256 = \"{}\"\norganisations = [{}]\ncan = [{}]\n",
299        hex(&Sha256::digest(token.as_bytes())),
300        list(organisations),
301        list(can),
302    ));
303    write_private(tokens_file, text.as_bytes())?;
304    Ok(token)
305}
306
307fn read_tokens(path: &Path) -> Result<Vec<TokenEntry>, String> {
308    let text = std::fs::read_to_string(path)
309        .map_err(|e| format!("could not read the tokens file `{}`: {e}", path.display()))?;
310    let doc: toml::Table = text
311        .parse()
312        .map_err(|e| format!("the tokens file `{}` isn't TOML: {e}", path.display()))?;
313    let list = |v: Option<&toml::Value>| -> Vec<String> {
314        v.and_then(|v| v.as_array())
315            .map(|a| {
316                a.iter()
317                    .filter_map(|x| x.as_str().map(str::to_owned))
318                    .collect()
319            })
320            .unwrap_or_default()
321    };
322    let entries = doc
323        .get("token")
324        .and_then(|v| v.as_array())
325        .map(|a| {
326            a.iter()
327                .filter_map(|t| {
328                    Some(TokenEntry {
329                        sha256: t.get("sha256")?.as_str()?.to_ascii_lowercase(),
330                        organisations: list(t.get("organisations")),
331                        can: list(t.get("can")),
332                    })
333                })
334                .collect()
335        })
336        .unwrap_or_default();
337    Ok(entries)
338}
339
340fn inside(path: &Path, dir: &Path) -> bool {
341    let abs = |p: &Path| {
342        p.canonicalize()
343            .unwrap_or_else(|_| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()))
344    };
345    abs(path).starts_with(abs(dir))
346}
347
348fn hex(bytes: &[u8]) -> String {
349    bytes.iter().map(|b| format!("{b:02x}")).collect()
350}
351
352/// Equality that takes the same time wherever the inputs differ.
353fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
354    a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
355}