Initial commit — cynfo RMM (clean history, no secrets)
54
.gitignore
vendored
@ -1,16 +1,44 @@
|
|||||||
build/
|
# ========================
|
||||||
rmm-agent
|
# Konfigurationen mit Passwörtern — NIEMALS einchecken
|
||||||
rmm-backend
|
# ========================
|
||||||
*.db
|
|
||||||
*.log
|
|
||||||
*.pid
|
|
||||||
config.yaml
|
config.yaml
|
||||||
beispielbilder/
|
|
||||||
certs/
|
|
||||||
opnsense-plugin/stage/
|
|
||||||
opnsense-plugin/*.pkg
|
|
||||||
frontend/node_modules/
|
|
||||||
frontend/dist/
|
|
||||||
agent/config.yaml
|
agent/config.yaml
|
||||||
backend/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
|
package collector
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CaddySite struct {
|
type CaddySite struct {
|
||||||
@ -12,12 +16,21 @@ type CaddySite struct {
|
|||||||
TLS bool `json:"tls"`
|
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 {
|
type CaddyData struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Sites []CaddySite `json:"sites"`
|
Sites []CaddySite `json:"sites"`
|
||||||
|
Upstreams []CaddyUpstream `json:"upstreams,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const caddyFile = "/usr/local/etc/caddy/Caddyfile"
|
const caddyFile = "/usr/local/etc/caddy/Caddyfile"
|
||||||
|
const caddyAdminAPI = "http://127.0.0.1:2019"
|
||||||
|
|
||||||
func CollectCaddy() *CaddyData {
|
func CollectCaddy() *CaddyData {
|
||||||
if _, err := os.Stat(caddyFile); err != nil {
|
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)
|
content, err := os.ReadFile(caddyFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return data
|
return data
|
||||||
@ -51,7 +64,6 @@ func CollectCaddy() *CaddyData {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for domain block start: "domain.com {" or "http://domain.com {"
|
|
||||||
if depth == 0 && strings.Contains(trimmed, "{") {
|
if depth == 0 && strings.Contains(trimmed, "{") {
|
||||||
parts := strings.Fields(trimmed)
|
parts := strings.Fields(trimmed)
|
||||||
if len(parts) >= 2 && parts[len(parts)-1] == "{" {
|
if len(parts) >= 2 && parts[len(parts)-1] == "{" {
|
||||||
@ -63,7 +75,6 @@ func CollectCaddy() *CaddyData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if depth > 0 {
|
if depth > 0 {
|
||||||
// Track nested braces
|
|
||||||
depth += strings.Count(trimmed, "{") - strings.Count(trimmed, "}")
|
depth += strings.Count(trimmed, "{") - strings.Count(trimmed, "}")
|
||||||
|
|
||||||
if strings.HasPrefix(trimmed, "reverse_proxy") {
|
if strings.HasPrefix(trimmed, "reverse_proxy") {
|
||||||
@ -85,5 +96,43 @@ func CollectCaddy() *CaddyData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin-API: Upstream-Statistiken
|
||||||
|
data.Upstreams = collectCaddyUpstreams()
|
||||||
|
|
||||||
return data
|
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"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/cynfo/rmm-backend/ws"
|
"github.com/cynfo/rmm-backend/ws"
|
||||||
@ -32,12 +33,32 @@ func (h *Handler) uploadFirmware(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
r.Body = http.MaxBytesReader(w, r.Body, 50*1024*1024)
|
||||||
binary, err := io.ReadAll(r.Body)
|
var binary []byte
|
||||||
if err != nil {
|
if ct := r.Header.Get("Content-Type"); strings.HasPrefix(ct, "multipart/") {
|
||||||
writeError(w, http.StatusBadRequest, "Binary lesen fehlgeschlagen")
|
if err := r.ParseMultipartForm(50 << 20); err != nil {
|
||||||
return
|
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 {
|
if len(binary) == 0 {
|
||||||
|
|||||||
@ -16,7 +16,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *db.Database
|
db *db.Database
|
||||||
|
scheduler *TaskScheduler
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(database *db.Database) *Handler {
|
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("POST /api/v1/tasks", h.createTask(hub))
|
||||||
mux.HandleFunc("DELETE /api/v1/tasks/{id}", h.deleteTask)
|
mux.HandleFunc("DELETE /api/v1/tasks/{id}", h.deleteTask)
|
||||||
mux.HandleFunc("POST /api/v1/tasks/{id}/cancel", h.cancelTask)
|
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("PUT /api/v1/tasks/{id}/toggle", h.toggleTask)
|
||||||
|
mux.HandleFunc("POST /api/v1/tasks/{id}/run", h.runTaskNow())
|
||||||
|
|
||||||
// WebSocket und Tunnel-Routes
|
// WebSocket und Tunnel-Routes
|
||||||
h.setupTunnelRoutes(mux, hub)
|
h.setupTunnelRoutes(mux, hub)
|
||||||
|
|||||||
@ -275,8 +275,56 @@ func (s *TaskScheduler) executeUpdateWithHealthCheck(agentID string, reboot bool
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TaskScheduler) sendWebhook(taskID int, url string, task interface{}) {
|
// WebhookPayload ist der strukturierte Payload fuer ERP-Serviceberichte
|
||||||
payload, err := json.Marshal(task)
|
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 {
|
if err != nil {
|
||||||
log.Printf("Scheduler: Webhook-Payload fuer Task #%d serialisieren fehlgeschlagen: %v", taskID, err)
|
log.Printf("Scheduler: Webhook-Payload fuer Task #%d serialisieren fehlgeschlagen: %v", taskID, err)
|
||||||
s.db.SetTaskWebhookResult(taskID, false, fmt.Sprintf("JSON-Fehler: %v", 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}
|
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 {
|
if err != nil {
|
||||||
log.Printf("Scheduler: Webhook fuer Task #%d fehlgeschlagen: %v", taskID, err)
|
log.Printf("Scheduler: Webhook fuer Task #%d fehlgeschlagen: %v", taskID, err)
|
||||||
s.db.SetTaskWebhookResult(taskID, false, fmt.Sprintf("Fehler: %v", 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"})
|
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}
|
// DELETE /api/v1/tasks/{id}
|
||||||
func (h *Handler) deleteTask(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) deleteTask(w http.ResponseWriter, r *http.Request) {
|
||||||
idStr := r.PathValue("id")
|
idStr := r.PathValue("id")
|
||||||
|
|||||||
@ -422,6 +422,27 @@ func (d *Database) UpdateTaskStatusDirect(id int, status string) error {
|
|||||||
return err
|
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 {
|
func (d *Database) DeleteTask(id int) error {
|
||||||
res, err := d.db.Exec("DELETE FROM tasks WHERE id = $1", id)
|
res, err := d.db.Exec("DELETE FROM tasks WHERE id = $1", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -78,6 +78,7 @@ func main() {
|
|||||||
// Geschuetzte Routes
|
// Geschuetzte Routes
|
||||||
protectedMux := http.NewServeMux()
|
protectedMux := http.NewServeMux()
|
||||||
handler := api.NewHandler(database)
|
handler := api.NewHandler(database)
|
||||||
|
handler.SetScheduler(scheduler)
|
||||||
handler.SetupRoutes(protectedMux, hub)
|
handler.SetupRoutes(protectedMux, hub)
|
||||||
// GET /api/v1/auth/me braucht JWT
|
// GET /api/v1/auth/me braucht JWT
|
||||||
protectedMux.HandleFunc("GET /api/v1/auth/me", authHandler.Me)
|
protectedMux.HandleFunc("GET /api/v1/auth/me", authHandler.Me)
|
||||||
|
|||||||
@ -264,9 +264,17 @@ type CaddySite struct {
|
|||||||
TLS bool `json:"tls"`
|
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 {
|
type CaddyData struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Sites []CaddySite `json:"sites"`
|
Sites []CaddySite `json:"sites"`
|
||||||
|
Upstreams []CaddyUpstream `json:"upstreams,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HeartbeatRequest
|
// 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}`)
|
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) {
|
toggleTask(id) {
|
||||||
return this.put(`/api/v1/tasks/${id}/toggle`)
|
return this.put(`/api/v1/tasks/${id}/toggle`)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { useSettingsStore } from '../stores/settings'
|
|||||||
import {
|
import {
|
||||||
X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route,
|
X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route,
|
||||||
Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone, Ban,
|
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'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Two-level navigation structure (DATAZONE style)
|
// Two-level navigation structure (DATAZONE style)
|
||||||
@ -1621,6 +1621,7 @@ function TasksTab({ agentId, agentName }) {
|
|||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showDialog, setShowDialog] = useState(false)
|
const [showDialog, setShowDialog] = useState(false)
|
||||||
|
const [editTask, setEditTask] = useState(null)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [showWebhook, setShowWebhook] = useState(false)
|
const [showWebhook, setShowWebhook] = useState(false)
|
||||||
@ -1631,6 +1632,45 @@ function TasksTab({ agentId, agentName }) {
|
|||||||
reboot: false, health_check: false, health_check_timeout: 600,
|
reboot: false, health_check: false, health_check_timeout: 600,
|
||||||
webhook_url: '', active: true,
|
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 [form, setForm] = useState(defaultForm)
|
||||||
|
|
||||||
const loadTasks = () => {
|
const loadTasks = () => {
|
||||||
@ -1669,9 +1709,47 @@ function TasksTab({ agentId, agentName }) {
|
|||||||
if (!confirm('Job wirklich loeschen?')) return
|
if (!confirm('Job wirklich loeschen?')) return
|
||||||
try { await api.deleteTask(id); loadTasks() } catch (e) { setError(e.message) }
|
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) => {
|
const handleRunNow = async (id) => {
|
||||||
// Trigger by creating a one-off copy? For now just toggle to trigger scheduler
|
try {
|
||||||
// Simple approach: we don't have a "run now" endpoint, so we skip for now
|
await api.runTaskNow(id)
|
||||||
|
setTimeout(loadTasks, 1500)
|
||||||
|
} catch (e) { setError(e.message) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const weekdays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
|
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="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="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">
|
<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>
|
<h3 className="text-white font-semibold">{editTask ? `Job bearbeiten — ${editTask.name || `#${editTask.id}`}` : `Neuer Job${agentName ? ` — ${agentName}` : ''}`}</h3>
|
||||||
<button onClick={() => setShowDialog(false)} className="text-gray-400 hover:text-white"><X className="w-5 h-5" /></button>
|
<button onClick={() => { setShowDialog(false); setEditTask(null) }} className="text-gray-400 hover:text-white"><X className="w-5 h-5" /></button>
|
||||||
</div>
|
</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 className="px-5 py-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>Name *</label>
|
<label className={labelCls}>Name *</label>
|
||||||
@ -1713,7 +1806,7 @@ function TasksTab({ agentId, agentName }) {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>Intervall *</label>
|
<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="once">Einmalig</option>
|
||||||
<option value="daily">Täglich</option>
|
<option value="daily">Täglich</option>
|
||||||
<option value="weekly">Wöchentlich</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} />
|
<input type="number" min="1" max="28" value={form.schedule_monthday} onChange={e => setForm({...form, schedule_monthday: e.target.value})} className={inputCls} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
{!editTask && (
|
||||||
<label className={labelCls}>Aktion *</label>
|
<div>
|
||||||
<div className="flex gap-2">
|
<label className={labelCls}>Aktion *</label>
|
||||||
{['backup', 'update'].map(a => (
|
<div className="flex gap-2">
|
||||||
<button key={a} onClick={() => setForm({...form, action: a})}
|
{['backup', 'update'].map(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'}`}>
|
<button key={a} onClick={() => setForm({...form, action: a})}
|
||||||
{a === 'backup' ? 'Backup' : 'Update'}
|
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'}`}>
|
||||||
</button>
|
{a === 'backup' ? 'Backup' : 'Update'}
|
||||||
))}
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{form.action === 'update' && (
|
{form.action === 'update' && (
|
||||||
<div className="space-y-2 pl-1">
|
<div className="space-y-2 pl-1">
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
|
<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)
|
{showWebhook ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />} Webhook-URL (optional)
|
||||||
</button>
|
</button>
|
||||||
{showWebhook && (
|
{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>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
|
<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>}
|
{error && <div className="text-sm text-red-400">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 px-5 py-4 border-t border-gray-700">
|
<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={() => { 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={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">
|
<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 ? 'Erstelle...' : 'Erstellen'}
|
{creating ? (editTask ? 'Speichere...' : 'Erstelle...') : (editTask ? 'Speichern' : 'Erstellen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1814,6 +1912,7 @@ function TasksTab({ agentId, agentName }) {
|
|||||||
<th className="px-3 py-2">Intervall</th>
|
<th className="px-3 py-2">Intervall</th>
|
||||||
<th className="px-3 py-2">Nächster Lauf</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">Letzter Lauf</th>
|
||||||
|
<th className="px-3 py-2">Webhook</th>
|
||||||
<th className="px-3 py-2 text-right">Aktionen</th>
|
<th className="px-3 py-2 text-right">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</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">{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.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-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">
|
<td className="px-3 py-2 text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<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'}>
|
<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" />
|
<Power className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@ -2380,12 +2494,55 @@ function CaddyTab({ sys }) {
|
|||||||
const caddy = sys?.caddy
|
const caddy = sys?.caddy
|
||||||
if (!caddy) return <div className="text-gray-500">Keine Caddy-Daten</div>
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300">Caddy Reverse Proxy</h3>
|
<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">
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<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 {
|
func startAgent() error {
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "freebsd":
|
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:
|
default:
|
||||||
return exec.Command("/usr/bin/systemctl", "start", "rmm-agent").Run()
|
return exec.Command("/usr/bin/systemctl", "start", "rmm-agent").Run()
|
||||||
}
|
}
|
||||||
|
|||||||