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

AI摘要
【知识分享】该内容为WebSSH终端与SFTP文件管理系统的技术实现方案,包含Nginx WebSocket代理配置、基于Swoole的PHP后端服务(支持SSH连接、PTY终端交互、SFTP文件操作)以及前端Web界面(使用xterm.js实现终端模拟器,具备设备收藏夹、文件上传下载、路径导航等功能)。系统通过WebSocket协议实现浏览器与后端SSH/SFTP服务的双向通信,并包含权限验证机制。

php8.1+laravel8+swoole在web中对设备进行ssh+sftp连接的可用代码

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

nginx配置


location /ws/ {
  proxy_pass http://php-worker:9506;
  proxy_http_version 1.1; # 必须指定HTTP/1.1,支持WebSocket
  proxy_set_header Upgrade $http_upgrade; # 升级协议头
  proxy_set_header Connection "upgrade"; # 连接类型为upgrade
  proxy_set_header Host $host; # 传递主机名
  proxy_set_header X-Real-IP $remote_addr; # 传递真实IP
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 传递转发IP
  proxy_connect_timeout 60; # 连接超时
  proxy_read_timeout 600; # 长连接超时(适配SSH终端长时间交互)
  proxy_send_timeout 600; # 发送超时
}

php-worker 配置

[program:ssh-terminal]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/autotest/platform/artisan swoole:ssh-terminal
directory=/var/www/autotest/platform
autostart=true
autorestart=true
numprocs=1
user=laradock
redirect_stderr=true
stdout_logfile=/var/www/autotest/platform/storage/logs/ssh_terminal.log
stopwaitsecs=10
killasgroup=true
stopasgroup=true

blade代码

<!doctype html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>SSH Terminal + SFTP</title>

    <link rel="stylesheet" href="/css/xterm-5.5.0-xterm.css">

    <style>
        :root{
            --bg: #0b1020;
            --panel: rgba(255,255,255,.04);
            --panel2: rgba(255,255,255,.06);
            --stroke: rgba(255,255,255,.10);
            --stroke2: rgba(255,255,255,.14);
            --text: #e5e7eb;
            --muted: rgba(229,231,235,.70);
            --muted2: rgba(229,231,235,.55);

            --accent: #7c3aed;
            --accent2: #22c55e;
            --danger: #ef4444;
            --warn: #f59e0b;

            --shadow: 0 10px 30px rgba(0,0,0,.35);
            --r12: 12px;
            --r16: 16px;
        }

        *{ box-sizing:border-box; }
        html, body { height: 100%; }

        body{
            margin:0;
            display: flex;
            flex-direction: column;
            min-height: 100vh;

            background:
                radial-gradient(900px 400px at 10% 0%, rgba(124,58,237,.20), transparent 60%),
                radial-gradient(700px 400px at 90% 10%, rgba(34,197,94,.12), transparent 60%),
                var(--bg);
        }


        /* ===== Top Bar ===== */
        .topbar{
            position: sticky;
            top: 0;
            z-index: 50;
            backdrop-filter: blur(10px);
            background: rgba(11,16,32,.72);
            border-bottom: 1px solid var(--stroke);
        }

        .topbar-inner{
            padding: 12px 14px;
            display: flex;
            align-items: center;
            gap: 12px;
            flex-wrap: wrap;
        }

        .brand{
            display:flex;
            align-items:center;
            gap:10px;
            padding: 8px 10px;
            border:1px solid var(--stroke);
            border-radius: 999px;
            background: rgba(255,255,255,.03);
        }
        .dot{
            width:10px; height:10px; border-radius:999px;
            background: linear-gradient(135deg, var(--accent), #38bdf8);
            box-shadow: 0 0 0 4px rgba(124,58,237,.15);
        }
        .brand b{ font-size: 13px; letter-spacing:.2px; }
        .brand span{ font-size: 12px; color: var(--muted); }

        .pill{
            padding: 6px 10px;
            border: 1px solid var(--stroke);
            border-radius: 999px;
            background: rgba(255,255,255,.03);
            color: var(--muted);
            font-size: 12px;
            max-width: 48vw;
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
        }

        .controls{
            display:flex;
            align-items:center;
            gap:10px;
            flex-wrap: wrap;
        }

        .btn{
            border: 1px solid var(--stroke);
            background: rgba(255,255,255,.03);
            color: var(--text);
            padding: 8px 12px;
            border-radius: 999px;
            cursor: pointer;
            user-select:none;
            display:inline-flex;
            align-items:center;
            gap:8px;
            transition: transform .05s ease, background .15s ease, border-color .15s ease;
            font-size: 13px;
        }
        .btn:hover{
            background: rgba(255,255,255,.06);
            border-color: var(--stroke2);
        }
        .btn:active{ transform: translateY(1px); }
        .btn.primary{
            background: linear-gradient(135deg, rgba(124,58,237,.35), rgba(124,58,237,.12));
            border-color: rgba(124,58,237,.35);
        }
        .btn.good{
            background: linear-gradient(135deg, rgba(34,197,94,.30), rgba(34,197,94,.10));
            border-color: rgba(34,197,94,.35);
        }
        .btn.danger{
            background: linear-gradient(135deg, rgba(239,68,68,.28), rgba(239,68,68,.10));
            border-color: rgba(239,68,68,.35);
        }
        .btn.ghost{ background: transparent; }

        .inp{
            border: 1px solid var(--stroke);
            background: rgba(255,255,255,.03);
            color: var(--text);
            padding: 9px 12px;
            border-radius: 999px;
            outline: none;
            width: 220px;
            font-size: 13px;
        }
        .inp:focus{
            border-color: rgba(124,58,237,.55);
            box-shadow: 0 0 0 4px rgba(124,58,237,.15);
        }

        .spacer{ flex: 1; }

        #wrap{
            flex: 1;          /* 占满 topbar 下面剩余高度 */
            min-height: 0;    /* 关键:允许内部滚动容器正确收缩 */
            padding: 12px;
            display: grid;
            grid-template-columns: 420px 1fr;
            gap: 12px;
        }

        .card{
            border: 1px solid var(--stroke);
            border-radius: var(--r16);
            background: var(--panel);
            box-shadow: var(--shadow);
            overflow: hidden;
            min-height: 0;
        }

        /* ===== SFTP Panel ===== */
        .sftp-head{
            padding: 12px;
            border-bottom: 1px solid var(--stroke);
            background: linear-gradient(180deg, rgba(255,255,255,.05), transparent);
            display:flex;
            flex-direction: column;
            gap: 10px;
        }

        .sftp-toolbar{
            display:flex;
            gap: 10px;
            flex-wrap: wrap;
            align-items: center;
        }

        .crumbs{
            display:flex;
            align-items:center;
            gap:8px;
            flex-wrap: wrap;
            padding: 8px 10px;
            border:1px solid var(--stroke);
            border-radius: var(--r12);
            background: rgba(255,255,255,.03);
            font-size: 12px;
            color: var(--muted);
        }
        .crumbs button{
            border: none;
            background: transparent;
            color: var(--text);
            cursor: pointer;
            padding: 4px 6px;
            border-radius: 8px;
            font-size: 12px;
        }
        .crumbs button:hover{ background: rgba(255,255,255,.06); }
        .crumbs .sep{ color: var(--muted2); }

        .sftp-sub{
            display:flex;
            align-items:center;
            gap:10px;
        }
        .sftp-sub .inp{ flex: 1; width: auto; border-radius: var(--r12); }
        .topbar .brand { display: none !important; }
        #boxIdInput { display: none !important; }

        .file-area{
            display:flex;
            flex-direction: column;
            min-height: 0;
        }

        .table-wrap{
            flex: 1;
            min-height: 0;
            overflow: auto;   /* 列表多就滚动 */
            padding: 10px;
        }


        .dropzone{
            margin-top: 8px;
            padding: 10px 12px;
            border: 1px dashed rgba(255,255,255,.20);
            border-radius: var(--r12);
            background: rgba(255,255,255,.02);
            color: var(--muted);
            font-size: 12px;
            display:flex;
            align-items:center;
            justify-content: space-between;
            gap: 10px;
        }
        .dropzone strong{ color: var(--text); font-weight: 600; }
        .dropzone.dragover{
            border-color: rgba(124,58,237,.60);
            background: rgba(124,58,237,.10);
            color: var(--text);
        }

        .progress{
            height: 10px;
            border-radius: 999px;
            background: rgba(255,255,255,.08);
            overflow: hidden;
            border: 1px solid rgba(255,255,255,.10);
        }
        .progress > div{
            height: 100%;
            width: 0%;
            background: linear-gradient(90deg, rgba(124,58,237,.9), rgba(34,197,94,.9));
            transition: width .08s ease;
        }
        .mini{
            font-size: 12px;
            color: var(--muted);
            display:flex;
            align-items:center;
            justify-content: space-between;
            gap: 10px;
        }

        /* 更小更精致的操作按钮 */
        .iconbtn{
            width: 28px;
            height: 28px;
            border-radius: 8px;
            padding: 0;
            border: 1px solid rgba(255,255,255,.12);
            background: rgba(255,255,255,.03);
            color: rgba(229,231,235,.92);
            cursor: pointer;
            display:inline-flex;
            align-items:center;
            justify-content:center;
            transition: background .15s ease, border-color .15s ease, transform .05s ease;
        }

        .iconbtn:hover{
            background: rgba(255,255,255,.06);
            border-color: rgba(255,255,255,.18);
        }

        .iconbtn:active{ transform: translateY(1px); }

        /* SVG 图标大小 */
        .iconbtn svg{
            width: 16px;
            height: 16px;
            display:block;
        }

        /* 删除按钮微红但不刺眼 */
        .iconbtn.danger{
            border-color: rgba(239,68,68,.35);
            background: rgba(239,68,68,.08);
        }
        .iconbtn.danger:hover{
            background: rgba(239,68,68,.12);
        }


        /* ===== TABLE (稳定版) ===== */
        .table{
            width: 100%;
            border-collapse: separate;
            border-spacing: 0;
            border: 1px solid var(--stroke);
            border-radius: var(--r12);
            background: rgba(255,255,255,.02);
            table-layout: fixed; /* 关键:列宽按th比例固定,不会挤成一坨 */
            overflow: hidden;
        }

        .table thead th{
            text-align: left;
            font-size: 12px;
            color: var(--muted);
            padding: 10px 12px;
            border-bottom: 1px solid var(--stroke);
            position: sticky;
            top: 0;
            z-index: 10;
            /* 给 sticky 一个实底,避免看起来像紫色遮罩 */
            background: rgba(11,16,32,.92);
            backdrop-filter: blur(6px);
        }

        .table td{
            padding: 10px 12px;
            border-bottom: 1px solid rgba(255,255,255,.06);
            font-size: 13px;
            color: var(--text);
            vertical-align: middle;
            overflow: hidden; /* 防止相邻列互相盖住 */
        }

        .table tbody tr:hover td{ background: rgba(255,255,255,.03); }

        /* 选中行的紫色高亮:不想要就把这段删掉 */
        .table tbody tr.active td{
            background: rgba(124,58,237,.15);
            border-bottom-color: rgba(124,58,237,.25);
        }

        /* 名称列:可换行 */
        .name-cell{
            display:flex;
            align-items:flex-start;
            gap:10px;
            min-width:0;
        }
        .icon{
            width: 28px; height: 28px;
            display:flex;
            align-items:center;
            justify-content:center;
            border-radius: 10px;
            background: rgba(255,255,255,.04);
            border: 1px solid rgba(255,255,255,.08);
            flex: 0 0 auto;
        }
        .fname{
            min-width: 0;
            white-space: normal;
            word-break: break-all;
            overflow-wrap: anywhere;
            line-height: 1.35;
        }

        /* 右侧meta列:不换行+省略,避免顶到操作列 */
        .meta{
            color: var(--muted);
            font-size: 12px;
            text-align:right;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        /* 只针对 修改时间 列:允许换行,不要省略号 */
        .table td.meta.mtime{
            white-space: normal;      /* 允许换行 */
            overflow: visible;        /* 不裁切 */
            text-overflow: clip;      /* 不要 ... */
            line-height: 1.25;
            text-align: left;         /* 看着更顺(你要右对齐就删掉这行) */
        }

        .table td.meta.mtime .celltext{
            white-space: normal;
            overflow: visible;
            text-overflow: clip;
            word-break: break-word;
            overflow-wrap: anywhere;
        }

        /* 修改时间包一层:强制省略(你JS里用<span class="celltext">...</span>最好) */
        .celltext{
            display:block;
            overflow:hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
        }

        /* 操作列:永远不被其它列盖住 */
        .table td:last-child,
        .table thead th:last-child{
            white-space: nowrap;
        }
        .table td:last-child{
            overflow: visible;
            position: relative;
            z-index: 30;
        }

        .ops{
            display:flex;
            justify-content:flex-end;
            gap:8px;
            flex-wrap: nowrap;
            position: relative;
            z-index: 40;
        }
        .ops .iconbtn{
            position: relative;
            z-index: 50;
        }

        /* ===== Terminal ===== */
        #terminalWrap{ width:100%; height:100%; padding: 10px; }
        #terminal{
            width:100%; height:100%;
            border:1px solid var(--stroke);
            border-radius: var(--r16);
            overflow:hidden;
            background: rgba(0,0,0,.12);
        }

        /* ===== Responsive ===== */
        @media (max-width: 1100px){
            #wrap{ grid-template-columns: 1fr; height: auto; }
            #terminalWrap{ height: 55vh; }
            .table-wrap{ max-height: 45vh; }
        }
    </style>

