Initial commit: RMM Agent + Backend

- Go Agent (FreeBSD/amd64) fuer OPNsense
- Go Backend (Linux/amd64) mit REST API + SQLite
- Collector: Hardware, CPU, Memory, Disks, Network, Services,
  WireGuard, DHCP (KEA/ISC/dnsmasq), Routes, Gateways,
  Certificates, Plugins, Updates
- Persistente Agent-ID
- TLS + API-Key Auth
- WebSocket-Infrastruktur (WIP): bidirektionaler Command-Kanal,
  TCP-Tunnel fuer Remote-Zugriff auf Firewall-WebUI
- Lastenheft und README
This commit is contained in:
cynfo3000 2026-02-28 07:38:14 +01:00
commit 6120d0cffa
35 changed files with 3992 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
build/
*.db
*.log
*.pid
config.yaml
beispielbilder/
certs/

221
Lastenheft.md Normal file
View File

@ -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 MonitoringPlattform ermöglicht, weitreichende Systeminformationen auszulesen sowie definierte Systemaktionen und Änderungen auf OPNsenseFirewalls durchzuführen.
Da die bestehende OPNsenseAPI 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 TunnelEndpunkt.
Fur das frontent orientiere dich an dem vorschalg der Beispielbilder
## 2. Ausgangssituation
* Mehrere OPNsenseFirewalls (verschiedene Versionen, Standorte, Kunden)
* Zentrale Verwaltungsoberfläche (ähnlich den gezeigten UIMockups)
* Bestehende OPNsenseAPI liefert nicht alle benötigten Informationen
* Bedarf an:
* SystemTransparenz
* 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 AgentVerbindungen
* 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)
* WebUI 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
* BIOSVersion
* CPU (Typ, Kerne, Threads, Takt)
* RAM (gesamt, frei)
* Massenspeicher (Typ, Kapazität, Belegung)
* OPNsenseVersion
* 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 NetzwerkInterfaces
Der Agent muss:
* Alle Interfaces anzeigen
* Rolle (WAN / LAN / OPT)
* IPAdresse(n)
* MACAdresse
* RX/TXTraffic
* Status (up/down)
### 4.4 VPNInformationen
#### 4.4.1 WireGuard
* Tunnelname
* Peers
* Status (up/down)
* Transferstatistiken
#### 4.4.2 OpenVPN
* Server / ClientInstanzen
* Verbindungsstatus
* RoadWarriorSessions
### 4.5 Routing
* Anzeige der RoutingTabelle
* Zielnetz
* Gateway
* Interface
* Typ (statisch / dynamisch)
### 4.6 DHCP (KEA)
* Aktive DHCPInstanzen
* Pools
* Leases (IPv4 / IPv6)
* LeaseStatus
* 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:
* KonfigurationsBackups erstellen können
* Backup lokal zwischenspeichern
* Backup an Backend übertragen
* Metadaten liefern (Größe, Zeitstempel)
## 5. TunnelFunktionalität
### 5.1 Ziel
Das Backend soll ondemand Tunnel zu einer Firewall aufbauen können, ohne dass direkte eingehende Verbindungen zur Firewall notwendig sind.
### 5.2 Tunnelarten
* SSHTunnel
* HTTPSTunnel (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 ReverseTunnel
* 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
* TLSverschlü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, ReadOnly, Operator)
### 6.3 Logging & Audit
* Alle Aktionen werden protokolliert
* TunnelNutzung wird geloggt
* Konfigurationsänderungen nachvollziehbar
## 7. Nichtfunktionale Anforderungen
### 7.1 Performance
* Geringe Last auf OPNsense
* Asynchrone Abfragen
### 7.2 Stabilität
* Agent überlebt OPNsenseUpdates
* Automatische ReconnectLogik
### 7.3 Skalierbarkeit
* Unterstützung für viele hundert Firewalls
* Mandantenfähigkeit (Kunden / Standorte)
## 8. Abgrenzung
Nicht Bestandteil dieses Lastenhefts:
* Vollständige FirewallRuleBearbeitung
* Deep Packet Inspection
* IDS/IPSRegelverwaltung
## 9. Abnahmekriterien
* Alle genannten Informationspunkte sind im Frontend sichtbar
* Tunnel können ondemand aufgebaut und beendet werden
* Backups sind abrufbar und wiederherstellbar
* Agent funktioniert mit mehreren OPNsenseVersionen
---
*Dieses Lastenheft basiert auf den bereitgestellten Screenshots, der beschriebenen Zielarchitektur und den funktionalen Anforderungen des Auftraggebers.*

