你的用户名 1 ヶ月 前
コミット
7849654dd3

+ 16 - 1
API.md

@@ -106,7 +106,7 @@ password=ngsadmin
 
 
 | 参数 | 说明 |
 | 参数 | 说明 |
 |------|------|
 |------|------|
-| `q` | 可选,按 PID / 用户 / 名称 / 命令行过滤 |
+| `q` | 可选,按 PID / 用户 / 名称 / 运行目录 / 命令行过滤 |
 
 
 ```json
 ```json
 {
 {
@@ -119,6 +119,7 @@ password=ngsadmin
       "state": "S",
       "state": "S",
       "name": "systemd",
       "name": "systemd",
       "cmdline": "/sbin/init",
       "cmdline": "/sbin/init",
+      "cwd": "/",
       "threads": 1,
       "threads": 1,
       "rss": 10485760,
       "rss": 10485760,
       "vms": 167772160,
       "vms": 167772160,
@@ -129,6 +130,20 @@ password=ngsadmin
 }
 }
 ```
 ```
 
 
+### WebSocket `/api/shell/ws?ticket=`
+
+交互式终端。先 `GET /api/shell/ticket`(需登录 Cookie)拿到一次性票据,再升级 WebSocket。
+
+- 握手:只回 `101 Switching Protocols`(不回 JSON body)
+- 客户端 → 服务端:原始键入,或 `{"type":"resize","cols":120,"rows":40}`
+- 服务端 → 客户端:PTY 输出(二进制帧)
+- 同时最多 8 个会话;断开连接自动回收
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/shell/ticket` | 签发 60s 一次性 WS ticket |
+| WS | `/api/shell/ws?ticket=` | 终端通道 |
+
 ### `POST /api/system/processes/kill`
 ### `POST /api/system/processes/kill`
 
 
 结束进程(默认 `SIGTERM`)。
 结束进程(默认 `SIGTERM`)。

+ 2 - 0
CMakeLists.txt

@@ -15,6 +15,7 @@ add_executable(ngs
     src/system/metrics.cpp
     src/system/metrics.cpp
     src/system/metrics_history.cpp
     src/system/metrics_history.cpp
     src/system/process.cpp
     src/system/process.cpp
+    src/system/shell.cpp
     src/files/files.cpp
     src/files/files.cpp
     src/tasks/tasks.cpp
     src/tasks/tasks.cpp
     src/schedule/schedule.cpp
     src/schedule/schedule.cpp
@@ -72,6 +73,7 @@ set(NGS_LIBS
     pthread
     pthread
     dl
     dl
     rt
     rt
+    util
     stdc++fs
     stdc++fs
 )
 )
 
 

+ 49 - 0
data/www/assets/app.css

@@ -185,6 +185,48 @@ html, body {
 #view-processes #btn-proc-refresh {
 #view-processes #btn-proc-refresh {
   flex: 0 0 auto;
   flex: 0 0 auto;
 }
 }
+
+#view-shell.is-active {
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+#view-shell.is-active > .shell-card {
+  flex: 1 1 auto;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+#view-shell .shell-body {
+  flex: 1 1 auto;
+  min-height: 0;
+  overflow: hidden;
+  background: #0b1220;
+}
+#view-shell .shell-terminal {
+  width: 100%;
+  height: 100%;
+  padding: 0.5rem 0.65rem;
+  box-sizing: border-box;
+}
+#view-shell .shell-terminal .xterm {
+  height: 100%;
+}
+#view-shell .shell-terminal .xterm-viewport {
+  overflow-y: auto !important;
+}
+#shell-status.is-online {
+  color: #0d9488 !important;
+}
+#shell-status.is-offline {
+  color: #94a3b8 !important;
+}
+#shell-status.is-error {
+  color: #dc2626 !important;
+}
+
 #proc-table th.proc-sort {
 #proc-table th.proc-sort {
   cursor: pointer;
   cursor: pointer;
   user-select: none;
   user-select: none;
@@ -895,6 +937,13 @@ html, body {
   word-break: break-all;
   word-break: break-all;
   max-width: clamp(12rem, 32vw, 40rem);
   max-width: clamp(12rem, 32vw, 40rem);
 }
 }
+.site-log-view.table-mode td.body {
+  white-space: pre-wrap;
+  word-break: break-all;
+  max-width: clamp(10rem, 24vw, 28rem);
+  color: #94a3b8;
+  font: 0.7rem/1.35 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
 .site-log-view.table-mode .status-ok { color: #4ade80; }
 .site-log-view.table-mode .status-ok { color: #4ade80; }
 .site-log-view.table-mode .status-redir { color: #38bdf8; }
 .site-log-view.table-mode .status-redir { color: #38bdf8; }
 .site-log-view.table-mode .status-client { color: #fbbf24; }
 .site-log-view.table-mode .status-client { color: #fbbf24; }

+ 187 - 5
data/www/assets/app.js

@@ -59,6 +59,10 @@
     procSort: "cpu_percent",
     procSort: "cpu_percent",
     procSortDir: "desc",
     procSortDir: "desc",
     procTimer: null,
     procTimer: null,
+    shellTerm: null,
+    shellFit: null,
+    shellWs: null,
+    shellConnected: false,
     siteNginxEditor: null,
     siteNginxEditor: null,
     siteNginxPath: "",
     siteNginxPath: "",
     scheduleJobs: [],
     scheduleJobs: [],
@@ -75,6 +79,7 @@
     mysql: { title: "数据库", eyebrow: "MySQL" },
     mysql: { title: "数据库", eyebrow: "MySQL" },
     files: { title: "文件管理", eyebrow: "Files" },
     files: { title: "文件管理", eyebrow: "Files" },
     processes: { title: "进程管理", eyebrow: "Processes" },
     processes: { title: "进程管理", eyebrow: "Processes" },
+    shell: { title: "终端", eyebrow: "Shell" },
     plans: { title: "计划", eyebrow: "Plans" },
     plans: { title: "计划", eyebrow: "Plans" },
   };
   };
 
 
@@ -149,6 +154,13 @@
     } else {
     } else {
       stopProcAutoRefresh();
       stopProcAutoRefresh();
     }
     }
+    if (name === "shell") {
+      ensureShellTerminal();
+      connectShell();
+    } else {
+      // Keep session alive when switching away; only dispose on logout/unload.
+      fitShellSoon();
+    }
     if (name === "plans") {
     if (name === "plans") {
       loadScheduleJobs().catch((err) => toast(err.message || String(err), "err"));
       loadScheduleJobs().catch((err) => toast(err.message || String(err), "err"));
       startScheduleAutoRefresh();
       startScheduleAutoRefresh();
@@ -165,6 +177,22 @@
       .replace(/"/g, """);
       .replace(/"/g, """);
   }
   }
 
 
+  function safeDecodeUri(s) {
+    let cur = String(s ?? "");
+    if (!cur) return "";
+    // Scanners often double/triple-encode; keep decoding until stable.
+    for (let i = 0; i < 8; i++) {
+      try {
+        const next = decodeURIComponent(cur.replace(/\+/g, "%20"));
+        if (next === cur) break;
+        cur = next;
+      } catch {
+        break;
+      }
+    }
+    return cur;
+  }
+
   function formatBytes(n) {
   function formatBytes(n) {
     const v = Number(n) || 0;
     const v = Number(n) || 0;
     if (v < 1024) return `${v} B`;
     if (v < 1024) return `${v} B`;
@@ -1201,6 +1229,142 @@
     await loadProcesses({ silent: true });
     await loadProcesses({ silent: true });
   }
   }
 
 
+  function setShellStatus(text, kind) {
+    const el = $("#shell-status");
+    if (!el) return;
+    el.textContent = text;
+    el.classList.remove("is-online", "is-offline", "is-error");
+    if (kind) el.classList.add(kind);
+  }
+
+  function shellWsUrl(ticket) {
+    const proto = location.protocol === "https:" ? "wss:" : "ws:";
+    return `${proto}//${location.host}/api/shell/ws?ticket=${encodeURIComponent(ticket || "")}`;
+  }
+
+  function fitShellSoon() {
+    requestAnimationFrame(() => {
+      try {
+        state.shellFit?.fit();
+        sendShellResize();
+      } catch (_) {}
+    });
+  }
+
+  function sendShellResize() {
+    const ws = state.shellWs;
+    const term = state.shellTerm;
+    if (!ws || ws.readyState !== WebSocket.OPEN || !term) return;
+    ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
+  }
+
+  function ensureShellTerminal() {
+    if (state.shellTerm || typeof Terminal === "undefined") return;
+    const host = $("#shell-terminal");
+    if (!host) return;
+    const term = new Terminal({
+      cursorBlink: true,
+      fontSize: 13,
+      fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
+      theme: {
+        background: "#0b1220",
+        foreground: "#e2e8f0",
+        cursor: "#2dd4bf",
+        selectionBackground: "rgba(45, 212, 191, 0.28)",
+      },
+      allowProposedApi: true,
+    });
+    const fit = typeof FitAddon !== "undefined" ? new FitAddon.FitAddon() : null;
+    if (fit) term.loadAddon(fit);
+    term.open(host);
+    state.shellTerm = term;
+    state.shellFit = fit;
+    term.onData((data) => {
+      const ws = state.shellWs;
+      if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
+    });
+    window.addEventListener("resize", () => {
+      if ($("#view-shell")?.classList.contains("is-active")) fitShellSoon();
+    });
+    fitShellSoon();
+  }
+
+  function disconnectShell() {
+    const ws = state.shellWs;
+    state.shellWs = null;
+    state.shellConnected = false;
+    if (ws) {
+      try {
+        ws.onopen = null;
+        ws.onclose = null;
+        ws.onerror = null;
+        ws.onmessage = null;
+        if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
+          ws.close();
+        }
+      } catch (_) {}
+    }
+    setShellStatus("未连接", "is-offline");
+  }
+
+  async function connectShell() {
+    ensureShellTerminal();
+    if (!state.shellTerm) {
+      setShellStatus("终端组件未加载", "is-error");
+      return;
+    }
+    if (state.shellWs &&
+        (state.shellWs.readyState === WebSocket.OPEN ||
+         state.shellWs.readyState === WebSocket.CONNECTING)) {
+      fitShellSoon();
+      state.shellTerm.focus();
+      return;
+    }
+
+    disconnectShell();
+    setShellStatus("连接中…", "is-offline");
+    let ticket = "";
+    try {
+      const data = await api("/api/shell/ticket");
+      ticket = (data && data.ticket) || "";
+    } catch (err) {
+      setShellStatus(err.message || "获取票据失败", "is-error");
+      return;
+    }
+    if (!ticket) {
+      setShellStatus("票据无效", "is-error");
+      return;
+    }
+
+    const ws = new WebSocket(shellWsUrl(ticket));
+    ws.binaryType = "arraybuffer";
+    state.shellWs = ws;
+
+    ws.onopen = () => {
+      state.shellConnected = true;
+      setShellStatus("已连接", "is-online");
+      fitShellSoon();
+      state.shellTerm?.focus();
+    };
+    ws.onmessage = (ev) => {
+      const term = state.shellTerm;
+      if (!term) return;
+      if (typeof ev.data === "string") {
+        term.write(ev.data);
+        return;
+      }
+      term.write(new Uint8Array(ev.data));
+    };
+    ws.onerror = () => {
+      setShellStatus("连接错误", "is-error");
+    };
+    ws.onclose = () => {
+      state.shellConnected = false;
+      if (state.shellWs === ws) state.shellWs = null;
+      setShellStatus("已断开", "is-offline");
+    };
+  }
+
   function renderCrumbs(path) {
   function renderCrumbs(path) {
     const parts = path === "/" ? [] : path.replace(/^\/+/, "").split("/").filter(Boolean);
     const parts = path === "/" ? [] : path.replace(/^\/+/, "").split("/").filter(Boolean);
     let cur = "";
     let cur = "";
@@ -2952,14 +3116,21 @@
     }
     }
     const rows = items
     const rows = items
       .map((r) => {
       .map((r) => {
-        const uri = esc(r.uri || "") + (r.args ? "?" + esc(r.args) : "");
+        const rawUri = (r.uri || "") + (r.args ? "?" + r.args : "");
+        const uri = esc(safeDecodeUri(r.uri || "")) + (r.args ? "?" + esc(safeDecodeUri(r.args)) : "");
+        const body = String(r.body || "");
+        const bodyCell = body
+          ? `<td class="body" title="${esc(body)}">${esc(body)}</td>`
+          : `<td class="body text-secondary">—</td>`;
         const rt = r.request_time != null ? Number(r.request_time).toFixed(3) : "";
         const rt = r.request_time != null ? Number(r.request_time).toFixed(3) : "";
         return `<tr>
         return `<tr>
           <td title="time_ms=${esc(r.time_ms)}">${esc(r.time)}</td>
           <td title="time_ms=${esc(r.time_ms)}">${esc(r.time)}</td>
           <td>${esc(r.ip)}</td>
           <td>${esc(r.ip)}</td>
+          <td title="${esc(r.host || "")}">${esc(r.host || "—")}</td>
           <td>${esc(r.method)}</td>
           <td>${esc(r.method)}</td>
           <td class="${accessStatusClass(r.status)}">${esc(r.status)}</td>
           <td class="${accessStatusClass(r.status)}">${esc(r.status)}</td>
-          <td class="uri">${uri}</td>
+          <td class="uri" title="${esc(rawUri)}">${uri}</td>
+          ${bodyCell}
           <td>${esc(rt)}</td>
           <td>${esc(rt)}</td>
           <td>${esc(formatBytes(Number(r.bytes_recv) || 0))}</td>
           <td>${esc(formatBytes(Number(r.bytes_recv) || 0))}</td>
           <td>${esc(formatBytes(Number(r.bytes_sent) || 0))}</td>
           <td>${esc(formatBytes(Number(r.bytes_sent) || 0))}</td>
@@ -2968,7 +3139,7 @@
       .join("");
       .join("");
     return `<table>
     return `<table>
       <thead><tr>
       <thead><tr>
-        <th>时间</th><th>IP</th><th>方法</th><th>状态</th><th>URI</th>
+        <th>时间</th><th>IP</th><th>域名</th><th>方法</th><th>状态</th><th>URI</th><th>Body</th>
         <th>耗时(s)</th><th>接收</th><th>发送</th>
         <th>耗时(s)</th><th>接收</th><th>发送</th>
       </tr></thead>
       </tr></thead>
       <tbody>${rows}</tbody>
       <tbody>${rows}</tbody>
@@ -3205,7 +3376,7 @@
       return rows
       return rows
         .map(
         .map(
           (r) => `<tr>
           (r) => `<tr>
-        <td class="text-break">${esc(r.uri)}</td>
+        <td class="text-break">${esc(safeDecodeUri(r.uri))}</td>
         <td>${esc(r.count)}</td>
         <td>${esc(r.count)}</td>
         <td>${esc(formatBytes(r.bytes_sent))}</td>
         <td>${esc(formatBytes(r.bytes_sent))}</td>
         <td>${Number(r.avg_request_time || 0).toFixed(3)}s</td>
         <td>${Number(r.avg_request_time || 0).toFixed(3)}s</td>
@@ -3216,7 +3387,7 @@
     return rows
     return rows
       .map(
       .map(
         (r) => `<tr>
         (r) => `<tr>
-        <td class="text-break">${esc(r.uri)}</td>
+        <td class="text-break">${esc(safeDecodeUri(r.uri))}</td>
         <td>${esc(r.count)}</td>
         <td>${esc(r.count)}</td>
         <td>${Number(r.avg_request_time || 0).toFixed(3)}s</td>
         <td>${Number(r.avg_request_time || 0).toFixed(3)}s</td>
         <td>${Number(r.max_request_time || 0).toFixed(3)}s</td>
         <td>${Number(r.max_request_time || 0).toFixed(3)}s</td>
@@ -4000,6 +4171,7 @@
       });
       });
       if (!ok) return;
       if (!ok) return;
       withBusy(async () => {
       withBusy(async () => {
+        disconnectShell();
         await api("/api/auth/logout", { method: "POST", body: {} });
         await api("/api/auth/logout", { method: "POST", body: {} });
         location.replace("/login.html");
         location.replace("/login.html");
       });
       });
@@ -4140,6 +4312,16 @@
       }, btn);
       }, btn);
     });
     });
 
 
