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:
parent
6252b600cd
commit
fa420b713f
5
.gitignore
vendored
5
.gitignore
vendored
@ -11,6 +11,11 @@ web/.cache
|
||||
web/coverage
|
||||
web/*.local
|
||||
|
||||
# Secrets — never commit real credentials
|
||||
*.env
|
||||
!*.env.example
|
||||
tests/test.env
|
||||
|
||||
# Build output
|
||||
static
|
||||
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -1669,6 +1669,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"host": "0.0.0.0",
|
||||
"host": "127.0.0.1",
|
||||
"port": 19876,
|
||||
"agent_prompt_reinject_every": 100
|
||||
},
|
||||
|
||||
@ -523,6 +523,11 @@ pub struct GatewayConfig {
|
||||
pub max_concurrent_requests: usize,
|
||||
#[serde(default, rename = "session_ttl_hours")]
|
||||
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)]
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
243
src/gateway/auth.rs
Normal file
243
src/gateway/auth.rs
Normal 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 返回 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::<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(""));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<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 {
|
||||
handle_socket(socket, state).await;
|
||||
})
|
||||
|
||||
@ -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<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] 元组,不抛异常。
|
||||
*/
|
||||
export async function apiFetch<T>(
|
||||
@ -33,9 +72,11 @@ export async function apiFetch<T>(
|
||||
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<T>(
|
||||
*/
|
||||
export async function apiGetSilent<T>(endpoint: string): Promise<T | null> {
|
||||
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<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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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<ModelOptionsResponse | null> {
|
||||
return apiGetSilent<ModelOptionsResponse>(API.modelOptions);
|
||||
}
|
||||
|
||||
export async function toggleExpert(
|
||||
export function toggleExpert(
|
||||
name: string,
|
||||
scope: string,
|
||||
enabled: boolean,
|
||||
): Promise<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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();
|
||||
}
|
||||
|
||||
@ -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<SkillListResponse | null> {
|
||||
return apiGetSilent<SkillListResponse>(API.skills);
|
||||
}
|
||||
|
||||
export async function toggleSkill(
|
||||
export function toggleSkill(
|
||||
name: string,
|
||||
scope: string,
|
||||
enabled: boolean,
|
||||
): Promise<Response> {
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<SubagentListResponse | null> {
|
||||
@ -10,10 +10,9 @@ export async function toggleSubagent(
|
||||
scope: string,
|
||||
enabled: boolean,
|
||||
): Promise<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
const params = new URLSearchParams({ name });
|
||||
return fetch(`${API.subagentsDelete}?${params}`, { method: 'DELETE' });
|
||||
return authedFetch(`${API.subagentsDelete}?${params}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
</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>
|
||||
<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
|
||||
{connToken.trim() && '?token=***'}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<button
|
||||
|
||||
@ -4,10 +4,15 @@ import { inputCls } from '../constants';
|
||||
import type { TabProps } from '../shared';
|
||||
|
||||
export function GatewayTab({ config, update }: TabProps) {
|
||||
const isLoopback =
|
||||
config.gateway.host === '127.0.0.1' ||
|
||||
config.gateway.host === 'localhost' ||
|
||||
config.gateway.host === '::1';
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SectionCard title="连接">
|
||||
<Field label="主机地址">
|
||||
<Field label="主机地址" hint="绑定到 127.0.0.1 仅本机可访问;0.0.0.0 允许远程访问(需配置 Token)">
|
||||
<input
|
||||
value={config.gateway.host}
|
||||
onChange={(e) => update('gateway', { ...config.gateway, host: e.target.value })}
|
||||
@ -22,6 +27,30 @@ export function GatewayTab({ config, update }: TabProps) {
|
||||
className={inputCls}
|
||||
/>
|
||||
</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 title="行为">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@ -29,6 +29,7 @@ export interface GatewayConfig {
|
||||
agent_prompt_reinject_every: number;
|
||||
max_concurrent_requests: number;
|
||||
session_ttl_hours?: number;
|
||||
auth_token?: string;
|
||||
}
|
||||
export interface TimeConfig {
|
||||
timezone: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user