Skip to main content

lfsx_server/
console.rs

1mod access;
2pub mod session;
3pub mod tokens;
4
5use std::path::Path;
6
7use axum::extract::State;
8use axum::http::{HeaderMap, StatusCode};
9use axum::response::{IntoResponse, Response};
10use axum::routing::get;
11use axum::{Json, Router};
12use prometheus::core::Collector;
13use serde::Serialize;
14use tower_http::services::{ServeDir, ServeFile};
15
16use crate::config::{Auth, Config, Dialect, Provider, Storage};
17use crate::error::Error;
18use crate::metrics::Metrics;
19use crate::state::Shared;
20
21#[derive(Debug, Serialize)]
22pub struct Overview {
23    pub version: &'static str,
24    pub uptime_seconds: u64,
25    pub storage: StorageFigures,
26    pub traffic: Traffic,
27    pub object_sizes: Vec<SizeBucket>,
28    pub cache: Option<CacheFigures>,
29    pub settings: Settings,
30}
31
32#[derive(Debug, Serialize)]
33pub struct StorageFigures {
34    pub kind: &'static str,
35    pub objects: Option<u64>,
36    pub bytes: Option<u64>,
37}
38
39#[derive(Debug, Serialize)]
40pub struct Traffic {
41    pub requests: u64,
42    pub server_errors: u64,
43    pub rejections: u64,
44    pub uploaded_bytes: u64,
45    pub downloaded_bytes: u64,
46    pub transfers_in_flight: i64,
47}
48
49#[derive(Debug, Serialize, PartialEq, Eq)]
50pub struct SizeBucket {
51    pub up_to: Option<u64>,
52    pub objects: u64,
53}
54
55#[derive(Debug, Serialize)]
56pub struct CacheFigures {
57    pub hits: u64,
58    pub misses: u64,
59    pub bytes: u64,
60}
61
62#[derive(Debug, Serialize)]
63pub struct Settings {
64    pub auth: &'static str,
65    pub allowed: Option<Vec<String>>,
66    pub restricted: Vec<String>,
67    pub anonymous_read: bool,
68    pub max_object_size: Option<u64>,
69    pub repo_quota: Option<u64>,
70    pub max_concurrent_transfers: usize,
71    pub compression: bool,
72    pub encryption: bool,
73    pub presign: bool,
74    pub locking: bool,
75    pub gc_grace_seconds: u64,
76    pub lock_max_age_seconds: Option<u64>,
77    pub forges: Vec<NamedForge>,
78}
79
80#[derive(Debug, Serialize)]
81pub struct NamedForge {
82    pub name: String,
83    pub auth: &'static str,
84    pub api_url: String,
85    pub allowed: Option<Vec<String>>,
86}
87
88pub struct Refused(Error);
89
90impl From<Error> for Refused {
91    fn from(error: Error) -> Self {
92        Self(error)
93    }
94}
95
96impl IntoResponse for Refused {
97    fn into_response(self) -> Response {
98        match self.0 {
99            Error::Unauthenticated => (
100                StatusCode::UNAUTHORIZED,
101                Json(serde_json::json!({ "message": "sign in to the dashboard" })),
102            )
103                .into_response(),
104            error => error.into_response(),
105        }
106    }
107}
108
109pub fn router(dir: &Path) -> Router<Shared> {
110    Router::new()
111        .route("/-/api/overview", get(overview))
112        .route(
113            "/-/api/access",
114            get(access::read).put(access::write).delete(access::reset),
115        )
116        .route(
117            "/-/api/session",
118            get(session::current)
119                .post(session::sign_in)
120                .delete(session::sign_out),
121        )
122        .nest_service(
123            "/-/dashboard",
124            ServeDir::new(dir).fallback(ServeFile::new(dir.join("index.html"))),
125        )
126}
127
128async fn overview(
129    State(state): State<Shared>,
130    headers: HeaderMap,
131) -> Result<Json<Overview>, Refused> {
132    admit(&state, &headers).await?;
133
134    let capacity = state.store.capacity().await;
135
136    Ok(Json(Overview {
137        version: env!("CARGO_PKG_VERSION"),
138        uptime_seconds: state.started.elapsed().as_secs(),
139        storage: StorageFigures {
140            kind: storage_kind(&state.config.storage),
141            objects: capacity.map(|(objects, _)| objects),
142            bytes: capacity.map(|(_, bytes)| bytes),
143        },
144        traffic: traffic(&state.metrics),
145        object_sizes: object_sizes(&state.metrics),
146        cache: state.store.cache_stats().map(|stats| CacheFigures {
147            hits: stats.hits,
148            misses: stats.misses,
149            bytes: stats.bytes,
150        }),
151        settings: settings(&state.config, state.authorizer.access()),
152    }))
153}
154
155pub(crate) async fn admit(state: &Shared, headers: &HeaderMap) -> Result<(), Error> {
156    let Some(dashboard) = &state.config.dashboard else {
157        return Err(Error::NotServed);
158    };
159
160    if session::viewer(state, headers).await?.is_some() {
161        return Ok(());
162    }
163
164    match (&state.config.auth, &dashboard.admins) {
165        (Auth::Forge { .. }, Some(admins)) => state
166            .authorizer
167            .forge_permission(headers, admins)
168            .await?
169            .require_admin(),
170        _ => Err(Error::Unauthenticated),
171    }
172}
173
174fn storage_kind(storage: &Storage) -> &'static str {
175    match storage {
176        Storage::Local => "local",
177        Storage::Bucket { dialect, .. } => match dialect {
178            Dialect::S3 { .. } => "s3",
179            Dialect::Azure { .. } => "azure",
180            Dialect::Gcs { .. } => "gcs",
181        },
182    }
183}
184
185fn counted(collector: &impl Collector, keep: impl Fn(&[(&str, &str)]) -> bool) -> u64 {
186    collector
187        .collect()
188        .iter()
189        .flat_map(|family| family.get_metric())
190        .filter(|metric| {
191            let labels: Vec<(&str, &str)> = metric
192                .get_label()
193                .iter()
194                .map(|label| (label.name(), label.value()))
195                .collect();
196            keep(&labels)
197        })
198        .map(|metric| metric.get_counter().get_value() as u64)
199        .sum()
200}
201
202pub(crate) fn traffic(metrics: &Metrics) -> Traffic {
203    Traffic {
204        requests: counted(&metrics.requests, |_| true),
205        server_errors: counted(&metrics.requests, |labels| {
206            labels
207                .iter()
208                .any(|(name, value)| *name == "status" && value.starts_with('5'))
209        }),
210        rejections: counted(&metrics.rejections, |_| true),
211        uploaded_bytes: metrics.uploaded_bytes.get(),
212        downloaded_bytes: metrics.downloaded_bytes.get(),
213        transfers_in_flight: metrics.transfers_in_flight.get(),
214    }
215}
216
217pub(crate) fn object_sizes(metrics: &Metrics) -> Vec<SizeBucket> {
218    let families = metrics.object_size.collect();
219    let Some(histogram) = families
220        .first()
221        .and_then(|family| family.get_metric().first())
222        .map(|metric| metric.get_histogram())
223    else {
224        return Vec::new();
225    };
226
227    let mut buckets = Vec::new();
228    let mut below = 0;
229    for bucket in histogram.get_bucket() {
230        let cumulative = bucket.cumulative_count();
231        buckets.push(SizeBucket {
232            up_to: Some(bucket.upper_bound() as u64),
233            objects: cumulative - below,
234        });
235        below = cumulative;
236    }
237    buckets.push(SizeBucket {
238        up_to: None,
239        objects: histogram.get_sample_count() - below,
240    });
241
242    buckets
243}
244
245pub fn start(state: Shared) {
246    access::keep_fresh(state);
247}
248
249pub(crate) fn settings(config: &Config, live: Option<crate::auth::Access>) -> Settings {
250    let auth = match &config.auth {
251        Auth::Disabled => "disabled",
252        Auth::Forge { provider, .. } => provider_name(*provider),
253    };
254    let (allowed, restricted, anonymous_read) = match live {
255        None => (None, Vec::new(), true),
256        Some(access) => (
257            access.allowed.as_ref().map(|allowed| allowed.entries()),
258            access.restricted.entries(),
259            access.anonymous_read,
260        ),
261    };
262    let (presign, locking) = match &config.storage {
263        Storage::Local => (false, true),
264        Storage::Bucket {
265            presign, locking, ..
266        } => (*presign, *locking),
267    };
268
269    Settings {
270        auth,
271        allowed,
272        restricted,
273        anonymous_read,
274        max_object_size: config.max_object_size,
275        repo_quota: config.repo_quota,
276        max_concurrent_transfers: config.max_concurrent_transfers,
277        compression: config.compression.is_some(),
278        encryption: config.encryption_key.is_some(),
279        presign,
280        locking,
281        gc_grace_seconds: config.gc_grace.as_secs(),
282        lock_max_age_seconds: config.lock_max_age.map(|age| age.as_secs()),
283        forges: config
284            .forges
285            .iter()
286            .filter_map(|forge| match &forge.auth {
287                Auth::Forge {
288                    provider,
289                    api_url,
290                    allowed,
291                    ..
292                } => Some(NamedForge {
293                    name: forge.name.clone(),
294                    auth: provider_name(*provider),
295                    api_url: api_url.clone(),
296                    allowed: allowed.as_ref().map(|allowed| allowed.entries()),
297                }),
298                Auth::Disabled => None,
299            })
300            .collect(),
301    }
302}
303
304fn provider_name(provider: Provider) -> &'static str {
305    match provider {
306        Provider::Github => "github",
307        Provider::Gitlab => "gitlab",
308        Provider::Gitea => "gitea",
309    }
310}