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    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
41// Everything an interrupted upload left behind, wherever it left it: a staging
42// file on the volume, or bytes under an upload key nobody ever reported. Built
43// from the same construction the server uses, so a bucket deployment does not
44// end up sweeping only half of itself.
45pub 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
57// Ask the bucket, once, whether it really refuses an upload whose body does not
58// match the checksum its URL was signed for, and give up pre-signing if it does
59// not say yes.
60//
61// Handing a client a write URL is safe only because of that refusal. Without it,
62// anyone with push rights to any repository can put chosen bytes under a chosen
63// digest, and objects are shared: bytes live once at `.content/{oid}`, so every
64// repository that later pushes that digest gets a marker pointing at them and
65// uploads nothing. One store that ignores the header decides what an object is
66// for everybody.
67//
68// Losing pre-signing costs throughput and nothing else, because transfers fall
69// back to coming through this server, which hashes what it is sent. That is why
70// a store which cannot be asked loses it too: the question guards data, and an
71// unanswered question is not a yes.
72pub 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
107fn keyspace(config: &Config) -> Option<Keyspace> {
108    let crate::config::Storage::Bucket {
109        endpoint,
110        bucket,
111        region,
112        access_key,
113        secret_key,
114        path_style,
115        ..
116    } = &config.storage
117    else {
118        return None;
119    };
120
121    Some(
122        Keyspace::new(&S3Config {
123            endpoint: endpoint.clone(),
124            bucket: bucket.clone(),
125            region: region.clone(),
126            access_key: access_key.clone(),
127            secret_key: secret_key.clone(),
128            path_style: *path_style,
129            lifetime: std::time::Duration::from_secs(config.action_lifetime.into()),
130        })
131        .expect("the bucket configuration is not usable"),
132    )
133}
134
135fn backends(config: &Config) -> (Store, LockStore) {
136    // Said out loud because it decides who can read the objects. It is off unless
137    // asked for, so this line means somebody asked: it belongs in the log so a
138    // deployment that inherited the flag from an older chart sees it rather than
139    // discovers it.
140    if let crate::config::Auth::Forge {
141        anonymous_read: true,
142        ..
143    } = config.auth
144    {
145        tracing::info!(
146            "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"
147        );
148    }
149
150    // Refusing to start beats starting without it. A server that silently wrote
151    // plaintext because a Secret failed to mount is the one failure this feature
152    // must never have: nothing downstream would notice, and the objects written
153    // in the meantime are the ones the operator believed were covered.
154    let keys = config.encryption_key_file.as_deref().map(|path| {
155        std::sync::Arc::new(
156            crate::storage::crypt::Keyring::load(path)
157                .expect("the encryption key file is not usable"),
158        )
159    });
160
161    let local = LocalStore::new(config.storage_root.clone())
162        .with_max_object_size(config.max_object_size)
163        .with_compression(config.compression)
164        .with_encryption(keys);
165
166    // The two backends are chosen together and the lock policy is applied once,
167    // to both. Deciding it per arm is how `LFSX_LOCK_MAX_AGE` came to be silently
168    // ignored in bucket mode: the arms are far apart, only one of them had it,
169    // and nothing failed.
170    let (store, lock_backend) = match &config.storage {
171        crate::config::Storage::Local => (
172            Store::local(local),
173            LockStore::local(config.storage_root.clone()),
174        ),
175        crate::config::Storage::Bucket { presign, .. } => {
176            // Built once and shared: the objects and the locks are two ways of
177            // using the same bucket, not two buckets. Signing, the connection
178            // pool and the retry policy are settled here, and neither layer
179            // reaches into the other to get at them.
180            let keys = keyspace(config).expect("a bucket keyspace for a bucket store");
181
182            tracing::warn!(
183                "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"
184            );
185
186            if *presign {
187                if config.encryption_key_file.is_some() || config.compression.is_some() {
188                    tracing::warn!(
189                        "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"
190                    );
191                } else {
192                    tracing::warn!(
193                        "LFSX_S3_PRESIGN=true, downloads are redirected to the bucket, so                          lfsx_downloaded_bytes stops counting them and the bucket serves the ranges"
194                    );
195                }
196
197                if config.encryption_key_file.is_some() {
198                    tracing::warn!(
199                        "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"
200                    );
201                } else if config.compression.is_some() {
202                    tracing::warn!(
203                        "LFSX_COMPRESSION is set, and objects clients upload straight to the                          bucket arrive uncompressed — only what passes through this server is                          compressed"
204                    );
205                }
206            }
207
208            // The locks go with the objects. Left on the volume they would make
209            // the bucket a half measure: capacity would be shared and the one
210            // piece of state a second replica must agree on would not be.
211            (
212                Store::bucket(S3Store::new(keys.clone(), *presign), local),
213                LockStore::bucket(keys),
214            )
215        }
216    };
217    (store, lock_backend.with_max_age(config.lock_max_age))
218}