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