OXYGENHUB
# 0cd118748131 oxygenhub / crates / hub / src / config.rs
13.0 KiB · Rust · last changed Backups to S3, keylessly 23 days ago source blame history raw
1
//! Configuration (hub spec §17.1): `hub.toml` in the data directory, every
2
//! key overridable by `OXYHUB_<SECTION>_<KEY>`.
3
4
use serde::Deserialize;
5
use std::net::SocketAddr;
6
use std::path::{Path, PathBuf};
7
8
#[derive(Clone, Debug, Deserialize)]
9
#[serde(default, deny_unknown_fields)]
10
pub struct Config {
11
    /// Set from the command line, never from the file.
12
    #[serde(skip)]
13
    pub data: PathBuf,
14
    pub site: Site,
15
    pub server: Server,
16
    pub tls: Tls,
17
    pub auth: Auth,
18
    pub registration: Registration,
19
    pub email: Email,
20
    pub limits: Limits,
21
    pub retention: Retention,
22
    pub flow: Flow,
23
    pub apt: Apt,
24
    pub diffusion: Diffusion,
25
    pub backups: Backups,
26
}
27
28
/// Backups (docs/backups.md, oxygenhub#169): the directories on this host
29
/// an owner may back up to. Empty, only GCS destinations can be chosen.
30
#[derive(Clone, Debug, Deserialize)]
31
#[serde(default, deny_unknown_fields)]
32
pub struct Backups {
33
    pub directories: Vec<PathBuf>,
34
    /// Google's token exchange and Cloud Storage, for GCS destinations;
35
    /// set only to point them elsewhere (tests, a private endpoint).
36
    pub sts_url: String,
37
    pub storage_url: String,
38
    /// AWS STS and S3, for S3 destinations; `{bucket}` and `{region}` in
39
    /// `s3_url` are filled in. Set only to point them elsewhere.
40
    pub aws_sts_url: String,
41
    pub s3_url: String,
42
}
43
44
impl Default for Backups {
45
    fn default() -> Self {
46
        Backups {
47
            directories: Vec::new(),
48
            sts_url: "https://sts.googleapis.com".into(),
49
            storage_url: "https://storage.googleapis.com".into(),
50
            aws_sts_url: "https://sts.amazonaws.com".into(),
51
            s3_url: "https://{bucket}.s3.{region}.amazonaws.com".into(),
52
        }
53
    }
54
}
55
56
/// Diffusion (#148, docs/diffusion.md §4.2): the site's default for
57
/// repositories that say nothing.
58
#[derive(Clone, Debug, Deserialize)]
59
#[serde(default, deny_unknown_fields)]
60
pub struct Diffusion {
61
    /// `off`, `green` or `any`.
62
    pub follow: String,
63
}
64
65
impl Default for Diffusion {
66
    fn default() -> Self {
67
        Diffusion { follow: "off".into() }
68
    }
69
}
70
71
/// The apt repository at `/apt` (oxygenhub#85): the `.deb` assets of these
72
/// public repositories' releases, signed with the key in `gnupg_home`.
73
#[derive(Clone, Debug, Deserialize)]
74
#[serde(default, deny_unknown_fields)]
75
pub struct Apt {
76
    /// `owner/name` of each repository whose release `.deb`s are served;
77
    /// none turns `/apt` off.
78
    pub repos: Vec<String>,
79
    /// The suite apt's `sources` names.
80
    pub suite: String,
81
    pub component: String,
82
    /// GnuPG home holding the signing key (relative to the data directory).
83
    pub gnupg_home: String,
84
    /// Architectures the index lists, each with its Packages file.
85
    pub architectures: Vec<String>,
86
}
87
88
impl Default for Apt {
89
    fn default() -> Self {
90
        Apt {
91
            repos: vec![],
92
            suite: "stable".into(),
93
            component: "main".into(),
94
            gnupg_home: "apt-gnupg".into(),
95
            architectures: vec!["amd64".into(), "arm64".into(), "riscv64".into()],
96
        }
97
    }
98
}
99
100
/// OxygenFlow runs (hub spec §13.2).
101
#[derive(Clone, Debug, Deserialize)]
102
#[serde(default, deny_unknown_fields)]
103
pub struct Flow {
104
    /// Bearer tokens runners present to claim jobs.
105
    pub runner_tokens: Vec<String>,
106
    /// `owner/*` or `owner/name`: repositories whose pushes start runs.
107
    pub repos: Vec<String>,
108
    pub job_timeout_minutes: u64,
109
}
110
111
impl Default for Flow {
112
    fn default() -> Self {
113
        Flow { runner_tokens: vec![], repos: vec![], job_timeout_minutes: 60 }
114
    }
115
}
116
117
impl Flow {
118
    /// Whether pushes to `owner/name` start runs.
119
    pub fn allows(&self, owner: &str, name: &str) -> bool {
120
        self.repos.iter().any(|p| match p.split_once('/') {
121
            Some((o, "*")) => o == owner,
122
            Some((o, n)) => o == owner && n == name,
123
            None => false,
124
        })
125
    }
126
}
127
128
#[derive(Clone, Debug, Default, Deserialize)]
129
#[serde(default, deny_unknown_fields)]
130
pub struct Email {
131
    /// `smtp://user:pass@host:port` or `smtps://…`; empty disables email.
132
    pub smtp: String,
133
    /// `OxygenHub <hub@example>`.
134
    pub from: String,
135
}
136
137
#[derive(Clone, Debug, Deserialize)]
138
#[serde(default, deny_unknown_fields)]
139
pub struct Registration {
140
    /// `open`, `invite` or `closed`; the admin page can override it.
141
    pub mode: String,
142
}
143
144
impl Default for Registration {
145
    fn default() -> Self {
146
        Registration { mode: "invite".into() }
147
    }
148
}
149
150
#[derive(Clone, Debug, Deserialize)]
151
#[serde(default, deny_unknown_fields)]
152
pub struct Site {
153
    pub name: String,
154
    pub description: String,
155
}
156
157
#[derive(Clone, Debug, Deserialize)]
158
#[serde(default, deny_unknown_fields)]
159
pub struct Server {
160
    /// Plain HTTP; empty disables it.
161
    pub bind: String,
162
    /// HTTPS; empty disables it. Needs `tls`.
163
    pub tls_bind: String,
164
    /// What links and clone URLs start with.
165
    pub base_url: String,
166
    /// Trust `X-Forwarded-*` from a terminating proxy.
167
    pub behind_proxy: bool,
168
}
169
170
#[derive(Clone, Debug, Deserialize)]
171
#[serde(default, deny_unknown_fields)]
172
pub struct Tls {
173
    /// Generate a certificate at `init` (developer edition).
174
    pub self_signed: bool,
175
    /// PEM files; relative paths are under the data directory.
176
    pub cert: String,
177
    pub key: String,
178
}
179
180
#[derive(Clone, Debug, Default, Deserialize)]
181
#[serde(default, deny_unknown_fields)]
182
pub struct Auth {
183
    /// Bearer tokens allowed to push until accounts exist (hub 0.2). Empty
184
    /// means every push is accepted, as `oxy serve` does.
185
    pub push_tokens: Vec<String>,
186
    /// For tests only: a base URL that stands in for every sign-in
187
    /// provider (`{base}/{provider}/authorize|token|userinfo`).
188
    pub signin_mock: Option<String>,
189
}
190
191
#[derive(Clone, Debug, Deserialize)]
192
#[serde(default, deny_unknown_fields)]
193
pub struct Limits {
194
    /// Largest file rendered in the browser, e.g. `2MiB`.
195
    pub render_limit: ByteSize,
196
    pub asset_limit: ByteSize,
197
    pub push_body_limit: ByteSize,
198
}
199
200
#[derive(Clone, Debug, Deserialize)]
201
#[serde(default, deny_unknown_fields)]
202
pub struct Retention {
203
    pub trash_days: u32,
204
    pub webhook_log_days: u32,
205
    /// The UTC hour at which every repository is garbage collected
206
    /// (spec §17.2); 24 or more turns the nightly gc off.
207
    pub gc_hour: u32,
208
}
209
210
/// A byte count written as `512`, `2MiB`, `4GiB`, `100kB`.
211
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
212
pub struct ByteSize(pub u64);
213
214
impl ByteSize {
215
    pub fn parse(s: &str) -> Option<ByteSize> {
216
        let s = s.trim();
217
        let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
218
        let unit = s[digits.len()..].trim();
219
        let n: u64 = digits.parse().ok()?;
220
        let mult = match unit {
221
            "" | "B" => 1,
222
            "k" | "kB" | "KB" => 1000,
223
            "KiB" | "K" => 1 << 10,
224
            "M" | "MB" => 1_000_000,
225
            "MiB" => 1 << 20,
226
            "G" | "GB" => 1_000_000_000,
227
            "GiB" => 1 << 30,
228
            _ => return None,
229
        };
230
        Some(ByteSize(n.checked_mul(mult)?))
231
    }
232
}
233
234
impl<'de> Deserialize<'de> for ByteSize {
235
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<ByteSize, D::Error> {
236
        #[derive(Deserialize)]
237
        #[serde(untagged)]
238
        enum Raw {
239
            Number(u64),
240
            Text(String),
241
        }
242
        match Raw::deserialize(d)? {
243
            Raw::Number(n) => Ok(ByteSize(n)),
244
            Raw::Text(t) => ByteSize::parse(&t).ok_or_else(|| serde::de::Error::custom(format!("not a byte size: {t:?}"))),
245
        }
246
    }
247
}
248
249
impl Default for Site {
250
    fn default() -> Self {
251
        Site { name: "OxygenHub".into(), description: "Repositories, proposals and issues for Oxygen".into() }
252
    }
253
}
254
255
impl Default for Server {
256
    fn default() -> Self {
257
        Server { bind: "127.0.0.1:7300".into(), tls_bind: String::new(), base_url: "http://127.0.0.1:7300".into(), behind_proxy: false }
258
    }
259
}
260
261
impl Default for Tls {
262
    fn default() -> Self {
263
        Tls { self_signed: false, cert: "keys/tls.crt".into(), key: "keys/tls.key".into() }
264
    }
265
}
266
267
impl Default for Limits {
268
    fn default() -> Self {
269
        Limits { render_limit: ByteSize(2 << 20), asset_limit: ByteSize(512 << 20), push_body_limit: ByteSize(4 << 30) }
270
    }
271
}
272
273
impl Default for Retention {
274
    fn default() -> Self {
275
        Retention { trash_days: 30, webhook_log_days: 30, gc_hour: 3 }
276
    }
277
}
278
279
impl Default for Config {
280
    fn default() -> Self {
281
        Config {
282
            data: PathBuf::from("data"),
283
            site: Site::default(),
284
            server: Server::default(),
285
            tls: Tls::default(),
286
            auth: Auth::default(),
287
            registration: Registration::default(),
288
            email: Email::default(),
289
            limits: Limits::default(),
290
            retention: Retention::default(),
291
            flow: Flow::default(),
292
            apt: Apt::default(),
293
            diffusion: Diffusion::default(),
294
            backups: Backups::default(),
295
        }
296
    }
297
}
298
299
impl Config {
300
    /// Read `<data>/hub.toml` if present, then apply environment overrides.
301
    pub fn load(data: &Path) -> anyhow::Result<Config> {
302
        let path = data.join("hub.toml");
303
        let mut cfg = match std::fs::read_to_string(&path) {
304
            Ok(text) => Config::parse(&text)?,
305
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(),
306
            Err(e) => return Err(anyhow::anyhow!("reading {}: {e}", path.display())),
307
        };
308
        cfg.data = data.to_path_buf();
309
        cfg.apply_env(|k| std::env::var(k).ok());
310
        Ok(cfg)
311
    }
312
313
    pub fn parse(text: &str) -> anyhow::Result<Config> {
314
        Ok(toml::from_str(text)?)
315
    }
316
317
    /// `OXYHUB_SERVER_BIND`, `OXYHUB_SITE_NAME`, … override the file.
318
    pub fn apply_env(&mut self, get: impl Fn(&str) -> Option<String>) {
319
        if let Some(v) = get("OXYHUB_SITE_NAME") {
320
            self.site.name = v;
321
        }
322
        if let Some(v) = get("OXYHUB_SITE_DESCRIPTION") {
323
            self.site.description = v;
324
        }
325
        if let Some(v) = get("OXYHUB_SERVER_BIND") {
326
            self.server.bind = v;
327
        }
328
        if let Some(v) = get("OXYHUB_SERVER_TLS_BIND") {
329
            self.server.tls_bind = v;
330
        }
331
        if let Some(v) = get("OXYHUB_SERVER_BASE_URL") {
332
            self.server.base_url = v;
333
        }
334
        if let Some(v) = get("OXYHUB_SERVER_BEHIND_PROXY") {
335
            self.server.behind_proxy = matches!(v.as_str(), "1" | "true" | "yes");
336
        }
337
        if let Some(v) = get("OXYHUB_TLS_SELF_SIGNED") {
338
            self.tls.self_signed = matches!(v.as_str(), "1" | "true" | "yes");
339
        }
340
        if let Some(v) = get("OXYHUB_REGISTRATION_MODE") {
341
            self.registration.mode = v;
342
        }
343
        if let Some(v) = get("OXYHUB_AUTH_PUSH_TOKENS") {
344
            self.auth.push_tokens = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
345
        }
346
        if let Some(v) = get("OXYHUB_LIMITS_RENDER_LIMIT").and_then(|v| ByteSize::parse(&v)) {
347
            self.limits.render_limit = v;
348
        }
349
    }
350
351
    pub fn bind_addr(&self) -> anyhow::Result<Option<SocketAddr>> {
352
        parse_addr(&self.server.bind)
353
    }
354
355
    pub fn tls_bind_addr(&self) -> anyhow::Result<Option<SocketAddr>> {
356
        parse_addr(&self.server.tls_bind)
357
    }
358
359
    pub fn tls_cert(&self) -> PathBuf {
360
        self.data.join(&self.tls.cert)
361
    }
362
363
    pub fn tls_key(&self) -> PathBuf {
364
        self.data.join(&self.tls.key)
365
    }
366
367
    pub fn repos_dir(&self) -> PathBuf {
368
        self.data.join("repos")
369
    }
370
371
    pub fn keys_dir(&self) -> PathBuf {
372
        self.data.join("keys")
373
    }
374
375
    pub fn db_path(&self) -> PathBuf {
376
        self.data.join("hub.db")
377
    }
378
379
    /// The base URL without a trailing slash.
380
    pub fn base_url(&self) -> &str {
381
        self.server.base_url.trim_end_matches('/')
382
    }
383
}
384
385
fn parse_addr(s: &str) -> anyhow::Result<Option<SocketAddr>> {
386
    if s.trim().is_empty() {
387
        return Ok(None);
388
    }
389
    Ok(Some(s.parse().map_err(|e| anyhow::anyhow!("bind address {s:?}: {e}"))?))
390
}
391
392
#[cfg(test)]
393
mod tests {
394
    use super::*;
395
396
    #[test]
397
    fn byte_sizes() {
398
        assert_eq!(ByteSize::parse("2MiB"), Some(ByteSize(2 << 20)));
399
        assert_eq!(ByteSize::parse("512"), Some(ByteSize(512)));
400
        assert_eq!(ByteSize::parse("4 GiB"), Some(ByteSize(4 << 30)));
401
        assert_eq!(ByteSize::parse("100kB"), Some(ByteSize(100_000)));
402
        assert_eq!(ByteSize::parse("lots"), None);
403
    }
404
405
    #[test]
406
    fn file_then_environment() {
407
        let mut cfg = Config::parse("[server]\nbind = \"0.0.0.0:7300\"\n[limits]\nrender_limit = \"1MiB\"\n").unwrap();
408
        assert_eq!(cfg.server.bind, "0.0.0.0:7300");
409
        assert_eq!(cfg.limits.render_limit, ByteSize(1 << 20));
410
        assert_eq!(cfg.site.name, "OxygenHub");
411
        cfg.apply_env(|k| match k {
412
            "OXYHUB_SITE_NAME" => Some("Earth".into()),
413
            "OXYHUB_AUTH_PUSH_TOKENS" => Some("a, b".into()),
414
            _ => None,
415
        });
416
        assert_eq!(cfg.site.name, "Earth");
417
        assert_eq!(cfg.auth.push_tokens, ["a", "b"]);
418
        assert!(Config::parse("[nope]\nx = 1\n").is_err(), "unknown sections are errors, not silence");
419
    }
420
}