189 lines
5.2 KiB
Go
189 lines
5.2 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/kandev/kandev/internal/agentctl/server/shell"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// shellTerminalStartRequest is the request body for POST /shell/terminal/start.
|
|
type shellTerminalStartRequest struct {
|
|
TerminalID string `json:"terminal_id"`
|
|
Cols int `json:"cols"`
|
|
Rows int `json:"rows"`
|
|
}
|
|
|
|
// handleShellTerminalStart creates a new per-terminal shell session.
|
|
func (s *Server) handleShellTerminalStart(c *gin.Context) {
|
|
var req shellTerminalStartRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
if req.TerminalID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "terminal_id is required"})
|
|
return
|
|
}
|
|
|
|
cfg := shell.DefaultConfig(s.cfg.WorkDir)
|
|
if req.Cols > 0 {
|
|
cfg.Cols = req.Cols
|
|
}
|
|
if req.Rows > 0 {
|
|
cfg.Rows = req.Rows
|
|
}
|
|
|
|
if _, err := s.procMgr.StartTerminalShell(req.TerminalID, cfg); err != nil {
|
|
s.logger.Error("failed to start terminal shell",
|
|
zap.String("terminal_id", req.TerminalID),
|
|
zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "started", "terminal_id": req.TerminalID})
|
|
}
|
|
|
|
// handleShellTerminalStreamWS is a binary WebSocket endpoint for per-terminal shell I/O.
|
|
// Protocol: byte 0x01 prefix = resize command (followed by JSON {cols, rows}),
|
|
// all other bytes = raw PTY input. Output is sent as binary frames.
|
|
func (s *Server) handleShellTerminalStreamWS(c *gin.Context) {
|
|
terminalID := c.Param("id")
|
|
if terminalID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "terminal id is required"})
|
|
return
|
|
}
|
|
|
|
mgr := s.procMgr.ShellManager()
|
|
session, ok := mgr.Get(terminalID)
|
|
if !ok {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "terminal not found"})
|
|
return
|
|
}
|
|
|
|
conn, err := s.upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
if err != nil {
|
|
s.logger.Error("failed to upgrade terminal shell websocket",
|
|
zap.String("terminal_id", terminalID),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
s.logger.Info("terminal shell WebSocket connected", zap.String("terminal_id", terminalID))
|
|
|
|
// Subscribe to shell output
|
|
outputCh := make(chan []byte, 256)
|
|
session.Subscribe(outputCh)
|
|
defer session.Unsubscribe(outputCh)
|
|
|
|
// Replay buffered output for immediate state
|
|
if buf := session.GetBufferedOutput(); len(buf) > 0 {
|
|
if err := conn.WriteMessage(websocket.BinaryMessage, buf); err != nil {
|
|
s.logger.Debug("failed to replay shell buffer", zap.Error(err))
|
|
return
|
|
}
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
defer close(done)
|
|
|
|
// Forward shell output to WebSocket
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case data, ok := <-outputCh:
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := conn.WriteMessage(websocket.BinaryMessage, data); err != nil {
|
|
s.logger.Debug("terminal shell output write error",
|
|
zap.String("terminal_id", terminalID),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Read input from WebSocket
|
|
s.shellTerminalReadLoop(conn, session, terminalID)
|
|
}
|
|
|
|
const shellResizeByte = 0x01
|
|
|
|
func (s *Server) shellTerminalReadLoop(conn *websocket.Conn, session *shell.Session, terminalID string) {
|
|
for {
|
|
messageType, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
|
s.logger.Debug("terminal shell read error",
|
|
zap.String("terminal_id", terminalID),
|
|
zap.Error(err))
|
|
}
|
|
return
|
|
}
|
|
if messageType != websocket.BinaryMessage && messageType != websocket.TextMessage {
|
|
continue
|
|
}
|
|
if len(data) == 0 {
|
|
continue
|
|
}
|
|
|
|
if data[0] == shellResizeByte {
|
|
var resize struct {
|
|
Cols uint16 `json:"cols"`
|
|
Rows uint16 `json:"rows"`
|
|
}
|
|
if err := json.Unmarshal(data[1:], &resize); err != nil {
|
|
s.logger.Debug("failed to decode terminal shell resize",
|
|
zap.String("terminal_id", terminalID),
|
|
zap.Error(err))
|
|
continue
|
|
}
|
|
if err := session.Resize(resize.Cols, resize.Rows); err != nil {
|
|
s.logger.Debug("failed to resize terminal shell",
|
|
zap.String("terminal_id", terminalID),
|
|
zap.Error(err))
|
|
}
|
|
continue
|
|
}
|
|
|
|
if _, err := io.Writer(session).Write(data); err != nil {
|
|
s.logger.Debug("failed to write terminal shell input",
|
|
zap.String("terminal_id", terminalID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleShellTerminalBuffer returns the buffered output for a terminal.
|
|
func (s *Server) handleShellTerminalBuffer(c *gin.Context) {
|
|
terminalID := c.Param("id")
|
|
mgr := s.procMgr.ShellManager()
|
|
buf, err := mgr.Buffer(terminalID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, ShellBufferResponse{Data: string(buf)})
|
|
}
|
|
|
|
// handleShellTerminalStop stops a per-terminal shell session.
|
|
func (s *Server) handleShellTerminalStop(c *gin.Context) {
|
|
terminalID := c.Param("id")
|
|
mgr := s.procMgr.ShellManager()
|
|
if err := mgr.Stop(terminalID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "stopped", "terminal_id": terminalID})
|
|
}
|