use axum::body::Body;
use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use kanade_shared::bin_platform::{
LINUX_SUFFIX_AARCH64, LINUX_SUFFIX_X86_64, MACOS_SUFFIX_AARCH64, platform_of_key,
};
use kanade_shared::kv::OBJECT_AGENT_RELEASES;
use kanade_shared::wire::ServerSettings;
use serde::Deserialize;
use tokio::io::AsyncReadExt;
use tracing::{info, warn};
use super::AppState;
use crate::audit;
use crate::audit::Caller;
const AGENT_TOML_TEMPLATE: &str = include_str!("../../assets/agent.toml");
const DEPLOY_AGENT_PS1: &str = include_str!("../../assets/deploy-agent.ps1");
const SETUP_AGENT_SH: &str = include_str!("../../assets/setup-agent.sh");
const AGENT_SERVICE: &str = include_str!("../../assets/kanade-agent.service");
const SETUP_AGENT_MACOS_SH: &str = include_str!("../../assets/setup-agent-macos.sh");
const AGENT_PLIST: &str = include_str!("../../assets/com.kanade.agent.plist");
const NATS_URL_LINE: &str = "nats_url = 'nats://127.0.0.1:4222'";
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
enum InstallerOs {
#[default]
Windows,
Linux,
Macos,
}
impl InstallerOs {
fn as_str(&self) -> &'static str {
match self {
InstallerOs::Windows => "windows",
InstallerOs::Linux => "linux",
InstallerOs::Macos => "macos",
}
}
}
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
enum InstallerArch {
#[default]
X86_64,
Aarch64,
}
impl InstallerArch {
fn as_str(&self) -> &'static str {
match self {
InstallerArch::X86_64 => "x86_64",
InstallerArch::Aarch64 => "aarch64",
}
}
fn linux_suffix(&self) -> &'static str {
match self {
InstallerArch::X86_64 => LINUX_SUFFIX_X86_64,
InstallerArch::Aarch64 => LINUX_SUFFIX_AARCH64,
}
}
}
#[derive(Deserialize, Debug, Default)]
pub struct InstallerParams {
#[serde(default)]
os: InstallerOs,
#[serde(default)]
arch: Option<InstallerArch>,
}
impl InstallerParams {
fn resolve_arch(&self) -> Result<InstallerArch, (StatusCode, String)> {
match (self.os, self.arch) {
(InstallerOs::Macos, Some(InstallerArch::X86_64)) => Err((
StatusCode::BAD_REQUEST,
"Intel (x86_64) Macs are not supported — kanade supports Apple Silicon (arm64) \
Macs only; use arch=aarch64"
.to_string(),
)),
(InstallerOs::Macos, None) => Ok(InstallerArch::Aarch64),
(_, arch) => Ok(arch.unwrap_or_default()),
}
}
}
pub async fn installer(
State(state): State<AppState>,
caller: Caller,
Query(params): Query<InstallerParams>,
) -> Result<Response, (StatusCode, String)> {
let arch = params.resolve_arch()?;
let store = state
.jetstream
.get_object_store(OBJECT_AGENT_RELEASES)
.await
.map_err(|e| {
warn!(error = %e, "get_object_store agent_releases");
(
StatusCode::SERVICE_UNAVAILABLE,
format!(
"Object Store '{OBJECT_AGENT_RELEASES}' not found — the backend creates it at startup, so it was removed since or this broker is not the one expected; check `GET /api/jetstream/status` (restarting the backend recreates it)"
),
)
})?;
let key = {
let metas = crate::projector::object_meta::list_bucket(&state.pool, OBJECT_AGENT_RELEASES)
.await
.map_err(|e| {
warn!(error = %e, "object_store_meta list agent_releases");
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})?;
let rows: Vec<(String, Option<String>)> =
metas.into_iter().map(|m| (m.key, m.modified)).collect();
match latest_key_for_platform(&rows, params.os, arch) {
Some(k) => k,
None => {
let label = match params.os {
InstallerOs::Windows => "windows".to_string(),
InstallerOs::Linux => format!("linux-{}", arch.as_str()),
InstallerOs::Macos => format!("macos-{}", arch.as_str()),
};
return Err((
StatusCode::NOT_FOUND,
format!(
"no {label} agent releases in {OBJECT_AGENT_RELEASES} — publish one first \
(`kanade agent publish`)"
),
));
}
}
};
check_version(&key)?;
let settings = super::server_settings::load(&state).await.map_err(|e| {
warn!(error = %format!("{e:#}"), "read server_settings for installer");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("read server_settings: {e:#}"),
)
})?;
let (nats_url, nats_token, user_pair) = resolve_nats(&settings, &state.nats_url)?;
let agent_toml = render_agent_toml(&nats_url)?;
let mut obj = store.get(&key).await.map_err(|e| {
let msg = e.to_string();
if msg.contains("not found") || msg.contains("no objects") {
return (
StatusCode::NOT_FOUND,
format!("release '{key}' not in Object Store"),
);
}
warn!(error = %e, %key, "object_store.get");
(StatusCode::INTERNAL_SERVER_ERROR, msg)
})?;
let mut exe = Vec::with_capacity(obj.info().size);
obj.read_to_end(&mut exe).await.map_err(|e| {
warn!(error = %e, %key, "read agent binary");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("read agent binary '{key}': {e}"),
)
})?;
let keyring = state.commands.keyring_entry();
let command_keys = match &keyring {
Some(entry) => Some(serde_json::to_string(&vec![entry]).map_err(|e| {
warn!(error = %e, "serialize command keyring");
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})?),
None => None,
};
let enforcement_requested = settings
.agent_install
.as_ref()
.and_then(|ai| ai.require_signed_commands)
.unwrap_or(false);
let enforcement = resolve_enforcement(enforcement_requested, keyring.is_some());
if enforcement == EnforcementPlan::RequestedButNoKey {
warn!(
%key,
"installer: require_signed_commands is set but this backend has no command-signing \
key — the generated installer will not enable enforcement"
);
}
let (content_type, filename, payload, command_keys_embedded) = match params.os {
InstallerOs::Windows => {
let install_ps1 = render_install_ps1(
&key,
nats_token.as_deref(),
user_pair.as_ref(),
command_keys.as_deref(),
enforcement == EnforcementPlan::Embedded,
);
let install_cmd = render_install_cmd(&key);
let mut readme = render_readme(&key, command_keys.is_some(), enforcement);
if user_pair.is_some() {
readme.push_str(&user_pair_note("\r\n"));
}
let entries: Vec<(&str, Vec<u8>)> = vec![
("kanade-agent.exe", exe),
("agent.toml", agent_toml.into_bytes()),
("deploy-agent.ps1", DEPLOY_AGENT_PS1.as_bytes().to_vec()),
("install-agent.ps1", install_ps1.into_bytes()),
("install.cmd", install_cmd.into_bytes()),
("README.txt", readme.into_bytes()),
];
let zip_bytes = tokio::task::spawn_blocking(move || build_zip(entries))
.await
.map_err(|e| {
warn!(error = %e, "installer zip task join");
(StatusCode::INTERNAL_SERVER_ERROR, format!("zip task: {e}"))
})?
.map_err(|e| {
warn!(error = %e, "build installer zip");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("build installer zip: {e}"),
)
})?;
(
"application/zip",
format!("kanade-agent-installer-{key}.zip"),
zip_bytes,
command_keys.is_some(),
)
}
InstallerOs::Linux | InstallerOs::Macos => {
let entries = unix_tar_entries(
params.os,
&key,
exe,
agent_toml,
nats_token.as_deref(),
user_pair.as_ref(),
command_keys.as_deref(),
enforcement,
);
let tgz_bytes = tokio::task::spawn_blocking(move || build_tar_gz(entries))
.await
.map_err(|e| {
warn!(error = %e, "installer tar task join");
(StatusCode::INTERNAL_SERVER_ERROR, format!("tar task: {e}"))
})?
.map_err(|e| {
warn!(error = %e, "build installer tarball");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("build installer tarball: {e}"),
)
})?;
info!(%key, arch = arch.as_str(), "installer: tar.gz generated");
(
"application/gzip",
format!("kanade-agent-installer-{key}.tar.gz"),
tgz_bytes,
command_keys.is_some(),
)
}
};
info!(%key, os = params.os.as_str(), nats_url = %nats_url, "installer: archive generated");
audit::record(
&state.nats,
"operator",
"agent_installer_download",
Some(&key),
Some(&caller),
serde_json::json!({
"version": key,
"os": params.os.as_str(),
"arch": arch.as_str(),
"nats_url": nats_url,
"token_embedded": nats_token.is_some(),
"user_pair_embedded": user_pair.is_some(),
"command_keys_embedded": command_keys_embedded,
}),
)
.await;
Ok((
[
(header::CONTENT_TYPE, content_type.to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
),
],
Body::from(payload),
)
.into_response())
}
pub async fn installer_ps1(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, (StatusCode, String)> {
let (base, token) = script_context(&state, &headers)?;
Ok((
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
render_installer_ps1(&base, &token),
)
.into_response())
}
pub async fn installer_sh(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, (StatusCode, String)> {
let (base, token) = script_context(&state, &headers)?;
Ok((
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
render_installer_sh(&base, &token),
)
.into_response())
}
fn script_context(
state: &AppState,
headers: &HeaderMap,
) -> Result<(String, String), (StatusCode, String)> {
let base = super::password_setup::link_base(state.public_url.as_deref(), headers).ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"cannot derive the backend's base URL (no [server] public_url configured and no Host \
header on the request) — set public_url in backend.toml"
.to_string(),
))?;
let token = bearer_token(headers).ok_or((
StatusCode::BAD_REQUEST,
"script generation requires a Bearer Authorization header".to_string(),
))?;
Ok((base, token.to_string()))
}
fn bearer_token(headers: &HeaderMap) -> Option<&str> {
headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.filter(|t| !t.is_empty())
}
fn render_installer_ps1(base: &str, token: &str) -> String {
let zip_url = ps_quote(&format!("{base}/api/agents/installer"));
let auth = ps_quote(&format!("Bearer {token}"));
let mut s = String::new();
s.push_str(
"# kanade-agent one-liner installer (generated by kanade-backend — do not edit)\r\n",
);
s.push_str("# Installs the latest agent as a Windows service. The embedded token expires\r\n");
s.push_str("# with the issuer's session.\r\n");
s.push_str("$ErrorActionPreference = 'Stop'\r\n");
s.push_str("$tmp = Join-Path $env:TEMP ('kanade-agent-install-' + [guid]::NewGuid())\r\n");
s.push_str("New-Item -ItemType Directory -Path $tmp | Out-Null\r\n");
s.push_str("$zip = Join-Path $tmp 'installer.zip'\r\n");
s.push_str("try {\r\n");
s.push_str(&format!(
" Invoke-WebRequest -Uri {zip_url} -Headers @{{ Authorization = {auth} }} -OutFile $zip\r\n"
));
s.push_str(" Expand-Archive -Path $zip -DestinationPath $tmp\r\n");
s.push_str(" $installScript = Join-Path $tmp 'install-agent.ps1'\r\n");
s.push_str(" $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)\r\n");
s.push_str(" if ($isAdmin) {\r\n");
s.push_str(" & $installScript\r\n");
s.push_str(" $code = $LASTEXITCODE\r\n");
s.push_str(" } else {\r\n");
s.push_str(" # Elevate ONLY the install step (UAC prompt); the download above\r\n");
s.push_str(
" # already ran unelevated, so the token never reaches an admin process.\r\n",
);
s.push_str(" $proc = Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File',\"`\"$installScript`\"\" -Wait -PassThru\r\n");
s.push_str(" $code = $proc.ExitCode\r\n");
s.push_str(" }\r\n");
s.push_str(" if ($code -ne 0) { throw \"install failed (exit $code)\" }\r\n");
s.push_str(" Write-Host \"kanade-agent installed.\"\r\n");
s.push_str(" Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue\r\n");
s.push_str("} catch {\r\n");
s.push_str(" # Covers every failure path (download, extract, admin-direct throw,\r\n");
s.push_str(" # and the exit-code check above): $tmp survives so there's something\r\n");
s.push_str(" # to inspect, and install.log is named only once it actually exists.\r\n");
s.push_str(" $log = Join-Path $tmp 'install.log'\r\n");
s.push_str(
" $hint = if (Test-Path $log) { \" See $log for details.\" } else { \" Installer files kept at $tmp.\" }\r\n",
);
s.push_str(" throw \"$($_.Exception.Message)$hint\"\r\n");
s.push_str("}\r\n");
s
}
fn render_installer_sh(base: &str, token: &str) -> String {
let url_os = sh_quote(&format!("{base}/api/agents/installer?os="));
let url_arch = sh_quote("&arch=");
let token_cfg = token.replace('\\', "\\\\").replace('"', "\\\"");
let mut s = String::new();
s.push_str("#!/bin/sh\n");
s.push_str("# kanade-agent one-liner installer (generated by kanade-backend — do not edit)\n");
s.push_str("# Meant to be run via the one-liner on the Agent Install page\n");
s.push_str(&format!(
"# ({base}/agent-install — piped through `sudo bash`).\n"
));
s.push_str("# Linux (systemd) and macOS (launchd) alike.\n");
s.push_str("# install.sh needs root; without the sudo pipe it fails there by design.\n");
s.push_str("# The embedded token expires with the issuer's session.\n");
s.push_str("set -eu\n");
s.push_str("case \"$(uname -s)\" in\n");
s.push_str(" Linux)\n");
s.push_str(" OS=linux\n");
s.push_str(" case \"$(uname -m)\" in\n");
s.push_str(" x86_64) ARCH=x86_64 ;;\n");
s.push_str(" aarch64|arm64) ARCH=aarch64 ;;\n");
s.push_str(" *) echo \"unsupported architecture: $(uname -m)\" >&2; exit 1 ;;\n");
s.push_str(" esac\n");
s.push_str(" ;;\n");
s.push_str(" Darwin)\n");
s.push_str(" OS=macos\n");
s.push_str(
" if [ \"$(sysctl -n hw.optional.arm64 2>/dev/null || true)\" = \"1\" ]; then\n",
);
s.push_str(" ARCH=aarch64\n");
s.push_str(" else\n");
s.push_str(" echo \"Intel Macs are not supported — kanade supports Apple Silicon Macs only\" >&2\n");
s.push_str(" exit 1\n");
s.push_str(" fi\n");
s.push_str(" ;;\n");
s.push_str(" *) echo \"unsupported OS: $(uname -s)\" >&2; exit 1 ;;\n");
s.push_str("esac\n");
s.push_str("TMP=\"$(mktemp -d)\"\n");
s.push_str("trap 'rm -rf \"$TMP\"' EXIT\n");
s.push_str(&format!(
"curl -fsSL -K - -o \"$TMP/installer.tar.gz\" {url_os}\"$OS\"{url_arch}\"$ARCH\" <<'KANADE_CURL_CONFIG'\n"
));
s.push_str(&format!("header = \"Authorization: Bearer {token_cfg}\"\n"));
s.push_str("KANADE_CURL_CONFIG\n");
s.push_str("tar xzf \"$TMP/installer.tar.gz\" -C \"$TMP\"\n");
s.push_str("sh \"$TMP/install.sh\"\n");
s.push_str("echo \"kanade-agent installed.\"\n");
s
}
fn latest_key_for_platform(
rows: &[(String, Option<String>)],
os: InstallerOs,
arch: InstallerArch,
) -> Option<String> {
let mut matches: Vec<&(String, Option<String>)> = rows
.iter()
.filter(|(key, _)| key_matches_platform(key, os, arch))
.collect();
matches.sort_by(|a, b| b.1.cmp(&a.1));
matches.first().map(|(k, _)| k.clone())
}
fn key_matches_platform(key: &str, os: InstallerOs, arch: InstallerArch) -> bool {
match os {
InstallerOs::Windows => platform_of_key(key) == "windows",
InstallerOs::Linux => key.ends_with(arch.linux_suffix()),
InstallerOs::Macos => arch == InstallerArch::Aarch64 && key.ends_with(MACOS_SUFFIX_AARCH64),
}
}
type ResolvedNats = (String, Option<String>, Option<NatsUserPair>);
fn resolve_nats(
settings: &ServerSettings,
backend_url: &str,
) -> Result<ResolvedNats, (StatusCode, String)> {
let Some(ai) = settings.agent_install.as_ref() else {
return Ok((backend_url.to_string(), None, None));
};
let url = match ai.nats_url.as_deref() {
Some(u) if u.is_empty() || u.contains('\'') || u.contains('\n') || u.contains('\r') => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"server_settings agent_install.nats_url is invalid (must be non-empty, with no \
single quote or newline) — fix it on the Settings page"
.into(),
));
}
Some(u) => u.to_string(),
None => backend_url.to_string(),
};
let token = ai.nats_token.clone().filter(|t| !t.is_empty());
let pair = match (
ai.nats_user.as_deref().filter(|v| !v.is_empty()),
ai.nats_password.as_deref().filter(|v| !v.is_empty()),
) {
(Some(user), Some(password)) => Some(NatsUserPair {
user: user.to_string(),
password: password.to_string(),
}),
(None, None) => None,
_ => {
warn!(
"installer: server_settings agent_install holds only one of nats_user / \
nats_password — the user pair is NOT embedded; set both on the Settings page"
);
None
}
};
Ok((url, token, pair))
}
#[derive(Clone, PartialEq, Eq)]
struct NatsUserPair {
user: String,
password: String,
}
impl std::fmt::Debug for NatsUserPair {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("NatsUserPair(<redacted>)")
}
}
fn user_pair_note(nl: &str) -> String {
[
"",
"This installer also embeds the agent role's NATS user and password (shared by",
"every agent by design) next to the token, so this host holds the credential",
"the broker may later require. Treat this archive as a secret.",
"",
]
.join(nl)
}
fn check_version(version: &str) -> Result<(), (StatusCode, String)> {
kanade_shared::bin_platform::check_release_key(version)
.map_err(|e| (StatusCode::BAD_REQUEST, e))
}
fn render_agent_toml(nats_url: &str) -> Result<String, (StatusCode, String)> {
if nats_url.is_empty() {
return Err((StatusCode::BAD_REQUEST, "nats_url must not be empty".into()));
}
if nats_url.contains('\'') || nats_url.contains('\n') || nats_url.contains('\r') {
return Err((
StatusCode::BAD_REQUEST,
"nats_url must not contain a single quote or newline (TOML literal-string safety)"
.into(),
));
}
if !AGENT_TOML_TEMPLATE.contains(NATS_URL_LINE) {
warn!("configs/agent.toml no longer contains the expected nats_url line");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!(
"configs/agent.toml template changed — expected line `{NATS_URL_LINE}` not found; \
update the installer rewrite to match"
),
));
}
Ok(AGENT_TOML_TEMPLATE.replacen(NATS_URL_LINE, &format!("nats_url = '{nats_url}'"), 1))
}
fn ps_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EnforcementPlan {
Off,
Embedded,
RequestedButNoKey,
}
fn resolve_enforcement(requested: bool, key_present: bool) -> EnforcementPlan {
match (requested, key_present) {
(false, _) => EnforcementPlan::Off,
(true, true) => EnforcementPlan::Embedded,
(true, false) => EnforcementPlan::RequestedButNoKey,
}
}
fn render_install_ps1(
version: &str,
nats_token: Option<&str>,
user_pair: Option<&NatsUserPair>,
command_keys: Option<&str>,
require_signed_commands: bool,
) -> String {
let mut args = String::new();
if let Some(token) = nats_token {
args.push_str(&format!(" -NatsToken {}", ps_quote(token)));
}
if let Some(pair) = user_pair {
args.push_str(&format!(
" -NatsUser {} -NatsPassword {}",
ps_quote(&pair.user),
ps_quote(&pair.password)
));
}
if let Some(keys) = command_keys {
args.push_str(&format!(" -CommandKeys {}", ps_quote(keys)));
}
if require_signed_commands {
args.push_str(" -RequireSignedCommands");
}
format!(
"# Generated by kanade-backend — do not edit.\r\n\
# Installs kanade-agent {version} as a Windows service. Run as Administrator.\r\n\
$ErrorActionPreference = 'Stop'\r\n\
Start-Transcript -Path (Join-Path $PSScriptRoot 'install.log') -Force | Out-Null\r\n\
try {{\r\n\
\x20 & (Join-Path $PSScriptRoot 'deploy-agent.ps1'){args}\r\n\
\x20 exit $LASTEXITCODE\r\n\
}} finally {{\r\n\
\x20 Stop-Transcript | Out-Null\r\n\
}}\r\n"
)
}
fn render_install_cmd(version: &str) -> String {
let mut s = String::new();
s.push_str("@echo off\r\n");
s.push_str(&format!(
"REM kanade-agent installer (generated by kanade-backend). Installs kanade-agent {version}.\r\n"
));
s.push_str(
"powershell -NoProfile -ExecutionPolicy Bypass -File \"%~dp0install-agent.ps1\"\r\n",
);
s.push_str("if errorlevel 1 (\r\n");
s.push_str(" echo.\r\n");
s.push_str(" echo INSTALL FAILED - see the output above.\r\n");
s.push_str(" pause\r\n");
s.push_str(" exit /b 1\r\n");
s.push_str(")\r\n");
s.push_str("echo.\r\n");
s.push_str(&format!("echo kanade-agent {version} installed.\r\n"));
s.push_str("pause\r\n");
s
}
fn render_readme(version: &str, signing: bool, enforcement: EnforcementPlan) -> String {
let signing_note = if signing {
"This ZIP embeds the backend's command-signing PUBLIC key (provisioned\r\n\
into the agent's keyring by the installer, so signed commands verify\r\n\
from first boot). Public keys are not secrets. Break-glass keys are\r\n\
NEVER included in this ZIP — distribute those separately.\r\n"
} else {
"The backend that generated this ZIP is not signing commands, so no\r\n\
command-signing keyring is provisioned by the installer.\r\n"
};
let enforcement_note = match enforcement {
EnforcementPlan::Off => "",
EnforcementPlan::Embedded => {
"\r\nThis installer passes -RequireSignedCommands: the agent will REFUSE any\r\n\
command it cannot verify against the embedded keyring, starting from\r\n\
first boot.\r\n"
}
EnforcementPlan::RequestedButNoKey => {
"\r\n*** WARNING: RequireSignedCommands was requested in server settings, but\r\n\
*** this backend has no command-signing key configured, so this installer\r\n\
*** does NOT enable enforcement — an agent enforcing against an empty\r\n\
*** keyring would refuse every command, which is not what was asked for.\r\n\
*** Run `kanade-backend command-key-generate` on the backend host, then\r\n\
*** re-download this installer.\r\n"
}
};
let mut s = String::new();
s.push_str(&format!("kanade-agent installer (version {version})\r\n"));
s.push_str("==========================================\r\n");
s.push_str("\r\n");
s.push_str("Contents:\r\n");
s.push_str("\r\n");
s.push_str(&format!(
" kanade-agent.exe the agent binary (release {version})\r\n"
));
s.push_str(" agent.toml agent configuration (NATS URL baked in)\r\n");
s.push_str(" deploy-agent.ps1 the canonical install/update script\r\n");
s.push_str(" install-agent.ps1 generated wrapper (tokens and keys baked in)\r\n");
s.push_str(" install.cmd double-click bootstrap\r\n");
s.push_str(" README.txt this file\r\n");
s.push_str("\r\n");
s.push_str("Install:\r\n");
s.push_str("\r\n");
s.push_str(" 1. Extract this ZIP to a folder on the target PC.\r\n");
s.push_str(" 2. Right-click install.cmd and choose \"Run as administrator\".\r\n");
s.push_str("\r\n");
s.push_str("Re-running the installer upgrades the agent in place.\r\n");
s.push_str("\r\n");
s.push_str(signing_note);
s.push_str(enforcement_note);
s
}
fn build_zip(entries: Vec<(&str, Vec<u8>)>) -> Result<Vec<u8>, zip::result::ZipError> {
use std::io::Write as _;
use zip::write::SimpleFileOptions;
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::<u8>::new()));
let opts = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for (name, data) in entries {
zw.start_file(name, opts)?;
zw.write_all(&data)?;
}
Ok(zw.finish()?.into_inner())
}
struct TarEntry {
name: &'static str,
mode: u32,
data: Vec<u8>,
}
impl TarEntry {
fn new(name: &'static str, mode: u32, data: Vec<u8>) -> Self {
Self { name, mode, data }
}
}
#[allow(clippy::too_many_arguments)]
fn unix_tar_entries(
os: InstallerOs,
key: &str,
exe: Vec<u8>,
agent_toml: String,
nats_token: Option<&str>,
user_pair: Option<&NatsUserPair>,
command_keys: Option<&str>,
enforcement: EnforcementPlan,
) -> Vec<TarEntry> {
let (service_entry, setup_name, setup_script, service_kind, mut readme) =
if os == InstallerOs::Macos {
(
TarEntry::new(
"launchd/com.kanade.agent.plist",
0o644,
AGENT_PLIST.as_bytes().to_vec(),
),
"setup-agent-macos.sh",
SETUP_AGENT_MACOS_SH,
"a launchd daemon",
render_readme_macos(key, command_keys.is_some(), enforcement),
)
} else {
(
TarEntry::new(
"systemd/kanade-agent.service",
0o644,
AGENT_SERVICE.as_bytes().to_vec(),
),
"setup-agent.sh",
SETUP_AGENT_SH,
"a systemd service",
render_readme_linux(key, command_keys.is_some(), enforcement),
)
};
if user_pair.is_some() {
readme.push_str(&user_pair_note("\n"));
}
let install_sh = render_install_sh(
nats_token,
user_pair,
command_keys,
enforcement == EnforcementPlan::Embedded,
setup_name,
service_kind,
);
vec![
TarEntry::new("bin/kanade-agent", 0o755, exe),
TarEntry::new("etc/agent.toml", 0o644, agent_toml.into_bytes()),
service_entry,
TarEntry::new(setup_name, 0o755, setup_script.as_bytes().to_vec()),
TarEntry::new("install.sh", 0o755, install_sh.into_bytes()),
TarEntry::new("README.txt", 0o644, readme.into_bytes()),
]
}
fn build_tar_gz(entries: Vec<TarEntry>) -> std::io::Result<Vec<u8>> {
let enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut builder = tar::Builder::new(enc);
for entry in &entries {
let mut header = tar::Header::new_gnu();
header.set_size(entry.data.len() as u64);
header.set_mode(entry.mode);
header.set_mtime(0);
header.set_cksum();
builder.append_data(&mut header, entry.name, entry.data.as_slice())?;
}
let enc = builder.into_inner()?;
enc.finish()
}
fn sh_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn render_install_sh(
nats_token: Option<&str>,
user_pair: Option<&NatsUserPair>,
command_keys: Option<&str>,
require_signed_commands: bool,
setup_script: &str,
service: &str,
) -> String {
let mut s = String::new();
s.push_str("#!/bin/sh\n");
s.push_str("# Generated by kanade-backend — do not edit.\n");
s.push_str(&format!(
"# Installs kanade-agent as {service}. Run as root (sudo).\n"
));
s.push_str("set -eu\n");
s.push_str("cd \"$(dirname \"$0\")\"\n");
if let Some(token) = nats_token {
s.push_str(&format!("export KANADE_NATS_TOKEN={}\n", sh_quote(token)));
}
if let Some(pair) = user_pair {
s.push_str(&format!(
"export KANADE_NATS_USER={}\n",
sh_quote(&pair.user)
));
s.push_str(&format!(
"export KANADE_NATS_PASSWORD={}\n",
sh_quote(&pair.password)
));
}
if let Some(keys) = command_keys {
s.push_str(&format!("export KANADE_COMMAND_KEYS={}\n", sh_quote(keys)));
}
if require_signed_commands {
s.push_str("export KANADE_REQUIRE_SIGNED_COMMANDS=1\n");
}
s.push_str(&format!("exec ./{setup_script}\n"));
s
}
fn unix_signing_note(signing: bool, enforcement: EnforcementPlan) -> String {
let mut s = String::new();
if signing {
s.push_str(
"This tarball embeds the backend's command-signing PUBLIC key (provisioned\n\
into the agent's keyring by the installer, so signed commands verify\n\
from first boot). Public keys are not secrets. Break-glass keys are\n\
NEVER included in this tarball — distribute those separately.\n",
);
} else {
s.push_str(
"The backend that generated this tarball is not signing commands, so no\n\
command-signing keyring is provisioned by the installer.\n",
);
}
match enforcement {
EnforcementPlan::Off => {}
EnforcementPlan::Embedded => s.push_str(
"\nThis installer sets KANADE_REQUIRE_SIGNED_COMMANDS=1: the agent will REFUSE\n\
any command it cannot verify against the embedded keyring, starting from\n\
first boot.\n",
),
EnforcementPlan::RequestedButNoKey => s.push_str(
"\n*** WARNING: RequireSignedCommands was requested in server settings, but\n\
*** this backend has no command-signing key configured, so this installer\n\
*** does NOT enable enforcement — an agent enforcing against an empty\n\
*** keyring would refuse every command, which is not what was asked for.\n\
*** Run `kanade-backend command-key-generate` on the backend host, then\n\
*** re-download this installer.\n",
),
}
s
}
fn render_readme_linux(key: &str, signing: bool, enforcement: EnforcementPlan) -> String {
let mut s = String::new();
s.push_str(&format!("kanade-agent installer (release {key})\n"));
s.push_str("=================================================\n");
s.push('\n');
s.push_str("Contents:\n");
s.push('\n');
s.push_str(" bin/kanade-agent the agent binary\n");
s.push_str(" etc/agent.toml agent configuration (NATS URL baked in)\n");
s.push_str(" systemd/kanade-agent.service the systemd unit\n");
s.push_str(" setup-agent.sh the canonical install/update script\n");
s.push_str(" install.sh generated wrapper (token baked in, if any)\n");
s.push_str(" README.txt this file\n");
s.push('\n');
s.push_str("Install:\n");
s.push('\n');
s.push_str(" 1. Extract this tarball on the target machine and enter the\n");
s.push_str(" directory, e.g.:\n");
s.push_str(" mkdir kanade-agent-installer && cd kanade-agent-installer\n");
s.push_str(&format!(
" tar xzf ../kanade-agent-installer-{key}.tar.gz\n"
));
s.push_str(" 2. Run the installer as root:\n");
s.push_str(" sudo ./install.sh\n");
s.push('\n');
s.push_str("Re-running the installer upgrades the agent in place (an existing\n");
s.push_str("/etc/kanade/agent.env token and broker URL are preserved).\n");
s.push('\n');
s.push_str(&unix_signing_note(signing, enforcement));
s
}
fn render_readme_macos(key: &str, signing: bool, enforcement: EnforcementPlan) -> String {
let mut s = String::new();
s.push_str(&format!("kanade-agent installer (release {key})\n"));
s.push_str("=================================================\n");
s.push('\n');
s.push_str("Contents:\n");
s.push('\n');
s.push_str(" bin/kanade-agent the agent binary\n");
s.push_str(" etc/agent.toml agent configuration (NATS URL baked in)\n");
s.push_str(" launchd/com.kanade.agent.plist the LaunchDaemon definition\n");
s.push_str(" setup-agent-macos.sh the canonical install/update script\n");
s.push_str(" install.sh generated wrapper (token baked in, if any)\n");
s.push_str(" README.txt this file\n");
s.push('\n');
s.push_str("Install:\n");
s.push('\n');
s.push_str(" 1. Extract this tarball on the target Mac and enter the directory,\n");
s.push_str(" e.g. in Terminal:\n");
s.push_str(" mkdir kanade-agent-installer && cd kanade-agent-installer\n");
s.push_str(&format!(
" tar xzf ../kanade-agent-installer-{key}.tar.gz\n"
));
s.push_str(" 2. Run the installer as root:\n");
s.push_str(" sudo ./install.sh\n");
s.push('\n');
s.push_str("The agent runs as the LaunchDaemon com.kanade.agent\n");
s.push_str("(/Library/LaunchDaemons/com.kanade.agent.plist), binary at\n");
s.push_str("/usr/local/bin/kanade-agent, config + token under /etc/kanade, logs\n");
s.push_str("under /var/log/kanade. Check it with:\n");
s.push_str(" sudo launchctl print system/com.kanade.agent\n");
s.push('\n');
s.push_str("Re-running the installer upgrades the agent in place (an existing\n");
s.push_str("/etc/kanade/agent.env token and broker URL are preserved).\n");
s.push('\n');
s.push_str("Gatekeeper: the agent binary is not notarized. A tarball downloaded\n");
s.push_str("with a browser carries the com.apple.quarantine attribute; the\n");
s.push_str("installer clears it from the installed binary, and launchd runs the\n");
s.push_str("unsigned command-line binary without a Gatekeeper prompt. Apple\n");
s.push_str("Silicon still requires at least an ad-hoc code signature, which the\n");
s.push_str("Rust toolchain's linker applies; a binary modified after the build\n");
s.push_str("must be re-signed (codesign --force --sign - <binary>).\n");
s.push('\n');
s.push_str(&unix_signing_note(signing, enforcement));
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn assets_match_the_workspace_originals() {
for (copy, original, path) in [
(
AGENT_TOML_TEMPLATE,
include_str!("../../../../configs/agent.toml"),
"configs/agent.toml",
),
(
DEPLOY_AGENT_PS1,
include_str!("../../../../scripts/deploy/agent.ps1"),
"scripts/deploy/agent.ps1",
),
(
SETUP_AGENT_SH,
include_str!("../../../../deploy/linux/setup-agent.sh"),
"deploy/linux/setup-agent.sh",
),
(
AGENT_SERVICE,
include_str!("../../../../deploy/linux/systemd/kanade-agent.service"),
"deploy/linux/systemd/kanade-agent.service",
),
(
SETUP_AGENT_MACOS_SH,
include_str!("../../../../deploy/macos/setup-agent.sh"),
"deploy/macos/setup-agent.sh",
),
(
AGENT_PLIST,
include_str!("../../../../deploy/macos/launchd/com.kanade.agent.plist"),
"deploy/macos/launchd/com.kanade.agent.plist",
),
] {
assert_eq!(
copy, original,
"crates/kanade-backend/assets/ has drifted from {path} — copy it across",
);
}
}
#[test]
fn agent_toml_url_line_is_rewritten() {
let out = render_agent_toml("nats://broker.corp:4222").unwrap();
assert!(out.contains("nats_url = 'nats://broker.corp:4222'"));
assert!(!out.contains(NATS_URL_LINE));
let template_lines: Vec<&str> = AGENT_TOML_TEMPLATE.lines().collect();
let out_lines: Vec<&str> = out.lines().collect();
assert_eq!(template_lines.len(), out_lines.len());
let differing = template_lines
.iter()
.zip(&out_lines)
.filter(|(a, b)| a != b)
.count();
assert_eq!(differing, 1);
}
#[test]
fn version_charset_is_restricted() {
for bad in [
"",
"0.43.99\n evil",
"0.43.99\revil",
"0.43.99\"x",
"0.43.99'x",
"0.43.99&del",
"0.43.99%PATH%",
] {
let (code, _) = check_version(bad).unwrap_err();
assert_eq!(code, StatusCode::BAD_REQUEST, "{bad:?}");
}
for good in ["0.43.99", "0.43.99-rc.1+build.5", "1.0.0_alpha"] {
check_version(good).unwrap();
}
}
#[test]
fn agent_toml_rejects_literal_string_injection() {
for bad in [
"nats://evil'\n[agent]\nid='x'",
"nats://a\nb",
"nats://a\rb",
] {
let (code, _) = render_agent_toml(bad).unwrap_err();
assert_eq!(code, StatusCode::BAD_REQUEST, "{bad:?}");
}
let (code, _) = render_agent_toml("").unwrap_err();
assert_eq!(code, StatusCode::BAD_REQUEST);
}
#[test]
fn installer_ps1_embeds_base_and_token_crlf_only() {
let out = render_installer_ps1("https://kanade.example.com", "tok-123");
assert!(
out.contains(
"Invoke-WebRequest -Uri 'https://kanade.example.com/api/agents/installer'"
)
);
assert!(out.contains("-Headers @{ Authorization = 'Bearer tok-123' }"));
assert!(out.contains("Expand-Archive"));
assert!(out.contains("-Verb RunAs"));
assert!(out.contains("$LASTEXITCODE"));
assert!(out.contains("token expires"));
for (i, b) in out.bytes().enumerate() {
if b == b'\n' {
assert!(
i > 0 && out.as_bytes()[i - 1] == b'\r',
"bare LF at byte {i}"
);
}
}
}
#[test]
fn installer_ps1_doubles_single_quotes_in_embeds() {
let out = render_installer_ps1("https://k", "it's");
assert!(out.contains("Authorization = 'Bearer it''s'"));
}
#[test]
fn installer_ps1_keeps_tmp_and_points_at_log_on_failure() {
let out = render_installer_ps1("https://kanade.example.com", "tok-123");
assert!(out.contains("install.log"));
assert!(out.contains("throw \"install failed (exit $code)"));
assert!(out.contains("} catch {"));
assert!(out.contains("Installer files kept at $tmp"));
assert_eq!(out.matches("Remove-Item").count(), 1);
}
#[test]
fn installer_sh_embeds_base_token_and_os_arch_map_lf_only() {
let out = render_installer_sh("https://kanade.example.com", "tok-123");
assert!(out.starts_with("#!/bin/sh\n"));
assert!(out.contains("set -eu\n"));
assert!(!out.contains("-H 'Authorization:"));
let curl_line = out.lines().find(|l| l.starts_with("curl ")).unwrap();
assert!(!curl_line.contains("tok-123"), "{curl_line}");
assert!(out.contains("curl -fsSL -K - -o \"$TMP/installer.tar.gz\""));
assert!(out.contains("header = \"Authorization: Bearer tok-123\""));
assert!(out.contains("<<'KANADE_CURL_CONFIG'"));
assert!(out.contains(
"'https://kanade.example.com/api/agents/installer?os='\"$OS\"'&arch='\"$ARCH\""
));
assert!(out.contains("case \"$(uname -s)\" in"));
assert!(out.contains("unsupported OS"));
assert!(out.contains("OS=linux\n"));
assert!(out.contains("case \"$(uname -m)\" in"));
assert!(out.contains("x86_64) ARCH=x86_64 ;;"));
assert!(out.contains("aarch64|arm64) ARCH=aarch64 ;;"));
assert!(out.contains("unsupported architecture"));
let darwin = &out[out.find(" Darwin)\n").expect("Darwin branch")..];
let darwin = &darwin[..darwin.find(";;").unwrap()];
assert!(darwin.contains("OS=macos\n"), "{darwin}");
assert!(
darwin.contains("\"$(sysctl -n hw.optional.arm64 2>/dev/null || true)\" = \"1\""),
"{darwin}"
);
assert!(darwin.contains("ARCH=aarch64\n"), "{darwin}");
assert!(!darwin.contains("uname -m"), "{darwin}");
assert!(!darwin.contains("ARCH=x86_64"), "{darwin}");
assert!(
darwin.contains(
"echo \"Intel Macs are not supported — kanade supports Apple Silicon Macs only\" >&2\n exit 1\n"
),
"{darwin}"
);
assert!(out.contains("tar xzf \"$TMP/installer.tar.gz\" -C \"$TMP\""));
assert!(out.contains("sh \"$TMP/install.sh\""));
assert!(out.contains("sudo bash"));
assert!(!out.contains('\r'));
}
#[test]
fn installer_sh_escapes_the_token_for_the_curl_config() {
let out = render_installer_sh("https://k", "we\"ird\\tok");
assert!(out.contains("header = \"Authorization: Bearer we\\\"ird\\\\tok\""));
}
#[test]
fn bearer_token_parsing() {
let mut h = HeaderMap::new();
assert_eq!(bearer_token(&h), None);
h.insert(header::AUTHORIZATION, "Bearer tok-123".parse().unwrap());
assert_eq!(bearer_token(&h), Some("tok-123"));
h.insert(header::AUTHORIZATION, "Basic dXNlcg==".parse().unwrap());
assert_eq!(bearer_token(&h), None);
h.insert(header::AUTHORIZATION, "Bearer ".parse().unwrap());
assert_eq!(bearer_token(&h), None);
}
#[test]
fn install_ps1_embeds_token_and_keys_when_given() {
let out = render_install_ps1(
"0.43.99",
Some("s3cret"),
None,
Some(r#"[{"kid":"backend-1","public_key":"AAAA","label":"backend"}]"#),
false,
);
assert!(out.contains("# Installs kanade-agent 0.43.99 as a Windows service."));
assert!(out.contains("-NatsToken 's3cret'"));
assert!(out.contains(
"-CommandKeys '[{\"kid\":\"backend-1\",\"public_key\":\"AAAA\",\"label\":\"backend\"}]'"
));
assert!(!out.contains("-RequireSignedCommands"));
assert!(out.contains("exit $LASTEXITCODE"));
assert!(out.contains("Start-Transcript -Path (Join-Path $PSScriptRoot 'install.log')"));
assert!(out.contains("Stop-Transcript"));
}
#[test]
fn install_ps1_embeds_require_signed_commands_when_enforcement_is_embedded() {
let out = render_install_ps1(
"0.43.99",
Some("s3cret"),
None,
Some(r#"[{"kid":"backend-1","public_key":"AAAA"}]"#),
true,
);
assert!(out.contains("-CommandKeys"));
assert!(out.contains("-RequireSignedCommands"));
let line = out
.lines()
.find(|l| l.contains("deploy-agent.ps1"))
.unwrap();
assert!(line.trim_end().ends_with("-RequireSignedCommands"));
}
#[test]
fn resolve_enforcement_off_when_not_requested() {
assert_eq!(resolve_enforcement(false, true), EnforcementPlan::Off);
assert_eq!(resolve_enforcement(false, false), EnforcementPlan::Off);
}
#[test]
fn resolve_enforcement_embeds_only_when_a_key_is_present() {
assert_eq!(resolve_enforcement(true, true), EnforcementPlan::Embedded);
}
#[test]
fn resolve_enforcement_flags_a_request_with_no_key_rather_than_silently_dropping_it() {
assert_eq!(
resolve_enforcement(true, false),
EnforcementPlan::RequestedButNoKey
);
}
#[test]
fn install_ps1_is_crlf_throughout() {
let out = render_install_ps1("0.43.99", Some("tok"), None, None, false);
for (i, b) in out.bytes().enumerate() {
if b == b'\n' {
assert!(
i > 0 && out.as_bytes()[i - 1] == b'\r',
"bare LF at byte {i}"
);
}
}
}
#[test]
#[cfg(target_os = "windows")]
fn install_agent_ps1_writes_install_log_when_deploy_agent_throws() {
use std::process::Command;
let dir = std::env::temp_dir().join(format!(
"kanade-install-agent-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("install-agent.ps1"),
render_install_ps1("9.9.9", None, None, None, false),
)
.unwrap();
std::fs::write(
dir.join("deploy-agent.ps1"),
"throw \"simulated deploy failure: file in use\"\r\n",
)
.unwrap();
let status = Command::new("powershell.exe")
.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"])
.arg(dir.join("install-agent.ps1"))
.status()
.expect("failed to spawn powershell.exe");
assert!(
!status.success(),
"the stub throw should propagate as a nonzero exit"
);
let log = std::fs::read_to_string(dir.join("install.log"))
.expect("install.log should exist even though the script failed");
assert!(
log.contains("simulated deploy failure: file in use"),
"install.log is missing the real error text: {log}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn install_ps1_omits_args_entirely_when_not_given() {
let out = render_install_ps1("0.43.99", None, None, None, false);
assert!(!out.contains("-NatsToken"));
assert!(!out.contains("-CommandKeys"));
assert!(!out.contains("-RequireSignedCommands"));
assert!(out.contains("& (Join-Path $PSScriptRoot 'deploy-agent.ps1')\r\n"));
}
#[test]
fn install_ps1_doubles_single_quotes() {
let out = render_install_ps1("0.43.99", Some("it's"), None, None, false);
assert!(out.contains("-NatsToken 'it''s'"));
}
#[test]
fn install_cmd_is_crlf_throughout() {
let out = render_install_cmd("0.43.99");
assert!(!out.is_empty());
for (i, b) in out.bytes().enumerate() {
if b == b'\n' {
assert!(
i > 0 && out.as_bytes()[i - 1] == b'\r',
"bare LF at byte {i}"
);
}
}
assert!(out.contains("%~dp0install-agent.ps1"));
assert!(out.contains("REM kanade-agent installer (generated by kanade-backend). Installs kanade-agent 0.43.99."));
for line in out.lines().filter(|l| l.trim_start().starts_with("echo ")) {
assert!(!line.contains('&'), "echo line with '&': {line}");
}
}
#[test]
fn zip_round_trips_all_entries() {
let agent_toml = render_agent_toml("nats://broker.corp:4222").unwrap();
let install_ps1 = render_install_ps1(
"0.43.99",
Some("tok"),
None,
Some("[{\"kid\":\"k\"}]"),
true,
);
let entries: Vec<(&str, Vec<u8>)> = vec![
("kanade-agent.exe", b"MZ-fake-exe".to_vec()),
("agent.toml", agent_toml.clone().into_bytes()),
("deploy-agent.ps1", DEPLOY_AGENT_PS1.as_bytes().to_vec()),
("install-agent.ps1", install_ps1.clone().into_bytes()),
("install.cmd", render_install_cmd("0.43.99").into_bytes()),
(
"README.txt",
render_readme("0.43.99", true, EnforcementPlan::Embedded).into_bytes(),
),
];
let bytes = build_zip(entries).unwrap();
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip reads back");
let names: std::collections::HashSet<String> =
archive.file_names().map(str::to_owned).collect();
for expected in [
"kanade-agent.exe",
"agent.toml",
"deploy-agent.ps1",
"install-agent.ps1",
"install.cmd",
"README.txt",
] {
assert!(names.contains(expected), "missing {expected}");
}
assert_eq!(names.len(), 6);
use std::io::Read as _;
let mut buf = String::new();
archive
.by_name("agent.toml")
.unwrap()
.read_to_string(&mut buf)
.unwrap();
assert_eq!(buf, agent_toml);
buf.clear();
archive
.by_name("install-agent.ps1")
.unwrap()
.read_to_string(&mut buf)
.unwrap();
assert_eq!(buf, install_ps1);
buf.clear();
archive
.by_name("deploy-agent.ps1")
.unwrap()
.read_to_string(&mut buf)
.unwrap();
assert_eq!(buf, DEPLOY_AGENT_PS1);
}
#[test]
fn readme_names_the_signing_state() {
let signed = render_readme("0.43.99", true, EnforcementPlan::Off);
assert!(signed.contains("command-signing PUBLIC key"));
assert!(signed.contains("Break-glass keys are\r\nNEVER included"));
let unsigned = render_readme("0.43.99", false, EnforcementPlan::Off);
assert!(unsigned.contains("not signing commands"));
assert!(!unsigned.contains("PUBLIC key (provisioned"));
}
#[test]
fn readme_names_embedded_enforcement() {
let out = render_readme("0.43.99", true, EnforcementPlan::Embedded);
assert!(out.contains("-RequireSignedCommands"));
assert!(out.contains("REFUSE"));
}
#[test]
fn readme_warns_loudly_when_enforcement_was_requested_but_no_key_exists() {
let out = render_readme("0.43.99", false, EnforcementPlan::RequestedButNoKey);
assert!(out.contains("WARNING"));
assert!(out.contains("command-key-generate"));
assert!(out.contains("does NOT enable enforcement"));
}
#[test]
fn resolve_nats_falls_back_to_the_backend_url() {
for settings in [
ServerSettings::default(),
ServerSettings {
agent_install: Some(kanade_shared::wire::AgentInstallSection {
nats_token: Some("tok".into()),
..Default::default()
}),
..Default::default()
},
] {
let (url, _, _) = resolve_nats(&settings, "nats://backend:4222").unwrap();
assert_eq!(url, "nats://backend:4222");
}
let (_, token, _) =
resolve_nats(&ServerSettings::default(), "nats://backend:4222").unwrap();
assert_eq!(token, None);
}
#[test]
fn resolve_nats_prefers_the_settings_values() {
let settings = ServerSettings {
agent_install: Some(kanade_shared::wire::AgentInstallSection {
nats_url: Some("nats://broker.corp:4222".into()),
nats_token: Some("s3cret".into()),
..Default::default()
}),
..Default::default()
};
let (url, token, _) = resolve_nats(&settings, "nats://backend:4222").unwrap();
assert_eq!(url, "nats://broker.corp:4222");
assert_eq!(token.as_deref(), Some("s3cret"));
}
#[test]
fn resolve_nats_treats_an_empty_token_as_no_token() {
let settings = ServerSettings {
agent_install: Some(kanade_shared::wire::AgentInstallSection {
nats_token: Some(String::new()),
..Default::default()
}),
..Default::default()
};
let (_, token, _) = resolve_nats(&settings, "nats://backend:4222").unwrap();
assert_eq!(token, None);
}
#[test]
fn resolve_nats_rejects_a_hostile_stored_url() {
for bad in ["", "nats://evil'\nx='y'", "nats://a\nb", "nats://a\rb"] {
let settings = ServerSettings {
agent_install: Some(kanade_shared::wire::AgentInstallSection {
nats_url: Some(bad.into()),
..Default::default()
}),
..Default::default()
};
let (code, msg) = resolve_nats(&settings, "nats://backend:4222").unwrap_err();
assert_eq!(code, StatusCode::INTERNAL_SERVER_ERROR, "{bad:?}");
assert!(msg.contains("Settings"), "{msg}");
}
}
#[test]
fn params_default_to_windows_x86_64_and_reject_unknowns() {
let p: InstallerParams = serde_urlencoded_defaults();
assert_eq!(p.os, InstallerOs::Windows);
assert_eq!(p.resolve_arch().unwrap(), InstallerArch::X86_64);
assert!(serde_json::from_str::<InstallerOs>(r#""freebsd""#).is_err());
assert!(serde_json::from_str::<InstallerArch>(r#""armv7""#).is_err());
assert_eq!(
serde_json::from_str::<InstallerOs>(r#""linux""#).unwrap(),
InstallerOs::Linux
);
assert_eq!(
serde_json::from_str::<InstallerOs>(r#""macos""#).unwrap(),
InstallerOs::Macos
);
assert_eq!(
serde_json::from_str::<InstallerArch>(r#""aarch64""#).unwrap(),
InstallerArch::Aarch64
);
}
fn serde_urlencoded_defaults() -> InstallerParams {
InstallerParams::default()
}
fn params_from(query: &str) -> InstallerParams {
let uri: axum::http::Uri = format!("/api/agents/installer?{query}").parse().unwrap();
Query::<InstallerParams>::try_from_uri(&uri).unwrap().0
}
#[test]
fn macos_is_apple_silicon_only() {
let (code, msg) = params_from("os=macos&arch=x86_64")
.resolve_arch()
.unwrap_err();
assert_eq!(code, StatusCode::BAD_REQUEST);
assert!(
msg.contains("Intel (x86_64) Macs are not supported"),
"{msg}"
);
assert!(msg.contains("Apple Silicon"), "{msg}");
for q in ["os=macos&arch=aarch64", "os=macos"] {
assert_eq!(
params_from(q).resolve_arch().unwrap(),
InstallerArch::Aarch64,
"{q}"
);
}
assert_eq!(
params_from("os=linux").resolve_arch().unwrap(),
InstallerArch::X86_64
);
assert_eq!(
params_from("os=linux&arch=aarch64").resolve_arch().unwrap(),
InstallerArch::Aarch64
);
let rows: Vec<(String, Option<String>)> = vec![(
"0.45.3-macos-x86_64".into(),
Some("2026-07-04T00:00:00Z".into()),
)];
assert_eq!(
latest_key_for_platform(&rows, InstallerOs::Macos, InstallerArch::Aarch64),
None
);
}
#[test]
fn latest_key_filters_by_platform() {
let rows: Vec<(String, Option<String>)> = vec![
("0.44.0".into(), Some("2026-07-01T00:00:00Z".into())),
("0.45.4".into(), Some("2026-07-03T00:00:00Z".into())),
(
"0.45.4-linux-x86_64".into(),
Some("2026-07-02T00:00:00Z".into()),
),
(
"0.45.3-linux-x86_64".into(),
Some("2026-07-04T00:00:00Z".into()),
),
(
"0.45.4-linux-aarch64".into(),
Some("2026-07-05T00:00:00Z".into()),
),
];
assert_eq!(
latest_key_for_platform(&rows, InstallerOs::Windows, InstallerArch::X86_64),
Some("0.45.4".into())
);
assert_eq!(
latest_key_for_platform(&rows, InstallerOs::Linux, InstallerArch::X86_64),
Some("0.45.3-linux-x86_64".into())
);
assert_eq!(
latest_key_for_platform(&rows, InstallerOs::Linux, InstallerArch::Aarch64),
Some("0.45.4-linux-aarch64".into())
);
assert_eq!(
latest_key_for_platform(&rows, InstallerOs::Windows, InstallerArch::Aarch64),
Some("0.45.4".into())
);
let bare_only: Vec<(String, Option<String>)> =
vec![("0.44.0".into(), Some("2026-07-01T00:00:00Z".into()))];
assert_eq!(
latest_key_for_platform(&bare_only, InstallerOs::Linux, InstallerArch::X86_64),
None
);
let rc: Vec<(String, Option<String>)> = vec![
(
"0.46.0-rc-linux".into(),
Some("2026-07-01T00:00:00Z".into()),
),
(
"0.46.0-rc.1-linux-x86_64".into(),
Some("2026-07-02T00:00:00Z".into()),
),
];
assert_eq!(
latest_key_for_platform(&rc, InstallerOs::Windows, InstallerArch::X86_64),
Some("0.46.0-rc-linux".into())
);
assert_eq!(
latest_key_for_platform(&rc, InstallerOs::Linux, InstallerArch::X86_64),
Some("0.46.0-rc.1-linux-x86_64".into())
);
}
#[test]
fn latest_key_keeps_macos_and_linux_apart() {
let rows: Vec<(String, Option<String>)> = vec![
("0.45.4".into(), Some("2026-07-01T00:00:00Z".into())),
(
"0.45.4-linux-aarch64".into(),
Some("2026-07-02T00:00:00Z".into()),
),
(
"0.45.4-macos-aarch64".into(),
Some("2026-07-03T00:00:00Z".into()),
),
(
"0.46.0-rc.1-macos-aarch64".into(),
Some("2026-07-05T00:00:00Z".into()),
),
];
assert_eq!(
latest_key_for_platform(&rows, InstallerOs::Macos, InstallerArch::Aarch64),
Some("0.46.0-rc.1-macos-aarch64".into())
);
assert_eq!(
latest_key_for_platform(&rows, InstallerOs::Linux, InstallerArch::Aarch64),
Some("0.45.4-linux-aarch64".into())
);
assert_eq!(
latest_key_for_platform(&rows, InstallerOs::Windows, InstallerArch::X86_64),
Some("0.45.4".into())
);
let linux_only: Vec<(String, Option<String>)> = vec![(
"0.45.4-linux-x86_64".into(),
Some("2026-07-01T00:00:00Z".into()),
)];
assert_eq!(
latest_key_for_platform(&linux_only, InstallerOs::Macos, InstallerArch::Aarch64),
None
);
}
#[test]
fn install_sh_exports_the_token_only_when_given() {
let with = render_install_sh(
Some("s3cret"),
None,
None,
false,
"setup-agent.sh",
"a systemd service",
);
assert!(with.starts_with("#!/bin/sh\n"));
assert!(with.contains("set -eu\n"));
assert!(with.contains("export KANADE_NATS_TOKEN='s3cret'\n"));
assert!(with.ends_with("exec ./setup-agent.sh\n"));
assert!(!with.contains("KANADE_NATS_URL"));
assert!(!with.contains('\r'));
let without = render_install_sh(
None,
None,
None,
false,
"setup-agent.sh",
"a systemd service",
);
assert!(!without.contains("KANADE_NATS_TOKEN"));
assert!(without.ends_with("exec ./setup-agent.sh\n"));
}
#[test]
fn install_sh_shell_escapes_single_quotes() {
let out = render_install_sh(
Some("it's"),
None,
None,
false,
"setup-agent.sh",
"a systemd service",
);
assert!(out.contains("export KANADE_NATS_TOKEN='it'\\''s'\n"));
}
fn tar_round_trip(entries: Vec<TarEntry>) -> std::collections::HashMap<String, (u32, Vec<u8>)> {
let bytes = build_tar_gz(entries).unwrap();
let dec = flate2::read::GzDecoder::new(bytes.as_slice());
let mut archive = tar::Archive::new(dec);
let mut seen = std::collections::HashMap::new();
for entry in archive.entries().unwrap() {
let mut entry = entry.unwrap();
let name = entry.path().unwrap().to_string_lossy().into_owned();
let mode = entry.header().mode().unwrap();
let mut data = Vec::new();
use std::io::Read as _;
entry.read_to_end(&mut data).unwrap();
seen.insert(name, (mode, data));
}
seen
}
#[test]
fn linux_tar_gz_round_trips_all_entries_with_modes() {
let agent_toml = render_agent_toml("nats://broker.corp:4222").unwrap();
let seen = tar_round_trip(unix_tar_entries(
InstallerOs::Linux,
"0.45.4-linux-x86_64",
b"\x7fELF-fake".to_vec(),
agent_toml.clone(),
Some("tok"),
None,
None,
EnforcementPlan::Off,
));
for expected in [
"bin/kanade-agent",
"etc/agent.toml",
"systemd/kanade-agent.service",
"setup-agent.sh",
"install.sh",
"README.txt",
] {
assert!(seen.contains_key(expected), "missing {expected}");
}
assert_eq!(seen.len(), 6);
for exe_name in ["bin/kanade-agent", "setup-agent.sh", "install.sh"] {
assert_eq!(seen[exe_name].0, 0o755, "{exe_name} mode");
}
for data_name in [
"etc/agent.toml",
"systemd/kanade-agent.service",
"README.txt",
] {
assert_eq!(seen[data_name].0, 0o644, "{data_name} mode");
}
assert_eq!(seen["bin/kanade-agent"].1, b"\x7fELF-fake");
assert_eq!(seen["etc/agent.toml"].1, agent_toml.as_bytes());
assert_eq!(
seen["install.sh"].1,
render_install_sh(
Some("tok"),
None,
None,
false,
"setup-agent.sh",
"a systemd service"
)
.as_bytes()
);
assert_eq!(seen["setup-agent.sh"].1, SETUP_AGENT_SH.as_bytes());
assert_eq!(
seen["systemd/kanade-agent.service"].1,
AGENT_SERVICE.as_bytes()
);
}
#[test]
fn macos_tar_gz_carries_the_launchd_layout() {
let agent_toml = render_agent_toml("nats://broker.corp:4222").unwrap();
let seen = tar_round_trip(unix_tar_entries(
InstallerOs::Macos,
"0.45.4-macos-aarch64",
b"\xcf\xfa\xed\xfe-fake".to_vec(),
agent_toml.clone(),
Some("tok"),
None,
None,
EnforcementPlan::Off,
));
let mut names: Vec<&str> = seen.keys().map(String::as_str).collect();
names.sort_unstable();
assert_eq!(
names,
[
"README.txt",
"bin/kanade-agent",
"etc/agent.toml",
"install.sh",
"launchd/com.kanade.agent.plist",
"setup-agent-macos.sh",
]
);
for exe_name in ["bin/kanade-agent", "setup-agent-macos.sh", "install.sh"] {
assert_eq!(seen[exe_name].0, 0o755, "{exe_name} mode");
}
for data_name in [
"etc/agent.toml",
"launchd/com.kanade.agent.plist",
"README.txt",
] {
assert_eq!(seen[data_name].0, 0o644, "{data_name} mode");
}
assert_eq!(seen["etc/agent.toml"].1, agent_toml.as_bytes());
assert_eq!(
seen["setup-agent-macos.sh"].1,
SETUP_AGENT_MACOS_SH.as_bytes()
);
assert_eq!(
seen["launchd/com.kanade.agent.plist"].1,
AGENT_PLIST.as_bytes()
);
let install_sh = String::from_utf8(seen["install.sh"].1.clone()).unwrap();
assert!(install_sh.contains("# Installs kanade-agent as a launchd daemon."));
assert!(install_sh.contains("export KANADE_NATS_TOKEN='tok'\n"));
assert!(install_sh.ends_with("exec ./setup-agent-macos.sh\n"));
assert!(!install_sh.contains("KANADE_NATS_URL"));
let readme = String::from_utf8(seen["README.txt"].1.clone()).unwrap();
assert!(readme.contains("release 0.45.4-macos-aarch64"));
}
#[test]
fn readme_linux_documents_the_flow_and_the_signing_state() {
let out = render_readme_linux("0.45.4-linux-x86_64", true, EnforcementPlan::Off);
assert!(out.contains("release 0.45.4-linux-x86_64"));
assert!(out.contains("tar xzf"));
assert!(out.contains("sudo ./install.sh"));
assert!(!out.contains("Windows-only"));
assert!(!out.contains("INACTIVE"));
assert!(out.contains("command-signing PUBLIC key"));
assert!(out.contains("Break-glass keys are"));
assert!(!out.contains('\r'));
}
#[test]
fn readme_macos_documents_the_flow_gatekeeper_and_the_signing_state() {
let out = render_readme_macos("0.45.4-macos-aarch64", false, EnforcementPlan::Off);
assert!(out.contains("release 0.45.4-macos-aarch64"));
assert!(out.contains("tar xzf ../kanade-agent-installer-0.45.4-macos-aarch64.tar.gz"));
assert!(out.contains("sudo ./install.sh"));
assert!(out.contains("launchctl print system/com.kanade.agent"));
assert!(out.contains("com.apple.quarantine"));
assert!(!out.contains("Windows-only"));
assert!(out.contains("is not signing commands"));
assert!(!out.contains('\r'));
}
const KEYS_JSON: &str = r#"[{"kid":"backend-1","public_key":"AAAA","label":"it's"}]"#;
fn install_sh_of(os: InstallerOs, keys: Option<&str>, enforcement: EnforcementPlan) -> String {
let seen = tar_round_trip(unix_tar_entries(
os,
"0.45.4-x",
b"bin".to_vec(),
"toml".into(),
Some("tok"),
None,
keys,
enforcement,
));
String::from_utf8(seen["install.sh"].1.clone()).unwrap()
}
#[test]
fn unix_install_sh_embeds_the_backend_key_and_enforcement() {
for os in [InstallerOs::Linux, InstallerOs::Macos] {
let sh = install_sh_of(os, Some(KEYS_JSON), EnforcementPlan::Embedded);
assert!(sh.contains(&format!(
"export KANADE_COMMAND_KEYS={}\n",
sh_quote(KEYS_JSON)
)));
assert!(sh.contains("export KANADE_REQUIRE_SIGNED_COMMANDS=1\n"));
assert!(sh.find("export KANADE_COMMAND_KEYS").unwrap() < sh.find("exec ./").unwrap());
assert!(!sh.contains('\r'));
let keys_only = install_sh_of(os, Some(KEYS_JSON), EnforcementPlan::Off);
assert!(keys_only.contains("KANADE_COMMAND_KEYS="));
assert!(!keys_only.contains("KANADE_REQUIRE_SIGNED_COMMANDS"));
let none = install_sh_of(os, None, EnforcementPlan::RequestedButNoKey);
assert!(!none.contains("KANADE_COMMAND_KEYS"));
assert!(!none.contains("KANADE_REQUIRE_SIGNED_COMMANDS"));
}
}
#[test]
fn unix_readmes_name_the_signing_and_enforcement_state() {
let embedded = render_readme_linux("k", true, EnforcementPlan::Embedded);
assert!(embedded.contains("KANADE_REQUIRE_SIGNED_COMMANDS=1"));
let warned = render_readme_macos("k", false, EnforcementPlan::RequestedButNoKey);
assert!(warned.contains("*** WARNING"));
assert!(warned.contains("does NOT enable enforcement"));
assert!(!warned.contains('\r'));
}
fn awkward_pair() -> NatsUserPair {
NatsUserPair {
user: "svc agent".into(),
password: r#"it's a "$pw" \ `x`"#.into(),
}
}
fn pair_settings(user: Option<&str>, password: Option<&str>) -> ServerSettings {
ServerSettings {
agent_install: Some(kanade_shared::wire::AgentInstallSection {
nats_token: Some("tok".into()),
nats_user: user.map(Into::into),
nats_password: password.map(Into::into),
..Default::default()
}),
..Default::default()
}
}
#[test]
fn install_ps1_embeds_the_user_pair_after_the_token_with_quoting() {
let pair = awkward_pair();
let out = render_install_ps1("0.43.99", Some("tok"), Some(&pair), None, false);
assert!(
out.contains(
" -NatsToken 'tok' -NatsUser 'svc agent' \
-NatsPassword 'it''s a \"$pw\" \\ `x`'"
),
"{out}"
);
let out = render_install_ps1("0.43.99", None, Some(&pair), None, false);
assert!(out.contains(" -NatsUser 'svc agent' -NatsPassword "));
assert!(!out.contains("-NatsToken"));
}
#[test]
fn install_ps1_without_a_pair_is_byte_for_byte_the_old_output() {
let out = render_install_ps1("0.43.99", Some("tok"), None, Some("[1]"), true);
assert_eq!(
out,
"# Generated by kanade-backend — do not edit.\r\n\
# Installs kanade-agent 0.43.99 as a Windows service. Run as Administrator.\r\n\
$ErrorActionPreference = 'Stop'\r\n\
Start-Transcript -Path (Join-Path $PSScriptRoot 'install.log') -Force | Out-Null\r\n\
try {\r\n\
\x20 & (Join-Path $PSScriptRoot 'deploy-agent.ps1') -NatsToken 'tok' -CommandKeys '[1]' -RequireSignedCommands\r\n\
\x20 exit $LASTEXITCODE\r\n\
} finally {\r\n\
\x20 Stop-Transcript | Out-Null\r\n\
}\r\n"
);
}
#[test]
fn install_sh_embeds_the_user_pair_with_sh_quoting() {
let pair = awkward_pair();
let out = render_install_sh(
Some("tok"),
Some(&pair),
None,
false,
"setup-agent.sh",
"a systemd service",
);
assert!(out.contains(
"export KANADE_NATS_TOKEN='tok'\n\
export KANADE_NATS_USER='svc agent'\n\
export KANADE_NATS_PASSWORD='it'\\''s a \"$pw\" \\ `x`'\n\
exec ./setup-agent.sh\n"
));
}
#[test]
fn install_sh_without_a_pair_is_byte_for_byte_the_old_output() {
let out = render_install_sh(
Some("tok"),
None,
Some("[1]"),
true,
"setup-agent.sh",
"a systemd service",
);
assert_eq!(
out,
"#!/bin/sh\n\
# Generated by kanade-backend — do not edit.\n\
# Installs kanade-agent as a systemd service. Run as root (sudo).\n\
set -eu\n\
cd \"$(dirname \"$0\")\"\n\
export KANADE_NATS_TOKEN='tok'\n\
export KANADE_COMMAND_KEYS='[1]'\n\
export KANADE_REQUIRE_SIGNED_COMMANDS=1\n\
exec ./setup-agent.sh\n"
);
}
#[test]
fn tarballs_embed_the_pair_and_say_so_in_the_readme_without_revealing_it() {
let pair = awkward_pair();
for (os, setup) in [
(InstallerOs::Linux, "setup-agent.sh"),
(InstallerOs::Macos, "setup-agent-macos.sh"),
] {
let build = |p: Option<&NatsUserPair>| {
tar_round_trip(unix_tar_entries(
os,
"0.45.4-x",
b"bin".to_vec(),
"toml".into(),
Some("tok"),
p,
None,
EnforcementPlan::Off,
))
};
let with = build(Some(&pair));
let sh = String::from_utf8(with["install.sh"].1.clone()).unwrap();
assert!(sh.contains("export KANADE_NATS_TOKEN='tok'\n"));
assert!(sh.contains("export KANADE_NATS_USER='svc agent'\n"));
assert!(sh.contains(&format!("exec ./{setup}\n")));
let readme = String::from_utf8(with["README.txt"].1.clone()).unwrap();
assert!(readme.contains("NATS user and password"));
assert!(!readme.contains("svc agent") && !readme.contains("$pw"));
assert!(!readme.contains('\r'));
let without = build(None);
let sh = String::from_utf8(without["install.sh"].1.clone()).unwrap();
assert!(!sh.contains("KANADE_NATS_USER") && !sh.contains("KANADE_NATS_PASSWORD"));
let readme = String::from_utf8(without["README.txt"].1.clone()).unwrap();
assert!(!readme.contains("NATS user"));
assert!(
String::from_utf8(with["README.txt"].1.clone())
.unwrap()
.starts_with(&readme)
);
}
}
#[test]
fn windows_readme_note_is_crlf_and_value_free() {
let note = user_pair_note("\r\n");
assert!(note.contains("NATS user and password"));
assert!(note.lines().all(|l| !l.contains('$')));
assert_eq!(note.matches('\n').count(), note.matches("\r\n").count());
}
#[test]
fn resolve_nats_returns_the_pair_only_when_both_halves_are_usable() {
let url = "nats://backend:4222";
let (_, token, pair) = resolve_nats(&pair_settings(Some("u"), Some("p")), url).unwrap();
assert_eq!(token.as_deref(), Some("tok"));
assert_eq!(
pair,
Some(NatsUserPair {
user: "u".into(),
password: "p".into()
})
);
for (u, p) in [
(Some("u"), None),
(None, Some("p")),
(Some(""), Some("p")),
(Some("u"), Some("")),
(None, None),
] {
let (_, _, pair) = resolve_nats(&pair_settings(u, p), url).unwrap();
assert_eq!(pair, None, "{u:?}/{p:?}");
}
}
#[test]
fn user_pair_debug_hides_the_values() {
let dbg = format!("{:?}", awkward_pair());
assert!(!dbg.contains("svc agent") && !dbg.contains("pw"), "{dbg}");
}
}