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        // Whether locks can be taken here. Not read from the environment: it
53        // starts true and the startup probes turn it off, the same way they turn
54        // `presign` off, when the store will not prove it can arbitrate between
55        // two writers racing for the same key.
56        locking: bool,
57    },
58}
59
60impl Storage {
61    fn from_env() -> Self {
62        if std::env::var("LFSX_STORAGE").as_deref() != Ok("s3") {
63            return Self::Local;
64        }
65
66        let required = |name: &str| {
67            std::env::var(name)
68                .ok()
69                .filter(|value| !value.is_empty())
70                .unwrap_or_else(|| panic!("LFSX_STORAGE=s3 needs {name}"))
71        };
72
73        Self::Bucket {
74            endpoint: required("LFSX_S3_ENDPOINT"),
75            bucket: required("LFSX_S3_BUCKET"),
76            region: std::env::var("LFSX_S3_REGION").unwrap_or_else(|_| "us-east-1".into()),
77            access_key: required("LFSX_S3_ACCESS_KEY"),
78            secret_key: required("LFSX_S3_SECRET_KEY"),
79            path_style: std::env::var("LFSX_S3_PATH_STYLE").as_deref() != Ok("false"),
80            presign: std::env::var("LFSX_S3_PRESIGN").as_deref() == Ok("true"),
81            locking: true,
82        }
83    }
84}
85
86#[derive(Debug, Clone)]
87pub enum Auth {
88    Forge {
89        provider: Provider,
90        api_url: String,
91        cache_ttl: Duration,
92        rejection_ttl: Duration,
93        // Lookups a minute this server will spend on the forge, counted only
94        // when neither cache could answer. None is no ceiling at all.
95        lookup_budget: Option<u32>,
96        // Whether a request with no credentials is resolved against the forge
97        // instead of refused. On by default, because that is what cloning a
98        // public repository does everywhere else.
99        anonymous_read: bool,
100    },
101    Disabled,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum Provider {
106    Github,
107    Gitlab,
108    // Gitea and Forgejo, which are one API: Forgejo is a fork of Gitea and
109    // answers the same routes, so the only thing that tells two instances apart
110    // is the root they are reached at.
111    Gitea,
112}
113
114impl Provider {
115    // None where there is no such thing as the instance.
116    //
117    // github.com and gitlab.com are where a repository is unless the operator
118    // says otherwise, so defaulting there is nearly always right. Gitea is
119    // software rather than a place. gitea.com exists, but an operator who names
120    // this provider is almost certainly running their own, and quietly resolving
121    // their namespaces against a stranger's forge is worse than not starting: a
122    // public repository there that happens to share a name would hand out
123    // anonymous read on objects it has nothing to do with.
124    fn default_api_url(self) -> Option<&'static str> {
125        match self {
126            Self::Github => Some("https://api.github.com"),
127            Self::Gitlab => Some("https://gitlab.com/api/v4"),
128            Self::Gitea => None,
129        }
130    }
131
132    fn api_url_variable(self) -> &'static str {
133        match self {
134            Self::Github => "LFSX_GITHUB_API_URL",
135            Self::Gitlab => "LFSX_GITLAB_API_URL",
136            Self::Gitea => "LFSX_GITEA_API_URL",
137        }
138    }
139}
140
141const CACHE_TTL: Duration = Duration::from_secs(60);
142const REJECTION_TTL: Duration = Duration::from_secs(10);
143// Generous enough that a busy server never meets it, since a lookup is one
144// distinct token against one repository per cache lifetime rather than one per
145// request, and tight enough that a flood costs ten a second instead of whatever
146// the network will carry.
147const LOOKUP_BUDGET: u32 = 600;
148const GC_GRACE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
149const STAGING_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
150
151impl Config {
152    pub fn from_env() -> Self {
153        let bind = std::env::var("LFSX_BIND")
154            .ok()
155            .and_then(|raw| raw.parse().ok())
156            .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 8080)));
157
158        let storage_root = std::env::var("LFSX_STORAGE_ROOT")
159            .map(PathBuf::from)
160            .unwrap_or_else(|_| PathBuf::from("/var/lib/lfsx"));
161
162        let public_url = std::env::var("LFSX_PUBLIC_URL")
163            .ok()
164            .filter(|url| !url.is_empty())
165            .map(|url| url.trim_end_matches('/').to_owned());
166
167        Self {
168            bind,
169            storage_root,
170            public_url,
171            action_lifetime: 1800,
172            gc_grace: seconds("LFSX_GC_GRACE").unwrap_or(GC_GRACE),
173            staging_max_age: seconds("LFSX_STAGING_MAX_AGE").unwrap_or(STAGING_MAX_AGE),
174            lock_max_age: seconds("LFSX_LOCK_MAX_AGE"),
175            max_object_size: bytes("LFSX_MAX_OBJECT_SIZE"),
176            repo_quota: bytes("LFSX_REPO_QUOTA"),
177            compression: compression(),
178            encryption_key_file: std::env::var("LFSX_ENCRYPTION_KEY_FILE")
179                .ok()
180                .filter(|path| !path.is_empty())
181                .map(PathBuf::from),
182            storage: Storage::from_env(),
183            auth: Auth::from_env(),
184        }
185    }
186
187    pub fn base_url(&self, headers: &HeaderMap) -> String {
188        if let Some(configured) = &self.public_url {
189            return configured.clone();
190        }
191
192        // Neither of these is this deployment speaking. They are what the caller
193        // sent, and what comes out of here is the URL that caller will send the
194        // object to, with its credential attached. So both are checked for being
195        // the thing they claim to be before either goes into a URL.
196        let scheme = headers
197            .get("x-forwarded-proto")
198            .and_then(|value| value.to_str().ok())
199            .and_then(|value| value.split(',').next())
200            .map(str::trim)
201            .filter(|scheme| matches!(*scheme, "http" | "https"))
202            .unwrap_or("http");
203
204        let authority = headers
205            .get(header::HOST)
206            .and_then(|value| value.to_str().ok())
207            .map(str::trim)
208            .filter(|host| is_an_authority(host))
209            .unwrap_or("localhost");
210
211        format!("{scheme}://{authority}")
212    }
213
214    pub fn object_url(&self, base: &str, ns: &Namespace, oid: &str) -> String {
215        format!("{base}/{ns}/objects/{oid}")
216    }
217
218    pub fn verify_url(&self, base: &str, ns: &Namespace) -> String {
219        format!("{base}/{ns}/objects/verify")
220    }
221
222    pub fn action(&self, href: String) -> Action {
223        Action {
224            href,
225            header: None,
226            expires_in: self.action_lifetime,
227        }
228    }
229
230    pub fn signed_action(&self, href: String, headers: Vec<(String, String)>) -> Action {
231        Action {
232            href,
233            header: Some(headers.into_iter().collect()),
234            expires_in: self.action_lifetime,
235        }
236    }
237}
238
239// Opt in, not opt out. Serving objects to a caller with no credentials at all is
240// a decision an operator should make on purpose: it costs them the bandwidth of
241// anyone who finds the endpoint, on a server whose whole job is to move files
242// measured in gigabytes. Nothing confidential is at stake, since a request with
243// no credentials is still resolved against the forge and a private repository is
244// still refused, but "anyone may pull from you" is not a sensible thing to
245// inherit by default.
246//
247// Only the exact string opens it. A typo, an empty value or a `1` leaves it
248// closed, because the failure that matters here is the one that opens the door
249// when nobody meant to.
250fn anonymous_read(value: Option<&str>) -> bool {
251    value == Some("true")
252}
253
254impl Auth {
255    fn from_env() -> Self {
256        if std::env::var("LFSX_AUTH").as_deref() == Ok("disabled") {
257            tracing::warn!(
258                "LFSX_AUTH=disabled — every request is accepted, run this on a trusted network only"
259            );
260            return Self::Disabled;
261        }
262
263        let provider = provider(std::env::var("LFSX_AUTH").ok().as_deref());
264
265        Self::Forge {
266            provider,
267            api_url: api_url(
268                provider,
269                std::env::var(provider.api_url_variable()).ok().as_deref(),
270            ),
271            cache_ttl: seconds("LFSX_AUTH_CACHE_TTL").unwrap_or(CACHE_TTL),
272            rejection_ttl: seconds("LFSX_AUTH_REJECTION_TTL").unwrap_or(REJECTION_TTL),
273            lookup_budget: lookup_budget(std::env::var("LFSX_AUTH_LOOKUP_BUDGET").ok().as_deref()),
274            anonymous_read: anonymous_read(std::env::var("LFSX_ANONYMOUS_READ").ok().as_deref()),
275        }
276    }
277}
278
279// Zero is the one value that cannot mean what it says. A ceiling of no lookups
280// is a server that refuses every caller it has not already seen, so it is read as
281// the operator turning the ceiling off, which is the only other thing they could
282// have meant. Anything unparseable is the default rather than a refusal to start:
283// this bounds a cost, and getting it wrong should not take the server down.
284fn lookup_budget(value: Option<&str>) -> Option<u32> {
285    match value.map(str::trim).map(str::parse::<u32>) {
286        Some(Ok(0)) => None,
287        Some(Ok(budget)) => Some(budget),
288        Some(Err(_)) | None => Some(LOOKUP_BUDGET),
289    }
290}
291
292// Anything unrecognised is GitHub, which is what an operator who set nothing
293// almost certainly meant. Forgejo is named alongside Gitea because they are one
294// API, and somebody running Forgejo should not have to know it began as a fork.
295fn provider(value: Option<&str>) -> Provider {
296    match value {
297        Some("gitlab") => Provider::Gitlab,
298        Some("gitea") | Some("forgejo") => Provider::Gitea,
299        _ => Provider::Github,
300    }
301}
302
303// The trailing slash matters: every route is built by appending to this, so one
304// left on the end produces `//repos/...`, which some forges answer and others do
305// not, and the ones that do not answer 404 for a repository that is right there.
306fn api_url(provider: Provider, configured: Option<&str>) -> String {
307    let variable = provider.api_url_variable();
308
309    configured
310        .map(str::to_owned)
311        .or_else(|| provider.default_api_url().map(str::to_owned))
312        .unwrap_or_else(|| {
313            panic!(
314                "{variable} must be set: a self-hosted forge has no default API root, and guessing \
315                 one would resolve your repositories against somebody else's"
316            )
317        })
318        .trim_end_matches('/')
319        .to_owned()
320}
321
322// Is this a host and a port, and nothing else?
323//
324// A `Host` carrying a `/` or an `@` is not one, and both change where the URL
325// built from it points. `real.example@evil.example` resolves to the second name
326// with the first read as a username, which turns a header somebody sent into a
327// redirect nobody wrote, and the client follows it carrying its token.
328//
329// Anything that fails this falls back to `localhost`, which is useless to
330// everybody and dangerous to nobody. `LFSX_PUBLIC_URL` is the fix, and startup
331// says so.
332fn is_an_authority(host: &str) -> bool {
333    !host.is_empty()
334        && host.len() <= 255
335        && host.bytes().all(|byte| {
336            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b':' | b'[' | b']')
337        })
338}
339
340// Unset means unlimited, which is what a server on its own volume wants. Zero
341// would refuse every push, so it is read as a typo rather than as a policy
342// nobody would choose deliberately.
343// zstd level 3 is the default because it is the one that costs nothing you can
344// measure: it compresses faster than a spinning disk writes, and the meshes and
345// uncompressed raster that make up most of an LFS store give most of their
346// ground at any level. Higher levels are there for a store that is short on
347// room rather than on time.
348fn compression() -> Option<i32> {
349    match std::env::var("LFSX_COMPRESSION").ok()?.trim() {
350        "" | "none" | "off" => None,
351        "zstd" => Some(3),
352        other => match other
353            .strip_prefix("zstd:")
354            .and_then(|level| level.parse().ok())
355        {
356            Some(level @ 1..=19) => Some(level),
357            _ => {
358                tracing::warn!(
359                    "LFSX_COMPRESSION={other} is not a codec this server knows — storing objects as they arrive"
360                );
361                None
362            }
363        },
364    }
365}
366
367fn bytes(variable: &str) -> Option<u64> {
368    let configured = std::env::var(variable).ok()?.trim().parse().ok()?;
369
370    if configured == 0 {
371        tracing::warn!("{variable}=0 would refuse every upload — ignoring it");
372        return None;
373    }
374
375    Some(configured)
376}
377
378fn seconds(variable: &str) -> Option<Duration> {
379    std::env::var(variable)
380        .ok()
381        .and_then(|raw| raw.parse().ok())
382        .map(Duration::from_secs)
383}
384
385#[cfg(test)]
386mod tests;