fix: MCP bridge fallback, loopback-exempt API key, terminal reconnect, and probe status
- stdio bridge now falls back to in-process handling unless the forward returns a real JSON-RPC response. A 401 from an API-key / data-dir mismatch used to be printed verbatim as the tool result, so clients never completed the handshake - MCP API key is enforced only when bound to a non-loopback address; on 127.0.0.1 it is exempt (local-first, like 1Shell). The remote toggle still governs HOST=0.0.0.0 - vps_terminal "key" action now passes through the command denylist; its fallback branch could previously carry arbitrary text and bypass the guard - vps_exec interactive-prompt early-exit now matches only explicit keywords, so ordinary commands whose last line ends in ':' / '?' are no longer aborted mid-run - terminal: add a "重新连接" button that recreates a pane in place after the session ends - file browser: downloads go through the resumable + SHA-256 transfer engine (default local path = Downloads, runs in background, non-blocking) - standalone terminal window: hide the dead 文件管理 / 详情图表 buttons - probe status: an SSH connection means online (probe failure no longer flips to red); a connect timeout maps to unknown instead of offline; latency now measures the connect only, not the probe - transfer: a resumed download fails instead of silently accepting when the remote hash is unavailable; secret.key is no longer overwritten when corrupt; a per-host lock serializes pane creation
This commit is contained in:
1 parent
fbf2ac2231
commit
3176ffdc4c
13 files changed
+328
-94
No files matched your search
@@ -763,6 +763,8 @@ async function checkSingleHost(host: PublicHost) {
|
||||
}
|
||||
if (data.host.status === 'online') {
|
||||
showToast(`${data.host.name} 在线 (${data.host.latencyMs ?? 0} ms)`);
|
||||
} else if (data.host.status === 'unknown') {
|
||||
showToast(`${data.host.name} 连接超时,未能判定(可能只是线路慢)`, 'err');
|
||||
} else {
|
||||
showToast(`${data.host.name} 离线`, 'err');
|
||||
}
|
||||
@@ -1003,6 +1005,39 @@ async function handleClosePane(paneId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 终端会话结束后原地重连:为该主机新建一个 pane 顶替退出那个的位置。
|
||||
// 先建后删——后端 create_pane 会顺手回收同主机已退出的旧 pane,且该主机没有活终端时
|
||||
// 会释放旧的终端连接、重新登录(横幅照常出现)。
|
||||
async function handleReconnectPane(paneId: string) {
|
||||
const tab = openTabs.value.find((t) => t.panes.some((p) => p.id === paneId));
|
||||
if (!tab || paneCreationInFlight.has(tab.hostId)) return;
|
||||
const idx = tab.panes.findIndex((p) => p.id === paneId);
|
||||
const cwd = tab.panes[idx]?.cwd || undefined;
|
||||
paneCreationInFlight.add(tab.hostId);
|
||||
paneCreationCount.value += 1;
|
||||
try {
|
||||
const res = await fetch('/api/terminals', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hostId: tab.hostId, cols: 100, rows: 30, cwd: cwd || undefined }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok || !data.pane) {
|
||||
showToast(data.error || '重连失败', 'err');
|
||||
return;
|
||||
}
|
||||
void fetch(`/api/terminals/${encodeURIComponent(paneId)}`, { method: 'DELETE' });
|
||||
const at = tab.panes.findIndex((p) => p.id === paneId);
|
||||
tab.panes.splice(at >= 0 ? at : idx, 1, data.pane as PaneMeta);
|
||||
showToast('终端已重新连接');
|
||||
} catch (e) {
|
||||
showToast(`重连失败: ${(e as Error).message}`, 'err');
|
||||
} finally {
|
||||
paneCreationInFlight.delete(tab.hostId);
|
||||
paneCreationCount.value = Math.max(0, paneCreationCount.value - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaneMetaUpdate(updated: PaneMeta) {
|
||||
for (const tab of openTabs.value) {
|
||||
const idx = tab.panes.findIndex((p) => p.id === updated.id);
|
||||
@@ -1292,6 +1327,7 @@ onBeforeUnmount(() => {
|
||||
:standalone="true"
|
||||
@split="handleSplitTerminal"
|
||||
@close-pane="handleClosePane"
|
||||
@reconnect-pane="handleReconnectPane"
|
||||
@toggle-dark="toggleTheme"
|
||||
@meta-update="handlePaneMetaUpdate"
|
||||
/>
|
||||
@@ -2490,6 +2526,7 @@ onBeforeUnmount(() => {
|
||||
:standalone="false"
|
||||
@split="handleSplitTerminal"
|
||||
@close-pane="handleClosePane"
|
||||
@reconnect-pane="handleReconnectPane"
|
||||
@toggle-dark="terminalDark = !terminalDark"
|
||||
@toggle-right-wing="toggleRightWing"
|
||||
@collapse-stage="switchToHomeView"
|
||||
|
||||
@@ -109,9 +109,9 @@ const editorContent = ref('');
|
||||
const editorLoading = ref(false);
|
||||
const editorSaving = ref(false);
|
||||
|
||||
// 新建/重命名/删除确认条
|
||||
// 新建/重命名/删除确认条 / 下载落点
|
||||
const inlinePrompt = ref<{
|
||||
kind: 'mkdir' | 'touch' | 'rename' | 'delete';
|
||||
kind: 'mkdir' | 'touch' | 'rename' | 'delete' | 'download';
|
||||
title: string;
|
||||
value: string;
|
||||
target?: FileEntry;
|
||||
@@ -510,14 +510,8 @@ async function saveFileEditor() {
|
||||
|
||||
function triggerDownload(item: FileEntry) {
|
||||
if (item.isDir) return;
|
||||
ensureTransferPolling();
|
||||
const url = `/api/files/download?hostId=${encodeURIComponent(props.host.id)}&path=${encodeURIComponent(item.path)}`;
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = item.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// 改走带断点续传的传输引擎(见 openInlineAction('download')),不再用浏览器直下
|
||||
openInlineAction('download', item);
|
||||
}
|
||||
|
||||
function copyEntryPath(path: string) {
|
||||
@@ -578,7 +572,7 @@ function onDropFiles(e: DragEvent) {
|
||||
}
|
||||
|
||||
function openInlineAction(
|
||||
kind: 'mkdir' | 'touch' | 'rename' | 'delete',
|
||||
kind: 'mkdir' | 'touch' | 'rename' | 'delete' | 'download',
|
||||
target?: FileEntry
|
||||
) {
|
||||
if (kind === 'mkdir') {
|
||||
@@ -594,6 +588,15 @@ function openInlineAction(
|
||||
value: target.name,
|
||||
target,
|
||||
};
|
||||
} else if (kind === 'download' && target) {
|
||||
// 走带断点续传 + SHA-256 校验的传输引擎(不再是浏览器直下、断了只能从头)。
|
||||
// 留空则落到系统下载目录。
|
||||
inlinePrompt.value = {
|
||||
kind,
|
||||
title: `下载「${target.name}」到本机路径(留空 = 系统下载目录)`,
|
||||
value: '',
|
||||
target,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,6 +673,26 @@ async function submitInlineAction() {
|
||||
editorOpen.value = false;
|
||||
}
|
||||
emit('toast', `已删除 ${act.target.name}`);
|
||||
} else if (act.kind === 'download' && act.target) {
|
||||
const res = await fetch('/api/transfers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
hostId: props.host.id,
|
||||
direction: 'download',
|
||||
localPath: val,
|
||||
remotePath: act.target.path,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(data.error || '发起下载失败');
|
||||
inlinePrompt.value = null;
|
||||
ensureTransferPolling();
|
||||
emit(
|
||||
'toast',
|
||||
`开始下载到 ${data.localPath || '系统下载目录'}(队列中看进度;中断后重下会自动续传)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
inlinePrompt.value = null;
|
||||
invalidateCache(props.host.id, currentPath.value);
|
||||
|
||||
@@ -18,6 +18,7 @@ const emit = defineEmits<{
|
||||
focus: [];
|
||||
closePane: [paneId: string];
|
||||
metaUpdate: [meta: PaneMeta];
|
||||
reconnect: [paneId: string];
|
||||
}>();
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
@@ -331,20 +332,31 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 会话已退出提示条 -->
|
||||
<!-- 会话已结束提示条:远端断开或进程退出都走这里。SSH 掉线无法在字节层恢复,
|
||||
只能重建一个该主机的新会话(后端会在没有活终端时释放旧连接、重新登录)。 -->
|
||||
<div
|
||||
v-if="exited"
|
||||
class="flex items-center justify-between border-t border-zinc-200/80 bg-zinc-50 px-3 py-1.5 text-xs text-zinc-500"
|
||||
class="flex items-center justify-between gap-2 border-t border-zinc-200/80 bg-zinc-50 px-3 py-1.5 text-xs text-zinc-500 dark:border-zinc-800 dark:bg-zinc-900/60 dark:text-zinc-400"
|
||||
>
|
||||
<span>终端进程已退出</span>
|
||||
<button
|
||||
v-if="showClose"
|
||||
type="button"
|
||||
class="rounded-md bg-zinc-900 px-2 py-0.5 text-white"
|
||||
@click="emit('closePane', paneId)"
|
||||
>
|
||||
移除面板
|
||||
</button>
|
||||
<span>终端会话已结束(远端断开或进程退出)</span>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-zinc-900 px-2 py-0.5 text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white"
|
||||
title="为该主机重新建立一个终端会话"
|
||||
@click="emit('reconnect', paneId)"
|
||||
>
|
||||
重新连接
|
||||
</button>
|
||||
<button
|
||||
v-if="showClose"
|
||||
type="button"
|
||||
class="rounded-md border border-zinc-300 bg-white px-2 py-0.5 text-zinc-600 hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
|
||||
@click="emit('closePane', paneId)"
|
||||
>
|
||||
移除面板
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,6 +16,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
split: [direction: 'row' | 'col'];
|
||||
closePane: [paneId: string];
|
||||
reconnectPane: [paneId: string];
|
||||
toggleDark: [];
|
||||
toggleRightWing: [mode: 'files' | 'detail'];
|
||||
collapseStage: [];
|
||||
@@ -123,6 +124,7 @@ const activePane = computed(() => {
|
||||
|
||||
<!-- 在最右栏展开与左栏等宽(336px)的「文件管理」 -->
|
||||
<button
|
||||
v-if="!standalone"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1 text-xs font-medium transition"
|
||||
:class="
|
||||
@@ -141,6 +143,7 @@ const activePane = computed(() => {
|
||||
|
||||
<!-- 在最右栏展开与左栏等宽(336px)的「详情图表」 -->
|
||||
<button
|
||||
v-if="!standalone"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1 text-xs font-medium transition"
|
||||
:class="
|
||||
@@ -201,6 +204,7 @@ const activePane = computed(() => {
|
||||
:show-close="panes.length > 1"
|
||||
@focus="focusedPaneId = pane.id"
|
||||
@close-pane="emit('closePane', $event)"
|
||||
@reconnect="emit('reconnectPane', $event)"
|
||||
@meta-update="emit('metaUpdate', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+71
-23
@@ -52,6 +52,9 @@ pub struct AppState {
|
||||
pub exec_tasks: Arc<ExecTaskManager>,
|
||||
pub sessions: Mutex<HashSet<String>>,
|
||||
pub port: u16,
|
||||
/// 服务是否绑在本机回环。绑回环时 MCP 端点免 API Key(本机优先,对齐 1Shell);
|
||||
/// 只有真正对外部署(HOST=0.0.0.0)时才强制校验 Key。
|
||||
pub bind_is_loopback: bool,
|
||||
}
|
||||
|
||||
pub fn build_api_router(state: Arc<AppState>) -> Router {
|
||||
@@ -986,31 +989,75 @@ struct LocalRemoteTransferPayload {
|
||||
remote_path: String,
|
||||
}
|
||||
|
||||
/// UI 未指定本机落点时的默认路径:系统下载目录(Windows: %USERPROFILE%\Downloads),
|
||||
/// 取不到就退回当前目录。文件名沿用远端文件名。
|
||||
fn default_download_path(remote_path: &str) -> String {
|
||||
let filename = remote_path
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("download.bin");
|
||||
let base = std::env::var("USERPROFILE")
|
||||
.or_else(|_| std::env::var("HOME"))
|
||||
.ok()
|
||||
.map(std::path::PathBuf::from)
|
||||
.map(|home| home.join("Downloads"))
|
||||
.filter(|p| p.is_dir())
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
base.join(filename).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
async fn api_create_local_remote_transfer(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(p): Json<LocalRemoteTransferPayload>,
|
||||
) -> impl IntoResponse {
|
||||
match files::transfer_local_remote_file(
|
||||
&state.ssh_pool,
|
||||
&state.db,
|
||||
&state.transfers,
|
||||
&p.host_id,
|
||||
&p.direction,
|
||||
&p.local_path,
|
||||
&p.remote_path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(task) => (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"task": task,
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => err_response(StatusCode::BAD_REQUEST, format!("{e:#}")).into_response(),
|
||||
if p.direction != "upload" && p.direction != "download" {
|
||||
return err_response(StatusCode::BAD_REQUEST, "direction 只能是 upload 或 download")
|
||||
.into_response();
|
||||
}
|
||||
if state.db.get_record(&p.host_id).ok().flatten().is_none() {
|
||||
return err_response(StatusCode::BAD_REQUEST, "主机不存在").into_response();
|
||||
}
|
||||
let local_path = if p.local_path.trim().is_empty() {
|
||||
if p.direction == "download" {
|
||||
default_download_path(&p.remote_path)
|
||||
} else {
|
||||
return err_response(StatusCode::BAD_REQUEST, "上传需要指定本机文件路径")
|
||||
.into_response();
|
||||
}
|
||||
} else {
|
||||
p.local_path.trim().to_string()
|
||||
};
|
||||
|
||||
// 后台跑:大文件不能把这条 HTTP 请求挂住。进度、速率、续传与校验状态都走
|
||||
// /api/transfers 队列查询(前端会轮询)。这里立刻返回本机落点供界面提示。
|
||||
let st = state.clone();
|
||||
let host_id = p.host_id.clone();
|
||||
let direction = p.direction.clone();
|
||||
let remote_path = p.remote_path.clone();
|
||||
let local_for_task = local_path.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = files::transfer_local_remote_file(
|
||||
&st.ssh_pool,
|
||||
&st.db,
|
||||
&st.transfers,
|
||||
&host_id,
|
||||
&direction,
|
||||
&local_for_task,
|
||||
&remote_path,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"started": true,
|
||||
"localPath": local_path,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn api_clear_transfers(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
@@ -1502,9 +1549,10 @@ async fn api_mcp_rpc(
|
||||
return err_response(StatusCode::FORBIDDEN, "MCP Server 当前已在设置中关闭").into_response();
|
||||
}
|
||||
|
||||
// API Key 一律校验。stdio 桥接同样走真实 Key(见 mcp.rs try_forward_to_daemon),
|
||||
// 不再存在任何客户端可伪造的豁免请求头。
|
||||
if settings.mcp_require_api_key {
|
||||
// API Key 只在**非回环绑定**(真的对外部署)时才校验。绑在本机回环时,端点只有本机
|
||||
// 进程够得着,和 1Shell 一样免校验——既符合"本机优先"的定位,也消除了"桥接进程与
|
||||
// 守护进程解析到不同数据目录、key 对不上 → 401 → 永远握不上手"这类纯本机自伤。
|
||||
if settings.mcp_require_api_key && !state.bind_is_loopback {
|
||||
let provided = extract_mcp_api_key(&headers);
|
||||
if provided.as_deref() != Some(settings.mcp_api_key.as_str()) {
|
||||
return err_response(
|
||||
|
||||
+16
-4
@@ -37,14 +37,26 @@ impl MasterKey {
|
||||
let key_path = data_dir.join("secret.key");
|
||||
if key_path.exists() {
|
||||
let raw = fs::read_to_string(&key_path).context("读取 secret.key 失败")?;
|
||||
let decoded = B64
|
||||
.decode(raw.trim())
|
||||
.context("解码 secret.key base64 失败")?;
|
||||
if decoded.len() == 32 {
|
||||
let trimmed = raw.trim();
|
||||
if !trimmed.is_empty() {
|
||||
let decoded = B64
|
||||
.decode(trimmed)
|
||||
.context("解码 secret.key base64 失败")?;
|
||||
if decoded.len() != 32 {
|
||||
// 关键:绝不覆盖。旧实现遇到非法内容会直接生成新密钥盖掉原文件,
|
||||
// 那会让库里所有已加密的密码/私钥**永久**解不开,且毫无提示。
|
||||
// 宁可拒绝启动,让人工处理(真要从头开始就手动删掉这个文件)。
|
||||
return Err(anyhow!(
|
||||
"secret.key 存在但不是合法的 32 字节密钥(解出 {} 字节),已拒绝覆盖,\
|
||||
以免破坏库中已加密的凭据。请人工恢复该文件;确需重新开始请手动删除它。",
|
||||
decoded.len()
|
||||
));
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&decoded);
|
||||
return Ok(Self { key });
|
||||
}
|
||||
// 空文件(例如上一次写入被中途打断):当作尚未创建,走下面重新生成
|
||||
}
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
|
||||
@@ -544,10 +544,14 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 记录一次检测结果。`status` 取 "online" | "offline" | "unknown":
|
||||
/// - online:SSH 已连上(探针是否采到数据不影响这个结论);
|
||||
/// - offline:确定连不上(连接被拒绝 / 认证失败);
|
||||
/// - unknown:无法判定(连接超时这类——可能只是线路慢,不直接判死)。
|
||||
pub fn update_check_result(
|
||||
&self,
|
||||
id: &str,
|
||||
online: bool,
|
||||
status: &str,
|
||||
latency_ms: Option<i64>,
|
||||
os_info: Option<OsInfo>,
|
||||
error: Option<String>,
|
||||
@@ -556,9 +560,13 @@ impl Database {
|
||||
.get_record(id)?
|
||||
.ok_or_else(|| anyhow!("主机不存在: {id}"))?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let status = if online { "online" } else { "offline" };
|
||||
let status = match status {
|
||||
"online" => "online",
|
||||
"offline" => "offline",
|
||||
_ => "unknown",
|
||||
};
|
||||
let merged_os = os_info.or(existing.os_info);
|
||||
let last_connected = if online {
|
||||
let last_connected = if status == "online" {
|
||||
Some(now.clone())
|
||||
} else {
|
||||
existing.last_connected_at
|
||||
|
||||
@@ -1350,6 +1350,18 @@ pub async fn transfer_local_remote_file(
|
||||
return Err(anyhow!(msg));
|
||||
}
|
||||
None => {
|
||||
// 远端算不出 SHA-256(没有 sha256sum/Get-FileHash)时,非续传的整文件下载
|
||||
// 仍可接受(只是无法"宣称已校验");但**续传**的下载绝不能静默放行——
|
||||
// 残片是不是这个源文件的前半段,唯一判据就是收尾这次对账,对不了账就
|
||||
// 可能把旧残片拼进成品。宁可报失败,也不产出一个悄悄坏掉的文件。
|
||||
if resume_from > 0 {
|
||||
let msg = format!(
|
||||
"本次从 {resume_from} 字节续传,但远端无法计算 SHA-256,无法确认残片与源文件一致;文件已保留,请人工核查后重传(或删除残片重下)"
|
||||
);
|
||||
transfers.fail_task(&task_id, &msg);
|
||||
transfers.set_verification(&task_id, Some(digest), None);
|
||||
return Err(anyhow!(msg));
|
||||
}
|
||||
transfers.set_verification(&task_id, Some(digest), None);
|
||||
}
|
||||
}
|
||||
|
||||
+33
-23
@@ -211,6 +211,13 @@ fn summarize_probe_output(raw: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接失败里区分"超时"与"被拒绝/认证失败"。
|
||||
/// 超时只说明这次没连上、可能只是线路慢(慢 VPS 的 SSH 握手常超过 8 秒),不能据此判离线;
|
||||
/// 被拒绝(Connection refused)或认证失败才是确定的"连不上"。
|
||||
fn connect_error_is_timeout(msg: &str) -> bool {
|
||||
msg.contains("超时") || msg.contains("deadline has elapsed") || msg.contains("timed out")
|
||||
}
|
||||
|
||||
impl InspectorEngine {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
@@ -243,23 +250,28 @@ impl InspectorEngine {
|
||||
None => None,
|
||||
};
|
||||
|
||||
let started = Instant::now();
|
||||
let connect_started = Instant::now();
|
||||
let session = match pool
|
||||
.get_or_connect(&record, jump.as_ref(), db.master_key())
|
||||
.await
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
let _ = db.update_check_result(
|
||||
host_id,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
Some(format!("{err:#}")),
|
||||
);
|
||||
// 连不上要分两种情况:**超时**(不确定,可能只是线路慢)与**拒绝/认证失败**
|
||||
// (确定连不上)。前者若也判离线,慢线路(zouter/lazy-* 这类)会长期误红,
|
||||
// 所以记成 unknown 交给界面用灰灯表示"这次没判定出来",绝不冒充"离线"。
|
||||
let msg = format!("{err:#}");
|
||||
let status = if connect_error_is_timeout(&msg) {
|
||||
"unknown"
|
||||
} else {
|
||||
"offline"
|
||||
};
|
||||
let _ = db.update_check_result(host_id, status, None, None, Some(msg));
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
// 「SSH 响应」只算连接(握手+认证)耗时,不含后面的排队与探针——否则探针慢会把延迟撑到几十秒。
|
||||
let latency_ms = connect_started.elapsed().as_millis() as i64;
|
||||
|
||||
let known_windows = record
|
||||
.os_info
|
||||
@@ -307,32 +319,30 @@ impl InspectorEngine {
|
||||
break;
|
||||
}
|
||||
|
||||
let latency_ms = started.elapsed().as_millis() as i64;
|
||||
let (inspection, os_info) = match parsed {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
let detail = failures.join(";");
|
||||
// 走到这里说明 SSH **已经连上**了(get_or_connect 成功)——这台机器是可连的。
|
||||
// 探针跑不出来只代表"这次没采到数据",不该把灯判成离线:慢/抖线路上 channel
|
||||
// 中途掉一次(例如回退的 Windows 探针返回 Disconnected)就会让一台能连的主机
|
||||
// 常年标红(zouter 已实测复现)。所以保持在线,把采集失败原因写进 last_error。
|
||||
if exec_errored {
|
||||
// 连接层面出过错,池里这条连接不再可信
|
||||
// exec 层面出过错,池里这条连接不再可信,回收它下次重建
|
||||
pool.invalidate_host(host_id).await;
|
||||
let _ =
|
||||
db.update_check_result(host_id, false, None, None, Some(detail.clone()));
|
||||
} else {
|
||||
// SSH 通了、命令也跑了,只是两套脚本都认不出来:如实记成在线 + 错误原因,
|
||||
// 绝不像旧版那样按 Linux 解析出一堆 0 再谎报采集成功。
|
||||
let _ = db.update_check_result(
|
||||
host_id,
|
||||
true,
|
||||
Some(latency_ms),
|
||||
None,
|
||||
Some(detail.clone()),
|
||||
);
|
||||
}
|
||||
let _ = db.update_check_result(
|
||||
host_id,
|
||||
"online",
|
||||
Some(latency_ms),
|
||||
None,
|
||||
Some(detail.clone()),
|
||||
);
|
||||
return Err(anyhow!("探针无法识别远端系统:{detail}"));
|
||||
}
|
||||
};
|
||||
|
||||
let _ = db.update_check_result(host_id, true, Some(latency_ms), Some(os_info), None);
|
||||
let _ = db.update_check_result(host_id, "online", Some(latency_ms), Some(os_info), None);
|
||||
self.latest_snapshots
|
||||
.lock()
|
||||
.unwrap()
|
||||
|
||||
@@ -194,6 +194,11 @@ fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
let exec_tasks = Arc::new(ExecTaskManager::new());
|
||||
// 绑定地址在构造 AppState 前先算出来:MCP 端点是否免 Key 取决于它(绑回环 = 本机 = 免校验)
|
||||
let bind_ip: std::net::IpAddr = std::env::var("HOST")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or_else(|| std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)));
|
||||
let state = Arc::new(AppState {
|
||||
db,
|
||||
guard: Arc::new(Guard::load_or_create(&data_dir)?),
|
||||
@@ -204,6 +209,7 @@ fn main() -> Result<()> {
|
||||
exec_tasks: exec_tasks.clone(),
|
||||
sessions: std::sync::Mutex::new(std::collections::HashSet::new()),
|
||||
port,
|
||||
bind_is_loopback: bind_ip.is_loopback(),
|
||||
});
|
||||
|
||||
if is_mcp_stdio {
|
||||
|
||||
+49
-15
@@ -5,7 +5,7 @@ use crate::{
|
||||
guard::Verdict,
|
||||
models::{HostRecord, LOCAL_HOST_ID},
|
||||
ssh::open_host_channel,
|
||||
terminal::looks_like_interactive_prompt,
|
||||
terminal::looks_like_explicit_interactive_prompt,
|
||||
};
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::Utc;
|
||||
@@ -257,7 +257,9 @@ impl ExecTaskManager {
|
||||
return Ok(g.snapshot());
|
||||
}
|
||||
|
||||
// 交互式提问早退保护:若命令输出末尾停在 [Y/n] / password: 等提问且静默超过 2 秒,立即中断提醒 Agent
|
||||
// 交互式提问早退保护:只在命中**明确关键词**([Y/n] / password: / proceed? …)时才判定。
|
||||
// 早先这里用宽松版("以 ? / : 结尾"也算),会把 `echo "Processing:"` 这类正常命令
|
||||
// 在静默 2 秒后直接掐断并误报成 interactive_stall(已实测复现)。
|
||||
if g.last_output_inst.elapsed() >= Duration::from_millis(2000)
|
||||
&& (!g.stdout.is_empty() || !g.stderr.is_empty())
|
||||
{
|
||||
@@ -267,7 +269,7 @@ impl ExecTaskManager {
|
||||
String::from_utf8_lossy(&g.stderr)
|
||||
);
|
||||
if let Some(last_line) = combined.lines().rev().find(|l| !l.trim().is_empty()) {
|
||||
if looks_like_interactive_prompt(last_line) {
|
||||
if looks_like_explicit_interactive_prompt(last_line) {
|
||||
if let Some(tx) = g.cancel_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
@@ -1235,11 +1237,17 @@ async fn tool_vps_terminal(state: &Arc<AppState>, args: Value) -> Result<Value>
|
||||
pane.send_and_wait(bytes, max_lines, wait_ms).await
|
||||
}
|
||||
"key" => {
|
||||
let key_name = args
|
||||
let raw_input = args
|
||||
.get("input")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("enter")
|
||||
.to_lowercase();
|
||||
.unwrap_or("enter");
|
||||
// 护栏:key 分支的兜底分支会把未知字符串原样当按键发出去,等于一条不受检查的文本
|
||||
// 通道(`key:"rm -rf /"` 再 `key:"enter"` 就能执行)。这里和 send 一样先过黑名单。
|
||||
// 真正的控制键(ctrl+c/enter/…)不会命中名单,照常发送。
|
||||
if let Verdict::Deny { why } = state.guard.check(raw_input) {
|
||||
return Err(anyhow!("{why}"));
|
||||
}
|
||||
let key_name = raw_input.to_lowercase();
|
||||
let bytes: Vec<u8> = match key_name.as_str() {
|
||||
"ctrl+c" | "c-c" | "sigint" => vec![0x03],
|
||||
"ctrl+d" | "c-d" | "eof" => vec![0x04],
|
||||
@@ -1520,13 +1528,24 @@ pub async fn run_stdio_bridge(state: Arc<AppState>, exec_tasks: Arc<ExecTaskMana
|
||||
continue;
|
||||
};
|
||||
|
||||
// 优先尝试转发给已运行的常驻 UI 主进程(以便共享 SshPool 与 UI 终端弹窗联动);
|
||||
// 带上与主进程共库的真实 API Key,不再依赖任何可伪造的"我是内部桥接"请求头
|
||||
let api_key = state
|
||||
.db
|
||||
.get_app_settings()
|
||||
.map(|s| s.mcp_api_key)
|
||||
.unwrap_or_default();
|
||||
let settings = state.db.get_app_settings().ok();
|
||||
// 尊重 MCP 总开关(否则下面的进程内回退会绕过它)
|
||||
if settings.as_ref().map(|s| !s.mcp_enabled).unwrap_or(false) {
|
||||
let err = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": req.id,
|
||||
"error": { "code": -32000, "message": "MCP Server 已在设置中关闭" }
|
||||
});
|
||||
println!("{}", serde_json::to_string(&err)?);
|
||||
continue;
|
||||
}
|
||||
let api_key = settings.map(|s| s.mcp_api_key).unwrap_or_default();
|
||||
|
||||
// 优先转发给已运行的常驻主进程(共享 SshPool 与 UI 终端联动)。只有转发**确实拿到了
|
||||
// 一个可用的 JSON-RPC 响应**才采用它;否则回退到进程内处理。
|
||||
// 此前是"拿到任何响应就原样打印"——当两个进程解析到不同的库、API Key 对不上时,
|
||||
// 服务端回 401,桥接把那段错误文本当结果吐给客户端,客户端解析失败 → 永远握不上手
|
||||
// (WorkBuddy 连不上的根因)。现在 401/403 之类会返回 None 触发回退。
|
||||
if let Some(resp_str) = try_forward_to_daemon(port, &api_key, trimmed).await {
|
||||
if !resp_str.trim().is_empty() {
|
||||
println!("{}", resp_str.trim());
|
||||
@@ -1541,6 +1560,9 @@ pub async fn run_stdio_bridge(state: Arc<AppState>, exec_tasks: Arc<ExecTaskMana
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把一条请求转发给常驻主进程。返回 `Some(body)` 仅当对方回了一个可用的 JSON-RPC 响应
|
||||
/// (HTTP 200 + 含 `jsonrpc` 字段),或 `Some("")` 表示通知类已被接收(202 无响应体)。
|
||||
/// 其余情况(连不上、401/403、响应体不是 JSON-RPC)一律返回 `None`,让调用方回退到进程内处理。
|
||||
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}");
|
||||
@@ -1561,8 +1583,20 @@ async fn try_forward_to_daemon(port: u16, api_key: &str, raw_json: &str) -> Opti
|
||||
let mut resp = Vec::new();
|
||||
stream.read_to_end(&mut resp).await.ok()?;
|
||||
let resp_str = String::from_utf8_lossy(&resp);
|
||||
let (_, body) = resp_str.split_once("\r\n\r\n")?;
|
||||
Some(body.trim().to_string())
|
||||
let (head, body) = resp_str.split_once("\r\n\r\n")?;
|
||||
let status_line = head.lines().next().unwrap_or("");
|
||||
if status_line.contains(" 202 ") {
|
||||
// 通知类请求:主进程已接收、本就不产生响应体
|
||||
return Some(String::new());
|
||||
}
|
||||
if !status_line.contains(" 200 ") {
|
||||
return None;
|
||||
}
|
||||
let body = body.trim();
|
||||
if !body.contains("\"jsonrpc\"") {
|
||||
return None;
|
||||
}
|
||||
Some(body.to_string())
|
||||
}
|
||||
|
||||
// ==================== 自动写入 Claude Code (~/.claude.json) 与 Codex (~/.codex/config.toml) ====================
|
||||
|
||||
+2
-2
@@ -562,7 +562,7 @@ pub async fn open_host_channel(
|
||||
let channel = pool
|
||||
.open_session_channel(&record, jump.as_ref(), db.master_key())
|
||||
.await?;
|
||||
let _ = db.update_check_result(&record.id, true, None, None, None);
|
||||
let _ = db.update_check_result(&record.id, "online", None, None, None);
|
||||
Ok((record, channel))
|
||||
}
|
||||
|
||||
@@ -595,6 +595,6 @@ pub async fn open_pane_channel(
|
||||
.context("打开 SSH 会话通道失败")?
|
||||
}
|
||||
};
|
||||
let _ = db.update_check_result(&record.id, true, None, None, None);
|
||||
let _ = db.update_check_result(&record.id, "online", None, None, None);
|
||||
Ok((record, channel))
|
||||
}
|
||||
+30
-2
@@ -414,7 +414,10 @@ fn strip_terminal_queries(data: &[u8]) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
pub fn looks_like_interactive_prompt(line: &str) -> bool {
|
||||
/// 严格版:只认明确的关键词(password: / [y/n] / proceed? …)。
|
||||
/// 用于非交互 exec 的早退判定——那里没有 stdin,误判会直接掐断一条正常命令,
|
||||
/// 所以宁可漏判,也不能凭"以 ? / : 结尾"这种文本特征去猜(`Processing:` 就会被误伤)。
|
||||
pub fn looks_like_explicit_interactive_prompt(line: &str) -> bool {
|
||||
let lower = line.trim().to_lowercase();
|
||||
if lower.is_empty() {
|
||||
return false;
|
||||
@@ -436,9 +439,20 @@ pub fn looks_like_interactive_prompt(line: &str) -> bool {
|
||||
"请确认",
|
||||
"请输入",
|
||||
];
|
||||
if patterns.iter().any(|p| lower.contains(p)) {
|
||||
patterns.iter().any(|p| lower.contains(p))
|
||||
}
|
||||
|
||||
/// 宽松版:明确关键词,或"以 ? / : 结尾且不像 shell 提示符"。
|
||||
/// 只用于交互式终端(vps_terminal)的屏幕状态判定——那里本来就在等用户输入,
|
||||
/// 多报一次 waiting_input 无伤大雅;非交互 exec 请用严格版。
|
||||
pub fn looks_like_interactive_prompt(line: &str) -> bool {
|
||||
if looks_like_explicit_interactive_prompt(line) {
|
||||
return true;
|
||||
}
|
||||
let lower = line.trim().to_lowercase();
|
||||
if lower.is_empty() {
|
||||
return false;
|
||||
}
|
||||
(lower.ends_with('?') || lower.ends_with(':')) && !looks_like_shell_prompt(line)
|
||||
}
|
||||
|
||||
@@ -590,6 +604,10 @@ impl PaneHandle {
|
||||
pub struct TerminalManager {
|
||||
panes: Mutex<HashMap<String, Arc<PaneHandle>>>,
|
||||
ui_events_tx: broadcast::Sender<UiEvent>,
|
||||
/// 每主机一把建 pane 的异步锁:串行化"检查有无活 pane → 释放旧连接 → 建新 pane"这段。
|
||||
/// 否则两个并发 create(例如 agent 同时调 vps_terminal)会让后者的 drop_pane_session
|
||||
/// 把前者刚建好的连接断掉,先建的那个 pane 立刻死。
|
||||
create_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||
}
|
||||
|
||||
impl Default for TerminalManager {
|
||||
@@ -604,6 +622,7 @@ impl TerminalManager {
|
||||
Self {
|
||||
panes: Mutex::new(HashMap::new()),
|
||||
ui_events_tx,
|
||||
create_locks: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,6 +719,15 @@ impl TerminalManager {
|
||||
let rows = rows.clamp(5, 200);
|
||||
let pane_id = format!("pane_{}", Uuid::new_v4());
|
||||
|
||||
// 串行化同一主机的并发建 pane(见 create_locks 注释)
|
||||
let host_lock = {
|
||||
let mut g = self.create_locks.lock().unwrap();
|
||||
g.entry(host_id.to_string())
|
||||
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
|
||||
.clone()
|
||||
};
|
||||
let _create_guard = host_lock.lock().await;
|
||||
|
||||
// 先回收该主机已退出的旧 pane。一个活的都不剩 = 上个终端已经关完了:
|
||||
// 释放终端连接,新 pane 会重新登录,登录横幅(MOTD)才会再次出现。
|
||||
let has_live = {
|
||||
|
||||
Reference in new issue
Block a user