#![allow(rustdoc::private_intra_doc_links)]
mod error;
mod hooks;
mod host;
mod multi;
mod read_work;
mod rest;
mod tickets;
mod ws;
pub use error::{ApiError, ApiErrorCode};
pub use host::{MissionHost, PendingApproval};
pub use multi::{
load_host_config, HostConfig, MultiRepoHost, RepoActivity, RepoConfig, RepoContext,
RepoSlackConfig, RepoSummary, SlackChannelRoute,
};
use axum::body::{Body, HttpBody};
use axum::extract::{Request, State};
use axum::http::{header, HeaderName, HeaderValue, Method, StatusCode, Uri};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{any, get, post};
use axum::{Json, Router};
use serde_json::json;
use std::fmt;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
pub const TOKEN_HEADER: &str = "x-kranz-token";
#[derive(Clone, PartialEq, Eq)]
pub struct MutationAuthority(String);
impl MutationAuthority {
pub fn new(token: impl Into<String>) -> Result<Self, InvalidMutationAuthority> {
let value = token.into();
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_graphic()) {
return Err(InvalidMutationAuthority);
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Debug for MutationAuthority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("MutationAuthority([REDACTED])")
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct InvalidMutationAuthority;
impl fmt::Display for InvalidMutationAuthority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("mutation authority must be non-empty visible ASCII without whitespace")
}
}
impl std::error::Error for InvalidMutationAuthority {}
pub fn generate_token() -> String {
uuid::Uuid::new_v4().simple().to_string()
}
#[derive(Clone, Copy, Debug)]
pub struct EmbeddedFile {
pub path: &'static str,
pub bytes: &'static [u8],
pub content_type: &'static str,
}
pub enum DashboardStatic {
Dir(PathBuf),
Embedded(&'static [EmbeddedFile]),
}
pub struct ServerState {
pub repo_root: PathBuf,
pub host: Arc<MissionHost>,
pub bind_addr: Option<SocketAddr>,
pub bind_is_loopback: bool,
}
pub fn router(repo_root: PathBuf, static_dir: Option<PathBuf>) -> Router {
router_with_static(repo_root, static_dir.map(DashboardStatic::Dir))
}
pub fn router_with_static(repo_root: PathBuf, static_assets: Option<DashboardStatic>) -> Router {
let authority = MutationAuthority::new(generate_token())
.expect("generated UUID mutation authority is valid");
router_with_token(repo_root, static_assets, authority)
}
pub fn router_with_token(
repo_root: PathBuf,
static_assets: Option<DashboardStatic>,
authority: MutationAuthority,
) -> Router {
router_with_host(MissionHost::new(repo_root), static_assets, authority)
}
pub fn router_with_host(
host: MissionHost,
static_assets: Option<DashboardStatic>,
authority: MutationAuthority,
) -> Router {
router_with_shared_host(Arc::new(host), static_assets, authority)
}
pub fn router_with_shared_host(
host: Arc<MissionHost>,
static_assets: Option<DashboardStatic>,
authority: MutationAuthority,
) -> Router {
router_with_shared_host_and_bind(host, static_assets, authority, None, true, false)
}
pub fn router_with_shared_host_and_bind(
host: Arc<MissionHost>,
static_assets: Option<DashboardStatic>,
authority: MutationAuthority,
bind_port: Option<u16>,
bind_is_loopback: bool,
require_read_token: bool,
) -> Router {
router_with_shared_host_and_addr(
host,
static_assets,
authority,
bind_port.map(|port| SocketAddr::from((Ipv4Addr::LOCALHOST, port))),
bind_is_loopback,
require_read_token,
)
}
pub fn router_with_shared_host_and_addr(
host: Arc<MissionHost>,
static_assets: Option<DashboardStatic>,
authority: MutationAuthority,
bind_addr: Option<SocketAddr>,
bind_is_loopback: bool,
require_read_token: bool,
) -> Router {
router_with_multi_repo_host_and_addr(
Arc::new(MultiRepoHost::with_host(host)),
static_assets,
authority,
bind_addr,
bind_is_loopback,
require_read_token,
)
}
pub fn router_with_multi_repo_host_and_addr(
multi_host: Arc<MultiRepoHost>,
static_assets: Option<DashboardStatic>,
authority: MutationAuthority,
bind_addr: Option<SocketAddr>,
bind_is_loopback: bool,
require_read_token: bool,
) -> Router {
router_with_read_authority_and_addr(
multi_host,
static_assets,
authority,
None,
bind_addr,
bind_is_loopback,
require_read_token,
)
}
pub fn router_with_read_authority_and_addr(
multi_host: Arc<MultiRepoHost>,
static_assets: Option<DashboardStatic>,
authority: MutationAuthority,
read_authority: Option<String>,
bind_addr: Option<SocketAddr>,
bind_is_loopback: bool,
require_read_token: bool,
) -> Router {
let gate = TokenGate {
read_authority: read_authority
.filter(|read| !read.is_empty() && !token_matches(read, authority.as_str()))
.unwrap_or_else(generate_token),
authority,
require_read_token,
};
let exchange_gate = gate.clone();
let mut repos = Router::new();
let catalog = Arc::clone(&multi_host);
let catalog_reads = read_work::ReadWork::default();
let mut app = Router::new()
.route("/api/health", get(rest::health))
.route(
"/api/repos",
get(move || {
let catalog = Arc::clone(&catalog);
let reads = catalog_reads.clone();
async move { reads.run(move || Ok(Json(catalog.summaries()))).await }
}),
)
.route("/api/read-token", get(read_token).with_state(exchange_gate));
let mut repo_reads = std::collections::HashMap::new();
for context in multi_host.contexts() {
let prefix = format!("/api/repos/{}", context.id());
match context.host().cloned() {
Some(host) => {
let reads = read_work::ReadWork::default();
repo_reads.insert(context.id().to_string(), reads.clone());
repos = repos.nest(
&prefix,
repo_context_router(
context,
host,
bind_addr,
bind_is_loopback,
reads,
gate.clone(),
),
);
}
None => {
let handler = repo_unavailable_handler(&context);
app = app
.route(&prefix, any(handler.clone()))
.route(&format!("{prefix}/{{*path}}"), any(handler));
}
}
}
let mut unavailable_default = None;
if let Some(context) = multi_host.compatibility_context() {
match context.host().cloned() {
Some(host) => {
let reads = repo_reads
.entry(context.id().to_string())
.or_default()
.clone();
repos = repos.nest(
"/api",
repo_context_router(
context,
host,
bind_addr,
bind_is_loopback,
reads,
gate.clone(),
),
);
}
None => unavailable_default = Some(repo_unavailable_handler(&context)),
}
}
app = match unavailable_default {
Some(handler) => app
.route("/api", any(handler.clone()))
.route("/api/{*path}", any(handler)),
None => app
.route("/api", any(api_not_found))
.route("/api/{*path}", any(api_not_found)),
};
let app = app
.layer(middleware::from_fn_with_state(gate, require_mutation_token))
.merge(repos);
let app = match static_assets {
Some(DashboardStatic::Dir(dir)) => {
let index = dir.join("index.html");
app.fallback_service(ServeDir::new(&dir).fallback(ServeFile::new(index)))
}
Some(DashboardStatic::Embedded(files)) => {
app.fallback(move |uri: Uri| async move { embedded_static_response(uri, files) })
}
None => app.route("/", get(root_info)),
};
app.layer(middleware::from_fn(require_json_api_posts))
.layer(middleware::from_fn_with_state(
HostGate { bind_is_loopback },
require_host,
))
.layer(cors_layer(bind_addr))
.layer(middleware::from_fn(cache_response_headers))
}
async fn api_not_found() -> impl IntoResponse {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "API route not found or repository scope required" })),
)
}
async fn cache_response_headers(request: Request, next: Next) -> Response {
let is_api = request.uri().path().starts_with("/api");
let mut response = next.run(request).await;
let cache_control = if is_api {
Some("no-store")
} else if response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|content_type| content_type.starts_with("text/html"))
{
Some("no-cache")
} else {
None
};
if let Some(value) = cache_control {
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
}
response
}
fn repo_unavailable_handler(
context: &RepoContext,
) -> impl Fn() -> std::future::Ready<(StatusCode, Json<serde_json::Value>)> + Clone {
let id = context.id().to_string();
let reason = context
.unavailable_reason()
.unwrap_or("repository is unavailable")
.to_string();
move || {
std::future::ready((
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"error": "repository unavailable",
"repoId": id.clone(),
"detail": reason.clone(),
})),
))
}
}
fn repo_context_router(
context: Arc<RepoContext>,
host: Arc<MissionHost>,
bind_addr: Option<SocketAddr>,
bind_is_loopback: bool,
reads: read_work::ReadWork,
gate: TokenGate,
) -> Router {
let state = Arc::new(ServerState {
repo_root: context.root().to_path_buf(),
host,
bind_addr,
bind_is_loopback,
});
repo_api_routes(gate)
.layer(axum::Extension(reads))
.with_state(state)
}
fn repo_api_routes(gate: TokenGate) -> Router<Arc<ServerState>> {
protected_repo_api_routes()
.route_layer(middleware::from_fn_with_state(gate, require_mutation_token))
.merge(independently_authenticated_hook_routes())
}
fn protected_repo_api_routes() -> Router<Arc<ServerState>> {
let routes = Router::new()
.route(
"/missions",
get(rest::list_missions).post(host::create_mission),
)
.route("/missions/outcomes", get(rest::mission_outcomes))
.route("/escalation-metrics", get(rest::escalation_metrics))
.route("/standards-metrics", get(rest::standards_metrics))
.route("/cost-per-merged-change", get(rest::cost_per_merged_change))
.route("/missions/{id}/state", get(rest::mission_state))
.route("/missions/{id}/standards", get(rest::mission_standards))
.route(
"/missions/{id}/standards/waiver",
post(rest::post_standards_waiver),
)
.route("/missions/{id}/workspace", get(rest::mission_workspace))
.route("/missions/{id}/events", get(rest::mission_events))
.route("/missions/{id}/plan", get(rest::mission_plan))
.route("/missions/{id}/plan.md", get(rest::mission_plan_md))
.route(
"/missions/{id}/revision-diff",
get(rest::mission_revision_diff),
)
.route("/missions/{id}/report.md", get(rest::mission_report_md))
.route("/missions/{id}/diff-stat", get(rest::mission_diff_stat))
.route("/missions/{id}/pr-handoff", get(rest::mission_pr_handoff))
.route(
"/missions/{id}/pr-handoff/create",
post(rest::mission_pr_create),
)
.route("/missions/{id}/readiness", get(rest::mission_readiness))
.route(
"/missions/{id}/runs/{run_id}/transcript",
get(rest::run_transcript),
)
.route("/missions/{id}/hook-status", get(rest::mission_hook_status))
.route("/missions/{id}/control", post(rest::post_control))
.route("/missions/{id}/revise", post(rest::post_revise))
.route(
"/missions/{id}/revision/approve",
post(rest::post_revision_approve),
)
.route(
"/missions/{id}/revision/reject",
post(rest::post_revision_reject),
)
.route(
"/missions/{id}/grant/approve",
post(rest::post_grant_approve),
)
.route("/missions/{id}/grant/deny", post(rest::post_grant_deny))
.route(
"/missions/{id}/question/answer",
post(rest::post_question_answer),
)
.route("/missions/{id}/planning/turn", post(host::planning_turn))
.route(
"/missions/{id}/planning/request-plan",
post(host::request_plan),
)
.route("/missions/{id}/approve", post(host::approve_mission))
.route("/missions/{id}/start", post(host::start_mission))
.route("/missions/{id}/pending-plan", get(host::pending_plan_route))
.route(
"/missions/{id}/approve-pending",
post(host::approve_pending_route),
)
.route("/missions/{id}/abandon", post(host::abandon_mission_route))
.route("/missions/{id}/release", post(host::release_mission_route))
.route("/missions/{id}/delete", post(host::delete_mission_route))
.route("/missions/{id}/merge", post(host::merge_mission_route))
.route("/missions/{id}/ws", get(ws::ws_handler))
.route(
"/tickets",
get(tickets::list_tickets).post(tickets::create_ticket),
)
.route("/tickets/{slug}", get(tickets::get_ticket))
.route("/tickets/{slug}/draft", post(tickets::draft_ticket))
.route("/tickets/{slug}/approve", post(tickets::approve_ticket))
.route("/queue", get(host::queue_state_route))
.route("/queue/drain", post(host::drain_queue_route));
#[cfg(test)]
let routes = routes
.route(
"/future/hook-status",
post(|| async { StatusCode::NO_CONTENT }),
)
.route(
"/future/hooks/github",
post(|| async { StatusCode::NO_CONTENT }),
);
routes
}
fn independently_authenticated_hook_routes() -> Router<Arc<ServerState>> {
Router::new()
.route("/hooks/github", post(hooks::github_hook))
.route(
"/hook-status",
post(rest::post_hook_status).route_layer(axum::extract::DefaultBodyLimit::max(
kranz_engine::hook_status::SIGNAL_BODY_MAX_BYTES,
)),
)
}
fn embedded_static_response(uri: Uri, files: &'static [EmbeddedFile]) -> Response {
let requested = uri.path().trim_start_matches('/');
let requested = if requested.is_empty() {
"index.html"
} else {
requested
};
let file = files
.iter()
.find(|file| file.path == requested)
.or_else(|| files.iter().find(|file| file.path == "index.html"));
let Some(file) = file else {
return StatusCode::NOT_FOUND.into_response();
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, file.content_type)
.body(Body::from(file.bytes))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
fn cors_layer(bind_addr: Option<SocketAddr>) -> CorsLayer {
CorsLayer::new()
.allow_origin(AllowOrigin::predicate(
move |origin: &HeaderValue, _request_parts| {
origin.to_str().is_ok_and(|o| origin_allowed(o, bind_addr))
},
))
.allow_methods([Method::GET, Method::POST])
.allow_headers([header::CONTENT_TYPE, HeaderName::from_static(TOKEN_HEADER)])
}
pub(crate) fn origin_allowed(origin: &str, bind_addr: Option<SocketAddr>) -> bool {
if origin == "tauri://localhost" || origin == "http://tauri.localhost" {
return true;
}
const DEV_PORTS: [u16; 2] = [5173, 1420];
let Some(authority) = origin.strip_prefix("http://") else {
return false;
};
let Some((host, port)) = split_host_port(authority) else {
return false;
};
let host_ip = host.parse::<std::net::IpAddr>().ok();
let host_local = host == "localhost" || host_ip.is_some_and(|ip| ip.is_loopback());
if !host_local {
return false;
}
let Some(bind) = bind_addr else {
return true; };
if DEV_PORTS.contains(&port) {
return host == "localhost"
|| host_ip == Some(std::net::IpAddr::V4(Ipv4Addr::LOCALHOST))
|| host_ip == Some(std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST));
}
if port != bind.port() {
return false;
}
match host_ip {
Some(ip) => ip == bind.ip() || (bind.ip().is_unspecified() && ip.is_loopback()),
None => {
bind.ip().is_unspecified()
|| bind.ip() == std::net::IpAddr::V4(Ipv4Addr::LOCALHOST)
|| bind.ip() == std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)
}
}
}
fn split_host_port(authority: &str) -> Option<(&str, u16)> {
if let Some(rest) = authority.strip_prefix('[') {
let (addr, tail) = rest.split_once(']')?;
let port = if tail.is_empty() {
80
} else {
tail.strip_prefix(':')?.parse().ok()?
};
return Some((addr, port));
}
match authority.rsplit_once(':') {
Some((host, _)) if host.contains(':') => Some((authority, 80)),
Some((host, port)) => Some((host, port.parse().ok()?)),
None => Some((authority, 80)),
}
}
pub(crate) fn ws_origin_allowed(
origin: Option<&str>,
bind_addr: Option<SocketAddr>,
bind_is_loopback: bool,
) -> bool {
match origin {
None => !bind_is_loopback,
Some(origin) => {
origin_allowed(origin, bind_addr) || (!bind_is_loopback && origin_host_is_ip(origin))
}
}
}
fn origin_host_is_ip(origin: &str) -> bool {
origin
.strip_prefix("http://")
.and_then(split_host_port)
.is_some_and(|(host, _)| host.parse::<std::net::IpAddr>().is_ok())
}
async fn require_host(State(gate): State<HostGate>, request: Request, next: Next) -> Response {
if let Some(host) = request.headers().get(header::HOST) {
if !host
.to_str()
.is_ok_and(|h| host_allowed(h, gate.bind_is_loopback))
{
return (
StatusCode::FORBIDDEN,
Json(json!({ "error": "invalid host" })),
)
.into_response();
}
}
next.run(request).await
}
fn host_allowed(host: &str, bind_is_loopback: bool) -> bool {
let host = host.trim().to_ascii_lowercase();
if host_is_loopback(&host) {
return true;
}
if bind_is_loopback {
return false;
}
host_ip(&host).is_some()
}
fn host_is_loopback(host: &str) -> bool {
if host == "localhost" {
return true;
}
if let Some(port) = host.strip_prefix("localhost:") {
return port.parse::<u16>().is_ok();
}
host_ip(host).is_some_and(|ip| ip.is_loopback())
}
fn host_ip(host: &str) -> Option<std::net::IpAddr> {
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return Some(ip);
}
if let Some(rest) = host.strip_prefix('[') {
let (addr, tail) = rest.split_once(']')?;
if !(tail.is_empty()
|| tail
.strip_prefix(':')
.is_some_and(|p| p.parse::<u16>().is_ok()))
{
return None;
}
return addr.parse().ok();
}
let (addr, port) = host.rsplit_once(':')?;
if port.parse::<u16>().is_err() {
return None;
}
addr.parse().ok()
}
async fn require_json_api_posts(request: Request, next: Next) -> Response {
if request.method() == Method::POST && request.uri().path().starts_with("/api/") {
let is_empty_body = request.body().is_end_stream();
let is_json = request
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(';').next())
.is_some_and(|mime| mime.trim().eq_ignore_ascii_case("application/json"));
if !is_empty_body && !is_json {
return (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
Json(json!({ "error": "POST bodies must be application/json" })),
)
.into_response();
}
}
next.run(request).await
}
#[derive(Clone)]
struct TokenGate {
authority: MutationAuthority,
read_authority: String,
require_read_token: bool,
}
#[derive(Clone)]
struct HostGate {
bind_is_loopback: bool,
}
async fn require_mutation_token(
State(gate): State<TokenGate>,
request: Request,
next: Next,
) -> Response {
let expected = gate.authority.as_str();
let path = request.uri().path();
let is_health = path == "/api/health";
let is_read = request.method() == Method::GET || request.method() == Method::HEAD;
let needs_auth =
!is_health && (request.method() == Method::POST || (gate.require_read_token && is_read));
if needs_auth {
let read_ok = |presented: &str| is_read && token_matches(presented, &gate.read_authority);
let header_ok = request
.headers()
.get(TOKEN_HEADER)
.and_then(|value| value.to_str().ok())
.is_some_and(|presented| token_matches(presented, expected) || read_ok(presented));
let query_ok = gate.require_read_token
&& is_read
&& request
.uri()
.query()
.map(|q| {
q.split('&').any(|pair| {
let mut parts = pair.splitn(2, '=');
matches!(parts.next(), Some("token"))
&& parts.next().is_some_and(|v| {
let decoded = percent_decode_token(v);
read_ok(&decoded)
})
})
})
.unwrap_or(false);
if !header_ok && !query_ok {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "missing or invalid token" })),
)
.into_response();
}
}
next.run(request).await
}
async fn read_token(State(gate): State<TokenGate>, request: Request) -> Response {
let valid = request
.headers()
.get(TOKEN_HEADER)
.and_then(|value| value.to_str().ok())
.is_some_and(|presented| {
token_matches(presented, gate.authority.as_str())
|| token_matches(presented, &gate.read_authority)
});
if !valid {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "missing or invalid token" })),
)
.into_response();
}
let value = gate.read_authority;
Json(json!({ "token": value })).into_response()
}
fn token_matches(presented: &str, expected: &str) -> bool {
use subtle::ConstantTimeEq;
presented.as_bytes().ct_eq(expected.as_bytes()).into()
}
fn percent_decode_token(raw: &str) -> String {
let bytes = raw.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (
(bytes[i + 1] as char).to_digit(16),
(bytes[i + 2] as char).to_digit(16),
) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
if bytes[i] == b'+' {
out.push(b' ');
} else {
out.push(bytes[i]);
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
async fn root_info() -> &'static str {
"kranz server is running (no dashboard bundle configured).\n\
REST + WebSocket API under /api — see docs/protocol.md.\n"
}
pub async fn serve(
repo_root: PathBuf,
port: u16,
static_dir: Option<PathBuf>,
authority: MutationAuthority,
) -> anyhow::Result<()> {
serve_with_static(
repo_root,
port,
static_dir.map(DashboardStatic::Dir),
authority,
)
.await
}
pub async fn serve_with_static(
repo_root: PathBuf,
port: u16,
static_assets: Option<DashboardStatic>,
authority: MutationAuthority,
) -> anyhow::Result<()> {
serve_with_shared_host(
Arc::new(MissionHost::new(repo_root)),
IpAddr::V4(Ipv4Addr::LOCALHOST),
port,
static_assets,
authority,
)
.await
}
pub async fn serve_with_shared_host(
host: Arc<MissionHost>,
bind: IpAddr,
port: u16,
static_assets: Option<DashboardStatic>,
authority: MutationAuthority,
) -> anyhow::Result<()> {
let shutdown = async {
if let Err(e) = tokio::signal::ctrl_c().await {
tracing::error!(error = %e, "failed to install ctrl-c handler");
}
};
serve_with_shutdown(host, bind, port, static_assets, authority, shutdown).await
}
pub async fn serve_with_shutdown(
host: Arc<MissionHost>,
bind: IpAddr,
port: u16,
static_assets: Option<DashboardStatic>,
authority: MutationAuthority,
shutdown: impl std::future::Future<Output = ()> + Send + 'static,
) -> anyhow::Result<()> {
let listener = bind_listener(bind, port).await?;
serve_on_listener(host, listener, static_assets, authority, shutdown).await
}
pub async fn bind_listener(bind: IpAddr, port: u16) -> anyhow::Result<tokio::net::TcpListener> {
Ok(tokio::net::TcpListener::bind(SocketAddr::from((bind, port))).await?)
}
pub async fn serve_on_listener(
host: Arc<MissionHost>,
listener: tokio::net::TcpListener,
static_assets: Option<DashboardStatic>,
authority: MutationAuthority,
shutdown: impl std::future::Future<Output = ()> + Send + 'static,
) -> anyhow::Result<()> {
serve_multi_on_listener(
Arc::new(MultiRepoHost::with_host(host)),
listener,
static_assets,
authority,
None,
false,
shutdown,
)
.await
}
pub async fn serve_multi_on_listener(
multi_host: Arc<MultiRepoHost>,
listener: tokio::net::TcpListener,
static_assets: Option<DashboardStatic>,
authority: MutationAuthority,
read_authority: Option<String>,
read_auth: bool,
shutdown: impl std::future::Future<Output = ()> + Send + 'static,
) -> anyhow::Result<()> {
let local_addr = listener.local_addr()?;
let bind_is_loopback = local_addr.ip().is_loopback();
let require_read_token = !bind_is_loopback || read_auth;
let app = router_with_read_authority_and_addr(
multi_host,
static_assets,
authority,
read_authority,
Some(local_addr),
bind_is_loopback,
require_read_token,
);
tracing::info!("kranz server listening on http://{local_addr}");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
host_allowed, origin_allowed, router_with_multi_repo_host_and_addr, EmbeddedFile,
HostConfig, MultiRepoHost, RepoConfig, RepoSlackConfig,
};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use kranz_engine::event_log::{EventLog, LockForce};
use kranz_engine::events::EventKind;
use kranz_engine::paths::MissionPaths;
use kranz_engine::types::MissionConfig;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tower::ServiceExt;
fn authority() -> super::MutationAuthority {
super::MutationAuthority::new("tok").unwrap()
}
#[tokio::test]
async fn registered_hook_suffix_posts_require_mutation_authority() {
let temp = tempfile::tempdir().unwrap();
let app = super::repo_api_routes(super::TokenGate {
authority: authority(),
read_authority: "dummy-read".into(),
require_read_token: true,
})
.with_state(Arc::new(super::ServerState {
repo_root: temp.path().into(),
host: Arc::new(super::MissionHost::new(temp.path().into())),
bind_addr: None,
bind_is_loopback: true,
}));
for path in ["/future/hook-status", "/future/hooks/github"] {
for (presented, expected) in [
(None, StatusCode::UNAUTHORIZED),
(Some("dummy-read"), StatusCode::UNAUTHORIZED),
(Some("tok"), StatusCode::NO_CONTENT),
] {
let mut request = Request::post(path);
if let Some(value) = presented {
request = request.header(super::TOKEN_HEADER, value);
}
let response = app
.clone()
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), expected, "registered route: {path}");
}
}
}
fn seed_planning_mission(root: &Path, goal: &str) {
std::fs::create_dir_all(root).unwrap();
let status = std::process::Command::new("git")
.args(["init", "-q"])
.arg(root)
.status()
.unwrap();
assert!(status.success());
let paths = MissionPaths::new(root, "same-id");
let mut log = EventLog::acquire(&paths, "same-id", Duration::ZERO, LockForce::No).unwrap();
log.append(EventKind::MissionCreated {
goal: goal.to_string(),
base_branch: "main".to_string(),
mission_branch: "kranz/mission-same-id".to_string(),
config: MissionConfig::default(),
})
.unwrap();
}
fn repo_config(id: &str, root: PathBuf) -> RepoConfig {
RepoConfig {
id: id.to_string(),
root,
display_name: None,
group: None,
pinned: false,
slack: RepoSlackConfig::default(),
}
}
#[tokio::test]
async fn unavailable_repo_routes_return_503_with_reason() {
let temp = tempfile::tempdir().unwrap();
let good = temp.path().join("good");
seed_planning_mission(&good, "goal");
let missing = temp.path().join("missing");
let multi = Arc::new(
MultiRepoHost::from_config(HostConfig {
default_repo: None,
max_concurrent_repos: 1,
repos: vec![repo_config("good", good), repo_config("gone", missing)],
})
.unwrap(),
);
let app = router_with_multi_repo_host_and_addr(multi, None, authority(), None, true, false);
for uri in ["/api/repos/gone", "/api/repos/gone/queue"] {
let response = app
.clone()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{uri}");
let body = response.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"], "repository unavailable", "{uri}");
assert_eq!(json["repoId"], "gone", "{uri}");
assert!(json["detail"].as_str().unwrap().contains("does not exist"));
}
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/repos/nope/queue")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/repos/good/missions/same-id/state")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn unavailable_default_repo_reports_503_on_the_unscoped_alias() {
let temp = tempfile::tempdir().unwrap();
let good = temp.path().join("good");
seed_planning_mission(&good, "goal");
let missing = temp.path().join("missing");
let multi = Arc::new(
MultiRepoHost::from_config(HostConfig {
default_repo: Some("gone".to_string()),
max_concurrent_repos: 1,
repos: vec![repo_config("good", good), repo_config("gone", missing)],
})
.unwrap(),
);
let app = router_with_multi_repo_host_and_addr(multi, None, authority(), None, true, false);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/queue")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["repoId"], "gone");
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/repos/good/missions/same-id/state")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn repo_scoped_routes_isolate_duplicate_mission_ids_and_mutations() {
let temp = tempfile::tempdir().unwrap();
let a = temp.path().join("a");
let b = temp.path().join("b");
seed_planning_mission(&a, "goal-a");
seed_planning_mission(&b, "goal-b");
let multi = Arc::new(
MultiRepoHost::from_config(HostConfig {
default_repo: None,
max_concurrent_repos: 1,
repos: vec![repo_config("a", a.clone()), repo_config("b", b.clone())],
})
.unwrap(),
);
static EMBEDDED: &[EmbeddedFile] = &[EmbeddedFile {
path: "index.html",
bytes: b"dashboard",
content_type: "text/html",
}];
let app = router_with_multi_repo_host_and_addr(
multi,
Some(super::DashboardStatic::Embedded(EMBEDDED)),
authority(),
None,
true,
false,
);
for (repo_id, expected_goal) in [("a", "goal-a"), ("b", "goal-b")] {
let response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/repos/{repo_id}/missions/same-id/state"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let state: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(state["mission"]["goal"], expected_goal);
}
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/repos/a/missions/same-id/control")
.header("content-type", "application/json")
.header(super::TOKEN_HEADER, "tok")
.body(Body::from(r#"{"kind":"pause"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
assert_eq!(
std::fs::read_dir(MissionPaths::new(&a, "same-id").control_dir())
.unwrap()
.count(),
1
);
assert_eq!(
std::fs::read_dir(MissionPaths::new(&b, "same-id").control_dir())
.unwrap()
.count(),
0
);
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/missions/same-id/control")
.header("content-type", "application/json")
.header(super::TOKEN_HEADER, "tok")
.body(Body::from(r#"{"kind":"pause"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(response.headers()["content-type"], "application/json");
let response = app
.oneshot(Request::builder().uri("/api").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(response.headers()["content-type"], "application/json");
}
#[test]
fn origin_allowlist_accepts_only_local_dev_and_tauri() {
for allowed in [
"http://localhost",
"http://localhost:80",
"http://localhost:5173",
"http://127.0.0.1",
"http://127.0.0.1:65535",
"http://127.0.0.10:8080",
"http://[::1]:5173",
"tauri://localhost",
"http://tauri.localhost",
] {
assert!(origin_allowed(allowed, None), "should allow {allowed}");
}
for denied in [
"https://evil.example",
"http://localhost.evil.example",
"http://localhost.evil.example:5173",
"http://127.0.0.1.evil.example",
"http://localhostx",
"http://192.168.1.5:4560",
"http://localhost:99999",
"http://localhost:5173.evil.example",
"https://localhost:5173",
"https://tauri.localhost",
"tauri://evil.example",
"null",
"",
] {
assert!(!origin_allowed(denied, None), "should deny {denied}");
}
}
#[test]
fn origin_allowlist_scopes_localhost_to_bind_and_dev_ports() {
let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 1], 4560)));
for allowed in [
"http://localhost:4560",
"http://127.0.0.1:4560",
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:1420",
"tauri://localhost",
"http://tauri.localhost",
] {
assert!(
origin_allowed(allowed, bind),
"should allow {allowed} for bind 127.0.0.1:4560"
);
}
for denied in [
"http://localhost:8080",
"http://127.0.0.1:8080",
"http://localhost", "http://127.0.0.1",
"http://127.0.0.2:4560",
"http://127.0.0.10:4560",
"http://127.0.0.2:5173",
"http://127.0.0.10:1420",
"http://[::1]:4560",
"http://localhost.evil.example:4560",
"https://localhost:4560",
"https://evil.example",
] {
assert!(
!origin_allowed(denied, bind),
"should deny {denied} for bind 127.0.0.1:4560"
);
}
assert!(origin_allowed(
"http://localhost",
Some(std::net::SocketAddr::from(([127, 0, 0, 1], 80)))
));
}
#[test]
fn origin_allowlist_follows_the_actual_bound_ip() {
let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 2], 4560)));
assert!(origin_allowed("http://127.0.0.2:4560", bind));
assert!(!origin_allowed("http://127.0.0.1:4560", bind));
assert!(!origin_allowed("http://localhost:4560", bind));
assert!(origin_allowed("http://localhost:5173", bind));
let bind_v6 = Some(std::net::SocketAddr::from((
std::net::Ipv6Addr::LOCALHOST,
4560,
)));
assert!(origin_allowed("http://[::1]:4560", bind_v6));
assert!(origin_allowed("http://localhost:4560", bind_v6));
assert!(!origin_allowed("http://127.0.0.2:4560", bind_v6));
let bind_any = Some(std::net::SocketAddr::from(([0, 0, 0, 0], 4560)));
assert!(origin_allowed("http://127.0.0.1:4560", bind_any));
assert!(origin_allowed("http://127.0.0.5:4560", bind_any));
assert!(origin_allowed("http://localhost:4560", bind_any));
assert!(!origin_allowed("http://localhost:8080", bind_any));
}
#[test]
fn ws_origin_loopback_keeps_strict_browser_allowlist() {
use super::ws_origin_allowed;
let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 1], 4560)));
assert!(ws_origin_allowed(Some("http://localhost:4560"), bind, true));
assert!(ws_origin_allowed(Some("http://localhost:5173"), bind, true));
assert!(
!ws_origin_allowed(None, bind, true),
"missing Origin stays rejected on loopback (reads are tokenless)"
);
assert!(!ws_origin_allowed(
Some("http://192.168.1.5:4560"),
bind,
true
));
assert!(
!ws_origin_allowed(Some("http://127.0.0.2:4560"), bind, true),
"co-resident loopback listener page must not open the tokenless WS"
);
assert!(
!ws_origin_allowed(Some("http://127.0.0.2:5173"), bind, true),
"a dev port must not privilege another independently bindable loopback IP"
);
assert!(!ws_origin_allowed(Some("http://evil.example"), bind, true));
}
#[test]
fn ws_origin_lan_accepts_ip_literals_and_native_clients() {
use super::ws_origin_allowed;
let bind = Some(std::net::SocketAddr::from(([0, 0, 0, 0], 4560)));
assert!(ws_origin_allowed(
Some("http://192.168.1.5:4560"),
bind,
false
));
assert!(ws_origin_allowed(
Some("http://[fd00::5]:4560"),
bind,
false
));
assert!(ws_origin_allowed(
Some("http://localhost:5173"),
bind,
false
));
assert!(ws_origin_allowed(None, bind, false));
for denied in [
"http://evil.example:4560",
"http://192.168.1.5.evil.example:4560",
"https://192.168.1.5:4560",
"http://[::1:4560",
"null",
"",
] {
assert!(
!ws_origin_allowed(Some(denied), bind, false),
"should deny {denied} off loopback"
);
}
}
#[test]
fn host_allowlist_loopback_rejects_lan_and_dns() {
for allowed in [
"localhost",
"localhost:4560",
"LOCALHOST:5173",
"127.0.0.1",
"127.0.0.1:65535",
"127.0.0.2:4560",
"::1",
"[::1]",
"[::1]:4560",
] {
assert!(
host_allowed(allowed, true),
"loopback bind should allow {allowed}"
);
}
for denied in [
"evil.example",
"evil.example:4560",
"localhost.evil.example",
"192.168.1.10",
"192.168.1.10:4560",
"10.0.0.1:8080",
"::1:4560",
"[::1",
"",
] {
assert!(
!host_allowed(denied, true),
"loopback bind should deny {denied}"
);
}
}
#[test]
fn host_allowlist_lan_accepts_ip_hosts() {
for allowed in [
"192.168.1.10",
"192.168.1.10:4560",
"10.0.0.1:8080",
"localhost",
"127.0.0.1:4560",
"[::1]:4560",
] {
assert!(
host_allowed(allowed, false),
"LAN bind should allow {allowed}"
);
}
for denied in [
"evil.example",
"evil.example:4560",
"localhost.evil.example",
"",
] {
assert!(
!host_allowed(denied, false),
"LAN bind should still deny DNS Host {denied}"
);
}
}
}