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