Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
Matt W1//! Runtime configuration, read from the environment.
Matt W2//!
Matt W3//! Everything is validated at startup. A process that cannot serve correctly —
Matt W4//! no database, no OIDC, a placeholder session secret — must fail loudly at
Matt W5//! boot rather than at the first request that needs it.
Matt W6
Matt W7use anyhow::{bail, Context, Result};
Matt W8use sqlx::types::ipnetwork::IpNetwork;
Matt W9
Matt W10#[derive(Debug, Clone)]
Matt W11pub struct Config {
Matt W12 pub database_url: String,
Matt W13 pub database_max_connections: u32,
Matt W14
Matt W15 pub base_url: String,
Matt W16 pub bind: String,
Matt W17 pub repo_root: String,
Matt W18
Matt W19 pub ssh_clone_host: String,
Matt W20 pub ssh_clone_port: u16,
Matt W21
Matt W22 pub oidc_issuer: String,
Matt W23 pub oidc_client_id: String,
Matt W24 pub oidc_client_secret: String,
Matt W25 pub oidc_redirect_url: String,
Matt W26 pub oidc_scopes: String,
Matt W27
Matt W28 /// Emails admitted without an invitation. Blank entries are dropped so a
Matt W29 /// stray comma cannot widen access.
Matt W30 pub allowlist: Vec<String>,
Matt W31
Matt W32 /// Networks whose `X-Forwarded-For` header is believed.
Matt W33 ///
Matt W34 /// Empty means "believe nobody", which is the only safe default: `XFF` is a
Matt W35 /// request header, so trusting it from an arbitrary peer lets any client
Matt W36 /// name its own address and walk straight through the rate limiter.
Matt W37 ///
Matt W38 /// This must list the edge proxy, or every request appears to come from the
Matt W39 /// proxy and all anonymous traffic shares one bucket — which is not a
Matt W40 /// tighter limit but a global one, and a single client can spend it.
Matt W41 pub trusted_proxies: Vec<IpNetwork>,
Matt W42
Matt W43 pub session_secret: Vec<u8>,
Matt W44 pub session_ttl_days: i64,
Matt W45
Matt W46 /// Path to the compiled `dogfood-hook` binary, as seen from *inside* the
Matt W47 /// container that serves pushes. Installed into each repository's `hooks/`
Matt W48 /// directory (spec §4: "Hooks are the compiled `dogfood-hook` binary …
Matt W49 /// Do not use shell scripts").
Matt W50 pub hook_binary: String,
Matt W51
Matt W52 pub max_pack_bytes: u64,
Matt W53 pub max_blob_render_bytes: usize,
Matt W54 pub max_diff_files: usize,
Matt W55 pub max_diff_lines: usize,
Matt W56}
Matt W57
Matt W58fn var(key: &str) -> Result<String> {
Matt W59 std::env::var(key).with_context(|| format!("{key} must be set"))
Matt W60}
Matt W61
Matt W62fn var_or(key: &str, default: &str) -> String {
Matt W63 std::env::var(key).unwrap_or_else(|_| default.to_string())
Matt W64}
Matt W65
Matt W66fn parse_num<T: std::str::FromStr>(key: &str, default: T) -> Result<T>
Matt W67where
Matt W68 T::Err: std::fmt::Display,
Matt W69{
Matt W70 match std::env::var(key) {
Matt W71 Ok(v) => v
Matt W72 .parse::<T>()
Matt W73 .map_err(|e| anyhow::anyhow!("{key} is not a valid number: {e}")),
Matt W74 Err(_) => Ok(default),
Matt W75 }
Matt W76}
Matt W77
Matt W78/// Parse `TRUSTED_PROXIES`: comma-separated CIDRs, or bare addresses meaning a
Matt W79/// single host.
Matt W80///
Matt W81/// A malformed entry is fatal rather than skipped. Dropping one silently would
Matt W82/// mean the proxy is not trusted, every client looks like the proxy, and the
Matt W83/// rate limiter degrades to a single global bucket — a failure that presents as
Matt W84/// mysterious 429s rather than as a configuration error.
Matt W85fn parse_trusted_proxies(raw: &str) -> Result<Vec<IpNetwork>> {
Matt W86 raw.split(',')
Matt W87 .map(str::trim)
Matt W88 .filter(|s| !s.is_empty())
Matt W89 .map(|s| {
Matt W90 s.parse::<IpNetwork>()
Matt W91 .with_context(|| format!("TRUSTED_PROXIES entry {s:?} is not an address or CIDR"))
Matt W92 })
Matt W93 .collect()
Matt W94}
Matt W95
Matt W96impl Config {
Matt W97 pub fn from_env() -> Result<Self> {
Matt W98 let base_url = var_or("BASE_URL", "http://localhost:8080")
Matt W99 .trim_end_matches('/')
Matt W100 .to_string();
Matt W101
Matt W102 let session_secret_hex = var("SESSION_SECRET")?;
Matt W103 let session_secret = hex::decode(session_secret_hex.trim())
Matt W104 .context("SESSION_SECRET must be hex")?;
Matt W105 if session_secret.len() < 32 {
Matt W106 bail!(
Matt W107 "SESSION_SECRET must be at least 32 bytes ({} given); \
Matt W108 generate with `openssl rand -hex 32`",
Matt W109 session_secret.len()
Matt W110 );
Matt W111 }
Matt W112
Matt W113 let allowlist = var_or("DOGFOOD_ALLOWLIST", "")
Matt W114 .split(',')
Matt W115 .map(|s| s.trim().to_string())
Matt W116 .filter(|s| !s.is_empty())
Matt W117 .collect::<Vec<_>>();
Matt W118
Matt W119 if allowlist.is_empty() {
Matt W120 // Not fatal — invitations still work — but silence here would look
Matt W121 // like a broken login rather than a deliberate policy.
Matt W122 tracing::warn!(
Matt W123 "DOGFOOD_ALLOWLIST is empty; only invited users will be able to sign in"
Matt W124 );
Matt W125 }
Matt W126
Matt W127 let trusted_proxies = parse_trusted_proxies(&var_or("TRUSTED_PROXIES", ""))?;
Matt W128 if trusted_proxies.is_empty() {
Matt W129 tracing::warn!(
Matt W130 "TRUSTED_PROXIES is empty; rate limits key on the TCP peer. \
Matt W131 Behind a reverse proxy that makes every anonymous request share \
Matt W132 one bucket — set it to the proxy's network."
Matt W133 );
Matt W134 } else {
Matt W135 tracing::info!(?trusted_proxies, "trusting X-Forwarded-For from these networks");
Matt W136 }
Matt W137
Matt W138 let cfg = Config {
Matt W139 database_url: var("DATABASE_URL")?,
Matt W140 database_max_connections: parse_num("DATABASE_MAX_CONNECTIONS", 10)?,
Matt W141
Matt W142 bind: var_or("BIND", "0.0.0.0:8080"),
Matt W143 repo_root: var_or("REPO_ROOT", "/srv/repos"),
Matt W144
Matt W145 ssh_clone_host: var_or("SSH_CLONE_HOST", "localhost"),
Matt W146 ssh_clone_port: parse_num("SSH_CLONE_PORT", 2222u16)?,
Matt W147
Matt W148 oidc_issuer: var("OIDC_ISSUER")?,
Matt W149 oidc_client_id: var("OIDC_CLIENT_ID")?,
Matt W150 oidc_client_secret: var("OIDC_CLIENT_SECRET")?,
Matt W151 oidc_redirect_url: var_or(
Matt W152 "OIDC_REDIRECT_URL",
Matt W153 &format!("{base_url}/auth/callback"),
Matt W154 ),
Matt W155 oidc_scopes: var_or("OIDC_SCOPES", "openid profile email"),
Matt W156
Matt W157 allowlist,
Matt W158 trusted_proxies,
Matt W159 session_secret,
Matt W160 session_ttl_days: parse_num("SESSION_TTL_DAYS", 14i64)?,
Matt W161
Matt W162 hook_binary: var_or("DOGFOOD_HOOK_BINARY", "/usr/local/bin/dogfood-hook"),
Matt W163 max_pack_bytes: parse_num("MAX_PACK_BYTES", 524_288_000u64)?,
Matt W164 max_blob_render_bytes: parse_num("MAX_BLOB_RENDER_BYTES", 1_048_576)?,
Matt W165 max_diff_files: parse_num("MAX_DIFF_FILES", 5_000)?,
Matt W166 max_diff_lines: parse_num("MAX_DIFF_LINES", 100_000)?,
Matt W167
Matt W168 base_url,
Matt W169 };
Matt W170
Matt W171 cfg.validate()?;
Matt W172 Ok(cfg)
Matt W173 }
Matt W174
Matt W175 fn validate(&self) -> Result<()> {
Matt W176 if !self.base_url.starts_with("http://") && !self.base_url.starts_with("https://") {
Matt W177 bail!("BASE_URL must be an absolute http(s) URL, got {}", self.base_url);
Matt W178 }
Matt W179 // The redirect URI must match what is registered with Hydra exactly, so
Matt W180 // a mismatch is worth catching here rather than as an opaque OAuth error.
Matt W181 if !self.oidc_redirect_url.starts_with(&self.base_url) {
Matt W182 tracing::warn!(
Matt W183 "OIDC_REDIRECT_URL ({}) is not under BASE_URL ({}); \
Matt W184 this is only correct behind a rewriting proxy",
Matt W185 self.oidc_redirect_url,
Matt W186 self.base_url
Matt W187 );
Matt W188 }
Matt W189 if self.session_ttl_days < 1 {
Matt W190 bail!("SESSION_TTL_DAYS must be at least 1");
Matt W191 }
Matt W192 Ok(())
Matt W193 }
Matt W194
Matt W195 /// Whether cookies may carry the `Secure` attribute.
Matt W196 ///
Matt W197 /// Always true in production. Over plain HTTP a `Secure` cookie is silently
Matt W198 /// dropped by the browser, which presents as "login does nothing" — so it
Matt W199 /// is tied to the scheme rather than hardcoded.
Matt W200 pub fn secure_cookies(&self) -> bool {
Matt W201 self.base_url.starts_with("https://")
Matt W202 }
Matt W203
Matt W204 /// The SSH clone URL shown in the UI.
Matt W205 pub fn ssh_clone_url(&self, owner: &str, repo: &str) -> String {
Matt W206 if self.ssh_clone_port == 22 {
Matt W207 format!("git@{}:{owner}/{repo}.git", self.ssh_clone_host)
Matt W208 } else {
Matt W209 // Port 22 is taken by the host sshd on this deployment, so the
Matt W210 // explicit-port form is the normal case.
Matt W211 format!(
Matt W212 "ssh://git@{}:{}/{owner}/{repo}.git",
Matt W213 self.ssh_clone_host, self.ssh_clone_port
Matt W214 )
Matt W215 }
Matt W216 }
Matt W217
Matt W218 pub fn https_clone_url(&self, owner: &str, repo: &str) -> String {
Matt W219 format!("{}/{owner}/{repo}.git", self.base_url)
Matt W220 }
Matt W221
Matt W222 /// A configuration for tests, with every cap set small.
Matt W223 ///
Matt W224 /// Small caps on purpose: a test that trips a limit should trip it quickly
Matt W225 /// rather than needing a megabyte of fixture to get there.
Matt W226 #[cfg(test)]
Matt W227 pub fn for_tests() -> Config {
Matt W228 Config {
Matt W229 database_url: "postgres://test".into(),
Matt W230 database_max_connections: 4,
Matt W231 base_url: "https://dogfood.test".into(),
Matt W232 bind: "127.0.0.1:0".into(),
Matt W233 repo_root: "/tmp/df-test-repos".into(),
Matt W234 ssh_clone_host: "dogfood.test".into(),
Matt W235 ssh_clone_port: 2222,
Matt W236 oidc_issuer: "https://oidc.test/".into(),
Matt W237 oidc_client_id: "test-client".into(),
Matt W238 oidc_client_secret: "test-secret".into(),
Matt W239 oidc_redirect_url: "https://dogfood.test/auth/callback".into(),
Matt W240 oidc_scopes: "openid profile email".into(),
Matt W241 allowlist: vec![],
Matt W242 trusted_proxies: vec![],
Matt W243 session_secret: vec![7; 32],
Matt W244 session_ttl_days: 14,
Matt W245 hook_binary: "/usr/local/bin/dogfood-hook".into(),
Matt W246 max_pack_bytes: 1024 * 1024,
Matt W247 max_blob_render_bytes: 64 * 1024,
Matt W248 max_diff_files: 50,
Matt W249 max_diff_lines: 5_000,
Matt W250 }
Matt W251 }
Matt W252
Matt W253 /// The instance's hostname, without scheme or port.
Matt W254 ///
Matt W255 /// Used to build the `noreply` addresses that end up in merge commits, so
Matt W256 /// it must be a bare host: an author line containing `https://` or a colon
Matt W257 /// produces a commit object Git will not parse.
Matt W258 pub fn host(&self) -> &str {
Matt W259 self.base_url
Matt W260 .split("://")
Matt W261 .nth(1)
Matt W262 .unwrap_or(&self.base_url)
Matt W263 .split('/')
Matt W264 .next()
Matt W265 .unwrap_or("localhost")
Matt W266 .split(':')
Matt W267 .next()
Matt W268 .unwrap_or("localhost")
Matt W269 }
Matt W270}
Matt W271
Matt W272#[cfg(test)]
Matt W273mod tests {
Matt W274 use super::*;
Matt W275
Matt W276 fn cfg(base: &str, port: u16) -> Config {
Matt W277 Config {
Matt W278 database_url: "postgres://x".into(),
Matt W279 database_max_connections: 5,
Matt W280 base_url: base.into(),
Matt W281 bind: "0.0.0.0:8080".into(),
Matt W282 repo_root: "/srv/repos".into(),
Matt W283 ssh_clone_host: "dogfood.sh".into(),
Matt W284 ssh_clone_port: port,
Matt W285 oidc_issuer: "https://oauth.dogfood.sh/".into(),
Matt W286 oidc_client_id: "id".into(),
Matt W287 oidc_client_secret: "secret".into(),
Matt W288 oidc_redirect_url: format!("{base}/auth/callback"),
Matt W289 oidc_scopes: "openid".into(),
Matt W290 allowlist: vec![],
Matt W291 trusted_proxies: vec![],
Matt W292 session_secret: vec![0; 32],
Matt W293 session_ttl_days: 14,
Matt W294 hook_binary: "/usr/local/bin/dogfood-hook".into(),
Matt W295 max_pack_bytes: 1024,
Matt W296 max_blob_render_bytes: 1024,
Matt W297 max_diff_files: 10,
Matt W298 max_diff_lines: 10,
Matt W299 }
Matt W300 }
Matt W301
Matt W302 #[test]
Matt W303 fn ssh_clone_url_includes_the_port_when_not_22() {
Matt W304 let c = cfg("https://dogfood.sh", 2222);
Matt W305 assert_eq!(
Matt W306 c.ssh_clone_url("dogfood", "dogfood"),
Matt W307 "ssh://git@dogfood.sh:2222/dogfood/dogfood.git"
Matt W308 );
Matt W309 }
Matt W310
Matt W311 #[test]
Matt W312 fn ssh_clone_url_uses_scp_syntax_on_port_22() {
Matt W313 let c = cfg("https://dogfood.sh", 22);
Matt W314 assert_eq!(
Matt W315 c.ssh_clone_url("dogfood", "dogfood"),
Matt W316 "git@dogfood.sh:dogfood/dogfood.git"
Matt W317 );
Matt W318 }
Matt W319
Matt W320 #[test]
Matt W321 fn https_clone_url_is_built_from_base_url() {
Matt W322 let c = cfg("https://dogfood.sh", 2222);
Matt W323 assert_eq!(
Matt W324 c.https_clone_url("dogfood", "dogfood"),
Matt W325 "https://dogfood.sh/dogfood/dogfood.git"
Matt W326 );
Matt W327 }
Matt W328
Matt W329 #[test]
Matt W330 fn secure_cookies_follow_the_scheme() {
Matt W331 assert!(cfg("https://dogfood.sh", 2222).secure_cookies());
Matt W332 assert!(
Matt W333 !cfg("http://localhost:8080", 2222).secure_cookies(),
Matt W334 "a Secure cookie over plain http is dropped by the browser"
Matt W335 );
Matt W336 }
Matt W337
Matt W338 #[test]
Matt W339 fn validate_rejects_a_relative_base_url() {
Matt W340 let mut c = cfg("dogfood.sh", 2222);
Matt W341 c.base_url = "dogfood.sh".into();
Matt W342 assert!(c.validate().is_err());
Matt W343 }
Matt W344
Matt W345 #[test]
Matt W346 fn validate_rejects_a_zero_session_ttl() {
Matt W347 let mut c = cfg("https://dogfood.sh", 2222);
Matt W348 c.session_ttl_days = 0;
Matt W349 assert!(c.validate().is_err());
Matt W350 }
Matt W351}

351 lines · Rust