From 361480f2d121010aea757cdf3b567269535c5e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:55:24 +0800 Subject: [PATCH] Add files via upload --- go.mod | 2 +- internal/app/app.go | 1 + internal/handler/terminal.go | 15 +- internal/handler/terminal_ws_unix.go | 95 +++++++++++ web/static/js/terminal.js | 247 +++++++-------------------- 5 files changed, 174 insertions(+), 186 deletions(-) create mode 100644 internal/handler/terminal_ws_unix.go diff --git a/go.mod b/go.mod index 9f8fe08c..a926de8d 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/creack/pty v1.1.24 github.com/gin-gonic/gin v1.9.1 github.com/google/uuid v1.5.0 + github.com/gorilla/websocket v1.5.0 github.com/larksuite/oapi-sdk-go/v3 v3.4.22 github.com/mattn/go-sqlite3 v1.14.18 github.com/modelcontextprotocol/go-sdk v1.2.0 @@ -29,7 +30,6 @@ require ( github.com/goccy/go-json v0.10.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/jsonschema-go v0.3.0 // indirect - github.com/gorilla/websocket v1.5.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect diff --git a/internal/app/app.go b/internal/app/app.go index b6ea16b2..1be512ea 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -634,6 +634,7 @@ func setupRoutes( // 系统设置 - 终端(执行命令,提高运维效率) protected.POST("/terminal/run", terminalHandler.RunCommand) protected.POST("/terminal/run/stream", terminalHandler.RunCommandStream) + protected.GET("/terminal/ws", terminalHandler.RunCommandWS) // 外部MCP管理 protected.GET("/external-mcp", externalMCPHandler.GetExternalMCPs) diff --git a/internal/handler/terminal.go b/internal/handler/terminal.go index f043bff3..d5090b62 100644 --- a/internal/handler/terminal.go +++ b/internal/handler/terminal.go @@ -27,6 +27,19 @@ type TerminalHandler struct { logger *zap.Logger } +// maskTerminalCommand 对可能包含敏感信息的终端命令做脱敏,避免在日志中直接记录密码等内容 +func maskTerminalCommand(cmd string) string { + trimmed := strings.TrimSpace(cmd) + lower := strings.ToLower(trimmed) + if strings.Contains(lower, "sudo") || strings.Contains(lower, "password") { + return "[masked sensitive terminal command]" + } + if len(trimmed) > 256 { + return trimmed[:256] + "..." + } + return trimmed +} + // NewTerminalHandler 创建终端处理器 func NewTerminalHandler(logger *zap.Logger) *TerminalHandler { return &TerminalHandler{logger: logger} @@ -146,7 +159,7 @@ func (h *TerminalHandler) RunCommand(c *gin.Context) { c.JSON(http.StatusOK, resp) return } - h.logger.Debug("终端命令执行异常", zap.String("command", cmdStr), zap.Error(err)) + h.logger.Debug("终端命令执行异常", zap.String("command", maskTerminalCommand(cmdStr)), zap.Error(err)) } // 统一为 \n,避免前端因 \r 出现错位/对角线排版 diff --git a/internal/handler/terminal_ws_unix.go b/internal/handler/terminal_ws_unix.go new file mode 100644 index 00000000..a8f5faae --- /dev/null +++ b/internal/handler/terminal_ws_unix.go @@ -0,0 +1,95 @@ +//go:build !windows + +package handler + +import ( + "net/http" + "os" + "os/exec" + "time" + + "github.com/creack/pty" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +// wsUpgrader 仅用于系统设置中的终端 WebSocket,会复用已有的登录保护(JWT 中间件在上层路由组) +var wsUpgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + // 由于已在 Gin 路由层做了认证,这里放宽 Origin,方便在同一域名下通过 HTTPS/WSS 访问 + return true + }, +} + +// RunCommandWS 提供真正交互式 Shell:基于 WebSocket + PTY 的长会话 +// 前端建立 WebSocket 连接后,所有键盘输入都会透传到 Shell,Shell 的输出也会实时写回前端。 +func (h *TerminalHandler) RunCommandWS(c *gin.Context) { + conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer conn.Close() + + // 启动交互式 Shell,这里优先使用 bash,找不到则退回 sh + shell := "bash" + if _, err := exec.LookPath(shell); err != nil { + shell = "sh" + } + cmd := exec.Command(shell) + cmd.Env = append(os.Environ(), + "COLUMNS=120", + "LINES=40", + "TERM=xterm-256color", + ) + + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: ptyCols, Rows: ptyRows}) + if err != nil { + return + } + defer ptmx.Close() + + // Shell -> WebSocket:将 PTY 输出实时发给前端 + doneChan := make(chan struct{}) + go func() { + buf := make([]byte, 4096) + for { + n, err := ptmx.Read(buf) + if n > 0 { + _ = conn.WriteMessage(websocket.TextMessage, buf[:n]) + } + if err != nil { + break + } + } + close(doneChan) + }() + + // WebSocket -> Shell:将前端输入写入 PTY(包括 sudo 密码、Ctrl+C 等) + conn.SetReadLimit(64 * 1024) + _ = conn.SetReadDeadline(time.Now().Add(terminalTimeout)) + conn.SetPongHandler(func(string) error { + _ = conn.SetReadDeadline(time.Now().Add(terminalTimeout)) + return nil + }) + + for { + msgType, data, err := conn.ReadMessage() + if err != nil { + _ = cmd.Process.Kill() + break + } + if msgType != websocket.TextMessage && msgType != websocket.BinaryMessage { + continue + } + if len(data) == 0 { + continue + } + if _, err := ptmx.Write(data); err != nil { + _ = cmd.Process.Kill() + break + } + } + + <-doneChan +} + diff --git a/web/static/js/terminal.js b/web/static/js/terminal.js index ee55a66a..b7c45f3d 100644 --- a/web/static/js/terminal.js +++ b/web/static/js/terminal.js @@ -15,7 +15,7 @@ var currentTabId = 1; var inited = false; var tabIdCounter = 1; - var PROMPT = '\x1b[32m$\x1b[0m '; + var PROMPT = ''; // 真实 Shell 自己输出提示符,这里不再自定义 var HISTORY_MAX = 100; var CANCEL_AFTER_MS = 125000; @@ -26,20 +26,16 @@ return terminals[0] || null; } - var WELCOME_LINE = 'CyberStrikeAI 终端 - 直接输入命令,Enter 执行;↑↓ 历史;Ctrl+L 清屏\r\n'; + var WELCOME_LINE = 'CyberStrikeAI 终端 - 真实 Shell 会话,直接输入命令;Ctrl+L 清屏\r\n'; function writePrompt(tab) { - var t = tab || getCurrent(); - if (t && t.term) t.term.write(PROMPT); + // 提示符交由后端 Shell 自行输出,这里仅保留占位函数,避免旧代码报错 } function redrawTabDisplay(t) { if (!t || !t.term) return; t.term.clear(); - t.lineBuffer = ''; - if (t.cursorIndex !== undefined) t.cursorIndex = 0; t.term.write(WELCOME_LINE); - t.term.write(PROMPT); } function writeln(tabOrS, s) { @@ -65,100 +61,66 @@ t.term.write(suffix); } - function getAuthHeaders() { - var h = new Headers(); - h.set('Content-Type', 'application/json'); + // 从本地存储中获取当前登录 token(与 auth.js 使用的结构保持一致) + function getStoredAuthToken() { try { - var auth = localStorage.getItem('cyberstrike-auth'); - if (auth) { - var o = JSON.parse(auth); - if (o && o.token) h.set('Authorization', 'Bearer ' + o.token); - } + var raw = localStorage.getItem('cyberstrike-auth'); + if (!raw) return null; + var o = JSON.parse(raw); + if (o && o.token) return o.token; } catch (e) {} - return h; + return null; } - function runCommand(cmd, tab) { - var t = tab || getCurrent(); - if (!t) return; - if (t.running) return; - runCommandImpl(cmd, t); + // WebSocket 地址构造(兼容 http/https,并通过 query 传递 token 以通过后端鉴权) + function buildTerminalWSURL() { + var proto = (window.location.protocol === 'https:') ? 'wss://' : 'ws://'; + var url = proto + window.location.host + '/api/terminal/ws'; + var token = getStoredAuthToken(); + if (token) { + url += '?token=' + encodeURIComponent(token); + } + return url; } - function runCommandImpl(cmd, t) { - t.running = true; - t.abortController = new AbortController(); - var cancelTimer = setTimeout(function () { - if (!t.running) return; - t.running = false; - writeln(t, '\x1b[2m(已取消 可继续输入)\x1b[0m'); - writePrompt(t); - }, CANCEL_AFTER_MS); + function ensureTerminalWS(tab) { + if (tab.ws && (tab.ws.readyState === WebSocket.OPEN || tab.ws.readyState === WebSocket.CONNECTING)) { + return; + } + try { + var ws = new WebSocket(buildTerminalWSURL()); + tab.ws = ws; + tab.running = true; - var done = function () { - clearTimeout(cancelTimer); - t.running = false; - t.abortController = null; - writePrompt(t); - }; + ws.onopen = function () { + if (tab.term) { + tab.term.focus(); + } + }; - fetch('/api/terminal/run/stream', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ command: cmd }), - signal: t.abortController.signal - }).then(function (res) { - if (!res.ok) return res.json().then(function (d) { throw new Error(d.error || 'HTTP ' + res.status); }); - var ct = res.headers.get('Content-Type') || ''; - if (ct.indexOf('text/event-stream') !== -1 && res.body) { - return readSSEStream(res.body, t).then(done).catch(function () { done(); }); - } - return res.json().then(function (data) { - if (data.stdout) writeOutput(t, data.stdout, false); - if (data.stderr) writeOutput(t, data.stderr, true); - done(); - }); - }).catch(function (err) { - if (err.name === 'AbortError') { - writeln(t, '\x1b[2m(已取消)\x1b[0m'); - } else { - writeln(t, '\x1b[31m错误: ' + (err.message || String(err)) + '\x1b[0m'); - } - done(); - }); - } + ws.onmessage = function (ev) { + if (!tab.term) return; + tab.term.write(ev.data); + }; - function readSSEStream(body, t) { - return new Promise(function (resolve, reject) { - var reader = body.getReader(); - var decoder = new TextDecoder(); - var buf = ''; - function read() { - reader.read().then(function (result) { - if (result.done) { resolve(); return; } - buf += decoder.decode(result.value, { stream: true }); - var i; - while ((i = buf.indexOf('\n\n')) !== -1) { - var block = buf.slice(0, i); - buf = buf.slice(i + 2); - var dataLine = block.match(/data:\s*(.+)/); - if (dataLine) { - try { - var ev = JSON.parse(dataLine[1]); - if (ev.t === 'out' && ev.d !== undefined) t.term.writeln(ev.d); - else if (ev.t === 'err' && ev.d !== undefined) t.term.write('\x1b[31m' + ev.d + '\x1b[0m\n'); - else if (ev.t === 'exit') { - resolve(); - return; - } - } catch (e) {} - } - } - read(); - }).catch(reject); + ws.onclose = function () { + tab.running = false; + if (tab.term) { + tab.term.writeln('\r\n\x1b[2m[会话已关闭]\x1b[0m'); + } + }; + + ws.onerror = function () { + tab.running = false; + if (tab.term) { + tab.term.writeln('\r\n\x1b[31m[终端连接出错]\x1b[0m'); + } + }; + } catch (e) { + if (tab.term) { + tab.term.writeln('\r\n\x1b[31m[无法连接终端服务: ' + String(e) + ']\x1b[0m'); } - read(); - }); + } } function createTerminalInContainer(container, tab) { @@ -206,7 +168,6 @@ } term.open(container); term.write(WELCOME_LINE); - term.write(PROMPT); container.addEventListener('click', function () { switchTerminalTab(tab.id); if (term) term.focus(); @@ -214,105 +175,23 @@ container.setAttribute('tabindex', '0'); container.title = '点击此处后输入命令'; - function redrawLine(t) { - if (!t || !t.term) return; - var n = t.lineBuffer.length - t.cursorIndex; - t.term.write('\r\x1b[K' + PROMPT + t.lineBuffer); - if (n > 0) t.term.write('\x1b[' + n + 'D'); + function sendToWS(data) { + ensureTerminalWS(tab); + if (tab.ws && tab.ws.readyState === WebSocket.OPEN) { + try { + tab.ws.send(data); + } catch (e) {} + } } term.onData(function (data) { + // Ctrl+L:本地清屏,同时把 ^L 也发给后端 if (data === '\x0c') { term.clear(); - tab.lineBuffer = ''; - tab.cursorIndex = 0; - writePrompt(tab); + sendToWS(data); return; } - if (data === '\x1b[A') { - if (tab.history.length === 0) return; - if (tab.historyIndex < 0) tab.historyIndex = tab.history.length; - tab.historyIndex--; - if (tab.historyIndex < 0) tab.historyIndex = 0; - tab.lineBuffer = tab.history[tab.historyIndex]; - tab.cursorIndex = tab.lineBuffer.length; - term.write('\r\x1b[K' + PROMPT + tab.lineBuffer); - return; - } - if (data === '\x1b[B') { - if (tab.history.length === 0) return; - tab.historyIndex++; - if (tab.historyIndex >= tab.history.length) { - tab.historyIndex = -1; - tab.lineBuffer = ''; - tab.cursorIndex = 0; - term.write('\r\x1b[K' + PROMPT); - } else { - tab.lineBuffer = tab.history[tab.historyIndex]; - tab.cursorIndex = tab.lineBuffer.length; - term.write('\r\x1b[K' + PROMPT + tab.lineBuffer); - } - return; - } - if (data === '\x1b[D') { - if (tab.cursorIndex > 0) { - tab.cursorIndex--; - term.write('\x1b[D'); - } - return; - } - if (data === '\x1b[C') { - if (tab.cursorIndex < tab.lineBuffer.length) { - tab.cursorIndex++; - term.write('\x1b[C'); - } - return; - } - var code = data.charCodeAt(0); - if (code === 13 || code === 10) { - var cmd = tab.lineBuffer.trim(); - tab.lineBuffer = ''; - tab.cursorIndex = 0; - tab.historyIndex = -1; - term.writeln(''); - if (cmd) { - if (tab.history.indexOf(cmd) === -1) { - tab.history.push(cmd); - if (tab.history.length > HISTORY_MAX) tab.history.shift(); - } - runCommand(cmd, tab); - } else { - writePrompt(tab); - } - return; - } - if (code === 127) { - if (tab.cursorIndex > 0) { - tab.lineBuffer = tab.lineBuffer.slice(0, tab.cursorIndex - 1) + tab.lineBuffer.slice(tab.cursorIndex); - tab.cursorIndex--; - redrawLine(tab); - } - return; - } - if (code === 3) { - if (tab.running && tab.abortController) { - tab.abortController.abort(); - } - tab.lineBuffer = ''; - tab.cursorIndex = 0; - term.writeln('^C'); - writePrompt(tab); - return; - } - if (data.length === 1 && code >= 32) { - tab.lineBuffer = tab.lineBuffer.slice(0, tab.cursorIndex) + data + tab.lineBuffer.slice(tab.cursorIndex); - tab.cursorIndex++; - redrawLine(tab); - return; - } - tab.lineBuffer += data; - tab.cursorIndex = tab.lineBuffer.length; - term.write(data); + sendToWS(data); }); tab.term = term;