diff --git a/agent-linux/go.mod b/agent-linux/go.mod index 18c1627..8e16a74 100644 --- a/agent-linux/go.mod +++ b/agent-linux/go.mod @@ -6,3 +6,5 @@ require ( github.com/gorilla/websocket v1.5.0 gopkg.in/yaml.v3 v3.0.1 ) + +require github.com/creack/pty v1.1.24 // indirect diff --git a/agent-linux/go.sum b/agent-linux/go.sum index 1a390aa..e59a6d4 100644 --- a/agent-linux/go.sum +++ b/agent-linux/go.sum @@ -1,3 +1,5 @@ +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/agent-linux/rmm-agent-linux b/agent-linux/rmm-agent-linux new file mode 100755 index 0000000..088ff65 Binary files /dev/null and b/agent-linux/rmm-agent-linux differ diff --git a/agent-linux/ws/client.go b/agent-linux/ws/client.go index e9b1cfa..a9fbf0c 100644 --- a/agent-linux/ws/client.go +++ b/agent-linux/ws/client.go @@ -31,6 +31,7 @@ type Client struct { messageHandler *Handler tunnelManager *TunnelManager + ptyManager *PTYManager } type Message struct { @@ -60,6 +61,7 @@ func NewClient(backendURL, agentID, apiKey string, insecure bool) *Client { client.messageHandler = NewHandler(client) client.tunnelManager = NewTunnelManager(client) + client.ptyManager = NewPTYManager(client) return client } @@ -250,6 +252,21 @@ func (c *Client) readMessages(conn *websocket.Conn, msgChan chan<- Message, errC } if messageType == websocket.BinaryMessage { + // PTY-Messages: session_id beginnt mit "pty-" + // Format: [len (1 byte)][session_id][data] + if len(data) > 1 { + idLen := int(data[0]) + if 1+idLen <= len(data) { + sessionID := string(data[1 : 1+idLen]) + payload := data[1+idLen:] + if len(sessionID) >= 4 && sessionID[:4] == "pty-" { + if err := c.ptyManager.WriteToPTY(sessionID, payload); err != nil { + log.Printf("PTY-Write fehlgeschlagen: %v", err) + } + continue + } + } + } if err := c.tunnelManager.ProcessBinaryMessage(data); err != nil { log.Printf("Binary-Message verarbeiten fehlgeschlagen: %v", err) } diff --git a/agent-linux/ws/handler.go b/agent-linux/ws/handler.go index 9ff6305..e004984 100644 --- a/agent-linux/ws/handler.go +++ b/agent-linux/ws/handler.go @@ -40,6 +40,14 @@ func (h *Handler) HandleCommand(msg Message) { return case "agent_update": response = h.handleAgentUpdate(msg) + case "pty_start": + response = h.handlePTYStart(msg) + case "pty_stop": + h.handlePTYStop(msg) + return + case "pty_resize": + h.handlePTYResize(msg) + return default: response.Status = "error" response.Error = fmt.Sprintf("Unbekanntes Command: %s", msg.Command) @@ -200,6 +208,60 @@ func (h *Handler) handleAgentUpdate(msg Message) Message { return response } +// --- PTY Handlers --- + +func (h *Handler) handlePTYStart(msg Message) Message { + response := Message{Type: "response", ID: msg.ID} + + sessionID, _ := msg.Params["session_id"].(string) + if sessionID == "" { + response.Status = "error" + response.Error = "session_id erforderlich" + return response + } + + cols := uint16(80) + rows := uint16(24) + if c, ok := msg.Params["cols"].(float64); ok && c > 0 { + cols = uint16(c) + } + if r, ok := msg.Params["rows"].(float64); ok && r > 0 { + rows = uint16(r) + } + + if err := h.client.ptyManager.StartPTY(sessionID, cols, rows); err != nil { + response.Status = "error" + response.Error = err.Error() + return response + } + + response.Status = "ok" + response.Data = map[string]interface{}{"session_id": sessionID} + return response +} + +func (h *Handler) handlePTYStop(msg Message) { + sessionID, _ := msg.Params["session_id"].(string) + if sessionID != "" { + h.client.ptyManager.StopPTY(sessionID) + } +} + +func (h *Handler) handlePTYResize(msg Message) { + sessionID, _ := msg.Params["session_id"].(string) + cols := uint16(80) + rows := uint16(24) + if c, ok := msg.Params["cols"].(float64); ok && c > 0 { + cols = uint16(c) + } + if r, ok := msg.Params["rows"].(float64); ok && r > 0 { + rows = uint16(r) + } + if sessionID != "" { + h.client.ptyManager.ResizePTY(sessionID, cols, rows) + } +} + // --- Tunnel Handlers --- func (h *Handler) handleTunnelConnect(msg Message) Message { diff --git a/agent-linux/ws/pty_manager.go b/agent-linux/ws/pty_manager.go new file mode 100644 index 0000000..b2f8a4c --- /dev/null +++ b/agent-linux/ws/pty_manager.go @@ -0,0 +1,170 @@ +package ws + +import ( + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "sync" + "syscall" + "unsafe" + + "github.com/creack/pty" +) + +type PTYSession struct { + ID string + ptmx *os.File + cmd *exec.Cmd + done chan struct{} +} + +type PTYManager struct { + sessions map[string]*PTYSession + mu sync.Mutex + client *Client +} + +func NewPTYManager(c *Client) *PTYManager { + return &PTYManager{ + sessions: make(map[string]*PTYSession), + client: c, + } +} + +// StartPTY startet eine neue PTY-Session mit /bin/bash +func (pm *PTYManager) StartPTY(sessionID string, cols, rows uint16) error { + pm.mu.Lock() + defer pm.mu.Unlock() + + if _, exists := pm.sessions[sessionID]; exists { + return fmt.Errorf("PTY-Session %s existiert bereits", sessionID) + } + + cmd := exec.Command("/bin/bash", "-l") + cmd.Env = append(os.Environ(), + "TERM=xterm-256color", + fmt.Sprintf("COLUMNS=%d", cols), + fmt.Sprintf("LINES=%d", rows), + ) + + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: rows, Cols: cols}) + if err != nil { + return fmt.Errorf("PTY starten fehlgeschlagen: %w", err) + } + + session := &PTYSession{ + ID: sessionID, + ptmx: ptmx, + cmd: cmd, + done: make(chan struct{}), + } + + pm.sessions[sessionID] = session + + // PTY-Ausgabe lesen und an Backend senden + go pm.readPTYOutput(session) + + log.Printf("PTY-Session gestartet: %s (%dx%d)", sessionID, cols, rows) + return nil +} + +// StopPTY beendet eine PTY-Session +func (pm *PTYManager) StopPTY(sessionID string) { + pm.mu.Lock() + session, exists := pm.sessions[sessionID] + if exists { + delete(pm.sessions, sessionID) + } + pm.mu.Unlock() + + if !exists { + return + } + + close(session.done) + session.cmd.Process.Kill() + session.ptmx.Close() + log.Printf("PTY-Session beendet: %s", sessionID) +} + +// WriteToPTY schreibt Daten in die PTY-Stdin +func (pm *PTYManager) WriteToPTY(sessionID string, data []byte) error { + pm.mu.Lock() + session, exists := pm.sessions[sessionID] + pm.mu.Unlock() + + if !exists { + return fmt.Errorf("PTY-Session %s nicht gefunden", sessionID) + } + + _, err := session.ptmx.Write(data) + return err +} + +// ResizePTY aendert die PTY-Groesse +func (pm *PTYManager) ResizePTY(sessionID string, cols, rows uint16) error { + pm.mu.Lock() + session, exists := pm.sessions[sessionID] + pm.mu.Unlock() + + if !exists { + return fmt.Errorf("PTY-Session %s nicht gefunden", sessionID) + } + + ws := &struct { + Rows, Cols, X, Y uint16 + }{Rows: rows, Cols: cols} + + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, + session.ptmx.Fd(), + syscall.TIOCSWINSZ, + uintptr(unsafe.Pointer(ws)), + ) + if errno != 0 { + return errno + } + return nil +} + +func (pm *PTYManager) readPTYOutput(session *PTYSession) { + buf := make([]byte, 4096) + for { + select { + case <-session.done: + return + default: + } + + n, err := session.ptmx.Read(buf) + if err != nil { + // PTY geschlossen — Session beenden und Backend informieren + pm.StopPTY(session.ID) + // Exit-Nachricht senden + msg := map[string]interface{}{ + "type": "pty_exit", + "session_id": session.ID, + } + if data, err := json.Marshal(msg); err == nil { + pm.client.SendMessage(Message{ + Type: "event", + Event: "pty_exit", + Params: map[string]interface{}{ + "session_id": session.ID, + }, + }) + _ = data + } + return + } + + if n > 0 { + // Daten als Binary-Message senden (session_id Prefix) + if err := pm.client.messageHandler.SendSessionData(session.ID, buf[:n]); err != nil { + log.Printf("PTY-Daten senden fehlgeschlagen: %v", err) + return + } + } + } +} diff --git a/backend/api/terminal.go b/backend/api/terminal.go new file mode 100644 index 0000000..2c0f953 --- /dev/null +++ b/backend/api/terminal.go @@ -0,0 +1,146 @@ +package api + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "github.com/cynfo/rmm-backend/ws" + "github.com/gorilla/websocket" +) + +var termUpgrader = websocket.Upgrader{ + ReadBufferSize: 4096, + WriteBufferSize: 4096, + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// terminalHandler bridget Browser-WebSocket mit Agent-PTY +func (h *Handler) terminalHandler(hub *ws.Hub) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + if agentID == "" { + http.Error(w, "agent_id erforderlich", http.StatusBadRequest) + return + } + + // Cols/Rows aus Query + cols := uint16(220) + rows := uint16(50) + if c := r.URL.Query().Get("cols"); c != "" { + var v uint16 + fmt.Sscanf(c, "%d", &v) + if v > 0 { + cols = v + } + } + if r := r.URL.Query().Get("rows"); r != "" { + var v uint16 + fmt.Sscanf(r, "%d", &v) + if v > 0 { + rows = v + } + } + + // WebSocket-Upgrade + browserConn, err := termUpgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("Terminal WS Upgrade fehlgeschlagen: %v", err) + return + } + defer browserConn.Close() + + // PTY-Session-ID generieren + sessionID := fmt.Sprintf("pty-%s-%d", agentID[:8], time.Now().UnixNano()) + + // Output-Channel registrieren + outputCh := hub.RegisterPTYSession(sessionID) + defer hub.UnregisterPTYSession(sessionID) + + // pty_start an Agent senden + err = h.sendCommandAndWaitPTY(hub, agentID, sessionID, cols, rows) + if err != nil { + log.Printf("PTY-Start fehlgeschlagen fuer %s: %v", agentID, err) + browserConn.WriteMessage(websocket.TextMessage, []byte("\r\nFehler: Agent nicht erreichbar oder PTY-Start fehlgeschlagen\r\n")) + return + } + + log.Printf("PTY-Session gestartet: %s fuer Agent %s", sessionID, agentID) + + // Goroutine: Agent-Ausgabe -> Browser + done := make(chan struct{}) + go func() { + defer close(done) + for { + select { + case data, ok := <-outputCh: + if !ok { + return + } + browserConn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := browserConn.WriteMessage(websocket.BinaryMessage, data); err != nil { + return + } + case <-time.After(30 * time.Second): + // Keep-alive ping + if err := browserConn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } + }() + + // Browser-Input -> Agent + browserConn.SetReadDeadline(time.Time{}) // kein Timeout fuer interaktive Sessions + for { + messageType, data, err := browserConn.ReadMessage() + if err != nil { + break + } + + if messageType == websocket.BinaryMessage { + // Eingabe an Agent-PTY weiterleiten + idBytes := []byte(sessionID) + msg := make([]byte, 1+len(idBytes)+len(data)) + msg[0] = byte(len(idBytes)) + copy(msg[1:], idBytes) + copy(msg[1+len(idBytes):], data) + hub.SendBinaryToAgent(agentID, msg) + + } else if messageType == websocket.TextMessage { + // JSON-Control-Messages (resize) + var ctrl struct { + Type string `json:"type"` + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` + } + if err := json.Unmarshal(data, &ctrl); err == nil && ctrl.Type == "resize" { + SendCommandToAgent(hub, agentID, "pty_resize", map[string]interface{}{ + "session_id": sessionID, + "cols": ctrl.Cols, + "rows": ctrl.Rows, + }) + } + } + } + + // Browser disconnected -> PTY stoppen + SendCommandToAgent(hub, agentID, "pty_stop", map[string]interface{}{ + "session_id": sessionID, + }) + log.Printf("PTY-Session beendet: %s", sessionID) + + <-done + } +} + +func (h *Handler) sendCommandAndWaitPTY(hub *ws.Hub, agentID, sessionID string, cols, rows uint16) error { + _, err := h.sendCommandAndWaitResult(hub, agentID, "pty_start", map[string]interface{}{ + "session_id": sessionID, + "cols": cols, + "rows": rows, + }, 10) + return err +} diff --git a/backend/api/tunnel_proxy.go b/backend/api/tunnel_proxy.go index c67b7b5..9a81180 100644 --- a/backend/api/tunnel_proxy.go +++ b/backend/api/tunnel_proxy.go @@ -43,6 +43,9 @@ func (h *Handler) setupTunnelRoutes(mux *http.ServeMux, hub *ws.Hub) { mux.HandleFunc("DELETE /api/v1/agents/{id}/backups/{backup_id}", h.deleteBackup()) mux.HandleFunc("GET /api/v1/agents/{id}/backups/diff", h.diffBackups()) + // Terminal-Endpoint (Browser -> Agent PTY) + mux.HandleFunc("GET /api/v1/agents/{id}/terminal", h.terminalHandler(hub)) + // WebSocket-Endpoint mux.HandleFunc("GET /api/v1/agent/ws", ws.NewWebSocketHandler(hub)) @@ -53,53 +56,26 @@ func (h *Handler) setupTunnelRoutes(mux *http.ServeMux, hub *ws.Hub) { func (h *Handler) executeCommand(hub *ws.Hub) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { agentID := r.PathValue("id") - - // Request parsen + var req struct { Command string `json:"command"` Timeout int `json:"timeout,omitempty"` } - + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return } - + if req.Command == "" { writeError(w, http.StatusBadRequest, "Parameter 'command' erforderlich") return } - + if req.Timeout == 0 { req.Timeout = 30 } - - // Prüfen ob Agent verbunden ist - if !hub.IsAgentConnected(agentID) { - writeError(w, http.StatusServiceUnavailable, "Agent nicht verbunden") - return - } - - // Command-Message erstellen - cmd := map[string]interface{}{ - "type": "command", - "id": generateID(), - "command": "exec", - "params": map[string]interface{}{ - "command": req.Command, - "timeout": req.Timeout, - }, - } - - cmdJSON, _ := json.Marshal(cmd) - - // Command an Agent senden - if err := hub.SendToAgent(agentID, cmdJSON); err != nil { - log.Printf("Command senden fehlgeschlagen: %v", err) - writeError(w, http.StatusInternalServerError, "Command senden fehlgeschlagen") - return - } - + // Audit cmdTrunc := req.Command if len(cmdTrunc) > 100 { @@ -107,11 +83,11 @@ func (h *Handler) executeCommand(hub *ws.Hub) http.HandlerFunc { } h.auditLog(r, "exec", "agent", agentID, "", cmdTrunc) - // Response zurückgeben - writeJSON(w, http.StatusOK, map[string]interface{}{ - "message": "Command gesendet", - "command_id": cmd["id"], - }) + // Command senden und auf Antwort warten + h.sendCommandAndWait(hub, agentID, "exec", map[string]interface{}{ + "command": req.Command, + "timeout": req.Timeout, + }, req.Timeout+5, w) } } @@ -337,6 +313,57 @@ func (h *Handler) sendCommandAndWait(hub *ws.Hub, agentID, command string, param } } +// sendCommandAndWaitResult wie sendCommandAndWait, gibt Response direkt zurueck (kein http.ResponseWriter) +func (h *Handler) sendCommandAndWaitResult(hub *ws.Hub, agentID, command string, params map[string]interface{}, timeoutSec int) (map[string]interface{}, error) { + if !hub.IsAgentConnected(agentID) { + return nil, fmt.Errorf("Agent nicht verbunden") + } + + cmdID := generateID() + cmd := map[string]interface{}{ + "type": "command", + "id": cmdID, + "command": command, + } + if params != nil { + cmd["params"] = params + } + + responseCh := hub.RegisterResponseWaiter(cmdID) + defer hub.UnregisterResponseWaiter(cmdID) + + cmdJSON, _ := json.Marshal(cmd) + if err := hub.SendToAgent(agentID, cmdJSON); err != nil { + return nil, err + } + + select { + case resp := <-responseCh: + if status, _ := resp["status"].(string); status == "error" { + if errMsg, _ := resp["error"].(string); errMsg != "" { + return nil, fmt.Errorf(errMsg) + } + } + return resp, nil + case <-time.After(time.Duration(timeoutSec) * time.Second): + return nil, fmt.Errorf("timeout") + } +} + +// SendCommandToAgent sendet ein Command ohne auf Response zu warten (fire-and-forget) +func SendCommandToAgent(hub *ws.Hub, agentID, command string, params map[string]interface{}) error { + cmd := map[string]interface{}{ + "type": "command", + "id": generateID(), + "command": command, + } + if params != nil { + cmd["params"] = params + } + cmdJSON, _ := json.Marshal(cmd) + return hub.SendToAgent(agentID, cmdJSON) +} + func (h *Handler) hubStatus(hub *ws.Hub) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { stats := hub.GetAgentStats() diff --git a/backend/ws/hub.go b/backend/ws/hub.go index 4d9e1a8..1445e9d 100644 --- a/backend/ws/hub.go +++ b/backend/ws/hub.go @@ -29,6 +29,10 @@ type Hub struct { // Event-Callback fuer DB-Events bei Connect/Disconnect eventCallback func(agentID, eventType, message string) + + // PTY-Sessions: session_id -> channel fuer Ausgabe an Browser + ptySessions map[string]chan []byte + ptySessionsMux sync.RWMutex } func NewHub() *Hub { @@ -41,6 +45,7 @@ func NewHub() *Hub { } hub.tunnelManager = NewTunnelManager(hub) + hub.ptySessions = make(map[string]chan []byte) return hub } @@ -281,8 +286,28 @@ func (h *Hub) ProcessAgentMessage(agentID string, messageType int, data []byte) agent.LastSeen = time.Now() } - // Binary Messages sind normalerweise Tunnel-Daten + // Binary Messages: PTY oder Tunnel if messageType == 2 { // websocket.BinaryMessage + // PTY-Session erkennen: session_id beginnt mit "pty-" + if len(data) > 1 { + idLen := int(data[0]) + if 1+idLen <= len(data) { + sessionID := string(data[1 : 1+idLen]) + payload := data[1+idLen:] + if len(sessionID) >= 4 && sessionID[:4] == "pty-" { + h.ptySessionsMux.RLock() + ch, ok := h.ptySessions[sessionID] + h.ptySessionsMux.RUnlock() + if ok { + select { + case ch <- payload: + default: + } + } + return nil + } + } + } return h.tunnelManager.ProcessBinaryMessage(agentID, data) } @@ -333,4 +358,19 @@ func (h *Hub) ProcessAgentMessage(agentID string, messageType int, data []byte) } return nil -} \ No newline at end of file +} +// RegisterPTYSession registriert einen Output-Channel fuer eine PTY-Session +func (h *Hub) RegisterPTYSession(sessionID string) chan []byte { + ch := make(chan []byte, 256) + h.ptySessionsMux.Lock() + h.ptySessions[sessionID] = ch + h.ptySessionsMux.Unlock() + return ch +} + +// UnregisterPTYSession entfernt eine PTY-Session +func (h *Hub) UnregisterPTYSession(sessionID string) { + h.ptySessionsMux.Lock() + delete(h.ptySessions, sessionID) + h.ptySessionsMux.Unlock() +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6e6e43d..1e58837 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,8 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "lucide-react": "^0.575.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -1858,6 +1860,21 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index f12ac55..6425f3c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,8 @@ "preview": "vite preview" }, "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "lucide-react": "^0.575.0", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/frontend/src/components/ProxmoxPanel.jsx b/frontend/src/components/ProxmoxPanel.jsx index 35e19da..b4d64a1 100644 --- a/frontend/src/components/ProxmoxPanel.jsx +++ b/frontend/src/components/ProxmoxPanel.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import api from '../api/client' import { useSettingsStore } from '../stores/settings' import StatusBadge from './StatusBadge' +import TerminalModal from './TerminalModal' import { X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor, Container, Database, FolderTree, Settings, Download, Trash2, Copy, Check, @@ -678,6 +679,8 @@ function TunnelTab({ agentId, agent, webguiPort = 8006 }) { const [customHost, setCustomHost] = useState('127.0.0.1') const [customPort, setCustomPort] = useState('') const [error, setError] = useState('') + const [terminalOpen, setTerminalOpen] = useState(false) + const [copiedCmd, setCopiedCmd] = useState(false) const backendHost = useSettingsStore.getState().getBackendHost() @@ -736,9 +739,24 @@ function TunnelTab({ agentId, agent, webguiPort = 8006 }) { setCustomPort('') } + const sshTunnel = tunnels.find(t => t.target_port === 22) + const sshCmd = sshTunnel ? `ssh root@${backendHost} -p ${sshTunnel.proxy_port}` : null + + const copySSHCmd = () => { + if (sshCmd) { + navigator.clipboard.writeText(sshCmd) + setCopiedCmd(true) + setTimeout(() => setCopiedCmd(false), 2000) + } + } + return (
-
+ {terminalOpen && ( + setTerminalOpen(false)} /> + )} + +
Schnellzugriff: +
+ {/* SSH-Copy-Karte wenn SSH-Tunnel aktiv */} + {sshTunnel && ( +
+ + {sshCmd} + +
+ )} + {customOpen && (
setCustomHost(e.target.value)} diff --git a/frontend/src/components/TerminalModal.jsx b/frontend/src/components/TerminalModal.jsx new file mode 100644 index 0000000..52cef67 --- /dev/null +++ b/frontend/src/components/TerminalModal.jsx @@ -0,0 +1,162 @@ +import { useEffect, useRef, useState } from 'react' +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import '@xterm/xterm/css/xterm.css' +import { useSettingsStore } from '../stores/settings' +import { API_KEY } from '../config' + +export default function TerminalModal({ agentId, agentName, onClose }) { + const termRef = useRef(null) + const xtermRef = useRef(null) + const fitRef = useRef(null) + const wsRef = useRef(null) + const [status, setStatus] = useState('connecting') // connecting | open | error | closed + + const backendHost = useSettingsStore.getState().getBackendHost() + const apiKey = API_KEY + + useEffect(() => { + if (!termRef.current) return + + // xterm initialisieren + const term = new Terminal({ + theme: { + background: '#0d1117', + foreground: '#e6edf3', + cursor: '#58a6ff', + selectionBackground: '#264f78', + black: '#0d1117', + red: '#ff7b72', + green: '#3fb950', + yellow: '#d29922', + blue: '#58a6ff', + magenta: '#bc8cff', + cyan: '#39c5cf', + white: '#e6edf3', + }, + fontFamily: '"Cascadia Code", "JetBrains Mono", "Fira Code", monospace', + fontSize: 13, + lineHeight: 1.3, + cursorBlink: true, + cursorStyle: 'block', + scrollback: 5000, + allowProposedApi: true, + }) + + const fitAddon = new FitAddon() + term.loadAddon(fitAddon) + term.open(termRef.current) + + setTimeout(() => { + fitAddon.fit() + }, 50) + + xtermRef.current = term + fitRef.current = fitAddon + + // WebSocket verbinden + const cols = term.cols || 220 + const rows = term.rows || 50 + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'wss:' + const wsUrl = `wss://${backendHost}:8443/api/v1/agents/${agentId}/terminal?cols=${cols}&rows=${rows}&api_key=${apiKey}` + + const ws = new WebSocket(wsUrl) + ws.binaryType = 'arraybuffer' + wsRef.current = ws + + ws.onopen = () => { + setStatus('open') + term.write('\r\n\x1b[32m Verbunden mit ' + (agentName || agentId) + '\x1b[0m\r\n\r\n') + } + + ws.onmessage = (e) => { + if (e.data instanceof ArrayBuffer) { + term.write(new Uint8Array(e.data)) + } else if (typeof e.data === 'string') { + term.write(e.data) + } + } + + ws.onerror = () => { + setStatus('error') + term.write('\r\n\x1b[31mVerbindungsfehler\x1b[0m\r\n') + } + + ws.onclose = () => { + setStatus('closed') + term.write('\r\n\x1b[33mVerbindung getrennt\x1b[0m\r\n') + } + + // Tastatureingaben -> WebSocket + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + const encoder = new TextEncoder() + ws.send(encoder.encode(data)) + } + }) + + // Resize + const resizeObserver = new ResizeObserver(() => { + try { + fitAddon.fit() + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })) + } + } catch (_) {} + }) + resizeObserver.observe(termRef.current) + + return () => { + resizeObserver.disconnect() + ws.close() + term.dispose() + } + }, [agentId]) + + const statusColor = { + connecting: 'text-yellow-400', + open: 'text-green-400', + error: 'text-red-400', + closed: 'text-gray-500', + }[status] + + const statusLabel = { + connecting: 'Verbinde...', + open: 'Verbunden', + error: 'Fehler', + closed: 'Getrennt', + }[status] + + return ( +
+
+ + {/* Header */} +
+
+ +
+
+
+ +
+ + root@{agentName || agentId} + +
+ +
+ ● {statusLabel} +
+
+ + {/* Terminal */} +
+
+
+ ) +}