diff --git a/src/agentsight/AGENTS.md b/src/agentsight/AGENTS.md index 990ad3035..7eaca4d48 100644 --- a/src/agentsight/AGENTS.md +++ b/src/agentsight/AGENTS.md @@ -142,6 +142,7 @@ OpenAI Responses API 的 `response.completed` 事件可能跨多个 TLS record | `agentsight discover` | `src/bin/cli/discover.rs` | 发现运行中的 AI Agent | | `agentsight metrics` | `src/bin/cli/metrics.rs` | Prometheus 格式指标 | | `agentsight interruption` | `src/bin/cli/interruption.rs` | 查询/管理会话中断事件 | +| `agentsight dashboard` | `src/bin/cli/dashboard.rs` | 查看 Dashboard 认证状态与 Token | ### 6.1 Interruption CLI 详细用法 @@ -217,6 +218,9 @@ agentsight interruption --db /path/to/interruption_events.db list --last 48 | `/api/interruptions/{id}/resolve` | POST | 标记中断为已解决 | | `/api/sessions/{id}/interruptions` | GET | 指定 session 的所有中断 | | `/api/conversations/{id}/interruptions` | GET | 指定 conversation 的所有中断 | +| `/api/auth/login` | POST | Dashboard 登录(Body: `{"token":"..."}` ),成功设置 httpOnly cookie | +| `/api/auth/status` | GET | 返回 `{"auth_enabled": bool}`(免认证,供前端判断是否需登录)| +| `/api/auth/verify` | GET | 校验当前 session cookie/token 是否有效,返回 `{"authenticated": bool}` | ## 9. Frontend diff --git a/src/agentsight/agentsight.json b/src/agentsight/agentsight.json index 674bcbf6b..3b0ec76a0 100644 --- a/src/agentsight/agentsight.json +++ b/src/agentsight/agentsight.json @@ -2,6 +2,11 @@ "runtime": { "sls_logtail_path": "" }, + "server": { + "auth": { + "enabled": true + } + }, "deadloop": { "enabled": false, "kill_after_count": 3 diff --git a/src/agentsight/dashboard/src/App.tsx b/src/agentsight/dashboard/src/App.tsx index 6d5f49689..accb4a130 100644 --- a/src/agentsight/dashboard/src/App.tsx +++ b/src/agentsight/dashboard/src/App.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { HashRouter, Routes, Route } from 'react-router-dom'; +import React, { useEffect, useState } from 'react'; +import { HashRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom'; import { NavBar } from './components/NavBar'; import { AgentHealthSidebar } from './components/AgentHealthSidebar'; import { ConversationList } from './pages/ConversationList'; @@ -7,25 +7,114 @@ import { AtifViewerPage } from './pages/AtifViewerPage'; import { TokenSavingsPage } from './pages/TokenSavingsPage'; import { SkillMetricsPage } from './pages/SkillMetricsPage'; import { SecurityObservabilityPage } from './pages/SecurityObservabilityPage'; +import { LoginPage } from './pages/LoginPage'; +import { fetchAuthStatus, fetchAuthVerify, login } from './utils/apiClient'; + +/** Auth gate: checks auth status and renders LoginPage when needed. */ +const AuthGate: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [authState, setAuthState] = useState<'loading' | 'authenticated' | 'unauthenticated' | 'disabled'>('loading'); + const navigate = useNavigate(); + const location = useLocation(); + + useEffect(() => { + // Check for token in URL query parameter (e.g. ?token=xxx from CLI link) + const urlParams = new URLSearchParams(window.location.search); + const urlToken = urlParams.get('token'); + if (urlToken) { + // Auto-login with the token from URL, then clean the URL + (async () => { + try { + const ok = await login(urlToken); + // Remove token from URL regardless of success + urlParams.delete('token'); + const cleanSearch = urlParams.toString(); + const cleanUrl = window.location.pathname + + (cleanSearch ? '?' + cleanSearch : '') + + window.location.hash; + window.history.replaceState(null, '', cleanUrl); + if (ok) { + setAuthState('authenticated'); + } else { + setAuthState('unauthenticated'); + } + } catch { + setAuthState('unauthenticated'); + } + })(); + return; + } + + // Always allow the login page itself + if (location.pathname === '/login') { + setAuthState('unauthenticated'); + return; + } + + (async () => { + try { + const status = await fetchAuthStatus(); + if (!status.auth_enabled) { + setAuthState('disabled'); + return; + } + const verify = await fetchAuthVerify(); + if (verify.authenticated) { + setAuthState('authenticated'); + } else { + setAuthState('unauthenticated'); + } + } catch { + // Server unreachable — skip auth check + setAuthState('disabled'); + } + })(); + }, [location.pathname]); + + const handleAuthenticated = () => { + setAuthState('authenticated'); + navigate('/'); + }; + + if (authState === 'loading') { + return ( +
+ Loading... +
+ ); + } + + if (authState === 'unauthenticated') { + return ; + } + + return <>{children}; +}; const App: React.FC = () => { return ( -
- -
-
- - } /> - } /> - } /> - } /> - } /> - -
- -
-
+ + { window.location.hash = '#/'; window.location.reload(); }} />} /> + +
+ +
+
+ + } /> + } /> + } /> + } /> + } /> + +
+ +
+
+ + } /> +
); }; diff --git a/src/agentsight/dashboard/src/pages/LoginPage.tsx b/src/agentsight/dashboard/src/pages/LoginPage.tsx new file mode 100644 index 000000000..1e17230ad --- /dev/null +++ b/src/agentsight/dashboard/src/pages/LoginPage.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react'; +import { login } from '../utils/apiClient'; + +interface LoginPageProps { + onAuthenticated: () => void; +} + +export const LoginPage: React.FC = ({ onAuthenticated }) => { + const [token, setToken] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!token.trim()) { + setError('Please enter a token'); + return; + } + setLoading(true); + setError(''); + try { + const ok = await login(token.trim()); + if (ok) { + onAuthenticated(); + } else { + setError('Invalid token. Check the token with `agentsight dashboard`.'); + } + } catch (err) { + setError('Connection error. Is the AgentSight server running?'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

AgentSight

+

Enter your dashboard token to continue

+
+ +
+
+ + setToken(e.target.value)} + placeholder="Paste your token here" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + autoFocus + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +
+

Run agentsight dashboard to view your token.

+

+ Or use --full-token to show the complete value. +

