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