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 let (store, locks) = backends(&config);
30 let authorizer = Authorizer::new(&config.auth);
31
32 routes::router(Arc::new(AppState {
33 store,
34 locks,
35 config,
36 authorizer,
37 metrics: Metrics::new(),
38 }))
39}
40
41pub async fn reclaim(config: &Config) {
46 let reclaimed = backends(config).0.reclaim(config.staging_max_age).await;
47
48 if reclaimed.files > 0 {
49 tracing::info!(
50 files = reclaimed.files,
51 bytes = reclaimed.bytes,
52 "reclaimed what interrupted uploads left behind"
53 );
54 }
55}
56
57pub async fn verify_presign(config: &mut Config) {
73 use crate::storage::s3::probe::{Checksums, checksums};
74
75 let crate::config::Storage::Bucket { presign: true, .. } = &config.storage else {
76 return;
77 };
78
79 let Some(keys) = keyspace(config) else {
80 return;
81 };
82
83 let refusal = match checksums(&keys).await {
84 Checksums::Enforced => return,
85 Checksums::Ignored => {
86 "this object store accepted an upload whose body did not match the checksum its own \
87 signature named. A store that does not verify that header lets a client with push \
88 rights put chosen bytes under a chosen digest, and every repository that later pushes \
89 that digest would get a marker pointing at them"
90 }
91 Checksums::Unknown => {
92 "this object store could not be asked whether it verifies upload checksums. Handing out \
93 a write URL is only safe if the store refuses a body that does not match it, and that \
94 has not been established"
95 }
96 };
97
98 tracing::error!(
99 "{refusal}, so LFSX_S3_PRESIGN is being ignored and uploads keep coming through this server"
100 );
101
102 if let crate::config::Storage::Bucket { presign, .. } = &mut config.storage {
103 *presign = false;
104 }
105}
106
107pub async fn verify_locking(config: &mut Config) {
122 use crate::storage::s3::probe::{Conditional, conditional_writes};
123
124 let Some(keys) = keyspace(config) else {
125 return;
126 };
127
128 let refusal = match conditional_writes(&keys).await {
129 Conditional::Enforced => return,
130 Conditional::Ignored => {
131 "this object store wrote the same key twice under a condition that should have refused \
132 the second, so it cannot say which of two clients racing for a lock arrived first"
133 }
134 Conditional::Unknown => {
135 "this object store could not be asked whether it refuses a conditional write, and lock \
136 uniqueness is exactly that refusal"
137 }
138 };
139
140 tracing::error!(
141 "{refusal}, so taking a lock here answers 501. Objects are unaffected, and so is everything \
142 else this server does"
143 );
144
145 if let crate::config::Storage::Bucket { locking, .. } = &mut config.storage {
146 *locking = false;
147 }
148}
149
150fn keyspace(config: &Config) -> Option<Keyspace> {
151 let crate::config::Storage::Bucket {
152 endpoint,
153 bucket,
154 region,
155 access_key,
156 secret_key,
157 path_style,
158 ..
159 } = &config.storage
160 else {
161 return None;
162 };
163
164 Some(
165 Keyspace::new(&S3Config {
166 endpoint: endpoint.clone(),
167 bucket: bucket.clone(),
168 region: region.clone(),
169 access_key: access_key.clone(),
170 secret_key: secret_key.clone(),
171 path_style: *path_style,
172 lifetime: std::time::Duration::from_secs(config.action_lifetime.into()),
173 })
174 .expect("the bucket configuration is not usable"),
175 )
176}
177
178fn backends(config: &Config) -> (Store, LockStore) {
179 if let crate::config::Auth::Forge {
184 anonymous_read: true,
185 ..
186 } = config.auth
187 {
188 tracing::info!(
189 "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"
190 );
191 }
192
193 let keys = config.encryption_key_file.as_deref().map(|path| {
198 std::sync::Arc::new(
199 crate::storage::crypt::Keyring::load(path)
200 .expect("the encryption key file is not usable"),
201 )
202 });
203
204 let local = LocalStore::new(config.storage_root.clone())
205 .with_max_object_size(config.max_object_size)
206 .with_compression(config.compression)
207 .with_encryption(keys);
208
209 let (store, lock_backend) = match &config.storage {
214 crate::config::Storage::Local => (
215 Store::local(local),
216 LockStore::local(config.storage_root.clone()),
217 ),
218 crate::config::Storage::Bucket {
219 presign, locking, ..
220 } => {
221 let keys = keyspace(config).expect("a bucket keyspace for a bucket store");
226
227 tracing::warn!(
228 "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"
229 );
230
231 if *presign {
232 if config.encryption_key_file.is_some() || config.compression.is_some() {
233 tracing::warn!(
234 "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"
235 );
236 } else {
237 tracing::warn!(
238 "LFSX_S3_PRESIGN=true, downloads are redirected to the bucket, so lfsx_downloaded_bytes stops counting them and the bucket serves the ranges"
239 );
240 }
241
242 if config.encryption_key_file.is_some() {
243 tracing::warn!(
244 "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"
245 );
246 } else if config.compression.is_some() {
247 tracing::warn!(
248 "LFSX_COMPRESSION is set, and objects clients upload straight to the bucket arrive uncompressed — only what passes through this server is compressed"
249 );
250 }
251 }
252
253 (
257 Store::bucket(S3Store::new(keys.clone(), *presign), local),
258 LockStore::bucket(keys).with_conditional_writes(*locking),
259 )
260 }
261 };
262 (store, lock_backend.with_max_age(config.lock_max_age))
263}