1use axum::http::{HeaderMap, header};
2
3use std::net::SocketAddr;
4use std::path::PathBuf;
5use std::time::Duration;
6
7use crate::auth::Namespaces;
8use crate::model::Action;
9use crate::namespace::Namespace;
10
11mod forges;
12
13pub use forges::Forge;
14
15#[derive(Debug, Clone)]
16pub struct Config {
17 pub bind: SocketAddr,
18 pub storage_root: PathBuf,
19 pub public_url: Option<String>,
20 pub action_lifetime: u32,
21 pub gc_grace: Duration,
22 pub staging_max_age: Duration,
23 pub lock_max_age: Option<Duration>,
27 pub max_object_size: Option<u64>,
28 pub max_concurrent_transfers: usize,
33 pub repo_quota: Option<u64>,
34 pub compression: Option<i32>,
35 pub encryption_key: Option<KeySource>,
41 pub storage: Storage,
42 pub auth: Auth,
43 pub dashboard: Option<Dashboard>,
44 pub forges: Vec<Forge>,
45}
46
47#[derive(Debug, Clone)]
48pub struct Dashboard {
49 pub dir: PathBuf,
50 pub admins: Option<Namespace>,
51}
52
53const DASHBOARD_DIR: &str = "/usr/share/lfsx/dashboard";
54
55fn dashboard(
56 enabled: Option<&str>,
57 dir: Option<&str>,
58 repo: Option<&str>,
59 auth: &Auth,
60) -> Option<Dashboard> {
61 if enabled != Some("true") {
62 return None;
63 }
64
65 let admins = repo.filter(|repo| !repo.is_empty()).map(|repo| {
66 repo.split_once('/')
67 .and_then(|(org, name)| Namespace::new(org, name).ok())
68 .unwrap_or_else(|| panic!("LFSX_DASHBOARD_REPO is not org/repo: {repo}"))
69 });
70 if admins.is_none() && matches!(auth, Auth::Forge { .. }) {
71 panic!(
72 "LFSX_DASHBOARD=true needs LFSX_DASHBOARD_REPO: the dashboard is shown to the admins of \
73 that repository, and nobody else"
74 );
75 }
76
77 Some(Dashboard {
78 dir: dir
79 .filter(|dir| !dir.is_empty())
80 .unwrap_or(DASHBOARD_DIR)
81 .into(),
82 admins,
83 })
84}
85
86#[derive(Debug, Clone)]
87pub enum Storage {
88 Local,
89 Bucket {
94 dialect: Dialect,
95 presign: bool,
100 cache: Option<DiskCache>,
104 locking: bool,
109 },
110}
111
112#[derive(Debug, Clone)]
113pub enum Dialect {
114 S3 {
115 endpoint: String,
116 bucket: String,
117 region: String,
118 access_key: String,
119 secret_key: String,
120 path_style: bool,
121 },
122 Azure {
123 endpoint: String,
124 account: String,
125 container: String,
126 credential: AzureCredential,
127 },
128 Gcs {
129 endpoint: String,
130 bucket: String,
131 credential: GcsCredential,
132 },
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum GcsCredential {
137 ServiceAccount(PathBuf),
138 Metadata,
139 Anonymous,
140}
141
142fn gcs_credential(value: Option<&str>) -> GcsCredential {
143 match value.filter(|value| !value.is_empty()) {
144 None => GcsCredential::Metadata,
145 Some("none") => GcsCredential::Anonymous,
146 Some(path) => GcsCredential::ServiceAccount(path.into()),
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum AzureCredential {
152 AccountKey(String),
153 Sas(String),
154 Identity,
155}
156
157fn azure_credential(key: Option<&str>, sas: Option<&str>) -> AzureCredential {
158 let key = key.filter(|key| !key.is_empty());
159 let sas = sas.filter(|sas| !sas.is_empty());
160
161 match (key, sas) {
162 (Some(_), Some(_)) => panic!(
163 "LFSX_AZURE_ACCOUNT_KEY and LFSX_AZURE_SAS_TOKEN are both set: pick one, or neither \
164 to authenticate with the pod's managed or workload identity"
165 ),
166 (Some(key), None) => AzureCredential::AccountKey(key.to_owned()),
167 (None, Some(sas)) => AzureCredential::Sas(sas.to_owned()),
168 (None, None) => AzureCredential::Identity,
169 }
170}
171
172impl Storage {
173 fn from_env() -> Self {
174 let kind = std::env::var("LFSX_STORAGE").unwrap_or_default();
175 if !matches!(kind.as_str(), "s3" | "azure" | "gcs") {
176 return Self::Local;
177 }
178
179 let required = |name: &str| {
180 std::env::var(name)
181 .ok()
182 .filter(|value| !value.is_empty())
183 .unwrap_or_else(|| panic!("LFSX_STORAGE={kind} needs {name}"))
184 };
185
186 let dialect = if kind == "gcs" {
187 Dialect::Gcs {
188 endpoint: std::env::var("LFSX_GCS_ENDPOINT")
189 .ok()
190 .filter(|value| !value.is_empty())
191 .unwrap_or_else(|| "https://storage.googleapis.com".into()),
192 bucket: required("LFSX_GCS_BUCKET"),
193 credential: gcs_credential(std::env::var("LFSX_GCS_CREDENTIALS").ok().as_deref()),
194 }
195 } else if kind == "azure" {
196 let account = required("LFSX_AZURE_ACCOUNT");
197 Dialect::Azure {
198 endpoint: std::env::var("LFSX_AZURE_ENDPOINT")
199 .ok()
200 .filter(|value| !value.is_empty())
201 .unwrap_or_else(|| format!("https://{account}.blob.core.windows.net")),
202 container: required("LFSX_AZURE_CONTAINER"),
203 credential: azure_credential(
204 std::env::var("LFSX_AZURE_ACCOUNT_KEY").ok().as_deref(),
205 std::env::var("LFSX_AZURE_SAS_TOKEN").ok().as_deref(),
206 ),
207 account,
208 }
209 } else {
210 Dialect::S3 {
211 endpoint: required("LFSX_S3_ENDPOINT"),
212 bucket: required("LFSX_S3_BUCKET"),
213 region: std::env::var("LFSX_S3_REGION").unwrap_or_else(|_| "us-east-1".into()),
214 access_key: required("LFSX_S3_ACCESS_KEY"),
215 secret_key: required("LFSX_S3_SECRET_KEY"),
216 path_style: std::env::var("LFSX_S3_PATH_STYLE").as_deref() != Ok("false"),
217 }
218 };
219
220 Self::Bucket {
221 dialect,
222 presign: std::env::var("LFSX_S3_PRESIGN").as_deref() == Ok("true"),
223 cache: disk_cache(
224 std::env::var("LFSX_S3_CACHE_DIR").ok().as_deref(),
225 std::env::var("LFSX_S3_CACHE_MAX_BYTES").ok().as_deref(),
226 ),
227 locking: true,
228 }
229 }
230}
231
232#[derive(Debug, Clone)]
233pub enum Auth {
234 Forge {
235 provider: Provider,
236 api_url: String,
237 github_app: Option<GithubApp>,
242 cache_ttl: Duration,
243 rejection_ttl: Duration,
244 lookup_budget: Option<u32>,
247 anonymous_read: bool,
251 restricted: Namespaces,
255 allowed: Option<Namespaces>,
256 },
257 Disabled,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct DiskCache {
262 pub dir: PathBuf,
263 pub max_bytes: u64,
264}
265
266fn disk_cache(dir: Option<&str>, max_bytes: Option<&str>) -> Option<DiskCache> {
271 let dir = dir.filter(|dir| !dir.is_empty())?;
272
273 let Some(max_bytes) = max_bytes
274 .map(str::trim)
275 .and_then(|raw| raw.parse::<u64>().ok())
276 .filter(|ceiling| *ceiling > 0)
277 else {
278 tracing::warn!(
279 "LFSX_S3_CACHE_DIR is set without a usable LFSX_S3_CACHE_MAX_BYTES, so nothing is \
280 cached: a cache with no ceiling would fill the volume this server stages uploads on"
281 );
282 return None;
283 };
284
285 Some(DiskCache {
286 dir: PathBuf::from(dir),
287 max_bytes,
288 })
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub enum KeySource {
293 File(PathBuf),
294 Command(String),
295}
296
297fn encryption_key(file: Option<&str>, command: Option<&str>) -> Option<KeySource> {
300 let file = file.filter(|path| !path.is_empty());
301 let command = command.filter(|hook| !hook.is_empty());
302
303 match (file, command) {
304 (None, None) => None,
305 (Some(path), None) => Some(KeySource::File(PathBuf::from(path))),
306 (None, Some(hook)) => Some(KeySource::Command(hook.to_owned())),
307 (Some(_), Some(_)) => panic!(
308 "LFSX_ENCRYPTION_KEY_FILE and LFSX_ENCRYPTION_KEY_COMMAND are both set: they are two \
309 answers to where the keys live, and picking one for you is how the wrong keys get used"
310 ),
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub struct GithubApp {
316 pub app_id: String,
317 pub key_file: PathBuf,
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321pub enum Provider {
322 Github,
323 Gitlab,
324 Gitea,
328}
329
330impl Provider {
331 fn default_api_url(self) -> Option<&'static str> {
341 match self {
342 Self::Github => Some("https://api.github.com"),
343 Self::Gitlab => Some("https://gitlab.com/api/v4"),
344 Self::Gitea => None,
345 }
346 }
347
348 fn api_url_variable(self) -> &'static str {
349 match self {
350 Self::Github => "LFSX_GITHUB_API_URL",
351 Self::Gitlab => "LFSX_GITLAB_API_URL",
352 Self::Gitea => "LFSX_GITEA_API_URL",
353 }
354 }
355}
356
357const CACHE_TTL: Duration = Duration::from_secs(60);
358const REJECTION_TTL: Duration = Duration::from_secs(10);
359const LOOKUP_BUDGET: u32 = 600;
364const TRANSFER_CAP: usize = 128;
365const GC_GRACE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
366const STAGING_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
367
368impl Config {
369 pub fn from_env() -> Self {
370 let bind = std::env::var("LFSX_BIND")
371 .ok()
372 .and_then(|raw| raw.parse().ok())
373 .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 8080)));
374
375 let storage_root = std::env::var("LFSX_STORAGE_ROOT")
376 .map(PathBuf::from)
377 .unwrap_or_else(|_| PathBuf::from("/var/lib/lfsx"));
378
379 let public_url = std::env::var("LFSX_PUBLIC_URL")
380 .ok()
381 .filter(|url| !url.is_empty())
382 .map(|url| url.trim_end_matches('/').to_owned());
383
384 Self {
385 bind,
386 storage_root,
387 public_url,
388 action_lifetime: 1800,
389 gc_grace: seconds("LFSX_GC_GRACE").unwrap_or(GC_GRACE),
390 staging_max_age: seconds("LFSX_STAGING_MAX_AGE").unwrap_or(STAGING_MAX_AGE),
391 lock_max_age: seconds("LFSX_LOCK_MAX_AGE"),
392 max_object_size: bytes("LFSX_MAX_OBJECT_SIZE"),
393 max_concurrent_transfers: transfer_cap(
394 std::env::var("LFSX_MAX_CONCURRENT_TRANSFERS")
395 .ok()
396 .as_deref(),
397 ),
398 repo_quota: bytes("LFSX_REPO_QUOTA"),
399 compression: compression(),
400 encryption_key: encryption_key(
401 std::env::var("LFSX_ENCRYPTION_KEY_FILE").ok().as_deref(),
402 std::env::var("LFSX_ENCRYPTION_KEY_COMMAND").ok().as_deref(),
403 ),
404 storage: Storage::from_env(),
405 dashboard: None,
406 forges: Vec::new(),
407 auth: Auth::from_env(),
408 }
409 .with_dashboard()
410 .with_forges()
411 }
412
413 fn with_forges(self) -> Self {
414 Self {
415 forges: forges::from_env(&self.auth),
416 ..self
417 }
418 }
419
420 fn with_dashboard(self) -> Self {
421 Self {
422 dashboard: dashboard(
423 std::env::var("LFSX_DASHBOARD").ok().as_deref(),
424 std::env::var("LFSX_DASHBOARD_DIR").ok().as_deref(),
425 std::env::var("LFSX_DASHBOARD_REPO").ok().as_deref(),
426 &self.auth,
427 ),
428 ..self
429 }
430 }
431
432 pub fn base_url(&self, headers: &HeaderMap) -> String {
433 if let Some(configured) = &self.public_url {
434 return configured.clone();
435 }
436
437 let scheme = headers
442 .get("x-forwarded-proto")
443 .and_then(|value| value.to_str().ok())
444 .and_then(|value| value.split(',').next())
445 .map(str::trim)
446 .filter(|scheme| matches!(*scheme, "http" | "https"))
447 .unwrap_or("http");
448
449 let authority = headers
450 .get(header::HOST)
451 .and_then(|value| value.to_str().ok())
452 .map(str::trim)
453 .filter(|host| is_an_authority(host))
454 .unwrap_or("localhost");
455
456 format!("{scheme}://{authority}")
457 }
458
459 pub fn object_url(&self, base: &str, ns: &Namespace, oid: &str) -> String {
460 format!("{base}/{}/objects/{oid}", ns.url_path())
461 }
462
463 pub fn verify_url(&self, base: &str, ns: &Namespace) -> String {
464 format!("{base}/{}/objects/verify", ns.url_path())
465 }
466
467 pub fn action(&self, href: String) -> Action {
468 Action {
469 href,
470 header: None,
471 expires_in: self.action_lifetime,
472 }
473 }
474
475 pub fn signed_action(&self, href: String, headers: Vec<(String, String)>) -> Action {
476 Action {
477 href,
478 header: Some(headers.into_iter().collect()),
479 expires_in: self.action_lifetime,
480 }
481 }
482}
483
484fn anonymous_read(value: Option<&str>) -> bool {
496 value == Some("true")
497}
498
499impl Auth {
500 fn from_env() -> Self {
501 if std::env::var("LFSX_AUTH").as_deref() == Ok("disabled") {
502 tracing::warn!(
503 "LFSX_AUTH=disabled: every request is accepted, run this on a trusted network only"
504 );
505 if is_set(std::env::var("LFSX_ALLOWED").ok().as_deref()) {
506 tracing::warn!(
507 "LFSX_ALLOWED is set and LFSX_AUTH=disabled, so it does nothing: with no forge \
508 to ask, every repository is served"
509 );
510 }
511 if is_set(std::env::var("LFSX_RESTRICTED").ok().as_deref()) {
512 tracing::warn!(
513 "LFSX_RESTRICTED is set and LFSX_AUTH=disabled, so it does nothing: every \
514 caller already holds every right"
515 );
516 }
517 return Self::Disabled;
518 }
519
520 let provider = provider(std::env::var("LFSX_AUTH").ok().as_deref());
521
522 Self::Forge {
523 provider,
524 api_url: api_url(
525 provider,
526 std::env::var(provider.api_url_variable()).ok().as_deref(),
527 ),
528 cache_ttl: seconds("LFSX_AUTH_CACHE_TTL").unwrap_or(CACHE_TTL),
529 rejection_ttl: seconds("LFSX_AUTH_REJECTION_TTL").unwrap_or(REJECTION_TTL),
530 lookup_budget: lookup_budget(std::env::var("LFSX_AUTH_LOOKUP_BUDGET").ok().as_deref()),
531 github_app: github_app(provider),
532 anonymous_read: anonymous_read(std::env::var("LFSX_ANONYMOUS_READ").ok().as_deref()),
533 restricted: Namespaces::parse(
534 "LFSX_RESTRICTED",
535 std::env::var("LFSX_RESTRICTED").ok().as_deref(),
536 ),
537 allowed: allowed(
538 "LFSX_ALLOWED",
539 std::env::var("LFSX_ALLOWED").ok().as_deref(),
540 ),
541 }
542 }
543}
544
545fn is_set(value: Option<&str>) -> bool {
546 value.is_some_and(|value| !value.trim().is_empty())
547}
548
549fn allowed(variable: &str, value: Option<&str>) -> Option<Namespaces> {
550 is_set(value).then(|| Namespaces::parse(variable, value))
551}
552
553fn github_app(provider: Provider) -> Option<GithubApp> {
557 let id = std::env::var("LFSX_GITHUB_APP_ID")
558 .ok()
559 .filter(|id| !id.is_empty());
560 let key_file = std::env::var("LFSX_GITHUB_APP_KEY_FILE")
561 .ok()
562 .filter(|path| !path.is_empty());
563
564 match (id, key_file) {
565 (None, None) => None,
566 (Some(app_id), Some(key_file)) => {
567 if provider != Provider::Github {
568 tracing::warn!(
569 "LFSX_GITHUB_APP_ID is set but LFSX_AUTH is not github, so it does nothing"
570 );
571 return None;
572 }
573 Some(GithubApp {
574 app_id,
575 key_file: PathBuf::from(key_file),
576 })
577 }
578 _ => panic!(
579 "LFSX_GITHUB_APP_ID and LFSX_GITHUB_APP_KEY_FILE come together: one without the \
580 other is half an identity, and guessing which half was meant is worse than stopping"
581 ),
582 }
583}
584
585fn lookup_budget(value: Option<&str>) -> Option<u32> {
591 match value.map(str::trim).map(str::parse::<u32>) {
592 Some(Ok(0)) => None,
593 Some(Ok(budget)) => Some(budget),
594 Some(Err(_)) | None => Some(LOOKUP_BUDGET),
595 }
596}
597
598fn provider(value: Option<&str>) -> Provider {
602 match value {
603 Some("gitlab") => Provider::Gitlab,
604 Some("gitea") | Some("forgejo") => Provider::Gitea,
605 _ => Provider::Github,
606 }
607}
608
609fn api_url(provider: Provider, configured: Option<&str>) -> String {
613 api_url_from(provider, provider.api_url_variable(), configured)
614}
615
616fn api_url_from(provider: Provider, variable: &str, configured: Option<&str>) -> String {
617 configured
618 .map(str::to_owned)
619 .or_else(|| provider.default_api_url().map(str::to_owned))
620 .unwrap_or_else(|| {
621 panic!(
622 "{variable} must be set: a self-hosted forge has no default API root, and guessing \
623 one would resolve your repositories against somebody else's"
624 )
625 })
626 .trim_end_matches('/')
627 .to_owned()
628}
629
630fn is_an_authority(host: &str) -> bool {
641 !host.is_empty()
642 && host.len() <= 255
643 && host.bytes().all(|byte| {
644 byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b':' | b'[' | b']')
645 })
646}
647
648fn compression() -> Option<i32> {
657 match std::env::var("LFSX_COMPRESSION").ok()?.trim() {
658 "" | "none" | "off" => None,
659 "zstd" => Some(3),
660 other => match other
661 .strip_prefix("zstd:")
662 .and_then(|level| level.parse().ok())
663 {
664 Some(level @ 1..=19) => Some(level),
665 _ => {
666 tracing::warn!(
667 "LFSX_COMPRESSION={other} is not a codec this server knows, storing objects as they arrive"
668 );
669 None
670 }
671 },
672 }
673}
674
675fn transfer_cap(value: Option<&str>) -> usize {
678 match value.map(str::trim).map(str::parse) {
679 Some(Ok(cap)) => cap,
680 None => TRANSFER_CAP,
681 Some(Err(_)) => {
682 tracing::warn!(
683 "LFSX_MAX_CONCURRENT_TRANSFERS is not a number, keeping the default of {TRANSFER_CAP}"
684 );
685 TRANSFER_CAP
686 }
687 }
688}
689
690fn bytes(variable: &str) -> Option<u64> {
691 let configured = std::env::var(variable).ok()?.trim().parse().ok()?;
692
693 if configured == 0 {
694 tracing::warn!("{variable}=0 would refuse every upload, ignoring it");
695 return None;
696 }
697
698 Some(configured)
699}
700
701fn seconds(variable: &str) -> Option<Duration> {
702 std::env::var(variable)
703 .ok()
704 .and_then(|raw| raw.parse().ok())
705 .map(Duration::from_secs)
706}
707
708#[cfg(test)]
709mod tests;