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;
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(
273 multi_host: Arc<MultiRepoHost>,
274 static_assets: Option<DashboardStatic>,
275 authority: MutationAuthority,
276 read_authority: Option<String>,
277 bind_addr: Option<SocketAddr>,
278 bind_is_loopback: bool,
279 require_read_token: bool,
280) -> Router {
281 let catalog = Arc::clone(&multi_host);
282 let catalog_reads = read_work::ReadWork::default();
283 let mut app = Router::new().route("/api/health", get(rest::health)).route(
284 "/api/repos",
285 get(move || {
286 let catalog = Arc::clone(&catalog);
287 let reads = catalog_reads.clone();
288 async move { reads.run(move || Ok(Json(catalog.summaries()))).await }
289 }),
290 );
291
292 let mut repo_reads = std::collections::HashMap::new();
294 for context in multi_host.contexts() {
295 let prefix = format!("/api/repos/{}", context.id());
296 match context.host().cloned() {
297 Some(host) => {
298 let reads = read_work::ReadWork::default();
299 repo_reads.insert(context.id().to_string(), reads.clone());
300 app = app.nest(
301 &prefix,
302 repo_context_router(context, host, bind_addr, bind_is_loopback, reads),
303 );
304 }
305 None => {
306 let handler = repo_unavailable_handler(&context);
312 app = app
313 .route(&prefix, any(handler.clone()))
314 .route(&format!("{prefix}/{{*path}}"), any(handler));
315 }
316 }
317 }
318
319 let mut unavailable_default = None;
320 if let Some(context) = multi_host.compatibility_context() {
321 match context.host().cloned() {
322 Some(host) => {
323 let reads = repo_reads
324 .entry(context.id().to_string())
325 .or_default()
326 .clone();
327 app = app.nest(
328 "/api",
329 repo_context_router(context, host, bind_addr, bind_is_loopback, reads),
330 );
331 }
332 None => unavailable_default = Some(repo_unavailable_handler(&context)),
336 }
337 }
338
339 app = match unavailable_default {
345 Some(handler) => app
346 .route("/api", any(handler.clone()))
347 .route("/api/{*path}", any(handler)),
348 None => app
349 .route("/api", any(api_not_found))
350 .route("/api/{*path}", any(api_not_found)),
351 };
352
353 let app = match static_assets {
354 Some(DashboardStatic::Dir(dir)) => {
355 let index = dir.join("index.html");
356 app.fallback_service(ServeDir::new(&dir).fallback(ServeFile::new(index)))
357 }
358 Some(DashboardStatic::Embedded(files)) => {
359 app.fallback(move |uri: Uri| async move { embedded_static_response(uri, files) })
360 }
361 None => app.route("/", get(root_info)),
362 };
363
364 app.layer(middleware::from_fn_with_state(
369 TokenGate {
370 authority,
371 read_authority,
372 require_read_token,
373 },
374 require_mutation_token,
375 ))
376 .layer(middleware::from_fn(require_json_api_posts))
377 .layer(middleware::from_fn_with_state(
378 HostGate { bind_is_loopback },
379 require_host,
380 ))
381 .layer(cors_layer(bind_addr))
382 .layer(middleware::from_fn(cache_response_headers))
385}
386
387async fn api_not_found() -> impl IntoResponse {
388 (
389 StatusCode::NOT_FOUND,
390 Json(json!({ "error": "API route not found or repository scope required" })),
391 )
392}
393
394async fn cache_response_headers(request: Request, next: Next) -> Response {
401 let is_api = request.uri().path().starts_with("/api");
402 let mut response = next.run(request).await;
403 let cache_control = if is_api {
404 Some("no-store")
405 } else if response
406 .headers()
407 .get(header::CONTENT_TYPE)
408 .and_then(|value| value.to_str().ok())
409 .is_some_and(|content_type| content_type.starts_with("text/html"))
410 {
411 Some("no-cache")
412 } else {
413 None
414 };
415 if let Some(value) = cache_control {
416 response
417 .headers_mut()
418 .insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
419 }
420 response
421}
422
423fn repo_unavailable_handler(
427 context: &RepoContext,
428) -> impl Fn() -> std::future::Ready<(StatusCode, Json<serde_json::Value>)> + Clone {
429 let id = context.id().to_string();
430 let reason = context
431 .unavailable_reason()
432 .unwrap_or("repository is unavailable")
433 .to_string();
434 move || {
435 std::future::ready((
436 StatusCode::SERVICE_UNAVAILABLE,
437 Json(json!({
438 "error": "repository unavailable",
439 "repoId": id.clone(),
440 "detail": reason.clone(),
441 })),
442 ))
443 }
444}
445
446fn repo_context_router(
447 context: Arc<RepoContext>,
448 host: Arc<MissionHost>,
449 bind_addr: Option<SocketAddr>,
450 bind_is_loopback: bool,
451 reads: read_work::ReadWork,
452) -> Router {
453 let state = Arc::new(ServerState {
454 repo_root: context.root().to_path_buf(),
455 host,
456 bind_addr,
457 bind_is_loopback,
458 });
459 repo_api_routes()
460 .layer(axum::Extension(reads))
461 .with_state(state)
462}
463
464fn repo_api_routes() -> Router<Arc<ServerState>> {
465 Router::new()
466 .route(
467 "/missions",
468 get(rest::list_missions).post(host::create_mission),
469 )
470 .route("/missions/outcomes", get(rest::mission_outcomes))
471 .route("/escalation-metrics", get(rest::escalation_metrics))
472 .route("/standards-metrics", get(rest::standards_metrics))
473 .route("/cost-per-merged-change", get(rest::cost_per_merged_change))
474 .route("/missions/{id}/state", get(rest::mission_state))
475 .route("/missions/{id}/standards", get(rest::mission_standards))
476 .route(
477 "/missions/{id}/standards/waiver",
478 post(rest::post_standards_waiver),
479 )
480 .route("/missions/{id}/workspace", get(rest::mission_workspace))
481 .route("/missions/{id}/events", get(rest::mission_events))
482 .route("/missions/{id}/plan", get(rest::mission_plan))
483 .route("/missions/{id}/plan.md", get(rest::mission_plan_md))
484 .route(
485 "/missions/{id}/revision-diff",
486 get(rest::mission_revision_diff),
487 )
488 .route("/missions/{id}/report.md", get(rest::mission_report_md))
489 .route("/missions/{id}/diff-stat", get(rest::mission_diff_stat))
490 .route("/missions/{id}/pr-handoff", get(rest::mission_pr_handoff))
491 .route(
492 "/missions/{id}/pr-handoff/create",
493 post(rest::mission_pr_create),
494 )
495 .route("/missions/{id}/readiness", get(rest::mission_readiness))
496 .route(
497 "/missions/{id}/runs/{run_id}/transcript",
498 get(rest::run_transcript),
499 )
500 .route(
507 "/hook-status",
508 post(rest::post_hook_status).route_layer(axum::extract::DefaultBodyLimit::max(
509 kranz_engine::hook_status::SIGNAL_BODY_MAX_BYTES,
510 )),
511 )
512 .route("/missions/{id}/hook-status", get(rest::mission_hook_status))
513 .route("/missions/{id}/control", post(rest::post_control))
514 .route("/missions/{id}/revise", post(rest::post_revise))
515 .route(
516 "/missions/{id}/revision/approve",
517 post(rest::post_revision_approve),
518 )
519 .route(
520 "/missions/{id}/revision/reject",
521 post(rest::post_revision_reject),
522 )
523 .route(
524 "/missions/{id}/grant/approve",
525 post(rest::post_grant_approve),
526 )
527 .route("/missions/{id}/grant/deny", post(rest::post_grant_deny))
528 .route(
529 "/missions/{id}/question/answer",
530 post(rest::post_question_answer),
531 )
532 .route("/missions/{id}/planning/turn", post(host::planning_turn))
533 .route(
534 "/missions/{id}/planning/request-plan",
535 post(host::request_plan),
536 )
537 .route("/missions/{id}/approve", post(host::approve_mission))
538 .route("/missions/{id}/start", post(host::start_mission))
539 .route("/missions/{id}/pending-plan", get(host::pending_plan_route))
540 .route(
541 "/missions/{id}/approve-pending",
542 post(host::approve_pending_route),
543 )
544 .route("/missions/{id}/abandon", post(host::abandon_mission_route))
545 .route("/missions/{id}/release", post(host::release_mission_route))
546 .route("/missions/{id}/delete", post(host::delete_mission_route))
547 .route("/missions/{id}/merge", post(host::merge_mission_route))
548 .route("/missions/{id}/ws", get(ws::ws_handler))
549 .route(
550 "/tickets",
551 get(tickets::list_tickets).post(tickets::create_ticket),
552 )
553 .route("/tickets/{slug}", get(tickets::get_ticket))
554 .route("/tickets/{slug}/draft", post(tickets::draft_ticket))
555 .route("/tickets/{slug}/approve", post(tickets::approve_ticket))
556 .route("/queue", get(host::queue_state_route))
557 .route("/queue/drain", post(host::drain_queue_route))
558 .route("/hooks/github", post(hooks::github_hook))
559}
560
561fn embedded_static_response(uri: Uri, files: &'static [EmbeddedFile]) -> Response {
562 let requested = uri.path().trim_start_matches('/');
563 let requested = if requested.is_empty() {
564 "index.html"
565 } else {
566 requested
567 };
568 let file = files
569 .iter()
570 .find(|file| file.path == requested)
571 .or_else(|| files.iter().find(|file| file.path == "index.html"));
572
573 let Some(file) = file else {
574 return StatusCode::NOT_FOUND.into_response();
575 };
576
577 Response::builder()
578 .status(StatusCode::OK)
579 .header(header::CONTENT_TYPE, file.content_type)
580 .body(Body::from(file.bytes))
581 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
582}
583
584fn cors_layer(bind_addr: Option<SocketAddr>) -> CorsLayer {
605 CorsLayer::new()
606 .allow_origin(AllowOrigin::predicate(
607 move |origin: &HeaderValue, _request_parts| {
608 origin.to_str().is_ok_and(|o| origin_allowed(o, bind_addr))
609 },
610 ))
611 .allow_methods([Method::GET, Method::POST])
612 .allow_headers([header::CONTENT_TYPE, HeaderName::from_static(TOKEN_HEADER)])
613}
614
615pub(crate) fn origin_allowed(origin: &str, bind_addr: Option<SocketAddr>) -> bool {
641 if origin == "tauri://localhost" || origin == "http://tauri.localhost" {
642 return true;
643 }
644 const DEV_PORTS: [u16; 2] = [5173, 1420];
649 let Some(authority) = origin.strip_prefix("http://") else {
650 return false;
651 };
652 let Some((host, port)) = split_host_port(authority) else {
653 return false;
654 };
655 let host_ip = host.parse::<std::net::IpAddr>().ok();
656 let host_local = host == "localhost" || host_ip.is_some_and(|ip| ip.is_loopback());
657 if !host_local {
658 return false;
659 }
660 let Some(bind) = bind_addr else {
661 return true; };
663 if DEV_PORTS.contains(&port) {
664 return host == "localhost"
665 || host_ip == Some(std::net::IpAddr::V4(Ipv4Addr::LOCALHOST))
666 || host_ip == Some(std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST));
667 }
668 if port != bind.port() {
669 return false;
670 }
671 match host_ip {
672 Some(ip) => ip == bind.ip() || (bind.ip().is_unspecified() && ip.is_loopback()),
673 None => {
676 bind.ip().is_unspecified()
677 || bind.ip() == std::net::IpAddr::V4(Ipv4Addr::LOCALHOST)
678 || bind.ip() == std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)
679 }
680 }
681}
682
683fn split_host_port(authority: &str) -> Option<(&str, u16)> {
688 if let Some(rest) = authority.strip_prefix('[') {
689 let (addr, tail) = rest.split_once(']')?;
690 let port = if tail.is_empty() {
691 80
692 } else {
693 tail.strip_prefix(':')?.parse().ok()?
694 };
695 return Some((addr, port));
696 }
697 match authority.rsplit_once(':') {
698 Some((host, _)) if host.contains(':') => Some((authority, 80)),
699 Some((host, port)) => Some((host, port.parse().ok()?)),
700 None => Some((authority, 80)),
701 }
702}
703
704pub(crate) fn ws_origin_allowed(
715 origin: Option<&str>,
716 bind_addr: Option<SocketAddr>,
717 bind_is_loopback: bool,
718) -> bool {
719 match origin {
720 None => !bind_is_loopback,
721 Some(origin) => {
722 origin_allowed(origin, bind_addr) || (!bind_is_loopback && origin_host_is_ip(origin))
723 }
724 }
725}
726
727fn origin_host_is_ip(origin: &str) -> bool {
731 origin
732 .strip_prefix("http://")
733 .and_then(split_host_port)
734 .is_some_and(|(host, _)| host.parse::<std::net::IpAddr>().is_ok())
735}
736
737async fn require_host(State(gate): State<HostGate>, request: Request, next: Next) -> Response {
744 if let Some(host) = request.headers().get(header::HOST) {
745 if !host
746 .to_str()
747 .is_ok_and(|h| host_allowed(h, gate.bind_is_loopback))
748 {
749 return (
750 StatusCode::FORBIDDEN,
751 Json(json!({ "error": "invalid host" })),
752 )
753 .into_response();
754 }
755 }
756 next.run(request).await
757}
758
759fn host_allowed(host: &str, bind_is_loopback: bool) -> bool {
773 let host = host.trim().to_ascii_lowercase();
774 if host_is_loopback(&host) {
775 return true;
776 }
777 if bind_is_loopback {
778 return false;
779 }
780 host_ip(&host).is_some()
781}
782
783fn host_is_loopback(host: &str) -> bool {
784 if host == "localhost" {
785 return true;
786 }
787 if let Some(port) = host.strip_prefix("localhost:") {
788 return port.parse::<u16>().is_ok();
789 }
790 host_ip(host).is_some_and(|ip| ip.is_loopback())
791}
792
793fn host_ip(host: &str) -> Option<std::net::IpAddr> {
798 if let Ok(ip) = host.parse::<std::net::IpAddr>() {
799 return Some(ip);
800 }
801 if let Some(rest) = host.strip_prefix('[') {
802 let (addr, tail) = rest.split_once(']')?;
803 if !(tail.is_empty()
804 || tail
805 .strip_prefix(':')
806 .is_some_and(|p| p.parse::<u16>().is_ok()))
807 {
808 return None;
809 }
810 return addr.parse().ok();
811 }
812 let (addr, port) = host.rsplit_once(':')?;
813 if port.parse::<u16>().is_err() {
814 return None;
815 }
816 addr.parse().ok()
817}
818
819async fn require_json_api_posts(request: Request, next: Next) -> Response {
839 if request.method() == Method::POST && request.uri().path().starts_with("/api/") {
840 let is_empty_body = request
841 .headers()
842 .get(header::CONTENT_LENGTH)
843 .and_then(|value| value.to_str().ok())
844 .is_none_or(|value| value == "0");
845 let is_json = request
846 .headers()
847 .get(header::CONTENT_TYPE)
848 .and_then(|value| value.to_str().ok())
849 .and_then(|value| value.split(';').next())
850 .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("application/json"));
851 if !is_empty_body && !is_json {
852 return (
853 StatusCode::UNSUPPORTED_MEDIA_TYPE,
854 Json(json!({ "error": "POST bodies must be application/json" })),
855 )
856 .into_response();
857 }
858 }
859 next.run(request).await
860}
861
862#[derive(Clone)]
866struct TokenGate {
867 authority: MutationAuthority,
868 read_authority: Option<String>,
869 require_read_token: bool,
870}
871
872#[derive(Clone)]
875struct HostGate {
876 bind_is_loopback: bool,
877}
878
879async fn require_mutation_token(
897 State(gate): State<TokenGate>,
898 request: Request,
899 next: Next,
900) -> Response {
901 let expected = gate.authority.as_str();
902 let path = request.uri().path();
903 let is_health = path == "/api/health";
904 let is_github_hook = path.ends_with("/hooks/github");
908 let is_hook_signal_post = request.method() == Method::POST && path.ends_with("/hook-status");
913 let is_read = request.method() == Method::GET || request.method() == Method::HEAD;
914 let needs_auth = path.starts_with("/api/")
915 && !is_health
916 && !is_github_hook
917 && !is_hook_signal_post
918 && (request.method() == Method::POST || (gate.require_read_token && is_read));
919 if needs_auth {
920 let read_ok = |presented: &str| {
922 is_read
923 && gate
924 .read_authority
925 .as_deref()
926 .is_some_and(|read| token_matches(presented, read))
927 };
928 let header_ok = request
929 .headers()
930 .get(TOKEN_HEADER)
931 .and_then(|value| value.to_str().ok())
932 .is_some_and(|presented| token_matches(presented, expected) || read_ok(presented));
933 let query_ok = gate.require_read_token
934 && is_read
935 && request
936 .uri()
937 .query()
938 .map(|q| {
939 q.split('&').any(|pair| {
940 let mut parts = pair.splitn(2, '=');
941 matches!(parts.next(), Some("token"))
942 && parts.next().is_some_and(|v| {
943 let decoded = percent_decode_token(v);
944 token_matches(&decoded, expected) || read_ok(&decoded)
945 })
946 })
947 })
948 .unwrap_or(false);
949 if !header_ok && !query_ok {
950 return (
951 StatusCode::UNAUTHORIZED,
952 Json(json!({ "error": "missing or invalid token" })),
953 )
954 .into_response();
955 }
956 }
957 next.run(request).await
958}
959
960fn token_matches(presented: &str, expected: &str) -> bool {
966 use subtle::ConstantTimeEq;
967 presented.as_bytes().ct_eq(expected.as_bytes()).into()
968}
969
970fn percent_decode_token(raw: &str) -> String {
973 let bytes = raw.as_bytes();
974 let mut out = Vec::with_capacity(bytes.len());
975 let mut i = 0;
976 while i < bytes.len() {
977 if bytes[i] == b'%' && i + 2 < bytes.len() {
978 if let (Some(hi), Some(lo)) = (
979 (bytes[i + 1] as char).to_digit(16),
980 (bytes[i + 2] as char).to_digit(16),
981 ) {
982 out.push((hi * 16 + lo) as u8);
983 i += 3;
984 continue;
985 }
986 }
987 if bytes[i] == b'+' {
988 out.push(b' ');
989 } else {
990 out.push(bytes[i]);
991 }
992 i += 1;
993 }
994 String::from_utf8_lossy(&out).into_owned()
995}
996
997async fn root_info() -> &'static str {
999 "kranz server is running (no dashboard bundle configured).\n\
1000 REST + WebSocket API under /api — see docs/protocol.md.\n"
1001}
1002
1003pub async fn serve(
1006 repo_root: PathBuf,
1007 port: u16,
1008 static_dir: Option<PathBuf>,
1009 authority: MutationAuthority,
1010) -> anyhow::Result<()> {
1011 serve_with_static(
1012 repo_root,
1013 port,
1014 static_dir.map(DashboardStatic::Dir),
1015 authority,
1016 )
1017 .await
1018}
1019
1020pub async fn serve_with_static(
1022 repo_root: PathBuf,
1023 port: u16,
1024 static_assets: Option<DashboardStatic>,
1025 authority: MutationAuthority,
1026) -> anyhow::Result<()> {
1027 serve_with_shared_host(
1028 Arc::new(MissionHost::new(repo_root)),
1029 IpAddr::V4(Ipv4Addr::LOCALHOST),
1030 port,
1031 static_assets,
1032 authority,
1033 )
1034 .await
1035}
1036
1037pub async fn serve_with_shared_host(
1044 host: Arc<MissionHost>,
1045 bind: IpAddr,
1046 port: u16,
1047 static_assets: Option<DashboardStatic>,
1048 authority: MutationAuthority,
1049) -> anyhow::Result<()> {
1050 let shutdown = async {
1051 if let Err(e) = tokio::signal::ctrl_c().await {
1052 tracing::error!(error = %e, "failed to install ctrl-c handler");
1053 }
1054 };
1055 serve_with_shutdown(host, bind, port, static_assets, authority, shutdown).await
1056}
1057
1058pub async fn serve_with_shutdown(
1062 host: Arc<MissionHost>,
1063 bind: IpAddr,
1064 port: u16,
1065 static_assets: Option<DashboardStatic>,
1066 authority: MutationAuthority,
1067 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1068) -> anyhow::Result<()> {
1069 let listener = bind_listener(bind, port).await?;
1070 serve_on_listener(host, listener, static_assets, authority, shutdown).await
1071}
1072
1073pub async fn bind_listener(bind: IpAddr, port: u16) -> anyhow::Result<tokio::net::TcpListener> {
1078 Ok(tokio::net::TcpListener::bind(SocketAddr::from((bind, port))).await?)
1079}
1080
1081pub async fn serve_on_listener(
1086 host: Arc<MissionHost>,
1087 listener: tokio::net::TcpListener,
1088 static_assets: Option<DashboardStatic>,
1089 authority: MutationAuthority,
1090 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1091) -> anyhow::Result<()> {
1092 serve_multi_on_listener(
1093 Arc::new(MultiRepoHost::with_host(host)),
1094 listener,
1095 static_assets,
1096 authority,
1097 None,
1098 false,
1099 shutdown,
1100 )
1101 .await
1102}
1103
1104pub async fn serve_multi_on_listener(
1111 multi_host: Arc<MultiRepoHost>,
1112 listener: tokio::net::TcpListener,
1113 static_assets: Option<DashboardStatic>,
1114 authority: MutationAuthority,
1115 read_authority: Option<String>,
1116 read_auth: bool,
1117 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1118) -> anyhow::Result<()> {
1119 let local_addr = listener.local_addr()?;
1120 let bind_is_loopback = local_addr.ip().is_loopback();
1121 let require_read_token = !bind_is_loopback || read_auth;
1122 let app = router_with_read_authority_and_addr(
1123 multi_host,
1124 static_assets,
1125 authority,
1126 read_authority,
1127 Some(local_addr),
1128 bind_is_loopback,
1129 require_read_token,
1130 );
1131 tracing::info!("kranz server listening on http://{local_addr}");
1132 axum::serve(listener, app)
1133 .with_graceful_shutdown(shutdown)
1134 .await?;
1135 Ok(())
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140 use super::{
1141 host_allowed, origin_allowed, router_with_multi_repo_host_and_addr, EmbeddedFile,
1142 HostConfig, MultiRepoHost, RepoConfig, RepoSlackConfig,
1143 };
1144 use axum::body::Body;
1145 use axum::http::{Request, StatusCode};
1146 use http_body_util::BodyExt;
1147 use kranz_engine::event_log::{EventLog, LockForce};
1148 use kranz_engine::events::EventKind;
1149 use kranz_engine::paths::MissionPaths;
1150 use kranz_engine::types::MissionConfig;
1151 use std::path::{Path, PathBuf};
1152 use std::sync::Arc;
1153 use std::time::Duration;
1154 use tower::ServiceExt;
1155
1156 fn authority() -> super::MutationAuthority {
1157 super::MutationAuthority::new("tok").unwrap()
1158 }
1159
1160 fn seed_planning_mission(root: &Path, goal: &str) {
1161 std::fs::create_dir_all(root).unwrap();
1162 let status = std::process::Command::new("git")
1163 .args(["init", "-q"])
1164 .arg(root)
1165 .status()
1166 .unwrap();
1167 assert!(status.success());
1168 let paths = MissionPaths::new(root, "same-id");
1169 let mut log = EventLog::acquire(&paths, "same-id", Duration::ZERO, LockForce::No).unwrap();
1170 log.append(EventKind::MissionCreated {
1171 goal: goal.to_string(),
1172 base_branch: "main".to_string(),
1173 mission_branch: "kranz/mission-same-id".to_string(),
1174 config: MissionConfig::default(),
1175 })
1176 .unwrap();
1177 }
1178
1179 fn repo_config(id: &str, root: PathBuf) -> RepoConfig {
1180 RepoConfig {
1181 id: id.to_string(),
1182 root,
1183 display_name: None,
1184 group: None,
1185 pinned: false,
1186 slack: RepoSlackConfig::default(),
1187 }
1188 }
1189
1190 #[tokio::test]
1191 async fn unavailable_repo_routes_return_503_with_reason() {
1192 let temp = tempfile::tempdir().unwrap();
1193 let good = temp.path().join("good");
1194 seed_planning_mission(&good, "goal");
1195 let missing = temp.path().join("missing");
1196
1197 let multi = Arc::new(
1198 MultiRepoHost::from_config(HostConfig {
1199 default_repo: None,
1200 max_concurrent_repos: 1,
1201 repos: vec![repo_config("good", good), repo_config("gone", missing)],
1202 })
1203 .unwrap(),
1204 );
1205 let app = router_with_multi_repo_host_and_addr(multi, None, authority(), None, true, false);
1206
1207 for uri in ["/api/repos/gone", "/api/repos/gone/queue"] {
1210 let response = app
1211 .clone()
1212 .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1213 .await
1214 .unwrap();
1215 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{uri}");
1216 let body = response.into_body().collect().await.unwrap().to_bytes();
1217 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1218 assert_eq!(json["error"], "repository unavailable", "{uri}");
1219 assert_eq!(json["repoId"], "gone", "{uri}");
1220 assert!(json["detail"].as_str().unwrap().contains("does not exist"));
1221 }
1222
1223 let response = app
1226 .clone()
1227 .oneshot(
1228 Request::builder()
1229 .uri("/api/repos/nope/queue")
1230 .body(Body::empty())
1231 .unwrap(),
1232 )
1233 .await
1234 .unwrap();
1235 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1236
1237 let response = app
1239 .clone()
1240 .oneshot(
1241 Request::builder()
1242 .uri("/api/repos/good/missions/same-id/state")
1243 .body(Body::empty())
1244 .unwrap(),
1245 )
1246 .await
1247 .unwrap();
1248 assert_eq!(response.status(), StatusCode::OK);
1249 }
1250
1251 #[tokio::test]
1252 async fn unavailable_default_repo_reports_503_on_the_unscoped_alias() {
1253 let temp = tempfile::tempdir().unwrap();
1254 let good = temp.path().join("good");
1255 seed_planning_mission(&good, "goal");
1256 let missing = temp.path().join("missing");
1257
1258 let multi = Arc::new(
1259 MultiRepoHost::from_config(HostConfig {
1260 default_repo: Some("gone".to_string()),
1261 max_concurrent_repos: 1,
1262 repos: vec![repo_config("good", good), repo_config("gone", missing)],
1263 })
1264 .unwrap(),
1265 );
1266 let app = router_with_multi_repo_host_and_addr(multi, None, authority(), None, true, false);
1267
1268 let response = app
1271 .clone()
1272 .oneshot(
1273 Request::builder()
1274 .uri("/api/queue")
1275 .body(Body::empty())
1276 .unwrap(),
1277 )
1278 .await
1279 .unwrap();
1280 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1281 let body = response.into_body().collect().await.unwrap().to_bytes();
1282 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1283 assert_eq!(json["repoId"], "gone");
1284
1285 let response = app
1287 .clone()
1288 .oneshot(
1289 Request::builder()
1290 .uri("/api/health")
1291 .body(Body::empty())
1292 .unwrap(),
1293 )
1294 .await
1295 .unwrap();
1296 assert_eq!(response.status(), StatusCode::OK);
1297 let response = app
1298 .clone()
1299 .oneshot(
1300 Request::builder()
1301 .uri("/api/repos/good/missions/same-id/state")
1302 .body(Body::empty())
1303 .unwrap(),
1304 )
1305 .await
1306 .unwrap();
1307 assert_eq!(response.status(), StatusCode::OK);
1308 }
1309
1310 #[tokio::test]
1311 async fn repo_scoped_routes_isolate_duplicate_mission_ids_and_mutations() {
1312 let temp = tempfile::tempdir().unwrap();
1313 let a = temp.path().join("a");
1314 let b = temp.path().join("b");
1315 seed_planning_mission(&a, "goal-a");
1316 seed_planning_mission(&b, "goal-b");
1317
1318 let multi = Arc::new(
1319 MultiRepoHost::from_config(HostConfig {
1320 default_repo: None,
1321 max_concurrent_repos: 1,
1322 repos: vec![repo_config("a", a.clone()), repo_config("b", b.clone())],
1323 })
1324 .unwrap(),
1325 );
1326 static EMBEDDED: &[EmbeddedFile] = &[EmbeddedFile {
1327 path: "index.html",
1328 bytes: b"dashboard",
1329 content_type: "text/html",
1330 }];
1331 let app = router_with_multi_repo_host_and_addr(
1332 multi,
1333 Some(super::DashboardStatic::Embedded(EMBEDDED)),
1334 authority(),
1335 None,
1336 true,
1337 false,
1338 );
1339
1340 for (repo_id, expected_goal) in [("a", "goal-a"), ("b", "goal-b")] {
1341 let response = app
1342 .clone()
1343 .oneshot(
1344 Request::builder()
1345 .uri(format!("/api/repos/{repo_id}/missions/same-id/state"))
1346 .body(Body::empty())
1347 .unwrap(),
1348 )
1349 .await
1350 .unwrap();
1351 assert_eq!(response.status(), StatusCode::OK);
1352 let body = response.into_body().collect().await.unwrap().to_bytes();
1353 let state: serde_json::Value = serde_json::from_slice(&body).unwrap();
1354 assert_eq!(state["mission"]["goal"], expected_goal);
1355 }
1356
1357 let response = app
1358 .clone()
1359 .oneshot(
1360 Request::builder()
1361 .method("POST")
1362 .uri("/api/repos/a/missions/same-id/control")
1363 .header("content-type", "application/json")
1364 .header(super::TOKEN_HEADER, "tok")
1365 .body(Body::from(r#"{"kind":"pause"}"#))
1366 .unwrap(),
1367 )
1368 .await
1369 .unwrap();
1370 assert_eq!(response.status(), StatusCode::ACCEPTED);
1371 assert_eq!(
1372 std::fs::read_dir(MissionPaths::new(&a, "same-id").control_dir())
1373 .unwrap()
1374 .count(),
1375 1
1376 );
1377 assert_eq!(
1378 std::fs::read_dir(MissionPaths::new(&b, "same-id").control_dir())
1379 .unwrap()
1380 .count(),
1381 0
1382 );
1383
1384 let response = app
1387 .clone()
1388 .oneshot(
1389 Request::builder()
1390 .method("POST")
1391 .uri("/api/missions/same-id/control")
1392 .header("content-type", "application/json")
1393 .header(super::TOKEN_HEADER, "tok")
1394 .body(Body::from(r#"{"kind":"pause"}"#))
1395 .unwrap(),
1396 )
1397 .await
1398 .unwrap();
1399 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1400 assert_eq!(response.headers()["content-type"], "application/json");
1401
1402 let response = app
1403 .oneshot(Request::builder().uri("/api").body(Body::empty()).unwrap())
1404 .await
1405 .unwrap();
1406 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1407 assert_eq!(response.headers()["content-type"], "application/json");
1408 }
1409
1410 #[test]
1411 fn origin_allowlist_accepts_only_local_dev_and_tauri() {
1412 for allowed in [
1416 "http://localhost",
1417 "http://localhost:80",
1418 "http://localhost:5173",
1419 "http://127.0.0.1",
1420 "http://127.0.0.1:65535",
1421 "http://127.0.0.10:8080",
1422 "http://[::1]:5173",
1423 "tauri://localhost",
1424 "http://tauri.localhost",
1425 ] {
1426 assert!(origin_allowed(allowed, None), "should allow {allowed}");
1427 }
1428 for denied in [
1429 "https://evil.example",
1430 "http://localhost.evil.example",
1432 "http://localhost.evil.example:5173",
1433 "http://127.0.0.1.evil.example",
1434 "http://localhostx",
1435 "http://192.168.1.5:4560",
1438 "http://localhost:99999",
1440 "http://localhost:5173.evil.example",
1441 "https://localhost:5173",
1443 "https://tauri.localhost",
1444 "tauri://evil.example",
1445 "null",
1446 "",
1447 ] {
1448 assert!(!origin_allowed(denied, None), "should deny {denied}");
1449 }
1450 }
1451
1452 #[test]
1453 fn origin_allowlist_scopes_localhost_to_bind_and_dev_ports() {
1454 let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 1], 4560)));
1458 for allowed in [
1459 "http://localhost:4560",
1460 "http://127.0.0.1:4560",
1461 "http://localhost:5173",
1462 "http://127.0.0.1:5173",
1463 "http://localhost:1420",
1464 "tauri://localhost",
1465 "http://tauri.localhost",
1466 ] {
1467 assert!(
1468 origin_allowed(allowed, bind),
1469 "should allow {allowed} for bind 127.0.0.1:4560"
1470 );
1471 }
1472 for denied in [
1473 "http://localhost:8080",
1474 "http://127.0.0.1:8080",
1475 "http://localhost", "http://127.0.0.1",
1477 "http://127.0.0.2:4560",
1481 "http://127.0.0.10:4560",
1482 "http://127.0.0.2:5173",
1483 "http://127.0.0.10:1420",
1484 "http://[::1]:4560",
1485 "http://localhost.evil.example:4560",
1486 "https://localhost:4560",
1487 "https://evil.example",
1488 ] {
1489 assert!(
1490 !origin_allowed(denied, bind),
1491 "should deny {denied} for bind 127.0.0.1:4560"
1492 );
1493 }
1494 assert!(origin_allowed(
1496 "http://localhost",
1497 Some(std::net::SocketAddr::from(([127, 0, 0, 1], 80)))
1498 ));
1499 }
1500
1501 #[test]
1502 fn origin_allowlist_follows_the_actual_bound_ip() {
1503 let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 2], 4560)));
1506 assert!(origin_allowed("http://127.0.0.2:4560", bind));
1507 assert!(!origin_allowed("http://127.0.0.1:4560", bind));
1508 assert!(!origin_allowed("http://localhost:4560", bind));
1509 assert!(origin_allowed("http://localhost:5173", bind));
1511
1512 let bind_v6 = Some(std::net::SocketAddr::from((
1514 std::net::Ipv6Addr::LOCALHOST,
1515 4560,
1516 )));
1517 assert!(origin_allowed("http://[::1]:4560", bind_v6));
1518 assert!(origin_allowed("http://localhost:4560", bind_v6));
1519 assert!(!origin_allowed("http://127.0.0.2:4560", bind_v6));
1520
1521 let bind_any = Some(std::net::SocketAddr::from(([0, 0, 0, 0], 4560)));
1524 assert!(origin_allowed("http://127.0.0.1:4560", bind_any));
1525 assert!(origin_allowed("http://127.0.0.5:4560", bind_any));
1526 assert!(origin_allowed("http://localhost:4560", bind_any));
1527 assert!(!origin_allowed("http://localhost:8080", bind_any));
1528 }
1529
1530 #[test]
1531 fn ws_origin_loopback_keeps_strict_browser_allowlist() {
1532 use super::ws_origin_allowed;
1533 let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 1], 4560)));
1534 assert!(ws_origin_allowed(Some("http://localhost:4560"), bind, true));
1535 assert!(ws_origin_allowed(Some("http://localhost:5173"), bind, true));
1536 assert!(
1537 !ws_origin_allowed(None, bind, true),
1538 "missing Origin stays rejected on loopback (reads are tokenless)"
1539 );
1540 assert!(!ws_origin_allowed(
1541 Some("http://192.168.1.5:4560"),
1542 bind,
1543 true
1544 ));
1545 assert!(
1546 !ws_origin_allowed(Some("http://127.0.0.2:4560"), bind, true),
1547 "co-resident loopback listener page must not open the tokenless WS"
1548 );
1549 assert!(
1550 !ws_origin_allowed(Some("http://127.0.0.2:5173"), bind, true),
1551 "a dev port must not privilege another independently bindable loopback IP"
1552 );
1553 assert!(!ws_origin_allowed(Some("http://evil.example"), bind, true));
1554 }
1555
1556 #[test]
1557 fn ws_origin_lan_accepts_ip_literals_and_native_clients() {
1558 use super::ws_origin_allowed;
1559 let bind = Some(std::net::SocketAddr::from(([0, 0, 0, 0], 4560)));
1560 assert!(ws_origin_allowed(
1563 Some("http://192.168.1.5:4560"),
1564 bind,
1565 false
1566 ));
1567 assert!(ws_origin_allowed(
1568 Some("http://[fd00::5]:4560"),
1569 bind,
1570 false
1571 ));
1572 assert!(ws_origin_allowed(
1573 Some("http://localhost:5173"),
1574 bind,
1575 false
1576 ));
1577 assert!(ws_origin_allowed(None, bind, false));
1578 for denied in [
1580 "http://evil.example:4560",
1581 "http://192.168.1.5.evil.example:4560",
1582 "https://192.168.1.5:4560",
1583 "http://[::1:4560",
1584 "null",
1585 "",
1586 ] {
1587 assert!(
1588 !ws_origin_allowed(Some(denied), bind, false),
1589 "should deny {denied} off loopback"
1590 );
1591 }
1592 }
1593
1594 #[test]
1595 fn host_allowlist_loopback_rejects_lan_and_dns() {
1596 for allowed in [
1597 "localhost",
1598 "localhost:4560",
1599 "LOCALHOST:5173",
1600 "127.0.0.1",
1601 "127.0.0.1:65535",
1602 "127.0.0.2:4560",
1604 "::1",
1605 "[::1]",
1606 "[::1]:4560",
1607 ] {
1608 assert!(
1609 host_allowed(allowed, true),
1610 "loopback bind should allow {allowed}"
1611 );
1612 }
1613 for denied in [
1614 "evil.example",
1615 "evil.example:4560",
1616 "localhost.evil.example",
1617 "192.168.1.10",
1618 "192.168.1.10:4560",
1619 "10.0.0.1:8080",
1620 "::1:4560",
1622 "[::1",
1624 "",
1625 ] {
1626 assert!(
1627 !host_allowed(denied, true),
1628 "loopback bind should deny {denied}"
1629 );
1630 }
1631 }
1632
1633 #[test]
1634 fn host_allowlist_lan_accepts_ip_hosts() {
1635 for allowed in [
1636 "192.168.1.10",
1637 "192.168.1.10:4560",
1638 "10.0.0.1:8080",
1639 "localhost",
1640 "127.0.0.1:4560",
1641 "[::1]:4560",
1642 ] {
1643 assert!(
1644 host_allowed(allowed, false),
1645 "LAN bind should allow {allowed}"
1646 );
1647 }
1648 for denied in [
1649 "evil.example",
1650 "evil.example:4560",
1651 "localhost.evil.example",
1652 "",
1653 ] {
1654 assert!(
1655 !host_allowed(denied, false),
1656 "LAN bind should still deny DNS Host {denied}"
1657 );
1658 }
1659 }
1660}