//! Instance configuration, read from TOML. use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; use ipnet::IpNet; use serde::{Deserialize, Deserializer}; use crate::error::{Error, Result}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Registration { Closed, Invite, Open, } /// Parses each entry as a CIDR network or as a single address (a /32 or /128). fn deserialize_trusted_proxies<'de, D: Deserializer<'de>>( deserializer: D, ) -> std::result::Result, D::Error> { let entries = Vec::::deserialize(deserializer)?; entries .iter() .map(|entry| { if let Ok(net) = entry.parse::() { return Ok(net); } entry .parse::() .map(IpNet::from) .map_err(|_| serde::de::Error::custom(format!("invalid trusted proxy `{entry}`"))) }) .collect() } fn default_user_content_listen() -> SocketAddr { "127.0.0.1:7661" .parse() .expect("valid default socket address") } /// Outbound webhook delivery policy. Internal destinations are refused unless listed in `allowed_hosts`. #[derive(Debug, Clone, Default, Deserialize)] #[serde(deny_unknown_fields)] pub struct WebhookConfig { /// Permit `http://` hook URLs in addition to `https://`. #[serde(default)] pub allow_http: bool, /// Exact hostnames or CIDR networks that may be reached even when they resolve to internal addresses. #[serde(default)] pub allowed_hosts: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { pub database: PathBuf, pub data_dir: PathBuf, pub listen: SocketAddr, pub base_url: String, pub user_content_base_url: String, /// Listener that serves raw user files on the separate user-content origin. #[serde(default = "default_user_content_listen")] pub user_content_listen: SocketAddr, pub ssh_listen: SocketAddr, pub ssh_public_host: String, pub ssh_public_port: u16, pub registration: Registration, /// Peers whose `X-Forwarded-For` header is trusted, such as the reverse proxy in front of forge. /// Entries are addresses or CIDR networks. Empty by default, so the TCP peer is the client. #[serde(default, deserialize_with = "deserialize_trusted_proxies")] pub trusted_proxies: Vec, /// Outbound webhook policy, from the `[webhooks]` section. #[serde(default)] pub webhooks: WebhookConfig, /// Scheduled backups and fsck, from the `[backup]` section. An empty `dir` disables both. #[serde(default)] pub backup: BackupConfig, /// Release asset limits, from the `[releases]` section. #[serde(default)] pub releases: ReleasesConfig, /// Standby replication, from the `[replication]` section. An empty `target` disables it. #[serde(default)] pub replication: ReplicationConfig, /// Git LFS limits, from the `[lfs]` section. #[serde(default)] pub lfs: LfsConfig, /// CI limits and retention, from the `[ci]` section. #[serde(default)] pub ci: CiConfig, /// Security advisories (opt-in), from the `[advisories]` section. #[serde(default)] pub advisories: AdvisoriesConfig, /// Outgoing mail transport, from the `[mail]` section. The SMTP account is set in the database. #[serde(default)] pub mail: MailConfig, /// Organization rules, from the `[orgs]` section. #[serde(default)] pub orgs: OrgsConfig, } /// Organization rules, from the `[orgs]` section. #[derive(Debug, Clone, Default, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct OrgsConfig { /// Who may create an organization: `all` (any signed-in person, the default) or `admin` /// (instance admins only). pub creation: OrgCreation, } /// Who may create organizations. See `OrgsConfig::creation`. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum OrgCreation { #[default] All, Admin, } /// Outgoing mail transport, from the `[mail]` section. The SMTP server, sender and password are /// set on `/admin/mail` and stored in the database; this section only picks the transport. #[derive(Debug, Clone, Default, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct MailConfig { pub transport: MailTransport, } /// `smtp` sends through the configured server. `log` writes each message to `/mail-log/` /// instead, for development and tests. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum MailTransport { #[default] Smtp, Log, } /// CI settings, from the `[ci]` section. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct CiConfig { /// Whether CI runs at all. When false, pushes and pull requests create no runs. pub enabled: bool, /// Largest job log, in MiB. Past it the log is truncated. pub max_log_mb: u64, /// Largest artifact, in MiB. pub max_artifact_mb: u64, /// Artifacts a single job may upload. pub max_artifacts_per_job: u32, /// Days a job log is kept. pub log_retention_days: u64, /// Days an artifact is kept. pub artifact_retention_days: u64, /// Hours a runner registration token stays valid. pub registration_token_hours: u32, } impl Default for CiConfig { fn default() -> Self { CiConfig { enabled: true, max_log_mb: 16, max_artifact_mb: 512, max_artifacts_per_job: 50, log_retention_days: 90, artifact_retention_days: 30, registration_token_hours: 24, } } } /// Security advisories, from the `[advisories]` section. Off by default: when on, the forge /// downloads the OSV dumps for `ecosystems` and scans each repo's lockfiles. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct AdvisoriesConfig { /// Whether the advisory download and the lockfile scans run at all. pub enabled: bool, /// OSV ecosystems to download and scan: any of `crates.io`, `npm`, `Go`. pub ecosystems: Vec, /// Hours before a downloaded dump is fetched again. pub refresh_hours: u64, } impl Default for AdvisoriesConfig { fn default() -> Self { AdvisoriesConfig { enabled: false, ecosystems: vec!["crates.io".into(), "npm".into(), "Go".into()], refresh_hours: 24, } } } /// Git LFS limits, from the `[lfs]` section. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct LfsConfig { /// Largest accepted LFS object, in MiB. pub max_object_mb: u64, } impl Default for LfsConfig { fn default() -> Self { LfsConfig { max_object_mb: 2048, } } } impl LfsConfig { /// The object size limit in bytes. pub fn max_object_bytes(&self) -> u64 { self.max_object_mb.saturating_mul(1024 * 1024) } } /// Standby replication, from the `[replication]` section. See `docs/backup.md`. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct ReplicationConfig { /// `[user@]host:/absolute/base` of the standby account. Empty disables replication. pub target: String, /// SSH port of the standby host. pub ssh_port: u16, /// Private key of the standby account (mode 0600). Required when `target` is set. pub identity_file: PathBuf, /// Pinned host keys of the standby. Required when `target` is set; StrictHostKeyChecking is always `yes`. pub known_hosts_file: PathBuf, /// Minutes between database snapshots shipped to the standby. pub db_snapshot_minutes: u64, /// Minutes between reconcile passes (refs checksums, deleted repos, healthcheck). pub reconcile_minutes: u64, /// Optional URL pinged after each reconcile pass. Empty disables it. pub healthcheck_url: String, } impl Default for ReplicationConfig { fn default() -> Self { ReplicationConfig { target: String::new(), ssh_port: 22, identity_file: PathBuf::new(), known_hosts_file: PathBuf::new(), db_snapshot_minutes: 15, reconcile_minutes: 60, healthcheck_url: String::new(), } } } impl ReplicationConfig { /// Whether the replication worker runs at all. pub fn enabled(&self) -> bool { !self.target.is_empty() } fn validate(&self) -> Result<()> { if !self.enabled() { return Ok(()); } if self.identity_file.as_os_str().is_empty() { return Err(Error::Config( "[replication] identity_file is required when target is set".into(), )); } if self.known_hosts_file.as_os_str().is_empty() { return Err(Error::Config( "[replication] known_hosts_file is required when target is set".into(), )); } if self.db_snapshot_minutes == 0 { return Err(Error::Config( "[replication] db_snapshot_minutes must be at least 1".into(), )); } if self.reconcile_minutes == 0 { return Err(Error::Config( "[replication] reconcile_minutes must be at least 1".into(), )); } Ok(()) } } /// Release settings, from the `[releases]` section. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct ReleasesConfig { /// Largest accepted release asset, in mebibytes. pub max_asset_mb: u64, } impl Default for ReleasesConfig { fn default() -> Self { ReleasesConfig { max_asset_mb: 512 } } } /// Scheduled backups, from the `[backup]` section. See `docs/backup-interfaces.md`. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct BackupConfig { /// Directory that receives the backup sets. Empty disables the scheduler. pub dir: PathBuf, /// Hours between full backup sets. pub interval_hours: u64, /// Newest complete sets kept in `dir`. Older complete sets are deleted after a good run. pub keep: usize, /// Optional healthchecks.io style URL. Pinged with `/start`, then the URL itself or `/fail`. pub healthcheck_url: String, /// Days between `git fsck` and integrity checks of every repo and the database. pub fsck_interval_days: u64, } impl Default for BackupConfig { fn default() -> Self { BackupConfig { dir: PathBuf::new(), interval_hours: 24, keep: 7, healthcheck_url: String::new(), fsck_interval_days: 7, } } } impl BackupConfig { /// Whether the scheduler runs at all. pub fn enabled(&self) -> bool { !self.dir.as_os_str().is_empty() } fn validate(&self) -> Result<()> { if self.interval_hours == 0 { return Err(Error::Config( "[backup] interval_hours must be at least 1".into(), )); } if self.keep == 0 { return Err(Error::Config("[backup] keep must be at least 1".into())); } if self.fsck_interval_days == 0 { return Err(Error::Config( "[backup] fsck_interval_days must be at least 1".into(), )); } Ok(()) } } /// Development defaults, written when the config file is missing. Keep in sync with `config/forge.example.toml`. const DEV_DEFAULTS: &str = r#"# forge configuration (development defaults). Production values belong in /etc/forge/forge.toml. # SQLite/Turso database file. database = "./data/forge.db" # Root for repos, instance key, gitconfig, hooks and sockets. data_dir = "./data" # HTTP listen address for the web UI and smart-HTTP. listen = "127.0.0.1:7660" # Public base URL used in links and clone URLs. base_url = "http://127.0.0.1:7660" # Separate origin for raw user content (Milestone 2). user_content_base_url = "http://127.0.0.1:7661" # Listener for raw user content, served on a separate origin. user_content_listen = "127.0.0.1:7661" # SSH listen address for git over SSH. ssh_listen = "0.0.0.0:2222" # Host and port shown in SSH clone URLs. ssh_public_host = "127.0.0.1" ssh_public_port = 2222 # Self-registration: "closed" | "invite" | "open". registration = "closed" # Proxies whose X-Forwarded-For is trusted for client IPs (login and git throttling). Addresses or CIDRs. trusted_proxies = [] "#; impl Config { /// Loads the config from `path`. Writes development defaults if the file is missing. pub fn load(path: &Path) -> Result { let text = match std::fs::read_to_string(path) { Ok(text) => text, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { std::fs::create_dir_all(parent)?; } std::fs::write(path, DEV_DEFAULTS)?; DEV_DEFAULTS.to_owned() } Err(e) => return Err(e.into()), }; let config: Config = toml::from_str(&text).map_err(|e| Error::Config(format!("{}: {e}", path.display())))?; config.backup.validate()?; config.replication.validate()?; Ok(config) } } #[cfg(test)] mod tests { use super::*; #[test] fn missing_file_writes_dev_defaults() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("nested").join("forge.toml"); let cfg = Config::load(&path).unwrap(); assert!(path.exists()); assert_eq!(cfg.database, PathBuf::from("./data/forge.db")); assert_eq!(cfg.listen, "127.0.0.1:7660".parse().unwrap()); assert_eq!(cfg.user_content_listen, "127.0.0.1:7661".parse().unwrap()); assert_eq!(cfg.ssh_listen, "0.0.0.0:2222".parse().unwrap()); assert_eq!(cfg.ssh_public_port, 2222); assert_eq!(cfg.registration, Registration::Closed); // Reloading the written file gives the same config. let again = Config::load(&path).unwrap(); assert_eq!(again.base_url, cfg.base_url); } #[test] fn missing_user_content_listen_uses_default() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("forge.toml"); let text: String = DEV_DEFAULTS .lines() .filter(|line| !line.starts_with("user_content_listen")) .collect::>() .join("\n"); std::fs::write(&path, text).unwrap(); let cfg = Config::load(&path).unwrap(); assert_eq!(cfg.user_content_listen, "127.0.0.1:7661".parse().unwrap()); } #[test] fn user_content_listen_is_parsed() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("forge.toml"); let text = DEV_DEFAULTS.replace("127.0.0.1:7661\"\n# SSH", "0.0.0.0:9000\"\n# SSH"); std::fs::write(&path, text).unwrap(); let cfg = Config::load(&path).unwrap(); assert_eq!(cfg.user_content_listen, "0.0.0.0:9000".parse().unwrap()); } #[test] fn unknown_keys_are_rejected() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("forge.toml"); let text = DEV_DEFAULTS.replace("registration", "bogus = 1\nregistration"); std::fs::write(&path, text).unwrap(); assert!(matches!(Config::load(&path), Err(Error::Config(_)))); } #[test] fn registration_is_lowercase() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("forge.toml"); std::fs::write(&path, DEV_DEFAULTS.replace("\"closed\"", "\"invite\"")).unwrap(); assert_eq!( Config::load(&path).unwrap().registration, Registration::Invite ); std::fs::write(&path, DEV_DEFAULTS.replace("\"closed\"", "\"Invite\"")).unwrap(); assert!(Config::load(&path).is_err()); } #[test] fn trusted_proxies_accept_addresses_and_networks() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("forge.toml"); let text = DEV_DEFAULTS.replace( "trusted_proxies = []", "trusted_proxies = [\"127.0.0.1\", \"10.0.0.0/8\", \"2001:db8::/32\"]", ); std::fs::write(&path, text).unwrap(); let cfg = Config::load(&path).unwrap(); assert_eq!(cfg.trusted_proxies.len(), 3); assert_eq!( cfg.trusted_proxies[0], "127.0.0.1/32".parse::().unwrap() ); assert_eq!( cfg.trusted_proxies[1], "10.0.0.0/8".parse::().unwrap() ); } #[test] fn invalid_trusted_proxy_is_rejected() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("forge.toml"); let text = DEV_DEFAULTS.replace("trusted_proxies = []", "trusted_proxies = [\"not-an-ip\"]"); std::fs::write(&path, text).unwrap(); assert!(matches!(Config::load(&path), Err(Error::Config(_)))); } #[test] fn backup_section_defaults_to_disabled() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("forge.toml"); std::fs::write(&path, DEV_DEFAULTS).unwrap(); let cfg = Config::load(&path).unwrap(); assert!(!cfg.backup.enabled()); assert_eq!(cfg.backup.interval_hours, 24); assert_eq!(cfg.backup.keep, 7); assert_eq!(cfg.backup.fsck_interval_days, 7); assert!(cfg.backup.healthcheck_url.is_empty()); } #[test] fn replication_section_defaults_to_disabled() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("forge.toml"); std::fs::write(&path, DEV_DEFAULTS).unwrap(); let cfg = Config::load(&path).unwrap(); assert!(!cfg.replication.enabled()); assert_eq!(cfg.replication.ssh_port, 22); assert_eq!(cfg.replication.db_snapshot_minutes, 15); assert_eq!(cfg.replication.reconcile_minutes, 60); assert!(cfg.replication.healthcheck_url.is_empty()); } #[test] fn replication_target_requires_keys_and_positive_intervals() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("forge.toml"); let base = format!("{DEV_DEFAULTS}\n[replication]\ntarget = \"sb@host:/srv/standby\"\n"); std::fs::write(&path, &base).unwrap(); assert!(matches!(Config::load(&path), Err(Error::Config(_)))); std::fs::write( &path, format!("{base}identity_file = \"/k\"\nknown_hosts_file = \"/kh\"\n"), ) .unwrap(); let cfg = Config::load(&path).unwrap(); assert!(cfg.replication.enabled()); assert_eq!(cfg.replication.identity_file, PathBuf::from("/k")); std::fs::write( &path, format!( "{base}identity_file = \"/k\"\nknown_hosts_file = \"/kh\"\nreconcile_minutes = 0\n" ), ) .unwrap(); assert!(matches!(Config::load(&path), Err(Error::Config(_)))); } #[test] fn backup_section_is_parsed_and_validated() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("forge.toml"); let text = format!( "{DEV_DEFAULTS}\n[backup]\ndir = \"/srv/backups\"\nkeep = 3\ninterval_hours = 6\n" ); std::fs::write(&path, &text).unwrap(); let cfg = Config::load(&path).unwrap(); assert!(cfg.backup.enabled()); assert_eq!(cfg.backup.dir, PathBuf::from("/srv/backups")); assert_eq!(cfg.backup.keep, 3); assert_eq!(cfg.backup.interval_hours, 6); std::fs::write(&path, format!("{DEV_DEFAULTS}\n[backup]\nkeep = 0\n")).unwrap(); assert!(matches!(Config::load(&path), Err(Error::Config(_)))); std::fs::write(&path, format!("{DEV_DEFAULTS}\n[backup]\nbogus = 1\n")).unwrap(); assert!(matches!(Config::load(&path), Err(Error::Config(_)))); } }