</head>

<body>
<!-- ===== Top Bar ===== -->
<div class="topbar">
    <div class="topbar-inner">
        <div class="brand">
            <div class="dot"></div>
            <div>
                <b>SSH Terminal</b>
                <span>+ SFTP</span>
            </div>
        </div>

        <input id="boxIdInput" class="inp" disabled />

        <div class="controls">
            <button id="btnFav" class="btn primary">⭐ 收藏夹</button>

            <button id="btnConnect" class="btn good">🖥️ 连接 SSH</button>
            <button id="btnDisconnect" class="btn ghost">⛔ 断开 SSH</button>

            <button id="btnSftpConnect" class="btn primary">📁 连接 SFTP</button>
            <button id="btnSftpDisconnect" class="btn ghost">⛔ 断开 SFTP</button>

            <button id="btnClear" class="btn">🧹 清空终端</button>

            <!-- ===== Quick Commands ===== -->
            <div class="controls" style="gap:8px;">
                <button id="btn-auth-check" class="btn primary">🔐 授权检测</button>
                <button id="btn-hosts-check" class="btn">🧾 hosts检查</button>
                <button id="btn-hosts-repair" class="btn good">🛠️ hosts修复</button>
                <button id="btn-algorithm-auth" class="btn primary">🧠 算法授权</button>
                <button id="btn-reboot" class="btn danger">♻️ 重启设备</button>
                <input id="aiboxVer" class="inp" style="width:140px;" placeholder="版本号" />
                <button id="btn-aibox-update" class="btn good">🚀 版本更新</button>
            </div>

        </div>

        <div class="spacer"></div>

        <span class="pill" id="wsStatus">WS: -</span>
        <span class="pill" id="sshStatus">SSH: -</span>
        <span class="pill" id="sftpStatus">SFTP: -</span>
        <span class="pill" id="wsUrlPill">URL: -</span>
    </div>
</div>
<!-- ===== Favorites Modal ===== -->
<div id="favMask" style="position:fixed; inset:0; background:rgba(0,0,0,.55); display:none; z-index:999;"></div>

<div id="favModal" style="position:fixed; left:50%; top:50%; transform:translate(-50%,-50%);
  width:min(920px,92vw); height:min(720px,86vh); display:none; z-index:1000;
  border:1px solid rgba(255,255,255,.12); border-radius:16px; background:rgba(11,16,32,.92);
  backdrop-filter: blur(10px); box-shadow: 0 10px 40px rgba(0,0,0,.55); overflow:hidden;">

    <div style="padding:12px 14px; display:flex; align-items:center; gap:10px; border-bottom:1px solid rgba(255,255,255,.12);">
        <b style="color:#e5e7eb;">⭐ 设备收藏夹</b>
        <span style="color:rgba(229,231,235,.6); font-size:12px;">保存在本地 localStorage(清浏览器数据才会消失)</span>
        <div style="flex:1;"></div>
        <input id="favSearch" class="inp" style="width:220px;" placeholder="搜索设备..." />
        <button id="btnFavClose" class="btn">关闭</button>
    </div>

    <div style="display:grid; grid-template-columns: 1.1fr .9fr; gap:12px; padding:12px; height:calc(100% - 54px); min-height:0;">
        <!-- Left list -->
        <div class="card" style="min-height:0;">
            <div style="padding:10px 12px; border-bottom:1px solid rgba(255,255,255,.10); display:flex; gap:10px; align-items:center;">
                <button id="btnFavAdd" class="btn good">➕ 新增设备</button>
                <button id="btnFavImport" class="btn">导入 JSON</button>
                <button id="btnFavExport" class="btn">导出 JSON</button>
                <div style="flex:1;"></div>
                <button id="btnFavClearStatus" class="btn">清空状态</button>
            </div>

            <div style="padding:10px; overflow:auto; height:calc(100% - 52px);">
                <table class="table">
                    <thead>
                    <tr>
                        <th style="width:36%;">名称</th>
                        <th style="width:32%;">Host</th>
                        <th style="width:18%;">用户</th>
                        <th style="width:14%;">操作</th>
                    </tr>
                    </thead>
                    <tbody id="favTbody"></tbody>
                </table>
            </div>
        </div>

        <!-- Right editor -->
        <div class="card" style="min-height:0;">
            <div style="padding:10px 12px; border-bottom:1px solid rgba(255,255,255,.10); display:flex; align-items:center; gap:10px;">
                <b style="color:#e5e7eb;">设备信息</b>
                <span id="favEditHint" style="color:rgba(229,231,235,.55); font-size:12px;">选择左侧设备或新增</span>
            </div>

            <div style="padding:12px; overflow:auto; height:calc(100% - 46px);">
                <div style="display:grid; grid-template-columns: 1fr 1fr; gap:10px;">
                    <input id="f_name" class="inp" placeholder="设备名称/别名 例如:Box-01" />
                    <input id="f_port" class="inp" placeholder="端口(可选,默认22)" />
                    <input id="f_host" class="inp" placeholder="SSH IP/域名 例如:192.168.1.10" />
                    <input id="f_user" class="inp" placeholder="登录名 例如:root" />
                </div>

                <div style="margin-top:10px; display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
                    <span style="color:rgba(229,231,235,.7); font-size:12px;">认证方式:</span>
                    <label style="color:#e5e7eb; font-size:12px; display:flex; align-items:center; gap:6px;">
                        <input type="radio" name="authType" value="password" checked /> 密码
                    </label>
                    <label style="color:#e5e7eb; font-size:12px; display:flex; align-items:center; gap:6px;">
                        <input type="radio" name="authType" value="key" /> 私钥
                    </label>
                </div>

                <div id="authPasswordWrap" style="margin-top:10px;">
                    <input id="f_password" class="inp" style="width:100%; border-radius:12px;" placeholder="密码(会保存在本地)" />
                </div>

                <div id="authKeyWrap" style="margin-top:10px; display:none;">
          <textarea id="f_privateKey" style="width:100%; height:180px; resize:vertical;
            border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.03); color:#e5e7eb;
            border-radius:12px; padding:10px 12px; outline:none; font-size:12px;"
                    placeholder="粘贴 OpenSSH 私钥内容(会保存在本地)"></textarea>
                    <input id="f_passphrase" class="inp" style="width:100%; border-radius:12px; margin-top:10px;" placeholder="Passphrase(可选)" />
                </div>

                <div style="margin-top:10px;">
          <textarea id="f_note" style="width:100%; height:90px; resize:vertical;
            border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.03); color:#e5e7eb;
            border-radius:12px; padding:10px 12px; outline:none; font-size:12px;"
                    placeholder="备注(可选)"></textarea>
                </div>

                <div style="margin-top:12px; display:flex; gap:10px; flex-wrap:wrap;">
                    <button id="btnFavSave" class="btn good">💾 保存</button>
                    <button id="btnFavConnect" class="btn primary">🖥️ 用此设备连接 SSH</button>
                    <button id="btnFavDelete" class="btn danger">🗑️ 删除</button>
                </div>

                <div style="margin-top:10px; font-size:12px; color:rgba(229,231,235,.6); line-height:1.5;">
                    <div><b style="color:#e5e7eb;">上次状态:</b><span id="favLastStatus">-</span></div>
                    <div><b style="color:#e5e7eb;">上次信息:</b><span id="favLastMsg">-</span></div>
                    <div><b style="color:#e5e7eb;">上次时间:</b><span id="favLastAt">-</span></div>
                </div>

                <div style="margin-top:10px; font-size:12px; color:rgba(253,224,71,.95);">
                    ⚠️ 本地保存密码/私钥有风险:同一台电脑的其他用户可能读取浏览器数据。”。
                </div>
            </div>
        </div>
    </div>
</div>

<!-- ===== Main ===== -->
<div id="wrap">
    <!-- ===== SFTP Panel ===== -->
    <div class="card file-area">
        <div class="sftp-head">
            <div class="sftp-toolbar">
                <button id="btnBack" class="btn">⬅️ 返回</button>
                <button id="btnRefresh" class="btn">🔄 刷新</button>
                <button id="btnNewFolder" class="btn">📂 新建目录</button>
                <button id="btnRename" class="btn">✏️ 重命名</button>
            </div>

            <div class="sftp-sub">
                <div class="crumbs" id="crumbs"></div>
            </div>

            <div class="sftp-sub">
                <input id="pathInput" class="inp" placeholder="输入路径,例如 /home" />
                <button id="btnGo" class="btn">前往</button>
                <input id="searchInput" class="inp" style="width: 180px;" placeholder="搜索文件名..." />
            </div>

            <div class="dropzone" id="dropzone">
                <div>拖拽文件到此处上传到 <strong id="dropPath">/</strong></div>
                <div style="display:flex; gap:8px; align-items:center;">
                    <input type="file" id="uploadFile" style="display:none" />
                    <button id="btnPick" class="btn">选择文件</button>
                    <button id="btnUpload" class="btn good">上传</button>
                    <button id="btnCancelOp" class="btn danger" disabled>取消</button>
                </div>
            </div>

            <div class="mini">
                <div id="opHint">就绪</div>
                <div style="width: 180px;">
                    <div class="progress"><div id="opBar"></div></div>
                </div>
            </div>
        </div>

        <div class="table-wrap">
            <table class="table">
                <thead>
                <tr>
                    <th style="width: 50%;">名称</th>
                    <th style="width: 18%;">大小</th>
                    <th style="width: 18%;">修改时间</th>
                    <th style="width: 14%;">操作</th>

                </tr>
                </thead>

                <tbody id="fileTbody">
                <!-- rows -->
                </tbody>
            </table>
        </div>
    </div>

    <!-- ===== Terminal ===== -->
    <div class="card" id="terminalWrap">
        <div id="terminal"></div>
    </div>
</div>

<script src="/js/xterm-5.5.0-xterm.js"></script>
<script src="/js/xterm-addon-fit@0.8.0-lib-xterm-addon-fit.js"></script>
<script src="{{ asset('getToken.js') }}"></script>
<script>
    function getCookie(name) {
        const cookies = document.cookie.split(';');
        for (let cookie of cookies) {
            const [cookieName, ...rest] = cookie.split('=');
            if (cookieName.trim() === name) {
                return decodeURIComponent(rest.join('='));
            }
        }
        return null;
    }
