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:
1 parent
6fdd134058
commit
14e7066db9
8 files changed
+302
-78
No files matched your search
+10
-4
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user