feat(gateway): 添加 HTTP API 认证与 CORS 防护,修复密钥泄露风险

- 新增 auth_token 配置项:非 loopback 绑定强制认证,loopback 可选加固
- 新增 auth.rs:loopback 判定(IpAddr 解析)、常量时间 token 比较、RFC 6750 Bearer 解析
- 新增 Bearer token 中间件(/api/*)与 WebSocket ?token= 校验
- 新增 CORS layer:loopback 镜像 Origin 防 DNS rebinding,非 loopback permissive
- 修复 .gitignore 遗漏 tests/test.env,补充通用 *.env 规则
- 修复前端 experts/skills/subagents.ts 直接 fetch 不带 token(13 处),统一走 authedFetch
- config.json host 从 0.0.0.0 改回 127.0.0.1
- mask_config/save_config 处理 auth_token 掩码与保留
- ConnectionTab/GatewayTab 增加认证 token 配置入口
This commit is contained in:
oudecheng 2026-08-05 22:41:00 +08:00
parent 6252b600cd
commit fa420b713f
18 changed files with 525 additions and 52 deletions

5
.gitignore vendored
View File

@ -11,6 +11,11 @@ web/.cache
web/coverage web/coverage
web/*.local web/*.local
# Secrets — never commit real credentials
*.env
!*.env.example
tests/test.env
# Build output # Build output
static static

1
Cargo.lock generated
View File

@ -1669,6 +1669,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"subtle",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",

View File

@ -55,6 +55,7 @@ rusqlite = { version = "0.39", features = ["bundled"] }
r2d2 = "0.8" r2d2 = "0.8"
r2d2_sqlite = "0.34" r2d2_sqlite = "0.34"
rustls = { version = "0.23", features = ["ring"] } rustls = { version = "0.23", features = ["ring"] }
subtle = "2.6"
wechatbot = { path = "vendor/wechatbot" } wechatbot = { path = "vendor/wechatbot" }
encoding_rs = "0.8" encoding_rs = "0.8"
libc = "0.2" libc = "0.2"
@ -68,7 +69,7 @@ rmcp = { version = "1.7", features = [
] } ] }
schemars = "1.0" schemars = "1.0"
http = "1" http = "1"
tower-http = { version = "0.6", features = ["fs"] } tower-http = { version = "0.6", features = ["fs", "cors"] }
rust-embed = "8" rust-embed = "8"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]

View File

@ -24,7 +24,7 @@
} }
}, },
"gateway": { "gateway": {
"host": "0.0.0.0", "host": "127.0.0.1",
"port": 19876, "port": 19876,
"agent_prompt_reinject_every": 100 "agent_prompt_reinject_every": 100
}, },

View File

@ -523,6 +523,11 @@ pub struct GatewayConfig {
pub max_concurrent_requests: usize, pub max_concurrent_requests: usize,
#[serde(default, rename = "session_ttl_hours")] #[serde(default, rename = "session_ttl_hours")]
pub session_ttl_hours: Option<u64>, pub session_ttl_hours: Option<u64>,
/// 网关认证 token。当绑定到非 loopback 地址时必须配置,否则启动会报错。
/// 绑定到 loopback 时可不配置(本地访问免认证)。
/// 前端通过 Authorization: Bearer <token> 头或 WS query param ?token=<token> 携带。
#[serde(default, rename = "auth_token")]
pub auth_token: Option<String>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@ -832,6 +837,7 @@ impl Default for GatewayConfig {
agent_prompt_reinject_every: default_agent_prompt_reinject_every(), agent_prompt_reinject_every: default_agent_prompt_reinject_every(),
max_concurrent_requests: default_max_concurrent_requests(), max_concurrent_requests: default_max_concurrent_requests(),
session_ttl_hours: Some(24), session_ttl_hours: Some(24),
auth_token: None,
} }
} }
} }

243
src/gateway/auth.rs Normal file
View File

@ -0,0 +1,243 @@
//! 网关认证与访问控制。
//!
//! 设计目标(第一性原理):
//! - 本地单机部署host 为 loopback免认证仅靠 CORS 防御 DNS rebinding / CSRF。
//! - 远程访问host 非 loopback必须配置 `auth_token`,所有 `/api/*` 与 `/ws` 强制校验。
//! - token 通过 `Authorization: Bearer <token>`HTTP或 `?token=<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::<std::net::IpAddr>() {
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<String>) -> 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<String>) -> bool {
match expected {
// 未配置 token放行调用方已通过 requires_auth 保证只在 loopback 下到达此处)
None => true,
Some(expected_str) => match provided {
None => false,
Some(provided_str) => {
// subtle::ConstantTimeEq 要求两端等长;长度不等时 ct_eq 返回 0false
// 但为避免长度差异导致的提前退出计时泄露,我们确保比较路径不因长度而分支提前返回。
let p = provided_str.as_bytes();
let e = expected_str.as_bytes();
// ct_eq 内部在长度不等时仍会遍历较短的一侧,返回 0无提前退出
p.ct_eq(e).unwrap_u8() == 1
}
},
}
}
/// 从 Authorization 头提取 Bearer tokenRFC 6750scheme 不区分大小写)。
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::<AuthConfig>()
.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<String>,
}
#[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(""));
}
}

View File

@ -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 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 } // read lock released here
// Validate timezone // Validate timezone

View File

@ -1,6 +1,7 @@
pub mod agent_factory; pub mod agent_factory;
pub mod agent_prompt_provider; pub mod agent_prompt_provider;
pub mod agent_task_executor; pub mod agent_task_executor;
pub mod auth;
pub mod cancel_manager; pub mod cancel_manager;
pub mod cli_session; pub mod cli_session;
pub mod command; pub mod command;
@ -29,11 +30,12 @@ pub mod tool_prompt_provider;
pub mod tool_registry_factory; pub mod tool_registry_factory;
pub mod ws; pub mod ws;
use axum::{Router, routing}; use axum::{Router, middleware, routing};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::net::TcpSocket; use tokio::net::TcpSocket;
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use crate::bus::MessageBus; use crate::bus::MessageBus;
@ -238,13 +240,42 @@ pub async fn run(
} }
// CLI args override config file values // 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 cfg = state.config.read().await;
let h = host.unwrap_or_else(|| cfg.gateway.host.clone()); let h = host.unwrap_or_else(|| cfg.gateway.host.clone());
let p = port.unwrap_or(cfg.gateway.port); 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 环境变量使用磁盘文件 // 开发模式下可通过 STATIC_DIR 环境变量使用磁盘文件
let use_embedded = std::env::var("STATIC_DIR").is_err(); let use_embedded = std::env::var("STATIC_DIR").is_err();
@ -354,6 +385,30 @@ pub async fn run(
.with_state(state.clone()) .with_state(state.clone())
}; };
// 条件性挂载认证中间件:仅在需要认证时启用。
// 中间件内部按 path 前缀判断,仅 /api/* 需要校验;
// /health、/wsWS 在 handler 内单独校验)和静态资源放行。
let app = if auth_required {
app.layer(axum::Extension(auth_config))
.layer(middleware::from_fn(auth::require_bearer_auth))
} else {
app
};
// CORSloopback 下宽松(仅同源);非 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 addr: std::net::SocketAddr = format!("{}:{}", bind_host, bind_port).parse()?;
let listener = { let listener = {
let socket = match addr { let socket = match addr {

View File

@ -32,7 +32,9 @@ use crate::storage::persistent_session_id;
use crate::tools::task::repository::TaskRepository; use crate::tools::task::repository::TaskRepository;
use axum::extract::State; use axum::extract::State;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; 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 base64::{Engine as _, engine::general_purpose::STANDARD};
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use std::collections::HashMap; use std::collections::HashMap;
@ -126,7 +128,35 @@ fn process_attachments_with_base64(
.collect() .collect()
} }
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<GatewayState>>) -> Response { #[derive(serde::Deserialize)]
pub struct WsAuthQuery {
/// 可选的认证 token浏览器原生 WebSocket 不支持自定义 header通过 query param 传递)
pub token: Option<String>,
}
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<GatewayState>>,
Query(query): Query<WsAuthQuery>,
auth_cfg: Option<axum::Extension<crate::gateway::auth::AuthConfig>>,
) -> 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 { ws.on_upgrade(|socket| async {
handle_socket(socket, state).await; handle_socket(socket, state).await;
}) })

View File

@ -24,8 +24,47 @@ export const API = {
sessionSelectedModel: '/api/session/selected-model', sessionSelectedModel: '/api/session/selected-model',
} as const; } 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<string, string> | undefined {
const token = getAuthToken();
return token ? { Authorization: `Bearer ${token}` } : undefined;
}
/** 合并 headers认证头优先级低于调用方显式传入的同名头 */
function mergeHeaders(
auth: Record<string, string> | undefined,
explicit: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (!auth && !explicit) return undefined;
return { ...auth, ...explicit };
}
/** /**
* fetch JSON headers * fetch JSON headers token
* [data, error] * [data, error]
*/ */
export async function apiFetch<T>( export async function apiFetch<T>(
@ -33,9 +72,11 @@ export async function apiFetch<T>(
options?: { method?: string; body?: unknown; signal?: AbortSignal }, options?: { method?: string; body?: unknown; signal?: AbortSignal },
): Promise<[T | null, { status: number; message: string } | null]> { ): Promise<[T | null, { status: number; message: string } | null]> {
try { try {
const bodyHeaders = options?.body ? { 'Content-Type': 'application/json' } : undefined;
const headers = mergeHeaders(authHeaders(), bodyHeaders);
const resp = await fetch(endpoint, { const resp = await fetch(endpoint, {
method: options?.method ?? 'GET', method: options?.method ?? 'GET',
headers: options?.body ? { 'Content-Type': 'application/json' } : undefined, headers,
body: options?.body ? JSON.stringify(options.body) : undefined, body: options?.body ? JSON.stringify(options.body) : undefined,
signal: options?.signal, signal: options?.signal,
}); });
@ -57,10 +98,28 @@ export async function apiFetch<T>(
*/ */
export async function apiGetSilent<T>(endpoint: string): Promise<T | null> { export async function apiGetSilent<T>(endpoint: string): Promise<T | null> {
try { try {
const resp = await fetch(endpoint); const headers = mergeHeaders(authHeaders(), undefined);
const resp = await fetch(endpoint, { headers });
if (!resp.ok) return null; if (!resp.ok) return null;
return (await resp.json()) as T; return (await resp.json()) as T;
} catch { } catch {
return null; return null;
} }
} }
/**
* Response fetch
* Response 使 experts/skills/subagents CRUD
*/
export async function authedFetch(
endpoint: string,
options?: { method?: string; body?: unknown },
): Promise<Response> {
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,
});
}

View File

@ -1,4 +1,4 @@
import { API, apiFetch } from './client'; import { API, apiFetch, getAuthToken } from './client';
import type { AppConfig } from '../components/Settings/types'; import type { AppConfig } from '../components/Settings/types';
export interface RestartResponse { 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 }> { 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 })); const data = await resp.json().catch(() => ({ success: false }));
return { status: resp.status, data }; return { status: resp.status, data };
} }

View File

@ -1,4 +1,4 @@
import { API, apiGetSilent } from './client'; import { API, apiGetSilent, authedFetch } from './client';
import type { import type {
ExpertListResponse, ExpertListResponse,
ExpertItem, ExpertItem,
@ -14,15 +14,14 @@ export function listModelOptions(): Promise<ModelOptionsResponse | null> {
return apiGetSilent<ModelOptionsResponse>(API.modelOptions); return apiGetSilent<ModelOptionsResponse>(API.modelOptions);
} }
export async function toggleExpert( export function toggleExpert(
name: string, name: string,
scope: string, scope: string,
enabled: boolean, enabled: boolean,
): Promise<Response> { ): Promise<Response> {
return fetch(API.expertsToggle, { return authedFetch(API.expertsToggle, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: { name, scope, enabled },
body: JSON.stringify({ name, scope, enabled }),
}); });
} }
@ -35,10 +34,9 @@ export async function createExpert(payload: {
provider?: string; provider?: string;
model?: string; model?: string;
}): Promise<Response> { }): Promise<Response> {
return fetch(API.expertsCreate, { return authedFetch(API.expertsCreate, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: payload,
body: JSON.stringify(payload),
}); });
} }
@ -51,23 +49,22 @@ export async function updateExpert(payload: {
provider?: string; provider?: string;
model?: string; model?: string;
}): Promise<Response> { }): Promise<Response> {
return fetch(API.expertsUpdate, { return authedFetch(API.expertsUpdate, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, body: payload,
body: JSON.stringify(payload),
}); });
} }
export async function deleteExpert(name: string, scope: string): Promise<Response> { export async function deleteExpert(name: string, scope: string): Promise<Response> {
const params = new URLSearchParams({ name, scope }); const params = new URLSearchParams({ name, scope });
return fetch(`${API.expertsDelete}?${params}`, { method: 'DELETE' }); return authedFetch(`${API.expertsDelete}?${params}`, { method: 'DELETE' });
} }
export async function getSelectedExpert( export async function getSelectedExpert(
sessionId: string, sessionId: string,
): Promise<{ expert_name: string | null; expert: ExpertItem | null }> { ): Promise<{ expert_name: string | null; expert: ExpertItem | null }> {
const params = new URLSearchParams({ session_id: sessionId }); 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 }; if (!resp.ok) return { expert_name: null, expert: null };
return resp.json(); return resp.json();
} }
@ -76,10 +73,9 @@ export async function selectExpert(
sessionId: string, sessionId: string,
expertName: string | null, expertName: string | null,
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const resp = await fetch(API.expertsSelect, { const resp = await authedFetch(API.expertsSelect, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: { session_id: sessionId, expert_name: expertName },
body: JSON.stringify({ session_id: sessionId, expert_name: expertName }),
}); });
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' }; if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' };
@ -92,10 +88,9 @@ export async function selectModel(
provider: string | null, provider: string | null,
model: string | null, model: string | null,
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const resp = await fetch(API.sessionSelectModel, { const resp = await authedFetch(API.sessionSelectModel, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: { session_id: sessionId, provider, model },
body: JSON.stringify({ session_id: sessionId, provider, model }),
}); });
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) return { success: false, error: data.error || '切换模型失败' }; if (!resp.ok || !data.success) return { success: false, error: data.error || '切换模型失败' };
@ -107,7 +102,7 @@ export async function getSelectedModel(
sessionId: string, sessionId: string,
): Promise<{ provider: string | null; model: string | null }> { ): Promise<{ provider: string | null; model: string | null }> {
const params = new URLSearchParams({ session_id: sessionId }); 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 }; if (!resp.ok) return { provider: null, model: null };
return resp.json(); return resp.json();
} }

