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