Skip to main content

tuff_console/
server.rs

1//! The console's HTTP API (RFC-108 D5 and D9) and the rules for where it
2//! may listen.
3
4use std::collections::HashMap;
5use std::future::Future;
6use std::net::SocketAddr;
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use axum::body::Bytes;
11use axum::extract::{DefaultBodyLimit, Path, Query, State};
12use axum::http::{HeaderMap, StatusCode, header};
13use axum::response::{IntoResponse, Response};
14use axum::routing::{get, post};
15use axum::{Json, Router};
16use serde_json::json;
17use tuff_core::error::{ErrorKind, Result, TuffError};
18use tuff_core::report::{REPORT_SCHEMA, Report, normalize_remote};
19
20use crate::oidc::{OidcError, Trust, Verifier};
21use crate::store::{EventFilter, KeyGrant, Store};
22use crate::ui;
23use crate::views::{self, ProjectModel};
24
25/// Largest report body the server reads.
26const MAX_REPORT_BYTES: usize = 16 * 1024 * 1024;
27
28/// Where and how `tuff console serve` listens.
29#[derive(Debug, Clone)]
30pub struct ServeConfig {
31    pub data_dir: PathBuf,
32    pub addr: SocketAddr,
33    /// Acknowledges that viewing is not authenticated on a non-loopback
34    /// address (D5).
35    pub public_read: bool,
36    /// Sources of OIDC tokens the console accepts for publishing.
37    pub trusts: Vec<Trust>,
38    /// The URL publishers reach the console at, which OIDC tokens name as
39    /// their audience. `http://<bound address>` when unset.
40    pub public_url: Option<String>,
41    /// Serve generated sample projects from a temporary database instead of
42    /// the data folder.
43    pub demo: bool,
44}
45
46/// How the running server decides who may publish, and what the UI and
47/// `GET /api/v1/settings` tell viewers about it.
48#[derive(Clone, Default)]
49pub struct ServerOptions {
50    /// Publishing always needs a credential, even with no key created yet.
51    pub require_key: bool,
52    /// Verifies OIDC tokens when a trust is configured.
53    pub oidc: Option<Arc<Verifier>>,
54    /// The address the server listens on. [`serve`] fills it from the
55    /// listener when it is unset.
56    pub address: Option<SocketAddr>,
57    /// Whether the server was started with `--public-read`.
58    pub public_read: bool,
59    /// Whether the data is generated sample data.
60    pub demo: bool,
61}
62
63/// The bind rules of D5. A loopback address needs nothing. Any other
64/// address needs `--public-read`, because viewing is not authenticated, and
65/// at least one publish credential (a key or a trust), because publishing
66/// is authenticated.
67pub fn check_bind(addr: SocketAddr, public_read: bool, credential_count: u64) -> Result<()> {
68    if addr.ip().is_loopback() {
69        return Ok(());
70    }
71    if !public_read {
72        return Err(TuffError::refused(format!(
73            "{addr} is not a loopback address, and the console does not authenticate people who view it"
74        ))
75        .with_hint(
76            "put the server behind a reverse proxy that authenticates viewers and pass --public-read, or bind 127.0.0.1",
77        ));
78    }
79    if credential_count == 0 {
80        return Err(TuffError::refused(format!(
81            "{addr} is not a loopback address, and no publish key or trust exists"
82        ))
83        .with_hint("run 'tuff console key create <name>' first, or pass --trust github:<owner>"));
84    }
85    Ok(())
86}
87
88/// Open the store, apply the bind rules, and serve until interrupted.
89/// `on_ready` receives the bound address once the server accepts
90/// connections.
91pub fn run(config: ServeConfig, on_ready: impl FnOnce(SocketAddr)) -> Result<()> {
92    let store = if config.demo {
93        let store = Store::open_in_memory()?;
94        crate::demo::populate(&store)?;
95        Arc::new(store)
96    } else {
97        Arc::new(Store::open(&config.data_dir)?)
98    };
99    check_bind(
100        config.addr,
101        config.public_read,
102        store.key_count()? + config.trusts.len() as u64,
103    )?;
104    let runtime = tokio::runtime::Builder::new_multi_thread()
105        .enable_all()
106        .build()?;
107    runtime.block_on(async {
108        let listener = tokio::net::TcpListener::bind(config.addr)
109            .await
110            .map_err(|error| {
111                TuffError::of(
112                    ErrorKind::Io,
113                    format!("cannot listen on {}: {error}", config.addr),
114                )
115                .with_hint("pass --addr with a free address, for example 127.0.0.1:7475")
116            })?;
117        let bound = listener.local_addr()?;
118        let public_url = config
119            .public_url
120            .clone()
121            .unwrap_or_else(|| format!("http://{bound}"));
122        let oidc = if config.trusts.is_empty() {
123            None
124        } else {
125            Some(Arc::new(Verifier::new(config.trusts.clone(), &public_url)?))
126        };
127        on_ready(bound);
128        serve(
129            store,
130            listener,
131            ServerOptions {
132                require_key: !config.addr.ip().is_loopback(),
133                oidc,
134                address: Some(bound),
135                public_read: config.public_read,
136                demo: config.demo,
137            },
138            shutdown_signal(),
139        )
140        .await
141    })
142}
143
144async fn shutdown_signal() {
145    let interrupt = async {
146        let _ = tokio::signal::ctrl_c().await;
147    };
148    #[cfg(unix)]
149    let terminate = async {
150        if let Ok(mut signal) =
151            tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
152        {
153            signal.recv().await;
154        } else {
155            std::future::pending::<()>().await;
156        }
157    };
158    #[cfg(not(unix))]
159    let terminate = std::future::pending::<()>();
160    tokio::select! {
161        () = interrupt => {},
162        () = terminate => {},
163    }
164}
165
166/// Serve `router(store, options)` on `listener` until `shutdown` completes.
167pub async fn serve(
168    store: Arc<Store>,
169    listener: tokio::net::TcpListener,
170    mut options: ServerOptions,
171    shutdown: impl Future<Output = ()> + Send + 'static,
172) -> Result<()> {
173    if options.address.is_none() {
174        options.address = listener.local_addr().ok();
175    }
176    axum::serve(listener, router(store, options))
177        .with_graceful_shutdown(shutdown)
178        .await?;
179    Ok(())
180}
181
182#[derive(Clone)]
183struct AppState {
184    store: Arc<Store>,
185    options: ServerOptions,
186}
187
188/// The API routes. Publishing needs a live key or a verified OIDC token in
189/// `Authorization: Bearer` when `require_key` is set, when a trust is
190/// configured, and whenever at least one key exists, on any address. With
191/// none of those, anyone who can connect may publish.
192pub fn router(store: Arc<Store>, options: ServerOptions) -> Router {
193    let state = AppState { store, options };
194    Router::new()
195        .route("/", get(ui::index))
196        .route("/assets/app.css", get(ui::stylesheet))
197        .route("/assets/app.js", get(ui::script))
198        .route("/healthz", get(healthz))
199        .route("/api/v1/healthz", get(healthz))
200        .route("/api/v1/reports", post(post_report))
201        .route("/api/v1/projects", get(list_projects))
202        .route("/api/v1/projects/{id}", get(get_project))
203        .route("/api/v1/projects/{id}/reports", get(project_reports))
204        .route("/api/v1/capabilities", get(list_capabilities))
205        .route("/api/v1/capabilities/{type}/{*id}", get(get_capability))
206        .route("/api/v1/harnesses", get(get_harnesses))
207        .route("/api/v1/policies", get(get_policies))
208        .route("/api/v1/events", get(list_events))
209        .route("/api/v1/settings", get(get_settings))
210        .layer(DefaultBodyLimit::max(MAX_REPORT_BYTES))
211        .layer(axum::middleware::from_fn(security_headers))
212        .with_state(state)
213}
214
215/// An error rendered in the CLI's `--json` envelope.
216struct ApiError {
217    status: StatusCode,
218    kind: &'static str,
219    message: String,
220    hint: Option<String>,
221}
222
223impl ApiError {
224    fn new(status: StatusCode, kind: &'static str, message: impl Into<String>) -> Self {
225        Self {
226            status,
227            kind,
228            message: message.into(),
229            hint: None,
230        }
231    }
232
233    fn hint(mut self, hint: impl Into<String>) -> Self {
234        self.hint = Some(hint.into());
235        self
236    }
237}
238
239impl From<TuffError> for ApiError {
240    fn from(error: TuffError) -> Self {
241        let status = match error.kind() {
242            ErrorKind::Usage => StatusCode::BAD_REQUEST,
243            ErrorKind::NotFound => StatusCode::NOT_FOUND,
244            _ => StatusCode::INTERNAL_SERVER_ERROR,
245        };
246        let mut api = Self::new(status, error.kind().as_str(), error.message());
247        api.hint = error.hint().map(str::to_string);
248        api
249    }
250}
251
252impl IntoResponse for ApiError {
253    fn into_response(self) -> Response {
254        let mut body = json!({ "error": { "kind": self.kind, "message": self.message } });
255        if let Some(hint) = self.hint {
256            body["error"]["hint"] = hint.into();
257        }
258        let mut response = (self.status, Json(body)).into_response();
259        if self.status == StatusCode::UNAUTHORIZED {
260            response.headers_mut().insert(
261                header::WWW_AUTHENTICATE,
262                header::HeaderValue::from_static("Bearer"),
263            );
264        }
265        response
266    }
267}
268
269/// Run blocking store work off the async threads.
270async fn blocking<T: Send + 'static>(
271    store: &Arc<Store>,
272    work: impl FnOnce(&Store) -> Result<T> + Send + 'static,
273) -> std::result::Result<T, ApiError> {
274    let store = Arc::clone(store);
275    tokio::task::spawn_blocking(move || work(&store))
276        .await
277        .map_err(|error| {
278            ApiError::new(
279                StatusCode::INTERNAL_SERVER_ERROR,
280                "internal",
281                format!("store task failed: {error}"),
282            )
283        })?
284        .map_err(ApiError::from)
285}
286
287async fn healthz() -> Json<serde_json::Value> {
288    Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") }))
289}
290
291/// Who is publishing.
292enum Principal {
293    /// Nothing is configured, so nobody is asked.
294    Anonymous,
295    Key(KeyGrant),
296    /// A verified token, bound to the repository it names.
297    Oidc {
298        report_repository: String,
299    },
300}
301
302fn unauthorized(message: impl Into<String>) -> ApiError {
303    ApiError::new(StatusCode::UNAUTHORIZED, "unauthorized", message)
304}
305
306async fn authorize(
307    state: &AppState,
308    headers: &HeaderMap,
309) -> std::result::Result<Principal, ApiError> {
310    let configured = state.options.require_key
311        || state.options.oidc.is_some()
312        || blocking(&state.store, Store::key_count).await? > 0;
313    if !configured {
314        return Ok(Principal::Anonymous);
315    }
316    let presented = headers
317        .get(header::AUTHORIZATION)
318        .and_then(|value| value.to_str().ok())
319        .and_then(|value| value.strip_prefix("Bearer "))
320        .map(str::trim)
321        .filter(|token| !token.is_empty());
322    let Some(token) = presented else {
323        return Err(unauthorized("publishing to this console needs a credential")
324            .hint("send 'Authorization: Bearer <key>', set TUFF_CONSOLE_KEY for 'tuff console publish', or publish from a trusted GitHub Actions job"));
325    };
326
327    let verifier = match state.options.oidc.as_ref() {
328        Some(verifier) if !token.starts_with(crate::store::KEY_PREFIX) => verifier,
329        _ => {
330            let key = token.to_string();
331            return match blocking(&state.store, move |store| store.verify_key(&key)).await? {
332                Some(grant) => Ok(Principal::Key(grant)),
333                None => Err(unauthorized("the key is not valid or was revoked")
334                    .hint("create one with 'tuff console key create <name>' on the server")),
335            };
336        }
337    };
338    match verifier.verify(token).await {
339        Ok(verified) => Ok(Principal::Oidc {
340            report_repository: verified.report_repository,
341        }),
342        Err(OidcError::Invalid(reason)) => Err(unauthorized(reason).hint(format!(
343            "the token's audience must be {}, and the job needs 'permissions: id-token: write'",
344            verifier.audience()
345        ))),
346        Err(OidcError::Untrusted(reason)) => {
347            Err(ApiError::new(StatusCode::FORBIDDEN, "refused", reason)
348                .hint("start the console with --trust github:<owner> for this owner"))
349        }
350        Err(OidcError::Unavailable(reason)) => {
351            Err(
352                ApiError::new(StatusCode::SERVICE_UNAVAILABLE, "source_failed", reason)
353                    .hint("the console must reach the token issuer; retry later"),
354            )
355        }
356    }
357}
358
359/// A scoped credential publishes only for its own repository (D5).
360fn check_binding(principal: &Principal, repository: &str) -> std::result::Result<(), ApiError> {
361    let bound = match principal {
362        Principal::Anonymous
363        | Principal::Key(KeyGrant {
364            repository: None, ..
365        }) => return Ok(()),
366        Principal::Key(KeyGrant {
367            repository: Some(bound),
368            ..
369        })
370        | Principal::Oidc {
371            report_repository: bound,
372        } => bound,
373    };
374    let reported = normalize_remote(repository);
375    if normalize_remote(bound).eq_ignore_ascii_case(&reported) {
376        return Ok(());
377    }
378    Err(ApiError::new(
379        StatusCode::FORBIDDEN,
380        "refused",
381        format!("this credential may publish only for {bound}, and the report is for {reported}"),
382    )
383    .hint("publish each repository with its own credential"))
384}
385
386async fn post_report(
387    State(state): State<AppState>,
388    headers: HeaderMap,
389    body: Bytes,
390) -> std::result::Result<Response, ApiError> {
391    let principal = authorize(&state, &headers).await?;
392
393    let raw: serde_json::Value = serde_json::from_slice(&body).map_err(|error| {
394        ApiError::new(
395            StatusCode::BAD_REQUEST,
396            "usage",
397            format!("the body is not JSON: {error}"),
398        )
399    })?;
400    match raw.get("schema").and_then(serde_json::Value::as_u64) {
401        Some(schema) if schema == u64::from(REPORT_SCHEMA) => {}
402        other => {
403            let seen = other.map_or_else(|| "none".to_string(), |schema| schema.to_string());
404            return Err(ApiError::new(
405                StatusCode::UNPROCESSABLE_ENTITY,
406                "unsupported",
407                format!(
408                    "report schema {seen} is not supported, and this server reads schema {REPORT_SCHEMA}"
409                ),
410            )
411            .hint("use a tuff version that matches the server's"));
412        }
413    }
414    let report: Report = serde_json::from_value(raw.clone()).map_err(|error| {
415        ApiError::new(
416            StatusCode::UNPROCESSABLE_ENTITY,
417            "corrupt",
418            format!("the report is not valid: {error}"),
419        )
420    })?;
421    if report.project.repository.trim().is_empty() || report.project.path.trim().is_empty() {
422        return Err(ApiError::new(
423            StatusCode::UNPROCESSABLE_ENTITY,
424            "corrupt",
425            "the report's project needs a repository and a path",
426        ));
427    }
428
429    check_binding(&principal, &report.project.repository)?;
430
431    let outcome = blocking(&state.store, move |store| store.ingest(&report, &raw)).await?;
432    let status = if outcome.deduplicated {
433        StatusCode::OK
434    } else {
435        StatusCode::CREATED
436    };
437    Ok((status, Json(outcome)).into_response())
438}
439
440type ApiResult = std::result::Result<Json<serde_json::Value>, ApiError>;
441
442/// The latest report of every project, read into the shapes the views use.
443async fn models(state: &AppState) -> std::result::Result<Vec<ProjectModel>, ApiError> {
444    blocking(&state.store, |store| {
445        Ok(store
446            .latest_reports()?
447            .into_iter()
448            .map(|(row, body)| ProjectModel::new(row, &body))
449            .collect())
450    })
451    .await
452}
453
454async fn list_projects(State(state): State<AppState>) -> ApiResult {
455    Ok(Json(views::projects(&models(&state).await?)))
456}
457
458fn no_project(id: i64) -> ApiError {
459    ApiError::new(
460        StatusCode::NOT_FOUND,
461        "not_found",
462        format!("no project {id}"),
463    )
464    .hint("GET /api/v1/projects lists the ids")
465}
466
467async fn get_project(State(state): State<AppState>, Path(id): Path<i64>) -> ApiResult {
468    match blocking(&state.store, move |store| store.project(id)).await? {
469        Some((row, latest_report)) => Ok(Json(views::project(
470            &ProjectModel::new(row, &latest_report),
471            latest_report,
472        ))),
473        None => Err(no_project(id)),
474    }
475}
476
477async fn project_reports(State(state): State<AppState>, Path(id): Path<i64>) -> ApiResult {
478    let reports = blocking(&state.store, move |store| {
479        if store.project(id)?.is_none() {
480            return Ok(None);
481        }
482        Ok(Some(store.report_history(id, 500)?))
483    })
484    .await?;
485    match reports {
486        Some(reports) => Ok(Json(json!({ "projectId": id, "reports": reports }))),
487        None => Err(no_project(id)),
488    }
489}
490
491async fn list_capabilities(
492    State(state): State<AppState>,
493    Query(query): Query<HashMap<String, String>>,
494) -> ApiResult {
495    let models = models(&state).await?;
496    Ok(Json(views::capabilities(
497        &models,
498        query
499            .get("type")
500            .map(String::as_str)
501            .filter(|t| !t.is_empty()),
502    )))
503}
504
505async fn get_capability(
506    State(state): State<AppState>,
507    Path((capability_type, id)): Path<(String, String)>,
508) -> ApiResult {
509    let models = models(&state).await?;
510    match views::capability(&models, &capability_type, &id) {
511        Some(value) => Ok(Json(value)),
512        None => Err(ApiError::new(
513            StatusCode::NOT_FOUND,
514            "not_found",
515            format!("no project uses {capability_type} '{id}'"),
516        )
517        .hint("GET /api/v1/capabilities lists what is in use")),
518    }
519}
520
521async fn get_harnesses(State(state): State<AppState>) -> ApiResult {
522    Ok(Json(views::harnesses(&models(&state).await?)))
523}
524
525async fn get_policies(State(state): State<AppState>) -> ApiResult {
526    Ok(Json(views::policies(&models(&state).await?)))
527}
528
529/// The most events one request returns.
530const MAX_EVENTS: u32 = 1000;
531
532async fn list_events(
533    State(state): State<AppState>,
534    Query(query): Query<HashMap<String, String>>,
535) -> ApiResult {
536    let text = |key: &str| query.get(key).filter(|value| !value.is_empty()).cloned();
537    let usage = |message: String| {
538        ApiError::new(StatusCode::BAD_REQUEST, "usage", message).hint(
539            "filters are project (an id), capability, kind, since (a date or time), before (an event id), and limit",
540        )
541    };
542    let project_id = match text("project") {
543        Some(value) => Some(
544            value
545                .parse::<i64>()
546                .map_err(|_| usage(format!("project '{value}' is not a project id")))?,
547        ),
548        None => None,
549    };
550    let limit = match text("limit") {
551        Some(value) => value
552            .parse::<u32>()
553            .map_err(|_| usage(format!("limit '{value}' is not a number")))?
554            .clamp(1, MAX_EVENTS),
555        None => 200,
556    };
557    let before = match text("before") {
558        Some(value) => Some(
559            value
560                .parse::<i64>()
561                .map_err(|_| usage(format!("before '{value}' is not an event id")))?,
562        ),
563        None => None,
564    };
565    let kind = text("kind");
566    if let Some(kind) = &kind
567        && !crate::events::kind::ALL.contains(&kind.as_str())
568    {
569        return Err(usage(format!("'{kind}' is not an event kind")));
570    }
571    let since = text("since");
572    if let Some(since) = &since
573        && !since.starts_with(|c: char| c.is_ascii_digit())
574    {
575        return Err(usage(format!("since '{since}' is not a date or time")));
576    }
577    let filter = EventFilter {
578        project_id,
579        capability: text("capability"),
580        kind,
581        since,
582        before,
583        limit: Some(limit),
584    };
585    let (events, projects) = blocking(&state.store, move |store| {
586        Ok((store.events(&filter)?, store.projects()?))
587    })
588    .await?;
589    let events: Vec<serde_json::Value> = events
590        .into_iter()
591        .map(|event| {
592            let mut value = serde_json::to_value(&event).unwrap_or_default();
593            if let Some(project) = projects.iter().find(|p| p.id == event.project_id) {
594                value["projectName"] = json!(project.name);
595                value["repository"] = json!(project.repository);
596                value["path"] = json!(project.path);
597            }
598            value
599        })
600        .collect();
601    // A full page may have more behind it: the id to pass as `before`.
602    let next_before = (events.len() == limit as usize)
603        .then(|| events.last().and_then(|event| event["id"].as_i64()))
604        .flatten();
605    Ok(Json(
606        json!({ "events": events, "kinds": crate::events::kind::ALL, "nextBefore": next_before }),
607    ))
608}
609
610async fn get_settings(State(state): State<AppState>) -> ApiResult {
611    let keys = blocking(&state.store, Store::keys).await?;
612    let options = &state.options;
613    let trusts: Vec<serde_json::Value> = options
614        .oidc
615        .iter()
616        .flat_map(|verifier| verifier.trusts())
617        .map(|trust| json!({ "provider": trust.provider, "owner": trust.owner }))
618        .collect();
619    let requires_auth = options.require_key || options.oidc.is_some() || !keys.is_empty();
620    let loopback = options
621        .address
622        .is_none_or(|address| address.ip().is_loopback());
623    Ok(Json(json!({
624        "server": {
625            "version": env!("CARGO_PKG_VERSION"),
626            "address": options.address.map(|address| address.to_string()),
627            "loopback": loopback,
628            "publicRead": options.public_read,
629            "publishRequiresAuth": requires_auth,
630            "demo": options.demo,
631            "audience": options.oidc.as_ref().map(|verifier| verifier.audience()),
632        },
633        "trusts": trusts,
634        "keys": keys,
635    })))
636}
637
638/// Headers for every response. The UI is static files that load nothing
639/// from elsewhere, and the content security policy says so to the browser.
640async fn security_headers(
641    request: axum::extract::Request,
642    next: axum::middleware::Next,
643) -> Response {
644    let path = request.uri().path().to_string();
645    let mut response = next.run(request).await;
646    let headers = response.headers_mut();
647    headers.insert(
648        header::X_CONTENT_TYPE_OPTIONS,
649        header::HeaderValue::from_static("nosniff"),
650    );
651    headers.insert(
652        header::REFERRER_POLICY,
653        header::HeaderValue::from_static("no-referrer"),
654    );
655    if !path.starts_with("/api/") && path != "/healthz" {
656        headers.insert(
657            header::CONTENT_SECURITY_POLICY,
658            header::HeaderValue::from_static(
659                "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
660            ),
661        );
662    }
663    response
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    fn addr(text: &str) -> SocketAddr {
671        text.parse().unwrap()
672    }
673
674    #[test]
675    fn loopback_binds_need_nothing() {
676        for text in ["127.0.0.1:7474", "127.0.0.1:0", "[::1]:7474"] {
677            check_bind(addr(text), false, 0).unwrap();
678        }
679    }
680
681    #[test]
682    fn a_public_bind_needs_public_read() {
683        for text in ["0.0.0.0:7474", "192.168.1.20:7474", "[::]:7474"] {
684            let error = check_bind(addr(text), false, 1).unwrap_err();
685            assert_eq!(error.kind(), ErrorKind::Refused, "{text}");
686            assert!(error.hint().unwrap().contains("--public-read"));
687        }
688    }
689
690    #[test]
691    fn a_public_bind_needs_a_key() {
692        let error = check_bind(addr("0.0.0.0:7474"), true, 0).unwrap_err();
693        assert_eq!(error.kind(), ErrorKind::Refused);
694        assert!(error.hint().unwrap().contains("key create"));
695        check_bind(addr("0.0.0.0:7474"), true, 1).unwrap();
696    }
697}