freighter_server/
lib.rs

1use axum::body::Body;
2use axum::extract::{MatchedPath, Query, State};
3use axum::http::{HeaderMap, Request, StatusCode};
4use axum::middleware::{from_fn, Next};
5use axum::response::{Html, IntoResponse, Response};
6use axum::routing::get;
7use axum::{Json, Router};
8use freighter_api_types::index::request::ListQuery;
9use freighter_api_types::index::response::ListAll;
10use freighter_api_types::index::IndexProvider;
11use freighter_api_types::storage::StorageProvider;
12use freighter_auth::AuthProvider;
13use metrics::{histogram, counter};
14use serde::Deserialize;
15use std::net::SocketAddr;
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18use tokio::time::timeout;
19use tokio::try_join;
20use tower_http::catch_panic::CatchPanicLayer;
21use tower_http::classify::StatusInRangeAsFailures;
22use tower_http::trace::{DefaultOnFailure, TraceLayer};
23
24pub mod index;
25
26pub mod api;
27
28pub mod downloads;
29
30#[derive(Clone, Deserialize)]
31pub struct ServiceConfig {
32    pub address: SocketAddr,
33    pub download_endpoint: String,
34    pub api_endpoint: String,
35    pub metrics_address: SocketAddr,
36    #[serde(default = "default_true")]
37    pub allow_registration: bool,
38
39    /// Use auth for all requests to the registry, including config and index.
40    /// Currently requires `-Z registry-auth` nightly feature.
41    #[serde(default = "default_true")]
42    pub auth_required: bool,
43}
44
45pub struct ServiceState<I, S, A> {
46    pub config: ServiceConfig,
47    pub index: I,
48    pub storage: S,
49    pub auth: A,
50}
51
52impl<I, S, A> ServiceState<I, S, A> {
53    pub fn new(config: ServiceConfig, index: I, storage: S, auth: A) -> Self {
54        Self {
55            config,
56            index,
57            storage,
58            auth,
59        }
60    }
61}
62
63pub fn router<I, S, A>(
64    config: ServiceConfig,
65    index_client: I,
66    storage_client: S,
67    auth_client: A,
68) -> Router
69where
70    I: IndexProvider + Send + Sync + 'static,
71    S: StorageProvider + Clone + Send + Sync + 'static,
72    A: AuthProvider + Send + Sync + 'static,
73{
74    let state = Arc::new(ServiceState::new(
75        config,
76        index_client,
77        storage_client,
78        auth_client,
79    ));
80
81    Router::new()
82        .nest("/downloads", downloads::downloads_router())
83        .nest("/index", index::index_router())
84        .nest("/api/v1/crates", api::api_router())
85        .route("/me", get(register))
86        .route("/all", get(list))
87        .route("/healthcheck", get(healthcheck))
88        .route("/", get(root_page))
89        .with_state(state)
90        .fallback(handle_global_fallback)
91        .layer(CatchPanicLayer::custom(|_| {
92            counter!("freighter_panics_total").increment(1);
93
94            StatusCode::INTERNAL_SERVER_ERROR.into_response()
95        }))
96        .layer(
97            TraceLayer::new(StatusInRangeAsFailures::new(400..=599).into_make_classifier())
98                .make_span_with(|request: &Request<Body>| {
99                    let method = request.method();
100                    let uri = request.uri();
101
102                    tracing::info_span!("http-request", ?method, ?uri)
103                })
104                .on_failure(DefaultOnFailure::new()),
105        )
106        .layer(from_fn(metrics_layer))
107}
108
109async fn metrics_layer<B>(request: Request<B>, next: Next<B>) -> Response {
110    let timer = Instant::now();
111
112    let path = if let Some(path) = request.extensions().get::<MatchedPath>() {
113        path.as_str().to_string()
114    } else {
115        request.uri().path().to_string()
116    };
117
118    let response = next.run(request).await;
119
120    let elapsed = timer.elapsed();
121
122    let code = response.status().as_u16().to_string();
123
124    histogram!("freighter_request_duration_seconds", "code" => code, "endpoint" => path)
125        .record(elapsed);
126
127    response
128}
129
130pub async fn root_page<I, S, A>(
131    State(state): State<Arc<ServiceState<I, S, A>>>,
132) -> String {
133    format!(
134        "This is root of the Freighter server. There's nothing here.
135The API endpoint is at {}.
136The download endpoint is at {}.
137Auth is always required: {}",
138        state.config.api_endpoint,
139        state.config.download_endpoint,
140        state.config.auth_required,
141    )
142}
143
144pub async fn register() -> Html<&'static str> {
145    Html(include_str!("../static/register.html"))
146}
147
148async fn list<I, S, A>(
149    headers: HeaderMap,
150    State(state): State<Arc<ServiceState<I, S, A>>>,
151    Query(query): Query<ListQuery>,
152) -> axum::response::Result<Json<ListAll>>
153where
154    I: IndexProvider,
155    A: AuthProvider + Sync,
156{
157    if state.config.auth_required {
158        let token = state.auth.token_from_headers(&headers)?.ok_or(StatusCode::UNAUTHORIZED)?;
159        state.auth.auth_view_full_index(token).await?;
160    }
161
162    let search_results = state.index.list(&query).await?;
163
164    Ok(Json(search_results))
165}
166
167async fn healthcheck<I, S, A>(State(state): State<Arc<ServiceState<I, S, A>>>) -> axum::response::Result<String>
168where
169    I: IndexProvider,
170    S: StorageProvider,
171    A: AuthProvider + Sync,
172{
173    let check_time = Duration::from_secs(4);
174    let label = |label, res: Result<Result<(), anyhow::Error>, _>| match res {
175        // healthcheck is unauthenticated and shouldn't leak internals via errors
176        Ok(Ok(())) => Ok(()),
177        Ok(Err(e)) => {
178            for e in e.chain() {
179                tracing::error!("{label} healthcheck: {e}");
180            }
181            Err(format!("{label} failed"))
182        },
183        Err(_) => Err(format!("{label} timed out")),
184    };
185
186    try_join! {
187        async { label("auth", timeout(check_time, state.auth.healthcheck()).await) },
188        async { label("index", timeout(check_time, state.index.healthcheck()).await) },
189        async { label("storage", timeout(check_time, state.storage.healthcheck()).await) },
190    }
191    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
192
193    Ok("OK".into())
194}
195
196pub async fn handle_global_fallback() -> (StatusCode, &'static str) {
197    (
198        StatusCode::NOT_FOUND,
199        "Freighter: There is no such URL at the root of the server",
200    )
201}
202
203#[inline(always)]
204fn default_true() -> bool {
205    true
206}
207