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
+
+
+
+
+
+
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"),
+ )),
}
}