anvilsign in

collin/anvil

1//! Server configuration: loaded from a TOML file with sensible defaults.
2
3use std::path::{
4 Path,
5 PathBuf,
6};
7
8use serde::{
9 Deserialize,
10 Serialize,
11};
12
13use crate::error::{
14 Error,
15 Result,
16};
17
18/// Top-level anvil configuration.
19///
20/// Load with [`Config::load`] (from a TOML file) or [`Config::default`].
21#[derive(Clone, Debug, Deserialize, Serialize)]
22#[serde(default)]
23pub struct Config {
24 /// Root directory holding all server state (database + repositories).
25 pub data_dir: PathBuf,
26 /// HTTP server settings.
27 pub http: HttpConfig,
28 /// SSH server settings.
29 pub ssh: SshConfig,
30 /// Continuous-deployment settings (the single-repo redeploy webhook).
31 pub ci: CiConfig,
32 /// Periodic background job settings.
33 pub periodic: PeriodicConfig,
34 /// Single sign-on against an OpenID Connect provider.
35 pub oidc: OidcConfig,
36}
37
38/// Path the provider redirects back to after an authorization. Registered at
39/// the provider as this app's redirect URI, and matched there character for
40/// character — see `docs/oidc.md`.
41pub const OIDC_CALLBACK_PATH: &str = "/-/oidc/callback";
42
43/// Sign-in delegated to an OpenID Connect provider (authorization code flow
44/// with PKCE). Off unless [`issuer`](OidcConfig::issuer) is set, so an
45/// unconfigured instance behaves exactly as it did before: local passwords
46/// only. When on, it is *additional* — existing accounts keep their passwords,
47/// and the two are reconciled on the `sub` claim.
48#[derive(Clone, Debug, Deserialize, Serialize)]
49#[serde(default)]
50pub struct OidcConfig {
51 /// Issuer URL, e.g. `https://login.richardscollin.com`. Empty disables
52 /// single sign-on entirely. Discovery, and the `iss` claim every id token
53 /// is checked against, both come from this.
54 pub issuer: String,
55 /// Client id registered at the provider. Defaults to `anvil`.
56 pub client_id: String,
57 /// Client secret. Empty for a client registered as public — PKCE protects
58 /// the code either way.
59 pub client_secret: String,
60 /// Overrides the redirect URI, which otherwise is
61 /// `base_url` + [`OIDC_CALLBACK_PATH`]. Must match the provider's
62 /// allowlist exactly.
63 pub redirect_uri: String,
64 /// What the sign-in button says, after `Sign in with `. Defaults to the
65 /// issuer's hostname.
66 pub label: String,
67 /// Whether signing out of anvil also ends the provider's session (an
68 /// RP-initiated logout). Needs a post-logout URI registered for this
69 /// client, or the provider drops the user on its own page instead of
70 /// bringing them back. Defaults to `true`.
71 pub sso_logout: bool,
72}
73
74/// CI configuration: job sandbox limits and the single-repo redeploy webhook.
75///
76/// On a successful CI run of [`deploy_branch`](CiConfig::deploy_branch) in the
77/// single repository named by [`deploy_repo`](CiConfig::deploy_repo), anvil
78/// POSTs to [`deploy_webhook`](CiConfig::deploy_webhook). This is deliberately
79/// scoped to **one** repository — no other repo can trigger the deploy, even
80/// with its own passing CI.
81#[derive(Clone, Debug, Deserialize, Serialize)]
82#[serde(default)]
83pub struct CiConfig {
84 /// The one repository (`owner/name`) permitted to trigger the deploy
85 /// webhook. Empty disables deploys entirely.
86 pub deploy_repo: String,
87 /// URL POSTed to when `deploy_repo`'s `deploy_branch` goes green. Should be
88 /// a host-local plaintext HTTP endpoint (a small deploy-script receiver);
89 /// HTTPS is intentionally unsupported to keep the build TLS-free.
90 pub deploy_webhook: String,
91 /// Shared secret sent as the `X-Anvil-Deploy-Secret` header so the receiver
92 /// can authenticate the call. Empty sends no header.
93 pub deploy_secret: String,
94 /// Branch whose successful run triggers a deploy. Defaults to `main`.
95 pub deploy_branch: String,
96 /// Images a pipeline may run in. Empty allows any image. An entry without a
97 /// tag (e.g. `rust`) allows every tag of that image; an entry with a tag
98 /// (e.g. `rust:1.95-bookworm`) allows exactly that image.
99 pub allowed_images: Vec<String>,
100 /// Memory cap for a job container, in MiB (swap is capped to the same
101 /// value). `0` means unlimited. Defaults to 2048.
102 pub memory_mb: i64,
103 /// CPU cap for a job container, in (possibly fractional) CPUs. `0` means
104 /// unlimited. Defaults to 2.
105 pub cpus: f64,
106 /// Process-count cap inside a job container. `0` means unlimited.
107 /// Defaults to 512.
108 pub pids_limit: i64,
109 /// Wall-clock timeout for a job, in seconds; on expiry the container is
110 /// force-removed and the run errors. `0` disables the timeout. Defaults to
111 /// 1800 (30 minutes).
112 pub timeout_secs: u64,
113 /// Whether job containers get network access (the default Docker network).
114 /// Most builds need it to fetch dependencies; disable for stricter
115 /// isolation. Defaults to `true`.
116 pub network: bool,
117 /// User to run the job as inside the container (`uid[:gid]` or a name known
118 /// to the image). Empty keeps the image's default user. Note many base
119 /// images assume root for e.g. `apt-get`.
120 pub run_as: String,
121 /// Size cap for a single artifact, in MiB; larger artifacts are skipped
122 /// (with a log note), never failing the run. `0` means unlimited.
123 /// Defaults to 256.
124 pub artifact_max_mb: i64,
125 /// Combined size cap for one run's artifacts, in MiB. Artifacts that would
126 /// push the run over it are skipped. `0` means unlimited. Defaults to 512.
127 pub artifact_run_max_mb: i64,
128 /// Combined artifact budget per repository, in MiB. After each run, oldest
129 /// commits' artifacts are deleted until the repo fits (branch-head commits
130 /// are pinned). `0` means unlimited. Defaults to 4096.
131 pub artifact_quota_mb: i64,
132}
133
134#[derive(Clone, Debug, Deserialize, Serialize)]
135#[serde(default)]
136pub struct HttpConfig {
137 /// Address the HTTP server binds to, e.g. `127.0.0.1:3000`.
138 pub listen: String,
139 /// Externally visible base URL, used when constructing clone URLs.
140 pub base_url: String,
141 /// Memory budget, in MiB, for the cache of syntax-highlighted file views
142 /// (rendered HTML keyed by blob oid). Highlighting large files is the most
143 /// CPU-expensive page render, so repeat views are served from this cache.
144 /// `0` disables it — lowest memory, every view re-highlights. Defaults
145 /// to 16.
146 pub highlight_cache_mb: usize,
147 /// Maximum size, in MiB, of a single uploaded attachment (e.g. an image
148 /// pasted into the file editor). Uploads over this are rejected. Defaults
149 /// to 16.
150 pub attachment_max_mb: usize,
151 /// Per-repository cap, in MiB, on total stored attachments. A new upload
152 /// that would push a repo over this is rejected (re-uploading existing,
153 /// deduped content is always free). `0` means unlimited. Defaults to 0.
154 pub attachment_quota_mb: usize,
155}
156
157#[derive(Clone, Debug, Deserialize, Serialize)]
158#[serde(default)]
159pub struct SshConfig {
160 /// Whether the SSH git transport is enabled.
161 pub enabled: bool,
162 /// Address the SSH server binds to internally, e.g. `0.0.0.0:2222`. Under
163 /// Docker this is the in-container bind, which may differ from the
164 /// externally forwarded port — see the `clone_*` fields below.
165 pub listen: String,
166 /// Hostname shown in SSH clone URLs (what users actually connect to).
167 pub clone_host: String,
168 /// Port shown in SSH clone URLs. Set this to the *externally forwarded*
169 /// port when it differs from the internal bind (e.g. Docker `-p 2200:2222`).
170 pub clone_port: u16,
171 /// Username shown in SSH clone URLs (conventionally `git`).
172 pub clone_user: String,
173}
174
175#[derive(Clone, Debug, Deserialize, Serialize)]
176#[serde(default)]
177pub struct PeriodicConfig {
178 /// Interval (seconds) between repository language-detection scans.
179 /// Defaults to 3600 (1 hour).
180 pub language_detection_interval_secs: u64,
181 /// Interval (seconds) between repository preview-image extractions from README.
182 /// Defaults to 3600 (1 hour).
183 pub preview_image_interval_secs: u64,
184 /// Interval (seconds) between disk-usage cache refreshes.
185 /// Defaults to 3600 (1 hour).
186 pub disk_usage_interval_secs: u64,
187}
188
189impl Default for Config {
190 fn default() -> Self {
191 Self {
192 data_dir: PathBuf::from("data"),
193 http: HttpConfig::default(),
194 ssh: SshConfig::default(),
195 ci: CiConfig::default(),
196 periodic: PeriodicConfig::default(),
197 oidc: OidcConfig::default(),
198 }
199 }
200}
201
202impl Default for OidcConfig {
203 fn default() -> Self {
204 Self {
205 issuer: String::new(),
206 client_id: "anvil".to_string(),
207 client_secret: String::new(),
208 redirect_uri: String::new(),
209 label: String::new(),
210 sso_logout: true,
211 }
212 }
213}
214
215impl OidcConfig {
216 /// Whether single sign-on is configured at all.
217 pub fn enabled(&self) -> bool {
218 !self.issuer.is_empty()
219 }
220
221 /// The issuer with any trailing slashes removed — the exact string the
222 /// `iss` claim must equal, and the prefix every endpoint is built from.
223 pub fn issuer(&self) -> &str {
224 self.issuer.trim_end_matches('/')
225 }
226
227 /// Text for the sign-in button, after `Sign in with `. The configured
228 /// label wins; otherwise the issuer's host, with a leading `login.` peeled
229 /// off when a domain is left over — `login.richardscollin.com` reads
230 /// better as `richardscollin.com`, while `login.localhost` must keep its
231 /// prefix or it would collapse to a bare `localhost`.
232 pub fn label(&self) -> String {
233 if !self.label.is_empty() {
234 return self.label.clone();
235 }
236 let host = self
237 .issuer()
238 .split_once("://")
239 .map_or(self.issuer(), |(_, rest)| rest)
240 .split(['/', ':'])
241 .next()
242 .unwrap_or_default();
243 match host.strip_prefix("login.") {
244 Some(domain) if domain.contains('.') => domain.to_string(),
245 _ => host.to_string(),
246 }
247 }
248}
249
250impl Default for CiConfig {
251 fn default() -> Self {
252 Self {
253 deploy_repo: String::new(),
254 deploy_webhook: String::new(),
255 deploy_secret: String::new(),
256 deploy_branch: "main".to_string(),
257 allowed_images: Vec::new(),
258 memory_mb: 2048,
259 cpus: 2.0,
260 pids_limit: 512,
261 timeout_secs: 1800,
262 network: true,
263 run_as: String::new(),
264 artifact_max_mb: 256,
265 artifact_run_max_mb: 512,
266 artifact_quota_mb: 4096,
267 }
268 }
269}
270
271impl CiConfig {
272 /// Whether `owner/name` on `branch` is the configured deploy target.
273 pub fn is_deploy_target(&self, owner: &str, name: &str, branch: &str) -> bool {
274 !self.deploy_repo.is_empty()
275 && !self.deploy_webhook.is_empty()
276 && self.deploy_repo == format!("{owner}/{name}")
277 && self.deploy_branch == branch
278 }
279
280 /// Whether `image` passes [`allowed_images`](CiConfig::allowed_images).
281 /// An empty allowlist permits any image; a tagless entry permits every tag
282 /// of that image; a tagged entry permits exactly itself.
283 pub fn image_allowed(&self, image: &str) -> bool {
284 self.allowed_images.is_empty()
285 || self.allowed_images.iter().any(|allowed| {
286 image == allowed
287 || (!allowed.contains(':')
288 && image
289 .strip_prefix(allowed.as_str())
290 .is_some_and(|rest| rest.starts_with(':')))
291 })
292 }
293}
294
295impl Default for HttpConfig {
296 fn default() -> Self {
297 Self {
298 listen: "127.0.0.1:3000".to_string(),
299 base_url: "http://localhost:3000".to_string(),
300 highlight_cache_mb: 16,
301 attachment_max_mb: 16,
302 attachment_quota_mb: 0,
303 }
304 }
305}
306
307impl Default for SshConfig {
308 fn default() -> Self {
309 Self {
310 enabled: false,
311 listen: "127.0.0.1:2222".to_string(),
312 clone_host: "localhost".to_string(),
313 clone_port: 2222,
314 clone_user: "git".to_string(),
315 }
316 }
317}
318
319impl Default for PeriodicConfig {
320 fn default() -> Self {
321 Self {
322 language_detection_interval_secs: 3600,
323 preview_image_interval_secs: 3600,
324 disk_usage_interval_secs: 3600,
325 }
326 }
327}
328
329impl Config {
330 /// Load configuration from a TOML file. Missing fields fall back to defaults.
331 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
332 let path = path.as_ref();
333 let text = std::fs::read_to_string(path)
334 .map_err(|e| Error::Config(format!("reading {}: {e}", path.display())))?;
335 toml::from_str(&text).map_err(|e| Error::Config(format!("parsing {}: {e}", path.display())))
336 }
337
338 /// Load from `path` if it exists, otherwise return defaults. Environment
339 /// overrides are applied either way — see [`Config::apply_env`].
340 pub fn load_or_default(path: impl AsRef<Path>) -> Result<Self> {
341 let path = path.as_ref();
342 let mut config = if path.exists() {
343 Self::load(path)?
344 } else {
345 Self::default()
346 };
347 config.apply_env(|key| std::env::var(key).ok());
348 Ok(config)
349 }
350
351 /// Overlay environment variables onto a loaded config, so a supervisor can
352 /// place anvil wherever it likes without a config file.
353 ///
354 /// - `ANVIL_LISTEN`, or `HOST`/`PORT` — the bind address. `PORT` (with
355 /// `HOST` defaulting to `127.0.0.1`) is the convention process managers
356 /// and local proxies use; portless, for one, hands the app a free port in
357 /// 4000-4999 and reverse-proxies a `.localhost` name to it.
358 /// - `ANVIL_BASE_URL`, or `PORTLESS_URL` — the externally visible URL that
359 /// clone commands and links are built from. Getting this right is what
360 /// makes the UI usable behind a proxy: the bind port is an implementation
361 /// detail, `https://anvil.localhost` is the address users see.
362 ///
363 /// Explicit `ANVIL_*` wins over the generic name, and both win over the
364 /// file, on the usual "closest to the invocation" principle.
365 pub fn apply_env(&mut self, env: impl Fn(&str) -> Option<String>) {
366 if let Some(listen) = env("ANVIL_LISTEN") {
367 self.http.listen = listen;
368 } else if let Some(port) = env("PORT").filter(|p| p.parse::<u16>().is_ok()) {
369 let host = env("HOST").unwrap_or_else(|| "127.0.0.1".to_string());
370 self.http.listen = format!("{host}:{port}");
371 }
372 if let Some(base) = env("ANVIL_BASE_URL").or_else(|| env("PORTLESS_URL")) {
373 self.http.base_url = base.trim_end_matches('/').to_string();
374 }
375 if let Some(dir) = env("ANVIL_DATA_DIR") {
376 self.data_dir = dir.into();
377 }
378 // Single sign-on. The secret especially wants an env var: config files
379 // get committed, and this one must not be.
380 if let Some(issuer) = env("ANVIL_OIDC_ISSUER") {
381 self.oidc.issuer = issuer.trim().trim_end_matches('/').to_string();
382 }
383 if let Some(id) = env("ANVIL_OIDC_CLIENT_ID") {
384 self.oidc.client_id = id;
385 }
386 if let Some(secret) = env("ANVIL_OIDC_CLIENT_SECRET") {
387 self.oidc.client_secret = secret;
388 }
389 if let Some(uri) = env("ANVIL_OIDC_REDIRECT_URI") {
390 self.oidc.redirect_uri = uri;
391 }
392 }
393
394 /// The redirect URI handed to the provider: the configured override, or
395 /// [`OIDC_CALLBACK_PATH`] on the public base URL.
396 pub fn oidc_redirect_uri(&self) -> String {
397 if !self.oidc.redirect_uri.is_empty() {
398 return self.oidc.redirect_uri.clone();
399 }
400 format!(
401 "{}{OIDC_CALLBACK_PATH}",
402 self.http.base_url.trim_end_matches('/')
403 )
404 }
405
406 /// Filesystem path to the SQLite database file.
407 pub fn database_path(&self) -> PathBuf {
408 self.data_dir.join("anvil.db")
409 }
410
411 /// Root directory under which bare repositories are stored.
412 pub fn repositories_dir(&self) -> PathBuf {
413 self.data_dir.join("repositories")
414 }
415
416 /// Root directory under which CI artifacts are stored
417 /// (`artifacts/{repo_id}/{commit}/…` — see `docs/ci-artifacts.md`).
418 pub fn artifacts_dir(&self) -> PathBuf {
419 self.data_dir.join("artifacts")
420 }
421
422 /// Root directory under which uploaded attachments are stored
423 /// (`attachments/{repo_id}/{hash}`). Kept out of `repositories/` so the
424 /// files are never git objects.
425 pub fn attachments_dir(&self) -> PathBuf {
426 self.data_dir.join("attachments")
427 }
428
429 /// Whether session cookies should carry the `Secure` attribute (HTTPS-only).
430 /// Derived from the public base URL's scheme, so local plaintext dev still
431 /// works while production behind TLS gets `Secure` automatically.
432 pub fn secure_cookies(&self) -> bool {
433 self.http.base_url.starts_with("https://")
434 }
435
436 /// The HTTP clone URL for `<owner>/<name>`, e.g.
437 /// `http://localhost:3000/alice/hello.git`.
438 pub fn http_clone_url(&self, owner: &str, name: &str) -> String {
439 format!(
440 "{}/{owner}/{name}.git",
441 self.http.base_url.trim_end_matches('/')
442 )
443 }
444
445 /// The SSH clone URL for `<owner>/<name>`, using the externally advertised
446 /// host/port/user (which may differ from the internal bind under Docker).
447 /// The port is omitted when it is the SSH default (22).
448 pub fn ssh_clone_url(&self, owner: &str, name: &str) -> String {
449 let ssh = &self.ssh;
450 if ssh.clone_port == 22 {
451 format!(
452 "ssh://{}@{}/{owner}/{name}.git",
453 ssh.clone_user, ssh.clone_host
454 )
455 } else {
456 format!(
457 "ssh://{}@{}:{}/{owner}/{name}.git",
458 ssh.clone_user, ssh.clone_host, ssh.clone_port
459 )
460 }
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 /// Look up from a fixed list, standing in for the process environment.
469 fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
470 move |key| {
471 pairs
472 .iter()
473 .find(|(k, _)| *k == key)
474 .map(|(_, v)| v.to_string())
475 }
476 }
477
478 #[test]
479 fn port_and_host_set_the_bind_address() {
480 let mut config = Config::default();
481 config.apply_env(env_of(&[("PORT", "4738")]));
482 assert_eq!(config.http.listen, "127.0.0.1:4738");
483
484 let mut config = Config::default();
485 config.apply_env(env_of(&[("PORT", "4738"), ("HOST", "0.0.0.0")]));
486 assert_eq!(config.http.listen, "0.0.0.0:4738");
487 }
488
489 #[test]
490 fn anvil_listen_wins_over_port() {
491 let mut config = Config::default();
492 config.apply_env(env_of(&[
493 ("PORT", "4738"),
494 ("ANVIL_LISTEN", "0.0.0.0:9000"),
495 ]));
496 assert_eq!(config.http.listen, "0.0.0.0:9000");
497 }
498
499 #[test]
500 fn a_nonsense_port_leaves_the_configured_address_alone() {
501 let mut config = Config::default();
502 config.apply_env(env_of(&[("PORT", "not-a-port")]));
503 assert_eq!(config.http.listen, "127.0.0.1:3000");
504 }
505
506 #[test]
507 fn proxy_url_becomes_the_base_url() {
508 let mut config = Config::default();
509 config.apply_env(env_of(&[("PORTLESS_URL", "https://anvil.localhost/")]));
510 assert_eq!(config.http.base_url, "https://anvil.localhost");
511 // …and drives the Secure cookie attribute, since it is https.
512 assert!(config.secure_cookies());
513
514 let mut config = Config::default();
515 config.apply_env(env_of(&[
516 ("PORTLESS_URL", "https://anvil.localhost"),
517 ("ANVIL_BASE_URL", "https://forge.example.com"),
518 ]));
519 assert_eq!(config.http.base_url, "https://forge.example.com");
520 }
521
522 #[test]
523 fn an_empty_environment_changes_nothing() {
524 let mut config = Config::default();
525 config.apply_env(env_of(&[]));
526 assert_eq!(config.http.listen, HttpConfig::default().listen);
527 assert_eq!(config.http.base_url, HttpConfig::default().base_url);
528 }
529
530 #[test]
531 fn image_allowlist_semantics() {
532 let mut ci = CiConfig::default();
533 assert!(ci.image_allowed("anything:latest"), "empty list allows all");
534
535 ci.allowed_images = vec!["rust".to_string(), "alpine:3.20".to_string()];
536 assert!(ci.image_allowed("rust"), "tagless entry, tagless image");
537 assert!(
538 ci.image_allowed("rust:1.95-bookworm"),
539 "tagless entry allows any tag"
540 );
541 assert!(ci.image_allowed("alpine:3.20"), "tagged entry, exact match");
542 assert!(!ci.image_allowed("alpine:3.21"), "tagged entry, other tag");
543 assert!(!ci.image_allowed("alpine"), "tagged entry, tagless image");
544 assert!(
545 !ci.image_allowed("rustlang/rust:nightly"),
546 "no prefix bleed"
547 );
548 assert!(!ci.image_allowed("rusty:latest"), "no name-prefix bleed");
549 }
550}