1pub mod auth;
2pub mod config;
3pub mod dashboard;
4pub mod error;
5pub mod locks;
6pub mod metrics;
7pub mod model;
8pub mod namespace;
9pub mod page;
10pub mod range;
11pub mod routes;
12pub mod state;
13pub mod storage;
14pub mod tls;
15
16use std::sync::Arc;
17
18use axum::Router;
19
20use crate::auth::Authorizer;
21use crate::config::Config;
22use crate::locks::LockStore;
23use crate::metrics::Metrics;
24use crate::state::AppState;
25use crate::storage::s3::{Keyspace, S3Config, S3Store};
26use crate::storage::{LocalStore, Store};
27
28pub fn app(config: Config) -> Router {
29 if config.public_url.is_none() && !matches!(config.auth, crate::config::Auth::Disabled) {
42 tracing::warn!(
43 "LFSX_PUBLIC_URL is not set, so the URLs handed to clients are built from the Host and \
44 X-Forwarded-Proto headers of whoever asked. Behind a proxy that does not rewrite them, \
45 a caller chooses where the next request goes and takes its token there. Set it to the \
46 address clients actually use"
47 );
48 }
49
50 let (store, locks) = backends(&config);
51 let authorizer = Authorizer::new(&config.auth);
52
53 routes::router(Arc::new(AppState {
54 store,
55 locks,
56 config,
57 authorizer,
58 metrics: Metrics::new(),
59 }))
60}
61
62pub async fn reclaim(config: &Config) {
67 let reclaimed = backends(config).0.reclaim(config.staging_max_age).await;
68
69 if reclaimed.files > 0 {
70 tracing::info!(
71 files = reclaimed.files,
72 bytes = reclaimed.bytes,
73 "reclaimed what interrupted uploads left behind"
74 );
75 }
76}
77
78pub async fn verify_presign(config: &mut Config) {
94 use crate::storage::s3::probe::{Checksums, checksums};
95
96 let crate::config::Storage::Bucket { presign: true, .. } = &config.storage else {
97 return;
98 };
99
100 let Some(keys) = keyspace(config) else {
101 return;
102 };
103
104 let refusal = match checksums(&keys).await {
105 Checksums::Enforced => return,
106 Checksums::Ignored => {
107 "this object store accepted an upload whose body did not match the checksum its own \
108 signature named. A store that does not verify that header lets a client with push \
109 rights put chosen bytes under a chosen digest, and every repository that later pushes \
110 that digest would get a marker pointing at them"
111 }
112 Checksums::Unknown => {
113 "this object store could not be asked whether it verifies upload checksums. Handing out \
114 a write URL is only safe if the store refuses a body that does not match it, and that \
115 has not been established"
116 }
117 };
118
119 tracing::error!(
120 "{refusal}, so LFSX_S3_PRESIGN is being ignored and uploads keep coming through this server"
121 );
122
123 if let crate::config::Storage::Bucket { presign, .. } = &mut config.storage {
124 *presign = false;
125 }
126}
127
128pub async fn verify_locking(config: &mut Config) {
143 use crate::storage::s3::probe::{Conditional, conditional_writes};
144
145 let Some(keys) = keyspace(config) else {
146 return;
147 };
148
149 let refusal = match conditional_writes(&keys).await {
150 Conditional::Enforced => return,
151 Conditional::Ignored => {
152 "this object store wrote the same key twice under a condition that should have refused \
153 the second, so it cannot say which of two clients racing for a lock arrived first"
154 }
155 Conditional::Unknown => {
156 "this object store could not be asked whether it refuses a conditional write, and lock \
157 uniqueness is exactly that refusal"
158 }
159 };
160
161 tracing::error!(
162 "{refusal}, so taking a lock here answers 501. Objects are unaffected, and so is everything \
163 else this server does"
164 );
165
166 if let crate::config::Storage::Bucket { locking, .. } = &mut config.storage {
167 *locking = false;
168 }
169}
170
171fn keyspace(config: &Config) -> Option<Keyspace> {
172 let crate::config::Storage::Bucket {
173 endpoint,
174 bucket,
175 region,
176 access_key,
177 secret_key,
178 path_style,
179 ..
180 } = &config.storage
181 else {
182 return None;
183 };
184
185 Some(
186 Keyspace::new(&S3Config {
187 endpoint: endpoint.clone(),
188 bucket: bucket.clone(),
189 region: region.clone(),
190 access_key: access_key.clone(),
191 secret_key: secret_key.clone(),
192 path_style: *path_style,
193 lifetime: std::time::Duration::from_secs(config.action_lifetime.into()),
194 })
195 .expect("the bucket configuration is not usable"),
196 )
197}
198
199fn backends(config: &Config) -> (Store, LockStore) {
200 if let crate::config::Auth::Forge {
205 anonymous_read: true,
206 ..
207 } = config.auth
208 {
209 tracing::info!(
210 "anonymous read is on: a request with no credentials is resolved against the forge, so objects in a repository the forge serves publicly can be read by anybody, and the bandwidth is yours. Unset LFSX_ANONYMOUS_READ to require a token whatever the repository's visibility"
211 );
212 }
213
214 let keys = config.encryption_key_file.as_deref().map(|path| {
219 std::sync::Arc::new(
220 crate::storage::crypt::Keyring::load(path)
221 .expect("the encryption key file is not usable"),
222 )
223 });
224
225 let local = LocalStore::new(config.storage_root.clone())
226 .with_max_object_size(config.max_object_size)
227 .with_compression(config.compression)
228 .with_encryption(keys);
229
230 let (store, lock_backend) = match &config.storage {
235 crate::config::Storage::Local => (
236 Store::local(local),
237 LockStore::local(config.storage_root.clone()),
238 ),
239 crate::config::Storage::Bucket {
240 presign, locking, ..
241 } => {
242 let keys = keyspace(config).expect("a bucket keyspace for a bucket store");
247
248 tracing::warn!(
249 "objects and locks are stored in a bucket: deduplication, rewriting and verification answer 501, and the lfsx_objects_stored and lfsx_store_bytes gauges are not measured — read capacity from the bucket itself"
250 );
251
252 if *presign {
253 if config.encryption_key_file.is_some() || config.compression.is_some() {
254 tracing::warn!(
255 "LFSX_S3_PRESIGN=true, but a codec is configured, so downloads keep streaming through this server: what sits in the bucket is a frame under the plaintext digest, and a client handed that directly would hash it and reject the object"
256 );
257 } else {
258 tracing::warn!(
259 "LFSX_S3_PRESIGN=true, downloads are redirected to the bucket, so lfsx_downloaded_bytes stops counting them and the bucket serves the ranges"
260 );
261 }
262
263 if config.encryption_key_file.is_some() {
264 tracing::warn!(
265 "LFSX_ENCRYPTION_KEY_FILE is set, so uploads keep coming through this server rather than going straight to the bucket: an object a client writes itself would arrive unencrypted"
266 );
267 } else if config.compression.is_some() {
268 tracing::warn!(
269 "LFSX_COMPRESSION is set, and objects clients upload straight to the bucket arrive uncompressed — only what passes through this server is compressed"
270 );
271 }
272 }
273
274 (
278 Store::bucket(S3Store::new(keys.clone(), *presign), local),
279 LockStore::bucket(keys).with_conditional_writes(*locking),
280 )
281 }
282 };
283 (store, lock_backend.with_max_age(config.lock_max_age))
284}