From 6120d0cffa940dfb7b2e0801044522b4f3872e39 Mon Sep 17 00:00:00 2001 From: cynfo3000 Date: Sat, 28 Feb 2026 07:38:14 +0100 Subject: [PATCH] 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 --- .gitignore | 7 + Lastenheft.md | 221 ++++++++++++++++++++++ Makefile | 85 +++++++++ README.md | 322 ++++++++++++++++++++++++++++++++ agent/client/client.go | 95 ++++++++++ agent/collector/certificates.go | 203 ++++++++++++++++++++ agent/collector/cpu.go | 109 +++++++++++ agent/collector/dhcp.go | 207 ++++++++++++++++++++ agent/collector/disk.go | 62 ++++++ agent/collector/gateways.go | 68 +++++++ agent/collector/hardware.go | 55 ++++++ agent/collector/memory.go | 42 +++++ agent/collector/network.go | 178 ++++++++++++++++++ agent/collector/opnsense.go | 61 ++++++ agent/collector/plugins.go | 64 +++++++ agent/collector/routing.go | 61 ++++++ agent/collector/services.go | 97 ++++++++++ agent/collector/updates.go | 88 +++++++++ agent/collector/wireguard.go | 133 +++++++++++++ agent/config/config.go | 33 ++++ agent/go.mod | 7 + agent/go.sum | 6 + agent/main.go | 174 +++++++++++++++++ agent/ws/client.go | 277 +++++++++++++++++++++++++++ agent/ws/handler.go | 214 +++++++++++++++++++++ agent/ws/tunnel.go | 207 ++++++++++++++++++++ backend/api/handlers.go | 204 ++++++++++++++++++++ backend/api/middleware.go | 33 ++++ backend/config/config.go | 49 +++++ backend/db/sqlite.go | 235 +++++++++++++++++++++++ backend/go.mod | 25 +++ backend/go.sum | 45 +++++ backend/main.go | 132 +++++++++++++ backend/models/agent.go | 29 +++ backend/models/system.go | 164 ++++++++++++++++ 35 files changed, 3992 insertions(+) create mode 100644 .gitignore create mode 100644 Lastenheft.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 agent/client/client.go create mode 100644 agent/collector/certificates.go create mode 100644 agent/collector/cpu.go create mode 100644 agent/collector/dhcp.go create mode 100644 agent/collector/disk.go create mode 100644 agent/collector/gateways.go create mode 100644 agent/collector/hardware.go create mode 100644 agent/collector/memory.go create mode 100644 agent/collector/network.go create mode 100644 agent/collector/opnsense.go create mode 100644 agent/collector/plugins.go create mode 100644 agent/collector/routing.go create mode 100644 agent/collector/services.go create mode 100644 agent/collector/updates.go create mode 100644 agent/collector/wireguard.go create mode 100644 agent/config/config.go create mode 100644 agent/go.mod create mode 100644 agent/go.sum create mode 100644 agent/main.go create mode 100644 agent/ws/client.go create mode 100644 agent/ws/handler.go create mode 100644 agent/ws/tunnel.go create mode 100644 backend/api/handlers.go create mode 100644 backend/api/middleware.go create mode 100644 backend/config/config.go create mode 100644 backend/db/sqlite.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/main.go create mode 100644 backend/models/agent.go create mode 100644 backend/models/system.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..698f0d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +build/ +*.db +*.log +*.pid +config.yaml +beispielbilder/ +certs/ diff --git a/Lastenheft.md b/Lastenheft.md new file mode 100644 index 0000000..53b39cb --- /dev/null +++ b/Lastenheft.md @@ -0,0 +1,221 @@ +# Lastenheft – OPNsense Zentralmanagement Agent + +## 1. Zielsetzung + +Ziel dieses Projektes ist die Entwicklung eines **OPNsense Zentralmanagement-Agents**, der es einer externen Management‑ und Monitoring‑Plattform ermöglicht, weitreichende Systeminformationen auszulesen sowie definierte Systemaktionen und Änderungen auf OPNsense‑Firewalls durchzuführen. + +Da die bestehende OPNsense‑API für den vorgesehenen Funktionsumfang nicht ausreichend ist, soll ein **Agent-basiertes Architekturmodell** umgesetzt werden. Der Agent stellt eine sichere, persistente Verbindung zu einem zentralen Backend her und fungiert als Kontroll‑, Informations‑ und Tunnel‑Endpunkt. + +Fur das frontent orientiere dich an dem vorschalg der Beispielbilder + +## 2. Ausgangssituation + +* Mehrere OPNsense‑Firewalls (verschiedene Versionen, Standorte, Kunden) +* Zentrale Verwaltungsoberfläche (ähnlich den gezeigten UI‑Mockups) +* Bestehende OPNsense‑API liefert nicht alle benötigten Informationen +* Bedarf an: + + * System‑Transparenz + * Fernzugriff (SSH / WebUI) + * Zentralem Backup‑ und Zertifikatsmanagement + +## 3. Gesamtarchitektur + +### 3.1 Komponenten + +#### 3.1.1 OPNsense Agent (pro Firewall) + +* Läuft lokal auf der OPNsense (Plugin oder Service) +* Baut **initiativ** eine Verbindung zum Backend auf (Outbound only) +* Liefert Systemdaten +* Führt Kommandos und Aktionen kontrolliert aus + +#### 3.1.2 Backend Server (Control Plane) + +* Zentrale Kommunikations‑ und Steuerinstanz +* Hält persistente Agent‑Verbindungen +* Stellt APIs für das Frontend bereit +* Baut Tunnel (SSH / HTTPS) zu den Firewalls über den Agent auf + +#### 3.1.3 Frontend Server (UI / Webinterface) + +* Web‑UI für Administratoren +* Darstellung der Firewalls (Übersichten, Details, Status) +* Steuerung von Aktionen (Tunnel, Backups, Reloads) + +## 4. Funktionale Anforderungen – Agent + +### 4.1 Hardware‑ und Systeminformationen + +Der Agent muss folgende Informationen bereitstellen: + +* Hersteller +* Modell +* Seriennummer +* BIOS‑Version +* CPU (Typ, Kerne, Threads, Takt) +* RAM (gesamt, frei) +* Massenspeicher (Typ, Kapazität, Belegung) +* OPNsense‑Version +* Uptime + +### 4.2 Dienste und Status + +Der Agent muss: + +* Alle Systemdienste auflisten +* Status je Dienst liefern (running / stopped) +* Dienst neu starten / stoppen / starten können (optional konfigurierbar) + +Beispiele: + +* configd +* dhcpd / kea +* unbound +* openvpn +* wireguard +* cron + +### 4.3 Netzwerk‑Interfaces + +Der Agent muss: + +* Alle Interfaces anzeigen +* Rolle (WAN / LAN / OPT) +* IP‑Adresse(n) +* MAC‑Adresse +* RX/TX‑Traffic +* Status (up/down) + +### 4.4 VPN‑Informationen + +#### 4.4.1 WireGuard + +* Tunnelname +* Peers +* Status (up/down) +* Transferstatistiken + +#### 4.4.2 OpenVPN + +* Server / Client‑Instanzen +* Verbindungsstatus +* Road‑Warrior‑Sessions + +### 4.5 Routing + +* Anzeige der Routing‑Tabelle +* Zielnetz +* Gateway +* Interface +* Typ (statisch / dynamisch) + +### 4.6 DHCP (KEA) + +* Aktive DHCP‑Instanzen +* Pools +* Leases (IPv4 / IPv6) +* Lease‑Status +* Zuordnung MAC ↔ IP + +### 4.7 Zertifikate + +* Auflistung aller Zertifikate +* Typ (CA, Server, User, ACME) +* Gültigkeitszeitraum +* Ablaufwarnungen +* Zertifikat neu laden / erneuern (optional) + +### 4.8 Backups + +Der Agent muss: + +* Konfigurations‑Backups erstellen können +* Backup lokal zwischenspeichern +* Backup an Backend übertragen +* Metadaten liefern (Größe, Zeitstempel) + +## 5. Tunnel‑Funktionalität + +### 5.1 Ziel + +Das Backend soll on‑demand Tunnel zu einer Firewall aufbauen können, ohne dass direkte eingehende Verbindungen zur Firewall notwendig sind. + +### 5.2 Tunnelarten + +* SSH‑Tunnel +* HTTPS‑Tunnel (WebUI) + +### 5.3 Funktionsweise + +* Agent hält persistente Verbindung zum Backend +* Backend fordert Tunnel an +* Agent öffnet lokalen Zielport (z. B. 127.0.0.1:22 oder 443) +* Agent erstellt Reverse‑Tunnel +* Backend stellt öffentlichen / internen Zugriffspunkt bereit + +### 5.4 Anforderungen an Tunnel + +* Vergleichbar mit **stunnel** +* Konfigurierbar: + + * Zielport + * Protokoll + * Laufzeit + * Zugriffsbeschränkung +* Frontend zeigt klickbaren Link zum Tunnel + +## 6. Sicherheit & Compliance + +### 6.1 Kommunikation + +* TLS‑verschlüsselt (mTLS bevorzugt) +* Agent authentifiziert sich eindeutig +* Zertifikatsbasierte Identität + +### 6.2 Rechte & Rollen + +* Agent darf nur explizit erlaubte Aktionen ausführen +* Rollenmodell im Backend (Admin, Read‑Only, Operator) + +### 6.3 Logging & Audit + +* Alle Aktionen werden protokolliert +* Tunnel‑Nutzung wird geloggt +* Konfigurationsänderungen nachvollziehbar + +## 7. Nicht‑funktionale Anforderungen + +### 7.1 Performance + +* Geringe Last auf OPNsense +* Asynchrone Abfragen + +### 7.2 Stabilität + +* Agent überlebt OPNsense‑Updates +* Automatische Reconnect‑Logik + +### 7.3 Skalierbarkeit + +* Unterstützung für viele hundert Firewalls +* Mandantenfähigkeit (Kunden / Standorte) + +## 8. Abgrenzung + +Nicht Bestandteil dieses Lastenhefts: + +* Vollständige Firewall‑Rule‑Bearbeitung +* Deep Packet Inspection +* IDS/IPS‑Regelverwaltung + +## 9. Abnahmekriterien + +* Alle genannten Informationspunkte sind im Frontend sichtbar +* Tunnel können on‑demand aufgebaut und beendet werden +* Backups sind abrufbar und wiederherstellbar +* Agent funktioniert mit mehreren OPNsense‑Versionen + +--- + +*Dieses Lastenheft basiert auf den bereitgestellten Screenshots, der beschriebenen Zielarchitektur und den funktionalen Anforderungen des Auftraggebers.* diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e627976 --- /dev/null +++ b/Makefile @@ -0,0 +1,85 @@ +.PHONY: all backend agent clean certs deploy-backend deploy-agent + +BACKEND_BIN = build/rmm-backend +AGENT_BIN = build/rmm-agent + +BACKEND_HOST = 192.168.85.13 +AGENT_HOST = 192.168.85.33 +SSH_USER = root + +all: backend agent + +backend: + @echo "==> Building Backend (linux/amd64)..." + cd backend && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o ../$(BACKEND_BIN) . + @echo "==> $(BACKEND_BIN) erstellt" + +agent: + @echo "==> Building Agent (freebsd/amd64)..." + cd agent && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -o ../$(AGENT_BIN) . + @echo "==> $(AGENT_BIN) erstellt" + +clean: + rm -rf build/ + +certs: + @echo "==> Generiere self-signed TLS-Zertifikate..." + mkdir -p certs + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \ + -keyout certs/server.key -out certs/server.crt \ + -days 3650 -nodes \ + -subj "/CN=rmm-backend/O=RMM" \ + -addext "subjectAltName=IP:192.168.85.13,IP:127.0.0.1,DNS:localhost" + @echo "==> Zertifikate in certs/ erstellt" + +deploy-backend: backend + @echo "==> Deploying Backend to $(BACKEND_HOST)..." + ssh $(SSH_USER)@$(BACKEND_HOST) "mkdir -p /opt/rmm/certs" + scp $(BACKEND_BIN) $(SSH_USER)@$(BACKEND_HOST):/opt/rmm/rmm-backend + scp backend/config.yaml $(SSH_USER)@$(BACKEND_HOST):/opt/rmm/config.yaml + @echo "==> Erstelle systemd Service..." + ssh $(SSH_USER)@$(BACKEND_HOST) 'cat > /etc/systemd/system/rmm-backend.service << EOF\n\ +[Unit]\n\ +Description=RMM Backend\n\ +After=network.target\n\ +\n\ +[Service]\n\ +Type=simple\n\ +WorkingDirectory=/opt/rmm\n\ +ExecStart=/opt/rmm/rmm-backend /opt/rmm/config.yaml\n\ +Restart=always\n\ +RestartSec=5\n\ +\n\ +[Install]\n\ +WantedBy=multi-user.target\n\ +EOF' + ssh $(SSH_USER)@$(BACKEND_HOST) "systemctl daemon-reload && systemctl enable rmm-backend && systemctl restart rmm-backend" + @echo "==> Backend deployed und gestartet" + +deploy-agent: agent + @echo "==> Deploying Agent to $(AGENT_HOST)..." + ssh $(SSH_USER)@$(AGENT_HOST) "mkdir -p /opt/rmm" + scp $(AGENT_BIN) $(SSH_USER)@$(AGENT_HOST):/opt/rmm/rmm-agent + scp agent/config.yaml $(SSH_USER)@$(AGENT_HOST):/opt/rmm/config.yaml + @echo "==> Erstelle rc.d Script..." + ssh $(SSH_USER)@$(AGENT_HOST) 'cat > /usr/local/etc/rc.d/rmm_agent << RCEOF\n\ +#!/bin/sh\n\ +#\n\ +# PROVIDE: rmm_agent\n\ +# REQUIRE: NETWORKING\n\ +# KEYWORD: shutdown\n\ +\n\ +. /etc/rc.subr\n\ +\n\ +name="rmm_agent"\n\ +rcvar="rmm_agent_enable"\n\ +pidfile="/var/run/$${name}.pid"\n\ +command="/opt/rmm/rmm-agent"\n\ +command_args="--config /opt/rmm/config.yaml --insecure &"\n\ +\n\ +load_rc_config $$name\n\ +run_rc_command "$$1"\n\ +RCEOF\n\ +chmod +x /usr/local/etc/rc.d/rmm_agent' + ssh $(SSH_USER)@$(AGENT_HOST) 'sysrc rmm_agent_enable="YES" && service rmm_agent restart' + @echo "==> Agent deployed und gestartet" diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2af5d9 --- /dev/null +++ b/README.md @@ -0,0 +1,322 @@ +# OPNsense RMM System + +Remote Monitoring & Management System fuer OPNsense Firewalls. + +## Ueberblick + +Dieses System besteht aus zwei Komponenten: + +- **Backend** (Go) - REST API Server mit TLS und SQLite-Datenbank, laeuft auf einem Linux-Server +- **Agent** (Go) - Laeuft auf OPNsense/FreeBSD, sammelt Systemdaten und sendet sie an das Backend + +## Architektur + +``` +┌──────────────────┐ HTTPS (TLS) ┌──────────────────┐ +│ OPNsense FW │ ────────────────────────► │ RMM Backend │ +│ (FreeBSD) │ POST /agent/register │ (Linux) │ +│ │ POST /agent/heartbeat │ │ +│ rmm-agent │ │ REST API │ +│ - Hardware │ │ SQLite DB │ +│ - CPU/RAM │ │ :8443 │ +│ - Disk/Net │ │ │ +│ - Services │ │ 192.168.85.13 │ +│ │ │ │ +│ 192.168.85.33 │ │ │ +└──────────────────┘ └──────────────────┘ +``` + +## Voraussetzungen + +- Go 1.22+ +- make +- OpenSSL (fuer manuelle Zertifikat-Generierung) +- SSH-Zugang zu den Zielservern + +## Build + +```bash +# Beide Komponenten bauen +make all + +# Nur Backend (linux/amd64) +make backend + +# Nur Agent (freebsd/amd64) +make agent + +# TLS-Zertifikate generieren (optional, Backend generiert automatisch) +make certs +``` + +Die Binaries landen in `build/`. + +## Konfiguration + +### Backend (`backend/config.yaml`) + +```yaml +listen_addr: ":8443" +tls_cert: "certs/server.crt" +tls_key: "certs/server.key" +db_path: "rmm.db" +api_keys: + - "01532e5a7c9e70bf2757df77a2f5b9b9" +``` + +| Parameter | Env-Variable | Beschreibung | +|-------------|------------------|-------------------------------------| +| listen_addr | RMM_LISTEN_ADDR | Bind-Adresse (default: `:8443`) | +| tls_cert | RMM_TLS_CERT | Pfad zum TLS-Zertifikat | +| tls_key | RMM_TLS_KEY | Pfad zum TLS-Key | +| db_path | RMM_DB_PATH | Pfad zur SQLite-Datenbank | +| api_keys | - | Liste erlaubter API-Keys | + +Das Backend generiert automatisch self-signed TLS-Zertifikate wenn keine vorhanden sind. + +### Agent (`agent/config.yaml`) + +```yaml +backend_url: "https://192.168.85.13:8443" +api_key: "01532e5a7c9e70bf2757df77a2f5b9b9" +interval_seconds: 60 +agent_name: "opnsense-fw01" +insecure: true +``` + +| Parameter | Beschreibung | +|-----------------|-------------------------------------------------| +| backend_url | URL des Backend-Servers | +| api_key | API-Key fuer Authentifizierung | +| interval_seconds | Heartbeat-Intervall in Sekunden (default: 60) | +| agent_name | Name des Agents | +| insecure | TLS-Zertifikatpruefung ueberspringen | + +## Deployment + +### Backend (192.168.85.13) + +```bash +make deploy-backend +``` + +Dies: +1. Baut das Backend fuer linux/amd64 +2. Kopiert Binary + Config nach `/opt/rmm/` +3. Erstellt einen systemd-Service +4. Startet den Service + +### Agent (192.168.85.33 / OPNsense) + +```bash +make deploy-agent +``` + +Dies: +1. Baut den Agent fuer freebsd/amd64 +2. Kopiert Binary + Config nach `/opt/rmm/` +3. Erstellt ein rc.d-Script +4. Aktiviert und startet den Service + +## API-Dokumentation + +Alle Endpoints erfordern den Header `X-API-Key` mit einem gueltigen API-Key. + +### POST /api/v1/agent/register + +Agent registrieren. + +**Request:** +```json +{ + "name": "opnsense-fw01", + "hostname": "fw01.local", + "ip": "192.168.85.33", + "opnsense_version": "OPNsense 24.1.1" +} +``` + +**Response (201):** +```json +{ + "id": "a1b2c3d4e5f6...", + "message": "Agent erfolgreich registriert" +} +``` + +### POST /api/v1/agent/heartbeat + +Systemdaten senden. + +**Request:** +```json +{ + "agent_id": "a1b2c3d4e5f6...", + "system_data": { + "hostname": "fw01.local", + "opnsense_version": "OPNsense 24.1.1", + "freebsd_version": "14.0-RELEASE-p5", + "uptime_seconds": 86400, + "hardware": { + "manufacturer": "Protectli", + "model": "VP2420", + "serial": "ABC123", + "bios_version": "1.0.0" + }, + "cpu": { + "model": "Intel(R) Celeron(R) J6412 @ 2.00GHz", + "cores": 4, + "threads": 4, + "freq_mhz": 2000, + "usage_percent": 12.5 + }, + "memory": { + "total_bytes": 8589934592, + "used_bytes": 2147483648, + "free_bytes": 6442450944 + }, + "disks": [ + { + "filesystem": "/dev/ada0p3", + "total_bytes": 107374182400, + "used_bytes": 5368709120, + "free_bytes": 93736247296, + "mount_point": "/" + } + ], + "network_interfaces": [ + { + "name": "igb0", + "role": "WAN", + "ip": "203.0.113.1", + "mac": "00:11:22:33:44:55", + "status": "up", + "rx_bytes": 1073741824, + "tx_bytes": 536870912 + }, + { + "name": "igb1", + "role": "LAN", + "ip": "192.168.1.1", + "mac": "00:11:22:33:44:56", + "status": "up", + "rx_bytes": 2147483648, + "tx_bytes": 1073741824 + } + ], + "services": [ + { + "name": "configd", + "description": "", + "status": "running" + }, + { + "name": "dhcpd", + "description": "", + "status": "running" + }, + { + "name": "unbound", + "description": "", + "status": "running" + } + ] + } +} +``` + +**Response (200):** +```json +{ + "message": "OK" +} +``` + +### GET /api/v1/agents + +Alle registrierten Agents auflisten. + +**Response (200):** +```json +[ + { + "id": "a1b2c3d4e5f6...", + "name": "opnsense-fw01", + "hostname": "fw01.local", + "ip": "192.168.85.33", + "opnsense_version": "OPNsense 24.1.1", + "registered_at": "2024-01-28T10:00:00Z", + "last_heartbeat": "2024-01-28T12:30:00Z" + } +] +``` + +### GET /api/v1/agents/{id} + +Agent-Details mit letzten Systemdaten. + +**Response (200):** +```json +{ + "agent": { + "id": "a1b2c3d4e5f6...", + "name": "opnsense-fw01", + "hostname": "fw01.local", + "ip": "192.168.85.33", + "opnsense_version": "OPNsense 24.1.1", + "registered_at": "2024-01-28T10:00:00Z", + "last_heartbeat": "2024-01-28T12:30:00Z" + }, + "system_data": { ... } +} +``` + +### GET /api/v1/agents/{id}/system + +Nur die aktuellen Systemdaten eines Agents. + +**Response (200):** +```json +{ + "hostname": "fw01.local", + "opnsense_version": "OPNsense 24.1.1", + "uptime_seconds": 86400, + "hardware": { ... }, + "cpu": { ... }, + "memory": { ... }, + "disks": [ ... ], + "network_interfaces": [ ... ], + "services": [ ... ] +} +``` + +### DELETE /api/v1/agents/{id} + +Agent entfernen. + +**Response (200):** +```json +{ + "message": "Agent geloescht" +} +``` + +### Fehlermeldungen + +```json +{ + "error": "unauthorized" +} +``` + +HTTP Status Codes: +- `200` - Erfolg +- `201` - Erstellt +- `400` - Ungueltige Anfrage +- `401` - Nicht autorisiert (fehlender/falscher API-Key) +- `404` - Nicht gefunden +- `500` - Server-Fehler + +## Lizenz + +Intern. diff --git a/agent/client/client.go b/agent/client/client.go new file mode 100644 index 0000000..bcf4a8d --- /dev/null +++ b/agent/client/client.go @@ -0,0 +1,95 @@ +package client + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type Client struct { + baseURL string + apiKey string + httpClient *http.Client +} + +func New(baseURL, apiKey string, insecure bool) *Client { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: insecure, + }, + } + + return &Client{ + baseURL: baseURL, + apiKey: apiKey, + httpClient: &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + }, + } +} + +// Register - Agent am Backend registrieren, gibt Agent-ID zurueck +func (c *Client) Register(req interface{}) (string, error) { + body, err := json.Marshal(req) + if err != nil { + return "", err + } + + resp, err := c.doRequest("POST", "/api/v1/agent/register", body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("Registrierung fehlgeschlagen (HTTP %d): %s", resp.StatusCode, string(respBody)) + } + + var result struct { + ID string `json:"id"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return "", fmt.Errorf("Response parsen: %w", err) + } + + return result.ID, nil +} + +// Heartbeat - Systemdaten an Backend senden +func (c *Client) Heartbeat(req interface{}) error { + body, err := json.Marshal(req) + if err != nil { + return err + } + + resp, err := c.doRequest("POST", "/api/v1/agent/heartbeat", body) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Heartbeat fehlgeschlagen (HTTP %d): %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +func (c *Client) doRequest(method, path string, body []byte) (*http.Response, error) { + req, err := http.NewRequest(method, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", c.apiKey) + + return c.httpClient.Do(req) +} diff --git a/agent/collector/certificates.go b/agent/collector/certificates.go new file mode 100644 index 0000000..6cb82a7 --- /dev/null +++ b/agent/collector/certificates.go @@ -0,0 +1,203 @@ +package collector + +import ( + "crypto/x509" + "encoding/pem" + "os/exec" + "path/filepath" + "strings" + "time" +) + +type Certificate struct { + Name string `json:"name"` + Subject string `json:"subject"` + Issuer string `json:"issuer"` + NotBefore string `json:"not_before"` + NotAfter string `json:"not_after"` + DaysLeft int `json:"days_left"` + Serial string `json:"serial"` + SAN string `json:"san,omitempty"` + KeyUsage string `json:"key_usage,omitempty"` + IsCA bool `json:"is_ca"` + Status string `json:"status"` // "valid", "expiring", "expired" + Source string `json:"source"` // "opnsense", "file" +} + +// CollectCertificates liest Zertifikate aus OPNsense config.xml und gaengigen Pfaden +func CollectCertificates() []Certificate { + var certs []Certificate + + // 1. OPNsense config.xml Zertifikate + certs = append(certs, collectOPNsenseCerts()...) + + // 2. Zertifikate aus /var/etc/cert/ (aktiv genutzte) + certs = append(certs, collectCertDir("/var/etc/cert/")...) + + return certs +} + +func collectOPNsenseCerts() []Certificate { + // configctl: Zertifikate als Liste ausgeben + // Alternativ: direkt config.xml parsen + out, err := exec.Command("grep", "-A2", "", "/conf/config.xml").Output() + if err != nil || len(out) == 0 { + return collectFromConfigXML() + } + return collectFromConfigXML() +} + +func collectFromConfigXML() []Certificate { + // PHP-basiertes Auslesen der Zertifikate (OPNsense hat php verfuegbar) + // Skript via stdin uebergeben, da -r bei langen Strings Probleme macht + script := ` 0 { + info.Cores = v + } else { + info.Cores = info.Threads + } + + // Frequenz in MHz + if v, err := strconv.Atoi(sysctlGet("dev.cpu.0.freq")); err == nil { + info.FreqMHz = v + } + + // CPU-Auslastung via kern.cp_time (zwei Messungen im Abstand von 1 Sekunde) + info.UsagePercent = measureCPUUsage() + + return info +} + +func sysctlGet(key string) string { + out, err := exec.Command("sysctl", "-n", key).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// measureCPUUsage misst die CPU-Auslastung ueber kern.cp_time +// Format: user nice sys intr idle +func measureCPUUsage() float64 { + parse := func() (idle, total int64, ok bool) { + out := sysctlGet("kern.cp_time") + if out == "" { + return 0, 0, false + } + fields := strings.Fields(out) + if len(fields) < 5 { + return 0, 0, false + } + var vals [5]int64 + for i := 0; i < 5; i++ { + v, err := strconv.ParseInt(fields[i], 10, 64) + if err != nil { + return 0, 0, false + } + vals[i] = v + } + // user + nice + sys + intr + idle + total = vals[0] + vals[1] + vals[2] + vals[3] + vals[4] + idle = vals[4] + return idle, total, true + } + + idle1, total1, ok1 := parse() + if !ok1 { + return 0 + } + + time.Sleep(1 * time.Second) + + idle2, total2, ok2 := parse() + if !ok2 { + return 0 + } + + totalDelta := float64(total2 - total1) + if totalDelta == 0 { + return 0 + } + + idleDelta := float64(idle2 - idle1) + usage := (1.0 - idleDelta/totalDelta) * 100.0 + + if usage < 0 { + usage = 0 + } + if usage > 100 { + usage = 100 + } + + return usage +} diff --git a/agent/collector/dhcp.go b/agent/collector/dhcp.go new file mode 100644 index 0000000..dfaee33 --- /dev/null +++ b/agent/collector/dhcp.go @@ -0,0 +1,207 @@ +package collector + +import ( + "encoding/json" + "os/exec" + "strconv" + "strings" + "time" +) + +type DHCPLease struct { + IP string `json:"ip"` + MAC string `json:"mac"` + Hostname string `json:"hostname"` + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` + Status string `json:"status"` // "active", "free", "backup" + Pool string `json:"pool,omitempty"` +} + +type DHCPInfo struct { + Server string `json:"server"` // "kea", "isc-dhcpd", "dnsmasq", "none" + Leases []DHCPLease `json:"leases"` +} + +func CollectDHCP() DHCPInfo { + // 1. Versuch: KEA via configctl (OPNsense 24.7+) + if info := tryKEA(); info != nil { + return *info + } + + // 2. Versuch: ISC dhcpd Lease-Datei + if info := tryISCDhcpd(); info != nil { + return *info + } + + // 3. Versuch: dnsmasq Leases + if info := tryDnsmasqLeases(); info != nil { + return *info + } + + return DHCPInfo{Server: "none"} +} + +// tryKEA versucht KEA-Leases ueber configctl abzufragen +func tryKEA() *DHCPInfo { + out, err := exec.Command("/usr/local/sbin/configctl", "kea", "list", "leases4").Output() + if err != nil { + return nil + } + + // JSON-Antwort: {"records": [{"address": "...", "hwaddr": "...", "hostname": "...", "state": "0", "expire": "1772261642", ...}]} + var result struct { + Records []struct { + Address string `json:"address"` + HWAddr string `json:"hwaddr"` + HWAddress string `json:"hw_address"` // Fallback fuer aeltere KEA-Versionen + Hostname string `json:"hostname"` + State string `json:"state"` + SubnetID string `json:"subnet_id"` + Expire string `json:"expire"` + ClientID string `json:"client_id"` + } `json:"records"` + } + + if err := json.Unmarshal(out, &result); err != nil { + return nil + } + + info := &DHCPInfo{Server: "kea"} + for _, r := range result.Records { + status := "active" + if r.State == "1" { + status = "declined" + } else if r.State == "2" { + status = "expired" + } + + mac := r.HWAddr + if mac == "" { + mac = r.HWAddress + } + + // Expire-Timestamp in lesbares Datum umwandeln + expireStr := r.Expire + if ts, err := strconv.ParseInt(r.Expire, 10, 64); err == nil && ts > 0 { + expireStr = time.Unix(ts, 0).Format("2006-01-02 15:04:05") + } + + info.Leases = append(info.Leases, DHCPLease{ + IP: r.Address, + MAC: mac, + Hostname: r.Hostname, + End: expireStr, + Status: status, + }) + } + return info +} + +// tryISCDhcpd parst ISC dhcpd Lease-Dateien +func tryISCDhcpd() *DHCPInfo { + // Gaengige Pfade auf OPNsense/FreeBSD + paths := []string{ + "/var/dhcpd/var/db/dhcpd.leases", + "/var/db/dhcpd.leases", + } + + for _, path := range paths { + out, err := exec.Command("cat", path).Output() + if err != nil || len(out) == 0 { + continue + } + leases := parseISCLeases(string(out)) + if len(leases) > 0 { + return &DHCPInfo{Server: "isc-dhcpd", Leases: leases} + } + } + return nil +} + +// parseISCLeases parst das ISC dhcpd.leases Format +func parseISCLeases(content string) []DHCPLease { + var leases []DHCPLease + var current *DHCPLease + + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + + if strings.HasPrefix(line, "lease ") && strings.HasSuffix(line, "{") { + ip := strings.TrimSuffix(strings.TrimPrefix(line, "lease "), " {") + current = &DHCPLease{IP: strings.TrimSpace(ip), Status: "active"} + continue + } + + if current == nil { + continue + } + + if line == "}" { + leases = append(leases, *current) + current = nil + continue + } + + line = strings.TrimSuffix(line, ";") + + if strings.HasPrefix(line, "starts ") { + parts := strings.SplitN(line, " ", 3) + if len(parts) >= 3 { + current.Start = parts[2] + } + } else if strings.HasPrefix(line, "ends ") { + parts := strings.SplitN(line, " ", 3) + if len(parts) >= 3 { + current.End = parts[2] + } + } else if strings.HasPrefix(line, "hardware ethernet ") { + current.MAC = strings.TrimPrefix(line, "hardware ethernet ") + } else if strings.HasPrefix(line, "client-hostname ") { + current.Hostname = strings.Trim(strings.TrimPrefix(line, "client-hostname "), "\"") + } else if strings.HasPrefix(line, "binding state ") { + current.Status = strings.TrimPrefix(line, "binding state ") + } + } + + return leases +} + +// tryDnsmasqLeases sucht dnsmasq-Lease-Dateien +func tryDnsmasqLeases() *DHCPInfo { + paths := []string{ + "/var/db/dnsmasq.leases", + "/tmp/dnsmasq.leases", + "/var/run/dnsmasq.leases", + } + + for _, path := range paths { + out, err := exec.Command("cat", path).Output() + if err != nil || len(out) == 0 { + continue + } + leases := parseDnsmasqLeases(string(out)) + if len(leases) > 0 { + return &DHCPInfo{Server: "dnsmasq", Leases: leases} + } + } + return nil +} + +// parseDnsmasqLeases parst dnsmasq lease Format: timestamp mac ip hostname clientid +func parseDnsmasqLeases(content string) []DHCPLease { + var leases []DHCPLease + for _, line := range strings.Split(strings.TrimSpace(content), "\n") { + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + leases = append(leases, DHCPLease{ + MAC: fields[1], + IP: fields[2], + Hostname: fields[3], + Status: "active", + }) + } + return leases +} diff --git a/agent/collector/disk.go b/agent/collector/disk.go new file mode 100644 index 0000000..2929702 --- /dev/null +++ b/agent/collector/disk.go @@ -0,0 +1,62 @@ +package collector + +import ( + "os/exec" + "strconv" + "strings" +) + +type DiskInfo struct { + Filesystem string `json:"filesystem"` + TotalBytes int64 `json:"total_bytes"` + UsedBytes int64 `json:"used_bytes"` + FreeBytes int64 `json:"free_bytes"` + MountPoint string `json:"mount_point"` +} + +func CollectDisks() []DiskInfo { + // df -k (1K-Bloecke) fuer einfacheres Parsen + out, err := exec.Command("df", "-k", "-t", "ufs,zfs").Output() + if err != nil { + // Fallback ohne Typ-Filter + out, err = exec.Command("df", "-k").Output() + if err != nil { + return nil + } + } + + var disks []DiskInfo + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + + for i, line := range lines { + if i == 0 { + continue // Header ueberspringen + } + + fields := strings.Fields(line) + if len(fields) < 6 { + continue + } + + // Pseudo-Filesysteme ueberspringen + fs := fields[0] + mount := fields[len(fields)-1] + if strings.HasPrefix(fs, "devfs") || strings.HasPrefix(fs, "tmpfs") || fs == "fdescfs" || fs == "procfs" { + continue + } + + total, _ := strconv.ParseInt(fields[1], 10, 64) + used, _ := strconv.ParseInt(fields[2], 10, 64) + avail, _ := strconv.ParseInt(fields[3], 10, 64) + + disks = append(disks, DiskInfo{ + Filesystem: fs, + TotalBytes: total * 1024, + UsedBytes: used * 1024, + FreeBytes: avail * 1024, + MountPoint: mount, + }) + } + + return disks +} diff --git a/agent/collector/gateways.go b/agent/collector/gateways.go new file mode 100644 index 0000000..e799c2c --- /dev/null +++ b/agent/collector/gateways.go @@ -0,0 +1,68 @@ +package collector + +import ( + "encoding/json" + "os/exec" +) + +type Gateway struct { + Name string `json:"name"` + Address string `json:"address"` + Status string `json:"status"` + Loss string `json:"loss,omitempty"` + Delay string `json:"delay,omitempty"` + Stddev string `json:"stddev,omitempty"` + Monitor string `json:"monitor,omitempty"` +} + +// CollectGateways liest OPNsense Gateways via configctl +func CollectGateways() []Gateway { + out, err := exec.Command("/usr/local/sbin/configctl", "interface", "gateways", "status").Output() + if err != nil { + return nil + } + + var raw []struct { + Name string `json:"name"` + Address string `json:"address"` + Status string `json:"status"` + Loss string `json:"loss"` + Delay string `json:"delay"` + Stddev string `json:"stddev"` + Monitor string `json:"monitor"` + StatusTranslated string `json:"status_translated"` + } + + if err := json.Unmarshal(out, &raw); err != nil { + return nil + } + + var gateways []Gateway + for _, r := range raw { + gw := Gateway{ + Name: r.Name, + Address: r.Address, + Status: r.StatusTranslated, + Loss: r.Loss, + Delay: r.Delay, + Stddev: r.Stddev, + Monitor: r.Monitor, + } + // Normalisiere "~" zu leer + if gw.Loss == "~" { + gw.Loss = "" + } + if gw.Delay == "~" { + gw.Delay = "" + } + if gw.Stddev == "~" { + gw.Stddev = "" + } + if gw.Monitor == "~" { + gw.Monitor = "" + } + gateways = append(gateways, gw) + } + + return gateways +} diff --git a/agent/collector/hardware.go b/agent/collector/hardware.go new file mode 100644 index 0000000..5a2a485 --- /dev/null +++ b/agent/collector/hardware.go @@ -0,0 +1,55 @@ +package collector + +import ( + "os/exec" + "strings" +) + +type HardwareInfo struct { + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` + Serial string `json:"serial"` + BIOSVersion string `json:"bios_version"` +} + +func CollectHardware() HardwareInfo { + info := HardwareInfo{} + + // Hersteller via kenv + info.Manufacturer = kenvGet("smbios.system.maker") + info.Model = kenvGet("smbios.system.product") + info.Serial = kenvGet("smbios.system.serial") + info.BIOSVersion = kenvGet("smbios.bios.version") + + // Fallback auf dmidecode wenn kenv leer + if info.Manufacturer == "" { + info.Manufacturer = dmidecodeGet("system-manufacturer") + } + if info.Model == "" { + info.Model = dmidecodeGet("system-product-name") + } + if info.Serial == "" { + info.Serial = dmidecodeGet("system-serial-number") + } + if info.BIOSVersion == "" { + info.BIOSVersion = dmidecodeGet("bios-version") + } + + return info +} + +func kenvGet(key string) string { + out, err := exec.Command("kenv", key).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func dmidecodeGet(keyword string) string { + out, err := exec.Command("dmidecode", "-s", keyword).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} diff --git a/agent/collector/memory.go b/agent/collector/memory.go new file mode 100644 index 0000000..5fc9a2e --- /dev/null +++ b/agent/collector/memory.go @@ -0,0 +1,42 @@ +package collector + +import ( + "strconv" +) + +type MemoryInfo struct { + TotalBytes int64 `json:"total_bytes"` + UsedBytes int64 `json:"used_bytes"` + FreeBytes int64 `json:"free_bytes"` +} + +func CollectMemory() MemoryInfo { + info := MemoryInfo{} + + // Gesamter physischer Speicher + if v, err := strconv.ParseInt(sysctlGet("hw.physmem"), 10, 64); err == nil { + info.TotalBytes = v + } + + // Freier Speicher berechnen via vm.stats.vm + pageSize := int64(4096) + if v, err := strconv.ParseInt(sysctlGet("hw.pagesize"), 10, 64); err == nil && v > 0 { + pageSize = v + } + + // Freie + inactive + cache Pages + freePages, _ := strconv.ParseInt(sysctlGet("vm.stats.vm.v_free_count"), 10, 64) + inactivePages, _ := strconv.ParseInt(sysctlGet("vm.stats.vm.v_inactive_count"), 10, 64) + + info.FreeBytes = (freePages + inactivePages) * pageSize + info.UsedBytes = info.TotalBytes - info.FreeBytes + + if info.UsedBytes < 0 { + info.UsedBytes = 0 + } + if info.FreeBytes < 0 { + info.FreeBytes = 0 + } + + return info +} diff --git a/agent/collector/network.go b/agent/collector/network.go new file mode 100644 index 0000000..3fd2cad --- /dev/null +++ b/agent/collector/network.go @@ -0,0 +1,178 @@ +package collector + +import ( + "os/exec" + "strconv" + "strings" +) + +type NetworkInterface struct { + Name string `json:"name"` + Role string `json:"role"` + IP string `json:"ip"` + MAC string `json:"mac"` + Status string `json:"status"` + RxBytes int64 `json:"rx_bytes"` + TxBytes int64 `json:"tx_bytes"` +} + +func CollectNetwork() []NetworkInterface { + ifaces := parseIfconfig() + addTrafficStats(ifaces) + return ifaces +} + +// parseIfconfig parst ifconfig -a Output +func parseIfconfig() []NetworkInterface { + out, err := exec.Command("ifconfig", "-a").Output() + if err != nil { + return nil + } + + var ifaces []NetworkInterface + var current *NetworkInterface + + for _, line := range strings.Split(string(out), "\n") { + // Neues Interface: "igb0: flags=..." + if len(line) > 0 && line[0] != '\t' && line[0] != ' ' && strings.Contains(line, ": flags=") { + if current != nil { + ifaces = append(ifaces, *current) + } + name := strings.Split(line, ":")[0] + + // Loopback und virtuelle Interfaces ueberspringen + if name == "lo0" || strings.HasPrefix(name, "pflog") || strings.HasPrefix(name, "pfsync") || strings.HasPrefix(name, "enc") { + current = nil + continue + } + + status := "down" + if strings.Contains(line, "UP") { + status = "up" + } + + current = &NetworkInterface{ + Name: name, + Status: status, + Role: guessRole(name), + } + continue + } + + if current == nil { + continue + } + + trimmed := strings.TrimSpace(line) + + // IPv4 Adresse + if strings.HasPrefix(trimmed, "inet ") && !strings.HasPrefix(trimmed, "inet6") { + fields := strings.Fields(trimmed) + if len(fields) >= 2 { + current.IP = fields[1] + } + } + + // MAC Adresse + if strings.HasPrefix(trimmed, "ether ") { + fields := strings.Fields(trimmed) + if len(fields) >= 2 { + current.MAC = fields[1] + } + } + } + + if current != nil { + ifaces = append(ifaces, *current) + } + + return ifaces +} + +// addTrafficStats fuegt RX/TX Bytes via netstat -ib hinzu +func addTrafficStats(ifaces []NetworkInterface) { + out, err := exec.Command("/usr/bin/netstat", "-ib").Output() + if err != nil { + return + } + + // Nur Zeilen haben die totalen Byte-Zaehler + // Format variiert, aber die letzten 4 Felder sind immer: Opkts Oerrs Obytes Coll + // und davor: Ipkts Ierrs Idrop Ibytes + lines := strings.Split(string(out), "\n") + for _, line := range lines[1:] { + if !strings.Contains(line, "vtnet1... + // Wir suchen nach IFNAME und schauen welcher Block (wan/lan/opt) es enthaelt + out, err := exec.Command("grep", "-B5", ""+ifName+"", "/conf/config.xml").Output() + if err != nil { + return "" + } + + content := string(out) + // Suche nach dem oeffnenden Tag des Parent-Elements + for _, role := range []string{"wan", "lan", "opt1", "opt2", "opt3", "opt4", "opt5", "opt6"} { + if strings.Contains(content, "<"+role+">") { + return strings.ToUpper(role) + } + } + + return "" +} diff --git a/agent/collector/opnsense.go b/agent/collector/opnsense.go new file mode 100644 index 0000000..ab97f14 --- /dev/null +++ b/agent/collector/opnsense.go @@ -0,0 +1,61 @@ +package collector + +import ( + "os/exec" + "strconv" + "strings" + "time" +) + +func CollectOPNsenseVersion() string { + out, err := exec.Command("opnsense-version").Output() + if err != nil { + return "" + } + // Format: "OPNsense 24.1.1_1" - alles nach erstem Leerzeichen + s := strings.TrimSpace(string(out)) + return s +} + +func CollectFreeBSDVersion() string { + out, err := exec.Command("freebsd-version").Output() + if err != nil { + return sysctlGet("kern.osrelease") + } + return strings.TrimSpace(string(out)) +} + +func CollectHostname() string { + return sysctlGet("kern.hostname") +} + +func CollectUptime() int64 { + // kern.boottime: { sec = 1706000000, usec = 0 } ... oder "sec = 1706000000" + out := sysctlGet("kern.boottime") + if out == "" { + return 0 + } + + // Parse "{ sec = TIMESTAMP, usec = ... }" + idx := strings.Index(out, "sec = ") + if idx == -1 { + return 0 + } + + numStr := out[idx+6:] + if commaIdx := strings.IndexAny(numStr, ", }"); commaIdx != -1 { + numStr = numStr[:commaIdx] + } + numStr = strings.TrimSpace(numStr) + + bootTime, err := strconv.ParseInt(numStr, 10, 64) + if err != nil { + return 0 + } + + uptime := time.Now().Unix() - bootTime + if uptime < 0 { + return 0 + } + return uptime +} diff --git a/agent/collector/plugins.go b/agent/collector/plugins.go new file mode 100644 index 0000000..709a1ed --- /dev/null +++ b/agent/collector/plugins.go @@ -0,0 +1,64 @@ +package collector + +import ( + "os/exec" + "strings" +) + +type Plugin struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` +} + +// CollectPlugins liest installierte OPNsense Plugins via pkg info +func CollectPlugins() []Plugin { + out, err := exec.Command("/usr/sbin/pkg", "info").Output() + if err != nil { + return nil + } + + var plugins []Plugin + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + + // Nur os-* Pakete sind OPNsense Plugins + if !strings.HasPrefix(line, "os-") { + continue + } + + // Format: "os-acme-client-4.13 ACME Client" + // Erste Spalte: Name-Version, Rest: Beschreibung + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + + nameVer := fields[0] + desc := strings.Join(fields[1:], " ") + + // Name und Version trennen: letztes "-" vor einer Ziffer ist der Trenner + name, version := splitNameVersion(nameVer) + + plugins = append(plugins, Plugin{ + Name: name, + Version: version, + Description: desc, + }) + } + + return plugins +} + +// splitNameVersion trennt "os-acme-client-4.13" in ("os-acme-client", "4.13") +func splitNameVersion(s string) (string, string) { + // Von hinten suchen: letztes "-" gefolgt von einer Ziffer + for i := len(s) - 1; i >= 0; i-- { + if s[i] == '-' && i+1 < len(s) && s[i+1] >= '0' && s[i+1] <= '9' { + return s[:i], s[i+1:] + } + } + return s, "" +} diff --git a/agent/collector/routing.go b/agent/collector/routing.go new file mode 100644 index 0000000..2f0a2f6 --- /dev/null +++ b/agent/collector/routing.go @@ -0,0 +1,61 @@ +package collector + +import ( + "os/exec" + "strings" +) + +type Route struct { + Destination string `json:"destination"` + Gateway string `json:"gateway"` + Flags string `json:"flags"` + Interface string `json:"interface"` +} + +// CollectRoutes liest die Routing-Tabelle via netstat -rn +func CollectRoutes() []Route { + out, err := exec.Command("/usr/bin/netstat", "-rn", "-f", "inet").Output() + if err != nil { + return nil + } + + var routes []Route + inTable := false + + for _, line := range strings.Split(string(out), "\n") { + trimmed := strings.TrimSpace(line) + + // Header erkennen + if strings.HasPrefix(trimmed, "Destination") { + inTable = true + continue + } + if !inTable || trimmed == "" { + continue + } + + fields := strings.Fields(trimmed) + if len(fields) < 4 { + continue + } + + route := Route{ + Destination: fields[0], + Gateway: fields[1], + Flags: fields[2], + } + + // Interface ist typischerweise das letzte oder vorletzte Feld + // FreeBSD netstat -rn: Destination Gateway Flags Refs Use Netif Expire + // Mindestens 6 Felder fuer Netif + if len(fields) >= 6 { + route.Interface = fields[5] + } else if len(fields) >= 4 { + route.Interface = fields[len(fields)-1] + } + + routes = append(routes, route) + } + + return routes +} diff --git a/agent/collector/services.go b/agent/collector/services.go new file mode 100644 index 0000000..a828d26 --- /dev/null +++ b/agent/collector/services.go @@ -0,0 +1,97 @@ +package collector + +import ( + "os/exec" + "path/filepath" + "strings" +) + +type ServiceInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` +} + +func CollectServices() []ServiceInfo { + // Versuche zuerst pluginctl -s (OPNsense-spezifisch) + services := tryPluginctl() + if len(services) > 0 { + return services + } + + // Fallback: service -e listet aktivierte Services + return tryServiceList() +} + +func tryPluginctl() []ServiceInfo { + out, err := exec.Command("pluginctl", "-s").Output() + if err != nil { + return nil + } + + var services []ServiceInfo + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + name := strings.TrimSpace(line) + if name == "" { + continue + } + + // pluginctl -s gibt nur Service-Namen, Status einzeln pruefen + status := "stopped" + statusOut, err := exec.Command("service", name, "status").CombinedOutput() + if err == nil && strings.Contains(string(statusOut), "is running") { + status = "running" + } + + // Beschreibung aus rc.d Script extrahieren (# PROVIDE: name) + desc := "" + descOut, _ := exec.Command("grep", "-h", "PROVIDE:", "/etc/rc.d/"+name, "/usr/local/etc/rc.d/"+name).Output() + if len(descOut) > 0 { + parts := strings.SplitN(string(descOut), "PROVIDE:", 2) + if len(parts) == 2 { + desc = strings.TrimSpace(parts[1]) + } + } + + services = append(services, ServiceInfo{ + Name: name, + Description: desc, + Status: status, + }) + } + + return services +} + +func tryServiceList() []ServiceInfo { + out, err := exec.Command("service", "-e").Output() + if err != nil { + return nil + } + + var services []ServiceInfo + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + + // service -e gibt volle Pfade: /etc/rc.d/sshd, /usr/local/etc/rc.d/openvpn + name := filepath.Base(strings.TrimSpace(line)) + + // Status pruefen + status := "running" // service -e listet nur laufende Services + statusOut, err := exec.Command("service", name, "status").Output() + if err != nil { + status = "stopped" + } else if strings.Contains(string(statusOut), "not running") { + status = "stopped" + } + + services = append(services, ServiceInfo{ + Name: name, + Status: status, + }) + } + + return services +} diff --git a/agent/collector/updates.go b/agent/collector/updates.go new file mode 100644 index 0000000..453036b --- /dev/null +++ b/agent/collector/updates.go @@ -0,0 +1,88 @@ +package collector + +import ( + "os/exec" + "strings" +) + +type PendingUpdate struct { + Package string `json:"package"` + CurrentVer string `json:"current_version"` + NewVer string `json:"new_version"` +} + +type UpdateInfo struct { + UpdateAvailable bool `json:"update_available"` + PendingCount int `json:"pending_count"` + Updates []PendingUpdate `json:"updates"` + OPNsenseUpdate string `json:"opnsense_update,omitempty"` // z.B. "26.1.2 -> 26.1.2_5" +} + +// CollectUpdates prueft ob Updates verfuegbar sind +func CollectUpdates() UpdateInfo { + info := UpdateInfo{} + + // 1. OPNsense Core Update pruefen + // opnsense-update -c: exit 0 = kein Update, exit 1 = Update verfuegbar (mit Versions-Info auf stdout) + if out, err := exec.Command("/usr/local/sbin/opnsense-update", "-c").CombinedOutput(); err != nil { + // Exit 1 = Update verfuegbar + outStr := strings.TrimSpace(string(out)) + if outStr != "" { + info.OPNsenseUpdate = outStr + info.UpdateAvailable = true + } + } + + // 2. Paket-Updates pruefen via pkg upgrade -n (dry-run) + out, err := exec.Command("/usr/sbin/pkg", "upgrade", "-n").CombinedOutput() + if err != nil || len(out) == 0 { + // Bei pkg upgrade -n ist exit 1 = Updates verfuegbar + // Trotzdem stdout parsen + } + + content := string(out) + + // Zeilen mit ":" im UPGRADED-Block parsen + // Format: "\tpackage: old_version -> new_version" + inUpgradeBlock := false + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + + if strings.Contains(trimmed, "packages to be UPGRADED") || + strings.Contains(trimmed, "will be affected") { + inUpgradeBlock = true + continue + } + + if inUpgradeBlock && strings.Contains(trimmed, "->") { + // Format: "package: old -> new" + parts := strings.SplitN(trimmed, ":", 2) + if len(parts) == 2 { + pkg := strings.TrimSpace(parts[0]) + versions := strings.TrimSpace(parts[1]) + + arrowParts := strings.SplitN(versions, "->", 2) + if len(arrowParts) == 2 { + update := PendingUpdate{ + Package: pkg, + CurrentVer: strings.TrimSpace(arrowParts[0]), + NewVer: strings.TrimSpace(arrowParts[1]), + } + info.Updates = append(info.Updates, update) + info.UpdateAvailable = true + } + } + } + + // Block endet bei Leerzeile oder "Number of packages" + if inUpgradeBlock && (trimmed == "" || strings.HasPrefix(trimmed, "Number of")) { + if strings.HasPrefix(trimmed, "Number of") { + continue + } + inUpgradeBlock = false + } + } + + info.PendingCount = len(info.Updates) + return info +} diff --git a/agent/collector/wireguard.go b/agent/collector/wireguard.go new file mode 100644 index 0000000..6592161 --- /dev/null +++ b/agent/collector/wireguard.go @@ -0,0 +1,133 @@ +package collector + +import ( + "os/exec" + "strconv" + "strings" + "time" +) + +type WireGuardTunnel struct { + Interface string `json:"interface"` + PublicKey string `json:"public_key"` + ListenPort int `json:"listen_port"` + Peers []WireGuardPeer `json:"peers"` +} + +type WireGuardPeer struct { + PublicKey string `json:"public_key"` + Endpoint string `json:"endpoint"` + AllowedIPs []string `json:"allowed_ips"` + LatestHandshake int64 `json:"latest_handshake_epoch"` + HandshakeAge string `json:"handshake_age"` + TransferRx int64 `json:"transfer_rx_bytes"` + TransferTx int64 `json:"transfer_tx_bytes"` + Keepalive int `json:"persistent_keepalive"` + Status string `json:"status"` // "active", "inactive", "unknown" +} + +func CollectWireGuard() []WireGuardTunnel { + // wg show all dump: maschinenlesbares Format + // Voller Pfad, da daemon(8) evtl. reduzierten PATH hat + wgPath := "/usr/bin/wg" + if _, err := exec.LookPath("wg"); err == nil { + wgPath = "wg" + } + out, err := exec.Command(wgPath, "show", "all", "dump").Output() + if err != nil { + return nil + } + + tunnels := make(map[string]*WireGuardTunnel) + var order []string + + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + fields := strings.Split(line, "\t") + if len(fields) < 5 { + continue + } + + iface := fields[0] + + // Interface-Zeile: iface privkey pubkey listenport fwmark + if _, exists := tunnels[iface]; !exists { + port := 0 + if len(fields) >= 4 { + port, _ = strconv.Atoi(fields[3]) + } + pubkey := "" + if len(fields) >= 3 { + pubkey = fields[2] + } + tunnels[iface] = &WireGuardTunnel{ + Interface: iface, + PublicKey: pubkey, + ListenPort: port, + } + order = append(order, iface) + continue + } + + // Peer-Zeile: iface pubkey presharedkey endpoint allowedips latest-handshake transfer-rx transfer-tx keepalive + if len(fields) < 9 { + continue + } + + peer := WireGuardPeer{ + PublicKey: fields[1], + Endpoint: fields[3], + } + + // Allowed IPs + if fields[4] != "(none)" { + peer.AllowedIPs = strings.Split(fields[4], ",") + } + + // Latest handshake (epoch) + if ts, err := strconv.ParseInt(fields[5], 10, 64); err == nil && ts > 0 { + peer.LatestHandshake = ts + age := time.Since(time.Unix(ts, 0)) + if age < 3*time.Minute { + peer.Status = "active" + } else if age < 10*time.Minute { + peer.Status = "inactive" + } else { + peer.Status = "inactive" + } + // Menschenlesbare Altersangabe + if age.Hours() >= 24 { + peer.HandshakeAge = strconv.Itoa(int(age.Hours()/24)) + "d " + strconv.Itoa(int(age.Hours())%24) + "h ago" + } else if age.Hours() >= 1 { + peer.HandshakeAge = strconv.Itoa(int(age.Hours())) + "h " + strconv.Itoa(int(age.Minutes())%60) + "m ago" + } else if age.Minutes() >= 1 { + peer.HandshakeAge = strconv.Itoa(int(age.Minutes())) + "m " + strconv.Itoa(int(age.Seconds())%60) + "s ago" + } else { + peer.HandshakeAge = strconv.Itoa(int(age.Seconds())) + "s ago" + } + } else { + peer.Status = "unknown" + peer.HandshakeAge = "never" + } + + // Transfer RX/TX + peer.TransferRx, _ = strconv.ParseInt(fields[6], 10, 64) + peer.TransferTx, _ = strconv.ParseInt(fields[7], 10, 64) + + // Keepalive + if fields[8] != "off" { + peer.Keepalive, _ = strconv.Atoi(fields[8]) + } + + tunnels[iface].Peers = append(tunnels[iface].Peers, peer) + } + + // Geordnet zurueckgeben + var result []WireGuardTunnel + for _, name := range order { + result = append(result, *tunnels[name]) + } + return result +} diff --git a/agent/config/config.go b/agent/config/config.go new file mode 100644 index 0000000..91287d6 --- /dev/null +++ b/agent/config/config.go @@ -0,0 +1,33 @@ +package config + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + BackendURL string `yaml:"backend_url"` + APIKey string `yaml:"api_key"` + IntervalSeconds int `yaml:"interval_seconds"` + AgentName string `yaml:"agent_name"` + Insecure bool `yaml:"insecure"` +} + +func Load(path string) (*Config, error) { + cfg := &Config{ + IntervalSeconds: 60, + Insecure: true, + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, err + } + + return cfg, nil +} diff --git a/agent/go.mod b/agent/go.mod new file mode 100644 index 0000000..b15359f --- /dev/null +++ b/agent/go.mod @@ -0,0 +1,7 @@ +module github.com/cynfo/rmm-agent + +go 1.22 + +require gopkg.in/yaml.v3 v3.0.1 + +require github.com/gorilla/websocket v1.5.3 // indirect diff --git a/agent/go.sum b/agent/go.sum new file mode 100644 index 0000000..6a2c68d --- /dev/null +++ b/agent/go.sum @@ -0,0 +1,6 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/agent/main.go b/agent/main.go new file mode 100644 index 0000000..8a7e552 --- /dev/null +++ b/agent/main.go @@ -0,0 +1,174 @@ +package main + +import ( + "flag" + "log" + "math" + "os" + "path/filepath" + "strings" + "time" + + "github.com/cynfo/rmm-agent/client" + "github.com/cynfo/rmm-agent/collector" + "github.com/cynfo/rmm-agent/config" +) + +func main() { + cfgPath := flag.String("config", "config.yaml", "Pfad zur Konfigurationsdatei") + insecure := flag.Bool("insecure", false, "TLS-Zertifikatpruefung deaktivieren") + flag.Parse() + + cfg, err := config.Load(*cfgPath) + if err != nil { + log.Fatalf("Konfiguration laden fehlgeschlagen: %v", err) + } + + if *insecure { + cfg.Insecure = true + } + + log.Printf("RMM Agent startet: %s", cfg.AgentName) + log.Printf("Backend: %s, Intervall: %ds", cfg.BackendURL, cfg.IntervalSeconds) + + c := client.New(cfg.BackendURL, cfg.APIKey, cfg.Insecure) + + // Agent-ID persistent laden oder neu registrieren + idFile := filepath.Join(filepath.Dir(*cfgPath), "agent_id") + var agentID string + + if data, err := os.ReadFile(idFile); err == nil && len(strings.TrimSpace(string(data))) > 0 { + agentID = strings.TrimSpace(string(data)) + log.Printf("Agent-ID aus Datei geladen: %s", agentID) + } + + // Registrierung mit Retry (sendet bestehende ID mit, falls vorhanden) + backoff := 5 * time.Second + + for { + hostname := collector.CollectHostname() + if hostname == "" { + hostname, _ = os.Hostname() + } + + regReq := map[string]string{ + "name": cfg.AgentName, + "hostname": hostname, + "ip": getLocalIP(), + "opnsense_version": collector.CollectOPNsenseVersion(), + } + if agentID != "" { + regReq["agent_id"] = agentID + } + + newID, regErr := c.Register(regReq) + if regErr != nil { + log.Printf("Registrierung fehlgeschlagen: %v (Retry in %v)", regErr, backoff) + time.Sleep(backoff) + backoff = time.Duration(math.Min(float64(backoff*2), float64(5*time.Minute))) + continue + } + agentID = newID + break + } + + // ID persistent speichern + if err := os.WriteFile(idFile, []byte(agentID), 0600); err != nil { + log.Printf("WARNUNG: Agent-ID konnte nicht gespeichert werden: %v", err) + } + + log.Printf("Agent registriert mit ID: %s", agentID) + + // Heartbeat-Loop + ticker := time.NewTicker(time.Duration(cfg.IntervalSeconds) * time.Second) + defer ticker.Stop() + + // Ersten Heartbeat sofort senden + sendHeartbeat(c, agentID) + + backoff = 5 * time.Second + for range ticker.C { + if err := sendHeartbeat(c, agentID); err != nil { + log.Printf("Heartbeat fehlgeschlagen: %v", err) + backoff = time.Duration(math.Min(float64(backoff*2), float64(5*time.Minute))) + } else { + backoff = 5 * time.Second + } + } +} + +func sendHeartbeat(c *client.Client, agentID string) error { + log.Println("Sammle Systemdaten...") + + hw := collector.CollectHardware() + cpu := collector.CollectCPU() + mem := collector.CollectMemory() + disks := collector.CollectDisks() + net := collector.CollectNetwork() + svcs := collector.CollectServices() + wg := collector.CollectWireGuard() + dhcp := collector.CollectDHCP() + routes := collector.CollectRoutes() + gateways := collector.CollectGateways() + certs := collector.CollectCertificates() + plugins := collector.CollectPlugins() + updates := collector.CollectUpdates() + + req := map[string]interface{}{ + "agent_id": agentID, + "system_data": map[string]interface{}{ + "agent_id": agentID, + "hostname": collector.CollectHostname(), + "opnsense_version": collector.CollectOPNsenseVersion(), + "freebsd_version": collector.CollectFreeBSDVersion(), + "uptime_seconds": collector.CollectUptime(), + "hardware": map[string]interface{}{ + "manufacturer": hw.Manufacturer, + "model": hw.Model, + "serial": hw.Serial, + "bios_version": hw.BIOSVersion, + }, + "cpu": map[string]interface{}{ + "model": cpu.Model, + "cores": cpu.Cores, + "threads": cpu.Threads, + "freq_mhz": cpu.FreqMHz, + "usage_percent": cpu.UsagePercent, + }, + "memory": map[string]interface{}{ + "total_bytes": mem.TotalBytes, + "used_bytes": mem.UsedBytes, + "free_bytes": mem.FreeBytes, + }, + "disks": disks, + "network_interfaces": net, + "services": svcs, + "wireguard": wg, + "dhcp": dhcp, + "routes": routes, + "gateways": gateways, + "certificates": certs, + "plugins": plugins, + "updates": updates, + }, + } + + if err := c.Heartbeat(req); err != nil { + return err + } + + log.Println("Heartbeat gesendet") + return nil +} + +// getLocalIP versucht die primaere IP-Adresse zu ermitteln +func getLocalIP() string { + // Versuche ueber ifconfig die erste nicht-loopback IP + ifaces := collector.CollectNetwork() + for _, iface := range ifaces { + if iface.IP != "" && iface.Status == "up" { + return iface.IP + } + } + return "" +} diff --git a/agent/ws/client.go b/agent/ws/client.go new file mode 100644 index 0000000..2b784ee --- /dev/null +++ b/agent/ws/client.go @@ -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 +} \ No newline at end of file diff --git a/agent/ws/handler.go b/agent/ws/handler.go new file mode 100644 index 0000000..fceb532 --- /dev/null +++ b/agent/ws/handler.go @@ -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 +} \ No newline at end of file diff --git a/agent/ws/tunnel.go b/agent/ws/tunnel.go new file mode 100644 index 0000000..3404eb8 --- /dev/null +++ b/agent/ws/tunnel.go @@ -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) + } + } +} \ No newline at end of file diff --git a/backend/api/handlers.go b/backend/api/handlers.go new file mode 100644 index 0000000..2b18ba6 --- /dev/null +++ b/backend/api/handlers.go @@ -0,0 +1,204 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "log" + "net/http" + "strings" + "time" + + "github.com/cynfo/rmm-backend/db" + "github.com/cynfo/rmm-backend/models" +) + +type Handler struct { + db *db.Database +} + +func NewHandler(database *db.Database) *Handler { + return &Handler{db: database} +} + +// SetupRoutes - Alle API-Routes registrieren +func (h *Handler) SetupRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /api/v1/agent/register", h.registerAgent) + mux.HandleFunc("POST /api/v1/agent/heartbeat", h.heartbeat) + mux.HandleFunc("GET /api/v1/agents", h.listAgents) + mux.HandleFunc("GET /api/v1/agents/{id}", h.getAgent) + mux.HandleFunc("GET /api/v1/agents/{id}/system", h.getSystemData) + mux.HandleFunc("DELETE /api/v1/agents/{id}", h.deleteAgent) +} + +func generateID() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +// POST /api/v1/agent/register +func (h *Handler) registerAgent(w http.ResponseWriter, r *http.Request) { + var req models.RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige Anfrage") + return + } + + if req.Name == "" || req.Hostname == "" { + writeError(w, http.StatusBadRequest, "Name und Hostname sind erforderlich") + return + } + + // Agent-ID bestimmen: vom Agent mitgeliefert > nach Hostname suchen > neu generieren + agentID := req.AgentID + isExisting := false + + if agentID != "" { + // Pruefen ob die ID schon in der DB existiert + if existing, _, err := h.db.GetAgent(agentID); err == nil && existing != nil { + isExisting = true + } + } + + if agentID == "" { + // Nach bestehendem Agent mit gleichem Hostname suchen + if existing, err := h.db.GetAgentByHostname(req.Hostname); err == nil && existing != nil { + agentID = existing.ID + isExisting = true + } + } + + if agentID == "" { + agentID = generateID() + } + + agent := &models.Agent{ + ID: agentID, + Name: req.Name, + Hostname: req.Hostname, + IP: req.IP, + OPNsenseVersion: req.OPNsenseVersion, + RegisteredAt: time.Now().UTC(), + } + + if err := h.db.RegisterAgent(agent); err != nil { + log.Printf("Fehler bei Registrierung: %v", err) + writeError(w, http.StatusInternalServerError, "Registrierung fehlgeschlagen") + return + } + + action := "registriert" + if isExisting { + action = "re-registriert" + } + log.Printf("Agent %s: %s (%s)", action, agent.Name, agent.ID) + writeJSON(w, http.StatusCreated, models.RegisterResponse{ + ID: agent.ID, + Message: "Agent erfolgreich registriert", + }) +} + +// POST /api/v1/agent/heartbeat +func (h *Handler) heartbeat(w http.ResponseWriter, r *http.Request) { + var req models.HeartbeatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige Anfrage") + return + } + + if req.AgentID == "" { + writeError(w, http.StatusBadRequest, "agent_id erforderlich") + return + } + + if err := h.db.SaveSystemData(req.AgentID, &req.SystemData); err != nil { + if strings.Contains(err.Error(), "nicht gefunden") { + writeError(w, http.StatusNotFound, "Agent nicht gefunden") + return + } + log.Printf("Fehler bei Heartbeat: %v", err) + writeError(w, http.StatusInternalServerError, "Heartbeat fehlgeschlagen") + return + } + + writeJSON(w, http.StatusOK, models.HeartbeatResponse{Message: "OK"}) +} + +// GET /api/v1/agents +func (h *Handler) listAgents(w http.ResponseWriter, r *http.Request) { + agents, err := h.db.GetAgents() + if err != nil { + log.Printf("Fehler beim Laden der Agents: %v", err) + writeError(w, http.StatusInternalServerError, "Fehler beim Laden") + return + } + if agents == nil { + agents = []models.Agent{} + } + writeJSON(w, http.StatusOK, agents) +} + +// GET /api/v1/agents/{id} +func (h *Handler) getAgent(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + agent, sysData, err := h.db.GetAgent(id) + if err != nil { + log.Printf("Fehler: %v", err) + writeError(w, http.StatusInternalServerError, "Fehler beim Laden") + return + } + if agent == nil { + writeError(w, http.StatusNotFound, "Agent nicht gefunden") + return + } + + result := map[string]interface{}{ + "agent": agent, + "system_data": sysData, + } + writeJSON(w, http.StatusOK, result) +} + +// GET /api/v1/agents/{id}/system +func (h *Handler) getSystemData(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + data, err := h.db.GetSystemData(id) + if err != nil { + log.Printf("Fehler: %v", err) + writeError(w, http.StatusInternalServerError, "Fehler beim Laden") + return + } + if data == nil { + writeError(w, http.StatusNotFound, "Keine Systemdaten vorhanden") + return + } + writeJSON(w, http.StatusOK, data) +} + +// DELETE /api/v1/agents/{id} +func (h *Handler) deleteAgent(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + deleted, err := h.db.DeleteAgent(id) + if err != nil { + log.Printf("Fehler beim Loeschen: %v", err) + writeError(w, http.StatusInternalServerError, "Loeschen fehlgeschlagen") + return + } + if !deleted { + writeError(w, http.StatusNotFound, "Agent nicht gefunden") + return + } + log.Printf("Agent geloescht: %s", id) + writeJSON(w, http.StatusOK, map[string]string{"message": "Agent geloescht"}) +} diff --git a/backend/api/middleware.go b/backend/api/middleware.go new file mode 100644 index 0000000..aa3b770 --- /dev/null +++ b/backend/api/middleware.go @@ -0,0 +1,33 @@ +package api + +import ( + "log" + "net/http" + "time" +) + +// APIKeyAuth - Middleware fuer API-Key Authentifizierung +func APIKeyAuth(validKeys []string, next http.Handler) http.Handler { + keySet := make(map[string]bool, len(validKeys)) + for _, k := range validKeys { + keySet[k] = true + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := r.Header.Get("X-API-Key") + if key == "" || !keySet[key] { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +// Logging - Request-Logging Middleware +func Logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start)) + }) +} diff --git a/backend/config/config.go b/backend/config/config.go new file mode 100644 index 0000000..0c05032 --- /dev/null +++ b/backend/config/config.go @@ -0,0 +1,49 @@ +package config + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + ListenAddr string `yaml:"listen_addr"` + TLSCert string `yaml:"tls_cert"` + TLSKey string `yaml:"tls_key"` + APIKeys []string `yaml:"api_keys"` + DBPath string `yaml:"db_path"` +} + +func Load(path string) (*Config, error) { + cfg := &Config{ + ListenAddr: ":8443", + TLSCert: "certs/server.crt", + TLSKey: "certs/server.key", + DBPath: "rmm.db", + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, err + } + + // Env overrides + if v := os.Getenv("RMM_LISTEN_ADDR"); v != "" { + cfg.ListenAddr = v + } + if v := os.Getenv("RMM_TLS_CERT"); v != "" { + cfg.TLSCert = v + } + if v := os.Getenv("RMM_TLS_KEY"); v != "" { + cfg.TLSKey = v + } + if v := os.Getenv("RMM_DB_PATH"); v != "" { + cfg.DBPath = v + } + + return cfg, nil +} diff --git a/backend/db/sqlite.go b/backend/db/sqlite.go new file mode 100644 index 0000000..490e8ec --- /dev/null +++ b/backend/db/sqlite.go @@ -0,0 +1,235 @@ +package db + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/cynfo/rmm-backend/models" + + _ "modernc.org/sqlite" +) + +type Database struct { + db *sql.DB +} + +func New(dbPath string) (*Database, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("Datenbank oeffnen: %w", err) + } + + // WAL-Modus fuer bessere Performance + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + return nil, err + } + + d := &Database{db: db} + if err := d.migrate(); err != nil { + return nil, fmt.Errorf("Migration fehlgeschlagen: %w", err) + } + + return d, nil +} + +func (d *Database) migrate() error { + schema := ` + CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + hostname TEXT NOT NULL, + ip TEXT NOT NULL, + opnsense_version TEXT NOT NULL DEFAULT '', + registered_at TEXT NOT NULL, + last_heartbeat TEXT + ); + + CREATE TABLE IF NOT EXISTS system_data ( + agent_id TEXT PRIMARY KEY, + data_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE + ); + ` + _, err := d.db.Exec(schema) + return err +} + +func (d *Database) Close() error { + return d.db.Close() +} + +// RegisterAgent - Neuen Agent registrieren oder bestehenden aktualisieren +func (d *Database) RegisterAgent(agent *models.Agent) error { + _, err := d.db.Exec(` + INSERT INTO agents (id, name, hostname, ip, opnsense_version, registered_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name=excluded.name, + hostname=excluded.hostname, + ip=excluded.ip, + opnsense_version=excluded.opnsense_version + `, agent.ID, agent.Name, agent.Hostname, agent.IP, agent.OPNsenseVersion, agent.RegisteredAt.UTC().Format(time.RFC3339)) + return err +} + +// SaveSystemData - Systemdaten speichern und Heartbeat aktualisieren +func (d *Database) SaveSystemData(agentID string, data *models.SystemData) error { + now := time.Now().UTC().Format(time.RFC3339) + + jsonData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("JSON Marshal: %w", err) + } + + tx, err := d.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + // Heartbeat Zeitstempel aktualisieren + res, err := tx.Exec("UPDATE agents SET last_heartbeat = ? WHERE id = ?", now, agentID) + if err != nil { + return err + } + rows, _ := res.RowsAffected() + if rows == 0 { + return fmt.Errorf("Agent %s nicht gefunden", agentID) + } + + // Systemdaten upsert + _, err = tx.Exec(` + INSERT INTO system_data (agent_id, data_json, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(agent_id) DO UPDATE SET + data_json=excluded.data_json, + updated_at=excluded.updated_at + `, agentID, string(jsonData), now) + if err != nil { + return err + } + + return tx.Commit() +} + +// GetAgents - Alle Agents auflisten +func (d *Database) GetAgents() ([]models.Agent, error) { + rows, err := d.db.Query("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat FROM agents ORDER BY name") + if err != nil { + return nil, err + } + defer rows.Close() + + var agents []models.Agent + for rows.Next() { + var a models.Agent + var regAt string + var lastHB sql.NullString + + if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, ®At, &lastHB); err != nil { + return nil, err + } + a.RegisteredAt, _ = time.Parse(time.RFC3339, regAt) + if lastHB.Valid { + t, _ := time.Parse(time.RFC3339, lastHB.String) + a.LastHeartbeat = &t + } + agents = append(agents, a) + } + return agents, rows.Err() +} + +// GetAgent - Einzelnen Agent mit Systemdaten +func (d *Database) GetAgentByHostname(hostname string) (*models.Agent, error) { + var a models.Agent + var regAt string + var lastHB sql.NullString + + err := d.db.QueryRow("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat FROM agents WHERE hostname = ? LIMIT 1", hostname). + Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, ®At, &lastHB) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + a.RegisteredAt, _ = time.Parse(time.RFC3339, regAt) + if lastHB.Valid { + t, _ := time.Parse(time.RFC3339, lastHB.String) + a.LastHeartbeat = &t + } + return &a, nil +} + +func (d *Database) GetAgent(id string) (*models.Agent, *models.SystemData, error) { + var a models.Agent + var regAt string + var lastHB sql.NullString + + err := d.db.QueryRow("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat FROM agents WHERE id = ?", id). + Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, ®At, &lastHB) + if err == sql.ErrNoRows { + return nil, nil, nil + } + if err != nil { + return nil, nil, err + } + a.RegisteredAt, _ = time.Parse(time.RFC3339, regAt) + if lastHB.Valid { + t, _ := time.Parse(time.RFC3339, lastHB.String) + a.LastHeartbeat = &t + } + + // Systemdaten laden + var dataJSON sql.NullString + err = d.db.QueryRow("SELECT data_json FROM system_data WHERE agent_id = ?", id).Scan(&dataJSON) + if err != nil && err != sql.ErrNoRows { + return nil, nil, err + } + + var sysData *models.SystemData + if dataJSON.Valid { + sysData = &models.SystemData{} + if err := json.Unmarshal([]byte(dataJSON.String), sysData); err != nil { + return &a, nil, nil + } + } + + return &a, sysData, nil +} + +// GetSystemData - Nur Systemdaten eines Agents +func (d *Database) GetSystemData(agentID string) (*models.SystemData, error) { + var dataJSON string + err := d.db.QueryRow("SELECT data_json FROM system_data WHERE agent_id = ?", agentID).Scan(&dataJSON) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + var data models.SystemData + if err := json.Unmarshal([]byte(dataJSON), &data); err != nil { + return nil, err + } + return &data, nil +} + +// DeleteAgent - Agent und zugehoerige Daten loeschen +func (d *Database) DeleteAgent(id string) (bool, error) { + // Foreign Key Cascade aktivieren + d.db.Exec("PRAGMA foreign_keys = ON") + + res, err := d.db.Exec("DELETE FROM agents WHERE id = ?", id) + if err != nil { + return false, err + } + rows, _ := res.RowsAffected() + // Sicherheitshalber auch system_data loeschen (falls kein CASCADE) + d.db.Exec("DELETE FROM system_data WHERE agent_id = ?", id) + return rows > 0, nil +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..b8470bd --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,25 @@ +module github.com/cynfo/rmm-backend + +go 1.22 + +require ( + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.29.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.16.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.41.0 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..aaef8af --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,45 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk= +modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.29.1 h1:19GY2qvWB4VPw0HppFlZCPAbmxFU41r+qjKZQdQ1ryA= +modernc.org/sqlite v1.29.1/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..c651363 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,132 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "log" + "math/big" + "net" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/cynfo/rmm-backend/api" + "github.com/cynfo/rmm-backend/config" + "github.com/cynfo/rmm-backend/db" +) + +func main() { + cfgPath := "config.yaml" + if len(os.Args) > 1 { + cfgPath = os.Args[1] + } + + cfg, err := config.Load(cfgPath) + if err != nil { + log.Fatalf("Konfiguration laden fehlgeschlagen: %v", err) + } + + // TLS-Zertifikate pruefen/generieren + if err := ensureTLS(cfg.TLSCert, cfg.TLSKey); err != nil { + log.Fatalf("TLS-Setup fehlgeschlagen: %v", err) + } + + // Datenbank initialisieren + database, err := db.New(cfg.DBPath) + if err != nil { + log.Fatalf("Datenbank-Fehler: %v", err) + } + defer database.Close() + + // Router aufbauen + mux := http.NewServeMux() + handler := api.NewHandler(database) + handler.SetupRoutes(mux) + + // Middleware-Chain: Logging -> Auth -> Handler + var chain http.Handler = mux + chain = api.APIKeyAuth(cfg.APIKeys, chain) + chain = api.Logging(chain) + + log.Printf("RMM Backend startet auf %s (TLS)", cfg.ListenAddr) + log.Printf("API-Keys konfiguriert: %d", len(cfg.APIKeys)) + + if err := http.ListenAndServeTLS(cfg.ListenAddr, cfg.TLSCert, cfg.TLSKey, chain); err != nil { + log.Fatalf("Server-Fehler: %v", err) + } +} + +// ensureTLS generiert self-signed Zertifikate wenn keine vorhanden +func ensureTLS(certPath, keyPath string) error { + if _, err := os.Stat(certPath); err == nil { + if _, err := os.Stat(keyPath); err == nil { + log.Println("TLS-Zertifikate gefunden") + return nil + } + } + + log.Println("Generiere self-signed TLS-Zertifikat...") + + // Verzeichnis erstellen + if dir := filepath.Dir(certPath); dir != "" && dir != "." { + os.MkdirAll(dir, 0755) + } + if dir := filepath.Dir(keyPath); dir != "" && dir != "." { + os.MkdirAll(dir, 0755) + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("Key generieren: %w", err) + } + + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + + template := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + Organization: []string{"RMM Backend"}, + CommonName: "rmm-backend", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour * 10), // 10 Jahre + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("192.168.85.13")}, + DNSNames: []string{"localhost", "rmm-backend"}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + return fmt.Errorf("Zertifikat erstellen: %w", err) + } + + certFile, err := os.Create(certPath) + if err != nil { + return err + } + defer certFile.Close() + pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + keyBytes, err := x509.MarshalECPrivateKey(key) + if err != nil { + return err + } + + keyFile, err := os.OpenFile(keyPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer keyFile.Close() + pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}) + + log.Printf("TLS-Zertifikat generiert: %s, %s", certPath, keyPath) + return nil +} diff --git a/backend/models/agent.go b/backend/models/agent.go new file mode 100644 index 0000000..000cb6d --- /dev/null +++ b/backend/models/agent.go @@ -0,0 +1,29 @@ +package models + +import "time" + +// Agent repraesentiert einen registrierten OPNsense-Agent +type Agent struct { + ID string `json:"id"` + Name string `json:"name"` + Hostname string `json:"hostname"` + IP string `json:"ip"` + OPNsenseVersion string `json:"opnsense_version"` + RegisteredAt time.Time `json:"registered_at"` + LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"` +} + +// RegisterRequest - Agent-Registrierung +type RegisterRequest struct { + AgentID string `json:"agent_id,omitempty"` + Name string `json:"name"` + Hostname string `json:"hostname"` + IP string `json:"ip"` + OPNsenseVersion string `json:"opnsense_version"` +} + +// RegisterResponse +type RegisterResponse struct { + ID string `json:"id"` + Message string `json:"message"` +} diff --git a/backend/models/system.go b/backend/models/system.go new file mode 100644 index 0000000..6604f34 --- /dev/null +++ b/backend/models/system.go @@ -0,0 +1,164 @@ +package models + +// SystemData - Alle Systemdaten die der Agent sendet +type SystemData struct { + AgentID string `json:"agent_id"` + Hostname string `json:"hostname"` + OPNsenseVersion string `json:"opnsense_version"` + FreeBSDVersion string `json:"freebsd_version"` + UptimeSeconds int64 `json:"uptime_seconds"` + Hardware HardwareInfo `json:"hardware"` + CPU CPUInfo `json:"cpu"` + Memory MemoryInfo `json:"memory"` + Disks []DiskInfo `json:"disks"` + NetworkInterfaces []NetworkInterface `json:"network_interfaces"` + Services []ServiceInfo `json:"services"` + WireGuard []WireGuardTunnel `json:"wireguard,omitempty"` + DHCP *DHCPInfo `json:"dhcp,omitempty"` + Routes []Route `json:"routes,omitempty"` + Gateways []GatewayInfo `json:"gateways,omitempty"` + Certificates []CertificateInfo `json:"certificates,omitempty"` + Plugins []PluginInfo `json:"plugins,omitempty"` + Updates *UpdateInfo `json:"updates,omitempty"` +} + +type WireGuardTunnel struct { + Interface string `json:"interface"` + PublicKey string `json:"public_key"` + ListenPort int `json:"listen_port"` + Peers []WireGuardPeer `json:"peers"` +} + +type WireGuardPeer struct { + PublicKey string `json:"public_key"` + Endpoint string `json:"endpoint"` + AllowedIPs []string `json:"allowed_ips"` + LatestHandshake int64 `json:"latest_handshake_epoch"` + HandshakeAge string `json:"handshake_age"` + TransferRx int64 `json:"transfer_rx_bytes"` + TransferTx int64 `json:"transfer_tx_bytes"` + Keepalive int `json:"persistent_keepalive"` + Status string `json:"status"` +} + +type DHCPInfo struct { + Server string `json:"server"` + Leases []DHCPLease `json:"leases"` +} + +type DHCPLease struct { + IP string `json:"ip"` + MAC string `json:"mac"` + Hostname string `json:"hostname"` + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` + Status string `json:"status"` + Pool string `json:"pool,omitempty"` +} + +type HardwareInfo struct { + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` + Serial string `json:"serial"` + BIOSVersion string `json:"bios_version"` +} + +type CPUInfo struct { + Model string `json:"model"` + Cores int `json:"cores"` + Threads int `json:"threads"` + FreqMHz int `json:"freq_mhz"` + UsagePercent float64 `json:"usage_percent"` +} + +type MemoryInfo struct { + TotalBytes int64 `json:"total_bytes"` + UsedBytes int64 `json:"used_bytes"` + FreeBytes int64 `json:"free_bytes"` +} + +type DiskInfo struct { + Filesystem string `json:"filesystem"` + TotalBytes int64 `json:"total_bytes"` + UsedBytes int64 `json:"used_bytes"` + FreeBytes int64 `json:"free_bytes"` + MountPoint string `json:"mount_point"` +} + +type NetworkInterface struct { + Name string `json:"name"` + Role string `json:"role"` + IP string `json:"ip"` + MAC string `json:"mac"` + Status string `json:"status"` + RxBytes int64 `json:"rx_bytes"` + TxBytes int64 `json:"tx_bytes"` +} + +type ServiceInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` +} + +type Route struct { + Destination string `json:"destination"` + Gateway string `json:"gateway"` + Flags string `json:"flags"` + Interface string `json:"interface"` +} + +type GatewayInfo struct { + Name string `json:"name"` + Address string `json:"address"` + Status string `json:"status"` + Loss string `json:"loss,omitempty"` + Delay string `json:"delay,omitempty"` + Stddev string `json:"stddev,omitempty"` + Monitor string `json:"monitor,omitempty"` +} + +type CertificateInfo struct { + Name string `json:"name"` + Subject string `json:"subject"` + Issuer string `json:"issuer"` + NotBefore string `json:"not_before"` + NotAfter string `json:"not_after"` + DaysLeft int `json:"days_left"` + Serial string `json:"serial"` + SAN string `json:"san,omitempty"` + KeyUsage string `json:"key_usage,omitempty"` + IsCA bool `json:"is_ca"` + Status string `json:"status"` + Source string `json:"source"` +} + +type PluginInfo struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` +} + +type UpdateInfo struct { + UpdateAvailable bool `json:"update_available"` + PendingCount int `json:"pending_count"` + Updates []PendingUpdateInfo `json:"updates"` + OPNsenseUpdate string `json:"opnsense_update,omitempty"` +} + +type PendingUpdateInfo struct { + Package string `json:"package"` + CurrentVer string `json:"current_version"` + NewVer string `json:"new_version"` +} + +// HeartbeatRequest +type HeartbeatRequest struct { + AgentID string `json:"agent_id"` + SystemData SystemData `json:"system_data"` +} + +// HeartbeatResponse +type HeartbeatResponse struct { + Message string `json:"message"` +}