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