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::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    // How long a lock may go untouched before anyone can take it. Unset means
20    // never, which is what happened before this existed and what a team that has
21    // not thought about it yet should keep getting.
22    pub lock_max_age: Option<Duration>,
23    pub max_object_size: Option<u64>,
24    // How many uploads and downloads may hold this server's disk and network
25    // open at once. A backstop for the bare deployment with nothing in front:
26    // the expensive thing here is a transfer held open, not a request counted,
27    // and anything smarter belongs to the reverse proxy.
28    pub max_concurrent_transfers: usize,
29    pub repo_quota: Option<u64>,
30    pub compression: Option<i32>,
31    // Never the key itself: a key in the environment is in the pod spec, in
32    // `docker inspect`, and in every log that dumps the environment. A file
33    // comes from a Kubernetes Secret mount without any of that, and a command
34    // is the one interface every KMS, Vault and SOPS already speaks, for the
35    // operator whose keys must never rest on disk at all.
36    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    // Endpoint, bucket and credentials all have to be there: a bucket the server
45    // cannot reach is a server that answers every push with an error, and
46    // discovering that on the first upload rather than at boot is the wrong
47    // order.
48    Bucket {
49        dialect: Dialect,
50        // Whether a download is redirected to the bucket instead of streamed
51        // through this server. Off by default: the streamed path is the one
52        // that counts bytes, serves ranges and holds the ceiling, and an
53        // operator should choose to give those up rather than discover it.
54        presign: bool,
55        // A local copy of what the bucket holds, so a second reader does not
56        // pay the round trip again. None is no cache at all, which is what a
57        // deployment that never set it keeps getting.
58        cache: Option<DiskCache>,
59        // Whether locks can be taken here. Not read from the environment: it
60        // starts true and the startup probes turn it off, the same way they turn
61        // `presign` off, when the store will not prove it can arbitrate between
62        // two writers racing for the same key.
63        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        // A GitHub App identity for the server's own calls, so the anonymous
117        // lookup spends the App installation's quota instead of the 60-an-hour
118        // unauthenticated one. A file path for the key, never the key itself,
119        // same discipline as the encryption key.
120        github_app: Option<GithubApp>,
121        cache_ttl: Duration,
122        rejection_ttl: Duration,
123        // Lookups a minute this server will spend on the forge, counted only
124        // when neither cache could answer. None is no ceiling at all.
125        lookup_budget: Option<u32>,
126        // Whether a request with no credentials is resolved against the forge
127        // instead of refused. Off unless asked for, because a server that serves
128        // strangers should be a decision somebody made.
129        anonymous_read: bool,
130        // Namespaces whose objects take write access to read, so a repository the
131        // forge serves publicly can still keep its assets to the people who could
132        // push them.
133        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
144// A directory and a ceiling, together or not at all. A cache with nowhere to
145// live is nothing, and one with no ceiling fills the disk the server also
146// stages uploads on, which is a worse outage than the bucket round trips it
147// was meant to save.
148fn 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
175// One source or none. Both is a configuration that says two things, and which
176// of them the operator trusts with the store is not a guess this server makes.
177fn 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 and Forgejo, which are one API: Forgejo is a fork of Gitea and
203    // answers the same routes, so the only thing that tells two instances apart
204    // is the root they are reached at.
205    Gitea,
206}
207
208impl Provider {
209    // None where there is no such thing as the instance.
210    //
211    // github.com and gitlab.com are where a repository is unless the operator
212    // says otherwise, so defaulting there is nearly always right. Gitea is
213    // software rather than a place. gitea.com exists, but an operator who names
214    // this provider is almost certainly running their own, and quietly resolving
215    // their namespaces against a stranger's forge is worse than not starting: a
216    // public repository there that happens to share a name would hand out
217    // anonymous read on objects it has nothing to do with.
218    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);
237// Generous enough that a busy server never meets it, since a lookup is one
238// distinct token against one repository per cache lifetime rather than one per
239// request, and tight enough that a flood costs ten a second instead of whatever
240// the network will carry.
241const 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        // Neither of these is this deployment speaking. They are what the caller
293        // sent, and what comes out of here is the URL that caller will send the
294        // object to, with its credential attached. So both are checked for being
295        // the thing they claim to be before either goes into a URL.
296        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
339// Opt in, not opt out. Serving objects to a caller with no credentials at all is
340// a decision an operator should make on purpose: it costs them the bandwidth of
341// anyone who finds the endpoint, on a server whose whole job is to move files
342// measured in gigabytes. Nothing confidential is at stake, since a request with
343// no credentials is still resolved against the forge and a private repository is
344// still refused, but "anyone may pull from you" is not a sensible thing to
345// inherit by default.
346//
347// Only the exact string opens it. A typo, an empty value or a `1` leaves it
348// closed, because the failure that matters here is the one that opens the door
349// when nobody meant to.
350fn 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
381// Both variables or neither. One without the other is a configuration that
382// says two things at once, and an operator who set up an App meant to have its
383// quota, so the mistake is refused at boot instead of quietly ignored.
384fn 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
413// Zero is the one value that cannot mean what it says. A ceiling of no lookups
414// is a server that refuses every caller it has not already seen, so it is read as
415// the operator turning the ceiling off, which is the only other thing they could
416// have meant. Anything unparseable is the default rather than a refusal to start:
417// this bounds a cost, and getting it wrong should not take the server down.
418fn 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
426// Anything unrecognised is GitHub, which is what an operator who set nothing
427// almost certainly meant. Forgejo is named alongside Gitea because they are one
428// API, and somebody running Forgejo should not have to know it began as a fork.
429fn 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
437// The trailing slash matters: every route is built by appending to this, so one
438// left on the end produces `//repos/...`, which some forges answer and others do
439// not, and the ones that do not answer 404 for a repository that is right there.
440fn 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
456// Is this a host and a port, and nothing else?
457//
458// A `Host` carrying a `/` or an `@` is not one, and both change where the URL
459// built from it points. `real.example@evil.example` resolves to the second name
460// with the first read as a username, which turns a header somebody sent into a
461// redirect nobody wrote, and the client follows it carrying its token.
462//
463// Anything that fails this falls back to `localhost`, which is useless to
464// everybody and dangerous to nobody. `LFSX_PUBLIC_URL` is the fix, and startup
465// says so.
466fn 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
474// Unset means unlimited, which is what a server on its own volume wants. Zero
475// would refuse every push, so it is read as a typo rather than as a policy
476// nobody would choose deliberately.
477// zstd level 3 is the default because it is the one that costs nothing you can
478// measure: it compresses faster than a spinning disk writes, and the meshes and
479// uncompressed raster that make up most of an LFS store give most of their
480// ground at any level. Higher levels are there for a store that is short on
481// room rather than on time.
482fn 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
501// Same posture as the lookup budget: this bounds a cost, so an unparseable
502// value falls back to the default with a warning rather than refusing to start.
503fn 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;