feat: Web Terminal (xterm.js + PTY over WebSocket) + SSH-Copy-Karte im Tunnel-Tab

This commit is contained in:
cynfo3000
2026-03-07 00:20:03 +01:00
parent 3c83cf11de
commit 67a337ed9f
13 changed files with 731 additions and 41 deletions
+146
View File
@@ -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
}
+64 -37
View File
@@ -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()