View File

@ -1,18 +1,17 @@
import { API, apiGetSilent } from './client'; import { API, apiGetSilent, authedFetch } from './client';
import type { SkillListResponse } from '../components/Settings/types'; import type { SkillListResponse } from '../components/Settings/types';
export function listSkills(): Promise<SkillListResponse | null> { export function listSkills(): Promise<SkillListResponse | null> {
return apiGetSilent<SkillListResponse>(API.skills); return apiGetSilent<SkillListResponse>(API.skills);
} }
export async function toggleSkill( export function toggleSkill(
name: string, name: string,
scope: string, scope: string,
enabled: boolean, enabled: boolean,
): Promise<Response> { ): Promise<Response> {
return fetch(API.skillsToggle, { return authedFetch(API.skillsToggle, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: { name, scope, enabled },
body: JSON.stringify({ name, scope, enabled }),
}); });
} }

View File

@ -1,4 +1,4 @@
import { API, apiGetSilent } from './client'; import { API, apiGetSilent, authedFetch } from './client';
import type { SubagentListResponse, CapabilityPolicy } from '../components/Settings/types'; import type { SubagentListResponse, CapabilityPolicy } from '../components/Settings/types';
export function listSubagents(): Promise<SubagentListResponse | null> { export function listSubagents(): Promise<SubagentListResponse | null> {
@ -10,10 +10,9 @@ export async function toggleSubagent(
scope: string, scope: string,
enabled: boolean, enabled: boolean,
): Promise<Response> { ): Promise<Response> {
return fetch(API.subagentsToggle, { return authedFetch(API.subagentsToggle, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: { name, scope, enabled },
body: JSON.stringify({ name, scope, enabled }),
}); });
} }
@ -26,10 +25,9 @@ export async function createSubagent(payload: {
provider?: string; provider?: string;
model?: string; model?: string;
}): Promise<Response> { }): Promise<Response> {
return fetch(API.subagentsCreate, { return authedFetch(API.subagentsCreate, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: payload,
body: JSON.stringify(payload),
}); });
} }
@ -41,14 +39,13 @@ export async function updateSubagent(payload: {
provider?: string; provider?: string;
model?: string; model?: string;
}): Promise<Response> { }): Promise<Response> {
return fetch(API.subagentsUpdate, { return authedFetch(API.subagentsUpdate, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, body: payload,
body: JSON.stringify(payload),
}); });
} }
export async function deleteSubagent(name: string): Promise<Response> { export async function deleteSubagent(name: string): Promise<Response> {
const params = new URLSearchParams({ name }); const params = new URLSearchParams({ name });
return fetch(`${API.subagentsDelete}?${params}`, { method: 'DELETE' }); return authedFetch(`${API.subagentsDelete}?${params}`, { method: 'DELETE' });
} }

