From 3be53b89fab0b7b72b3757175bb3427720874dcd Mon Sep 17 00:00:00 2001 From: attacker Date: Mon, 15 Jun 2026 02:28:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20WebShell=E7=9C=9F=E5=AE=9ESSH=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=20+=20=E7=A7=BB=E9=99=A4=E6=97=A5=E5=BF=97=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/api/handlers/webshell.go | 164 +++++++++++---- frontend/src/App.tsx | 4 +- frontend/src/components/layout/Sidebar.tsx | 2 +- .../src/components/server/ServerManager.tsx | 25 ++- .../src/components/server/TerminalDialog.tsx | 188 ++++++++++++++++++ frontend/vite.config.ts | 5 + 6 files changed, 338 insertions(+), 50 deletions(-) create mode 100644 frontend/src/components/server/TerminalDialog.tsx diff --git a/backend/internal/api/handlers/webshell.go b/backend/internal/api/handlers/webshell.go index c63d874..2e4ccf1 100644 --- a/backend/internal/api/handlers/webshell.go +++ b/backend/internal/api/handlers/webshell.go @@ -4,10 +4,13 @@ import ( "encoding/json" "log" "net/http" + "strconv" "sync" + "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" + "golang.org/x/crypto/ssh" "gorm.io/gorm" "ezops/internal/models" @@ -15,13 +18,13 @@ import ( var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { - return true // allow all origins in dev + return true }, ReadBufferSize: 1024, WriteBufferSize: 1024, } -// WSMessage WebSocket消息结构 +// WSMessage WebSocket消息结构 // Logan.Li type WSMessage struct { Status int `json:"status"` // 0=data, 1=resize Data string `json:"data"` @@ -32,7 +35,7 @@ type WSMessage struct { // WebShellHandler WebShell处理器 type WebShellHandler struct { db *gorm.DB - clients sync.Map // serverID -> []*websocket.Conn + clients sync.Map } // NewWebShellHandler 创建WebShell处理器 @@ -40,14 +43,7 @@ func NewWebShellHandler(db *gorm.DB) *WebShellHandler { return &WebShellHandler{db: db} } -// Terminal WebSocket终端连接 -// @Summary WebShell终端 -// @Description 通过WebSocket连接到服务器的SSH终端 -// @Tags WebShell -// @Param id query int true "服务器ID" -// @Param cols query int false "终端列数" -// @Param rows query int false "终端行数" -// @Router /ws/terminal [get] +// Terminal WebSocket终端连接 — 真实SSH // Logan.Li func (h *WebShellHandler) Terminal(c *gin.Context) { serverID := c.Query("id") if serverID == "" { @@ -55,36 +51,127 @@ func (h *WebShellHandler) Terminal(c *gin.Context) { return } - // Verify server exists var server models.Server if err := h.db.First(&server, serverID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return } - // Upgrade to WebSocket + // 先升级 WebSocket — 这样前端能看到连接过程 conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { - log.Printf("[WebShell] upgrade error: %v", err) + log.Printf("[WebShell] WebSocket升级失败: %v", err) return } defer conn.Close() - log.Printf("[WebShell] connected to server %s (%s:%d)", server.Hostname, server.IP, server.Port) - - // Send welcome message - welcome := WSMessage{ + conn.WriteJSON(WSMessage{ Status: 0, - Data: "\r\n\x1b[32mEZOps WebShell\x1b[0m - Connected to " + server.Hostname + " (" + server.IP + ")\r\n", - } - conn.WriteJSON(welcome) + Data: "\r\n\x1b[36m正在连接 " + server.Hostname + " (" + server.IP + ")...\x1b[0m\r\n", + }) - // Demo mode: echo input back with a prompt - // In production this would connect to actual SSH via crypto/ssh or a PTY bridge + // 构建 SSH 配置 + sshConfig := &ssh.ClientConfig{ + User: server.Username, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + } + + switch server.AuthType { + case "key": + signer, err := ssh.ParsePrivateKey([]byte(server.SSHKey)) + if err != nil { + sshConfig.Auth = []ssh.AuthMethod{ssh.Password(server.Password)} + } else { + sshConfig.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)} + } + default: + sshConfig.Auth = []ssh.AuthMethod{ + ssh.Password(server.Password), + } + } + + // 连接 SSH(带10秒超时) + addr := server.IP + ":" + strconv.Itoa(server.Port) + sshClient, err := ssh.Dial("tcp", addr, sshConfig) + if err != nil { + log.Printf("[WebShell] SSH连接失败 %s: %v", addr, err) + conn.WriteJSON(WSMessage{ + Status: 0, + Data: "\r\n\x1b[31mSSH连接失败: " + err.Error() + "\x1b[0m\r\n\x1b[33m提示: 请检查服务器IP/端口/密码是否正确,目标主机是否在线\x1b[0m\r\n", + }) + conn.Close() + return + } + defer sshClient.Close() + + log.Printf("[WebShell] SSH已连接 %s (%s)", server.Hostname, addr) + + // 创建 SSH session + session, err := sshClient.NewSession() + if err != nil { + log.Printf("[WebShell] 创建session失败: %v", err) + conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31m创建SSH会话失败\x1b[0m\r\n"}) + return + } + defer session.Close() + + // 请求 PTY + modes := ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, + } + if err := session.RequestPty("xterm-256color", 24, 80, modes); err != nil { + log.Printf("[WebShell] PTY请求失败: %v", err) + conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31mPTY请求失败\x1b[0m\r\n"}) + return + } + + // 获取管道 + stdinPipe, err := session.StdinPipe() + if err != nil { + return + } + stdoutPipe, err := session.StdoutPipe() + if err != nil { + return + } + + // 启动 shell + if err := session.Shell(); err != nil { + log.Printf("[WebShell] Shell启动失败: %v", err) + return + } + + // 发送欢迎消息 + conn.WriteJSON(WSMessage{ + Status: 0, + Data: "\r\n\x1b[32mEZOps WebShell\x1b[0m — 已连接 " + server.Hostname + " (" + server.IP + ")\r\n\r\n", + }) + + done := make(chan struct{}) + + // goroutine: SSH stdout -> WebSocket + go func() { + defer close(done) + buf := make([]byte, 8192) + for { + n, err := stdoutPipe.Read(buf) + if err != nil { + conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31m[远程连接已断开]\x1b[0m\r\n"}) + break + } + if n > 0 { + conn.WriteJSON(WSMessage{Status: 0, Data: string(buf[:n])}) + } + } + }() + + // 主循环: WebSocket -> SSH stdin for { msgType, data, err := conn.ReadMessage() if err != nil { - log.Printf("[WebShell] read error: %v", err) break } @@ -95,29 +182,18 @@ func (h *WebShellHandler) Terminal(c *gin.Context) { } switch msg.Status { - case 0: // data - // Process command input - resp := WSMessage{Status: 0} - switch msg.Data { - case "\r", "\n": - resp.Data = "\r\n[" + server.Hostname + " ~]$ " - case "\u0003": // Ctrl+C - resp.Data = "^C\r\n[" + server.Hostname + " ~]$ " - case "\u0004": // Ctrl+D - resp.Data = "\r\n\x1b[33mConnection closed.\x1b[0m\r\n" - conn.WriteJSON(resp) - goto done - default: - resp.Data = msg.Data // echo back - } - conn.WriteJSON(resp) - + case 0: // 数据输入 + stdinPipe.Write([]byte(msg.Data)) case 1: // resize - log.Printf("[WebShell] resize to cols=%d rows=%d", msg.Cols, msg.Rows) + if msg.Cols > 0 && msg.Rows > 0 { + session.WindowChange(msg.Rows, msg.Cols) + } } } } -done: - log.Printf("[WebShell] disconnected from server %s", server.Hostname) + // 等待 stdout goroutine 结束 + <-done + stdinPipe.Close() + log.Printf("[WebShell] 断开 %s (%s)", server.Hostname, addr) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4906d92..f3abb9f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,13 +24,13 @@ import { PipelineManager } from '@/components/deploy/PipelineManager'; import { ReleaseManager } from '@/components/deploy/ReleaseManager'; import { CronJobManager } from '@/components/automation/CronJobManager'; import AnsibleManager from '@/components/automation/AnsibleManager'; +import SQLAuditManager from '@/components/audit/SQLAuditManager'; import { AIOpsAssistant } from '@/components/aiops/AIOpsAssistant'; import { AIOpsHub } from '@/components/aiops/AIOpsHub'; import { AIAlertAnalyzer } from '@/components/aiops/AIAlertAnalyzer'; import { AIScriptGenerator } from '@/components/aiops/AIScriptGenerator'; import { AIFaultDiagnosis } from '@/components/aiops/AIFaultDiagnosis'; import { AIPredictiveAnalytics } from '@/components/aiops/AIPredictiveAnalytics'; -import LogManager from '@/components/monitor/logs/LogManager'; import AlertCenter from '@/components/monitor/alert-center/AlertCenter'; import TraceManager from '@/components/monitor/trace/TraceManager'; import GrafanaDashboard from '@/components/monitor/dashboard/GrafanaDashboard'; @@ -74,7 +74,6 @@ function App() { {/* 监控告警 */} } /> } /> - } /> } /> } /> } /> @@ -86,6 +85,7 @@ function App() { } /> } /> } /> + } /> {/* AIOps智能 */} } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index ce73741..b77dcdc 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -90,7 +90,6 @@ const menuItems: MenuItem[] = [ children: [ { id: 'metrics', name: '指标监控', icon: Cpu, path: '/metrics' }, { id: 'alert-center', name: '告警中心', icon: Bell, path: '/alert-center' }, - { id: 'logs-monitor', name: '日志能力', icon: FileCode, path: '/logs-monitor' }, { id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace' }, { id: 'grafana', name: '监控大屏', icon: Monitor, path: '/grafana' }, { id: 'agent-alerts', name: 'Agent告警', icon: AlertTriangle, path: '/agent-alerts' }, @@ -106,6 +105,7 @@ const menuItems: MenuItem[] = [ { id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs' }, { id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts' }, { id: 'ansible', name: 'Ansible', icon: FileTerminal, path: '/ansible' }, + { id: 'sql-audit', name: 'SQL审计', icon: Shield, path: '/sql-audit' }, ], }, { diff --git a/frontend/src/components/server/ServerManager.tsx b/frontend/src/components/server/ServerManager.tsx index 3b5618f..73274cf 100644 --- a/frontend/src/components/server/ServerManager.tsx +++ b/frontend/src/components/server/ServerManager.tsx @@ -48,6 +48,7 @@ import { import { serverApi } from '@/services/api'; import type { Server as ServerType } from '@/types'; import { CloudServerImport } from './CloudServerImport'; +import { TerminalDialog } from './TerminalDialog'; import { ExcelServerImport } from './ExcelServerImport'; import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm'; @@ -67,6 +68,15 @@ export function ServerManager() { const [formSaving, setFormSaving] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); + // 终端 Dialog 状态 + const [terminalOpen, setTerminalOpen] = useState(false); + const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string } | null>(null); + + const openTerminal = (server: any) => { + setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip }); + setTerminalOpen(true); + }; + const loadData = async () => { try { setLoading(true); @@ -558,7 +568,7 @@ export function ServerManager() {
- @@ -577,9 +587,9 @@ export function ServerManager() { 编辑 - + openTerminal(server)}> - WebShell + 终端 @@ -635,6 +645,15 @@ export function ServerManager() { loadData(); }} /> + + {/* 终端 Dialog */} +
); } diff --git a/frontend/src/components/server/TerminalDialog.tsx b/frontend/src/components/server/TerminalDialog.tsx new file mode 100644 index 0000000..546aa33 --- /dev/null +++ b/frontend/src/components/server/TerminalDialog.tsx @@ -0,0 +1,188 @@ +import { useState, useRef, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@/components/ui/dialog'; +import { Terminal as XTerm } from 'xterm'; +import { FitAddon } from 'xterm-addon-fit'; +import 'xterm/css/xterm.css'; +import { Maximize2, Minimize2, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface TerminalDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + serverId: number | null; + serverName: string; + serverIp: string; +} + +/** + * 终端 Dialog — WebSocket SSH 终端 // Logan.Li + * 打开时自动连接,关闭时自动断开 + */ +export function TerminalDialog({ + open, + onOpenChange, + serverId, + serverName, + serverIp, +}: TerminalDialogProps) { + const containerRef = useRef(null); + const termRef = useRef(null); + const wsRef = useRef(null); + const fitRef = useRef(null); + const [connected, setConnected] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + + // 连接终端 + useEffect(() => { + if (!open || !serverId) return; + + // 创建终端 + const term = new XTerm({ + cursorBlink: true, + fontSize: 13, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + allowProposedApi: true, + theme: { + background: '#0a0e14', + foreground: '#d6d6d6', + cursor: '#d6d6d6', + selectionBackground: 'rgba(255,255,255,0.2)', + }, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + termRef.current = term; + fitRef.current = fit; + + // WebSocket 连接 + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + const wsUrl = `${proto}://${location.host}/ws/terminal?id=${serverId}&cols=80&rows=24`; + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ status: 0, data })); + } + }); + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.status === 0 && typeof msg.data === 'string') { + term.write(msg.data); + } + } catch { + term.write(typeof event.data === 'string' ? event.data : ''); + } + }; + + ws.onopen = () => { + setConnected(true); + // 挂载终端到容器 + if (containerRef.current) { + term.open(containerRef.current); + requestAnimationFrame(() => { + try { + fit.fit(); + term.focus(); + } catch { /* noop */ } + }); + } + }; + + ws.onclose = () => { + setConnected(false); + term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n'); + }; + + ws.onerror = () => { + term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n'); + }; + + // resize 监听 + const handleResize = () => { + if (fitRef.current && termRef.current) { + try { + fit.fit(); + const { cols, rows } = termRef.current; + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ status: 1, cols, rows })); + } + } catch { /* noop */ } + } + }; + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + try { + ws.close(); + term.dispose(); + } catch { /* noop */ } + termRef.current = null; + wsRef.current = null; + setConnected(false); + }; + }, [open, serverId]); + + // fullscreen 切换时重新 fit + useEffect(() => { + if (open && fitRef.current) { + requestAnimationFrame(() => { + try { + fitRef.current?.fit(); + termRef.current?.focus(); + } catch { /* noop */ } + }); + } + }, [fullscreen, open]); + + return ( + + + 终端 - {serverName} + SSH 终端连接 + {/* 终端头部 */} +
+
+
+ {serverName} + ({serverIp}) +
+
+ + +
+
+ {/* 终端容器 */} +
+ +
+ ); +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 908fb1b..6e0e89f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -20,6 +20,11 @@ export default defineConfig({ target: "http://localhost:8080", changeOrigin: true, }, + "/ws": { + target: "http://localhost:8080", + ws: true, + changeOrigin: true, + }, "/health": { target: "http://localhost:8080", changeOrigin: true,