1use axum::http::{HeaderMap, header};
2
3use std::net::SocketAddr;
4use std::path::PathBuf;
5use std::time::Duration;
6
7use crate::auth::Restricted;
8use crate::model::Action;
9use crate::namespace::Namespace;
10
11#[derive(Debug, Clone)]
12pub struct Config {
13 pub bind: SocketAddr,
14 pub storage_root: PathBuf,
15 pub public_url: Option<String>,
16 pub action_lifetime: u32,
17 pub gc_grace: Duration,
18 pub staging_max_age: Duration,
19 pub lock_max_age: Option<Duration>,
23 pub max_object_size: Option<u64>,
24 pub max_concurrent_transfers: usize,
29 pub repo_quota: Option<u64>,
30 pub compression: Option<i32>,
31 pub encryption_key: Option<KeySource>,
37 pub storage: Storage,
38 pub auth: Auth,
39}
40
41#[derive(Debug, Clone)]
42pub enum Storage {
43 Local,
44 Bucket {
49 dialect: Dialect,
50 presign: bool,
55 cache: Option<DiskCache>,
59 locking: bool,
64 },
65}
66
67#[derive(Debug, Clone)]
68pub enum Dialect {
69 S3 {
70 endpoint: String,
71 bucket: String,
72 region: String,
73 access_key: String,
74 secret_key: String,
75 path_style: bool,
76 },
77}
78
79impl Storage {
80 fn from_env() -> Self {
81 if std::env::var("LFSX_STORAGE").as_deref() != Ok("s3") {
82 return Self::Local;
83 }
84
85 let required = |name: &str| {
86 std::env::var(name)
87 .ok()
88 .filter(|value| !value.is_empty())
89 .unwrap_or_else(|| panic!("LFSX_STORAGE=s3 needs {name}"))
90 };
91
92 Self::Bucket {
93 dialect: Dialect::S3 {
94 endpoint: required("LFSX_S3_ENDPOINT"),
95 bucket: required("LFSX_S3_BUCKET"),
96 region: std::env::var("LFSX_S3_REGION").unwrap_or_else(|_| "us-east-1".into()),
97 access_key: required("LFSX_S3_ACCESS_KEY"),
98 secret_key: required("LFSX_S3_SECRET_KEY"),
99 path_style: std::env::var("LFSX_S3_PATH_STYLE").as_deref() != Ok("false"),
100 },
101 presign: std::env::var("LFSX_S3_PRESIGN").as_deref() == Ok("true"),
102 cache: disk_cache(
103 std::env::var("LFSX_S3_CACHE_DIR").ok().as_deref(),
104 std::env::var("LFSX_S3_CACHE_MAX_BYTES").ok().as_deref(),
105 ),
106 locking: true,
107 }
108 }
109}
110
111#[derive(Debug, Clone)]
112pub enum Auth {
113 Forge {
114 provider: Provider,
115 api_url: String,
116 github_app: Option<GithubApp>,
121 cache_ttl: Duration,
122 rejection_ttl: Duration,
123 lookup_budget: Option<u32>,
126 anonymous_read: bool,
130 restricted: Restricted,
134 },
135 Disabled,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct DiskCache {
140 pub dir: PathBuf,
141 pub max_bytes: u64,
142}
143
144fn disk_cache(dir: Option<&str>, max_bytes: Option<&str>) -> Option<DiskCache> {
149 let dir = dir.filter(|dir| !dir.is_empty())?;
150
151 let Some(max_bytes) = max_bytes
152 .map(str::trim)
153 .and_then(|raw| raw.parse::<u64>().ok())
154 .filter(|ceiling| *ceiling > 0)
155 else {
156 tracing::warn!(
157 "LFSX_S3_CACHE_DIR is set without a usable LFSX_S3_CACHE_MAX_BYTES, so nothing is \
158 cached: a cache with no ceiling would fill the volume this server stages uploads on"
159 );
160 return None;
161 };
162
163 Some(DiskCache {
164 dir: PathBuf::from(dir),
165 max_bytes,
166 })
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum KeySource {
171 File(PathBuf),
172 Command(String),
173}
174
175fn encryption_key(file: Option<&str>, command: Option<&str>) -> Option<KeySource> {
178 let file = file.filter(|path| !path.is_empty());
179 let command = command.filter(|hook| !hook.is_empty());
180
181 match (file, command) {
182 (None, None) => None,
183 (Some(path), None) => Some(KeySource::File(PathBuf::from(path))),
184 (None, Some(hook)) => Some(KeySource::Command(hook.to_owned())),
185 (Some(_), Some(_)) => panic!(
186 "LFSX_ENCRYPTION_KEY_FILE and LFSX_ENCRYPTION_KEY_COMMAND are both set: they are two \
187 answers to where the keys live, and picking one for you is how the wrong keys get used"
188 ),
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct GithubApp {
194 pub app_id: String,
195 pub key_file: PathBuf,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum Provider {
200 Github,
201 Gitlab,
202 Gitea,
206}
207
208impl Provider {
209 fn default_api_url(self) -> Option<&'static str> {
219 match self {
220 Self::Github => Some("https://api.github.com"),
221 Self::Gitlab => Some("https://gitlab.com/api/v4"),
222 Self::Gitea => None,
223 }
224 }
225
226 fn api_url_variable(self) -> &'static str {
227 match self {
228 Self::Github => "LFSX_GITHUB_API_URL",
229 Self::Gitlab => "LFSX_GITLAB_API_URL",
230 Self::Gitea => "LFSX_GITEA_API_URL",
231 }
232 }
233}
234
235const CACHE_TTL: Duration = Duration::from_secs(60);
236const REJECTION_TTL: Duration = Duration::from_secs(10);
237const LOOKUP_BUDGET: u32 = 600;
242const TRANSFER_CAP: usize = 128;
243const GC_GRACE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
244const STAGING_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
245
246impl Config {
247 pub fn from_env() -> Self {
248 let bind = std::env::var("LFSX_BIND")
249 .ok()
250 .and_then(|raw| raw.parse().ok())
251 .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 8080)));
252
253 let storage_root = std::env::var("LFSX_STORAGE_ROOT")
254 .map(PathBuf::from)
255 .unwrap_or_else(|_| PathBuf::from("/var/lib/lfsx"));
256
257 let public_url = std::env::var("LFSX_PUBLIC_URL")
258 .ok()
259 .filter(|url| !url.is_empty())
260 .map(|url| url.trim_end_matches('/').to_owned());
261
262 Self {
263 bind,
264 storage_root,
265 public_url,
266 action_lifetime: 1800,
267 gc_grace: seconds("LFSX_GC_GRACE").unwrap_or(GC_GRACE),
268 staging_max_age: seconds("LFSX_STAGING_MAX_AGE").unwrap_or(STAGING_MAX_AGE),
269 lock_max_age: seconds("LFSX_LOCK_MAX_AGE"),
270 max_object_size: bytes("LFSX_MAX_OBJECT_SIZE"),
271 max_concurrent_transfers: transfer_cap(
272 std::env::var("LFSX_MAX_CONCURRENT_TRANSFERS")
273 .ok()
274 .as_deref(),
275 ),
276 repo_quota: bytes("LFSX_REPO_QUOTA"),
277 compression: compression(),
278 encryption_key: encryption_key(
279 std::env::var("LFSX_ENCRYPTION_KEY_FILE").ok().as_deref(),
280 std::env::var("LFSX_ENCRYPTION_KEY_COMMAND").ok().as_deref(),
281 ),
282 storage: Storage::from_env(),
283 auth: Auth::from_env(),
284 }
285 }
286
287 pub fn base_url(&self, headers: &HeaderMap) -> String {
288 if let Some(configured) = &self.public_url {
289 return configured.clone();
290 }
291
292 let scheme = headers
297 .get("x-forwarded-proto")
298 .and_then(|value| value.to_str().ok())
299 .and_then(|value| value.split(',').next())
300 .map(str::trim)
301 .filter(|scheme| matches!(*scheme, "http" | "https"))
302 .unwrap_or("http");
303
304 let authority = headers
305 .get(header::HOST)
306 .and_then(|value| value.to_str().ok())
307 .map(str::trim)
308 .filter(|host| is_an_authority(host))
309 .unwrap_or("localhost");
310
311 format!("{scheme}://{authority}")
312 }
313
314 pub fn object_url(&self, base: &str, ns: &Namespace, oid: &str) -> String {
315 format!("{base}/{ns}/objects/{oid}")
316 }
317
318 pub fn verify_url(&self, base: &str, ns: &Namespace) -> String {
319 format!("{base}/{ns}/objects/verify")
320 }
321
322 pub fn action(&self, href: String) -> Action {
323 Action {
324 href,
325 header: None,
326 expires_in: self.action_lifetime,
327 }
328 }
329
330 pub fn signed_action(&self, href: String, headers: Vec<(String, String)>) -> Action {
331 Action {
332 href,
333 header: Some(headers.into_iter().collect()),
334 expires_in: self.action_lifetime,
335 }
336 }
337}
338
339fn anonymous_read(value: Option<&str>) -> bool {
351 value == Some("true")
352}
353
354impl Auth {
355 fn from_env() -> Self {
356 if std::env::var("LFSX_AUTH").as_deref() == Ok("disabled") {
357 tracing::warn!(
358 "LFSX_AUTH=disabled: every request is accepted, run this on a trusted network only"
359 );
360 return Self::Disabled;
361 }
362
363 let provider = provider(std::env::var("LFSX_AUTH").ok().as_deref());
364
365 Self::Forge {
366 provider,
367 api_url: api_url(
368 provider,
369 std::env::var(provider.api_url_variable()).ok().as_deref(),
370 ),
371 cache_ttl: seconds("LFSX_AUTH_CACHE_TTL").unwrap_or(CACHE_TTL),
372 rejection_ttl: seconds("LFSX_AUTH_REJECTION_TTL").unwrap_or(REJECTION_TTL),
373 lookup_budget: lookup_budget(std::env::var("LFSX_AUTH_LOOKUP_BUDGET").ok().as_deref()),
374 github_app: github_app(provider),
375 anonymous_read: anonymous_read(std::env::var("LFSX_ANONYMOUS_READ").ok().as_deref()),
376 restricted: Restricted::parse(std::env::var("LFSX_RESTRICTED").ok().as_deref()),
377 }
378 }
379}
380
381fn github_app(provider: Provider) -> Option<GithubApp> {
385 let id = std::env::var("LFSX_GITHUB_APP_ID")
386 .ok()
387 .filter(|id| !id.is_empty());
388 let key_file = std::env::var("LFSX_GITHUB_APP_KEY_FILE")
389 .ok()
390 .filter(|path| !path.is_empty());
391
392 match (id, key_file) {
393 (None, None) => None,
394 (Some(app_id), Some(key_file)) => {
395 if provider != Provider::Github {
396 tracing::warn!(
397 "LFSX_GITHUB_APP_ID is set but LFSX_AUTH is not github, so it does nothing"
398 );
399 return None;
400 }
401 Some(GithubApp {
402 app_id,
403 key_file: PathBuf::from(key_file),
404 })
405 }
406 _ => panic!(
407 "LFSX_GITHUB_APP_ID and LFSX_GITHUB_APP_KEY_FILE come together: one without the \
408 other is half an identity, and guessing which half was meant is worse than stopping"
409 ),
410 }
411}
412
413fn lookup_budget(value: Option<&str>) -> Option<u32> {
419 match value.map(str::trim).map(str::parse::<u32>) {
420 Some(Ok(0)) => None,
421 Some(Ok(budget)) => Some(budget),
422 Some(Err(_)) | None => Some(LOOKUP_BUDGET),
423 }
424}
425
426fn provider(value: Option<&str>) -> Provider {
430 match value {
431 Some("gitlab") => Provider::Gitlab,
432 Some("gitea") | Some("forgejo") => Provider::Gitea,
433 _ => Provider::Github,
434 }
435}
436
437fn api_url(provider: Provider, configured: Option<&str>) -> String {
441 let variable = provider.api_url_variable();
442
443 configured
444 .map(str::to_owned)
445 .or_else(|| provider.default_api_url().map(str::to_owned))
446 .unwrap_or_else(|| {
447 panic!(
448 "{variable} must be set: a self-hosted forge has no default API root, and guessing \
449 one would resolve your repositories against somebody else's"
450 )
451 })
452 .trim_end_matches('/')
453 .to_owned()
454}
455
456fn is_an_authority(host: &str) -> bool {
467 !host.is_empty()
468 && host.len() <= 255
469 && host.bytes().all(|byte| {
470 byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b':' | b'[' | b']')
471 })
472}
473
474fn compression() -> Option<i32> {
483 match std::env::var("LFSX_COMPRESSION").ok()?.trim() {
484 "" | "none" | "off" => None,
485 "zstd" => Some(3),
486 other => match other
487 .strip_prefix("zstd:")
488 .and_then(|level| level.parse().ok())
489 {
490 Some(level @ 1..=19) => Some(level),
491 _ => {
492 tracing::warn!(
493 "LFSX_COMPRESSION={other} is not a codec this server knows, storing objects as they arrive"
494 );
495 None
496 }
497 },
498 }
499}
500
501fn transfer_cap(value: Option<&str>) -> usize {
504 match value.map(str::trim).map(str::parse) {
505 Some(Ok(cap)) => cap,
506 None => TRANSFER_CAP,
507 Some(Err(_)) => {
508 tracing::warn!(
509 "LFSX_MAX_CONCURRENT_TRANSFERS is not a number, keeping the default of {TRANSFER_CAP}"
510 );
511 TRANSFER_CAP
512 }
513 }
514}
515
516fn bytes(variable: &str) -> Option<u64> {
517 let configured = std::env::var(variable).ok()?.trim().parse().ok()?;
518
519 if configured == 0 {
520 tracing::warn!("{variable}=0 would refuse every upload, ignoring it");
521 return None;
522 }
523
524 Some(configured)
525}
526
527fn seconds(variable: &str) -> Option<Duration> {
528 std::env::var(variable)
529 .ok()
530 .and_then(|raw| raw.parse().ok())
531 .map(Duration::from_secs)
532}
533
534#[cfg(test)]
535mod tests;