package controllers import ( "bufio" "encoding/json" "io" "os" "path/filepath" "runtime" "sync" "time" "strings" "unicode/utf8" "github.com/engigu/baihu-panel/internal/constant" "github.com/engigu/baihu-panel/internal/services" "github.com/engigu/baihu-panel/internal/utils" "github.com/creack/pty" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "golang.org/x/text/encoding/simplifiedchinese" "golang.org/x/text/transform" ) type TerminalController struct { envService *services.EnvService } func NewTerminalController(envService *services.EnvService) *TerminalController { return &TerminalController{ envService: envService, } } var upgrader = websocket.Upgrader{ CheckOrigin: utils.CheckWSOrigin, } // toUTF8 将可能是 GBK 编码的字节转换为 UTF-8 func toUTF8(data []byte) string { if utf8.Valid(data) { return string(data) } // 尝试从 GBK 转换 reader := transform.NewReader( bufio.NewReader( &byteReader{data: data}, ), simplifiedchinese.GBK.NewDecoder(), ) result, err := io.ReadAll(reader) if err != nil { return string(data) } return string(result) } type byteReader struct { data []byte pos int } func (r *byteReader) Read(p []byte) (n int, err error) { if r.pos >= len(r.data) { return 0, io.EOF } n = copy(p, r.data[r.pos:]) r.pos += n return n, nil } func (tc *TerminalController) HandleWebSocket(c *gin.Context) { conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { return } defer conn.Close() // 演示模式下禁用终端 if constant.DemoMode { conn.WriteMessage(websocket.TextMessage, []byte("\r\n\033[1;33m[演示模式] 终端功能已禁用\033[0m\r\n")) return } // Windows 使用 pipe 模式,Unix 使用 PTY 模式 userID := c.GetString("userID") if userID == "" { userID = "1" // 兜底 } if runtime.GOOS == "windows" { tc.handlePipeMode(conn, userID) } else { tc.handlePtyMode(conn, userID) } } // handlePtyMode 使用 PTY 处理终端(Unix/macOS) func (tc *TerminalController) handlePtyMode(conn *websocket.Conn, userID string) { conn.SetReadLimit(constant.MaxMessageSize) conn.SetReadDeadline(time.Now().Add(constant.PongWait)) conn.SetPongHandler(func(string) error { conn.SetReadDeadline(time.Now().Add(constant.PongWait)) return nil }) // 发送 PTY 模式标识 conn.WriteMessage(websocket.TextMessage, []byte("__PTY_MODE__")) cmd := utils.NewShellCmd() if absDir, err := filepath.Abs(constant.ScriptsWorkDir); err == nil { cmd.Dir = absDir } cmd.Env = tc.buildTerminalEnv(userID, "TERM=xterm-256color") ptmx, err := pty.Start(cmd) if err != nil { conn.WriteMessage(websocket.TextMessage, []byte("Error starting shell: "+err.Error())) return } defer ptmx.Close() pty.Setsize(ptmx, &pty.Winsize{Rows: 24, Cols: 80}) var wg sync.WaitGroup var connMu sync.Mutex writeMessage := func(data []byte) { connMu.Lock() defer connMu.Unlock() conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) conn.WriteMessage(websocket.TextMessage, data) } wg.Add(1) go func() { defer wg.Done() buf := make([]byte, 4096) for { n, err := ptmx.Read(buf) if err != nil { return } if n > 0 { text := toUTF8(buf[:n]) writeMessage([]byte(text)) } } }() // 启动 ping 协程 pingDone := make(chan struct{}) go func() { ticker := time.NewTicker(constant.PingPeriod) defer ticker.Stop() for { select { case <-ticker.C: connMu.Lock() conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { connMu.Unlock() return } connMu.Unlock() case <-pingDone: return } } }() for { _, message, err := conn.ReadMessage() if err != nil { break } // 处理调整窗口大小的消息 if len(message) > 0 && message[0] == '{' { var resizeMsg struct { Type string `json:"type"` Rows uint16 `json:"rows"` Cols uint16 `json:"cols"` } if err := json.Unmarshal(message, &resizeMsg); err == nil && resizeMsg.Type == "resize" { pty.Setsize(ptmx, &pty.Winsize{Rows: resizeMsg.Rows, Cols: resizeMsg.Cols}) continue } } if _, err := ptmx.Write(message); err != nil { break } } close(pingDone) cmd.Process.Kill() cmd.Wait() wg.Wait() } // handlePipeMode 使用 pipe 处理终端(Windows) func (tc *TerminalController) handlePipeMode(conn *websocket.Conn, userID string) { conn.SetReadLimit(constant.MaxMessageSize) conn.SetReadDeadline(time.Now().Add(constant.PongWait)) conn.SetPongHandler(func(string) error { conn.SetReadDeadline(time.Now().Add(constant.PongWait)) return nil }) // 发送 pipe 模式标识 conn.WriteMessage(websocket.TextMessage, []byte("__PIPE_MODE__")) cmd := utils.NewShellCmd() if absDir, err := filepath.Abs(constant.ScriptsWorkDir); err == nil { cmd.Dir = absDir } // 注入环境变量 cmd.Env = tc.buildTerminalEnv(userID) stdin, err := cmd.StdinPipe() if err != nil { conn.WriteMessage(websocket.TextMessage, []byte("Error: "+err.Error())) return } stdout, err := cmd.StdoutPipe() if err != nil { conn.WriteMessage(websocket.TextMessage, []byte("Error: "+err.Error())) return } stderr, err := cmd.StderrPipe() if err != nil { conn.WriteMessage(websocket.TextMessage, []byte("Error: "+err.Error())) return } if err := cmd.Start(); err != nil { conn.WriteMessage(websocket.TextMessage, []byte("Error: "+err.Error())) return } var wg sync.WaitGroup var connMu sync.Mutex writeMessage := func(data []byte) { connMu.Lock() defer connMu.Unlock() conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) conn.WriteMessage(websocket.TextMessage, data) } readOutput := func(reader io.Reader) { defer wg.Done() defer func() { recover() }() buf := make([]byte, 4096) for { n, err := reader.Read(buf) if err != nil { return } if n > 0 { text := toUTF8(buf[:n]) writeMessage([]byte(text)) } } } wg.Add(2) go readOutput(stdout) go readOutput(stderr) // 启动 ping 协程 pingDone := make(chan struct{}) go func() { ticker := time.NewTicker(constant.PingPeriod) defer ticker.Stop() for { select { case <-ticker.C: connMu.Lock() conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { connMu.Unlock() return } connMu.Unlock() case <-pingDone: return } } }() for { _, message, err := conn.ReadMessage() if err != nil { break } // 过滤调整窗口大小的消息(Windows Pipe 模式不支持调整尺寸,需过滤掉避免写入 stdin) if len(message) > 0 && message[0] == '{' { var resizeMsg struct { Type string `json:"type"` } if err := json.Unmarshal(message, &resizeMsg); err == nil && resizeMsg.Type == "resize" { continue } } if _, err := stdin.Write(message); err != nil { break } } close(pingDone) stdin.Close() cmd.Process.Kill() cmd.Wait() wg.Wait() } // ExecuteShellCommand 执行单个命令并返回结果 func (tc *TerminalController) ExecuteShellCommand(c *gin.Context) { // 演示模式下禁止执行命令 if constant.DemoMode { utils.BadRequest(c, "演示模式下不能执行命令") return } var req struct { Command string `json:"command" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { utils.BadRequest(c, err.Error()) return } cmd := utils.NewShellCommandCmd(req.Command) userID := c.GetString("userID") if userID == "" { userID = "1" // 与 WebSocket 终端保持一致,保留原有兜底行为 } cmd.Env = tc.buildTerminalEnv(userID) output, err := cmd.CombinedOutput() if err != nil { utils.Success(c, gin.H{ "output": string(output), "error": err.Error(), }) return } utils.Success(c, gin.H{ "output": string(output), }) } func (tc *TerminalController) buildTerminalEnv(userID string, extraEnvs ...string) []string { env := os.Environ() env = append(env, extraEnvs...) // 注入 baihu 命令运行时路径与配置,保证在终端里手动执行 baihu 子命令时 // 仍然能连接到与主服务一致的数据库,而不是回退到默认 sqlite。 if absBinDir, err := filepath.Abs(filepath.Join(constant.DataDir, "bin")); err == nil { pathStr := absBinDir + string(os.PathListSeparator) + os.Getenv("PATH") env = append(env, "PATH="+pathStr) } env = append(env, utils.BuildRuntimeProcessEnv()...) // 注入环境变量(支持同名合并) if tc.envService != nil { envVars := tc.envService.GetFormattedEnvVarsByUserID(userID) env = append(env, envVars...) } // 为 Docker 环境或二进制版本注入所有 mise 已安装 Node 的全局依赖路径到 NODE_PATH (Issue-90) if !utils.IsInDocker() || (!strings.Contains(os.Args[0], "go-build") && !strings.Contains(os.Args[0], "tmp")) { versions, _ := utils.ListMiseInstalledVersions("node") var nodePaths []string for _, v := range versions { if p := utils.GetMiseNodePath(v); p != "" { nodePaths = append(nodePaths, p) } } if len(nodePaths) > 0 { sep := ":" if runtime.GOOS == "windows" { sep = ";" } env = append(env, "NODE_PATH="+strings.Join(nodePaths, sep)) } } return env } // GetCommands 获取所有可用的 cmd 列表及说明 func (tc *TerminalController) GetCommands(c *gin.Context) { var cmds []map[string]string for _, cmdInfo := range constant.Commands { cmds = append(cmds, map[string]string{ "name": cmdInfo.Name, "description": cmdInfo.Description, }) } utils.Success(c, cmds) }