+    $("#btn-shell-reconnect")?.addEventListener("click", () => {
+      disconnectShell();
+      connectShell();
+    });
+    $("#btn-shell-clear")?.addEventListener("click", () => {
+      state.shellTerm?.clear();
+      state.shellTerm?.focus();
+    });
+    window.addEventListener("beforeunload", () => disconnectShell());
+
     $("#task-badge").addEventListener("click", () => openTaskPanel());
     $("#task-badge").addEventListener("click", () => openTaskPanel());
     $("#task-panel-close").addEventListener("click", closeTaskPanel);
     $("#task-panel-close").addEventListener("click", closeTaskPanel);
     $("#task-panel-backdrop").addEventListener("click", closeTaskPanel);
     $("#task-panel-backdrop").addEventListener("click", closeTaskPanel);

+ 2 - 0
data/www/assets/xterm/xterm-addon-fit.js

@@ -0,0 +1,2 @@
+!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
+//# sourceMappingURL=xterm-addon-fit.js.map

+ 209 - 0
data/www/assets/xterm/xterm.css

@@ -0,0 +1,209 @@
+/**
+ * Copyright (c) 2014 The xterm.js authors. All rights reserved.
+ * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
+ * https://github.com/chjj/term.js
+ * @license MIT
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * Originally forked from (with the author's permission):
+ *   Fabrice Bellard's javascript vt100 for jslinux:
+ *   http://bellard.org/jslinux/
+ *   Copyright (c) 2011 Fabrice Bellard
+ *   The original design remains. The terminal itself
+ *   has been extended to include xterm CSI codes, among
+ *   other features.
+ */
+
+/**
+ *  Default styles for xterm.js
+ */
+
+.xterm {
+    cursor: text;
+    position: relative;
+    user-select: none;
+    -ms-user-select: none;
+    -webkit-user-select: none;
+}
+
+.xterm.focus,
+.xterm:focus {
+    outline: none;
+}
+
+.xterm .xterm-helpers {
+    position: absolute;
+    top: 0;
+    /**
+     * The z-index of the helpers must be higher than the canvases in order for
+     * IMEs to appear on top.
+     */
+    z-index: 5;
+}
+
+.xterm .xterm-helper-textarea {
+    padding: 0;
+    border: 0;
+    margin: 0;
+    /* Move textarea out of the screen to the far left, so that the cursor is not visible */
+    position: absolute;
+    opacity: 0;
+    left: -9999em;
+    top: 0;
+    width: 0;
+    height: 0;
+    z-index: -5;
+    /** Prevent wrapping so the IME appears against the textarea at the correct position */
+    white-space: nowrap;
+    overflow: hidden;
+    resize: none;
+}
+
+.xterm .composition-view {
+    /* TODO: Composition position got messed up somewhere */
+    background: #000;
+    color: #FFF;
+    display: none;
+    position: absolute;
+    white-space: nowrap;
+    z-index: 1;
+}
+
+.xterm .composition-view.active {
+    display: block;
+}
+
+.xterm .xterm-viewport {
+    /* On OS X this is required in order for the scroll bar to appear fully opaque */
+    background-color: #000;
+    overflow-y: scroll;
+    cursor: default;
+    position: absolute;
+    right: 0;
+    left: 0;
+    top: 0;
+    bottom: 0;
+}
+
+.xterm .xterm-screen {
+    position: relative;
+}
+
+.xterm .xterm-screen canvas {
+    position: absolute;
+    left: 0;
+    top: 0;
+}
+
+.xterm .xterm-scroll-area {
+    visibility: hidden;
+}
+
+.xterm-char-measure-element {
+    display: inline-block;
+    visibility: hidden;
+    position: absolute;
+    top: 0;
+    left: -9999em;
+    line-height: normal;
+}
+
+.xterm.enable-mouse-events {
+    /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
+    cursor: default;
+}
+
+.xterm.xterm-cursor-pointer,
+.xterm .xterm-cursor-pointer {
+    cursor: pointer;
+}
+
+.xterm.column-select.focus {
+    /* Column selection mode */
+    cursor: crosshair;
+}
+
+.xterm .xterm-accessibility,
+.xterm .xterm-message {
+    position: absolute;
+    left: 0;
+    top: 0;
+    bottom: 0;
+    right: 0;
+    z-index: 10;
+    color: transparent;
+    pointer-events: none;
+}
+
+.xterm .live-region {
+    position: absolute;
+    left: -9999px;
+    width: 1px;
+    height: 1px;
+    overflow: hidden;
+}
+
+.xterm-dim {
+    /* Dim should not apply to background, so the opacity of the foreground color is applied
+     * explicitly in the generated class and reset to 1 here */
+    opacity: 1 !important;
+}
+
+.xterm-underline-1 { text-decoration: underline; }
+.xterm-underline-2 { text-decoration: double underline; }
+.xterm-underline-3 { text-decoration: wavy underline; }
+.xterm-underline-4 { text-decoration: dotted underline; }
+.xterm-underline-5 { text-decoration: dashed underline; }
+
+.xterm-overline {
+    text-decoration: overline;
+}
+
+.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
+.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
+.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
+.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
+.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
+
+.xterm-strikethrough {
+    text-decoration: line-through;
+}
+
+.xterm-screen .xterm-decoration-container .xterm-decoration {
+	z-index: 6;
+	position: absolute;
+}
+
+.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
+	z-index: 7;
+}
+
+.xterm-decoration-overview-ruler {
+    z-index: 8;
+    position: absolute;
+    top: 0;
+    right: 0;
+    pointer-events: none;
+}
+
+.xterm-decoration-top {
+    z-index: 2;
+    position: relative;
+}