View File

@ -2,6 +2,8 @@
// 原 SettingsModal 组件已删除(功能由 ConfigPage 的 Connection Tab 承担), // 原 SettingsModal 组件已删除(功能由 ConfigPage 的 Connection Tab 承担),
// 仅保留 App.tsx 使用的 getGatewaySettings / buildWsUrl / GatewaySettings 工具函数 // 仅保留 App.tsx 使用的 getGatewaySettings / buildWsUrl / GatewaySettings 工具函数
import { getAuthToken } from '../../api/client';
export interface GatewaySettings { export interface GatewaySettings {
host: string; host: string;
port: number; port: number;
@ -22,5 +24,8 @@ export function getGatewaySettings(): GatewaySettings {
} }
export function buildWsUrl(settings: GatewaySettings): string { 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;
} }

View File

@ -1,9 +1,10 @@
// ConnectionTab - WebSocket 连接设置localStorage 持久化,不走后端 config // ConnectionTab - WebSocket 连接设置localStorage 持久化,不走后端 config
import { useState } from 'react'; import { useState } from 'react';
import { Wifi } from 'lucide-react'; import { Wifi, KeyRound } from 'lucide-react';
import { Field, SectionCard } from '../ui'; import { Field, SectionCard } from '../ui';
import { inputCls } from '../constants'; import { inputCls } from '../constants';
import { ErrorBanner } from '../shared'; import { ErrorBanner } from '../shared';
import { getAuthToken, setAuthToken } from '../../../api/client';
interface ConnectionTabProps { interface ConnectionTabProps {
onSaveConnection?: (host: string, port: number) => void; onSaveConnection?: (host: string, port: number) => void;
@ -26,8 +27,14 @@ export function ConnectionTab({ onSaveConnection, setToast }: ConnectionTabProps
return 19876; return 19876;
} }
}); });
const [connToken, setConnToken] = useState(() => getAuthToken() || '');
const [connError, setConnError] = useState(''); const [connError, setConnError] = useState('');
const isLoopback =
connHost.trim() === '127.0.0.1' ||
connHost.trim() === 'localhost' ||
connHost.trim() === '::1';
const handleSaveConnection = () => { const handleSaveConnection = () => {
const host = connHost.trim(); const host = connHost.trim();
if (!host) { if (!host) {
@ -41,6 +48,7 @@ export function ConnectionTab({ onSaveConnection, setToast }: ConnectionTabProps
setConnError(''); setConnError('');
localStorage.setItem('picobot-gateway-host', host); localStorage.setItem('picobot-gateway-host', host);
localStorage.setItem('picobot-gateway-port', String(connPort)); localStorage.setItem('picobot-gateway-port', String(connPort));
setAuthToken(connToken.trim() || null);
onSaveConnection?.(host, connPort); onSaveConnection?.(host, connPort);
setToast('连接设置已保存,正在重连...'); setToast('连接设置已保存,正在重连...');
}; };
@ -73,9 +81,31 @@ export function ConnectionTab({ onSaveConnection, setToast }: ConnectionTabProps
placeholder="19876" placeholder="19876"
/> />
</Field> </Field>
<Field label="认证 Token">
<div className="relative">
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--text-muted)] pointer-events-none" />
<input
value={connToken}
onChange={(e) => {
setConnToken(e.target.value);
setConnError('');
}}
className={inputCls + ' pl-9'}
placeholder={isLoopback ? '本地访问可留空' : '远程访问必填'}
type="password"
autoComplete="off"
/>
</div>
</Field>
{!isLoopback && !connToken.trim() && (
<div className="text-xs text-[var(--accent-orange)] bg-[var(--overlay-dim)] rounded-lg px-3 py-2">
访 Token config.json gateway.auth_token
</div>
)}
<ErrorBanner>{connError}</ErrorBanner> <ErrorBanner>{connError}</ErrorBanner>
<div className="text-xs text-[var(--text-muted)] bg-[var(--overlay-dim)] rounded-lg px-3 py-2 font-mono"> <div className="text-xs text-[var(--text-muted)] bg-[var(--overlay-dim)] rounded-lg px-3 py-2 font-mono break-all">
ws://{connHost.trim() || '...'}:{connPort || '...'}/ws ws://{connHost.trim() || '...'}:{connPort || '...'}/ws
{connToken.trim() && '?token=***'}
</div> </div>
</SectionCard> </SectionCard>
<button <button

View File

@ -4,10 +4,15 @@ import { inputCls } from '../constants';
import type { TabProps } from '../shared'; import type { TabProps } from '../shared';
export function GatewayTab({ config, update }: TabProps) { export function GatewayTab({ config, update }: TabProps) {
const isLoopback =
config.gateway.host === '127.0.0.1' ||
config.gateway.host === 'localhost' ||
config.gateway.host === '::1';
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<SectionCard title="连接"> <SectionCard title="连接">
<Field label="主机地址"> <Field label="主机地址" hint="绑定到 127.0.0.1 仅本机可访问0.0.0.0 允许远程访问(需配置 Token">
<input <input
value={config.gateway.host} value={config.gateway.host}
onChange={(e) => update('gateway', { ...config.gateway, host: e.target.value })} onChange={(e) => update('gateway', { ...config.gateway, host: e.target.value })}
@ -22,6 +27,30 @@ export function GatewayTab({ config, update }: TabProps) {
className={inputCls} className={inputCls}
/> />
</Field> </Field>
<Field
label="认证 Token"
hint={
isLoopback
? '本地访问可留空;填写后强制认证。修改后需重启网关生效'
: '远程访问必填,否则网关无法启动。修改后需重启网关生效'
}
>
<input
value={config.gateway.auth_token ?? ''}
onChange={(e) =>
update('gateway', { ...config.gateway, auth_token: e.target.value || undefined })
}
className={inputCls}
type="password"
autoComplete="off"
placeholder={isLoopback ? '留空则本地免认证' : '必填'}
/>
</Field>
{!isLoopback && !config.gateway.auth_token && (
<div className="text-xs text-[var(--accent-orange)] bg-[var(--overlay-dim)] rounded-lg px-3 py-2">
Token
</div>
)}
</SectionCard> </SectionCard>
<SectionCard title="行为"> <SectionCard title="行为">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@ -29,6 +29,7 @@ export interface GatewayConfig {
agent_prompt_reinject_every: number; agent_prompt_reinject_every: number;
max_concurrent_requests: number; max_concurrent_requests: number;
session_ttl_hours?: number; session_ttl_hours?: number;
auth_token?: string;
} }
export interface TimeConfig { export interface TimeConfig {
timezone: string; timezone: string;