Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
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
7use anyhow::{bail, Context, Result};
8use sqlx::types::ipnetwork::IpNetwork;
9
10#[derive(Debug, Clone)]
11pub 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
58fn var(key: &str) -> Result<String> {
59 std::env::var(key).with_context(|| format!("{key} must be set"))
60}
61
62fn var_or(key: &str, default: &str) -> String {
63 std::env::var(key).unwrap_or_else(|_| default.to_string())
64}
65
66fn parse_num<T: std::str::FromStr>(key: &str, default: T) -> Result<T>
67where
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.
85fn 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
96impl 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)]
273mod 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