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