| 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