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>SFTP终端</title>
<link rel="stylesheet" href="/css/xterm-6.0.0.css">
<style>
:root{
--bg: #0a1220;
--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: #2563eb;
--accent2: #06b6d4;
--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;
overflow-x: hidden;
font-family: "SF Pro Display","PingFang SC","Segoe UI","Hiragino Sans GB","Microsoft YaHei",sans-serif;
background:
radial-gradient(900px 420px at 10% 0%, rgba(37,99,235,.22), transparent 62%),
radial-gradient(700px 420px at 90% 10%, rgba(6,182,212,.16), transparent 62%),
var(--bg);
}
/* ===== Top Bar ===== */
.topbar{
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(10px);
background: rgba(10,18,32,.78);
border-bottom: 1px solid var(--stroke);
}
.topbar-inner{
padding: 10px 14px 8px;
display: flex;
align-items: center;
gap: 10px;
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), var(--accent2));
box-shadow: 0 0 0 4px rgba(37,99,235,.16);
}
.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:8px;
flex-wrap: wrap;
}
.controls-main .btn{
min-width: 96px;
justify-content: center;
}
.box-web-access{
display:flex;
align-items:center;
gap:8px;
flex-wrap:wrap;
}
.box-web-label,
.box-web-warning{
font-size:12px;
color:var(--muted);
white-space:nowrap;
}
.box-web-warning{
color:#fbbf24;
}
.controls-quick{
width: 100%;
gap: 8px;
}
.terminal-safety-layer{
position: absolute;
left: 0;
right: 0;
top: 64px;
height: 42px;
z-index: 80;
overflow: hidden;
pointer-events: none;
contain: paint;
}
.terminal-safety-float{
position: absolute;
top: 0;
left: 0;
width: max-content;
max-width: none;
animation: safetyFloatCycle 18s linear infinite;
will-change: transform, opacity;
}
.terminal-safety-float-inner{
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 7px 14px;
border: 1px solid rgba(251,191,36,.42);
border-radius: 999px;
background:
linear-gradient(135deg, rgba(251,191,36,.18), rgba(239,68,68,.14)),
rgba(11,16,32,.92);
color: #fde68a;
box-shadow: 0 12px 30px rgba(0,0,0,.28), 0 0 0 1px rgba(239,68,68,.10) inset;
overflow: hidden;
white-space: nowrap;
}
.terminal-safety-icon{
flex: 0 0 auto;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(239,68,68,.22);
color: #fecaca;
font-size: 13px;
}
.terminal-safety-text{
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
line-height: 1.2;
font-weight: 700;
}
@keyframes safetyFloatCycle {
0%, 8% {
opacity: 0;
transform: translateX(calc(100vw + 32px));
}
14%, 78% {
opacity: 1;
}
92%, 100% {
opacity: 0;
transform: translateX(calc(-100% - 32px));
}
}
.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(37,99,235,.34), rgba(6,182,212,.14));
border-color: rgba(37,99,235,.42);
}
.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; }
.btn:disabled{
opacity: .45;
cursor: not-allowed;
transform: none;
}
.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(37,99,235,.58);
box-shadow: 0 0 0 4px rgba(37,99,235,.16);
}
.spacer{ flex: 1; }
.quick-strip{
padding: 0 14px 10px;
border-top: 1px dashed rgba(255,255,255,.09);
}
.quick-strip.hidden{
display: none;
}
.status-group{
display:flex;
align-items:center;
gap:8px;
flex-wrap: wrap;
}
#wrap{
flex: 1; /* 占满 topbar 下面剩余高度 */
min-height: 0; /* 关键:允许内部滚动容器正确收缩 */
padding: 12px;
display: grid;
grid-template-columns: minmax(360px, 420px) minmax(0, 1fr);
gap: 12px;
}
#wrap.sftp-hidden{
grid-template-columns: minmax(0, 1fr);
}
#wrap.sftp-hidden #sftpPanel{
display: none;
}
.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); }
.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:flex-start;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.dropzone strong{ color: var(--text); font-weight: 600; }
.dropzone-copy{
flex: 1 1 220px;
min-width: 0;
line-height: 1.5;
}
.dropzone-actions{
flex: 0 1 auto;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
max-width: 100%;
}
.dropzone-actions-primary,
.dropzone-actions-secondary{
display:flex;
align-items:center;
justify-content:flex-start;
gap:8px;
flex-wrap: wrap;
width: 100%;
}
.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);
}
.check-cell{
text-align:center;
}
.file-check,
#fileCheckAll{
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--accent);
cursor: pointer;
}
.file-check:disabled{
cursor: not-allowed;
opacity: .35;
}
.selected-count{
display:inline-flex;
align-items:center;
min-height: 28px;
padding: 0 10px;
border: 1px solid var(--stroke);
border-radius: 999px;
background: rgba(255,255,255,.03);
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
/* 名称列:可换行 */
.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.size{
white-space: normal; /* 允许换行 */
overflow: visible; /* 不裁切 */
text-overflow: clip; /* 不要 ... */
line-height: 1.25;
word-break: break-word;
overflow-wrap: anywhere;
text-align: left; /* 你要右对齐就删掉这行 */
}
/* 只针对 修改时间 列:允许换行,不要省略号 */
.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; min-width: 0; }
#terminal{
width:100%;
height:auto;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
border:1px solid var(--stroke);
border-radius: 12px;
overflow:hidden;
background: rgba(0,0,0,.18);
box-shadow: inset 0 1px 0 rgba(255,255,255,.04);
}
#terminalWrap{
display:flex;
flex-direction: column;
gap: 10px;
padding: 10px;
}
.terminal-head{
flex: 0 0 auto;
display:flex;
align-items:center;
justify-content: space-between;
gap: 12px;
border:1px solid var(--stroke);
border-radius: var(--r12);
padding: 10px 12px;
background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.01));
}
.terminal-title{
color: var(--text);
font-size: 14px;
font-weight: 700;
letter-spacing: .2px;
}
.terminal-sub{
color: var(--muted);
font-size: 12px;
margin-top: 2px;
}
.kbd-hint{
color: var(--muted2);
font-size: 12px;
text-align: right;
white-space: nowrap;
}
.pill{
border-color: rgba(255,255,255,.14);
}
.pill[data-ok="1"]{
color: #86efac;
background: rgba(34,197,94,.12);
border-color: rgba(34,197,94,.30);
}
.pill[data-ok="0"]{
color: #fca5a5;
background: rgba(239,68,68,.10);
border-color: rgba(239,68,68,.30);
}
.pill[data-ok="-"]{
color: var(--muted);
background: rgba(255,255,255,.03);
}
/* ===== Read-only command whitelist ===== */
.command-whitelist-overlay[hidden]{
display: none;
}
.command-whitelist-overlay{
position: fixed;
inset: 0;
z-index: 1200;
display: grid;
place-items: center;
padding: 24px;
background: rgba(1,6,18,.72);
backdrop-filter: blur(8px);
}
.command-whitelist-modal{
width: min(1040px, 96vw);
max-height: min(820px, 92vh);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(148,163,184,.24);
border-radius: 18px;
background:
radial-gradient(700px 260px at 15% 0%, rgba(37,99,235,.20), transparent 62%),
#0b1425;
color: var(--text);
box-shadow: 0 30px 80px rgba(0,0,0,.58);
}
.command-whitelist-head{
flex: 0 0 auto;
display: flex;
align-items: flex-start;
gap: 14px;
padding: 18px 20px;
border-bottom: 1px solid var(--stroke);
}
.command-whitelist-title{
margin: 0;
font-size: 18px;
}
.command-whitelist-subtitle{
margin: 5px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.command-whitelist-state{
display: inline-flex;
align-items: center;
margin-top: 8px;
padding: 4px 9px;
border: 1px solid rgba(34,197,94,.32);
border-radius: 999px;
background: rgba(34,197,94,.12);
color: #86efac;
font-size: 12px;
}
.command-whitelist-state.is-reference{
border-color: rgba(245,158,11,.34);
background: rgba(245,158,11,.12);
color: #fde68a;
}
.command-whitelist-close{
margin-left: auto;
flex: 0 0 auto;
}
.command-whitelist-toolbar{
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border-bottom: 1px solid var(--stroke);
background: rgba(255,255,255,.025);
}
.command-whitelist-search{
width: min(420px, 100%);
}
.command-whitelist-count{
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.command-whitelist-body{
min-height: 0;
overflow: auto;
padding: 18px 20px 22px;
}
.command-whitelist-notes{
margin: 0 0 16px;
padding: 12px 14px 12px 32px;
border: 1px solid rgba(245,158,11,.22);
border-radius: 12px;
background: rgba(245,158,11,.07);
color: #fde9ad;
font-size: 12px;
line-height: 1.7;
}
.command-whitelist-grid{
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.command-whitelist-group{
padding: 14px;
border: 1px solid var(--stroke);
border-radius: 14px;
background: rgba(255,255,255,.025);
}
.command-whitelist-group h3{
margin: 0;
font-size: 14px;
}
.command-whitelist-group p{
min-height: 36px;
margin: 5px 0 11px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.command-whitelist-chips{
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.command-whitelist-chip{
padding: 4px 8px;
border: 1px solid rgba(96,165,250,.22);
border-radius: 7px;
background: rgba(37,99,235,.10);
color: #bfdbfe;
font: 12px/1.35 "SF Mono", Menlo, Consolas, monospace;
}
.command-whitelist-rules-title{
margin: 20px 0 10px;
font-size: 14px;
}
.command-whitelist-rules{
display: grid;
gap: 7px;
}
.command-whitelist-rule{
display: grid;
grid-template-columns: minmax(190px, .75fr) minmax(0, 2fr);
gap: 12px;
padding: 9px 11px;
border: 1px solid var(--stroke);
border-radius: 10px;
background: rgba(255,255,255,.02);
font-size: 12px;
line-height: 1.55;
}
.command-whitelist-rule code{
color: #93c5fd;
font-family: "SF Mono", Menlo, Consolas, monospace;
}
.command-whitelist-rule span{
color: var(--muted);
}
.command-whitelist-empty{
margin: 16px 0 0;
color: var(--muted);
text-align: center;
font-size: 13px;
}
/* ===== Responsive ===== */
@media (max-width: 1100px){
#wrap{ grid-template-columns: 1fr; height: auto; }
#terminalWrap{ height: 55vh; }
.table-wrap{ max-height: 45vh; }
.kbd-hint{ white-space: normal; text-align: left; }
.controls-main .btn{ min-width: auto; }
.terminal-safety-text{
font-size: 12px;
}
.dropzone-actions{
width: 100%;
align-items: stretch;
}
.dropzone-actions-primary,
.dropzone-actions-secondary{
justify-content: flex-start;
}
.command-whitelist-grid{
grid-template-columns: 1fr;
}
.command-whitelist-rule{
grid-template-columns: 1fr;
gap: 4px;
}
}
</style>
</head>
<body>
<!-- ===== Top Bar ===== -->
<div class="topbar">
<div class="terminal-safety-layer" aria-hidden="true">
<div class="terminal-safety-float">
<div class="terminal-safety-float-inner">
<span class="terminal-safety-icon">!</span>
<span class="terminal-safety-text">您的行为正在受到监控,严禁替换修改程序,一切操作都将被平台记录并可追溯,由您导致的测试结果错误变更将由您承担责任</span>
</div>
</div>
</div>
<div class="topbar-inner">
<div class="controls controls-main">
<button id="btnFav" class="btn primary">⭐ 收藏夹</button>
<button id="btnCommandWhitelist" class="btn">🛡️ 查看只读白名单</button>
<button id="btnConnect" class="btn good">🖥️ 连接 SSH</button>
<button id="btnSftpConnect" class="btn primary">📁 连接 SFTP</button>
<button id="btnSftpPanelToggle" class="btn" hidden>📁 展开 SFTP</button>
<div class="box-web-access">
<span class="box-web-label">盒子网页</span>
<button type="button" class="btn primary" data-box-web-target="https://192.168.3.254">https://192.168.3.254</button>
<button type="button" class="btn primary" data-box-web-target="https://{{ \App\Services\EdgeGateway\BoxBusinessAddressResolver::DEFAULT_BUSINESS_ADDRESS }}">https://{{ \App\Services\EdgeGateway\BoxBusinessAddressResolver::DEFAULT_BUSINESS_ADDRESS }}</button>
<input id="webPreviewUrl" class="inp" style="width:min(28vw,360px); min-width:220px;" placeholder="自定义网址" title="自定义网址可能出现不兼容现象" />
<button id="btnOpenBoxWeb" class="btn">打开自定义</button>
<span class="box-web-warning">其他网页可能会出现不兼容现象</span>
</div>
<button id="btnQuickToggle" class="btn">⚡ 收起快捷命令</button>
</div>
<div class="spacer"></div>
<div class="status-group">
<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 class="controls">
<button id="btnDisconnect" class="btn ghost">⛔ 断开 SSH</button>
<button id="btnSftpDisconnect" class="btn ghost">⛔ 断开 SFTP</button>
<button id="btnClear" class="btn">🧹 清空终端</button>
</div>
</div>
<div class="quick-strip" id="quickCmdPanel">
<div class="controls controls-quick">
<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>
<input id="boxSnInput" class="inp" style="width:160px;" placeholder="SN号" />
<button id="btn-register-sn" class="btn good">写SN号</button>
</div>
</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>
@php
$commandWhitelist = $terminalCommandWhitelist ?? [
'groups' => [],
'restricted_rules' => [],
'global_rules' => [],
'command_count' => 0,
];
@endphp
<!-- ===== Read-only command whitelist ===== -->
<div id="commandWhitelistOverlay" class="command-whitelist-overlay" hidden>
<section class="command-whitelist-modal" role="dialog" aria-modal="true" aria-labelledby="commandWhitelistTitle">
<header class="command-whitelist-head">
<div>
<h2 id="commandWhitelistTitle" class="command-whitelist-title">🛡️ 终端只读命令白名单</h2>
<p class="command-whitelist-subtitle">本页内容与服务端实际校验策略来自同一份配置,用于客户查看当前可用的诊断能力。</p>
<span class="command-whitelist-state {{ ($terminalStrongRestriction ?? true) ? '' : 'is-reference' }}">
{{ ($terminalStrongRestriction ?? true) ? '当前设备:强限制已开启' : '当前设备:仅作为参考(强限制未开启)' }}
</span>
</div>
<button id="btnCommandWhitelistClose" type="button" class="btn command-whitelist-close">关闭</button>
</header>
<div class="command-whitelist-toolbar">
<input id="commandWhitelistSearch" class="inp command-whitelist-search" placeholder="搜索命令或规则,例如 du、df、systemctl" autocomplete="off" />
<span class="command-whitelist-count">共 {{ (int) ($commandWhitelist['command_count'] ?? 0) }} 个命令入口</span>
</div>
<div class="command-whitelist-body">
<ul class="command-whitelist-notes">
@foreach (($commandWhitelist['global_rules'] ?? []) as $rule)
<li>{{ $rule }}</li>
@endforeach
</ul>
<div class="command-whitelist-grid">
@foreach (($commandWhitelist['groups'] ?? []) as $group)
<article class="command-whitelist-group" data-whitelist-group>
<h3>{{ $group['title'] }}</h3>
<p>{{ $group['description'] }}</p>
<div class="command-whitelist-chips">
@foreach ($group['commands'] as $command)
<code class="command-whitelist-chip" data-whitelist-command>{{ $command }}</code>
@endforeach
</div>
</article>
@endforeach
</div>
<h3 class="command-whitelist-rules-title">双用命令参数限制</h3>
<div class="command-whitelist-rules">
@foreach (($commandWhitelist['restricted_rules'] ?? []) as $command => $rule)
<div class="command-whitelist-rule" data-whitelist-rule>
<code>{{ $command }}</code>
<span>{{ $rule }}</span>
</div>
@endforeach
</div>
<p id="commandWhitelistEmpty" class="command-whitelist-empty" hidden>没有找到匹配的命令或规则。</p>
</div>
</section>
</div>
<script>
(() => {
const openButton = document.getElementById('btnCommandWhitelist');
const closeButton = document.getElementById('btnCommandWhitelistClose');
const overlay = document.getElementById('commandWhitelistOverlay');
const search = document.getElementById('commandWhitelistSearch');
const empty = document.getElementById('commandWhitelistEmpty');
if (!openButton || !closeButton || !overlay || !search || !empty) return;
let previousBodyOverflow = '';
let pointerStartedOnOverlay = false;
const filterWhitelist = () => {
const keyword = search.value.trim().toLowerCase();
let visibleGroups = 0;
let visibleRules = 0;
overlay.querySelectorAll('[data-whitelist-group]').forEach((group) => {
const groupCopy = `${group.querySelector('h3')?.textContent || ''} ${group.querySelector('p')?.textContent || ''}`.toLowerCase();
const groupMatched = !keyword || groupCopy.includes(keyword);
let visibleCommands = 0;
group.querySelectorAll('[data-whitelist-command]').forEach((command) => {
const matched = groupMatched || command.textContent.toLowerCase().includes(keyword);
command.hidden = !matched;
if (matched) visibleCommands++;
});
group.hidden = visibleCommands === 0;
if (!group.hidden) visibleGroups++;
});
overlay.querySelectorAll('[data-whitelist-rule]').forEach((rule) => {
const matched = !keyword || rule.textContent.toLowerCase().includes(keyword);
rule.hidden = !matched;
if (matched) visibleRules++;
});
empty.hidden = visibleGroups + visibleRules > 0;
};
const openWhitelist = () => {
previousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
overlay.hidden = false;
search.value = '';
filterWhitelist();
window.requestAnimationFrame(() => search.focus());
};
const closeWhitelist = () => {
overlay.hidden = true;
document.body.style.overflow = previousBodyOverflow;
openButton.focus();
};
openButton.addEventListener('click', openWhitelist);
closeButton.addEventListener('click', closeWhitelist);
search.addEventListener('input', filterWhitelist);
overlay.addEventListener('pointerdown', (event) => {
pointerStartedOnOverlay = event.target === overlay;
});
overlay.addEventListener('pointerup', (event) => {
if (pointerStartedOnOverlay && event.target === overlay) closeWhitelist();
pointerStartedOnOverlay = false;
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !overlay.hidden) closeWhitelist();
});
})();
</script>
<!-- ===== Main ===== -->
<div id="wrap" class="sftp-hidden">
<!-- ===== SFTP Panel ===== -->
<div class="card file-area" id="sftpPanel" aria-hidden="true">
<div class="sftp-head">
<div class="sftp-toolbar">
<button id="btnBack" class="btn">⬅️ 返回</button>
<button id="btnRefresh" class="btn">🔄 刷新</button>
<button id="btnDownloadSelected" class="btn primary" disabled>⬇️ 下载选中</button>
<button id="btnNewFolder" class="btn">📂 新建目录</button>
<button id="btnRename" class="btn">✏️ 重命名</button>
<span id="selectedCount" class="selected-count">已选 0 个文件</span>
</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 class="dropzone-copy">拖拽文件到此处上传到 <strong id="dropPath">/</strong></div>
<div class="dropzone-actions">
<div class="dropzone-actions-primary">
<input type="file" id="uploadFile" style="display:none" />
<button id="btnPick" class="btn">选择文件</button>
</div>
<div class="dropzone-actions-secondary">
<button id="btnUpload" class="btn good">上传</button>
<button id="btnCancelOp" class="btn danger" disabled>取消</button>
</div>
</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: 7%;" class="check-cell">
<input type="checkbox" id="fileCheckAll" title="选择当前列表全部文件" disabled>
</th>
<th style="width: 43%;">名称</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 class="terminal-head">
<div>
<div class="terminal-title">实时终端会话</div>
<div class="terminal-sub">输出采用帧级缓冲渲染,窗口变化会自动重算尺寸</div>
</div>
<div class="kbd-hint">Ctrl+Shift+K 清屏</div>
</div>
<div id="terminal"></div>
</div>
</div>
<script>
window.GLOBAL_BUNNY_LOADER_CONFIG = Object.assign({}, window.GLOBAL_BUNNY_LOADER_CONFIG || {}, {
text: '数据加载中...'
});
</script>
<script src="{{ asset('js/global-loading-bunny.js') }}"></script>
<script src="{{ asset('js/http-error-message.js') }}?v={{ filemtime(public_path('js/http-error-message.js')) }}"></script>
<script>
window.CAPTCHA_TYPE = "{{ config('platform.captcha_mode', 'smart') }}";
function fetchWithAlert(url, options = {}) {
const { skipGlobal422 = false, skipGlobalLoading = true, ...fetchOptions } = options;
const token = getToken();
const requestPath = (() => {
try {
return new URL(url, window.location.origin).pathname;
} catch (e) {
return String(url || '').split('?')[0];
}
})();
const noLoadingUrls = [
'/api/general/user',
'/api/general/login',
'/api/general/recommend',
'/api/general/finger',
'/api/general/platform-version',
'/api/general/navigation',
'/api/general/clues',
'/api/general/check-user-agent'
];
const shouldShowGlobalLoading = !skipGlobalLoading && !noLoadingUrls.includes(requestPath);
fetchOptions.headers = fetchOptions.headers || {};
if (token && !fetchOptions.headers['Authorization']) {
fetchOptions.headers['Authorization'] = `Bearer ${token}`;
}
if (shouldShowGlobalLoading && typeof window.showGlobalBunnyLoader === 'function') {
window.showGlobalBunnyLoader();
}
fetchOptions.__globalLoadingHandled = true;
return fetch(url, fetchOptions)
.then(async response => {
if (response.status === 460) {
window.location.href = '/ban';
return Promise.reject(new Error('460'));
}
const noAlertUrls = [
'/api/general/user',
'/api/general/login',
'/api/general/recommend',
'/api/general/finger',
'/api/general/platform-version'
];
if (!noAlertUrls.includes(url)) {
if (response.status === 403) {
alert('您的账户权限受限!');
} else if (response.status === 401) {
deleteCookie('accessToken')
deleteCookie('userType')
window.location.href = '/login';
} else if (response.status === 422 && !skipGlobal422) {
alert(await window.getResponseErrorMessage(response));
}
}
return response;
})
.catch(error => {
console.error('请求失败:', error);
throw error;
})
.finally(() => {
if (shouldShowGlobalLoading && typeof window.hideGlobalBunnyLoader === 'function') {
window.hideGlobalBunnyLoader();
}
});
}
</script>
<script src="{{ asset('js/xterm-6.0.0.js') }}?v={{ filemtime(public_path('js/xterm-6.0.0.js')) }}"></script>
<script src="{{ asset('js/xterm-addon-fit-0.11.0.js') }}?v={{ filemtime(public_path('js/xterm-addon-fit-0.11.0.js')) }}"></script>
<script src="{{ asset('getToken.js') }}"></script>
<script src="{{ asset('js/login.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 isFavoriteConnection = Boolean(favId);
const boxId = (qs.get('id') || '').trim();
const TERMINAL_STRONG_RESTRICTION = @json((bool) ($terminalStrongRestriction ?? true));
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}`;
wsUrlPill.title = WS_URL;
const pageWrap = document.getElementById('wrap');
const sftpPanel = document.getElementById('sftpPanel');
const btnSftpPanelToggle = document.getElementById('btnSftpPanelToggle');
const quickPanel = document.getElementById('quickCmdPanel');
const btnQuickToggle = document.getElementById('btnQuickToggle');
const webPreviewUrl = document.getElementById('webPreviewUrl');
const btnOpenBoxWeb = document.getElementById('btnOpenBoxWeb');
const boxWebTargetButtons = document.querySelectorAll('[data-box-web-target]');
const QUICK_PANEL_KEY = 'ssh_terminal_quick_open_v1';
const WEB_PREVIEW_URL_KEY = `ssh_terminal_web_preview_url_v2_${boxId || 'default'}`;
const BOX_WEB_PROXY_KEY_QUERY = '__box_proxy_key';
const BOX_WEB_PROXY_TARGET_QUERY = '__box_proxy_target';
const setPill = (el, label, text, ok) => {
el.textContent = `${label}: ${text}`;
if (ok === true) {
el.dataset.ok = '1';
} else if (ok === false) {
el.dataset.ok = '0';
} else {
el.dataset.ok = '-';
}
};
const setWsStatus = (text, ok) => setPill(wsStatusEl, 'WS', text, ok);
const setSshStatus = (text, ok) => {
setPill(sshStatusEl, 'SSH', text, ok);
updateActionAvailability();
};
const setSftpStatus = (text, ok) => {
setPill(sftpStatusEl, 'SFTP', text, ok);
updateActionAvailability();
};
// ===== 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,
disableStdin: true,
fontSize: 14,
fontFamily: '"JetBrains Mono", "SF Mono", Menlo, Consolas, monospace',
lineHeight: 1.2,
scrollback: 8000,
convertEol: false,
windowsMode: true,
theme: { background: '#081120', foreground: '#e5e7eb' }
});
term.options.backspaceAsControlH = false;
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
const terminalEl = document.getElementById('terminal');
term.open(terminalEl);
fitAddon.fit();
term.focus();
terminalEl?.addEventListener('mousedown', () => {
setTimeout(() => term.focus(), 0);
});
terminalEl?.addEventListener('click', () => term.focus());
const sendTerminalKeyPayload = (payload) => {
if (!payload) return true;
if (!sshConnected || !isWsOpen()) {
return true;
}
sendTerminalInput(payload, true);
return true;
};
const ownTerminalKeyEvent = (event) => {
event.preventDefault();
event.stopPropagation();
if (typeof event.stopImmediatePropagation === 'function') {
event.stopImmediatePropagation();
}
};
const forwardTerminalBrowserShortcut = (event) => {
if (event.type !== 'keydown') return false;
if (event.isComposing) return false;
const key = (event.key || '').toLowerCase();
if (key === 'tab' && !event.ctrlKey && !event.metaKey && !event.altKey) {
ownTerminalKeyEvent(event);
return sendTerminalKeyPayload(event.shiftKey ? '\x1b[Z' : '\t');
}
const isCtrlC = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && key === 'c';
if (isCtrlC) {
if (typeof term.hasSelection === 'function' && term.hasSelection()) {
return false;
}
ownTerminalKeyEvent(event);
return sendTerminalKeyPayload('\x03');
}
return false;
};
const terminalTextArea = term.textarea || terminalEl?.querySelector('textarea');
terminalTextArea?.addEventListener('keydown', forwardTerminalBrowserShortcut, true);
if (typeof term.attachCustomKeyEventHandler === 'function') {
term.attachCustomKeyEventHandler((event) => {
if (forwardTerminalBrowserShortcut(event)) {
return false;
}
return true;
});
}
const TERMINAL_LOW_LATENCY_MODE = true;
const ENABLE_TERMINAL_STATUS_LOG = false;
const println = (s) => term.writeln(s);
const print = (s) => {
if (!s) return;
term.write(s);
};
const printStatus = (message) => {
if (!ENABLE_TERMINAL_STATUS_LOG || !message) return;
println(message);
};
if (!boxId) {
println('\x1b[31m[ERROR]\x1b[0m URL 缺少 ?id=xxx,将无法连接 SSH/SFTP。\r\n');
}
if (TERMINAL_STRONG_RESTRICTION) {
println('\x1b[33m[安全模式]\x1b[0m 终端强限制已开启:允许白名单内的只读诊断命令(可点击页面顶部“查看只读白名单”);SFTP 禁止上传、删除、改名和新建。\r\n');
}
// ===== 4) WS =====
let ws = null;
let wsOpenPromise = null;
let sshConnected = false;
let sftpConnected = false;
let sftpPanelVisible = false;
let selectedPaths = new Set();
let currentOp = { type: null, path: null, cancelled: false }; // upload/download
let userDisconnectedSsh = false; // 标记:用户是否主动断开SSH(默认false=允许自动连)
let userDisconnectedSftp = false;
let pageIsLeaving = false;
let reconnectTimer = null;
let reconnectTimes = 0;
let wsHeartbeatTimer = null;
let wsLastPongAt = 0;
const WS_HEARTBEAT_INTERVAL_MS = 25 * 1000;
const terminalInputState = {
buffer: '',
timer: null,
flushDelayMs: 8,
maxBatchSize: 256,
};
const RESIZE_SYNC_DELAYS = [0, 120, 360];
const lastTerminalSize = { cols: 0, rows: 0 };
let fitTicking = false;
const sshDependentButtonIds = [
'btn-auth-check',
'btn-hosts-check',
'btn-hosts-repair',
'btn-algorithm-auth',
'btn-reboot',
'btn-aibox-update',
'btn-register-sn',
];
const sftpDependentButtonIds = [
'btnBack',
'btnRefresh',
'btnDownloadSelected',
'btnNewFolder',
'btnRename',
'btnGo',
'btnPick',
'btnUpload',
'pathInput',
'searchInput',
'uploadFile',
];
const sshRestrictedButtonIds = [
'btn-auth-check',
'btn-hosts-repair',
'btn-algorithm-auth',
'btn-reboot',
'btn-aibox-update',
'btn-register-sn',
];
const sftpRestrictedButtonIds = [
'btnNewFolder',
'btnRename',
'btnPick',
'btnUpload',
'uploadFile',
];
function setQuickPanelVisible(visible) {
if (!quickPanel) return;
quickPanel.classList.toggle('hidden', !visible);
if (btnQuickToggle) {
btnQuickToggle.textContent = visible ? '⚡ 收起快捷命令' : '⚡ 展开快捷命令';
}
localStorage.setItem(QUICK_PANEL_KEY, visible ? '1' : '0');
}
function setElementsDisabled(ids, disabled) {
ids.forEach((id) => {
const el = document.getElementById(id);
if (!el) return;
el.disabled = disabled;
});
}
function updateSftpPanelToggle() {
if (!btnSftpPanelToggle) return;
const canToggle = sftpConnected || sftpPanelVisible;
btnSftpPanelToggle.hidden = !canToggle;
btnSftpPanelToggle.disabled = !canToggle;
btnSftpPanelToggle.textContent = sftpPanelVisible ? '📁 收起 SFTP' : '📁 展开 SFTP';
}
function setSftpPanelVisible(visible, resize = true) {
sftpPanelVisible = Boolean(visible);
pageWrap?.classList.toggle('sftp-hidden', !sftpPanelVisible);
sftpPanel?.setAttribute('aria-hidden', sftpPanelVisible ? 'false' : 'true');
updateSftpPanelToggle();
if (resize) {
syncTerminalSize();
}
}
function updateActionAvailability() {
const terminalInputDisabled = !sshConnected || !isWsOpen();
if (term.options.disableStdin !== terminalInputDisabled) {
term.options.disableStdin = terminalInputDisabled;
}
terminalEl?.setAttribute('aria-disabled', terminalInputDisabled ? 'true' : 'false');
setElementsDisabled(sshDependentButtonIds, !sshConnected);
setElementsDisabled(sftpDependentButtonIds, !sftpConnected);
if (TERMINAL_STRONG_RESTRICTION) {
setElementsDisabled(sshRestrictedButtonIds, true);
setElementsDisabled(sftpRestrictedButtonIds, true);
}
const btnDisconnect = document.getElementById('btnDisconnect');
if (btnDisconnect) btnDisconnect.disabled = !sshConnected;
const btnSftpDisconnect = document.getElementById('btnSftpDisconnect');
if (btnSftpDisconnect) btnSftpDisconnect.disabled = !sftpConnected;
updateSftpPanelToggle();
updateSelectedDownloadUi();
}
function normalizeBoxWebTarget(rawValue) {
const raw = (rawValue || '').trim();
if (!raw) return null;
const prefixed = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`;
try {
const parsed = new URL(prefixed);
if (!['http:', 'https:'].includes(parsed.protocol)) {
return null;
}
return parsed.toString();
} catch (err) {
return null;
}
}
async function buildBoxWebProxyUrl(targetUrl) {
if (!boxId || !targetUrl) return null;
const response = await fetchWithAlert('/api/general/box-web-proxy-access', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
box_id: Number(boxId),
target: targetUrl,
}),
skipGlobal422: true,
});
const responseErrorMessage = !response.ok
? await window.getResponseErrorMessage(response, '无法获取盒子网页访问凭证')
: '';
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload.access_key) {
throw new Error(responseErrorMessage || payload.message || '无法获取盒子网页访问凭证');
}
const rawPath = `/box-web-proxy/${encodeURIComponent(boxId)}/`;
const baseUrl = typeof window.appUrl === 'function' ? window.appUrl(rawPath) : rawPath;
const proxyUrl = new URL(baseUrl, window.location.origin);
proxyUrl.searchParams.set(BOX_WEB_PROXY_KEY_QUERY, payload.access_key);
proxyUrl.searchParams.set(BOX_WEB_PROXY_TARGET_QUERY, targetUrl);
return proxyUrl.toString();
}
async function openBoxWebTarget(rawTargetUrl, remember = false) {
if (!boxId) {
toast('当前页面缺少盒子 ID', 'warn');
return false;
}
const normalizedTargetUrl = normalizeBoxWebTarget(rawTargetUrl || '');
if (!normalizedTargetUrl) {
toast('设备网页地址不合法', 'warn');
webPreviewUrl?.focus();
return false;
}
const popup = window.open('about:blank', '_blank');
if (!popup) {
toast('浏览器阻止了新窗口,请允许弹窗后重试', 'warn');
return false;
}
popup.opener = null;
try {
popup.document.title = '正在打开盒子网页';
popup.document.body.textContent = '正在获取短时访问凭证...';
const targetUrl = await buildBoxWebProxyUrl(normalizedTargetUrl);
if (!targetUrl) {
throw new Error('设备网页地址不合法');
}
if (remember) {
localStorage.setItem(WEB_PREVIEW_URL_KEY, normalizedTargetUrl);
}
popup.location.replace(targetUrl);
return true;
} catch (error) {
popup.close();
toast(error?.message || '盒子网页打开失败', 'warn');
return false;
}
}
function clearPendingTerminalInput() {
if (terminalInputState.timer) {
clearTimeout(terminalInputState.timer);
terminalInputState.timer = null;
}
terminalInputState.buffer = '';
}
function flushPendingTerminalInput() {
if (terminalInputState.timer) {
clearTimeout(terminalInputState.timer);
terminalInputState.timer = null;
}
if (TERMINAL_LOW_LATENCY_MODE) {
terminalInputState.buffer = '';
return false;
}
if (!terminalInputState.buffer) return false;
const payload = terminalInputState.buffer;
terminalInputState.buffer = '';
if (!isWsOpen()) return false;
ws.send(JSON.stringify({ action: 'input', data: payload }));
return true;
}
function shouldSendTerminalInputImmediately(data) {
if (typeof data !== 'string' || data === '') return false;
if (data.length >= terminalInputState.maxBatchSize) return true;
return /[\r\n\x00-\x1f\x7f]/.test(data) || data.includes('\x1b');
}
function sendTerminalInput(data, immediate = false) {
if (typeof data !== 'string' || data === '') return false;
if (!isWsOpen()) return false;
if (TERMINAL_LOW_LATENCY_MODE) {
ws.send(JSON.stringify({ action: 'input', data }));
return true;
}
// 普通可打印字符短暂缓冲后合并发送;Tab / Enter / 方向键 / Ctrl 组合键等特殊输入立即发送。
if (immediate || shouldSendTerminalInputImmediately(data)) {
flushPendingTerminalInput();
ws.send(JSON.stringify({ action: 'input', data }));
return true;
}
terminalInputState.buffer += data;
if (terminalInputState.buffer.length >= terminalInputState.maxBatchSize) {
return flushPendingTerminalInput();
}
if (!terminalInputState.timer) {
terminalInputState.timer = setTimeout(() => flushPendingTerminalInput(), terminalInputState.flushDelayMs);
}
return true;
}
const savedQuickState = localStorage.getItem(QUICK_PANEL_KEY);
const shouldOpenQuick = savedQuickState === null ? window.innerWidth > 1200 : savedQuickState === '1';
setQuickPanelVisible(shouldOpenQuick);
const savedWebPreviewUrl = localStorage.getItem(WEB_PREVIEW_URL_KEY);
if (webPreviewUrl && savedWebPreviewUrl && !webPreviewUrl.value) {
webPreviewUrl.value = savedWebPreviewUrl;
}
btnQuickToggle?.addEventListener('click', () => {
const isOpen = !quickPanel?.classList.contains('hidden');
setQuickPanelVisible(!isOpen);
setTimeout(() => sendResize(), 80);
});
btnSftpPanelToggle?.addEventListener('click', () => {
setSftpPanelVisible(!sftpPanelVisible);
});
btnOpenBoxWeb?.addEventListener('click', () => {
openBoxWebTarget(webPreviewUrl?.value || '', true);
});
boxWebTargetButtons.forEach((button) => {
button.addEventListener('click', () => openBoxWebTarget(button.dataset.boxWebTarget || '', false));
});
webPreviewUrl?.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
btnOpenBoxWeb?.click();
});
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => sendResize());
const wrapEl = document.getElementById('terminalWrap');
const topbarEl = document.querySelector('.topbar');
if (wrapEl) ro.observe(wrapEl);
if (topbarEl) ro.observe(topbarEl);
}
updateActionAvailability();
function flushCommandLogOnLeave() {
}
function isWsOpen(){ return ws && ws.readyState === WebSocket.OPEN; }
function clearReconnectTimer() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
function clearWsHeartbeat() {
if (wsHeartbeatTimer) {
clearInterval(wsHeartbeatTimer);
wsHeartbeatTimer = null;
}
}
function startWsHeartbeat() {
clearWsHeartbeat();
wsLastPongAt = Date.now();
wsHeartbeatTimer = setInterval(() => {
if (!isWsOpen()) {
clearWsHeartbeat();
return;
}
send('ping', { ts: Date.now() });
}, WS_HEARTBEAT_INTERVAL_MS);
}
function triggerSftpConnect() {
if (!isWsOpen() || sftpConnected) return false;
setSftpPanelVisible(true);
setSftpStatus('连接中...', false);
toast('SFTP 连接中...', 'warn');
send('sftp_connect');
return true;
}
function disposeWsForPageLeave() {
if (pageIsLeaving) return;
pageIsLeaving = true;
clearPendingTerminalInput();
clearReconnectTimer();
clearWsHeartbeat();
wsOpenPromise = null;
if (!ws) return;
try {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: 'disconnect' }));
}
} catch (err) {
}
try {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close(1000, 'page leaving');
}
} catch (err) {
}
}
function ensureWSOpen(){
if (pageIsLeaving) return Promise.resolve(false);
if (isWsOpen()) return Promise.resolve(true);
if (wsOpenPromise) return wsOpenPromise;
setWsStatus('连接中...', false);
setSshStatus('-', false);
setSftpStatus('-', false);
sshConnected = false;
sftpConnected = false;
setSftpPanelVisible(false, false);
wsOpenPromise = new Promise((resolve) => {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
if (pageIsLeaving) {
wsOpenPromise = null;
try { ws.close(1000, 'page leaving'); } catch (err) {}
resolve(false);
return;
}
reconnectTimes = 0;
clearReconnectTimer();
startWsHeartbeat();
setWsStatus('已连接', true);
printStatus('\r\n\x1b[32m[WS] WebSocket连接成功\x1b[0m\r\n');
if (boxId) {
const shouldAutoConnectSsh = !userDisconnectedSsh;
if (shouldAutoConnectSsh) {
sendSshConnect();
setSshStatus('自动连接中...', false);
printStatus('\x1b[36m[自动连接] 开始连接SSH\x1b[0m');
}
setSftpStatus('待手动连接', false);
} else {
printStatus('\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 === 'pong') {
wsLastPongAt = Date.now();
return;
}
if (msg.type === 'output') {
print(msg.data);
return;
}
if (msg.type === 'connected') {
sshConnected = true;
userDisconnectedSsh = false;
setSshStatus('已连接', true);
onSshResultToFav('ok', msg.msg);
printStatus(`\r\n\x1b[32m[SSH] ${msg.msg}\x1b[0m\r\n`);
term.focus();
syncTerminalSize();
setSftpStatus(sftpConnected ? '已连接' : '待手动连接', sftpConnected);
return;
}
if (msg.type === 'sftp') { handleSftpMsg(msg); return; }
if (msg.type === 'system') {
const systemMsg = String(msg.msg || '');
if (systemMsg === 'SSH disconnected.') {
clearPendingTerminalInput();
sshConnected = false;
sftpConnected = false;
setSshStatus('已断开', false);
setSftpStatus('待手动连接', false);
setSftpPanelVisible(false, false);
}
printStatus(`\r\n\x1b[36m[系统] ${msg.msg}\x1b[0m\r\n`);
return;
}
if (msg.type === 'error') {
// 可能来自 SSH 或 SFTP
setSshStatus(sshConnected ? '已连接' : '错误', sshConnected);
setSftpStatus(sftpConnected ? '已连接' : '错误', sftpConnected);
if (typeof currentOp !== 'undefined' && currentOp.type === 'download') {
downloading = null;
clearOp('下载失败');
}
onSshResultToFav('bad', msg.msg);
toast(msg.msg, 'bad');
println(`\r\n\x1b[31m[错误] ${msg.msg}\x1b[0m\r\n`);
return;
}
};
ws.onclose = (event) => {
wsOpenPromise = null;
clearWsHeartbeat();
if (pageIsLeaving) {
return;
}
clearPendingTerminalInput();
sshConnected = false;
sftpConnected = false;
setWsStatus('已断开', false);
setSshStatus('-', false);
setSftpStatus('-', false);
const delay = Math.min(10000, 3000 + reconnectTimes * 1200);
const closeCode = event?.code ? ` code=${event.code}` : '';
const closeReason = event?.reason ? ` ${event.reason}` : '';
console.warn(`[WS] closed${closeCode}${closeReason}`);
toast(`WebSocket已断开,${Math.ceil(delay / 1000)}秒后重连`, 'bad');
println(`\r\n\x1b[31m[WS] WebSocket已断开,${Math.ceil(delay / 1000)}秒后重连\x1b[0m\r\n`);
reconnectTimes += 1;
reconnectTimer = setTimeout(() => ensureWSOpen(), delay);
};
ws.onerror = () => {
wsOpenPromise = null;
clearWsHeartbeat();
if (pageIsLeaving) {
resolve(false);
return;
}
clearPendingTerminalInput();
sshConnected = false;
sftpConnected = false;
setWsStatus('连接失败', false);
setSshStatus('-', false);
setSftpStatus('-', false);
toast('WebSocket连接失败', 'bad');
println('\r\n\x1b[31m[WS] WebSocket连接失败\x1b[0m\r\n');
resolve(false);
};
});
return wsOpenPromise;
}
function send(action, payload = {}){
if (!isWsOpen()) return false;
ws.send(JSON.stringify({ action, ...payload }));
return true;
}
function sendSshConnect(payload = {}) {
fitAddon.fit();
const cols = term.cols;
const rows = term.rows;
lastTerminalSize.cols = cols;
lastTerminalSize.rows = rows;
return send('connect_ssh', { cols, rows, ...payload });
}
// ===================== 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;
const auth = (it.authType === 'key')
? { type:'key', privateKey: it.privateKey || '', passphrase: it.passphrase || '' }
: { type:'password', password: it.password || '' };
sendSshConnect({
host: it.host,
user: it.user,
auth,
port: it.port ? Number(it.port) : undefined,
name: it.name || undefined,
});
userDisconnectedSsh = false;
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;
// 你说后端支持 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 || '' };
sendSshConnect({
// === 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`);
const payload = noNewline ? cmd : (cmd.endsWith('\n') ? cmd : (cmd + '\n'));
sendTerminalInput(payload, true);
}
// ===== 5 Buttons events =====
document.getElementById('btn-auth-check')?.addEventListener('click', () => {
executeTerminalCommand('cd /home/nle/app/sdk/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('{!! config('platform.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');
});
document.getElementById('btn-register-sn')?.addEventListener('click', () => {
if (!sshConnected) {
println("\r\n\x1b[31m[ERROR]\x1b[0m SSH not connected\r\n");
return;
}
const input = document.getElementById('boxSnInput');
const sn = (input?.value || '').trim();
if (!/^[A-Za-z0-9_-]{4,64}$/.test(sn)) {
toast('请输入有效 SN 号,仅支持字母、数字、下划线和短横线', 'warn');
input?.focus();
return;
}
if (confirm(`确认要将 SN 号 ${sn} 写入当前设备吗?\n\n将发送 RegisterSNtoCloud 命令到下方 SSH 终端。`)) {
executeTerminalCommand(`cd /usr/local/bin/NLRemoteUpdate/ && ./RegisterSNtoCloud ${sn}`);
}
});
function doDelete(item){
if (TERMINAL_STRONG_RESTRICTION) return toast('终端强限制已开启,禁止删除文件', 'warn');
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 (!isDownloadableItem(item)) return toast('目录不支持直接下载', 'warn');
if (currentOp.type) return toast('当前有传输任务进行中,请先完成或取消', 'warn');
const remote = joinPath(currentPath, item.name);
startSingleDownload(remote);
}
function startSingleDownload(remote, queueMeta = null){
setOp('download', remote);
downloading = {
path: remote,
chunks: [],
size: 0,
receivedBytes: 0,
queueMeta,
};
setProgress(0);
if (queueMeta) {
toast(`开始下载 ${queueMeta.index}/${queueMeta.total}: ${remote.split('/').pop() || remote}`, 'warn');
} else {
toast('开始下载...', 'warn');
}
send('sftp_download_begin', { path: remote, chunk: CHUNK });
}
function doDownloadSelected(){
if (!sftpConnected) return toast('请先连接 SFTP', 'warn');
if (currentOp.type) return toast('当前有传输任务进行中,请先完成或取消', 'warn');
const downloadablePaths = new Set(
allItems
.filter(isDownloadableItem)
.map(item => joinPath(currentPath, item.name))
);
const paths = Array.from(selectedPaths).filter(path => downloadablePaths.has(path));
if (paths.length !== selectedPaths.size) {
selectedPaths = new Set(paths);
updateSelectedDownloadUi();
}
if (paths.length === 0) return toast('请先勾选要下载的文件', 'warn');
if (paths.length === 1) {
const onlyPath = paths[0];
startSingleDownload(onlyPath, { index: 1, total: 1, fromSelection: true });
return;
}
if (!confirm(`确定下载选中的 ${paths.length} 个文件吗?\n浏览器会按顺序逐个下载。`)) {
return;
}
downloadQueue = paths.slice();
downloadQueueTotal = downloadQueue.length;
startNextQueuedDownload();
}
function startNextQueuedDownload(){
if (!sftpConnected) {
downloadQueue = [];
downloadQueueTotal = 0;
toast('SFTP 已断开,下载队列已停止', 'warn');
return;
}
const nextPath = downloadQueue.shift();
if (!nextPath) {
downloadQueue = [];
downloadQueueTotal = 0;
selectedPaths.clear();
updateSelectedDownloadUi();
applySearch();
clearOp('全部下载完成');
return;
}
const index = downloadQueueTotal - downloadQueue.length;
startSingleDownload(nextPath, {
index,
total: downloadQueueTotal,
});
}
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' ? '上传' : '下载'}`;
updateSelectedDownloadUi();
}
function clearOp(msg){
currentOp.type = null;
currentOp.path = null;
currentOp.cancelled = false;
btnCancelOp.disabled = true;
btnCancelOp.textContent = '取消';
if (msg) {
toast(msg, 'warn');
setProgress(0);
}
updateSelectedDownloadUi();
}
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 });
downloadQueue = [];
downloadQueueTotal = 0;
downloading = null;
clearOp('已取消下载');
}
});
function publishResize(force = false) {
const cols = term.cols;
const rows = term.rows;
if (!force && lastTerminalSize.cols === cols && lastTerminalSize.rows === rows) {
return;
}
lastTerminalSize.cols = cols;
lastTerminalSize.rows = rows;
send('resize', { cols, rows });
}
function sendResize(force = false){
if (force) {
fitTicking = false;
fitAddon.fit();
publishResize(true);
return;
}
if (fitTicking) return;
fitTicking = true;
requestAnimationFrame(() => {
fitTicking = false;
fitAddon.fit();
publishResize(false);
});
}
function syncTerminalSize(){
RESIZE_SYNC_DELAYS.forEach((delay, index) => {
setTimeout(() => sendResize(index === 0), delay);
});
}
// ===== 5) SSH events (保留原逻辑) =====
document.getElementById('btnConnect').addEventListener('click', async () => {
if (!boxId) return alert('URL 缺少 ?id=xxx,不能连接。');
const ok = await ensureWSOpen();
if (!ok) return;
userDisconnectedSsh = false;
sendSshConnect();
setSshStatus('连接中...', false);
});
document.getElementById('btnDisconnect').addEventListener('click', () => {
clearPendingTerminalInput();
send('disconnect');
sshConnected = false;
userDisconnectedSsh = true; // 标记:用户主动断开,后续WS重连不再自动连
setSshStatus('已断开', false);
printStatus('\x1b[33m[手动操作] 已主动断开SSH,后续WS重连不会自动连接\x1b[0m');
});
document.getElementById('btnClear').addEventListener('click', () => term.clear());
term.onData((data) => {
if (!sshConnected || !isWsOpen()) return;
sendTerminalInput(data);
});
window.addEventListener('resize', () => sendResize());
window.addEventListener('pagehide', () => {
flushPendingTerminalInput();
flushCommandLogOnLeave();
disposeWsForPageLeave();
});
window.addEventListener('beforeunload', () => {
flushPendingTerminalInput();
flushCommandLogOnLeave();
disposeWsForPageLeave();
});
// ===== 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');
const btnDownloadSelected = document.getElementById('btnDownloadSelected');
const selectedCountEl = document.getElementById('selectedCount');
const fileCheckAll = document.getElementById('fileCheckAll');
let currentPath = '/';
let allItems = [];
let selected = null;
// download state
let downloading = null; // {path, chunks:[], size, receivedBytes}
let downloadQueue = [];
let downloadQueueTotal = 0;
const CHUNK = 64 * 1024;
function escapeHtml(value){
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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 isDownloadableItem(item){
if (!item || item.is_dir) return false;
return item.is_file !== false;
}
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 = { check: 7, name: 43, size: 18, mtime: 18, ops: 14 };
function visibleFilePaths(){
const body = document.getElementById('fileTbody');
if (!body) return [];
return [...body.querySelectorAll('.file-check')]
.map(input => input.dataset.path)
.filter(Boolean);
}
function updateSelectedDownloadUi(){
const btn = document.getElementById('btnDownloadSelected');
const countEl = document.getElementById('selectedCount');
const checkAll = document.getElementById('fileCheckAll');
const count = selectedPaths.size;
const busy = typeof currentOp !== 'undefined' && !!currentOp.type;
if (countEl) {
countEl.textContent = `已选 ${count} 个文件`;
}
if (btn) {
btn.textContent = count > 0 ? `⬇️ 下载选中(${count})` : '⬇️ 下载选中';
btn.disabled = !sftpConnected || count === 0 || busy;
}
if (checkAll) {
const visible = visibleFilePaths();
const selectedVisible = visible.filter(path => selectedPaths.has(path)).length;
checkAll.disabled = !sftpConnected || visible.length === 0;
checkAll.checked = visible.length > 0 && selectedVisible === visible.length;
checkAll.indeterminate = selectedVisible > 0 && selectedVisible < visible.length;
}
}
function renderTable(items){
tbody.innerHTML = '';
selected = null;
// parent row (..)
if (currentPath !== '/') {
const tr = document.createElement('tr');
tr.innerHTML = `
<td style="width:${COLS.check}%;" class="check-cell"></td>
<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 ? '📁' : '📄';
const pathKey = joinPath(currentPath, it.name);
const safeName = escapeHtml(it.name);
const checked = selectedPaths.has(pathKey) ? 'checked' : '';
const canDownload = isDownloadableItem(it);
tr.innerHTML = `
<td style="width:${COLS.check}%;" class="check-cell">
${canDownload ? `<input type="checkbox" class="file-check" data-path="${escapeHtml(pathKey)}" ${checked} title="选择下载">` : ''}
</td>
<td style="width:${COLS.name}%;">
<div class="name-cell">
<div class="icon">${icon}</div>
<div class="fname" title="${safeName}">${safeName}</div>
</div>
</td>
<td style="width:${COLS.size}%;" class="meta size">${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">
${canDownload ? `
<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>
` : ''}
${TERMINAL_STRONG_RESTRICTION ? '' : `
<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());
});
const fileCheck = tr.querySelector('.file-check');
if (fileCheck) {
fileCheck.addEventListener('click', (e) => e.stopPropagation());
fileCheck.addEventListener('change', () => {
if (fileCheck.checked) {
selectedPaths.add(pathKey);
} else {
selectedPaths.delete(pathKey);
}
updateSelectedDownloadUi();
});
}
tbody.appendChild(tr);
});
updateSelectedDownloadUi();
}
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,不能连接。');
if (sftpConnected) {
setSftpPanelVisible(true);
return;
}
const ok = await ensureWSOpen();
if (!ok) return;
userDisconnectedSftp = false;
triggerSftpConnect();
});
document.getElementById('btnSftpDisconnect').addEventListener('click', () => {
send('sftp_disconnect');
sftpConnected = false;
userDisconnectedSftp = true;
setSftpPanelVisible(false);
setSftpStatus('已断开', false);
toast('SFTP 已断开', 'warn');
printStatus('\x1b[33m[手动操作] 已主动断开SFTP\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);
});
fileCheckAll.addEventListener('change', () => {
const paths = visibleFilePaths();
if (fileCheckAll.checked) {
paths.forEach(path => selectedPaths.add(path));
} else {
paths.forEach(path => selectedPaths.delete(path));
}
tbody.querySelectorAll('.file-check').forEach(input => {
input.checked = selectedPaths.has(input.dataset.path);
});
updateSelectedDownloadUi();
});
btnDownloadSelected.addEventListener('click', () => doDownloadSelected());
document.getElementById('btnNewFolder').addEventListener('click', () => {
if (TERMINAL_STRONG_RESTRICTION) return toast('终端强限制已开启,禁止新建目录', 'warn');
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 (TERMINAL_STRONG_RESTRICTION) return toast('终端强限制已开启,禁止重命名', 'warn');
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', () => {
if (TERMINAL_STRONG_RESTRICTION) return toast('终端强限制已开启,禁止上传文件', 'warn');
uploadFileEl.click();
});
document.getElementById('btnUpload').addEventListener('click', async () => {
if (TERMINAL_STRONG_RESTRICTION) return toast('终端强限制已开启,禁止上传文件', 'warn');
const f = uploadFileEl.files && uploadFileEl.files[0];
if (!f) return toast('请选择要上传的文件', 'warn');
await uploadFile(f);
});
function debounce(fn, wait = 100) {
let timer = null;
return (...args) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
const applySearchDebounced = debounce(applySearch, 100);
searchInput.addEventListener('input', () => applySearchDebounced());
pathInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') goList(pathInput.value || '/'); });
window.addEventListener('keydown', (e) => {
if (!e.ctrlKey || !e.shiftKey) return;
const key = (e.key || '').toLowerCase();
if (key !== 'k') return;
e.preventDefault();
term.clear();
toast('终端已清屏', 'ok');
});
// ===== 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');
if (TERMINAL_STRONG_RESTRICTION) return toast('终端强限制已开启,禁止上传文件', 'warn');
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 (TERMINAL_STRONG_RESTRICTION) return toast('终端强限制已开启,禁止上传文件', 'warn');
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;
userDisconnectedSftp = false;
setSftpPanelVisible(true);
setSftpStatus('已连接', true);
toast('SFTP 已连接', 'ok');
printStatus(`\r\n\x1b[32m[SFTP] ${msg.msg}\x1b[0m\r\n`);
// 自动拉目录
currentPath = normalizePath(pathInput.value || '/');
goList(currentPath);
return;
}
if (msg.event === 'disconnected') {
sftpConnected = false;
setSftpPanelVisible(false);
setSftpStatus('已断开', false);
toast('SFTP 已断开', 'warn');
return;
}
if (msg.event === 'upload_put_start') {
if (currentOp.type === 'upload') {
toast('文件已发送,正在写入远端...', 'warn');
setProgress(100);
}
return;
}
if (msg.event === 'upload_cancelled') {
clearOp('服务端已取消上传');
return;
}
if (msg.event === 'download_cancelled') {
downloadQueue = [];
downloadQueueTotal = 0;
downloading = null;
clearOp('服务端已取消下载');
return;
}
if (msg.event === 'list') {
currentPath = normalizePath(msg.path || currentPath);
pathInput.value = currentPath;
dropPathEl.textContent = currentPath;
buildCrumbs(currentPath);
allItems = (msg.items || []);
selectedPaths.clear();
applySearch();
toast(`已加载:${currentPath}`, 'ok');
setProgress(0);
return;
}
if (msg.event === 'mkdir_ok' || msg.event === 'rm_ok' || msg.event === 'rename_ok' || msg.event === 'upload_ok') {
if (msg.event === 'upload_ok') {
clearOp('上传完成');
toast('上传完成 ✅', 'ok');
setProgress(0);
send('sftp_list', { path: currentPath });
return;
}
toast(`${msg.event} ✅`, 'ok');
setProgress(0);
// 刷新
send('sftp_list', { path: currentPath });
return;
}
if (msg.event === 'download_begin') {
// 服务端会 normalize path,这里用服务端的 path 作为唯一准
const serverPath = msg.path;
const queueMeta = downloading && downloading.path === serverPath
? downloading.queueMeta
: null;
// 让 currentOp / downloading 都对齐到 serverPath
currentOp.type = 'download';
currentOp.path = serverPath;
currentOp.cancelled = false;
btnCancelOp.disabled = false;
downloading = {
path: serverPath,
filename: msg.filename || (serverPath.split('/').pop() || 'download.bin'),
chunks: [],
size: msg.size || 0,
receivedBytes: 0,
queueMeta,
};
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) && Number(downloading.size || 0) > 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 = msg.filename || downloading.filename || (downloading.path.split('/').pop() || 'download.bin');
const queueMeta = downloading.queueMeta || null;
const downloadedPath = downloading.path;
triggerDownload(filename, merged);
downloading = null;
if (queueMeta && downloadQueue.length > 0) {
clearOp(`已完成 ${queueMeta.index}/${queueMeta.total},继续下载...`);
setTimeout(() => startNextQueuedDownload(), 350);
return;
}
if (queueMeta) {
selectedPaths.clear();
updateSelectedDownloadUi();
applySearch();
clearOp('全部下载完成');
return;
}
clearOp('下载完成');
return;
}
}
// ===== Auto connect WS only =====
ensureWSOpen();
autoConnectFromFavQuery();
})();
</script>
</body>
</html>
command代码
<?php
namespace App\Console\Commands;
use App\Models\Box;
use App\Models\EdgeGateway\EdgeGatewayLog;
use App\Services\RestrictedTerminalLineEditor;
use App\Services\TerminalCommandRestrictionService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Net\SFTP;
use phpseclib3\Net\SSH2;
use Swoole\Coroutine;
use Swoole\Runtime;
use Swoole\Server\Task;
use Swoole\Table;
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,
* 'disconnecting' => 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 = [];
private array $boxFdMap = [];
private Table $boxFdTable;
// ========== SFTP SAFETY ==========
// 允许访问的远端根目录(强烈建议设置,防止访问 /etc 等)
private ?string $sftpRoot = '/'; // 例如:'/home' 或 '/home/ubuntu'
// 下载/上传分片大小(字节)
private int $chunkSize = 64 * 1024; // 64KB
// 交互终端优先追求输入/输出手感,避免空转等待过长。
private int $sshConnectTimeout = 5;
private int $sftpConnectTimeout = 5;
private int $sftpUploadConnectTimeout = 10;
private float $sshReadTimeout = 0.05;
private float $sshInitWait = 0.01;
private float $sshIdleSleep = 0.001;
private float $sshStartupDrainMaxWait = 0.8;
private float $sshStartupDrainQuietWait = 0.12;
private bool $debugEnabled = false;
private ?TerminalCommandRestrictionService $terminalRestrictionService = null;
/**
* @var array<string, string>
*/
private array $boxPrivateKeyPemCache = [];
/**
* @var array<string, int>
*/
private array $boxPrivateKeyMtimeCache = [];
/**
* @var array<string, object>
*/
private array $boxPrivateKeyObjectCache = [];
public function handle()
{
Runtime::enableCoroutine(true);
$this->debugEnabled = filter_var((string) env('SWOOLE_SSH_TERMINAL_DEBUG', 'false'), FILTER_VALIDATE_BOOL);
$cpuNum = function_exists('swoole_cpu_num') ? max(1, (int) swoole_cpu_num()) : 2;
$workerNum = max(2, min(4, $cpuNum));
$taskWorkerNum = max(2, min(4, $workerNum));
$this->boxFdTable = new Table(4096);
$this->boxFdTable->column('fd', Table::TYPE_INT, 8);
$this->boxFdTable->column('updated_at', Table::TYPE_INT, 8);
$this->boxFdTable->create();
$this->ws = new Server('0.0.0.0', 9506);
$this->ws->set([
'task_worker_num' => $taskWorkerNum, // SFTP put 走 task worker
'task_enable_coroutine' => true, // 可选:让 task 里也能用协程(不影响)
'worker_num' => $workerNum,
'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,
'heartbeat_check_interval' => 60,
'heartbeat_idle_time' => 300,
'max_request' => 0,
'reload_async' => true,
]);
$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;
}
$loginUserId = (int) Redis::get($key);
if ($loginUserId <= 0) {
$server->push($fd, json_encode(['type' => 'error','msg' => 'Invalid login user'], JSON_UNESCAPED_UNICODE));
$server->disconnect($fd);
return;
}
$rawId = (string)($request->get['id'] ?? '');
$boxId = ctype_digit($rawId) ? (int)$rawId : null;
$this->clients[$fd] = [
'ssh' => null,
'sftp' => null,
'connected' => false,
'disconnecting' => false,
'reader_co' => null,
'box_id' => $boxId,
'ip' => '',
'port' => 0,
'user' => '',
'user_id' => $loginUserId,
'upload_tmp' => null,
'upload_target' => null,
'download_co' => null,
'download_path' => null,
'auth' => null, // 保存 sftp/ssh 的 auth,用于 task worker 重新登录
'upload_task_id' => null, // 可选:记录上传 task id(不影响功能)
'history_marker' => null,
'history_file' => null,
'history_marker_buffer' => '',
'history_last_no' => null,
'terminal_restricted' => null,
'restricted_line_editor' => null,
'restricted_interactive_command' => null,
'restricted_completion_sftp' => null,
'restricted_completion_home' => null,
'restricted_completion_cwd' => '~',
'restricted_completion_previous_cwd' => null,
];
if ($boxId) {
$this->boxFdMap[$boxId] = $fd;
$this->boxFdTable->set((string) $boxId, [
'fd' => $fd,
'updated_at' => time(),
]);
}
$this->dbg($fd, 'WS Open', [
'remote_addr' => $request->server['remote_addr'] ?? '',
'remote_port' => $request->server['remote_port'] ?? '',
'box_id' => $boxId,
'raw_id' => $rawId,
]);
});
$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 'ping':
if ($server->isEstablished($fd)) {
$server->push($fd, json_encode([
'type' => 'pong',
'ts' => $data['ts'] ?? null,
], JSON_UNESCAPED_UNICODE));
}
break;
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 = 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('Task', function (Server $server, Task $task) {
$payload = $task->data;
// Log::info('[TASK] start', [
// 'taskId' => $task->id,
// 'srcWorkerId' => $task->worker_id,
// 'payload_type' => gettype($payload),
// ]);
$result = null;
try {
if (!is_array($payload) || ($payload['type'] ?? '') !== 'sftp_upload_put') {
$result = ['ok' => false, 'msg' => 'unknown task'];
$task->finish($result);
return;
}
$fd = (int)($payload['fd'] ?? 0);
$boxId = (int)($payload['box_id'] ?? 0);
$userId = (int)($payload['user_id'] ?? 0);
if ($this->terminalRestriction()->enabledForBox($boxId)) {
$result = ['ok' => false, 'msg' => $this->terminalRestrictionMessage()];
$task->finish($result);
return;
}
$host = (string)($payload['host'] ?? '');
$port = (int)($payload['port'] ?? 22);
$user = (string)($payload['user'] ?? '');
$auth = $payload['auth'] ?? null;
$remote = (string)($payload['remote'] ?? '');
$tmp = (string)($payload['tmp'] ?? '');
// Log::info('[TASK] put begin', [
// 'taskId' => $task->id,
// 'remote' => $remote,
// 'tmp' => $tmp,
// 'size' => (is_file($tmp) ? filesize($tmp) : -1),
// ]);
if ($fd <= 0 || $host === '' || $user === '' || $remote === '' || $tmp === '') {
$result = ['ok' => false, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'msg' => 'task payload invalid', 'remote' => $remote, 'tmp' => $tmp];
$task->finish($result);
return;
}
if (!is_array($auth)) {
$result = ['ok' => false, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'msg' => 'missing auth', 'remote' => $remote, 'tmp' => $tmp];
$task->finish($result);
return;
}
if (!is_file($tmp)) {
$result = ['ok' => false, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'msg' => 'tmp missing', 'remote' => $remote, 'tmp' => $tmp];
$task->finish($result);
return;
}
$sftp = new \phpseclib3\Net\SFTP($host, $port, $this->sftpUploadConnectTimeout);
$type = (string)($auth['type'] ?? '');
$okLogin = false;
if ($type === 'password') {
$pwd = (string)($auth['password'] ?? '');
if ($pwd === '') {
$result = ['ok' => false, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'msg' => 'password empty', 'remote' => $remote, 'tmp' => $tmp];
$task->finish($result);
return;
}
$okLogin = $sftp->login($user, $pwd);
} elseif ($type === 'key') {
$privateKey = (string)($auth['privateKey'] ?? '');
$passphrase = (string)($auth['passphrase'] ?? '');
if ($privateKey === '') {
$result = ['ok' => false, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'msg' => 'privateKey empty', 'remote' => $remote, 'tmp' => $tmp];
$task->finish($result);
return;
}
$key = $passphrase !== ''
? \phpseclib3\Crypt\PublicKeyLoader::loadPrivateKey($privateKey, $passphrase)
: \phpseclib3\Crypt\PublicKeyLoader::loadPrivateKey($privateKey);
$okLogin = $sftp->login($user, $key);
} else {
$result = ['ok' => false, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'msg' => 'unknown auth.type', 'remote' => $remote, 'tmp' => $tmp];
$task->finish($result);
return;
}
if (!$okLogin) {
$result = ['ok' => false, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'msg' => 'SFTP login failed', 'remote' => $remote, 'tmp' => $tmp];
$task->finish($result);
return;
}
$okPut = $sftp->put($remote, $tmp, \phpseclib3\Net\SFTP::SOURCE_LOCAL_FILE);
// Log::info('[TASK] put end', [
// 'taskId' => $task->id,
// 'ok' => (bool)$okPut,
// 'last' => $sftp->getLastError(),
// ]);
if (!$okPut) {
$result = ['ok' => false, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'msg' => 'SFTP put failed: ' . $sftp->getLastError(), 'remote' => $remote, 'tmp' => $tmp];
$task->finish($result);
return;
}
$result = ['ok' => true, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'remote' => $remote, 'tmp' => $tmp];
$task->finish($result);
return;
} catch (\Throwable $e) {
$result = [
'ok' => false,
'msg' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
];
$task->finish($result);
return;
}
});
$this->ws->on('Finish', function (Server $server, int $taskId, $result) {
// Log::info('[FINISH] recv', [
// 'taskId' => $taskId,
// 'result_type' => is_array($result) ? 'array' : gettype($result),
// 'result' => $result,
// ]);
if (!is_array($result)) {
return;
}
$fd = (int)($result['fd'] ?? 0);
$boxId = (int)($result['box_id'] ?? 0);
$userId = (int)($result['user_id'] ?? 0);
$pushFd = ($fd > 0 && $server->isEstablished($fd))
? $fd
: ($boxId > 0 ? $this->findAliveFdByBoxId($boxId) : null);
$remote = (string)($result['remote'] ?? '');
if (!empty($result['ok'])) {
$this->recordOperationLog((int)($pushFd ?: $fd), '上传 ' . $remote, $boxId, $userId);
}
// Log::info('[FINISH] route', [
// 'taskId' => $taskId,
// 'fd' => $fd,
// 'boxId' => $boxId,
// 'pushFd' => $pushFd,
// 'pushFd_established' => $pushFd ? $server->isEstablished($pushFd) : null,
// 'ok' => !empty($result['ok']),
// 'remote' => (string)($result['remote'] ?? ''),
// ]);
if ($pushFd && $server->isEstablished($pushFd)) {
if (!empty($result['ok'])) {
$server->push($pushFd, json_encode([
'type' => 'sftp',
'event' => 'upload_ok',
'path' => $remote,
], JSON_UNESCAPED_UNICODE));
} else {
$server->push($pushFd, json_encode([
'type' => 'error',
'msg' => 'upload_end: put failed | ' . (string)($result['msg'] ?? 'unknown'),
], JSON_UNESCAPED_UNICODE));
}
}
$tmp = $result['tmp'] ?? null;
if (is_string($tmp) && $tmp !== '' && is_file($tmp)) {
@unlink($tmp);
}
});
$this->ws->on('Close', function (Server $server, int $fd) {
$this->dbg($fd, 'WS Close');
$boxId = $this->clients[$fd]['box_id'] ?? null;
if ($boxId && (($this->boxFdMap[$boxId] ?? null) === $fd)) {
unset($this->boxFdMap[$boxId]);
}
if ($boxId) {
$row = $this->boxFdTable->get((string) $boxId);
if ($row && (int) ($row['fd'] ?? 0) === $fd) {
$this->boxFdTable->del((string) $boxId);
}
}
$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;
}
if (!$this->ensureBoxTerminalAccess($fd, $box)) {
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;
}
[$cols, $rows] = $this->normalizeTerminalSize($cols, $rows);
// 基本校验
$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, true);
try {
$ssh = new SSH2($host, $port, $this->sshConnectTimeout);
$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;
}
$this->prepareInteractivePty($ssh, $cols, $rows);
$ssh->exec('env TERM=xterm-256color /bin/bash -li');
$ssh->setTimeout($this->sshReadTimeout);
Coroutine::sleep($this->sshInitWait);
$this->drainStartupOutput($fd, $ssh);
$this->clients[$fd]['ssh'] = $ssh;
$this->clients[$fd]['connected'] = true;
$this->clients[$fd]['disconnecting'] = false;
$this->clients[$fd]['ip'] = $host;
$this->clients[$fd]['port'] = $port;
$this->clients[$fd]['user'] = $user;
$this->clients[$fd]['auth'] = $auth;
$this->configureRemoteHistoryLogging($fd, $ssh);
$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;
}
[$cols, $rows] = $this->normalizeTerminalSize($cols, $rows);
$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, true);
try {
$keyMeta = $this->loadBoxPrivateKey($boxId);
if ($keyMeta === null) {
$this->pushError($fd, sprintf('Key not found for box: %d', $boxId));
return;
}
$ssh = new SSH2($ip, $port, $this->sshConnectTimeout);
$this->dbg($fd, 'login start');
$ok = $ssh->login($user, $keyMeta['key']);
$this->dbg($fd, 'login result', ['ok' => $ok]);
if (!$ok) {
$this->pushError($fd, 'SSH login returned false.');
return;
}
$this->prepareInteractivePty($ssh, $cols, $rows);
$ssh->exec('env TERM=xterm-256color /bin/bash -li');
$ssh->setTimeout($this->sshReadTimeout);
Coroutine::sleep($this->sshInitWait);
$this->drainStartupOutput($fd, $ssh);
$this->clients[$fd]['ssh'] = $ssh;
$this->clients[$fd]['connected'] = true;
$this->clients[$fd]['disconnecting'] = false;
$this->clients[$fd]['ip'] = $ip;
$this->clients[$fd]['port'] = $port;
$this->clients[$fd]['user'] = $user;
$this->configureRemoteHistoryLogging($fd, $ssh);
if (!isset($this->clients[$fd]) || !$this->ws->isEstablished($fd)) {
$ssh->disconnect();
return;
}
$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
{
$readerCo = $this->clients[$fd]['reader_co'] ?? null;
if ($readerCo && $readerCo !== Coroutine::getCid()) {
try {
Coroutine::cancel($readerCo);
} 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->clients[$fd]['disconnecting'] ?? false) === true) {
break;
}
if (($this->clients[$fd]['connected'] ?? false) !== true) {
break;
}
if (!$this->ws->isEstablished($fd)) {
break;
}
if (!$ssh->isConnected()) {
break;
}
$out = $ssh->read('', SSH2::READ_NEXT);
$readTimedOut = $ssh->isTimeout();
if (!is_string($out)) {
if (!$this->shouldCloseReaderForReadResult($out, $readTimedOut)) {
Coroutine::sleep($this->sshIdleSleep);
continue;
}
$this->safeLog('warning', '[SSH_READER_CLOSED_BY_INVALID_READ]', [
'fd' => $fd,
'read_type' => gettype($out),
'timed_out' => $readTimedOut,
]);
break;
}
if ($out !== '') {
$this->pushOutput($fd, $out);
continue;
}
Coroutine::sleep($this->sshIdleSleep);
}
} catch (\Throwable $e) {
$this->safeLog('error', '[ERR] reader exception', [
'fd' => $fd,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
if (isset($this->clients[$fd]) && (($this->clients[$fd]['reader_co'] ?? null) === Coroutine::getCid())) {
$this->clients[$fd]['reader_co'] = null;
}
$this->disconnect($fd, false);
});
}
private function shouldCloseReaderForReadResult($out, bool $timedOut = false): bool
{
return !is_string($out) && !$timedOut;
}
private function prepareInteractivePty(SSH2 $ssh, int $cols, int $rows): void
{
if (method_exists($ssh, 'setTerminal')) {
$ssh->setTerminal('xterm-256color');
}
if (method_exists($ssh, 'setWindowSize')) {
$ssh->setWindowSize($cols, $rows);
}
$ssh->enablePTY();
}
private function drainStartupOutput(int $fd, SSH2 $ssh): void
{
$deadline = microtime(true) + $this->sshStartupDrainMaxWait;
$quietDeadline = microtime(true) + $this->sshStartupDrainQuietWait;
while (microtime(true) < $deadline) {
if (!isset($this->clients[$fd])
|| ($this->clients[$fd]['disconnecting'] ?? false)
|| !$this->ws->isEstablished($fd)
|| !$ssh->isConnected()) {
return;
}
$remaining = max(0.001, min($deadline, $quietDeadline) - microtime(true));
$ssh->setTimeout(min($this->sshReadTimeout, $remaining));
$out = $ssh->read('', SSH2::READ_NEXT);
if (is_string($out) && $out !== '') {
$this->pushOutput($fd, $out);
$quietDeadline = microtime(true) + $this->sshStartupDrainQuietWait;
continue;
}
if (microtime(true) >= $quietDeadline) {
break;
}
Coroutine::sleep($this->sshIdleSleep);
}
$ssh->setTimeout($this->sshReadTimeout);
}
private function configureRemoteHistoryLogging(int $fd, SSH2 $ssh): void
{
$conn = $this->clients[$fd] ?? null;
if (!$conn) {
return;
}
if (($conn['disconnecting'] ?? false)) {
return;
}
$boxId = (int)($conn['box_id'] ?? 0);
$userId = (int)($conn['user_id'] ?? 0);
if ($boxId <= 0 || $userId <= 0) {
return;
}
$marker = 'autotest_' . $this->randomHex(12);
$historyFile = sprintf('/tmp/autotest_ssh_history_%d_%d_%s', $boxId, $fd, $this->randomHex(6));
$this->clients[$fd]['history_marker'] = $marker;
$this->clients[$fd]['history_file'] = $historyFile;
$this->clients[$fd]['history_marker_buffer'] = '';
$this->clients[$fd]['history_last_no'] = null;
$script = implode("\n", [
'set +o history 2>/dev/null || true',
'export AUTOTEST_HISTORY_MARKER=' . $this->shellSingleQuote($marker),
'export HISTFILE=' . $this->shellSingleQuote($historyFile),
'export HISTTIMEFORMAT=',
'export HISTCONTROL=',
'export HISTIGNORE=',
'export HISTSIZE=100000',
'export HISTFILESIZE=100000',
'shopt -s histappend 2>/dev/null || true',
'history -c 2>/dev/null || true',
': > "$HISTFILE"',
'__autotest_history_emit() {',
' local __at_entry __at_no __at_cmd',
' history -a 2>/dev/null || true',
' __at_entry=$(HISTTIMEFORMAT= history 1 2>/dev/null)',
' __at_no=$(printf \'%s\n\' "$__at_entry" | sed -n \'s/^[[:space:]]*\([0-9][0-9]*\)[[:space:]]*.*/\1/p\')',
' __at_cmd=$(printf \'%s\n\' "$__at_entry" | sed \'s/^[[:space:]]*[0-9][0-9]*[[:space:]]*//\')',
' [ -n "$__at_no" ] || return 0',
' [ -n "$__at_cmd" ] || return 0',
' printf \'\033]777;AUTOTEST_HISTORY;%s;%s;%s\007\' "$AUTOTEST_HISTORY_MARKER" "$__at_no" "$__at_cmd"',
'}',
'case ";$PROMPT_COMMAND;" in',
' *"__autotest_history_emit"*) ;;',
' *) PROMPT_COMMAND="__autotest_history_emit${PROMPT_COMMAND:+; $PROMPT_COMMAND}" ;;',
'esac',
'history -c 2>/dev/null || true',
': > "$HISTFILE"',
'set -o history 2>/dev/null || true',
]);
try {
if (($this->clients[$fd]['disconnecting'] ?? false) || !$ssh->isConnected()) {
return;
}
$ssh->write($script . "\n");
$this->drainSilentOutput($fd, $ssh, 0.35);
} catch (\Throwable $e) {
$this->clients[$fd]['history_marker'] = null;
$this->clients[$fd]['history_file'] = null;
$this->clients[$fd]['history_marker_buffer'] = '';
$this->err($fd, 'configure history logging exception', $e);
}
}
private function cleanupRemoteHistoryLogging(int $fd, SSH2 $ssh): void
{
if (!isset($this->clients[$fd])) {
return;
}
$historyFile = (string)($this->clients[$fd]['history_file'] ?? '');
$this->clients[$fd]['history_marker'] = null;
$this->clients[$fd]['history_marker_buffer'] = '';
$this->clients[$fd]['history_last_no'] = null;
if ($historyFile === '') {
return;
}
try {
if (($this->clients[$fd]['disconnecting'] ?? false) || !$ssh->isConnected()) {
return;
}
$ssh->write(
'set +o history 2>/dev/null || true; ' .
'rm -f ' . $this->shellSingleQuote($historyFile) . '; ' .
'unset AUTOTEST_HISTORY_MARKER; ' .
"set -o history 2>/dev/null || true\n"
);
} catch (\Throwable $e) {
$this->dbg($fd, 'cleanup history logging failed', ['error' => $e->getMessage()]);
}
if (isset($this->clients[$fd])) {
$this->clients[$fd]['history_file'] = null;
}
}
private function drainSilentOutput(int $fd, SSH2 $ssh, float $maxWait): void
{
$deadline = microtime(true) + max(0.02, $maxWait);
$quietDeadline = microtime(true) + 0.05;
while (microtime(true) < $deadline) {
if (!isset($this->clients[$fd]) || ($this->clients[$fd]['disconnecting'] ?? false) || !$ssh->isConnected()) {
return;
}
$remaining = max(0.001, min($deadline, $quietDeadline) - microtime(true));
$ssh->setTimeout(min($this->sshReadTimeout, $remaining));
$out = $ssh->read('', SSH2::READ_NEXT);
if (is_string($out) && $out !== '') {
$quietDeadline = microtime(true) + 0.05;
continue;
}
if (microtime(true) >= $quietDeadline) {
break;
}
Coroutine::sleep($this->sshIdleSleep);
}
$ssh->setTimeout($this->sshReadTimeout);
}
private function writeInput(int $fd, string $data): void
{
$conn = $this->clients[$fd] ?? null;
if (!$conn || !$conn['connected'] || !empty($conn['disconnecting']) || !$conn['ssh']) {
$this->pushError($fd, 'SSH not connected.');
return;
}
try {
$restricted = $this->clients[$fd]['terminal_restricted'] ?? null;
if ($restricted === null) {
$boxId = (int) ($this->clients[$fd]['box_id'] ?? 0);
$restricted = $this->terminalRestriction()->enabledForBox($boxId);
$this->clients[$fd]['terminal_restricted'] = $restricted;
}
if ($restricted) {
$this->writeRestrictedInput($fd, $conn['ssh'], $data);
return;
}
$conn['ssh']->write($data);
} catch (\Throwable $e) {
$this->err($fd, 'writeInput exception', $e);
$this->disconnect($fd);
}
}
private function writeRestrictedInput(int $fd, SSH2 $ssh, string $data): void
{
$editor = $this->clients[$fd]['restricted_line_editor'] ?? null;
if (!$editor instanceof RestrictedTerminalLineEditor) {
$editor = new RestrictedTerminalLineEditor(
$this->terminalRestriction()->commandNames(),
fn (string $buffer): ?string => $this->completeRestrictedPath($fd, $buffer)
);
$this->clients[$fd]['restricted_line_editor'] = $editor;
}
if (($this->clients[$fd]['restricted_interactive_command'] ?? null) === 'top') {
$ssh->write($data);
if (str_contains($data, 'q') || str_contains($data, "\x03")) {
$this->clients[$fd]['restricted_interactive_command'] = null;
$editor->clearLine();
}
return;
}
foreach ($editor->feed($data) as $event) {
if ($event['type'] === 'forward') {
$ssh->write((string) ($event['data'] ?? ''));
continue;
}
if ($event['type'] === 'bell') {
$this->pushOutput($fd, "\x07");
continue;
}
$command = (string) ($event['command'] ?? '');
$lineEnding = (string) ($event['data'] ?? "\r");
$inspection = $this->terminalRestriction()->inspect($command);
if (!$inspection['allowed']) {
$ssh->write("\x15");
$this->pushTerminalRestrictionBlocked($fd, $command, $inspection['reason']);
continue;
}
$ssh->write($lineEnding);
$editor->remember($command);
$this->trackRestrictedWorkingDirectory($fd, $command);
if ($this->terminalRestriction()->isInteractiveCommand($command)) {
$this->clients[$fd]['restricted_interactive_command'] = 'top';
}
}
}
private function completeRestrictedPath(int $fd, string $buffer): ?string
{
if (!preg_match('/(?:^|\s)([^\s]*)$/u', $buffer, $matches)) {
return null;
}
$token = (string) ($matches[1] ?? '');
if (
$token === ''
|| str_starts_with($token, '-')
|| preg_match('/[\\\\*?\[\]{}()\'"`$;|&<>]/u', $token)
) {
return null;
}
$sftp = $this->restrictedCompletionSftp($fd);
if (!$sftp) {
return null;
}
$slash = strrpos($token, '/');
$directoryToken = $slash === false ? '' : substr($token, 0, $slash + 1);
$namePrefix = $slash === false ? $token : substr($token, $slash + 1);
$directory = $this->restrictedCompletionDirectory($fd, $directoryToken);
if ($directory === null) {
return null;
}
try {
$entries = $sftp->rawlist($directory);
} catch (\Throwable $e) {
$this->dbg($fd, 'restricted completion list failed', ['error' => $e->getMessage()]);
return null;
}
if (!is_array($entries)) {
return null;
}
$names = [];
foreach (array_keys($entries) as $name) {
$name = (string) $name;
if (
$name === '.' || $name === '..'
|| !str_starts_with($name, $namePrefix)
|| !$this->isSafeRestrictedCompletionName($name)
) {
continue;
}
$names[] = $name;
}
sort($names, SORT_STRING);
if ($names === []) {
return null;
}
$completion = array_shift($names);
foreach ($names as $name) {
$completion = $this->restrictedCommonPrefix($completion, $name);
}
$suffix = substr($completion, strlen($namePrefix));
if ($names === []) {
$fullPath = rtrim($directory, '/') . '/' . $completion;
try {
if ($sftp->is_dir($fullPath)) {
$suffix .= '/';
}
} catch (\Throwable) {
}
}
return $suffix !== '' ? $suffix : null;
}
private function restrictedCompletionSftp(int $fd): ?SFTP
{
$existing = $this->clients[$fd]['restricted_completion_sftp'] ?? null;
if ($existing instanceof SFTP) {
return $existing;
}
$conn = $this->clients[$fd] ?? null;
if (!$conn || empty($conn['connected'])) {
return null;
}
$host = (string) ($conn['ip'] ?? '');
$port = (int) ($conn['port'] ?? 22);
$user = (string) ($conn['user'] ?? '');
if ($host === '' || $user === '') {
return null;
}
try {
$sftp = new SFTP($host, $port, $this->sftpConnectTimeout);
$auth = $conn['auth'] ?? null;
if (is_array($auth)) {
$type = (string) ($auth['type'] ?? '');
if ($type === 'password') {
$credential = (string) ($auth['password'] ?? '');
} elseif ($type === 'key') {
$privateKey = (string) ($auth['privateKey'] ?? '');
$passphrase = (string) ($auth['passphrase'] ?? '');
if ($privateKey === '') {
return null;
}
$credential = $passphrase !== ''
? PublicKeyLoader::loadPrivateKey($privateKey, $passphrase)
: PublicKeyLoader::loadPrivateKey($privateKey);
} else {
return null;
}
} else {
$boxId = (int) ($conn['box_id'] ?? 0);
$keyMeta = $boxId > 0 ? $this->loadBoxPrivateKey($boxId) : null;
if ($keyMeta === null) {
return null;
}
$credential = $keyMeta['key'];
}
if (!$sftp->login($user, $credential)) {
return null;
}
$home = (string) ($sftp->pwd() ?: '/');
$logicalCwd = (string) ($conn['restricted_completion_cwd'] ?? '~');
$cwd = $this->restrictedAbsolutePath($logicalCwd, $home, $home);
$resolvedCwd = $sftp->realpath($cwd);
if (!is_string($resolvedCwd) || !$sftp->is_dir($resolvedCwd)) {
$resolvedCwd = $home;
}
$this->clients[$fd]['restricted_completion_sftp'] = $sftp;
$this->clients[$fd]['restricted_completion_home'] = $home;
$this->clients[$fd]['restricted_completion_cwd'] = $resolvedCwd;
return $sftp;
} catch (\Throwable $e) {
$this->dbg($fd, 'restricted completion SFTP failed', ['error' => $e->getMessage()]);
return null;
}
}
private function restrictedCompletionDirectory(int $fd, string $directoryToken): ?string
{
$home = (string) ($this->clients[$fd]['restricted_completion_home'] ?? '');
$cwd = (string) ($this->clients[$fd]['restricted_completion_cwd'] ?? '');
if ($home === '' || $cwd === '') {
return null;
}
$candidate = $directoryToken === '' ? $cwd : $directoryToken;
$absolute = $this->restrictedAbsolutePath($candidate, $cwd, $home);
$sftp = $this->clients[$fd]['restricted_completion_sftp'] ?? null;
if (!$sftp instanceof SFTP) {
return null;
}
try {
$resolved = $sftp->realpath($absolute);
return is_string($resolved) && $sftp->is_dir($resolved) ? $resolved : null;
} catch (\Throwable) {
return null;
}
}
private function trackRestrictedWorkingDirectory(int $fd, string $command): void
{
if (!preg_match('/^cd(?:\s+([^\s]+))?\s*$/u', trim($command), $matches)) {
return;
}
$target = (string) ($matches[1] ?? '~');
if (
$target !== '~'
&& ($target === ''
|| preg_match('/[\\\\*?\[\]{}()\'"`$;|&<>]/u', $target))
) {
return;
}
$current = (string) ($this->clients[$fd]['restricted_completion_cwd'] ?? '~');
if ($target === '-') {
$previous = $this->clients[$fd]['restricted_completion_previous_cwd'] ?? null;
if (is_string($previous) && $previous !== '') {
$this->clients[$fd]['restricted_completion_previous_cwd'] = $current;
$this->clients[$fd]['restricted_completion_cwd'] = $previous;
}
return;
}
$home = (string) ($this->clients[$fd]['restricted_completion_home'] ?? '~');
$candidate = $this->restrictedAbsolutePath($target, $current, $home);
$sftp = $this->clients[$fd]['restricted_completion_sftp'] ?? null;
if ($sftp instanceof SFTP) {
try {
$resolved = $sftp->realpath($candidate);
if (!is_string($resolved) || !$sftp->is_dir($resolved)) {
return;
}
$candidate = $resolved;
} catch (\Throwable) {
return;
}
}
$this->clients[$fd]['restricted_completion_previous_cwd'] = $current;
$this->clients[$fd]['restricted_completion_cwd'] = $candidate;
}
private function restrictedAbsolutePath(string $path, string $cwd, string $home): string
{
$symbolicHome = $home === '~';
if ($path === '~') {
return $home;
} elseif (str_starts_with($path, '~/')) {
if (!$symbolicHome) {
$path = rtrim($home, '/') . substr($path, 1);
}
} elseif (!str_starts_with($path, '/')) {
$path = rtrim($cwd, '/') . '/' . $path;
}
$usesSymbolicHome = $symbolicHome && str_starts_with($path, '~/');
if ($usesSymbolicHome) {
$path = substr($path, 2);
}
$parts = [];
foreach (explode('/', $path) as $part) {
if ($part === '' || $part === '.') {
continue;
}
if ($part === '..') {
if ($parts !== [] && end($parts) !== '..') {
array_pop($parts);
} elseif ($usesSymbolicHome) {
$parts[] = '..';
}
continue;
}
$parts[] = $part;
}
if ($usesSymbolicHome) {
return $parts === [] ? '~' : '~/' . implode('/', $parts);
}
return '/' . implode('/', $parts);
}
private function isSafeRestrictedCompletionName(string $name): bool
{
return preg_match('/^[\pL\pN._@%+=:,~-]+$/u', $name) === 1;
}
private function restrictedCommonPrefix(string $left, string $right): string
{
$leftCharacters = preg_split('//u', $left, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$rightCharacters = preg_split('//u', $right, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$limit = min(count($leftCharacters), count($rightCharacters));
$prefix = [];
for ($index = 0; $index < $limit && $leftCharacters[$index] === $rightCharacters[$index]; $index++) {
$prefix[] = $leftCharacters[$index];
}
return implode('', $prefix);
}
private function pushTerminalRestrictionBlocked(int $fd, string $command, string $reason): void
{
$command = trim($command);
$detail = $reason !== '' ? $reason : $this->terminalRestrictionMessage();
$line = $command !== ''
? "已阻止命令:{$command};{$detail}"
: $detail;
$this->pushOutput($fd, "\r\n\x1b[31m[BLOCKED]\x1b[0m {$line}\r\n");
}
private function terminalRestriction(): TerminalCommandRestrictionService
{
if (!$this->terminalRestrictionService) {
$this->terminalRestrictionService = app(TerminalCommandRestrictionService::class);
}
return $this->terminalRestrictionService;
}
private function terminalRestrictionMessage(): string
{
return '终端强限制已开启,仅允许 cat、tail、head、grep、ls 等查看类命令,禁止上传、删除、改名、新建和修改服务状态';
}
private function rejectMutatingSftpWhenRestricted(int $fd, string $action): bool
{
$boxId = (int) ($this->clients[$fd]['box_id'] ?? 0);
if (!$this->terminalRestriction()->enabledForBox($boxId)) {
return false;
}
$this->pushError($fd, "{$action} 已被阻止:{$this->terminalRestrictionMessage()}");
return true;
}
private function resizePty(int $fd, int $cols, int $rows): void
{
$conn = $this->clients[$fd] ?? null;
if (!$conn || !$conn['connected'] || !empty($conn['disconnecting']) || !$conn['ssh']) {
return;
}
[$cols, $rows] = $this->normalizeTerminalSize($cols, $rows);
try {
if (method_exists($conn['ssh'], 'setWindowSize')) {
$conn['ssh']->setWindowSize($cols, $rows);
}
$this->sendPtyWindowChange($fd, $conn['ssh'], $cols, $rows);
} catch (\Throwable $e) {
$this->err($fd, 'resize exception', $e);
}
}
private function sendPtyWindowChange(int $fd, SSH2 $ssh, int $cols, int $rows): bool
{
try {
$sendWindowChange = \Closure::bind(function (int $columns, int $rowCount): bool {
$channel = \phpseclib3\Net\SSH2::CHANNEL_EXEC;
if (!isset($this->server_channels[$channel])) {
return false;
}
$packet = \phpseclib3\Common\Functions\Strings::packSSH2(
'CNsCN4',
98, // SSH_MSG_CHANNEL_REQUEST
$this->server_channels[$channel],
'window-change',
0,
$columns,
$rowCount,
0,
0
);
$this->send_binary_packet($packet);
return true;
}, $ssh, \phpseclib3\Net\SSH2::class);
return (bool) $sendWindowChange($cols, $rows);
} catch (\Throwable $e) {
$this->err($fd, 'window-change exception', $e);
return false;
}
}
/**
* @return array{0:int,1:int}
*/
private function normalizeTerminalSize(int $cols, int $rows): array
{
return [
min(max($cols, 40), 260),
min(max($rows, 10), 120),
];
}
private function randomHex(int $bytes): string
{
try {
return bin2hex(random_bytes($bytes));
} catch (\Throwable $e) {
return str_replace('.', '', uniqid('', true));
}
}
private function shellSingleQuote(string $value): string
{
return "'" . str_replace("'", "'\"'\"'", $value) . "'";
}
private function filterHistoryMarkersFromOutput(int $fd, string $out): string
{
if (($this->clients[$fd]['history_marker'] ?? null) === null) {
return $out;
}
$buffer = (string)($this->clients[$fd]['history_marker_buffer'] ?? '') . $out;
$this->clients[$fd]['history_marker_buffer'] = '';
$clean = '';
$offset = 0;
$pattern = "/\x1b\]777;AUTOTEST_HISTORY;([^;\x07]*);([0-9]+);([^\x07]*)\x07/s";
while (preg_match($pattern, $buffer, $match, PREG_OFFSET_CAPTURE, $offset)) {
$markerStart = $match[0][1];
$markerEnd = $markerStart + strlen($match[0][0]);
$clean .= substr($buffer, $offset, $markerStart - $offset);
$this->recordHistoryCommand(
$fd,
(string)$match[1][0],
(int)$match[2][0],
(string)$match[3][0]
);
$offset = $markerEnd;
}
$remaining = substr($buffer, $offset);
$prefix = "\x1b]777;AUTOTEST_HISTORY;";
$tailPos = strrpos($remaining, $prefix);
$pendingStart = null;
if ($tailPos !== false && strpos(substr($remaining, $tailPos), "\x07") === false) {
$pendingStart = $tailPos;
} else {
$partialLen = $this->historyMarkerPrefixSuffixLength($remaining, $prefix);
if ($partialLen > 0) {
$pendingStart = strlen($remaining) - $partialLen;
}
}
if ($pendingStart !== null) {
$clean .= substr($remaining, 0, $pendingStart);
$pending = substr($remaining, $pendingStart);
if (strlen($pending) <= 8192) {
$this->clients[$fd]['history_marker_buffer'] = $pending;
} else {
$clean .= $pending;
}
} else {
$clean .= $remaining;
}
return $clean;
}
private function historyMarkerPrefixSuffixLength(string $value, string $prefix): int
{
$maxLen = min(strlen($value), strlen($prefix) - 1);
for ($len = $maxLen; $len > 0; $len--) {
if (substr($value, -$len) === substr($prefix, 0, $len)) {
return $len;
}
}
return 0;
}
private function recordHistoryCommand(int $fd, string $marker, int $historyNo, string $command): void
{
if (!isset($this->clients[$fd])) {
return;
}
if ($marker === '' || $marker !== (string)($this->clients[$fd]['history_marker'] ?? '')) {
return;
}
if ($historyNo <= 0 || (int)($this->clients[$fd]['history_last_no'] ?? 0) === $historyNo) {
return;
}
$this->clients[$fd]['history_last_no'] = $historyNo;
$command = $this->sanitizeOperationLog($command);
if ($this->shouldSkipHistoryCommand($command)) {
return;
}
$this->recordOperationLog($fd, $command);
}
private function shouldSkipHistoryCommand(string $command): bool
{
$trimmed = trim($command);
if ($trimmed === '') {
return true;
}
foreach ([
'AUTOTEST_HISTORY',
'__autotest_history_emit',
'AUTOTEST_HISTORY_MARKER',
'autotest_ssh_history_',
] as $needle) {
if (stripos($trimmed, $needle) !== false) {
return true;
}
}
return false;
}
private function sanitizeOperationLog(string $log): string
{
$log = str_replace(["\r", "\n", "\0"], ' ', $log);
$log = trim($log);
return strlen($log) > 20000 ? substr($log, 0, 20000) : $log;
}
private function recordOperationLog(int $fd, string $log, ?int $boxId = null, ?int $userId = null): void
{
$log = $this->sanitizeOperationLog($log);
if ($log === '') {
return;
}
$conn = $this->clients[$fd] ?? [];
$boxId = (int)($boxId ?: ($conn['box_id'] ?? 0));
$userId = (int)($userId ?: ($conn['user_id'] ?? 0));
if ($boxId <= 0 || $userId <= 0) {
return;
}
try {
EdgeGatewayLog::query()->create([
'user_id' => $userId,
'operator_name' => $this->clients[$fd]['user_name'] ?? null,
'box_id' => $boxId,
'action_type' => 'terminal',
'log' => $log,
]);
} catch (\Throwable $e) {
$this->safeLog('warning', '[SSH_OPERATION_LOG_FAILED]', [
'fd' => $fd,
'box_id' => $boxId,
'user_id' => $userId,
'error' => $e->getMessage(),
]);
}
}
// =========================
// ========== 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;
}
if (!$this->ensureBoxTerminalAccess($fd, $box)) {
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, $this->sftpConnectTimeout);
$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->clients[$fd]['auth'] = $auth;
$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 {
$keyMeta = $this->loadBoxPrivateKey($boxId);
if ($keyMeta === null) {
$this->pushError($fd, sprintf('Key not found for box: %d', $boxId));
return;
}
$sftp = new SFTP($ip, $port, $this->sftpConnectTimeout);
$ok = $sftp->login($user, $keyMeta['key']);
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;
// 关键:保存 auth,给 task worker 的 put 使用
$this->clients[$fd]['auth'] = [
'type' => 'key',
'privateKey' => $keyMeta['pem'],
'passphrase' => '',
];
$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;
// 如果失败,也清掉 auth,避免后续误用旧 auth
if (isset($this->clients[$fd])) {
$this->clients[$fd]['auth'] = 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, 3=symlink (phpseclib)
$childPath = $this->sftpNormalizePath(rtrim($path, '/') . '/' . $name);
$stat = $sftp->stat($childPath);
$statType = $stat !== false ? ($stat['type'] ?? null) : null;
$resolvedType = $statType ?? $type;
$isDir = $resolvedType === 2;
$isFile = $resolvedType === 1;
$items[] = [
'name' => $name,
'is_dir' => $isDir,
'is_file' => $isFile,
'type' => $resolvedType,
'size' => (int)(($stat !== false ? ($stat['size'] ?? null) : null) ?? ($meta['size'] ?? 0)),
'mtime' => (int)(($stat !== false ? ($stat['mtime'] ?? null) : null) ?? ($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
{
if ($this->rejectMutatingSftpWhenRestricted($fd, '新建目录')) {
return;
}
$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
{
if ($this->rejectMutatingSftpWhenRestricted($fd, '删除文件')) {
return;
}
$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) {
// 记录递归删除中的异常
$this->safeLog('error', '递归删除目录异常', [
'dir' => $dir,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return false;
}
}
private function sftpRename(int $fd, string $from, string $to): void
{
if ($this->rejectMutatingSftpWhenRestricted($fd, '重命名')) {
return;
}
$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->recordOperationLog($fd, '下载 ' . $path);
$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, bool $notify = true): void
{
$hadActiveDownload = false;
if (isset($this->clients[$fd])) {
$hadActiveDownload = !empty($this->clients[$fd]['download_path']) || !empty($this->clients[$fd]['download_co']);
// 先让协程的 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 ($notify && $hadActiveDownload && $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
{
if ($this->rejectMutatingSftpWhenRestricted($fd, '上传文件')) {
return;
}
$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
{
if ($this->rejectMutatingSftpWhenRestricted($fd, '上传文件')) {
return;
}
$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 findAliveFdByBoxId(int $boxId): ?int
{
if ($boxId <= 0) {
return null;
}
$tableRow = $this->boxFdTable->get((string) $boxId);
if ($tableRow) {
$fd = (int) ($tableRow['fd'] ?? 0);
if ($fd > 0 && $this->ws->isEstablished($fd)) {
return $fd;
}
$this->boxFdTable->del((string) $boxId);
}
// 优先用 boxFdMap(最准)
$fd = $this->boxFdMap[$boxId] ?? null;
if ($fd && $this->ws->isEstablished((int)$fd)) {
$this->boxFdTable->set((string) $boxId, [
'fd' => (int) $fd,
'updated_at' => time(),
]);
return (int)$fd;
}
// fallback:遍历 clients
foreach ($this->clients as $fd2 => $conn) {
if (($conn['box_id'] ?? null) === $boxId && $this->ws->isEstablished((int)$fd2)) {
// 顺手修正映射
$this->boxFdMap[$boxId] = (int)$fd2;
$this->boxFdTable->set((string) $boxId, [
'fd' => (int) $fd2,
'updated_at' => time(),
]);
return (int)$fd2;
}
}
return null;
}
private function sftpUploadEnd(int $fd): void
{
if ($this->rejectMutatingSftpWhenRestricted($fd, '上传文件')) {
$this->sftpUploadCleanup($fd);
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;
}
$host = (string)($this->clients[$fd]['ip'] ?? '');
$port = (int)($this->clients[$fd]['port'] ?? 22);
$user = (string)($this->clients[$fd]['user'] ?? '');
$auth = $this->clients[$fd]['auth'] ?? null;
$boxId = (int)($this->clients[$fd]['box_id'] ?? 0);
$userId = (int)($this->clients[$fd]['user_id'] ?? 0);
if ($host === '' || $user === '' || !is_array($auth)) {
$this->pushError($fd, 'upload_end: missing sftp connection/auth (connect sftp first)');
return;
}
// 提示前端:开始“远端写入”
if ($this->ws->isEstablished($fd)) {
$this->ws->push($fd, json_encode([
'type' => 'sftp',
'event' => 'upload_put_start',
'path' => $remote,
], JSON_UNESCAPED_UNICODE));
}
// 投递 task
$taskId = $this->ws->task([
'type' => 'sftp_upload_put',
'fd' => $fd,
'box_id' => $boxId,
'user_id' => $userId,
'host' => $host,
'port' => $port,
'user' => $user,
'auth' => $auth,
'remote' => $remote,
'tmp' => $tmp,
]);
$this->clients[$fd]['upload_task_id'] = $taskId;
// 清掉上传状态,避免重复 end;但 tmp 不删,Finish 里删
$this->clients[$fd]['upload_tmp'] = null;
$this->clients[$fd]['upload_target'] = null;
}
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 (isset($this->clients[$fd])) {
$this->clients[$fd]['auth'] = 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;
}
if (($conn['disconnecting'] ?? false)) {
$readerCo = $conn['reader_co'] ?? null;
if ($readerCo && $readerCo !== Coroutine::getCid()) {
try {
Coroutine::cancel($readerCo);
} catch (\Throwable $e) {
}
}
return;
}
$this->clients[$fd]['disconnecting'] = true;
$this->clients[$fd]['connected'] = false;
// 先停 SSH reader
$readerCo = $conn['reader_co'] ?? null;
if ($readerCo && $readerCo !== Coroutine::getCid()) {
try {
Coroutine::cancel($readerCo);
} catch (\Throwable $e) {
}
}
if (isset($this->clients[$fd])) {
$this->clients[$fd]['reader_co'] = null;
}
// 断 SSH
if (!empty($conn['ssh'])) {
try {
$this->cleanupRemoteHistoryLogging($fd, $conn['ssh']);
$conn['ssh']->disconnect();
} catch (\Throwable $e) {
}
$this->clients[$fd]['ssh'] = null;
}
$this->sftpDownloadCancel($fd, false);
// 断 SFTP(新增)
$this->sftpDisconnect($fd, true);
if (!empty($conn['restricted_completion_sftp'])) {
try {
$conn['restricted_completion_sftp']->disconnect();
} catch (\Throwable $e) {
}
}
$this->clients[$fd]['ip'] = '';
$this->clients[$fd]['port'] = 0;
$this->clients[$fd]['user'] = '';
$this->clients[$fd]['auth'] = null;
$this->clients[$fd]['upload_task_id'] = null;
$this->clients[$fd]['terminal_restricted'] = null;
$this->clients[$fd]['restricted_line_editor'] = null;
$this->clients[$fd]['restricted_interactive_command'] = null;
$this->clients[$fd]['restricted_completion_sftp'] = null;
$this->clients[$fd]['restricted_completion_home'] = null;
$this->clients[$fd]['restricted_completion_cwd'] = '~';
$this->clients[$fd]['restricted_completion_previous_cwd'] = null;
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
{
$out = $this->filterHistoryMarkersFromOutput($fd, $out);
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
{
$this->safeLog('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
{
if (!$this->debugEnabled) {
return;
}
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();
}
$this->safeLog('error', '[ERR] ' . $msg, $payload);
$this->pushError($fd, $msg . ($e ? (' | ' . $e->getMessage()) : ''));
}
private function safeLog(string $level, string $message, array $context = []): void
{
try {
if ($level === 'error') {
Log::error($message, $context);
return;
}
if ($level === 'warning') {
Log::warning($message, $context);
return;
}
Log::info($message, $context);
} catch (\Throwable) {
}
}
private function ensureBoxTerminalAccess(int $fd, Box $box): bool
{
return true;
}
/**
* @return array{path:string,pem:string,key:object}|null
*/
private function loadBoxPrivateKey(int $boxId): ?array
{
$path = $this->resolveBoxPrivateKeyPath($boxId);
if ($path === null) {
return null;
}
$mtime = (int) (@filemtime($path) ?: 0);
if (($this->boxPrivateKeyMtimeCache[$path] ?? null) !== $mtime) {
unset($this->boxPrivateKeyPemCache[$path], $this->boxPrivateKeyObjectCache[$path]);
$this->boxPrivateKeyMtimeCache[$path] = $mtime;
}
if (!isset($this->boxPrivateKeyPemCache[$path])) {
$pem = @file_get_contents($path);
if (!is_string($pem) || trim($pem) === '') {
return null;
}
$this->boxPrivateKeyPemCache[$path] = $pem;
}
if (!isset($this->boxPrivateKeyObjectCache[$path])) {
$this->boxPrivateKeyObjectCache[$path] = PublicKeyLoader::loadPrivateKey($this->boxPrivateKeyPemCache[$path]);
}
return [
'path' => $path,
'pem' => $this->boxPrivateKeyPemCache[$path],
'key' => $this->boxPrivateKeyObjectCache[$path],
];
}
private function resolveBoxPrivateKeyPath(int $boxId): ?string
{
$boxKeyPath = storage_path('app/edge-gateway-id-rsa/id_rsa_' . $boxId);
if (is_file($boxKeyPath)) {
return $boxKeyPath;
}
$defaultKeyPath = storage_path('app/edge-gateway-id-rsa/id_rsa');
return is_file($defaultKeyPath) ? $defaultKeyPath : null;
}
}
关于 LearnKu
推荐文章: