php8.1+laravel8+swoole(在laradock环境中 需配置php-worker和nginx)在web中对固定设备免密连接和自定义设备进行ssh+sftp连接的可用代码


nginx配置
location /ws/ {
proxy_pass http://php-worker:9506;
proxy_http_version 1.1; # 必须指定HTTP/1.1,支持WebSocket
proxy_set_header Upgrade $http_upgrade; # 升级协议头
proxy_set_header Connection "upgrade"; # 连接类型为upgrade
proxy_set_header Host $host; # 传递主机名
proxy_set_header X-Real-IP $remote_addr; # 传递真实IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 传递转发IP
proxy_connect_timeout 60; # 连接超时
proxy_read_timeout 600; # 长连接超时(适配SSH终端长时间交互)
proxy_send_timeout 600; # 发送超时
}
php-worker 配置
[program:ssh-terminal]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/autotest/platform/artisan swoole:ssh-terminal
directory=/var/www/autotest/platform
autostart=true
autorestart=true
numprocs=1
user=laradock
redirect_stderr=true
stdout_logfile=/var/www/autotest/platform/storage/logs/ssh_terminal.log
stopwaitsecs=10
killasgroup=true
stopasgroup=true
blade代码
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SSH Terminal + SFTP</title>
<link rel="stylesheet" href="/css/xterm-5.5.0-xterm.css">
<style>
:root{
--bg: #0b1020;
--panel: rgba(255,255,255,.04);
--panel2: rgba(255,255,255,.06);
--stroke: rgba(255,255,255,.10);
--stroke2: rgba(255,255,255,.14);
--text: #e5e7eb;
--muted: rgba(229,231,235,.70);
--muted2: rgba(229,231,235,.55);
--accent: #7c3aed;
--accent2: #22c55e;
--danger: #ef4444;
--warn: #f59e0b;
--shadow: 0 10px 30px rgba(0,0,0,.35);
--r12: 12px;
--r16: 16px;
}
*{ box-sizing:border-box; }
html, body { height: 100%; }
body{
margin:0;
display: flex;
flex-direction: column;
min-height: 100vh;
background:
radial-gradient(900px 400px at 10% 0%, rgba(124,58,237,.20), transparent 60%),
radial-gradient(700px 400px at 90% 10%, rgba(34,197,94,.12), transparent 60%),
var(--bg);
}
/* ===== Top Bar ===== */
.topbar{
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(10px);
background: rgba(11,16,32,.72);
border-bottom: 1px solid var(--stroke);
}
.topbar-inner{
padding: 12px 14px;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.brand{
display:flex;
align-items:center;
gap:10px;
padding: 8px 10px;
border:1px solid var(--stroke);
border-radius: 999px;
background: rgba(255,255,255,.03);
}
.dot{
width:10px; height:10px; border-radius:999px;
background: linear-gradient(135deg, var(--accent), #38bdf8);
box-shadow: 0 0 0 4px rgba(124,58,237,.15);
}
.brand b{ font-size: 13px; letter-spacing:.2px; }
.brand span{ font-size: 12px; color: var(--muted); }
.pill{
padding: 6px 10px;
border: 1px solid var(--stroke);
border-radius: 999px;
background: rgba(255,255,255,.03);
color: var(--muted);
font-size: 12px;
max-width: 48vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.controls{
display:flex;
align-items:center;
gap:10px;
flex-wrap: wrap;
}
.btn{
border: 1px solid var(--stroke);
background: rgba(255,255,255,.03);
color: var(--text);
padding: 8px 12px;
border-radius: 999px;
cursor: pointer;
user-select:none;
display:inline-flex;
align-items:center;
gap:8px;
transition: transform .05s ease, background .15s ease, border-color .15s ease;
font-size: 13px;
}
.btn:hover{
background: rgba(255,255,255,.06);
border-color: var(--stroke2);
}
.btn:active{ transform: translateY(1px); }
.btn.primary{
background: linear-gradient(135deg, rgba(124,58,237,.35), rgba(124,58,237,.12));
border-color: rgba(124,58,237,.35);
}
.btn.good{
background: linear-gradient(135deg, rgba(34,197,94,.30), rgba(34,197,94,.10));
border-color: rgba(34,197,94,.35);
}
.btn.danger{
background: linear-gradient(135deg, rgba(239,68,68,.28), rgba(239,68,68,.10));
border-color: rgba(239,68,68,.35);
}
.btn.ghost{ background: transparent; }
.inp{
border: 1px solid var(--stroke);
background: rgba(255,255,255,.03);
color: var(--text);
padding: 9px 12px;
border-radius: 999px;
outline: none;
width: 220px;
font-size: 13px;
}
.inp:focus{
border-color: rgba(124,58,237,.55);
box-shadow: 0 0 0 4px rgba(124,58,237,.15);
}
.spacer{ flex: 1; }
#wrap{
flex: 1; /* 占满 topbar 下面剩余高度 */
min-height: 0; /* 关键:允许内部滚动容器正确收缩 */
padding: 12px;
display: grid;
grid-template-columns: 420px 1fr;
gap: 12px;
}
.card{
border: 1px solid var(--stroke);
border-radius: var(--r16);
background: var(--panel);
box-shadow: var(--shadow);
overflow: hidden;
min-height: 0;
}
/* ===== SFTP Panel ===== */
.sftp-head{
padding: 12px;
border-bottom: 1px solid var(--stroke);
background: linear-gradient(180deg, rgba(255,255,255,.05), transparent);
display:flex;
flex-direction: column;
gap: 10px;
}
.sftp-toolbar{
display:flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.crumbs{
display:flex;
align-items:center;
gap:8px;
flex-wrap: wrap;
padding: 8px 10px;
border:1px solid var(--stroke);
border-radius: var(--r12);
background: rgba(255,255,255,.03);
font-size: 12px;
color: var(--muted);
}
.crumbs button{
border: none;
background: transparent;
color: var(--text);
cursor: pointer;
padding: 4px 6px;
border-radius: 8px;
font-size: 12px;
}
.crumbs button:hover{ background: rgba(255,255,255,.06); }
.crumbs .sep{ color: var(--muted2); }
.sftp-sub{
display:flex;
align-items:center;
gap:10px;
}
.sftp-sub .inp{ flex: 1; width: auto; border-radius: var(--r12); }
.topbar .brand { display: none !important; }
#boxIdInput { display: none !important; }
.file-area{
display:flex;
flex-direction: column;
min-height: 0;
}
.table-wrap{
flex: 1;
min-height: 0;
overflow: auto; /* 列表多就滚动 */
padding: 10px;
}
.dropzone{
margin-top: 8px;
padding: 10px 12px;
border: 1px dashed rgba(255,255,255,.20);
border-radius: var(--r12);
background: rgba(255,255,255,.02);
color: var(--muted);
font-size: 12px;
display:flex;
align-items:center;
justify-content: space-between;
gap: 10px;
}
.dropzone strong{ color: var(--text); font-weight: 600; }
.dropzone.dragover{
border-color: rgba(124,58,237,.60);
background: rgba(124,58,237,.10);
color: var(--text);
}
.progress{
height: 10px;
border-radius: 999px;
background: rgba(255,255,255,.08);
overflow: hidden;
border: 1px solid rgba(255,255,255,.10);
}
.progress > div{
height: 100%;
width: 0%;
background: linear-gradient(90deg, rgba(124,58,237,.9), rgba(34,197,94,.9));
transition: width .08s ease;
}
.mini{
font-size: 12px;
color: var(--muted);
display:flex;
align-items:center;
justify-content: space-between;
gap: 10px;
}
/* 更小更精致的操作按钮 */
.iconbtn{
width: 28px;
height: 28px;
border-radius: 8px;
padding: 0;
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.03);
color: rgba(229,231,235,.92);
cursor: pointer;
display:inline-flex;
align-items:center;
justify-content:center;
transition: background .15s ease, border-color .15s ease, transform .05s ease;
}
.iconbtn:hover{
background: rgba(255,255,255,.06);
border-color: rgba(255,255,255,.18);
}
.iconbtn:active{ transform: translateY(1px); }
/* SVG 图标大小 */
.iconbtn svg{
width: 16px;
height: 16px;
display:block;
}
/* 删除按钮微红但不刺眼 */
.iconbtn.danger{
border-color: rgba(239,68,68,.35);
background: rgba(239,68,68,.08);
}
.iconbtn.danger:hover{
background: rgba(239,68,68,.12);
}
/* ===== TABLE (稳定版) ===== */
.table{
width: 100%;
border-collapse: separate;
border-spacing: 0;
border: 1px solid var(--stroke);
border-radius: var(--r12);
background: rgba(255,255,255,.02);
table-layout: fixed; /* 关键:列宽按th比例固定,不会挤成一坨 */
overflow: hidden;
}
.table thead th{
text-align: left;
font-size: 12px;
color: var(--muted);
padding: 10px 12px;
border-bottom: 1px solid var(--stroke);
position: sticky;
top: 0;
z-index: 10;
/* 给 sticky 一个实底,避免看起来像紫色遮罩 */
background: rgba(11,16,32,.92);
backdrop-filter: blur(6px);
}
.table td{
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,.06);
font-size: 13px;
color: var(--text);
vertical-align: middle;
overflow: hidden; /* 防止相邻列互相盖住 */
}
.table tbody tr:hover td{ background: rgba(255,255,255,.03); }
/* 选中行的紫色高亮:不想要就把这段删掉 */
.table tbody tr.active td{
background: rgba(124,58,237,.15);
border-bottom-color: rgba(124,58,237,.25);
}
/* 名称列:可换行 */
.name-cell{
display:flex;
align-items:flex-start;
gap:10px;
min-width:0;
}
.icon{
width: 28px; height: 28px;
display:flex;
align-items:center;
justify-content:center;
border-radius: 10px;
background: rgba(255,255,255,.04);
border: 1px solid rgba(255,255,255,.08);
flex: 0 0 auto;
}
.fname{
min-width: 0;
white-space: normal;
word-break: break-all;
overflow-wrap: anywhere;
line-height: 1.35;
}
/* 右侧meta列:不换行+省略,避免顶到操作列 */
.meta{
color: var(--muted);
font-size: 12px;
text-align:right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 只针对 修改时间 列:允许换行,不要省略号 */
.table td.meta.mtime{
white-space: normal; /* 允许换行 */
overflow: visible; /* 不裁切 */
text-overflow: clip; /* 不要 ... */
line-height: 1.25;
text-align: left; /* 看着更顺(你要右对齐就删掉这行) */
}
.table td.meta.mtime .celltext{
white-space: normal;
overflow: visible;
text-overflow: clip;
word-break: break-word;
overflow-wrap: anywhere;
}
/* 修改时间包一层:强制省略(你JS里用<span class="celltext">...</span>最好) */
.celltext{
display:block;
overflow:hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 操作列:永远不被其它列盖住 */
.table td:last-child,
.table thead th:last-child{
white-space: nowrap;
}
.table td:last-child{
overflow: visible;
position: relative;
z-index: 30;
}
.ops{
display:flex;
justify-content:flex-end;
gap:8px;
flex-wrap: nowrap;
position: relative;
z-index: 40;
}
.ops .iconbtn{
position: relative;
z-index: 50;
}
/* ===== Terminal ===== */
#terminalWrap{ width:100%; height:100%; padding: 10px; }
#terminal{
width:100%; height:100%;
border:1px solid var(--stroke);
border-radius: var(--r16);
overflow:hidden;
background: rgba(0,0,0,.12);
}
/* ===== Responsive ===== */
@media (max-width: 1100px){
#wrap{ grid-template-columns: 1fr; height: auto; }
#terminalWrap{ height: 55vh; }
.table-wrap{ max-height: 45vh; }
}
</style>
</head>
<body>
<!-- ===== Top Bar ===== -->
<div class="topbar">
<div class="topbar-inner">
<div class="brand">
<div class="dot"></div>
<div>
<b>SSH Terminal</b>
<span>+ SFTP</span>
</div>
</div>
<input id="boxIdInput" class="inp" disabled />
<div class="controls">
<button id="btnFav" class="btn primary">⭐ 收藏夹</button>
<button id="btnConnect" class="btn good">🖥️ 连接 SSH</button>
<button id="btnDisconnect" class="btn ghost">⛔ 断开 SSH</button>
<button id="btnSftpConnect" class="btn primary">📁 连接 SFTP</button>
<button id="btnSftpDisconnect" class="btn ghost">⛔ 断开 SFTP</button>
<button id="btnClear" class="btn">🧹 清空终端</button>
<!-- ===== Quick Commands ===== -->
<div class="controls" style="gap:8px;">
<button id="btn-auth-check" class="btn primary">🔐 授权检测</button>
<button id="btn-hosts-check" class="btn">🧾 hosts检查</button>
<button id="btn-hosts-repair" class="btn good">🛠️ hosts修复</button>
<button id="btn-algorithm-auth" class="btn primary">🧠 算法授权</button>
<button id="btn-reboot" class="btn danger">♻️ 重启设备</button>
<input id="aiboxVer" class="inp" style="width:140px;" placeholder="版本号" />
<button id="btn-aibox-update" class="btn good">🚀 版本更新</button>
</div>
</div>
<div class="spacer"></div>
<span class="pill" id="wsStatus">WS: -</span>
<span class="pill" id="sshStatus">SSH: -</span>
<span class="pill" id="sftpStatus">SFTP: -</span>
<span class="pill" id="wsUrlPill">URL: -</span>
</div>
</div>
<!-- ===== Favorites Modal ===== -->
<div id="favMask" style="position:fixed; inset:0; background:rgba(0,0,0,.55); display:none; z-index:999;"></div>
<div id="favModal" style="position:fixed; left:50%; top:50%; transform:translate(-50%,-50%);
width:min(920px,92vw); height:min(720px,86vh); display:none; z-index:1000;
border:1px solid rgba(255,255,255,.12); border-radius:16px; background:rgba(11,16,32,.92);
backdrop-filter: blur(10px); box-shadow: 0 10px 40px rgba(0,0,0,.55); overflow:hidden;">
<div style="padding:12px 14px; display:flex; align-items:center; gap:10px; border-bottom:1px solid rgba(255,255,255,.12);">
<b style="color:#e5e7eb;">⭐ 设备收藏夹</b>
<span style="color:rgba(229,231,235,.6); font-size:12px;">保存在本地 localStorage(清浏览器数据才会消失)</span>
<div style="flex:1;"></div>
<input id="favSearch" class="inp" style="width:220px;" placeholder="搜索设备..." />
<button id="btnFavClose" class="btn">关闭</button>
</div>
<div style="display:grid; grid-template-columns: 1.1fr .9fr; gap:12px; padding:12px; height:calc(100% - 54px); min-height:0;">
<!-- Left list -->
<div class="card" style="min-height:0;">
<div style="padding:10px 12px; border-bottom:1px solid rgba(255,255,255,.10); display:flex; gap:10px; align-items:center;">
<button id="btnFavAdd" class="btn good">➕ 新增设备</button>
<button id="btnFavImport" class="btn">导入 JSON</button>
<button id="btnFavExport" class="btn">导出 JSON</button>
<div style="flex:1;"></div>
<button id="btnFavClearStatus" class="btn">清空状态</button>
</div>
<div style="padding:10px; overflow:auto; height:calc(100% - 52px);">
<table class="table">
<thead>
<tr>
<th style="width:36%;">名称</th>
<th style="width:32%;">Host</th>
<th style="width:18%;">用户</th>
<th style="width:14%;">操作</th>
</tr>
</thead>
<tbody id="favTbody"></tbody>
</table>
</div>
</div>
<!-- Right editor -->
<div class="card" style="min-height:0;">
<div style="padding:10px 12px; border-bottom:1px solid rgba(255,255,255,.10); display:flex; align-items:center; gap:10px;">
<b style="color:#e5e7eb;">设备信息</b>
<span id="favEditHint" style="color:rgba(229,231,235,.55); font-size:12px;">选择左侧设备或新增</span>
</div>
<div style="padding:12px; overflow:auto; height:calc(100% - 46px);">
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:10px;">
<input id="f_name" class="inp" placeholder="设备名称/别名 例如:Box-01" />
<input id="f_port" class="inp" placeholder="端口(可选,默认22)" />
<input id="f_host" class="inp" placeholder="SSH IP/域名 例如:192.168.1.10" />
<input id="f_user" class="inp" placeholder="登录名 例如:root" />
</div>
<div style="margin-top:10px; display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<span style="color:rgba(229,231,235,.7); font-size:12px;">认证方式:</span>
<label style="color:#e5e7eb; font-size:12px; display:flex; align-items:center; gap:6px;">
<input type="radio" name="authType" value="password" checked /> 密码
</label>
<label style="color:#e5e7eb; font-size:12px; display:flex; align-items:center; gap:6px;">
<input type="radio" name="authType" value="key" /> 私钥
</label>
</div>
<div id="authPasswordWrap" style="margin-top:10px;">
<input id="f_password" class="inp" style="width:100%; border-radius:12px;" placeholder="密码(会保存在本地)" />
</div>
<div id="authKeyWrap" style="margin-top:10px; display:none;">
<textarea id="f_privateKey" style="width:100%; height:180px; resize:vertical;
border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.03); color:#e5e7eb;
border-radius:12px; padding:10px 12px; outline:none; font-size:12px;"
placeholder="粘贴 OpenSSH 私钥内容(会保存在本地)"></textarea>
<input id="f_passphrase" class="inp" style="width:100%; border-radius:12px; margin-top:10px;" placeholder="Passphrase(可选)" />
</div>
<div style="margin-top:10px;">
<textarea id="f_note" style="width:100%; height:90px; resize:vertical;
border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.03); color:#e5e7eb;
border-radius:12px; padding:10px 12px; outline:none; font-size:12px;"
placeholder="备注(可选)"></textarea>
</div>
<div style="margin-top:12px; display:flex; gap:10px; flex-wrap:wrap;">
<button id="btnFavSave" class="btn good">💾 保存</button>
<button id="btnFavConnect" class="btn primary">🖥️ 用此设备连接 SSH</button>
<button id="btnFavDelete" class="btn danger">🗑️ 删除</button>
</div>
<div style="margin-top:10px; font-size:12px; color:rgba(229,231,235,.6); line-height:1.5;">
<div><b style="color:#e5e7eb;">上次状态:</b><span id="favLastStatus">-</span></div>
<div><b style="color:#e5e7eb;">上次信息:</b><span id="favLastMsg">-</span></div>
<div><b style="color:#e5e7eb;">上次时间:</b><span id="favLastAt">-</span></div>
</div>
<div style="margin-top:10px; font-size:12px; color:rgba(253,224,71,.95);">
⚠️ 本地保存密码/私钥有风险:同一台电脑的其他用户可能读取浏览器数据。”。
</div>
</div>
</div>
</div>
</div>
<!-- ===== Main ===== -->
<div id="wrap">
<!-- ===== SFTP Panel ===== -->
<div class="card file-area">
<div class="sftp-head">
<div class="sftp-toolbar">
<button id="btnBack" class="btn">⬅️ 返回</button>
<button id="btnRefresh" class="btn">🔄 刷新</button>
<button id="btnNewFolder" class="btn">📂 新建目录</button>
<button id="btnRename" class="btn">✏️ 重命名</button>
</div>
<div class="sftp-sub">
<div class="crumbs" id="crumbs"></div>
</div>
<div class="sftp-sub">
<input id="pathInput" class="inp" placeholder="输入路径,例如 /home" />
<button id="btnGo" class="btn">前往</button>
<input id="searchInput" class="inp" style="width: 180px;" placeholder="搜索文件名..." />
</div>
<div class="dropzone" id="dropzone">
<div>拖拽文件到此处上传到 <strong id="dropPath">/</strong></div>
<div style="display:flex; gap:8px; align-items:center;">
<input type="file" id="uploadFile" style="display:none" />
<button id="btnPick" class="btn">选择文件</button>
<button id="btnUpload" class="btn good">上传</button>
<button id="btnCancelOp" class="btn danger" disabled>取消</button>
</div>
</div>
<div class="mini">
<div id="opHint">就绪</div>
<div style="width: 180px;">
<div class="progress"><div id="opBar"></div></div>
</div>
</div>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th style="width: 50%;">名称</th>
<th style="width: 18%;">大小</th>
<th style="width: 18%;">修改时间</th>
<th style="width: 14%;">操作</th>
</tr>
</thead>
<tbody id="fileTbody">
<!-- rows -->
</tbody>
</table>
</div>
</div>
<!-- ===== Terminal ===== -->
<div class="card" id="terminalWrap">
<div id="terminal"></div>
</div>
</div>
<script src="/js/xterm-5.5.0-xterm.js"></script>
<script src="/js/xterm-addon-fit@0.8.0-lib-xterm-addon-fit.js"></script>
<script src="{{ asset('getToken.js') }}"></script>
<script>
function getCookie(name) {
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [cookieName, ...rest] = cookie.split('=');
if (cookieName.trim() === name) {
return decodeURIComponent(rest.join('='));
}
}
return null;
}
</script>
<script>
(() => {
const ssh_key = getCookie("ssh_value"); // 例如 login.js 里提供了 getCookie()
if (!ssh_key) {
alert('未授权或登录已过期,请重新登录');
return;
}
// ===== 1) URL box id =====
const qs = new URLSearchParams(window.location.search);
const favId = (qs.get('fav') || '').trim();
const boxId = (qs.get('id') || '').trim();
const boxIdInput = document.getElementById('boxIdInput');
boxIdInput.value = boxId ? `Box ID: ${boxId}` : '(缺少 ?id=xxx)';
if (!getToken()) {
alert('未登录,请先登录后再连接。');
return;
}
// ===== 2) WS URL =====
const wsProto = (window.location.protocol === 'https:') ? 'wss' : 'ws';
const WS_URL = `${wsProto}://${window.location.host}/ws/?id=${encodeURIComponent(boxId)}&key=${encodeURIComponent(ssh_key)}`;
const wsStatusEl = document.getElementById('wsStatus');
const sshStatusEl = document.getElementById('sshStatus');
const sftpStatusEl = document.getElementById('sftpStatus');
const wsUrlPill = document.getElementById('wsUrlPill');
wsUrlPill.textContent = `URL: ${WS_URL}`;
const setPill = (el, label, text, ok) => {
el.textContent = `${label}: ${text}`;
el.style.color = ok ? '#86efac' : '#fca5a5';
};
const setWsStatus = (text, ok) => setPill(wsStatusEl, 'WS', text, ok);
const setSshStatus = (text, ok) => setPill(sshStatusEl, 'SSH', text, ok);
const setSftpStatus = (text, ok) => setPill(sftpStatusEl, 'SFTP', text, ok);
// ===== 3) xterm =====
if (typeof Terminal === 'undefined') { alert('Terminal 未定义:请确认 xterm.js 路径'); return; }
if (!window.FitAddon || !window.FitAddon.FitAddon) { alert('FitAddon 未定义'); return; }
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
scrollback: 8000,
convertEol: true,
theme: { background: '#0b1020', foreground: '#e5e7eb' }
});
term.options.backspaceAsControlH = false;
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal'));
fitAddon.fit();
term.focus();
const println = (s) => term.writeln(s);
const print = (s) => term.write(s);
println('\x1b[36m[SYSTEM]\x1b[0m xterm loaded ✅');
println(`\x1b[36m[SYSTEM]\x1b[0m WS_URL = ${WS_URL}`);
if (!boxId) {
println('\x1b[31m[ERROR]\x1b[0m URL 缺少 ?id=xxx,将无法连接 SSH/SFTP。\r\n');
}
// ===== 4) WS =====
let ws = null;
let wsOpenPromise = null;
let sshConnected = false;
let sftpConnected = false;
let userDisconnectedSsh = false; // 标记:用户是否主动断开SSH(默认false=允许自动连)
let userDisconnectedSftp = false; // 标记:用户是否主动断开SFTP(默认false=允许自动连)
function isWsOpen(){ return ws && ws.readyState === WebSocket.OPEN; }
function ensureWSOpen(){
if (isWsOpen()) return Promise.resolve(true);
if (wsOpenPromise) return wsOpenPromise;
setWsStatus('连接中...', false);
setSshStatus('-', false);
setSftpStatus('-', false);
sshConnected = false;
sftpConnected = false;
wsOpenPromise = new Promise((resolve) => {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
setWsStatus('已连接', true);
println('\r\n\x1b[32m[WS] WebSocket连接成功\x1b[0m\r\n');
// ===== 新增以下自动连接逻辑 =====
if (boxId) { // 有boxId才自动连,避免无意义请求
// 自动连SSH(用户没主动断开才执行)
if (!userDisconnectedSsh) {
sendResize(); // 先同步终端尺寸
send('connect_ssh', { cols: term.cols, rows: term.rows });
setSshStatus('自动连接中...', false);
println('\x1b[36m[自动连接] 开始连接SSH\x1b[0m');
}
// 自动连SFTP(用户没主动断开才执行)
if (!userDisconnectedSftp) {
setSftpStatus('自动连接中...', false);
send('sftp_connect');
println('\x1b[36m[自动连接] 开始连接SFTP\x1b[0m');
}
} else {
println('\x1b[33m[提示] 缺少boxId,跳过自动连接SSH/SFTP\x1b[0m');
}
resolve(true);
};
ws.onmessage = (e) => {
let msg;
try { msg = JSON.parse(e.data); } catch { return; }
if (msg.type === 'output') { print(msg.data); return; }
if (msg.type === 'connected') {
sshConnected = true;
setSshStatus('已连接', true);
onSshResultToFav('ok', msg.msg);
println(`\r\n\x1b[32m[SSH] ${msg.msg}\x1b[0m\r\n`);
term.focus();
return;
}
if (msg.type === 'sftp') { handleSftpMsg(msg); return; }
if (msg.type === 'system') {
println(`\r\n\x1b[36m[系统] ${msg.msg}\x1b[0m\r\n`);
return;
}
if (msg.type === 'error') {
// 可能来自 SSH 或 SFTP
setSshStatus(sshConnected ? '已连接' : '错误', sshConnected);
setSftpStatus(sftpConnected ? '已连接' : '错误', sftpConnected);
onSshResultToFav('bad', msg.msg);
toast(msg.msg, 'bad');
println(`\r\n\x1b[31m[错误] ${msg.msg}\x1b[0m\r\n`);
return;
}
};
ws.onclose = () => {
sshConnected = false;
sftpConnected = false;
setWsStatus('已断开', false);
setSshStatus('-', false);
setSftpStatus('-', false);
toast('WebSocket已断开,请确认您的权限,3秒后重连', 'bad');
println('\r\n\x1b[31m[WS] WebSocket已断开,请确认您的权限,3秒后重连\x1b[0m\r\n');
wsOpenPromise = null;
setTimeout(() => ensureWSOpen(), 3000);
};
ws.onerror = () => {
sshConnected = false;
sftpConnected = false;
setWsStatus('连接失败', false);
toast('WebSocket连接失败', 'bad');
println('\r\n\x1b[31m[WS] WebSocket连接失败\x1b[0m\r\n');
wsOpenPromise = null;
resolve(false);
};
});
return wsOpenPromise;
}
function send(action, payload = {}){
if (!isWsOpen()) return false;
ws.send(JSON.stringify({ action, ...payload }));
return true;
}
// ===================== Favorites (localStorage) =====================
const FAV_KEY = 'ssh_favorites_v1';
function loadFavs(){
try { return JSON.parse(localStorage.getItem(FAV_KEY) || '[]') || []; }
catch { return []; }
}
function saveFavs(arr){
localStorage.setItem(FAV_KEY, JSON.stringify(arr || []));
}
async function autoConnectFromFavQuery(){
if (!favId) return;
const favsNow = loadFavs();
const it = favsNow.find(x => x.id === favId);
if (!it) {
println(`\r\n\x1b[31m[FAV]\x1b[0m 未找到收藏:${favId}\r\n`);
return;
}
// 可选:标题显示设备名,多个 tab 不迷路
document.title = (it.name || it.host || 'SSH Terminal') + ' - SSH';
const ok = await ensureWSOpen();
if (!ok) return;
println(`\r\n\x1b[36m[FAV]\x1b[0m Auto connect: ${it.name || it.host}\r\n`);
// 记录一下,connected/error 时写回 lastStatus
favPendingConnectId = it.id;
sendResize();
const auth = (it.authType === 'key')
? { type:'key', privateKey: it.privateKey || '', passphrase: it.passphrase || '' }
: { type:'password', password: it.password || '' };
send('connect_ssh', {
cols: term.cols,
rows: term.rows,
host: it.host,
user: it.user,
auth,
port: it.port ? Number(it.port) : undefined,
name: it.name || undefined,
});
setSshStatus('连接中...', false);
toast(`连接中:${it.name || it.host}`, 'warn');
}
function uuid(){
return 'f_' + Math.random().toString(16).slice(2) + Date.now().toString(16);
}
function nowISO(){
return new Date().toISOString();
}
let favs = loadFavs();
let favSelectedId = null; // 当前编辑的设备id
let favPendingConnectId = null; // 本次连接发起所用的设备id(用于写回结果)
// modal els
const favMask = document.getElementById('favMask');
const favModal = document.getElementById('favModal');
const btnFav = document.getElementById('btnFav');
const btnFavClose = document.getElementById('btnFavClose');
const favTbody = document.getElementById('favTbody');
const favSearch = document.getElementById('favSearch');
const f_name = document.getElementById('f_name');
const f_port = document.getElementById('f_port');
const f_host = document.getElementById('f_host');
const f_user = document.getElementById('f_user');
const f_password = document.getElementById('f_password');
const f_privateKey = document.getElementById('f_privateKey');
const f_passphrase = document.getElementById('f_passphrase');
const f_note = document.getElementById('f_note');
const authPasswordWrap = document.getElementById('authPasswordWrap');
const authKeyWrap = document.getElementById('authKeyWrap');
const btnFavAdd = document.getElementById('btnFavAdd');
const btnFavSave = document.getElementById('btnFavSave');
const btnFavDelete = document.getElementById('btnFavDelete');
const btnFavConnect = document.getElementById('btnFavConnect');
const btnFavImport = document.getElementById('btnFavImport');
const btnFavExport = document.getElementById('btnFavExport');
const btnFavClearStatus = document.getElementById('btnFavClearStatus');
const favLastStatus = document.getElementById('favLastStatus');
const favLastMsg = document.getElementById('favLastMsg');
const favLastAt = document.getElementById('favLastAt');
const favEditHint = document.getElementById('favEditHint');
function openFav(){
favMask.style.display = 'block';
favModal.style.display = 'block';
renderFavTable();
syncAuthUI();
}
function closeFav(){
favMask.style.display = 'none';
favModal.style.display = 'none';
}
btnFav?.addEventListener('click', openFav);
btnFavClose?.addEventListener('click', closeFav);
favMask?.addEventListener('click', closeFav);
function getAuthType(){
const r = document.querySelector('input[name="authType"]:checked');
return r ? r.value : 'password';
}
function setAuthType(v){
document.querySelectorAll('input[name="authType"]').forEach(x => x.checked = (x.value === v));
syncAuthUI();
}
function syncAuthUI(){
const t = getAuthType();
authPasswordWrap.style.display = (t === 'password') ? 'block' : 'none';
authKeyWrap.style.display = (t === 'key') ? 'block' : 'none';
}
document.querySelectorAll('input[name="authType"]').forEach(r => {
r.addEventListener('change', syncAuthUI);
});
function fmtLastAt(v){
if (!v) return '-';
try {
const d = new Date(v);
const pad = (x)=>String(x).padStart(2,'0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
} catch { return String(v); }
}
function renderFavTable(){
const q = (favSearch?.value || '').trim().toLowerCase();
const list = favs.filter(it => {
if (!q) return true;
return (it.name||'').toLowerCase().includes(q)
|| (it.host||'').toLowerCase().includes(q)
|| (it.user||'').toLowerCase().includes(q);
});
favTbody.innerHTML = '';
list.forEach(it => {
const tr = document.createElement('tr');
const statusDot = it.lastStatus === 'ok' ? '🟢' : (it.lastStatus === 'bad' ? '🔴' : '⚪');
tr.innerHTML = `
<td style="width:36%;">
<div style="display:flex; align-items:center; gap:8px; min-width:0;">
<span title="${it.lastStatus||''}">${statusDot}</span>
<span style="color:#e5e7eb; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${it.name || '-'}</span>
</div>
</td>
<td style="width:32%;" class="meta">${it.host || '-'}</td>
<td style="width:18%;" class="meta">${it.user || '-'}</td>
<td style="width:14%;">
<div class="ops">
<button class="iconbtn" data-act="edit" title="编辑">
<svg viewBox="0 0 24 24" fill="none"><path d="M4 20h4l10-10-4-4L4 16v4z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
</button>
<button class="iconbtn" data-act="use" title="连接">
<svg viewBox="0 0 24 24" fill="none"><path d="M7 7h10v10H7z" stroke="currentColor" stroke-width="2"/><path d="M3 12h4m10 0h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
</td>
`;
tr.querySelector('[data-act="edit"]').addEventListener('click', () => selectFav(it.id));
tr.querySelector('[data-act="use"]').addEventListener('click', () => {
selectFav(it.id);
openFavInNewTab(it.id);
});
tr.addEventListener('click', () => selectFav(it.id));
favTbody.appendChild(tr);
});
if (favs.length && !favSelectedId) {
selectFav(favs[0].id);
} else if (!favs.length) {
clearFavForm();
}
}
favSearch?.addEventListener('input', renderFavTable);
function clearFavForm(){
favSelectedId = null;
favEditHint.textContent = '选择左侧设备或新增';
f_name.value = '';
f_port.value = '';
f_host.value = '';
f_user.value = '';
f_password.value = '';
f_privateKey.value = '';
f_passphrase.value = '';
f_note.value = '';
setAuthType('password');
favLastStatus.textContent = '-';
favLastMsg.textContent = '-';
favLastAt.textContent = '-';
}
function selectFav(id){
const it = favs.find(x => x.id === id);
if (!it) return;
favSelectedId = id;
favEditHint.textContent = `编辑:${it.name || it.host || it.id}`;
f_name.value = it.name || '';
f_port.value = it.port || '';
f_host.value = it.host || '';
f_user.value = it.user || '';
f_note.value = it.note || '';
setAuthType(it.authType || 'password');
f_password.value = it.password || '';
f_privateKey.value = it.privateKey || '';
f_passphrase.value = it.passphrase || '';
favLastStatus.textContent = it.lastStatus || '-';
favLastMsg.textContent = it.lastMessage || '-';
favLastAt.textContent = fmtLastAt(it.lastAt);
}
function readFavForm(){
const authType = getAuthType();
const obj = {
id: favSelectedId || uuid(),
name: (f_name.value || '').trim(),
host: (f_host.value || '').trim(),
user: (f_user.value || '').trim(),
port: (f_port.value || '').trim(),
authType,
password: authType === 'password' ? (f_password.value || '') : '',
privateKey: authType === 'key' ? (f_privateKey.value || '') : '',
passphrase: authType === 'key' ? (f_passphrase.value || '') : '',
note: (f_note.value || ''),
// keep status fields if exists
lastStatus: undefined,
lastMessage: undefined,
lastAt: undefined,
};
// basic validate (front-end)
if (!obj.host) { toast('Host 不能为空', 'warn'); return null; }
if (!obj.user) { toast('用户不能为空', 'warn'); return null; }
if (authType === 'password' && !obj.password) { toast('密码不能为空(或改用私钥)', 'warn'); return null; }
if (authType === 'key' && !obj.privateKey) { toast('私钥不能为空(或改用密码)', 'warn'); return null; }
return obj;
}
btnFavAdd?.addEventListener('click', () => {
clearFavForm();
f_name.focus();
});
btnFavSave?.addEventListener('click', () => {
const obj = readFavForm();
if (!obj) return;
const old = favs.find(x => x.id === obj.id);
if (old) {
obj.lastStatus = old.lastStatus;
obj.lastMessage = old.lastMessage;
obj.lastAt = old.lastAt;
favs = favs.map(x => x.id === obj.id ? obj : x);
} else {
favs.unshift(obj);
}
saveFavs(favs);
toast('已保存到收藏夹', 'ok');
renderFavTable();
selectFav(obj.id);
});
btnFavDelete?.addEventListener('click', () => {
if (!favSelectedId) return toast('未选择设备', 'warn');
const it = favs.find(x => x.id === favSelectedId);
if (!it) return;
if (!confirm(`确定删除设备:${it.name || it.host} ?`)) return;
favs = favs.filter(x => x.id !== favSelectedId);
saveFavs(favs);
toast('已删除', 'ok');
favSelectedId = null;
renderFavTable();
clearFavForm();
});
btnFavClearStatus?.addEventListener('click', () => {
favs = favs.map(x => ({...x, lastStatus: undefined, lastMessage: undefined, lastAt: undefined}));
saveFavs(favs);
toast('已清空状态', 'ok');
renderFavTable();
if (favSelectedId) selectFav(favSelectedId);
});
// Export / Import
btnFavExport?.addEventListener('click', () => {
const data = JSON.stringify(favs, null, 2);
const blob = new Blob([data], {type:'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'ssh_favorites.json';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast('已导出 JSON', 'ok');
});
btnFavImport?.addEventListener('click', async () => {
const text = prompt('粘贴要导入的 JSON(会与现有合并,id 相同则覆盖)');
if (!text) return;
try {
const arr = JSON.parse(text);
if (!Array.isArray(arr)) throw new Error('JSON不是数组');
// merge by id
const map = new Map(favs.map(x => [x.id, x]));
for (const it of arr) {
if (!it || typeof it !== 'object') continue;
if (!it.id) it.id = uuid();
map.set(it.id, {...map.get(it.id), ...it});
}
favs = Array.from(map.values());
saveFavs(favs);
toast('导入成功', 'ok');
renderFavTable();
} catch (e) {
toast('导入失败:JSON格式不正确', 'bad');
}
});
// ===== Connect with selected fav =====
async function connectWithSelectedFav(){
const it = favs.find(x => x.id === favSelectedId);
if (!it) return toast('未选择设备', 'warn');
// 确保 WS
const ok = await ensureWSOpen();
if (!ok) return;
// 让 UI 也同步一下(可选)
println(`\r\n\x1b[36m[FAV]\x1b[0m Using favorite: ${it.name || it.host}\r\n`);
// 标记本次连接来源,后面 connected/error 要写回
favPendingConnectId = it.id;
sendResize();
// 你说后端支持 3 个参数:这里给你一个清晰的 payload
// 你可按后端最终字段名调整:例如 host/user/auth 或 ip/username/password 等
const auth = (it.authType === 'key')
? { type:'key', privateKey: it.privateKey || '', passphrase: it.passphrase || '' }
: { type:'password', password: it.password || '' };
send('connect_ssh', {
cols: term.cols,
rows: term.rows,
// === three params (example) ===
host: it.host,
user: it.user,
auth,
// optional
port: it.port ? Number(it.port) : undefined,
name: it.name || undefined,
});
setSshStatus('连接中...', false);
toast(`连接中:${it.name || it.host}`, 'warn');
}
function openFavInNewTab(id){
const url = new URL(window.location.href);
// 用 fav 参数标识:新页从 localStorage 读配置再连接
url.searchParams.set('fav', id);
// 重要:不要把密码/私钥放 URL
window.open(url.toString(), '_blank', 'noopener');
}
btnFavConnect?.addEventListener('click', () => {
if (!favSelectedId) return toast('未选择设备', 'warn');
openFavInNewTab(favSelectedId);
});
// ===================== Hook WS events: write back status =====================
// 在你现有 ws.onmessage 里:connected/error 分支处加两行调用即可:
// onSshResultToFav('ok', msg.msg) / onSshResultToFav('bad', msg.msg)
function onSshResultToFav(status, message){
if (!favPendingConnectId) return;
const id = favPendingConnectId;
favPendingConnectId = null;
const it = favs.find(x => x.id === id);
if (!it) return;
it.lastStatus = status;
it.lastMessage = String(message || '');
it.lastAt = nowISO();
saveFavs(favs);
// 如果弹窗开着,刷新右侧状态
if (favModal.style.display !== 'none') {
renderFavTable();
if (favSelectedId === id) selectFav(id);
}
}
// ===== Quick Command Runner (send terminal command) =====
function executeTerminalCommand(cmd, noNewline = false){
if (!sshConnected) {
println("\r\n\x1b[31m[ERROR]\x1b[0m SSH not connected\r\n");
return;
}
if (!isWsOpen()) {
println("\r\n\x1b[31m[ERROR]\x1b[0m WS not open\r\n");
return;
}
// 打印一行提示,便于审计/回看
println(`\r\n\x1b[36m[CMD]\x1b[0m ${cmd}\r\n`);
// 复用你现有 input 通道:相当于在终端里输入
const payload = noNewline ? cmd : (cmd.endsWith('\n') ? cmd : (cmd + '\n'));
ws.send(JSON.stringify({ action: 'input', data: payload }));
}
// ===== 5 Buttons events =====
document.getElementById('btn-auth-check')?.addEventListener('click', () => {
executeTerminalCommand('cd /home/nle/app/aibox/lic/bin && ./licCheck');
});
document.getElementById('btn-hosts-check')?.addEventListener('click', () => {
executeTerminalCommand('cat /etc/hosts');
});
document.getElementById('btn-hosts-repair')?.addEventListener('click', () => {
executeTerminalCommand(`echo nle | sudo -S sed -i '/auth.newland.com.cn/d' /etc/hosts && echo "192.168.136.180 auth.newland.com.cn" | sudo tee -a /etc/hosts`);
});
document.getElementById('btn-algorithm-auth')?.addEventListener('click', () => {
// 这里保持你原来的 Laravel Blade 写法
executeTerminalCommand('{!! env('ALGORITHM_LICENSE_CMD') !!}');
});
document.getElementById('btn-reboot')?.addEventListener('click', () => {
if (confirm("确认要重启当前设备吗?重启后终端连接会中断!")) {
executeTerminalCommand('sudo reboot');
}
});
document.getElementById('btn-aibox-update')?.addEventListener('click', () => {
if (!sshConnected) {
println("\r\n\x1b[31m[ERROR]\x1b[0m SSH not connected\r\n");
return;
}
const ver = (document.getElementById('aiboxVer')?.value || '').trim();
if (!ver) {
toast('请输入版本号', 'warn');
document.getElementById('aiboxVer')?.focus();
return;
}
// 前两条执行(会回车)
executeTerminalCommand('cd /home/nle');
executeTerminalCommand('chmod 777 AiBox-Update.sh');
// 拼接版本号:不回车(你说“你不要回车”)
executeTerminalCommand(`./AiBox-Update.sh ${ver}`, true);
println('\r\n\x1b[36m[CMD]\x1b[0m 已拼接版本号(未回车),需要你手动回车执行。\r\n');
});
function doDelete(item){
if (!sftpConnected) return toast('请先连接 SFTP', 'warn');
if (!item) return toast('无效项', 'warn');
const target = joinPath(currentPath, item.name);
if (!confirm(`确定删除:\n${target}\n\n`)) return;
send('sftp_rm', { path: target });
}
function doDownload(item){
if (!sftpConnected) return toast('请先连接 SFTP', 'warn');
if (!item) return toast('无效项', 'warn');
if (item.is_dir) return toast('目录不支持直接下载(当前示例仅文件)', 'warn');
const remote = joinPath(currentPath, item.name);
setOp('download', remote);
downloading = { path: remote, chunks: [], size: 0, receivedBytes: 0 };
setProgress(0);
toast('开始下载...', 'warn');
send('sftp_download_begin', { path: remote, chunk: CHUNK });
}
let currentOp = { type: null, path: null, cancelled: false }; // upload/download
const btnCancelOp = document.getElementById('btnCancelOp');
function setOp(type, path){
currentOp.type = type;
currentOp.path = path;
currentOp.cancelled = false;
btnCancelOp.disabled = false;
btnCancelOp.textContent = `取消${type === 'upload' ? '上传' : '下载'}`;
}
function clearOp(msg){
currentOp.type = null;
currentOp.path = null;
currentOp.cancelled = false;
btnCancelOp.disabled = true;
btnCancelOp.textContent = '取消';
if (msg) {
toast(msg, 'warn');
setProgress(0);
}
}
btnCancelOp.addEventListener('click', () => {
if (!currentOp.type) return;
currentOp.cancelled = true;
if (currentOp.type === 'upload') {
// 通知后端清理临时文件
send('sftp_upload_cancel', { path: currentOp.path });
clearOp('已取消上传');
}
if (currentOp.type === 'download') {
// 通知后端取消下载协程
send('sftp_download_cancel', { path: currentOp.path });
downloading = null;
clearOp('已取消下载');
}
});
function sendResize(){
fitAddon.fit();
send('resize', { cols: term.cols, rows: term.rows });
}
// ===== 5) SSH events (保留原逻辑) =====
document.getElementById('btnConnect').addEventListener('click', async () => {
if (!boxId) return alert('URL 缺少 ?id=xxx,不能连接。');
const ok = await ensureWSOpen();
if (!ok) return;
sendResize();
send('connect_ssh', { cols: term.cols, rows: term.rows });
setSshStatus('连接中...', false);
});
document.getElementById('btnDisconnect').addEventListener('click', () => {
send('disconnect');
sshConnected = false;
userDisconnectedSsh = true; // 标记:用户主动断开,后续WS重连不再自动连
setSshStatus('已断开', false);
println('\x1b[33m[手动操作] 已主动断开SSH,后续WS重连不会自动连接\x1b[0m');
});
document.getElementById('btnClear').addEventListener('click', () => term.clear());
term.onData((data) => {
if (!sshConnected) { term.writeln("\r\n\x1b[31m[ERROR]\x1b[0m SSH not connected\r\n"); return; }
if (!isWsOpen()) { term.writeln("\r\n\x1b[31m[ERROR]\x1b[0m WS not open\r\n"); return; }
ws.send(JSON.stringify({ action: 'input', data }));
});
window.addEventListener('resize', () => sendResize());
// ===== 6) SFTP Modern UI =====
const pathInput = document.getElementById('pathInput');
const crumbsEl = document.getElementById('crumbs');
const tbody = document.getElementById('fileTbody');
const searchInput = document.getElementById('searchInput');
const dropzone = document.getElementById('dropzone');
const dropPathEl = document.getElementById('dropPath');
const uploadFileEl = document.getElementById('uploadFile');
const opHint = document.getElementById('opHint');
const opBar = document.getElementById('opBar');
let currentPath = '/';
let allItems = [];
let selected = null;
// download state
let downloading = null; // {path, chunks:[], size, receivedBytes}
const CHUNK = 64 * 1024;
function toast(text, type='ok'){
opHint.textContent = text;
if (type === 'bad') opHint.style.color = 'rgba(252,165,165,.95)';
else if (type === 'warn') opHint.style.color = 'rgba(253,224,71,.95)';
else opHint.style.color = 'rgba(134,239,172,.95)';
setTimeout(() => { opHint.style.color = 'var(--muted)'; }, 1800);
}
function setProgress(pct){
opBar.style.width = `${Math.max(0, Math.min(100, pct))}%`;
}
function normalizePath(p){
p = (p || '/').trim();
if (!p) p = '/';
if (!p.startsWith('/')) p = '/' + p;
// normalize .. and .
const parts = [];
p.split('/').forEach(seg => {
if (!seg || seg === '.') return;
if (seg === '..') { parts.pop(); return; }
parts.push(seg);
});
return '/' + parts.join('/');
}
function joinPath(base, name){
base = normalizePath(base);
if (!base.endsWith('/')) base += '/';
return normalizePath(base + name);
}
function fmtSize(n){
n = Number(n || 0);
if (n < 1024) return `${n} B`;
if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB`;
if (n < 1024*1024*1024) return `${(n/1024/1024).toFixed(1)} MB`;
return `${(n/1024/1024/1024).toFixed(1)} GB`;
}
function fmtTime(ts){
ts = Number(ts || 0);
if (!ts) return '-';
const d = new Date(ts * 1000);
const pad = (x) => String(x).padStart(2,'0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function buildCrumbs(path){
path = normalizePath(path);
const parts = path.split('/').filter(Boolean);
crumbsEl.innerHTML = '';
const rootBtn = document.createElement('button');
rootBtn.textContent = 'Root';
rootBtn.onclick = () => goList('/');
crumbsEl.appendChild(rootBtn);
let acc = '';
parts.forEach((seg) => {
const sep = document.createElement('span');
sep.className = 'sep';
sep.textContent = '›';
crumbsEl.appendChild(sep);
acc += '/' + seg;
const btn = document.createElement('button');
btn.textContent = seg;
btn.onclick = () => goList(acc);
crumbsEl.appendChild(btn);
});
}
const COLS = { name: 50, size: 18, mtime: 18, ops: 14 };
function renderTable(items){
tbody.innerHTML = '';
selected = null;
// parent row (..)
if (currentPath !== '/') {
const tr = document.createElement('tr');
tr.innerHTML = `
<td style="width:${COLS.name}%;">
<div class="name-cell">
<div class="icon">⬆️</div>
<div class="fname">..</div>
</div>
</td>
<td style="width:${COLS.size}%;" class="meta">DIR</td>
<td style="width:${COLS.mtime}%;" class="meta">-</td>
<td style="width:${COLS.ops}%;"></td>
`;
tr.ondblclick = () => {
const parent = normalizePath(currentPath.replace(/\/+$/,'')).split('/').slice(0,-1).join('/') || '/';
goList(parent);
};
tbody.appendChild(tr);
}
items.forEach(it => {
const tr = document.createElement('tr');
const icon = it.is_dir ? '📁' : '📄';
tr.innerHTML = `
<td style="width:${COLS.name}%;">
<div class="name-cell">
<div class="icon">${icon}</div>
<div class="fname" title="${it.name}">${it.name}</div>
</div>
</td>
<td style="width:${COLS.size}%;" class="meta">${it.is_dir ? 'DIR' : fmtSize(it.size)}</td>
<td style="width:${COLS.mtime}%;" class="meta mtime">
<span class="celltext">${fmtTime(it.mtime)}</span>
</td>
<td style="width:${COLS.ops}%;">
<div class="ops">
${it.is_dir ? '' : `
<button class="iconbtn" data-act="download" title="下载">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 3v10m0 0l4-4m-4 4l-4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 17v3h16v-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
`}
<button class="iconbtn danger" data-act="delete" title="删除">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M9 3h6m-8 4h10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M10 11v7m4-7v7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M6 7l1 14h10l1-14" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
</svg>
</button>
</div>
</td>
`;
// 行选中
tr.onclick = () => {
[...tbody.querySelectorAll('tr')].forEach(x => x.classList.remove('active'));
tr.classList.add('active');
selected = it;
};
// 双击进目录
tr.ondblclick = () => {
if (it.is_dir) goList(joinPath(currentPath, it.name));
};
// 操作按钮事件(阻止冒泡,避免影响选中/双击)
tr.querySelectorAll('button[data-act]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const act = btn.getAttribute('data-act');
if (act === 'download') doDownload(it);
if (act === 'delete') doDelete(it);
});
btn.addEventListener('dblclick', (e) => e.stopPropagation());
});
tbody.appendChild(tr);
});
}
function applySearch(){
const q = (searchInput.value || '').trim().toLowerCase();
if (!q) { renderTable(allItems); return; }
renderTable(allItems.filter(it => (it.name || '').toLowerCase().includes(q)));
}
async function goList(path){
const ok = await ensureWSOpen();
if (!ok) return;
if (!sftpConnected) {
toast('请先连接 SFTP', 'warn');
return;
}
currentPath = normalizePath(path);
pathInput.value = currentPath;
dropPathEl.textContent = currentPath;
setProgress(0);
toast('加载目录中...', 'warn');
send('sftp_list', { path: currentPath });
}
// ===== SFTP controls =====
document.getElementById('btnSftpConnect').addEventListener('click', async () => {
if (!boxId) return alert('URL 缺少 ?id=xxx,不能连接。');
const ok = await ensureWSOpen();
if (!ok) return;
setSftpStatus('连接中...', false);
toast('SFTP 连接中...', 'warn');
send('sftp_connect');
});
document.getElementById('btnSftpDisconnect').addEventListener('click', () => {
send('sftp_disconnect');
sftpConnected = false;
userDisconnectedSftp = true; // 标记:用户主动断开,后续WS重连不再自动连
setSftpStatus('已断开', false);
toast('SFTP 已断开', 'warn');
// 可选:终端打印提示
println('\x1b[33m[手动操作] 已主动断开SFTP,后续WS重连不会自动连接\x1b[0m');
});
document.getElementById('btnGo').addEventListener('click', () => {
goList(pathInput.value || '/');
});
document.getElementById('btnRefresh').addEventListener('click', () => goList(currentPath));
document.getElementById('btnBack').addEventListener('click', () => {
const parent = normalizePath(currentPath.replace(/\/+$/,'')).split('/').slice(0,-1).join('/') || '/';
goList(parent);
});
document.getElementById('btnNewFolder').addEventListener('click', () => {
if (!sftpConnected) return toast('请先连接 SFTP', 'warn');
const name = prompt('新目录名称(创建在当前目录下)');
if (!name) return;
const target = joinPath(currentPath, name);
send('sftp_mkdir', { path: target });
});
document.getElementById('btnRename').addEventListener('click', () => {
if (!sftpConnected) return toast('请先连接 SFTP', 'warn');
if (!selected) return toast('请先选中一个文件/目录', 'warn');
const oldPath = joinPath(currentPath, selected.name);
const newName = prompt('新名称', selected.name);
if (!newName) return;
const newPath = joinPath(currentPath, newName);
send('sftp_rename', { from: oldPath, to: newPath });
});
document.getElementById('btnPick').addEventListener('click', () => uploadFileEl.click());
document.getElementById('btnUpload').addEventListener('click', async () => {
const f = uploadFileEl.files && uploadFileEl.files[0];
if (!f) return toast('请选择要上传的文件', 'warn');
await uploadFile(f);
});
searchInput.addEventListener('input', () => applySearch());
pathInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') goList(pathInput.value || '/'); });
// ===== Drag & Drop Upload =====
dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); });
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
dropzone.addEventListener('drop', async (e) => {
e.preventDefault();
dropzone.classList.remove('dragover');
const f = e.dataTransfer.files && e.dataTransfer.files[0];
if (!f) return;
await uploadFile(f);
});
function arrayBufferToBase64(buffer){
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
function base64ToUint8Array(b64){
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function triggerDownload(filename, uint8){
const blob = new Blob([uint8]);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function uploadFile(file){
if (!sftpConnected) return toast('请先连接 SFTP', 'warn');
const remote = joinPath(currentPath, file.name);
setOp('upload', remote);
toast(`上传中:${file.name}`, 'warn');
setProgress(0);
send('sftp_upload_begin', { path: remote, size: file.size });
const chunk = CHUNK;
const total = file.size;
let seq = 0;
for (let offset = 0; offset < total; offset += chunk) {
if (currentOp.cancelled) {
// 不再发送剩余分片,也不发 upload_end
return;
}
const blob = file.slice(offset, Math.min(total, offset + chunk));
const buf = await blob.arrayBuffer();
const b64 = arrayBufferToBase64(buf);
send('sftp_upload_chunk', { seq, data_b64: b64 });
seq++;
const pct = Math.floor(((offset + blob.size) / total) * 100);
setProgress(pct);
await new Promise(r => setTimeout(r, 1));
}
if (!currentOp.cancelled) {
send('sftp_upload_end');
}
}
// ===== Handle SFTP messages =====
function handleSftpMsg(msg){
if (msg.event === 'connected') {
sftpConnected = true;
setSftpStatus('已连接', true);
toast('SFTP 已连接', 'ok');
println(`\r\n\x1b[32m[SFTP] ${msg.msg}\x1b[0m\r\n`);
// 自动拉目录
currentPath = normalizePath(pathInput.value || '/');
goList(currentPath);
return;
}
if (msg.event === 'disconnected') {
sftpConnected = false;
setSftpStatus('已断开', false);
toast('SFTP 已断开', 'warn');
return;
}
if (msg.event === 'upload_cancelled') {
clearOp('服务端已取消上传');
return;
}
if (msg.event === 'download_cancelled') {
downloading = null;
clearOp('服务端已取消下载');
return;
}
if (msg.event === 'list') {
currentPath = normalizePath(msg.path || currentPath);
pathInput.value = currentPath;
dropPathEl.textContent = currentPath;
buildCrumbs(currentPath);
allItems = (msg.items || []);
applySearch();
toast(`已加载:${currentPath}`, 'ok');
setProgress(0);
return;
}
if (msg.event === 'mkdir_ok' || msg.event === 'rm_ok' || msg.event === 'rename_ok' || msg.event === 'upload_ok') {
toast(`${msg.event} ✅`, 'ok');
setProgress(0);
// 刷新
send('sftp_list', { path: currentPath });
return;
}
// Download flow
if (msg.event === 'download_begin') {
// 服务端会 normalize path,这里用服务端的 path 作为唯一准
const serverPath = msg.path;
// 让 currentOp / downloading 都对齐到 serverPath
currentOp.type = 'download';
currentOp.path = serverPath;
currentOp.cancelled = false;
btnCancelOp.disabled = false;
downloading = {
path: serverPath,
chunks: [],
size: msg.size || 0,
receivedBytes: 0,
};
setProgress(0);
return;
}
if (msg.event === 'download_chunk') {
if (currentOp.cancelled) return;
if (!downloading || downloading.path !== msg.path) return;
const bin = base64ToUint8Array(msg.data_b64);
downloading.chunks.push(bin);
downloading.receivedBytes += bin.byteLength;
if (downloading.size > 0) {
setProgress(Math.floor(downloading.receivedBytes / downloading.size * 100));
}
return;
}
if (msg.event === 'download_end') {
if (currentOp.cancelled) return;
if (!downloading || downloading.path !== msg.path) return;
if (!downloading.chunks || downloading.chunks.length === 0) {
downloading = null;
clearOp('下载结束,但没收到数据');
return;
}
const total = downloading.chunks.reduce((s, c) => s + c.byteLength, 0);
const merged = new Uint8Array(total);
let off = 0;
for (const c of downloading.chunks) { merged.set(c, off); off += c.byteLength; }
const filename = (downloading.path.split('/').pop() || 'download.bin');
triggerDownload(filename, merged);
downloading = null; // ✅ 必须
clearOp('下载完成');
return;
}
}
// ===== Auto connect WS only =====
ensureWSOpen();
autoConnectFromFavQuery();
})();
</script>
</body>
</html>
command代码
<?php
namespace App\Console\Commands;
use App\Models\Box;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Net\SFTP;
use phpseclib3\Net\SSH2;
use Swoole\Coroutine;
use Swoole\Runtime;
use Swoole\WebSocket\Frame;
use Swoole\WebSocket\Server;
class SwooleSshTerminal extends Command
{
protected $signature = 'swoole:ssh-terminal';
protected $description = 'Swoole WebSocket SSH Terminal (xterm.js) + SFTP - connect by box id';
private Server $ws;
/**
* fd => [
* 'ssh' => SSH2|null,
* 'sftp' => SFTP|null,
* 'connected' => bool,
* 'reader_co' => int|null,
* 'box_id' => int|null,
* 'ip' => string,
* 'port' => int,
* 'user' => string,
* 'upload_tmp' => string|null, // temp file path for upload
* 'upload_target' => string|null // remote target path
* ]
*/
private array $clients = [];
// ========== SFTP SAFETY ==========
// 允许访问的远端根目录(强烈建议设置,防止访问 /etc 等)
// 如果你想不限制,设为 null(不推荐)
private ?string $sftpRoot = '/'; // 例如:'/home' 或 '/home/ubuntu'
// 下载/上传分片大小(字节)
private int $chunkSize = 64 * 1024; // 64KB
public function handle()
{
Runtime::enableCoroutine(true);
$this->ws = new Server('0.0.0.0', 9506);
$this->ws->set([
'worker_num' => 1,
'daemonize' => false,
'log_file' => storage_path('logs/ssh_terminal.log'),
'log_level' => SWOOLE_LOG_INFO,
'open_tcp_nodelay' => true,
'enable_coroutine' => true,
'max_conn' => 1024,
'dispatch_mode' => 2,
]);
$this->ws->on('Open', function (Server $server, $request) {
$fd = $request->fd;
$key = (string)($request->get['key'] ?? '');
if ($key === '' || !Redis::exists($key)) {
$server->push($fd, json_encode(['type'=>'error','msg'=>'Invalid/expired key'], JSON_UNESCAPED_UNICODE));
$server->disconnect($fd);
return;
}
// ✅ 从 ws://host/ws/?id=123 读取 box id
$rawId = (string)($request->get['id'] ?? '');
$boxId = ctype_digit($rawId) ? (int)$rawId : null;
$this->clients[$fd] = [
'ssh' => null,
'sftp' => null,
'connected' => false,
'reader_co' => null,
'box_id' => $boxId,
'ip' => '',
'port' => 0,
'user' => '',
'upload_tmp' => null,
'upload_target' => null,
'download_co' => null,
'download_path' => null,
];
$this->dbg($fd, 'WS Open', [
'remote_addr' => $request->server['remote_addr'] ?? '',
'remote_port' => $request->server['remote_port'] ?? '',
'box_id' => $boxId,
'raw_id' => $rawId,
]);
$server->push($fd, json_encode([
'type' => 'system',
'msg' => $boxId
? "WebSocket connected. BoxID={$boxId}. Click Connect SSH / Connect SFTP."
: "WebSocket connected. Missing/invalid ?id=xxx"
], JSON_UNESCAPED_UNICODE));
});
$this->ws->on('Message', function (Server $server, Frame $frame) {
$fd = $frame->fd;
$data = json_decode($frame->data, true);
if (!is_array($data) || empty($data['action'])) {
$this->err($fd, 'Bad message format', null, ['raw' => $frame->data]);
return;
}
try {
switch ($data['action']) {
case 'sftp_upload_cancel':
$this->sftpUploadCancel($fd);
break;
case 'sftp_download_cancel':
$this->sftpDownloadCancel($fd);
break;
case 'connect_ssh':
$cols = (int)($data['cols'] ?? 80);
$rows = (int)($data['rows'] ?? 24);
// ✅ 新增:优先使用前端传的 host/user/auth
$host = trim((string)($data['host'] ?? ''));
$user = trim((string)($data['user'] ?? ''));
$port = (int)($data['port'] ?? 22);
$auth = $data['auth'] ?? null;
if ($host !== '' && $user !== '' && is_array($auth)) {
$this->connectSshCustom($fd, $host, $port, $user, $cols, $rows, $auth);
} else {
// 兼容老逻辑:按 box_id
$this->connectSshByBoxId($fd, $cols, $rows);
}
break;
case 'input':
$this->writeInput($fd, (string)($data['data'] ?? ''));
break;
case 'resize':
$this->resizePty($fd, (int)($data['cols'] ?? 80), (int)($data['rows'] ?? 24));
break;
case 'disconnect':
$this->disconnect($fd);
break;
// ===== SFTP (新增) =====
case 'sftp_connect':
$host = trim((string)($data['host'] ?? ''));
$user = trim((string)($data['user'] ?? ''));
$port = (int)($data['port'] ?? 22);
$auth = $data['auth'] ?? null;
if ($host !== '' && $user !== '' && is_array($auth)) {
$this->connectSftpCustom($fd, $host, $port, $user, $auth);
} else {
$this->connectSftpByBoxId($fd);
}
break;
case 'sftp_list':
$this->sftpList($fd, (string)($data['path'] ?? '/'));
break;
case 'sftp_mkdir':
$this->sftpMkdir($fd, (string)($data['path'] ?? ''));
break;
case 'sftp_rm':
$this->sftpRemove($fd, (string)($data['path'] ?? ''));
break;
case 'sftp_rename':
$this->sftpRename($fd, (string)($data['from'] ?? ''), (string)($data['to'] ?? ''));
break;
case 'sftp_download_begin':
$this->sftpDownloadBegin($fd, (string)($data['path'] ?? ''), (int)($data['chunk'] ?? $this->chunkSize));
break;
case 'sftp_upload_begin':
$this->sftpUploadBegin($fd, (string)($data['path'] ?? ''), (int)($data['size'] ?? 0));
break;
case 'sftp_upload_chunk':
$this->sftpUploadChunk($fd, (int)($data['seq'] ?? 0), (string)($data['data_b64'] ?? ''));
break;
case 'sftp_upload_end':
$this->sftpUploadEnd($fd);
break;
case 'sftp_disconnect':
$this->sftpDisconnect($fd, false);
break;
default:
$this->pushError($fd, 'Unknown action: ' . $data['action']);
}
} catch (\Throwable $e) {
$this->err($fd, 'Message handler exception', $e, ['action' => $data['action'] ?? '']);
}
});
$this->ws->on('Close', function (Server $server, int $fd) {
$this->dbg($fd, 'WS Close');
$this->disconnect($fd, true);
unset($this->clients[$fd]);
});
$this->info('SSH+SFTP Terminal WS started at ws://0.0.0.0:9506');
$this->ws->start();
}
private function sftpUploadCancel(int $fd): void
{
// 清理临时文件 & 状态
$this->sftpUploadCleanup($fd);
if ($this->ws->isEstablished($fd)) {
$this->ws->push($fd, json_encode([
'type' => 'sftp',
'event' => 'upload_cancelled',
], JSON_UNESCAPED_UNICODE));
}
}
/**
* ✅ 按 box_id 查 Box,拿 ssh_ip / ssh_port / ssh_name
*/
private function connectSshByBoxId(int $fd, int $cols, int $rows): void
{
$conn = $this->clients[$fd] ?? null;
if (!$conn) return;
$boxId = $conn['box_id'] ?? null;
if (!$boxId) {
$this->pushError($fd, 'Missing/invalid box id (ws url must have ?id=123).');
return;
}
$box = Box::query()->find($boxId);
if (!$box) {
$this->pushError($fd, "Box not found: id={$boxId}");
return;
}
$ip = (string)($box->ssh_ip ?? '');
$port = (int)($box->ssh_port ?? 0);
$user = (string)($box->ssh_name ?? '');
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
$this->pushError($fd, "Invalid ssh_ip in Box(id={$boxId}): {$ip}");
return;
}
if ($port < 1 || $port > 65535) {
$this->pushError($fd, "Invalid ssh_port in Box(id={$boxId}): {$port}");
return;
}
if ($user === '') {
$this->pushError($fd, "Invalid ssh_name in Box(id={$boxId})");
return;
}
$this->connectSsh($fd, $ip, $port, $user, $cols, $rows, $boxId);
}
/**
* ✅ 自定义 SSH 连接(来自收藏夹)
* $auth example:
* ['type'=>'password','password'=>'xxx']
* ['type'=>'key','privateKey'=>'-----BEGIN...','passphrase'=>'optional']
*/
private function connectSshCustom(int $fd, string $host, int $port, string $user, int $cols, int $rows, array $auth): void
{
if (!isset($this->clients[$fd])) return;
// 基本校验
$port = ($port >= 1 && $port <= 65535) ? $port : 22;
// host 允许 IP 或域名
if ($host === '') {
$this->pushError($fd, 'Custom SSH: host empty');
return;
}
if ($user === '') {
$this->pushError($fd, 'Custom SSH: user empty');
return;
}
$this->dbg($fd, 'connect_ssh custom', [
'host' => $host,
'port' => $port,
'user' => $user,
'cols' => $cols,
'rows' => $rows,
'auth_type' => (string)($auth['type'] ?? ''),
]);
// 断开旧连接(SSH + SFTP都断,避免冲突)
$this->disconnect($fd);
try {
$ssh = new SSH2($host, $port, 10);
$type = (string)($auth['type'] ?? '');
$ok = false;
if ($type === 'password') {
$password = (string)($auth['password'] ?? '');
if ($password === '') {
$this->pushError($fd, 'Custom SSH: password empty');
return;
}
$ok = $ssh->login($user, $password);
} elseif ($type === 'key') {
$privateKey = (string)($auth['privateKey'] ?? '');
$passphrase = (string)($auth['passphrase'] ?? '');
if ($privateKey === '') {
$this->pushError($fd, 'Custom SSH: privateKey empty');
return;
}
// phpseclib 支持带 passphrase 的 key
$key = $passphrase !== ''
? PublicKeyLoader::loadPrivateKey($privateKey, $passphrase)
: PublicKeyLoader::loadPrivateKey($privateKey);
$ok = $ssh->login($user, $key);
} else {
$this->pushError($fd, 'Custom SSH: unknown auth.type');
return;
}
if (!$ok) {
$this->pushError($fd, 'Custom SSH: login returned false.');
return;
}
$cols = max(10, $cols);
$rows = max(5, $rows);
$ssh->enablePTY('xterm', $cols, $rows);
$ssh->exec('/bin/bash -li');
$ssh->setTimeout(1);
$ssh->write("stty cols {$cols} rows {$rows} erase '^?' echo -ixon icanon\n");
Coroutine::sleep(0.05);
$this->clients[$fd]['ssh'] = $ssh;
$this->clients[$fd]['connected'] = true;
$this->clients[$fd]['ip'] = $host;
$this->clients[$fd]['port'] = $port;
$this->clients[$fd]['user'] = $user;
$this->ws->push($fd, json_encode([
'type' => 'connected',
'msg' => "Connected: {$user}@{$host}:{$port} (Custom)"
], JSON_UNESCAPED_UNICODE));
$this->startReaderCoroutine($fd, $ssh);
} catch (\Throwable $e) {
$this->err($fd, 'connectSshCustom exception', $e);
$this->disconnect($fd);
}
}
/**
* ✅ 固定密钥 id_rsa + PTY + bash + reader
*/
private function connectSsh(int $fd, string $ip, int $port, string $user, int $cols, int $rows, int $boxId): void
{
if (!isset($this->clients[$fd])) return;
$this->dbg($fd, 'connect_ssh resolved from Box', [
'box_id' => $boxId,
'ip' => $ip,
'port' => $port,
'user' => $user,
'cols' => $cols,
'rows' => $rows,
]);
// 断开旧连接(SSH + SFTP 都断,避免冲突)
$this->disconnect($fd);
try {
$keyPath = 'edge-gateway-id-rsa/id_rsa_' . $boxId;
// 如果这台设备没有生成专属 key,就退回默认
if (!Storage::disk('local')->exists($keyPath)) {
$keyPath = 'edge-gateway-id-rsa/id_rsa';
}
if (!Storage::disk('local')->exists($keyPath)) {
$this->pushError($fd, 'Key not found: ' . $keyPath);
return;
}
$rsa = PublicKeyLoader::load(Storage::disk('local')->get($keyPath));
$ssh = new SSH2($ip, $port, 10);
$this->dbg($fd, 'login start');
$ok = $ssh->login($user, $rsa);
$this->dbg($fd, 'login result', ['ok' => $ok]);
if (!$ok) {
$this->pushError($fd, 'SSH login returned false.');
return;
}
$cols = max(10, $cols);
$rows = max(5, $rows);
$ssh->enablePTY('xterm', $cols, $rows);
// ⚠️ timeout 只能整数秒
$ssh->exec('/bin/bash -li');
$ssh->setTimeout(0.1);
$ssh->write("stty cols {$cols} rows {$rows} erase '^?' echo -ixon icanon\n");
Coroutine::sleep(0.05);
$this->clients[$fd]['ssh'] = $ssh;
$this->clients[$fd]['connected'] = true;
$this->clients[$fd]['ip'] = $ip;
$this->clients[$fd]['port'] = $port;
$this->clients[$fd]['user'] = $user;
$this->ws->push($fd, json_encode([
'type' => 'connected',
'msg' => "Connected: {$user}@{$ip}:{$port} (BoxID={$boxId})"
], JSON_UNESCAPED_UNICODE));
$this->startReaderCoroutine($fd, $ssh);
} catch (\Throwable $e) {
$this->err($fd, 'connectSsh exception', $e);
$this->disconnect($fd);
}
}
private function startReaderCoroutine(int $fd, SSH2 $ssh): void
{
if (!empty($this->clients[$fd]['reader_co'])) {
try { Coroutine::cancel($this->clients[$fd]['reader_co']); } catch (\Throwable $e) {}
$this->clients[$fd]['reader_co'] = null;
}
$this->clients[$fd]['reader_co'] = Coroutine::create(function () use ($fd, $ssh) {
try {
while (true) {
if (!isset($this->clients[$fd])) break;
if (!$this->ws->isEstablished($fd)) break;
if (!$ssh->isConnected()) break;
$out = $ssh->read('', SSH2::READ_NEXT);
// ✅ 防止刷 "1"
if (!is_string($out)) {
Coroutine::sleep(0.005);
continue;
}
if ($out !== '') {
$this->pushOutput($fd, $out);
continue;
}
Coroutine::sleep(0.005);
}
} catch (\Throwable $e) {
Log::error('[ERR] reader exception', [
'fd' => $fd,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
$this->disconnect($fd, false);
});
}
private function writeInput(int $fd, string $data): void
{
$conn = $this->clients[$fd] ?? null;
if (!$conn || !$conn['connected'] || !$conn['ssh']) {
$this->pushError($fd, 'SSH not connected.');
return;
}
try {
$conn['ssh']->write($data);
} catch (\Throwable $e) {
$this->err($fd, 'writeInput exception', $e);
$this->disconnect($fd);
}
}
private function resizePty(int $fd, int $cols, int $rows): void
{
$conn = $this->clients[$fd] ?? null;
if (!$conn || !$conn['connected'] || !$conn['ssh']) return;
$cols = max(10, $cols);
$rows = max(5, $rows);
try {
if (method_exists($conn['ssh'], 'setWindowSize')) {
$conn['ssh']->setWindowSize($cols, $rows);
}
$conn['ssh']->write("stty cols {$cols} rows {$rows}\n");
} catch (\Throwable $e) {
$this->err($fd, 'resize exception', $e);
}
}
// =========================
// ========== SFTP ==========
// =========================
private function connectSftpByBoxId(int $fd): void
{
$conn = $this->clients[$fd] ?? null;
if (!$conn) return;
$boxId = $conn['box_id'] ?? null;
if (!$boxId) {
$this->pushError($fd, 'Missing/invalid box id (ws url must have ?id=123).');
return;
}
$box = Box::query()->find($boxId);
if (!$box) {
$this->pushError($fd, "Box not found: id={$boxId}");
return;
}
$ip = (string)($box->ssh_ip ?? '');
$port = (int)($box->ssh_port ?? 0);
$user = (string)($box->ssh_name ?? '');
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
$this->pushError($fd, "Invalid ssh_ip in Box(id={$boxId}): {$ip}");
return;
}
if ($port < 1 || $port > 65535) {
$this->pushError($fd, "Invalid ssh_port in Box(id={$boxId}): {$port}");
return;
}
if ($user === '') {
$this->pushError($fd, "Invalid ssh_name in Box(id={$boxId})");
return;
}
$this->connectSftp($fd, $ip, $port, $user, $boxId);
}
private function connectSftpCustom(int $fd, string $host, int $port, string $user, array $auth): void
{
if (!isset($this->clients[$fd])) return;
$port = ($port >= 1 && $port <= 65535) ? $port : 22;
// 先断开旧 SFTP(保留 SSH 不动)
$this->sftpDisconnect($fd, true);
try {
$sftp = new SFTP($host, $port, 10);
$type = (string)($auth['type'] ?? '');
$ok = false;
if ($type === 'password') {
$password = (string)($auth['password'] ?? '');
if ($password === '') {
$this->pushError($fd, 'Custom SFTP: password empty');
return;
}
$ok = $sftp->login($user, $password);
} elseif ($type === 'key') {
$privateKey = (string)($auth['privateKey'] ?? '');
$passphrase = (string)($auth['passphrase'] ?? '');
if ($privateKey === '') {
$this->pushError($fd, 'Custom SFTP: privateKey empty');
return;
}
$key = $passphrase !== ''
? PublicKeyLoader::loadPrivateKey($privateKey, $passphrase)
: PublicKeyLoader::loadPrivateKey($privateKey);
$ok = $sftp->login($user, $key);
} else {
$this->pushError($fd, 'Custom SFTP: unknown auth.type');
return;
}
if (!$ok) {
$this->pushError($fd, 'Custom SFTP: login returned false.');
return;
}
$this->clients[$fd]['sftp'] = $sftp;
$this->clients[$fd]['ip'] = $host;
$this->clients[$fd]['port'] = $port;
$this->clients[$fd]['user'] = $user;
$this->ws->push($fd, json_encode([
'type' => 'sftp',
'event' => 'connected',
'msg' => "SFTP connected: {$user}@{$host}:{$port} (Custom)",
'root' => $this->sftpRoot,
], JSON_UNESCAPED_UNICODE));
} catch (\Throwable $e) {
$this->err($fd, 'connectSftpCustom exception', $e);
$this->clients[$fd]['sftp'] = null;
}
}
private function connectSftp(int $fd, string $ip, int $port, string $user, int $boxId): void
{
if (!isset($this->clients[$fd])) return;
// 先断开旧 SFTP(保留 SSH 不动)
$this->sftpDisconnect($fd, true);
try {
$keyPath = 'edge-gateway-id-rsa/id_rsa_' . $boxId;
if (!Storage::disk('local')->exists($keyPath)) {
$keyPath = 'edge-gateway-id-rsa/id_rsa';
}
if (!Storage::disk('local')->exists($keyPath)) {
$this->pushError($fd, 'Key not found: ' . $keyPath);
return;
}
$rsa = PublicKeyLoader::load(Storage::disk('local')->get($keyPath));
$sftp = new SFTP($ip, $port, 10);
$ok = $sftp->login($user, $rsa);
if (!$ok) {
$this->pushError($fd, 'SFTP login returned false.');
return;
}
$this->clients[$fd]['sftp'] = $sftp;
$this->clients[$fd]['ip'] = $ip;
$this->clients[$fd]['port'] = $port;
$this->clients[$fd]['user'] = $user;
$this->ws->push($fd, json_encode([
'type' => 'sftp',
'event' => 'connected',
'msg' => "SFTP connected: {$user}@{$ip}:{$port} (BoxID={$boxId})",
'root' => $this->sftpRoot,
], JSON_UNESCAPED_UNICODE));
} catch (\Throwable $e) {
$this->err($fd, 'connectSftp exception', $e);
$this->clients[$fd]['sftp'] = null;
}
}
private function sftpEnsure(int $fd): ?SFTP
{
$sftp = $this->clients[$fd]['sftp'] ?? null;
if (!$sftp) {
$this->pushError($fd, 'SFTP not connected. Click "Connect SFTP" first.');
return null;
}
return $sftp;
}
private function sftpNormalizePath(string $path): string
{
$path = trim($path);
if ($path === '') return '/';
if ($path[0] !== '/') $path = '/' . $path;
// 规范化:去掉 /./ 和处理 /../
$parts = [];
foreach (explode('/', $path) as $p) {
if ($p === '' || $p === '.') continue;
if ($p === '..') { array_pop($parts); continue; }
$parts[] = $p;
}
return '/' . implode('/', $parts);
}
private function sftpAssertAllowed(int $fd, string $path): ?string
{
$norm = $this->sftpNormalizePath($path);
if ($this->sftpRoot === null) return $norm;
$root = $this->sftpNormalizePath($this->sftpRoot);
if ($root !== '/' && !str_starts_with($norm . '/', $root . '/')) {
$this->pushError($fd, "Path not allowed. root={$root}, path={$norm}");
return null;
}
return $norm;
}
private function sftpList(int $fd, string $path): void
{
$sftp = $this->sftpEnsure($fd);
if (!$sftp) return;
$path = $this->sftpAssertAllowed($fd, $path);
if ($path === null) return;
$list = $sftp->rawlist($path);
if ($list === false) {
$this->pushError($fd, "List failed: {$path}");
return;
}
$items = [];
foreach ($list as $name => $meta) {
if ($name === '.' || $name === '..') continue;
$type = $meta['type'] ?? null; // 1=file, 2=dir (phpseclib)
$items[] = [
'name' => $name,
'is_dir' => ($type === 2),
'size' => (int)($meta['size'] ?? 0),
'mtime' => (int)($meta['mtime'] ?? 0),
];
}
usort($items, function ($a, $b) {
if ($a['is_dir'] !== $b['is_dir']) return $a['is_dir'] ? -1 : 1;
return strcmp($a['name'], $b['name']);
});
$this->ws->push($fd, json_encode([
'type' => 'sftp',
'event' => 'list',
'path' => $path,
'items' => $items,
], JSON_UNESCAPED_UNICODE));
}
private function sftpMkdir(int $fd, string $path): void
{
$sftp = $this->sftpEnsure($fd);
if (!$sftp) return;
$path = $this->sftpAssertAllowed($fd, $path);
if ($path === null) return;
$ok = $sftp->mkdir($path, -1, true);
if (!$ok) {
$this->pushError($fd, "mkdir failed: {$path}");
return;
}
$this->ws->push($fd, json_encode([
'type' => 'sftp',
'event' => 'mkdir_ok',
'path' => $path,
], JSON_UNESCAPED_UNICODE));
}
/**
* 递归删除文件/目录(模拟 rm -rf)- 适配 phpseclib3
*/
private function sftpRemove(int $fd, string $path): void
{
$sftp = $this->sftpEnsure($fd);
if (!$sftp) return;
$path = $this->sftpAssertAllowed($fd, $path);
if ($path === null) return;
try {
// 1. 检查路径是否存在(phpseclib3 stat失败返回false,无异常)
$st = $sftp->stat($path);
if ($st === false) {
$error = $sftp->getLastError() ?: '路径不存在或无访问权限';
$this->pushError($fd, "删除失败: {$path} | {$error}");
return;
}
$isDir = (($st['type'] ?? null) === 2);
$ok = false;
if ($isDir) {
// 2. 目录:递归删除内部所有内容
$ok = $this->sftpRecursiveRemoveDir($sftp, $path);
} else {
// 3. 文件:直接删除
$ok = $sftp->delete($path);
}
if (!$ok) {
$error = $sftp->getLastError() ?: '未知错误';
$this->pushError($fd, ($isDir ? "递归删除目录" : "删除文件") . "失败: {$path} | {$error} | 请检查SFTP用户权限");
return;
}
$this->ws->push($fd, json_encode([
'type' => 'sftp',
'event' => 'rm_ok',
'path' => $path,
], JSON_UNESCAPED_UNICODE));
} catch (\Throwable $e) {
// 捕获phpseclib3可能抛出的异常
$this->err($fd, '删除操作异常', $e, ['path' => $path]);
}
}
/**
* 递归删除目录(内部方法)- 适配 phpseclib3
*/
private function sftpRecursiveRemoveDir(SFTP $sftp, string $dir): bool
{
try {
// 获取目录下所有文件/子目录(包含隐藏文件,phpseclib3 rawlist返回数组或false)
$list = $sftp->rawlist($dir);
if ($list === false) {
return false;
}
foreach ($list as $name => $meta) {
if ($name === '.' || $name === '..') continue;
$fullPath = rtrim($dir, '/') . '/' . $name;
$isSubDir = (($meta['type'] ?? null) === 2);
if ($isSubDir) {
// 递归删除子目录
if (!$this->sftpRecursiveRemoveDir($sftp, $fullPath)) {
return false;
}
} else {
// 删除文件(phpseclib3 delete返回bool)
if (!$sftp->delete($fullPath)) {
return false;
}
}
}
// 最后删除空目录(phpseclib3 rmdir返回bool)
return $sftp->rmdir($dir);
} catch (\Throwable $e) {
// 记录递归删除中的异常
Log::error('递归删除目录异常', [
'dir' => $dir,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return false;
}
}
private function sftpRename(int $fd, string $from, string $to): void
{
$sftp = $this->sftpEnsure($fd);
if (!$sftp) return;
$from = $this->sftpAssertAllowed($fd, $from);
$to = $this->sftpAssertAllowed($fd, $to);
if ($from === null || $to === null) return;
$ok = $sftp->rename($from, $to);
if (!$ok) {
$this->pushError($fd, "rename failed: {$from} -> {$to}");
return;
}
$this->ws->push($fd, json_encode([
'type' => 'sftp',
'event' => 'rename_ok',
'from' => $from,
'to' => $to,
], JSON_UNESCAPED_UNICODE));
}
private function sftpDownloadBegin(int $fd, string $path, int $chunk): void
{
$sftp = $this->sftpEnsure($fd);
if (!$sftp) return;
$path = $this->sftpAssertAllowed($fd, $path);
if ($path === null) return;
$chunk = max(16 * 1024, min(512 * 1024, $chunk)); // 16KB ~ 512KB
$st = $sftp->stat($path);
if ($st === false) {
$this->pushError($fd, "download stat failed: {$path}");
return;
}
if (($st['type'] ?? null) === 2) {
$this->pushError($fd, "download failed: is a directory: {$path}");
return;
}
$size = (int)($st['size'] ?? 0);
$this->ws->push($fd, json_encode([
'type' => 'sftp',
'event' => 'download_begin',
'path' => $path,
'size' => $size,
'chunk' => $chunk,
], JSON_UNESCAPED_UNICODE));
// 开始新下载前,先取消旧的
if (!empty($this->clients[$fd]['download_co'])) {
try { Coroutine::cancel($this->clients[$fd]['download_co']); } catch (\Throwable $e) {}
$this->clients[$fd]['download_co'] = null;
$this->clients[$fd]['download_path'] = null;
}
$this->clients[$fd]['download_path'] = $path;
$coId = Coroutine::create(function () use ($fd, $sftp, $path, $size, $chunk) {
try {
$offset = 0;
$seq = 0;
while ($offset < $size) {
if (!isset($this->clients[$fd]) || !$this->ws->isEstablished($fd)) return;
// ✅ 如果被取消/切换到另一个下载,就退出
if (($this->clients[$fd]['download_path'] ?? '') !== $path) {
return;
}
$len = min($chunk, $size - $offset);
$bin = $sftp->get($path, false, $offset, $len);
if ($bin === false) {
$this->pushError($fd, "download get failed at offset={$offset}: {$path}");
return;
}
$this->ws->push($fd, json_encode([
'type' => 'sftp',
'event' => 'download_chunk',
'path' => $path,
'seq' => $seq,
'offset' => $offset,
'data_b64' => base64_encode($bin),
], JSON_UNESCAPED_UNICODE));
$offset += $len;
$seq++;
Coroutine::sleep(0.001);
}
// 正常结束时,清掉状态
if (isset($this->clients[$fd])) {
$this->clients[$fd]['download_co'] = null;
$this->clients[$fd]['download_path'] = null;
}
$this->ws->push($fd, json_encode([
'type' => 'sftp',
'event' => 'download_end',
'path' => $path,
'chunks' => $seq,
'size' => $size,
], JSON_UNESCAPED_UNICODE));
} catch (\Throwable $e) {
$this->err($fd, 'download coroutine exception', $e);
}
});
$this->clients[$fd]['download_co'] = $coId;
}
private function sftpDownloadCancel(int $fd): void
{
if (isset($this->clients[$fd])) {
// ✅ 先让协程的 while 检查能立刻退出
$this->clients[$fd]['download_path'] = null;
}
$co = $this->clients[$fd]['download_co'] ?? null;
if ($co) {
try { Coroutine::cancel($co); } catch (\Throwable $e) {}
}
if (isset($this->clients[$fd])) {
$this->clients[$fd]['download_co'] = null;
}
if ($this->ws->isEstablished($fd)) {
$this->ws->push($fd, json_encode([
'type' => 'sftp',
'event' => 'download_cancelled',
], JSON_UNESCAPED_UNICODE));
}
}
private function sftpUploadBegin(int $fd, string $remotePath, int $size): void
{
$sftp = $this->sftpEnsure($fd);
if (!$sftp) return;
$remotePath = $this->sftpAssertAllowed($fd, $remotePath);
if ($remotePath === null) return;
// 清理旧的
$this->sftpUploadCleanup($fd);
$tmp = storage_path('app/sftp_upload_' . $fd . '_' . md5($remotePath . microtime(true)) . '.tmp');
@file_put_contents($tmp, '');
$this->clients[$fd]['upload_tmp'] = $tmp;
$this->clients[$fd]['upload_target'] = $remotePath;
$this->ws->push($fd, json_encode([
'type' => 'sftp',
'event' => 'upload_begin_ok',
'path' => $remotePath,
'tmp' => basename($tmp),
'size' => $size,
], JSON_UNESCAPED_UNICODE));
}
private function sftpUploadChunk(int $fd, int $seq, string $b64): void
{
$tmp = $this->clients[$fd]['upload_tmp'] ?? null;
if (!$tmp || !is_string($tmp)) {
$this->pushError($fd, 'upload_chunk: no active upload (call sftp_upload_begin first)');
return;
}
$bin = base64_decode($b64, true);
if ($bin === false) {
$this->pushError($fd, 'upload_chunk: invalid base64');
return;
}
$ok = @file_put_contents($tmp, $bin, FILE_APPEND);
if ($ok === false) {
$this->pushError($fd, 'upload_chunk: write tmp failed');
return;
}
// 可选:回 ACK(前端可不处理)
$this->ws->push($fd, json_encode([
'type' => 'sftp',
'event' => 'upload_chunk_ok',
'seq' => $seq,
], JSON_UNESCAPED_UNICODE));
}
private function sftpUploadEnd(int $fd): void
{
$sftp = $this->sftpEnsure($fd);
if (!$sftp) return;
$tmp = $this->clients[$fd]['upload_tmp'] ?? null;
$remote = $this->clients[$fd]['upload_target'] ?? null;
if (!$tmp || !$remote) {
$this->pushError($fd, 'upload_end: no active upload');
return;
}
if (!is_file($tmp)) {
$this->pushError($fd, 'upload_end: tmp missing');
$this->sftpUploadCleanup($fd);
return;
}
try {
// ✅ 用 SOURCE_LOCAL_FILE 让 phpseclib 自己读本地文件(避免一次性读入内存)
$ok = $sftp->put($remote, $tmp, SFTP::SOURCE_LOCAL_FILE);
if (!$ok) {
$this->pushError($fd, "upload_end: put failed: {$remote}");
$this->sftpUploadCleanup($fd);
return;
}
$this->ws->push($fd, json_encode([
'type' => 'sftp',
'event' => 'upload_ok',
'path' => $remote,
], JSON_UNESCAPED_UNICODE));
} catch (\Throwable $e) {
$this->err($fd, 'upload_end exception', $e);
} finally {
$this->sftpUploadCleanup($fd);
}
}
private function sftpUploadCleanup(int $fd): void
{
$tmp = $this->clients[$fd]['upload_tmp'] ?? null;
if ($tmp && is_string($tmp) && is_file($tmp)) {
@unlink($tmp);
}
if (isset($this->clients[$fd])) {
$this->clients[$fd]['upload_tmp'] = null;
$this->clients[$fd]['upload_target'] = null;
}
}
private function sftpDisconnect(int $fd, bool $silent = false): void
{
$conn = $this->clients[$fd] ?? null;
if (!$conn) return;
$this->sftpUploadCleanup($fd);
if (!empty($conn['sftp'])) {
try { $conn['sftp']->disconnect(); } catch (\Throwable $e) {}
$this->clients[$fd]['sftp'] = null;
}
if (!$silent && $this->ws->isEstablished($fd)) {
$this->ws->push($fd, json_encode([
'type' => 'sftp',
'event' => 'disconnected',
'msg' => 'SFTP disconnected.',
], JSON_UNESCAPED_UNICODE));
}
}
// =========================
// ===== Disconnect All =====
// =========================
private function disconnect(int $fd, bool $isClose = false): void
{
$conn = $this->clients[$fd] ?? null;
if (!$conn) return;
// 先停 SSH reader
if (!empty($conn['reader_co'])) {
try { Coroutine::cancel($conn['reader_co']); } catch (\Throwable $e) {}
$this->clients[$fd]['reader_co'] = null;
}
// 断 SSH
if (!empty($conn['ssh'])) {
try { $conn['ssh']->disconnect(); } catch (\Throwable $e) {}
$this->clients[$fd]['ssh'] = null;
}
$this->clients[$fd]['connected'] = false;
$this->sftpDownloadCancel($fd);
// 断 SFTP(新增)
$this->sftpDisconnect($fd, true);
if (!$isClose && $this->ws->isEstablished($fd)) {
$this->ws->push($fd, json_encode([
'type' => 'system',
'msg' => 'SSH disconnected.'
], JSON_UNESCAPED_UNICODE));
}
}
private function pushOutput(int $fd, string $out): void
{
if ($out === '') return;
if ($this->ws->isEstablished($fd)) {
$this->ws->push($fd, json_encode([
'type' => 'output',
'data' => $out
], JSON_UNESCAPED_UNICODE));
}
}
private function pushError(int $fd, string $msg): void
{
Log::warning('[PUSH_ERROR]', ['fd' => $fd, 'msg' => $msg]);
if ($this->ws->isEstablished($fd)) {
$this->ws->push($fd, json_encode([
'type' => 'error',
'msg' => $msg
], JSON_UNESCAPED_UNICODE));
}
}
private function dbg(int $fd, string $msg, array $ctx = []): void
{
Log::info('[DBG] ' . $msg, array_merge(['fd' => $fd], $ctx));
if ($this->ws->isEstablished($fd)) {
$this->ws->push($fd, json_encode([
'type' => 'system',
'msg' => '[DBG] ' . $msg . (empty($ctx) ? '' : ' ' . json_encode($ctx, JSON_UNESCAPED_UNICODE))
], JSON_UNESCAPED_UNICODE));
}
}
private function err(int $fd, string $msg, \Throwable $e = null, array $ctx = []): void
{
$payload = array_merge(['fd' => $fd], $ctx);
if ($e) {
$payload['error'] = $e->getMessage();
$payload['trace'] = $e->getTraceAsString();
$payload['file'] = $e->getFile();
$payload['line'] = $e->getLine();
}
Log::error('[ERR] ' . $msg, $payload);
$this->pushError($fd, $msg . ($e ? (' | ' . $e->getMessage()) : ''));
}
}
关于 LearnKu
推荐文章: