forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
19.8 KiB
1//! Instance configuration, read from TOML.
2
3use std::net::{IpAddr, SocketAddr};
4use std::path::{Path, PathBuf};
5
6use ipnet::IpNet;
7use serde::{Deserialize, Deserializer};
8
9use crate::error::{Error, Result};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum Registration {
14 Closed,
15 Invite,
16 Open,
17}
18
19/// Parses each entry as a CIDR network or as a single address (a /32 or /128).
20fn deserialize_trusted_proxies<'de, D: Deserializer<'de>>(
21 deserializer: D,
22) -> std::result::Result<Vec<IpNet>, D::Error> {
23 let entries = Vec::<String>::deserialize(deserializer)?;
24 entries
25 .iter()
26 .map(|entry| {
27 if let Ok(net) = entry.parse::<IpNet>() {
28 return Ok(net);
29 }
30 entry
31 .parse::<IpAddr>()
32 .map(IpNet::from)
33 .map_err(|_| serde::de::Error::custom(format!("invalid trusted proxy `{entry}`")))
34 })
35 .collect()
36}
37
38fn default_user_content_listen() -> SocketAddr {
39 "127.0.0.1:7661"
40 .parse()
41 .expect("valid default socket address")
42}
43
44/// Outbound webhook delivery policy. Internal destinations are refused unless listed in `allowed_hosts`.
45#[derive(Debug, Clone, Default, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct WebhookConfig {
48 /// Permit `http://` hook URLs in addition to `https://`.
49 #[serde(default)]
50 pub allow_http: bool,
51 /// Exact hostnames or CIDR networks that may be reached even when they resolve to internal addresses.
52 #[serde(default)]
53 pub allowed_hosts: Vec<String>,
54}
55
56#[derive(Debug, Clone, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct Config {
59 pub database: PathBuf,
60 pub data_dir: PathBuf,
61 pub listen: SocketAddr,
62 pub base_url: String,
63 pub user_content_base_url: String,
64 /// Listener that serves raw user files on the separate user-content origin.
65 #[serde(default = "default_user_content_listen")]
66 pub user_content_listen: SocketAddr,
67 pub ssh_listen: SocketAddr,
68 pub ssh_public_host: String,
69 pub ssh_public_port: u16,
70 pub registration: Registration,
71 /// Peers whose `X-Forwarded-For` header is trusted, such as the reverse proxy in front of forge.
72 /// Entries are addresses or CIDR networks. Empty by default, so the TCP peer is the client.
73 #[serde(default, deserialize_with = "deserialize_trusted_proxies")]
74 pub trusted_proxies: Vec<IpNet>,
75 /// Outbound webhook policy, from the `[webhooks]` section.
76 #[serde(default)]
77 pub webhooks: WebhookConfig,
78 /// Scheduled backups and fsck, from the `[backup]` section. An empty `dir` disables both.
79 #[serde(default)]
80 pub backup: BackupConfig,
81 /// Release asset limits, from the `[releases]` section.
82 #[serde(default)]
83 pub releases: ReleasesConfig,
84 /// Standby replication, from the `[replication]` section. An empty `target` disables it.
85 #[serde(default)]
86 pub replication: ReplicationConfig,
87 /// Git LFS limits, from the `[lfs]` section.
88 #[serde(default)]
89 pub lfs: LfsConfig,
90 /// CI limits and retention, from the `[ci]` section.
91 #[serde(default)]
92 pub ci: CiConfig,
93 /// Security advisories (opt-in), from the `[advisories]` section.
94 #[serde(default)]
95 pub advisories: AdvisoriesConfig,
96 /// Outgoing mail transport, from the `[mail]` section. The SMTP account is set in the database.
97 #[serde(default)]
98 pub mail: MailConfig,
99 /// Organization rules, from the `[orgs]` section.
100 #[serde(default)]
101 pub orgs: OrgsConfig,
102}
103
104/// Organization rules, from the `[orgs]` section.
105#[derive(Debug, Clone, Default, Deserialize)]
106#[serde(deny_unknown_fields, default)]
107pub struct OrgsConfig {
108 /// Who may create an organization: `all` (any signed-in person, the default) or `admin`
109 /// (instance admins only).
110 pub creation: OrgCreation,
111}
112
113/// Who may create organizations. See `OrgsConfig::creation`.
114#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
115#[serde(rename_all = "lowercase")]
116pub enum OrgCreation {
117 #[default]
118 All,
119 Admin,
120}
121
122/// Outgoing mail transport, from the `[mail]` section. The SMTP server, sender and password are
123/// set on `/admin/mail` and stored in the database; this section only picks the transport.
124#[derive(Debug, Clone, Default, Deserialize)]
125#[serde(deny_unknown_fields, default)]
126pub struct MailConfig {
127 pub transport: MailTransport,
128}
129
130/// `smtp` sends through the configured server. `log` writes each message to `<data_dir>/mail-log/`
131/// instead, for development and tests.
132#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
133#[serde(rename_all = "lowercase")]
134pub enum MailTransport {
135 #[default]
136 Smtp,
137 Log,
138}
139
140/// CI settings, from the `[ci]` section.
141#[derive(Debug, Clone, Deserialize)]
142#[serde(deny_unknown_fields, default)]
143pub struct CiConfig {
144 /// Whether CI runs at all. When false, pushes and pull requests create no runs.
145 pub enabled: bool,
146 /// Largest job log, in MiB. Past it the log is truncated.
147 pub max_log_mb: u64,
148 /// Largest artifact, in MiB.
149 pub max_artifact_mb: u64,
150 /// Artifacts a single job may upload.
151 pub max_artifacts_per_job: u32,
152 /// Days a job log is kept.
153 pub log_retention_days: u64,
154 /// Days an artifact is kept.
155 pub artifact_retention_days: u64,
156 /// Hours a runner registration token stays valid.
157 pub registration_token_hours: u32,
158}
159
160impl Default for CiConfig {
161 fn default() -> Self {
162 CiConfig {
163 enabled: true,
164 max_log_mb: 16,
165 max_artifact_mb: 512,
166 max_artifacts_per_job: 50,
167 log_retention_days: 90,
168 artifact_retention_days: 30,
169 registration_token_hours: 24,
170 }
171 }
172}
173
174/// Security advisories, from the `[advisories]` section. Off by default: when on, the forge
175/// downloads the OSV dumps for `ecosystems` and scans each repo's lockfiles.
176#[derive(Debug, Clone, Deserialize)]
177#[serde(deny_unknown_fields, default)]
178pub struct AdvisoriesConfig {
179 /// Whether the advisory download and the lockfile scans run at all.
180 pub enabled: bool,
181 /// OSV ecosystems to download and scan: any of `crates.io`, `npm`, `Go`.
182 pub ecosystems: Vec<String>,
183 /// Hours before a downloaded dump is fetched again.
184 pub refresh_hours: u64,
185}
186
187impl Default for AdvisoriesConfig {
188 fn default() -> Self {
189 AdvisoriesConfig {
190 enabled: false,
191 ecosystems: vec!["crates.io".into(), "npm".into(), "Go".into()],
192 refresh_hours: 24,
193 }
194 }
195}
196
197/// Git LFS limits, from the `[lfs]` section.
198#[derive(Debug, Clone, Deserialize)]
199#[serde(deny_unknown_fields, default)]
200pub struct LfsConfig {
201 /// Largest accepted LFS object, in MiB.
202 pub max_object_mb: u64,
203}
204
205impl Default for LfsConfig {
206 fn default() -> Self {
207 LfsConfig {
208 max_object_mb: 2048,
209 }
210 }
211}
212
213impl LfsConfig {
214 /// The object size limit in bytes.
215 pub fn max_object_bytes(&self) -> u64 {
216 self.max_object_mb.saturating_mul(1024 * 1024)
217 }
218}
219
220/// Standby replication, from the `[replication]` section. See `docs/backup.md`.
221#[derive(Debug, Clone, Deserialize)]
222#[serde(deny_unknown_fields, default)]
223pub struct ReplicationConfig {
224 /// `[user@]host:/absolute/base` of the standby account. Empty disables replication.
225 pub target: String,
226 /// SSH port of the standby host.
227 pub ssh_port: u16,
228 /// Private key of the standby account (mode 0600). Required when `target` is set.
229 pub identity_file: PathBuf,
230 /// Pinned host keys of the standby. Required when `target` is set; StrictHostKeyChecking is always `yes`.
231 pub known_hosts_file: PathBuf,
232 /// Minutes between database snapshots shipped to the standby.
233 pub db_snapshot_minutes: u64,
234 /// Minutes between reconcile passes (refs checksums, deleted repos, healthcheck).
235 pub reconcile_minutes: u64,
236 /// Optional URL pinged after each reconcile pass. Empty disables it.
237 pub healthcheck_url: String,
238}
239
240impl Default for ReplicationConfig {
241 fn default() -> Self {
242 ReplicationConfig {
243 target: String::new(),
244 ssh_port: 22,
245 identity_file: PathBuf::new(),
246 known_hosts_file: PathBuf::new(),
247 db_snapshot_minutes: 15,
248 reconcile_minutes: 60,
249 healthcheck_url: String::new(),
250 }
251 }
252}
253
254impl ReplicationConfig {
255 /// Whether the replication worker runs at all.
256 pub fn enabled(&self) -> bool {
257 !self.target.is_empty()
258 }
259
260 fn validate(&self) -> Result<()> {
261 if !self.enabled() {
262 return Ok(());
263 }
264 if self.identity_file.as_os_str().is_empty() {
265 return Err(Error::Config(
266 "[replication] identity_file is required when target is set".into(),
267 ));
268 }
269 if self.known_hosts_file.as_os_str().is_empty() {
270 return Err(Error::Config(
271 "[replication] known_hosts_file is required when target is set".into(),
272 ));
273 }
274 if self.db_snapshot_minutes == 0 {
275 return Err(Error::Config(
276 "[replication] db_snapshot_minutes must be at least 1".into(),
277 ));
278 }
279 if self.reconcile_minutes == 0 {
280 return Err(Error::Config(
281 "[replication] reconcile_minutes must be at least 1".into(),
282 ));
283 }
284 Ok(())
285 }
286}
287
288/// Release settings, from the `[releases]` section.
289#[derive(Debug, Clone, Deserialize)]
290#[serde(deny_unknown_fields, default)]
291pub struct ReleasesConfig {
292 /// Largest accepted release asset, in mebibytes.
293 pub max_asset_mb: u64,
294}
295
296impl Default for ReleasesConfig {
297 fn default() -> Self {
298 ReleasesConfig { max_asset_mb: 512 }
299 }
300}
301
302/// Scheduled backups, from the `[backup]` section. See `docs/backup-interfaces.md`.
303#[derive(Debug, Clone, Deserialize)]
304#[serde(deny_unknown_fields, default)]
305pub struct BackupConfig {
306 /// Directory that receives the backup sets. Empty disables the scheduler.
307 pub dir: PathBuf,
308 /// Hours between full backup sets.
309 pub interval_hours: u64,
310 /// Newest complete sets kept in `dir`. Older complete sets are deleted after a good run.
311 pub keep: usize,
312 /// Optional healthchecks.io style URL. Pinged with `/start`, then the URL itself or `/fail`.
313 pub healthcheck_url: String,
314 /// Days between `git fsck` and integrity checks of every repo and the database.
315 pub fsck_interval_days: u64,
316}
317
318impl Default for BackupConfig {
319 fn default() -> Self {
320 BackupConfig {
321 dir: PathBuf::new(),
322 interval_hours: 24,
323 keep: 7,
324 healthcheck_url: String::new(),
325 fsck_interval_days: 7,
326 }
327 }
328}
329
330impl BackupConfig {
331 /// Whether the scheduler runs at all.
332 pub fn enabled(&self) -> bool {
333 !self.dir.as_os_str().is_empty()
334 }
335
336 fn validate(&self) -> Result<()> {
337 if self.interval_hours == 0 {
338 return Err(Error::Config(
339 "[backup] interval_hours must be at least 1".into(),
340 ));
341 }
342 if self.keep == 0 {
343 return Err(Error::Config("[backup] keep must be at least 1".into()));
344 }
345 if self.fsck_interval_days == 0 {
346 return Err(Error::Config(
347 "[backup] fsck_interval_days must be at least 1".into(),
348 ));
349 }
350 Ok(())
351 }
352}
353
354/// Development defaults, written when the config file is missing. Keep in sync with `config/forge.example.toml`.
355const DEV_DEFAULTS: &str = r#"# forge configuration (development defaults). Production values belong in /etc/forge/forge.toml.
356
357# SQLite/Turso database file.
358database = "./data/forge.db"
359# Root for repos, instance key, gitconfig, hooks and sockets.
360data_dir = "./data"
361# HTTP listen address for the web UI and smart-HTTP.
362listen = "127.0.0.1:7660"
363# Public base URL used in links and clone URLs.
364base_url = "http://127.0.0.1:7660"
365# Separate origin for raw user content (Milestone 2).
366user_content_base_url = "http://127.0.0.1:7661"
367# Listener for raw user content, served on a separate origin.
368user_content_listen = "127.0.0.1:7661"
369# SSH listen address for git over SSH.
370ssh_listen = "0.0.0.0:2222"
371# Host and port shown in SSH clone URLs.
372ssh_public_host = "127.0.0.1"
373ssh_public_port = 2222
374# Self-registration: "closed" | "invite" | "open".
375registration = "closed"
376# Proxies whose X-Forwarded-For is trusted for client IPs (login and git throttling). Addresses or CIDRs.
377trusted_proxies = []
378"#;
379
380impl Config {
381 /// Loads the config from `path`. Writes development defaults if the file is missing.
382 pub fn load(path: &Path) -> Result<Config> {
383 let text = match std::fs::read_to_string(path) {
384 Ok(text) => text,
385 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
386 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
387 std::fs::create_dir_all(parent)?;
388 }
389 std::fs::write(path, DEV_DEFAULTS)?;
390 DEV_DEFAULTS.to_owned()
391 }
392 Err(e) => return Err(e.into()),
393 };
394 let config: Config =
395 toml::from_str(&text).map_err(|e| Error::Config(format!("{}: {e}", path.display())))?;
396 config.backup.validate()?;
397 config.replication.validate()?;
398 Ok(config)
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn missing_file_writes_dev_defaults() {
408 let dir = tempfile::tempdir().unwrap();
409 let path = dir.path().join("nested").join("forge.toml");
410 let cfg = Config::load(&path).unwrap();
411 assert!(path.exists());
412 assert_eq!(cfg.database, PathBuf::from("./data/forge.db"));
413 assert_eq!(cfg.listen, "127.0.0.1:7660".parse().unwrap());
414 assert_eq!(cfg.user_content_listen, "127.0.0.1:7661".parse().unwrap());
415 assert_eq!(cfg.ssh_listen, "0.0.0.0:2222".parse().unwrap());
416 assert_eq!(cfg.ssh_public_port, 2222);
417 assert_eq!(cfg.registration, Registration::Closed);
418 // Reloading the written file gives the same config.
419 let again = Config::load(&path).unwrap();
420 assert_eq!(again.base_url, cfg.base_url);
421 }
422
423 #[test]
424 fn missing_user_content_listen_uses_default() {
425 let dir = tempfile::tempdir().unwrap();
426 let path = dir.path().join("forge.toml");
427 let text: String = DEV_DEFAULTS
428 .lines()
429 .filter(|line| !line.starts_with("user_content_listen"))
430 .collect::<Vec<_>>()
431 .join("\n");
432 std::fs::write(&path, text).unwrap();
433 let cfg = Config::load(&path).unwrap();
434 assert_eq!(cfg.user_content_listen, "127.0.0.1:7661".parse().unwrap());
435 }
436
437 #[test]
438 fn user_content_listen_is_parsed() {
439 let dir = tempfile::tempdir().unwrap();
440 let path = dir.path().join("forge.toml");
441 let text = DEV_DEFAULTS.replace("127.0.0.1:7661\"\n# SSH", "0.0.0.0:9000\"\n# SSH");
442 std::fs::write(&path, text).unwrap();
443 let cfg = Config::load(&path).unwrap();
444 assert_eq!(cfg.user_content_listen, "0.0.0.0:9000".parse().unwrap());
445 }
446
447 #[test]
448 fn unknown_keys_are_rejected() {
449 let dir = tempfile::tempdir().unwrap();
450 let path = dir.path().join("forge.toml");
451 let text = DEV_DEFAULTS.replace("registration", "bogus = 1\nregistration");
452 std::fs::write(&path, text).unwrap();
453 assert!(matches!(Config::load(&path), Err(Error::Config(_))));
454 }
455
456 #[test]
457 fn registration_is_lowercase() {
458 let dir = tempfile::tempdir().unwrap();
459 let path = dir.path().join("forge.toml");
460 std::fs::write(&path, DEV_DEFAULTS.replace("\"closed\"", "\"invite\"")).unwrap();
461 assert_eq!(
462 Config::load(&path).unwrap().registration,
463 Registration::Invite
464 );
465 std::fs::write(&path, DEV_DEFAULTS.replace("\"closed\"", "\"Invite\"")).unwrap();
466 assert!(Config::load(&path).is_err());
467 }
468
469 #[test]
470 fn trusted_proxies_accept_addresses_and_networks() {
471 let dir = tempfile::tempdir().unwrap();
472 let path = dir.path().join("forge.toml");
473 let text = DEV_DEFAULTS.replace(
474 "trusted_proxies = []",
475 "trusted_proxies = [\"127.0.0.1\", \"10.0.0.0/8\", \"2001:db8::/32\"]",
476 );
477 std::fs::write(&path, text).unwrap();
478 let cfg = Config::load(&path).unwrap();
479 assert_eq!(cfg.trusted_proxies.len(), 3);
480 assert_eq!(
481 cfg.trusted_proxies[0],
482 "127.0.0.1/32".parse::<IpNet>().unwrap()
483 );
484 assert_eq!(
485 cfg.trusted_proxies[1],
486 "10.0.0.0/8".parse::<IpNet>().unwrap()
487 );
488 }
489
490 #[test]
491 fn invalid_trusted_proxy_is_rejected() {
492 let dir = tempfile::tempdir().unwrap();
493 let path = dir.path().join("forge.toml");
494 let text =
495 DEV_DEFAULTS.replace("trusted_proxies = []", "trusted_proxies = [\"not-an-ip\"]");
496 std::fs::write(&path, text).unwrap();
497 assert!(matches!(Config::load(&path), Err(Error::Config(_))));
498 }
499
500 #[test]
501 fn backup_section_defaults_to_disabled() {
502 let dir = tempfile::tempdir().unwrap();
503 let path = dir.path().join("forge.toml");
504 std::fs::write(&path, DEV_DEFAULTS).unwrap();
505 let cfg = Config::load(&path).unwrap();
506 assert!(!cfg.backup.enabled());
507 assert_eq!(cfg.backup.interval_hours, 24);
508 assert_eq!(cfg.backup.keep, 7);
509 assert_eq!(cfg.backup.fsck_interval_days, 7);
510 assert!(cfg.backup.healthcheck_url.is_empty());
511 }
512
513 #[test]
514 fn replication_section_defaults_to_disabled() {
515 let dir = tempfile::tempdir().unwrap();
516 let path = dir.path().join("forge.toml");
517 std::fs::write(&path, DEV_DEFAULTS).unwrap();
518 let cfg = Config::load(&path).unwrap();
519 assert!(!cfg.replication.enabled());
520 assert_eq!(cfg.replication.ssh_port, 22);
521 assert_eq!(cfg.replication.db_snapshot_minutes, 15);
522 assert_eq!(cfg.replication.reconcile_minutes, 60);
523 assert!(cfg.replication.healthcheck_url.is_empty());
524 }
525
526 #[test]
527 fn replication_target_requires_keys_and_positive_intervals() {
528 let dir = tempfile::tempdir().unwrap();
529 let path = dir.path().join("forge.toml");
530 let base = format!("{DEV_DEFAULTS}\n[replication]\ntarget = \"sb@host:/srv/standby\"\n");
531 std::fs::write(&path, &base).unwrap();
532 assert!(matches!(Config::load(&path), Err(Error::Config(_))));
533 std::fs::write(
534 &path,
535 format!("{base}identity_file = \"/k\"\nknown_hosts_file = \"/kh\"\n"),
536 )
537 .unwrap();
538 let cfg = Config::load(&path).unwrap();
539 assert!(cfg.replication.enabled());
540 assert_eq!(cfg.replication.identity_file, PathBuf::from("/k"));
541 std::fs::write(
542 &path,
543 format!(
544 "{base}identity_file = \"/k\"\nknown_hosts_file = \"/kh\"\nreconcile_minutes = 0\n"
545 ),
546 )
547 .unwrap();
548 assert!(matches!(Config::load(&path), Err(Error::Config(_))));
549 }
550
551 #[test]
552 fn backup_section_is_parsed_and_validated() {
553 let dir = tempfile::tempdir().unwrap();
554 let path = dir.path().join("forge.toml");
555 let text = format!(
556 "{DEV_DEFAULTS}\n[backup]\ndir = \"/srv/backups\"\nkeep = 3\ninterval_hours = 6\n"
557 );
558 std::fs::write(&path, &text).unwrap();
559 let cfg = Config::load(&path).unwrap();
560 assert!(cfg.backup.enabled());
561 assert_eq!(cfg.backup.dir, PathBuf::from("/srv/backups"));
562 assert_eq!(cfg.backup.keep, 3);
563 assert_eq!(cfg.backup.interval_hours, 6);
564
565 std::fs::write(&path, format!("{DEV_DEFAULTS}\n[backup]\nkeep = 0\n")).unwrap();
566 assert!(matches!(Config::load(&path), Err(Error::Config(_))));
567 std::fs::write(&path, format!("{DEV_DEFAULTS}\n[backup]\nbogus = 1\n")).unwrap();
568 assert!(matches!(Config::load(&path), Err(Error::Config(_))));
569 }
570}