| |
| |
| |
| |
| |
| |
| |
|
|
| use anyhow::{anyhow, Context, Result}; |
| use serde::Deserialize; |
| use serde_json::{json, Value}; |
| use star::Runtime; |
| use std::collections::{hash_map::DefaultHasher, HashMap}; |
| use std::fs; |
| use std::hash::{Hash, Hasher}; |
| use std::io::{Read, Write}; |
| use std::net::IpAddr; |
| use std::path::{Path, PathBuf}; |
| use std::process::{Command, Stdio}; |
| use std::sync::atomic::{AtomicU64, Ordering}; |
| use std::sync::{Arc, Mutex}; |
| use std::thread; |
| use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; |
| use tracing::{info, warn}; |
|
|
| pub const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024; |
| const DEFAULT_RATE_LIMIT_PER_MINUTE: u32 = 120; |
| const RATE_WINDOW_SECONDS: u64 = 60; |
| const MAX_TRACKED_CLIENTS: usize = 4_096; |
| const PUBLIC_HISTORY_LIMIT: usize = 24; |
| const PUBLIC_HISTORY_ITEM_BYTES: usize = 8 * 1024; |
| const PUBLIC_MESSAGE_BYTES: usize = 16 * 1024; |
| const PUBLIC_WORKER_TIMEOUT_SECONDS: u64 = 90; |
| const PUBLIC_WORKER_OUTPUT_BYTES: u64 = 1024 * 1024; |
| static PUBLIC_REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1); |
|
|
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| enum BoundaryMode { |
| LocalShared, |
| PublicRequestEphemeral, |
| } |
|
|
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| enum ProxyPolicy { |
| DirectPeer, |
| ForwardedForLast, |
| } |
|
|
| #[derive(Debug, Clone)] |
| struct SecurityConfig { |
| rate_limit_per_minute: u32, |
| mode: BoundaryMode, |
| proxy_policy: ProxyPolicy, |
| rate_probe_enabled: bool, |
| } |
|
|
| impl SecurityConfig { |
| fn from_env(host: &str) -> Result<Self> { |
| let rate_limit_per_minute = std::env::var("STARFIRE_RATE_LIMIT_PER_MINUTE") |
| .ok() |
| .and_then(|value| value.parse::<u32>().ok()) |
| .filter(|value| (1..=10_000).contains(value)) |
| .unwrap_or(DEFAULT_RATE_LIMIT_PER_MINUTE); |
|
|
| let requested_mode = std::env::var("STARFIRE_PUBLIC_RUNTIME_MODE").unwrap_or_default(); |
| let hosted = env_truthy("STARFIRE_HOSTED_MODE"); |
| let loopback = host_is_loopback(host); |
|
|
| let mode = if requested_mode == "request_ephemeral" { |
| BoundaryMode::PublicRequestEphemeral |
| } else if !requested_mode.is_empty() { |
| return Err(anyhow!( |
| "unsupported STARFIRE_PUBLIC_RUNTIME_MODE: {}", |
| requested_mode |
| )); |
| } else if hosted { |
| |
| |
| |
| BoundaryMode::PublicRequestEphemeral |
| } else if !loopback { |
| return Err(anyhow!( |
| "non-loopback binding requires STARFIRE_PUBLIC_RUNTIME_MODE=request_ephemeral" |
| )); |
| } else { |
| BoundaryMode::LocalShared |
| }; |
|
|
| let proxy_policy = match mode { |
| BoundaryMode::LocalShared => ProxyPolicy::DirectPeer, |
| BoundaryMode::PublicRequestEphemeral => { |
| match std::env::var("STARFIRE_TRUSTED_PROXY_HEADERS").as_deref() { |
| Ok("render") => ProxyPolicy::ForwardedForLast, |
| _ if hosted => ProxyPolicy::ForwardedForLast, |
| _ => { |
| return Err(anyhow!( |
| "non-hosted public mode requires STARFIRE_TRUSTED_PROXY_HEADERS=render" |
| )) |
| } |
| } |
| } |
| }; |
|
|
| Self::from_values( |
| rate_limit_per_minute, |
| mode, |
| proxy_policy, |
| hosted || env_truthy("STARFIRE_RATE_LIMIT_PROBE"), |
| ) |
| } |
|
|
| fn from_values( |
| rate_limit_per_minute: u32, |
| mode: BoundaryMode, |
| proxy_policy: ProxyPolicy, |
| rate_probe_enabled: bool, |
| ) -> Result<Self> { |
| if rate_limit_per_minute == 0 { |
| return Err(anyhow!("request rate limit must be greater than zero")); |
| } |
| Ok(Self { |
| rate_limit_per_minute, |
| mode, |
| proxy_policy, |
| rate_probe_enabled, |
| }) |
| } |
| } |
|
|
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| enum RoutePolicy { |
| Health, |
| Preflight, |
| PublicRoot, |
| PublicChat, |
| ForwardLocal, |
| NotFound, |
| } |
|
|
| fn route_policy(mode: BoundaryMode, method: &str, path: &str) -> RoutePolicy { |
| if method == "OPTIONS" { |
| return RoutePolicy::Preflight; |
| } |
| if method == "GET" && path == "/health" { |
| return RoutePolicy::Health; |
| } |
|
|
| match mode { |
| BoundaryMode::PublicRequestEphemeral => match (method, path) { |
| ("GET", "/") => RoutePolicy::PublicRoot, |
| ("POST", "/chat") => RoutePolicy::PublicChat, |
| _ => RoutePolicy::NotFound, |
| }, |
| BoundaryMode::LocalShared => RoutePolicy::ForwardLocal, |
| } |
| } |
|
|
| #[derive(Debug, Clone, PartialEq, Eq)] |
| enum BoundaryError { |
| PayloadTooLarge, |
| RateLimited { retry_after: u64 }, |
| BadRequest, |
| WorkerTimeout, |
| Internal, |
| } |
|
|
| impl BoundaryError { |
| fn response(self) -> ApiResponse { |
| match self { |
| Self::PayloadTooLarge => ApiResponse::error( |
| 413, |
| "payload_too_large", |
| "Request body exceeds the 262144-byte limit", |
| ), |
| Self::RateLimited { retry_after } => { |
| ApiResponse::error(429, "rate_limited", "Too many requests") |
| .with_header("Retry-After", retry_after.to_string()) |
| } |
| Self::BadRequest => { |
| ApiResponse::error(400, "bad_request", "Malformed or incomplete JSON request") |
| } |
| Self::WorkerTimeout => { |
| ApiResponse::error(503, "worker_timeout", "Temporary public session timed out") |
| } |
| Self::Internal => ApiResponse::error(500, "internal_error", "Internal server error"), |
| } |
| } |
| } |
|
|
| #[derive(Debug, Clone)] |
| struct ApiResponse { |
| status: u16, |
| body: String, |
| content_type: &'static str, |
| headers: Vec<(&'static str, String)>, |
| } |
|
|
| impl ApiResponse { |
| fn new(status: u16, body: String) -> Self { |
| Self { |
| status, |
| body, |
| content_type: "application/json; charset=utf-8", |
| headers: Vec::new(), |
| } |
| } |
|
|
| fn html(status: u16, body: String) -> Self { |
| Self { |
| status, |
| body, |
| content_type: "text/html; charset=utf-8", |
| headers: Vec::new(), |
| } |
| } |
|
|
| fn error(status: u16, code: &str, message: &str) -> Self { |
| Self::new( |
| status, |
| json!({ |
| "error": message, |
| "code": code, |
| }) |
| .to_string(), |
| ) |
| } |
|
|
| fn with_header(mut self, name: &'static str, value: impl Into<String>) -> Self { |
| self.headers.push((name, value.into())); |
| self |
| } |
| } |
|
|
| #[derive(Debug, Clone, Copy)] |
| struct ClientWindow { |
| started_at: u64, |
| count: u32, |
| } |
|
|
| #[derive(Debug)] |
| struct RateLimiter { |
| limit: u32, |
| window_seconds: u64, |
| clients: HashMap<String, ClientWindow>, |
| } |
|
|
| impl RateLimiter { |
| fn new(limit: u32) -> Self { |
| Self { |
| limit, |
| window_seconds: RATE_WINDOW_SECONDS, |
| clients: HashMap::new(), |
| } |
| } |
|
|
| fn check(&mut self, client: &str) -> std::result::Result<(), BoundaryError> { |
| self.check_at(client, unix_seconds()) |
| } |
|
|
| fn check_at(&mut self, client: &str, now: u64) -> std::result::Result<(), BoundaryError> { |
| self.clients.retain(|_, window| { |
| now.saturating_sub(window.started_at) < self.window_seconds.saturating_mul(2) |
| }); |
|
|
| if !self.clients.contains_key(client) && self.clients.len() >= MAX_TRACKED_CLIENTS { |
| let oldest = self |
| .clients |
| .iter() |
| .min_by(|(left_key, left), (right_key, right)| { |
| left.started_at |
| .cmp(&right.started_at) |
| .then_with(|| left_key.cmp(right_key)) |
| }) |
| .map(|(key, _)| key.clone()); |
| if let Some(oldest) = oldest { |
| self.clients.remove(&oldest); |
| } |
| } |
|
|
| let window = self |
| .clients |
| .entry(client.to_owned()) |
| .or_insert(ClientWindow { |
| started_at: now, |
| count: 0, |
| }); |
| let elapsed = now.saturating_sub(window.started_at); |
| if elapsed >= self.window_seconds { |
| *window = ClientWindow { |
| started_at: now, |
| count: 0, |
| }; |
| } |
|
|
| if window.count >= self.limit { |
| let retry_after = self |
| .window_seconds |
| .saturating_sub(now.saturating_sub(window.started_at)) |
| .max(1); |
| return Err(BoundaryError::RateLimited { retry_after }); |
| } |
|
|
| window.count = window.count.saturating_add(1); |
| Ok(()) |
| } |
| } |
|
|
| #[derive(Debug, Clone)] |
| struct ClientIdentity { |
| key: String, |
| source: &'static str, |
| } |
|
|
| #[derive(Debug, Deserialize)] |
| #[serde(deny_unknown_fields)] |
| struct PublicChatRequest { |
| message: String, |
| #[serde(default)] |
| history: Vec<PublicHistoryItem>, |
| } |
|
|
| #[derive(Debug, Deserialize)] |
| #[serde(deny_unknown_fields)] |
| struct PublicHistoryItem { |
| role: String, |
| content: String, |
| } |
|
|
| pub fn start(host: &str, port: u16, data_dir: &Path) -> Result<()> { |
| let config = SecurityConfig::from_env(host)?; |
| let backend_port = match config.mode { |
| BoundaryMode::LocalShared => { |
| let runtime = Runtime::new(data_dir)?; |
| let runtime = Arc::new(Mutex::new(runtime)); |
| let port = backend_port(port); |
| let backend_data_dir = data_dir.to_path_buf(); |
| thread::Builder::new() |
| .name("starfire-local-backend".to_owned()) |
| .spawn(move || { |
| if let Err(error) = start_backend(runtime, port, &backend_data_dir) { |
| warn!("Starfire loopback backend exited: {}", error); |
| } |
| }) |
| .context("spawn Starfire loopback backend")?; |
| wait_for_backend(port)?; |
| Some(port) |
| } |
| BoundaryMode::PublicRequestEphemeral => None, |
| }; |
|
|
| let address = format!("{}:{}", host, port); |
| let server = tiny_http::Server::http(&address) |
| .map_err(|error| anyhow!("Starfire public API server error: {}", error))?; |
| let mut rate_limiter = RateLimiter::new(config.rate_limit_per_minute); |
| let probe_salt = probe_salt(); |
|
|
| info!( |
| "Starfire API ready at http://{} ({:?}; operator persistence exposed: false in public mode)", |
| address, config.mode |
| ); |
|
|
| for request in server.incoming_requests() { |
| if let Err(error) = handle_request( |
| request, |
| backend_port, |
| data_dir, |
| &config, |
| &mut rate_limiter, |
| probe_salt, |
| ) { |
| warn!("Starfire request transport failure: {}", error); |
| } |
| } |
| Ok(()) |
| } |
|
|
| #[cfg(feature = "starfire-live")] |
| fn start_backend(runtime: Arc<Mutex<Runtime>>, port: u16, data_dir: &Path) -> Result<()> { |
| crate::live_api::start(runtime, "127.0.0.1", port, data_dir) |
| } |
|
|
| #[cfg(not(feature = "starfire-live"))] |
| fn start_backend(runtime: Arc<Mutex<Runtime>>, port: u16, _data_dir: &Path) -> Result<()> { |
| star::api::start(runtime, "127.0.0.1", port) |
| } |
|
|
| fn backend_port(external_port: u16) -> u16 { |
| if let Some(parsed) = std::env::var("STARFIRE_SECURE_BACKEND_PORT") |
| .ok() |
| .and_then(|value| value.parse::<u16>().ok()) |
| .filter(|parsed| *parsed != external_port) |
| { |
| return parsed; |
| } |
| external_port.checked_add(2).unwrap_or(18_082) |
| } |
|
|
| fn wait_for_backend(port: u16) -> Result<()> { |
| let url = format!("http://127.0.0.1:{}/health", port); |
| for _ in 0..400 { |
| if ureq::get(&url).call().is_ok() { |
| return Ok(()); |
| } |
| thread::sleep(Duration::from_millis(50)); |
| } |
| Err(anyhow!( |
| "Starfire loopback backend did not become ready at {}", |
| url |
| )) |
| } |
|
|
| fn handle_request( |
| mut request: tiny_http::Request, |
| backend_port: Option<u16>, |
| _operator_data_dir: &Path, |
| config: &SecurityConfig, |
| rate_limiter: &mut RateLimiter, |
| probe_salt: u64, |
| ) -> Result<()> { |
| let method = request.method().as_str().to_owned(); |
| let path = request.url().to_owned(); |
| let policy = route_policy(config.mode, &method, &path); |
|
|
| if policy == RoutePolicy::Preflight { |
| return respond(request, ApiResponse::new(204, String::new())); |
| } |
|
|
| if policy == RoutePolicy::Health { |
| let response = match config.mode { |
| BoundaryMode::PublicRequestEphemeral => public_health_response(), |
| BoundaryMode::LocalShared => forward( |
| "GET", |
| "/health", |
| &[], |
| backend_port.ok_or_else(|| anyhow!("local backend port missing"))?, |
| ), |
| }; |
| return respond(request, response); |
| } |
|
|
| let identity = client_identity(&request, config.proxy_policy); |
| let probe_requested = |
| config.rate_probe_enabled && request_header(&request, "X-Starfire-Rate-Probe") == Some("1"); |
|
|
| if let Err(error) = rate_limiter.check(&identity.key) { |
| let response = |
| attach_probe_headers(error.response(), probe_requested, &identity, probe_salt); |
| return respond(request, response); |
| } |
|
|
| if policy == RoutePolicy::NotFound { |
| let response = attach_probe_headers( |
| ApiResponse::new(404, json!({ "error": "Not found" }).to_string()), |
| probe_requested, |
| &identity, |
| probe_salt, |
| ); |
| return respond(request, response); |
| } |
|
|
| if content_length(&request).is_some_and(|length| length > MAX_REQUEST_BODY_BYTES) { |
| let response = attach_probe_headers( |
| BoundaryError::PayloadTooLarge.response(), |
| probe_requested, |
| &identity, |
| probe_salt, |
| ); |
| return respond(request, response); |
| } |
|
|
| let mut body = Vec::new(); |
| request |
| .as_reader() |
| .take((MAX_REQUEST_BODY_BYTES + 1) as u64) |
| .read_to_end(&mut body) |
| .context("read bounded request body")?; |
| if body.len() > MAX_REQUEST_BODY_BYTES { |
| let response = attach_probe_headers( |
| BoundaryError::PayloadTooLarge.response(), |
| probe_requested, |
| &identity, |
| probe_salt, |
| ); |
| return respond(request, response); |
| } |
|
|
| if validate_json_request(config.mode, &method, &path, &body).is_err() { |
| let response = attach_probe_headers( |
| BoundaryError::BadRequest.response(), |
| probe_requested, |
| &identity, |
| probe_salt, |
| ); |
| return respond(request, response); |
| } |
|
|
| let response = match policy { |
| RoutePolicy::PublicRoot => public_root_response(), |
| RoutePolicy::PublicChat => run_public_chat_worker(&body), |
| RoutePolicy::ForwardLocal => forward( |
| &method, |
| &path, |
| &body, |
| backend_port.ok_or_else(|| anyhow!("local backend port missing"))?, |
| ), |
| RoutePolicy::Health | RoutePolicy::Preflight | RoutePolicy::NotFound => { |
| BoundaryError::Internal.response() |
| } |
| }; |
| let response = attach_probe_headers(response, probe_requested, &identity, probe_salt); |
| respond(request, response) |
| } |
|
|
| fn public_health_response() -> ApiResponse { |
| ApiResponse::new( |
| 200, |
| json!({ |
| "status": "ok", |
| "mode": "public_request_ephemeral", |
| "state_scope": "single_request", |
| "persistent": false, |
| "operator_state_access": false, |
| "sensitive_routes_exposed": false, |
| "ei_collection_enabled": false, |
| "live_learning_enabled": false |
| }) |
| .to_string(), |
| ) |
| } |
|
|
| fn public_root_response() -> ApiResponse { |
| ApiResponse::html(200, PUBLIC_CHAT_UI.to_owned()).with_header( |
| "Content-Security-Policy", |
| "default-src 'self'; connect-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'self'", |
| ) |
| } |
|
|
| const PUBLIC_CHAT_UI: &str = r#"<!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>Starfire</title> |
| <style> |
| :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #10111a; color: #f4f0ff; } |
| * { box-sizing: border-box; } body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px; } |
| main { width: min(760px, 100%); min-height: min(760px, calc(100vh - 48px)); display: flex; flex-direction: column; background: #181a27; border: 1px solid #333650; border-radius: 20px; box-shadow: 0 18px 70px #0008; overflow: hidden; } |
| header { padding: 24px 26px 18px; background: linear-gradient(120deg, #31255d, #1b2141); border-bottom: 1px solid #45456d; } |
| h1 { margin: 0; font-size: 1.45rem; } header p { margin: 7px 0 0; color: #cbc9e6; font-size: .92rem; } |
| #conversation { flex: 1; overflow-y: auto; padding: 22px; display: flex; flex-direction: column; gap: 14px; } |
| .message { max-width: 88%; white-space: pre-wrap; overflow-wrap: anywhere; padding: 12px 14px; border-radius: 14px; line-height: 1.45; } |
| .assistant { align-self: flex-start; background: #292c42; border-bottom-left-radius: 4px; } |
| .user { align-self: flex-end; background: #6f4ed6; border-bottom-right-radius: 4px; } |
| .status { align-self: flex-start; color: #b8b8ce; font-size: .88rem; padding: 4px; } |
| form { display: flex; gap: 10px; padding: 16px; border-top: 1px solid #333650; background: #141622; } |
| textarea { flex: 1; resize: vertical; min-height: 48px; max-height: 160px; padding: 12px; border: 1px solid #4b4e6d; border-radius: 11px; background: #22243a; color: inherit; font: inherit; } |
| button { border: 0; border-radius: 11px; padding: 0 18px; background: #8a67ff; color: #fff; font: inherit; font-weight: 650; cursor: pointer; } |
| button:disabled, textarea:disabled { opacity: .6; cursor: wait; } .secondary { background: transparent; color: #cbc9e6; border: 1px solid #4b4e6d; } |
| footer { padding: 0 18px 14px; color: #9898b7; font-size: .78rem; } @media (max-width: 560px) { body { padding: 0; } main { min-height: 100vh; border-radius: 0; border: 0; } form { flex-wrap: wrap; } form button { min-height: 42px; } } |
| </style> |
| </head> |
| <body> |
| <main> |
| <header><h1>Starfire</h1><p>Public, request-ephemeral conversation. Nothing is saved between requests.</p></header> |
| <section id="conversation" aria-live="polite"><div class="message assistant">Hi — what would you like to explore?</div></section> |
| <form id="chat-form"><textarea id="message" aria-label="Message" placeholder="Write a message…" required maxlength="16384"></textarea><button id="send" type="submit">Send</button><button id="clear" class="secondary" type="button">Clear</button></form> |
| <footer>Temporary browser history is kept only in this tab to provide conversational context.</footer> |
| </main> |
| <script> |
| (() => { |
| const conversation = document.getElementById('conversation'); |
| const form = document.getElementById('chat-form'); |
| const input = document.getElementById('message'); |
| const send = document.getElementById('send'); |
| const clear = document.getElementById('clear'); |
| let history = []; |
| function add(role, text, kind = 'message') { const item = document.createElement('div'); item.className = `${kind} ${role}`; item.textContent = text; conversation.append(item); conversation.scrollTop = conversation.scrollHeight; return item; } |
| form.addEventListener('submit', async (event) => { |
| event.preventDefault(); const message = input.value.trim(); if (!message) return; |
| add('user', message); history.push({ role: 'user', content: message }); input.value = ''; input.disabled = true; send.disabled = true; |
| const pending = add('status', 'Starfire is thinking…', 'status'); |
| try { |
| const response = await fetch('/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, history: history.slice(0, -1) }) }); |
| const payload = await response.json(); if (!response.ok || typeof payload.response !== 'string') throw new Error(payload.error || 'The request could not be completed.'); |
| pending.remove(); add('assistant', payload.response); history.push({ role: 'assistant', content: payload.response }); |
| } catch (error) { pending.remove(); add('status', error instanceof Error ? error.message : 'The request could not be completed.', 'status'); history.pop(); } |
| finally { input.disabled = false; send.disabled = false; input.focus(); } |
| }); |
| clear.addEventListener('click', () => { history = []; conversation.replaceChildren(); add('assistant', 'Conversation cleared. What would you like to explore?'); input.focus(); }); |
| })(); |
| </script> |
| </body> |
| </html>"#; |
|
|
| fn run_public_chat_worker(body: &[u8]) -> ApiResponse { |
| let session = match EphemeralDirectory::create() { |
| Ok(session) => session, |
| Err(error) => { |
| warn!("create public ephemeral directory: {}", error); |
| return BoundaryError::Internal.response(); |
| } |
| }; |
| let request_id = session.request_id().to_owned(); |
| let executable = match std::env::current_exe() { |
| Ok(executable) => executable, |
| Err(error) => { |
| warn!("resolve public worker executable: {}", error); |
| return BoundaryError::Internal.response(); |
| } |
| }; |
|
|
| let mut child = match Command::new(executable) |
| .arg("--data-dir") |
| .arg(session.path()) |
| .arg("public-chat-once") |
| .env("STARFIRE_HOSTED_MODE", "false") |
| .env_remove("STARFIRE_PUBLIC_RUNTIME_MODE") |
| .env_remove("STARFIRE_TRUSTED_PROXY_HEADERS") |
| .env_remove("STARFIRE_RATE_LIMIT_PROBE") |
| .env("STARFIRE_PUBLIC_REQUEST_ID", &request_id) |
| .stdin(Stdio::piped()) |
| .stdout(Stdio::piped()) |
| .stderr(Stdio::null()) |
| .spawn() |
| { |
| Ok(child) => child, |
| Err(error) => { |
| warn!("spawn public chat worker: {}", error); |
| return BoundaryError::Internal.response(); |
| } |
| }; |
|
|
| if let Some(mut stdin) = child.stdin.take() { |
| if let Err(error) = stdin.write_all(body) { |
| let _ = child.kill(); |
| let _ = child.wait(); |
| warn!("write public chat worker request: {}", error); |
| return BoundaryError::Internal.response(); |
| } |
| } |
|
|
| let deadline = Instant::now() + Duration::from_secs(PUBLIC_WORKER_TIMEOUT_SECONDS); |
| let status = loop { |
| match child.try_wait() { |
| Ok(Some(status)) => break status, |
| Ok(None) if Instant::now() < deadline => { |
| thread::sleep(Duration::from_millis(20)); |
| } |
| Ok(None) => { |
| let _ = child.kill(); |
| let _ = child.wait(); |
| return BoundaryError::WorkerTimeout.response(); |
| } |
| Err(error) => { |
| let _ = child.kill(); |
| let _ = child.wait(); |
| warn!("wait for public chat worker: {}", error); |
| return BoundaryError::Internal.response(); |
| } |
| } |
| }; |
|
|
| let mut output = Vec::new(); |
| if let Some(stdout) = child.stdout.take() { |
| let mut bounded = stdout.take(PUBLIC_WORKER_OUTPUT_BYTES + 1); |
| if let Err(error) = bounded.read_to_end(&mut output) { |
| warn!("read public chat worker response: {}", error); |
| return BoundaryError::Internal.response(); |
| } |
| } |
| if !status.success() || output.len() as u64 > PUBLIC_WORKER_OUTPUT_BYTES { |
| warn!( |
| "public chat worker failed: status={}, output_bytes={}", |
| status, |
| output.len() |
| ); |
| return BoundaryError::Internal.response(); |
| } |
|
|
| let value = match worker_json_value(&output) { |
| Some(value) => value, |
| None => { |
| warn!("public chat worker returned no valid JSON payload"); |
| return BoundaryError::Internal.response(); |
| } |
| }; |
| let valid = value.get("response").and_then(Value::as_str).is_some() |
| && value |
| .get("public_session") |
| .and_then(|session| session.get("request_id")) |
| .and_then(Value::as_str) |
| == Some(request_id.as_str()) |
| && value |
| .pointer("/public_session/persistence") |
| .and_then(Value::as_bool) |
| == Some(false) |
| && value |
| .pointer("/public_session/operator_state_access") |
| .and_then(Value::as_bool) |
| == Some(false); |
| if !valid { |
| warn!("public chat worker returned an invalid authority envelope"); |
| return BoundaryError::Internal.response(); |
| } |
|
|
| ApiResponse::new(200, value.to_string()) |
| } |
|
|
| fn worker_json_value(output: &[u8]) -> Option<Value> { |
| output |
| .split(|byte| *byte == b'\n') |
| .rev() |
| .find_map(|line| serde_json::from_slice::<Value>(line).ok()) |
| } |
|
|
| struct EphemeralDirectory { |
| path: PathBuf, |
| request_id: String, |
| } |
|
|
| impl EphemeralDirectory { |
| fn create() -> Result<Self> { |
| let counter = PUBLIC_REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed); |
| let request_id = format!("public-{}-{}-{}", std::process::id(), unix_nanos(), counter); |
| let root = std::env::temp_dir().join("starfire-public-requests"); |
| fs::create_dir_all(&root) |
| .with_context(|| format!("create public request root {}", root.display()))?; |
| let path = root.join(&request_id); |
| fs::create_dir(&path) |
| .with_context(|| format!("create public request directory {}", path.display()))?; |
| Ok(Self { path, request_id }) |
| } |
|
|
| fn path(&self) -> &Path { |
| &self.path |
| } |
|
|
| fn request_id(&self) -> &str { |
| &self.request_id |
| } |
| } |
|
|
| impl Drop for EphemeralDirectory { |
| fn drop(&mut self) { |
| if let Err(error) = fs::remove_dir_all(&self.path) { |
| warn!( |
| "remove public request directory {}: {}", |
| self.path.display(), |
| error |
| ); |
| } |
| } |
| } |
|
|
| fn forward(method: &str, path: &str, body: &[u8], port: u16) -> ApiResponse { |
| let url = format!("http://127.0.0.1:{}{}", port, path); |
| let forwarded = ureq::request(method, &url).set("Content-Type", "application/json"); |
| let result = if body.is_empty() { |
| forwarded.call() |
| } else { |
| forwarded.send_bytes(body) |
| }; |
|
|
| match result { |
| Ok(response) => { |
| let status = response.status(); |
| match response.into_string() { |
| Ok(body) => map_backend_response(status, body), |
| Err(_) => BoundaryError::Internal.response(), |
| } |
| } |
| Err(ureq::Error::Status(status, response)) => { |
| let body = response |
| .into_string() |
| .unwrap_or_else(|_| json!({ "error": "Backend request failed" }).to_string()); |
| map_backend_response(status, body) |
| } |
| Err(ureq::Error::Transport(_)) => BoundaryError::Internal.response(), |
| } |
| } |
|
|
| fn map_backend_response(status: u16, body: String) -> ApiResponse { |
| if status == 404 { |
| return ApiResponse::new(404, json!({ "error": "Not found" }).to_string()); |
| } |
| if status >= 500 { |
| return BoundaryError::Internal.response(); |
| } |
| if status >= 400 { |
| return ApiResponse::new(status, body); |
| } |
|
|
| let backend_error = serde_json::from_str::<Value>(&body).ok().and_then(|value| { |
| value |
| .get("error") |
| .and_then(Value::as_str) |
| .map(str::to_owned) |
| }); |
| match backend_error.as_deref() { |
| Some(error) if error.starts_with("Invalid request:") => { |
| BoundaryError::BadRequest.response() |
| } |
| Some(error) |
| if error.starts_with("Lock poisoned:") |
| || error.starts_with("Chat error:") |
| || error.starts_with("Serialization:") => |
| { |
| BoundaryError::Internal.response() |
| } |
| _ => ApiResponse::new(status, body), |
| } |
| } |
|
|
| fn validate_json_request( |
| mode: BoundaryMode, |
| method: &str, |
| path: &str, |
| body: &[u8], |
| ) -> std::result::Result<(), BoundaryError> { |
| if method != "POST" { |
| return Ok(()); |
| } |
|
|
| if mode == BoundaryMode::PublicRequestEphemeral && path == "/chat" { |
| let request: PublicChatRequest = |
| serde_json::from_slice(body).map_err(|_| BoundaryError::BadRequest)?; |
| if request.message.trim().is_empty() || request.message.len() > PUBLIC_MESSAGE_BYTES { |
| return Err(BoundaryError::BadRequest); |
| } |
| if request.history.len() > PUBLIC_HISTORY_LIMIT { |
| return Err(BoundaryError::BadRequest); |
| } |
| for item in request.history { |
| if !matches!(item.role.as_str(), "user" | "assistant") |
| || item.content.trim().is_empty() |
| || item.content.len() > PUBLIC_HISTORY_ITEM_BYTES |
| { |
| return Err(BoundaryError::BadRequest); |
| } |
| } |
| return Ok(()); |
| } |
|
|
| let required_field = match path { |
| "/chat" => Some("message"), |
| "/reason" => Some("query"), |
| "/remember" => Some("topic"), |
| _ => return Ok(()), |
| }; |
|
|
| let value: Value = serde_json::from_slice(body).map_err(|_| BoundaryError::BadRequest)?; |
| let object = value.as_object().ok_or(BoundaryError::BadRequest)?; |
|
|
| if let Some(field) = required_field { |
| let valid = object |
| .get(field) |
| .and_then(Value::as_str) |
| .is_some_and(|text| !text.trim().is_empty()); |
| if !valid { |
| return Err(BoundaryError::BadRequest); |
| } |
| } |
|
|
| if path == "/reason" { |
| if let Some(memories) = object.get("memories") { |
| let valid = memories |
| .as_array() |
| .is_some_and(|items| items.iter().all(Value::is_string)); |
| if !valid { |
| return Err(BoundaryError::BadRequest); |
| } |
| } |
| } |
|
|
| if path == "/remember" { |
| if let Some(limit) = object.get("limit") { |
| if !limit.is_null() && limit.as_u64().is_none() { |
| return Err(BoundaryError::BadRequest); |
| } |
| } |
| } |
|
|
| Ok(()) |
| } |
|
|
| fn client_identity(request: &tiny_http::Request, policy: ProxyPolicy) -> ClientIdentity { |
| if policy == ProxyPolicy::ForwardedForLast { |
| if let Some(ip) = request_header(request, "X-Forwarded-For").and_then(forwarded_for_last) { |
| return ClientIdentity { |
| key: ip, |
| source: "x-forwarded-for-last", |
| }; |
| } |
| } |
|
|
| ClientIdentity { |
| key: request |
| .remote_addr() |
| .map(|address| address.ip().to_string()) |
| .unwrap_or_else(|| "unknown-client".to_owned()), |
| source: if policy == ProxyPolicy::ForwardedForLast { |
| "proxy-peer-fallback" |
| } else { |
| "direct-peer" |
| }, |
| } |
| } |
|
|
| fn forwarded_for_last(value: &str) -> Option<String> { |
| value |
| .split(',') |
| .rev() |
| .map(str::trim) |
| .find_map(|candidate| candidate.parse::<IpAddr>().ok()) |
| .map(|ip| ip.to_string()) |
| } |
|
|
| fn attach_probe_headers( |
| response: ApiResponse, |
| requested: bool, |
| identity: &ClientIdentity, |
| salt: u64, |
| ) -> ApiResponse { |
| if !requested { |
| return response; |
| } |
| response |
| .with_header("X-Starfire-Rate-Key-Source", identity.source) |
| .with_header( |
| "X-Starfire-Rate-Bucket", |
| format!("{:016x}", bucket_fingerprint(&identity.key, salt)), |
| ) |
| } |
|
|
| fn bucket_fingerprint(key: &str, salt: u64) -> u64 { |
| let mut hasher = DefaultHasher::new(); |
| salt.hash(&mut hasher); |
| key.hash(&mut hasher); |
| hasher.finish() |
| } |
|
|
| fn request_header<'a>(request: &'a tiny_http::Request, name: &'static str) -> Option<&'a str> { |
| request |
| .headers() |
| .iter() |
| .find(|header| header.field.equiv(name)) |
| .map(|header| header.value.as_str()) |
| } |
|
|
| fn content_length(request: &tiny_http::Request) -> Option<usize> { |
| request_header(request, "Content-Length")? |
| .parse::<usize>() |
| .ok() |
| } |
|
|
| fn respond(request: tiny_http::Request, api: ApiResponse) -> Result<()> { |
| let mut response = tiny_http::Response::from_data(api.body.into_bytes()) |
| .with_status_code(api.status) |
| .with_header(header("Content-Type", api.content_type)) |
| .with_header(header("Access-Control-Allow-Origin", "*")) |
| .with_header(header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")) |
| .with_header(header("Access-Control-Allow-Headers", "Content-Type")) |
| .with_header(header("Access-Control-Max-Age", "600")) |
| .with_header(header("Cache-Control", "no-store")) |
| .with_header(header("X-Content-Type-Options", "nosniff")); |
|
|
| for (name, value) in api.headers { |
| response.add_header(header(name, &value)); |
| } |
|
|
| request |
| .respond(response) |
| .map_err(|error| anyhow!("send Starfire response: {}", error)) |
| } |
|
|
| fn header(name: &str, value: &str) -> tiny_http::Header { |
| tiny_http::Header::from_bytes(name.as_bytes(), value.as_bytes()) |
| .expect("static HTTP header must be valid") |
| } |
|
|
| fn env_truthy(name: &str) -> bool { |
| std::env::var(name) |
| .ok() |
| .is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes")) |
| } |
|
|
| fn host_is_loopback(host: &str) -> bool { |
| host == "localhost" |
| || host |
| .parse::<IpAddr>() |
| .ok() |
| .is_some_and(|address| address.is_loopback()) |
| } |
|
|
| fn probe_salt() -> u64 { |
| unix_nanos() as u64 ^ u64::from(std::process::id()) |
| } |
|
|
| fn unix_seconds() -> u64 { |
| SystemTime::now() |
| .duration_since(UNIX_EPOCH) |
| .unwrap_or_default() |
| .as_secs() |
| } |
|
|
| fn unix_nanos() -> u128 { |
| SystemTime::now() |
| .duration_since(UNIX_EPOCH) |
| .unwrap_or_default() |
| .as_nanos() |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use super::*; |
|
|
| #[test] |
| fn public_mode_exposes_only_health_root_and_chat() { |
| assert_eq!( |
| route_policy(BoundaryMode::PublicRequestEphemeral, "GET", "/health"), |
| RoutePolicy::Health |
| ); |
| assert_eq!( |
| route_policy(BoundaryMode::PublicRequestEphemeral, "GET", "/"), |
| RoutePolicy::PublicRoot |
| ); |
| assert_eq!( |
| route_policy(BoundaryMode::PublicRequestEphemeral, "POST", "/chat"), |
| RoutePolicy::PublicChat |
| ); |
| for path in [ |
| "/reason", |
| "/remember", |
| "/identity", |
| "/memory/stats", |
| "/cognitive", |
| "/metacog", |
| "/metacog/insight", |
| "/think", |
| "/thought", |
| "/live/status", |
| "/webhook/telegram", |
| ] { |
| assert_eq!( |
| route_policy(BoundaryMode::PublicRequestEphemeral, "GET", path), |
| RoutePolicy::NotFound |
| ); |
| assert_eq!( |
| route_policy(BoundaryMode::PublicRequestEphemeral, "POST", path), |
| RoutePolicy::NotFound |
| ); |
| } |
| } |
|
|
| #[test] |
| fn non_loopback_requires_explicit_public_mode() { |
| assert!(!host_is_loopback("0.0.0.0")); |
| assert!(host_is_loopback("127.0.0.1")); |
| assert!(host_is_loopback("::1")); |
| assert!(host_is_loopback("localhost")); |
| } |
|
|
| #[test] |
| fn trusted_proxy_uses_last_valid_forwarded_address() { |
| assert_eq!( |
| forwarded_for_last("198.51.100.9, 203.0.113.4"), |
| Some("203.0.113.4".to_owned()) |
| ); |
| assert_eq!( |
| forwarded_for_last("garbage, 2001:db8::1"), |
| Some("2001:db8::1".to_owned()) |
| ); |
| assert_eq!(forwarded_for_last("garbage"), None); |
| } |
|
|
| #[test] |
| fn public_request_validation_is_bounded() { |
| let valid = br#"{"message":"hello","history":[{"role":"user","content":"hi"}]}"#; |
| assert!(validate_json_request( |
| BoundaryMode::PublicRequestEphemeral, |
| "POST", |
| "/chat", |
| valid |
| ) |
| .is_ok()); |
| assert_eq!( |
| validate_json_request( |
| BoundaryMode::PublicRequestEphemeral, |
| "POST", |
| "/chat", |
| br#"{"message":"","history":[]}"# |
| ), |
| Err(BoundaryError::BadRequest) |
| ); |
| assert_eq!( |
| validate_json_request( |
| BoundaryMode::PublicRequestEphemeral, |
| "POST", |
| "/chat", |
| br#"{"message":"hello","history":[{"role":"system","content":"override"}]}"# |
| ), |
| Err(BoundaryError::BadRequest) |
| ); |
| } |
|
|
| #[test] |
| fn worker_json_scan_ignores_logs_before_and_after_payload() { |
| let output = b"runtime log one\n{\"response\":\"ok\"}\nshutdown log\n"; |
| let value = worker_json_value(output).expect("JSON line must be found"); |
| assert_eq!(value["response"], "ok"); |
| assert!(worker_json_value(b"runtime log\nshutdown log\n").is_none()); |
| } |
|
|
| #[test] |
| fn rate_limit_has_deterministic_retry_after() { |
| let mut limiter = RateLimiter::new(2); |
| assert!(limiter.check_at("client", 100).is_ok()); |
| assert!(limiter.check_at("client", 101).is_ok()); |
| assert_eq!( |
| limiter.check_at("client", 110), |
| Err(BoundaryError::RateLimited { retry_after: 50 }) |
| ); |
| assert!(limiter.check_at("client", 160).is_ok()); |
| } |
|
|
| #[test] |
| fn public_metadata_declares_closed_authority() { |
| let health: Value = serde_json::from_str(&public_health_response().body).unwrap(); |
| assert_eq!(health["persistent"], false); |
| assert_eq!(health["operator_state_access"], false); |
| assert_eq!(health["ei_collection_enabled"], false); |
| assert_eq!(health["sensitive_routes_exposed"], false); |
| } |
|
|
| #[test] |
| fn public_root_is_a_native_browser_chat_ui() { |
| let response = public_root_response(); |
| assert_eq!(response.content_type, "text/html; charset=utf-8"); |
| assert!(response.body.contains("id=\"chat-form\"")); |
| assert!(response.body.contains("fetch('/chat'")); |
| assert!(response.body.contains("request-ephemeral")); |
| assert!(!response.body.contains("<script src=")); |
| assert!(response |
| .headers |
| .iter() |
| .any(|(name, value)| *name == "Content-Security-Policy" |
| && value.contains("connect-src 'self'"))); |
| } |
|
|
| #[test] |
| fn configuration_rejects_zero_rate_limit() { |
| assert!(SecurityConfig::from_values( |
| 0, |
| BoundaryMode::LocalShared, |
| ProxyPolicy::DirectPeer, |
| false |
| ) |
| .is_err()); |
| } |
|
|
| #[test] |
| fn errors_keep_expected_status_codes() { |
| assert_eq!(BoundaryError::PayloadTooLarge.response().status, 413); |
| assert_eq!( |
| BoundaryError::RateLimited { retry_after: 7 } |
| .response() |
| .status, |
| 429 |
| ); |
| assert_eq!(BoundaryError::BadRequest.response().status, 400); |
| assert_eq!(BoundaryError::WorkerTimeout.response().status, 503); |
| assert_eq!(BoundaryError::Internal.response().status, 500); |
| } |
| } |
|
|