1use std::collections::{BTreeMap, HashMap};
8use std::convert::Infallible;
9use std::fmt;
10use std::net::{IpAddr, Ipv4Addr, SocketAddr};
11use std::panic::AssertUnwindSafe;
12use std::sync::atomic::{AtomicI64, Ordering};
13use std::sync::{Arc, Mutex, MutexGuard};
14use std::time::{Duration, Instant};
15
16use axum::body::Bytes;
17use axum::extract::rejection::{BytesRejection, QueryRejection};
18use axum::extract::{ConnectInfo, DefaultBodyLimit, FromRequestParts, Path, Query, Request, State};
19use axum::http::request::Parts;
20use axum::http::{HeaderValue, Method, StatusCode, header};
21use axum::middleware::{self, Next};
22use axum::response::sse::{KeepAlive, Sse};
23use axum::response::{IntoResponse, Response};
24use axum::routing::{get, post};
25use axum::{Json, Router};
26use chrono::{DateTime, Utc};
27use futures::FutureExt;
28use serde_json::{Map, Value, json};
29use talon_core::audit::{SharedAuditSink, event};
30use talon_core::channel::ChannelHost;
31use talon_core::time::now_iso;
32use talon_core::types::{
33 AdditionalMount, ChatMetadata, ContainerConfig, NewMessage, RegisteredGroup,
34};
35use tokio::sync::Mutex as AsyncMutex;
36
37use crate::auth::{AuthThrottle, KeyVerifier, SecurityKeyVerifier};
38use crate::config::HttpConfig;
39use crate::files;
40use crate::investigate::{
41 self, InvestigationRequest, investigation_prompt, investigation_record, investigation_task,
42 is_duplicate, job_id,
43};
44use crate::lanes::{LaneIds, OpenError, StreamRegistry, USER_SESSION_KEY_PREFIX, UserId};
45use crate::metrics::{self, Gauges, Metrics};
46use crate::palace::{
47 DisabledPalaceRunner, PalaceError, PalaceRunner, ScriptPalaceRunner, add_lesson_request,
48 forget_request, search_request,
49};
50use crate::sse::{WriterGuard, WriterStream};
51use crate::store::{HttpStore, run_blocking};
52use crate::validate::{
53 LESSON_TYPES, MAX_LESSON_CHARS, MAX_QUERY_CHARS, is_valid_customer_code, is_valid_drawer_id,
54 is_valid_investigation_id, is_valid_lesson_type, is_valid_template_name, is_valid_triggered_by,
55 parse_alert_id, sanitize_label, truncate_chars,
56};
57
58const COPILOT_MOUNTS: &[(&str, bool)] = &[
64 ("siem", true),
65 ("mysql", true),
66 ("copilot-mcp", true),
67 ("wazuh-mcp", true),
68 ("velociraptor-mcp", true),
69 ("mempalace", true),
70 ("mempalace-data", false),
71 ("shuffle-mcp", true),
72 ("cve-mcp", true),
73];
74const MAX_LABEL_CHARS: usize = 64;
76const DEFAULT_INVESTIGATIONS_LIMIT: usize = 50;
78const MAX_INVESTIGATIONS_LIMIT: usize = 500;
79const METRICS_INVESTIGATION_WINDOW: usize = 1_000;
81const MAX_AUDIT_PATH_CHARS: usize = 256;
83
84pub struct HttpDeps {
86 pub host: Arc<dyn ChannelHost>,
88 pub store: Arc<dyn HttpStore>,
90 pub audit: SharedAuditSink,
92 pub verifier: Arc<dyn KeyVerifier>,
94 pub palace: Arc<dyn PalaceRunner>,
96}
97
98impl HttpDeps {
99 pub fn new(
109 config: &HttpConfig,
110 host: Arc<dyn ChannelHost>,
111 store: Arc<dyn HttpStore>,
112 audit: SharedAuditSink,
113 ) -> Self {
114 Self {
115 host,
116 store,
117 audit,
118 verifier: Arc::new(SecurityKeyVerifier),
119 palace: default_palace_runner(config),
120 }
121 }
122
123 pub fn with_palace(mut self, palace: Arc<dyn PalaceRunner>) -> Self {
126 self.palace = palace;
127 self
128 }
129}
130
131fn default_palace_runner(config: &HttpConfig) -> Arc<dyn PalaceRunner> {
134 if !config.palace_host_exec {
135 return Arc::new(DisabledPalaceRunner);
136 }
137 tracing::warn!(
138 script = %config.palace_script.display(),
139 "TALON_PALACE_HOST_EXEC=unsafe: MemPalace calls run palace-call.sh ON THE HOST against \
140 agent-writable mempalace-data; a compromised agent can get code executed as this user. \
141 Use a containerized palace runner instead"
142 );
143 Arc::new(ScriptPalaceRunner::new(
144 config.palace_script.clone(),
145 config.palace_timeout,
146 config.palace_max_output_bytes,
147 config.palace_max_concurrency,
148 ))
149}
150
151impl fmt::Debug for HttpDeps {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 f.debug_struct("HttpDeps").finish_non_exhaustive()
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub struct LaneLimitReached;
160
161pub(crate) struct Shared {
162 pub config: HttpConfig,
163 pub lanes: LaneIds,
164 pub host: Arc<dyn ChannelHost>,
165 pub store: Arc<dyn HttpStore>,
166 pub audit: SharedAuditSink,
167 pub verifier: Arc<dyn KeyVerifier>,
168 pub palace: Arc<dyn PalaceRunner>,
169 pub streams: Arc<StreamRegistry>,
170 pub throttle: AuthThrottle,
171 pub metrics: Metrics,
172 pub started_at: Instant,
173 base_group: Mutex<Option<RegisteredGroup>>,
174 last_seen: Mutex<HashMap<String, Instant>>,
175 lane_lock: AsyncMutex<()>,
176 investigate_lock: AsyncMutex<()>,
177 last_job_millis: AtomicI64,
178}
179
180fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
181 m.lock().unwrap_or_else(|e| e.into_inner())
182}
183
184#[derive(Clone)]
187pub struct HttpState(pub(crate) Arc<Shared>);
188
189impl fmt::Debug for HttpState {
190 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191 f.debug_struct("HttpState")
192 .field("config", &self.0.config)
193 .finish_non_exhaustive()
194 }
195}
196
197impl HttpState {
198 pub fn new(config: HttpConfig, deps: HttpDeps) -> Self {
200 let throttle = AuthThrottle::new(config.auth_max_failures, config.auth_window);
201 Self(Arc::new(Shared {
202 lanes: LaneIds::new(&config.base_jid),
203 throttle,
204 config,
205 host: deps.host,
206 store: deps.store,
207 audit: deps.audit,
208 verifier: deps.verifier,
209 palace: deps.palace,
210 streams: Arc::new(StreamRegistry::default()),
211 metrics: Metrics::default(),
212 started_at: Instant::now(),
213 base_group: Mutex::new(None),
214 last_seen: Mutex::new(HashMap::new()),
215 lane_lock: AsyncMutex::new(()),
216 investigate_lock: AsyncMutex::new(()),
217 last_job_millis: AtomicI64::new(0),
218 }))
219 }
220
221 pub fn config(&self) -> &HttpConfig {
223 &self.0.config
224 }
225
226 pub fn lanes(&self) -> &LaneIds {
228 &self.0.lanes
229 }
230
231 pub async fn register_base_lanes(&self) {
234 let s = &self.0;
235 let group = base_group(&s.config);
236 let chat = RegisteredGroup {
239 trusted_session_commands: true,
240 ..group.clone()
241 };
242 *lock(&s.base_group) = Some(chat.clone());
243 s.register_lane(&s.lanes.base, chat, true).await;
244 let key = s.config.investigation_session_key();
248 let investigate = RegisteredGroup {
249 name: "CoPilot Investigations".to_string(),
250 session_key: Some(key.to_string()),
251 ipc_key: Some(key.to_string()),
252 ephemeral_session: true,
253 ..group
254 };
255 s.register_lane(&s.lanes.investigate, investigate, true)
256 .await;
257 }
258
259 pub fn set_base_group(&self, group: RegisteredGroup) {
262 *lock(&self.0.base_group) = Some(group);
263 }
264
265 pub async fn ensure_user_lane(
268 &self,
269 user_id: Option<&UserId>,
270 user_name: Option<&str>,
271 ) -> Result<String, LaneLimitReached> {
272 self.0.ensure_user_lane(user_id, user_name).await
273 }
274
275 pub fn user_lane_jids(&self) -> Vec<String> {
277 self.0.user_lane_jids()
278 }
279
280 pub async fn sweep_idle_lanes(&self) -> Vec<String> {
283 match Instant::now().checked_sub(self.0.config.lane_idle) {
284 Some(cutoff) => self.0.sweep_idle_lanes_before(cutoff).await,
285 None => Vec::new(),
286 }
287 }
288
289 pub async fn sweep_idle_lanes_before(&self, cutoff: Instant) -> Vec<String> {
291 self.0.sweep_idle_lanes_before(cutoff).await
292 }
293
294 pub async fn seed_digest_task(&self) -> talon_core::Result<bool> {
296 let folder = self.0.config.group_folder.clone();
297 let tz = self.0.config.timezone;
298 run_blocking(&self.0.store, move |st| {
299 investigate::seed_digest_task(st, &folder, tz, Utc::now())
300 })
301 .await
302 }
303
304 #[cfg(test)]
305 pub(crate) fn set_last_seen(&self, jid: &str, at: Instant) {
306 lock(&self.0.last_seen).insert(jid.to_string(), at);
307 }
308}
309
310fn base_group(config: &HttpConfig) -> RegisteredGroup {
312 let additional_mounts = COPILOT_MOUNTS
313 .iter()
314 .map(|(dir, readonly)| AdditionalMount {
315 host_path: config.project_root.join(dir).to_string_lossy().into_owned(),
316 container_path: Some((*dir).to_string()),
317 readonly: *readonly,
318 })
319 .collect();
320 RegisteredGroup {
321 name: "CoPilot".to_string(),
322 folder: config.group_folder.clone(),
323 trigger: String::new(),
324 added_at: now_iso(),
325 container_config: Some(ContainerConfig {
326 additional_mounts,
327 ..ContainerConfig::default()
328 }),
329 requires_trigger: Some(false),
330 is_main: false,
331 session_key: None,
332 ipc_key: None,
333 ephemeral_session: false,
334 trusted_session_commands: false,
335 }
336}
337
338impl Shared {
339 pub(crate) async fn register_lane(&self, jid: &str, group: RegisteredGroup, persist: bool) {
344 let name = group.name.clone();
345 self.host.register_group(jid, group, persist).await;
346 self.host
347 .on_chat_metadata(ChatMetadata {
348 chat_jid: jid.to_string(),
349 timestamp: now_iso(),
350 name: Some(name),
351 channel: Some("http".to_string()),
352 is_group: Some(false),
353 })
354 .await;
355 }
356
357 fn user_ids_by_session_key(&self) -> HashMap<String, String> {
360 self.host
361 .registered_groups()
362 .into_iter()
363 .filter_map(|(jid, group)| {
364 let user = jid.strip_prefix(self.lanes.user_prefix.as_str())?;
365 if user.is_empty() {
366 return None;
367 }
368 Some((group.session_key?, user.to_string()))
369 })
370 .collect()
371 }
372
373 pub(crate) fn user_lane_jids(&self) -> Vec<String> {
374 let mut jids: Vec<String> = self
375 .host
376 .registered_groups()
377 .into_keys()
378 .filter(|jid| self.lanes.is_user_lane(jid))
379 .collect();
380 jids.sort();
381 jids
382 }
383
384 async fn ensure_user_lane(
385 &self,
386 user_id: Option<&UserId>,
387 user_name: Option<&str>,
388 ) -> Result<String, LaneLimitReached> {
389 let Some(user_id) = user_id else {
390 return Ok(self.lanes.base.clone());
391 };
392 let jid = self.lanes.user_lane(user_id.as_str());
393 let _guard = self.lane_lock.lock().await;
394
395 if self.host.registered_groups().contains_key(&jid) {
396 lock(&self.last_seen).insert(jid.clone(), Instant::now());
397 return Ok(jid);
398 }
399 let Some(base) = lock(&self.base_group).clone() else {
400 tracing::warn!(%jid, "HTTP channel: no base group, using anon lane");
403 return Ok(self.lanes.base.clone());
404 };
405 if self.user_lane_jids().len() >= self.config.max_user_lanes {
406 tracing::warn!(
407 limit = self.config.max_user_lanes,
408 "HTTP channel: user lane limit reached"
409 );
410 return Err(LaneLimitReached);
411 }
412
413 let label = user_name
414 .map(|n| sanitize_label(n, MAX_LABEL_CHARS))
415 .filter(|n| !n.is_empty())
416 .unwrap_or_else(|| user_id.to_string());
417 let key = user_id.session_key();
420 let group = RegisteredGroup {
421 name: format!("CoPilot — {label}"),
422 session_key: Some(key.clone()),
423 ipc_key: Some(key),
424 trusted_session_commands: true,
425 ..base
426 };
427 lock(&self.last_seen).insert(jid.clone(), Instant::now());
428 self.register_lane(&jid, group, false).await;
429 tracing::info!(%jid, %user_id, "HTTP channel: registered user lane");
430 Ok(jid)
431 }
432
433 async fn sweep_idle_lanes_before(&self, cutoff: Instant) -> Vec<String> {
437 let _guard = self.lane_lock.lock().await;
438 let mut swept = Vec::new();
439 for jid in self.user_lane_jids() {
440 let last_seen = lock(&self.last_seen).get(&jid).copied();
441 if last_seen.is_some_and(|t| t > cutoff) {
442 continue;
443 }
444 if self.streams.has_streams(&jid) {
445 continue;
446 }
447 self.host.clear_session(&jid).await;
448 self.host.unregister_group(&jid).await;
449 lock(&self.last_seen).remove(&jid);
450 self.streams.forget_lane(&jid);
451 tracing::info!(%jid, "HTTP channel: swept idle user lane");
452 swept.push(jid);
453 }
454 swept
455 }
456
457 fn next_job_millis(&self, now: DateTime<Utc>) -> i64 {
458 let now_ms = now.timestamp_millis();
459 let mut prev = self.last_job_millis.load(Ordering::SeqCst);
460 loop {
461 let next = now_ms.max(prev.saturating_add(1));
462 match self.last_job_millis.compare_exchange(
463 prev,
464 next,
465 Ordering::SeqCst,
466 Ordering::SeqCst,
467 ) {
468 Ok(_) => return next,
469 Err(actual) => prev = actual,
470 }
471 }
472 }
473
474 fn audit(&self, kind: &str, actor: &str, target: Option<&str>, outcome: &str, details: Value) {
475 self.audit
476 .record(event(kind, actor, target, outcome, details));
477 }
478}
479
480#[derive(Debug, Clone)]
486pub(crate) struct ApiError {
487 status: StatusCode,
488 message: String,
489}
490
491impl ApiError {
492 pub(crate) fn new(status: StatusCode, message: impl Into<String>) -> Self {
493 Self {
494 status,
495 message: message.into(),
496 }
497 }
498
499 fn bad_request(message: impl Into<String>) -> Self {
500 Self::new(StatusCode::BAD_REQUEST, message)
501 }
502
503 fn internal(message: impl Into<String>) -> Self {
504 Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
505 }
506}
507
508impl IntoResponse for ApiError {
509 fn into_response(self) -> Response {
510 (self.status, Json(json!({ "error": self.message }))).into_response()
511 }
512}
513
514type ApiResult = Result<Response, ApiError>;
515
516fn body_error(e: BytesRejection) -> ApiError {
517 let status = e.status();
518 if status == StatusCode::PAYLOAD_TOO_LARGE {
519 ApiError::new(status, "request body too large")
520 } else {
521 ApiError::new(status, "invalid request body")
522 }
523}
524
525fn query_error(_: QueryRejection) -> ApiError {
526 ApiError::bad_request("invalid query string")
527}
528
529fn parse_object(body: &[u8], allow_empty: bool) -> Result<Map<String, Value>, ApiError> {
531 if allow_empty && body.iter().all(u8::is_ascii_whitespace) {
532 return Ok(Map::new());
533 }
534 match serde_json::from_slice::<Value>(body) {
535 Ok(Value::Object(m)) => Ok(m),
536 Ok(_) => Err(ApiError::bad_request("JSON body must be an object")),
537 Err(_) => Err(ApiError::bad_request("Invalid JSON")),
538 }
539}
540
541fn is_falsy(v: Option<&Value>) -> bool {
543 match v {
544 None | Some(Value::Null) | Some(Value::Bool(false)) => true,
545 Some(Value::String(s)) => s.is_empty(),
546 Some(Value::Number(n)) => n.as_f64() == Some(0.0),
547 _ => false,
548 }
549}
550
551fn optional_str<'a>(obj: &'a Map<String, Value>, key: &str) -> Result<Option<&'a str>, ()> {
553 match obj.get(key) {
554 None | Some(Value::Null) => Ok(None),
555 Some(Value::String(s)) if s.is_empty() => Ok(None),
556 Some(Value::String(s)) => Ok(Some(s.as_str())),
557 Some(_) => Err(()),
558 }
559}
560
561fn lesson_types_message(field: &str) -> String {
562 format!("{field} must be one of: {}", LESSON_TYPES.join(", "))
563}
564
565pub(crate) fn ip_from_extensions(ext: &axum::http::Extensions) -> IpAddr {
566 ext.get::<ConnectInfo<SocketAddr>>()
567 .map(|c| c.0.ip().to_canonical())
568 .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED))
569}
570
571pub(crate) struct ClientIp(pub IpAddr);
574
575impl<S: Send + Sync> FromRequestParts<S> for ClientIp {
576 type Rejection = Infallible;
577
578 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
579 Ok(ClientIp(ip_from_extensions(&parts.extensions)))
580 }
581}
582
583pub fn router(state: HttpState) -> Router {
589 let limit = state.0.config.max_body_bytes;
590 Router::new()
591 .route("/health", get(health))
592 .route("/status", get(status))
593 .route("/metrics", get(metrics_handler))
594 .route("/session/reset", post(session_reset))
595 .route("/investigate", post(investigate_handler))
596 .route("/investigations", get(list_investigations))
597 .route("/investigations/{id}", get(get_investigation))
598 .route("/evals/{alert_id}", get(evals))
599 .route("/templates", get(templates))
600 .route("/palace/lesson", post(palace_lesson))
601 .route("/palace/search", get(palace_search))
602 .route("/palace/forget", post(palace_forget))
603 .route("/message", post(message))
604 .fallback(not_found)
605 .layer(DefaultBodyLimit::max(limit))
606 .layer(middleware::from_fn_with_state(
607 state.clone(),
608 require_api_key,
609 ))
610 .layer(middleware::from_fn_with_state(state.clone(), cors))
611 .layer(middleware::from_fn(catch_panic))
612 .layer(middleware::from_fn(security_headers))
613 .layer(middleware::from_fn_with_state(
614 state.clone(),
615 count_responses,
616 ))
617 .with_state(state)
618}
619
620async fn count_responses(State(st): State<HttpState>, req: Request, next: Next) -> Response {
621 let res = next.run(req).await;
622 st.0.metrics.record_response(res.status().as_u16());
623 res
624}
625
626async fn security_headers(req: Request, next: Next) -> Response {
627 let mut res = next.run(req).await;
628 let h = res.headers_mut();
629 h.insert(
630 header::X_CONTENT_TYPE_OPTIONS,
631 HeaderValue::from_static("nosniff"),
632 );
633 h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
634 h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
635 h.insert(
636 header::CONTENT_SECURITY_POLICY,
637 HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"),
638 );
639 h.insert(
640 header::REFERRER_POLICY,
641 HeaderValue::from_static("no-referrer"),
642 );
643 res
644}
645
646async fn catch_panic(req: Request, next: Next) -> Response {
648 match AssertUnwindSafe(next.run(req)).catch_unwind().await {
649 Ok(res) => res,
650 Err(_) => {
651 tracing::error!("HTTP channel: request handler panicked");
652 ApiError::internal("internal error").into_response()
653 }
654 }
655}
656
657fn allowed_origin(allowed: &[String], origin: &str) -> Option<HeaderValue> {
658 if allowed.iter().any(|o| o == "*") {
659 return Some(HeaderValue::from_static("*"));
660 }
661 allowed
662 .iter()
663 .any(|o| o.eq_ignore_ascii_case(origin))
664 .then(|| HeaderValue::from_str(origin).ok())
665 .flatten()
666}
667
668async fn cors(State(st): State<HttpState>, req: Request, next: Next) -> Response {
672 let allow = req
673 .headers()
674 .get(header::ORIGIN)
675 .and_then(|v| v.to_str().ok())
676 .and_then(|o| allowed_origin(&st.0.config.cors_origins, o));
677 if req.method() == Method::OPTIONS {
678 let mut res = StatusCode::NO_CONTENT.into_response();
679 let h = res.headers_mut();
680 if let Some(origin) = allow {
681 h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin);
682 h.insert(
683 header::ACCESS_CONTROL_ALLOW_METHODS,
684 HeaderValue::from_static("GET, POST, OPTIONS"),
685 );
686 h.insert(
687 header::ACCESS_CONTROL_ALLOW_HEADERS,
688 HeaderValue::from_static("Authorization, Content-Type, X-Api-Key"),
689 );
690 h.insert(
691 header::ACCESS_CONTROL_MAX_AGE,
692 HeaderValue::from_static("600"),
693 );
694 }
695 h.insert(header::VARY, HeaderValue::from_static("Origin"));
696 return res;
697 }
698 let mut res = next.run(req).await;
699 if let Some(origin) = allow {
700 let h = res.headers_mut();
701 h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin);
702 h.append(header::VARY, HeaderValue::from_static("Origin"));
703 }
704 res
705}
706
707async fn require_api_key(State(st): State<HttpState>, req: Request, next: Next) -> Response {
710 if req.method() == Method::GET && req.uri().path() == "/health" {
711 return next.run(req).await;
712 }
713 let s = &st.0;
714 let ip = ip_from_extensions(req.extensions());
715
716 if let Some(retry_after) = s.throttle.blocked(ip) {
717 Metrics::inc(&s.metrics.auth_throttled);
718 let mut res = ApiError::new(
719 StatusCode::TOO_MANY_REQUESTS,
720 "too many failed authentication attempts",
721 )
722 .into_response();
723 res.headers_mut().insert(
724 header::RETRY_AFTER,
725 HeaderValue::from(retry_after.as_secs().max(1)),
726 );
727 return res;
728 }
729
730 let headers = req.headers();
731 let x_api_key = headers.get("x-api-key").and_then(|v| v.to_str().ok());
732 let authorization = headers
733 .get(header::AUTHORIZATION)
734 .and_then(|v| v.to_str().ok());
735 let provided = s.verifier.extract(x_api_key, authorization);
736 let configured = s.config.api_key.is_configured();
737 if configured
738 && s.verifier
739 .verify(provided.as_deref(), s.config.api_key.expose())
740 {
741 s.throttle.record_success(ip);
742 return next.run(req).await;
743 }
744
745 let reason = if !configured {
746 "api_key_unset"
747 } else if provided.is_none() {
748 "missing_credentials"
749 } else {
750 "invalid_credentials"
751 };
752 Metrics::inc(&s.metrics.auth_failures);
753 let record = s.throttle.record_failure(ip);
754 let path = truncate_chars(req.uri().path(), MAX_AUDIT_PATH_CHARS);
755 s.audit(
756 "http.auth.failed",
757 &ip.to_string(),
758 Some(path),
759 "denied",
760 json!({
761 "reason": reason,
762 "method": req.method().as_str(),
763 "failures": record.failures,
764 "throttled": record.now_throttled,
765 }),
766 );
767 if record.now_throttled {
768 tracing::warn!(%ip, failures = record.failures, "HTTP channel: throttling client after repeated auth failures");
769 }
770 ApiError::new(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
771}
772
773async fn not_found() -> ApiError {
778 ApiError::new(StatusCode::NOT_FOUND, "Not found")
779}
780
781async fn health() -> Response {
782 Json(json!({ "status": "ok", "channel": "http" })).into_response()
783}
784
785async fn status(State(st): State<HttpState>) -> ApiResult {
786 let s = &st.0;
787 let folder = s.config.group_folder.clone();
788 let (tasks, rows) = run_blocking(&s.store, move |store| {
789 Ok((
790 store.get_tasks_for_group(&folder)?,
791 store.get_session_rows()?,
792 ))
793 })
794 .await
795 .map_err(|e| {
796 tracing::error!(err = %e, "GET /status failed");
797 ApiError::internal("failed to read status")
798 })?;
799
800 let folder = &s.config.group_folder;
804 let user_ids = s.user_ids_by_session_key();
805 let sessions: Vec<Value> = rows
806 .into_iter()
807 .filter(|r| {
808 r.group_folder.starts_with(folder.as_str())
809 || r.group_folder.starts_with(USER_SESSION_KEY_PREFIX)
810 })
811 .map(|r| {
812 let user_id = user_ids.get(&r.group_folder).cloned();
815 json!({
816 "lane": r.group_folder,
817 "user_id": user_id,
818 "session_id": r.session_id,
819 "input_tokens": r.input_tokens,
820 "updated_at": r.updated_at,
821 })
822 })
823 .collect();
824 let scheduled_tasks: Vec<Value> = tasks
825 .iter()
826 .map(|t| {
827 json!({
828 "id": t.id,
829 "status": t.status.as_str(),
830 "schedule_type": t.schedule_type.as_str(),
831 "schedule_value": t.schedule_value,
832 "next_run": t.next_run,
833 "last_run": t.last_run,
834 })
835 })
836 .collect();
837 let counts = s.streams.counts();
838 let queue = s.host.queue_status();
839 Ok(Json(json!({
840 "status": "ok",
841 "uptime_seconds": s.started_at.elapsed().as_secs(),
842 "active_investigations": counts.lanes_in_turn,
843 "pending_queue_depth": s.streams.pending_len(&s.lanes.base),
844 "sessions": sessions,
845 "scheduled_tasks": scheduled_tasks,
846 "user_lanes": s.user_lane_jids().len(),
847 "queue": {
848 "active_containers": queue.active_containers,
849 "max_containers": queue.max_containers,
850 "waiting_groups": queue.waiting_groups,
851 },
852 }))
853 .into_response())
854}
855
856async fn metrics_handler(State(st): State<HttpState>) -> Response {
857 let s = &st.0;
858 let by_status = match run_blocking(&s.store, |store| {
859 store.list_investigations(METRICS_INVESTIGATION_WINDOW)
860 })
861 .await
862 {
863 Ok(list) => {
864 let mut map = BTreeMap::new();
865 for inv in list {
866 *map.entry(inv.status).or_insert(0u64) += 1;
867 }
868 Some(map)
869 }
870 Err(e) => {
871 tracing::debug!(err = %e, "metrics: investigation lookup failed");
872 None
873 }
874 };
875 let counts = s.streams.counts();
876 let gauges = Gauges {
877 uptime_seconds: s.started_at.elapsed().as_secs(),
878 queue: s.host.queue_status(),
879 sse_pending: counts.pending,
880 sse_active: counts.active,
881 lanes_in_turn: counts.lanes_in_turn,
882 user_lanes: s.user_lane_jids().len(),
883 investigations_by_status: by_status,
884 investigations_window: METRICS_INVESTIGATION_WINDOW,
885 };
886 (
887 [(
888 header::CONTENT_TYPE,
889 HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
890 )],
891 metrics::render(&s.metrics, &gauges),
892 )
893 .into_response()
894}
895
896async fn session_reset(
897 State(st): State<HttpState>,
898 ClientIp(ip): ClientIp,
899 body: Result<Bytes, BytesRejection>,
900) -> ApiResult {
901 let s = &st.0;
902 let body = body.map_err(body_error)?;
903 let obj = parse_object(&body, true)?;
904 let scope = match optional_str(&obj, "scope") {
905 Ok(v) => v.unwrap_or("chat"),
906 Err(()) => "",
907 };
908 let user_id =
909 UserId::from_json(obj.get("user_id")).map_err(|e| ApiError::bad_request(e.to_string()))?;
910
911 let targets: Vec<String> = match scope {
915 "all" => {
916 let mut t = vec![s.lanes.base.clone(), s.lanes.investigate.clone()];
917 t.extend(s.user_lane_jids());
918 t
919 }
920 "investigate" => vec![s.lanes.investigate.clone()],
921 "chat" => vec![
922 user_id
923 .as_ref()
924 .map_or_else(|| s.lanes.base.clone(), |u| s.lanes.user_lane(u.as_str())),
925 ],
926 _ => {
927 return Err(ApiError::bad_request(
928 "scope must be one of 'chat', 'investigate', 'all'",
929 ));
930 }
931 };
932
933 let mut cleared = Vec::new();
934 for jid in targets {
935 if s.host.clear_session(&jid).await {
936 cleared.push(jid);
937 }
938 }
939 let user_id = user_id.as_ref().map(UserId::as_str);
940 tracing::info!(scope, user_id = ?user_id, cleared = ?cleared, "HTTP channel: session reset");
941 s.audit(
942 "http.session.reset",
943 &ip.to_string(),
944 None,
945 "info",
946 json!({ "scope": scope, "user_id": user_id, "lanes": cleared }),
947 );
948 Ok(Json(json!({ "cleared": cleared.len(), "lanes": cleared })).into_response())
949}
950
951fn parse_investigation_request(obj: &Map<String, Value>) -> Result<InvestigationRequest, ApiError> {
954 if is_falsy(obj.get("alert_id")) || is_falsy(obj.get("customer_code")) {
955 return Err(ApiError::bad_request("alert_id and customer_code required"));
956 }
957 let alert_id = obj
958 .get("alert_id")
959 .and_then(parse_alert_id)
960 .ok_or_else(|| ApiError::bad_request("alert_id must be a positive integer"))?;
961 let customer_code = obj
962 .get("customer_code")
963 .and_then(Value::as_str)
964 .filter(|c| is_valid_customer_code(c))
965 .ok_or_else(|| ApiError::bad_request("customer_code must match ^[A-Za-z0-9_-]{1,64}$"))?
966 .to_string();
967 let triggered_by = match optional_str(obj, "triggered_by") {
968 Ok(None) => "webhook".to_string(),
969 Ok(Some(t)) if is_valid_triggered_by(t) => t.to_string(),
970 _ => {
971 return Err(ApiError::bad_request(
972 "triggered_by must be one of: manual, scheduled, webhook",
973 ));
974 }
975 };
976 let template_override = match optional_str(obj, "template_override") {
977 Ok(None) => None,
978 Ok(Some(t)) if is_valid_template_name(t) => Some(t.to_string()),
979 _ => {
980 return Err(ApiError::bad_request(
981 "template_override must be a plain .txt filename (no path separators)",
982 ));
983 }
984 };
985 Ok(InvestigationRequest {
986 alert_id,
987 customer_code,
988 triggered_by,
989 template_override,
990 })
991}
992
993async fn investigate_handler(
994 State(st): State<HttpState>,
995 ClientIp(ip): ClientIp,
996 body: Result<Bytes, BytesRejection>,
997) -> ApiResult {
998 let s = &st.0;
999 let actor = ip.to_string();
1000 let body = body.map_err(body_error)?;
1001 let obj = parse_object(&body, false)?;
1002 let req = match parse_investigation_request(&obj) {
1003 Ok(r) => r,
1004 Err(e) => {
1005 s.audit(
1006 "http.investigate",
1007 &actor,
1008 None,
1009 "denied",
1010 json!({ "reason": e.message }),
1011 );
1012 return Err(e);
1013 }
1014 };
1015
1016 if let Some(template) = req.template_override.clone() {
1019 let dir = s.config.group_dir();
1020 let exists = tokio::task::spawn_blocking(move || files::template_exists(&dir, &template))
1021 .await
1022 .unwrap_or(false);
1023 if !exists {
1024 let name = req.template_override.as_deref().unwrap_or_default();
1025 return Err(ApiError::new(
1026 StatusCode::NOT_FOUND,
1027 format!("template_override not found: {name}"),
1028 ));
1029 }
1030 }
1031
1032 let guard = s.investigate_lock.lock().await;
1035 let now = Utc::now();
1036 let window = s.config.dedup_window;
1037 if !window.is_zero() {
1038 let alert = req.alert_id.to_string();
1039 match run_blocking(&s.store, move |st| {
1040 st.latest_investigation_for_alert(&alert)
1041 })
1042 .await
1043 {
1044 Ok(Some(existing)) if is_duplicate(&existing, &req, window, now) => {
1045 Metrics::inc(&s.metrics.investigations_deduplicated);
1046 s.audit(
1047 "http.investigate",
1048 &actor,
1049 Some(&existing.id),
1050 "info",
1051 json!({
1052 "alert_id": req.alert_id,
1053 "customer_code": req.customer_code,
1054 "deduplicated": true,
1055 }),
1056 );
1057 tracing::info!(alert_id = req.alert_id, job_id = %existing.id, "HTTP channel: duplicate investigation trigger");
1058 return Ok((
1059 StatusCode::OK,
1060 Json(json!({
1061 "job_id": existing.id,
1062 "status": existing.status,
1063 "template_override": existing.template,
1064 "deduplicated": true,
1065 })),
1066 )
1067 .into_response());
1068 }
1069 Ok(_) => {}
1070 Err(e) => tracing::warn!(err = %e, "investigation dedup lookup failed"),
1071 }
1072 }
1073
1074 let id = job_id(req.alert_id, s.next_job_millis(now));
1075 let prompt = investigation_prompt(&req, &id);
1076 let task = investigation_task(
1077 &id,
1078 &s.config.group_folder,
1079 &s.lanes.investigate,
1080 prompt,
1081 s.config.timezone,
1082 now,
1083 )
1084 .map_err(|e| {
1085 tracing::error!(err = %e, "failed to build investigation task");
1086 ApiError::internal("failed to queue investigation")
1087 })?;
1088 let inv = investigation_record(&id, &req, now);
1089 run_blocking(&s.store, move |st| {
1090 investigate::persist_investigation(st, &task, &inv)
1091 })
1092 .await
1093 .map_err(|e| {
1094 tracing::error!(err = %e, "failed to persist investigation");
1095 ApiError::internal("failed to queue investigation")
1096 })?;
1097 drop(guard);
1098
1099 s.host.notify_tasks_changed();
1100 Metrics::inc(&s.metrics.investigations_created);
1101 s.audit(
1102 "http.investigate",
1103 &actor,
1104 Some(&id),
1105 "allowed",
1106 json!({
1107 "alert_id": req.alert_id,
1108 "customer_code": req.customer_code,
1109 "triggered_by": req.triggered_by,
1110 "template_override": req.template_override,
1111 }),
1112 );
1113 tracing::info!(
1114 alert_id = req.alert_id,
1115 customer_code = %req.customer_code,
1116 job_id = %id,
1117 triggered_by = %req.triggered_by,
1118 template_override = ?req.template_override,
1119 "HTTP channel: investigation queued"
1120 );
1121 Ok((
1122 StatusCode::ACCEPTED,
1123 Json(json!({
1124 "job_id": id,
1125 "status": investigate::STATUS_QUEUED,
1126 "template_override": req.template_override,
1127 "deduplicated": false,
1128 })),
1129 )
1130 .into_response())
1131}
1132
1133async fn list_investigations(
1134 State(st): State<HttpState>,
1135 query: Result<Query<HashMap<String, String>>, QueryRejection>,
1136) -> ApiResult {
1137 let Query(params) = query.map_err(query_error)?;
1138 let limit = match params.get("limit").map(|l| l.trim()) {
1139 None | Some("") => DEFAULT_INVESTIGATIONS_LIMIT,
1140 Some(l) => l
1141 .parse::<usize>()
1142 .map_err(|_| ApiError::bad_request("limit must be a positive integer"))?
1143 .clamp(1, MAX_INVESTIGATIONS_LIMIT),
1144 };
1145 let list = run_blocking(&st.0.store, move |s| s.list_investigations(limit))
1146 .await
1147 .map_err(|e| {
1148 tracing::error!(err = %e, "GET /investigations failed");
1149 ApiError::internal("failed to list investigations")
1150 })?;
1151 Ok(Json(json!({ "investigations": list })).into_response())
1152}
1153
1154async fn get_investigation(State(st): State<HttpState>, Path(id): Path<String>) -> ApiResult {
1155 if !is_valid_investigation_id(&id) {
1156 return Err(ApiError::bad_request("invalid investigation id"));
1157 }
1158 let found = run_blocking(&st.0.store, move |s| s.get_investigation(&id))
1159 .await
1160 .map_err(|e| {
1161 tracing::error!(err = %e, "GET /investigations/{{id}} failed");
1162 ApiError::internal("failed to read investigation")
1163 })?;
1164 match found {
1165 Some(inv) => Ok(Json(inv).into_response()),
1166 None => Err(ApiError::new(
1167 StatusCode::NOT_FOUND,
1168 "investigation not found",
1169 )),
1170 }
1171}
1172
1173async fn evals(State(st): State<HttpState>, Path(alert_id): Path<String>) -> ApiResult {
1182 if alert_id.is_empty()
1183 || alert_id.len() > crate::validate::MAX_ALERT_ID_DIGITS
1184 || !alert_id.bytes().all(|b| b.is_ascii_digit())
1185 {
1186 return Err(ApiError::bad_request("invalid alert_id"));
1187 }
1188 let numeric: u64 = alert_id
1189 .parse()
1190 .map_err(|_| ApiError::bad_request("invalid alert_id"))?;
1191 let config = &st.0.config;
1192 let sources = files::EvalSources {
1193 sessions_dir: config.sessions_dir(),
1194 lane_key: config.investigation_session_key().to_string(),
1195 legacy_group_dir: config.evals_legacy_group_dir.then(|| config.group_dir()),
1196 };
1197 let evals =
1198 tokio::task::spawn_blocking(move || files::read_investigation_evals(&sources, &alert_id))
1199 .await
1200 .map_err(|_| ApiError::internal("failed to read evals"))?
1201 .map_err(|e| {
1202 tracing::error!(err = %e, "GET /evals failed");
1203 ApiError::internal("failed to read evals")
1204 })?;
1205 Ok(Json(json!({ "alert_id": numeric, "evals": evals })).into_response())
1206}
1207
1208async fn templates(State(st): State<HttpState>) -> ApiResult {
1211 let dir = st.0.config.group_dir();
1212 let templates = tokio::task::spawn_blocking(move || files::list_templates(&dir))
1213 .await
1214 .map_err(|_| ApiError::internal("failed to list templates"))?
1215 .map_err(|e| {
1216 tracing::error!(err = %e, "GET /templates failed");
1217 ApiError::internal("failed to list templates")
1218 })?;
1219 Ok(Json(json!({ "templates": templates })).into_response())
1220}
1221
1222fn palace_response(s: &Shared, op: &str, result: Result<Value, PalaceError>) -> ApiResult {
1223 match result {
1224 Ok(v) => {
1225 Metrics::inc(&s.metrics.palace_ok);
1226 Ok(Json(v).into_response())
1227 }
1228 Err(PalaceError::Disabled) => {
1229 Metrics::inc(&s.metrics.palace_failed);
1230 tracing::debug!(op, "palace call refused: no palace runner configured");
1231 Err(ApiError::new(
1232 StatusCode::SERVICE_UNAVAILABLE,
1233 PalaceError::Disabled.to_string(),
1234 ))
1235 }
1236 Err(e) => {
1237 Metrics::inc(&s.metrics.palace_failed);
1238 tracing::error!(op, err = %e, "palace call failed");
1239 let status = match e {
1240 PalaceError::Timeout(_) => StatusCode::GATEWAY_TIMEOUT,
1241 PalaceError::Busy => StatusCode::SERVICE_UNAVAILABLE,
1242 _ => StatusCode::INTERNAL_SERVER_ERROR,
1243 };
1244 Err(ApiError::new(status, e.to_string()))
1245 }
1246 }
1247}
1248
1249async fn palace_lesson(
1252 State(st): State<HttpState>,
1253 ClientIp(ip): ClientIp,
1254 body: Result<Bytes, BytesRejection>,
1255) -> ApiResult {
1256 let s = &st.0;
1257 let body = body.map_err(body_error)?;
1258 let obj = parse_object(&body, false)?;
1259 if is_falsy(obj.get("customer_code"))
1260 || is_falsy(obj.get("lesson_type"))
1261 || is_falsy(obj.get("lesson_text"))
1262 {
1263 return Err(ApiError::bad_request(
1264 "customer_code, lesson_type, and lesson_text are required",
1265 ));
1266 }
1267 let lesson_type = obj
1268 .get("lesson_type")
1269 .and_then(Value::as_str)
1270 .filter(|t| is_valid_lesson_type(t))
1271 .ok_or_else(|| ApiError::bad_request(lesson_types_message("lesson_type")))?;
1272 let customer_code = obj
1273 .get("customer_code")
1274 .and_then(Value::as_str)
1275 .filter(|c| is_valid_customer_code(c))
1276 .ok_or_else(|| ApiError::bad_request("customer_code must match ^[A-Za-z0-9_-]{1,64}$"))?;
1277 let lesson_text = obj
1278 .get("lesson_text")
1279 .and_then(Value::as_str)
1280 .ok_or_else(|| ApiError::bad_request("lesson_text must be a string"))?;
1281 if lesson_text.chars().count() > MAX_LESSON_CHARS {
1282 return Err(ApiError::bad_request(format!(
1283 "lesson_text exceeds {MAX_LESSON_CHARS} characters"
1284 )));
1285 }
1286 let durability = if obj.get("durability").and_then(Value::as_str) == Some("one_off") {
1287 "one_off"
1288 } else {
1289 "durable"
1290 };
1291
1292 s.audit(
1295 "http.palace.lesson",
1296 &ip.to_string(),
1297 Some(customer_code),
1298 "info",
1299 json!({
1300 "lesson_type": lesson_type,
1301 "durability": durability,
1302 "length": lesson_text.chars().count(),
1303 }),
1304 );
1305 let result = s
1306 .palace
1307 .call(add_lesson_request(
1308 customer_code,
1309 lesson_type,
1310 lesson_text,
1311 durability,
1312 ))
1313 .await;
1314 palace_response(s, "add_drawer", result)
1315}
1316
1317async fn palace_search(
1319 State(st): State<HttpState>,
1320 query: Result<Query<HashMap<String, String>>, QueryRejection>,
1321) -> ApiResult {
1322 let s = &st.0;
1323 let Query(params) = query.map_err(query_error)?;
1324 let customer_code = params
1325 .get("customer_code")
1326 .map(String::as_str)
1327 .unwrap_or("");
1328 let q = params.get("query").map(String::as_str).unwrap_or("");
1329 if customer_code.is_empty() || q.is_empty() {
1330 return Err(ApiError::bad_request(
1331 "customer_code and query are required",
1332 ));
1333 }
1334 if !is_valid_customer_code(customer_code) {
1335 return Err(ApiError::bad_request(
1336 "customer_code must match ^[A-Za-z0-9_-]{1,64}$",
1337 ));
1338 }
1339 if q.chars().count() > MAX_QUERY_CHARS {
1340 return Err(ApiError::bad_request(format!(
1341 "query exceeds {MAX_QUERY_CHARS} characters"
1342 )));
1343 }
1344 let room = params
1345 .get("room")
1346 .map(String::as_str)
1347 .filter(|r| !r.is_empty());
1348 if room.is_some_and(|r| !is_valid_lesson_type(r)) {
1349 return Err(ApiError::bad_request(lesson_types_message("room")));
1350 }
1351 let limit = match params
1352 .get("limit")
1353 .map(|l| l.trim())
1354 .filter(|l| !l.is_empty())
1355 {
1356 None => 5,
1357 Some(l) => l
1358 .parse::<i64>()
1359 .map_err(|_| ApiError::bad_request("limit must be an integer"))?
1360 .clamp(1, 25) as u32,
1361 };
1362 let result = s
1363 .palace
1364 .call(search_request(customer_code, room, q, limit))
1365 .await;
1366 palace_response(s, "search", result)
1367}
1368
1369async fn palace_forget(
1373 State(st): State<HttpState>,
1374 ClientIp(ip): ClientIp,
1375 body: Result<Bytes, BytesRejection>,
1376) -> ApiResult {
1377 let s = &st.0;
1378 let body = body.map_err(body_error)?;
1379 let obj = parse_object(&body, false)?;
1380 let drawer_id = match obj.get("drawer_id") {
1381 Some(Value::String(d)) if !d.is_empty() => d.as_str(),
1382 _ => return Err(ApiError::bad_request("drawer_id (string) is required")),
1383 };
1384 if !is_valid_drawer_id(drawer_id) {
1385 return Err(ApiError::bad_request(
1386 "drawer_id contains invalid characters",
1387 ));
1388 }
1389 s.audit(
1390 "http.palace.forget",
1391 &ip.to_string(),
1392 Some(drawer_id),
1393 "info",
1394 json!({}),
1395 );
1396 let result = s.palace.call(forget_request(drawer_id)).await;
1397 palace_response(s, "delete_drawer", result)
1398}
1399
1400async fn message(State(st): State<HttpState>, body: Result<Bytes, BytesRejection>) -> ApiResult {
1404 let s = &st.0;
1405 let body = body.map_err(body_error)?;
1406 let obj = parse_object(&body, false)?;
1407 let text = match obj.get("message") {
1408 Some(Value::String(m)) => m.trim(),
1409 _ => "",
1410 };
1411 if text.is_empty() {
1412 return Err(ApiError::bad_request("message field required"));
1413 }
1414 let sender = obj
1415 .get("sender")
1416 .and_then(Value::as_str)
1417 .map(|v| sanitize_label(v, MAX_LABEL_CHARS))
1418 .filter(|v| !v.is_empty())
1419 .unwrap_or_else(|| "copilot".to_string());
1420 let user_id =
1422 UserId::from_json(obj.get("user_id")).map_err(|e| ApiError::bad_request(e.to_string()))?;
1423 let user_name = obj.get("user_name").and_then(Value::as_str);
1424
1425 let lane = s
1428 .ensure_user_lane(user_id.as_ref(), user_name)
1429 .await
1430 .map_err(|_| {
1431 ApiError::new(
1432 StatusCode::SERVICE_UNAVAILABLE,
1433 "too many active user lanes",
1434 )
1435 })?;
1436 let (writer_id, rx) = s
1437 .streams
1438 .open(&lane, s.config.max_pending_per_lane, s.config.max_streams)
1439 .map_err(|e| match e {
1440 OpenError::LaneBusy => ApiError::new(
1441 StatusCode::TOO_MANY_REQUESTS,
1442 "too many requests queued on this lane",
1443 ),
1444 OpenError::TooManyStreams => {
1445 ApiError::new(StatusCode::SERVICE_UNAVAILABLE, "too many open streams")
1446 }
1447 })?;
1448 let stream = WriterStream::new(
1449 rx,
1450 WriterGuard {
1451 registry: Arc::clone(&s.streams),
1452 jid: lane.clone(),
1453 id: writer_id,
1454 },
1455 );
1456
1457 let suffix = uuid::Uuid::new_v4().simple().to_string();
1458 let msg = NewMessage {
1459 id: format!(
1460 "http-{}-{}",
1461 Utc::now().timestamp_millis(),
1462 suffix.get(..8).unwrap_or(&suffix)
1463 ),
1464 chat_jid: lane.clone(),
1465 sender: sender.clone(),
1466 sender_name: sender.clone(),
1467 content: text.to_string(),
1468 timestamp: now_iso(),
1469 is_from_me: false,
1470 ..NewMessage::default()
1471 };
1472 s.host.on_message(&lane, msg).await;
1473 Metrics::inc(&s.metrics.messages);
1474 tracing::info!(lane = %lane, sender = %sender, length = text.len(), "HTTP channel: message received");
1475
1476 let keepalive = KeepAlive::new()
1477 .interval(s.config.sse_keepalive.max(Duration::from_millis(10)))
1478 .text("keepalive");
1479 let mut res = Sse::new(stream).keep_alive(keepalive).into_response();
1480 res.headers_mut()
1481 .insert("x-accel-buffering", HeaderValue::from_static("no"));
1482 Ok(res)
1483}