1pub(crate) mod audit;
2pub mod auth;
3pub mod config;
4pub mod dashboard;
5pub mod error;
6#[cfg(feature = "fuzzing")]
7pub mod fuzzing;
8pub mod locks;
9pub mod metrics;
10pub mod model;
11pub mod namespace;
12pub mod oid;
13pub mod page;
14pub mod range;
15pub mod routes;
16pub mod state;
17pub mod storage;
18pub mod telemetry;
19pub mod tls;
20
21use std::sync::Arc;
22
23use axum::Router;
24
25use crate::auth::Authorizer;
26use crate::config::Config;
27use crate::locks::LockStore;
28use crate::metrics::Metrics;
29use crate::state::AppState;
30use crate::storage::s3::{Keyspace, S3Config, S3Keys, S3Store};
31use crate::storage::{LocalStore, Store};
32
33pub fn app(config: Config) -> Router {
34 if config.public_url.is_none() && !matches!(config.auth, crate::config::Auth::Disabled) {
47 tracing::warn!(
48 "LFSX_PUBLIC_URL is not set, so the URLs handed to clients are built from the Host and \
49 X-Forwarded-Proto headers of whoever asked. Behind a proxy that does not rewrite them, \
50 a caller chooses where the next request goes and takes its token there. Set it to the \
51 address clients actually use"
52 );
53 }
54
55 let (store, locks) = backends(&config);
56 let authorizer = Authorizer::new(&config.auth);
57 let transfers = (config.max_concurrent_transfers > 0)
58 .then(|| Arc::new(tokio::sync::Semaphore::new(config.max_concurrent_transfers)));
59
60 routes::router(Arc::new(AppState {
61 store,
62 locks,
63 config,
64 authorizer,
65 metrics: Metrics::new(),
66 transfers,
67 }))
68}
69
70pub async fn reclaim(config: &Config) {
75 let reclaimed = backends(config).0.reclaim(config.staging_max_age).await;
76
77 if reclaimed.files > 0 {
78 tracing::info!(
79 files = reclaimed.files,
80 bytes = reclaimed.bytes,
81 "reclaimed what interrupted uploads left behind"
82 );
83 }
84}
85
86pub async fn verify_presign(config: &mut Config) {
102 use crate::storage::s3::probe::{Checksums, checksums};
103
104 let crate::config::Storage::Bucket { presign: true, .. } = &config.storage else {
105 return;
106 };
107
108 let Some(keys) = keyspace(config) else {
109 return;
110 };
111
112 let refusal = match checksums(&keys).await {
113 Checksums::Enforced => return,
114 Checksums::Ignored => {
115 "this object store accepted an upload whose body did not match the checksum its own \
116 signature named. A store that does not verify that header lets a client with push \
117 rights put chosen bytes under a chosen digest, and every repository that later pushes \
118 that digest would get a marker pointing at them"
119 }
120 Checksums::Unknown => {
121 "this object store could not be asked whether it verifies upload checksums. Handing out \
122 a write URL is only safe if the store refuses a body that does not match it, and that \
123 has not been established"
124 }
125 };
126
127 tracing::error!(
128 "{refusal}, so LFSX_S3_PRESIGN is being ignored and uploads keep coming through this server"
129 );
130
131 if let crate::config::Storage::Bucket { presign, .. } = &mut config.storage {
132 *presign = false;
133 }
134}
135
136pub async fn verify_locking(config: &mut Config) {
151 use crate::storage::s3::probe::{Conditional, conditional_writes};
152
153 let Some(keys) = keyspace(config) else {
154 return;
155 };
156
157 let refusal = match conditional_writes(&keys).await {
158 Conditional::Enforced => return,
159 Conditional::Ignored => {
160 "this object store wrote the same key twice under a condition that should have refused \
161 the second, so it cannot say which of two clients racing for a lock arrived first"
162 }
163 Conditional::Unknown => {
164 "this object store could not be asked whether it refuses a conditional write, and lock \
165 uniqueness is exactly that refusal"
166 }
167 };
168
169 tracing::error!(
170 "{refusal}, so taking a lock here answers 501. Objects are unaffected, and so is everything \
171 else this server does"
172 );
173
174 if let crate::config::Storage::Bucket { locking, .. } = &mut config.storage {
175 *locking = false;
176 }
177}
178
179fn keyspace(config: &Config) -> Option<Keyspace> {
180 let crate::config::Storage::Bucket { dialect, .. } = &config.storage else {
181 return None;
182 };
183
184 let crate::config::Dialect::S3 {
185 endpoint,
186 bucket,
187 region,
188 access_key,
189 secret_key,
190 path_style,
191 } = dialect;
192
193 Some(Keyspace::S3(
194 S3Keys::new(&S3Config {
195 endpoint: endpoint.clone(),
196 bucket: bucket.clone(),
197 region: region.clone(),
198 access_key: access_key.clone(),
199 secret_key: secret_key.clone(),
200 path_style: *path_style,
201 lifetime: std::time::Duration::from_secs(config.action_lifetime.into()),
202 })
203 .expect("the bucket configuration is not usable"),
204 ))
205}
206
207fn backends(config: &Config) -> (Store, LockStore) {
208 if let crate::config::Auth::Forge {
213 anonymous_read: true,
214 ..
215 } = config.auth
216 {
217 tracing::info!(
218 "anonymous read is on: a request with no credentials is resolved against the forge, so \
219 objects in a repository the forge serves publicly can be read by anybody, and the \
220 bandwidth is yours. Unset LFSX_ANONYMOUS_READ to require a token whatever the \
221 repository's visibility"
222 );
223 }
224
225 if let crate::config::Auth::Forge { restricted, .. } = &config.auth
229 && !restricted.is_empty()
230 {
231 tracing::info!(
232 "restricted namespaces are configured: objects in a listed repository take write \
233 access to read, so a caller the forge grants pull is refused. Unset LFSX_RESTRICTED \
234 to serve every repository the permissions the forge gives it"
235 );
236 }
237
238 let keys = config.encryption_key.as_ref().map(|source| {
243 std::sync::Arc::new(
244 crate::storage::crypt::Keyring::from_source(source)
245 .expect("the encryption key source is not usable"),
246 )
247 });
248
249 let local = LocalStore::new(config.storage_root.clone())
250 .with_max_object_size(config.max_object_size)
251 .with_compression(config.compression)
252 .with_encryption(keys);
253
254 let (store, lock_backend) = match &config.storage {
259 crate::config::Storage::Local => (
260 Store::local(local),
261 LockStore::local(config.storage_root.clone()),
262 ),
263 crate::config::Storage::Bucket {
264 presign,
265 locking,
266 cache,
267 ..
268 } => {
269 let keys = keyspace(config).expect("a bucket keyspace for a bucket store");
274
275 tracing::warn!(
276 "objects and locks are stored in a bucket: deduplication, rewriting and \
277 verification answer 501, and the lfsx_objects_stored and lfsx_store_bytes \
278 gauges are not measured: read capacity from the bucket itself"
279 );
280
281 if *presign {
282 if config.encryption_key.is_some() || config.compression.is_some() {
283 tracing::warn!(
284 "LFSX_S3_PRESIGN=true, but a codec is configured, so downloads keep \
285 streaming through this server: what sits in the bucket is a frame under \
286 the plaintext digest, and a client handed that directly would hash it \
287 and reject the object"
288 );
289 } else {
290 tracing::warn!(
291 "LFSX_S3_PRESIGN=true, downloads are redirected to the bucket, so \
292 lfsx_downloaded_bytes stops counting them and the bucket serves the ranges"
293 );
294 }
295
296 if config.encryption_key.is_some() {
297 tracing::warn!(
298 "an encryption key is configured, so uploads keep coming through this \
299 server rather than going straight to the bucket: an object a client \
300 writes itself would arrive unencrypted"
301 );
302 } else if config.compression.is_some() {
303 tracing::warn!(
304 "LFSX_COMPRESSION is set, and objects clients upload straight to the \
305 bucket arrive uncompressed: only what passes through this server is \
306 compressed"
307 );
308 }
309 }
310
311 let disk = cache.as_ref().map(|disk| {
318 crate::storage::cache::Cache::new(disk.dir.clone(), disk.max_bytes)
319 .expect("the cache directory is not usable")
320 });
321
322 if disk.is_some() && *presign {
323 tracing::warn!(
324 "LFSX_S3_CACHE_DIR is set with LFSX_S3_PRESIGN=true, so downloads go straight to the \
325 bucket and the cache never sees them: the two settings pull in opposite directions"
326 );
327 }
328
329 (
330 Store::bucket(S3Store::new(keys.clone(), *presign), local).with_cache(disk),
331 LockStore::bucket(keys).with_conditional_writes(*locking),
332 )
333 }
334 };
335 (store, lock_backend.with_max_age(config.lock_max_age))
336}