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/// Continuous-deployment configuration.
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}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(default)]
51pub struct HttpConfig {
52 /// Address the HTTP server binds to, e.g. `127.0.0.1:3000`.
53 pub listen: String,
54 /// Externally visible base URL, used when constructing clone URLs.
55 pub base_url: String,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(default)]
60pub struct SshConfig {
61 /// Whether the SSH git transport is enabled.
62 pub enabled: bool,
63 /// Address the SSH server binds to internally, e.g. `0.0.0.0:2222`. Under
64 /// Docker this is the in-container bind, which may differ from the
65 /// externally forwarded port — see the `clone_*` fields below.
66 pub listen: String,
67 /// Hostname shown in SSH clone URLs (what users actually connect to).
68 pub clone_host: String,
69 /// Port shown in SSH clone URLs. Set this to the *externally forwarded*
70 /// port when it differs from the internal bind (e.g. Docker `-p 2200:2222`).
71 pub clone_port: u16,
72 /// Username shown in SSH clone URLs (conventionally `git`).
73 pub clone_user: String,
74}
75
76impl Default for Config {
77 fn default() -> Self {
78 Self {
79 data_dir: PathBuf::from("data"),
80 http: HttpConfig::default(),
81 ssh: SshConfig::default(),
82 ci: CiConfig::default(),
83 }
84 }
85}
86
87impl Default for CiConfig {
88 fn default() -> Self {
89 Self {
90 deploy_repo: String::new(),
91 deploy_webhook: String::new(),
92 deploy_secret: String::new(),
93 deploy_branch: "main".to_string(),
94 }
95 }
96}
97
98impl CiConfig {
99 /// Whether `owner/name` on `branch` is the configured deploy target.
100 pub fn is_deploy_target(&self, owner: &str, name: &str, branch: &str) -> bool {
101 !self.deploy_repo.is_empty()
102 && !self.deploy_webhook.is_empty()
103 && self.deploy_repo == format!("{owner}/{name}")
104 && self.deploy_branch == branch
105 }
106}
107
108impl Default for HttpConfig {
109 fn default() -> Self {
110 Self {
111 listen: "127.0.0.1:3000".to_string(),
112 base_url: "http://localhost:3000".to_string(),
113 }
114 }
115}
116
117impl Default for SshConfig {
118 fn default() -> Self {
119 Self {
120 enabled: false,
121 listen: "127.0.0.1:2222".to_string(),
122 clone_host: "localhost".to_string(),
123 clone_port: 2222,
124 clone_user: "git".to_string(),
125 }
126 }
127}
128
129impl Config {
130 /// Load configuration from a TOML file. Missing fields fall back to defaults.
131 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
132 let path = path.as_ref();
133 let text = std::fs::read_to_string(path)
134 .map_err(|e| Error::Config(format!("reading {}: {e}", path.display())))?;
135 toml::from_str(&text).map_err(|e| Error::Config(format!("parsing {}: {e}", path.display())))
136 }
137
138 /// Load from `path` if it exists, otherwise return defaults.
139 pub fn load_or_default(path: impl AsRef<Path>) -> Result<Self> {
140 let path = path.as_ref();
141 if path.exists() {
142 Self::load(path)
143 } else {
144 Ok(Self::default())
145 }
146 }
147
148 /// Filesystem path to the SQLite database file.
149 pub fn database_path(&self) -> PathBuf {
150 self.data_dir.join("anvil.db")
151 }
152
153 /// Root directory under which bare repositories are stored.
154 pub fn repositories_dir(&self) -> PathBuf {
155 self.data_dir.join("repositories")
156 }
157
158 /// Whether session cookies should carry the `Secure` attribute (HTTPS-only).
159 /// Derived from the public base URL's scheme, so local plaintext dev still
160 /// works while production behind TLS gets `Secure` automatically.
161 pub fn secure_cookies(&self) -> bool {
162 self.http.base_url.starts_with("https://")
163 }
164
165 /// The HTTP clone URL for `<owner>/<name>`, e.g.
166 /// `http://localhost:3000/alice/hello.git`.
167 pub fn http_clone_url(&self, owner: &str, name: &str) -> String {
168 format!(
169 "{}/{owner}/{name}.git",
170 self.http.base_url.trim_end_matches('/')
171 )
172 }
173
174 /// The SSH clone URL for `<owner>/<name>`, using the externally advertised
175 /// host/port/user (which may differ from the internal bind under Docker).
176 /// The port is omitted when it is the SSH default (22).
177 pub fn ssh_clone_url(&self, owner: &str, name: &str) -> String {
178 let ssh = &self.ssh;
179 if ssh.clone_port == 22 {
180 format!(
181 "ssh://{}@{}/{owner}/{name}.git",
182 ssh.clone_user, ssh.clone_host
183 )
184 } else {
185 format!(
186 "ssh://{}@{}:{}/{owner}/{name}.git",
187 ssh.clone_user, ssh.clone_host, ssh.clone_port
188 )
189 }
190 }
191}