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 storage: Storage,
22    pub auth: Auth,
23}
24
25#[derive(Debug, Clone)]
26pub enum Storage {
27    Local,
28    // Endpoint, bucket and credentials all have to be there: a bucket the server
29    // cannot reach is a server that answers every push with an error, and
30    // discovering that on the first upload rather than at boot is the wrong
31    // order.
32    Bucket {
33        endpoint: String,
34        bucket: String,
35        region: String,
36        access_key: String,
37        secret_key: String,
38        path_style: bool,
39        // Whether a download is redirected to the bucket instead of streamed
40        // through this server. Off by default: the streamed path is the one
41        // that counts bytes, serves ranges and holds the ceiling, and an
42        // operator should choose to give those up rather than discover it.
43        presign: bool,
44    },
45}
46
47impl Storage {
48    fn from_env() -> Self {
49        if std::env::var("LFSX_STORAGE").as_deref() != Ok("s3") {
50            return Self::Local;
51        }
52
53        let required = |name: &str| {
54            std::env::var(name)
55                .ok()
56                .filter(|value| !value.is_empty())
57                .unwrap_or_else(|| panic!("LFSX_STORAGE=s3 needs {name}"))
58        };
59
60        Self::Bucket {
61            endpoint: required("LFSX_S3_ENDPOINT"),
62            bucket: required("LFSX_S3_BUCKET"),
63            region: std::env::var("LFSX_S3_REGION").unwrap_or_else(|_| "us-east-1".into()),
64            access_key: required("LFSX_S3_ACCESS_KEY"),
65            secret_key: required("LFSX_S3_SECRET_KEY"),
66            path_style: std::env::var("LFSX_S3_PATH_STYLE").as_deref() != Ok("false"),
67            presign: std::env::var("LFSX_S3_PRESIGN").as_deref() == Ok("true"),
68        }
69    }
70}
71
72#[derive(Debug, Clone)]
73pub enum Auth {
74    Forge {
75        provider: Provider,
76        api_url: String,
77        cache_ttl: Duration,
78        rejection_ttl: Duration,
79    },
80    Disabled,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum Provider {
85    Github,
86    Gitlab,
87}
88
89impl Provider {
90    fn default_api_url(self) -> &'static str {
91        match self {
92            Self::Github => "https://api.github.com",
93            Self::Gitlab => "https://gitlab.com/api/v4",
94        }
95    }
96
97    fn api_url_variable(self) -> &'static str {
98        match self {
99            Self::Github => "LFSX_GITHUB_API_URL",
100            Self::Gitlab => "LFSX_GITLAB_API_URL",
101        }
102    }
103}
104
105const CACHE_TTL: Duration = Duration::from_secs(60);
106const REJECTION_TTL: Duration = Duration::from_secs(10);
107const GC_GRACE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
108const STAGING_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
109
110impl Config {
111    pub fn from_env() -> Self {
112        let bind = std::env::var("LFSX_BIND")
113            .ok()
114            .and_then(|raw| raw.parse().ok())
115            .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 8080)));
116
117        let storage_root = std::env::var("LFSX_STORAGE_ROOT")
118            .map(PathBuf::from)
119            .unwrap_or_else(|_| PathBuf::from("/var/lib/lfsx"));
120
121        let public_url = std::env::var("LFSX_PUBLIC_URL")
122            .ok()
123            .filter(|url| !url.is_empty())
124            .map(|url| url.trim_end_matches('/').to_owned());
125
126        Self {
127            bind,
128            storage_root,
129            public_url,
130            action_lifetime: 1800,
131            gc_grace: seconds("LFSX_GC_GRACE").unwrap_or(GC_GRACE),
132            staging_max_age: seconds("LFSX_STAGING_MAX_AGE").unwrap_or(STAGING_MAX_AGE),
133            max_object_size: bytes("LFSX_MAX_OBJECT_SIZE"),
134            repo_quota: bytes("LFSX_REPO_QUOTA"),
135            compression: compression(),
136            storage: Storage::from_env(),
137            auth: Auth::from_env(),
138        }
139    }
140
141    pub fn base_url(&self, headers: &HeaderMap) -> String {
142        if let Some(configured) = &self.public_url {
143            return configured.clone();
144        }
145
146        let scheme = headers
147            .get("x-forwarded-proto")
148            .and_then(|value| value.to_str().ok())
149            .and_then(|value| value.split(',').next())
150            .map(str::trim)
151            .filter(|scheme| !scheme.is_empty())
152            .unwrap_or("http");
153
154        let authority = headers
155            .get(header::HOST)
156            .and_then(|value| value.to_str().ok())
157            .map(str::trim)
158            .filter(|host| !host.is_empty())
159            .unwrap_or("localhost");
160
161        format!("{scheme}://{authority}")
162    }
163
164    pub fn object_url(&self, base: &str, ns: &Namespace, oid: &str) -> String {
165        format!("{base}/{ns}/objects/{oid}")
166    }
167
168    pub fn verify_url(&self, base: &str, ns: &Namespace) -> String {
169        format!("{base}/{ns}/objects/verify")
170    }
171
172    pub fn action(&self, href: String) -> Action {
173        Action {
174            href,
175            expires_in: self.action_lifetime,
176        }
177    }
178}
179
180impl Auth {
181    fn from_env() -> Self {
182        if std::env::var("LFSX_AUTH").as_deref() == Ok("disabled") {
183            tracing::warn!(
184                "LFSX_AUTH=disabled — every request is accepted, run this on a trusted network only"
185            );
186            return Self::Disabled;
187        }
188
189        let provider = match std::env::var("LFSX_AUTH").as_deref() {
190            Ok("gitlab") => Provider::Gitlab,
191            _ => Provider::Github,
192        };
193
194        let api_url = std::env::var(provider.api_url_variable())
195            .unwrap_or_else(|_| provider.default_api_url().to_owned())
196            .trim_end_matches('/')
197            .to_owned();
198
199        Self::Forge {
200            provider,
201            api_url,
202            cache_ttl: seconds("LFSX_AUTH_CACHE_TTL").unwrap_or(CACHE_TTL),
203            rejection_ttl: seconds("LFSX_AUTH_REJECTION_TTL").unwrap_or(REJECTION_TTL),
204        }
205    }
206}
207
208// Unset means unlimited, which is what a server on its own volume wants. Zero
209// would refuse every push, so it is read as a typo rather than as a policy
210// nobody would choose deliberately.
211// zstd level 3 is the default because it is the one that costs nothing you can
212// measure: it compresses faster than a spinning disk writes, and the meshes and
213// uncompressed raster that make up most of an LFS store give most of their
214// ground at any level. Higher levels are there for a store that is short on
215// room rather than on time.
216fn compression() -> Option<i32> {
217    match std::env::var("LFSX_COMPRESSION").ok()?.trim() {
218        "" | "none" | "off" => None,
219        "zstd" => Some(3),
220        other => match other
221            .strip_prefix("zstd:")
222            .and_then(|level| level.parse().ok())
223        {
224            Some(level @ 1..=19) => Some(level),
225            _ => {
226                tracing::warn!(
227                    "LFSX_COMPRESSION={other} is not a codec this server knows — storing objects as they arrive"
228                );
229                None
230            }
231        },
232    }
233}
234
235fn bytes(variable: &str) -> Option<u64> {
236    let configured = std::env::var(variable).ok()?.trim().parse().ok()?;
237
238    if configured == 0 {
239        tracing::warn!("{variable}=0 would refuse every upload — ignoring it");
240        return None;
241    }
242
243    Some(configured)
244}
245
246fn seconds(variable: &str) -> Option<Duration> {
247    std::env::var(variable)
248        .ok()
249        .and_then(|raw| raw.parse().ok())
250        .map(Duration::from_secs)
251}