Skip to main content

lfsx_server/
config.rs

1use axum::http::{HeaderMap, header};
2
3use std::net::SocketAddr;
4use std::path::PathBuf;
5use std::time::Duration;
6
7use crate::model::Action;
8use crate::namespace::Namespace;
9
10#[derive(Debug, Clone)]
11pub struct Config {
12    pub bind: SocketAddr,
13    pub storage_root: PathBuf,
14    pub public_url: Option<String>,
15    pub action_lifetime: u32,
16    pub gc_grace: Duration,
17    pub staging_max_age: Duration,
18    pub max_object_size: Option<u64>,
19    pub repo_quota: Option<u64>,
20    pub compression: Option<i32>,
21    pub auth: Auth,
22}
23
24#[derive(Debug, Clone)]
25pub enum Auth {
26    Forge {
27        provider: Provider,
28        api_url: String,
29        cache_ttl: Duration,
30        rejection_ttl: Duration,
31    },
32    Disabled,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Provider {
37    Github,
38    Gitlab,
39}
40
41impl Provider {
42    fn default_api_url(self) -> &'static str {
43        match self {
44            Self::Github => "https://api.github.com",
45            Self::Gitlab => "https://gitlab.com/api/v4",
46        }
47    }
48
49    fn api_url_variable(self) -> &'static str {
50        match self {
51            Self::Github => "LFSX_GITHUB_API_URL",
52            Self::Gitlab => "LFSX_GITLAB_API_URL",
53        }
54    }
55}
56
57const CACHE_TTL: Duration = Duration::from_secs(60);
58const REJECTION_TTL: Duration = Duration::from_secs(10);
59const GC_GRACE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
60const STAGING_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
61
62impl Config {
63    pub fn from_env() -> Self {
64        let bind = std::env::var("LFSX_BIND")
65            .ok()
66            .and_then(|raw| raw.parse().ok())
67            .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 8080)));
68
69        let storage_root = std::env::var("LFSX_STORAGE_ROOT")
70            .map(PathBuf::from)
71            .unwrap_or_else(|_| PathBuf::from("/var/lib/lfsx"));
72
73        let public_url = std::env::var("LFSX_PUBLIC_URL")
74            .ok()
75            .filter(|url| !url.is_empty())
76            .map(|url| url.trim_end_matches('/').to_owned());
77
78        Self {
79            bind,
80            storage_root,
81            public_url,
82            action_lifetime: 1800,
83            gc_grace: seconds("LFSX_GC_GRACE").unwrap_or(GC_GRACE),
84            staging_max_age: seconds("LFSX_STAGING_MAX_AGE").unwrap_or(STAGING_MAX_AGE),
85            max_object_size: bytes("LFSX_MAX_OBJECT_SIZE"),
86            repo_quota: bytes("LFSX_REPO_QUOTA"),
87            compression: compression(),
88            auth: Auth::from_env(),
89        }
90    }
91
92    pub fn base_url(&self, headers: &HeaderMap) -> String {
93        if let Some(configured) = &self.public_url {
94            return configured.clone();
95        }
96
97        let scheme = headers
98            .get("x-forwarded-proto")
99            .and_then(|value| value.to_str().ok())
100            .and_then(|value| value.split(',').next())
101            .map(str::trim)
102            .filter(|scheme| !scheme.is_empty())
103            .unwrap_or("http");
104
105        let authority = headers
106            .get(header::HOST)
107            .and_then(|value| value.to_str().ok())
108            .map(str::trim)
109            .filter(|host| !host.is_empty())
110            .unwrap_or("localhost");
111
112        format!("{scheme}://{authority}")
113    }
114
115    pub fn object_url(&self, base: &str, ns: &Namespace, oid: &str) -> String {
116        format!("{base}/{ns}/objects/{oid}")
117    }
118
119    pub fn verify_url(&self, base: &str, ns: &Namespace) -> String {
120        format!("{base}/{ns}/objects/verify")
121    }
122
123    pub fn action(&self, href: String) -> Action {
124        Action {
125            href,
126            expires_in: self.action_lifetime,
127        }
128    }
129}
130
131impl Auth {
132    fn from_env() -> Self {
133        if std::env::var("LFSX_AUTH").as_deref() == Ok("disabled") {
134            tracing::warn!(
135                "LFSX_AUTH=disabled — every request is accepted, run this on a trusted network only"
136            );
137            return Self::Disabled;
138        }
139
140        let provider = match std::env::var("LFSX_AUTH").as_deref() {
141            Ok("gitlab") => Provider::Gitlab,
142            _ => Provider::Github,
143        };
144
145        let api_url = std::env::var(provider.api_url_variable())
146            .unwrap_or_else(|_| provider.default_api_url().to_owned())
147            .trim_end_matches('/')
148            .to_owned();
149
150        Self::Forge {
151            provider,
152            api_url,
153            cache_ttl: seconds("LFSX_AUTH_CACHE_TTL").unwrap_or(CACHE_TTL),
154            rejection_ttl: seconds("LFSX_AUTH_REJECTION_TTL").unwrap_or(REJECTION_TTL),
155        }
156    }
157}
158
159// Unset means unlimited, which is what a server on its own volume wants. Zero
160// would refuse every push, so it is read as a typo rather than as a policy
161// nobody would choose deliberately.
162// zstd level 3 is the default because it is the one that costs nothing you can
163// measure: it compresses faster than a spinning disk writes, and the meshes and
164// uncompressed raster that make up most of an LFS store give most of their
165// ground at any level. Higher levels are there for a store that is short on
166// room rather than on time.
167fn compression() -> Option<i32> {
168    match std::env::var("LFSX_COMPRESSION").ok()?.trim() {
169        "" | "none" | "off" => None,
170        "zstd" => Some(3),
171        other => match other
172            .strip_prefix("zstd:")
173            .and_then(|level| level.parse().ok())
174        {
175            Some(level @ 1..=19) => Some(level),
176            _ => {
177                tracing::warn!(
178                    "LFSX_COMPRESSION={other} is not a codec this server knows — storing objects as they arrive"
179                );
180                None
181            }
182        },
183    }
184}
185
186fn bytes(variable: &str) -> Option<u64> {
187    let configured = std::env::var(variable).ok()?.trim().parse().ok()?;
188
189    if configured == 0 {
190        tracing::warn!("{variable}=0 would refuse every upload — ignoring it");
191        return None;
192    }
193
194    Some(configured)
195}
196
197fn seconds(variable: &str) -> Option<Duration> {
198    std::env::var(variable)
199        .ok()
200        .and_then(|raw| raw.parse().ok())
201        .map(Duration::from_secs)
202}