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