Initial commit — cynfo RMM (clean history, no secrets)

This commit is contained in:
cynfo3000 2026-03-06 09:18:45 +01:00
parent d4b6ca86e5
commit 48ac353d28
35 changed files with 732 additions and 52 deletions

54
.gitignore vendored
View File

@ -1,16 +1,44 @@
build/
rmm-agent
rmm-backend
*.db
*.log
*.pid
# ========================
# Konfigurationen mit Passwörtern — NIEMALS einchecken
# ========================
config.yaml
beispielbilder/
certs/
opnsense-plugin/stage/
opnsense-plugin/*.pkg
frontend/node_modules/
frontend/dist/
agent/config.yaml
backend/config.yaml
frontend/src/config.js
opnsense-plugin/src/opnsense/service/templates/OPNsense/RMMAgent/config.yaml
# Beispiel-Configs sind erlaubt
!config.yaml.example
!**/config.yaml.example
# ========================
# Zertifikate & Secrets
# ========================
certs/
*.pem
*.key
*.crt
*.p12
# ========================
# Kompilierte Binaries
# ========================
backend/rmm-backend
agent/rmm-agent
agent-linux/rmm-agent
build/
updater/rmm-updater
# ========================
# Frontend Build
# ========================
frontend/node_modules/
frontend/dist/
# ========================
# OS / Editor
# ========================
.DS_Store
*.swp
*.swo
.idea/
.vscode/

View File