85
Makefile Normal file
View File

@ -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"

322
README.md Normal file
View File

@ -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.

95
agent/client/client.go Normal file
View File

@ -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)
}

View File

@ -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", "<crt>", "/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 := `<?php
require_once("config.inc");
$config = parse_config();
if (isset($config['cert'])) {
foreach ($config['cert'] as $cert) {
$x509 = openssl_x509_parse(base64_decode($cert['crt']));
if ($x509 === false) continue;
$name = isset($cert['descr']) ? $cert['descr'] : 'unnamed';
$subject = isset($x509['subject']['CN']) ? $x509['subject']['CN'] : '';
$issuer = isset($x509['issuer']['CN']) ? $x509['issuer']['CN'] : '';
$notBefore = date('Y-m-d H:i:s', $x509['validFrom_time_t']);
$notAfter = date('Y-m-d H:i:s', $x509['validTo_time_t']);
$serial = $x509['serialNumberHex'];
$san = '';
if (isset($x509['extensions']['subjectAltName'])) {
$san = $x509['extensions']['subjectAltName'];
}
$isCA = isset($x509['extensions']['basicConstraints']) &&
strpos($x509['extensions']['basicConstraints'], 'CA:TRUE') !== false;
echo "$name|$subject|$issuer|$notBefore|$notAfter|$serial|$san|" . ($isCA ? "1" : "0") . "\n";
}
}
if (isset($config['ca'])) {
foreach ($config['ca'] as $ca) {
$x509 = openssl_x509_parse(base64_decode($ca['crt']));
if ($x509 === false) continue;
$name = isset($ca['descr']) ? $ca['descr'] : 'unnamed-ca';
$subject = isset($x509['subject']['CN']) ? $x509['subject']['CN'] : '';
$issuer = isset($x509['issuer']['CN']) ? $x509['issuer']['CN'] : '';
$notBefore = date('Y-m-d H:i:s', $x509['validFrom_time_t']);
$notAfter = date('Y-m-d H:i:s', $x509['validTo_time_t']);
$serial = $x509['serialNumberHex'];
echo "$name|$subject|$issuer|$notBefore|$notAfter|$serial||1\n";
}
}
`
cmd := exec.Command("/usr/local/bin/php")
cmd.Stdin = strings.NewReader(script)
out, err := cmd.Output()
if err != nil {
// Fallback: PEM-Dateien scannen
return collectCertDir("/var/etc/cert/")
}
var certs []Certificate
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
fields := strings.SplitN(line, "|", 8)
if len(fields) < 8 {
continue
}
cert := Certificate{
Name: fields[0],
Subject: fields[1],
Issuer: fields[2],
NotBefore: fields[3],
NotAfter: fields[4],
Serial: fields[5],
SAN: fields[6],
IsCA: fields[7] == "1",
Source: "opnsense",
}
// Status berechnen
if notAfter, err := time.Parse("2006-01-02 15:04:05", cert.NotAfter); err == nil {
daysLeft := int(time.Until(notAfter).Hours() / 24)
cert.DaysLeft = daysLeft
if daysLeft < 0 {
cert.Status = "expired"
} else if daysLeft < 30 {
cert.Status = "expiring"
} else {
cert.Status = "valid"
}
}
certs = append(certs, cert)
}
return certs
}
func collectCertDir(dir string) []Certificate {
matches, err := filepath.Glob(filepath.Join(dir, "*.crt"))
if err != nil {
return nil
}
// Auch .pem Dateien
pemMatches, _ := filepath.Glob(filepath.Join(dir, "*.pem"))
matches = append(matches, pemMatches...)
var certs []Certificate
for _, path := range matches {
out, err := exec.Command("cat", path).Output()
if err != nil {
continue
}
block, _ := pem.Decode(out)
if block == nil || block.Type != "CERTIFICATE" {
continue
}
x509Cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
continue
}
name := filepath.Base(path)
name = strings.TrimSuffix(name, filepath.Ext(name))
daysLeft := int(time.Until(x509Cert.NotAfter).Hours() / 24)
status := "valid"
if daysLeft < 0 {
status = "expired"
} else if daysLeft < 30 {
status = "expiring"
}
var sans []string
for _, dns := range x509Cert.DNSNames {
sans = append(sans, "DNS:"+dns)
}
for _, ip := range x509Cert.IPAddresses {
sans = append(sans, "IP:"+ip.String())
}
cert := Certificate{
Name: name,
Subject: x509Cert.Subject.CommonName,
Issuer: x509Cert.Issuer.CommonName,
NotBefore: x509Cert.NotBefore.Format("2006-01-02 15:04:05"),
NotAfter: x509Cert.NotAfter.Format("2006-01-02 15:04:05"),
DaysLeft: daysLeft,
Serial: x509Cert.SerialNumber.Text(16),
SAN: strings.Join(sans, ", "),
IsCA: x509Cert.IsCA,
Status: status,
Source: "file",
}
certs = append(certs, cert)
}
return certs
}

