OXYGENHUB
◆ v0.9.24 oxygenhub / crates / hub / src / config.rs
24.4 KiB · Rust · last changed New repository page with board and starting files; assetlinks.json; hung tests stopped; changelog on the WASM tier (closes #360, closes #365, closes #370) 1 day 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,
4f40eed0 Andy McCall ↶ 28
    pub tlog: Tlog,
aeed63c0 Andy McCall ↶ 29
    pub push: Push,
42fa9554 Andy McCall ↶ 30
}
31
3b1bc27f Andy McCall 32
/// Where a hub built with the `postgres` feature keeps its tables (#211); a
33
/// SQLite build keeps them in `<data>/hub.db` and ignores this.
34
#[derive(Clone, Debug, Default, Deserialize)]
35
#[serde(default, deny_unknown_fields)]
36
pub struct Database {
37
    /// `postgres://user@host/db`; the password from the URL or `PGPASSWORD`.
38
    pub url: String,
39
    /// The schema to use, made if missing; empty for `public`, `*` for one
40
    /// per data directory (named after it), as the tests use.
41
    pub schema: String,
42
}
43
9a777fec Andy McCall ↶ 44
/// Outbound requests to addresses people give (oxygenhub#219): private
45
/// and local addresses are refused unless listed here, as `scheme://host`
46
/// (any port) or `scheme://host:port`, e.g. a local Ollama:
47
/// `allow = ["http://127.0.0.1:11434"]`.
48
#[derive(Clone, Debug, Default, Deserialize)]
49
#[serde(default, deny_unknown_fields)]
50
pub struct Outbound {
51
    pub allow: Vec<String>,
52
}
53
aeed63c0 Andy McCall ↶ 54
/// Phone alerts (oxygenhub-android#16, oxygenhub-gcp decision 0008): a
55
/// notification is also sent, sealed to each phone's key, through Firebase
56
/// Cloud Messaging. Off while `fcm_project` is empty. The hub sends as the
57
/// service account its VM runs as, whose token the metadata server gives:
58
/// no key.
59
#[derive(Clone, Debug, Deserialize)]
60
#[serde(default, deny_unknown_fields)]
61
pub struct Push {
62
    /// The Firebase (GCP) project, `OXYHUB_PUSH_FCM_PROJECT`.
63
    pub fcm_project: String,
64
    /// Cloud Messaging and the token source; set only to point them
65
    /// elsewhere (tests).
66
    pub fcm_url: String,
67
    pub token_url: String,
40a43921 Andy McCall ↶ 68
    /// The Android apps this hub's links open (App Links, #370), each
69
    /// `PACKAGE:SHA256` with the release key's certificate fingerprint as
70
    /// colon-separated hex; served at `/.well-known/assetlinks.json`. The
71
    /// official app by default; empty for none.
72
    pub android_apps: Vec<String>,
aeed63c0 Andy McCall ↶ 73
}
74
40a43921 Andy McCall ↶ 75
/// The OxygenHub Android app's package and its release key's SHA-256
76
/// certificate fingerprint (oxygenhub-android CHANGELOG, 0.1.0).
77
pub const OFFICIAL_ANDROID_APP: &str =
78
    "io.oxygenhub.android:B0:99:57:36:44:42:AF:B2:27:28:B7:EF:93:F8:23:2D:F1:41:17:DA:50:28:6D:45:93:04:0F:9C:9F:A2:2A:48";
