Initial commit: RMM Agent + Backend

- Go Agent (FreeBSD/amd64) fuer OPNsense
- Go Backend (Linux/amd64) mit REST API + SQLite
- Collector: Hardware, CPU, Memory, Disks, Network, Services,
  WireGuard, DHCP (KEA/ISC/dnsmasq), Routes, Gateways,
  Certificates, Plugins, Updates
- Persistente Agent-ID
- TLS + API-Key Auth
- WebSocket-Infrastruktur (WIP): bidirektionaler Command-Kanal,
  TCP-Tunnel fuer Remote-Zugriff auf Firewall-WebUI
- Lastenheft und README
This commit is contained in:
cynfo3000
2026-02-28 07:38:14 +01:00
commit 6120d0cffa
35 changed files with 3992 additions and 0 deletions
+277
View File
@@ -0,0 +1,277 @@
package ws
import (
"context"
"crypto/tls"
"fmt"
"log"
"net/http"
"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 {
var msg Message
if err := conn.ReadJSON(&msg); err != nil {
errChan <- err
return
}
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
}
+214
View File
@@ -0,0 +1,214 @@
package ws
import (
"context"
"fmt"
"log"
"os/exec"
"strconv"
"strings"
"time"
)
type Handler struct {
client *Client
}
func NewHandler(client *Client) *Handler {
return &Handler{client: client}
}
func (h *Handler) HandleCommand(msg Message) {
log.Printf("Command erhalten: %s (ID: %s)", msg.Command, msg.ID)
var response Message
response.Type = "response"
response.ID = msg.ID
switch msg.Command {
case "exec":
response = h.handleExec(msg)
case "tunnel_open":
response = h.handleTunnelOpen(msg)
case "tunnel_close":
response = h.handleTunnelClose(msg)
case "tunnel_data":
// Tunnel-Daten werden als Binary-Messages gesendet, nicht als JSON
h.handleTunnelData(msg)
return
default:
response.Status = "error"
response.Error = fmt.Sprintf("Unbekanntes Command: %s", msg.Command)
}
if err := h.client.SendMessage(response); err != nil {
log.Printf("Response senden fehlgeschlagen: %v", err)
}
}
func (h *Handler) handleExec(msg Message) Message {
response := Message{
Type: "response",
ID: msg.ID,
}
// Parameter extrahieren
command, ok := msg.Params["command"].(string)
if !ok || command == "" {
response.Status = "error"
response.Error = "Parameter 'command' erforderlich"
return response
}
timeoutSec := 30 // Default
if t, ok := msg.Params["timeout"]; ok {
if ts, ok := t.(float64); ok {
timeoutSec = int(ts)
} else if ts, ok := t.(string); ok {
if parsed, err := strconv.Atoi(ts); err == nil {
timeoutSec = parsed
}
}
}
// Command ausführen mit Timeout
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
defer cancel()
// FreeBSD/OPNsense nutzt /bin/sh
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
output, err := cmd.CombinedOutput()
if err != nil {
response.Status = "error"
response.Error = err.Error()
response.Data = map[string]interface{}{
"output": string(output),
}
} else {
response.Status = "ok"
response.Data = map[string]interface{}{
"output": string(output),
}
}
return response
}
func (h *Handler) handleTunnelOpen(msg Message) Message {
response := Message{
Type: "response",
ID: msg.ID,
}
// Parameter extrahieren
targetHost, ok := msg.Params["target_host"].(string)
if !ok || targetHost == "" {
response.Status = "error"
response.Error = "Parameter 'target_host' erforderlich"
return response
}
targetPortFloat, ok := msg.Params["target_port"].(float64)
if !ok {
response.Status = "error"
response.Error = "Parameter 'target_port' erforderlich"
return response
}
targetPort := int(targetPortFloat)
// Tunnel öffnen
tunnelID, err := h.client.tunnelManager.OpenTunnel(targetHost, targetPort)
if err != nil {
response.Status = "error"
response.Error = fmt.Sprintf("Tunnel öffnen fehlgeschlagen: %v", err)
return response
}
response.Status = "ok"
response.Data = map[string]interface{}{
"tunnel_id": tunnelID,
}
log.Printf("Tunnel geöffnet: %s -> %s:%d", tunnelID, targetHost, targetPort)
return response
}
func (h *Handler) handleTunnelClose(msg Message) Message {
response := Message{
Type: "response",
ID: msg.ID,
}
// Tunnel-ID extrahieren
tunnelID, ok := msg.Params["tunnel_id"].(string)
if !ok || tunnelID == "" {
response.Status = "error"
response.Error = "Parameter 'tunnel_id' erforderlich"
return response
}
// Tunnel schließen
if err := h.client.tunnelManager.CloseTunnel(tunnelID); err != nil {
response.Status = "error"
response.Error = fmt.Sprintf("Tunnel schließen fehlgeschlagen: %v", err)
return response
}
response.Status = "ok"
log.Printf("Tunnel geschlossen: %s", tunnelID)
return response
}
func (h *Handler) handleTunnelData(msg Message) {
// Diese Funktion wird für JSON-basierte Tunnel-Daten verwendet
// In der Praxis verwenden wir Binary WebSocket Messages für bessere Performance
tunnelID, ok := msg.Params["tunnel_id"].(string)
if !ok {
log.Printf("Tunnel-Daten ohne tunnel_id erhalten")
return
}
dataStr, ok := msg.Params["data"].(string)
if !ok {
log.Printf("Tunnel-Daten ohne data erhalten")
return
}
// Base64-Dekodierung würde hier stattfinden
// Aber wir verwenden Binary Messages, daher ist das nur ein Fallback
log.Printf("Tunnel-Daten via JSON erhalten für %s (Länge: %d)", tunnelID, len(dataStr))
}
// SendTunnelData sendet Tunnel-Daten als Binary-Message zurück zum Backend
func (h *Handler) SendTunnelData(tunnelID string, data []byte) error {
// Format: [tunnel_id_length:1][tunnel_id][data]
tunnelIDBytes := []byte(tunnelID)
if len(tunnelIDBytes) > 255 {
return fmt.Errorf("tunnel_id zu lang")
}
message := make([]byte, 1+len(tunnelIDBytes)+len(data))
message[0] = byte(len(tunnelIDBytes))
copy(message[1:], tunnelIDBytes)
copy(message[1+len(tunnelIDBytes):], data)
return h.client.SendBinaryData(message)
}
// ParseTunnelData parst eingehende Binary-Messages mit Tunnel-Daten
func ParseTunnelData(data []byte) (tunnelID string, payload []byte, err error) {
if len(data) < 1 {
return "", nil, fmt.Errorf("Daten zu kurz")
}
tunnelIDLen := int(data[0])
if len(data) < 1+tunnelIDLen {
return "", nil, fmt.Errorf("Tunnel-ID zu kurz")
}
tunnelID = string(data[1 : 1+tunnelIDLen])
payload = data[1+tunnelIDLen:]
return tunnelID, payload, nil
}
+207
View File
@@ -0,0 +1,207 @@
package ws
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log"
"net"
"sync"
"time"
)
type Tunnel struct {
ID string
TargetHost string
TargetPort int
Conn net.Conn
Active bool
CreatedAt time.Time
}
type TunnelManager struct {
client *Client
tunnels map[string]*Tunnel
mutex sync.RWMutex
}
func NewTunnelManager(client *Client) *TunnelManager {
return &TunnelManager{
client: client,
tunnels: make(map[string]*Tunnel),
}
}
func (tm *TunnelManager) OpenTunnel(targetHost string, targetPort int) (string, error) {
// Eindeutige Tunnel-ID generieren
tunnelID := tm.generateTunnelID()
// Verbindung zum Ziel aufbauen
target := fmt.Sprintf("%s:%d", targetHost, targetPort)
conn, err := net.DialTimeout("tcp", target, 10*time.Second)
if err != nil {
return "", fmt.Errorf("Verbindung zu %s fehlgeschlagen: %w", target, err)
}
// Tunnel erstellen
tunnel := &Tunnel{
ID: tunnelID,
TargetHost: targetHost,
TargetPort: targetPort,
Conn: conn,
Active: true,
CreatedAt: time.Now(),
}
// Tunnel registrieren
tm.mutex.Lock()
tm.tunnels[tunnelID] = tunnel
tm.mutex.Unlock()
// Data-Forwarding starten
go tm.forwardData(tunnel)
return tunnelID, nil
}
func (tm *TunnelManager) CloseTunnel(tunnelID string) error {
tm.mutex.Lock()
tunnel, exists := tm.tunnels[tunnelID]
if !exists {
tm.mutex.Unlock()
return fmt.Errorf("Tunnel %s nicht gefunden", tunnelID)
}
tunnel.Active = false
delete(tm.tunnels, tunnelID)
tm.mutex.Unlock()
// Verbindung schließen
if tunnel.Conn != nil {
tunnel.Conn.Close()
}
return nil
}
func (tm *TunnelManager) GetActiveTunnels() []string {
tm.mutex.RLock()
defer tm.mutex.RUnlock()
tunnels := make([]string, 0, len(tm.tunnels))
for id := range tm.tunnels {
tunnels = append(tunnels, id)
}
return tunnels
}
func (tm *TunnelManager) SendDataToTunnel(tunnelID string, data []byte) error {
tm.mutex.RLock()
tunnel, exists := tm.tunnels[tunnelID]
tm.mutex.RUnlock()
if !exists || !tunnel.Active {
return fmt.Errorf("Tunnel %s nicht aktiv", tunnelID)
}
if tunnel.Conn == nil {
return fmt.Errorf("Tunnel %s hat keine Verbindung", tunnelID)
}
_, err := tunnel.Conn.Write(data)
if err != nil {
// Bei Fehler Tunnel schließen
tm.CloseTunnel(tunnelID)
return fmt.Errorf("Daten schreiben fehlgeschlagen: %w", err)
}
return nil
}
func (tm *TunnelManager) forwardData(tunnel *Tunnel) {
defer func() {
if tunnel.Active {
tm.CloseTunnel(tunnel.ID)
}
// Event senden: Tunnel geschlossen
event := Message{
Type: "event",
Event: "tunnel_closed",
Data: map[string]interface{}{
"tunnel_id": tunnel.ID,
},
}
tm.client.SendMessage(event)
log.Printf("Tunnel %s: Data-Forwarding beendet", tunnel.ID)
}()
buffer := make([]byte, 32768) // 32KB Buffer
for tunnel.Active {
// Read-Timeout setzen um regelmäßig Active-Status zu prüfen
tunnel.Conn.SetReadDeadline(time.Now().Add(5 * time.Second))
n, err := tunnel.Conn.Read(buffer)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
// Timeout ist OK, einfach weiter versuchen
continue
}
if err == io.EOF {
log.Printf("Tunnel %s: Ziel-Verbindung geschlossen", tunnel.ID)
} else {
log.Printf("Tunnel %s: Read-Fehler: %v", tunnel.ID, err)
}
break
}
if n > 0 {
// Daten über WebSocket an Backend weiterleiten
data := buffer[:n]
if err := tm.client.messageHandler.SendTunnelData(tunnel.ID, data); err != nil {
log.Printf("Tunnel %s: WebSocket senden fehlgeschlagen: %v", tunnel.ID, err)
break
}
}
}
}
func (tm *TunnelManager) generateTunnelID() string {
bytes := make([]byte, 8)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
// ProcessBinaryMessage verarbeitet eingehende Binary-Messages vom Backend
func (tm *TunnelManager) ProcessBinaryMessage(data []byte) error {
tunnelID, payload, err := ParseTunnelData(data)
if err != nil {
return fmt.Errorf("Binary-Message parsen: %w", err)
}
return tm.SendDataToTunnel(tunnelID, payload)
}
// CleanupInactiveTunnels schließt inaktive Tunnel (Cleanup-Task)
func (tm *TunnelManager) CleanupInactiveTunnels() {
tm.mutex.Lock()
defer tm.mutex.Unlock()
now := time.Now()
for id, tunnel := range tm.tunnels {
// Tunnel die länger als 1 Stunde inaktiv sind, schließen
if !tunnel.Active || now.Sub(tunnel.CreatedAt) > time.Hour {
tunnel.Active = false
if tunnel.Conn != nil {
tunnel.Conn.Close()
}
delete(tm.tunnels, id)
log.Printf("Inaktiver Tunnel %s bereinigt", id)
}
}
}