ファイルの差分が大きいため隠しています
+ 0 - 0
data/www/assets/xterm/xterm.js


+ 24 - 0
data/www/index.html

@@ -6,8 +6,11 @@
   <title>NGS · 控制台</title>
   <title>NGS · 控制台</title>
   <link rel="stylesheet" href="/assets/bootstrap/css/bootstrap.min.css" />
   <link rel="stylesheet" href="/assets/bootstrap/css/bootstrap.min.css" />
   <link rel="stylesheet" href="/assets/bootstrap-icons/bootstrap-icons.css" />
   <link rel="stylesheet" href="/assets/bootstrap-icons/bootstrap-icons.css" />
+  <link rel="stylesheet" href="/assets/xterm/xterm.css" />
   <link rel="stylesheet" href="/assets/app.css" />
   <link rel="stylesheet" href="/assets/app.css" />
   <script src="/assets/chartjs/chart.umd.min.js"></script>
   <script src="/assets/chartjs/chart.umd.min.js"></script>
+  <script src="/assets/xterm/xterm.js"></script>
+  <script src="/assets/xterm/xterm-addon-fit.js"></script>
 </head>
 </head>
 <body>
 <body>
   <div class="app d-flex">
   <div class="app d-flex">
@@ -29,6 +32,7 @@
         <button type="button" class="nav-link text-start nav-item" data-view="files"><i class="bi bi-folder2-open me-2"></i>文件</button>
         <button type="button" class="nav-link text-start nav-item" data-view="files"><i class="bi bi-folder2-open me-2"></i>文件</button>
         <button type="button" class="nav-link text-start nav-item" data-view="mysql"><i class="bi bi-database me-2"></i>数据库</button>
         <button type="button" class="nav-link text-start nav-item" data-view="mysql"><i class="bi bi-database me-2"></i>数据库</button>
         <button type="button" class="nav-link text-start nav-item" data-view="processes"><i class="bi bi-cpu me-2"></i>进程</button>
         <button type="button" class="nav-link text-start nav-item" data-view="processes"><i class="bi bi-cpu me-2"></i>进程</button>
