chio_http_core/emergency.rs
1//! Emergency kill-switch HTTP surface.
2//!
3//! This module is intentionally substrate-agnostic -- `chio-http-core`
4//! does not embed an HTTP server. It exposes:
5//!
6//! - Route constants used by every substrate adapter
7//! (`chio-tower`, `chio-api-protect`, hosted sidecars).
8//! - Request/response DTOs that serialize into the wire shapes
9//! documented in `STRUCTURAL-SECURITY-FIXES.md` section 5.4.
10//! - Pure handler functions that take parsed inputs and return a
11//! structured response. Each substrate adapter calls the handler
12//! from its own framework route, preserving framework-native
13//! streaming, tracing, and error-mapping behavior.
14//!
15//! Authentication: the handlers require an `X-Admin-Token` header
16//! whose value matches the string configured on [`EmergencyAdmin`].
17//! The check lives inside the handler, so adapters do not need a
18//! separate middleware layer. Adapters that already have their own
19//! auth middleware can either pass the caller's bearer token through
20//! as the admin token (when configured that way) or short-circuit the
21//! `expected_admin_token` check.
22
23use std::sync::Arc;
24
25use chio_kernel::{ChioKernel, KernelError};
26use serde::{Deserialize, Serialize};
27
28use crate::routes::{
29 EMERGENCY_ADMIN_TOKEN_HEADER, EMERGENCY_RESUME_PATH, EMERGENCY_STATUS_PATH, EMERGENCY_STOP_PATH,
30};
31
32/// Canonical JSON body for `POST /emergency-stop`.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct EmergencyStopRequest {
35 /// Operator-supplied rationale for the kill switch. Recorded on
36 /// the kernel and surfaced via `/emergency-status` so runbooks
37 /// can correlate the halt with an incident.
38 pub reason: String,
39}
40
41/// Wire response for `POST /emergency-stop`.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct EmergencyStopResponse {
44 /// Always `true` for a successful stop.
45 pub stopped: bool,
46}
47
48/// Wire response for `POST /emergency-resume`.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct EmergencyResumeResponse {
51 /// Always `false` for a successful resume.
52 pub stopped: bool,
53}
54
55/// Wire response for `GET /emergency-status`.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct EmergencyStatusResponse {
58 /// Whether the kill switch is currently engaged.
59 pub stopped: bool,
60
61 /// RFC 3339 / ISO 8601 timestamp of the stop. `None` when the
62 /// kernel has never been stopped or is currently resumed.
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub since: Option<String>,
65
66 /// Operator-supplied reason for the current stop. `None` when
67 /// the kernel is running normally.
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub reason: Option<String>,
70}
71
72/// Errors returned by the emergency handlers. Each variant maps
73/// cleanly onto an HTTP status code via [`EmergencyHandlerError::status`].
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum EmergencyHandlerError {
76 /// `X-Admin-Token` header missing or does not match the configured value.
77 /// Returns HTTP 401 and a minimal JSON error body.
78 Unauthorized,
79
80 /// Request body could not be parsed as the expected JSON shape. The
81 /// operator supplied bad input; returns HTTP 400.
82 BadRequest(String),
83
84 /// Kernel-side failure while toggling the kill switch. Fail-closed:
85 /// the handler has already engaged the stop (see
86 /// [`handle_emergency_stop`]); this error just reports what went wrong
87 /// after the flag flipped. Returns HTTP 500.
88 Kernel(String),
89}
90
91impl EmergencyHandlerError {
92 /// HTTP status code for this error.
93 #[must_use]
94 pub fn status(&self) -> u16 {
95 match self {
96 Self::Unauthorized => 401,
97 Self::BadRequest(_) => 400,
98 Self::Kernel(_) => 500,
99 }
100 }
101
102 /// Stable error code string (snake_case) for machine-readable error
103 /// payloads. Adapters serialize `{ "error": "<code>", "message": ... }`.
104 #[must_use]
105 pub fn code(&self) -> &'static str {
106 match self {
107 Self::Unauthorized => "unauthorized",
108 Self::BadRequest(_) => "bad_request",
109 Self::Kernel(_) => "internal_error",
110 }
111 }
112
113 /// Human-readable message.
114 #[must_use]
115 pub fn message(&self) -> String {
116 match self {
117 Self::Unauthorized => "missing or invalid X-Admin-Token header".to_string(),
118 Self::BadRequest(reason) | Self::Kernel(reason) => reason.clone(),
119 }
120 }
121
122 /// Wire body for this error response.
123 #[must_use]
124 pub fn body(&self) -> serde_json::Value {
125 serde_json::json!({
126 "error": self.code(),
127 "message": self.message(),
128 })
129 }
130}
131
132impl From<KernelError> for EmergencyHandlerError {
133 fn from(error: KernelError) -> Self {
134 Self::Kernel(error.to_string())
135 }
136}
137
138/// Admin handle bound to a kernel and a configured admin token.
139///
140/// The handle is cheap to clone (`Arc<ChioKernel>` + short strings) and
141/// safe to share across threads. It holds the only reference to the
142/// kernel needed by the emergency endpoints, so substrate adapters can
143/// construct one `EmergencyAdmin` at startup and pass it to every
144/// route registration.
145#[derive(Clone)]
146pub struct EmergencyAdmin {
147 kernel: Arc<ChioKernel>,
148 expected_admin_token: String,
149}
150
151impl std::fmt::Debug for EmergencyAdmin {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 f.debug_struct("EmergencyAdmin")
154 .field("admin_token_len", &self.expected_admin_token.len())
155 .field(
156 "admin_token_usable",
157 &admin_token_usable(&self.expected_admin_token),
158 )
159 .finish_non_exhaustive()
160 }
161}
162
163impl EmergencyAdmin {
164 /// Create a new admin handle. `expected_admin_token` must match the
165 /// value of the `X-Admin-Token` header on every incoming admin call.
166 /// The token is compared with `==`; adapters that need constant-time
167 /// comparison can wrap the check in their own middleware before
168 /// delegating to the handler.
169 #[must_use]
170 pub fn new(kernel: Arc<ChioKernel>, expected_admin_token: String) -> Self {
171 Self {
172 kernel,
173 expected_admin_token,
174 }
175 }
176
177 /// Shared kernel reference, primarily for tests and for adapters
178 /// that want to re-use the same `Arc<ChioKernel>` for other routes.
179 #[must_use]
180 pub fn kernel(&self) -> &Arc<ChioKernel> {
181 &self.kernel
182 }
183
184 fn authorize(&self, admin_token: Option<&str>) -> Result<(), EmergencyHandlerError> {
185 if !admin_token_usable(&self.expected_admin_token) {
186 return Err(EmergencyHandlerError::Unauthorized);
187 }
188 match admin_token {
189 Some(token) if token == self.expected_admin_token => Ok(()),
190 _ => Err(EmergencyHandlerError::Unauthorized),
191 }
192 }
193}
194
195fn admin_token_usable(token: &str) -> bool {
196 !token.trim().is_empty() && !token.chars().any(char::is_control)
197}
198
199/// Handler for `POST /emergency-stop`.
200///
201/// Fail-closed: if the token check passes, the kernel's `emergency_stop`
202/// is invoked immediately. If it returns an error, the flag has still
203/// been set (the kernel flips its atomic before any fallible step) so
204/// the system is left in the safer stopped state even when the caller
205/// sees a 500.
206pub fn handle_emergency_stop(
207 admin: &EmergencyAdmin,
208 admin_token: Option<&str>,
209 body: &[u8],
210) -> Result<EmergencyStopResponse, EmergencyHandlerError> {
211 admin.authorize(admin_token)?;
212
213 let parsed: EmergencyStopRequest = serde_json::from_slice(body).map_err(|error| {
214 EmergencyHandlerError::BadRequest(format!("invalid emergency-stop request body: {error}"))
215 })?;
216
217 admin.kernel.emergency_stop(&parsed.reason)?;
218
219 Ok(EmergencyStopResponse { stopped: true })
220}
221
222/// Handler for `POST /emergency-resume`.
223///
224/// Body is ignored (any bytes, including empty, are accepted) so
225/// adapters can keep wiring identical to `POST /emergency-stop`.
226pub fn handle_emergency_resume(
227 admin: &EmergencyAdmin,
228 admin_token: Option<&str>,
229 _body: &[u8],
230) -> Result<EmergencyResumeResponse, EmergencyHandlerError> {
231 admin.authorize(admin_token)?;
232 admin.kernel.emergency_resume()?;
233 Ok(EmergencyResumeResponse { stopped: false })
234}
235
236/// Handler for `GET /emergency-status`.
237pub fn handle_emergency_status(
238 admin: &EmergencyAdmin,
239 admin_token: Option<&str>,
240) -> Result<EmergencyStatusResponse, EmergencyHandlerError> {
241 admin.authorize(admin_token)?;
242
243 let stopped = admin.kernel.is_emergency_stopped();
244 let since = admin
245 .kernel
246 .emergency_stopped_since()
247 .and_then(|unix_secs| {
248 // i64 is what chrono expects; secs fit comfortably for any
249 // realistic operator timestamp.
250 let secs = i64::try_from(unix_secs).ok()?;
251 chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0).map(|dt| dt.to_rfc3339())
252 });
253 let reason = admin.kernel.emergency_stop_reason();
254
255 Ok(EmergencyStatusResponse {
256 stopped,
257 since,
258 reason,
259 })
260}
261
262// Path constants re-exported at module scope so adapters can write
263// `emergency::EMERGENCY_STOP_PATH`.
264pub use crate::routes::EMERGENCY_ADMIN_TOKEN_HEADER as ADMIN_TOKEN_HEADER;
265pub use crate::routes::EMERGENCY_RESUME_PATH as RESUME_PATH;
266pub use crate::routes::EMERGENCY_STATUS_PATH as STATUS_PATH;
267pub use crate::routes::EMERGENCY_STOP_PATH as STOP_PATH;
268
269// Internal compile-time sanity: the module-level re-exports above must
270// remain in sync with `routes::`. A `const _` guard catches drift if
271// someone renames either set.
272const _: &str = EMERGENCY_STOP_PATH;
273const _: &str = EMERGENCY_RESUME_PATH;
274const _: &str = EMERGENCY_STATUS_PATH;
275const _: &str = EMERGENCY_ADMIN_TOKEN_HEADER;