anvilsign in

collin/anvil

1//! Server configuration: loaded from a TOML file with sensible defaults.
2
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::{Error, Result};
8
9/// Top-level anvil configuration.
10///
11/// Load with [`Config::load`] (from a TOML file) or [`Config::default`].
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(default)]
14pub struct Config {
15 /// Root directory holding all server state (database + repositories).
16 pub data_dir: PathBuf,
17 /// HTTP server settings.
18 pub http: HttpConfig,
19 /// SSH server settings.
20 pub ssh: SshConfig,
21 /// Continuous-deployment settings (the single-repo redeploy webhook).
22 pub ci: CiConfig,
23}
24
25/// CI configuration: job sandbox limits and the single-repo redeploy webhook.
26///
27/// On a successful CI run of [`deploy_branch`](CiConfig::deploy_branch) in the
28/// single repository named by [`deploy_repo`](CiConfig::deploy_repo), anvil
29/// POSTs to [`deploy_webhook`](CiConfig::deploy_webhook). This is deliberately
30/// scoped to **one** repository — no other repo can trigger the deploy, even
31/// with its own passing CI.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(default)]
34pub struct CiConfig {
35 /// The one repository (`owner/name`) permitted to trigger the deploy
36 /// webhook. Empty disables deploys entirely.
37 pub deploy_repo: String,
38 /// URL POSTed to when `deploy_repo`'s `deploy_branch` goes green. Should be
39 /// a host-local plaintext HTTP endpoint (a small deploy-script receiver);
40 /// HTTPS is intentionally unsupported to keep the build TLS-free.
41 pub deploy_webhook: String,
42 /// Shared secret sent as the `X-Anvil-Deploy-Secret` header so the receiver
43 /// can authenticate the call. Empty sends no header.
44 pub deploy_secret: String,
45 /// Branch whose successful run triggers a deploy. Defaults to `main`.
46 pub deploy_branch: String,
47 /// Images a pipeline may run in. Empty allows any image. An entry without a
48 /// tag (e.g. `rust`) allows every tag of that image; an entry with a tag
49 /// (e.g. `rust:1.95-bookworm`) allows exactly that image.
50 pub allowed_images: Vec<String>,
51 /// Memory cap for a job container, in MiB (swap is capped to the same
52 /// value). `0` means unlimited. Defaults to 2048.
53 pub memory_mb: i64,
54 /// CPU cap for a job container, in (possibly fractional) CPUs. `0` means
55 /// unlimited. Defaults to 2.
56 pub cpus: f64,
57 /// Process-count cap inside a job container. `0` means unlimited.
58 /// Defaults to 512.
59 pub pids_limit: i64,
60 /// Wall-clock timeout for a job, in seconds; on expiry the container is
61 /// force-removed and the run errors. `0` disables the timeout. Defaults to
62 /// 1800 (30 minutes).
63 pub timeout_secs: u64,
64 /// Whether job containers get network access (the default Docker network).
65 /// Most builds need it to fetch dependencies; disable for stricter
66 /// isolation. Defaults to `true`.
67 pub network: bool,
68 /// User to run the job as inside the container (`uid[:gid]` or a name known
69 /// to the image). Empty keeps the image's default user. Note many base
70 /// images assume root for e.g. `apt-get`.
71 pub run_as: String,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(default)]
76pub struct HttpConfig {
77 /// Address the HTTP server binds to, e.g. `127.0.0.1:3000`.
78 pub listen: String,
79 /// Externally visible base URL, used when constructing clone URLs.
80 pub base_url: String,
81 /// Memory budget, in MiB, for the cache of syntax-highlighted file views
82 /// (rendered HTML keyed by blob oid). Highlighting large files is the most
83 /// CPU-expensive page render, so repeat views are served from this cache.
84 /// `0` disables it — lowest memory, every view re-highlights. Defaults
85 /// to 16.
86 pub highlight_cache_mb: usize,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(default)]
91pub struct SshConfig {
92 /// Whether the SSH git transport is enabled.
93 pub enabled: bool,
94 /// Address the SSH server binds to internally, e.g. `0.0.0.0:2222`. Under
95 /// Docker this is the in-container bind, which may differ from the
96 /// externally forwarded port — see the `clone_*` fields below.
97 pub listen: String,
98 /// Hostname shown in SSH clone URLs (what users actually connect to).
99 pub clone_host: String,
100 /// Port shown in SSH clone URLs. Set this to the *externally forwarded*
101 /// port when it differs from the internal bind (e.g. Docker `-p 2200:2222`).
102 pub clone_port: u16,
103 /// Username shown in SSH clone URLs (conventionally `git`).
104 pub clone_user: String,
105}
106
107impl Default for Config {
108 fn default() -> Self {
109 Self {
110 data_dir: PathBuf::from("data"),
111 http: HttpConfig::default(),
112 ssh: SshConfig::default(),
113 ci: CiConfig::default(),
114 }
115 }
116}
117
118impl Default for CiConfig {
119 fn default() -> Self {
120 Self {
121 deploy_repo: String::new(),
122 deploy_webhook: String::new(),
123 deploy_secret: String::new(),
124 deploy_branch: "main".to_string(),
125 allowed_images: Vec::new(),
126 memory_mb: 2048,
127 cpus: 2.0,
128 pids_limit: 512,
129 timeout_secs: 1800,
130 network: true,
131 run_as: String::new(),
132 }
133 }
134}
135
136impl CiConfig {
137 /// Whether `owner/name` on `branch` is the configured deploy target.
138 pub fn is_deploy_target(&self, owner: &str, name: &str, branch: &str) -> bool {
139 !self.deploy_repo.is_empty()
140 && !self.deploy_webhook.is_empty()
141 && self.deploy_repo == format!("{owner}/{name}")
142 && self.deploy_branch == branch
143 }
144
145 /// Whether `image` passes [`allowed_images`](CiConfig::allowed_images).
146 /// An empty allowlist permits any image; a tagless entry permits every tag
147 /// of that image; a tagged entry permits exactly itself.
148 pub fn image_allowed(&self, image: &str) -> bool {
149 self.allowed_images.is_empty()
150 || self.allowed_images.iter().any(|allowed| {
151 image == allowed
152 || (!allowed.contains(':')
153 && image
154 .strip_prefix(allowed.as_str())
155 .is_some_and(|rest| rest.starts_with(':')))
156 })
157 }
158}
159
160impl Default for HttpConfig {
161 fn default() -> Self {
162 Self {
163 listen: "127.0.0.1:3000".to_string(),
164 base_url: "http://localhost:3000".to_string(),
165 highlight_cache_mb: 16,
166 }
167 }
168}
169
170impl Default for SshConfig {
171 fn default() -> Self {
172 Self {
173 enabled: false,
174 listen: "127.0.0.1:2222".to_string(),
175 clone_host: "localhost".to_string(),
176 clone_port: 2222,
177 clone_user: "git".to_string(),
178 }
179 }
180}
181
182impl Config {
183 /// Load configuration from a TOML file. Missing fields fall back to defaults.
184 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
185 let path = path.as_ref();
186 let text = std::fs::read_to_string(path)
187 .map_err(|e| Error::Config(format!("reading {}: {e}", path.display())))?;
188 toml::from_str(&text).map_err(|e| Error::Config(format!("parsing {}: {e}", path.display())))
189 }
190
191 /// Load from `path` if it exists, otherwise return defaults.
192 pub fn load_or_default(path: impl AsRef<Path>) -> Result<Self> {
193 let path = path.as_ref();
194 if path.exists() {
195 Self::load(path)
196 } else {
197 Ok(Self::default())
198 }
199 }
200
201 /// Filesystem path to the SQLite database file.
202 pub fn database_path(&self) -> PathBuf {
203 self.data_dir.join("anvil.db")
204 }
205
206 /// Root directory under which bare repositories are stored.
207 pub fn repositories_dir(&self) -> PathBuf {
208 self.data_dir.join("repositories")
209 }
210
211 /// Whether session cookies should carry the `Secure` attribute (HTTPS-only).
212 /// Derived from the public base URL's scheme, so local plaintext dev still
213 /// works while production behind TLS gets `Secure` automatically.
214 pub fn secure_cookies(&self) -> bool {
215 self.http.base_url.starts_with("https://")
216 }
217
218 /// The HTTP clone URL for `<owner>/<name>`, e.g.
219 /// `http://localhost:3000/alice/hello.git`.
220 pub fn http_clone_url(&self, owner: &str, name: &str) -> String {
221 format!(
222 "{}/{owner}/{name}.git",
223 self.http.base_url.trim_end_matches('/')
224 )
225 }
226
227 /// The SSH clone URL for `<owner>/<name>`, using the externally advertised
228 /// host/port/user (which may differ from the internal bind under Docker).
229 /// The port is omitted when it is the SSH default (22).
230 pub fn ssh_clone_url(&self, owner: &str, name: &str) -> String {
231 let ssh = &self.ssh;
232 if ssh.clone_port == 22 {
233 format!(
234 "ssh://{}@{}/{owner}/{name}.git",
235 ssh.clone_user, ssh.clone_host
236 )
237 } else {
238 format!(
239 "ssh://{}@{}:{}/{owner}/{name}.git",
240 ssh.clone_user, ssh.clone_host, ssh.clone_port
241 )
242 }
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn image_allowlist_semantics() {
252 let mut ci = CiConfig::default();
253 assert!(ci.image_allowed("anything:latest"), "empty list allows all");
254
255 ci.allowed_images = vec!["rust".to_string(), "alpine:3.20".to_string()];
256 assert!(ci.image_allowed("rust"), "tagless entry, tagless image");
257 assert!(
258 ci.image_allowed("rust:1.95-bookworm"),
259 "tagless entry allows any tag"
260 );
261 assert!(ci.image_allowed("alpine:3.20"), "tagged entry, exact match");
262 assert!(!ci.image_allowed("alpine:3.21"), "tagged entry, other tag");
263 assert!(!ci.image_allowed("alpine"), "tagged entry, tagless image");
264 assert!(
265 !ci.image_allowed("rustlang/rust:nightly"),
266 "no prefix bleed"
267 );
268 assert!(!ci.image_allowed("rusty:latest"), "no name-prefix bleed");
269 }
270}