+
+
+
+ ); +}; diff --git a/src/agentsight/dashboard/src/utils/apiClient.ts b/src/agentsight/dashboard/src/utils/apiClient.ts index e7cb96508..d883aa147 100644 --- a/src/agentsight/dashboard/src/utils/apiClient.ts +++ b/src/agentsight/dashboard/src/utils/apiClient.ts @@ -74,7 +74,12 @@ export interface TraceEventDetail { // ─── Internal helpers ──────────────────────────────────────────────────────── async function apiFetch(url: string): Promise { - const res = await fetch(url); + const res = await fetch(url, { credentials: 'same-origin' }); + if (res.status === 401) { + // Session expired or invalid — redirect to login + window.location.hash = '#/login'; + throw new Error('Authentication required'); + } if (!res.ok) { const text = await res.text().catch(() => res.statusText); throw new Error(`API ${url} -> ${res.status}: ${text}`); @@ -477,7 +482,7 @@ export async function fetchInterruptionCount( export async function resolveInterruption(interruptionId: string): Promise { const res = await fetch( `${API_BASE}/api/interruptions/${encodeURIComponent(interruptionId)}/resolve`, - { method: 'POST' } + { method: 'POST', credentials: 'same-origin' } ); if (!res.ok) { const text = await res.text().catch(() => res.statusText); @@ -529,7 +534,7 @@ export async function fetchAgentHealth(opts?: { includeClients?: boolean }): Pro * Acknowledge and remove an offline agent by PID. */ export async function deleteAgentHealth(pid: number): Promise { - const res = await fetch(`${API_BASE}/api/agent-health/${pid}`, { method: 'DELETE' }); + const res = await fetch(`${API_BASE}/api/agent-health/${pid}`, { method: 'DELETE', credentials: 'same-origin' }); if (!res.ok) { const text = await res.text().catch(() => res.statusText); throw new Error(`DELETE /api/agent-health/${pid} -> ${res.status}: ${text}`); @@ -541,7 +546,7 @@ export async function deleteAgentHealth(pid: number): Promise { * Returns the new PID on success. */ export async function restartAgentHealth(pid: number): Promise<{ ok: boolean; new_pid: number; cmd: string[] }> { - const res = await fetch(`${API_BASE}/api/agent-health/${pid}/restart`, { method: 'POST' }); + const res = await fetch(`${API_BASE}/api/agent-health/${pid}/restart`, { method: 'POST', credentials: 'same-origin' }); const body = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(`POST /api/agent-health/${pid}/restart -> ${res.status}: ${body.error ?? res.statusText}`); @@ -819,7 +824,11 @@ function buildQuery(params?: object): string { } async function securityFetch(url: string): Promise> { - const res = await fetch(url); + const res = await fetch(url, { credentials: 'same-origin' }); + if (res.status === 401) { + window.location.hash = '#/login'; + throw new Error('Authentication required'); + } const text = await res.text().catch(() => ''); let body: unknown = null; if (text) { @@ -1015,3 +1024,44 @@ export async function fetchSkillMetrics( `${API_BASE}/api/skill-metrics${buildSkillMetricsParams(startNs, endNs, agentName, granularity)}` ); } + +// ─── Authentication API ────────────────────────────────────────────────────── + +export interface AuthStatusResponse { + auth_enabled: boolean; +} + +export interface AuthVerifyResponse { + authenticated: boolean; +} + +/** + * Check whether dashboard authentication is enabled. + */ +export async function fetchAuthStatus(): Promise { + const res = await fetch(`${API_BASE}/api/auth/status`); + if (!res.ok) throw new Error(`GET /api/auth/status -> ${res.status}`); + return res.json(); +} + +/** + * Verify the current session (cookie-based). + */ +export async function fetchAuthVerify(): Promise { + const res = await fetch(`${API_BASE}/api/auth/verify`, { credentials: 'same-origin' }); + if (!res.ok) throw new Error(`GET /api/auth/verify -> ${res.status}`); + return res.json(); +} + +/** + * Log in with a token string. Sets a session cookie on success. + */ +export async function login(token: string): Promise { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ token }), + }); + return res.ok; +} diff --git a/src/agentsight/src/bin/agentsight.rs b/src/agentsight/src/bin/agentsight.rs index 0c1358f8d..9a76c14bc 100644 --- a/src/agentsight/src/bin/agentsight.rs +++ b/src/agentsight/src/bin/agentsight.rs @@ -10,6 +10,8 @@ use structopt::StructOpt; mod cli; #[cfg(feature = "server")] +use cli::dashboard::DashboardCommand; +#[cfg(feature = "server")] use cli::serve::ServeCommand; use cli::{ audit::AuditCommand, discover::DiscoverCommand, interruption::InterruptionCommand, @@ -43,6 +45,9 @@ pub enum Command { /// Start the API server #[cfg(feature = "server")] Serve(ServeCommand), + /// Display dashboard authentication status and token + #[cfg(feature = "server")] + Dashboard(DashboardCommand), } fn main() { @@ -59,5 +64,7 @@ fn main() { Command::Summary(summary_cmd) => summary_cmd.execute(), #[cfg(feature = "server")] Command::Serve(serve_cmd) => serve_cmd.execute(), + #[cfg(feature = "server")] + Command::Dashboard(dashboard_cmd) => dashboard_cmd.execute(), } } diff --git a/src/agentsight/src/bin/cli/dashboard.rs b/src/agentsight/src/bin/cli/dashboard.rs new file mode 100644 index 000000000..98013257b --- /dev/null +++ b/src/agentsight/src/bin/cli/dashboard.rs @@ -0,0 +1,97 @@ +//! Dashboard subcommand — display dashboard authentication status + +use agentsight::server::auth::DashboardAuth; +use structopt::StructOpt; + +use super::{DEFAULT_CONFIG_PATH, load_server_auth_config}; + +/// Display the current AgentSight dashboard status (auth, token, URL) +#[derive(Debug, StructOpt, Clone)] +pub struct DashboardCommand { + /// Custom database path (used to locate the token file) + #[structopt(long)] + pub db: Option, + + /// Host the server is bound to + #[structopt(long, default_value = "0.0.0.0")] + pub host: String, + + /// Port the server is listening on + #[structopt(long, default_value = "7396")] + pub port: u16, + + /// Path to JSON configuration file + #[structopt(long, default_value = DEFAULT_CONFIG_PATH)] + pub config: String, +} + +impl DashboardCommand { + pub fn execute(&self) { + let storage_base = self + .db + .as_ref() + .map(std::path::PathBuf::from) + .and_then(|p| p.parent().map(|pp| pp.to_path_buf())) + .unwrap_or_else(|| { + agentsight::storage::sqlite::GenAISqliteStore::default_path() + .parent() + .unwrap_or(std::path::Path::new("/var/log/sysak/.agentsight")) + .to_path_buf() + }); + + // Load server.auth.enabled from config file (same source as `serve`) + let auth_config = load_server_auth_config(&self.config); + let auth = DashboardAuth::init(&auth_config, &storage_base); + + println!("AgentSight Dashboard Status"); + println!("==========================="); + println!(); + + if auth.enabled { + println!(" Auth: enabled"); + } else { + println!(" Auth: disabled"); + } + + // Display URLs + println!( + " Local: http://127.0.0.1:{} (no auth required)", + self.port + ); + let net_ip = local_addresses().into_iter().next(); + match (net_ip, auth.read_token_from_file()) { + (Some(ip), Some(token)) => { + println!(" Network: http://{}:{}/?token={}", ip, self.port, token); + } + (Some(ip), None) => { + println!(" Network: http://{}:{}", ip, self.port); + } + _ => println!(" Network: (no non-loopback interface found)"), + } + println!(); + } +} + +/// Get non-loopback local IP addresses for URL display. +fn local_addresses() -> Vec { + let Ok(output) = std::process::Command::new("ip") + .args(["-4", "addr", "show"]) + .output() + else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + let text = String::from_utf8_lossy(&output.stdout); + text.lines() + .filter_map(|line| { + line.trim() + .strip_prefix("inet ") + .and_then(|r| r.split('/').next()) + }) + .filter_map(|ip_str| ip_str.trim().parse::().ok()) + .filter(|ip| !ip.is_loopback() && !ip.is_unspecified()) + .map(|ip| ip.to_string()) + .collect() +} diff --git a/src/agentsight/src/bin/cli/mod.rs b/src/agentsight/src/bin/cli/mod.rs index 180881151..70cf30fec 100644 --- a/src/agentsight/src/bin/cli/mod.rs +++ b/src/agentsight/src/bin/cli/mod.rs @@ -8,6 +8,8 @@ //! - `interruption`: Query and manage session interruption events pub mod audit; +#[cfg(feature = "server")] +pub mod dashboard; pub mod discover; pub mod interruption; pub mod metrics; @@ -18,6 +20,33 @@ pub mod summary; pub mod token; pub mod trace; +/// Default configuration file path (shared by trace / serve / dashboard). +#[cfg(feature = "server")] +pub const DEFAULT_CONFIG_PATH: &str = "/etc/agentsight/config.json"; + +/// Load `ServerAuthConfig` from the agentsight config file. +/// +/// Falls back to defaults if the file cannot be read or parsed. +#[cfg(feature = "server")] +pub fn load_server_auth_config(config_path: &str) -> agentsight::config::ServerAuthConfig { + use agentsight::config::{AgentsightConfig, ensure_default_agents_config}; + + let path = std::path::Path::new(config_path); + let mut config = AgentsightConfig::new(); + + // Ensure the config file exists (generate default if missing) + if let Err(e) = ensure_default_agents_config(path) { + log::warn!("Failed to ensure default config at {config_path:?}: {e}, using defaults"); + return config.server_auth; + } + + if let Err(e) = config.load_from_file(path) { + log::warn!("Failed to load config from {config_path:?}: {e}, using defaults"); + } + + config.server_auth +} + /// Parse period string into TimePeriod pub fn parse_period(s: &str) -> agentsight::TimePeriod { match s { diff --git a/src/agentsight/src/bin/cli/serve.rs b/src/agentsight/src/bin/cli/serve.rs index 846d38f8d..a0ef4a6bb 100644 --- a/src/agentsight/src/bin/cli/serve.rs +++ b/src/agentsight/src/bin/cli/serve.rs @@ -4,6 +4,8 @@ use agentsight::server::run_server; use agentsight::storage::sqlite::GenAISqliteStore; use structopt::StructOpt; +use super::{DEFAULT_CONFIG_PATH, load_server_auth_config}; + /// Start the AgentSight API server #[derive(Debug, StructOpt, Clone)] pub struct ServeCommand { @@ -18,6 +20,10 @@ pub struct ServeCommand { /// Custom database path #[structopt(long)] pub db: Option, + + /// Path to JSON configuration file + #[structopt(long, default_value = DEFAULT_CONFIG_PATH)] + pub config: String, } impl ServeCommand { @@ -32,8 +38,11 @@ impl ServeCommand { let host = self.host.clone(); let port = self.port; + // Load server.auth.enabled from config file (same source as `trace`) + let auth_config = load_server_auth_config(&self.config); + actix_web::rt::System::new().block_on(async move { - if let Err(e) = run_server(&host, port, db_path).await { + if let Err(e) = run_server(&host, port, db_path, auth_config).await { eprintln!("Server error: {e}"); std::process::exit(1); } diff --git a/src/agentsight/src/config.rs b/src/agentsight/src/config.rs index 10e8cbf66..73601a972 100644 --- a/src/agentsight/src/config.rs +++ b/src/agentsight/src/config.rs @@ -214,6 +214,37 @@ impl FromStr for TcpTarget { } } +/// Server authentication configuration (JSON). +/// +/// Only `enabled` is configurable. The token is always auto-generated +/// (or read from the default `.dashboard_token` file) — there is no +/// way to specify a fixed token or custom token-file path. +#[derive(serde::Deserialize, Clone, Debug, Default)] +#[serde(default)] +pub struct JsonServerAuth { + pub enabled: Option, +} + +/// Server configuration block (JSON). +#[derive(serde::Deserialize, Clone, Debug, Default)] +#[serde(default)] +pub struct JsonServer { + pub auth: Option, +} + +/// Runtime server authentication configuration. +#[derive(Debug, Clone)] +pub struct ServerAuthConfig { + /// Whether dashboard authentication is enabled. + pub enabled: bool, +} + +impl Default for ServerAuthConfig { + fn default() -> Self { + Self { enabled: true } + } +} + /// Internal JSON structures for parsing the config file (same format as FFI). #[derive(serde::Deserialize)] struct JsonFullConfig { @@ -243,6 +274,8 @@ struct JsonFullConfig { features: Option, #[serde(default)] runtime_limits: Option, + #[serde(default)] + server: Option, } /// DeadLoop 检测配置区段 @@ -747,6 +780,10 @@ pub struct AgentsightConfig { // --- Runtime Resource Limits --- /// Bounded channel capacities, pending queue limits, etc. pub runtime_limits: RuntimeLimits, + + // --- Server Authentication --- + /// Dashboard authentication configuration. + pub server_auth: ServerAuthConfig, } impl Default for AgentsightConfig { @@ -821,6 +858,9 @@ impl Default for AgentsightConfig { // Runtime resource limits runtime_limits: RuntimeLimits::default(), + + // Server authentication + server_auth: ServerAuthConfig::default(), } } } @@ -1055,6 +1095,15 @@ impl AgentsightConfig { }; } + // Parse server auth configuration + if let Some(ref server) = parsed.server { + if let Some(ref auth) = server.auth { + if let Some(enabled) = auth.enabled { + self.server_auth.enabled = enabled; + } + } + } + let (cmdline_rules, https_rules, http_targets) = extract_rules(&parsed); self.cmdline_rules.extend(cmdline_rules); self.https_rules.extend(https_rules); @@ -1796,4 +1845,40 @@ mod tests { assert!(config.features.token_consumption_enabled); assert!(config.features.sls_logtail_enabled); } + + #[test] + fn load_from_json_parses_server_auth_enabled() { + let json = r#"{ + "server": { + "auth": { + "enabled": true + } + } + }"#; + let mut config = AgentsightConfig::new(); + config.load_from_json(json).unwrap(); + assert!(config.server_auth.enabled); + } + + #[test] + fn load_from_json_server_auth_defaults_when_absent() { + let json = r#"{}"#; + let mut config = AgentsightConfig::new(); + config.load_from_json(json).unwrap(); + assert!(config.server_auth.enabled); // default is true + } + + #[test] + fn load_from_json_server_auth_disabled() { + let json = r#"{ + "server": { + "auth": { + "enabled": false + } + } + }"#; + let mut config = AgentsightConfig::new(); + config.load_from_json(json).unwrap(); + assert!(!config.server_auth.enabled); + } } diff --git a/src/agentsight/src/server/auth.rs b/src/agentsight/src/server/auth.rs new file mode 100644 index 000000000..a204cf208 --- /dev/null +++ b/src/agentsight/src/server/auth.rs @@ -0,0 +1,1128 @@ +//! Dashboard authentication middleware and token management. +//! +//! Provides token-based API key authentication for the AgentSight dashboard. +//! On first startup a random 32-byte token is generated and persisted to a +//! local file so it survives restarts. Requests are authenticated via: +//! +//! 1. `Authorization: Bearer ` header +//! 2. `?token=` query parameter +//! 3. `agentsight_session` cookie (set after a successful login) + +use std::future::{Future, Ready, ready}; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use actix_web::body::EitherBody; +use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform}; +use actix_web::{Error, HttpResponse}; +use sha2::{Digest, Sha256}; + +use crate::config::ServerAuthConfig; + +// ─── Cookie signing ───────────────────────────────────────────────────────── + +/// Sign a session cookie value using a SHA-256 keyed hash. +/// +/// Format: `.` where +/// `signature = SHA256(token || "." || expires_secs || "." || token)`. +/// +/// Note: this is a simple keyed-hash sandwich construction, not RFC 2104 +/// HMAC. It is sufficient for short-lived session cookies in a local +/// deployment context. +fn sign_cookie(token: &str, expires_secs: u64) -> String { + let payload = format!("{token}.{expires_secs}.{token}"); + let sig = hex::encode(Sha256::digest(payload.as_bytes())); + format!("{expires_secs}.{sig}") +} + +/// Verify a signed cookie value. Returns `true` when the signature matches +/// **and** the expiry timestamp has not passed. +fn verify_cookie(token: &str, cookie_value: &str) -> bool { + let Some((expires_str, _sig)) = cookie_value.split_once('.') else { + return false; + }; + let Ok(expires_secs) = expires_str.parse::() else { + return false; + }; + + // Check expiry (Unix seconds since epoch) + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + if now_secs > expires_secs { + return false; + } + + let expected = sign_cookie(token, expires_secs); + // Constant-time comparison to avoid timing attacks + constant_time_eq(cookie_value.as_bytes(), expected.as_bytes()) +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter() + .zip(b.iter()) + .fold(0u8, |acc, (x, y)| acc | (x ^ y)) + == 0 +} + +// ─── Token generation ─────────────────────────────────────────────────────── + +/// Generate a random 32-byte token (64 hex characters). +/// +/// Mixes multiple entropy sources (system time, thread ID, PID, stack +/// address) via `DefaultHasher`, then XORs with `/dev/urandom` when +/// available. On Linux the `/dev/urandom` path provides the real +/// cryptographic entropy; the hash-based fallback is a safety net for +/// environments where `/dev/urandom` is unavailable. +fn generate_token() -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + // Use multiple rounds of system entropy + let mut token_bytes = [0u8; 32]; + for chunk in token_bytes.chunks_mut(8) { + let mut h = DefaultHasher::new(); + // Time-based entropy + std::time::SystemTime::now().hash(&mut h); + // Thread ID entropy + std::thread::current().id().hash(&mut h); + // Process ID + std::process::id().hash(&mut h); + // Stack address entropy + let stack_var = 0u64; + (&stack_var as *const u64 as usize).hash(&mut h); + let hash_val = h.finish(); + let len = chunk.len().min(8); + chunk[..len].copy_from_slice(&hash_val.to_le_bytes()[..len]); + // Small delay to ensure different timestamps + std::thread::sleep(std::time::Duration::from_nanos(1)); + } + // Also mix in /dev/urandom if available (read only 32 bytes — it's an infinite stream) + if let Ok(mut file) = std::fs::File::open("/dev/urandom") { + use std::io::Read; + let mut random_bytes = [0u8; 32]; + if file.read_exact(&mut random_bytes).is_ok() { + for (i, byte) in random_bytes.iter().enumerate() { + token_bytes[i] ^= byte; + } + } + } + hex::encode(token_bytes) +} + +// ─── Token file I/O ───────────────────────────────────────────────────────── + +/// Default token file name (stored alongside the SQLite database). +const TOKEN_FILE_NAME: &str = ".dashboard_token"; + +/// Read the token from a file, or generate and persist a new one. +fn read_or_create_token(token_file: &Path) -> String { + // Try reading existing token + if let Ok(content) = std::fs::read_to_string(token_file) { + let trimmed = content.trim(); + if !trimmed.is_empty() && trimmed.len() >= 32 { + return trimmed.to_string(); + } + } + + // Generate new token + let token = generate_token(); + + // Persist to file + if let Some(parent) = token_file.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + log::warn!("Failed to create token directory {parent:?}: {e}"); + } + } + + // Write token with 0600 permissions atomically (avoid TOCTOU race) + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + match std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(token_file) + { + Ok(mut f) => { + use std::io::Write; + if let Err(e) = f.write_all(token.as_bytes()) { + log::warn!("Failed to write dashboard token to {token_file:?}: {e}"); + } else { + log::info!("Dashboard auth token generated and saved to {token_file:?}"); + } + } + Err(e) => { + log::warn!("Failed to create dashboard token file {token_file:?}: {e}"); + } + } + } + + #[cfg(not(unix))] + { + match std::fs::write(token_file, &token) { + Ok(()) => { + log::info!("Dashboard auth token generated and saved to {token_file:?}"); + } + Err(e) => { + log::warn!("Failed to persist dashboard token to {token_file:?}: {e}"); + } + } + } + + token +} + +// ─── DashboardAuth ────────────────────────────────────────────────────────── + +/// Holds the authentication state for the dashboard server. +#[derive(Clone, Debug)] +pub struct DashboardAuth { + /// Whether authentication is enabled. + pub enabled: bool, + /// The secret token used for authentication. + token: Option, + /// Path to the token file (for CLI queries). + token_file: PathBuf, +} + +impl DashboardAuth { + /// Initialize authentication from configuration. + /// + /// The token is always read from the default token file + /// (`/.dashboard_token`), or auto-generated on first run. + pub fn init(config: &ServerAuthConfig, storage_base: &Path) -> Self { + let token_file = storage_base.join(TOKEN_FILE_NAME); + + if !config.enabled { + log::info!("Dashboard authentication is disabled"); + return Self { + enabled: false, + token: None, + token_file, + }; + } + + let token = read_or_create_token(&token_file); + + Self { + enabled: true, + token: Some(token), + token_file, + } + } + + /// Return the token value (for cookie signing/verification). + pub fn token(&self) -> Option<&str> { + self.token.as_deref() + } + + /// Return the path to the token file (for CLI queries). + pub fn token_file(&self) -> &Path { + &self.token_file + } + + /// Verify a candidate token against the stored secret. + pub fn verify_token(&self, candidate: &str) -> bool { + match &self.token { + Some(secret) => constant_time_eq(candidate.as_bytes(), secret.as_bytes()), + None => false, + } + } + + /// Create a signed session cookie value. + /// + /// The cookie expires after `ttl_secs` seconds (default 24 h). + pub fn create_session_cookie(&self, ttl_secs: u64) -> String { + let expires = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() + ttl_secs) + .unwrap_or(0); + match &self.token { + Some(secret) => sign_cookie(secret, expires), + None => String::new(), + } + } + + /// Verify a session cookie value. + pub fn verify_session_cookie(&self, cookie_value: &str) -> bool { + match &self.token { + Some(secret) => verify_cookie(secret, cookie_value), + None => false, + } + } + + /// Read the token from the file (used by CLI `dashboard` command). + pub fn read_token_from_file(&self) -> Option { + if let Some(ref t) = self.token { + return Some(t.clone()); + } + std::fs::read_to_string(&self.token_file) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } +} + +// ─── Exempt paths ─────────────────────────────────────────────────────────── + +/// Paths that do not require authentication (but still subject to localhost-only restriction). +const EXEMPT_PREFIXES: &[&str] = &[ + "/health", + "/api/auth/login", + "/api/auth/status", + "/api/auth/verify", +]; + +/// Paths that are only accessible from localhost (loopback interface). +/// Non-loopback requests to these paths receive 403 Forbidden. +const LOCALHOST_ONLY_PREFIXES: &[&str] = &["/health", "/metrics"]; + +fn is_exempt(path: &str) -> bool { + EXEMPT_PREFIXES + .iter() + .any(|prefix| path.starts_with(prefix)) +} + +fn is_localhost_only(path: &str) -> bool { + LOCALHOST_ONLY_PREFIXES + .iter() + .any(|prefix| path.starts_with(prefix)) +} + +// ─── actix-web middleware ──────────────────────────────────────────────────── + +/// actix-web `Transform` that wraps every request with token authentication. +pub struct AuthMiddleware { + auth: Arc, +} + +impl AuthMiddleware { + pub fn new(auth: Arc) -> Self { + Self { auth } + } +} + +impl Transform for AuthMiddleware +where + S: Service, Error = Error> + 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type Transform = AuthMiddlewareService; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(AuthMiddlewareService { + service, + auth: self.auth.clone(), + })) + } +} + +pub struct AuthMiddlewareService { + service: S, + auth: Arc, +} + +impl Service for AuthMiddlewareService +where + S: Service, Error = Error> + 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type Future = Pin>>>; + + fn poll_ready(&self, cx: &mut Context<'_>) -> Poll> { + self.service.poll_ready(cx) + } + + fn call(&self, req: ServiceRequest) -> Self::Future { + let path = req.path().to_string(); + let is_loopback = req + .peer_addr() + .map(|addr| addr.ip().is_loopback()) + .unwrap_or(false); + + // Localhost-only endpoints: reject non-loopback requests with 403. + if is_localhost_only(&path) && !is_loopback { + let response = req.into_response( + HttpResponse::Forbidden() + .json(serde_json::json!({"error": "forbidden", "message": "This endpoint is only accessible from localhost"})) + .map_into_right_body(), + ); + return Box::pin(async move { Ok(response) }); + } + + // OPTIONS preflight requests: pass through to CORS middleware. + // Without this, cross-origin preflight to non-exempt /api/* routes + // would be 401'd before Cors can answer. + if *req.method() == actix_web::http::Method::OPTIONS { + let fut = self.service.call(req); + return Box::pin(async move { fut.await.map(|res| res.map_into_left_body()) }); + } + + // If auth is disabled, pass through immediately. + if !self.auth.enabled { + let fut = self.service.call(req); + return Box::pin(async move { fut.await.map(|res| res.map_into_left_body()) }); + } + + // Localhost (loopback) requests bypass authentication. + if is_loopback { + let fut = self.service.call(req); + return Box::pin(async move { fut.await.map(|res| res.map_into_left_body()) }); + } + + // Exempt paths pass through. + if is_exempt(&path) { + let fut = self.service.call(req); + return Box::pin(async move { fut.await.map(|res| res.map_into_left_body()) }); + } + + // Try to extract and verify the token or session cookie. + let authenticated = extract_token(&req) + .map(|candidate| { + // Try raw token match first, then session cookie verification. + self.auth.verify_token(&candidate) || self.auth.verify_session_cookie(&candidate) + }) + .unwrap_or(false); + + if authenticated { + let fut = self.service.call(req); + return Box::pin(async move { fut.await.map(|res| res.map_into_left_body()) }); + } + + // Not authenticated. + // For API paths: return 401 JSON. + // For pages/static assets: pass through — the SPA frontend handles auth via + // GET /api/auth/status and renders LoginPage client-side. + if path.starts_with("/api/") { + let response = req.into_response( + HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "unauthorized", "message": "Authentication required"})) + .map_into_right_body(), + ); + return Box::pin(async move { Ok(response) }); + } + + let fut = self.service.call(req); + Box::pin(async move { fut.await.map(|res| res.map_into_left_body()) }) + } +} + +/// Extract a candidate token from the request. +/// +/// Checks in order: +/// 1. `Authorization: Bearer ` header +/// 2. `token` query parameter +/// 3. `agentsight_session` cookie +fn extract_token(req: &ServiceRequest) -> Option { + // 1. Authorization header + if let Some(auth_header) = req.headers().get("Authorization") { + if let Ok(value) = auth_header.to_str() { + if let Some(token) = value.strip_prefix("Bearer ") { + let trimmed = token.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + } + + // 2. Query parameter + let query_string = req.query_string(); + if let Some(token_param) = extract_query_param(query_string, "token") { + if !token_param.is_empty() { + return Some(token_param); + } + } + + // 3. Session cookie + if let Some(cookie) = req.cookie("agentsight_session") { + let value = cookie.value(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + + None +} + +/// Extract a query parameter value from a query string without full parsing. +fn extract_query_param(query: &str, key: &str) -> Option { + let prefix = format!("{key}="); + for part in query.split('&') { + if let Some(value) = part.strip_prefix(&prefix) { + return Some(percent_decode(value)); + } + } + None +} + +/// Minimal percent-decoding for query values (handles `%XX` sequences). +fn percent_decode(input: &str) -> String { + let mut out = Vec::with_capacity(input.len()); + let bytes = input.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(byte) = + u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16) + { + out.push(byte); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +// ─── hex encoding (avoid extra dependency) ────────────────────────────────── + +mod hex { + pub fn encode(bytes: impl AsRef<[u8]>) -> String { + bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect() + } +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sign_and_verify_cookie_roundtrip() { + let token = "test-token-abc123"; + let ttl = 3600u64; + let cookie = sign_cookie( + token, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + ttl, + ); + assert!(verify_cookie(token, &cookie)); + } + + #[test] + fn expired_cookie_fails_verification() { + let token = "test-token-abc123"; + // Already expired (1 second in the past) + let cookie = sign_cookie(token, 1); + assert!(!verify_cookie(token, &cookie)); + } + + #[test] + fn wrong_token_fails_cookie_verification() { + let token = "correct-token"; + let wrong = "wrong-token"; + let cookie = sign_cookie( + token, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600, + ); + assert!(!verify_cookie(wrong, &cookie)); + } + + #[test] + fn malformed_cookie_fails_verification() { + let token = "test-token"; + assert!(!verify_cookie(token, "")); + assert!(!verify_cookie(token, "not-a-number.signature")); + assert!(!verify_cookie(token, "abc")); + } + + #[test] + fn generate_token_produces_64_hex_chars() { + let token = generate_token(); + assert_eq!(token.len(), 64, "token should be 64 hex characters"); + assert!(token.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn constant_time_eq_works() { + assert!(constant_time_eq(b"hello", b"hello")); + assert!(!constant_time_eq(b"hello", b"world")); + assert!(!constant_time_eq(b"short", b"longer")); + } + + #[test] + fn exempt_paths_are_recognized() { + assert!(is_exempt("/health")); + assert!(is_exempt("/health/")); + assert!(!is_exempt("/metrics")); // requires auth — contains token stats + assert!(is_exempt("/api/auth/login")); + assert!(is_exempt("/api/auth/status")); + assert!(is_exempt("/api/auth/verify")); + assert!(!is_exempt("/api/sessions")); + assert!(!is_exempt("/api/interruptions")); + assert!(!is_exempt("/")); + } + + #[test] + fn localhost_only_paths_are_recognized() { + assert!(is_localhost_only("/health")); + assert!(is_localhost_only("/health/")); + assert!(is_localhost_only("/metrics")); + assert!(is_localhost_only("/metrics?foo=bar")); + assert!(!is_localhost_only("/api/sessions")); + assert!(!is_localhost_only("/api/auth/login")); + assert!(!is_localhost_only("/")); + } + + #[test] + fn extract_query_param_parses_correctly() { + assert_eq!( + extract_query_param("foo=bar&token=abc123&baz=qux", "token"), + Some("abc123".to_string()) + ); + assert_eq!( + extract_query_param("token=hello", "token"), + Some("hello".to_string()) + ); + assert_eq!(extract_query_param("foo=bar", "token"), None); + } + + #[test] + fn percent_decode_handles_simple_cases() { + assert_eq!(percent_decode("hello%20world"), "hello world"); + assert_eq!(percent_decode("abc"), "abc"); + assert_eq!(percent_decode("%41%42%43"), "ABC"); + } + + #[test] + fn dashboard_auth_disabled_passes_through() { + let config = ServerAuthConfig { enabled: false }; + let auth = DashboardAuth::init(&config, Path::new("/tmp")); + assert!(!auth.enabled); + assert!(auth.token().is_none()); + } + + #[test] + fn dashboard_auth_token_file_path() { + let config = ServerAuthConfig { enabled: true }; + let auth = DashboardAuth::init(&config, Path::new("/var/log/sysak/.agentsight")); + assert_eq!( + auth.token_file(), + Path::new("/var/log/sysak/.agentsight/.dashboard_token") + ); + } + + #[test] + fn dashboard_auth_create_and_verify_session_cookie() { + let config = ServerAuthConfig { enabled: true }; + let dir = std::env::temp_dir().join("auth_test_cookie"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok(); + let auth = DashboardAuth::init(&config, &dir); + let cookie = auth.create_session_cookie(3600); + assert!(!cookie.is_empty()); + assert!(auth.verify_session_cookie(&cookie)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn dashboard_auth_session_cookie_different_storage_fails() { + let dir1 = std::env::temp_dir().join("auth_test_cookie_a"); + let dir2 = std::env::temp_dir().join("auth_test_cookie_b"); + let _ = std::fs::remove_dir_all(&dir1); + let _ = std::fs::remove_dir_all(&dir2); + std::fs::create_dir_all(&dir1).ok(); + std::fs::create_dir_all(&dir2).ok(); + let config = ServerAuthConfig { enabled: true }; + let auth1 = DashboardAuth::init(&config, &dir1); + let auth2 = DashboardAuth::init(&config, &dir2); + let cookie = auth1.create_session_cookie(3600); + // Different storage dirs → different tokens → cookie should fail + assert!(!auth2.verify_session_cookie(&cookie)); + let _ = std::fs::remove_dir_all(&dir1); + let _ = std::fs::remove_dir_all(&dir2); + } + + #[test] + fn dashboard_auth_disabled_returns_empty_cookie() { + let config = ServerAuthConfig { enabled: false }; + let auth = DashboardAuth::init(&config, Path::new("/tmp")); + let cookie = auth.create_session_cookie(3600); + assert!(cookie.is_empty()); + assert!(!auth.verify_session_cookie("anything")); + } + + #[test] + fn dashboard_auth_verify_token_when_disabled_returns_false() { + let config = ServerAuthConfig { enabled: false }; + let auth = DashboardAuth::init(&config, Path::new("/tmp")); + assert!(!auth.verify_token("any-token")); + } + + #[test] + fn read_or_create_token_creates_file_when_missing() { + let dir = std::env::temp_dir().join("auth_test_create_missing"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok(); + let token_file = dir.join(".dashboard_token"); + assert!(!token_file.exists()); + let token = read_or_create_token(&token_file); + assert_eq!(token.len(), 64); + assert!(token.chars().all(|c| c.is_ascii_hexdigit())); + assert!(token_file.exists()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn read_or_create_token_reads_existing_file() { + let dir = std::env::temp_dir().join("auth_test_read_existing"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok(); + let token_file = dir.join(".dashboard_token"); + std::fs::write(&token_file, "existing-token-value-1234567890abcdef").ok(); + let token = read_or_create_token(&token_file); + assert_eq!(token, "existing-token-value-1234567890abcdef"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn read_or_create_token_regenerates_when_too_short() { + let dir = std::env::temp_dir().join("auth_test_short"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok(); + let token_file = dir.join(".dashboard_token"); + std::fs::write(&token_file, "short").ok(); + let token = read_or_create_token(&token_file); + assert_eq!(token.len(), 64); + assert_ne!(token, "short"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn dashboard_auth_read_token_from_file_reads_persisted() { + let dir = std::env::temp_dir().join("auth_test_read_persisted"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok(); + let config = ServerAuthConfig { enabled: true }; + let auth = DashboardAuth::init(&config, &dir); + let token = auth.read_token_from_file(); + assert!(token.is_some()); + assert_eq!(token.as_ref().map(|t| t.len()), Some(64)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn dashboard_auth_init_auto_generates_token_file() { + let dir = std::env::temp_dir().join("auth_test_auto_gen"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok(); + let config = ServerAuthConfig { enabled: true }; + let auth = DashboardAuth::init(&config, &dir); + assert!(auth.enabled); + assert!(auth.token().is_some()); + assert!(dir.join(".dashboard_token").exists()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn hex_encode_produces_correct_output() { + assert_eq!(hex::encode([0u8, 1, 2, 255]), "000102ff"); + assert_eq!(hex::encode(b"\x00"), "00"); + assert_eq!(hex::encode([] as [u8; 0]), ""); + } + + // ─── Middleware integration tests ────────────────────────────────────────── + + #[actix_web::test] + async fn middleware_passes_through_when_disabled() { + let auth = Arc::new(DashboardAuth::init( + &ServerAuthConfig { enabled: false }, + Path::new("/tmp"), + )); + let app = actix_web::test::init_service( + actix_web::App::new().wrap(AuthMiddleware::new(auth)).route( + "/api/test", + actix_web::web::get().to(|| async { HttpResponse::Ok().body("ok") }), + ), + ) + .await; + let req = actix_web::test::TestRequest::get() + .uri("/api/test") + .to_request(); + let resp = actix_web::test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + } + + #[actix_web::test] + async fn middleware_passes_exempt_paths_without_token() { + let dir = std::env::temp_dir().join("auth_mw_exempt"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok(); + let auth = Arc::new(DashboardAuth::init( + &ServerAuthConfig { enabled: true }, + &dir, + )); + let app = actix_web::test::init_service( + actix_web::App::new() + .wrap(AuthMiddleware::new(auth)) + .route( + "/api/auth/login", + actix_web::web::post().to(|| async { HttpResponse::Ok().body("ok") }), + ) + .route( + "/api/auth/status", + actix_web::web::get().to(|| async { HttpResponse::Ok().body("ok") }), + ), + ) + .await; + // GET exempt paths (NOT localhost-only) + for uri in &["/api/auth/status"] { + let req = actix_web::test::TestRequest::get().uri(uri).to_request(); + let resp = actix_web::test::call_service(&app, req).await; + assert_eq!(resp.status(), 200, "exempt path {uri} should pass"); + } + // POST exempt path + let req = actix_web::test::TestRequest::post() + .uri("/api/auth/login") + .to_request(); + let resp = actix_web::test::call_service(&app, req).await; + assert_eq!( + resp.status(), + 200, + "exempt path /api/auth/login should pass" + ); + } + + #[actix_web::test] + async fn middleware_blocks_localhost_only_from_remote() { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + let dir = std::env::temp_dir().join("auth_mw_localhost_only"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok(); + let auth = Arc::new(DashboardAuth::init( + &ServerAuthConfig { enabled: true }, + &dir, + )); + let app = actix_web::test::init_service( + actix_web::App::new() + .wrap(AuthMiddleware::new(auth)) + .route( + "/health", + actix_web::web::get().to(|| async { HttpResponse::Ok().body("ok") }), + ) + .route( + "/metrics", + actix_web::web::get().to(|| async { HttpResponse::Ok().body("ok") }), + ), + ) + .await; + + let remote_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), 12345); + let loopback_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 12345); + + // Remote request to /health should be blocked (403) + let req = actix_web::test::TestRequest::get() + .uri("/health") + .peer_addr(remote_addr) + .to_request(); + let resp = actix_web::test::call_service(&app, req).await; + assert_eq!( + resp.status(), + 403, + "/health should be forbidden from remote" + ); + + // Remote request to /metrics should be blocked (403) + let req = actix_web::test::TestRequest::get() + .uri("/metrics") + .peer_addr(remote_addr) + .to_request(); + let resp = actix_web::test::call_service(&app, req).await; + assert_eq!( + resp.status(), + 403, + "/metrics should be forbidden from remote" + ); + + // Loopback request to /health should pass + let req = actix_web::test::TestRequest::get() + .uri("/health") + .peer_addr(loopback_addr) + .to_request(); + let resp = actix_web::test::call_service(&app, req).await; + assert_eq!(resp.status(), 200, "/health should pass from loopback"); + + // Loopback request to /metrics should pass + let req = actix_web::test::TestRequest::get() + .uri("/metrics") + .peer_addr(loopback_addr) + .to_request(); + let resp = actix_web::test::call_service(&app, req).await; + assert_eq!(resp.status(), 200, "/metrics should pass from loopback"); + } + + #[actix_web::test] + async fn middleware_passes_options_preflight_without_token() { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + let dir = std::env::temp_dir().join("auth_mw_options"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok(); + let auth = Arc::new(DashboardAuth::init( + &ServerAuthConfig { enabled: true }, + &dir, + )); + let app = actix_web::test::init_service( + actix_web::App::new().wrap(AuthMiddleware::new(auth)).route( + "/api/sessions", + actix_web::web::get().to(|| async { HttpResponse::Ok().body("ok") }), + ), + ) + .await; + + let remote_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), 12345); + + // GET to protected API from remote should be 401 + let req = actix_web::test::TestRequest::get() + .uri("/api/sessions") + .peer_addr(remote_addr) + .to_request(); + let resp = actix_web::test::call_service(&app, req).await; + assert_eq!(resp.status(), 401); + + // OPTIONS preflight to the same API should NOT be 401'd by auth middleware. + // (404 is expected here — no OPTIONS route registered; in production the + // CORS middleware handles OPTIONS. The key assertion is: not 401.) + let req = actix_web::test::TestRequest::default() + .method(actix_web::http::Method::OPTIONS) + .uri("/api/sessions") + .peer_addr(remote_addr) + .to_request(); + let resp = actix_web::test::call_service(&app, req).await; + assert_ne!( + resp.status(), + 401, + "OPTIONS preflight should not be blocked by auth middleware" + ); + } + + #[actix_web::test] + async fn middleware_returns_401_for_protected_api_without_token() { + let dir = std::env::temp_dir().join("auth_mw_401"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok(); + let auth = Arc::new(DashboardAuth::init( + &ServerAuthConfig { enabled: true }, + &dir, + )); + let app = actix_web::test::init_service( + actix_web::App::new().wrap(AuthMiddleware::new(auth)).route( + "/api/sessions", + actix_web::web::get().to(|| async { HttpResponse::Ok().body("ok") }), + ), + ) + .await; + let req = actix_web::test::TestRequest::get() + .uri("/api/sessions") + .to_request(); + let resp = actix_web::test::call_service(&app, req).await; + assert_eq!(resp.status(), 401); + } + + #[actix_web::test] + async fn middleware_allows_valid_bearer_token() { + let dir = std::env::temp_dir().join("auth_mw_bearer"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok(); + let auth = Arc::new(DashboardAuth::init( + &ServerAuthConfig { enabled: true }, + &dir, + )); + let token = auth.token().unwrap_or("").to_string(); + let app = actix_web::test::init_service( + actix_web::App::new().wrap(AuthMiddleware::new(auth)).route( + "/api/sessions", + actix_web::web::get().to(|| async { HttpResponse::Ok().body("ok") }), + ), + ) + .await; + let req = actix_web::test::TestRequest::get() + .uri("/api/sessions") + .insert_header(("Authorization", format!("Bearer {token}"))) + .to_request(); + let resp = actix_web::test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + } + + #[actix_web::test] + async fn middleware_allows_valid_query_token() { + let dir = std::env::temp_dir().join("auth_mw_query"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok(); + let auth = Arc::new(DashboardAuth::init( + &ServerAuthConfig { enabled: true }, + &dir, + )); + let token = auth.token().unwrap_or("").to_string(); + let app = actix_web::test::init_service( + actix_web::App::new().wrap(AuthMiddleware::new(auth)).route( + "/api/sessions", + actix_web::web::get().to(|| async { HttpResponse::Ok().body("ok") }), + ), + ) + .await; + let req = actix_web::test::TestRequest::get() + .uri(&format!("/api/sessions?token={token}")) + .to_request(); + let resp = actix_web::test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + } + + #[actix_web::test] + async fn middleware_allows_valid_session_cookie() { + let dir = std::env::temp_dir().join("auth_mw_cookie"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok(); + let auth = Arc::new(DashboardAuth::init( + &ServerAuthConfig { enabled: true }, + &dir, + )); + let cookie_value = auth.create_session_cookie(3600); + let app = actix_web::test::init_service( + actix_web::App::new().wrap(AuthMiddleware::new(auth)).route( + "/api/sessions", + actix_web::web::get().to(|| async { HttpResponse::Ok().body("ok") }), + ), + ) + .await; + let req = actix_web::test::TestRequest::get() + .uri("/api/sessions") + .cookie(actix_web::cookie::Cookie::new( + "agentsight_session", + cookie_value, + )) + .to_request(); + let resp = actix_web::test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + } + + #[actix_web::test] + async fn middleware_rejects_invalid_bearer_token() { + let dir = std::env::temp_dir().join("auth_mw_reject"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok(); + let auth = Arc::new(DashboardAuth::init( + &ServerAuthConfig { enabled: true }, + &dir, + )); + let app = actix_web::test::init_service( + actix_web::App::new().wrap(AuthMiddleware::new(auth)).route( + "/api/sessions", + actix_web::web::get().to(|| async { HttpResponse::Ok().body("ok") }), + ), + ) + .await; + let req = actix_web::test::TestRequest::get() + .uri("/api/sessions") + .insert_header(("Authorization", "Bearer wrong-token")) + .to_request(); + let resp = actix_web::test::call_service(&app, req).await; + assert_eq!(resp.status(), 401); + } + + #[actix_web::test] + async fn middleware_passes_through_non_api_paths_without_token() { + let dir = std::env::temp_dir().join("auth_mw_nonapi"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok(); + let auth = Arc::new(DashboardAuth::init( + &ServerAuthConfig { enabled: true }, + &dir, + )); + let app = actix_web::test::init_service( + actix_web::App::new().wrap(AuthMiddleware::new(auth)).route( + "/", + actix_web::web::get().to(|| async { HttpResponse::Ok().body("index") }), + ), + ) + .await; + let req = actix_web::test::TestRequest::get().uri("/").to_request(); + let resp = actix_web::test::call_service(&app, req).await; + // Non-API paths should pass through (SPA handles auth client-side) + assert_eq!(resp.status(), 200); + } + + // ─── extract_token unit tests ──────────────────────────────────────────── + + #[actix_web::test] + async fn extract_token_from_bearer_header() { + let req = actix_web::test::TestRequest::get() + .uri("/api/test") + .insert_header(("Authorization", "Bearer abc123")) + .to_srv_request(); + let token = extract_token(&req); + assert_eq!(token, Some("abc123".to_string())); + } + + #[actix_web::test] + async fn extract_token_from_query_param() { + let req = actix_web::test::TestRequest::get() + .uri("/api/test?token=query-tok") + .to_srv_request(); + let token = extract_token(&req); + assert_eq!(token, Some("query-tok".to_string())); + } + + #[actix_web::test] + async fn extract_token_from_cookie() { + let req = actix_web::test::TestRequest::get() + .uri("/api/test") + .cookie(actix_web::cookie::Cookie::new( + "agentsight_session", + "cookie-val", + )) + .to_srv_request(); + let token = extract_token(&req); + assert_eq!(token, Some("cookie-val".to_string())); + } + + #[actix_web::test] + async fn extract_token_prefers_bearer_over_query_and_cookie() { + let req = actix_web::test::TestRequest::get() + .uri("/api/test?token=query-tok") + .insert_header(("Authorization", "Bearer bearer-tok")) + .cookie(actix_web::cookie::Cookie::new( + "agentsight_session", + "cookie-val", + )) + .to_srv_request(); + let token = extract_token(&req); + assert_eq!(token, Some("bearer-tok".to_string())); + } + + #[actix_web::test] + async fn extract_token_returns_none_when_no_credentials() { + let req = actix_web::test::TestRequest::get() + .uri("/api/test") + .to_srv_request(); + let token = extract_token(&req); + assert_eq!(token, None); + } +} diff --git a/src/agentsight/src/server/handlers.rs b/src/agentsight/src/server/handlers.rs index 5e09893d8..67acf867a 100644 --- a/src/agentsight/src/server/handlers.rs +++ b/src/agentsight/src/server/handlers.rs @@ -33,6 +33,65 @@ pub async fn health(data: web::Data) -> impl Responder { })) } +// ─── Authentication endpoints ──────────────────────────────────────────────── + +/// POST /api/auth/login +/// +/// Accepts `{"token": "..."}` and sets a signed session cookie on success. +pub async fn auth_login( + data: web::Data, + body: web::Json, +) -> impl Responder { + let candidate = body.get("token").and_then(|v| v.as_str()).unwrap_or(""); + + if !data.auth.verify_token(candidate) { + return HttpResponse::Unauthorized() + .json(json!({"error": "invalid_token", "message": "The provided token is incorrect"})); + } + + // Create a signed session cookie (24-hour TTL) + let cookie_value = data.auth.create_session_cookie(24 * 3600); + let cookie = actix_web::cookie::Cookie::build("agentsight_session", cookie_value) + .path("/") + .http_only(true) + .same_site(actix_web::cookie::SameSite::Lax) + .max_age(actix_web::cookie::time::Duration::hours(24)) + .finish(); + + HttpResponse::Ok() + .cookie(cookie) + .json(json!({"status": "authenticated"})) +} + +/// GET /api/auth/status +/// +/// Returns whether authentication is enabled. Exempt from auth middleware. +#[get("/status")] +pub async fn auth_status(data: web::Data) -> impl Responder { + HttpResponse::Ok().json(json!({ + "auth_enabled": data.auth.enabled, + })) +} + +/// GET /api/auth/verify +/// +/// Checks whether the current request carries a valid session. Exempt from +/// auth middleware so the frontend can probe authentication state. +#[get("/verify")] +pub async fn auth_verify(data: web::Data, req: actix_web::HttpRequest) -> impl Responder { + if !data.auth.enabled { + return HttpResponse::Ok().json(json!({"authenticated": true})); + } + + // Check session cookie + let authenticated = req + .cookie("agentsight_session") + .map(|c| data.auth.verify_session_cookie(c.value())) + .unwrap_or(false); + + HttpResponse::Ok().json(json!({"authenticated": authenticated})) +} + // ─── Session / Trace query endpoints ─────────────────────────────────────── /// Query parameters for /api/sessions @@ -845,12 +904,18 @@ mod tests { } fn test_app_state(timeout_ms: u64) -> web::Data { + let auth_config = crate::config::ServerAuthConfig { enabled: false }; + let auth = Arc::new(crate::server::auth::DashboardAuth::init( + &auth_config, + std::path::Path::new("/tmp"), + )); web::Data::new(AppState { storage_path: PathBuf::from(":memory:"), start_time: Instant::now(), health_store: Arc::new(RwLock::new(HealthStore::new())), interruption_store: None, security_observability: super::super::SecurityObservabilityConfig { timeout_ms }, + auth, }) } @@ -919,6 +984,153 @@ mod tests { "SPA path /dashboard must not get API 404, got status={status}" ); } + + // ─── Auth endpoint tests ────────────────────────────────────────────────── + + /// A test token that is >= 32 chars (required by read_or_create_token). + const TEST_TOKEN: &str = "correct-token-for-auth-testing-32chars!!"; + + fn test_app_state_with_auth(enabled: bool) -> web::Data { + // Use a unique dir per call to avoid test-parallelism races + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("handler_auth_test_{id}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok(); + if enabled { + std::fs::write(dir.join(".dashboard_token"), TEST_TOKEN).ok(); + } + let auth_config = crate::config::ServerAuthConfig { enabled }; + let auth = Arc::new(crate::server::auth::DashboardAuth::init(&auth_config, &dir)); + web::Data::new(AppState { + storage_path: PathBuf::from(":memory:"), + start_time: Instant::now(), + health_store: Arc::new(RwLock::new(HealthStore::new())), + interruption_store: None, + security_observability: super::super::SecurityObservabilityConfig { timeout_ms: 0 }, + auth, + }) + } + + #[actix_web::test] + async fn auth_login_success_with_correct_token() { + let app = awtest::init_service( + App::new() + .app_data(test_app_state_with_auth(true)) + .route("/api/auth/login", web::post().to(auth_login)), + ) + .await; + let req = awtest::TestRequest::post() + .uri("/api/auth/login") + .set_json(json!({"token": TEST_TOKEN})) + .to_request(); + let resp = awtest::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + // Should have a session cookie set + let cookie = resp + .response() + .cookies() + .find(|c| c.name() == "agentsight_session"); + assert!(cookie.is_some(), "response should contain session cookie"); + } + + #[actix_web::test] + async fn auth_login_fails_with_wrong_token() { + let app = awtest::init_service( + App::new() + .app_data(test_app_state_with_auth(true)) + .route("/api/auth/login", web::post().to(auth_login)), + ) + .await; + let req = awtest::TestRequest::post() + .uri("/api/auth/login") + .set_json(json!({"token": "wrong-token"})) + .to_request(); + let resp = awtest::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body: Value = + serde_json::from_slice(&actix_web::body::to_bytes(resp.into_body()).await.unwrap()) + .unwrap(); + assert_eq!(body["error"], "invalid_token"); + } + + #[actix_web::test] + async fn auth_status_reports_enabled() { + let app = awtest::init_service( + App::new() + .app_data(test_app_state_with_auth(true)) + .service(auth_status), + ) + .await; + let req = awtest::TestRequest::get().uri("/status").to_request(); + let resp = awtest::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = + serde_json::from_slice(&actix_web::body::to_bytes(resp.into_body()).await.unwrap()) + .unwrap(); + assert_eq!(body["auth_enabled"], true); + } + + #[actix_web::test] + async fn auth_status_reports_disabled() { + let app = + awtest::init_service(App::new().app_data(test_app_state(0)).service(auth_status)).await; + let req = awtest::TestRequest::get().uri("/status").to_request(); + let resp = awtest::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = + serde_json::from_slice(&actix_web::body::to_bytes(resp.into_body()).await.unwrap()) + .unwrap(); + assert_eq!(body["auth_enabled"], false); + } + + #[actix_web::test] + async fn auth_verify_returns_true_when_disabled() { + let app = + awtest::init_service(App::new().app_data(test_app_state(0)).service(auth_verify)).await; + let req = awtest::TestRequest::get().uri("/verify").to_request(); + let resp = awtest::call_service(&app, req).await; + let body: Value = + serde_json::from_slice(&actix_web::body::to_bytes(resp.into_body()).await.unwrap()) + .unwrap(); + assert_eq!(body["authenticated"], true); + } + + #[actix_web::test] + async fn auth_verify_returns_false_without_cookie() { + let app = awtest::init_service( + App::new() + .app_data(test_app_state_with_auth(true)) + .service(auth_verify), + ) + .await; + let req = awtest::TestRequest::get().uri("/verify").to_request(); + let resp = awtest::call_service(&app, req).await; + let body: Value = + serde_json::from_slice(&actix_web::body::to_bytes(resp.into_body()).await.unwrap()) + .unwrap(); + assert_eq!(body["authenticated"], false); + } + + #[actix_web::test] + async fn auth_verify_returns_true_with_valid_cookie() { + let state = test_app_state_with_auth(true); + let cookie_value = state.auth.create_session_cookie(3600); + let app = awtest::init_service(App::new().app_data(state).service(auth_verify)).await; + let req = awtest::TestRequest::get() + .uri("/verify") + .cookie(actix_web::cookie::Cookie::new( + "agentsight_session", + cookie_value, + )) + .to_request(); + let resp = awtest::call_service(&app, req).await; + let body: Value = + serde_json::from_slice(&actix_web::body::to_bytes(resp.into_body()).await.unwrap()) + .unwrap(); + assert_eq!(body["authenticated"], true); + } } // ─── Prometheus metrics endpoint ───────────────────────────────────────────── diff --git a/src/agentsight/src/server/mod.rs b/src/agentsight/src/server/mod.rs index 956ef77d7..848d06529 100644 --- a/src/agentsight/src/server/mod.rs +++ b/src/agentsight/src/server/mod.rs @@ -3,6 +3,7 @@ //! Provides a lightweight HTTP API server using actix-web for querying //! AgentSight storage data, and optionally serves the embedded frontend. +pub mod auth; mod handlers; mod token_savings; @@ -14,9 +15,12 @@ use actix_cors::Cors; use actix_web::{App, HttpRequest, HttpResponse, HttpServer, Responder, get, web}; use include_dir::{Dir, include_dir}; +use crate::config::ServerAuthConfig; use crate::health::{HealthChecker, HealthStore}; use crate::storage::sqlite::InterruptionStore; +use self::auth::{AuthMiddleware, DashboardAuth}; + /// Embedded frontend static files (built from dashboard/ via `npm run build:embed`) /// The directory `frontend-dist/` must exist at compile time; if it is absent /// (e.g. first build before running npm), Rust will use an empty dir. @@ -47,6 +51,8 @@ pub struct AppState { pub interruption_store: Option>, /// agent-sec security observability integration configuration pub security_observability: SecurityObservabilityConfig, + /// Dashboard authentication state + pub auth: Arc, } // ─── Static file handler ───────────────────────────────────────────────────── @@ -122,6 +128,13 @@ fn configure_routes(cfg: &mut web::ServiceConfig) { // Top-level health & metrics (not under /api) .service(handlers::health) .service(handlers::metrics) + // Auth endpoints (exempt from middleware) + .service( + web::scope("/api/auth") + .service(handlers::auth_status) + .service(handlers::auth_verify) + .service(web::resource("/login").route(web::post().to(handlers::auth_login))), + ) // All API routes under /api scope .service( web::scope("/api") @@ -191,9 +204,32 @@ async fn api_not_found() -> impl Responder { /// /// Binds to the given host:port and serves API endpoints + embedded frontend. /// This function blocks until the server is shut down. -pub async fn run_server(host: &str, port: u16, storage_path: PathBuf) -> std::io::Result<()> { +pub async fn run_server( + host: &str, + port: u16, + storage_path: PathBuf, + auth_config: ServerAuthConfig, +) -> std::io::Result<()> { let security_observability = SecurityObservabilityConfig::default(); + // Initialize dashboard authentication + let storage_base = storage_path + .parent() + .unwrap_or(std::path::Path::new("/var/log/sysak/.agentsight")); + let dashboard_auth = Arc::new(DashboardAuth::init(&auth_config, storage_base)); + if dashboard_auth.enabled { + if let Some(token) = dashboard_auth.read_token_from_file() { + let masked = if token.len() > 8 { + format!("{}****", &token[..8]) + } else { + "****".to_string() + }; + eprintln!( + "Dashboard auth enabled. Token: {masked} (use `agentsight dashboard` to view)" + ); + } + } + // Initialize GenAI SQLite store (needed for HealthChecker to query pending calls) let genai_store: Option> = match crate::storage::sqlite::GenAISqliteStore::new() { @@ -243,6 +279,7 @@ pub async fn run_server(host: &str, port: u16, storage_path: PathBuf) -> std::io health_store, interruption_store, security_observability, + auth: dashboard_auth.clone(), }); let has_frontend = FRONTEND.get_file("index.html").is_some(); @@ -260,11 +297,12 @@ pub async fn run_server(host: &str, port: u16, storage_path: PathBuf) -> std::io let cors = Cors::default() .allow_any_origin() .allowed_methods(vec!["GET", "DELETE", "POST", "OPTIONS"]) - .allowed_headers(vec!["Content-Type"]) + .allowed_headers(vec!["Content-Type", "Authorization"]) .max_age(3600); App::new() .wrap(cors) + .wrap(AuthMiddleware::new(dashboard_auth.clone())) .app_data(data.clone()) .configure(configure_routes) }) @@ -285,10 +323,12 @@ mod tests { use crate::health::HealthStore; + use super::auth::DashboardAuth; use super::{ AppState, SecurityObservabilityConfig, configure_routes, serve_frontend, serve_frontend_root, }; + use crate::config::ServerAuthConfig; #[test] fn security_observability_config_defaults_to_five_seconds() { @@ -336,12 +376,18 @@ mod tests { } fn test_app_state(timeout_ms: u64) -> web::Data { + let auth_config = ServerAuthConfig { enabled: false }; + let auth = Arc::new(DashboardAuth::init( + &auth_config, + std::path::Path::new("/tmp"), + )); web::Data::new(AppState { storage_path: PathBuf::from(":memory:"), start_time: Instant::now(), health_store: Arc::new(RwLock::new(HealthStore::new())), interruption_store: None, security_observability: SecurityObservabilityConfig { timeout_ms }, + auth, }) } } diff --git a/src/agentsight/src/server/token_savings.rs b/src/agentsight/src/server/token_savings.rs index ee5a1a6bc..a6d8abee4 100644 --- a/src/agentsight/src/server/token_savings.rs +++ b/src/agentsight/src/server/token_savings.rs @@ -934,6 +934,10 @@ mod tests { health_store: Arc::new(RwLock::new(crate::health::HealthStore::default())), interruption_store: None, security_observability: crate::server::SecurityObservabilityConfig::default(), + auth: Arc::new(crate::server::auth::DashboardAuth::init( + &crate::config::ServerAuthConfig { enabled: false }, + std::path::Path::new("/tmp"), + )), } }