+        <button type="button" class="nav-link text-start nav-item" data-view="shell"><i class="bi bi-terminal me-2"></i>终端</button>
         <button type="button" class="nav-link text-start nav-item" data-view="software"><i class="bi bi-box-seam me-2"></i>软件</button>
         <button type="button" class="nav-link text-start nav-item" data-view="software"><i class="bi bi-box-seam me-2"></i>软件</button>
         <button type="button" class="nav-link text-start nav-item" data-view="plans"><i class="bi bi-calendar2-check me-2"></i>计划</button>
         <button type="button" class="nav-link text-start nav-item" data-view="plans"><i class="bi bi-calendar2-check me-2"></i>计划</button>
       </nav>
       </nav>
@@ -257,6 +261,26 @@
         </div>
         </div>
       </section>
       </section>
 
 
+      <section class="view" id="view-shell" data-view-panel="shell">
+        <div class="card shell-card">
+          <div class="card-header py-2">
+            <div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
+              <div class="text-nowrap">
+                <strong>终端</strong>
+                <span class="small text-secondary ms-2" id="shell-status">未连接</span>
+              </div>
+              <div class="d-flex flex-nowrap align-items-center gap-2">
+                <button type="button" class="btn btn-sm btn-outline-secondary" id="btn-shell-reconnect">重新连接</button>
+                <button type="button" class="btn btn-sm btn-outline-secondary" id="btn-shell-clear">清屏</button>
+              </div>
+            </div>
+          </div>
+          <div class="card-body p-0 shell-body">
+            <div id="shell-terminal" class="shell-terminal"></div>
+          </div>
+        </div>
+      </section>
+
       <section class="view" id="view-software" data-view-panel="software">
       <section class="view" id="view-software" data-view-panel="software">
         <div class="row g-2" id="software-grid"></div>
         <div class="row g-2" id="software-grid"></div>
       </section>
       </section>

+ 46 - 2
src/api/api_server.cpp

@@ -11,6 +11,7 @@
 #include "../system/metrics.h"
 #include "../system/metrics.h"
 #include "../system/metrics_history.h"
 #include "../system/metrics_history.h"
 #include "../system/process.h"
 #include "../system/process.h"
+#include "../system/shell.h"
 #include "../schedule/schedule.h"
 #include "../schedule/schedule.h"
 #include "../tasks/tasks.h"
 #include "../tasks/tasks.h"
 #include "../utils.h"
 #include "../utils.h"
@@ -53,6 +54,7 @@ ylib::network::http::center* g_center = nullptr;
 
 
 void stop_background_services() {
 void stop_background_services() {
     schedule::stop();
     schedule::stop();
+    system::shell_shutdown();
     weblog::stop();
     weblog::stop();
     system::history_stop();
     system::history_stop();
     tasks::stop();
     tasks::stop();
@@ -1571,6 +1573,7 @@ void h_websites_logs(request* req, response* resp) {
             item["referer"] = r.referer;
             item["referer"] = r.referer;
             item["ua"] = r.ua;
             item["ua"] = r.ua;
             item["request"] = r.request;
             item["request"] = r.request;
+            item["body"] = r.body;
             arr.push_back(item);
             arr.push_back(item);
         }
         }
     }
     }
@@ -1795,6 +1798,22 @@ void h_system_processes_kill(request* req, response* resp) {
     reply_ok(resp, data, "process signaled");
     reply_ok(resp, data, "process signaled");
 }
 }
 
 
