feat: Web Terminal (xterm.js + PTY over WebSocket) + SSH-Copy-Karte im Tunnel-Tab
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
+42
-2
@@ -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
|
||||
}
|
||||
}
|
||||
// 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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user