Skip to main content

lfsx_server/
lib.rs

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    // Here rather than in `backends`, which `reclaim` also calls: a reclaim pass
35    // hands nobody a URL, and saying this twice at every boot teaches an operator
36    // to skim it.
37    //
38    // The hrefs in a batch answer are where a client sends the object, and it
39    // sends its credential with them. Unset, they are built from the `Host` and
40    // `X-Forwarded-Proto` of whoever asked, which is a deployment fact only for
41    // as long as something in front is rewriting both.
42    //
43    // Warned rather than refused. Every deployment that works today works without
44    // it, and taking those down to close a hole most of them do not have is the
45    // wrong trade.
46    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
70// Everything an interrupted upload left behind, wherever it left it: a staging
71// file on the volume, or bytes under an upload key nobody ever reported. Built
72// from the same construction the server uses, so a bucket deployment does not
73// end up sweeping only half of itself.
74pub 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
86// Ask the bucket, once, whether it really refuses an upload whose body does not
87// match the checksum its URL was signed for, and give up pre-signing if it does
88// not say yes.
89//
90// Handing a client a write URL is safe only because of that refusal. Without it,
91// anyone with push rights to any repository can put chosen bytes under a chosen
92// digest, and objects are shared: bytes live once at `.content/{oid}`, so every
93// repository that later pushes that digest gets a marker pointing at them and
94// uploads nothing. One store that ignores the header decides what an object is
95// for everybody.
96//
97// Losing pre-signing costs throughput and nothing else, because transfers fall
98// back to coming through this server, which hashes what it is sent. That is why
99// a store which cannot be asked loses it too: the question guards data, and an
100// unanswered question is not a yes.
101pub 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
136// Ask the bucket, once, whether it refuses the second of two conditional writes,
137// and give up locking if it will not say yes.
138//
139// That refusal is the entirety of lock uniqueness here. Two clients race for the
140// same path, both write, and the store is the only thing that can say one of them
141// arrived second. A store that accepts `If-None-Match: *` without implementing it
142// performs both writes and reports success twice, so both are told the lock is
143// theirs, and nothing anywhere notices.
144//
145// There is no safe degraded mode for that, so taking a lock becomes a `501`
146// instead. It is the loudest honest answer: a client sees a refusal at the moment
147// it asks, rather than a lock somebody else also holds. Everything else about the
148// deployment is untouched, objects included, because a team that never takes a
149// lock should not lose a working server over this.
150pub 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    // Said out loud because it decides who can read the objects. It is off unless
209    // asked for, so this line means somebody asked: it belongs in the log so a
210    // deployment that inherited the flag from an older chart sees it rather than
211    // discovers it.
212    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    // Same discipline: a list inherited from a chart is worth seeing at boot
226    // rather than discovering when somebody reports a repository they can clone
227    // and cannot pull.
228    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    // Refusing to start beats starting without it. A server that silently wrote
239    // plaintext because a Secret failed to mount is the one failure this feature
240    // must never have: nothing downstream would notice, and the objects written
241    // in the meantime are the ones the operator believed were covered.
242    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    // The two backends are chosen together and the lock policy is applied once,
255    // to both. Deciding it per arm is how `LFSX_LOCK_MAX_AGE` came to be silently
256    // ignored in bucket mode: the arms are far apart, only one of them had it,
257    // and nothing failed.
258    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            // Built once and shared: the objects and the locks are two ways of
270            // using the same bucket, not two buckets. Signing, the connection
271            // pool and the retry policy are settled here, and neither layer
272            // reaches into the other to get at them.
273            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            // The locks go with the objects. Left on the volume they would make
312            // the bucket a half measure: capacity would be shared and the one
313            // piece of state a second replica must agree on would not be.
314            // A cache the server cannot create is a misconfiguration worth
315            // stopping for: the alternative is a deployment that silently keeps
316            // paying the round trips the operator thought they had bought out of.
317            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}