use std::sync::Arc;
use axum::extract::Extension;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{get, post, put};
use axum::{Json, Router};
use crate::error::CertmeshError;
use crate::{CertmeshCore, CertmeshState};
use koi_common::encoding::{hex_decode, hex_encode};
use crate::protocol::{
AuditLogResponse, BackupRequest, BackupResponse, CertmeshStatus, ComplianceResponse,
CreateCaRequest, CreateCaResponse, DestroyResponse, HealthRequest, HealthResponse, JoinRequest,
JoinResponse, PolicyRequest, PolicySummary, PromoteRequest, PromoteResponse, RenewRequest,
RenewResponse, RestoreRequest, RestoreResponse, RevokeRequest, RevokeResponse, RosterManifest,
RotateAuthRequest, RotateAuthResponse, SetHookRequest, SetHookResponse, UnlockRequest,
UnlockResponse,
};
#[derive(Clone, Debug)]
pub struct ClientCn(pub String);
pub mod paths {
pub const PREFIX: &str = "/v1/certmesh";
pub const JOIN: &str = "/v1/certmesh/join";
pub const STATUS: &str = "/v1/certmesh/status";
pub const SET_HOOK: &str = "/v1/certmesh/set-hook";
pub const PROMOTE: &str = "/v1/certmesh/promote";
pub const RENEW: &str = "/v1/certmesh/renew";
pub const ROSTER: &str = "/v1/certmesh/roster";
pub const HEALTH: &str = "/v1/certmesh/health";
pub const CREATE: &str = "/v1/certmesh/create";
pub const UNLOCK: &str = "/v1/certmesh/unlock";
pub const ROTATE_AUTH: &str = "/v1/certmesh/rotate-auth";
pub const LOG: &str = "/v1/certmesh/log";
pub const DESTROY: &str = "/v1/certmesh/destroy";
pub const BACKUP: &str = "/v1/certmesh/backup";
pub const RESTORE: &str = "/v1/certmesh/restore";
pub const REVOKE: &str = "/v1/certmesh/revoke";
pub const COMPLIANCE: &str = "/v1/certmesh/compliance";
pub const OPEN_ENROLLMENT: &str = "/v1/certmesh/open-enrollment";
pub const CLOSE_ENROLLMENT: &str = "/v1/certmesh/close-enrollment";
pub const SET_POLICY: &str = "/v1/certmesh/set-policy";
pub fn rel(full: &str) -> &str {
full.strip_prefix(PREFIX).unwrap_or(full)
}
}
pub(crate) fn routes(state: Arc<CertmeshState>) -> Router {
use paths::rel;
Router::new()
.route(rel(paths::JOIN), post(join_handler))
.route(rel(paths::STATUS), get(status_handler))
.route(rel(paths::SET_HOOK), put(set_hook_handler))
.route(rel(paths::RENEW), post(renew_handler))
.route(rel(paths::ROSTER), get(roster_handler))
.route(rel(paths::HEALTH), post(health_handler))
.route(rel(paths::CREATE), post(create_handler))
.route(rel(paths::UNLOCK), post(unlock_handler))
.route(rel(paths::ROTATE_AUTH), post(rotate_auth_handler))
.route(rel(paths::LOG), get(log_handler))
.route(rel(paths::DESTROY), post(destroy_handler))
.route(rel(paths::BACKUP), post(backup_handler))
.route(rel(paths::RESTORE), post(restore_handler))
.route(rel(paths::REVOKE), post(revoke_handler))
.route(rel(paths::COMPLIANCE), get(compliance_handler))
.route(rel(paths::OPEN_ENROLLMENT), post(open_enrollment_handler))
.route(rel(paths::CLOSE_ENROLLMENT), post(close_enrollment_handler))
.route(rel(paths::SET_POLICY), put(set_policy_handler))
.layer(Extension(state))
}
pub(crate) fn inter_node_routes(state: Arc<CertmeshState>) -> Router {
use paths::rel;
Router::new()
.route(rel(paths::PROMOTE), post(promote_handler))
.route(rel(paths::HEALTH), post(health_handler))
.route(rel(paths::RENEW), post(renew_handler))
.route(rel(paths::ROSTER), get(roster_handler))
.route(rel(paths::SET_HOOK), put(set_hook_handler))
.layer(Extension(state))
}
#[utoipa::path(post, path = "/join", tag = "certmesh",
summary = "Enroll a new member in the certificate mesh",
request_body = JoinRequest,
responses((status = 200, body = JoinResponse)))]
async fn join_handler(
Extension(state): Extension<Arc<CertmeshState>>,
Json(request): Json<JoinRequest>,
) -> impl IntoResponse {
let core = CertmeshCore::from_state(Arc::clone(&state));
match core.enroll(&request).await {
Ok(response) => match serde_json::to_value(&response) {
Ok(val) => (StatusCode::OK, Json(val)).into_response(),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Serialization error: {e}")),
),
},
Err(e) => {
let code = koi_common::error::ErrorCode::from(&e);
let status = StatusCode::from_u16(code.http_status())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
error_response(status, &e)
}
}
}
#[utoipa::path(get, path = "/status", tag = "certmesh",
summary = "Certificate mesh status overview",
responses((status = 200, body = CertmeshStatus)))]
async fn status_handler(Extension(state): Extension<Arc<CertmeshState>>) -> impl IntoResponse {
let ca_guard = state.ca.lock().await;
let roster = state.roster.lock().await;
let profile = state.profile.lock().await;
let auth_guard = state.auth.lock().await;
let auth_method = auth_guard.as_ref().map(|a| a.method_name());
let status = crate::build_status(&state.paths, &ca_guard, &roster, &profile, auth_method);
Json(status)
}
#[utoipa::path(put, path = "/set-hook", tag = "certmesh",
summary = "Set reload hook for a member",
request_body = SetHookRequest,
responses((status = 200, body = SetHookResponse)))]
async fn set_hook_handler(
Extension(state): Extension<Arc<CertmeshState>>,
client_cn: Option<Extension<ClientCn>>,
Json(request): Json<SetHookRequest>,
) -> impl IntoResponse {
if let Some(Extension(ClientCn(ref caller))) = client_cn {
if caller != &request.hostname {
return error_response(
StatusCode::FORBIDDEN,
&CertmeshError::Internal(format!(
"CN mismatch: authenticated as '{}' but requesting hook for '{}'",
caller, request.hostname
)),
);
}
}
const HOOK_FORBIDDEN: &[char] = &[
';', '|', '&', '$', '`', '>', '<', '(', ')', '\n', '\r', '\0', '*', '?', '[', ']', '{',
'}', '~', '%', '!',
];
if request.reload.contains(HOOK_FORBIDDEN) {
return error_response(
StatusCode::BAD_REQUEST,
&CertmeshError::Internal("reload hook contains forbidden characters".into()),
);
}
#[cfg(unix)]
if !request.reload.starts_with('/') {
return error_response(
StatusCode::BAD_REQUEST,
&CertmeshError::Internal("reload hook must be an absolute path".into()),
);
}
#[cfg(windows)]
if !(request.reload.len() >= 3 && request.reload.as_bytes()[1] == b':') {
return error_response(
StatusCode::BAD_REQUEST,
&CertmeshError::Internal("reload hook must be an absolute path".into()),
);
}
let mut roster = state.roster.lock().await;
match roster.find_member_mut(&request.hostname) {
Some(member) => {
member.reload_hook = Some(request.reload.clone());
let roster_clone = roster.clone();
let roster_path = state.paths.roster_path();
drop(roster);
if let Err(e) = tokio::task::spawn_blocking(move || {
crate::roster::save_roster(&roster_clone, &roster_path)
})
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))
.and_then(|r| r.map_err(CertmeshError::Io))
{
tracing::warn!(error = %e, "Failed to save roster after set-hook");
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Failed to save roster: {e}")),
);
}
let resp = crate::protocol::SetHookResponse {
hostname: request.hostname,
reload: request.reload,
};
match serde_json::to_value(&resp) {
Ok(val) => (StatusCode::OK, Json(val)).into_response(),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Serialization error: {e}")),
),
}
}
None => error_response(
StatusCode::NOT_FOUND,
&CertmeshError::Internal("member not found".into()),
),
}
}
#[utoipa::path(post, path = "/create", tag = "certmesh",
summary = "Initialize private CA",
request_body = CreateCaRequest,
responses((status = 200, body = CreateCaResponse)))]
async fn create_handler(
Extension(state): Extension<Arc<CertmeshState>>,
Json(request): Json<CreateCaRequest>,
) -> impl IntoResponse {
let entropy = match decode_hex(&request.entropy_hex) {
Some(bytes) if bytes.len() == 32 => bytes,
Some(bytes) => {
return error_response(
StatusCode::BAD_REQUEST,
&CertmeshError::Internal(format!(
"entropy must be exactly 32 bytes, got {}",
bytes.len()
)),
);
}
None => {
return error_response(
StatusCode::BAD_REQUEST,
&CertmeshError::Internal("invalid hex entropy".to_string()),
);
}
};
if state.paths.is_ca_initialized() {
return error_response(
StatusCode::CONFLICT,
&CertmeshError::Internal("CA is already initialized".to_string()),
);
}
let passphrase_clone = request.passphrase.clone();
let paths_clone = state.paths.clone();
let (ca_state, _master_key) = match tokio::task::spawn_blocking(move || {
crate::ca::create_ca(&passphrase_clone, &entropy, &paths_clone)
})
.await
.map_err(|e| CertmeshError::Internal(format!("CA creation task: {e}")))
.and_then(|r| r)
{
Ok(ca) => ca,
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
};
let ca_fingerprint = crate::ca::ca_fingerprint(&ca_state);
let totp_secret = if let Some(ref hex) = request.totp_secret_hex {
match koi_common::encoding::hex_decode(hex) {
Ok(bytes) => koi_crypto::totp::TotpSecret::from_bytes(bytes),
Err(_) => {
return error_response(
StatusCode::BAD_REQUEST,
&CertmeshError::Internal("totp_secret_hex: invalid hex encoding".into()),
);
}
}
} else {
koi_crypto::totp::generate_secret()
};
let stored = match koi_crypto::auth::store_totp(&totp_secret, &request.passphrase) {
Ok(s) => s,
Err(e) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("auth store: {e}")),
)
}
};
let auth_json = match serde_json::to_string_pretty(&stored) {
Ok(j) => j,
Err(e) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("auth serialize: {e}")),
)
}
};
{
let auth_path = state.paths.auth_path();
let auth_json_clone = auth_json.clone();
if let Err(e) =
tokio::task::spawn_blocking(move || std::fs::write(&auth_path, &auth_json_clone))
.await
.map_err(|e| std::io::Error::other(format!("file I/O: {e}")))
.and_then(|r| r)
{
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &CertmeshError::Io(e));
}
}
let totp_uri = koi_crypto::totp::build_totp_uri(&totp_secret, "Koi Certmesh", "enrollment");
let mut new_roster = crate::roster::Roster::new_with_policy(
request.profile,
request.operator.clone(),
request.enrollment_open,
request.requires_approval,
);
let roster_path = state.paths.roster_path();
{
let roster_clone = new_roster.clone();
let roster_path_clone = roster_path.clone();
if let Err(e) = tokio::task::spawn_blocking(move || {
crate::roster::save_roster(&roster_clone, &roster_path_clone)
})
.await
.map_err(|e| std::io::Error::other(format!("roster save task: {e}")))
.and_then(|r| r)
{
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &CertmeshError::Io(e));
}
}
let local_hostname = hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|_| "localhost".to_string());
let sans = vec![
local_hostname.clone(),
"localhost".to_string(),
"127.0.0.1".to_string(),
"::1".to_string(),
];
match crate::ca::issue_certificate(&ca_state, &local_hostname, &sans) {
Ok(issued) => {
let cert_dir_base = state.paths.certs_dir().join(&local_hostname);
let cert_dir_base_clone = cert_dir_base.clone();
let issued_for_write = issued.clone();
let cert_dir = match tokio::task::spawn_blocking(move || {
crate::certfiles::write_cert_files_to(&cert_dir_base_clone, &issued_for_write)
})
.await
{
Ok(Ok(dir)) => dir,
Ok(Err(e)) => {
tracing::warn!(error = %e, "Could not write CA node cert files");
cert_dir_base
}
Err(e) => {
tracing::warn!(error = %e, "Cert file write task panicked");
cert_dir_base
}
};
let ca_fp = crate::ca::ca_fingerprint(&ca_state);
let member = crate::roster::RosterMember {
hostname: local_hostname.clone(),
role: crate::roster::MemberRole::Primary,
enrolled_at: chrono::Utc::now(),
enrolled_by: request.operator.clone(),
cert_fingerprint: issued.fingerprint,
cert_expires: issued.expires,
cert_sans: sans,
cert_path: cert_dir.display().to_string(),
status: crate::roster::MemberStatus::Active,
reload_hook: None,
last_seen: Some(chrono::Utc::now()),
pinned_ca_fingerprint: Some(ca_fp),
proxy_entries: Vec::new(),
};
new_roster.members.push(member);
{
let roster_clone = new_roster.clone();
let roster_path_clone = roster_path.clone();
if let Err(e) = tokio::task::spawn_blocking(move || {
crate::roster::save_roster(&roster_clone, &roster_path_clone)
})
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))
.and_then(|r| r.map_err(CertmeshError::Io))
{
tracing::warn!(error = %e, "Could not save roster after self-enrollment");
}
}
let _ = crate::audit::append_entry_to(
&state.paths.audit_log_path(),
"member_joined",
&[
("hostname", local_hostname.as_str()),
("role", "primary"),
("approved_by", "self-enroll"),
],
);
tracing::info!(hostname = %local_hostname, "CA node self-enrolled as primary");
}
Err(e) => {
tracing::warn!(error = %e, "Could not self-enroll CA node - roster will be empty");
}
}
if let Err(e) = koi_truststore::install_ca_cert(&ca_state.cert_pem, "koi-certmesh") {
tracing::warn!(error = %e, "Could not install CA cert in trust store");
}
*state.ca.lock().await = Some(ca_state);
*state.auth.lock().await = Some(koi_crypto::auth::AuthState::Totp(totp_secret));
*state.roster.lock().await = new_roster;
*state.profile.lock().await = request.profile;
let _ = crate::audit::append_entry_to(
&state.paths.audit_log_path(),
"pond_initialized",
&[
("profile", &request.profile.to_string()),
("operator", request.operator.as_deref().unwrap_or("none")),
],
);
tracing::info!(profile = %request.profile, "CA initialized via service");
let response = CreateCaResponse {
auth_setup: koi_crypto::auth::AuthSetup::Totp { totp_uri },
ca_fingerprint,
};
match serde_json::to_value(&response) {
Ok(val) => (StatusCode::OK, Json(val)).into_response(),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Serialization error: {e}")),
),
}
}
#[utoipa::path(post, path = "/unlock", tag = "certmesh",
summary = "Decrypt CA key with passphrase",
request_body = UnlockRequest,
responses((status = 200, body = UnlockResponse)))]
async fn unlock_handler(
Extension(state): Extension<Arc<CertmeshState>>,
Json(request): Json<UnlockRequest>,
) -> impl IntoResponse {
let ca_state = match crate::ca::load_ca(&request.passphrase, &state.paths) {
Ok(ca) => ca,
Err(e) => {
let code = koi_common::error::ErrorCode::from(&e);
let status = StatusCode::from_u16(code.http_status())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
return error_response(status, &e);
}
};
let auth_path = state.paths.auth_path();
if auth_path.exists() {
let auth_path_clone = auth_path.clone();
match tokio::task::spawn_blocking(move || std::fs::read_to_string(&auth_path_clone)).await {
Ok(Ok(json)) => match serde_json::from_str::<koi_crypto::auth::StoredAuth>(&json) {
Ok(stored) => match stored.unlock(&request.passphrase) {
Ok(auth_state) => {
*state.auth.lock().await = Some(auth_state);
}
Err(e) => tracing::warn!(error = %e, "Failed to unlock auth credential"),
},
Err(e) => tracing::warn!(error = %e, "Failed to parse auth.json"),
},
Ok(Err(e)) => tracing::warn!(error = %e, "Failed to read auth.json"),
Err(e) => tracing::warn!(error = %e, "Failed to spawn auth.json read task"),
}
}
*state.ca.lock().await = Some(ca_state);
tracing::info!("CA unlocked via service");
let response = UnlockResponse { success: true };
match serde_json::to_value(&response) {
Ok(val) => (StatusCode::OK, Json(val)).into_response(),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Serialization error: {e}")),
),
}
}
#[utoipa::path(post, path = "/rotate-auth", tag = "certmesh",
summary = "Rotate enrollment auth credential",
request_body = RotateAuthRequest,
responses((status = 200, body = RotateAuthResponse)))]
async fn rotate_auth_handler(
Extension(state): Extension<Arc<CertmeshState>>,
Json(request): Json<RotateAuthRequest>,
) -> impl IntoResponse {
let core = CertmeshCore::from_state(Arc::clone(&state));
match core
.rotate_auth(&request.passphrase, request.method.as_deref())
.await
{
Ok(setup) => {
let response = RotateAuthResponse { auth_setup: setup };
match serde_json::to_value(&response) {
Ok(val) => (StatusCode::OK, Json(val)).into_response(),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Serialization error: {e}")),
),
}
}
Err(e) => {
let code = koi_common::error::ErrorCode::from(&e);
let status = StatusCode::from_u16(code.http_status())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
error_response(status, &e)
}
}
}
#[utoipa::path(get, path = "/log", tag = "certmesh",
summary = "Read audit log entries",
responses((status = 200, body = AuditLogResponse)))]
async fn log_handler(Extension(state): Extension<Arc<CertmeshState>>) -> impl IntoResponse {
match crate::audit::read_log_from(&state.paths.audit_log_path()) {
Ok(entries) => {
let response = crate::protocol::AuditLogResponse { entries };
match serde_json::to_value(&response) {
Ok(val) => (StatusCode::OK, Json(val)).into_response(),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Serialization error: {e}")),
),
}
}
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &CertmeshError::Io(e)),
}
}
#[utoipa::path(post, path = "/destroy", tag = "certmesh",
summary = "Destroy all certmesh state",
responses((status = 200, body = DestroyResponse)))]
async fn destroy_handler(Extension(state): Extension<Arc<CertmeshState>>) -> impl IntoResponse {
if let Err(e) = state.destroy().await {
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e);
}
let response = crate::protocol::DestroyResponse { destroyed: true };
match serde_json::to_value(&response) {
Ok(val) => (StatusCode::OK, Json(val)).into_response(),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Serialization error: {e}")),
),
}
}
#[utoipa::path(post, path = "/backup", tag = "certmesh",
summary = "Create encrypted backup",
request_body = BackupRequest,
responses((status = 200, body = BackupResponse)))]
async fn backup_handler(
Extension(state): Extension<Arc<CertmeshState>>,
Json(request): Json<BackupRequest>,
) -> impl IntoResponse {
let core = crate::CertmeshCore::from_state(Arc::clone(&state));
match core
.backup(&request.ca_passphrase, &request.backup_passphrase)
.await
{
Ok(bundle) => {
let response = BackupResponse {
backup_hex: hex_encode(&bundle),
format: "koi-backup-v1".to_string(),
version: crate::backup::BACKUP_VERSION,
};
match serde_json::to_value(&response) {
Ok(val) => (StatusCode::OK, Json(val)).into_response(),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Serialization error: {e}")),
),
}
}
Err(e) => {
let code = koi_common::error::ErrorCode::from(&e);
let status = StatusCode::from_u16(code.http_status())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
error_response(status, &e)
}
}
}
#[utoipa::path(post, path = "/restore", tag = "certmesh",
summary = "Restore from backup",
request_body = RestoreRequest,
responses((status = 200, body = RestoreResponse)))]
async fn restore_handler(
Extension(state): Extension<Arc<CertmeshState>>,
Json(request): Json<RestoreRequest>,
) -> impl IntoResponse {
let backup_bytes = match hex_decode(&request.backup_hex) {
Ok(bytes) => bytes,
Err(e) => return error_response(StatusCode::BAD_REQUEST, &CertmeshError::BackupInvalid(e)),
};
let core = crate::CertmeshCore::from_state(Arc::clone(&state));
match core
.restore(
&backup_bytes,
&request.backup_passphrase,
&request.new_passphrase,
)
.await
{
Ok(()) => {
let response = RestoreResponse { restored: true };
match serde_json::to_value(&response) {
Ok(val) => (StatusCode::OK, Json(val)).into_response(),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Serialization error: {e}")),
),
}
}
Err(e) => {
let code = koi_common::error::ErrorCode::from(&e);
let status = StatusCode::from_u16(code.http_status())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
error_response(status, &e)
}
}
}
#[utoipa::path(post, path = "/revoke", tag = "certmesh",
summary = "Revoke a member certificate",
request_body = RevokeRequest,
responses((status = 200, body = RevokeResponse)))]
async fn revoke_handler(
Extension(state): Extension<Arc<CertmeshState>>,
Json(request): Json<RevokeRequest>,
) -> impl IntoResponse {
let core = crate::CertmeshCore::from_state(Arc::clone(&state));
match core
.revoke_member(
&request.hostname,
request.operator.clone(),
request.reason.clone(),
)
.await
{
Ok(()) => {
let response = RevokeResponse { revoked: true };
match serde_json::to_value(&response) {
Ok(val) => (StatusCode::OK, Json(val)).into_response(),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Serialization error: {e}")),
),
}
}
Err(e) => {
let code = koi_common::error::ErrorCode::from(&e);
let status = StatusCode::from_u16(code.http_status())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
error_response(status, &e)
}
}
}
#[utoipa::path(post, path = "/open-enrollment", tag = "certmesh",
summary = "Open enrollment window",
responses((status = 200, body = PolicySummary)))]
async fn open_enrollment_handler(
Extension(state): Extension<Arc<CertmeshState>>,
body: Option<Json<serde_json::Value>>,
) -> impl IntoResponse {
let deadline = body
.and_then(|Json(v)| v.get("deadline")?.as_str().map(String::from))
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc));
let mut roster = state.roster.lock().await;
roster.open_enrollment(deadline);
let roster_clone = roster.clone();
let roster_path = state.paths.roster_path();
drop(roster);
if let Err(e) =
tokio::task::spawn_blocking(move || crate::roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))
.and_then(|r| r.map_err(CertmeshError::Io))
{
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Failed to save roster: {e}")),
);
}
let _ = crate::audit::append_entry_to(
&state.paths.audit_log_path(),
"enrollment_opened",
&[(
"deadline",
&deadline
.map(|d| d.to_rfc3339())
.unwrap_or_else(|| "none".to_string()),
)],
);
let body = serde_json::json!({
"enrollment_state": "open",
"deadline": deadline.map(|d| d.to_rfc3339()),
});
(StatusCode::OK, Json(body)).into_response()
}
#[utoipa::path(post, path = "/close-enrollment", tag = "certmesh",
summary = "Close enrollment window",
responses((status = 200, body = PolicySummary)))]
async fn close_enrollment_handler(
Extension(state): Extension<Arc<CertmeshState>>,
) -> impl IntoResponse {
let mut roster = state.roster.lock().await;
roster.close_enrollment();
let roster_clone = roster.clone();
let roster_path = state.paths.roster_path();
drop(roster);
if let Err(e) =
tokio::task::spawn_blocking(move || crate::roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))
.and_then(|r| r.map_err(CertmeshError::Io))
{
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Failed to save roster: {e}")),
);
}
let _ = crate::audit::append_entry_to(&state.paths.audit_log_path(), "enrollment_closed", &[]);
let body = serde_json::json!({ "enrollment_state": "closed" });
(StatusCode::OK, Json(body)).into_response()
}
#[utoipa::path(put, path = "/set-policy", tag = "certmesh",
summary = "Set enrollment scope constraints",
request_body = PolicyRequest,
responses((status = 200, body = PolicySummary)))]
async fn set_policy_handler(
Extension(state): Extension<Arc<CertmeshState>>,
Json(request): Json<PolicyRequest>,
) -> impl IntoResponse {
if let Some(ref cidr) = request.allowed_subnet {
if let Err(e) = crate::enrollment::parse_cidr(cidr) {
return error_response(StatusCode::BAD_REQUEST, &e);
}
}
let mut roster = state.roster.lock().await;
roster.metadata.allowed_domain = request.allowed_domain.clone();
roster.metadata.allowed_subnet = request.allowed_subnet.clone();
let roster_clone = roster.clone();
let roster_path = state.paths.roster_path();
drop(roster);
if let Err(e) =
tokio::task::spawn_blocking(move || crate::roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))
.and_then(|r| r.map_err(CertmeshError::Io))
{
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Failed to save roster: {e}")),
);
}
let _ = crate::audit::append_entry_to(
&state.paths.audit_log_path(),
"policy_updated",
&[
(
"allowed_domain",
request.allowed_domain.as_deref().unwrap_or("none"),
),
(
"allowed_subnet",
request.allowed_subnet.as_deref().unwrap_or("none"),
),
],
);
let body = serde_json::json!({
"allowed_domain": request.allowed_domain,
"allowed_subnet": request.allowed_subnet,
});
(StatusCode::OK, Json(body)).into_response()
}
#[utoipa::path(get, path = "/compliance", tag = "certmesh",
summary = "Compliance summary",
responses((status = 200, body = ComplianceResponse)))]
async fn compliance_handler(Extension(state): Extension<Arc<CertmeshState>>) -> impl IntoResponse {
let roster = state.roster.lock().await;
let profile = roster.metadata.trust_profile;
let policy = PolicySummary {
enrollment_state: roster.metadata.enrollment_state.clone(),
enrollment_deadline: roster.metadata.enrollment_deadline.map(|d| d.to_rfc3339()),
allowed_domain: roster.metadata.allowed_domain.clone(),
allowed_subnet: roster.metadata.allowed_subnet.clone(),
profile,
requires_approval: roster.requires_approval(),
};
drop(roster);
let audit_entries = crate::audit::read_log_from(&state.paths.audit_log_path())
.map(|content| content.lines().filter(|l| !l.trim().is_empty()).count())
.unwrap_or(0);
let response = ComplianceResponse {
policy,
audit_entries,
};
match serde_json::to_value(&response) {
Ok(val) => (StatusCode::OK, Json(val)).into_response(),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Serialization error: {e}")),
),
}
}
#[utoipa::path(post, path = "/promote", tag = "certmesh",
summary = "Promote standby CA (key transfer)",
request_body = PromoteRequest,
responses((status = 200, body = PromoteResponse)))]
async fn promote_handler(
Extension(state): Extension<Arc<CertmeshState>>,
client_cn: Option<Extension<ClientCn>>,
Json(request): Json<PromoteRequest>,
) -> impl IntoResponse {
if let Some(Extension(ClientCn(ref caller))) = client_cn {
tracing::info!(%caller, "promote requested by authenticated member");
}
let ca_guard = state.ca.lock().await;
let ca = match ca_guard.as_ref() {
Some(ca) => ca,
None => {
return if state.paths.is_ca_initialized() {
error_response(StatusCode::SERVICE_UNAVAILABLE, &CertmeshError::CaLocked)
} else {
error_response(
StatusCode::SERVICE_UNAVAILABLE,
&CertmeshError::CaNotInitialized,
)
};
}
};
let auth_guard = state.auth.lock().await;
let auth_state = match auth_guard.as_ref() {
Some(s) => s,
None => {
return error_response(StatusCode::SERVICE_UNAVAILABLE, &CertmeshError::CaLocked);
}
};
let mut rate_limiter = state.rate_limiter.lock().await;
let adapter = koi_crypto::auth::adapter_for(auth_state);
let challenge_guard = state.pending_challenge.lock().await;
let challenge = challenge_guard
.as_ref()
.cloned()
.unwrap_or(koi_crypto::auth::AuthChallenge::Totp);
let valid = adapter
.verify(auth_state, &challenge, &request.auth)
.unwrap_or(false);
match rate_limiter.check_and_record(valid) {
Ok(()) => {}
Err(koi_crypto::totp::RateLimitError::LockedOut { remaining_secs }) => {
return error_response(
StatusCode::TOO_MANY_REQUESTS,
&CertmeshError::RateLimited { remaining_secs },
);
}
Err(koi_crypto::totp::RateLimitError::InvalidCode { .. }) => {
return error_response(StatusCode::UNAUTHORIZED, &CertmeshError::InvalidAuth);
}
}
let roster = state.roster.lock().await;
let Some(client_pk) = request.ephemeral_public.as_ref() else {
return error_response(
StatusCode::BAD_REQUEST,
&CertmeshError::Internal("ephemeral_public is required for promotion".into()),
);
};
match crate::failover::prepare_promotion(ca, auth_state, &roster, client_pk) {
Ok(response) => match serde_json::to_value(&response) {
Ok(val) => {
let _ = crate::audit::append_entry_to(
&state.paths.audit_log_path(),
"promotion_prepared",
&[],
);
(StatusCode::OK, Json(val)).into_response()
}
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Serialization error: {e}")),
),
},
Err(e) => {
let code = koi_common::error::ErrorCode::from(&e);
let status = StatusCode::from_u16(code.http_status())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
error_response(status, &e)
}
}
}
#[utoipa::path(post, path = "/renew", tag = "certmesh",
summary = "Trigger certificate renewal",
request_body = RenewRequest,
responses((status = 200, body = RenewResponse)))]
async fn renew_handler(
Extension(state): Extension<Arc<CertmeshState>>,
client_cn: Option<Extension<ClientCn>>,
Json(request): Json<RenewRequest>,
) -> impl IntoResponse {
if let Some(Extension(ClientCn(ref caller))) = client_cn {
if caller != &request.hostname {
return error_response(
StatusCode::FORBIDDEN,
&CertmeshError::Internal(format!(
"CN mismatch: authenticated as '{}' but renewing for '{}'",
caller, request.hostname
)),
);
}
}
let issued = crate::ca::IssuedCert {
cert_pem: request.cert_pem,
key_pem: request.key_pem,
ca_pem: request.ca_pem,
fullchain_pem: request.fullchain_pem,
fingerprint: request.fingerprint.clone(),
expires: chrono::DateTime::parse_from_rfc3339(&request.expires)
.map(|dt| dt.with_timezone(&chrono::Utc))
.unwrap_or_else(|_| chrono::Utc::now()),
};
let cert_dir = state.paths.certs_dir().join(&request.hostname);
if let Err(e) = crate::certfiles::write_cert_files_to(&cert_dir, &issued) {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::RenewalFailed {
hostname: request.hostname,
reason: format!("failed to write cert files: {e}"),
},
);
}
let mut roster = state.roster.lock().await;
if let Some(member) = roster.find_member_mut(&request.hostname) {
member.cert_fingerprint = issued.fingerprint.clone();
member.cert_expires = issued.expires;
}
let hook_result = roster
.find_member(&request.hostname)
.and_then(|m| m.reload_hook.as_ref())
.map(|hook| crate::lifecycle::execute_reload_hook(hook));
let response = RenewResponse {
hostname: request.hostname.clone(),
renewed: true,
hook_result,
};
match serde_json::to_value(&response) {
Ok(val) => (StatusCode::OK, Json(val)).into_response(),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Serialization error: {e}")),
),
}
}
#[utoipa::path(get, path = "/roster", tag = "certmesh",
summary = "Get signed roster manifest",
responses((status = 200, body = RosterManifest)))]
async fn roster_handler(
Extension(state): Extension<Arc<CertmeshState>>,
client_cn: Option<Extension<ClientCn>>,
) -> impl IntoResponse {
if let Some(Extension(ClientCn(ref caller))) = client_cn {
tracing::debug!(%caller, "roster requested by authenticated member");
}
let ca_guard = state.ca.lock().await;
let ca = match ca_guard.as_ref() {
Some(ca) => ca,
None => {
return if state.paths.is_ca_initialized() {
error_response(StatusCode::SERVICE_UNAVAILABLE, &CertmeshError::CaLocked)
} else {
error_response(
StatusCode::SERVICE_UNAVAILABLE,
&CertmeshError::CaNotInitialized,
)
};
}
};
let roster = state.roster.lock().await;
match crate::failover::build_signed_manifest(ca, &roster) {
Ok(manifest) => match serde_json::to_value(&manifest) {
Ok(val) => (StatusCode::OK, Json(val)).into_response(),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Serialization error: {e}")),
),
},
Err(e) => {
let code = koi_common::error::ErrorCode::from(&e);
let status = StatusCode::from_u16(code.http_status())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
error_response(status, &e)
}
}
}
#[utoipa::path(post, path = "/health", tag = "certmesh",
summary = "Member health heartbeat",
request_body = HealthRequest,
responses((status = 200, body = HealthResponse)))]
async fn health_handler(
Extension(state): Extension<Arc<CertmeshState>>,
client_cn: Option<Extension<ClientCn>>,
Json(request): Json<HealthRequest>,
) -> impl IntoResponse {
if let Some(Extension(ClientCn(ref caller))) = client_cn {
if caller != &request.hostname {
return error_response(
StatusCode::FORBIDDEN,
&CertmeshError::Internal(format!(
"CN mismatch: authenticated as '{}' but reporting health for '{}'",
caller, request.hostname
)),
);
}
}
let ca_guard = state.ca.lock().await;
let ca = match ca_guard.as_ref() {
Some(ca) => ca,
None => {
return if state.paths.is_ca_initialized() {
error_response(StatusCode::SERVICE_UNAVAILABLE, &CertmeshError::CaLocked)
} else {
error_response(
StatusCode::SERVICE_UNAVAILABLE,
&CertmeshError::CaNotInitialized,
)
};
}
};
let current_fp = crate::ca::ca_fingerprint(ca);
let valid =
crate::health::validate_pinned_fingerprint(¤t_fp, &request.pinned_ca_fingerprint);
let mut roster = state.roster.lock().await;
roster.touch_member(&request.hostname);
let roster_clone = roster.clone();
let roster_path = state.paths.roster_path();
drop(roster);
if let Err(e) =
tokio::task::spawn_blocking(move || crate::roster::save_roster(&roster_clone, &roster_path))
.await
.map_err(|e| CertmeshError::Internal(format!("roster save task: {e}")))
.and_then(|r| r.map_err(CertmeshError::Io))
{
tracing::warn!(error = %e, "Failed to save roster after health heartbeat");
}
let response = HealthResponse {
valid,
ca_fingerprint: current_fp,
};
match serde_json::to_value(&response) {
Ok(val) => (StatusCode::OK, Json(val)).into_response(),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&CertmeshError::Internal(format!("Serialization error: {e}")),
),
}
}
fn decode_hex(hex: &str) -> Option<Vec<u8>> {
if !hex.len().is_multiple_of(2) {
return None;
}
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
.collect()
}
fn error_response(status: StatusCode, error: &CertmeshError) -> axum::response::Response {
let code = koi_common::error::ErrorCode::from(error);
koi_common::http::error_response_with_status(status, code, error.to_string())
}
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
join_handler,
status_handler,
set_hook_handler,
promote_handler,
renew_handler,
roster_handler,
health_handler,
create_handler,
unlock_handler,
rotate_auth_handler,
log_handler,
destroy_handler,
backup_handler,
restore_handler,
revoke_handler,
compliance_handler,
open_enrollment_handler,
close_enrollment_handler,
set_policy_handler,
),
components(schemas(
crate::protocol::JoinRequest,
crate::protocol::JoinResponse,
crate::protocol::CertmeshStatus,
crate::protocol::MemberSummary,
crate::protocol::SetHookRequest,
crate::protocol::SetHookResponse,
crate::protocol::CreateCaRequest,
crate::protocol::CreateCaResponse,
crate::protocol::UnlockRequest,
crate::protocol::UnlockResponse,
crate::protocol::RotateAuthRequest,
crate::protocol::RotateAuthResponse,
crate::protocol::AuditLogResponse,
crate::protocol::DestroyResponse,
crate::protocol::BackupRequest,
crate::protocol::BackupResponse,
crate::protocol::RestoreRequest,
crate::protocol::RestoreResponse,
crate::protocol::RevokeRequest,
crate::protocol::RevokeResponse,
crate::protocol::PolicyRequest,
crate::protocol::OpenEnrollmentRequest,
crate::protocol::PolicySummary,
crate::protocol::ComplianceResponse,
crate::protocol::PromoteRequest,
crate::protocol::PromoteResponse,
crate::protocol::RenewRequest,
crate::protocol::RenewResponse,
crate::protocol::HookResult,
crate::protocol::RosterManifest,
crate::protocol::HealthRequest,
crate::protocol::HealthResponse,
crate::profiles::TrustProfile,
crate::roster::EnrollmentState,
koi_crypto::keys::EncryptedKey,
))
)]
pub struct CertmeshApiDoc;
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
fn test_extension() -> Arc<CertmeshState> {
use crate::certmesh_paths::CertmeshPaths;
use crate::profiles::TrustProfile;
use crate::roster::{EnrollmentState, Roster, RosterMetadata};
use koi_crypto::totp::RateLimiter;
let data_dir = koi_common::test::ensure_data_dir("koi-certmesh-http-tests");
Arc::new(CertmeshState {
paths: CertmeshPaths::with_data_dir(data_dir),
ca: tokio::sync::Mutex::new(None),
roster: tokio::sync::Mutex::new(Roster {
metadata: RosterMetadata {
created_at: chrono::Utc::now(),
trust_profile: TrustProfile::JustMe,
operator: None,
requires_approval: Some(false),
enrollment_state: EnrollmentState::Closed,
enrollment_deadline: None,
allowed_domain: None,
allowed_subnet: None,
},
members: vec![],
revocation_list: vec![],
}),
auth: tokio::sync::Mutex::new(None),
pending_challenge: tokio::sync::Mutex::new(None),
rate_limiter: tokio::sync::Mutex::new(RateLimiter::new()),
profile: tokio::sync::Mutex::new(TrustProfile::JustMe),
approval_tx: tokio::sync::Mutex::new(None),
event_tx: tokio::sync::broadcast::channel(16).0,
})
}
#[test]
fn certmesh_state_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<CertmeshState>();
}
#[tokio::test]
async fn status_endpoint_returns_200() {
let app = routes(test_extension());
let req = Request::get("/status").body(Body::empty()).unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn status_endpoint_returns_json() {
let app = routes(test_extension());
let req = Request::get("/status").body(Body::empty()).unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("ca_initialized").is_some() || json.get("ca_locked").is_some());
}
#[tokio::test]
async fn join_without_ca_returns_503() {
let app = routes(test_extension());
let req = Request::post("/join")
.header("content-type", "application/json")
.body(Body::from(
r#"{"hostname":"stone-05","auth":{"method":"totp","code":"123456"}}"#,
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn promote_without_ca_returns_503() {
let app = inter_node_routes(test_extension());
let req = Request::post("/promote")
.header("content-type", "application/json")
.body(Body::from(r#"{"auth":{"method":"totp","code":"654321"}}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn roster_without_ca_returns_503() {
let app = routes(test_extension());
let req = Request::get("/roster").body(Body::empty()).unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn health_without_ca_returns_503() {
let app = routes(test_extension());
let req = Request::post("/health")
.header("content-type", "application/json")
.body(Body::from(
r#"{"hostname":"stone-01","pinned_ca_fingerprint":"abc"}"#,
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn set_hook_unknown_member_returns_404() {
let app = routes(test_extension());
let reload = if cfg!(unix) {
"/usr/bin/systemctl restart nginx"
} else {
"C:\\Windows\\System32\\cmd.exe /c restart"
};
let body = serde_json::json!({"hostname": "nobody", "reload": reload}).to_string();
let req = Request::put("/set-hook")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn error_response_includes_error_code() {
let resp = error_response(
StatusCode::SERVICE_UNAVAILABLE,
&CertmeshError::CaNotInitialized,
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("error").is_some());
assert!(json.get("message").is_some());
}
#[tokio::test]
async fn nonexistent_route_returns_404() {
let app = routes(test_extension());
let req = Request::get("/nonexistent").body(Body::empty()).unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
fn assert_ca_unavailable_error(json: &serde_json::Value) {
let error = json.get("error").unwrap().as_str().unwrap();
assert!(
error == "ca_locked" || error == "ca_not_initialized",
"expected ca_locked or ca_not_initialized, got: {error}"
);
assert!(json.get("message").is_some());
}
#[tokio::test]
async fn join_without_ca_body_has_error_code() {
let app = routes(test_extension());
let req = Request::post("/join")
.header("content-type", "application/json")
.body(Body::from(
r#"{"hostname":"stone-05","auth":{"method":"totp","code":"123456"}}"#,
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_ca_unavailable_error(&json);
}
#[tokio::test]
async fn promote_without_ca_body_has_error_code() {
let app = inter_node_routes(test_extension());
let req = Request::post("/promote")
.header("content-type", "application/json")
.body(Body::from(r#"{"auth":{"method":"totp","code":"654321"}}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_ca_unavailable_error(&json);
}
#[tokio::test]
async fn roster_without_ca_body_has_error_code() {
let app = routes(test_extension());
let req = Request::get("/roster").body(Body::empty()).unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_ca_unavailable_error(&json);
}
#[tokio::test]
async fn health_without_ca_body_has_error_code() {
let app = routes(test_extension());
let req = Request::post("/health")
.header("content-type", "application/json")
.body(Body::from(
r#"{"hostname":"stone-01","pinned_ca_fingerprint":"abc"}"#,
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_ca_unavailable_error(&json);
}
#[tokio::test]
async fn status_body_has_expected_fields() {
let app = routes(test_extension());
let req = Request::get("/status").body(Body::empty()).unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json.get("ca_initialized").is_some(),
"missing ca_initialized"
);
assert!(json.get("ca_locked").is_some(), "missing ca_locked");
assert!(json.get("profile").is_some(), "missing profile");
assert!(
json.get("enrollment_state").is_some(),
"missing enrollment_state"
);
assert!(json.get("member_count").is_some(), "missing member_count");
assert!(json.get("members").is_some(), "missing members");
}
#[tokio::test]
async fn set_hook_not_found_body_has_error() {
let app = routes(test_extension());
let reload = if cfg!(unix) {
"/usr/bin/systemctl restart nginx"
} else {
"C:\\Windows\\System32\\cmd.exe /c restart"
};
let body = serde_json::json!({"hostname": "nobody", "reload": reload}).to_string();
let req = Request::put("/set-hook")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("error").is_some(), "missing error field");
let msg = json.get("message").unwrap().as_str().unwrap();
assert!(
msg.contains("not found"),
"message should indicate not found: {msg}"
);
}
#[tokio::test]
async fn set_hook_relative_path_returns_400() {
let app = routes(test_extension());
let req = Request::put("/set-hook")
.header("content-type", "application/json")
.body(Body::from(
r#"{"hostname":"stone-01","reload":"systemctl restart nginx"}"#,
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn open_enrollment_returns_200() {
let app = routes(test_extension());
let req = Request::post("/open-enrollment")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
json.get("enrollment_state").unwrap().as_str().unwrap(),
"open"
);
}
#[tokio::test]
async fn open_enrollment_with_deadline() {
let app = routes(test_extension());
let req = Request::post("/open-enrollment")
.header("content-type", "application/json")
.body(Body::from(r#"{"deadline":"2026-12-31T23:59:59Z"}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("deadline").unwrap().as_str().is_some());
}
#[tokio::test]
async fn open_enrollment_accepts_empty_body() {
let app = routes(test_extension());
let req = Request::post("/open-enrollment")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn close_enrollment_returns_200() {
let app = routes(test_extension());
let req = Request::post("/close-enrollment")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
json.get("enrollment_state").unwrap().as_str().unwrap(),
"closed"
);
}
#[tokio::test]
async fn set_policy_returns_200() {
let app = routes(test_extension());
let req = Request::put("/set-policy")
.header("content-type", "application/json")
.body(Body::from(
r#"{"allowed_domain":"lab.local","allowed_subnet":"192.168.1.0/24"}"#,
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
json.get("allowed_domain").unwrap().as_str().unwrap(),
"lab.local"
);
assert_eq!(
json.get("allowed_subnet").unwrap().as_str().unwrap(),
"192.168.1.0/24"
);
}
#[tokio::test]
async fn set_policy_invalid_cidr_returns_400() {
let app = routes(test_extension());
let req = Request::put("/set-policy")
.header("content-type", "application/json")
.body(Body::from(r#"{"allowed_subnet":"not-a-cidr"}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn set_policy_invalid_cidr_ip_returns_400() {
let app = routes(test_extension());
let req = Request::put("/set-policy")
.header("content-type", "application/json")
.body(Body::from(r#"{"allowed_subnet":"xyz.abc/24"}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn set_policy_clears_with_nulls() {
let app = routes(test_extension());
let req = Request::put("/set-policy")
.header("content-type", "application/json")
.body(Body::from(r#"{}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("allowed_domain").unwrap().is_null());
assert!(json.get("allowed_subnet").unwrap().is_null());
}
#[tokio::test]
async fn create_with_bad_entropy_returns_400() {
let app = routes(test_extension());
let req = Request::post("/create")
.header("content-type", "application/json")
.body(Body::from(
r#"{"passphrase":"test","entropy_hex":"bad","profile":"just_me"}"#,
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_with_short_entropy_returns_400() {
let app = routes(test_extension());
let req = Request::post("/create")
.header("content-type", "application/json")
.body(Body::from(r#"{"passphrase":"test","entropy_hex":"00112233445566778899aabbccddeeff","profile":"just_me"}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn unlock_with_wrong_passphrase_returns_error() {
let app = routes(test_extension());
let req = Request::post("/unlock")
.header("content-type", "application/json")
.body(Body::from(r#"{"passphrase":"wrong-passphrase"}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert!(resp.status().is_client_error() || resp.status().is_server_error());
}
#[tokio::test]
async fn rotate_auth_without_ca_returns_503() {
let app = routes(test_extension());
let req = Request::post("/rotate-auth")
.header("content-type", "application/json")
.body(Body::from(r#"{"passphrase":"test"}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn log_endpoint_returns_200() {
let app = routes(test_extension());
let req = Request::get("/log").body(Body::empty()).unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn log_endpoint_body_has_entries_field() {
let app = routes(test_extension());
let req = Request::get("/log").body(Body::empty()).unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json.get("entries").is_some(),
"response should have 'entries' field"
);
}
#[tokio::test]
async fn destroy_endpoint_returns_200() {
let app = routes(test_extension());
let req = Request::post("/destroy").body(Body::empty()).unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("destroyed").unwrap().as_bool().unwrap());
}
#[tokio::test]
async fn decode_hex_valid() {
assert_eq!(decode_hex("0011ff"), Some(vec![0x00, 0x11, 0xff]));
}
#[tokio::test]
async fn decode_hex_invalid() {
assert_eq!(decode_hex("zz"), None);
}
#[tokio::test]
async fn decode_hex_odd_length() {
assert_eq!(decode_hex("abc"), None);
}
}