109
agent/collector/cpu.go Normal file
View File

@ -0,0 +1,109 @@
package collector
import (
"os/exec"
"strconv"
"strings"
"time"
)
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"`
}
func CollectCPU() CPUInfo {
info := CPUInfo{}
// CPU Model
info.Model = sysctlGet("hw.model")
// Anzahl CPUs (Threads)
if v, err := strconv.Atoi(sysctlGet("hw.ncpu")); err == nil {
info.Threads = v
}
// Kerne - versuche kern.smp.cores, Fallback auf ncpu
if v, err := strconv.Atoi(sysctlGet("kern.smp.cores")); err == nil && v > 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
}

207
agent/collector/dhcp.go Normal file
View File

@ -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
}

62
agent/collector/disk.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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))
}

42
agent/collector/memory.go Normal file
View File

@ -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
}

178
agent/collector/network.go Normal file
View File

@ -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 <Link#> 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, "<Link#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 11 {
continue
}
name := fields[0]
// Von hinten zaehlen: Coll=last, Obytes=last-1, Oerrs=last-2, Opkts=last-3
// Ibytes=last-4 von Opkts aus: Ibytes ist 4 vor Opkts
n := len(fields)
obytes, err1 := strconv.ParseInt(fields[n-2], 10, 64)
// Ibytes: 7 von hinten (Coll, Obytes, Oerrs, Opkts, Ibytes, Idrop, Ierrs)
ibytes, err2 := strconv.ParseInt(fields[n-5], 10, 64)
if err1 != nil || err2 != nil {
continue
}
for i := range ifaces {
if ifaces[i].Name == name {
ifaces[i].RxBytes = ibytes
ifaces[i].TxBytes = obytes
}
}
}
}
// guessRole versucht die Rolle anhand des Interface-Namens zu erraten
func guessRole(name string) string {
// OPNsense benutzt typischerweise config.xml fuer Zuordnungen
// Hier: Heuristik basierend auf gaengigen Konventionen
lower := strings.ToLower(name)
// Versuche OPNsense-Config zu lesen
role := getOPNsenseIfRole(name)
if role != "" {
return role
}
// Fallback-Heuristik
if strings.Contains(lower, "wan") {
return "WAN"
}
if strings.Contains(lower, "lan") {
return "LAN"
}
if strings.HasPrefix(lower, "igb0") || strings.HasPrefix(lower, "em0") || strings.HasPrefix(lower, "ix0") {
return "WAN"
}
if strings.HasPrefix(lower, "igb1") || strings.HasPrefix(lower, "em1") || strings.HasPrefix(lower, "ix1") {
return "LAN"
}
return "OPT"
}
// getOPNsenseIfRole liest die Interface-Rolle aus der OPNsense config.xml
func getOPNsenseIfRole(ifName string) string {
// config.xml Struktur: <interfaces><wan><if>vtnet1</if>...
// Wir suchen nach <if>IFNAME</if> und schauen welcher Block (wan/lan/opt) es enthaelt
out, err := exec.Command("grep", "-B5", "<if>"+ifName+"</if>", "/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 ""
}

View File

@ -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
}