79
aeed63c0 Andy McCall ↶ 80
impl Default for Push {
81
    fn default() -> Self {
82
        Push {
83
            fcm_project: String::new(),
84
            fcm_url: "https://fcm.googleapis.com".into(),
85
            token_url: "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token".into(),
40a43921 Andy McCall ↶ 86
            android_apps: vec![OFFICIAL_ANDROID_APP.into()],
aeed63c0 Andy McCall ↶ 87
        }
88
    }
89
}
90
4f40eed0 Andy McCall ↶ 91
/// The transparency log's witnesses (docs/transparency.md, #238): who
92
/// cosigns this hub's checkpoints, and whose logs this hub cosigns.
93
#[derive(Clone, Debug, Default, Deserialize)]
94
#[serde(default, deny_unknown_fields)]
95
pub struct Tlog {
96
    pub witnesses: Vec<TlogWitness>,
97
    pub witness_for: Vec<TlogWitnessed>,
98
}
99
100
/// A witness this hub's log asks: its name in cosignatures, its
101
/// add-checkpoint address (`https://HUB/log/witness/add-checkpoint` for
102
/// another hub), and its identity object in base64 (another hub's
103
/// `/.well-known/oxyhub-key`).
104
#[derive(Clone, Debug, Deserialize)]
105
#[serde(deny_unknown_fields)]
106
pub struct TlogWitness {
107
    pub name: String,
108
    pub url: String,
109
    pub key: String,
110
}
111
112
/// A log this hub witnesses: its origin (`HOST/log`) and its key, an
113
/// identity object in base64.
114
#[derive(Clone, Debug, Deserialize)]
115
#[serde(deny_unknown_fields)]
116
pub struct TlogWitnessed {
117
    pub origin: String,
118
    pub key: String,
119
}
120
d7d18246 Andy McCall ↶ 121
/// Backups (docs/backups.md, oxygenhub#169): the directories on this host
122
/// an owner may back up to. Empty, only GCS destinations can be chosen.
35fea423 Andy McCall ↶ 123
#[derive(Clone, Debug, Deserialize)]
d7d18246 Andy McCall ↶ 124
#[serde(default, deny_unknown_fields)]
125
pub struct Backups {
126
    pub directories: Vec<PathBuf>,
35fea423 Andy McCall ↶ 127
    /// Google's token exchange and Cloud Storage, for GCS destinations;
128
    /// set only to point them elsewhere (tests, a private endpoint).
129
    pub sts_url: String,
130
    pub storage_url: String,
7b9f4570 Andy McCall ↶ 131
    /// AWS STS and S3, for S3 destinations; `{bucket}` and `{region}` in
132
    /// `s3_url` are filled in. Set only to point them elsewhere.
133
    pub aws_sts_url: String,
134
    pub s3_url: String,
35fea423 Andy McCall ↶ 135
}
136
137
impl Default for Backups {
138
    fn default() -> Self {
139
        Backups {
140
            directories: Vec::new(),
141
            sts_url: "https://sts.googleapis.com".into(),
142
            storage_url: "https://storage.googleapis.com".into(),
7b9f4570 Andy McCall ↶ 143
            aws_sts_url: "https://sts.amazonaws.com".into(),
144
            s3_url: "https://{bucket}.s3.{region}.amazonaws.com".into(),
35fea423 Andy McCall ↶ 145
        }
146
    }
d7d18246 Andy McCall ↶ 147
}
148
e7158350 Andy McCall ↶ 149
/// Diffusion (#148, docs/diffusion.md §4.2): the site's default for
150
/// repositories that say nothing.
151
#[derive(Clone, Debug, Deserialize)]
152
#[serde(default, deny_unknown_fields)]
153
pub struct Diffusion {
154
    /// `off`, `green` or `any`.
155
    pub follow: String,
156
}
157
158
impl Default for Diffusion {
159
    fn default() -> Self {
160
        Diffusion { follow: "off".into() }
161
    }
162
}
163
ea85398a Andy McCall ↶ 164
/// The apt repository at `/apt` (oxygenhub#85): the `.deb` assets of these
165
/// public repositories' releases, signed with the key in `gnupg_home`.
166
#[derive(Clone, Debug, Deserialize)]
167
#[serde(default, deny_unknown_fields)]
168
pub struct Apt {
169
    /// `owner/name` of each repository whose release `.deb`s are served;
170
    /// none turns `/apt` off.
171
    pub repos: Vec<String>,
172
    /// The suite apt's `sources` names.
173
    pub suite: String,
174
    pub component: String,
175
    /// GnuPG home holding the signing key (relative to the data directory).
176
    pub gnupg_home: String,
177
    /// Architectures the index lists, each with its Packages file.
178
    pub architectures: Vec<String>,
179
}
180
181
impl Default for Apt {
182
    fn default() -> Self {
183
        Apt {
184
            repos: vec![],
185
            suite: "stable".into(),
186
            component: "main".into(),
187
            gnupg_home: "apt-gnupg".into(),
188
            architectures: vec!["amd64".into(), "arm64".into(), "riscv64".into()],
189
        }
190
    }
191
}
192
42fa9554 Andy McCall ↶ 193
/// OxygenFlow runs (hub spec §13.2).
194
#[derive(Clone, Debug, Deserialize)]
195
#[serde(default, deny_unknown_fields)]
196
pub struct Flow {
197
    /// Bearer tokens runners present to claim jobs.
198
    pub runner_tokens: Vec<String>,
199
    /// `owner/*` or `owner/name`: repositories whose pushes start runs.
200
    pub repos: Vec<String>,
201
    pub job_timeout_minutes: u64,
4a4400c5 Andy McCall ↶ 202
    /// A runner whose last this many jobs all ended in an infrastructure
203
    /// error (status `error`, not `failed`) is drained and the
204
    /// administrators told (oxygenflow#30); 0 never drains one.
205
    pub drain_after_errors: u32,
42fa9554 Andy McCall ↶ 206
}
207
208
impl Default for Flow {
209
    fn default() -> Self {
4a4400c5 Andy McCall ↶ 210
        Flow { runner_tokens: vec![], repos: vec![], job_timeout_minutes: 60, drain_after_errors: 5 }
42fa9554 Andy McCall ↶ 211
    }
d23464c0 Andy McCall ↶ 212
}
213
42fa9554 Andy McCall ↶ 214
impl Flow {
215
    /// Whether pushes to `owner/name` start runs.
216
    pub fn allows(&self, owner: &str, name: &str) -> bool {
217
        self.repos.iter().any(|p| match p.split_once('/') {
218
            Some((o, "*")) => o == owner,
219
            Some((o, n)) => o == owner && n == name,
220
            None => false,
221
        })
222
    }
223
}
224
18e381ea Andy McCall ↶ 225
#[derive(Clone, Debug, Default, Deserialize)]
226
#[serde(default, deny_unknown_fields)]
227
pub struct Email {
228
    /// `smtp://user:pass@host:port` or `smtps://…`; empty disables email.
229
    pub smtp: String,
8d2caf12 Andy McCall ↶ 230
    /// `OxygenHub <hub@example>`.
18e381ea Andy McCall ↶ 231
    pub from: String,
232
}
233
d23464c0 Andy McCall ↶ 234
#[derive(Clone, Debug, Deserialize)]
235
#[serde(default, deny_unknown_fields)]
19bb31ff Andy McCall ↶ 236
pub struct Registration {
237
    /// `open`, `invite` or `closed`; the admin page can override it.
238
    pub mode: String,
c8684d44 Andy McCall 239
    /// The setup code the first account must bring (oxygenhub#245), for
240
    /// automation; empty, the hub makes one and keeps it in
241
    /// `<data>/setup-code`.
242
    pub setup_code: String,
19bb31ff Andy McCall ↶ 243
}
244
245
impl Default for Registration {
246
    fn default() -> Self {
c8684d44 Andy McCall 247
        Registration { mode: "invite".into(), setup_code: String::new() }
19bb31ff Andy McCall ↶ 248
    }
249
}
250
251
#[derive(Clone, Debug, Deserialize)]
252
#[serde(default, deny_unknown_fields)]
d23464c0 Andy McCall ↶ 253
pub struct Site {
254
    pub name: String,
255
    pub description: String,
256
}
257
258
#[derive(Clone, Debug, Deserialize)]
259
#[serde(default, deny_unknown_fields)]
260
pub struct Server {
261
    /// Plain HTTP; empty disables it.
262
    pub bind: String,
263
    /// HTTPS; empty disables it. Needs `tls`.
264
    pub tls_bind: String,
265
    /// What links and clone URLs start with.
266
    pub base_url: String,
267
    /// Trust `X-Forwarded-*` from a terminating proxy.
268
    pub behind_proxy: bool,
2cbf6f34 Andy McCall ↶ 269
    /// Strict-Transport-Security's max-age in seconds, sent when
270
    /// `base_url` is https; 0 sends none (oxygenhub#231). Raise it in
271
    /// stages: browsers keep it, so it is hard to take back.
272
    pub hsts_max_age: u64,
8a48dd83 Andy McCall ↶ 273
    /// The bearer token `/metrics` asks for (oxygenhub#212); empty, the
274
    /// metrics are off.
275
    pub metrics_token: String,
e97500b7 Andy McCall ↶ 276
    /// A Grafana for this hub's metrics, linked from Insights (oxygenhub#257)
277
    /// for charts of one's own; empty, no link.
278
    pub grafana_url: String,
e57765de Andy McCall ↶ 279
    /// What this process does (docs/roles.md, #272): `web` (pages, the
280
    /// API, the wire protocol, the runner API) and `worker` (the background
281
    /// tasks). Empty, both. `serve --role` overrides it.
282
    pub roles: Vec<String>,
d23464c0 Andy McCall ↶ 283
}
284
e57765de Andy McCall ↶ 285
/// One part of the hub's work a process can take (docs/roles.md, #272).
286
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
287
pub enum Role {
288
    /// Pages, the JSON API, the wire protocol, live pages and the runner
289
    /// API, and the transparency log they append to.
290
    Web,
291
    /// The background tasks: webhooks, Flow's scheduler, reaper and
292
    /// placement pass, nightly upkeep, backups and Diffusion's index.
293
    Worker,
294
}
295
296
impl Role {
297
    pub const ALL: [Role; 2] = [Role::Web, Role::Worker];
298
299
    pub fn name(self) -> &'static str {
300
        match self {
301
            Role::Web => "web",
302
            Role::Worker => "worker",
303
        }
304
    }
