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:
commit
6120d0cffa
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
build/
|
||||||
|
*.db
|
||||||
|
*.log
|
||||||
|
*.pid
|
||||||
|
config.yaml
|
||||||
|
beispielbilder/
|
||||||
|
certs/
|
||||||
221
Lastenheft.md
Normal file
221
Lastenheft.md
Normal 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 Monitoring‑Plattform ermöglicht, weitreichende Systeminformationen auszulesen sowie definierte Systemaktionen und Änderungen auf OPNsense‑Firewalls durchzuführen.
|
||||||
|
|
||||||
|
Da die bestehende OPNsense‑API für den vorgesehenen Funktionsumfang nicht ausreichend ist, soll ein **Agent-basiertes Architekturmodell** umgesetzt werden. Der Agent stellt eine sichere, persistente Verbindung zu einem zentralen Backend her und fungiert als Kontroll‑, Informations‑ und Tunnel‑Endpunkt.
|
||||||
|
|
||||||
|
Fur das frontent orientiere dich an dem vorschalg der Beispielbilder
|
||||||
|
|
||||||
|
## 2. Ausgangssituation
|
||||||
|
|
||||||
|
* Mehrere OPNsense‑Firewalls (verschiedene Versionen, Standorte, Kunden)
|
||||||
|
* Zentrale Verwaltungsoberfläche (ähnlich den gezeigten UI‑Mockups)
|
||||||
|
* Bestehende OPNsense‑API liefert nicht alle benötigten Informationen
|
||||||
|
* Bedarf an:
|
||||||
|
|
||||||
|
* System‑Transparenz
|
||||||
|
* Fernzugriff (SSH / WebUI)
|
||||||
|
* Zentralem Backup‑ und Zertifikatsmanagement
|
||||||
|
|
||||||
|
## 3. Gesamtarchitektur
|
||||||
|
|
||||||
|
### 3.1 Komponenten
|
||||||
|
|
||||||
|
#### 3.1.1 OPNsense Agent (pro Firewall)
|
||||||
|
|
||||||
|
* Läuft lokal auf der OPNsense (Plugin oder Service)
|
||||||
|
* Baut **initiativ** eine Verbindung zum Backend auf (Outbound only)
|
||||||
|
* Liefert Systemdaten
|
||||||
|
* Führt Kommandos und Aktionen kontrolliert aus
|
||||||
|
|
||||||
|
#### 3.1.2 Backend Server (Control Plane)
|
||||||
|
|
||||||
|
* Zentrale Kommunikations‑ und Steuerinstanz
|
||||||
|
* Hält persistente Agent‑Verbindungen
|
||||||
|
* Stellt APIs für das Frontend bereit
|
||||||
|
* Baut Tunnel (SSH / HTTPS) zu den Firewalls über den Agent auf
|
||||||
|
|
||||||
|
#### 3.1.3 Frontend Server (UI / Webinterface)
|
||||||
|
|
||||||
|
* Web‑UI für Administratoren
|
||||||
|
* Darstellung der Firewalls (Übersichten, Details, Status)
|
||||||
|
* Steuerung von Aktionen (Tunnel, Backups, Reloads)
|
||||||
|
|
||||||
|
## 4. Funktionale Anforderungen – Agent
|
||||||
|
|
||||||
|
### 4.1 Hardware‑ und Systeminformationen
|
||||||
|
|
||||||
|
Der Agent muss folgende Informationen bereitstellen:
|
||||||
|
|
||||||
|
* Hersteller
|
||||||
|
* Modell
|
||||||
|
* Seriennummer
|
||||||
|
* BIOS‑Version
|
||||||
|
* CPU (Typ, Kerne, Threads, Takt)
|
||||||
|
* RAM (gesamt, frei)
|
||||||
|
* Massenspeicher (Typ, Kapazität, Belegung)
|
||||||
|
* OPNsense‑Version
|
||||||
|
* Uptime
|
||||||
|
|
||||||
|
### 4.2 Dienste und Status
|
||||||
|
|
||||||
|
Der Agent muss:
|
||||||
|
|
||||||
|
* Alle Systemdienste auflisten
|
||||||
|
* Status je Dienst liefern (running / stopped)
|
||||||
|
* Dienst neu starten / stoppen / starten können (optional konfigurierbar)
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
* configd
|
||||||
|
* dhcpd / kea
|
||||||
|
* unbound
|
||||||
|
* openvpn
|
||||||
|
* wireguard
|
||||||
|
* cron
|
||||||
|
|
||||||
|
### 4.3 Netzwerk‑Interfaces
|
||||||
|
|
||||||
|
Der Agent muss:
|
||||||
|
|
||||||
|
* Alle Interfaces anzeigen
|
||||||
|
* Rolle (WAN / LAN / OPT)
|
||||||
|
* IP‑Adresse(n)
|
||||||
|
* MAC‑Adresse
|
||||||
|
* RX/TX‑Traffic
|
||||||
|
* Status (up/down)
|
||||||
|
|
||||||
|
### 4.4 VPN‑Informationen
|
||||||
|
|
||||||
|
#### 4.4.1 WireGuard
|
||||||
|
|
||||||
|
* Tunnelname
|
||||||
|
* Peers
|
||||||
|
* Status (up/down)
|
||||||
|
* Transferstatistiken
|
||||||
|
|
||||||
|
#### 4.4.2 OpenVPN
|
||||||
|
|
||||||
|
* Server / Client‑Instanzen
|
||||||
|
* Verbindungsstatus
|
||||||
|
* Road‑Warrior‑Sessions
|
||||||
|
|
||||||
|
### 4.5 Routing
|
||||||
|
|
||||||
|
* Anzeige der Routing‑Tabelle
|
||||||
|
* Zielnetz
|
||||||
|
* Gateway
|
||||||
|
* Interface
|
||||||
|
* Typ (statisch / dynamisch)
|
||||||
|
|
||||||
|
### 4.6 DHCP (KEA)
|
||||||
|
|
||||||
|
* Aktive DHCP‑Instanzen
|
||||||
|
* Pools
|
||||||
|
* Leases (IPv4 / IPv6)
|
||||||
|
* Lease‑Status
|
||||||
|
* Zuordnung MAC ↔ IP
|
||||||
|
|
||||||
|
### 4.7 Zertifikate
|
||||||
|
|
||||||
|
* Auflistung aller Zertifikate
|
||||||
|
* Typ (CA, Server, User, ACME)
|
||||||
|
* Gültigkeitszeitraum
|
||||||
|
* Ablaufwarnungen
|
||||||
|
* Zertifikat neu laden / erneuern (optional)
|
||||||
|
|
||||||
|
### 4.8 Backups
|
||||||
|
|
||||||
|
Der Agent muss:
|
||||||
|
|
||||||
|
* Konfigurations‑Backups erstellen können
|
||||||
|
* Backup lokal zwischenspeichern
|
||||||
|
* Backup an Backend übertragen
|
||||||
|
* Metadaten liefern (Größe, Zeitstempel)
|
||||||
|
|
||||||
|
## 5. Tunnel‑Funktionalität
|
||||||
|
|
||||||
|
### 5.1 Ziel
|
||||||
|
|
||||||
|
Das Backend soll on‑demand Tunnel zu einer Firewall aufbauen können, ohne dass direkte eingehende Verbindungen zur Firewall notwendig sind.
|
||||||
|
|
||||||
|
### 5.2 Tunnelarten
|
||||||
|
|
||||||
|
* SSH‑Tunnel
|
||||||
|
* HTTPS‑Tunnel (WebUI)
|
||||||
|
|
||||||
|
### 5.3 Funktionsweise
|
||||||
|
|
||||||
|
* Agent hält persistente Verbindung zum Backend
|
||||||
|
* Backend fordert Tunnel an
|
||||||
|
* Agent öffnet lokalen Zielport (z. B. 127.0.0.1:22 oder 443)
|
||||||
|
* Agent erstellt Reverse‑Tunnel
|
||||||
|
* Backend stellt öffentlichen / internen Zugriffspunkt bereit
|
||||||
|
|
||||||
|
### 5.4 Anforderungen an Tunnel
|
||||||
|
|
||||||
|
* Vergleichbar mit **stunnel**
|
||||||
|
* Konfigurierbar:
|
||||||
|
|
||||||
|
* Zielport
|
||||||
|
* Protokoll
|
||||||
|
* Laufzeit
|
||||||
|
* Zugriffsbeschränkung
|
||||||
|
* Frontend zeigt klickbaren Link zum Tunnel
|
||||||
|
|
||||||
|
## 6. Sicherheit & Compliance
|
||||||
|
|
||||||
|
### 6.1 Kommunikation
|
||||||
|
|
||||||
|
* TLS‑verschlüsselt (mTLS bevorzugt)
|
||||||
|
* Agent authentifiziert sich eindeutig
|
||||||
|
* Zertifikatsbasierte Identität
|
||||||
|
|
||||||
|
### 6.2 Rechte & Rollen
|
||||||
|
|
||||||
|
* Agent darf nur explizit erlaubte Aktionen ausführen
|
||||||
|
* Rollenmodell im Backend (Admin, Read‑Only, Operator)
|
||||||
|
|
||||||
|
### 6.3 Logging & Audit
|
||||||
|
|
||||||
|
* Alle Aktionen werden protokolliert
|
||||||
|
* Tunnel‑Nutzung wird geloggt
|
||||||
|
* Konfigurationsänderungen nachvollziehbar
|
||||||
|
|
||||||
|
## 7. Nicht‑funktionale Anforderungen
|
||||||
|
|
||||||
|
### 7.1 Performance
|
||||||
|
|
||||||
|
* Geringe Last auf OPNsense
|
||||||
|
* Asynchrone Abfragen
|
||||||
|
|
||||||
|
### 7.2 Stabilität
|
||||||
|
|
||||||
|
* Agent überlebt OPNsense‑Updates
|
||||||
|
* Automatische Reconnect‑Logik
|
||||||
|
|
||||||
|
### 7.3 Skalierbarkeit
|
||||||
|
|
||||||
|
* Unterstützung für viele hundert Firewalls
|
||||||
|
* Mandantenfähigkeit (Kunden / Standorte)
|
||||||
|
|
||||||
|
## 8. Abgrenzung
|
||||||
|
|
||||||
|
Nicht Bestandteil dieses Lastenhefts:
|
||||||
|
|
||||||
|
* Vollständige Firewall‑Rule‑Bearbeitung
|
||||||
|
* Deep Packet Inspection
|
||||||
|
* IDS/IPS‑Regelverwaltung
|
||||||
|
|
||||||
|
## 9. Abnahmekriterien
|
||||||
|
|
||||||
|
* Alle genannten Informationspunkte sind im Frontend sichtbar
|
||||||
|
* Tunnel können on‑demand aufgebaut und beendet werden
|
||||||
|
* Backups sind abrufbar und wiederherstellbar
|
||||||
|
* Agent funktioniert mit mehreren OPNsense‑Versionen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Dieses Lastenheft basiert auf den bereitgestellten Screenshots, der beschriebenen Zielarchitektur und den funktionalen Anforderungen des Auftraggebers.*
|
||||||
85
Makefile
Normal file
85
Makefile
Normal 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
322
README.md
Normal 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
95
agent/client/client.go
Normal 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)
|
||||||
|
}
|
||||||
203
agent/collector/certificates.go
Normal file
203
agent/collector/certificates.go
Normal 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
109
agent/collector/cpu.go
Normal 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
207
agent/collector/dhcp.go
Normal 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
62
agent/collector/disk.go
Normal 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
|
||||||
|
}
|
||||||
68
agent/collector/gateways.go
Normal file
68
agent/collector/gateways.go
Normal 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
|
||||||
|
}
|
||||||
55
agent/collector/hardware.go
Normal file
55
agent/collector/hardware.go
Normal 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
42
agent/collector/memory.go
Normal 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
178
agent/collector/network.go
Normal 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 ""
|
||||||
|
}
|
||||||
61
agent/collector/opnsense.go
Normal file
61
agent/collector/opnsense.go
Normal 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
|
||||||
|
}
|
||||||
64
agent/collector/plugins.go
Normal file
64
agent/collector/plugins.go
Normal 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, ""
|
||||||
|
}
|
||||||
61
agent/collector/routing.go
Normal file
61
agent/collector/routing.go
Normal 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
|
||||||
|
}
|
||||||
97
agent/collector/services.go
Normal file
97
agent/collector/services.go
Normal 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
|
||||||
|
}
|
||||||
88
agent/collector/updates.go
Normal file
88
agent/collector/updates.go
Normal 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
|
||||||
|
}
|
||||||
133
agent/collector/wireguard.go
Normal file
133
agent/collector/wireguard.go
Normal 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
33
agent/config/config.go
Normal 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
7
agent/go.mod
Normal 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
6
agent/go.sum
Normal 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
174
agent/main.go
Normal 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
277
agent/ws/client.go
Normal 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
214
agent/ws/handler.go
Normal 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
207
agent/ws/tunnel.go
Normal 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
204
backend/api/handlers.go
Normal 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
33
backend/api/middleware.go
Normal 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
49
backend/config/config.go
Normal 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
235
backend/db/sqlite.go
Normal 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, ®At, &lastHB); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.RegisteredAt, _ = time.Parse(time.RFC3339, regAt)
|
||||||
|
if lastHB.Valid {
|
||||||
|
t, _ := time.Parse(time.RFC3339, lastHB.String)
|
||||||
|
a.LastHeartbeat = &t
|
||||||
|
}
|
||||||
|
agents = append(agents, a)
|
||||||
|
}
|
||||||
|
return agents, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAgent - Einzelnen Agent mit Systemdaten
|
||||||
|
func (d *Database) GetAgentByHostname(hostname string) (*models.Agent, error) {
|
||||||
|
var a models.Agent
|
||||||
|
var regAt string
|
||||||
|
var lastHB sql.NullString
|
||||||
|
|
||||||
|
err := d.db.QueryRow("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat FROM agents WHERE hostname = ? LIMIT 1", hostname).
|
||||||
|
Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, ®At, &lastHB)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.RegisteredAt, _ = time.Parse(time.RFC3339, regAt)
|
||||||
|
if lastHB.Valid {
|
||||||
|
t, _ := time.Parse(time.RFC3339, lastHB.String)
|
||||||
|
a.LastHeartbeat = &t
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) GetAgent(id string) (*models.Agent, *models.SystemData, error) {
|
||||||
|
var a models.Agent
|
||||||
|
var regAt string
|
||||||
|
var lastHB sql.NullString
|
||||||
|
|
||||||
|
err := d.db.QueryRow("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat FROM agents WHERE id = ?", id).
|
||||||
|
Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, ®At, &lastHB)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
a.RegisteredAt, _ = time.Parse(time.RFC3339, regAt)
|
||||||
|
if lastHB.Valid {
|
||||||
|
t, _ := time.Parse(time.RFC3339, lastHB.String)
|
||||||
|
a.LastHeartbeat = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Systemdaten laden
|
||||||
|
var dataJSON sql.NullString
|
||||||
|
err = d.db.QueryRow("SELECT data_json FROM system_data WHERE agent_id = ?", id).Scan(&dataJSON)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var sysData *models.SystemData
|
||||||
|
if dataJSON.Valid {
|
||||||
|
sysData = &models.SystemData{}
|
||||||
|
if err := json.Unmarshal([]byte(dataJSON.String), sysData); err != nil {
|
||||||
|
return &a, nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &a, sysData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSystemData - Nur Systemdaten eines Agents
|
||||||
|
func (d *Database) GetSystemData(agentID string) (*models.SystemData, error) {
|
||||||
|
var dataJSON string
|
||||||
|
err := d.db.QueryRow("SELECT data_json FROM system_data WHERE agent_id = ?", agentID).Scan(&dataJSON)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var data models.SystemData
|
||||||
|
if err := json.Unmarshal([]byte(dataJSON), &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAgent - Agent und zugehoerige Daten loeschen
|
||||||
|
func (d *Database) DeleteAgent(id string) (bool, error) {
|
||||||
|
// Foreign Key Cascade aktivieren
|
||||||
|
d.db.Exec("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
res, err := d.db.Exec("DELETE FROM agents WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
rows, _ := res.RowsAffected()
|
||||||
|
// Sicherheitshalber auch system_data loeschen (falls kein CASCADE)
|
||||||
|
d.db.Exec("DELETE FROM system_data WHERE agent_id = ?", id)
|
||||||
|
return rows > 0, nil
|
||||||
|
}
|
||||||
25
backend/go.mod
Normal file
25
backend/go.mod
Normal 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
45
backend/go.sum
Normal 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
132
backend/main.go
Normal 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
29
backend/models/agent.go
Normal 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
164
backend/models/system.go
Normal 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"`
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user