+void h_shell_ticket(request* req, response* resp) {
+    if (!require_method(req, resp, "GET")) {
+        return;
+    }
+    std::string username;
+    if (!request_authed(req, &username)) {
+        reply_err(resp, "未登录或会话已过期", 401);
+        return;
+    }
+    const std::string ticket = system::shell_issue_ticket(username);
+    ylib::json data;
+    data["ticket"] = ticket;
+    data["ttl_sec"] = 60;
+    reply_ok(resp, data);
+}
+
 void h_system_metrics_history(request* req, response* resp) {
 void h_system_metrics_history(request* req, response* resp) {
     if (!require_method(req, resp, "GET")) {
     if (!require_method(req, resp, "GET")) {
         return;
         return;
@@ -2161,6 +2180,20 @@ void register_routes(ylib::network::http::router* router) {
     reg(router, "/api/system/metrics/history", h_system_metrics_history);
     reg(router, "/api/system/metrics/history", h_system_metrics_history);
     reg(router, "/api/system/processes", h_system_processes);
     reg(router, "/api/system/processes", h_system_processes);
     reg(router, "/api/system/processes/kill", h_system_processes_kill);
     reg(router, "/api/system/processes/kill", h_system_processes_kill);
+
+    router->subscribe()->add(
+        R"(/api/shell/ws(?:\?.*)?)", "",
+        [](request* req, response* resp, websocket_message* ws, const std::string&,
+           const std::string&) {
+            // 鉴权在 upgrade 内用 ticket;此处不要 reply_err JSON。
+            system::shell_ws_handler(req, resp, ws);
+        });
+    router->on_close([](uint64 connid, websocket_message*) {
+        system::shell_on_conn_close(static_cast<uint64_t>(connid));
+    });
+
+    reg(router, "/api/shell/ticket", h_shell_ticket);
+
     reg(router, "/api/tasks", h_tasks_list);
     reg(router, "/api/tasks", h_tasks_list);
     reg(router, "/api/tasks/log", h_tasks_log);
     reg(router, "/api/tasks/log", h_tasks_log);
     reg(router, "/api/tasks/clear", h_tasks_clear);
     reg(router, "/api/tasks/clear", h_tasks_clear);
@@ -2228,7 +2261,18 @@ void register_routes(ylib::network::http::router* router) {
     reg(router, "/api/files/download", h_files_download);
     reg(router, "/api/files/download", h_files_download);
 
 
     const std::string www = panel_www_dir();
     const std::string www = panel_www_dir();
-    router->other([www](request* req, response* resp, websocket_message*) {
+    router->other([www](request* req, response* resp, websocket_message* ws) {
+        // WebSocket 未命中订阅:绝不能 reply_err JSON,否则握手被 200+JSON 打断。
+        if (ws) {
+            if (ws->type ==
+                ylib::network::http::HTTP_SERVER_WEBSOCKET_TYPE_UPGRADE) {
+                resp->send_header(404, "Not Found");
+            } else if (ws->type !=
+                       ylib::network::http::HTTP_SERVER_WEBSOCKET_TYPE_CLOSE) {
+                resp->response_done();
+            }
+            return;
+        }
         std::string path = req ? req->filepath() : "/";
         std::string path = req ? req->filepath() : "/";
         if (path.empty() || path == "/") {
         if (path.empty() || path == "/") {
             path = "/index.html";
             path = "/index.html";
@@ -2336,7 +2380,7 @@ bool run(const std::string& listen_addr, uint16_t listen_port) {
     ws_config.router.threadpool.size = 8;
     ws_config.router.threadpool.size = 8;
     ws_config.router.threadpool.queuemax = 10000;
     ws_config.router.threadpool.queuemax = 10000;
     config.max_upload_size = 64 * 1024 * 1024;
     config.max_upload_size = 64 * 1024 * 1024;
-    config.websocket_enable = false;
+    config.websocket_enable = true;
     config.website.push_back(ws_config);
     config.website.push_back(ws_config);
 
 
     if (!center->create(config)) {
     if (!center->create(config)) {

+ 8 - 0
src/software/nginx/nginx.cpp

@@ -328,8 +328,14 @@ bool ensure_access_log_format() {
 
 
     // JSON access log for traffic / IP / attack / latency analysis.
     // JSON access log for traffic / IP / attack / latency analysis.
     // Must appear BEFORE vhost include so sites can reference ngs_detail.
     // Must appear BEFORE vhost include so sites can reference ngs_detail.
+    // $ngs_body_mirror + per-server mirror reads POST body into $request_body.
     const std::string block = R"NGINX(
     const std::string block = R"NGINX(
     # NGS_LOG_FORMAT_BEGIN
     # NGS_LOG_FORMAT_BEGIN
+    # Mirror sink used only for POST so $request_body is available in access_log.
+    map $request_method $ngs_body_mirror {
+        default "";
+        POST    /__ngs_req_body_mirror;
+    }
     # Detailed JSON access log (one request per line).
     # Detailed JSON access log (one request per line).
     log_format ngs_detail escape=json
     log_format ngs_detail escape=json
         '{"time":"$time_iso8601",'
         '{"time":"$time_iso8601",'
@@ -356,6 +362,8 @@ bool ensure_access_log_format() {
         '"referer":"$http_referer",'
         '"referer":"$http_referer",'
         '"ua":"$http_user_agent",'
         '"ua":"$http_user_agent",'
         '"content_type":"$sent_http_content_type",'
         '"content_type":"$sent_http_content_type",'
+        '"request_content_type":"$content_type",'
+        '"body":"$request_body",'
         '"protocol":"$server_protocol",'
         '"protocol":"$server_protocol",'
         '"server_port":$server_port,'
         '"server_port":$server_port,'
         '"connection":$connection,'
         '"connection":$connection,'

+ 548 - 0
src/system/shell.cpp

@@ -0,0 +1,548 @@
+#include "shell.h"
+
+#include "util/codec.h"
+#include "util/json.h"
+#include "net/http_request.h"
+#include "net/http_response.h"
+#include "net/http_reqpack.h"
+#include "net/http_define.h"
+#include "net/http_server.h"
+
+#include "HPSocket/HPSocket.h"
+
+#include <atomic>
+#include <cerrno>
+#include <chrono>
+#include <cstring>
+#include <fcntl.h>
+#include <map>
+#include <memory>
+#include <mutex>
+#include <pty.h>
+#include <random>
+#include <signal.h>
+#include <string>
+#include <sys/ioctl.h>
+#include <sys/select.h>
+#include <sys/wait.h>
+#include <thread>
+#include <unistd.h>
+#include <vector>
+
+namespace ngs {
+namespace system {
+namespace {
+
+using ylib::network::http::request;
+using ylib::network::http::response;
+using ylib::network::http::websocket_message;
+using ylib::network::http::HTTP_SERVER_WEBSOCKET_TYPE_UPGRADE;
+using ylib::network::http::HTTP_SERVER_WEBSOCKET_TYPE_MESSAGE_HEADER;
+using ylib::network::http::HTTP_SERVER_WEBSOCKET_TYPE_MESSAGE_BODY;
+using ylib::network::http::HTTP_SERVER_WEBSOCKET_TYPE_CLOSE;
+
+constexpr int kMaxSessions = 8;
+constexpr int64_t kTicketTtlSec = 60;
+constexpr const char* kWsGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
+
+struct ShellSession {
+    uint64_t connid = 0;
+    IHttpServer* hp = nullptr;
+    int master = -1;
+    pid_t pid = -1;
+    std::atomic<bool> alive{true};
+    std::thread reader;
+};
+
+struct ShellTicket {
+    std::string username;
+    int64_t expire_at = 0;
+};
+
+std::mutex g_mu;
+std::map<uint64_t, std::shared_ptr<ShellSession>> g_sessions;
+std::map<std::string, ShellTicket> g_tickets;
+
+int64_t now_sec() {
+    using clock = std::chrono::system_clock;
+    return std::chrono::duration_cast<std::chrono::seconds>(
+               clock::now().time_since_epoch())
+        .count();
+}
+
+std::string random_hex(size_t bytes) {
+    static thread_local std::mt19937_64 rng{std::random_device{}()};
+    static const char* hex = "0123456789abcdef";
+    std::string out;
+    out.reserve(bytes * 2);
+    for (size_t i = 0; i < bytes; ++i) {
+        const auto v = static_cast<unsigned>(rng() & 0xff);
+        out.push_back(hex[v >> 4]);
+        out.push_back(hex[v & 0xf]);
+    }
+    return out;
+}
+
+void purge_tickets_locked() {
+    const int64_t now = now_sec();
+    for (auto it = g_tickets.begin(); it != g_tickets.end();) {
+        if (it->second.expire_at < now) {
+            it = g_tickets.erase(it);
+        } else {
+            ++it;
+        }
+    }
+}
+
+std::string query_param(const std::string& url, const std::string& key) {
+    const auto qpos = url.find('?');
+    if (qpos == std::string::npos) {
+        return "";
+    }
+    std::string qs = url.substr(qpos + 1);
+    const auto hash = qs.find('#');
+    if (hash != std::string::npos) {
+        qs = qs.substr(0, hash);
+    }
+    size_t start = 0;
+    while (start < qs.size()) {
+        size_t amp = qs.find('&', start);
+        if (amp == std::string::npos) {
+            amp = qs.size();
+        }
+        const std::string part = qs.substr(start, amp - start);
+        const auto eq = part.find('=');
+        const std::string k = eq == std::string::npos ? part : part.substr(0, eq);
+        if (k == key) {
+            std::string v = eq == std::string::npos ? "" : part.substr(eq + 1);
+            std::string out;
+            for (size_t i = 0; i < v.size(); ++i) {
+                if (v[i] == '%' && i + 2 < v.size()) {
+                    auto hex = [](char c) -> int {
+                        if (c >= '0' && c <= '9') return c - '0';
+                        if (c >= 'a' && c <= 'f') return c - 'a' + 10;
+                        if (c >= 'A' && c <= 'F') return c - 'A' + 10;
+                        return -1;
+                    };
+                    const int hi = hex(v[i + 1]);
+                    const int lo = hex(v[i + 2]);
+                    if (hi >= 0 && lo >= 0) {
+                        out.push_back(static_cast<char>((hi << 4) | lo));
+                        i += 2;
+                        continue;
+                    }
+                }
+                if (v[i] == '+') {
+                    out.push_back(' ');
+                } else {
+                    out.push_back(v[i]);
+                }
+            }
+            return out;
+        }
+        start = amp + 1;
+    }
+    return "";
+}
+
+bool send_ws(IHttpServer* hp, uint64_t connid, const char* data, size_t len,
+             BYTE opcode = 0x1) {
+    if (!hp || !data || len == 0) {
+        return false;
+    }
+    return hp->SendWSMessage((CONNID)connid, TRUE, 0, opcode,
+                             reinterpret_cast<const BYTE*>(data),
+                             static_cast<int>(len)) == TRUE;
+}
+
+void close_fd(int& fd) {
+    if (fd >= 0) {
+        ::close(fd);
+        fd = -1;
+    }
+}
+
+void kill_child(pid_t& pid) {
+    if (pid <= 0) {
+        return;
+    }
+    ::kill(pid, SIGTERM);
+    for (int i = 0; i < 20; ++i) {
+        int st = 0;
+        pid_t r = ::waitpid(pid, &st, WNOHANG);
+        if (r == pid || (r < 0 && errno == ECHILD)) {
+            pid = -1;
+            return;
+        }
+        ::usleep(50000);
+    }
+    ::kill(pid, SIGKILL);
+    ::waitpid(pid, nullptr, 0);
+    pid = -1;
+}
+
+void destroy_session_locked(std::shared_ptr<ShellSession> s) {
+    if (!s) {
+        return;
+    }
+    s->alive.store(false);
+    close_fd(s->master);
+    if (s->reader.joinable()) {
+        if (std::this_thread::get_id() != s->reader.get_id()) {
+            s->reader.join();
+        } else {
+            s->reader.detach();
+        }
+    }
+    kill_child(s->pid);
+}
+
+void reader_loop(std::shared_ptr<ShellSession> s) {
+    char buf[4096];
+    while (s->alive.load()) {
+        if (s->master < 0 || !s->hp) {
+            break;
+        }
+        fd_set rfds;
+        FD_ZERO(&rfds);
+        FD_SET(s->master, &rfds);
+        timeval tv{};
+        tv.tv_sec = 0;
+        tv.tv_usec = 200000;
+        const int ready = ::select(s->master + 1, &rfds, nullptr, nullptr, &tv);
+        if (ready < 0) {
+            if (errno == EINTR) {
+                continue;
+            }
+            break;
+        }
+        if (ready == 0) {
+            continue;
+        }
+        if (!FD_ISSET(s->master, &rfds)) {
+            continue;
+        }
+        const ssize_t n = ::read(s->master, buf, sizeof(buf));
+        if (n > 0) {
+            if (!send_ws(s->hp, s->connid, buf, static_cast<size_t>(n), 0x2)) {
+                break;
+            }
+            continue;
+        }
+        if (n == 0) {
+            break;
+        }
+        if (errno == EINTR || errno == EAGAIN) {
+            continue;
+        }
+        break;
+    }
+
+    if (s->alive.exchange(false)) {
+        const char* bye = "\r\n\x1b[90m[shell 已退出]\x1b[0m\r\n";
+        send_ws(s->hp, s->connid, bye, std::strlen(bye), 0x1);
+    }
+
+    close_fd(s->master);
+    kill_child(s->pid);
+
+    std::lock_guard<std::mutex> lock(g_mu);
+    auto it = g_sessions.find(s->connid);
+    if (it != g_sessions.end() && it->second.get() == s.get()) {
+        g_sessions.erase(it);
+    }
+}
+
+bool spawn_shell(std::shared_ptr<ShellSession> s, unsigned short cols,
+                 unsigned short rows, std::string& err) {
+    winsize wsz{};
+    wsz.ws_col = cols > 0 ? cols : 80;
+    wsz.ws_row = rows > 0 ? rows : 24;
+
+    int master = -1;
+    const pid_t pid = forkpty(&master, nullptr, nullptr, &wsz);
+    if (pid < 0) {
+        err = std::string("forkpty 失败: ") + std::strerror(errno);
+        return false;
+    }
+    if (pid == 0) {
+        ::setenv("TERM", "xterm-256color", 1);
+        ::setenv("COLORTERM", "truecolor", 1);
+        const char* shell = ::getenv("SHELL");
+        if (!shell || !*shell) {
+            shell = "/bin/bash";
+        }
+        ::execl(shell, shell, "-l", static_cast<char*>(nullptr));
+        ::execl("/bin/bash", "bash", "-l", static_cast<char*>(nullptr));
+        ::execl("/bin/sh", "sh", static_cast<char*>(nullptr));
+        ::_exit(127);
+    }
+
+    const int flags = ::fcntl(master, F_GETFL, 0);
+    if (flags >= 0) {
+        ::fcntl(master, F_SETFL, flags | O_NONBLOCK);
+    }
+
+    s->master = master;
+    s->pid = pid;
+    s->reader = std::thread(reader_loop, s);
+    return true;
+}
+
+void write_input(const std::shared_ptr<ShellSession>& s, const char* data,
+                 size_t len) {
+    if (!s || s->master < 0 || !data || len == 0) {
+        return;
+    }
+    size_t off = 0;
+    while (off < len && s->alive.load()) {
+        const ssize_t n = ::write(s->master, data + off, len - off);
+        if (n > 0) {
+            off += static_cast<size_t>(n);
+            continue;
+        }
+        if (n < 0 && (errno == EINTR || errno == EAGAIN)) {
+            ::usleep(1000);
+            continue;
+        }
+        break;
+    }
+}
+
+void resize_pty(const std::shared_ptr<ShellSession>& s, unsigned short cols,
+                unsigned short rows) {
+    if (!s || s->master < 0) {
+        return;
+    }
+    winsize wsz{};
+    wsz.ws_col = cols > 0 ? cols : 80;
+    wsz.ws_row = rows > 0 ? rows : 24;
+    ::ioctl(s->master, TIOCSWINSZ, &wsz);
+}
+
+std::string ws_accept_key(const std::string& sec_key) {
+    const std::string material = sec_key + kWsGuid;
+    ylib::buffer dig = ylib::codec::sha1(ylib::buffer(material));
+    return ylib::codec::base64::en(dig);
+}
+
+bool handle_upgrade(request* req, response* resp, websocket_message* ws) {
+    if (!req || !resp || !ws) {
+        return false;
+    }
+    auto fail = [&](ushort code, const char* desc) {
+        resp->send_header(code, desc ? desc : "Error");
+        return false;
+    };
+
+    if (!req->reqpack() || !req->reqpack()->server()) {
+        return fail(500, "Internal Server Error");
+    }
+
+    // Cookie 在升级异步回调里经常读不到;用一次性 ticket(URL query)鉴权。
+    const std::string path = req->filepath();
+    const std::string ticket = query_param(path, "ticket");
+    std::string user;
+    if (!shell_consume_ticket(ticket, &user)) {
+        return fail(401, "Unauthorized");
+    }
+
+    {
+        std::lock_guard<std::mutex> lock(g_mu);
+        if (static_cast<int>(g_sessions.size()) >= kMaxSessions) {
+            return fail(503, "Service Unavailable");
+        }
+        if (g_sessions.count(ws->connid)) {
+            return fail(409, "Conflict");
+        }
+    }
+
+    auto* hp =
+        static_cast<IHttpServer*>(req->reqpack()->server()->hpserver());
+    if (!hp) {
+        return fail(500, "Internal Server Error");
+    }
+
+    auto session = std::make_shared<ShellSession>();
+    session->connid = ws->connid;
+    session->hp = hp;
+
+    std::string err;
+    if (!spawn_shell(session, 80, 24, err)) {
+        return fail(500, "Internal Server Error");
+    }
+
+    {
+        std::lock_guard<std::mutex> lock(g_mu);
+        g_sessions[ws->connid] = session;
+    }
+
+    const std::string accept = ws_accept_key(ws->sec_websocket_key);
+    (*resp->headers())["Upgrade"] = "websocket";
+    (*resp->headers())["Connection"] = "Upgrade";
+    (*resp->headers())["Sec-WebSocket-Accept"] = accept;
+    resp->send_header(101, "Switching Protocols");
+    return true;
+}
+
+void handle_body(request* req, response* resp, websocket_message* ws) {
+    if (!resp || !ws) {
+        return;
+    }
+    const int opcode = ws->header.opcode;
+    const ylib::buffer& body = req ? req->body() : ylib::buffer();
+
+    if (opcode == 0x8) {
+        resp->send_ws(reinterpret_cast<const char*>(body.data()), body.length(),
+                      0x8);
+        shell_on_conn_close(ws->connid);
+        return;
+    }
+    if (opcode == 0x9) {
+        resp->send_ws(reinterpret_cast<const char*>(body.data()), body.length(),
+                      0xA);
+        return;
+    }
+    if (opcode == 0xA) {
+        resp->response_done();
+        return;
+    }
+
+    std::shared_ptr<ShellSession> session;
+    {
+        std::lock_guard<std::mutex> lock(g_mu);
+        auto it = g_sessions.find(ws->connid);
+        if (it != g_sessions.end()) {
+            session = it->second;
+        }
+    }
+    if (!session) {
+        resp->response_done();
+        return;
+    }
+
+    if (body.length() == 0) {
+        resp->response_done();
+        return;
+    }
+
+    const char* p = reinterpret_cast<const char*>(body.data());
+    const size_t n = body.length();
+
+    if (p[0] == '{') {
+        try {
+            ylib::json j = ylib::json::from(std::string(p, n));
+            const std::string type =
+                (j.exist("type") && j["type"].is_string())
+                    ? j["type"].to<std::string>(true)
+                    : "";
+            if (type == "resize") {
+                unsigned short cols = 80;
+                unsigned short rows = 24;
+                if (j.exist("cols")) {
+                    cols = static_cast<unsigned short>(j["cols"].to<int32>(true));
+                }
+                if (j.exist("rows")) {
+                    rows = static_cast<unsigned short>(j["rows"].to<int32>(true));
+                }
+                resize_pty(session, cols, rows);
+                resp->response_done();
+                return;
+            }
+        } catch (...) {
+        }
+    }
+
+    write_input(session, p, n);
+    resp->response_done();
+}
+
+}  // namespace
+
+std::string shell_issue_ticket(const std::string& username) {
+    std::lock_guard<std::mutex> lock(g_mu);
+    purge_tickets_locked();
+    const std::string ticket = random_hex(24);
+    ShellTicket t;
+    t.username = username.empty() ? "admin" : username;
+    t.expire_at = now_sec() + kTicketTtlSec;
+    g_tickets[ticket] = t;
+    return ticket;
+}
+
+bool shell_consume_ticket(const std::string& ticket, std::string* username) {
+    if (ticket.empty()) {
+        return false;
+    }
+    std::lock_guard<std::mutex> lock(g_mu);
+    purge_tickets_locked();
+    auto it = g_tickets.find(ticket);
+    if (it == g_tickets.end()) {
+        return false;
+    }
+    if (username) {
+        *username = it->second.username;
+    }
+    g_tickets.erase(it);
+    return true;
+}
+
+void shell_ws_handler(request* req, response* resp, websocket_message* ws) {
+    if (!resp) {
+        return;
+    }
+    if (!ws) {
+        resp->response_done();
+        return;
+    }
+
+    switch (ws->type) {
+        case HTTP_SERVER_WEBSOCKET_TYPE_UPGRADE:
+            handle_upgrade(req, resp, ws);
+            break;
+        case HTTP_SERVER_WEBSOCKET_TYPE_MESSAGE_HEADER:
+            resp->response_done();
+            break;
+        case HTTP_SERVER_WEBSOCKET_TYPE_MESSAGE_BODY:
+            handle_body(req, resp, ws);
+            break;
+        case HTTP_SERVER_WEBSOCKET_TYPE_CLOSE:
+            shell_on_conn_close(ws->connid);
+            return;
+        default:
+            resp->response_done();
+            break;
+    }
+}
+
+void shell_on_conn_close(uint64_t connid) {
+    std::shared_ptr<ShellSession> s;
+    {
+        std::lock_guard<std::mutex> lock(g_mu);
+        auto it = g_sessions.find(connid);
+        if (it == g_sessions.end()) {
+            return;
+        }
+        s = it->second;
+        g_sessions.erase(it);
+    }
+    destroy_session_locked(s);
+}
+
+void shell_shutdown() {
+    std::vector<std::shared_ptr<ShellSession>> all;
+    {
+        std::lock_guard<std::mutex> lock(g_mu);
+        g_tickets.clear();
+        for (auto& kv : g_sessions) {
+            all.push_back(kv.second);
+        }
+        g_sessions.clear();
+    }
+    for (auto& s : all) {
+        destroy_session_locked(s);
+    }
+}
+
+}  // namespace system
+}  // namespace ngs

+ 40 - 0
src/system/shell.h

@@ -0,0 +1,40 @@
+#ifndef NGS_SYSTEM_SHELL_H
+#define NGS_SYSTEM_SHELL_H
+
+#include <cstdint>
+#include <string>
+
+namespace ylib {
+namespace network {
+namespace http {
+class request;
+class response;
+struct websocket_message;
+}  // namespace http
+}  // namespace network
+}  // namespace ylib
+
+namespace ngs {
+namespace system {
+
+// Issue a short-lived one-time ticket for WebSocket auth (Cookie 在 WS 升级异步阶段不可靠).
+std::string shell_issue_ticket(const std::string& username);
+
+// Consume ticket; returns username on success.
+bool shell_consume_ticket(const std::string& ticket, std::string* username);
+
+// Handle /api/shell/ws WebSocket lifecycle (upgrade / header / body).
+void shell_ws_handler(ylib::network::http::request* req,
+                      ylib::network::http::response* resp,
+                      ylib::network::http::websocket_message* ws);
+
+// Cleanup when TCP connection drops (router on_close).
+void shell_on_conn_close(uint64_t connid);
+
+// Tear down all sessions (server shutdown).
+void shell_shutdown();
+
+}  // namespace system
+}  // namespace ngs
+
+#endif

+ 88 - 4
src/weblog/weblog.cpp

@@ -361,7 +361,8 @@ CREATE TABLE IF NOT EXISTS access_log (
   upstream_addr TEXT,
   upstream_addr TEXT,
   referer TEXT,
   referer TEXT,
   ua TEXT,
   ua TEXT,
-  request TEXT
+  request TEXT,
+  body TEXT
 );
 );
 CREATE INDEX IF NOT EXISTS idx_access_time ON access_log(time);
 CREATE INDEX IF NOT EXISTS idx_access_time ON access_log(time);
 CREATE INDEX IF NOT EXISTS idx_access_time_ms ON access_log(time_ms);
 CREATE INDEX IF NOT EXISTS idx_access_time_ms ON access_log(time_ms);
@@ -396,6 +397,7 @@ CREATE TABLE IF NOT EXISTS ingest_meta (
                           "INTEGER NOT NULL DEFAULT 0");
                           "INTEGER NOT NULL DEFAULT 0");
     add_column_if_missing(db, "access_log", "request_time",
     add_column_if_missing(db, "access_log", "request_time",
                           "REAL NOT NULL DEFAULT 0");
                           "REAL NOT NULL DEFAULT 0");
+    add_column_if_missing(db, "access_log", "body", "TEXT");
     add_column_if_missing(db, "error_log", "time_ms",
     add_column_if_missing(db, "error_log", "time_ms",
                           "INTEGER NOT NULL DEFAULT 0");
                           "INTEGER NOT NULL DEFAULT 0");
     return true;
     return true;
@@ -419,6 +421,84 @@ bool meta_set(ylib::sqlite3& db, const std::string& key,
                            ") ON CONFLICT(key) DO UPDATE SET value=excluded.value");
                            ") ON CONFLICT(key) DO UPDATE SET value=excluded.value");
 }
 }
 
 
+bool is_post_method(const std::string& method) {
+    return method.size() == 4 && (method[0] == 'P' || method[0] == 'p') &&
+           (method[1] == 'O' || method[1] == 'o') &&
+           (method[2] == 'S' || method[2] == 's') &&
+           (method[3] == 'T' || method[3] == 't');
+}
+
+std::string content_type_mime(const std::string& raw) {
+    std::string ct;
+    ct.reserve(raw.size());
+    for (unsigned char c : raw) {
+        if (c == ';') {
+            break;
+        }
+        ct.push_back(static_cast<char>(std::tolower(c)));
+    }
+    while (!ct.empty() &&
+           std::isspace(static_cast<unsigned char>(ct.front()))) {
+        ct.erase(ct.begin());
+    }
+    while (!ct.empty() &&
+           std::isspace(static_cast<unsigned char>(ct.back()))) {
+        ct.pop_back();
+    }
+    return ct;
+}
+
+bool is_text_content_type(const std::string& ct_raw) {
+    const std::string ct = content_type_mime(ct_raw);
+    if (ct.empty()) {
+        // Missing Content-Type: allow if body itself looks like text.
+        return true;
+    }
+    if (ct.rfind("text/", 0) == 0) {
+        return true;
+    }
+    if (ct == "application/json" || ct == "application/ld+json" ||
+        ct == "application/xml" || ct == "application/javascript" ||
+        ct == "application/x-www-form-urlencoded" ||
+        ct == "application/graphql" || ct == "application/xhtml+xml") {
+        return true;
+    }
+    if (ct.size() > 5 && ct.find("+json") != std::string::npos) {
+        return true;
+    }
+    if (ct.size() > 4 && ct.find("+xml") != std::string::npos) {
+        return true;
+    }
+    return false;
+}
+
+bool looks_like_text_body(const std::string& body) {
+    for (unsigned char c : body) {
+        if (c == 0) {
+            return false;
+        }
+    }
+    return true;
+}
+
+// Persist only POST text bodies smaller than 1KB.
+std::string extract_loggable_body(const ylib::json& j) {
+    if (!is_post_method(jstr(j, "method"))) {
+        return "";
+    }
+    const std::string body = jstr(j, "body");
+    if (body.empty() || body.size() >= 1024) {
+        return "";
+    }
+    if (!is_text_content_type(jstr(j, "request_content_type"))) {
+        return "";
+    }
+    if (!looks_like_text_body(body)) {
+        return "";
+    }
+    return body;
+}
+
 bool insert_access_row(ylib::sqlite3& db, const ylib::json& j) {
 bool insert_access_row(ylib::sqlite3& db, const ylib::json& j) {
     std::string time_s;
     std::string time_s;
     int64_t time_ms = 0;
     int64_t time_ms = 0;
@@ -427,12 +507,13 @@ bool insert_access_row(ylib::sqlite3& db, const ylib::json& j) {
     const int64_t bytes_recv = jint64(j, "request_length");
     const int64_t bytes_recv = jint64(j, "request_length");
     const int64_t bytes_sent = jint64(j, "bytes_sent");
     const int64_t bytes_sent = jint64(j, "bytes_sent");
     const double request_time = jdbl(j, "request_time");
     const double request_time = jdbl(j, "request_time");
+    const std::string body = extract_loggable_body(j);
 
 
     std::ostringstream sql;
     std::ostringstream sql;
     sql << "INSERT INTO access_log("
     sql << "INSERT INTO access_log("
            "time,time_ms,ip,method,host,uri,args,status,"
            "time,time_ms,ip,method,host,uri,args,status,"
            "bytes_recv,bytes_sent,request_time,"
            "bytes_recv,bytes_sent,request_time,"
-           "upstream_status,upstream_addr,referer,ua,request) VALUES("
+           "upstream_status,upstream_addr,referer,ua,request,body) VALUES("
         << sql_quote(time_s) << "," << time_ms << ","
         << sql_quote(time_s) << "," << time_ms << ","
         << sql_quote(jstr(j, "ip")) << ","
         << sql_quote(jstr(j, "ip")) << ","
         << sql_quote(jstr(j, "method")) << ","
         << sql_quote(jstr(j, "method")) << ","
@@ -445,7 +526,8 @@ bool insert_access_row(ylib::sqlite3& db, const ylib::json& j) {
         << sql_quote(jstr(j, "upstream_addr")) << ","
         << sql_quote(jstr(j, "upstream_addr")) << ","
         << sql_quote(jstr(j, "referer")) << ","
         << sql_quote(jstr(j, "referer")) << ","
         << sql_quote(jstr(j, "ua")) << ","
         << sql_quote(jstr(j, "ua")) << ","
-        << sql_quote(jstr(j, "request")) << ")";
+        << sql_quote(jstr(j, "request")) << ","
+        << sql_quote(body) << ")";
     return exec_db(db, sql.str());
     return exec_db(db, sql.str());
 }
 }
 
 
@@ -810,7 +892,7 @@ bool query_access(const AccessQuery& q, std::vector<AccessRow>& rows,
     std::ostringstream sql;
     std::ostringstream sql;
     sql << "SELECT id,time,time_ms,ip,method,host,uri,args,status,"
     sql << "SELECT id,time,time_ms,ip,method,host,uri,args,status,"
            "bytes_recv,bytes_sent,request_time,"
            "bytes_recv,bytes_sent,request_time,"
-           "upstream_status,upstream_addr,referer,ua,request "
+           "upstream_status,upstream_addr,referer,ua,request,body "
            "FROM access_log WHERE 1=1";
            "FROM access_log WHERE 1=1";
     if (q.before_id > 0) {
     if (q.before_id > 0) {
         sql << " AND id < " << q.before_id;
         sql << " AND id < " << q.before_id;
@@ -831,6 +913,7 @@ bool query_access(const AccessQuery& q, std::vector<AccessRow>& rows,
                "args LIKE " << like << " OR "
                "args LIKE " << like << " OR "
                "CAST(status AS TEXT) LIKE " << like << " OR "
                "CAST(status AS TEXT) LIKE " << like << " OR "
                "request LIKE " << like << " OR "
                "request LIKE " << like << " OR "
+               "body LIKE " << like << " OR "
                "ua LIKE " << like << " OR "
                "ua LIKE " << like << " OR "
                "referer LIKE " << like << " OR "
                "referer LIKE " << like << " OR "
                "upstream_addr LIKE " << like <<
                "upstream_addr LIKE " << like <<
@@ -866,6 +949,7 @@ bool query_access(const AccessQuery& q, std::vector<AccessRow>& rows,
         r.referer = map_get(row, "referer");
         r.referer = map_get(row, "referer");
         r.ua = map_get(row, "ua");
         r.ua = map_get(row, "ua");
         r.request = map_get(row, "request");
         r.request = map_get(row, "request");
+        r.body = map_get(row, "body");
         rows.push_back(std::move(r));
         rows.push_back(std::move(r));
     }
     }
     return true;
     return true;

+ 1 - 0
src/weblog/weblog.h

@@ -26,6 +26,7 @@ struct AccessRow {
     std::string referer;
     std::string referer;
     std::string ua;
     std::string ua;
     std::string request;
     std::string request;
+    std::string body;  // POST text body (<1KB), empty otherwise
 };
 };
 
 
 struct ErrorRow {
 struct ErrorRow {

+ 8 - 1
src/website/website.cpp

@@ -765,7 +765,14 @@ std::string nginx_quote_path(const std::string& path) {
 std::string nginx_log_block(const SiteInfo& s) {
 std::string nginx_log_block(const SiteInfo& s) {
     const std::string dir = site_nginx_log_dir(s);
     const std::string dir = site_nginx_log_dir(s);
     std::ostringstream out;
     std::ostringstream out;
-    out << "    access_log   " << nginx_quote_path(join_path(dir, "access.log"))
+    // POST-only mirror forces nginx to buffer body so $request_body is logged.
+    out << "    mirror $ngs_body_mirror;\n"
+        << "    mirror_request_body on;\n"
+        << "    location = /__ngs_req_body_mirror {\n"
+        << "        internal;\n"
+        << "        return 204;\n"
+        << "    }\n"
+        << "    access_log   " << nginx_quote_path(join_path(dir, "access.log"))
         << "  ngs_detail;\n"
         << "  ngs_detail;\n"
         << "    error_log    " << nginx_quote_path(join_path(dir, "error.log"))
         << "    error_log    " << nginx_quote_path(join_path(dir, "error.log"))
         << "  warn;\n";
         << "  warn;\n";

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません