diff --git a/.gitignore b/.gitignore index 34afc39..ae5478e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,11 @@ web/.cache web/coverage web/*.local +# Secrets — never commit real credentials +*.env +!*.env.example +tests/test.env + # Build output static diff --git a/Cargo.lock b/Cargo.lock index 3b76a3b..03e4649 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1669,6 +1669,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "subtle", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 5090a08..263e388 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ rusqlite = { version = "0.39", features = ["bundled"] } r2d2 = "0.8" r2d2_sqlite = "0.34" rustls = { version = "0.23", features = ["ring"] } +subtle = "2.6" wechatbot = { path = "vendor/wechatbot" } encoding_rs = "0.8" libc = "0.2" @@ -68,7 +69,7 @@ rmcp = { version = "1.7", features = [ ] } schemars = "1.0" http = "1" -tower-http = { version = "0.6", features = ["fs"] } +tower-http = { version = "0.6", features = ["fs", "cors"] } rust-embed = "8" [target.'cfg(windows)'.dependencies] diff --git a/config.json b/config.json index acf2fca..0641d98 100644 --- a/config.json +++ b/config.json @@ -24,7 +24,7 @@ } }, "gateway": { - "host": "0.0.0.0", + "host": "127.0.0.1", "port": 19876, "agent_prompt_reinject_every": 100 }, diff --git a/src/config/mod.rs b/src/config/mod.rs index 83bcc06..6d8773a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -523,6 +523,11 @@ pub struct GatewayConfig { pub max_concurrent_requests: usize, #[serde(default, rename = "session_ttl_hours")] pub session_ttl_hours: Option, + /// 网关认证 token。当绑定到非 loopback 地址时必须配置,否则启动会报错。 + /// 绑定到 loopback 时可不配置(本地访问免认证)。 + /// 前端通过 Authorization: Bearer 头或 WS query param ?token= 携带。 + #[serde(default, rename = "auth_token")] + pub auth_token: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -832,6 +837,7 @@ impl Default for GatewayConfig { agent_prompt_reinject_every: default_agent_prompt_reinject_every(), max_concurrent_requests: default_max_concurrent_requests(), session_ttl_hours: Some(24), + auth_token: None, } } } diff --git a/src/gateway/auth.rs b/src/gateway/auth.rs new file mode 100644 index 0000000..7856e13 --- /dev/null +++ b/src/gateway/auth.rs @@ -0,0 +1,243 @@ +//! 网关认证与访问控制。 +//! +//! 设计目标(第一性原理): +//! - 本地单机部署(host 为 loopback):免认证,仅靠 CORS 防御 DNS rebinding / CSRF。 +//! - 远程访问(host 非 loopback):必须配置 `auth_token`,所有 `/api/*` 与 `/ws` 强制校验。 +//! - token 通过 `Authorization: Bearer `(HTTP)或 `?token=`(WS)传递。 +//! - 校验使用常量时间比较,避免计时侧信道。 + +use axum::extract::Request; +use axum::http::{HeaderMap, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; +use subtle::ConstantTimeEq; + +/// 判定给定 host 是否为 loopback 地址。 +/// 通过 `std::net::IpAddr` 解析,覆盖 IPv4/IPv6 的所有等价表示。 +/// 也接受 `localhost` 主机名。 +pub fn is_loopback_host(host: &str) -> bool { + let h = host.trim(); + if h.eq_ignore_ascii_case("localhost") { + return true; + } + // 去除 IPv6 方括号(如 `[::1]`) + let bare = h + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .unwrap_or(h); + // 尝试解析为 IpAddr + match bare.parse::() { + Ok(std::net::IpAddr::V4(v4)) => v4.is_loopback(), + Ok(std::net::IpAddr::V6(v6)) => v6.is_loopback(), + Err(_) => { + // 解析失败(如域名),保守判定为非 loopback,强制认证 + false + } + } +} + +/// 判定当前配置是否需要强制认证。 +/// - host 非 loopback:必须认证(且 auth_token 必须存在,否则启动会报错) +/// - host 为 loopback 但显式配置了 auth_token:也启用认证(用户主动加固) +pub fn requires_auth(host: &str, auth_token: &Option) -> bool { + !is_loopback_host(host) || auth_token.is_some() +} + +/// 校验请求携带的 token 是否与配置的 auth_token 匹配(常量时间)。 +/// 返回 true 表示通过(含「未配置 token 则放行」的兜底,调用方应先用 requires_auth 判定)。 +pub fn token_matches(provided: Option<&str>, expected: &Option) -> bool { + match expected { + // 未配置 token:放行(调用方已通过 requires_auth 保证只在 loopback 下到达此处) + None => true, + Some(expected_str) => match provided { + None => false, + Some(provided_str) => { + // subtle::ConstantTimeEq 要求两端等长;长度不等时 ct_eq 返回 0(false), + // 但为避免长度差异导致的提前退出计时泄露,我们确保比较路径不因长度而分支提前返回。 + let p = provided_str.as_bytes(); + let e = expected_str.as_bytes(); + // ct_eq 内部在长度不等时仍会遍历较短的一侧,返回 0,无提前退出 + p.ct_eq(e).unwrap_u8() == 1 + } + }, + } +} + +/// 从 Authorization 头提取 Bearer token(RFC 6750,scheme 不区分大小写)。 +pub fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> { + headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|s| { + // RFC 6750: scheme 名不区分大小写(Bearer / bearer / BEARER 均合法) + // 找到第一个空格分隔 scheme 与 token,比较 scheme 部分忽略大小写 + let trimmed = s.trim_start(); + let split = trimmed.find(char::is_whitespace)?; + let (scheme, rest) = trimmed.split_at(split); + if scheme.eq_ignore_ascii_case("Bearer") { + Some(rest.trim()) + } else { + None + } + }) +} + +/// axum 中间件:对 `/api/*` 路由强制 Bearer token 校验。 +/// 仅在 `requires_auth` 为 true 时挂载。 +/// `/health`、`/ws`、静态资源放行;`/ws` 的 token 校验在 ws_handler 内完成。 +pub async fn require_bearer_auth( + headers: HeaderMap, + request: Request, + next: Next, +) -> Response { + let path = request.uri().path(); + + // 仅对 /api/ 前缀的请求强制认证 + if !path.starts_with("/api/") { + return next.run(request).await; + } + + // expected_token 通过 extension 注入(见 mod.rs 装配处) + let expected = request + .extensions() + .get::() + .map(|c| c.token.clone()) + .unwrap_or(None); + + let provided = extract_bearer_token(&headers); + if token_matches(provided, &expected) { + next.run(request).await + } else { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "unauthorized", "message": "missing or invalid token" })), + ) + .into_response() + } +} + +/// 通过 extension 注入到 Router 的认证配置。 +#[derive(Clone, Debug)] +pub struct AuthConfig { + pub token: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderMap; + + #[test] + fn loopback_detection() { + // IPv4 loopback 整个 127.0.0.0/8 段 + assert!(is_loopback_host("127.0.0.1")); + assert!(is_loopback_host("127.0.0.2")); + assert!(is_loopback_host("127.1.2.3")); + assert!(is_loopback_host("127.255.255.255")); + // IPv6 loopback 的各种等价表示 + assert!(is_loopback_host("::1")); + assert!(is_loopback_host("[::1]")); + assert!(is_loopback_host("0:0:0:0:0:0:0:1")); + // localhost 主机名(大小写不敏感) + assert!(is_loopback_host("localhost")); + assert!(is_loopback_host("LOCALHOST")); + assert!(is_loopback_host("Localhost")); + // 空白容错 + assert!(is_loopback_host(" 127.0.0.1 ")); + assert!(is_loopback_host(" localhost ")); + // 非 loopback + assert!(!is_loopback_host("0.0.0.0")); + assert!(!is_loopback_host("192.168.1.1")); + assert!(!is_loopback_host("10.0.0.1")); + assert!(!is_loopback_host("172.16.0.1")); + assert!(!is_loopback_host("example.com")); + assert!(!is_loopback_host("picobot.local")); + assert!(!is_loopback_host("::")); + assert!(!is_loopback_host("")); + } + + #[test] + fn requires_auth_logic() { + // 非 loopback 无论 token 是否配置都要认证 + assert!(requires_auth("0.0.0.0", &None)); + assert!(requires_auth("192.168.1.1", &None)); + assert!(requires_auth("0.0.0.0", &Some("t".into()))); + // loopback 无 token:免认证 + assert!(!requires_auth("127.0.0.1", &None)); + assert!(!requires_auth("::1", &None)); + assert!(!requires_auth("localhost", &None)); + // loopback 有 token:认证(用户主动加固) + assert!(requires_auth("127.0.0.1", &Some("t".into()))); + // 域名始终需认证 + assert!(requires_auth("myserver.com", &None)); + } + + #[test] + fn token_matching() { + // 未配置 token:始终放行 + assert!(token_matches(None, &None)); + assert!(token_matches(Some("anything"), &None)); + // 配置了 token + let expected = Some("s3cr3t".to_string()); + assert!(token_matches(Some("s3cr3t"), &expected)); + assert!(!token_matches(Some("wrong"), &expected)); + assert!(!token_matches(None, &expected)); + // 长度不同 + assert!(!token_matches(Some("s3cr3t-extra"), &expected)); + assert!(!token_matches(Some("s3"), &expected)); + assert!(!token_matches(Some(""), &expected)); + // 空字符串 token(配置了但为空 — 等同于未配置的放行语义由 requires_auth 控制) + let empty_expected = Some(String::new()); + assert!(token_matches(Some(""), &empty_expected)); + assert!(!token_matches(Some("x"), &empty_expected)); + } + + #[test] + fn extract_bearer_token_rfc6750() { + fn make_auth_header(value: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert( + axum::http::header::AUTHORIZATION, + axum::http::HeaderValue::from_str(value).unwrap(), + ); + h + } + + // 标准格式 + assert_eq!( + extract_bearer_token(&make_auth_header("Bearer abc123")), + Some("abc123") + ); + // scheme 大小写不敏感 + assert_eq!( + extract_bearer_token(&make_auth_header("bearer abc123")), + Some("abc123") + ); + assert_eq!( + extract_bearer_token(&make_auth_header("BEARER abc123")), + Some("abc123") + ); + // 多空格容忍 + assert_eq!( + extract_bearer_token(&make_auth_header("Bearer abc123")), + Some("abc123") + ); + assert_eq!( + extract_bearer_token(&make_auth_header("Bearer\tabc123")), + Some("abc123") + ); + // 非 Bearer scheme + assert_eq!( + extract_bearer_token(&make_auth_header("Basic dXNlcjpwYXNz")), + None + ); + // 无 Authorization 头 + let empty = HeaderMap::new(); + assert_eq!(extract_bearer_token(&empty), None); + // 缺少 token 部分 + assert_eq!(extract_bearer_token(&make_auth_header("Bearer")), None); + assert_eq!(extract_bearer_token(&make_auth_header("Bearer ")), Some("")); + } +} diff --git a/src/gateway/http.rs b/src/gateway/http.rs index f251de8..24916f7 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -72,6 +72,13 @@ fn mask_config(config: &Config) -> Config { } } } + // 掩码网关认证 token(避免通过 /api/config 泄露) + if let Some(ref token) = masked.gateway.auth_token { + if !token.is_empty() { + let visible: String = token.chars().take(4).collect(); + masked.gateway.auth_token = Some(format!("{}{}", visible, API_KEY_MASK)); + } + } masked } @@ -126,6 +133,12 @@ pub async fn save_config( } } } + // 保留原始 auth_token(若提交的是掩码值) + if let Some(ref submitted) = new_config.gateway.auth_token { + if is_masked_key(submitted) { + new_config.gateway.auth_token = cfg.gateway.auth_token.clone(); + } + } } // read lock released here // Validate timezone diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 0a5e01b..5665e20 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -1,6 +1,7 @@ pub mod agent_factory; pub mod agent_prompt_provider; pub mod agent_task_executor; +pub mod auth; pub mod cancel_manager; pub mod cli_session; pub mod command; @@ -29,11 +30,12 @@ pub mod tool_prompt_provider; pub mod tool_registry_factory; pub mod ws; -use axum::{Router, routing}; +use axum::{Router, middleware, routing}; use std::collections::HashMap; use std::sync::Arc; use tokio::net::TcpSocket; use tokio::sync::Semaphore; +use tower_http::cors::{Any, CorsLayer}; use tower_http::services::ServeDir; use crate::bus::MessageBus; @@ -238,13 +240,42 @@ pub async fn run( } // CLI args override config file values - let (bind_host, bind_port) = { + let (bind_host, bind_port, auth_token) = { let cfg = state.config.read().await; let h = host.unwrap_or_else(|| cfg.gateway.host.clone()); let p = port.unwrap_or(cfg.gateway.port); - (h, p) + (h, p, cfg.gateway.auth_token.clone()) }; + // 安全校验:绑定到非 loopback 地址时必须配置 auth_token + if !auth::is_loopback_host(&bind_host) && auth_token.is_none() { + return Err(format!( + "Gateway is bound to non-loopback address '{}' but no `gateway.auth_token` is configured. \ + Remote access requires authentication. \ + Please set `auth_token` in the `gateway` section of config.json.", + bind_host + ) + .into()); + } + + let auth_required = auth::requires_auth(&bind_host, &auth_token); + let auth_config = auth::AuthConfig { + token: auth_token.clone(), + }; + + if auth_required { + tracing::info!( + host = %bind_host, + has_token = auth_token.is_some(), + "Authentication enabled for gateway" + ); + } else { + tracing::info!( + host = %bind_host, + "Authentication disabled (loopback binding without explicit token)" + ); + } + // 使用嵌入的静态文件(编译时打包进二进制) // 开发模式下可通过 STATIC_DIR 环境变量使用磁盘文件 let use_embedded = std::env::var("STATIC_DIR").is_err(); @@ -354,6 +385,30 @@ pub async fn run( .with_state(state.clone()) }; + // 条件性挂载认证中间件:仅在需要认证时启用。 + // 中间件内部按 path 前缀判断,仅 /api/* 需要校验; + // /health、/ws(WS 在 handler 内单独校验)和静态资源放行。 + let app = if auth_required { + app.layer(axum::Extension(auth_config)) + .layer(middleware::from_fn(auth::require_bearer_auth)) + } else { + app + }; + + // CORS:loopback 下宽松(仅同源);非 loopback 下允许任意来源(由 auth_token 保护)。 + // 不论哪种情况都显式设置以避免浏览器默认行为差异。 + let cors = if auth::is_loopback_host(&bind_host) { + // 本地开发:同源即可,阻止跨域(防 DNS rebinding) + CorsLayer::new() + .allow_origin(tower_http::cors::AllowOrigin::mirror_request()) + .allow_methods(Any) + .allow_headers(Any) + } else { + // 远程访问:允许跨域,但由 token 保护 + CorsLayer::permissive() + }; + let app = app.layer(cors); + let addr: std::net::SocketAddr = format!("{}:{}", bind_host, bind_port).parse()?; let listener = { let socket = match addr { diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index 64994c6..c0593dd 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -32,7 +32,9 @@ use crate::storage::persistent_session_id; use crate::tools::task::repository::TaskRepository; use axum::extract::State; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; -use axum::response::Response; +use axum::extract::Query; +use axum::response::{IntoResponse, Response}; +use axum::http::StatusCode; use base64::{Engine as _, engine::general_purpose::STANDARD}; use futures_util::{SinkExt, StreamExt}; use std::collections::HashMap; @@ -126,7 +128,35 @@ fn process_attachments_with_base64( .collect() } -pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State>) -> Response { +#[derive(serde::Deserialize)] +pub struct WsAuthQuery { + /// 可选的认证 token(浏览器原生 WebSocket 不支持自定义 header,通过 query param 传递) + pub token: Option, +} + +pub async fn ws_handler( + ws: WebSocketUpgrade, + State(state): State>, + Query(query): Query, + auth_cfg: Option>, +) -> Response { + // 若启用了认证(auth_cfg 存在且 token 已配置),校验 query param 中的 token + if let Some(axum::Extension(cfg)) = auth_cfg { + if let Some(ref expected) = cfg.token { + let provided = query.token.as_deref(); + if !crate::gateway::auth::token_matches(provided, &Some(expected.clone())) { + tracing::warn!( + "WebSocket connection rejected: missing or invalid token" + ); + return ( + StatusCode::UNAUTHORIZED, + "missing or invalid token", + ) + .into_response(); + } + } + } + ws.on_upgrade(|socket| async { handle_socket(socket, state).await; }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a44f8f1..88276bc 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -24,8 +24,47 @@ export const API = { sessionSelectedModel: '/api/session/selected-model', } as const; +const TOKEN_KEY = 'picobot-gateway-token'; + +/** 读取 localStorage 中的认证 token(远程访问时需要) */ +export function getAuthToken(): string | null { + try { + return localStorage.getItem(TOKEN_KEY); + } catch { + return null; + } +} + +/** 设置认证 token */ +export function setAuthToken(token: string | null): void { + try { + if (token) { + localStorage.setItem(TOKEN_KEY, token); + } else { + localStorage.removeItem(TOKEN_KEY); + } + } catch { + // ignore + } +} + +/** 构建认证头:有 token 时返回 { Authorization },否则返回 undefined */ +function authHeaders(): Record | undefined { + const token = getAuthToken(); + return token ? { Authorization: `Bearer ${token}` } : undefined; +} + +/** 合并 headers,认证头优先级低于调用方显式传入的同名头 */ +function mergeHeaders( + auth: Record | undefined, + explicit: Record | undefined, +): Record | undefined { + if (!auth && !explicit) return undefined; + return { ...auth, ...explicit }; +} + /** - * 基础 fetch 封装:自动添加 JSON headers,解析响应。 + * 基础 fetch 封装:自动添加 JSON headers 和认证 token,解析响应。 * 返回 [data, error] 元组,不抛异常。 */ export async function apiFetch( @@ -33,9 +72,11 @@ export async function apiFetch( options?: { method?: string; body?: unknown; signal?: AbortSignal }, ): Promise<[T | null, { status: number; message: string } | null]> { try { + const bodyHeaders = options?.body ? { 'Content-Type': 'application/json' } : undefined; + const headers = mergeHeaders(authHeaders(), bodyHeaders); const resp = await fetch(endpoint, { method: options?.method ?? 'GET', - headers: options?.body ? { 'Content-Type': 'application/json' } : undefined, + headers, body: options?.body ? JSON.stringify(options.body) : undefined, signal: options?.signal, }); @@ -57,10 +98,28 @@ export async function apiFetch( */ export async function apiGetSilent(endpoint: string): Promise { try { - const resp = await fetch(endpoint); + const headers = mergeHeaders(authHeaders(), undefined); + const resp = await fetch(endpoint, { headers }); if (!resp.ok) return null; return (await resp.json()) as T; } catch { return null; } } + +/** + * 返回原始 Response 的 fetch 封装:自动注入认证头。 + * 供需要原始 Response 的调用方使用(如 experts/skills/subagents 的 CRUD)。 + */ +export async function authedFetch( + endpoint: string, + options?: { method?: string; body?: unknown }, +): Promise { + const bodyHeaders = options?.body ? { 'Content-Type': 'application/json' } : undefined; + const headers = mergeHeaders(authHeaders(), bodyHeaders); + return fetch(endpoint, { + method: options?.method ?? 'GET', + headers, + body: options?.body ? JSON.stringify(options.body) : undefined, + }); +} diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 0134587..64919f9 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -1,4 +1,4 @@ -import { API, apiFetch } from './client'; +import { API, apiFetch, getAuthToken } from './client'; import type { AppConfig } from '../components/Settings/types'; export interface RestartResponse { @@ -20,7 +20,11 @@ export async function updateAppConfig(config: AppConfig): Promise<[true, null] | } export async function restartGateway(): Promise<{ status: number; data: RestartResponse }> { - const resp = await fetch(API.restart, { method: 'POST' }); + const token = getAuthToken(); + const resp = await fetch(API.restart, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); const data = await resp.json().catch(() => ({ success: false })); return { status: resp.status, data }; } diff --git a/web/src/api/experts.ts b/web/src/api/experts.ts index 6675def..246dc37 100644 --- a/web/src/api/experts.ts +++ b/web/src/api/experts.ts @@ -1,4 +1,4 @@ -import { API, apiGetSilent } from './client'; +import { API, apiGetSilent, authedFetch } from './client'; import type { ExpertListResponse, ExpertItem, @@ -14,15 +14,14 @@ export function listModelOptions(): Promise { return apiGetSilent(API.modelOptions); } -export async function toggleExpert( +export function toggleExpert( name: string, scope: string, enabled: boolean, ): Promise { - return fetch(API.expertsToggle, { + return authedFetch(API.expertsToggle, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, scope, enabled }), + body: { name, scope, enabled }, }); } @@ -35,10 +34,9 @@ export async function createExpert(payload: { provider?: string; model?: string; }): Promise { - return fetch(API.expertsCreate, { + return authedFetch(API.expertsCreate, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }); } @@ -51,23 +49,22 @@ export async function updateExpert(payload: { provider?: string; model?: string; }): Promise { - return fetch(API.expertsUpdate, { + return authedFetch(API.expertsUpdate, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }); } export async function deleteExpert(name: string, scope: string): Promise { const params = new URLSearchParams({ name, scope }); - return fetch(`${API.expertsDelete}?${params}`, { method: 'DELETE' }); + return authedFetch(`${API.expertsDelete}?${params}`, { method: 'DELETE' }); } export async function getSelectedExpert( sessionId: string, ): Promise<{ expert_name: string | null; expert: ExpertItem | null }> { const params = new URLSearchParams({ session_id: sessionId }); - const resp = await fetch(`${API.expertsSelected}?${params}`); + const resp = await authedFetch(`${API.expertsSelected}?${params}`); if (!resp.ok) return { expert_name: null, expert: null }; return resp.json(); } @@ -76,10 +73,9 @@ export async function selectExpert( sessionId: string, expertName: string | null, ): Promise<{ success: boolean; error?: string }> { - const resp = await fetch(API.expertsSelect, { + const resp = await authedFetch(API.expertsSelect, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: sessionId, expert_name: expertName }), + body: { session_id: sessionId, expert_name: expertName }, }); const data = await resp.json().catch(() => ({})); if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' }; @@ -92,10 +88,9 @@ export async function selectModel( provider: string | null, model: string | null, ): Promise<{ success: boolean; error?: string }> { - const resp = await fetch(API.sessionSelectModel, { + const resp = await authedFetch(API.sessionSelectModel, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: sessionId, provider, model }), + body: { session_id: sessionId, provider, model }, }); const data = await resp.json().catch(() => ({})); if (!resp.ok || !data.success) return { success: false, error: data.error || '切换模型失败' }; @@ -107,7 +102,7 @@ export async function getSelectedModel( sessionId: string, ): Promise<{ provider: string | null; model: string | null }> { const params = new URLSearchParams({ session_id: sessionId }); - const resp = await fetch(`${API.sessionSelectedModel}?${params}`); + const resp = await authedFetch(`${API.sessionSelectedModel}?${params}`); if (!resp.ok) return { provider: null, model: null }; return resp.json(); } diff --git a/web/src/api/skills.ts b/web/src/api/skills.ts index 6987ccf..8996d85 100644 --- a/web/src/api/skills.ts +++ b/web/src/api/skills.ts @@ -1,18 +1,17 @@ -import { API, apiGetSilent } from './client'; +import { API, apiGetSilent, authedFetch } from './client'; import type { SkillListResponse } from '../components/Settings/types'; export function listSkills(): Promise { return apiGetSilent(API.skills); } -export async function toggleSkill( +export function toggleSkill( name: string, scope: string, enabled: boolean, ): Promise { - return fetch(API.skillsToggle, { + return authedFetch(API.skillsToggle, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, scope, enabled }), + body: { name, scope, enabled }, }); } diff --git a/web/src/api/subagents.ts b/web/src/api/subagents.ts index dca4998..a5dd4cc 100644 --- a/web/src/api/subagents.ts +++ b/web/src/api/subagents.ts @@ -1,4 +1,4 @@ -import { API, apiGetSilent } from './client'; +import { API, apiGetSilent, authedFetch } from './client'; import type { SubagentListResponse, CapabilityPolicy } from '../components/Settings/types'; export function listSubagents(): Promise { @@ -10,10 +10,9 @@ export async function toggleSubagent( scope: string, enabled: boolean, ): Promise { - return fetch(API.subagentsToggle, { + return authedFetch(API.subagentsToggle, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, scope, enabled }), + body: { name, scope, enabled }, }); } @@ -26,10 +25,9 @@ export async function createSubagent(payload: { provider?: string; model?: string; }): Promise { - return fetch(API.subagentsCreate, { + return authedFetch(API.subagentsCreate, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }); } @@ -41,14 +39,13 @@ export async function updateSubagent(payload: { provider?: string; model?: string; }): Promise { - return fetch(API.subagentsUpdate, { + return authedFetch(API.subagentsUpdate, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }); } export async function deleteSubagent(name: string): Promise { const params = new URLSearchParams({ name }); - return fetch(`${API.subagentsDelete}?${params}`, { method: 'DELETE' }); + return authedFetch(`${API.subagentsDelete}?${params}`, { method: 'DELETE' }); } diff --git a/web/src/components/Settings/SettingsModal.tsx b/web/src/components/Settings/SettingsModal.tsx index 00421bf..458c27f 100644 --- a/web/src/components/Settings/SettingsModal.tsx +++ b/web/src/components/Settings/SettingsModal.tsx @@ -2,6 +2,8 @@ // 原 SettingsModal 组件已删除(功能由 ConfigPage 的 Connection Tab 承担), // 仅保留 App.tsx 使用的 getGatewaySettings / buildWsUrl / GatewaySettings 工具函数 +import { getAuthToken } from '../../api/client'; + export interface GatewaySettings { host: string; port: number; @@ -22,5 +24,8 @@ export function getGatewaySettings(): GatewaySettings { } export function buildWsUrl(settings: GatewaySettings): string { - return `ws://${settings.host}:${settings.port}/ws`; + const base = `ws://${settings.host}:${settings.port}/ws`; + // 远程访问时需要携带认证 token(浏览器原生 WebSocket 不支持自定义 header) + const token = getAuthToken(); + return token ? `${base}?token=${encodeURIComponent(token)}` : base; } diff --git a/web/src/components/Settings/tabs/ConnectionTab.tsx b/web/src/components/Settings/tabs/ConnectionTab.tsx index 38ed9a1..3c07bd1 100644 --- a/web/src/components/Settings/tabs/ConnectionTab.tsx +++ b/web/src/components/Settings/tabs/ConnectionTab.tsx @@ -1,9 +1,10 @@ // ConnectionTab - WebSocket 连接设置(localStorage 持久化,不走后端 config) import { useState } from 'react'; -import { Wifi } from 'lucide-react'; +import { Wifi, KeyRound } from 'lucide-react'; import { Field, SectionCard } from '../ui'; import { inputCls } from '../constants'; import { ErrorBanner } from '../shared'; +import { getAuthToken, setAuthToken } from '../../../api/client'; interface ConnectionTabProps { onSaveConnection?: (host: string, port: number) => void; @@ -26,8 +27,14 @@ export function ConnectionTab({ onSaveConnection, setToast }: ConnectionTabProps return 19876; } }); + const [connToken, setConnToken] = useState(() => getAuthToken() || ''); const [connError, setConnError] = useState(''); + const isLoopback = + connHost.trim() === '127.0.0.1' || + connHost.trim() === 'localhost' || + connHost.trim() === '::1'; + const handleSaveConnection = () => { const host = connHost.trim(); if (!host) { @@ -41,6 +48,7 @@ export function ConnectionTab({ onSaveConnection, setToast }: ConnectionTabProps setConnError(''); localStorage.setItem('picobot-gateway-host', host); localStorage.setItem('picobot-gateway-port', String(connPort)); + setAuthToken(connToken.trim() || null); onSaveConnection?.(host, connPort); setToast('连接设置已保存,正在重连...'); }; @@ -73,9 +81,31 @@ export function ConnectionTab({ onSaveConnection, setToast }: ConnectionTabProps placeholder="19876" /> + +
+ + { + setConnToken(e.target.value); + setConnError(''); + }} + className={inputCls + ' pl-9'} + placeholder={isLoopback ? '本地访问可留空' : '远程访问必填'} + type="password" + autoComplete="off" + /> +
+
+ {!isLoopback && !connToken.trim() && ( +
+ 非本地地址访问必须填写认证 Token(与后端 config.json 中 gateway.auth_token 一致) +
+ )} {connError} -
+
ws://{connHost.trim() || '...'}:{connPort || '...'}/ws + {connToken.trim() && '?token=***'}