fix: close pre-release security holes and correctness bugs

- 静态资源回退存在路径穿越,未鉴权即可读出 secret.key 与 vps-switch.db
- MCP 端点默认要求 API Key;删除可被客户端伪造的 x-vps-switch-stdio 豁免头
- 跨源只放行本机回环来源;绑回环时校验 Host,挡 DNS rebinding
- 本机命令输出中文乱码(本机与远端共用 EncodedCommand + UTF-8)
- 远端拿不到退出码时不再报成功;cross 传输超时不再谎报"后台传输中"
- 任务表与单流输出加上限;传输队列双锁死锁改单锁;回收已退出的终端 pane
- 前端终端创建改为按主机去重;终端断线自动重连并给出提示

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
weidu12123andClaude Fable 5 committed 2026-10-13 22:12:39 +08:00
1 parent 6fdd134058
commit 14e7066db9
8 files changed
+302 -78

No files matched your search

+10 -4
View File
@@ -53,7 +53,9 @@ const hosts = ref<PublicHost[]>([]);
const loading = ref(false);
const checkingAll = ref(false);
const checkingIds = ref<Set<string>>(new Set());
const creatingPane = ref(false);
const paneCreationInFlight = new Set<string>();
const paneCreationCount = ref(0);
const creatingPane = computed(() => paneCreationCount.value > 0);
// 全局昼夜主题
const isDark = ref(false);
@@ -763,8 +765,11 @@ async function ensureHostHasPane(tab: OpenSessionTab) {
}
async function createPaneForTab(tab: OpenSessionTab, cwd?: string | null) {
if (creatingPane.value) return;
creatingPane.value = true;
// 按主机去重:全局布尔会让"主机 A 创建中"静默吞掉"主机 B 的创建请求",
// 导致 B 的标签页永久停在"正在连接"。
if (paneCreationInFlight.has(tab.hostId)) return;
paneCreationInFlight.add(tab.hostId);
paneCreationCount.value += 1;
try {
const res = await fetch('/api/terminals', {
method: 'POST',
@@ -789,7 +794,8 @@ async function createPaneForTab(tab: OpenSessionTab, cwd?: string | null) {
} catch (e) {
showToast(`启动终端失败: ${(e as Error).message}`, 'err');
} finally {
creatingPane.value = false;
paneCreationInFlight.delete(tab.hostId);
paneCreationCount.value = Math.max(0, paneCreationCount.value - 1);
}
}
@@ -23,6 +23,7 @@ const emit = defineEmits<{
const containerRef = ref<HTMLDivElement | null>(null);
const connected = ref(false);
const exited = ref(false);
const disconnected = ref(false);
let term: Terminal | null = null;
let fitAddon: FitAddon | null = null;
@@ -30,6 +31,10 @@ let ws: WebSocket | null = null;
let resizeObserver: ResizeObserver | null = null;
let fitRaf: number | null = null;
let replayingDepth = 0;
let disposed = false;
let reconnectTimer: number | null = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 6;
const textEncoder = new TextEncoder();
// 拦截 xterm.js 自动发出的设备属性应答(如 DA1 \x1b[?1;2c、DA2 \x1b[>0;276;0c),防止在 Shell 提示符上打出 [?1;2c
@@ -129,6 +134,8 @@ function connectWebSocket() {
ws.onopen = () => {
connected.value = true;
disconnected.value = false;
reconnectAttempts = 0;
scheduleFit();
};
@@ -164,9 +171,31 @@ function connectWebSocket() {
ws.onclose = () => {
connected.value = false;
if (disposed) return;
// 后端在新连接上会补发 0x02 历史字节环回放帧,重连即恢复现场;
// 退避重试有上限,避免服务端已关闭该 pane 时无限重连。
disconnected.value = true;
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return;
const delay = Math.min(400 * 2 ** reconnectAttempts, 4000);
reconnectAttempts += 1;
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
if (!disposed) connectWebSocket();
}, delay);
};
}
function manualReconnect() {
if (disposed) return;
if (reconnectTimer != null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
reconnectAttempts = 0;
disconnected.value = false;
connectWebSocket();
}
watch(
() => props.dark,
(isDark) => {
@@ -243,6 +272,8 @@ onMounted(() => {
});
onBeforeUnmount(() => {
disposed = true;
if (reconnectTimer != null) clearTimeout(reconnectTimer);
if (fitRaf != null) cancelAnimationFrame(fitRaf);
resizeObserver?.disconnect();
if (ws) {
@@ -283,6 +314,23 @@ onBeforeUnmount(() => {
<!-- xterm 挂载容器 -->
<div ref="containerRef" class="h-full w-full flex-1 overflow-hidden px-3 py-2" />
<!-- 连接断开提示条:后端支持字节环回放,重连即恢复现场 -->
<div
v-if="disconnected && !exited"
class="flex items-center justify-between border-t border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-700"
>
<span>{{
reconnectAttempts >= MAX_RECONNECT_ATTEMPTS ? '终端连接已断开' : '连接中断,正在重连…'
}}</span>
<button
type="button"
class="rounded-md bg-amber-600 px-2 py-0.5 text-white hover:bg-amber-700"
@click="manualReconnect"
>
立即重连
</button>
</div>
<!-- 会话已退出提示条 -->
<div
v-if="exited"
+3 -6
View File
@@ -1466,12 +1466,9 @@ async fn api_mcp_rpc(
return err_response(StatusCode::FORBIDDEN, "MCP Server 当前已在设置中关闭").into_response();
}
// 本机 --mcp-stdio 内部桥接请求带 x-vps-switch-stdio: 1;其余外部 HTTP 请求若开启了 API Key 校验则必须匹配
let is_local_stdio = headers
.get("x-vps-switch-stdio")
.and_then(|v| v.to_str().ok())
== Some("1");
if settings.mcp_require_api_key && !is_local_stdio {
// API Key 一律校验。stdio 桥接同样走真实 Key(见 mcp.rs try_forward_to_daemon),
// 不再存在任何客户端可伪造的豁免请求头。
if settings.mcp_require_api_key {
let provided = extract_mcp_api_key(&headers);
if provided.as_deref() != Some(settings.mcp_api_key.as_str()) {
return err_response(
+3 -3
View File
@@ -784,7 +784,7 @@ impl Database {
[],
)?;
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES ('mcp_require_api_key', 'false')",
"INSERT OR IGNORE INTO settings (key, value) VALUES ('mcp_require_api_key', 'true')",
[],
)?;
conn.execute(
@@ -839,8 +839,8 @@ impl Database {
.unwrap_or(true);
let mcp_require_api_key = self
.get_setting("mcp_require_api_key")?
.map(|v| v == "true")
.unwrap_or(false);
.map(|v| v != "false")
.unwrap_or(true);
let mcp_api_key = match self.get_setting("mcp_api_key")? {
Some(k) if !k.is_empty() => k,
_ => {
+33 -19
View File
@@ -68,11 +68,18 @@ pub struct TransferTask {
pub ended_at: Option<String>,
}
/// 传输任务表与顺序表由同一把锁持有:两把锁交叉获取曾构成 AB-BA 死锁
/// (create_task 持 order 取 tasks,clear_finished 持 tasks 取 order),单锁后该问题不复存在。
#[derive(Default)]
struct TransferStore {
tasks: HashMap<String, TransferTask>,
order: Vec<String>,
}
/// 统一传输队列管理器(Web UI 与未来板块六 MCP 工具共用同一套传输状态与引擎)
#[derive(Default)]
pub struct TransferManager {
tasks: Mutex<HashMap<String, TransferTask>>,
order: Mutex<Vec<String>>,
store: Mutex<TransferStore>,
}
impl TransferManager {
@@ -107,19 +114,19 @@ impl TransferManager {
started_at: Utc::now().to_rfc3339(),
ended_at: None,
};
self.tasks.lock().unwrap().insert(id.clone(), task);
let mut ord = self.order.lock().unwrap();
ord.insert(0, id.clone());
if ord.len() > 100 {
if let Some(old_id) = ord.pop() {
self.tasks.lock().unwrap().remove(&old_id);
let mut store = self.store.lock().unwrap();
store.tasks.insert(id.clone(), task);
store.order.insert(0, id.clone());
if store.order.len() > 100 {
if let Some(old_id) = store.order.pop() {
store.tasks.remove(&old_id);
}
}
id
}
pub fn update_progress(&self, id: &str, transferred: u64, total: u64, elapsed_secs: f64) {
if let Some(task) = self.tasks.lock().unwrap().get_mut(id) {
if let Some(task) = self.store.lock().unwrap().tasks.get_mut(id) {
task.transferred_bytes = transferred;
if total > 0 {
task.total_bytes = total;
@@ -131,7 +138,7 @@ impl TransferManager {
}
pub fn finish_task(&self, id: &str, final_bytes: u64, elapsed_secs: f64) {
if let Some(task) = self.tasks.lock().unwrap().get_mut(id) {
if let Some(task) = self.store.lock().unwrap().tasks.get_mut(id) {
task.transferred_bytes = final_bytes;
if task.total_bytes == 0 {
task.total_bytes = final_bytes;
@@ -145,7 +152,7 @@ impl TransferManager {
}
pub fn fail_task(&self, id: &str, err_msg: impl ToString) {
if let Some(task) = self.tasks.lock().unwrap().get_mut(id) {
if let Some(task) = self.store.lock().unwrap().tasks.get_mut(id) {
task.state = "failed".to_string();
task.error = Some(err_msg.to_string());
task.ended_at = Some(Utc::now().to_rfc3339());
@@ -153,19 +160,26 @@ impl TransferManager {
}
pub fn list_tasks(&self, host_id: Option<&str>) -> Vec<TransferTask> {
let ord = self.order.lock().unwrap().clone();
let map = self.tasks.lock().unwrap();
ord.into_iter()
.filter_map(|id| map.get(&id).cloned())
let store = self.store.lock().unwrap();
store
.order
.iter()
.filter_map(|id| store.tasks.get(id))
.filter(|t| host_id.map(|h| t.host_id == h).unwrap_or(true))
.cloned()
.collect()
}
pub fn clear_finished(&self) {
let mut map = self.tasks.lock().unwrap();
let mut ord = self.order.lock().unwrap();
map.retain(|_, t| t.state == "running");
ord.retain(|id| map.contains_key(id));
let mut store = self.store.lock().unwrap();
let keep: std::collections::HashSet<String> = store
.tasks
.iter()
.filter(|(_, t)| t.state == "running")
.map(|(id, _)| id.clone())
.collect();
store.tasks.retain(|id, _| keep.contains(id));
store.order.retain(|id| keep.contains(id));
}
}
+59 -3
View File
@@ -19,7 +19,9 @@ use anyhow::Result;
use api::{build_api_router, AppState};
use axum::{
body::Body,
http::{header, StatusCode, Uri},
extract::{Request, State},
http::{header, HeaderMap, HeaderValue, StatusCode, Uri},
middleware::{self, Next},
response::{IntoResponse, Response},
};
use crypto::MasterKey;
@@ -57,6 +59,38 @@ fn resolve_data_dir() -> PathBuf {
cwd_data
}
/// 静态资源相对路径白名单校验:拒绝 `..`、空段、反斜杠与盘符,杜绝跳出 frontend/dist 读本机任意文件
fn is_safe_asset_path(rel: &str) -> bool {
!rel.is_empty()
&& !rel.contains('\\')
&& !rel.contains(':')
&& rel
.split('/')
.all(|seg| !seg.is_empty() && seg != "." && seg != "..")
}
/// 只接受本机回环 Host。桌面 WebView、Vite 开发代理、stdio 桥接全部走 127.0.0.1;
/// 借此挡掉 DNS rebinding(恶意网页把自己的域名解析到 127.0.0.1 后即被浏览器视为"同源")。
fn host_header_is_loopback(headers: &HeaderMap) -> bool {
let Some(raw) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) else {
return false;
};
let host = raw.trim();
let name = if let Some(rest) = host.strip_prefix('[') {
rest.split(']').next().unwrap_or("")
} else {
host.split(':').next().unwrap_or("")
};
matches!(name, "127.0.0.1" | "localhost" | "::1")
}
async fn require_loopback_host(State(enforce): State<bool>, req: Request, next: Next) -> Response {
if enforce && !host_header_is_loopback(req.headers()) {
return (StatusCode::FORBIDDEN, "仅允许通过本机回环地址访问").into_response();
}
next.run(req).await
}
/// 静态前端资源服务:优先读本机 frontend/dist(方便开发热更新),不存在时直接走二进制内嵌的 EmbeddedFrontend(单文件打包免外部依赖)
async fn serve_frontend_asset(uri: Uri) -> Response {
let raw_path = uri.path().trim_start_matches('/');
@@ -66,6 +100,10 @@ async fn serve_frontend_asset(uri: Uri) -> Response {
raw_path
};
if !is_safe_asset_path(rel_path) {
return (StatusCode::NOT_FOUND, "Not Found").into_response();
}
let disk_candidate = PathBuf::from("frontend/dist").join(rel_path);
if let Ok(bytes) = tokio::fs::read(&disk_candidate).await {
let mime = mime_guess::from_path(rel_path).first_or_octet_stream();
@@ -203,14 +241,32 @@ fn main() -> Result<()> {
.unwrap_or_else(|| std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)));
let addr = SocketAddr::new(bind_ip, port);
// 跨源只放行本机回环来源(桌面 WebView 与 Vite 开发端口),不再对任意网站开放读取响应
let mut allowed_origins: Vec<HeaderValue> = Vec::new();
for origin in [
format!("http://127.0.0.1:{port}"),
format!("http://localhost:{port}"),
"http://127.0.0.1:5173".to_string(),
"http://localhost:5173".to_string(),
] {
if let Ok(value) = HeaderValue::from_str(&origin) {
allowed_origins.push(value);
}
}
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_origin(allowed_origins)
.allow_methods(Any)
.allow_headers(Any);
// 绑回环时才强制校验 Host;显式设 HOST=0.0.0.0 对外提供服务时交给登录鉴权把关
let enforce_loopback_host = bind_ip.is_loopback();
let app = build_api_router(state.clone())
.fallback(serve_frontend_asset)
.layer(cors);
.layer(cors)
.layer(middleware::from_fn_with_state(
enforce_loopback_host,
require_loopback_host,
));
#[cfg(windows)]
if !is_headless {
+144 -43
View File
@@ -130,11 +130,52 @@ pub struct ExecTaskManager {
tasks: Mutex<HashMap<String, Arc<Mutex<ExecTaskInner>>>>,
}
/// 单条任务 stdout / stderr 各自的上限,超出即截断并显式标注(避免常驻进程被大输出撑爆内存)
const MAX_OUTPUT_BYTES: usize = 1024 * 1024;
/// 任务表保留上限,超出后按开始时间淘汰最旧的已完成任务
const MAX_TASKS: usize = 60;
fn append_capped(buf: &mut Vec<u8>, chunk: &[u8]) {
if buf.len() >= MAX_OUTPUT_BYTES {
return;
}
let room = MAX_OUTPUT_BYTES - buf.len();
if chunk.len() <= room {
buf.extend_from_slice(chunk);
} else {
buf.extend_from_slice(&chunk[..room]);
buf.extend_from_slice("\n...[输出超过 1 MiB,已截断]".as_bytes());
}
}
impl ExecTaskManager {
pub fn new() -> Self {
Self::default()
}
/// 淘汰最旧的已完成任务,保证长驻进程的任务表不会无界增长
fn evict_old_finished(&self, map: &mut HashMap<String, Arc<Mutex<ExecTaskInner>>>) {
if map.len() <= MAX_TASKS {
return;
}
let mut finished: Vec<(Instant, String)> = map
.iter()
.filter_map(|(id, task)| {
let guard = task.lock().unwrap();
if guard.state == "running" {
None
} else {
Some((guard.started_inst, id.clone()))
}
})
.collect();
finished.sort();
let remove_n = map.len().saturating_sub(MAX_TASKS);
for (_, id) in finished.into_iter().take(remove_n) {
map.remove(&id);
}
}
pub fn get_task(&self, task_id: &str) -> Option<ExecTaskSnapshot> {
let map = self.tasks.lock().unwrap();
map.get(task_id).map(|t| t.lock().unwrap().snapshot())
@@ -185,10 +226,11 @@ impl ExecTaskManager {
cancel_tx: Some(cancel_tx),
}));
self.tasks
.lock()
.unwrap()
.insert(task_id.clone(), inner.clone());
{
let mut map = self.tasks.lock().unwrap();
map.insert(task_id.clone(), inner.clone());
self.evict_old_finished(&mut map);
}
let st = state.clone();
let host_clone = host.clone();
@@ -260,8 +302,17 @@ async fn run_local_exec_task(
cancel_rx: &mut oneshot::Receiver<()>,
) {
use tokio::io::AsyncReadExt;
let encoded = windows_ps_encoded_payload(cmd);
let mut ps_cmd = tokio::process::Command::new("powershell.exe");
ps_cmd.args(["-NoProfile", "-NonInteractive", "-Command", cmd])
ps_cmd
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encoded.as_str(),
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(windows)]
@@ -300,7 +351,7 @@ async fn run_local_exec_task(
Ok(0) | Err(_) => out_eof = true,
Ok(n) => {
let mut g = inner.lock().unwrap();
g.stdout.extend_from_slice(&out_buf[..n]);
append_capped(&mut g.stdout, &out_buf[..n]);
g.last_output_inst = Instant::now();
}
}
@@ -310,7 +361,7 @@ async fn run_local_exec_task(
Ok(0) | Err(_) => err_eof = true,
Ok(n) => {
let mut g = inner.lock().unwrap();
g.stderr.extend_from_slice(&err_buf[..n]);
append_capped(&mut g.stderr, &err_buf[..n]);
g.last_output_inst = Instant::now();
}
}
@@ -329,12 +380,10 @@ async fn run_local_exec_task(
g.ended_ms = Some(g.started_inst.elapsed().as_millis() as u64);
}
fn wrap_windows_remote_command(cmd: &str) -> String {
let trimmed = cmd.trim();
if trimmed.starts_with("powershell") || trimmed.starts_with("cmd.exe") {
return cmd.to_string();
}
/// 把任意 PowerShell 脚本编码为 `-EncodedCommand` 所需的 UTF-16LE base64。
/// 本机与远端两条执行路径共用:统一注入 UTF-8 输出编码(否则中文按 GBK 落管道变乱码)、
/// 抑制进度条噪音,并把真实退出码原样带出。
fn windows_ps_encoded_payload(command: &str) -> String {
let payload = format!(
"$ProgressPreference = 'SilentlyContinue'\n\
try {{ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 }} catch {{}}\n\
@@ -344,7 +393,7 @@ fn wrap_windows_remote_command(cmd: &str) -> String {
if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) {{ exit $LASTEXITCODE }}\n\
if (-not $__vpsOk) {{ exit 1 }}\n\
exit 0",
trimmed
command.trim()
);
let utf16_bytes: Vec<u8> = payload
@@ -352,9 +401,19 @@ fn wrap_windows_remote_command(cmd: &str) -> String {
.flat_map(|u| u.to_le_bytes())
.collect();
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&utf16_bytes);
base64::engine::general_purpose::STANDARD.encode(&utf16_bytes)
}
format!("powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {b64}")
fn wrap_windows_remote_command(cmd: &str) -> String {
let trimmed = cmd.trim();
if trimmed.starts_with("powershell") || trimmed.starts_with("cmd.exe") {
return cmd.to_string();
}
format!(
"powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {}",
windows_ps_encoded_payload(cmd)
)
}
fn clean_clixml_stderr(raw: &[u8]) -> Vec<u8> {
@@ -435,6 +494,7 @@ async fn run_ssh_exec_task(
}
let mut exit_code: Option<i32> = None;
let mut connection_lost = false;
loop {
tokio::select! {
_ = &mut *cancel_rx => {
@@ -445,7 +505,7 @@ async fn run_ssh_exec_task(
match msg_opt {
Some(ChannelMsg::Data { data }) => {
let mut g = inner.lock().unwrap();
g.stdout.extend_from_slice(&data);
append_capped(&mut g.stdout, &data);
g.last_output_inst = Instant::now();
}
Some(ChannelMsg::ExtendedData { data, .. }) => {
@@ -455,13 +515,18 @@ async fn run_ssh_exec_task(
} else {
data.to_vec()
};
g.stderr.extend_from_slice(&cleaned);
append_capped(&mut g.stderr, &cleaned);
g.last_output_inst = Instant::now();
}
Some(ChannelMsg::ExitStatus { exit_status }) => {
exit_code = Some(exit_status as i32);
}
Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break,
Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) => break,
// russh 在收到对端 CHANNEL_CLOSE 时会先投递显式的 Close/Eof,只有会话整体消失才会得到 None
None => {
connection_lost = true;
break;
}
_ => {}
}
}
@@ -470,14 +535,37 @@ async fn run_ssh_exec_task(
let mut g = inner.lock().unwrap();
if g.state == "running" {
let code = exit_code.unwrap_or(0);
g.exit_code = Some(code);
g.state = if code == 0 {
"completed".to_string()
} else {
"failed".to_string()
};
g.ended_ms = Some(g.started_inst.elapsed().as_millis() as u64);
match exit_code {
Some(code) => {
g.exit_code = Some(code);
g.state = if code == 0 {
"completed".to_string()
} else {
"failed".to_string()
};
}
None => {
// 没拿到远端退出码就不能声称成功:连接中断与"服务端未上报退出码"都如实报失败
g.exit_code = None;
g.state = "failed".to_string();
if connection_lost {
append_capped(
&mut g.stderr,
"\n[SSH 连接在命令结束前中断,输出可能不完整,退出码未知]".as_bytes(),
);
g.hint = Some(
"SSH 连接中断,命令结果不完整。请重试,或改用 vps_task 查询后续输出。"
.to_string(),
);
} else {
g.hint = Some(
"远端未上报退出码,无法确认命令是否成功(已按失败处理,stdout/stderr 仍原样返回)。"
.to_string(),
);
}
}
}
}
}
@@ -1264,19 +1352,26 @@ async fn tool_vps_transfer(state: &Arc<AppState>, args: Value) -> Result<Value>
let src_host = resolve_host(&state.db, src_q)?;
let dst_host = resolve_host(&state.db, dst_q)?;
let st = state.clone();
let src_id = src_host.id.clone();
let dst_id = dst_host.id.clone();
let fut = files::transfer_remote_to_remote(
&st.ssh_pool,
&st.db,
&st.transfers,
&src_host.id,
&src_path,
&dst_host.id,
&dst_path,
);
match tokio::time::timeout(Duration::from_secs(wait_seconds), fut).await {
Ok(res) => {
let task = res?;
// 必须 spawn:若把 future 直接交给 timeout,超时会把它 drop 掉(传输被真正取消),
// 却仍对外声称"仍在后台传输",属于谎报。spawn 后超时只是不再等待,传输继续跑。
let handle = tokio::spawn(async move {
files::transfer_remote_to_remote(
&st.ssh_pool,
&st.db,
&st.transfers,
&src_id,
&src_path,
&dst_id,
&dst_path,
)
.await
});
match tokio::time::timeout(Duration::from_secs(wait_seconds), handle).await {
Ok(join_res) => {
let task = join_res??;
return Ok(json!({ "ok": true, "transfer": task }));
}
Err(_) => {
@@ -1384,7 +1479,7 @@ async fn tool_vps_task(
}
}
/// `--mcp-stdio` 本地模式:若 127.0.0.1:3302 主进程正在运行则直接桥接过去(与桌面 UI 共享连接池、探针缓存和终端唤起事件),
/// `--mcp-stdio` 本地模式:若常驻主进程正在监听则直接桥接过去(与桌面 UI 共享连接池、探针缓存和终端唤起事件),
/// 若主进程未运行则在当前进程内直接处理。
pub async fn run_stdio_bridge(state: Arc<AppState>, exec_tasks: Arc<ExecTaskManager>, port: u16) -> Result<()> {
let stdin = tokio::io::stdin();
@@ -1399,8 +1494,14 @@ pub async fn run_stdio_bridge(state: Arc<AppState>, exec_tasks: Arc<ExecTaskMana
continue;
};
// 优先尝试转发给已运行的常驻 UI 主进程(以便共享 SshPool 与 UI 终端弹窗联动)
if let Some(resp_str) = try_forward_to_daemon(port, trimmed).await {
// 优先尝试转发给已运行的常驻 UI 主进程(以便共享 SshPool 与 UI 终端弹窗联动);
// 带上与主进程共库的真实 API Key,不再依赖任何可伪造的"我是内部桥接"请求头
let api_key = state
.db
.get_app_settings()
.map(|s| s.mcp_api_key)
.unwrap_or_default();
if let Some(resp_str) = try_forward_to_daemon(port, &api_key, trimmed).await {
if !resp_str.trim().is_empty() {
println!("{}", resp_str.trim());
}
@@ -1414,7 +1515,7 @@ pub async fn run_stdio_bridge(state: Arc<AppState>, exec_tasks: Arc<ExecTaskMana
Ok(())
}
async fn try_forward_to_daemon(port: u16, raw_json: &str) -> Option<String> {
async fn try_forward_to_daemon(port: u16, api_key: &str, raw_json: &str) -> Option<String> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let addr = format!("127.0.0.1:{port}");
let mut stream = tokio::time::timeout(
@@ -1426,7 +1527,7 @@ async fn try_forward_to_daemon(port: u16, raw_json: &str) -> Option<String> {
.ok()?;
let req_bytes = format!(
"POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nx-vps-switch-stdio: 1\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
"POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nAuthorization: Bearer {api_key}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
raw_json.len(),
raw_json
);
+2
View File
@@ -679,6 +679,8 @@ impl TerminalManager {
let meta = handle.snapshot_meta();
{
let mut guard = self.panes.lock().unwrap();
// 顺手回收该主机下已退出的旧 pane:否则反复打开终端会一直堆积 ring buffer 与终端网格
guard.retain(|_, p| !(p.host_id == host_id && p.state.lock().unwrap().exited));
guard.insert(pane_id, handle);
}
Ok(meta)