</script>
<script>

    (() => {
        const ssh_key = getCookie("ssh_value");   // 例如 login.js 里提供了 getCookie()
        if (!ssh_key) {
            alert('未授权或登录已过期,请重新登录');
            return;
        }
        // ===== 1) URL box id =====
        const qs = new URLSearchParams(window.location.search);
        const favId = (qs.get('fav') || '').trim();

        const boxId = (qs.get('id') || '').trim();

        const boxIdInput = document.getElementById('boxIdInput');
        boxIdInput.value = boxId ? `Box ID: ${boxId}` : '(缺少 ?id=xxx)';

        if (!getToken()) {
            alert('未登录,请先登录后再连接。');
            return;
        }

        // ===== 2) WS URL =====
        const wsProto = (window.location.protocol === 'https:') ? 'wss' : 'ws';

        const WS_URL = `${wsProto}://${window.location.host}/ws/?id=${encodeURIComponent(boxId)}&key=${encodeURIComponent(ssh_key)}`;


        const wsStatusEl = document.getElementById('wsStatus');
        const sshStatusEl = document.getElementById('sshStatus');
        const sftpStatusEl = document.getElementById('sftpStatus');
        const wsUrlPill = document.getElementById('wsUrlPill');
        wsUrlPill.textContent = `URL: ${WS_URL}`;

        const setPill = (el, label, text, ok) => {
            el.textContent = `${label}: ${text}`;
            el.style.color = ok ? '#86efac' : '#fca5a5';
        };
        const setWsStatus = (text, ok) => setPill(wsStatusEl, 'WS', text, ok);
        const setSshStatus = (text, ok) => setPill(sshStatusEl, 'SSH', text, ok);
        const setSftpStatus = (text, ok) => setPill(sftpStatusEl, 'SFTP', text, ok);

        // ===== 3) xterm =====
        if (typeof Terminal === 'undefined') { alert('Terminal 未定义:请确认 xterm.js 路径'); return; }
        if (!window.FitAddon || !window.FitAddon.FitAddon) { alert('FitAddon 未定义'); return; }

        const term = new Terminal({
            cursorBlink: true,
            fontSize: 14,
            scrollback: 8000,
            convertEol: true,
            theme: { background: '#0b1020', foreground: '#e5e7eb' }
        });
        term.options.backspaceAsControlH = false;
        const fitAddon = new FitAddon.FitAddon();
        term.loadAddon(fitAddon);
        term.open(document.getElementById('terminal'));
        fitAddon.fit();
        term.focus();

        const println = (s) => term.writeln(s);
        const print = (s) => term.write(s);

        println('\x1b[36m[SYSTEM]\x1b[0m xterm loaded ✅');
        println(`\x1b[36m[SYSTEM]\x1b[0m WS_URL = ${WS_URL}`);

        if (!boxId) {
            println('\x1b[31m[ERROR]\x1b[0m URL 缺少 ?id=xxx,将无法连接 SSH/SFTP。\r\n');
        }

        // ===== 4) WS =====
        let ws = null;
        let wsOpenPromise = null;
        let sshConnected = false;
        let sftpConnected = false;
        let userDisconnectedSsh = false; // 标记:用户是否主动断开SSH(默认false=允许自动连)
        let userDisconnectedSftp = false; // 标记:用户是否主动断开SFTP(默认false=允许自动连)
        function isWsOpen(){ return ws && ws.readyState === WebSocket.OPEN; }

        function ensureWSOpen(){
            if (isWsOpen()) return Promise.resolve(true);
            if (wsOpenPromise) return wsOpenPromise;

            setWsStatus('连接中...', false);
            setSshStatus('-', false);
            setSftpStatus('-', false);
            sshConnected = false;
            sftpConnected = false;

            wsOpenPromise = new Promise((resolve) => {
                ws = new WebSocket(WS_URL);

                ws.onopen = () => {
                    setWsStatus('已连接', true);
                    println('\r\n\x1b[32m[WS] WebSocket连接成功\x1b[0m\r\n');
                    // ===== 新增以下自动连接逻辑 =====
                    if (boxId) { // 有boxId才自动连,避免无意义请求
                        // 自动连SSH(用户没主动断开才执行)
                        if (!userDisconnectedSsh) {
                            sendResize(); // 先同步终端尺寸
                            send('connect_ssh', { cols: term.cols, rows: term.rows });
                            setSshStatus('自动连接中...', false);
                            println('\x1b[36m[自动连接] 开始连接SSH\x1b[0m');
                        }
                        // 自动连SFTP(用户没主动断开才执行)
                        if (!userDisconnectedSftp) {
                            setSftpStatus('自动连接中...', false);
                            send('sftp_connect');
                            println('\x1b[36m[自动连接] 开始连接SFTP\x1b[0m');
                        }
                    } else {
                        println('\x1b[33m[提示] 缺少boxId,跳过自动连接SSH/SFTP\x1b[0m');
                    }

                    resolve(true);
                };

                ws.onmessage = (e) => {
                    let msg;
                    try { msg = JSON.parse(e.data); } catch { return; }

                    if (msg.type === 'output') { print(msg.data); return; }

                    if (msg.type === 'connected') {
                        sshConnected = true;
                        setSshStatus('已连接', true);
                        onSshResultToFav('ok', msg.msg);
                        println(`\r\n\x1b[32m[SSH] ${msg.msg}\x1b[0m\r\n`);
                        term.focus();
                        return;
                    }




                    if (msg.type === 'sftp') { handleSftpMsg(msg); return; }

                    if (msg.type === 'system') {
                        println(`\r\n\x1b[36m[系统] ${msg.msg}\x1b[0m\r\n`);
                        return;
                    }

                    if (msg.type === 'error') {
                        // 可能来自 SSH 或 SFTP
                        setSshStatus(sshConnected ? '已连接' : '错误', sshConnected);
                        setSftpStatus(sftpConnected ? '已连接' : '错误', sftpConnected);
                        onSshResultToFav('bad', msg.msg);
                        toast(msg.msg, 'bad');
                        println(`\r\n\x1b[31m[错误] ${msg.msg}\x1b[0m\r\n`);
                        return;
                    }
                };

                ws.onclose = () => {
                    sshConnected = false;
                    sftpConnected = false;
                    setWsStatus('已断开', false);
                    setSshStatus('-', false);
                    setSftpStatus('-', false);
                    toast('WebSocket已断开,请确认您的权限,3秒后重连', 'bad');
                    println('\r\n\x1b[31m[WS] WebSocket已断开,请确认您的权限,3秒后重连\x1b[0m\r\n');
                    wsOpenPromise = null;
                    setTimeout(() => ensureWSOpen(), 3000);
                };

                ws.onerror = () => {
                    sshConnected = false;
                    sftpConnected = false;
                    setWsStatus('连接失败', false);
                    toast('WebSocket连接失败', 'bad');
                    println('\r\n\x1b[31m[WS] WebSocket连接失败\x1b[0m\r\n');
                    wsOpenPromise = null;
                    resolve(false);
                };
            });

            return wsOpenPromise;
        }

        function send(action, payload = {}){
            if (!isWsOpen()) return false;
            ws.send(JSON.stringify({ action, ...payload }));
            return true;
        }


        // ===================== Favorites (localStorage) =====================
        const FAV_KEY = 'ssh_favorites_v1';

        function loadFavs(){
            try { return JSON.parse(localStorage.getItem(FAV_KEY) || '[]') || []; }
            catch { return []; }
        }
        function saveFavs(arr){
            localStorage.setItem(FAV_KEY, JSON.stringify(arr || []));
        }
        async function autoConnectFromFavQuery(){
            if (!favId) return;

            const favsNow = loadFavs();
            const it = favsNow.find(x => x.id === favId);
            if (!it) {
                println(`\r\n\x1b[31m[FAV]\x1b[0m 未找到收藏:${favId}\r\n`);
                return;
            }

            // 可选:标题显示设备名,多个 tab 不迷路
            document.title = (it.name || it.host || 'SSH Terminal') + ' - SSH';

            const ok = await ensureWSOpen();
            if (!ok) return;

            println(`\r\n\x1b[36m[FAV]\x1b[0m Auto connect: ${it.name || it.host}\r\n`);

            // 记录一下,connected/error 时写回 lastStatus
            favPendingConnectId = it.id;

            sendResize();

            const auth = (it.authType === 'key')
                ? { type:'key', privateKey: it.privateKey || '', passphrase: it.passphrase || '' }
                : { type:'password', password: it.password || '' };

            send('connect_ssh', {
                cols: term.cols,
                rows: term.rows,
                host: it.host,
                user: it.user,
                auth,
                port: it.port ? Number(it.port) : undefined,
                name: it.name || undefined,
            });

            setSshStatus('连接中...', false);
            toast(`连接中:${it.name || it.host}`, 'warn');
        }

        function uuid(){
            return 'f_' + Math.random().toString(16).slice(2) + Date.now().toString(16);
        }
        function nowISO(){
            return new Date().toISOString();
        }

        let favs = loadFavs();
        let favSelectedId = null;        // 当前编辑的设备id
        let favPendingConnectId = null;  // 本次连接发起所用的设备id(用于写回结果)

// modal els
        const favMask = document.getElementById('favMask');
        const favModal = document.getElementById('favModal');
        const btnFav = document.getElementById('btnFav');
        const btnFavClose = document.getElementById('btnFavClose');
        const favTbody = document.getElementById('favTbody');
        const favSearch = document.getElementById('favSearch');

        const f_name = document.getElementById('f_name');
        const f_port = document.getElementById('f_port');
        const f_host = document.getElementById('f_host');
        const f_user = document.getElementById('f_user');
        const f_password = document.getElementById('f_password');
        const f_privateKey = document.getElementById('f_privateKey');
        const f_passphrase = document.getElementById('f_passphrase');
        const f_note = document.getElementById('f_note');

        const authPasswordWrap = document.getElementById('authPasswordWrap');
        const authKeyWrap = document.getElementById('authKeyWrap');

        const btnFavAdd = document.getElementById('btnFavAdd');
        const btnFavSave = document.getElementById('btnFavSave');
        const btnFavDelete = document.getElementById('btnFavDelete');
        const btnFavConnect = document.getElementById('btnFavConnect');
        const btnFavImport = document.getElementById('btnFavImport');
        const btnFavExport = document.getElementById('btnFavExport');
        const btnFavClearStatus = document.getElementById('btnFavClearStatus');

        const favLastStatus = document.getElementById('favLastStatus');
        const favLastMsg = document.getElementById('favLastMsg');
        const favLastAt = document.getElementById('favLastAt');
        const favEditHint = document.getElementById('favEditHint');

        function openFav(){
            favMask.style.display = 'block';
            favModal.style.display = 'block';
            renderFavTable();
            syncAuthUI();
        }
        function closeFav(){
            favMask.style.display = 'none';
            favModal.style.display = 'none';
        }
        btnFav?.addEventListener('click', openFav);
        btnFavClose?.addEventListener('click', closeFav);
        favMask?.addEventListener('click', closeFav);

        function getAuthType(){
            const r = document.querySelector('input[name="authType"]:checked');
            return r ? r.value : 'password';
        }
        function setAuthType(v){
            document.querySelectorAll('input[name="authType"]').forEach(x => x.checked = (x.value === v));
            syncAuthUI();
        }
        function syncAuthUI(){
            const t = getAuthType();
            authPasswordWrap.style.display = (t === 'password') ? 'block' : 'none';
            authKeyWrap.style.display = (t === 'key') ? 'block' : 'none';
        }

        document.querySelectorAll('input[name="authType"]').forEach(r => {
            r.addEventListener('change', syncAuthUI);
        });

        function fmtLastAt(v){
            if (!v) return '-';
            try {
                const d = new Date(v);
                const pad = (x)=>String(x).padStart(2,'0');
                return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
            } catch { return String(v); }
        }

        function renderFavTable(){
            const q = (favSearch?.value || '').trim().toLowerCase();
            const list = favs.filter(it => {
                if (!q) return true;
                return (it.name||'').toLowerCase().includes(q)
                    || (it.host||'').toLowerCase().includes(q)
                    || (it.user||'').toLowerCase().includes(q);
            });

            favTbody.innerHTML = '';
            list.forEach(it => {
                const tr = document.createElement('tr');
                const statusDot = it.lastStatus === 'ok' ? '🟢' : (it.lastStatus === 'bad' ? '🔴' : '⚪');
                tr.innerHTML = `
      <td style="width:36%;">
        <div style="display:flex; align-items:center; gap:8px; min-width:0;">
          <span title="${it.lastStatus||''}">${statusDot}</span>
          <span style="color:#e5e7eb; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${it.name || '-'}</span>
        </div>
      </td>
      <td style="width:32%;" class="meta">${it.host || '-'}</td>
      <td style="width:18%;" class="meta">${it.user || '-'}</td>
      <td style="width:14%;">
        <div class="ops">
          <button class="iconbtn" data-act="edit" title="编辑">
            <svg viewBox="0 0 24 24" fill="none"><path d="M4 20h4l10-10-4-4L4 16v4z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
          </button>
          <button class="iconbtn" data-act="use" title="连接">
            <svg viewBox="0 0 24 24" fill="none"><path d="M7 7h10v10H7z" stroke="currentColor" stroke-width="2"/><path d="M3 12h4m10 0h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
          </button>
        </div>
      </td>
    `;
                tr.querySelector('[data-act="edit"]').addEventListener('click', () => selectFav(it.id));
                tr.querySelector('[data-act="use"]').addEventListener('click', () => {
                    selectFav(it.id);
                    openFavInNewTab(it.id);
                });

                tr.addEventListener('click', () => selectFav(it.id));
                favTbody.appendChild(tr);
            });

            if (favs.length && !favSelectedId) {
                selectFav(favs[0].id);
            } else if (!favs.length) {
                clearFavForm();
            }
        }
        favSearch?.addEventListener('input', renderFavTable);

        function clearFavForm(){
            favSelectedId = null;
            favEditHint.textContent = '选择左侧设备或新增';
            f_name.value = '';
            f_port.value = '';
            f_host.value = '';
            f_user.value = '';
            f_password.value = '';
            f_privateKey.value = '';
            f_passphrase.value = '';
            f_note.value = '';
            setAuthType('password');
            favLastStatus.textContent = '-';
            favLastMsg.textContent = '-';
            favLastAt.textContent = '-';
        }

        function selectFav(id){
            const it = favs.find(x => x.id === id);
            if (!it) return;

            favSelectedId = id;
            favEditHint.textContent = `编辑:${it.name || it.host || it.id}`;

            f_name.value = it.name || '';
            f_port.value = it.port || '';
            f_host.value = it.host || '';
            f_user.value = it.user || '';
            f_note.value = it.note || '';

            setAuthType(it.authType || 'password');
            f_password.value = it.password || '';
            f_privateKey.value = it.privateKey || '';
            f_passphrase.value = it.passphrase || '';

            favLastStatus.textContent = it.lastStatus || '-';
            favLastMsg.textContent = it.lastMessage || '-';
            favLastAt.textContent = fmtLastAt(it.lastAt);
        }

        function readFavForm(){
            const authType = getAuthType();
            const obj = {
                id: favSelectedId || uuid(),
                name: (f_name.value || '').trim(),
                host: (f_host.value || '').trim(),
                user: (f_user.value || '').trim(),
                port: (f_port.value || '').trim(),
                authType,
                password: authType === 'password' ? (f_password.value || '') : '',
                privateKey: authType === 'key' ? (f_privateKey.value || '') : '',
                passphrase: authType === 'key' ? (f_passphrase.value || '') : '',
                note: (f_note.value || ''),

                // keep status fields if exists
                lastStatus: undefined,
                lastMessage: undefined,
                lastAt: undefined,
            };

            // basic validate (front-end)
            if (!obj.host) { toast('Host 不能为空', 'warn'); return null; }
            if (!obj.user) { toast('用户不能为空', 'warn'); return null; }
            if (authType === 'password' && !obj.password) { toast('密码不能为空(或改用私钥)', 'warn'); return null; }
            if (authType === 'key' && !obj.privateKey) { toast('私钥不能为空(或改用密码)', 'warn'); return null; }

            return obj;
        }

        btnFavAdd?.addEventListener('click', () => {
            clearFavForm();
            f_name.focus();
        });

        btnFavSave?.addEventListener('click', () => {
            const obj = readFavForm();
            if (!obj) return;

            const old = favs.find(x => x.id === obj.id);
            if (old) {
                obj.lastStatus = old.lastStatus;
                obj.lastMessage = old.lastMessage;
                obj.lastAt = old.lastAt;
                favs = favs.map(x => x.id === obj.id ? obj : x);
            } else {
                favs.unshift(obj);
            }
            saveFavs(favs);
            toast('已保存到收藏夹', 'ok');
            renderFavTable();
            selectFav(obj.id);
        });

        btnFavDelete?.addEventListener('click', () => {
            if (!favSelectedId) return toast('未选择设备', 'warn');
            const it = favs.find(x => x.id === favSelectedId);
            if (!it) return;
            if (!confirm(`确定删除设备:${it.name || it.host} ?`)) return;
            favs = favs.filter(x => x.id !== favSelectedId);
            saveFavs(favs);
            toast('已删除', 'ok');
            favSelectedId = null;
            renderFavTable();
            clearFavForm();
        });

        btnFavClearStatus?.addEventListener('click', () => {
            favs = favs.map(x => ({...x, lastStatus: undefined, lastMessage: undefined, lastAt: undefined}));
            saveFavs(favs);
            toast('已清空状态', 'ok');
            renderFavTable();
            if (favSelectedId) selectFav(favSelectedId);
        });

// Export / Import
        btnFavExport?.addEventListener('click', () => {
            const data = JSON.stringify(favs, null, 2);
            const blob = new Blob([data], {type:'application/json'});
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = 'ssh_favorites.json';
            document.body.appendChild(a);
            a.click();
            a.remove();
            URL.revokeObjectURL(url);
            toast('已导出 JSON', 'ok');
        });

        btnFavImport?.addEventListener('click', async () => {
            const text = prompt('粘贴要导入的 JSON(会与现有合并,id 相同则覆盖)');
            if (!text) return;
            try {
                const arr = JSON.parse(text);
                if (!Array.isArray(arr)) throw new Error('JSON不是数组');

                // merge by id
                const map = new Map(favs.map(x => [x.id, x]));
                for (const it of arr) {
                    if (!it || typeof it !== 'object') continue;
                    if (!it.id) it.id = uuid();
                    map.set(it.id, {...map.get(it.id), ...it});
                }
                favs = Array.from(map.values());
                saveFavs(favs);
                toast('导入成功', 'ok');
                renderFavTable();
            } catch (e) {
                toast('导入失败:JSON格式不正确', 'bad');
            }
        });

// ===== Connect with selected fav =====
        async function connectWithSelectedFav(){
            const it = favs.find(x => x.id === favSelectedId);
            if (!it) return toast('未选择设备', 'warn');

            // 确保 WS
            const ok = await ensureWSOpen();
            if (!ok) return;

            // 让 UI 也同步一下(可选)
            println(`\r\n\x1b[36m[FAV]\x1b[0m Using favorite: ${it.name || it.host}\r\n`);

            // 标记本次连接来源,后面 connected/error 要写回
            favPendingConnectId = it.id;

            sendResize();

            // 你说后端支持 3 个参数:这里给你一个清晰的 payload
            // 你可按后端最终字段名调整:例如 host/user/auth 或 ip/username/password 等
            const auth = (it.authType === 'key')
                ? { type:'key', privateKey: it.privateKey || '', passphrase: it.passphrase || '' }
                : { type:'password', password: it.password || '' };

            send('connect_ssh', {
                cols: term.cols,
                rows: term.rows,

                // === three params (example) ===
                host: it.host,
                user: it.user,
                auth,

                // optional
                port: it.port ? Number(it.port) : undefined,
                name: it.name || undefined,
            });

            setSshStatus('连接中...', false);
            toast(`连接中:${it.name || it.host}`, 'warn');
        }

        function openFavInNewTab(id){
            const url = new URL(window.location.href);

            // 用 fav 参数标识:新页从 localStorage 读配置再连接
            url.searchParams.set('fav', id);

            // 重要:不要把密码/私钥放 URL
            window.open(url.toString(), '_blank', 'noopener');
        }

        btnFavConnect?.addEventListener('click', () => {
            if (!favSelectedId) return toast('未选择设备', 'warn');
            openFavInNewTab(favSelectedId);
        });


// ===================== Hook WS events: write back status =====================
// 在你现有 ws.onmessage 里:connected/error 分支处加两行调用即可:
// onSshResultToFav('ok', msg.msg) / onSshResultToFav('bad', msg.msg)

        function onSshResultToFav(status, message){
            if (!favPendingConnectId) return;
            const id = favPendingConnectId;
            favPendingConnectId = null;

            const it = favs.find(x => x.id === id);
            if (!it) return;

            it.lastStatus = status;
            it.lastMessage = String(message || '');
            it.lastAt = nowISO();
            saveFavs(favs);

            // 如果弹窗开着,刷新右侧状态
            if (favModal.style.display !== 'none') {
                renderFavTable();
                if (favSelectedId === id) selectFav(id);
            }
        }



        // ===== Quick Command Runner (send terminal command) =====
        function executeTerminalCommand(cmd, noNewline = false){
            if (!sshConnected) {
                println("\r\n\x1b[31m[ERROR]\x1b[0m SSH not connected\r\n");
                return;
            }
            if (!isWsOpen()) {
                println("\r\n\x1b[31m[ERROR]\x1b[0m WS not open\r\n");
                return;
            }

            // 打印一行提示,便于审计/回看
            println(`\r\n\x1b[36m[CMD]\x1b[0m ${cmd}\r\n`);

            // 复用你现有 input 通道:相当于在终端里输入
            const payload = noNewline ? cmd : (cmd.endsWith('\n') ? cmd : (cmd + '\n'));
            ws.send(JSON.stringify({ action: 'input', data: payload }));
        }

// ===== 5 Buttons events =====
        document.getElementById('btn-auth-check')?.addEventListener('click', () => {
            executeTerminalCommand('cd /home/nle/app/aibox/lic/bin && ./licCheck');
        });

        document.getElementById('btn-hosts-check')?.addEventListener('click', () => {
            executeTerminalCommand('cat /etc/hosts');
        });

        document.getElementById('btn-hosts-repair')?.addEventListener('click', () => {
            executeTerminalCommand(`echo nle | sudo -S sed -i '/auth.newland.com.cn/d' /etc/hosts && echo "192.168.136.180 auth.newland.com.cn" | sudo tee -a /etc/hosts`);
        });

        document.getElementById('btn-algorithm-auth')?.addEventListener('click', () => {
            // 这里保持你原来的 Laravel Blade 写法
            executeTerminalCommand('{!! env('ALGORITHM_LICENSE_CMD') !!}');
        });

        document.getElementById('btn-reboot')?.addEventListener('click', () => {
            if (confirm("确认要重启当前设备吗?重启后终端连接会中断!")) {
                executeTerminalCommand('sudo reboot');
            }
        });
        document.getElementById('btn-aibox-update')?.addEventListener('click', () => {
            if (!sshConnected) {
                println("\r\n\x1b[31m[ERROR]\x1b[0m SSH not connected\r\n");
                return;
            }

            const ver = (document.getElementById('aiboxVer')?.value || '').trim();
            if (!ver) {
                toast('请输入版本号', 'warn');
                document.getElementById('aiboxVer')?.focus();
                return;
            }

            // 前两条执行(会回车)
            executeTerminalCommand('cd /home/nle');
            executeTerminalCommand('chmod 777 AiBox-Update.sh');

            // 拼接版本号:不回车(你说“你不要回车”)
            executeTerminalCommand(`./AiBox-Update.sh ${ver}`, true);

            println('\r\n\x1b[36m[CMD]\x1b[0m 已拼接版本号(未回车),需要你手动回车执行。\r\n');
        });



        function doDelete(item){
            if (!sftpConnected) return toast('请先连接 SFTP', 'warn');
            if (!item) return toast('无效项', 'warn');

            const target = joinPath(currentPath, item.name);
            if (!confirm(`确定删除:\n${target}\n\n`)) return;
            send('sftp_rm', { path: target });
        }

        function doDownload(item){
            if (!sftpConnected) return toast('请先连接 SFTP', 'warn');
            if (!item) return toast('无效项', 'warn');
            if (item.is_dir) return toast('目录不支持直接下载(当前示例仅文件)', 'warn');

            const remote = joinPath(currentPath, item.name);

            setOp('download', remote);
            downloading = { path: remote, chunks: [], size: 0, receivedBytes: 0 };

            setProgress(0);
            toast('开始下载...', 'warn');
            send('sftp_download_begin', { path: remote, chunk: CHUNK });
        }

        let currentOp = { type: null, path: null, cancelled: false }; // upload/download
        const btnCancelOp = document.getElementById('btnCancelOp');

        function setOp(type, path){
            currentOp.type = type;
            currentOp.path = path;
            currentOp.cancelled = false;
            btnCancelOp.disabled = false;
            btnCancelOp.textContent = `取消${type === 'upload' ? '上传' : '下载'}`;
        }

        function clearOp(msg){
            currentOp.type = null;
            currentOp.path = null;
            currentOp.cancelled = false;
            btnCancelOp.disabled = true;
            btnCancelOp.textContent = '取消';
            if (msg) {
                toast(msg, 'warn');
                setProgress(0);
            }
        }
        btnCancelOp.addEventListener('click', () => {
            if (!currentOp.type) return;

            currentOp.cancelled = true;

            if (currentOp.type === 'upload') {
                // 通知后端清理临时文件
                send('sftp_upload_cancel', { path: currentOp.path });
                clearOp('已取消上传');
            }

            if (currentOp.type === 'download') {
                // 通知后端取消下载协程
                send('sftp_download_cancel', { path: currentOp.path });
                downloading = null;
                clearOp('已取消下载');
            }
        });

        function sendResize(){
            fitAddon.fit();
            send('resize', { cols: term.cols, rows: term.rows });
        }

        // ===== 5) SSH events (保留原逻辑) =====
        document.getElementById('btnConnect').addEventListener('click', async () => {
            if (!boxId) return alert('URL 缺少 ?id=xxx,不能连接。');
            const ok = await ensureWSOpen();
            if (!ok) return;
            sendResize();
            send('connect_ssh', { cols: term.cols, rows: term.rows });
            setSshStatus('连接中...', false);
        });

        document.getElementById('btnDisconnect').addEventListener('click', () => {
            send('disconnect');

            sshConnected = false;
            userDisconnectedSsh = true; // 标记:用户主动断开,后续WS重连不再自动连
            setSshStatus('已断开', false);
            println('\x1b[33m[手动操作] 已主动断开SSH,后续WS重连不会自动连接\x1b[0m');
        });

        document.getElementById('btnClear').addEventListener('click', () => term.clear());


        term.onData((data) => {
            if (!sshConnected) { term.writeln("\r\n\x1b[31m[ERROR]\x1b[0m SSH not connected\r\n"); return; }
            if (!isWsOpen()) { term.writeln("\r\n\x1b[31m[ERROR]\x1b[0m WS not open\r\n"); return; }
            ws.send(JSON.stringify({ action: 'input', data }));
        });

        window.addEventListener('resize', () => sendResize());

        // ===== 6) SFTP Modern UI =====
        const pathInput = document.getElementById('pathInput');
        const crumbsEl = document.getElementById('crumbs');
        const tbody = document.getElementById('fileTbody');
        const searchInput = document.getElementById('searchInput');
        const dropzone = document.getElementById('dropzone');
        const dropPathEl = document.getElementById('dropPath');
        const uploadFileEl = document.getElementById('uploadFile');
        const opHint = document.getElementById('opHint');
        const opBar = document.getElementById('opBar');

        let currentPath = '/';
        let allItems = [];
        let selected = null;

        // download state
        let downloading = null; // {path, chunks:[], size, receivedBytes}
        const CHUNK = 64 * 1024;

        function toast(text, type='ok'){
            opHint.textContent = text;
            if (type === 'bad') opHint.style.color = 'rgba(252,165,165,.95)';
            else if (type === 'warn') opHint.style.color = 'rgba(253,224,71,.95)';
            else opHint.style.color = 'rgba(134,239,172,.95)';
            setTimeout(() => { opHint.style.color = 'var(--muted)'; }, 1800);
        }

        function setProgress(pct){
            opBar.style.width = `${Math.max(0, Math.min(100, pct))}%`;
        }

        function normalizePath(p){
            p = (p || '/').trim();
            if (!p) p = '/';
            if (!p.startsWith('/')) p = '/' + p;
            // normalize .. and .
            const parts = [];
            p.split('/').forEach(seg => {
                if (!seg || seg === '.') return;
                if (seg === '..') { parts.pop(); return; }
                parts.push(seg);
            });
            return '/' + parts.join('/');
        }

        function joinPath(base, name){
            base = normalizePath(base);
            if (!base.endsWith('/')) base += '/';
            return normalizePath(base + name);
        }

        function fmtSize(n){
            n = Number(n || 0);
            if (n < 1024) return `${n} B`;
            if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB`;
            if (n < 1024*1024*1024) return `${(n/1024/1024).toFixed(1)} MB`;
            return `${(n/1024/1024/1024).toFixed(1)} GB`;
        }

        function fmtTime(ts){
            ts = Number(ts || 0);
            if (!ts) return '-';
            const d = new Date(ts * 1000);
            const pad = (x) => String(x).padStart(2,'0');
            return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
        }

        function buildCrumbs(path){
            path = normalizePath(path);
            const parts = path.split('/').filter(Boolean);

            crumbsEl.innerHTML = '';
            const rootBtn = document.createElement('button');
            rootBtn.textContent = 'Root';
            rootBtn.onclick = () => goList('/');
            crumbsEl.appendChild(rootBtn);

            let acc = '';
            parts.forEach((seg) => {
                const sep = document.createElement('span');
                sep.className = 'sep';
                sep.textContent = '›';
                crumbsEl.appendChild(sep);

                acc += '/' + seg;
                const btn = document.createElement('button');
                btn.textContent = seg;
                btn.onclick = () => goList(acc);
                crumbsEl.appendChild(btn);
            });
        }
        const COLS = { name: 50, size: 18, mtime: 18, ops: 14 };

        function renderTable(items){
            tbody.innerHTML = '';
            selected = null;

            // parent row (..)
            if (currentPath !== '/') {
                const tr = document.createElement('tr');
                tr.innerHTML = `
  <td style="width:${COLS.name}%;">
    <div class="name-cell">
      <div class="icon">⬆️</div>
      <div class="fname">..</div>
    </div>
  </td>
  <td style="width:${COLS.size}%;" class="meta">DIR</td>
  <td style="width:${COLS.mtime}%;" class="meta">-</td>
  <td style="width:${COLS.ops}%;"></td>
`;

                tr.ondblclick = () => {
                    const parent = normalizePath(currentPath.replace(/\/+$/,'')).split('/').slice(0,-1).join('/') || '/';
                    goList(parent);
                };
                tbody.appendChild(tr);
            }

            items.forEach(it => {
                const tr = document.createElement('tr');
                const icon = it.is_dir ? '📁' : '📄';

                tr.innerHTML = `
  <td style="width:${COLS.name}%;">
    <div class="name-cell">
      <div class="icon">${icon}</div>
      <div class="fname" title="${it.name}">${it.name}</div>
    </div>
  </td>
  <td style="width:${COLS.size}%;" class="meta">${it.is_dir ? 'DIR' : fmtSize(it.size)}</td>
<td style="width:${COLS.mtime}%;" class="meta mtime">
  <span class="celltext">${fmtTime(it.mtime)}</span>
</td>


  <td style="width:${COLS.ops}%;">
    <div class="ops">
    ${it.is_dir ? '' : `
  <button class="iconbtn" data-act="download" title="下载">
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M12 3v10m0 0l4-4m-4 4l-4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
      <path d="M4 17v3h16v-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
    </svg>
  </button>
`}
<button class="iconbtn danger" data-act="delete" title="删除">
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
    <path d="M9 3h6m-8 4h10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
    <path d="M10 11v7m4-7v7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
    <path d="M6 7l1 14h10l1-14" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
  </svg>
</button>

    </div>
  </td>
`;


                // 行选中
                tr.onclick = () => {
                    [...tbody.querySelectorAll('tr')].forEach(x => x.classList.remove('active'));
                    tr.classList.add('active');
                    selected = it;
                };

                // 双击进目录
                tr.ondblclick = () => {
                    if (it.is_dir) goList(joinPath(currentPath, it.name));
                };

                // 操作按钮事件(阻止冒泡,避免影响选中/双击)
                tr.querySelectorAll('button[data-act]').forEach(btn => {
                    btn.addEventListener('click', (e) => {
                        e.stopPropagation();
                        const act = btn.getAttribute('data-act');
                        if (act === 'download') doDownload(it);
                        if (act === 'delete') doDelete(it);
                    });
                    btn.addEventListener('dblclick', (e) => e.stopPropagation());
                });

                tbody.appendChild(tr);
            });

        }

        function applySearch(){
            const q = (searchInput.value || '').trim().toLowerCase();
            if (!q) { renderTable(allItems); return; }
            renderTable(allItems.filter(it => (it.name || '').toLowerCase().includes(q)));
        }

        async function goList(path){
            const ok = await ensureWSOpen();
            if (!ok) return;

            if (!sftpConnected) {
                toast('请先连接 SFTP', 'warn');
                return;
            }

            currentPath = normalizePath(path);
            pathInput.value = currentPath;
            dropPathEl.textContent = currentPath;

            setProgress(0);
            toast('加载目录中...', 'warn');
            send('sftp_list', { path: currentPath });
        }

        // ===== SFTP controls =====
        document.getElementById('btnSftpConnect').addEventListener('click', async () => {
            if (!boxId) return alert('URL 缺少 ?id=xxx,不能连接。');
            const ok = await ensureWSOpen();
            if (!ok) return;
            setSftpStatus('连接中...', false);
            toast('SFTP 连接中...', 'warn');
            send('sftp_connect');
        });

        document.getElementById('btnSftpDisconnect').addEventListener('click', () => {
            send('sftp_disconnect');
            sftpConnected = false;
            userDisconnectedSftp = true; // 标记:用户主动断开,后续WS重连不再自动连
            setSftpStatus('已断开', false);
            toast('SFTP 已断开', 'warn');
            // 可选:终端打印提示
            println('\x1b[33m[手动操作] 已主动断开SFTP,后续WS重连不会自动连接\x1b[0m');
        });

        document.getElementById('btnGo').addEventListener('click', () => {
            goList(pathInput.value || '/');
        });

        document.getElementById('btnRefresh').addEventListener('click', () => goList(currentPath));
        document.getElementById('btnBack').addEventListener('click', () => {
            const parent = normalizePath(currentPath.replace(/\/+$/,'')).split('/').slice(0,-1).join('/') || '/';
            goList(parent);
        });

        document.getElementById('btnNewFolder').addEventListener('click', () => {
            if (!sftpConnected) return toast('请先连接 SFTP', 'warn');
            const name = prompt('新目录名称(创建在当前目录下)');
            if (!name) return;
            const target = joinPath(currentPath, name);
            send('sftp_mkdir', { path: target });
        });



        document.getElementById('btnRename').addEventListener('click', () => {
            if (!sftpConnected) return toast('请先连接 SFTP', 'warn');
            if (!selected) return toast('请先选中一个文件/目录', 'warn');
            const oldPath = joinPath(currentPath, selected.name);
            const newName = prompt('新名称', selected.name);
            if (!newName) return;
            const newPath = joinPath(currentPath, newName);
            send('sftp_rename', { from: oldPath, to: newPath });
        });


        document.getElementById('btnPick').addEventListener('click', () => uploadFileEl.click());
        document.getElementById('btnUpload').addEventListener('click', async () => {
            const f = uploadFileEl.files && uploadFileEl.files[0];
            if (!f) return toast('请选择要上传的文件', 'warn');
            await uploadFile(f);
        });

        searchInput.addEventListener('input', () => applySearch());
        pathInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') goList(pathInput.value || '/'); });

        // ===== Drag & Drop Upload =====
        dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); });
        dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
        dropzone.addEventListener('drop', async (e) => {
            e.preventDefault();
            dropzone.classList.remove('dragover');
            const f = e.dataTransfer.files && e.dataTransfer.files[0];
            if (!f) return;
            await uploadFile(f);
        });

        function arrayBufferToBase64(buffer){
            let binary = '';
            const bytes = new Uint8Array(buffer);
            const len = bytes.byteLength;
            for (let i = 0; i < len; i++) binary += String.fromCharCode(bytes[i]);
            return btoa(binary);
        }

        function base64ToUint8Array(b64){
            const binary = atob(b64);
            const bytes = new Uint8Array(binary.length);
            for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
            return bytes;
        }

        function triggerDownload(filename, uint8){
            const blob = new Blob([uint8]);
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            a.remove();
            URL.revokeObjectURL(url);
        }

        async function uploadFile(file){
            if (!sftpConnected) return toast('请先连接 SFTP', 'warn');

            const remote = joinPath(currentPath, file.name);

            setOp('upload', remote);
            toast(`上传中:${file.name}`, 'warn');
            setProgress(0);

            send('sftp_upload_begin', { path: remote, size: file.size });

            const chunk = CHUNK;
            const total = file.size;
            let seq = 0;

            for (let offset = 0; offset < total; offset += chunk) {
                if (currentOp.cancelled) {
                    // 不再发送剩余分片,也不发 upload_end
                    return;
                }

                const blob = file.slice(offset, Math.min(total, offset + chunk));
                const buf = await blob.arrayBuffer();
                const b64 = arrayBufferToBase64(buf);

                send('sftp_upload_chunk', { seq, data_b64: b64 });
                seq++;

                const pct = Math.floor(((offset + blob.size) / total) * 100);
                setProgress(pct);

                await new Promise(r => setTimeout(r, 1));
            }

            if (!currentOp.cancelled) {
                send('sftp_upload_end');
            }
        }


        // ===== Handle SFTP messages =====
        function handleSftpMsg(msg){
            if (msg.event === 'connected') {
                sftpConnected = true;
                setSftpStatus('已连接', true);
                toast('SFTP 已连接', 'ok');
                println(`\r\n\x1b[32m[SFTP] ${msg.msg}\x1b[0m\r\n`);
                // 自动拉目录
                currentPath = normalizePath(pathInput.value || '/');
                goList(currentPath);
                return;
            }

            if (msg.event === 'disconnected') {
                sftpConnected = false;
                setSftpStatus('已断开', false);
                toast('SFTP 已断开', 'warn');
                return;
            }
            if (msg.event === 'upload_cancelled') {
                clearOp('服务端已取消上传');
                return;
            }
            if (msg.event === 'download_cancelled') {
                downloading = null;
                clearOp('服务端已取消下载');
                return;
            }

            if (msg.event === 'list') {
                currentPath = normalizePath(msg.path || currentPath);
                pathInput.value = currentPath;
                dropPathEl.textContent = currentPath;
                buildCrumbs(currentPath);

                allItems = (msg.items || []);
                applySearch();

                toast(`已加载:${currentPath}`, 'ok');
                setProgress(0);
                return;
            }

            if (msg.event === 'mkdir_ok' || msg.event === 'rm_ok' || msg.event === 'rename_ok' || msg.event === 'upload_ok') {
                toast(`${msg.event} ✅`, 'ok');
                setProgress(0);
                // 刷新
                send('sftp_list', { path: currentPath });
                return;
            }

            // Download flow
            if (msg.event === 'download_begin') {
                // 服务端会 normalize path,这里用服务端的 path 作为唯一准
                const serverPath = msg.path;

                // 让 currentOp / downloading 都对齐到 serverPath
                currentOp.type = 'download';
                currentOp.path = serverPath;
                currentOp.cancelled = false;
                btnCancelOp.disabled = false;

                downloading = {
                    path: serverPath,
                    chunks: [],
                    size: msg.size || 0,
                    receivedBytes: 0,
                };

                setProgress(0);
                return;
            }


            if (msg.event === 'download_chunk') {
                if (currentOp.cancelled) return;
                if (!downloading || downloading.path !== msg.path) return;

                const bin = base64ToUint8Array(msg.data_b64);
                downloading.chunks.push(bin);
                downloading.receivedBytes += bin.byteLength;

                if (downloading.size > 0) {
                    setProgress(Math.floor(downloading.receivedBytes / downloading.size * 100));
                }
                return;
            }


            if (msg.event === 'download_end') {
                if (currentOp.cancelled) return;
                if (!downloading || downloading.path !== msg.path) return;

                if (!downloading.chunks || downloading.chunks.length === 0) {
                    downloading = null;
                    clearOp('下载结束,但没收到数据');
                    return;
                }

                const total = downloading.chunks.reduce((s, c) => s + c.byteLength, 0);
                const merged = new Uint8Array(total);
                let off = 0;
                for (const c of downloading.chunks) { merged.set(c, off); off += c.byteLength; }

                const filename = (downloading.path.split('/').pop() || 'download.bin');
                triggerDownload(filename, merged);

                downloading = null;          // ✅ 必须
                clearOp('下载完成');
                return;
            }


        }


        // ===== Auto connect WS only =====
        ensureWSOpen();
        autoConnectFromFavQuery();
    })();
</script>
</body>
</html>

command代码

<?php

namespace App\Console\Commands;

use App\Models\Box;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Net\SFTP;
use phpseclib3\Net\SSH2;
use Swoole\Coroutine;
use Swoole\Runtime;
use Swoole\WebSocket\Frame;
use Swoole\WebSocket\Server;

class SwooleSshTerminal extends Command
{
    protected $signature = 'swoole:ssh-terminal';
    protected $description = 'Swoole WebSocket SSH Terminal (xterm.js) + SFTP - connect by box id';

    private Server $ws;

    /**
     * fd => [
     *   'ssh' => SSH2|null,
     *   'sftp' => SFTP|null,
     *   'connected' => bool,
     *   'reader_co' => int|null,
     *   'box_id' => int|null,
     *   'ip' => string,
     *   'port' => int,
     *   'user' => string,
     *   'upload_tmp' => string|null,   // temp file path for upload
     *   'upload_target' => string|null // remote target path
     * ]
     */
    private array $clients = [];

    // ========== SFTP SAFETY ==========
    // 允许访问的远端根目录(强烈建议设置,防止访问 /etc 等)
    // 如果你想不限制,设为 null(不推荐)
    private ?string $sftpRoot = '/'; // 例如:'/home' 或 '/home/ubuntu'

    // 下载/上传分片大小(字节)
    private int $chunkSize = 64 * 1024; // 64KB

    public function handle()
    {
        Runtime::enableCoroutine(true);

        $this->ws = new Server('0.0.0.0', 9506);
        $this->ws->set([
            'worker_num' => 1,
            'daemonize' => false,
            'log_file' => storage_path('logs/ssh_terminal.log'),
            'log_level' => SWOOLE_LOG_INFO,
            'open_tcp_nodelay' => true,
            'enable_coroutine' => true,
            'max_conn' => 1024,
            'dispatch_mode' => 2,
        ]);


        $this->ws->on('Open', function (Server $server, $request) {
            $fd = $request->fd;
            $key = (string)($request->get['key'] ?? '');
            if ($key === '' || !Redis::exists($key)) {
                $server->push($fd, json_encode(['type'=>'error','msg'=>'Invalid/expired key'], JSON_UNESCAPED_UNICODE));
                $server->disconnect($fd);
                return;
            }
            // ✅ 从 ws://host/ws/?id=123 读取 box id
            $rawId = (string)($request->get['id'] ?? '');
            $boxId = ctype_digit($rawId) ? (int)$rawId : null;

            $this->clients[$fd] = [
                'ssh' => null,
                'sftp' => null,
                'connected' => false,
                'reader_co' => null,
                'box_id' => $boxId,
                'ip' => '',
                'port' => 0,
                'user' => '',
                'upload_tmp' => null,
                'upload_target' => null,
                'download_co' => null,
                'download_path' => null,
            ];

            $this->dbg($fd, 'WS Open', [
                'remote_addr' => $request->server['remote_addr'] ?? '',
                'remote_port' => $request->server['remote_port'] ?? '',
                'box_id' => $boxId,
                'raw_id' => $rawId,
            ]);

            $server->push($fd, json_encode([
                'type' => 'system',
                'msg'  => $boxId
                    ? "WebSocket connected. BoxID={$boxId}. Click Connect SSH / Connect SFTP."
                    : "WebSocket connected. Missing/invalid ?id=xxx"
            ], JSON_UNESCAPED_UNICODE));
        });

        $this->ws->on('Message', function (Server $server, Frame $frame) {
            $fd = $frame->fd;

            $data = json_decode($frame->data, true);
            if (!is_array($data) || empty($data['action'])) {
                $this->err($fd, 'Bad message format', null, ['raw' => $frame->data]);
                return;
            }

            try {
                switch ($data['action']) {
                    case 'sftp_upload_cancel':
                        $this->sftpUploadCancel($fd);
                        break;

                    case 'sftp_download_cancel':
                        $this->sftpDownloadCancel($fd);
                        break;

                    case 'connect_ssh':
                        $cols = (int)($data['cols'] ?? 80);
                        $rows = (int)($data['rows'] ?? 24);

                        // ✅ 新增:优先使用前端传的 host/user/auth
                        $host = trim((string)($data['host'] ?? ''));
                        $user = trim((string)($data['user'] ?? ''));
                        $port = (int)($data['port'] ?? 22);
                        $auth = $data['auth'] ?? null;

                        if ($host !== '' && $user !== '' && is_array($auth)) {
                            $this->connectSshCustom($fd, $host, $port, $user, $cols, $rows, $auth);
                        } else {
                            // 兼容老逻辑:按 box_id
                            $this->connectSshByBoxId($fd, $cols, $rows);
                        }
                        break;


                    case 'input':
                        $this->writeInput($fd, (string)($data['data'] ?? ''));
                        break;

                    case 'resize':
                        $this->resizePty($fd, (int)($data['cols'] ?? 80), (int)($data['rows'] ?? 24));
                        break;

                    case 'disconnect':
                        $this->disconnect($fd);
                        break;

                    // ===== SFTP (新增) =====
                    case 'sftp_connect':
                        $host = trim((string)($data['host'] ?? ''));
                        $user = trim((string)($data['user'] ?? ''));
                        $port = (int)($data['port'] ?? 22);
                        $auth = $data['auth'] ?? null;

                        if ($host !== '' && $user !== '' && is_array($auth)) {
                            $this->connectSftpCustom($fd, $host, $port, $user, $auth);
                        } else {
                            $this->connectSftpByBoxId($fd);
                        }
                        break;


                    case 'sftp_list':
                        $this->sftpList($fd, (string)($data['path'] ?? '/'));
                        break;

                    case 'sftp_mkdir':
                        $this->sftpMkdir($fd, (string)($data['path'] ?? ''));
                        break;

                    case 'sftp_rm':
                        $this->sftpRemove($fd, (string)($data['path'] ?? ''));
                        break;

                    case 'sftp_rename':
                        $this->sftpRename($fd, (string)($data['from'] ?? ''), (string)($data['to'] ?? ''));
                        break;

                    case 'sftp_download_begin':
                        $this->sftpDownloadBegin($fd, (string)($data['path'] ?? ''), (int)($data['chunk'] ?? $this->chunkSize));
                        break;

                    case 'sftp_upload_begin':
                        $this->sftpUploadBegin($fd, (string)($data['path'] ?? ''), (int)($data['size'] ?? 0));
                        break;

                    case 'sftp_upload_chunk':
                        $this->sftpUploadChunk($fd, (int)($data['seq'] ?? 0), (string)($data['data_b64'] ?? ''));
                        break;

                    case 'sftp_upload_end':
                        $this->sftpUploadEnd($fd);
                        break;

                    case 'sftp_disconnect':
                        $this->sftpDisconnect($fd, false);
                        break;

                    default:
                        $this->pushError($fd, 'Unknown action: ' . $data['action']);
                }
            } catch (\Throwable $e) {
                $this->err($fd, 'Message handler exception', $e, ['action' => $data['action'] ?? '']);
            }
        });

        $this->ws->on('Close', function (Server $server, int $fd) {
            $this->dbg($fd, 'WS Close');
            $this->disconnect($fd, true);
            unset($this->clients[$fd]);
        });

        $this->info('SSH+SFTP Terminal WS started at ws://0.0.0.0:9506');
        $this->ws->start();
    }

    private function sftpUploadCancel(int $fd): void
    {
        // 清理临时文件 & 状态
        $this->sftpUploadCleanup($fd);

        if ($this->ws->isEstablished($fd)) {
            $this->ws->push($fd, json_encode([
                'type' => 'sftp',
                'event' => 'upload_cancelled',
            ], JSON_UNESCAPED_UNICODE));
        }
    }


    /**
     * ✅ 按 box_id 查 Box,拿 ssh_ip / ssh_port / ssh_name
     */
    private function connectSshByBoxId(int $fd, int $cols, int $rows): void
    {
        $conn = $this->clients[$fd] ?? null;
        if (!$conn) return;

        $boxId = $conn['box_id'] ?? null;
        if (!$boxId) {
            $this->pushError($fd, 'Missing/invalid box id (ws url must have ?id=123).');
            return;
        }

        $box = Box::query()->find($boxId);
        if (!$box) {
            $this->pushError($fd, "Box not found: id={$boxId}");
            return;
        }

        $ip = (string)($box->ssh_ip ?? '');
        $port = (int)($box->ssh_port ?? 0);
        $user = (string)($box->ssh_name ?? '');

        if (!filter_var($ip, FILTER_VALIDATE_IP)) {
            $this->pushError($fd, "Invalid ssh_ip in Box(id={$boxId}): {$ip}");
            return;
        }
        if ($port < 1 || $port > 65535) {
            $this->pushError($fd, "Invalid ssh_port in Box(id={$boxId}): {$port}");
            return;
        }
        if ($user === '') {
            $this->pushError($fd, "Invalid ssh_name in Box(id={$boxId})");
            return;
        }

        $this->connectSsh($fd, $ip, $port, $user, $cols, $rows, $boxId);
    }

    /**
     * ✅ 自定义 SSH 连接(来自收藏夹)
     * $auth example:
     *   ['type'=>'password','password'=>'xxx']
     *   ['type'=>'key','privateKey'=>'-----BEGIN...','passphrase'=>'optional']
     */
    private function connectSshCustom(int $fd, string $host, int $port, string $user, int $cols, int $rows, array $auth): void
    {
        if (!isset($this->clients[$fd])) return;

        // 基本校验
        $port = ($port >= 1 && $port <= 65535) ? $port : 22;

        // host 允许 IP 或域名
        if ($host === '') {
            $this->pushError($fd, 'Custom SSH: host empty');
            return;
        }
        if ($user === '') {
            $this->pushError($fd, 'Custom SSH: user empty');
            return;
        }

        $this->dbg($fd, 'connect_ssh custom', [
            'host' => $host,
            'port' => $port,
            'user' => $user,
            'cols' => $cols,
            'rows' => $rows,
            'auth_type' => (string)($auth['type'] ?? ''),
        ]);

        // 断开旧连接(SSH + SFTP都断,避免冲突)
        $this->disconnect($fd);

        try {
            $ssh = new SSH2($host, $port, 10);

            $type = (string)($auth['type'] ?? '');
            $ok = false;

            if ($type === 'password') {
                $password = (string)($auth['password'] ?? '');
                if ($password === '') {
                    $this->pushError($fd, 'Custom SSH: password empty');
                    return;
                }
                $ok = $ssh->login($user, $password);
            } elseif ($type === 'key') {
                $privateKey = (string)($auth['privateKey'] ?? '');
                $passphrase = (string)($auth['passphrase'] ?? '');

                if ($privateKey === '') {
                    $this->pushError($fd, 'Custom SSH: privateKey empty');
                    return;
                }

                // phpseclib 支持带 passphrase 的 key
                $key = $passphrase !== ''
                    ? PublicKeyLoader::loadPrivateKey($privateKey, $passphrase)
                    : PublicKeyLoader::loadPrivateKey($privateKey);

                $ok = $ssh->login($user, $key);
            } else {
                $this->pushError($fd, 'Custom SSH: unknown auth.type');
                return;
            }

            if (!$ok) {
                $this->pushError($fd, 'Custom SSH: login returned false.');
                return;
            }

            $cols = max(10, $cols);
            $rows = max(5, $rows);

            $ssh->enablePTY('xterm', $cols, $rows);


            $ssh->exec('/bin/bash -li');
            $ssh->setTimeout(1);
            $ssh->write("stty cols {$cols} rows {$rows} erase '^?' echo -ixon icanon\n");
            Coroutine::sleep(0.05);

            $this->clients[$fd]['ssh'] = $ssh;
            $this->clients[$fd]['connected'] = true;
            $this->clients[$fd]['ip'] = $host;
            $this->clients[$fd]['port'] = $port;
            $this->clients[$fd]['user'] = $user;

            $this->ws->push($fd, json_encode([
                'type' => 'connected',
                'msg'  => "Connected: {$user}@{$host}:{$port} (Custom)"
            ], JSON_UNESCAPED_UNICODE));

            $this->startReaderCoroutine($fd, $ssh);
        } catch (\Throwable $e) {
            $this->err($fd, 'connectSshCustom exception', $e);
            $this->disconnect($fd);
        }
    }

    /**
     * ✅ 固定密钥 id_rsa + PTY + bash + reader
     */
    private function connectSsh(int $fd, string $ip, int $port, string $user, int $cols, int $rows, int $boxId): void
    {
        if (!isset($this->clients[$fd])) return;

        $this->dbg($fd, 'connect_ssh resolved from Box', [
            'box_id' => $boxId,
            'ip' => $ip,
            'port' => $port,
            'user' => $user,
            'cols' => $cols,
            'rows' => $rows,
        ]);

        // 断开旧连接(SSH + SFTP 都断,避免冲突)
        $this->disconnect($fd);

        try {
            $keyPath = 'edge-gateway-id-rsa/id_rsa_' . $boxId;

// 如果这台设备没有生成专属 key,就退回默认
            if (!Storage::disk('local')->exists($keyPath)) {
                $keyPath = 'edge-gateway-id-rsa/id_rsa';
            }

            if (!Storage::disk('local')->exists($keyPath)) {
                $this->pushError($fd, 'Key not found: ' . $keyPath);
                return;
            }

            $rsa = PublicKeyLoader::load(Storage::disk('local')->get($keyPath));
            $ssh = new SSH2($ip, $port, 10);


            $this->dbg($fd, 'login start');
            $ok = $ssh->login($user, $rsa);
            $this->dbg($fd, 'login result', ['ok' => $ok]);

            if (!$ok) {
                $this->pushError($fd, 'SSH login returned false.');
                return;
            }

            $cols = max(10, $cols);
            $rows = max(5, $rows);

            $ssh->enablePTY('xterm', $cols, $rows);

            // ⚠️ timeout 只能整数秒


            $ssh->exec('/bin/bash -li');
            $ssh->setTimeout(0.1);
            $ssh->write("stty cols {$cols} rows {$rows} erase '^?' echo -ixon icanon\n");
            Coroutine::sleep(0.05);

            $this->clients[$fd]['ssh'] = $ssh;
            $this->clients[$fd]['connected'] = true;
            $this->clients[$fd]['ip'] = $ip;
            $this->clients[$fd]['port'] = $port;
            $this->clients[$fd]['user'] = $user;

            $this->ws->push($fd, json_encode([
                'type' => 'connected',
                'msg'  => "Connected: {$user}@{$ip}:{$port} (BoxID={$boxId})"
            ], JSON_UNESCAPED_UNICODE));

            $this->startReaderCoroutine($fd, $ssh);
        } catch (\Throwable $e) {
            $this->err($fd, 'connectSsh exception', $e);
            $this->disconnect($fd);
        }
    }

    private function startReaderCoroutine(int $fd, SSH2 $ssh): void
    {
        if (!empty($this->clients[$fd]['reader_co'])) {
            try { Coroutine::cancel($this->clients[$fd]['reader_co']); } catch (\Throwable $e) {}
            $this->clients[$fd]['reader_co'] = null;
        }

        $this->clients[$fd]['reader_co'] = Coroutine::create(function () use ($fd, $ssh) {
            try {
                while (true) {
                    if (!isset($this->clients[$fd])) break;
                    if (!$this->ws->isEstablished($fd)) break;
                    if (!$ssh->isConnected()) break;

                    $out = $ssh->read('', SSH2::READ_NEXT);

                    // ✅ 防止刷 "1"
                    if (!is_string($out)) {
                        Coroutine::sleep(0.005);
                        continue;
                    }

                    if ($out !== '') {
                        $this->pushOutput($fd, $out);
                        continue;
                    }

                    Coroutine::sleep(0.005);
                }
            } catch (\Throwable $e) {
                Log::error('[ERR] reader exception', [
                    'fd' => $fd,
                    'error' => $e->getMessage(),
                    'trace' => $e->getTraceAsString(),
                ]);
            }

            $this->disconnect($fd, false);
        });
    }

    private function writeInput(int $fd, string $data): void
    {
        $conn = $this->clients[$fd] ?? null;
        if (!$conn || !$conn['connected'] || !$conn['ssh']) {
            $this->pushError($fd, 'SSH not connected.');
            return;
        }

        try {
            $conn['ssh']->write($data);
        } catch (\Throwable $e) {
            $this->err($fd, 'writeInput exception', $e);
            $this->disconnect($fd);
        }
    }

    private function resizePty(int $fd, int $cols, int $rows): void
    {
        $conn = $this->clients[$fd] ?? null;
        if (!$conn || !$conn['connected'] || !$conn['ssh']) return;

        $cols = max(10, $cols);
        $rows = max(5, $rows);

        try {
            if (method_exists($conn['ssh'], 'setWindowSize')) {
                $conn['ssh']->setWindowSize($cols, $rows);
            }
            $conn['ssh']->write("stty cols {$cols} rows {$rows}\n");
        } catch (\Throwable $e) {
            $this->err($fd, 'resize exception', $e);
        }
    }

    // =========================
    // ========== SFTP ==========
    // =========================

    private function connectSftpByBoxId(int $fd): void
    {
        $conn = $this->clients[$fd] ?? null;
        if (!$conn) return;

        $boxId = $conn['box_id'] ?? null;
        if (!$boxId) {
            $this->pushError($fd, 'Missing/invalid box id (ws url must have ?id=123).');
            return;
        }

        $box = Box::query()->find($boxId);
        if (!$box) {
            $this->pushError($fd, "Box not found: id={$boxId}");
            return;
        }

        $ip = (string)($box->ssh_ip ?? '');
        $port = (int)($box->ssh_port ?? 0);
        $user = (string)($box->ssh_name ?? '');

        if (!filter_var($ip, FILTER_VALIDATE_IP)) {
            $this->pushError($fd, "Invalid ssh_ip in Box(id={$boxId}): {$ip}");
            return;
        }
        if ($port < 1 || $port > 65535) {
            $this->pushError($fd, "Invalid ssh_port in Box(id={$boxId}): {$port}");
            return;
        }
        if ($user === '') {
            $this->pushError($fd, "Invalid ssh_name in Box(id={$boxId})");
            return;
        }

        $this->connectSftp($fd, $ip, $port, $user, $boxId);
    }
    private function connectSftpCustom(int $fd, string $host, int $port, string $user, array $auth): void
    {
        if (!isset($this->clients[$fd])) return;

        $port = ($port >= 1 && $port <= 65535) ? $port : 22;

        // 先断开旧 SFTP(保留 SSH 不动)
        $this->sftpDisconnect($fd, true);

        try {
            $sftp = new SFTP($host, $port, 10);

            $type = (string)($auth['type'] ?? '');
            $ok = false;

            if ($type === 'password') {
                $password = (string)($auth['password'] ?? '');
                if ($password === '') {
                    $this->pushError($fd, 'Custom SFTP: password empty');
                    return;
                }
                $ok = $sftp->login($user, $password);
            } elseif ($type === 'key') {
                $privateKey = (string)($auth['privateKey'] ?? '');
                $passphrase = (string)($auth['passphrase'] ?? '');
                if ($privateKey === '') {
                    $this->pushError($fd, 'Custom SFTP: privateKey empty');
                    return;
                }
                $key = $passphrase !== ''
                    ? PublicKeyLoader::loadPrivateKey($privateKey, $passphrase)
                    : PublicKeyLoader::loadPrivateKey($privateKey);

                $ok = $sftp->login($user, $key);
            } else {
                $this->pushError($fd, 'Custom SFTP: unknown auth.type');
                return;
            }

            if (!$ok) {
                $this->pushError($fd, 'Custom SFTP: login returned false.');
                return;
            }

            $this->clients[$fd]['sftp'] = $sftp;
            $this->clients[$fd]['ip'] = $host;
            $this->clients[$fd]['port'] = $port;
            $this->clients[$fd]['user'] = $user;

            $this->ws->push($fd, json_encode([
                'type' => 'sftp',
                'event' => 'connected',
                'msg' => "SFTP connected: {$user}@{$host}:{$port} (Custom)",
                'root' => $this->sftpRoot,
            ], JSON_UNESCAPED_UNICODE));
        } catch (\Throwable $e) {
            $this->err($fd, 'connectSftpCustom exception', $e);
            $this->clients[$fd]['sftp'] = null;
        }
    }

    private function connectSftp(int $fd, string $ip, int $port, string $user, int $boxId): void
    {
        if (!isset($this->clients[$fd])) return;

        // 先断开旧 SFTP(保留 SSH 不动)
        $this->sftpDisconnect($fd, true);

        try {
            $keyPath = 'edge-gateway-id-rsa/id_rsa_' . $boxId;

            if (!Storage::disk('local')->exists($keyPath)) {
                $keyPath = 'edge-gateway-id-rsa/id_rsa';
            }

            if (!Storage::disk('local')->exists($keyPath)) {
                $this->pushError($fd, 'Key not found: ' . $keyPath);
                return;
            }

            $rsa = PublicKeyLoader::load(Storage::disk('local')->get($keyPath));


            $sftp = new SFTP($ip, $port, 10);
            $ok = $sftp->login($user, $rsa);

            if (!$ok) {
                $this->pushError($fd, 'SFTP login returned false.');
                return;
            }

            $this->clients[$fd]['sftp'] = $sftp;
            $this->clients[$fd]['ip'] = $ip;
            $this->clients[$fd]['port'] = $port;
            $this->clients[$fd]['user'] = $user;

            $this->ws->push($fd, json_encode([
                'type' => 'sftp',
                'event' => 'connected',
                'msg' => "SFTP connected: {$user}@{$ip}:{$port} (BoxID={$boxId})",
                'root' => $this->sftpRoot,
            ], JSON_UNESCAPED_UNICODE));
        } catch (\Throwable $e) {
            $this->err($fd, 'connectSftp exception', $e);
            $this->clients[$fd]['sftp'] = null;
        }
    }

    private function sftpEnsure(int $fd): ?SFTP
    {
        $sftp = $this->clients[$fd]['sftp'] ?? null;
        if (!$sftp) {
            $this->pushError($fd, 'SFTP not connected. Click "Connect SFTP" first.');
            return null;
        }
        return $sftp;
    }

    private function sftpNormalizePath(string $path): string
    {
        $path = trim($path);
        if ($path === '') return '/';
        if ($path[0] !== '/') $path = '/' . $path;

        // 规范化:去掉 /./ 和处理 /../
        $parts = [];
        foreach (explode('/', $path) as $p) {
            if ($p === '' || $p === '.') continue;
            if ($p === '..') { array_pop($parts); continue; }
            $parts[] = $p;
        }
        return '/' . implode('/', $parts);
    }

    private function sftpAssertAllowed(int $fd, string $path): ?string
    {
        $norm = $this->sftpNormalizePath($path);
        if ($this->sftpRoot === null) return $norm;

        $root = $this->sftpNormalizePath($this->sftpRoot);
        if ($root !== '/' && !str_starts_with($norm . '/', $root . '/')) {
            $this->pushError($fd, "Path not allowed. root={$root}, path={$norm}");
            return null;
        }
        return $norm;
    }

    private function sftpList(int $fd, string $path): void
    {
        $sftp = $this->sftpEnsure($fd);
        if (!$sftp) return;

        $path = $this->sftpAssertAllowed($fd, $path);
        if ($path === null) return;

        $list = $sftp->rawlist($path);
        if ($list === false) {
            $this->pushError($fd, "List failed: {$path}");
            return;
        }

        $items = [];
        foreach ($list as $name => $meta) {
            if ($name === '.' || $name === '..') continue;
            $type = $meta['type'] ?? null; // 1=file, 2=dir (phpseclib)
            $items[] = [
                'name' => $name,
                'is_dir' => ($type === 2),
                'size' => (int)($meta['size'] ?? 0),
                'mtime' => (int)($meta['mtime'] ?? 0),
            ];
        }

        usort($items, function ($a, $b) {
            if ($a['is_dir'] !== $b['is_dir']) return $a['is_dir'] ? -1 : 1;
            return strcmp($a['name'], $b['name']);
        });

        $this->ws->push($fd, json_encode([
            'type' => 'sftp',
            'event' => 'list',
            'path' => $path,
            'items' => $items,
        ], JSON_UNESCAPED_UNICODE));
    }

    private function sftpMkdir(int $fd, string $path): void
    {
        $sftp = $this->sftpEnsure($fd);
        if (!$sftp) return;

        $path = $this->sftpAssertAllowed($fd, $path);
        if ($path === null) return;

        $ok = $sftp->mkdir($path, -1, true);
        if (!$ok) {
            $this->pushError($fd, "mkdir failed: {$path}");
            return;
        }

        $this->ws->push($fd, json_encode([
            'type' => 'sftp',
            'event' => 'mkdir_ok',
            'path' => $path,
        ], JSON_UNESCAPED_UNICODE));
    }

    /**
     * 递归删除文件/目录(模拟 rm -rf)- 适配 phpseclib3
     */
    private function sftpRemove(int $fd, string $path): void
    {
        $sftp = $this->sftpEnsure($fd);
        if (!$sftp) return;

        $path = $this->sftpAssertAllowed($fd, $path);
        if ($path === null) return;

        try {
            // 1. 检查路径是否存在(phpseclib3 stat失败返回false,无异常)
            $st = $sftp->stat($path);
            if ($st === false) {
                $error = $sftp->getLastError() ?: '路径不存在或无访问权限';
                $this->pushError($fd, "删除失败: {$path} | {$error}");
                return;
            }

            $isDir = (($st['type'] ?? null) === 2);
            $ok = false;

            if ($isDir) {
                // 2. 目录:递归删除内部所有内容
                $ok = $this->sftpRecursiveRemoveDir($sftp, $path);
            } else {
                // 3. 文件:直接删除
                $ok = $sftp->delete($path);
            }

            if (!$ok) {
                $error = $sftp->getLastError() ?: '未知错误';
                $this->pushError($fd, ($isDir ? "递归删除目录" : "删除文件") . "失败: {$path} | {$error} | 请检查SFTP用户权限");
                return;
            }

            $this->ws->push($fd, json_encode([
                'type' => 'sftp',
                'event' => 'rm_ok',
                'path' => $path,
            ], JSON_UNESCAPED_UNICODE));
        } catch (\Throwable $e) {
            // 捕获phpseclib3可能抛出的异常
            $this->err($fd, '删除操作异常', $e, ['path' => $path]);
        }
    }

    /**
     * 递归删除目录(内部方法)- 适配 phpseclib3
     */
    private function sftpRecursiveRemoveDir(SFTP $sftp, string $dir): bool
    {
        try {
            // 获取目录下所有文件/子目录(包含隐藏文件,phpseclib3 rawlist返回数组或false)
            $list = $sftp->rawlist($dir);
            if ($list === false) {
                return false;
            }

            foreach ($list as $name => $meta) {
                if ($name === '.' || $name === '..') continue;

                $fullPath = rtrim($dir, '/') . '/' . $name;
                $isSubDir = (($meta['type'] ?? null) === 2);

                if ($isSubDir) {
                    // 递归删除子目录
                    if (!$this->sftpRecursiveRemoveDir($sftp, $fullPath)) {
                        return false;
                    }
                } else {
                    // 删除文件(phpseclib3 delete返回bool)
                    if (!$sftp->delete($fullPath)) {
                        return false;
                    }
                }
            }

            // 最后删除空目录(phpseclib3 rmdir返回bool)
            return $sftp->rmdir($dir);
        } catch (\Throwable $e) {
            // 记录递归删除中的异常
            Log::error('递归删除目录异常', [
                'dir' => $dir,
                'error' => $e->getMessage(),
                'trace' => $e->getTraceAsString()
            ]);
            return false;
        }
    }

    private function sftpRename(int $fd, string $from, string $to): void
    {
        $sftp = $this->sftpEnsure($fd);
        if (!$sftp) return;

        $from = $this->sftpAssertAllowed($fd, $from);
        $to = $this->sftpAssertAllowed($fd, $to);
        if ($from === null || $to === null) return;

        $ok = $sftp->rename($from, $to);
        if (!$ok) {
            $this->pushError($fd, "rename failed: {$from} -> {$to}");
            return;
        }

        $this->ws->push($fd, json_encode([
            'type' => 'sftp',
            'event' => 'rename_ok',
            'from' => $from,
            'to' => $to,
        ], JSON_UNESCAPED_UNICODE));
    }

    private function sftpDownloadBegin(int $fd, string $path, int $chunk): void
    {
        $sftp = $this->sftpEnsure($fd);
        if (!$sftp) return;

        $path = $this->sftpAssertAllowed($fd, $path);
        if ($path === null) return;

        $chunk = max(16 * 1024, min(512 * 1024, $chunk)); // 16KB ~ 512KB

        $st = $sftp->stat($path);
        if ($st === false) {
            $this->pushError($fd, "download stat failed: {$path}");
            return;
        }
        if (($st['type'] ?? null) === 2) {
            $this->pushError($fd, "download failed: is a directory: {$path}");
            return;
        }

        $size = (int)($st['size'] ?? 0);

        $this->ws->push($fd, json_encode([
            'type' => 'sftp',
            'event' => 'download_begin',
            'path' => $path,
            'size' => $size,
            'chunk' => $chunk,
        ], JSON_UNESCAPED_UNICODE));

        // 开始新下载前,先取消旧的

        if (!empty($this->clients[$fd]['download_co'])) {
            try { Coroutine::cancel($this->clients[$fd]['download_co']); } catch (\Throwable $e) {}
            $this->clients[$fd]['download_co'] = null;
            $this->clients[$fd]['download_path'] = null;
        }



        $this->clients[$fd]['download_path'] = $path;

        $coId = Coroutine::create(function () use ($fd, $sftp, $path, $size, $chunk) {
            try {
                $offset = 0;
                $seq = 0;

                while ($offset < $size) {
                    if (!isset($this->clients[$fd]) || !$this->ws->isEstablished($fd)) return;

                    // ✅ 如果被取消/切换到另一个下载,就退出
                    if (($this->clients[$fd]['download_path'] ?? '') !== $path) {
                        return;
                    }


                    $len = min($chunk, $size - $offset);
                    $bin = $sftp->get($path, false, $offset, $len);
                    if ($bin === false) {
                        $this->pushError($fd, "download get failed at offset={$offset}: {$path}");
                        return;
                    }

                    $this->ws->push($fd, json_encode([
                        'type' => 'sftp',
                        'event' => 'download_chunk',
                        'path' => $path,
                        'seq' => $seq,
                        'offset' => $offset,
                        'data_b64' => base64_encode($bin),
                    ], JSON_UNESCAPED_UNICODE));

                    $offset += $len;
                    $seq++;
                    Coroutine::sleep(0.001);
                }

                // 正常结束时,清掉状态
                if (isset($this->clients[$fd])) {
                    $this->clients[$fd]['download_co'] = null;
                    $this->clients[$fd]['download_path'] = null;
                }

                $this->ws->push($fd, json_encode([
                    'type' => 'sftp',
                    'event' => 'download_end',
                    'path' => $path,
                    'chunks' => $seq,
                    'size' => $size,
                ], JSON_UNESCAPED_UNICODE));
            } catch (\Throwable $e) {
                $this->err($fd, 'download coroutine exception', $e);
            }
        });

        $this->clients[$fd]['download_co'] = $coId;

    }

    private function sftpDownloadCancel(int $fd): void
    {
        if (isset($this->clients[$fd])) {
            // ✅ 先让协程的 while 检查能立刻退出
            $this->clients[$fd]['download_path'] = null;
        }

        $co = $this->clients[$fd]['download_co'] ?? null;
        if ($co) {
            try { Coroutine::cancel($co); } catch (\Throwable $e) {}
        }

        if (isset($this->clients[$fd])) {
            $this->clients[$fd]['download_co'] = null;
        }

        if ($this->ws->isEstablished($fd)) {
            $this->ws->push($fd, json_encode([
                'type' => 'sftp',
                'event' => 'download_cancelled',
            ], JSON_UNESCAPED_UNICODE));
        }
    }



    private function sftpUploadBegin(int $fd, string $remotePath, int $size): void
    {
        $sftp = $this->sftpEnsure($fd);
        if (!$sftp) return;

        $remotePath = $this->sftpAssertAllowed($fd, $remotePath);
        if ($remotePath === null) return;

        // 清理旧的
        $this->sftpUploadCleanup($fd);

        $tmp = storage_path('app/sftp_upload_' . $fd . '_' . md5($remotePath . microtime(true)) . '.tmp');
        @file_put_contents($tmp, '');

        $this->clients[$fd]['upload_tmp'] = $tmp;
        $this->clients[$fd]['upload_target'] = $remotePath;

        $this->ws->push($fd, json_encode([
            'type' => 'sftp',
            'event' => 'upload_begin_ok',
            'path' => $remotePath,
            'tmp' => basename($tmp),
            'size' => $size,
        ], JSON_UNESCAPED_UNICODE));
    }

    private function sftpUploadChunk(int $fd, int $seq, string $b64): void
    {
        $tmp = $this->clients[$fd]['upload_tmp'] ?? null;
        if (!$tmp || !is_string($tmp)) {
            $this->pushError($fd, 'upload_chunk: no active upload (call sftp_upload_begin first)');
            return;
        }

        $bin = base64_decode($b64, true);
        if ($bin === false) {
            $this->pushError($fd, 'upload_chunk: invalid base64');
            return;
        }

        $ok = @file_put_contents($tmp, $bin, FILE_APPEND);
        if ($ok === false) {
            $this->pushError($fd, 'upload_chunk: write tmp failed');
            return;
        }

        // 可选:回 ACK(前端可不处理)
        $this->ws->push($fd, json_encode([
            'type' => 'sftp',
            'event' => 'upload_chunk_ok',
            'seq' => $seq,
        ], JSON_UNESCAPED_UNICODE));
    }

    private function sftpUploadEnd(int $fd): void
    {
        $sftp = $this->sftpEnsure($fd);
        if (!$sftp) return;

        $tmp = $this->clients[$fd]['upload_tmp'] ?? null;
        $remote = $this->clients[$fd]['upload_target'] ?? null;

        if (!$tmp || !$remote) {
            $this->pushError($fd, 'upload_end: no active upload');
            return;
        }

        if (!is_file($tmp)) {
            $this->pushError($fd, 'upload_end: tmp missing');
            $this->sftpUploadCleanup($fd);
            return;
        }

        try {
            // ✅ 用 SOURCE_LOCAL_FILE 让 phpseclib 自己读本地文件(避免一次性读入内存)
            $ok = $sftp->put($remote, $tmp, SFTP::SOURCE_LOCAL_FILE);
            if (!$ok) {
                $this->pushError($fd, "upload_end: put failed: {$remote}");
                $this->sftpUploadCleanup($fd);
                return;
            }

            $this->ws->push($fd, json_encode([
                'type' => 'sftp',
                'event' => 'upload_ok',
                'path' => $remote,
            ], JSON_UNESCAPED_UNICODE));
        } catch (\Throwable $e) {
            $this->err($fd, 'upload_end exception', $e);
        } finally {
            $this->sftpUploadCleanup($fd);
        }
    }

    private function sftpUploadCleanup(int $fd): void
    {
        $tmp = $this->clients[$fd]['upload_tmp'] ?? null;
        if ($tmp && is_string($tmp) && is_file($tmp)) {
            @unlink($tmp);
        }
        if (isset($this->clients[$fd])) {
            $this->clients[$fd]['upload_tmp'] = null;
            $this->clients[$fd]['upload_target'] = null;
        }
    }

    private function sftpDisconnect(int $fd, bool $silent = false): void
    {
        $conn = $this->clients[$fd] ?? null;
        if (!$conn) return;

        $this->sftpUploadCleanup($fd);

        if (!empty($conn['sftp'])) {
            try { $conn['sftp']->disconnect(); } catch (\Throwable $e) {}
            $this->clients[$fd]['sftp'] = null;
        }

        if (!$silent && $this->ws->isEstablished($fd)) {
            $this->ws->push($fd, json_encode([
                'type' => 'sftp',
                'event' => 'disconnected',
                'msg' => 'SFTP disconnected.',
            ], JSON_UNESCAPED_UNICODE));
        }
    }

    // =========================
    // ===== Disconnect All =====
    // =========================

    private function disconnect(int $fd, bool $isClose = false): void
    {
        $conn = $this->clients[$fd] ?? null;
        if (!$conn) return;

        // 先停 SSH reader
        if (!empty($conn['reader_co'])) {
            try { Coroutine::cancel($conn['reader_co']); } catch (\Throwable $e) {}
            $this->clients[$fd]['reader_co'] = null;
        }

        // 断 SSH
        if (!empty($conn['ssh'])) {
            try { $conn['ssh']->disconnect(); } catch (\Throwable $e) {}
            $this->clients[$fd]['ssh'] = null;
        }
        $this->clients[$fd]['connected'] = false;
        $this->sftpDownloadCancel($fd);
        // 断 SFTP(新增)
        $this->sftpDisconnect($fd, true);

        if (!$isClose && $this->ws->isEstablished($fd)) {
            $this->ws->push($fd, json_encode([
                'type' => 'system',
                'msg'  => 'SSH disconnected.'
            ], JSON_UNESCAPED_UNICODE));
        }
    }

    private function pushOutput(int $fd, string $out): void
    {
        if ($out === '') return;
        if ($this->ws->isEstablished($fd)) {
            $this->ws->push($fd, json_encode([
                'type' => 'output',
                'data' => $out
            ], JSON_UNESCAPED_UNICODE));
        }
    }

    private function pushError(int $fd, string $msg): void
    {
        Log::warning('[PUSH_ERROR]', ['fd' => $fd, 'msg' => $msg]);
        if ($this->ws->isEstablished($fd)) {
            $this->ws->push($fd, json_encode([
                'type' => 'error',
                'msg'  => $msg
            ], JSON_UNESCAPED_UNICODE));
        }
    }

    private function dbg(int $fd, string $msg, array $ctx = []): void
    {
        Log::info('[DBG] ' . $msg, array_merge(['fd' => $fd], $ctx));
        if ($this->ws->isEstablished($fd)) {
            $this->ws->push($fd, json_encode([
                'type' => 'system',
                'msg'  => '[DBG] ' . $msg . (empty($ctx) ? '' : ' ' . json_encode($ctx, JSON_UNESCAPED_UNICODE))
            ], JSON_UNESCAPED_UNICODE));
        }
    }

    private function err(int $fd, string $msg, \Throwable $e = null, array $ctx = []): void
    {
        $payload = array_merge(['fd' => $fd], $ctx);
        if ($e) {
            $payload['error'] = $e->getMessage();
            $payload['trace'] = $e->getTraceAsString();
            $payload['file'] = $e->getFile();
            $payload['line'] = $e->getLine();
        }
        Log::error('[ERR] ' . $msg, $payload);
        $this->pushError($fd, $msg . ($e ? (' | ' . $e->getMessage()) : ''));
    }
}
chowjiawei
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!