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