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("/missions/{id}/standards", get(rest::mission_standards))
509 .route(
510 "/missions/{id}/standards/waiver",
511 post(rest::post_standards_waiver),
512 )
513 .route("/missions/{id}/workspace", get(rest::mission_workspace))
514 .route("/missions/{id}/events", get(rest::mission_events))
515 .route("/missions/{id}/plan", get(rest::mission_plan))
516 .route("/missions/{id}/plan.md", get(rest::mission_plan_md))
517 .route(
518 "/missions/{id}/revision-diff",
519 get(rest::mission_revision_diff),
520 )
521 .route("/missions/{id}/report.md", get(rest::mission_report_md))
522 .route("/missions/{id}/diff-stat", get(rest::mission_diff_stat))
523 .route("/missions/{id}/pr-handoff", get(rest::mission_pr_handoff))
524 .route(
525 "/missions/{id}/pr-handoff/create",
526 post(rest::mission_pr_create),
527 )
528 .route("/missions/{id}/readiness", get(rest::mission_readiness))
529 .route(
530 "/missions/{id}/runs/{run_id}/transcript",
531 get(rest::run_transcript),
532 )
533 .route("/missions/{id}/hook-status", get(rest::mission_hook_status))
534 .route("/missions/{id}/control", post(rest::post_control))
535 .route("/missions/{id}/revise", post(rest::post_revise))
536 .route(
537 "/missions/{id}/revision/approve",
538 post(rest::post_revision_approve),
539 )
540 .route(
541 "/missions/{id}/revision/reject",
542 post(rest::post_revision_reject),
543 )
544 .route(
545 "/missions/{id}/grant/approve",
546 post(rest::post_grant_approve),
547 )
548 .route("/missions/{id}/grant/deny", post(rest::post_grant_deny))
549 .route(
550 "/missions/{id}/question/answer",
551 post(rest::post_question_answer),
552 )
553 .route("/missions/{id}/planning/turn", post(host::planning_turn))
554 .route(
555 "/missions/{id}/planning/request-plan",
556 post(host::request_plan),
557 )
558 .route("/missions/{id}/approve", post(host::approve_mission))
559 .route("/missions/{id}/start", post(host::start_mission))
560 .route("/missions/{id}/pending-plan", get(host::pending_plan_route))
561 .route(
562 "/missions/{id}/approve-pending",
563 post(host::approve_pending_route),
564 )
565 .route("/missions/{id}/abandon", post(host::abandon_mission_route))
566 .route("/missions/{id}/release", post(host::release_mission_route))
567 .route("/missions/{id}/delete", post(host::delete_mission_route))
568 .route("/missions/{id}/merge", post(host::merge_mission_route))
569 .route("/missions/{id}/ws", get(ws::ws_handler))
570 .route(
571 "/tickets",
572 get(tickets::list_tickets).post(tickets::create_ticket),
573 )
574 .route("/tickets/{slug}", get(tickets::get_ticket))
575 .route("/tickets/{slug}/draft", post(tickets::draft_ticket))
576 .route("/tickets/{slug}/approve", post(tickets::approve_ticket))
577 .route("/queue", get(host::queue_state_route))
578 .route("/queue/drain", post(host::drain_queue_route));
579 #[cfg(test)]
582 let routes = routes
583 .route(
584 "/future/hook-status",
585 post(|| async { StatusCode::NO_CONTENT }),
586 )
587 .route(
588 "/future/hooks/github",
589 post(|| async { StatusCode::NO_CONTENT }),
590 );
591 routes
592}
593
594fn independently_authenticated_hook_routes() -> Router<Arc<ServerState>> {
597 Router::new()
598 .route("/hooks/github", post(hooks::github_hook))
599 .route(
600 "/hook-status",
601 post(rest::post_hook_status).route_layer(axum::extract::DefaultBodyLimit::max(
602 kranz_engine::hook_status::SIGNAL_BODY_MAX_BYTES,
603 )),
604 )
605}
606
607fn embedded_static_response(uri: Uri, files: &'static [EmbeddedFile]) -> Response {
608 let requested = uri.path().trim_start_matches('/');
609 let requested = if requested.is_empty() {
610 "index.html"
611 } else {
612 requested
613 };
614 let file = files
615 .iter()
616 .find(|file| file.path == requested)
617 .or_else(|| files.iter().find(|file| file.path == "index.html"));
618
619 let Some(file) = file else {
620 return StatusCode::NOT_FOUND.into_response();
621 };
622
623 Response::builder()
624 .status(StatusCode::OK)
625 .header(header::CONTENT_TYPE, file.content_type)
626 .body(Body::from(file.bytes))
627 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
628}
629
630fn cors_layer(bind_addr: Option<SocketAddr>) -> CorsLayer {
651 CorsLayer::new()
652 .allow_origin(AllowOrigin::predicate(
653 move |origin: &HeaderValue, _request_parts| {
654 origin.to_str().is_ok_and(|o| origin_allowed(o, bind_addr))
655 },
656 ))
657 .allow_methods([Method::GET, Method::POST])
658 .allow_headers([header::CONTENT_TYPE, HeaderName::from_static(TOKEN_HEADER)])
659}
660
661pub(crate) fn origin_allowed(origin: &str, bind_addr: Option<SocketAddr>) -> bool {
687 if origin == "tauri://localhost" || origin == "http://tauri.localhost" {
688 return true;
689 }
690 const DEV_PORTS: [u16; 2] = [5173, 1420];
695 let Some(authority) = origin.strip_prefix("http://") else {
696 return false;
697 };
698 let Some((host, port)) = split_host_port(authority) else {
699 return false;
700 };
701 let host_ip = host.parse::<std::net::IpAddr>().ok();
702 let host_local = host == "localhost" || host_ip.is_some_and(|ip| ip.is_loopback());
703 if !host_local {
704 return false;
705 }
706 let Some(bind) = bind_addr else {
707 return true; };
709 if DEV_PORTS.contains(&port) {
710 return host == "localhost"
711 || host_ip == Some(std::net::IpAddr::V4(Ipv4Addr::LOCALHOST))
712 || host_ip == Some(std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST));
713 }
714 if port != bind.port() {
715 return false;
716 }
717 match host_ip {
718 Some(ip) => ip == bind.ip() || (bind.ip().is_unspecified() && ip.is_loopback()),
719 None => {
722 bind.ip().is_unspecified()
723 || bind.ip() == std::net::IpAddr::V4(Ipv4Addr::LOCALHOST)
724 || bind.ip() == std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)
725 }
726 }
727}
728
729fn split_host_port(authority: &str) -> Option<(&str, u16)> {
734 if let Some(rest) = authority.strip_prefix('[') {
735 let (addr, tail) = rest.split_once(']')?;
736 let port = if tail.is_empty() {
737 80
738 } else {
739 tail.strip_prefix(':')?.parse().ok()?
740 };
741 return Some((addr, port));
742 }
743 match authority.rsplit_once(':') {
744 Some((host, _)) if host.contains(':') => Some((authority, 80)),
745 Some((host, port)) => Some((host, port.parse().ok()?)),
746 None => Some((authority, 80)),
747 }
748}
749
750pub(crate) fn ws_origin_allowed(
761 origin: Option<&str>,
762 bind_addr: Option<SocketAddr>,
763 bind_is_loopback: bool,
764) -> bool {
765 match origin {
766 None => !bind_is_loopback,
767 Some(origin) => {
768 origin_allowed(origin, bind_addr) || (!bind_is_loopback && origin_host_is_ip(origin))
769 }
770 }
771}
772
773fn origin_host_is_ip(origin: &str) -> bool {
777 origin
778 .strip_prefix("http://")
779 .and_then(split_host_port)
780 .is_some_and(|(host, _)| host.parse::<std::net::IpAddr>().is_ok())
781}
782
783async fn require_host(State(gate): State<HostGate>, request: Request, next: Next) -> Response {
790 if let Some(host) = request.headers().get(header::HOST) {
791 if !host
792 .to_str()
793 .is_ok_and(|h| host_allowed(h, gate.bind_is_loopback))
794 {
795 return (
796 StatusCode::FORBIDDEN,
797 Json(json!({ "error": "invalid host" })),
798 )
799 .into_response();
800 }
801 }
802 next.run(request).await
803}
804
805fn host_allowed(host: &str, bind_is_loopback: bool) -> bool {
819 let host = host.trim().to_ascii_lowercase();
820 if host_is_loopback(&host) {
821 return true;
822 }
823 if bind_is_loopback {
824 return false;
825 }
826 host_ip(&host).is_some()
827}
828
829fn host_is_loopback(host: &str) -> bool {
830 if host == "localhost" {
831 return true;
832 }
833 if let Some(port) = host.strip_prefix("localhost:") {
834 return port.parse::<u16>().is_ok();
835 }
836 host_ip(host).is_some_and(|ip| ip.is_loopback())
837}
838
839fn host_ip(host: &str) -> Option<std::net::IpAddr> {
844 if let Ok(ip) = host.parse::<std::net::IpAddr>() {
845 return Some(ip);
846 }
847 if let Some(rest) = host.strip_prefix('[') {
848 let (addr, tail) = rest.split_once(']')?;
849 if !(tail.is_empty()
850 || tail
851 .strip_prefix(':')
852 .is_some_and(|p| p.parse::<u16>().is_ok()))
853 {
854 return None;
855 }
856 return addr.parse().ok();
857 }
858 let (addr, port) = host.rsplit_once(':')?;
859 if port.parse::<u16>().is_err() {
860 return None;
861 }
862 addr.parse().ok()
863}
864
865async fn require_json_api_posts(request: Request, next: Next) -> Response {
881 if request.method() == Method::POST && request.uri().path().starts_with("/api/") {
882 let is_empty_body = request.body().is_end_stream();
883 let is_json = request
884 .headers()
885 .get(header::CONTENT_TYPE)
886 .and_then(|value| value.to_str().ok())
887 .and_then(|value| value.split(';').next())
888 .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("application/json"));
889 if !is_empty_body && !is_json {
890 return (
891 StatusCode::UNSUPPORTED_MEDIA_TYPE,
892 Json(json!({ "error": "POST bodies must be application/json" })),
893 )
894 .into_response();
895 }
896 }
897 next.run(request).await
898}
899
900#[derive(Clone)]
904struct TokenGate {
905 authority: MutationAuthority,
906 read_authority: String,
907 require_read_token: bool,
908}
909
910#[derive(Clone)]
913struct HostGate {
914 bind_is_loopback: bool,
915}
916
917async fn require_mutation_token(
925 State(gate): State<TokenGate>,
926 request: Request,
927 next: Next,
928) -> Response {
929 let expected = gate.authority.as_str();
930 let path = request.uri().path();
931 let is_health = path == "/api/health";
932 let is_read = request.method() == Method::GET || request.method() == Method::HEAD;
933 let needs_auth =
934 !is_health && (request.method() == Method::POST || (gate.require_read_token && is_read));
935 if needs_auth {
936 let read_ok = |presented: &str| is_read && token_matches(presented, &gate.read_authority);
937 let header_ok = request
938 .headers()
939 .get(TOKEN_HEADER)
940 .and_then(|value| value.to_str().ok())
941 .is_some_and(|presented| token_matches(presented, expected) || read_ok(presented));
942 let query_ok = gate.require_read_token
943 && is_read
944 && request
945 .uri()
946 .query()
947 .map(|q| {
948 q.split('&').any(|pair| {
949 let mut parts = pair.splitn(2, '=');
950 matches!(parts.next(), Some("token"))
951 && parts.next().is_some_and(|v| {
952 let decoded = percent_decode_token(v);
953 read_ok(&decoded)
954 })
955 })
956 })
957 .unwrap_or(false);
958 if !header_ok && !query_ok {
959 return (
960 StatusCode::UNAUTHORIZED,
961 Json(json!({ "error": "missing or invalid token" })),
962 )
963 .into_response();
964 }
965 }
966 next.run(request).await
967}
968
969async fn read_token(State(gate): State<TokenGate>, request: Request) -> Response {
973 let valid = request
974 .headers()
975 .get(TOKEN_HEADER)
976 .and_then(|value| value.to_str().ok())
977 .is_some_and(|presented| {
978 token_matches(presented, gate.authority.as_str())
979 || token_matches(presented, &gate.read_authority)
980 });
981 if !valid {
982 return (
983 StatusCode::UNAUTHORIZED,
984 Json(json!({ "error": "missing or invalid token" })),
985 )
986 .into_response();
987 }
988 let value = gate.read_authority;
989 Json(json!({ "token": value })).into_response()
990}
991
992fn token_matches(presented: &str, expected: &str) -> bool {
998 use subtle::ConstantTimeEq;
999 presented.as_bytes().ct_eq(expected.as_bytes()).into()
1000}
1001
1002fn percent_decode_token(raw: &str) -> String {
1005 let bytes = raw.as_bytes();
1006 let mut out = Vec::with_capacity(bytes.len());
1007 let mut i = 0;
1008 while i < bytes.len() {
1009 if bytes[i] == b'%' && i + 2 < bytes.len() {
1010 if let (Some(hi), Some(lo)) = (
1011 (bytes[i + 1] as char).to_digit(16),
1012 (bytes[i + 2] as char).to_digit(16),
1013 ) {
1014 out.push((hi * 16 + lo) as u8);
1015 i += 3;
1016 continue;
1017 }
1018 }
1019 if bytes[i] == b'+' {
1020 out.push(b' ');
1021 } else {
1022 out.push(bytes[i]);
1023 }
1024 i += 1;
1025 }
1026 String::from_utf8_lossy(&out).into_owned()
1027}
1028
1029async fn root_info() -> &'static str {
1031 "kranz server is running (no dashboard bundle configured).\n\
1032 REST + WebSocket API under /api — see docs/protocol.md.\n"
1033}
1034
1035pub async fn serve(
1038 repo_root: PathBuf,
1039 port: u16,
1040 static_dir: Option<PathBuf>,
1041 authority: MutationAuthority,
1042) -> anyhow::Result<()> {
1043 serve_with_static(
1044 repo_root,
1045 port,
1046 static_dir.map(DashboardStatic::Dir),
1047 authority,
1048 )
1049 .await
1050}
1051
1052pub async fn serve_with_static(
1054 repo_root: PathBuf,
1055 port: u16,
1056 static_assets: Option<DashboardStatic>,
1057 authority: MutationAuthority,
1058) -> anyhow::Result<()> {
1059 serve_with_shared_host(
1060 Arc::new(MissionHost::new(repo_root)),
1061 IpAddr::V4(Ipv4Addr::LOCALHOST),
1062 port,
1063 static_assets,
1064 authority,
1065 )
1066 .await
1067}
1068
1069pub async fn serve_with_shared_host(
1076 host: Arc<MissionHost>,
1077 bind: IpAddr,
1078 port: u16,
1079 static_assets: Option<DashboardStatic>,
1080 authority: MutationAuthority,
1081) -> anyhow::Result<()> {
1082 let shutdown = async {
1083 if let Err(e) = tokio::signal::ctrl_c().await {
1084 tracing::error!(error = %e, "failed to install ctrl-c handler");
1085 }
1086 };
1087 serve_with_shutdown(host, bind, port, static_assets, authority, shutdown).await
1088}
1089
1090pub async fn serve_with_shutdown(
1094 host: Arc<MissionHost>,
1095 bind: IpAddr,
1096 port: u16,
1097 static_assets: Option<DashboardStatic>,
1098 authority: MutationAuthority,
1099 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1100) -> anyhow::Result<()> {
1101 let listener = bind_listener(bind, port).await?;
1102 serve_on_listener(host, listener, static_assets, authority, shutdown).await
1103}
1104
1105pub async fn bind_listener(bind: IpAddr, port: u16) -> anyhow::Result<tokio::net::TcpListener> {
1110 Ok(tokio::net::TcpListener::bind(SocketAddr::from((bind, port))).await?)
1111}
1112
1113pub async fn serve_on_listener(
1118 host: Arc<MissionHost>,
1119 listener: tokio::net::TcpListener,
1120 static_assets: Option<DashboardStatic>,
1121 authority: MutationAuthority,
1122 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1123) -> anyhow::Result<()> {
1124 serve_multi_on_listener(
1125 Arc::new(MultiRepoHost::with_host(host)),
1126 listener,
1127 static_assets,
1128 authority,
1129 None,
1130 false,
1131 shutdown,
1132 )
1133 .await
1134}
1135
1136pub async fn serve_multi_on_listener(
1143 multi_host: Arc<MultiRepoHost>,
1144 listener: tokio::net::TcpListener,
1145 static_assets: Option<DashboardStatic>,
1146 authority: MutationAuthority,
1147 read_authority: Option<String>,
1148 read_auth: bool,
1149 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1150) -> anyhow::Result<()> {
1151 let local_addr = listener.local_addr()?;
1152 let bind_is_loopback = local_addr.ip().is_loopback();
1153 let require_read_token = !bind_is_loopback || read_auth;
1154 let app = router_with_read_authority_and_addr(
1155 multi_host,
1156 static_assets,
1157 authority,
1158 read_authority,
1159 Some(local_addr),
1160 bind_is_loopback,
1161 require_read_token,
1162 );
1163 tracing::info!("kranz server listening on http://{local_addr}");
1164 axum::serve(listener, app)
1165 .with_graceful_shutdown(shutdown)
1166 .await?;
1167 Ok(())
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172 use super::{
1173 host_allowed, origin_allowed, router_with_multi_repo_host_and_addr, EmbeddedFile,
1174 HostConfig, MultiRepoHost, RepoConfig, RepoSlackConfig,
1175 };
1176 use axum::body::Body;
1177 use axum::http::{Request, StatusCode};
1178 use http_body_util::BodyExt;
1179 use kranz_engine::event_log::{EventLog, LockForce};
1180 use kranz_engine::events::EventKind;
1181 use kranz_engine::paths::MissionPaths;
1182 use kranz_engine::types::MissionConfig;
1183 use std::path::{Path, PathBuf};
1184 use std::sync::Arc;
1185 use std::time::Duration;
1186 use tower::ServiceExt;
1187
1188 fn authority() -> super::MutationAuthority {
1189 super::MutationAuthority::new("tok").unwrap()
1190 }
1191
1192 #[tokio::test]
1193 async fn registered_hook_suffix_posts_require_mutation_authority() {
1194 let temp = tempfile::tempdir().unwrap();
1195 let app = super::repo_api_routes(super::TokenGate {
1196 authority: authority(),
1197 read_authority: "dummy-read".into(),
1198 require_read_token: true,
1199 })
1200 .with_state(Arc::new(super::ServerState {
1201 repo_root: temp.path().into(),
1202 host: Arc::new(super::MissionHost::new(temp.path().into())),
1203 bind_addr: None,
1204 bind_is_loopback: true,
1205 }));
1206 for path in ["/future/hook-status", "/future/hooks/github"] {
1207 for (presented, expected) in [
1208 (None, StatusCode::UNAUTHORIZED),
1209 (Some("dummy-read"), StatusCode::UNAUTHORIZED),
1210 (Some("tok"), StatusCode::NO_CONTENT),
1211 ] {
1212 let mut request = Request::post(path);
1213 if let Some(value) = presented {
1214 request = request.header(super::TOKEN_HEADER, value);
1215 }
1216 let response = app
1217 .clone()
1218 .oneshot(request.body(Body::empty()).unwrap())
1219 .await
1220 .unwrap();
1221 assert_eq!(response.status(), expected, "registered route: {path}");
1222 }
1223 }
1224 }
1225
1226 fn seed_planning_mission(root: &Path, goal: &str) {
1227 std::fs::create_dir_all(root).unwrap();
1228 let status = std::process::Command::new("git")
1229 .args(["init", "-q"])
1230 .arg(root)
1231 .status()
1232 .unwrap();
1233 assert!(status.success());
1234 let paths = MissionPaths::new(root, "same-id");
1235 let mut log = EventLog::acquire(&paths, "same-id", Duration::ZERO, LockForce::No).unwrap();
1236 log.append(EventKind::MissionCreated {
1237 goal: goal.to_string(),
1238 base_branch: "main".to_string(),
1239 mission_branch: "kranz/mission-same-id".to_string(),
1240 config: MissionConfig::default(),
1241 })
1242 .unwrap();
1243 }
1244
1245 fn repo_config(id: &str, root: PathBuf) -> RepoConfig {
1246 RepoConfig {
1247 id: id.to_string(),
1248 root,
1249 display_name: None,
1250 group: None,
1251 pinned: false,
1252 slack: RepoSlackConfig::default(),
1253 }
1254 }
1255
1256 #[tokio::test]
1257 async fn unavailable_repo_routes_return_503_with_reason() {
1258 let temp = tempfile::tempdir().unwrap();
1259 let good = temp.path().join("good");
1260 seed_planning_mission(&good, "goal");
1261 let missing = temp.path().join("missing");
1262
1263 let multi = Arc::new(
1264 MultiRepoHost::from_config(HostConfig {
1265 default_repo: None,
1266 max_concurrent_repos: 1,
1267 repos: vec![repo_config("good", good), repo_config("gone", missing)],
1268 })
1269 .unwrap(),
1270 );
1271 let app = router_with_multi_repo_host_and_addr(multi, None, authority(), None, true, false);
1272
1273 for uri in ["/api/repos/gone", "/api/repos/gone/queue"] {
1276 let response = app
1277 .clone()
1278 .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1279 .await
1280 .unwrap();
1281 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{uri}");
1282 let body = response.into_body().collect().await.unwrap().to_bytes();
1283 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1284 assert_eq!(json["error"], "repository unavailable", "{uri}");
1285 assert_eq!(json["repoId"], "gone", "{uri}");
1286 assert!(json["detail"].as_str().unwrap().contains("does not exist"));
1287 }
1288
1289 let response = app
1292 .clone()
1293 .oneshot(
1294 Request::builder()
1295 .uri("/api/repos/nope/queue")
1296 .body(Body::empty())
1297 .unwrap(),
1298 )
1299 .await
1300 .unwrap();
1301 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1302
1303 let response = app
1305 .clone()
1306 .oneshot(
1307 Request::builder()
1308 .uri("/api/repos/good/missions/same-id/state")
1309 .body(Body::empty())
1310 .unwrap(),
1311 )
1312 .await
1313 .unwrap();
1314 assert_eq!(response.status(), StatusCode::OK);
1315 }
1316
1317 #[tokio::test]
1318 async fn unavailable_default_repo_reports_503_on_the_unscoped_alias() {
1319 let temp = tempfile::tempdir().unwrap();
1320 let good = temp.path().join("good");
1321 seed_planning_mission(&good, "goal");
1322 let missing = temp.path().join("missing");
1323
1324 let multi = Arc::new(
1325 MultiRepoHost::from_config(HostConfig {
1326 default_repo: Some("gone".to_string()),
1327 max_concurrent_repos: 1,
1328 repos: vec![repo_config("good", good), repo_config("gone", missing)],
1329 })
1330 .unwrap(),
1331 );
1332 let app = router_with_multi_repo_host_and_addr(multi, None, authority(), None, true, false);
1333
1334 let response = app
1337 .clone()
1338 .oneshot(
1339 Request::builder()
1340 .uri("/api/queue")
1341 .body(Body::empty())
1342 .unwrap(),
1343 )
1344 .await
1345 .unwrap();
1346 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1347 let body = response.into_body().collect().await.unwrap().to_bytes();
1348 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1349 assert_eq!(json["repoId"], "gone");
1350
1351 let response = app
1353 .clone()
1354 .oneshot(
1355 Request::builder()
1356 .uri("/api/health")
1357 .body(Body::empty())
1358 .unwrap(),
1359 )
1360 .await
1361 .unwrap();
1362 assert_eq!(response.status(), StatusCode::OK);
1363 let response = app
1364 .clone()
1365 .oneshot(
1366 Request::builder()
1367 .uri("/api/repos/good/missions/same-id/state")
1368 .body(Body::empty())
1369 .unwrap(),
1370 )
1371 .await
1372 .unwrap();
1373 assert_eq!(response.status(), StatusCode::OK);
1374 }
1375
1376 #[tokio::test]
1377 async fn repo_scoped_routes_isolate_duplicate_mission_ids_and_mutations() {
1378 let temp = tempfile::tempdir().unwrap();
1379 let a = temp.path().join("a");
1380 let b = temp.path().join("b");
1381 seed_planning_mission(&a, "goal-a");
1382 seed_planning_mission(&b, "goal-b");
1383
1384 let multi = Arc::new(
1385 MultiRepoHost::from_config(HostConfig {
1386 default_repo: None,
1387 max_concurrent_repos: 1,
1388 repos: vec![repo_config("a", a.clone()), repo_config("b", b.clone())],
1389 })
1390 .unwrap(),
1391 );
1392 static EMBEDDED: &[EmbeddedFile] = &[EmbeddedFile {
1393 path: "index.html",
1394 bytes: b"dashboard",
1395 content_type: "text/html",
1396 }];
1397 let app = router_with_multi_repo_host_and_addr(
1398 multi,
1399 Some(super::DashboardStatic::Embedded(EMBEDDED)),
1400 authority(),
1401 None,
1402 true,
1403 false,
1404 );
1405
1406 for (repo_id, expected_goal) in [("a", "goal-a"), ("b", "goal-b")] {
1407 let response = app
1408 .clone()
1409 .oneshot(
1410 Request::builder()
1411 .uri(format!("/api/repos/{repo_id}/missions/same-id/state"))
1412 .body(Body::empty())
1413 .unwrap(),
1414 )
1415 .await
1416 .unwrap();
1417 assert_eq!(response.status(), StatusCode::OK);
1418 let body = response.into_body().collect().await.unwrap().to_bytes();
1419 let state: serde_json::Value = serde_json::from_slice(&body).unwrap();
1420 assert_eq!(state["mission"]["goal"], expected_goal);
1421 }
1422
1423 let response = app
1424 .clone()
1425 .oneshot(
1426 Request::builder()
1427 .method("POST")
1428 .uri("/api/repos/a/missions/same-id/control")
1429 .header("content-type", "application/json")
1430 .header(super::TOKEN_HEADER, "tok")
1431 .body(Body::from(r#"{"kind":"pause"}"#))
1432 .unwrap(),
1433 )
1434 .await
1435 .unwrap();
1436 assert_eq!(response.status(), StatusCode::ACCEPTED);
1437 assert_eq!(
1438 std::fs::read_dir(MissionPaths::new(&a, "same-id").control_dir())
1439 .unwrap()
1440 .count(),
1441 1
1442 );
1443 assert_eq!(
1444 std::fs::read_dir(MissionPaths::new(&b, "same-id").control_dir())
1445 .unwrap()
1446 .count(),
1447 0
1448 );
1449
1450 let response = app
1453 .clone()
1454 .oneshot(
1455 Request::builder()
1456 .method("POST")
1457 .uri("/api/missions/same-id/control")
1458 .header("content-type", "application/json")
1459 .header(super::TOKEN_HEADER, "tok")
1460 .body(Body::from(r#"{"kind":"pause"}"#))
1461 .unwrap(),
1462 )
1463 .await
1464 .unwrap();
1465 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1466 assert_eq!(response.headers()["content-type"], "application/json");
1467
1468 let response = app
1469 .oneshot(Request::builder().uri("/api").body(Body::empty()).unwrap())
1470 .await
1471 .unwrap();
1472 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1473 assert_eq!(response.headers()["content-type"], "application/json");
1474 }
1475
1476 #[test]
1477 fn origin_allowlist_accepts_only_local_dev_and_tauri() {
1478 for allowed in [
1482 "http://localhost",
1483 "http://localhost:80",
1484 "http://localhost:5173",
1485 "http://127.0.0.1",
1486 "http://127.0.0.1:65535",
1487 "http://127.0.0.10:8080",
1488 "http://[::1]:5173",
1489 "tauri://localhost",
1490 "http://tauri.localhost",
1491 ] {
1492 assert!(origin_allowed(allowed, None), "should allow {allowed}");
1493 }
1494 for denied in [
1495 "https://evil.example",
1496 "http://localhost.evil.example",
1498 "http://localhost.evil.example:5173",
1499 "http://127.0.0.1.evil.example",
1500 "http://localhostx",
1501 "http://192.168.1.5:4560",
1504 "http://localhost:99999",
1506 "http://localhost:5173.evil.example",
1507 "https://localhost:5173",
1509 "https://tauri.localhost",
1510 "tauri://evil.example",
1511 "null",
1512 "",
1513 ] {
1514 assert!(!origin_allowed(denied, None), "should deny {denied}");
1515 }
1516 }
1517
1518 #[test]
1519 fn origin_allowlist_scopes_localhost_to_bind_and_dev_ports() {
1520 let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 1], 4560)));
1524 for allowed in [
1525 "http://localhost:4560",
1526 "http://127.0.0.1:4560",
1527 "http://localhost:5173",
1528 "http://127.0.0.1:5173",
1529 "http://localhost:1420",
1530 "tauri://localhost",
1531 "http://tauri.localhost",
1532 ] {
1533 assert!(
1534 origin_allowed(allowed, bind),
1535 "should allow {allowed} for bind 127.0.0.1:4560"
1536 );
1537 }
1538 for denied in [
1539 "http://localhost:8080",
1540 "http://127.0.0.1:8080",
1541 "http://localhost", "http://127.0.0.1",
1543 "http://127.0.0.2:4560",
1547 "http://127.0.0.10:4560",
1548 "http://127.0.0.2:5173",
1549 "http://127.0.0.10:1420",
1550 "http://[::1]:4560",
1551 "http://localhost.evil.example:4560",
1552 "https://localhost:4560",
1553 "https://evil.example",
1554 ] {
1555 assert!(
1556 !origin_allowed(denied, bind),
1557 "should deny {denied} for bind 127.0.0.1:4560"
1558 );
1559 }
1560 assert!(origin_allowed(
1562 "http://localhost",
1563 Some(std::net::SocketAddr::from(([127, 0, 0, 1], 80)))
1564 ));
1565 }
1566
1567 #[test]
1568 fn origin_allowlist_follows_the_actual_bound_ip() {
1569 let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 2], 4560)));
1572 assert!(origin_allowed("http://127.0.0.2:4560", bind));
1573 assert!(!origin_allowed("http://127.0.0.1:4560", bind));
1574 assert!(!origin_allowed("http://localhost:4560", bind));
1575 assert!(origin_allowed("http://localhost:5173", bind));
1577
1578 let bind_v6 = Some(std::net::SocketAddr::from((
1580 std::net::Ipv6Addr::LOCALHOST,
1581 4560,
1582 )));
1583 assert!(origin_allowed("http://[::1]:4560", bind_v6));
1584 assert!(origin_allowed("http://localhost:4560", bind_v6));
1585 assert!(!origin_allowed("http://127.0.0.2:4560", bind_v6));
1586
1587 let bind_any = Some(std::net::SocketAddr::from(([0, 0, 0, 0], 4560)));
1590 assert!(origin_allowed("http://127.0.0.1:4560", bind_any));
1591 assert!(origin_allowed("http://127.0.0.5:4560", bind_any));
1592 assert!(origin_allowed("http://localhost:4560", bind_any));
1593 assert!(!origin_allowed("http://localhost:8080", bind_any));
1594 }
1595
1596 #[test]
1597 fn ws_origin_loopback_keeps_strict_browser_allowlist() {
1598 use super::ws_origin_allowed;
1599 let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 1], 4560)));
1600 assert!(ws_origin_allowed(Some("http://localhost:4560"), bind, true));
1601 assert!(ws_origin_allowed(Some("http://localhost:5173"), bind, true));
1602 assert!(
1603 !ws_origin_allowed(None, bind, true),
1604 "missing Origin stays rejected on loopback (reads are tokenless)"
1605 );
1606 assert!(!ws_origin_allowed(
1607 Some("http://192.168.1.5:4560"),
1608 bind,
1609 true
1610 ));
1611 assert!(
1612 !ws_origin_allowed(Some("http://127.0.0.2:4560"), bind, true),
1613 "co-resident loopback listener page must not open the tokenless WS"
1614 );
1615 assert!(
1616 !ws_origin_allowed(Some("http://127.0.0.2:5173"), bind, true),
1617 "a dev port must not privilege another independently bindable loopback IP"
1618 );
1619 assert!(!ws_origin_allowed(Some("http://evil.example"), bind, true));
1620 }
1621
1622 #[test]
1623 fn ws_origin_lan_accepts_ip_literals_and_native_clients() {
1624 use super::ws_origin_allowed;
1625 let bind = Some(std::net::SocketAddr::from(([0, 0, 0, 0], 4560)));
1626 assert!(ws_origin_allowed(
1629 Some("http://192.168.1.5:4560"),
1630 bind,
1631 false
1632 ));
1633 assert!(ws_origin_allowed(
1634 Some("http://[fd00::5]:4560"),
1635 bind,
1636 false
1637 ));
1638 assert!(ws_origin_allowed(
1639 Some("http://localhost:5173"),
1640 bind,
1641 false
1642 ));
1643 assert!(ws_origin_allowed(None, bind, false));
1644 for denied in [
1646 "http://evil.example:4560",
1647 "http://192.168.1.5.evil.example:4560",
1648 "https://192.168.1.5:4560",
1649 "http://[::1:4560",
1650 "null",
1651 "",
1652 ] {
1653 assert!(
1654 !ws_origin_allowed(Some(denied), bind, false),
1655 "should deny {denied} off loopback"
1656 );
1657 }
1658 }
1659
1660 #[test]
1661 fn host_allowlist_loopback_rejects_lan_and_dns() {
1662 for allowed in [
1663 "localhost",
1664 "localhost:4560",
1665 "LOCALHOST:5173",
1666 "127.0.0.1",
1667 "127.0.0.1:65535",
1668 "127.0.0.2:4560",
1670 "::1",
1671 "[::1]",
1672 "[::1]:4560",
1673 ] {
1674 assert!(
1675 host_allowed(allowed, true),
1676 "loopback bind should allow {allowed}"
1677 );
1678 }
1679 for denied in [
1680 "evil.example",
1681 "evil.example:4560",
1682 "localhost.evil.example",
1683 "192.168.1.10",
1684 "192.168.1.10:4560",
1685 "10.0.0.1:8080",
1686 "::1:4560",
1688 "[::1",
1690 "",
1691 ] {
1692 assert!(
1693 !host_allowed(denied, true),
1694 "loopback bind should deny {denied}"
1695 );
1696 }
1697 }
1698
1699 #[test]
1700 fn host_allowlist_lan_accepts_ip_hosts() {
1701 for allowed in [
1702 "192.168.1.10",
1703 "192.168.1.10:4560",
1704 "10.0.0.1:8080",
1705 "localhost",
1706 "127.0.0.1:4560",
1707 "[::1]:4560",
1708 ] {
1709 assert!(
1710 host_allowed(allowed, false),
1711 "LAN bind should allow {allowed}"
1712 );
1713 }
1714 for denied in [
1715 "evil.example",
1716 "evil.example:4560",
1717 "localhost.evil.example",
1718 "",
1719 ] {
1720 assert!(
1721 !host_allowed(denied, false),
1722 "LAN bind should still deny DNS Host {denied}"
1723 );
1724 }
1725 }
1726}