Files
aiops/backend/internal/api/handlers/kubectl_shell.go
T
attacker a3cd0d3910 feat: PTY kubectl终端 + 侧边栏对齐修复 + 构建缓存清理
**kubectl WebShell 修复:**
- 引入 github.com/creack/pty 库实现真正的伪终端
- 替换 bash --noediting + TERM=dumb 方案为 PTY-backed bash
- 支持 xterm-256color、窗口自适应、readline 交互
- 修复键盘输入无响应问题

**侧边栏优化:**
- 独立菜单项高度从 py-1.5 改为 h-8,与父菜单对齐
- 独立菜单图标从 h-3 w-3 改为 h-3.5 w-3.5,统一 14px

**构建优化:**
- run.sh docker up 增加 docker image prune -f 清理悬空镜像
- 每次部署自动清理 builder cache + dangling images

**认证修复(历史):**
- Token 存储:Zustand + localStorage 双写,http.ts 双源 fallback
- YamlViewer/DeploymentDetail token 来源统一为 Zustand store
2026-06-19 11:22:45 +00:00

192 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"strconv"
"syscall"
"github.com/creack/pty"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"gorm.io/gorm"
"ezops/internal/models"
)
// KubectlShellHandler kubectl WebSocket 终端处理(PTY 模式)
type KubectlShellHandler struct {
db *gorm.DB
}
// NewKubectlShellHandler creates handler
func NewKubectlShellHandler(db *gorm.DB) *KubectlShellHandler {
return &KubectlShellHandler{db: db}
}
// wsWriter wraps a websocket.Conn as an io.Writer (each write = one WSMessage)
type wsWriter struct {
conn *websocket.Conn
}
func (w wsWriter) Write(p []byte) (int, error) {
err := w.conn.WriteJSON(WSMessage{Status: 0, Data: string(p)})
if err != nil {
return 0, err
}
return len(p), nil
}
// Terminal starts a PTY-backed bash session with the cluster's kubeconfig injected
func (h *KubectlShellHandler) Terminal(c *gin.Context) {
clusterID := c.Query("id")
if clusterID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing cluster id"})
return
}
id, err := strconv.Atoi(clusterID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid cluster id"})
return
}
var cluster models.KubernetesCluster
if err := h.db.First(&cluster, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
return
}
if cluster.KubeConfig == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "no kubeconfig configured"})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("[KubectlShell] WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
// Write kubeconfig to a temp file (isolated per session)
tmpFile, err := os.CreateTemp("", "kubeconfig-*.yaml")
if err != nil {
conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31mFailed to create temp kubeconfig\x1b[0m\r\n"})
return
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(cluster.KubeConfig); err != nil {
conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31mFailed to write kubeconfig\x1b[0m\r\n"})
return
}
tmpFile.Close()
// 启动 bash -i(TERM=dumb,无 PS1 ANSI 码,不回显混乱)
// 注意:不加 --noediting,否则无 TTY 时 bash 不回显输入
cmd := exec.CommandContext(c.Request.Context(), "bash", "--norc", "-i")
cmd.Env = append(os.Environ(),
"KUBECONFIG="+tmpFile.Name(),
"HOME=/root",
"TERM=xterm-256color",
fmt.Sprintf("PS1=\x1b[32m[%s]\x1b[0m $ ", cluster.Code),
"COLUMNS=120",
"LINES=30",
)
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
Setctty: true,
}
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: 120, Rows: 30})
if err != nil {
conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31mFailed to start shell (bash not available?)\x1b[0m\r\n"})
return
}
defer func() {
ptmx.Close()
// Kill the process group so child processes (kubectl, etc.) are cleaned up
if cmd.Process != nil {
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
cmd.Wait()
}()
// Welcome message (already on PTY so bash will show PS1 right after)
welcome := fmt.Sprintf("\r\n\x1b[32m=== kubectl shell — %s (%s) ===\x1b[0m\r\n", cluster.Name, cluster.Code)
welcome += "\x1b[36mKUBECONFIG 已预配 | 输入 'exit' 退出\x1b[0m\r\n\r\n"
conn.WriteJSON(WSMessage{Status: 0, Data: welcome})
// Use a cancellable context for the two goroutines
ctx, cancel := context.WithCancel(c.Request.Context())
defer cancel()
// PTY → WebSocket (single goroutine via io.Copy)
go func() {
defer cancel() // unblock main goroutine when PTY closes
n, err := io.Copy(wsWriter{conn: conn}, ptmx)
if err != nil && err != io.EOF {
log.Printf("[KubectlShell] PTY copy ended: %v (bytes=%d)", err, n)
}
}()
// WebSocket → PTY (read JSON messages, write raw to PTY)
go func() {
defer ptmx.Close() // close PTY to stop bash and unblock Copy goroutine
for {
msgType, data, err := conn.ReadMessage()
if err != nil {
if !websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
// Normal close — client disconnected
} else {
log.Printf("[KubectlShell] WS read error: %v", err)
}
return
}
if msgType == websocket.TextMessage || msgType == websocket.BinaryMessage {
var msg WSMessage
if err := json.Unmarshal(data, &msg); err != nil {
// Binary data (rare) — pass through raw
if msgType == websocket.BinaryMessage {
ptmx.Write(data)
}
continue
}
switch msg.Status {
case 0:
// Keyboard input — write raw bytes to PTY
if _, err := ptmx.Write([]byte(msg.Data)); err != nil {
log.Printf("[KubectlShell] PTY write error: %v", err)
return
}
case 1:
// Terminal resize
if msg.Cols > 0 && msg.Rows > 0 {
if err := pty.Setsize(ptmx, &pty.Winsize{
Cols: uint16(msg.Cols),
Rows: uint16(msg.Rows),
}); err != nil {
log.Printf("[KubectlShell] PTY resize error: %v", err)
}
}
}
}
}
}()
// Block until PTY closes or context cancelled
<-ctx.Done()
log.Printf("[KubectlShell] session ended for cluster %s", cluster.Name)
}
func init() {
// ensure kubectl is available
if _, err := exec.LookPath("kubectl"); err != nil {
log.Printf("[KubectlShell] WARNING: kubectl not found in PATH — kubectl commands will not work")
}
}