1#![allow(rustdoc::private_intra_doc_links)]
2
3mod error;
19mod hooks;
20mod host;
21mod multi;
22mod read_work;
23mod rest;
24mod tickets;
25mod ws;
26
27pub use error::{ApiError, ApiErrorCode};
28pub use host::{MissionHost, PendingApproval};
29pub use multi::{
30 load_host_config, HostConfig, MultiRepoHost, RepoActivity, RepoConfig, RepoContext,
31 RepoSlackConfig, RepoSummary, SlackChannelRoute,
32};
33
34use axum::body::{Body, HttpBody};
35use axum::extract::{Request, State};
36use axum::http::{header, HeaderName, HeaderValue, Method, StatusCode, Uri};
37use axum::middleware::{self, Next};
38use axum::response::{IntoResponse, Response};
39use axum::routing::{any, get, post};
40use axum::{Json, Router};
41use serde_json::json;
42use std::fmt;
43use std::net::{IpAddr, Ipv4Addr, SocketAddr};
44use std::path::PathBuf;
45use std::sync::Arc;
46use tower_http::cors::{AllowOrigin, CorsLayer};
47use tower_http::services::{ServeDir, ServeFile};
48
49pub const TOKEN_HEADER: &str = "x-kranz-token";
52
53#[derive(Clone, PartialEq, Eq)]
57pub struct MutationAuthority(String);
58
59impl MutationAuthority {
60 pub fn new(token: impl Into<String>) -> Result<Self, InvalidMutationAuthority> {
64 let value = token.into();
68 if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_graphic()) {
69 return Err(InvalidMutationAuthority);
70 }
71 Ok(Self(value))
72 }
73
74 pub fn as_str(&self) -> &str {
76 &self.0
77 }
78}
79
80impl fmt::Debug for MutationAuthority {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 f.write_str("MutationAuthority([REDACTED])")
83 }
84}
85
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub struct InvalidMutationAuthority;
90
91impl fmt::Display for InvalidMutationAuthority {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 f.write_str("mutation authority must be non-empty visible ASCII without whitespace")
94 }
95}
96
97impl std::error::Error for InvalidMutationAuthority {}
98
99pub fn generate_token() -> String {
102 uuid::Uuid::new_v4().simple().to_string()
103}
104
105#[derive(Clone, Copy, Debug)]
107pub struct EmbeddedFile {
108 pub path: &'static str,
109 pub bytes: &'static [u8],
110 pub content_type: &'static str,
111}
112
113pub enum DashboardStatic {
115 Dir(PathBuf),
116 Embedded(&'static [EmbeddedFile]),
117}
118
119pub struct ServerState {
122 pub repo_root: PathBuf,
123 pub host: Arc<MissionHost>,
127 pub bind_addr: Option<SocketAddr>,
130 pub bind_is_loopback: bool,
135}
136
137pub fn router(repo_root: PathBuf, static_dir: Option<PathBuf>) -> Router {
146 router_with_static(repo_root, static_dir.map(DashboardStatic::Dir))
147}
148
149pub fn router_with_static(repo_root: PathBuf, static_assets: Option<DashboardStatic>) -> Router {
152 let authority = MutationAuthority::new(generate_token())
153 .expect("generated UUID mutation authority is valid");
154 router_with_token(repo_root, static_assets, authority)
155}
156
157pub fn router_with_token(
161 repo_root: PathBuf,
162 static_assets: Option<DashboardStatic>,
163 authority: MutationAuthority,
164) -> Router {
165 router_with_host(MissionHost::new(repo_root), static_assets, authority)
166}
167
168pub fn router_with_host(
172 host: MissionHost,
173 static_assets: Option<DashboardStatic>,
174 authority: MutationAuthority,
175) -> Router {
176 router_with_shared_host(Arc::new(host), static_assets, authority)
177}
178
179pub fn router_with_shared_host(
186 host: Arc<MissionHost>,
187 static_assets: Option<DashboardStatic>,
188 authority: MutationAuthority,
189) -> Router {
190 router_with_shared_host_and_bind(host, static_assets, authority, None, true, false)
191}
192
193pub fn router_with_shared_host_and_bind(
203 host: Arc<MissionHost>,
204 static_assets: Option<DashboardStatic>,
205 authority: MutationAuthority,
206 bind_port: Option<u16>,
207 bind_is_loopback: bool,
208 require_read_token: bool,
209) -> Router {
210 router_with_shared_host_and_addr(
211 host,
212 static_assets,
213 authority,
214 bind_port.map(|port| SocketAddr::from((Ipv4Addr::LOCALHOST, port))),
215 bind_is_loopback,
216 require_read_token,
217 )
218}
219
220pub fn router_with_shared_host_and_addr(
226 host: Arc<MissionHost>,
227 static_assets: Option<DashboardStatic>,
228 authority: MutationAuthority,
229 bind_addr: Option<SocketAddr>,
230 bind_is_loopback: bool,
231 require_read_token: bool,
232) -> Router {
233 router_with_multi_repo_host_and_addr(
234 Arc::new(MultiRepoHost::with_host(host)),
235 static_assets,
236 authority,
237 bind_addr,
238 bind_is_loopback,
239 require_read_token,
240 )
241}
242
243pub fn router_with_multi_repo_host_and_addr(
248 multi_host: Arc<MultiRepoHost>,
249 static_assets: Option<DashboardStatic>,
250 authority: MutationAuthority,
251 bind_addr: Option<SocketAddr>,
252 bind_is_loopback: bool,
253 require_read_token: bool,
254) -> Router {
255 router_with_read_authority_and_addr(
256 multi_host,
257 static_assets,
258 authority,
259 None,
260 bind_addr,
261 bind_is_loopback,
262 require_read_token,
263 )
264}
265
266pub fn router_with_read_authority_and_addr(
274 multi_host: Arc<MultiRepoHost>,
275 static_assets: Option<DashboardStatic>,
276 authority: MutationAuthority,
277 read_authority: Option<String>,
278 bind_addr: Option<SocketAddr>,
279 bind_is_loopback: bool,
280 require_read_token: bool,
281) -> Router {
282 let gate = TokenGate {
283 read_authority: read_authority
284 .filter(|read| !read.is_empty() && !token_matches(read, authority.as_str()))
285 .unwrap_or_else(generate_token),
286 authority,
287 require_read_token,
288 };
289 let exchange_gate = gate.clone();
290 let mut repos = Router::new();
291 let catalog = Arc::clone(&multi_host);
292 let catalog_reads = read_work::ReadWork::default();
293 let mut app = Router::new()
294 .route("/api/health", get(rest::health))
295 .route(
296 "/api/repos",
297 get(move || {
298 let catalog = Arc::clone(&catalog);
299 let reads = catalog_reads.clone();
300 async move { reads.run(move || Ok(Json(catalog.summaries()))).await }
301 }),
302 )
303 .route("/api/read-token", get(read_token).with_state(exchange_gate));
304
305 let mut repo_reads = std::collections::HashMap::new();
307 for context in multi_host.contexts() {
308 let prefix = format!("/api/repos/{}", context.id());
309 match context.host().cloned() {
310 Some(host) => {
311 let reads = read_work::ReadWork::default();
312 repo_reads.insert(context.id().to_string(), reads.clone());
313 repos = repos.nest(
314 &prefix,
315 repo_context_router(
316 context,
317 host,
318 bind_addr,
319 bind_is_loopback,
320 reads,
321 gate.clone(),
322 ),
323 );
324 }
325 None => {
326 let handler = repo_unavailable_handler(&context);
332 app = app
333 .route(&prefix, any(handler.clone()))
334 .route(&format!("{prefix}/{{*path}}"), any(handler));
335 }
336 }
337 }
338
339 let mut unavailable_default = None;
340 if let Some(context) = multi_host.compatibility_context() {
341 match context.host().cloned() {
342 Some(host) => {
343 let reads = repo_reads
344 .entry(context.id().to_string())
345 .or_default()
346 .clone();
347 repos = repos.nest(
348 "/api",
349 repo_context_router(
350 context,
351 host,
352 bind_addr,
353 bind_is_loopback,
354 reads,
355 gate.clone(),
356 ),
357 );
358 }
359 None => unavailable_default = Some(repo_unavailable_handler(&context)),
363 }
364 }
365
366 app = match unavailable_default {
372 Some(handler) => app
373 .route("/api", any(handler.clone()))
374 .route("/api/{*path}", any(handler)),
375 None => app
376 .route("/api", any(api_not_found))
377 .route("/api/{*path}", any(api_not_found)),
378 };
379
380 let app = app
384 .layer(middleware::from_fn_with_state(gate, require_mutation_token))
385 .merge(repos);
386
387 let app = match static_assets {
388 Some(DashboardStatic::Dir(dir)) => {
389 let index = dir.join("index.html");
390 app.fallback_service(ServeDir::new(&dir).fallback(ServeFile::new(index)))
391 }
392 Some(DashboardStatic::Embedded(files)) => {
393 app.fallback(move |uri: Uri| async move { embedded_static_response(uri, files) })
394 }
395 None => app.route("/", get(root_info)),
396 };
397
398 app.layer(middleware::from_fn(require_json_api_posts))
403 .layer(middleware::from_fn_with_state(
404 HostGate { bind_is_loopback },
405 require_host,
406 ))
407 .layer(cors_layer(bind_addr))
408 .layer(middleware::from_fn(cache_response_headers))
411}
412
413async fn api_not_found() -> impl IntoResponse {
414 (
415 StatusCode::NOT_FOUND,
416 Json(json!({ "error": "API route not found or repository scope required" })),
417 )
418}
419
420async fn cache_response_headers(request: Request, next: Next) -> Response {
427 let is_api = request.uri().path().starts_with("/api");
428 let mut response = next.run(request).await;
429 let cache_control = if is_api {
430 Some("no-store")
431 } else if response
432 .headers()
433 .get(header::CONTENT_TYPE)
434 .and_then(|value| value.to_str().ok())
435 .is_some_and(|content_type| content_type.starts_with("text/html"))
436 {
437 Some("no-cache")
438 } else {
439 None
440 };
441 if let Some(value) = cache_control {
442 response
443 .headers_mut()
444 .insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
445 }
446 response
447}
448
449fn repo_unavailable_handler(
453 context: &RepoContext,
454) -> impl Fn() -> std::future::Ready<(StatusCode, Json<serde_json::Value>)> + Clone {
455 let id = context.id().to_string();
456 let reason = context
457 .unavailable_reason()
458 .unwrap_or("repository is unavailable")
459 .to_string();
460 move || {
461 std::future::ready((
462 StatusCode::SERVICE_UNAVAILABLE,
463 Json(json!({
464 "error": "repository unavailable",
465 "repoId": id.clone(),
466 "detail": reason.clone(),
467 })),
468 ))
469 }
470}
471
472fn repo_context_router(
473 context: Arc<RepoContext>,
474 host: Arc<MissionHost>,
475 bind_addr: Option<SocketAddr>,
476 bind_is_loopback: bool,
477 reads: read_work::ReadWork,
478 gate: TokenGate,
479) -> Router {
480 let state = Arc::new(ServerState {
481 repo_root: context.root().to_path_buf(),
482 host,
483 bind_addr,
484 bind_is_loopback,
485 });
486 repo_api_routes(gate)
487 .layer(axum::Extension(reads))
488 .with_state(state)
489}
490
491fn repo_api_routes(gate: TokenGate) -> Router<Arc<ServerState>> {
492 protected_repo_api_routes()
493 .route_layer(middleware::from_fn_with_state(gate, require_mutation_token))
494 .merge(independently_authenticated_hook_routes())
495}
496
497fn protected_repo_api_routes() -> Router<Arc<ServerState>> {
498 let routes = Router::new()
499 .route(
500 "/missions",
501 get(rest::list_missions).post(host::create_mission),
502 )
503 .route("/missions/outcomes", get(rest::mission_outcomes))
504 .route("/escalation-metrics", get(rest::escalation_metrics))
505 .route("/standards-metrics", get(rest::standards_metrics))
506 .route("/cost-per-merged-change", get(rest::cost_per_merged_change))
507 .route("/missions/{id}/state", get(rest::mission_state))
508 .route(
509 "/missions/{id}/review-packet",
510 get(rest::mission_review_packet),
511 )
512 .route("/missions/{id}/standards", get(rest::mission_standards))
513 .route(
514 "/missions/{id}/standards/waiver",
515 post(rest::post_standards_waiver),
516 )
517 .route("/missions/{id}/workspace", get(rest::mission_workspace))
518 .route("/missions/{id}/events", get(rest::mission_events))
519 .route("/missions/{id}/plan", get(rest::mission_plan))
520 .route("/missions/{id}/plan.md", get(rest::mission_plan_md))
521 .route(
522 "/missions/{id}/revision-diff",
523 get(rest::mission_revision_diff),
524 )
525 .route("/missions/{id}/report.md", get(rest::mission_report_md))
526 .route("/missions/{id}/diff-stat", get(rest::mission_diff_stat))
527 .route("/missions/{id}/pr-handoff", get(rest::mission_pr_handoff))
528 .route(
529 "/missions/{id}/pr-handoff/create",
530 post(rest::mission_pr_create),
531 )
532 .route("/missions/{id}/readiness", get(rest::mission_readiness))
533 .route(
534 "/missions/{id}/runs/{run_id}/transcript",
535 get(rest::run_transcript),
536 )
537 .route("/missions/{id}/hook-status", get(rest::mission_hook_status))
538 .route("/missions/{id}/control", post(rest::post_control))
539 .route("/missions/{id}/revise", post(rest::post_revise))
540 .route(
541 "/missions/{id}/revision/approve",
542 post(rest::post_revision_approve),
543 )
544 .route(
545 "/missions/{id}/revision/reject",
546 post(rest::post_revision_reject),
547 )
548 .route(
549 "/missions/{id}/grant/approve",
550 post(rest::post_grant_approve),
551 )
552 .route("/missions/{id}/grant/deny", post(rest::post_grant_deny))
553 .route(
554 "/missions/{id}/permission/answer",
555 post(rest::post_permission_answer),
556 )
557 .route(
558 "/missions/{id}/question/answer",
559 post(rest::post_question_answer),
560 )
561 .route("/missions/{id}/planning/turn", post(host::planning_turn))
562 .route(
563 "/missions/{id}/planning/request-plan",
564 post(host::request_plan),
565 )
566 .route("/missions/{id}/approve", post(host::approve_mission))
567 .route("/missions/{id}/start", post(host::start_mission))
568 .route("/missions/{id}/pending-plan", get(host::pending_plan_route))
569 .route(
570 "/missions/{id}/approve-pending",
571 post(host::approve_pending_route),
572 )
573 .route("/missions/{id}/abandon", post(host::abandon_mission_route))
574 .route("/missions/{id}/release", post(host::release_mission_route))
575 .route("/missions/{id}/delete", post(host::delete_mission_route))
576 .route("/missions/{id}/merge", post(host::merge_mission_route))
577 .route("/missions/{id}/ws", get(ws::ws_handler))
578 .route(
579 "/tickets",
580 get(tickets::list_tickets).post(tickets::create_ticket),
581 )
582 .route("/tickets/{slug}", get(tickets::get_ticket))
583 .route("/tickets/{slug}/draft", post(tickets::draft_ticket))
584 .route("/tickets/{slug}/approve", post(tickets::approve_ticket))
585 .route("/queue", get(host::queue_state_route))
586 .route("/queue/drain", post(host::drain_queue_route));
587 #[cfg(test)]
590 let routes = routes
591 .route(
592 "/future/hook-status",
593 post(|| async { StatusCode::NO_CONTENT }),
594 )
595 .route(
596 "/future/hooks/github",
597 post(|| async { StatusCode::NO_CONTENT }),
598 );
599 routes
600}
601
602fn independently_authenticated_hook_routes() -> Router<Arc<ServerState>> {
605 Router::new()
606 .route("/hooks/github", post(hooks::github_hook))
607 .route(
608 "/hook-status",
609 post(rest::post_hook_status).route_layer(axum::extract::DefaultBodyLimit::max(
610 kranz_engine::hook_status::SIGNAL_BODY_MAX_BYTES,
611 )),
612 )
613}
614
615fn embedded_static_response(uri: Uri, files: &'static [EmbeddedFile]) -> Response {
616 let requested = uri.path().trim_start_matches('/');
617 let requested = if requested.is_empty() {
618 "index.html"
619 } else {
620 requested
621 };
622 let file = files
623 .iter()
624 .find(|file| file.path == requested)
625 .or_else(|| files.iter().find(|file| file.path == "index.html"));
626
627 let Some(file) = file else {
628 return StatusCode::NOT_FOUND.into_response();
629 };
630
631 Response::builder()
632 .status(StatusCode::OK)
633 .header(header::CONTENT_TYPE, file.content_type)
634 .body(Body::from(file.bytes))
635 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
636}
637
638fn cors_layer(bind_addr: Option<SocketAddr>) -> CorsLayer {
659 CorsLayer::new()
660 .allow_origin(AllowOrigin::predicate(
661 move |origin: &HeaderValue, _request_parts| {
662 origin.to_str().is_ok_and(|o| origin_allowed(o, bind_addr))
663 },
664 ))
665 .allow_methods([Method::GET, Method::POST])
666 .allow_headers([header::CONTENT_TYPE, HeaderName::from_static(TOKEN_HEADER)])
667}
668
669pub(crate) fn origin_allowed(origin: &str, bind_addr: Option<SocketAddr>) -> bool {
695 if origin == "tauri://localhost" || origin == "http://tauri.localhost" {
696 return true;
697 }
698 const DEV_PORTS: [u16; 2] = [5173, 1420];
703 let Some(authority) = origin.strip_prefix("http://") else {
704 return false;
705 };
706 let Some((host, port)) = split_host_port(authority) else {
707 return false;
708 };
709 let host_ip = host.parse::<std::net::IpAddr>().ok();
710 let host_local = host == "localhost" || host_ip.is_some_and(|ip| ip.is_loopback());
711 if !host_local {
712 return false;
713 }
714 let Some(bind) = bind_addr else {
715 return true; };
717 if DEV_PORTS.contains(&port) {
718 return host == "localhost"
719 || host_ip == Some(std::net::IpAddr::V4(Ipv4Addr::LOCALHOST))
720 || host_ip == Some(std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST));
721 }
722 if port != bind.port() {
723 return false;
724 }
725 match host_ip {
726 Some(ip) => ip == bind.ip() || (bind.ip().is_unspecified() && ip.is_loopback()),
727 None => {
730 bind.ip().is_unspecified()
731 || bind.ip() == std::net::IpAddr::V4(Ipv4Addr::LOCALHOST)
732 || bind.ip() == std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)
733 }
734 }
735}
736
737fn split_host_port(authority: &str) -> Option<(&str, u16)> {
742 if let Some(rest) = authority.strip_prefix('[') {
743 let (addr, tail) = rest.split_once(']')?;
744 let port = if tail.is_empty() {
745 80
746 } else {
747 tail.strip_prefix(':')?.parse().ok()?
748 };
749 return Some((addr, port));
750 }
751 match authority.rsplit_once(':') {
752 Some((host, _)) if host.contains(':') => Some((authority, 80)),
753 Some((host, port)) => Some((host, port.parse().ok()?)),
754 None => Some((authority, 80)),
755 }
756}
757
758pub(crate) fn ws_origin_allowed(
769 origin: Option<&str>,
770 bind_addr: Option<SocketAddr>,
771 bind_is_loopback: bool,
772) -> bool {
773 match origin {
774 None => !bind_is_loopback,
775 Some(origin) => {
776 origin_allowed(origin, bind_addr) || (!bind_is_loopback && origin_host_is_ip(origin))
777 }
778 }
779}
780
781fn origin_host_is_ip(origin: &str) -> bool {
785 origin
786 .strip_prefix("http://")
787 .and_then(split_host_port)
788 .is_some_and(|(host, _)| host.parse::<std::net::IpAddr>().is_ok())
789}
790
791async fn require_host(State(gate): State<HostGate>, request: Request, next: Next) -> Response {
798 if let Some(host) = request.headers().get(header::HOST) {
799 if !host
800 .to_str()
801 .is_ok_and(|h| host_allowed(h, gate.bind_is_loopback))
802 {
803 return (
804 StatusCode::FORBIDDEN,
805 Json(json!({ "error": "invalid host" })),
806 )
807 .into_response();
808 }
809 }
810 next.run(request).await
811}
812
813fn host_allowed(host: &str, bind_is_loopback: bool) -> bool {
827 let host = host.trim().to_ascii_lowercase();
828 if host_is_loopback(&host) {
829 return true;
830 }
831 if bind_is_loopback {
832 return false;
833 }
834 host_ip(&host).is_some()
835}
836
837fn host_is_loopback(host: &str) -> bool {
838 if host == "localhost" {
839 return true;
840 }
841 if let Some(port) = host.strip_prefix("localhost:") {
842 return port.parse::<u16>().is_ok();
843 }
844 host_ip(host).is_some_and(|ip| ip.is_loopback())
845}
846
847fn host_ip(host: &str) -> Option<std::net::IpAddr> {
852 if let Ok(ip) = host.parse::<std::net::IpAddr>() {
853 return Some(ip);
854 }
855 if let Some(rest) = host.strip_prefix('[') {
856 let (addr, tail) = rest.split_once(']')?;
857 if !(tail.is_empty()
858 || tail
859 .strip_prefix(':')
860 .is_some_and(|p| p.parse::<u16>().is_ok()))
861 {
862 return None;
863 }
864 return addr.parse().ok();
865 }
866 let (addr, port) = host.rsplit_once(':')?;
867 if port.parse::<u16>().is_err() {
868 return None;
869 }
870 addr.parse().ok()
871}
872
873async fn require_json_api_posts(request: Request, next: Next) -> Response {
889 if request.method() == Method::POST && request.uri().path().starts_with("/api/") {
890 let is_empty_body = request.body().is_end_stream();
891 let is_json = request
892 .headers()
893 .get(header::CONTENT_TYPE)
894 .and_then(|value| value.to_str().ok())
895 .and_then(|value| value.split(';').next())
896 .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("application/json"));
897 if !is_empty_body && !is_json {
898 return (
899 StatusCode::UNSUPPORTED_MEDIA_TYPE,
900 Json(json!({ "error": "POST bodies must be application/json" })),
901 )
902 .into_response();
903 }
904 }
905 next.run(request).await
906}
907
908#[derive(Clone)]
912struct TokenGate {
913 authority: MutationAuthority,
914 read_authority: String,
915 require_read_token: bool,
916}
917
918#[derive(Clone)]
921struct HostGate {
922 bind_is_loopback: bool,
923}
924
925async fn require_mutation_token(
933 State(gate): State<TokenGate>,
934 request: Request,
935 next: Next,
936) -> Response {
937 let expected = gate.authority.as_str();
938 let path = request.uri().path();
939 let is_health = path == "/api/health";
940 let is_read = request.method() == Method::GET || request.method() == Method::HEAD;
941 let human_review = path.ends_with("/review-packet")
944 || (path.ends_with("/report.md")
945 && axum::extract::Query::<rest::ReportQuery>::try_from_uri(request.uri())
946 .map_or(true, |query| query.review));
947 let needs_auth = !is_health
948 && (request.method() == Method::POST
949 || ((gate.require_read_token || human_review) && is_read));
950 if needs_auth {
951 let read_ok = |presented: &str| is_read && token_matches(presented, &gate.read_authority);
952 let header_ok = request
953 .headers()
954 .get(TOKEN_HEADER)
955 .and_then(|value| value.to_str().ok())
956 .is_some_and(|presented| token_matches(presented, expected) || read_ok(presented));
957 let query_ok = gate.require_read_token
958 && !human_review
959 && is_read
960 && request
961 .uri()
962 .query()
963 .map(|q| {
964 q.split('&').any(|pair| {
965 let mut parts = pair.splitn(2, '=');
966 matches!(parts.next(), Some("token"))
967 && parts.next().is_some_and(|v| {
968 let decoded = percent_decode_token(v);
969 read_ok(&decoded)
970 })
971 })
972 })
973 .unwrap_or(false);
974 if !header_ok && !query_ok {
975 return (
976 StatusCode::UNAUTHORIZED,
977 Json(json!({ "error": "missing or invalid token" })),
978 )
979 .into_response();
980 }
981 }
982 next.run(request).await
983}
984
985async fn read_token(State(gate): State<TokenGate>, request: Request) -> Response {
989 let valid = request
990 .headers()
991 .get(TOKEN_HEADER)
992 .and_then(|value| value.to_str().ok())
993 .is_some_and(|presented| {
994 token_matches(presented, gate.authority.as_str())
995 || token_matches(presented, &gate.read_authority)
996 });
997 if !valid {
998 return (
999 StatusCode::UNAUTHORIZED,
1000 Json(json!({ "error": "missing or invalid token" })),
1001 )
1002 .into_response();
1003 }
1004 let value = gate.read_authority;
1005 Json(json!({ "token": value })).into_response()
1006}
1007
1008fn token_matches(presented: &str, expected: &str) -> bool {
1014 use subtle::ConstantTimeEq;
1015 presented.as_bytes().ct_eq(expected.as_bytes()).into()
1016}
1017
1018fn percent_decode_token(raw: &str) -> String {
1021 let bytes = raw.as_bytes();
1022 let mut out = Vec::with_capacity(bytes.len());
1023 let mut i = 0;
1024 while i < bytes.len() {
1025 if bytes[i] == b'%' && i + 2 < bytes.len() {
1026 if let (Some(hi), Some(lo)) = (
1027 (bytes[i + 1] as char).to_digit(16),
1028 (bytes[i + 2] as char).to_digit(16),
1029 ) {
1030 out.push((hi * 16 + lo) as u8);
1031 i += 3;
1032 continue;
1033 }
1034 }
1035 if bytes[i] == b'+' {
1036 out.push(b' ');
1037 } else {
1038 out.push(bytes[i]);
1039 }
1040 i += 1;
1041 }
1042 String::from_utf8_lossy(&out).into_owned()
1043}
1044
1045async fn root_info() -> &'static str {
1047 "kranz server is running (no dashboard bundle configured).\n\
1048 REST + WebSocket API under /api — see docs/protocol.md.\n"
1049}
1050
1051pub async fn serve(
1054 repo_root: PathBuf,
1055 port: u16,
1056 static_dir: Option<PathBuf>,
1057 authority: MutationAuthority,
1058) -> anyhow::Result<()> {
1059 serve_with_static(
1060 repo_root,
1061 port,
1062 static_dir.map(DashboardStatic::Dir),
1063 authority,
1064 )
1065 .await
1066}
1067
1068pub async fn serve_with_static(
1070 repo_root: PathBuf,
1071 port: u16,
1072 static_assets: Option<DashboardStatic>,
1073 authority: MutationAuthority,
1074) -> anyhow::Result<()> {
1075 serve_with_shared_host(
1076 Arc::new(MissionHost::new(repo_root)),
1077 IpAddr::V4(Ipv4Addr::LOCALHOST),
1078 port,
1079 static_assets,
1080 authority,
1081 )
1082 .await
1083}
1084
1085pub async fn serve_with_shared_host(
1092 host: Arc<MissionHost>,
1093 bind: IpAddr,
1094 port: u16,
1095 static_assets: Option<DashboardStatic>,
1096 authority: MutationAuthority,
1097) -> anyhow::Result<()> {
1098 let shutdown = async {
1099 if let Err(e) = tokio::signal::ctrl_c().await {
1100 tracing::error!(error = %e, "failed to install ctrl-c handler");
1101 }
1102 };
1103 serve_with_shutdown(host, bind, port, static_assets, authority, shutdown).await
1104}
1105
1106pub async fn serve_with_shutdown(
1110 host: Arc<MissionHost>,
1111 bind: IpAddr,
1112 port: u16,
1113 static_assets: Option<DashboardStatic>,
1114 authority: MutationAuthority,
1115 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1116) -> anyhow::Result<()> {
1117 let listener = bind_listener(bind, port).await?;
1118 serve_on_listener(host, listener, static_assets, authority, shutdown).await
1119}
1120
1121pub async fn bind_listener(bind: IpAddr, port: u16) -> anyhow::Result<tokio::net::TcpListener> {
1126 Ok(tokio::net::TcpListener::bind(SocketAddr::from((bind, port))).await?)
1127}
1128
1129pub async fn serve_on_listener(
1134 host: Arc<MissionHost>,
1135 listener: tokio::net::TcpListener,
1136 static_assets: Option<DashboardStatic>,
1137 authority: MutationAuthority,
1138 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1139) -> anyhow::Result<()> {
1140 serve_multi_on_listener(
1141 Arc::new(MultiRepoHost::with_host(host)),
1142 listener,
1143 static_assets,
1144 authority,
1145 None,
1146 false,
1147 shutdown,
1148 )
1149 .await
1150}
1151
1152pub async fn serve_multi_on_listener(
1159 multi_host: Arc<MultiRepoHost>,
1160 listener: tokio::net::TcpListener,
1161 static_assets: Option<DashboardStatic>,
1162 authority: MutationAuthority,
1163 read_authority: Option<String>,
1164 read_auth: bool,
1165 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1166) -> anyhow::Result<()> {
1167 let local_addr = listener.local_addr()?;
1168 let bind_is_loopback = local_addr.ip().is_loopback();
1169 let require_read_token = !bind_is_loopback || read_auth;
1170 let app = router_with_read_authority_and_addr(
1171 multi_host,
1172 static_assets,
1173 authority,
1174 read_authority,
1175 Some(local_addr),
1176 bind_is_loopback,
1177 require_read_token,
1178 );
1179 tracing::info!("kranz server listening on http://{local_addr}");
1180 axum::serve(listener, app)
1181 .with_graceful_shutdown(shutdown)
1182 .await?;
1183 Ok(())
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188 use super::{
1189 host_allowed, origin_allowed, router_with_multi_repo_host_and_addr, EmbeddedFile,
1190 HostConfig, MultiRepoHost, RepoConfig, RepoSlackConfig,
1191 };
1192 use axum::body::Body;
1193 use axum::http::{Request, StatusCode};
1194 use http_body_util::BodyExt;
1195 use kranz_engine::event_log::{EventLog, LockForce};
1196 use kranz_engine::events::EventKind;
1197 use kranz_engine::paths::MissionPaths;
1198 use kranz_engine::types::MissionConfig;
1199 use std::path::{Path, PathBuf};
1200 use std::sync::Arc;
1201 use std::time::Duration;
1202 use tower::ServiceExt;
1203
1204 fn authority() -> super::MutationAuthority {
1205 super::MutationAuthority::new("tok").unwrap()
1206 }
1207
1208 #[tokio::test]
1209 async fn registered_hook_suffix_posts_require_mutation_authority() {
1210 let temp = tempfile::tempdir().unwrap();
1211 let app = super::repo_api_routes(super::TokenGate {
1212 authority: authority(),
1213 read_authority: "dummy-read".into(),
1214 require_read_token: true,
1215 })
1216 .with_state(Arc::new(super::ServerState {
1217 repo_root: temp.path().into(),
1218 host: Arc::new(super::MissionHost::new(temp.path().into())),
1219 bind_addr: None,
1220 bind_is_loopback: true,
1221 }));
1222 for path in ["/future/hook-status", "/future/hooks/github"] {
1223 for (presented, expected) in [
1224 (None, StatusCode::UNAUTHORIZED),
1225 (Some("dummy-read"), StatusCode::UNAUTHORIZED),
1226 (Some("tok"), StatusCode::NO_CONTENT),
1227 ] {
1228 let mut request = Request::post(path);
1229 if let Some(value) = presented {
1230 request = request.header(super::TOKEN_HEADER, value);
1231 }
1232 let response = app
1233 .clone()
1234 .oneshot(request.body(Body::empty()).unwrap())
1235 .await
1236 .unwrap();
1237 assert_eq!(response.status(), expected, "registered route: {path}");
1238 }
1239 }
1240 }
1241
1242 fn seed_planning_mission(root: &Path, goal: &str) {
1243 std::fs::create_dir_all(root).unwrap();
1244 let status = std::process::Command::new("git")
1245 .args(["init", "-q"])
1246 .arg(root)
1247 .status()
1248 .unwrap();
1249 assert!(status.success());
1250 let paths = MissionPaths::new(root, "same-id");
1251 let mut log = EventLog::acquire(&paths, "same-id", Duration::ZERO, LockForce::No).unwrap();
1252 log.append(EventKind::MissionCreated {
1253 goal: goal.to_string(),
1254 base_branch: "main".to_string(),
1255 mission_branch: "kranz/mission-same-id".to_string(),
1256 config: MissionConfig::default(),
1257 })
1258 .unwrap();
1259 }
1260
1261 fn repo_config(id: &str, root: PathBuf) -> RepoConfig {
1262 RepoConfig {
1263 id: id.to_string(),
1264 root,
1265 display_name: None,
1266 group: None,
1267 pinned: false,
1268 slack: RepoSlackConfig::default(),
1269 }
1270 }
1271
1272 #[tokio::test]
1273 async fn unavailable_repo_routes_return_503_with_reason() {
1274 let temp = tempfile::tempdir().unwrap();
1275 let good = temp.path().join("good");
1276 seed_planning_mission(&good, "goal");
1277 let missing = temp.path().join("missing");
1278
1279 let multi = Arc::new(
1280 MultiRepoHost::from_config(HostConfig {
1281 default_repo: None,
1282 max_concurrent_repos: 1,
1283 repos: vec![repo_config("good", good), repo_config("gone", missing)],
1284 })
1285 .unwrap(),
1286 );
1287 let app = router_with_multi_repo_host_and_addr(multi, None, authority(), None, true, false);
1288
1289 for uri in ["/api/repos/gone", "/api/repos/gone/queue"] {
1292 let response = app
1293 .clone()
1294 .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1295 .await
1296 .unwrap();
1297 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{uri}");
1298 let body = response.into_body().collect().await.unwrap().to_bytes();
1299 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1300 assert_eq!(json["error"], "repository unavailable", "{uri}");
1301 assert_eq!(json["repoId"], "gone", "{uri}");
1302 assert!(json["detail"].as_str().unwrap().contains("does not exist"));
1303 }
1304
1305 let response = app
1308 .clone()
1309 .oneshot(
1310 Request::builder()
1311 .uri("/api/repos/nope/queue")
1312 .body(Body::empty())
1313 .unwrap(),
1314 )
1315 .await
1316 .unwrap();
1317 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1318
1319 let response = app
1321 .clone()
1322 .oneshot(
1323 Request::builder()
1324 .uri("/api/repos/good/missions/same-id/state")
1325 .body(Body::empty())
1326 .unwrap(),
1327 )
1328 .await
1329 .unwrap();
1330 assert_eq!(response.status(), StatusCode::OK);
1331 }
1332
1333 #[tokio::test]
1334 async fn unavailable_default_repo_reports_503_on_the_unscoped_alias() {
1335 let temp = tempfile::tempdir().unwrap();
1336 let good = temp.path().join("good");
1337 seed_planning_mission(&good, "goal");
1338 let missing = temp.path().join("missing");
1339
1340 let multi = Arc::new(
1341 MultiRepoHost::from_config(HostConfig {
1342 default_repo: Some("gone".to_string()),
1343 max_concurrent_repos: 1,
1344 repos: vec![repo_config("good", good), repo_config("gone", missing)],
1345 })
1346 .unwrap(),
1347 );
1348 let app = router_with_multi_repo_host_and_addr(multi, None, authority(), None, true, false);
1349
1350 let response = app
1353 .clone()
1354 .oneshot(
1355 Request::builder()
1356 .uri("/api/queue")
1357 .body(Body::empty())
1358 .unwrap(),
1359 )
1360 .await
1361 .unwrap();
1362 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1363 let body = response.into_body().collect().await.unwrap().to_bytes();
1364 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1365 assert_eq!(json["repoId"], "gone");
1366
1367 let response = app
1369 .clone()
1370 .oneshot(
1371 Request::builder()
1372 .uri("/api/health")
1373 .body(Body::empty())
1374 .unwrap(),
1375 )
1376 .await
1377 .unwrap();
1378 assert_eq!(response.status(), StatusCode::OK);
1379 let response = app
1380 .clone()
1381 .oneshot(
1382 Request::builder()
1383 .uri("/api/repos/good/missions/same-id/state")
1384 .body(Body::empty())
1385 .unwrap(),
1386 )
1387 .await
1388 .unwrap();
1389 assert_eq!(response.status(), StatusCode::OK);
1390 }
1391
1392 #[tokio::test]
1393 async fn repo_scoped_routes_isolate_duplicate_mission_ids_and_mutations() {
1394 let temp = tempfile::tempdir().unwrap();
1395 let a = temp.path().join("a");
1396 let b = temp.path().join("b");
1397 seed_planning_mission(&a, "goal-a");
1398 seed_planning_mission(&b, "goal-b");
1399
1400 let multi = Arc::new(
1401 MultiRepoHost::from_config(HostConfig {
1402 default_repo: None,
1403 max_concurrent_repos: 1,
1404 repos: vec![repo_config("a", a.clone()), repo_config("b", b.clone())],
1405 })
1406 .unwrap(),
1407 );
1408 static EMBEDDED: &[EmbeddedFile] = &[EmbeddedFile {
1409 path: "index.html",
1410 bytes: b"dashboard",
1411 content_type: "text/html",
1412 }];
1413 let app = router_with_multi_repo_host_and_addr(
1414 multi,
1415 Some(super::DashboardStatic::Embedded(EMBEDDED)),
1416 authority(),
1417 None,
1418 true,
1419 false,
1420 );
1421
1422 for (repo_id, expected_goal) in [("a", "goal-a"), ("b", "goal-b")] {
1423 let response = app
1424 .clone()
1425 .oneshot(
1426 Request::builder()
1427 .uri(format!("/api/repos/{repo_id}/missions/same-id/state"))
1428 .body(Body::empty())
1429 .unwrap(),
1430 )
1431 .await
1432 .unwrap();
1433 assert_eq!(response.status(), StatusCode::OK);
1434 let body = response.into_body().collect().await.unwrap().to_bytes();
1435 let state: serde_json::Value = serde_json::from_slice(&body).unwrap();
1436 assert_eq!(state["mission"]["goal"], expected_goal);
1437 }
1438
1439 let response = app
1440 .clone()
1441 .oneshot(
1442 Request::builder()
1443 .method("POST")
1444 .uri("/api/repos/a/missions/same-id/control")
1445 .header("content-type", "application/json")
1446 .header(super::TOKEN_HEADER, "tok")
1447 .body(Body::from(r#"{"kind":"pause"}"#))
1448 .unwrap(),
1449 )
1450 .await
1451 .unwrap();
1452 assert_eq!(response.status(), StatusCode::ACCEPTED);
1453 assert_eq!(
1454 std::fs::read_dir(MissionPaths::new(&a, "same-id").control_dir())
1455 .unwrap()
1456 .count(),
1457 1
1458 );
1459 assert_eq!(
1460 std::fs::read_dir(MissionPaths::new(&b, "same-id").control_dir())
1461 .unwrap()
1462 .count(),
1463 0
1464 );
1465
1466 let response = app
1469 .clone()
1470 .oneshot(
1471 Request::builder()
1472 .method("POST")
1473 .uri("/api/missions/same-id/control")
1474 .header("content-type", "application/json")
1475 .header(super::TOKEN_HEADER, "tok")
1476 .body(Body::from(r#"{"kind":"pause"}"#))
1477 .unwrap(),
1478 )
1479 .await
1480 .unwrap();
1481 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1482 assert_eq!(response.headers()["content-type"], "application/json");
1483
1484 let response = app
1485 .oneshot(Request::builder().uri("/api").body(Body::empty()).unwrap())
1486 .await
1487 .unwrap();
1488 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1489 assert_eq!(response.headers()["content-type"], "application/json");
1490 }
1491
1492 #[test]
1493 fn origin_allowlist_accepts_only_local_dev_and_tauri() {
1494 for allowed in [
1498 "http://localhost",
1499 "http://localhost:80",
1500 "http://localhost:5173",
1501 "http://127.0.0.1",
1502 "http://127.0.0.1:65535",
1503 "http://127.0.0.10:8080",
1504 "http://[::1]:5173",
1505 "tauri://localhost",
1506 "http://tauri.localhost",
1507 ] {
1508 assert!(origin_allowed(allowed, None), "should allow {allowed}");
1509 }
1510 for denied in [
1511 "https://evil.example",
1512 "http://localhost.evil.example",
1514 "http://localhost.evil.example:5173",
1515 "http://127.0.0.1.evil.example",
1516 "http://localhostx",
1517 "http://192.168.1.5:4560",
1520 "http://localhost:99999",
1522 "http://localhost:5173.evil.example",
1523 "https://localhost:5173",
1525 "https://tauri.localhost",
1526 "tauri://evil.example",
1527 "null",
1528 "",
1529 ] {
1530 assert!(!origin_allowed(denied, None), "should deny {denied}");
1531 }
1532 }
1533
1534 #[test]
1535 fn origin_allowlist_scopes_localhost_to_bind_and_dev_ports() {
1536 let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 1], 4560)));
1540 for allowed in [
1541 "http://localhost:4560",
1542 "http://127.0.0.1:4560",
1543 "http://localhost:5173",
1544 "http://127.0.0.1:5173",
1545 "http://localhost:1420",
1546 "tauri://localhost",
1547 "http://tauri.localhost",
1548 ] {
1549 assert!(
1550 origin_allowed(allowed, bind),
1551 "should allow {allowed} for bind 127.0.0.1:4560"
1552 );
1553 }
1554 for denied in [
1555 "http://localhost:8080",
1556 "http://127.0.0.1:8080",
1557 "http://localhost", "http://127.0.0.1",
1559 "http://127.0.0.2:4560",
1563 "http://127.0.0.10:4560",
1564 "http://127.0.0.2:5173",
1565 "http://127.0.0.10:1420",
1566 "http://[::1]:4560",
1567 "http://localhost.evil.example:4560",
1568 "https://localhost:4560",
1569 "https://evil.example",
1570 ] {
1571 assert!(
1572 !origin_allowed(denied, bind),
1573 "should deny {denied} for bind 127.0.0.1:4560"
1574 );
1575 }
1576 assert!(origin_allowed(
1578 "http://localhost",
1579 Some(std::net::SocketAddr::from(([127, 0, 0, 1], 80)))
1580 ));
1581 }
1582
1583 #[test]
1584 fn origin_allowlist_follows_the_actual_bound_ip() {
1585 let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 2], 4560)));
1588 assert!(origin_allowed("http://127.0.0.2:4560", bind));
1589 assert!(!origin_allowed("http://127.0.0.1:4560", bind));
1590 assert!(!origin_allowed("http://localhost:4560", bind));
1591 assert!(origin_allowed("http://localhost:5173", bind));
1593
1594 let bind_v6 = Some(std::net::SocketAddr::from((
1596 std::net::Ipv6Addr::LOCALHOST,
1597 4560,
1598 )));
1599 assert!(origin_allowed("http://[::1]:4560", bind_v6));
1600 assert!(origin_allowed("http://localhost:4560", bind_v6));
1601 assert!(!origin_allowed("http://127.0.0.2:4560", bind_v6));
1602
1603 let bind_any = Some(std::net::SocketAddr::from(([0, 0, 0, 0], 4560)));
1606 assert!(origin_allowed("http://127.0.0.1:4560", bind_any));
1607 assert!(origin_allowed("http://127.0.0.5:4560", bind_any));
1608 assert!(origin_allowed("http://localhost:4560", bind_any));
1609 assert!(!origin_allowed("http://localhost:8080", bind_any));
1610 }
1611
1612 #[test]
1613 fn ws_origin_loopback_keeps_strict_browser_allowlist() {
1614 use super::ws_origin_allowed;
1615 let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 1], 4560)));
1616 assert!(ws_origin_allowed(Some("http://localhost:4560"), bind, true));
1617 assert!(ws_origin_allowed(Some("http://localhost:5173"), bind, true));
1618 assert!(
1619 !ws_origin_allowed(None, bind, true),
1620 "missing Origin stays rejected on loopback (reads are tokenless)"
1621 );
1622 assert!(!ws_origin_allowed(
1623 Some("http://192.168.1.5:4560"),
1624 bind,
1625 true
1626 ));
1627 assert!(
1628 !ws_origin_allowed(Some("http://127.0.0.2:4560"), bind, true),
1629 "co-resident loopback listener page must not open the tokenless WS"
1630 );
1631 assert!(
1632 !ws_origin_allowed(Some("http://127.0.0.2:5173"), bind, true),
1633 "a dev port must not privilege another independently bindable loopback IP"
1634 );
1635 assert!(!ws_origin_allowed(Some("http://evil.example"), bind, true));
1636 }
1637
1638 #[test]
1639 fn ws_origin_lan_accepts_ip_literals_and_native_clients() {
1640 use super::ws_origin_allowed;
1641 let bind = Some(std::net::SocketAddr::from(([0, 0, 0, 0], 4560)));
1642 assert!(ws_origin_allowed(
1645 Some("http://192.168.1.5:4560"),
1646 bind,
1647 false
1648 ));
1649 assert!(ws_origin_allowed(
1650 Some("http://[fd00::5]:4560"),
1651 bind,
1652 false
1653 ));
1654 assert!(ws_origin_allowed(
1655 Some("http://localhost:5173"),
1656 bind,
1657 false
1658 ));
1659 assert!(ws_origin_allowed(None, bind, false));
1660 for denied in [
1662 "http://evil.example:4560",
1663 "http://192.168.1.5.evil.example:4560",
1664 "https://192.168.1.5:4560",
1665 "http://[::1:4560",
1666 "null",
1667 "",
1668 ] {
1669 assert!(
1670 !ws_origin_allowed(Some(denied), bind, false),
1671 "should deny {denied} off loopback"
1672 );
1673 }
1674 }
1675
1676 #[test]
1677 fn host_allowlist_loopback_rejects_lan_and_dns() {
1678 for allowed in [
1679 "localhost",
1680 "localhost:4560",
1681 "LOCALHOST:5173",
1682 "127.0.0.1",
1683 "127.0.0.1:65535",
1684 "127.0.0.2:4560",
1686 "::1",
1687 "[::1]",
1688 "[::1]:4560",
1689 ] {
1690 assert!(
1691 host_allowed(allowed, true),
1692 "loopback bind should allow {allowed}"
1693 );
1694 }
1695 for denied in [
1696 "evil.example",
1697 "evil.example:4560",
1698 "localhost.evil.example",
1699 "192.168.1.10",
1700 "192.168.1.10:4560",
1701 "10.0.0.1:8080",
1702 "::1:4560",
1704 "[::1",
1706 "",
1707 ] {
1708 assert!(
1709 !host_allowed(denied, true),
1710 "loopback bind should deny {denied}"
1711 );
1712 }
1713 }
1714
1715 #[test]
1716 fn host_allowlist_lan_accepts_ip_hosts() {
1717 for allowed in [
1718 "192.168.1.10",
1719 "192.168.1.10:4560",
1720 "10.0.0.1:8080",
1721 "localhost",
1722 "127.0.0.1:4560",
1723 "[::1]:4560",
1724 ] {
1725 assert!(
1726 host_allowed(allowed, false),
1727 "LAN bind should allow {allowed}"
1728 );
1729 }
1730 for denied in [
1731 "evil.example",
1732 "evil.example:4560",
1733 "localhost.evil.example",
1734 "",
1735 ] {
1736 assert!(
1737 !host_allowed(denied, false),
1738 "LAN bind should still deny DNS Host {denied}"
1739 );
1740 }
1741 }
1742}