305
}
306
307
impl std::str::FromStr for Role {
308
    type Err = String;
309
310
    fn from_str(s: &str) -> Result<Role, String> {
311
        match s.trim().to_ascii_lowercase().as_str() {
312
            "web" => Ok(Role::Web),
313
            "worker" => Ok(Role::Worker),
314
            _ => Err(format!("unknown role {:?}: the roles are web and worker", s.trim())),
315
        }
316
    }
317
}
318
319
/// The roles one process takes: every role unless some are named.
320
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
321
pub struct Roles {
322
    pub web: bool,
323
    pub worker: bool,
324
}
325
326
impl Default for Roles {
327
    fn default() -> Self {
328
        Roles { web: true, worker: true }
329
    }
330
}
331
332
impl Roles {
333
    /// The roles named, each `web` or `worker`; none named is every role.
334
    pub fn from_names<S: AsRef<str>>(names: &[S]) -> Result<Roles, String> {
335
        let names: Vec<&str> = names.iter().map(|s| s.as_ref().trim()).filter(|s| !s.is_empty()).collect();
336
        if names.is_empty() {
337
            return Ok(Roles::default());
338
        }
339
        let mut roles = Roles { web: false, worker: false };
340
        for n in names {
341
            match n.parse::<Role>()? {
342
                Role::Web => roles.web = true,
343
                Role::Worker => roles.worker = true,
344
            }
345
        }
346
        Ok(roles)
347
    }
348
349
    pub fn has(self, role: Role) -> bool {
350
        match role {
351
            Role::Web => self.web,
352
            Role::Worker => self.worker,
353
        }
354
    }
355
356
    /// The roles' names, in a fixed order: what `/health` reports.
357
    pub fn names(self) -> Vec<&'static str> {
358
        Role::ALL.into_iter().filter(|r| self.has(*r)).map(Role::name).collect()
359
    }
