Initial commit — cynfo RMM (clean history, no secrets)
54
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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"`
|
||||
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
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"net/http"
|
||||
|
||||
"github.com/cynfo/rmm-backend/ws"
|
||||
@ -32,13 +33,33 @@ 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)
|
||||
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 {
|
||||
writeError(w, http.StatusBadRequest, "Leeres Binary")
|
||||
|
||||
@ -17,6 +17,7 @@ import (
|
||||
|
||||
type Handler struct {
|
||||
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)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"`
|
||||
Upstreams []CaddyUpstream `json:"upstreams,omitempty"`
|
||||
}
|
||||
|
||||
// HeartbeatRequest
|
||||
|
||||
BIN
beispielbilder/Firewall_sec_wazuh.png
Normal file
|
After Width: | Height: | Size: 174 KiB |
221
beispielbilder/Lastenheft
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.
|
||||
|
||||
im Ordner beispielbilder sind vergleichbare Portale
|
||||
|
||||
## 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.*
|
||||
BIN
beispielbilder/firewal_dienste.png
Normal file
|
After Width: | Height: | Size: 343 KiB |
BIN
beispielbilder/firewall_agent.png
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
beispielbilder/firewall_backup.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
beispielbilder/firewall_check.png
Normal file
|
After Width: | Height: | Size: 312 KiB |
BIN
beispielbilder/firewall_dhcp.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
beispielbilder/firewall_interface.png
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
beispielbilder/firewall_loschen.png
Normal file
|
After Width: | Height: | Size: 260 KiB |
BIN
beispielbilder/firewall_portal_log.png
Normal file
|
After Width: | Height: | Size: 367 KiB |
BIN
beispielbilder/firewall_routen.png
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
beispielbilder/firewall_shell.png
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
beispielbilder/firewall_tunnel.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
beispielbilder/firewall_ubersicht.png
Normal file
|
After Width: | Height: | Size: 318 KiB |
BIN
beispielbilder/firewall_update.png
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
beispielbilder/firewall_vpn.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
beispielbilder/firewall_wazuh.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
beispielbilder/firewall_zertifikate.png
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
beispielbilder/firewalljob.png
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
beispielbilder/startseite.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
14
deploy-frontend.sh
Executable 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."
|
||||
@ -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`)
|
||||
}
|
||||
|
||||
@ -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,6 +1838,7 @@ 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>
|
||||
)}
|
||||
{!editTask && (
|
||||
<div>
|
||||
<label className={labelCls}>Aktion *</label>
|
||||
<div className="flex gap-2">
|
||||
@ -1756,6 +1850,7 @@ function TasksTab({ agentId, agentName }) {
|
||||
))}
|
||||
</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
@ -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'
|
||||
BIN
opnsense-plugin/os-rmm-agent-1.0.0.pkg
Normal 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()
|
||||
}
|
||||
|
||||