1use 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
25const MAX_REPORT_BYTES: usize = 16 * 1024 * 1024;
27
28#[derive(Debug, Clone)]
30pub struct ServeConfig {
31 pub data_dir: PathBuf,
32 pub addr: SocketAddr,
33 pub public_read: bool,
36 pub trusts: Vec<Trust>,
38 pub public_url: Option<String>,
41 pub demo: bool,
44}
45
46#[derive(Clone, Default)]
49pub struct ServerOptions {
50 pub require_key: bool,
52 pub oidc: Option<Arc<Verifier>>,
54 pub address: Option<SocketAddr>,
57 pub public_read: bool,
59 pub demo: bool,
61}
62
63pub 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
88pub 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
166pub 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
188pub 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
215struct 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
269async 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
291enum Principal {
293 Anonymous,
295 Key(KeyGrant),
296 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
359fn 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
442async 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
529const 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 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
638async 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}