rmm2/agent/ws/client.go
cynfo3000 f47154787d WebSocket-Infrastruktur: bidirektionaler Command-Kanal + TCP-Tunnel
- Agent: WS-Client mit Reconnect, Command-Handler (exec, tunnel_open/close/data)
- Backend: WS-Hub, Tunnel-Manager mit dynamischen Proxy-Ports (10000-20000)
- API: /agents/{id}/exec, /tunnel, /tunnels, /proxy/{tunnel_id}
- Binary WebSocket Messages fuer effiziente Tunnel-Daten
- gorilla/websocket (pure Go, kein CGO)
- README aktualisiert
2026-02-28 07:42:33 +01:00

292 lines
5.9 KiB
Go

package ws
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log"
"net/url"
"sync"
"time"
"github.com/gorilla/websocket"
)
type Client struct {
backendURL string
agentID string
apiKey string
insecure bool
conn *websocket.Conn
connMux sync.RWMutex
reconnectInterval time.Duration
pingInterval time.Duration
ctx context.Context
cancel context.CancelFunc
messageHandler *Handler
tunnelManager *TunnelManager
}
type Message struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Command string `json:"command,omitempty"`
Event string `json:"event,omitempty"`
Status string `json:"status,omitempty"`
Params map[string]interface{} `json:"params,omitempty"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
func NewClient(backendURL, agentID, apiKey string, insecure bool) *Client {
ctx, cancel := context.WithCancel(context.Background())
client := &Client{
backendURL: backendURL,
agentID: agentID,
apiKey: apiKey,
insecure: insecure,
reconnectInterval: 5 * time.Second,
pingInterval: 30 * time.Second,
ctx: ctx,
cancel: cancel,
}
client.messageHandler = NewHandler(client)
client.tunnelManager = NewTunnelManager(client)
return client
}
func (c *Client) Start() error {
log.Println("WebSocket Client startet...")
go c.reconnectLoop()
return nil
}
func (c *Client) Stop() {
log.Println("WebSocket Client stoppt...")
c.cancel()
c.connMux.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.connMux.Unlock()
}
func (c *Client) reconnectLoop() {
backoff := c.reconnectInterval
maxBackoff := 5 * time.Minute
for {
select {
case <-c.ctx.Done():
return
default:
}
if err := c.connect(); err != nil {
log.Printf("WebSocket-Verbindung fehlgeschlagen: %v", err)
select {
case <-time.After(backoff):
// Exponential backoff mit Maximum
backoff = time.Duration(float64(backoff) * 1.5)
if backoff > maxBackoff {
backoff = maxBackoff
}
case <-c.ctx.Done():
return
}
continue
}
// Verbindung erfolgreich - backoff zurücksetzen
backoff = c.reconnectInterval
log.Println("WebSocket-Verbindung hergestellt")
// Ping/Pong und Message-Handling starten
c.runConnection()
log.Println("WebSocket-Verbindung getrennt")
}
}
func (c *Client) connect() error {
// WebSocket-URL aufbauen
u, err := url.Parse(c.backendURL)
if err != nil {
return fmt.Errorf("Backend-URL parsen: %w", err)
}
if u.Scheme == "https" {
u.Scheme = "wss"
} else {
u.Scheme = "ws"
}
u.Path = "/api/v1/agent/ws"
q := u.Query()
q.Set("agent_id", c.agentID)
q.Set("api_key", c.apiKey)
u.RawQuery = q.Encode()
// Dialer mit TLS-Config
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: c.insecure,
},
}
conn, _, err := dialer.Dial(u.String(), nil)
if err != nil {
return fmt.Errorf("WebSocket-Dial: %w", err)
}
c.connMux.Lock()
c.conn = conn
c.connMux.Unlock()
return nil
}
func (c *Client) runConnection() {
// Ping-Ticker starten
pingTicker := time.NewTicker(c.pingInterval)
defer pingTicker.Stop()
// Pong-Handler setzen
c.connMux.RLock()
conn := c.conn
c.connMux.RUnlock()
if conn == nil {
return
}
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(c.pingInterval * 2))
return nil
})
conn.SetReadDeadline(time.Now().Add(c.pingInterval * 2))
// Goroutine für eingehende Nachrichten
msgChan := make(chan Message, 10)
errChan := make(chan error, 1)
go c.readMessages(conn, msgChan, errChan)
for {
select {
case <-c.ctx.Done():
return
case <-pingTicker.C:
c.connMux.RLock()
if c.conn != nil {
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
c.connMux.RUnlock()
log.Printf("Ping senden fehlgeschlagen: %v", err)
return
}
}
c.connMux.RUnlock()
case msg := <-msgChan:
go c.handleMessage(msg)
case err := <-errChan:
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("WebSocket-Fehler: %v", err)
}
return
}
}
}
func (c *Client) readMessages(conn *websocket.Conn, msgChan chan<- Message, errChan chan<- error) {
defer close(msgChan)
for {
messageType, data, err := conn.ReadMessage()
if err != nil {
errChan <- err
return
}
if messageType == websocket.BinaryMessage {
// Binary-Message für Tunnel-Daten verarbeiten
if err := c.tunnelManager.ProcessBinaryMessage(data); err != nil {
log.Printf("Binary-Message verarbeiten fehlgeschlagen: %v", err)
}
continue
}
// JSON-Message parsen
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
log.Printf("JSON-Message parsen fehlgeschlagen: %v", err)
continue
}
select {
case msgChan <- msg:
case <-c.ctx.Done():
return
}
}
}
func (c *Client) handleMessage(msg Message) {
switch msg.Type {
case "command":
c.messageHandler.HandleCommand(msg)
case "event":
// Events vom Backend verarbeiten (falls nötig)
log.Printf("Event erhalten: %s", msg.Event)
default:
log.Printf("Unbekannter Nachrichtentyp: %s", msg.Type)
}
}
func (c *Client) SendMessage(msg Message) error {
c.connMux.RLock()
conn := c.conn
c.connMux.RUnlock()
if conn == nil {
return fmt.Errorf("keine WebSocket-Verbindung")
}
return conn.WriteJSON(msg)
}
func (c *Client) SendBinaryData(data []byte) error {
c.connMux.RLock()
conn := c.conn
c.connMux.RUnlock()
if conn == nil {
return fmt.Errorf("keine WebSocket-Verbindung")
}
return conn.WriteMessage(websocket.BinaryMessage, data)
}
func (c *Client) IsConnected() bool {
c.connMux.RLock()
connected := c.conn != nil
c.connMux.RUnlock()
return connected
}