@ -1,9 +1,13 @@
package collector
import (
"encoding/json"
"io"
"net/http"
"os"
"os/exec"
"strings"
"time"
)
type CaddySite struct {
@ -12,12 +16,21 @@ type CaddySite struct {
TLS bool `json:"tls"`
}
type CaddyUpstream struct {
Address string `json:"address"`
NumRequests int `json:"num_requests"`
Fails int `json:"fails"`
Healthy bool `json:"healthy"`
}
type CaddyData struct {
Version string `json:"version"`
Sites []CaddySite `json:"sites"`
Version string `json:"version"`
Sites []CaddySite `json:"sites"`
Upstreams []CaddyUpstream `json:"upstreams,omitempty"`
}
const caddyFile = "/usr/local/etc/caddy/Caddyfile"
const caddyAdminAPI = "http://127.0.0.1:2019"
func CollectCaddy() *CaddyData {
if _, err := os.Stat(caddyFile); err != nil {
@ -34,7 +47,7 @@ func CollectCaddy() *CaddyData {
}
}
// Parse Caddyfile
// Parse Caddyfile fuer Sites
content, err := os.ReadFile(caddyFile)
if err != nil {
return data
@ -51,7 +64,6 @@ func CollectCaddy() *CaddyData {
continue
}
// Check for domain block start: "domain.com {" or "http://domain.com {"
if depth == 0 && strings.Contains(trimmed, "{") {
parts := strings.Fields(trimmed)
if len(parts) >= 2 && parts[len(parts)-1] == "{" {
@ -63,7 +75,6 @@ func CollectCaddy() *CaddyData {
}
if depth > 0 {
// Track nested braces
depth += strings.Count(trimmed, "{") - strings.Count(trimmed, "}")
if strings.HasPrefix(trimmed, "reverse_proxy") {
@ -85,5 +96,43 @@ func CollectCaddy() *CaddyData {
}
}
// Admin-API: Upstream-Statistiken
data.Upstreams = collectCaddyUpstreams()
return data
}
func collectCaddyUpstreams() []CaddyUpstream {
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(caddyAdminAPI + "/api/reverse_proxy/upstreams")
if err != nil {
return nil
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return nil
}
// Admin-API liefert: [{"address":"host:port","num_requests":N,"fails":N}]
var raw []struct {
Address string `json:"address"`
NumRequests int `json:"num_requests"`
Fails int `json:"fails"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil
}
upstreams := make([]CaddyUpstream, 0, len(raw))
for _, u := range raw {
upstreams = append(upstreams, CaddyUpstream{
Address: u.Address,
NumRequests: u.NumRequests,
Fails: u.Fails,
Healthy: u.Fails == 0,
})
}
return upstreams
}

View File

@ -8,6 +8,7 @@ import (
"fmt"
"io"
"log"
"strings"
"net/http"
"github.com/cynfo/rmm-backend/ws"
@ -32,12 +33,32 @@ func (h *Handler) uploadFirmware(w http.ResponseWriter, r *http.Request) {
return
}
// Binary aus Body lesen (max 50MB)
// Binary lesen — Multipart (curl -F) oder raw Body (Content-Type: application/octet-stream)
r.Body = http.MaxBytesReader(w, r.Body, 50*1024*1024)
binary, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "Binary lesen fehlgeschlagen")
return
var binary []byte
if ct := r.Header.Get("Content-Type"); strings.HasPrefix(ct, "multipart/") {
if err := r.ParseMultipartForm(50 << 20); err != nil {
writeError(w, http.StatusBadRequest, "Multipart-Parse fehlgeschlagen")
return
}
file, _, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "Feld 'file' fehlt im Multipart-Form")
return
}
defer file.Close()
binary, err = io.ReadAll(file)
if err != nil {
writeError(w, http.StatusBadRequest, "Binary lesen fehlgeschlagen")
return
}
} else {
var err error
binary, err = io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "Binary lesen fehlgeschlagen")
return
}
}
if len(binary) == 0 {

View File

@ -16,7 +16,8 @@ import (
)
type Handler struct {
db *db.Database
db *db.Database
scheduler *TaskScheduler
}
func NewHandler(database *db.Database) *Handler {
@ -86,7 +87,9 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) {
mux.HandleFunc("POST /api/v1/tasks", h.createTask(hub))
mux.HandleFunc("DELETE /api/v1/tasks/{id}", h.deleteTask)
mux.HandleFunc("POST /api/v1/tasks/{id}/cancel", h.cancelTask)
mux.HandleFunc("PUT /api/v1/tasks/{id}", h.updateTask)
mux.HandleFunc("PUT /api/v1/tasks/{id}/toggle", h.toggleTask)
mux.HandleFunc("POST /api/v1/tasks/{id}/run", h.runTaskNow())
// WebSocket und Tunnel-Routes
h.setupTunnelRoutes(mux, hub)

View File

@ -275,8 +275,56 @@ func (s *TaskScheduler) executeUpdateWithHealthCheck(agentID string, reboot bool
return result, nil
}
func (s *TaskScheduler) sendWebhook(taskID int, url string, task interface{}) {
payload, err := json.Marshal(task)
// WebhookPayload ist der strukturierte Payload fuer ERP-Serviceberichte
type WebhookPayload struct {
CustomerID string `json:"customer_id"`
Hostname string `json:"hostname"`
OPNsenseVersion string `json:"opnsense_version"`
Timestamp string `json:"timestamp"`
Trigger string `json:"trigger"`
Reachable bool `json:"reachable"`
UpdateStatus string `json:"update_status"`
}
func (s *TaskScheduler) sendWebhook(taskID int, url string, task *models.Task) {
// Agent-Daten aus DB holen (hostname, opnsense_version)
agent, _, err := s.db.GetAgent(task.AgentID)
if err != nil || agent == nil {
log.Printf("Scheduler: Webhook Task #%d — Agent %s nicht gefunden: %v", taskID, task.AgentID, err)
s.db.SetTaskWebhookResult(taskID, false, fmt.Sprintf("Agent nicht gefunden: %v", err))
return
}
// Erreichbarkeit aus Health-Check-Result extrahieren
reachable := true
if result, ok := task.Result.(map[string]interface{}); ok {
if hc, ok := result["health_check"].(map[string]interface{}); ok {
if online, ok := hc["agent_online"].(bool); ok {
reachable = online
}
}
}
// Trigger aus Action ableiten
trigger := task.Action + "_completed"
// Timestamp: FinishedAt oder jetzt
timestamp := time.Now().UTC().Format(time.RFC3339)
if task.FinishedAt != nil && *task.FinishedAt != "" {
timestamp = *task.FinishedAt
}
payload := WebhookPayload{
CustomerID: task.CustomerNumber,
Hostname: agent.Hostname,
OPNsenseVersion: agent.OPNsenseVersion,
Timestamp: timestamp,
Trigger: trigger,
Reachable: reachable,
UpdateStatus: task.Status,
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
log.Printf("Scheduler: Webhook-Payload fuer Task #%d serialisieren fehlgeschlagen: %v", taskID, err)
s.db.SetTaskWebhookResult(taskID, false, fmt.Sprintf("JSON-Fehler: %v", err))
@ -284,7 +332,7 @@ func (s *TaskScheduler) sendWebhook(taskID int, url string, task interface{}) {
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Post(url, "application/json", bytes.NewReader(payload))
resp, err := client.Post(url, "application/json", bytes.NewReader(payloadJSON))
if err != nil {
log.Printf("Scheduler: Webhook fuer Task #%d fehlgeschlagen: %v", taskID, err)
s.db.SetTaskWebhookResult(taskID, false, fmt.Sprintf("Fehler: %v", err))

View File

@ -180,6 +180,90 @@ func (h *Handler) cancelTask(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"message": "Task abgebrochen"})
}
// PUT /api/v1/tasks/{id}
func (h *Handler) updateTask(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungueltige Task-ID")
return
}
var req struct {
Name string `json:"name"`
ScheduleType string `json:"schedule_type"`
ScheduleTime string `json:"schedule_time"`
ScheduleWeekday *int `json:"schedule_weekday"`
ScheduleMonthday *int `json:"schedule_monthday"`
Reboot bool `json:"reboot"`
HealthCheck bool `json:"health_check"`
HealthCheckTimeout int `json:"health_check_timeout"`
WebhookURL string `json:"webhook_url"`
Active *bool `json:"active"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungueltige Anfrage")
return
}
active := true
if req.Active != nil {
active = *req.Active
}
if req.HealthCheckTimeout <= 0 {
req.HealthCheckTimeout = 600
}
task, err := h.db.UpdateTask(id, req.Name, req.ScheduleType, req.ScheduleTime,
req.ScheduleWeekday, req.ScheduleMonthday,
req.Reboot, req.HealthCheck, req.HealthCheckTimeout, req.WebhookURL, active)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler: "+err.Error())
return
}
if task == nil {
writeError(w, http.StatusNotFound, "Task nicht gefunden")
return
}
h.auditLog(r, "task.update", "task", idStr, "", "")
writeJSON(w, http.StatusOK, task)
}
// SetScheduler setzt den Scheduler im Handler (fuer Run-Now)
func (h *Handler) SetScheduler(s *TaskScheduler) {
h.scheduler = s
}
// POST /api/v1/tasks/{id}/run — Task sofort ausfuehren
func (h *Handler) runTaskNow() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungueltige Task-ID")
return
}
task, err := h.db.GetTask(id)
if err != nil || task == nil {
writeError(w, http.StatusNotFound, "Task nicht gefunden")
return
}
if h.scheduler == nil {
writeError(w, http.StatusInternalServerError, "Scheduler nicht verfuegbar")
return
}
// Sofort im Hintergrund starten
go h.scheduler.executeTask(*task)
h.auditLog(r, "task.run_now", "task", idStr, "", task.Action)
writeJSON(w, http.StatusOK, map[string]string{"message": "Task gestartet"})
}
}
// DELETE /api/v1/tasks/{id}
func (h *Handler) deleteTask(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")

View File

@ -422,6 +422,27 @@ func (d *Database) UpdateTaskStatusDirect(id int, status string) error {
return err
}
func (d *Database) UpdateTask(id int, name, scheduleType, scheduleTime string, weekday, monthday *int,
reboot, healthCheck bool, healthCheckTimeout int, webhookURL string, active bool) (*models.Task, error) {
now := time.Now()
nextRun := CalculateNextRun(scheduleType, scheduleTime, weekday, monthday, now)
_, err := d.db.Exec(`
UPDATE tasks SET
name = $1, schedule_type = $2, schedule_time = $3,
schedule_weekday = $4, schedule_monthday = $5,
reboot = $6, health_check = $7, health_check_timeout = $8,
webhook_url = $9, active = $10, next_run = $11
WHERE id = $12
`, name, scheduleType, scheduleTime, weekday, monthday,
reboot, healthCheck, healthCheckTimeout, webhookURL, active, nextRun, id)
if err != nil {
return nil, fmt.Errorf("Task aktualisieren: %w", err)
}
return d.GetTask(id)
}
func (d *Database) DeleteTask(id int) error {
res, err := d.db.Exec("DELETE FROM tasks WHERE id = $1", id)
if err != nil {

View File

@ -78,6 +78,7 @@ func main() {
// Geschuetzte Routes
protectedMux := http.NewServeMux()
handler := api.NewHandler(database)
handler.SetScheduler(scheduler)
handler.SetupRoutes(protectedMux, hub)
// GET /api/v1/auth/me braucht JWT
protectedMux.HandleFunc("GET /api/v1/auth/me", authHandler.Me)

View File

@ -264,9 +264,17 @@ type CaddySite struct {
TLS bool `json:"tls"`
}
type CaddyUpstream struct {
Address string `json:"address"`
NumRequests int `json:"num_requests"`
Fails int `json:"fails"`
Healthy bool `json:"healthy"`
}
type CaddyData struct {
Version string `json:"version"`
Sites []CaddySite `json:"sites"`
Version string `json:"version"`
Sites []CaddySite `json:"sites"`
Upstreams []CaddyUpstream `json:"upstreams,omitempty"`
}
// HeartbeatRequest

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

221
beispielbilder/Lastenheft Normal file
View File

@ -0,0 +1,221 @@
# Lastenheft OPNsense Zentralmanagement Agent
## 1. Zielsetzung
Ziel dieses Projektes ist die Entwicklung eines **OPNsense Zentralmanagement-Agents**, der es einer externen Management und MonitoringPlattform ermöglicht, weitreichende Systeminformationen auszulesen sowie definierte Systemaktionen und Änderungen auf OPNsenseFirewalls durchzuführen.
Da die bestehende OPNsenseAPI für den vorgesehenen Funktionsumfang nicht ausreichend ist, soll ein **Agent-basiertes Architekturmodell** umgesetzt werden. Der Agent stellt eine sichere, persistente Verbindung zu einem zentralen Backend her und fungiert als Kontroll, Informations und TunnelEndpunkt.
im Ordner beispielbilder sind vergleichbare Portale
## 2. Ausgangssituation
* Mehrere OPNsenseFirewalls (verschiedene Versionen, Standorte, Kunden)
* Zentrale Verwaltungsoberfläche (ähnlich den gezeigten UIMockups)
* Bestehende OPNsenseAPI liefert nicht alle benötigten Informationen
* Bedarf an:
* SystemTransparenz
* Fernzugriff (SSH / WebUI)
* Zentralem Backup und Zertifikatsmanagement
## 3. Gesamtarchitektur
### 3.1 Komponenten
#### 3.1.1 OPNsense Agent (pro Firewall)
* Läuft lokal auf der OPNsense (Plugin oder Service)
* Baut **initiativ** eine Verbindung zum Backend auf (Outbound only)
* Liefert Systemdaten
* Führt Kommandos und Aktionen kontrolliert aus
#### 3.1.2 Backend Server (Control Plane)
* Zentrale Kommunikations und Steuerinstanz
* Hält persistente AgentVerbindungen
* Stellt APIs für das Frontend bereit
* Baut Tunnel (SSH / HTTPS) zu den Firewalls über den Agent auf
#### 3.1.3 Frontend Server (UI / Webinterface)
* WebUI für Administratoren
* Darstellung der Firewalls (Übersichten, Details, Status)
* Steuerung von Aktionen (Tunnel, Backups, Reloads)
## 4. Funktionale Anforderungen Agent
### 4.1 Hardware und Systeminformationen
Der Agent muss folgende Informationen bereitstellen:
* Hersteller
* Modell
* Seriennummer
* BIOSVersion
* CPU (Typ, Kerne, Threads, Takt)
* RAM (gesamt, frei)
* Massenspeicher (Typ, Kapazität, Belegung)
* OPNsenseVersion
* Uptime
### 4.2 Dienste und Status
Der Agent muss:
* Alle Systemdienste auflisten
* Status je Dienst liefern (running / stopped)
* Dienst neu starten / stoppen / starten können (optional konfigurierbar)
Beispiele:
* configd
* dhcpd / kea
* unbound
* openvpn
* wireguard
* cron
### 4.3 NetzwerkInterfaces
Der Agent muss:
* Alle Interfaces anzeigen
* Rolle (WAN / LAN / OPT)
* IPAdresse(n)
* MACAdresse
* RX/TXTraffic
* Status (up/down)
### 4.4 VPNInformationen
#### 4.4.1 WireGuard
* Tunnelname
* Peers
* Status (up/down)
* Transferstatistiken
#### 4.4.2 OpenVPN
* Server / ClientInstanzen
* Verbindungsstatus
* RoadWarriorSessions
### 4.5 Routing
* Anzeige der RoutingTabelle
* Zielnetz
* Gateway
* Interface
* Typ (statisch / dynamisch)
### 4.6 DHCP (KEA)
* Aktive DHCPInstanzen
* Pools
* Leases (IPv4 / IPv6)
* LeaseStatus
* Zuordnung MAC ↔ IP
### 4.7 Zertifikate
* Auflistung aller Zertifikate
* Typ (CA, Server, User, ACME)
* Gültigkeitszeitraum
* Ablaufwarnungen
* Zertifikat neu laden / erneuern (optional)
### 4.8 Backups
Der Agent muss:
* KonfigurationsBackups erstellen können
* Backup lokal zwischenspeichern
* Backup an Backend übertragen
* Metadaten liefern (Größe, Zeitstempel)
## 5. TunnelFunktionalität
### 5.1 Ziel
Das Backend soll ondemand Tunnel zu einer Firewall aufbauen können, ohne dass direkte eingehende Verbindungen zur Firewall notwendig sind.
### 5.2 Tunnelarten
* SSHTunnel
* HTTPSTunnel (WebUI)
### 5.3 Funktionsweise
* Agent hält persistente Verbindung zum Backend
* Backend fordert Tunnel an
* Agent öffnet lokalen Zielport (z.B. 127.0.0.1:22 oder 443)
* Agent erstellt ReverseTunnel
* Backend stellt öffentlichen / internen Zugriffspunkt bereit
### 5.4 Anforderungen an Tunnel
* Vergleichbar mit **stunnel**
* Konfigurierbar:
* Zielport
* Protokoll
* Laufzeit
* Zugriffsbeschränkung
* Frontend zeigt klickbaren Link zum Tunnel
## 6. Sicherheit & Compliance
### 6.1 Kommunikation
* TLSverschlüsselt (mTLS bevorzugt)
* Agent authentifiziert sich eindeutig
* Zertifikatsbasierte Identität
### 6.2 Rechte & Rollen
* Agent darf nur explizit erlaubte Aktionen ausführen
* Rollenmodell im Backend (Admin, ReadOnly, Operator)
### 6.3 Logging & Audit
* Alle Aktionen werden protokolliert
* TunnelNutzung wird geloggt
* Konfigurationsänderungen nachvollziehbar
## 7. Nichtfunktionale Anforderungen
### 7.1 Performance
* Geringe Last auf OPNsense
* Asynchrone Abfragen
### 7.2 Stabilität
* Agent überlebt OPNsenseUpdates
* Automatische ReconnectLogik
### 7.3 Skalierbarkeit
* Unterstützung für viele hundert Firewalls
* Mandantenfähigkeit (Kunden / Standorte)
## 8. Abgrenzung
Nicht Bestandteil dieses Lastenhefts:
* Vollständige FirewallRuleBearbeitung
* Deep Packet Inspection
* IDS/IPSRegelverwaltung
## 9. Abnahmekriterien
* Alle genannten Informationspunkte sind im Frontend sichtbar
* Tunnel können ondemand aufgebaut und beendet werden
* Backups sind abrufbar und wiederherstellbar
* Agent funktioniert mit mehreren OPNsenseVersionen
---
*Dieses Lastenheft basiert auf den bereitgestellten Screenshots, der beschriebenen Zielarchitektur und den funktionalen Anforderungen des Auftraggebers.*

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

14
deploy-frontend.sh Executable file
View File

@ -0,0 +1,14 @@
#!/bin/bash
set -e
FRONTEND_DIR="$(dirname "$0")/frontend"
REMOTE="root@192.168.85.20"
WEB_ROOT="/var/www/html"
echo "Building..."
cd "$FRONTEND_DIR" && npm run build
echo "Deploying..."
ssh "$REMOTE" "rm -rf ${WEB_ROOT}/assets/*"
scp -r dist/* "$REMOTE:${WEB_ROOT}/"
ssh "$REMOTE" "chmod -R 755 ${WEB_ROOT}/assets && chmod 644 ${WEB_ROOT}/assets/* ${WEB_ROOT}/index.html ${WEB_ROOT}/vite.svg 2>/dev/null; nginx -s reload"
echo "Done."

View File

@ -315,6 +315,14 @@ class ApiClient {
return this.del(`/api/v1/tasks/${id}`)
}
runTaskNow(id) {
return this.post(`/api/v1/tasks/${id}/run`)
}
updateTask(id, data) {
return this.put(`/api/v1/tasks/${id}`, data)
}
toggleTask(id) {
return this.put(`/api/v1/tasks/${id}/toggle`)
}

View File

@ -6,7 +6,7 @@ import { useSettingsStore } from '../stores/settings'
import {
X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route,
Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone, Ban,
FileKey, UserCheck, Database, CalendarClock, Cable, Settings, Server, GitBranch, Play, Power, ChevronDown, ChevronRight,
FileKey, UserCheck, Database, CalendarClock, Cable, Settings, Server, GitBranch, Play, Power, ChevronDown, ChevronRight, Pencil,
} from 'lucide-react'
// Two-level navigation structure (DATAZONE style)
@ -1621,6 +1621,7 @@ function TasksTab({ agentId, agentName }) {
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [showDialog, setShowDialog] = useState(false)
const [editTask, setEditTask] = useState(null)
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
const [showWebhook, setShowWebhook] = useState(false)
@ -1631,6 +1632,45 @@ function TasksTab({ agentId, agentName }) {
reboot: false, health_check: false, health_check_timeout: 600,
webhook_url: '', active: true,
}
const jobTemplates = [
{
label: 'Wöchentliches Update',
icon: '🔄',
form: { name: 'Wöchentliches Update', action: 'update', schedule_type: 'weekly',
schedule_time: '02:00', schedule_weekday: 0, reboot: true, health_check: true,
health_check_timeout: 600, webhook_url: '', active: true },
},
{
label: 'Monatliches Update',
icon: '📅',
form: { name: 'Monatliches Update', action: 'update', schedule_type: 'monthly',
schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true,
health_check_timeout: 600, webhook_url: '', active: true },
},
{
label: 'Tägliches Backup',
icon: '💾',
form: { name: 'Tägliches Backup', action: 'backup', schedule_type: 'daily',
schedule_time: '03:00', reboot: false, health_check: false,
health_check_timeout: 600, webhook_url: '', active: true },
},
{
label: 'Monatliches Backup',
icon: '🗄️',
form: { name: 'Monatliches Backup', action: 'backup', schedule_type: 'monthly',
schedule_time: '03:00', schedule_monthday: 1, reboot: false, health_check: false,
health_check_timeout: 600, webhook_url: '', active: true },
},
{
label: 'Update + ERP-Webhook',
icon: '🔗',
form: { name: 'Monatliches Update + Servicebericht', action: 'update', schedule_type: 'monthly',
schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true,
health_check_timeout: 600, webhook_url: '', active: true },
showWebhook: true,
},
]
const [form, setForm] = useState(defaultForm)
const loadTasks = () => {
@ -1669,9 +1709,47 @@ function TasksTab({ agentId, agentName }) {
if (!confirm('Job wirklich loeschen?')) return
try { await api.deleteTask(id); loadTasks() } catch (e) { setError(e.message) }
}
const handleEdit = (t) => {
setEditTask(t)
setForm({
name: t.name || '',
action: t.action,
schedule_type: t.schedule_type || 'once',
schedule_time: t.schedule_time || '02:00',
scheduled_at: '',
schedule_weekday: t.schedule_weekday ?? 1,
schedule_monthday: t.schedule_monthday ?? 1,
reboot: t.reboot || false,
health_check: t.health_check || false,
health_check_timeout: t.health_check_timeout || 600,
webhook_url: t.webhook_url || '',
active: t.active !== false,
})
setShowWebhook(!!t.webhook_url)
setShowDialog(true)
}
const handleSave = async () => {
setCreating(true); setError('')
try {
const data = {
name: form.name, schedule_type: form.schedule_type, schedule_time: form.schedule_time,
active: form.active, reboot: form.reboot, health_check: form.health_check,
health_check_timeout: parseInt(form.health_check_timeout) || 600,
webhook_url: form.webhook_url,
}
if (form.schedule_type === 'weekly') data.schedule_weekday = parseInt(form.schedule_weekday)
if (form.schedule_type === 'monthly') data.schedule_monthday = parseInt(form.schedule_monthday)
await api.updateTask(editTask.id, data)
setShowDialog(false); setEditTask(null); setForm(defaultForm); setShowWebhook(false); loadTasks()
} catch (e) { setError(e.message) } finally { setCreating(false) }
}
const handleRunNow = async (id) => {
// Trigger by creating a one-off copy? For now just toggle to trigger scheduler
// Simple approach: we don't have a "run now" endpoint, so we skip for now
try {
await api.runTaskNow(id)
setTimeout(loadTasks, 1500)
} catch (e) { setError(e.message) }
}
const weekdays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
@ -1702,9 +1780,24 @@ function TasksTab({ agentId, agentName }) {
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setShowDialog(false)}>
<div className="bg-gray-800 rounded-lg border border-gray-700 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-700">
<h3 className="text-white font-semibold">Neuer Job{agentName ? `${agentName}` : ''}</h3>
<button onClick={() => setShowDialog(false)} className="text-gray-400 hover:text-white"><X className="w-5 h-5" /></button>
<h3 className="text-white font-semibold">{editTask ? `Job bearbeiten — ${editTask.name || `#${editTask.id}`}` : `Neuer Job${agentName ? `${agentName}` : ''}`}</h3>
<button onClick={() => { setShowDialog(false); setEditTask(null) }} className="text-gray-400 hover:text-white"><X className="w-5 h-5" /></button>
</div>
{!editTask && (
<div className="px-5 pt-4 pb-2 border-b border-gray-700/50">
<p className="text-xs text-gray-500 mb-2">Vorlage wählen</p>
<div className="flex flex-wrap gap-2">
{jobTemplates.map((tpl, i) => (
<button key={i} onClick={() => {
setForm({ ...defaultForm, ...tpl.form, scheduled_at: '', schedule_weekday: tpl.form.schedule_weekday ?? 1, schedule_monthday: tpl.form.schedule_monthday ?? 1 })
setShowWebhook(!!tpl.showWebhook)
}} className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs text-gray-300 hover:text-white transition-colors">
{tpl.label}
</button>
))}
</div>
</div>
)}
<div className="px-5 py-4 space-y-4">
<div>
<label className={labelCls}>Name *</label>
@ -1713,7 +1806,7 @@ function TasksTab({ agentId, agentName }) {
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>Intervall *</label>
<select value={form.schedule_type} onChange={e => setForm({...form, schedule_type: e.target.value})} className={inputCls}>
<select value={form.schedule_type} onChange={e => setForm({...form, schedule_type: e.target.value})} className={inputCls} disabled={!!editTask}>
<option value="once">Einmalig</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
@ -1745,17 +1838,19 @@ function TasksTab({ agentId, agentName }) {
<input type="number" min="1" max="28" value={form.schedule_monthday} onChange={e => setForm({...form, schedule_monthday: e.target.value})} className={inputCls} />
</div>
)}
<div>
<label className={labelCls}>Aktion *</label>
<div className="flex gap-2">
{['backup', 'update'].map(a => (
<button key={a} onClick={() => setForm({...form, action: a})}
className={`flex-1 py-2 rounded text-sm font-medium transition-colors ${form.action === a ? 'bg-orange-600 text-white' : 'bg-gray-700 text-gray-400 hover:bg-gray-600'}`}>
{a === 'backup' ? 'Backup' : 'Update'}
</button>
))}
{!editTask && (
<div>
<label className={labelCls}>Aktion *</label>
<div className="flex gap-2">
{['backup', 'update'].map(a => (
<button key={a} onClick={() => setForm({...form, action: a})}
className={`flex-1 py-2 rounded text-sm font-medium transition-colors ${form.action === a ? 'bg-orange-600 text-white' : 'bg-gray-700 text-gray-400 hover:bg-gray-600'}`}>
{a === 'backup' ? 'Backup' : 'Update'}
</button>
))}
</div>
</div>
</div>
)}
{form.action === 'update' && (
<div className="space-y-2 pl-1">
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
@ -1779,7 +1874,10 @@ function TasksTab({ agentId, agentName }) {
{showWebhook ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />} Webhook-URL (optional)
</button>
{showWebhook && (
<input type="text" value={form.webhook_url} onChange={e => setForm({...form, webhook_url: e.target.value})} placeholder="https://..." className={inputCls + ' mt-1'} />
<div>
<input type="text" value={form.webhook_url} onChange={e => setForm({...form, webhook_url: e.target.value})} placeholder="https://erp.example.com/api/service-reports" className={inputCls + ' mt-1'} />
<p className="text-xs text-gray-500 mt-1">Empfangt: customer_id, hostname, opnsense_version, reachable, update_status</p>
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
@ -1789,9 +1887,9 @@ function TasksTab({ agentId, agentName }) {
{error && <div className="text-sm text-red-400">{error}</div>}
</div>
<div className="flex justify-end gap-2 px-5 py-4 border-t border-gray-700">
<button onClick={() => { setShowDialog(false); setError('') }} className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300 transition-colors">Abbrechen</button>
<button onClick={handleCreate} disabled={creating || !form.name} className="px-4 py-2 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-700 disabled:text-gray-500 rounded text-sm text-white transition-colors">
{creating ? 'Erstelle...' : 'Erstellen'}
<button onClick={() => { setShowDialog(false); setEditTask(null); setError('') }} className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300 transition-colors">Abbrechen</button>
<button onClick={editTask ? handleSave : handleCreate} disabled={creating || !form.name} className="px-4 py-2 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-700 disabled:text-gray-500 rounded text-sm text-white transition-colors">
{creating ? (editTask ? 'Speichere...' : 'Erstelle...') : (editTask ? 'Speichern' : 'Erstellen')}
</button>
</div>
</div>
@ -1814,6 +1912,7 @@ function TasksTab({ agentId, agentName }) {
<th className="px-3 py-2">Intervall</th>
<th className="px-3 py-2">Nächster Lauf</th>
<th className="px-3 py-2">Letzter Lauf</th>
<th className="px-3 py-2">Webhook</th>
<th className="px-3 py-2 text-right">Aktionen</th>
</tr>
</thead>
@ -1834,8 +1933,23 @@ function TasksTab({ agentId, agentName }) {
<td className="px-3 py-2 text-gray-400 text-xs">{fmtInterval(t)}</td>
<td className="px-3 py-2 text-gray-400 text-xs">{fmtDate(t.next_run)}</td>
<td className="px-3 py-2 text-gray-400 text-xs">{fmtDate(t.last_run)}</td>
<td className="px-3 py-2 text-xs">
{t.webhook_url ? (
t.webhook_sent
? <span className="text-green-400" title={t.webhook_response || 'Gesendet'}>OK</span>
: t.last_run
? <span className="text-red-400" title={t.webhook_response || 'Fehlgeschlagen'}>Fehler</span>
: <span className="text-gray-500"></span>
) : <span className="text-gray-600"></span>}
</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => handleEdit(t)} className="text-gray-400 hover:text-white p-1 rounded hover:bg-gray-700 transition-colors" title="Bearbeiten">
<Pencil className="w-4 h-4" />
</button>
<button onClick={() => handleRunNow(t.id)} className="text-blue-400 hover:text-blue-300 p-1 rounded hover:bg-blue-900/30 transition-colors" title="Jetzt ausführen" disabled={t.status === 'running'}>
<Play className="w-4 h-4" />
</button>
<button onClick={() => handleToggle(t.id)} className={`p-1 rounded transition-colors ${t.active ? 'text-green-400 hover:text-green-300 hover:bg-green-900/30' : 'text-gray-500 hover:text-gray-300 hover:bg-gray-700'}`} title={t.active ? 'Deaktivieren' : 'Aktivieren'}>
<Power className="w-4 h-4" />
</button>
@ -2380,12 +2494,55 @@ function CaddyTab({ sys }) {
const caddy = sys?.caddy
if (!caddy) return <div className="text-gray-500">Keine Caddy-Daten</div>
const upstreams = caddy.upstreams || []
const healthyCount = upstreams.filter(u => u.healthy).length
const activeRequests = upstreams.reduce((s, u) => s + (u.num_requests || 0), 0)
const totalFails = upstreams.reduce((s, u) => s + (u.fails || 0), 0)
return (
<div className="space-y-4">
<h3 className="text-sm font-medium text-gray-300">Caddy Reverse Proxy</h3>
{caddy.version && (
<div className="text-xs text-gray-400">Version: <span className="text-white font-mono">{caddy.version}</span></div>
<div className="grid grid-cols-4 gap-3">
<StatusCard icon={Server} value={caddy.version || '—'} label="Version" />
<StatusCard icon={Network} value={`${healthyCount} / ${upstreams.length}`} label="Upstreams OK" />
<StatusCard icon={Globe} value={activeRequests} label="Aktive Requests" />
<StatusCard value={totalFails} label="Fehler gesamt" />
</div>
{upstreams.length > 0 && (
<>
<h3 className="text-sm font-medium text-gray-300">Upstreams</h3>
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
<th className="px-3 py-2">Adresse</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Aktive Req.</th>
<th className="px-3 py-2">Fehler</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{upstreams.map((u, i) => (
<tr key={i}>
<td className="px-3 py-2 text-white font-mono">{u.address}</td>
<td className="px-3 py-2">
<span className={`text-xs px-1.5 py-0.5 rounded ${
u.healthy ? 'bg-green-900/50 text-green-400' : 'bg-red-900/50 text-red-400'
}`}>{u.healthy ? 'Healthy' : 'Unhealthy'}</span>
</td>
<td className="px-3 py-2 text-gray-400">{u.num_requests}</td>
<td className="px-3 py-2 text-gray-400">{u.fails}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
<h3 className="text-sm font-medium text-gray-300">Konfigurierte Sites</h3>
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
<table className="w-full text-sm">
<thead>

4
frontend/src/config.js Normal file
View File

@ -0,0 +1,4 @@
// Frontend-Konfiguration — bei Deployment anpassen
export const BACKEND_HOST = '192.168.85.13'
export const BACKEND_URL = `https://${BACKEND_HOST}:8443`
export const API_KEY = '01532e5a7c9e70bf2757df77a2f5b9b9'

Binary file not shown.

View File

@ -73,7 +73,20 @@ func stopAgent() error {
func startAgent() error {
switch runtime.GOOS {
case "freebsd":
return exec.Command("/usr/sbin/service", "rmm_agent", "start").Run()
// Versuche zuerst via rc.d service
err := exec.Command("/usr/sbin/service", "rmm_agent", "start").Run()
if err != nil {
// Fallback: direkt via daemon starten
log.Printf("service start fehlgeschlagen (%v), versuche daemon-Fallback", err)
err = exec.Command("/usr/sbin/daemon",
"-p", "/var/run/rmm-agent.pid",
"-r",
agentBinaryPath,
"--config", configPath,
"--insecure",
).Start()
}
return err
default:
return exec.Command("/usr/bin/systemctl", "start", "rmm-agent").Run()
}