diff --git a/Makefile b/Makefile
index bf96585..2399fa9 100644
--- a/Makefile
+++ b/Makefile
@@ -1,15 +1,17 @@
-.PHONY: all backend agent agent-linux updater plugin clean certs deploy-backend deploy-agent
+.PHONY: all backend agent agent-linux updater-freebsd updater-linux plugin clean certs release deploy-backend
+
+VERSION ?= 0.0.0
BACKEND_BIN = build/rmm-backend
AGENT_BIN = build/rmm-agent
AGENT_LINUX_BIN = build/rmm-agent-linux
-UPDATER_BIN = build/rmm-updater
+UPDATER_FREEBSD_BIN = build/rmm-updater-freebsd
+UPDATER_LINUX_BIN = build/rmm-updater-linux
BACKEND_HOST = 192.168.85.13
-AGENT_HOST = 192.168.85.33
SSH_USER = root
-all: backend agent agent-linux updater
+all: backend agent agent-linux updater-freebsd updater-linux
backend:
@echo "==> Building Backend (linux/amd64)..."
@@ -26,10 +28,29 @@ agent-linux:
cd agent-linux && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X main.Version=$(VERSION)" -o ../$(AGENT_LINUX_BIN) .
@echo "==> $(AGENT_LINUX_BIN) erstellt"
-updater:
+updater-freebsd:
@echo "==> Building Updater (freebsd/amd64)..."
- cd updater && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -o ../$(UPDATER_BIN) .
- @echo "==> $(UPDATER_BIN) erstellt"
+ cd updater && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -o ../$(UPDATER_FREEBSD_BIN) .
+ @echo "==> $(UPDATER_FREEBSD_BIN) erstellt"
+
+updater-linux:
+ @echo "==> Building Updater (linux/amd64)..."
+ cd updater && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o ../$(UPDATER_LINUX_BIN) .
+ @echo "==> $(UPDATER_LINUX_BIN) erstellt"
+
+# Release: Baut alles und erzeugt ZIP-Bundles pro Plattform
+release: agent agent-linux updater-freebsd updater-linux
+ @echo "==> Creating release bundles v$(VERSION)..."
+ @mkdir -p build/release
+ @# FreeBSD Bundle (Agent + Updater)
+ @cd build && zip -j release/rmm-freebsd-$(VERSION).zip rmm-agent rmm-updater-freebsd
+ @echo "==> build/release/rmm-freebsd-$(VERSION).zip erstellt"
+ @# Linux Bundle (Agent + Updater)
+ @cd build && zip -j release/rmm-linux-$(VERSION).zip rmm-agent-linux rmm-updater-linux
+ @echo "==> build/release/rmm-linux-$(VERSION).zip erstellt"
+ @echo ""
+ @echo "Release v$(VERSION) fertig:"
+ @ls -lh build/release/rmm-*-$(VERSION).zip
plugin:
@echo "==> Building OPNsense Plugin Package..."
@@ -51,52 +72,7 @@ certs:
deploy-backend: backend
@echo "==> Deploying Backend to $(BACKEND_HOST)..."
- ssh $(SSH_USER)@$(BACKEND_HOST) "mkdir -p /opt/rmm/certs"
- scp $(BACKEND_BIN) $(SSH_USER)@$(BACKEND_HOST):/opt/rmm/rmm-backend
- scp backend/config.yaml $(SSH_USER)@$(BACKEND_HOST):/opt/rmm/config.yaml
- @echo "==> Erstelle systemd Service..."
- ssh $(SSH_USER)@$(BACKEND_HOST) 'cat > /etc/systemd/system/rmm-backend.service << EOF\n\
-[Unit]\n\
-Description=RMM Backend\n\
-After=network.target\n\
-\n\
-[Service]\n\
-Type=simple\n\
-WorkingDirectory=/opt/rmm\n\
-ExecStart=/opt/rmm/rmm-backend /opt/rmm/config.yaml\n\
-Restart=always\n\
-RestartSec=5\n\
-\n\
-[Install]\n\
-WantedBy=multi-user.target\n\
-EOF'
- ssh $(SSH_USER)@$(BACKEND_HOST) "systemctl daemon-reload && systemctl enable rmm-backend && systemctl restart rmm-backend"
+ ssh $(SSH_USER)@$(BACKEND_HOST) "systemctl stop rmm-backend || true"
+ scp $(BACKEND_BIN) $(SSH_USER)@$(BACKEND_HOST):/home/cynfo/rmm/rmm-backend
+ ssh $(SSH_USER)@$(BACKEND_HOST) "systemctl start rmm-backend"
@echo "==> Backend deployed und gestartet"
-
-deploy-agent: agent
- @echo "==> Deploying Agent to $(AGENT_HOST)..."
- ssh $(SSH_USER)@$(AGENT_HOST) "mkdir -p /opt/rmm"
- scp $(AGENT_BIN) $(SSH_USER)@$(AGENT_HOST):/opt/rmm/rmm-agent
- scp agent/config.yaml $(SSH_USER)@$(AGENT_HOST):/opt/rmm/config.yaml
- @echo "==> Erstelle rc.d Script..."
- ssh $(SSH_USER)@$(AGENT_HOST) 'cat > /usr/local/etc/rc.d/rmm_agent << RCEOF\n\
-#!/bin/sh\n\
-#\n\
-# PROVIDE: rmm_agent\n\
-# REQUIRE: NETWORKING\n\
-# KEYWORD: shutdown\n\
-\n\
-. /etc/rc.subr\n\
-\n\
-name="rmm_agent"\n\
-rcvar="rmm_agent_enable"\n\
-pidfile="/var/run/$${name}.pid"\n\
-command="/opt/rmm/rmm-agent"\n\
-command_args="--config /opt/rmm/config.yaml --insecure &"\n\
-\n\
-load_rc_config $$name\n\
-run_rc_command "$$1"\n\
-RCEOF\n\
-chmod +x /usr/local/etc/rc.d/rmm_agent'
- ssh $(SSH_USER)@$(AGENT_HOST) 'sysrc rmm_agent_enable="YES" && service rmm_agent restart'
- @echo "==> Agent deployed und gestartet"
diff --git a/agent/ws/handler.go b/agent/ws/handler.go
index 35d9f58..dee7dea 100644
--- a/agent/ws/handler.go
+++ b/agent/ws/handler.go
@@ -114,12 +114,14 @@ func (h *Handler) handleUpdateCheck(msg Message) Message {
result := map[string]interface{}{}
// 1. OPNsense Core Update pruefen
+ // opnsense-update -c gibt Exit 1 wenn Updates da sind, aber auch bei anderen Fehlern.
+ // Nur wenn stdout tatsaechlich Update-Infos enthaelt, ist ein Update verfuegbar.
coreOutput, coreErr := h.runCommand("/usr/local/sbin/opnsense-update -c", 60)
- if coreErr != nil {
- // Exit 1 = Update verfuegbar ODER Reboot noetig
- info := strings.TrimSpace(coreOutput)
+ coreInfo := strings.TrimSpace(coreOutput)
+ if coreErr != nil && coreInfo != "" {
+ // Exit 1 MIT Output = echtes Update verfuegbar
result["core_update_available"] = true
- result["core_update_info"] = info
+ result["core_update_info"] = coreInfo
} else {
result["core_update_available"] = false
result["core_update_info"] = "Kein Core-Update verfuegbar"
diff --git a/backend/api/apikeys.go b/backend/api/apikeys.go
new file mode 100644
index 0000000..5928f01
--- /dev/null
+++ b/backend/api/apikeys.go
@@ -0,0 +1,65 @@
+package api
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "log"
+ "net/http"
+ "strconv"
+)
+
+// GET /api/v1/apikeys
+func (h *Handler) listAPIKeys(w http.ResponseWriter, r *http.Request) {
+ keys, err := h.db.ListAPIKeys()
+ if err != nil {
+ log.Printf("API-Keys laden fehlgeschlagen: %v", err)
+ writeError(w, http.StatusInternalServerError, "Laden fehlgeschlagen")
+ return
+ }
+ writeJSON(w, http.StatusOK, keys)
+}
+
+// POST /api/v1/apikeys
+func (h *Handler) createAPIKey(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
+ writeError(w, http.StatusBadRequest, "Parameter 'name' erforderlich")
+ return
+ }
+
+ // Zufaelligen 32-Byte Key generieren
+ b := make([]byte, 16)
+ rand.Read(b)
+ key := hex.EncodeToString(b)
+
+ apiKey, err := h.db.CreateAPIKey(req.Name, key)
+ if err != nil {
+ log.Printf("API-Key erstellen fehlgeschlagen: %v", err)
+ writeError(w, http.StatusInternalServerError, "Erstellen fehlgeschlagen")
+ return
+ }
+
+ log.Printf("Neuer API-Key erstellt: %s (%s...)", apiKey.Name, key[:8])
+ writeJSON(w, http.StatusCreated, apiKey)
+}
+
+// DELETE /api/v1/apikeys/{id}
+func (h *Handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
+ idStr := r.PathValue("id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "Ungueltige ID")
+ return
+ }
+
+ if err := h.db.DeleteAPIKey(id); err != nil {
+ writeError(w, http.StatusNotFound, "API-Key nicht gefunden")
+ return
+ }
+
+ log.Printf("API-Key %d geloescht", id)
+ writeJSON(w, http.StatusOK, map[string]string{"message": "API-Key geloescht"})
+}
diff --git a/backend/api/handlers.go b/backend/api/handlers.go
index 4463504..2b510e0 100644
--- a/backend/api/handlers.go
+++ b/backend/api/handlers.go
@@ -54,6 +54,11 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) {
mux.HandleFunc("PUT /api/v1/users/{id}/password", h.changePassword)
mux.HandleFunc("DELETE /api/v1/users/{id}", h.deleteUser)
+ // API-Key Routes
+ mux.HandleFunc("GET /api/v1/apikeys", h.listAPIKeys)
+ mux.HandleFunc("POST /api/v1/apikeys", h.createAPIKey)
+ mux.HandleFunc("DELETE /api/v1/apikeys/{id}", h.deleteAPIKey)
+
// Firmware/Agent-Update Routes
mux.HandleFunc("GET /api/v1/firmware", h.getFirmwareInfo)
mux.HandleFunc("POST /api/v1/firmware/upload", h.uploadFirmware)
diff --git a/backend/api/middleware.go b/backend/api/middleware.go
index 8a16a79..bbb47b0 100644
--- a/backend/api/middleware.go
+++ b/backend/api/middleware.go
@@ -5,34 +5,101 @@ import (
"log"
"net/http"
"strings"
+ "sync"
"time"
+
+ "github.com/cynfo/rmm-backend/db"
)
type contextKey string
const UserContextKey contextKey = "user"
-// APIKeyAuth - Middleware fuer API-Key Authentifizierung
-func APIKeyAuth(validKeys []string, next http.Handler) http.Handler {
- keySet := make(map[string]bool, len(validKeys))
- for _, k := range validKeys {
- keySet[k] = true
+// APIKeyCache cached DB-basierte API-Keys mit periodischem Refresh
+type APIKeyCache struct {
+ db *db.Database
+ mu sync.RWMutex
+ keys map[string]bool
+ lastLoad time.Time
+ ttl time.Duration
+}
+
+func NewAPIKeyCache(database *db.Database) *APIKeyCache {
+ c := &APIKeyCache{
+ db: database,
+ keys: make(map[string]bool),
+ ttl: 30 * time.Second,
+ }
+ c.refresh()
+ return c
+}
+
+func (c *APIKeyCache) refresh() {
+ dbKeys, err := c.db.GetAllAPIKeys()
+ if err != nil {
+ log.Printf("API-Key Cache Refresh fehlgeschlagen: %v", err)
+ return
+ }
+ newKeys := make(map[string]bool, len(dbKeys))
+ for _, k := range dbKeys {
+ newKeys[k] = true
+ }
+ c.mu.Lock()
+ c.keys = newKeys
+ c.lastLoad = time.Now()
+ c.mu.Unlock()
+}
+
+func (c *APIKeyCache) IsValid(key string) bool {
+ c.mu.RLock()
+ expired := time.Since(c.lastLoad) > c.ttl
+ valid := c.keys[key]
+ c.mu.RUnlock()
+
+ if expired {
+ c.refresh()
+ c.mu.RLock()
+ valid = c.keys[key]
+ c.mu.RUnlock()
}
+ if valid {
+ go c.db.UpdateAPIKeyLastUsed(key)
+ }
+
+ return valid
+}
+
+// CombinedAuthWithCache - API-Key (aus DB-Cache) ODER JWT Token
+func CombinedAuthWithCache(cache *APIKeyCache, auth *AuthHandler, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Try API key first
key := r.Header.Get("X-API-Key")
if key == "" {
key = r.URL.Query().Get("api_key")
}
- if key == "" || !keySet[key] {
- http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
+ if key != "" && cache.IsValid(key) {
+ next.ServeHTTP(w, r)
return
}
- next.ServeHTTP(w, r)
+
+ // Try JWT
+ authHeader := r.Header.Get("Authorization")
+ if strings.HasPrefix(authHeader, "Bearer ") {
+ token := strings.TrimPrefix(authHeader, "Bearer ")
+ claims, err := auth.ValidateToken(token)
+ if err == nil {
+ ctx := context.WithValue(r.Context(), UserContextKey, claims)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ return
+ }
+ }
+
+ http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
})
}
-// CombinedAuth - API-Key ODER JWT Token
+// CombinedAuth - Abwaertskompatibel mit statischen Keys (Fallback)
func CombinedAuth(validKeys []string, auth *AuthHandler, next http.Handler) http.Handler {
keySet := make(map[string]bool, len(validKeys))
for _, k := range validKeys {
@@ -40,7 +107,6 @@ func CombinedAuth(validKeys []string, auth *AuthHandler, next http.Handler) http
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Try API key first
key := r.Header.Get("X-API-Key")
if key == "" {
key = r.URL.Query().Get("api_key")
@@ -50,7 +116,6 @@ func CombinedAuth(validKeys []string, auth *AuthHandler, next http.Handler) http
return
}
- // Try JWT
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
token := strings.TrimPrefix(authHeader, "Bearer ")
diff --git a/backend/db/postgres.go b/backend/db/postgres.go
index 4984f73..4439356 100644
--- a/backend/db/postgres.go
+++ b/backend/db/postgres.go
@@ -182,6 +182,15 @@ func (d *Database) migrate() error {
// Indices fuer Metrics
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_metrics_agent_metric ON metrics(agent_id, metric, time DESC)`)
+ // API-Keys Tabelle
+ d.db.Exec(`CREATE TABLE IF NOT EXISTS api_keys (
+ id SERIAL PRIMARY KEY,
+ name TEXT NOT NULL,
+ key TEXT NOT NULL UNIQUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ last_used TIMESTAMPTZ
+ )`)
+
// Retention Policy (90 Tage)
d.setupRetention()
@@ -954,3 +963,96 @@ func (d *Database) GetAgentStatus(agentID string) string {
}
return models.CalculateAgentStatus(&lastHB.Time)
}
+
+// ==================== API-Keys ====================
+
+type APIKey struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Key string `json:"key"`
+ CreatedAt time.Time `json:"created_at"`
+ LastUsed *time.Time `json:"last_used,omitempty"`
+}
+
+func (d *Database) ListAPIKeys() ([]APIKey, error) {
+ rows, err := d.db.Query("SELECT id, name, key, created_at, last_used FROM api_keys ORDER BY created_at")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var keys []APIKey
+ for rows.Next() {
+ var k APIKey
+ var lastUsed sql.NullTime
+ if err := rows.Scan(&k.ID, &k.Name, &k.Key, &k.CreatedAt, &lastUsed); err != nil {
+ return nil, err
+ }
+ if lastUsed.Valid {
+ k.LastUsed = &lastUsed.Time
+ }
+ keys = append(keys, k)
+ }
+ if keys == nil {
+ keys = []APIKey{}
+ }
+ return keys, rows.Err()
+}
+
+func (d *Database) CreateAPIKey(name, key string) (*APIKey, error) {
+ var k APIKey
+ err := d.db.QueryRow(
+ "INSERT INTO api_keys (name, key) VALUES ($1, $2) RETURNING id, name, key, created_at",
+ name, key,
+ ).Scan(&k.ID, &k.Name, &k.Key, &k.CreatedAt)
+ if err != nil {
+ return nil, err
+ }
+ return &k, nil
+}
+
+func (d *Database) DeleteAPIKey(id int) error {
+ result, err := d.db.Exec("DELETE FROM api_keys WHERE id = $1", id)
+ if err != nil {
+ return err
+ }
+ rows, _ := result.RowsAffected()
+ if rows == 0 {
+ return fmt.Errorf("API-Key nicht gefunden")
+ }
+ return nil
+}
+
+func (d *Database) GetAllAPIKeys() ([]string, error) {
+ rows, err := d.db.Query("SELECT key FROM api_keys")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var keys []string
+ for rows.Next() {
+ var k string
+ if err := rows.Scan(&k); err != nil {
+ return nil, err
+ }
+ keys = append(keys, k)
+ }
+ return keys, rows.Err()
+}
+
+func (d *Database) UpdateAPIKeyLastUsed(key string) {
+ d.db.Exec("UPDATE api_keys SET last_used = NOW() WHERE key = $1", key)
+}
+
+// MigrateConfigAPIKeys migriert API-Keys aus der Config in die DB (einmalig)
+func (d *Database) MigrateConfigAPIKeys(configKeys []string) {
+ for _, key := range configKeys {
+ var exists bool
+ d.db.QueryRow("SELECT EXISTS(SELECT 1 FROM api_keys WHERE key = $1)", key).Scan(&exists)
+ if !exists {
+ d.db.Exec("INSERT INTO api_keys (name, key) VALUES ($1, $2)", "Default (migriert)", key)
+ log.Printf("API-Key aus Config migriert: %s...", key[:8])
+ }
+ }
+}
diff --git a/backend/main.go b/backend/main.go
index 336058b..1b934a8 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -57,6 +57,12 @@ func main() {
mon := monitor.New(database)
go mon.Run()
+ // Config API-Keys in DB migrieren (einmalig)
+ database.MigrateConfigAPIKeys(cfg.APIKeys)
+
+ // API-Key Cache (laedt Keys aus DB, refresht alle 30s)
+ apiKeyCache := api.NewAPIKeyCache(database)
+
// Auth-Handler initialisieren
authHandler := api.NewAuthHandler(database, cfg.JWTSecret)
authHandler.EnsureDefaultAdmin()
@@ -72,10 +78,10 @@ func main() {
// GET /api/v1/auth/me braucht JWT
protectedMux.HandleFunc("GET /api/v1/auth/me", authHandler.Me)
- // Combined router: auth routes ungeschuetzt, rest mit CombinedAuth
+ // Combined router: auth routes ungeschuetzt, rest mit DB-basierter Auth
mux := http.NewServeMux()
mux.Handle("POST /api/v1/auth/login", authMux)
- mux.Handle("/", api.CombinedAuth(cfg.APIKeys, authHandler, protectedMux))
+ mux.Handle("/", api.CombinedAuthWithCache(apiKeyCache, authHandler, protectedMux))
// Middleware-Chain: CORS -> Logging -> Handler
var chain http.Handler = mux
@@ -83,7 +89,7 @@ func main() {
chain = api.CORS(chain)
log.Printf("RMM Backend startet auf %s (TLS)", cfg.ListenAddr)
- log.Printf("API-Keys konfiguriert: %d", len(cfg.APIKeys))
+ log.Printf("API-Keys (Config): %d, DB-basierte Validierung aktiv", len(cfg.APIKeys))
if err := http.ListenAndServeTLS(cfg.ListenAddr, cfg.TLSCert, cfg.TLSKey, chain); err != nil {
log.Fatalf("Server-Fehler: %v", err)
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index 482b210..81e9147 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -223,6 +223,19 @@ class ApiClient {
return this.put(`/api/v1/users/${userId}/password`, { password })
}
+ // API-Keys
+ getAPIKeys() {
+ return this.get('/api/v1/apikeys')
+ }
+
+ createAPIKey(name) {
+ return this.post('/api/v1/apikeys', { name })
+ }
+
+ deleteAPIKey(id) {
+ return this.delete(`/api/v1/apikeys/${id}`)
+ }
+
// Firmware
getFirmwareInfo() {
return this.get('/api/v1/firmware')
diff --git a/frontend/src/components/InstallGuide.jsx b/frontend/src/components/InstallGuide.jsx
index 43ae278..65a9b1b 100644
--- a/frontend/src/components/InstallGuide.jsx
+++ b/frontend/src/components/InstallGuide.jsx
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { Copy, Check, Terminal, Monitor } from 'lucide-react'
-const BACKEND_URL = 'https://192.168.85.13:8443'
+const BACKEND_URL = 'https://dsbmueller.spdns.org:8443'
const API_KEY = '01532e5a7c9e70bf2757df77a2f5b9b9'
const INSTALL_SH_URL = 'https://git.cynfo.net/christian/rmm/raw/branch/main/opnsense-plugin/install.sh'
diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx
index ac0721c..bbf842f 100644
--- a/frontend/src/pages/SettingsPage.jsx
+++ b/frontend/src/pages/SettingsPage.jsx
@@ -1,13 +1,13 @@
import { useEffect, useState } from 'react'
import api from '../api/client'
import { useAuthStore } from '../stores/auth'
-import { Plus, Trash2, KeyRound, ShieldAlert } from 'lucide-react'
+import { Plus, Trash2, KeyRound, ShieldAlert, Copy, Check, Key } from 'lucide-react'
export default function SettingsPage() {
const [users, setUsers] = useState([])
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState({ username: '', password: '', display_name: '' })
- const [pwChange, setPwChange] = useState(null) // userId being changed
+ const [pwChange, setPwChange] = useState(null)
const [pwForm, setPwForm] = useState({ password: '', confirm: '' })
const [pwMsg, setPwMsg] = useState('')
const { user: currentUser } = useAuthStore()
@@ -185,6 +185,9 @@ export default function SettingsPage() {
+ {/* API Keys */}
+
+ {newKey.key}
+
+
+