package api import ( "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "log" "net" "net/http" "sync" "time" "clicd/internal/config" "github.com/gorilla/websocket" "golang.org/x/crypto/ssh" ) type terminalResizeMessage struct { Type string `json:"type"` Cols int `json:"cols"` Rows int `json:"rows"` } type webSSHTicket struct { ContainerName string SubUser bool ExpiresAt time.Time } var webSSHTickets = struct { sync.Mutex items map[string]webSSHTicket }{items: map[string]webSSHTicket{}} func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } if !requireScope(w, r, "terminal:ssh") { return } var req struct { ContainerName string `json:"container_name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ContainerName == "" { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name required"}) return } if !isContainerAllowedForRequest(r, req.ContainerName) { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"}) return } c := config.FindContainerByName(req.ContainerName) if c == nil { jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) return } if isSubUserRequest(r) && c.PolicyBlocked { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)}) return } ticket := randomHex(32) webSSHTickets.Lock() cleanupExpiredWebSSHTicketsLocked(time.Now()) webSSHTickets.items[ticket] = webSSHTicket{ ContainerName: req.ContainerName, SubUser: isSubUserRequest(r), ExpiresAt: time.Now().Add(60 * time.Second), } webSSHTickets.Unlock() jsonResponse(w, http.StatusOK, APIResponse{ Success: true, Data: map[string]string{"ticket": ticket}, }) } // HandleWebSSH proxies an SSH session to the browser over WebSocket. func HandleWebSSH(w http.ResponseWriter, r *http.Request) { ticket := webSSHTicketFromRequest(r) if ticket == "" { http.Error(w, "ticket required", http.StatusUnauthorized) return } containerName := r.URL.Query().Get("container") if containerName == "" { http.Error(w, "container name required", http.StatusBadRequest) return } item, ok := consumeWebSSHTicket(ticket, containerName) if !ok { http.Error(w, "invalid or expired ticket", http.StatusUnauthorized) return } c := config.FindContainerByName(containerName) if c == nil { http.Error(w, "container not found", http.StatusNotFound) return } if item.SubUser && c.PolicyBlocked { http.Error(w, "虚拟机被策略临时封禁", http.StatusForbidden) return } if c.Status != "running" { http.Error(w, "container is not running", http.StatusBadRequest) return } if c.IP == "" { var ip string var err error if c.IsKVM() { ip, err = kvmManager.GetContainerIP(c.VirshName()) } else { ip, err = lxcManager.GetContainerIP(c.LxcName()) } if err == nil { c.IP = ip config.SaveConfig() } } if c.IP == "" && !c.IsKVM() { if ip, err := lxcManager.EnsureContainerIPv4(c.ID); err == nil && ip != "" { c.IP = ip } } if c.IP == "" && c.IsKVM() { if err := kvmManager.EnsureSSH(c.ID); err == nil { if refreshed := config.FindContainer(c.ID); refreshed != nil { c = refreshed } } } if c.IP == "" { http.Error(w, "container ip is not available", http.StatusBadRequest) return } responseHeader := http.Header{} if protocol := webSSHTicketProtocol(r); protocol != "" { responseHeader.Set("Sec-WebSocket-Protocol", protocol) } ws, err := upgrader.Upgrade(w, r, responseHeader) if err != nil { log.Printf("WebSSH upgrade failed: %v", err) return } defer ws.Close() if c.SSHPassword == "" { if c.IsKVM() { writeWebSocketText(ws, nil, "\r\nKVM SSH password is not available. Reinstall or reset after SSH is ready.\r\n") return } writeWebSocketText(ws, nil, "\r\nPreparing SSH service. This can take up to 90 seconds on first boot...\r\n") if err := lxcManager.EnsureSSH(c.ID); err != nil { writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", err)) return } if refreshed := config.FindContainer(c.ID); refreshed != nil { c = refreshed } } if c.SSHPassword == "" { writeWebSocketText(ws, nil, "\r\nSSH password is empty after auto setup\r\n") return } sshConfig := &ssh.ClientConfig{ User: "root", Auth: []ssh.AuthMethod{ ssh.Password(c.SSHPassword), }, HostKeyCallback: containerHostKeyCallback(c), Timeout: 4 * time.Second, } addr := net.JoinHostPort(c.IP, "22") writeWebSocketText(ws, nil, fmt.Sprintf("Connecting to %s...\r\n", addr)) client, err := ssh.Dial("tcp", addr, sshConfig) if err != nil { if c.IsKVM() { writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing KVM guest service. This can take a few minutes on first boot...\r\n") if setupErr := kvmManager.EnsureSSH(c.ID); setupErr != nil { writeWebSocketText(ws, nil, fmt.Sprintf("\r\nKVM SSH auto setup failed: %v\r\n", setupErr)) return } if refreshed := config.FindContainer(c.ID); refreshed != nil { c = refreshed } if ip, ipErr := kvmManager.GetContainerIP(c.VirshName()); ipErr == nil && ip != "" { c.IP = ip config.SaveConfig() addr = net.JoinHostPort(c.IP, "22") } sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)} sshConfig.Timeout = 10 * time.Second client, err = ssh.Dial("tcp", addr, sshConfig) if err != nil { writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err)) return } } else { writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n") if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil { writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr)) return } if refreshed := config.FindContainer(c.ID); refreshed != nil { c = refreshed } if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" { c.IP = ip config.SaveConfig() addr = net.JoinHostPort(c.IP, "22") } sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)} sshConfig.Timeout = 10 * time.Second client, err = ssh.Dial("tcp", addr, sshConfig) if err != nil { writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err)) return } } } defer client.Close() session, err := client.NewSession() if err != nil { writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to create SSH session: %v\r\n", err)) return } defer session.Close() stdin, err := session.StdinPipe() if err != nil { writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to open SSH stdin: %v\r\n", err)) return } stdout, err := session.StdoutPipe() if err != nil { writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to open SSH stdout: %v\r\n", err)) return } stderr, err := session.StderrPipe() if err != nil { writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to open SSH stderr: %v\r\n", err)) return } if err := session.RequestPty("xterm-256color", 40, 120, ssh.TerminalModes{ ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_OSPEED: 14400, }); err != nil { writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to request pty: %v\r\n", err)) return } if err := session.Shell(); err != nil { writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to start shell: %v\r\n", err)) return } writeWebSocketText(ws, nil, "\r\nSSH shell ready. Press Enter if the prompt is not visible.\r\n") _, _ = stdin.Write([]byte("\n")) log.Printf("WebSSH connected for container %s -> %s", containerName, addr) done := make(chan struct{}, 3) var writeMu sync.Mutex go streamSSHOutput(ws, &writeMu, stdout, done) go streamSSHOutput(ws, &writeMu, stderr, done) go func() { defer func() { done <- struct{}{} }() for { messageType, msg, err := ws.ReadMessage() if err != nil { return } if messageType == websocket.TextMessage { var resize terminalResizeMessage if err := json.Unmarshal(msg, &resize); err == nil && resize.Type == "resize" { if resize.Rows > 0 && resize.Cols > 0 { _ = session.WindowChange(resize.Rows, resize.Cols) } continue } } if _, err := stdin.Write(msg); err != nil { return } } }() <-done _ = session.Signal(ssh.SIGTERM) log.Printf("WebSSH disconnected for container %s", containerName) } func containerHostKeyCallback(c *config.Container) ssh.HostKeyCallback { return func(hostname string, remote net.Addr, key ssh.PublicKey) error { sum := sha256.Sum256(key.Marshal()) fingerprint := hex.EncodeToString(sum[:]) if c.SSHHostKey != "" && c.SSHHostKey != fingerprint { return fmt.Errorf("container SSH host key mismatch") } if c.SSHHostKey == "" { c.SSHHostKey = fingerprint config.SaveConfig() } return nil } } func webSSHTicketFromRequest(r *http.Request) string { for _, protocol := range websocket.Subprotocols(r) { const prefix = "clicd-ticket." if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix { return protocol[len(prefix):] } } return "" } func webSSHTicketProtocol(r *http.Request) string { for _, protocol := range websocket.Subprotocols(r) { const prefix = "clicd-ticket." if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix { return protocol } } return "" } func streamSSHOutput(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- struct{}) { defer func() { done <- struct{}{} }() buf := make([]byte, 8192) for { n, err := src.Read(buf) if n > 0 { writeMu.Lock() writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]) writeMu.Unlock() if writeErr != nil { return } } if err != nil { return } } } func writeWebSocketText(ws *websocket.Conn, writeMu *sync.Mutex, msg string) { if writeMu != nil { writeMu.Lock() defer writeMu.Unlock() } _ = ws.WriteMessage(websocket.TextMessage, []byte(msg)) } func consumeWebSSHTicket(ticket, containerName string) (webSSHTicket, bool) { now := time.Now() webSSHTickets.Lock() defer webSSHTickets.Unlock() cleanupExpiredWebSSHTicketsLocked(now) item, ok := webSSHTickets.items[ticket] if !ok { return webSSHTicket{}, false } delete(webSSHTickets.items, ticket) return item, item.ContainerName == containerName && now.Before(item.ExpiresAt) } func cleanupExpiredWebSSHTicketsLocked(now time.Time) { for ticket, item := range webSSHTickets.items { if !now.Before(item.ExpiresAt) { delete(webSSHTickets.items, ticket) } } } func randomHex(bytesLen int) string { b := make([]byte, bytesLen) if _, err := rand.Read(b); err != nil { return fmt.Sprintf("%d", time.Now().UnixNano()) } return hex.EncodeToString(b) }