| 1 | //! Runtime configuration, read from the environment. | |
| 2 | //! | |
| 3 | //! Everything is validated at startup. A process that cannot serve correctly — | |
| 4 | //! no database, no OIDC, a placeholder session secret — must fail loudly at | |
| 5 | //! boot rather than at the first request that needs it. | |
| 6 | ||
| 7 | use anyhow::{bail, Context, Result}; | |
| 8 | use sqlx::types::ipnetwork::IpNetwork; | |
| 9 | ||
| 10 | #[derive(Debug, Clone)] | |
| 11 | pub struct Config { | |
| 12 | pub database_url: String, | |
| 13 | pub database_max_connections: u32, | |
| 14 | ||
| 15 | pub base_url: String, | |
| 16 | pub bind: String, | |
| 17 | pub repo_root: String, | |
| 18 | ||
| 19 | pub ssh_clone_host: String, | |
| 20 | pub ssh_clone_port: u16, | |
| 21 | ||
| 22 | pub oidc_issuer: String, | |
| 23 | pub oidc_client_id: String, | |
| 24 | pub oidc_client_secret: String, | |
| 25 | pub oidc_redirect_url: String, | |
| 26 | pub oidc_scopes: String, | |
| 27 | ||
| 28 | /// Emails admitted without an invitation. Blank entries are dropped so a | |
| 29 | /// stray comma cannot widen access. | |
| 30 | pub allowlist: Vec<String>, | |
| 31 | ||
| 32 | /// Networks whose `X-Forwarded-For` header is believed. | |
| 33 | /// | |
| 34 | /// Empty means "believe nobody", which is the only safe default: `XFF` is a | |
| 35 | /// request header, so trusting it from an arbitrary peer lets any client | |
| 36 | /// name its own address and walk straight through the rate limiter. | |
| 37 | /// | |
| 38 | /// This must list the edge proxy, or every request appears to come from the | |
| 39 | /// proxy and all anonymous traffic shares one bucket — which is not a | |
| 40 | /// tighter limit but a global one, and a single client can spend it. | |
| 41 | pub trusted_proxies: Vec<IpNetwork>, | |
| 42 | ||
| 43 | pub session_secret: Vec<u8>, | |
| 44 | pub session_ttl_days: i64, | |
| 45 | ||
| 46 | /// Path to the compiled `dogfood-hook` binary, as seen from *inside* the | |
| 47 | /// container that serves pushes. Installed into each repository's `hooks/` | |
| 48 | /// directory (spec §4: "Hooks are the compiled `dogfood-hook` binary … | |
| 49 | /// Do not use shell scripts"). | |
| 50 | pub hook_binary: String, | |
| 51 | ||
| 52 | pub max_pack_bytes: u64, | |
| 53 | pub max_blob_render_bytes: usize, | |
| 54 | pub max_diff_files: usize, | |
| 55 | pub max_diff_lines: usize, | |
| 56 | } | |
| 57 | ||
| 58 | fn var(key: &str) -> Result<String> { | |
| 59 | std::env::var(key).with_context(|| format!("{key} must be set")) | |
| 60 | } | |
| 61 | ||
| 62 | fn var_or(key: &str, default: &str) -> String { | |
| 63 | std::env::var(key).unwrap_or_else(|_| default.to_string()) | |
| 64 | } | |
| 65 | ||
| 66 | fn parse_num<T: std::str::FromStr>(key: &str, default: T) -> Result<T> | |
| 67 | where | |
| 68 | T::Err: std::fmt::Display, | |
| 69 | { | |
| 70 | match std::env::var(key) { | |
| 71 | Ok(v) => v | |
| 72 | .parse::<T>() | |
| 73 | .map_err(|e| anyhow::anyhow!("{key} is not a valid number: {e}")), | |
| 74 | Err(_) => Ok(default), | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | /// Parse `TRUSTED_PROXIES`: comma-separated CIDRs, or bare addresses meaning a | |
| 79 | /// single host. | |
| 80 | /// | |
| 81 | /// A malformed entry is fatal rather than skipped. Dropping one silently would | |
| 82 | /// mean the proxy is not trusted, every client looks like the proxy, and the | |
| 83 | /// rate limiter degrades to a single global bucket — a failure that presents as | |
| 84 | /// mysterious 429s rather than as a configuration error. | |
| 85 | fn parse_trusted_proxies(raw: &str) -> Result<Vec<IpNetwork>> { | |
| 86 | raw.split(',') | |
| 87 | .map(str::trim) | |
| 88 | .filter(|s| !s.is_empty()) | |
| 89 | .map(|s| { | |
| 90 | s.parse::<IpNetwork>() | |
| 91 | .with_context(|| format!("TRUSTED_PROXIES entry {s:?} is not an address or CIDR")) | |
| 92 | }) | |
| 93 | .collect() | |
| 94 | } | |
| 95 | ||
| 96 | impl Config { | |
| 97 | pub fn from_env() -> Result<Self> { | |
| 98 | let base_url = var_or("BASE_URL", "http://localhost:8080") | |
| 99 | .trim_end_matches('/') | |
| 100 | .to_string(); | |
| 101 | ||
| 102 | let session_secret_hex = var("SESSION_SECRET")?; | |
| 103 | let session_secret = hex::decode(session_secret_hex.trim()) | |
| 104 | .context("SESSION_SECRET must be hex")?; | |
| 105 | if session_secret.len() < 32 { | |
| 106 | bail!( | |
| 107 | "SESSION_SECRET must be at least 32 bytes ({} given); \ | |
| 108 | generate with `openssl rand -hex 32`", | |
| 109 | session_secret.len() | |
| 110 | ); | |
| 111 | } | |
| 112 | ||
| 113 | let allowlist = var_or("DOGFOOD_ALLOWLIST", "") | |
| 114 | .split(',') | |
| 115 | .map(|s| s.trim().to_string()) | |
| 116 | .filter(|s| !s.is_empty()) | |
| 117 | .collect::<Vec<_>>(); | |
| 118 | ||
| 119 | if allowlist.is_empty() { | |
| 120 | // Not fatal — invitations still work — but silence here would look | |
| 121 | // like a broken login rather than a deliberate policy. | |
| 122 | tracing::warn!( | |
| 123 | "DOGFOOD_ALLOWLIST is empty; only invited users will be able to sign in" | |
| 124 | ); | |
| 125 | } | |
| 126 | ||
| 127 | let trusted_proxies = parse_trusted_proxies(&var_or("TRUSTED_PROXIES", ""))?; | |
| 128 | if trusted_proxies.is_empty() { | |
| 129 | tracing::warn!( | |
| 130 | "TRUSTED_PROXIES is empty; rate limits key on the TCP peer. \ | |
| 131 | Behind a reverse proxy that makes every anonymous request share \ | |
| 132 | one bucket — set it to the proxy's network." | |
| 133 | ); | |
| 134 | } else { | |
| 135 | tracing::info!(?trusted_proxies, "trusting X-Forwarded-For from these networks"); | |
| 136 | } | |
| 137 | ||
| 138 | let cfg = Config { | |
| 139 | database_url: var("DATABASE_URL")?, | |
| 140 | database_max_connections: parse_num("DATABASE_MAX_CONNECTIONS", 10)?, | |
| 141 | ||
| 142 | bind: var_or("BIND", "0.0.0.0:8080"), | |
| 143 | repo_root: var_or("REPO_ROOT", "/srv/repos"), | |
| 144 | ||
| 145 | ssh_clone_host: var_or("SSH_CLONE_HOST", "localhost"), | |
| 146 | ssh_clone_port: parse_num("SSH_CLONE_PORT", 2222u16)?, | |
| 147 | ||
| 148 | oidc_issuer: var("OIDC_ISSUER")?, | |
| 149 | oidc_client_id: var("OIDC_CLIENT_ID")?, | |
| 150 | oidc_client_secret: var("OIDC_CLIENT_SECRET")?, | |
| 151 | oidc_redirect_url: var_or( | |
| 152 | "OIDC_REDIRECT_URL", | |
| 153 | &format!("{base_url}/auth/callback"), | |
| 154 | ), | |
| 155 | oidc_scopes: var_or("OIDC_SCOPES", "openid profile email"), | |
| 156 | ||
| 157 | allowlist, | |
| 158 | trusted_proxies, | |
| 159 | session_secret, | |
| 160 | session_ttl_days: parse_num("SESSION_TTL_DAYS", 14i64)?, | |
| 161 | ||
| 162 | hook_binary: var_or("DOGFOOD_HOOK_BINARY", "/usr/local/bin/dogfood-hook"), | |
| 163 | max_pack_bytes: parse_num("MAX_PACK_BYTES", 524_288_000u64)?, | |
| 164 | max_blob_render_bytes: parse_num("MAX_BLOB_RENDER_BYTES", 1_048_576)?, | |
| 165 | max_diff_files: parse_num("MAX_DIFF_FILES", 5_000)?, | |
| 166 | max_diff_lines: parse_num("MAX_DIFF_LINES", 100_000)?, | |
| 167 | ||
| 168 | base_url, | |
| 169 | }; | |
| 170 | ||
| 171 | cfg.validate()?; | |
| 172 | Ok(cfg) | |
| 173 | } | |
| 174 | ||
| 175 | fn validate(&self) -> Result<()> { | |
| 176 | if !self.base_url.starts_with("http://") && !self.base_url.starts_with("https://") { | |
| 177 | bail!("BASE_URL must be an absolute http(s) URL, got {}", self.base_url); | |
| 178 | } | |
| 179 | // The redirect URI must match what is registered with Hydra exactly, so | |
| 180 | // a mismatch is worth catching here rather than as an opaque OAuth error. | |
| 181 | if !self.oidc_redirect_url.starts_with(&self.base_url) { | |
| 182 | tracing::warn!( | |
| 183 | "OIDC_REDIRECT_URL ({}) is not under BASE_URL ({}); \ | |
| 184 | this is only correct behind a rewriting proxy", | |
| 185 | self.oidc_redirect_url, | |
| 186 | self.base_url | |
| 187 | ); | |
| 188 | } | |
| 189 | if self.session_ttl_days < 1 { | |
| 190 | bail!("SESSION_TTL_DAYS must be at least 1"); | |
| 191 | } | |
| 192 | Ok(()) | |
| 193 | } | |
| 194 | ||
| 195 | /// Whether cookies may carry the `Secure` attribute. | |
| 196 | /// | |
| 197 | /// Always true in production. Over plain HTTP a `Secure` cookie is silently | |
| 198 | /// dropped by the browser, which presents as "login does nothing" — so it | |
| 199 | /// is tied to the scheme rather than hardcoded. | |
| 200 | pub fn secure_cookies(&self) -> bool { | |
| 201 | self.base_url.starts_with("https://") | |
| 202 | } | |
| 203 | ||
| 204 | /// The SSH clone URL shown in the UI. | |
| 205 | pub fn ssh_clone_url(&self, owner: &str, repo: &str) -> String { | |
| 206 | if self.ssh_clone_port == 22 { | |
| 207 | format!("git@{}:{owner}/{repo}.git", self.ssh_clone_host) | |
| 208 | } else { | |
| 209 | // Port 22 is taken by the host sshd on this deployment, so the | |
| 210 | // explicit-port form is the normal case. | |
| 211 | format!( | |
| 212 | "ssh://git@{}:{}/{owner}/{repo}.git", | |
| 213 | self.ssh_clone_host, self.ssh_clone_port | |
| 214 | ) | |
| 215 | } | |
| 216 | } | |
| 217 | ||
| 218 | pub fn https_clone_url(&self, owner: &str, repo: &str) -> String { | |
| 219 | format!("{}/{owner}/{repo}.git", self.base_url) | |
| 220 | } | |
| 221 | ||
| 222 | /// A configuration for tests, with every cap set small. | |
| 223 | /// | |
| 224 | /// Small caps on purpose: a test that trips a limit should trip it quickly | |
| 225 | /// rather than needing a megabyte of fixture to get there. | |
| 226 | #[cfg(test)] | |
| 227 | pub fn for_tests() -> Config { | |
| 228 | Config { | |
| 229 | database_url: "postgres://test".into(), | |
| 230 | database_max_connections: 4, | |
| 231 | base_url: "https://dogfood.test".into(), | |
| 232 | bind: "127.0.0.1:0".into(), | |
| 233 | repo_root: "/tmp/df-test-repos".into(), | |
| 234 | ssh_clone_host: "dogfood.test".into(), | |
| 235 | ssh_clone_port: 2222, | |
| 236 | oidc_issuer: "https://oidc.test/".into(), | |
| 237 | oidc_client_id: "test-client".into(), | |
| 238 | oidc_client_secret: "test-secret".into(), | |
| 239 | oidc_redirect_url: "https://dogfood.test/auth/callback".into(), | |
| 240 | oidc_scopes: "openid profile email".into(), | |
| 241 | allowlist: vec![], | |
| 242 | trusted_proxies: vec![], | |
| 243 | session_secret: vec![7; 32], | |
| 244 | session_ttl_days: 14, | |
| 245 | hook_binary: "/usr/local/bin/dogfood-hook".into(), | |
| 246 | max_pack_bytes: 1024 * 1024, | |
| 247 | max_blob_render_bytes: 64 * 1024, | |
| 248 | max_diff_files: 50, | |
| 249 | max_diff_lines: 5_000, | |
| 250 | } | |
| 251 | } | |
| 252 | ||
| 253 | /// The instance's hostname, without scheme or port. | |
| 254 | /// | |
| 255 | /// Used to build the `noreply` addresses that end up in merge commits, so | |
| 256 | /// it must be a bare host: an author line containing `https://` or a colon | |
| 257 | /// produces a commit object Git will not parse. | |
| 258 | pub fn host(&self) -> &str { | |
| 259 | self.base_url | |
| 260 | .split("://") | |
| 261 | .nth(1) | |
| 262 | .unwrap_or(&self.base_url) | |
| 263 | .split('/') | |
| 264 | .next() | |
| 265 | .unwrap_or("localhost") | |
| 266 | .split(':') | |
| 267 | .next() | |
| 268 | .unwrap_or("localhost") | |
| 269 | } | |
| 270 | } | |
| 271 | ||
| 272 | #[cfg(test)] | |
| 273 | mod tests { | |
| 274 | use super::*; | |
| 275 | ||
| 276 | fn cfg(base: &str, port: u16) -> Config { | |
| 277 | Config { | |
| 278 | database_url: "postgres://x".into(), | |
| 279 | database_max_connections: 5, | |
| 280 | base_url: base.into(), | |
| 281 | bind: "0.0.0.0:8080".into(), | |
| 282 | repo_root: "/srv/repos".into(), | |
| 283 | ssh_clone_host: "dogfood.sh".into(), | |
| 284 | ssh_clone_port: port, | |
| 285 | oidc_issuer: "https://oauth.dogfood.sh/".into(), | |
| 286 | oidc_client_id: "id".into(), | |
| 287 | oidc_client_secret: "secret".into(), | |
| 288 | oidc_redirect_url: format!("{base}/auth/callback"), | |
| 289 | oidc_scopes: "openid".into(), | |
| 290 | allowlist: vec![], | |
| 291 | trusted_proxies: vec![], | |
| 292 | session_secret: vec![0; 32], | |
| 293 | session_ttl_days: 14, | |
| 294 | hook_binary: "/usr/local/bin/dogfood-hook".into(), | |
| 295 | max_pack_bytes: 1024, | |
| 296 | max_blob_render_bytes: 1024, | |
| 297 | max_diff_files: 10, | |
| 298 | max_diff_lines: 10, | |
| 299 | } | |
| 300 | } | |
| 301 | ||
| 302 | #[test] | |
| 303 | fn ssh_clone_url_includes_the_port_when_not_22() { | |
| 304 | let c = cfg("https://dogfood.sh", 2222); | |
| 305 | assert_eq!( | |
| 306 | c.ssh_clone_url("dogfood", "dogfood"), | |
| 307 | "ssh://git@dogfood.sh:2222/dogfood/dogfood.git" | |
| 308 | ); | |
| 309 | } | |
| 310 | ||
| 311 | #[test] | |
| 312 | fn ssh_clone_url_uses_scp_syntax_on_port_22() { | |
| 313 | let c = cfg("https://dogfood.sh", 22); | |
| 314 | assert_eq!( | |
| 315 | c.ssh_clone_url("dogfood", "dogfood"), | |
| 316 | "git@dogfood.sh:dogfood/dogfood.git" | |
| 317 | ); | |
| 318 | } | |
| 319 | ||
| 320 | #[test] | |
| 321 | fn https_clone_url_is_built_from_base_url() { | |
| 322 | let c = cfg("https://dogfood.sh", 2222); | |
| 323 | assert_eq!( | |
| 324 | c.https_clone_url("dogfood", "dogfood"), | |
| 325 | "https://dogfood.sh/dogfood/dogfood.git" | |
| 326 | ); | |
| 327 | } | |
| 328 | ||
| 329 | #[test] | |
| 330 | fn secure_cookies_follow_the_scheme() { | |
| 331 | assert!(cfg("https://dogfood.sh", 2222).secure_cookies()); | |
| 332 | assert!( | |
| 333 | !cfg("http://localhost:8080", 2222).secure_cookies(), | |
| 334 | "a Secure cookie over plain http is dropped by the browser" | |
| 335 | ); | |
| 336 | } | |
| 337 | ||
| 338 | #[test] | |
| 339 | fn validate_rejects_a_relative_base_url() { | |
| 340 | let mut c = cfg("dogfood.sh", 2222); | |
| 341 | c.base_url = "dogfood.sh".into(); | |
| 342 | assert!(c.validate().is_err()); | |
| 343 | } | |
| 344 | ||
| 345 | #[test] | |
| 346 | fn validate_rejects_a_zero_session_ttl() { | |
| 347 | let mut c = cfg("https://dogfood.sh", 2222); | |
| 348 | c.session_ttl_days = 0; | |
| 349 | assert!(c.validate().is_err()); | |
| 350 | } | |
| 351 | } |
351 lines · Rust