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/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
1
Cargo.lock
generated
@ -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",
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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
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
|
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
|
||||||
|
|||||||
@ -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、/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 addr: std::net::SocketAddr = format!("{}:{}", bind_host, bind_port).parse()?;
|
||||||
let listener = {
|
let listener = {
|
||||||
let socket = match addr {
|
let socket = match addr {
|
||||||
|
|||||||
@ -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;
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user