View File

@ -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, ""
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

33
agent/config/config.go Normal file
View File

@ -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
}

7
agent/go.mod Normal file
View File

@ -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

6
agent/go.sum Normal file
View File

@ -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=

174
agent/main.go Normal file
View File

@ -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 ""
}

277
agent/ws/client.go Normal file
View File

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

214
agent/ws/handler.go Normal file
View File

@ -0,0 +1,214 @@
package ws
import (
"context"
"fmt"
"log"
"os/exec"
"strconv"
"strings"
"time"
)
type Handler struct {
client *Client
}
func NewHandler(client *Client) *Handler {
return &Handler{client: client}
}
func (h *Handler) HandleCommand(msg Message) {
log.Printf("Command erhalten: %s (ID: %s)", msg.Command, msg.ID)
var response Message
response.Type = "response"
response.ID = msg.ID
switch msg.Command {
case "exec":
response = h.handleExec(msg)
case "tunnel_open":
response = h.handleTunnelOpen(msg)
case "tunnel_close":
response = h.handleTunnelClose(msg)
case "tunnel_data":
// Tunnel-Daten werden als Binary-Messages gesendet, nicht als JSON
h.handleTunnelData(msg)
return
default:
response.Status = "error"
response.Error = fmt.Sprintf("Unbekanntes Command: %s", msg.Command)
}
if err := h.client.SendMessage(response); err != nil {
log.Printf("Response senden fehlgeschlagen: %v", err)
}
}
func (h *Handler) handleExec(msg Message) Message {
response := Message{
Type: "response",
ID: msg.ID,
}
// Parameter extrahieren
command, ok := msg.Params["command"].(string)
if !ok || command == "" {
response.Status = "error"
response.Error = "Parameter 'command' erforderlich"
return response
}
timeoutSec := 30 // Default
if t, ok := msg.Params["timeout"]; ok {
if ts, ok := t.(float64); ok {
timeoutSec = int(ts)
} else if ts, ok := t.(string); ok {
if parsed, err := strconv.Atoi(ts); err == nil {
timeoutSec = parsed
}
}
}
// Command ausführen mit Timeout
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
defer cancel()
// FreeBSD/OPNsense nutzt /bin/sh
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
output, err := cmd.CombinedOutput()
if err != nil {
response.Status = "error"
response.Error = err.Error()
response.Data = map[string]interface{}{
"output": string(output),
}
} else {
response.Status = "ok"
response.Data = map[string]interface{}{
"output": string(output),
}
}
return response
}
func (h *Handler) handleTunnelOpen(msg Message) Message {
response := Message{
Type: "response",
ID: msg.ID,
}
// Parameter extrahieren
targetHost, ok := msg.Params["target_host"].(string)
if !ok || targetHost == "" {
response.Status = "error"
response.Error = "Parameter 'target_host' erforderlich"
return response
}
targetPortFloat, ok := msg.Params["target_port"].(float64)
if !ok {
response.Status = "error"
response.Error = "Parameter 'target_port' erforderlich"
return response
}
targetPort := int(targetPortFloat)
// Tunnel öffnen
tunnelID, err := h.client.tunnelManager.OpenTunnel(targetHost, targetPort)
if err != nil {
response.Status = "error"
response.Error = fmt.Sprintf("Tunnel öffnen fehlgeschlagen: %v", err)
return response
}
response.Status = "ok"
response.Data = map[string]interface{}{
"tunnel_id": tunnelID,
}
log.Printf("Tunnel geöffnet: %s -> %s:%d", tunnelID, targetHost, targetPort)
return response
}
func (h *Handler) handleTunnelClose(msg Message) Message {
response := Message{
Type: "response",
ID: msg.ID,
}
// Tunnel-ID extrahieren
tunnelID, ok := msg.Params["tunnel_id"].(string)
if !ok || tunnelID == "" {
response.Status = "error"
response.Error = "Parameter 'tunnel_id' erforderlich"
return response
}
// Tunnel schließen
if err := h.client.tunnelManager.CloseTunnel(tunnelID); err != nil {
response.Status = "error"
response.Error = fmt.Sprintf("Tunnel schließen fehlgeschlagen: %v", err)
return response
}
response.Status = "ok"
log.Printf("Tunnel geschlossen: %s", tunnelID)
return response
}
func (h *Handler) handleTunnelData(msg Message) {
// Diese Funktion wird für JSON-basierte Tunnel-Daten verwendet
// In der Praxis verwenden wir Binary WebSocket Messages für bessere Performance
tunnelID, ok := msg.Params["tunnel_id"].(string)
if !ok {
log.Printf("Tunnel-Daten ohne tunnel_id erhalten")
return
}
dataStr, ok := msg.Params["data"].(string)
if !ok {
log.Printf("Tunnel-Daten ohne data erhalten")
return
}
// Base64-Dekodierung würde hier stattfinden
// Aber wir verwenden Binary Messages, daher ist das nur ein Fallback
log.Printf("Tunnel-Daten via JSON erhalten für %s (Länge: %d)", tunnelID, len(dataStr))
}
// SendTunnelData sendet Tunnel-Daten als Binary-Message zurück zum Backend
func (h *Handler) SendTunnelData(tunnelID string, data []byte) error {
// Format: [tunnel_id_length:1][tunnel_id][data]
tunnelIDBytes := []byte(tunnelID)
if len(tunnelIDBytes) > 255 {
return fmt.Errorf("tunnel_id zu lang")
}
message := make([]byte, 1+len(tunnelIDBytes)+len(data))
message[0] = byte(len(tunnelIDBytes))
copy(message[1:], tunnelIDBytes)
copy(message[1+len(tunnelIDBytes):], data)
return h.client.SendBinaryData(message)
}
// ParseTunnelData parst eingehende Binary-Messages mit Tunnel-Daten
func ParseTunnelData(data []byte) (tunnelID string, payload []byte, err error) {
if len(data) < 1 {
return "", nil, fmt.Errorf("Daten zu kurz")
}
tunnelIDLen := int(data[0])
if len(data) < 1+tunnelIDLen {
return "", nil, fmt.Errorf("Tunnel-ID zu kurz")
}
tunnelID = string(data[1 : 1+tunnelIDLen])
payload = data[1+tunnelIDLen:]
return tunnelID, payload, nil
}