360
}
361
d23464c0 Andy McCall ↶ 362
#[derive(Clone, Debug, Deserialize)]
363
#[serde(default, deny_unknown_fields)]
364
pub struct Tls {
365
    /// Generate a certificate at `init` (developer edition).
366
    pub self_signed: bool,
367
    /// PEM files; relative paths are under the data directory.
368
    pub cert: String,
369
    pub key: String,
370
}
371
372
#[derive(Clone, Debug, Default, Deserialize)]
373
#[serde(default, deny_unknown_fields)]
374
pub struct Auth {
375
    /// Bearer tokens allowed to push until accounts exist (hub 0.2). Empty
376
    /// means every push is accepted, as `oxy serve` does.
377
    pub push_tokens: Vec<String>,
83940d53 Andy McCall ↶ 378
    /// For tests only: a base URL that stands in for every sign-in
379
    /// provider (`{base}/{provider}/authorize|token|userinfo`).
380
    pub signin_mock: Option<String>,
d23464c0 Andy McCall ↶ 381
}
382
383
#[derive(Clone, Debug, Deserialize)]
384
#[serde(default, deny_unknown_fields)]
385
pub struct Limits {
386
    /// Largest file rendered in the browser, e.g. `2MiB`.
387
    pub render_limit: ByteSize,
388
    pub asset_limit: ByteSize,
389
    pub push_body_limit: ByteSize,
0ee5c3fb Andy McCall ↶ 390
    /// Push protection's budget (oxygenhub#268): what one push may have
391
    /// scanned, the largest file or inserted text scanned, and how long
392
    /// the scan may take. Past them the rest goes unscanned.
393
    pub scan_limit: ByteSize,
394
    pub scan_file_limit: ByteSize,
395
    pub scan_time_ms: u64,
cb7b3cd4 Andy McCall ↶ 396
    /// The history scan's budget (oxygenhub#277): what one pass over one
397
    /// repository may scan, and for how long, before it moves on to the
398
    /// next and resumes there later. A limit of 0 turns the scan off.
399
    pub history_scan_limit: ByteSize,
400
    pub history_scan_time_ms: u64,
d23464c0 Andy McCall ↶ 401
}
402
403
#[derive(Clone, Debug, Deserialize)]
404
#[serde(default, deny_unknown_fields)]
405
pub struct Retention {
406
    pub trash_days: u32,
407
    pub webhook_log_days: u32,
8a3334b7 Andy McCall ↶ 408
    /// The UTC hour at which every repository is garbage collected
409
    /// (spec §17.2); 24 or more turns the nightly gc off.
410
    pub gc_hour: u32,
d23464c0 Andy McCall ↶ 411
}
412
413
/// A byte count written as `512`, `2MiB`, `4GiB`, `100kB`.
414
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
415
pub struct ByteSize(pub u64);
416
417
impl ByteSize {
418
    pub fn parse(s: &str) -> Option<ByteSize> {
419
        let s = s.trim();
420
        let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
421
        let unit = s[digits.len()..].trim();
422
        let n: u64 = digits.parse().ok()?;
423
        let mult = match unit {
424
            "" | "B" => 1,
425
            "k" | "kB" | "KB" => 1000,
426
            "KiB" | "K" => 1 << 10,
427
            "M" | "MB" => 1_000_000,
428
            "MiB" => 1 << 20,
429
            "G" | "GB" => 1_000_000_000,
430
            "GiB" => 1 << 30,
431
            _ => return None,
432
        };
433
        Some(ByteSize(n.checked_mul(mult)?))
434
    }
435
}
436
437
impl<'de> Deserialize<'de> for ByteSize {
438
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<ByteSize, D::Error> {
439
        #[derive(Deserialize)]
440
        #[serde(untagged)]
441
        enum Raw {
442
            Number(u64),
443
            Text(String),
444
        }
445
        match Raw::deserialize(d)? {
446
            Raw::Number(n) => Ok(ByteSize(n)),
447
            Raw::Text(t) => ByteSize::parse(&t).ok_or_else(|| serde::de::Error::custom(format!("not a byte size: {t:?}"))),
448
        }
449
    }
450
}
451
452
impl Default for Site {
453
    fn default() -> Self {
8d2caf12 Andy McCall ↶ 454
        Site { name: "OxygenHub".into(), description: "Repositories, proposals and issues for Oxygen".into() }
d23464c0 Andy McCall ↶ 455
    }
456
}
457
458
impl Default for Server {
459
    fn default() -> Self {
2cbf6f34 Andy McCall ↶ 460
        Server {
461
            bind: "127.0.0.1:7300".into(),
462
            tls_bind: String::new(),
463
            base_url: "http://127.0.0.1:7300".into(),
464
            behind_proxy: false,
465
            hsts_max_age: 0,
8a48dd83 Andy McCall ↶ 466
            metrics_token: String::new(),
e97500b7 Andy McCall ↶ 467
            grafana_url: String::new(),
e57765de Andy McCall ↶ 468
            roles: Vec::new(),
2cbf6f34 Andy McCall ↶ 469
        }
d23464c0 Andy McCall ↶ 470
    }
471
}
472
473
impl Default for Tls {
474
    fn default() -> Self {
475
        Tls { self_signed: false, cert: "keys/tls.crt".into(), key: "keys/tls.key".into() }
476
    }
477
}
478
479
impl Default for Limits {
480
    fn default() -> Self {
0ee5c3fb Andy McCall ↶ 481
        let scan = crate::push_protection::Budget::default();
cb7b3cd4 Andy McCall ↶ 482
        let history = crate::history_scan::Budget::default();
0ee5c3fb Andy McCall ↶ 483
        Limits {
484
            render_limit: ByteSize(2 << 20),
485
            asset_limit: ByteSize(512 << 20),
486
            push_body_limit: ByteSize(4 << 30),
487
            scan_limit: ByteSize(scan.bytes),
488
            scan_file_limit: ByteSize(scan.file),
489
            scan_time_ms: scan.time.as_millis() as u64,
cb7b3cd4 Andy McCall ↶ 490
            history_scan_limit: ByteSize(history.bytes),
491
            history_scan_time_ms: history.time.as_millis() as u64,
0ee5c3fb Andy McCall ↶ 492
        }
d23464c0 Andy McCall ↶ 493
    }
494
}
495
496
impl Default for Retention {
497
    fn default() -> Self {
8a3334b7 Andy McCall ↶ 498
        Retention { trash_days: 30, webhook_log_days: 30, gc_hour: 3 }
d23464c0 Andy McCall ↶ 499
    }
500
}
501
502
impl Default for Config {
503
    fn default() -> Self {
504
        Config {
505
            data: PathBuf::from("data"),
506
            site: Site::default(),
507
            server: Server::default(),
508
            tls: Tls::default(),
509
            auth: Auth::default(),
19bb31ff Andy McCall ↶ 510
            registration: Registration::default(),
18e381ea Andy McCall ↶ 511
            email: Email::default(),
d23464c0 Andy McCall ↶ 512
            limits: Limits::default(),
513
            retention: Retention::default(),
42fa9554 Andy McCall ↶ 514
            flow: Flow::default(),
ea85398a Andy McCall ↶ 515
            apt: Apt::default(),
e7158350 Andy McCall ↶ 516
            diffusion: Diffusion::default(),
d7d18246 Andy McCall ↶ 517
            backups: Backups::default(),
9a777fec Andy McCall ↶ 518
            outbound: Outbound::default(),
3b1bc27f Andy McCall 519
            database: Database::default(),
4f40eed0 Andy McCall ↶ 520
            tlog: Tlog::default(),
aeed63c0 Andy McCall ↶ 521
            push: Push::default(),
d23464c0 Andy McCall ↶ 522
        }
523
    }
524
}
525
526
impl Config {
527
    /// Read `<data>/hub.toml` if present, then apply environment overrides.
528
    pub fn load(data: &Path) -> anyhow::Result<Config> {
529
        let path = data.join("hub.toml");
530
        let mut cfg = match std::fs::read_to_string(&path) {
531
            Ok(text) => Config::parse(&text)?,
532
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(),
533
            Err(e) => return Err(anyhow::anyhow!("reading {}: {e}", path.display())),
534
        };
535
        cfg.data = data.to_path_buf();
536
        cfg.apply_env(|k| std::env::var(k).ok());
e57765de Andy McCall ↶ 537
        cfg.roles()?;
d23464c0 Andy McCall ↶ 538
        Ok(cfg)
539
    }
540
541
    pub fn parse(text: &str) -> anyhow::Result<Config> {
542
        Ok(toml::from_str(text)?)
543
    }
544
545
    /// `OXYHUB_SERVER_BIND`, `OXYHUB_SITE_NAME`, … override the file.
546
    pub fn apply_env(&mut self, get: impl Fn(&str) -> Option<String>) {
3b1bc27f Andy McCall 547
        if let Some(v) = get("OXYHUB_DATABASE_URL") {
548
            self.database.url = v;
549
        }
550
        if let Some(v) = get("OXYHUB_DATABASE_SCHEMA") {
551
            self.database.schema = v;
aeed63c0 Andy McCall ↶ 552
        }
553
        if let Some(v) = get("OXYHUB_PUSH_FCM_PROJECT") {
554
            self.push.fcm_project = v;
3b1bc27f Andy McCall 555
        }
d23464c0 Andy McCall ↶ 556
        if let Some(v) = get("OXYHUB_SITE_NAME") {
557
            self.site.name = v;
558
        }
559
        if let Some(v) = get("OXYHUB_SITE_DESCRIPTION") {
560
            self.site.description = v;
561
        }
562
        if let Some(v) = get("OXYHUB_SERVER_BIND") {
563
            self.server.bind = v;
564
        }
565
        if let Some(v) = get("OXYHUB_SERVER_TLS_BIND") {
566
            self.server.tls_bind = v;
567
        }
568
        if let Some(v) = get("OXYHUB_SERVER_BASE_URL") {
569
            self.server.base_url = v;
570
        }
571
        if let Some(v) = get("OXYHUB_SERVER_BEHIND_PROXY") {
572
            self.server.behind_proxy = matches!(v.as_str(), "1" | "true" | "yes");
2cbf6f34 Andy McCall ↶ 573
        }
574
        if let Some(v) = get("OXYHUB_SERVER_HSTS_MAX_AGE") {
575
            self.server.hsts_max_age = v.parse().unwrap_or(0);
d23464c0 Andy McCall ↶ 576
        }
0843f23e Andy McCall ↶ 577
        // The VM's setup gives these (oxygenhub-gcp#12): the token its
578
        // collector scrapes /metrics with, and its Grafana for Insights' link.
579
        if let Some(v) = get("OXYHUB_SERVER_METRICS_TOKEN") {
580
            self.server.metrics_token = v;
581
        }
582
        if let Some(v) = get("OXYHUB_SERVER_GRAFANA_URL") {
583
            self.server.grafana_url = v;
584
        }
e57765de Andy McCall ↶ 585
        if let Some(v) = get("OXYHUB_SERVER_ROLES") {
586
            self.server.roles = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
587
        }
d23464c0 Andy McCall ↶ 588
        if let Some(v) = get("OXYHUB_TLS_SELF_SIGNED") {
589
            self.tls.self_signed = matches!(v.as_str(), "1" | "true" | "yes");
590
        }
19bb31ff Andy McCall ↶ 591
        if let Some(v) = get("OXYHUB_REGISTRATION_MODE") {
592
            self.registration.mode = v;
593
        }
c8684d44 Andy McCall 594
        if let Some(v) = get("OXYHUB_REGISTRATION_SETUP_CODE") {
595
            self.registration.setup_code = v;
596
        }
d23464c0 Andy McCall ↶ 597
        if let Some(v) = get("OXYHUB_AUTH_PUSH_TOKENS") {
598
            self.auth.push_tokens = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
599
        }
600
        if let Some(v) = get("OXYHUB_LIMITS_RENDER_LIMIT").and_then(|v| ByteSize::parse(&v)) {
601
            self.limits.render_limit = v;
602
        }
e57765de Andy McCall ↶ 603
    }
604
605
    /// This process's roles (`[server] roles`); an unknown name is an error.
606
    pub fn roles(&self) -> anyhow::Result<Roles> {
607
        Roles::from_names(&self.server.roles).map_err(|e| anyhow::anyhow!("[server] roles: {e}"))
d23464c0 Andy McCall ↶ 608
    }
609
610
    pub fn bind_addr(&self) -> anyhow::Result<Option<SocketAddr>> {
611
        parse_addr(&self.server.bind)
612
    }
613
614
    pub fn tls_bind_addr(&self) -> anyhow::Result<Option<SocketAddr>> {
615
        parse_addr(&self.server.tls_bind)
616
    }
617
618
    pub fn tls_cert(&self) -> PathBuf {
619
        self.data.join(&self.tls.cert)
620
    }
621
622
    pub fn tls_key(&self) -> PathBuf {
623
        self.data.join(&self.tls.key)
624
    }
625
626
    pub fn repos_dir(&self) -> PathBuf {
627
        self.data.join("repos")
628
    }
629
630
    pub fn keys_dir(&self) -> PathBuf {
631
        self.data.join("keys")
632
    }
633
634
    pub fn db_path(&self) -> PathBuf {
635
        self.data.join("hub.db")
636
    }
637
638
    /// The base URL without a trailing slash.
639
    pub fn base_url(&self) -> &str {
640
        self.server.base_url.trim_end_matches('/')
641
    }
642
}
643
644
fn parse_addr(s: &str) -> anyhow::Result<Option<SocketAddr>> {
645
    if s.trim().is_empty() {
646
        return Ok(None);
647
    }
648
    Ok(Some(s.parse().map_err(|e| anyhow::anyhow!("bind address {s:?}: {e}"))?))
649
}
650
651
#[cfg(test)]
652
mod tests {
653
    use super::*;
654
655
    #[test]
656
    fn byte_sizes() {
657
        assert_eq!(ByteSize::parse("2MiB"), Some(ByteSize(2 << 20)));
658
        assert_eq!(ByteSize::parse("512"), Some(ByteSize(512)));
659
        assert_eq!(ByteSize::parse("4 GiB"), Some(ByteSize(4 << 30)));
660
        assert_eq!(ByteSize::parse("100kB"), Some(ByteSize(100_000)));
661
        assert_eq!(ByteSize::parse("lots"), None);
662
    }
663
664
    #[test]
665
    fn file_then_environment() {
666
        let mut cfg = Config::parse("[server]\nbind = \"0.0.0.0:7300\"\n[limits]\nrender_limit = \"1MiB\"\n").unwrap();
667
        assert_eq!(cfg.server.bind, "0.0.0.0:7300");
668
        assert_eq!(cfg.limits.render_limit, ByteSize(1 << 20));
8d2caf12 Andy McCall ↶ 669
        assert_eq!(cfg.site.name, "OxygenHub");
d23464c0 Andy McCall ↶ 670
        cfg.apply_env(|k| match k {
671
            "OXYHUB_SITE_NAME" => Some("Earth".into()),
672
            "OXYHUB_AUTH_PUSH_TOKENS" => Some("a, b".into()),
0843f23e Andy McCall ↶ 673
            "OXYHUB_SERVER_METRICS_TOKEN" => Some("m".into()),
674
            "OXYHUB_SERVER_GRAFANA_URL" => Some("https://grafana.example".into()),
d23464c0 Andy McCall ↶ 675
            _ => None,
676
        });
677
        assert_eq!(cfg.site.name, "Earth");
678
        assert_eq!(cfg.auth.push_tokens, ["a", "b"]);
0843f23e Andy McCall ↶ 679
        assert_eq!((cfg.server.metrics_token.as_str(), cfg.server.grafana_url.as_str()), ("m", "https://grafana.example"));
d23464c0 Andy McCall ↶ 680
        assert!(Config::parse("[nope]\nx = 1\n").is_err(), "unknown sections are errors, not silence");
e57765de Andy McCall ↶ 681
    }
682
683
    #[test]
684
    fn roles_default_to_all_and_unknown_names_are_errors() {
685
        let cfg = Config::default();
686
        assert_eq!(cfg.roles().unwrap(), Roles { web: true, worker: true });
687
        assert_eq!(cfg.roles().unwrap().names(), ["web", "worker"]);
688
        assert_eq!(Roles::from_names(&["worker"]).unwrap(), Roles { web: false, worker: true });
689
        assert_eq!(Roles::from_names(&[" Web ", "web"]).unwrap().names(), ["web"]);
690
        assert_eq!(Roles::from_names(&["worker", "web"]).unwrap().names(), ["web", "worker"]);
691
        assert_eq!(Roles::from_names::<&str>(&[]).unwrap(), Roles::default());
692
        let e = Roles::from_names(&["web", "edge"]).unwrap_err();
693
        assert_eq!(e, "unknown role \"edge\": the roles are web and worker");
694
695
        let mut cfg = Config::parse("[server]\nroles = [\"worker\"]\n").unwrap();
696
        assert_eq!(cfg.roles().unwrap().names(), ["worker"]);
697
        cfg.apply_env(|k| (k == "OXYHUB_SERVER_ROLES").then(|| "web, worker".into()));
698
        assert_eq!(cfg.roles().unwrap(), Roles::default());
699
        cfg.apply_env(|k| (k == "OXYHUB_SERVER_ROLES").then(|| "store".into()));
700
        assert!(cfg.roles().unwrap_err().to_string().contains("unknown role \"store\""));
701
    }
702
703
    #[test]
704
    fn an_unknown_role_in_hub_toml_stops_loading() {
705
        let dir = tempfile::tempdir().unwrap();
706
        std::fs::write(dir.path().join("hub.toml"), "[server]\nroles = [\"wrker\"]\n").unwrap();
707
        let e = Config::load(dir.path()).unwrap_err().to_string();
708
        assert!(e.contains("unknown role \"wrker\""), "{e}");
d23464c0 Andy McCall ↶ 709
    }
710
}