Skip to main content

lfsx_server/
lib.rs

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    // Here rather than in `backends`, which `reclaim` also calls: a reclaim pass
30    // hands nobody a URL, and saying this twice at every boot teaches an operator
31    // to skim it.
32    //
33    // The hrefs in a batch answer are where a client sends the object, and it
34    // sends its credential with them. Unset, they are built from the `Host` and
35    // `X-Forwarded-Proto` of whoever asked, which is a deployment fact only for
36    // as long as something in front is rewriting both.
37    //
38    // Warned rather than refused. Every deployment that works today works without
39    // it, and taking those down to close a hole most of them do not have is the
40    // wrong trade.
41    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
62// Everything an interrupted upload left behind, wherever it left it: a staging
63// file on the volume, or bytes under an upload key nobody ever reported. Built
64// from the same construction the server uses, so a bucket deployment does not
65// end up sweeping only half of itself.
66pub 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
78// Ask the bucket, once, whether it really refuses an upload whose body does not
79// match the checksum its URL was signed for, and give up pre-signing if it does
80// not say yes.
81//
82// Handing a client a write URL is safe only because of that refusal. Without it,
83// anyone with push rights to any repository can put chosen bytes under a chosen
84// digest, and objects are shared: bytes live once at `.content/{oid}`, so every
85// repository that later pushes that digest gets a marker pointing at them and
86// uploads nothing. One store that ignores the header decides what an object is
87// for everybody.
88//
89// Losing pre-signing costs throughput and nothing else, because transfers fall
90// back to coming through this server, which hashes what it is sent. That is why
91// a store which cannot be asked loses it too: the question guards data, and an
92// unanswered question is not a yes.
93pub 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
128// Ask the bucket, once, whether it refuses the second of two conditional writes,
129// and give up locking if it will not say yes.
130//
131// That refusal is the entirety of lock uniqueness here. Two clients race for the
132// same path, both write, and the store is the only thing that can say one of them
133// arrived second. A store that accepts `If-None-Match: *` without implementing it
134// performs both writes and reports success twice, so both are told the lock is
135// theirs, and nothing anywhere notices.
136//
137// There is no safe degraded mode for that, so taking a lock becomes a `501`
138// instead. It is the loudest honest answer: a client sees a refusal at the moment
139// it asks, rather than a lock somebody else also holds. Everything else about the
140// deployment is untouched, objects included, because a team that never takes a
141// lock should not lose a working server over this.
142pub 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    // Said out loud because it decides who can read the objects. It is off unless
201    // asked for, so this line means somebody asked: it belongs in the log so a
202    // deployment that inherited the flag from an older chart sees it rather than
203    // discovers it.
204    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    // Refusing to start beats starting without it. A server that silently wrote
215    // plaintext because a Secret failed to mount is the one failure this feature
216    // must never have: nothing downstream would notice, and the objects written
217    // in the meantime are the ones the operator believed were covered.
218    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    // The two backends are chosen together and the lock policy is applied once,
231    // to both. Deciding it per arm is how `LFSX_LOCK_MAX_AGE` came to be silently
232    // ignored in bucket mode: the arms are far apart, only one of them had it,
233    // and nothing failed.
234    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            // Built once and shared: the objects and the locks are two ways of
243            // using the same bucket, not two buckets. Signing, the connection
244            // pool and the retry policy are settled here, and neither layer
245            // reaches into the other to get at them.
246            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            // The locks go with the objects. Left on the volume they would make
275            // the bucket a half measure: capacity would be shared and the one
276            // piece of state a second replica must agree on would not be.
277            (
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}