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    // How long a lock may go untouched before anyone can take it. Unset means
19    // never, which is what happened before this existed and what a team that has
20    // not thought about it yet should keep getting.
21    pub lock_max_age: Option<Duration>,
22    pub max_object_size: Option<u64>,
23    pub repo_quota: Option<u64>,
24    pub compression: Option<i32>,
25    // A path rather than the key itself: a key in the environment is in the pod
26    // spec, in `docker inspect`, and in every log that dumps the environment. A
27    // file comes from a Kubernetes Secret mount or FerrVault without any of that.
28    pub encryption_key_file: Option<PathBuf>,
29    pub storage: Storage,
30    pub auth: Auth,
31}
32
33#[derive(Debug, Clone)]
34pub enum Storage {
35    Local,
36    // Endpoint, bucket and credentials all have to be there: a bucket the server
37    // cannot reach is a server that answers every push with an error, and
38    // discovering that on the first upload rather than at boot is the wrong
39    // order.
40    Bucket {
41        endpoint: String,
42        bucket: String,
43        region: String,
44        access_key: String,
45        secret_key: String,
46        path_style: bool,
47        // Whether a download is redirected to the bucket instead of streamed
48        // through this server. Off by default: the streamed path is the one
49        // that counts bytes, serves ranges and holds the ceiling, and an
50        // operator should choose to give those up rather than discover it.
51        presign: bool,
52    },
53}
54
55impl Storage {
56    fn from_env() -> Self {
57        if std::env::var("LFSX_STORAGE").as_deref() != Ok("s3") {
58            return Self::Local;
59        }
60
61        let required = |name: &str| {
62            std::env::var(name)
63                .ok()
64                .filter(|value| !value.is_empty())
65                .unwrap_or_else(|| panic!("LFSX_STORAGE=s3 needs {name}"))
66        };
67
68        Self::Bucket {
69            endpoint: required("LFSX_S3_ENDPOINT"),
70            bucket: required("LFSX_S3_BUCKET"),
71            region: std::env::var("LFSX_S3_REGION").unwrap_or_else(|_| "us-east-1".into()),
72            access_key: required("LFSX_S3_ACCESS_KEY"),
73            secret_key: required("LFSX_S3_SECRET_KEY"),
74            path_style: std::env::var("LFSX_S3_PATH_STYLE").as_deref() != Ok("false"),
75            presign: std::env::var("LFSX_S3_PRESIGN").as_deref() == Ok("true"),
76        }
77    }
78}
79
80#[derive(Debug, Clone)]
81pub enum Auth {
82    Forge {
83        provider: Provider,
84        api_url: String,
85        cache_ttl: Duration,
86        rejection_ttl: Duration,
87        // Whether a request with no credentials is resolved against the forge
88        // instead of refused. On by default, because that is what cloning a
89        // public repository does everywhere else.
90        anonymous_read: bool,
91    },
92    Disabled,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum Provider {
97    Github,
98    Gitlab,
99}
100
101impl Provider {
102    fn default_api_url(self) -> &'static str {
103        match self {
104            Self::Github => "https://api.github.com",
105            Self::Gitlab => "https://gitlab.com/api/v4",
106        }
107    }
108
109    fn api_url_variable(self) -> &'static str {
110        match self {
111            Self::Github => "LFSX_GITHUB_API_URL",
112            Self::Gitlab => "LFSX_GITLAB_API_URL",
113        }
114    }
115}
116
117const CACHE_TTL: Duration = Duration::from_secs(60);
118const REJECTION_TTL: Duration = Duration::from_secs(10);
119const GC_GRACE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
120const STAGING_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
121
122impl Config {
123    pub fn from_env() -> Self {
124        let bind = std::env::var("LFSX_BIND")
125            .ok()
126            .and_then(|raw| raw.parse().ok())
127            .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 8080)));
128
129        let storage_root = std::env::var("LFSX_STORAGE_ROOT")
130            .map(PathBuf::from)
131            .unwrap_or_else(|_| PathBuf::from("/var/lib/lfsx"));
132
133        let public_url = std::env::var("LFSX_PUBLIC_URL")
134            .ok()
135            .filter(|url| !url.is_empty())
136            .map(|url| url.trim_end_matches('/').to_owned());
137
138        Self {
139            bind,
140            storage_root,
141            public_url,
142            action_lifetime: 1800,
143            gc_grace: seconds("LFSX_GC_GRACE").unwrap_or(GC_GRACE),
144            staging_max_age: seconds("LFSX_STAGING_MAX_AGE").unwrap_or(STAGING_MAX_AGE),
145            lock_max_age: seconds("LFSX_LOCK_MAX_AGE"),
146            max_object_size: bytes("LFSX_MAX_OBJECT_SIZE"),
147            repo_quota: bytes("LFSX_REPO_QUOTA"),
148            compression: compression(),
149            encryption_key_file: std::env::var("LFSX_ENCRYPTION_KEY_FILE")
150                .ok()
151                .filter(|path| !path.is_empty())
152                .map(PathBuf::from),
153            storage: Storage::from_env(),
154            auth: Auth::from_env(),
155        }
156    }
157
158    pub fn base_url(&self, headers: &HeaderMap) -> String {
159        if let Some(configured) = &self.public_url {
160            return configured.clone();
161        }
162
163        let scheme = headers
164            .get("x-forwarded-proto")
165            .and_then(|value| value.to_str().ok())
166            .and_then(|value| value.split(',').next())
167            .map(str::trim)
168            .filter(|scheme| !scheme.is_empty())
169            .unwrap_or("http");
170
171        let authority = headers
172            .get(header::HOST)
173            .and_then(|value| value.to_str().ok())
174            .map(str::trim)
175            .filter(|host| !host.is_empty())
176            .unwrap_or("localhost");
177
178        format!("{scheme}://{authority}")
179    }
180
181    pub fn object_url(&self, base: &str, ns: &Namespace, oid: &str) -> String {
182        format!("{base}/{ns}/objects/{oid}")
183    }
184
185    pub fn verify_url(&self, base: &str, ns: &Namespace) -> String {
186        format!("{base}/{ns}/objects/verify")
187    }
188
189    pub fn action(&self, href: String) -> Action {
190        Action {
191            href,
192            header: None,
193            expires_in: self.action_lifetime,
194        }
195    }
196
197    pub fn signed_action(&self, href: String, headers: Vec<(String, String)>) -> Action {
198        Action {
199            href,
200            header: Some(headers.into_iter().collect()),
201            expires_in: self.action_lifetime,
202        }
203    }
204}
205
206// Opt in, not opt out. Serving objects to a caller with no credentials at all is
207// a decision an operator should make on purpose: it costs them the bandwidth of
208// anyone who finds the endpoint, on a server whose whole job is to move files
209// measured in gigabytes. Nothing confidential is at stake, since a request with
210// no credentials is still resolved against the forge and a private repository is
211// still refused, but "anyone may pull from you" is not a sensible thing to
212// inherit by default.
213//
214// Only the exact string opens it. A typo, an empty value or a `1` leaves it
215// closed, because the failure that matters here is the one that opens the door
216// when nobody meant to.
217fn anonymous_read(value: Option<&str>) -> bool {
218    value == Some("true")
219}
220
221impl Auth {
222    fn from_env() -> Self {
223        if std::env::var("LFSX_AUTH").as_deref() == Ok("disabled") {
224            tracing::warn!(
225                "LFSX_AUTH=disabled — every request is accepted, run this on a trusted network only"
226            );
227            return Self::Disabled;
228        }
229
230        let provider = match std::env::var("LFSX_AUTH").as_deref() {
231            Ok("gitlab") => Provider::Gitlab,
232            _ => Provider::Github,
233        };
234
235        let api_url = std::env::var(provider.api_url_variable())
236            .unwrap_or_else(|_| provider.default_api_url().to_owned())
237            .trim_end_matches('/')
238            .to_owned();
239
240        Self::Forge {
241            provider,
242            api_url,
243            cache_ttl: seconds("LFSX_AUTH_CACHE_TTL").unwrap_or(CACHE_TTL),
244            rejection_ttl: seconds("LFSX_AUTH_REJECTION_TTL").unwrap_or(REJECTION_TTL),
245            anonymous_read: anonymous_read(std::env::var("LFSX_ANONYMOUS_READ").ok().as_deref()),
246        }
247    }
248}
249
250// Unset means unlimited, which is what a server on its own volume wants. Zero
251// would refuse every push, so it is read as a typo rather than as a policy
252// nobody would choose deliberately.
253// zstd level 3 is the default because it is the one that costs nothing you can
254// measure: it compresses faster than a spinning disk writes, and the meshes and
255// uncompressed raster that make up most of an LFS store give most of their
256// ground at any level. Higher levels are there for a store that is short on
257// room rather than on time.
258fn compression() -> Option<i32> {
259    match std::env::var("LFSX_COMPRESSION").ok()?.trim() {
260        "" | "none" | "off" => None,
261        "zstd" => Some(3),
262        other => match other
263            .strip_prefix("zstd:")
264            .and_then(|level| level.parse().ok())
265        {
266            Some(level @ 1..=19) => Some(level),
267            _ => {
268                tracing::warn!(
269                    "LFSX_COMPRESSION={other} is not a codec this server knows — storing objects as they arrive"
270                );
271                None
272            }
273        },
274    }
275}
276
277fn bytes(variable: &str) -> Option<u64> {
278    let configured = std::env::var(variable).ok()?.trim().parse().ok()?;
279
280    if configured == 0 {
281        tracing::warn!("{variable}=0 would refuse every upload — ignoring it");
282        return None;
283    }
284
285    Some(configured)
286}
287
288fn seconds(variable: &str) -> Option<Duration> {
289    std::env::var(variable)
290        .ok()
291        .and_then(|raw| raw.parse().ok())
292        .map(Duration::from_secs)
293}
294
295#[cfg(test)]
296mod tests;