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