329 lines
6.7 KiB
Go
329 lines
6.7 KiB
Go
package system
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/atlasos/calypso/internal/common/logger"
|
|
"github.com/creack/pty"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
const (
|
|
// WebSocket timeouts
|
|
writeWait = 10 * time.Second
|
|
pongWait = 60 * time.Second
|
|
pingPeriod = (pongWait * 9) / 10
|
|
)
|
|
|
|
var upgrader = websocket.Upgrader{
|
|
ReadBufferSize: 4096,
|
|
WriteBufferSize: 4096,
|
|
CheckOrigin: func(r *http.Request) bool {
|
|
// Allow all origins - in production, validate against allowed domains
|
|
return true
|
|
},
|
|
}
|
|
|
|
// TerminalSession manages a single terminal session
|
|
type TerminalSession struct {
|
|
conn *websocket.Conn
|
|
pty *os.File
|
|
cmd *exec.Cmd
|
|
logger *logger.Logger
|
|
mu sync.RWMutex
|
|
closed bool
|
|
username string
|
|
done chan struct{}
|
|
}
|
|
|
|
// HandleTerminalWebSocket handles WebSocket connection for terminal
|
|
func HandleTerminalWebSocket(c *gin.Context, log *logger.Logger) {
|
|
// Verify authentication
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
log.Warn("Terminal WebSocket: unauthorized access", "ip", c.ClientIP())
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
username, _ := c.Get("username")
|
|
if username == nil {
|
|
username = userID
|
|
}
|
|
|
|
log.Info("Terminal WebSocket: connection attempt", "username", username, "ip", c.ClientIP())
|
|
|
|
// Upgrade connection
|
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
if err != nil {
|
|
log.Error("Terminal WebSocket: upgrade failed", "error", err)
|
|
return
|
|
}
|
|
|
|
log.Info("Terminal WebSocket: connection upgraded", "username", username)
|
|
|
|
// Create session
|
|
session := &TerminalSession{
|
|
conn: conn,
|
|
logger: log,
|
|
username: username.(string),
|
|
done: make(chan struct{}),
|
|
}
|
|
|
|
// Start terminal
|
|
if err := session.startPTY(); err != nil {
|
|
log.Error("Terminal WebSocket: failed to start PTY", "error", err, "username", username)
|
|
session.sendError(err.Error())
|
|
session.close()
|
|
return
|
|
}
|
|
|
|
// Handle messages and PTY output
|
|
go session.handleRead()
|
|
go session.handleWrite()
|
|
}
|
|
|
|
// startPTY starts the PTY session
|
|
func (s *TerminalSession) startPTY() error {
|
|
// Get user info
|
|
currentUser, err := user.Lookup(s.username)
|
|
if err != nil {
|
|
// Fallback to current user
|
|
currentUser, err = user.Current()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Determine shell
|
|
shell := os.Getenv("SHELL")
|
|
if shell == "" {
|
|
shell = "/bin/bash"
|
|
}
|
|
|
|
// Create command
|
|
s.cmd = exec.Command(shell)
|
|
s.cmd.Env = append(os.Environ(),
|
|
"TERM=xterm-256color",
|
|
"HOME="+currentUser.HomeDir,
|
|
"USER="+currentUser.Username,
|
|
"USERNAME="+currentUser.Username,
|
|
)
|
|
s.cmd.Dir = currentUser.HomeDir
|
|
|
|
// Start PTY
|
|
ptyFile, err := pty.Start(s.cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.pty = ptyFile
|
|
|
|
// Set initial size
|
|
pty.Setsize(ptyFile, &pty.Winsize{
|
|
Rows: 24,
|
|
Cols: 80,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleRead handles incoming WebSocket messages
|
|
func (s *TerminalSession) handleRead() {
|
|
defer s.close()
|
|
|
|
// Set read deadline and pong handler
|
|
s.conn.SetReadDeadline(time.Now().Add(pongWait))
|
|
s.conn.SetPongHandler(func(string) error {
|
|
s.conn.SetReadDeadline(time.Now().Add(pongWait))
|
|
return nil
|
|
})
|
|
|
|
for {
|
|
select {
|
|
case <-s.done:
|
|
return
|
|
default:
|
|
messageType, data, err := s.conn.ReadMessage()
|
|
if err != nil {
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
|
s.logger.Error("Terminal WebSocket: read error", "error", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Handle binary messages (raw input)
|
|
if messageType == websocket.BinaryMessage {
|
|
s.writeToPTY(data)
|
|
continue
|
|
}
|
|
|
|
// Handle text messages (JSON commands)
|
|
if messageType == websocket.TextMessage {
|
|
var msg map[string]interface{}
|
|
if err := json.Unmarshal(data, &msg); err != nil {
|
|
continue
|
|
}
|
|
|
|
switch msg["type"] {
|
|
case "input":
|
|
if data, ok := msg["data"].(string); ok {
|
|
s.writeToPTY([]byte(data))
|
|
}
|
|
|
|
case "resize":
|
|
if cols, ok1 := msg["cols"].(float64); ok1 {
|
|
if rows, ok2 := msg["rows"].(float64); ok2 {
|
|
s.resizePTY(uint16(cols), uint16(rows))
|
|
}
|
|
}
|
|
|
|
case "ping":
|
|
s.writeWS(websocket.TextMessage, []byte(`{"type":"pong"}`))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleWrite handles PTY output to WebSocket
|
|
func (s *TerminalSession) handleWrite() {
|
|
defer s.close()
|
|
|
|
ticker := time.NewTicker(pingPeriod)
|
|
defer ticker.Stop()
|
|
|
|
// Read from PTY and write to WebSocket
|
|
buffer := make([]byte, 4096)
|
|
for {
|
|
select {
|
|
case <-s.done:
|
|
return
|
|
case <-ticker.C:
|
|
// Send ping
|
|
if err := s.writeWS(websocket.PingMessage, nil); err != nil {
|
|
return
|
|
}
|
|
default:
|
|
// Read from PTY
|
|
if s.pty != nil {
|
|
n, err := s.pty.Read(buffer)
|
|
if err != nil {
|
|
if err != io.EOF {
|
|
s.logger.Error("Terminal WebSocket: PTY read error", "error", err)
|
|
}
|
|
return
|
|
}
|
|
if n > 0 {
|
|
// Write binary data to WebSocket
|
|
if err := s.writeWS(websocket.BinaryMessage, buffer[:n]); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// writeToPTY writes data to PTY
|
|
func (s *TerminalSession) writeToPTY(data []byte) {
|
|
s.mu.RLock()
|
|
closed := s.closed
|
|
pty := s.pty
|
|
s.mu.RUnlock()
|
|
|
|
if closed || pty == nil {
|
|
return
|
|
}
|
|
|
|
if _, err := pty.Write(data); err != nil {
|
|
s.logger.Error("Terminal WebSocket: PTY write error", "error", err)
|
|
}
|
|
}
|
|
|
|
// resizePTY resizes the PTY
|
|
func (s *TerminalSession) resizePTY(cols, rows uint16) {
|
|
s.mu.RLock()
|
|
closed := s.closed
|
|
ptyFile := s.pty
|
|
s.mu.RUnlock()
|
|
|
|
if closed || ptyFile == nil {
|
|
return
|
|
}
|
|
|
|
// Use pty.Setsize from package, not method from variable
|
|
pty.Setsize(ptyFile, &pty.Winsize{
|
|
Cols: cols,
|
|
Rows: rows,
|
|
})
|
|
}
|
|
|
|
// writeWS writes message to WebSocket
|
|
func (s *TerminalSession) writeWS(messageType int, data []byte) error {
|
|
s.mu.RLock()
|
|
closed := s.closed
|
|
conn := s.conn
|
|
s.mu.RUnlock()
|
|
|
|
if closed || conn == nil {
|
|
return io.ErrClosedPipe
|
|
}
|
|
|
|
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
|
return conn.WriteMessage(messageType, data)
|
|
}
|
|
|
|
// sendError sends error message
|
|
func (s *TerminalSession) sendError(errMsg string) {
|
|
msg := map[string]interface{}{
|
|
"type": "error",
|
|
"error": errMsg,
|
|
}
|
|
data, _ := json.Marshal(msg)
|
|
s.writeWS(websocket.TextMessage, data)
|
|
}
|
|
|
|
// close closes the terminal session
|
|
func (s *TerminalSession) close() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.closed {
|
|
return
|
|
}
|
|
|
|
s.closed = true
|
|
close(s.done)
|
|
|
|
// Close PTY
|
|
if s.pty != nil {
|
|
s.pty.Close()
|
|
}
|
|
|
|
// Kill process
|
|
if s.cmd != nil && s.cmd.Process != nil {
|
|
s.cmd.Process.Signal(syscall.SIGTERM)
|
|
time.Sleep(100 * time.Millisecond)
|
|
if s.cmd.ProcessState == nil || !s.cmd.ProcessState.Exited() {
|
|
s.cmd.Process.Kill()
|
|
}
|
|
}
|
|
|
|
// Close WebSocket
|
|
if s.conn != nil {
|
|
s.conn.Close()
|
|
}
|
|
|
|
s.logger.Info("Terminal WebSocket: session closed", "username", s.username)
|
|
}
|