207
agent/ws/tunnel.go Normal file
View File

@ -0,0 +1,207 @@
package ws
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log"
"net"
"sync"
"time"
)
type Tunnel struct {
ID string
TargetHost string
TargetPort int
Conn net.Conn
Active bool
CreatedAt time.Time
}
type TunnelManager struct {
client *Client
tunnels map[string]*Tunnel
mutex sync.RWMutex
}
func NewTunnelManager(client *Client) *TunnelManager {
return &TunnelManager{
client: client,
tunnels: make(map[string]*Tunnel),
}
}
func (tm *TunnelManager) OpenTunnel(targetHost string, targetPort int) (string, error) {
// Eindeutige Tunnel-ID generieren
tunnelID := tm.generateTunnelID()
// Verbindung zum Ziel aufbauen
target := fmt.Sprintf("%s:%d", targetHost, targetPort)
conn, err := net.DialTimeout("tcp", target, 10*time.Second)
if err != nil {
return "", fmt.Errorf("Verbindung zu %s fehlgeschlagen: %w", target, err)
}
// Tunnel erstellen
tunnel := &Tunnel{
ID: tunnelID,
TargetHost: targetHost,
TargetPort: targetPort,
Conn: conn,
Active: true,
CreatedAt: time.Now(),
}
// Tunnel registrieren
tm.mutex.Lock()
tm.tunnels[tunnelID] = tunnel
tm.mutex.Unlock()
// Data-Forwarding starten
go tm.forwardData(tunnel)
return tunnelID, nil
}
func (tm *TunnelManager) CloseTunnel(tunnelID string) error {
tm.mutex.Lock()
tunnel, exists := tm.tunnels[tunnelID]
if !exists {
tm.mutex.Unlock()
return fmt.Errorf("Tunnel %s nicht gefunden", tunnelID)
}
tunnel.Active = false
delete(tm.tunnels, tunnelID)
tm.mutex.Unlock()
// Verbindung schließen
if tunnel.Conn != nil {
tunnel.Conn.Close()
}
return nil
}
func (tm *TunnelManager) GetActiveTunnels() []string {
tm.mutex.RLock()
defer tm.mutex.RUnlock()
tunnels := make([]string, 0, len(tm.tunnels))
for id := range tm.tunnels {
tunnels = append(tunnels, id)
}
return tunnels
}
func (tm *TunnelManager) SendDataToTunnel(tunnelID string, data []byte) error {
tm.mutex.RLock()
tunnel, exists := tm.tunnels[tunnelID]
tm.mutex.RUnlock()
if !exists || !tunnel.Active {
return fmt.Errorf("Tunnel %s nicht aktiv", tunnelID)
}
if tunnel.Conn == nil {
return fmt.Errorf("Tunnel %s hat keine Verbindung", tunnelID)
}
_, err := tunnel.Conn.Write(data)
if err != nil {
// Bei Fehler Tunnel schließen
tm.CloseTunnel(tunnelID)
return fmt.Errorf("Daten schreiben fehlgeschlagen: %w", err)
}
return nil
}
func (tm *TunnelManager) forwardData(tunnel *Tunnel) {
defer func() {
if tunnel.Active {
tm.CloseTunnel(tunnel.ID)
}
// Event senden: Tunnel geschlossen
event := Message{
Type: "event",
Event: "tunnel_closed",
Data: map[string]interface{}{
"tunnel_id": tunnel.ID,
},
}
tm.client.SendMessage(event)
log.Printf("Tunnel %s: Data-Forwarding beendet", tunnel.ID)
}()
buffer := make([]byte, 32768) // 32KB Buffer
for tunnel.Active {
// Read-Timeout setzen um regelmäßig Active-Status zu prüfen
tunnel.Conn.SetReadDeadline(time.Now().Add(5 * time.Second))
n, err := tunnel.Conn.Read(buffer)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
// Timeout ist OK, einfach weiter versuchen
continue
}
if err == io.EOF {
log.Printf("Tunnel %s: Ziel-Verbindung geschlossen", tunnel.ID)
} else {
log.Printf("Tunnel %s: Read-Fehler: %v", tunnel.ID, err)
}
break
}
if n > 0 {
// Daten über WebSocket an Backend weiterleiten
data := buffer[:n]
if err := tm.client.messageHandler.SendTunnelData(tunnel.ID, data); err != nil {
log.Printf("Tunnel %s: WebSocket senden fehlgeschlagen: %v", tunnel.ID, err)
break
}
}
}
}
func (tm *TunnelManager) generateTunnelID() string {
bytes := make([]byte, 8)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
// ProcessBinaryMessage verarbeitet eingehende Binary-Messages vom Backend
func (tm *TunnelManager) ProcessBinaryMessage(data []byte) error {
tunnelID, payload, err := ParseTunnelData(data)
if err != nil {
return fmt.Errorf("Binary-Message parsen: %w", err)
}
return tm.SendDataToTunnel(tunnelID, payload)
}
// CleanupInactiveTunnels schließt inaktive Tunnel (Cleanup-Task)
func (tm *TunnelManager) CleanupInactiveTunnels() {
tm.mutex.Lock()
defer tm.mutex.Unlock()
now := time.Now()
for id, tunnel := range tm.tunnels {
// Tunnel die länger als 1 Stunde inaktiv sind, schließen
if !tunnel.Active || now.Sub(tunnel.CreatedAt) > time.Hour {
tunnel.Active = false
if tunnel.Conn != nil {
tunnel.Conn.Close()
}
delete(tm.tunnels, id)
log.Printf("Inaktiver Tunnel %s bereinigt", id)
}
}
}

204
backend/api/handlers.go Normal file
View File

@ -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"})
}

33
backend/api/middleware.go Normal file
View File

@ -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))
})
}

49
backend/config/config.go Normal file
View File

@ -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
}

235
backend/db/sqlite.go Normal file
View File

@ -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, &regAt, &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, &regAt, &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, &regAt, &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
}

25
backend/go.mod Normal file
View File

@ -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
)

45
backend/go.sum Normal file
View File

@ -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=

132
backend/main.go Normal file
View File

@ -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
}

29
backend/models/agent.go Normal file
View File

@ -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"`
}

164
backend/models/system.go Normal file
View File

@ -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"`
}