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 */} + + {/* 2FA */}
@@ -211,10 +214,162 @@ export default function SettingsPage() {
Authentifizierung - JWT (8h Token) + JWT (8h Token) + API-Keys
) } + +function APIKeysSection() { + const [keys, setKeys] = useState([]) + const [showAdd, setShowAdd] = useState(false) + const [newName, setNewName] = useState('') + const [newKey, setNewKey] = useState(null) + const [copied, setCopied] = useState(null) + const [loading, setLoading] = useState(true) + + const reload = () => { + setLoading(true) + api.getAPIKeys() + .then(k => setKeys(k || [])) + .finally(() => setLoading(false)) + } + useEffect(() => { reload() }, []) + + const handleCreate = async () => { + if (!newName.trim()) return + try { + const result = await api.createAPIKey(newName.trim()) + setNewKey(result) + setNewName('') + reload() + } catch (e) { + alert('Fehler: ' + e.message) + } + } + + const handleDelete = async (id, name) => { + if (!confirm(`API-Key "${name}" wirklich loeschen? Alle Agents mit diesem Key verlieren den Zugang.`)) return + await api.deleteAPIKey(id) + reload() + } + + const copyToClipboard = (text, id) => { + navigator.clipboard.writeText(text) + setCopied(id) + setTimeout(() => setCopied(null), 2000) + } + + return ( +
+
+
+ + API-Keys + ({keys.length}) +
+ +
+ + {/* Neuer Key Dialog */} + {showAdd && !newKey && ( +
+
+ + setNewName(e.target.value)} + placeholder="z.B. Agent-Key Kunde XY" + className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500" + autoFocus + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> +
+ + +
+ )} + + {/* Neuer Key Anzeige */} + {newKey && ( +
+
API-Key erstellt — jetzt kopieren, er wird nur einmal angezeigt!
+
+ + {newKey.key} + + +
+ +
+ )} + + {/* Key Liste */} + {loading ? ( +
Laden...
+ ) : keys.length === 0 ? ( +
Keine API-Keys vorhanden
+ ) : ( +
+ {keys.map((k) => ( +
+
+
+ {k.name} +
+
+ {k.key.substring(0, 8)}...{k.key.substring(k.key.length - 4)} + + + Erstellt: {new Date(k.created_at).toLocaleDateString('de-DE')} + + {k.last_used && ( + + Letzter Zugriff: {new Date(k.last_used).toLocaleString('de-DE')} + + )} +
+
+ +
+ ))} +
+ )} +
+ ) +} diff --git a/opnsense-plugin/install.sh b/opnsense-plugin/install.sh index b34f84f..712659b 100755 --- a/opnsense-plugin/install.sh +++ b/opnsense-plugin/install.sh @@ -16,8 +16,8 @@ set -e BASEDIR="/tmp" BINARY="${BASEDIR}/rmm-agent" UPDATER_BINARY="${BASEDIR}/rmm-updater" -BACKEND_URL="https://192.168.85.13:8443" -API_KEY="01532e5a7c9e70bf2757df77a2f5b9b9" +BACKEND_URL="${RMM_BACKEND:-https://dsbmueller.spdns.org:8443}" +API_KEY="${RMM_API_KEY:-01532e5a7c9e70bf2757df77a2f5b9b9}" echo "========================================" echo " RMM Agent Plugin Installer" diff --git a/updater/main.go b/updater/main.go index 6f066ae..cfae05c 100644 --- a/updater/main.go +++ b/updater/main.go @@ -1,6 +1,8 @@ package main import ( + "archive/zip" + "bytes" "crypto/sha256" "crypto/tls" "encoding/hex" @@ -12,6 +14,7 @@ import ( "os" "os/exec" "runtime" + "strings" "time" "gopkg.in/yaml.v3" @@ -26,24 +29,35 @@ type Config struct { // Plattform-spezifische Pfade und Befehle var ( - agentBinaryPath string - configPath string - agentIDPath string - platformName string + agentBinaryPath string + updaterBinaryPath string + configPath string + agentIDPath string + platformName string + + // Dateinamen im ZIP-Bundle + agentZipName string + updaterZipName string ) func init() { switch runtime.GOOS { case "freebsd": agentBinaryPath = "/usr/local/rmm/rmm-agent" + updaterBinaryPath = "/usr/local/rmm/rmm-updater" configPath = "/usr/local/rmm/config.yaml" agentIDPath = "/usr/local/rmm/agent_id" platformName = "freebsd" + agentZipName = "rmm-agent" + updaterZipName = "rmm-updater-freebsd" default: // linux agentBinaryPath = "/usr/local/bin/rmm-agent" + updaterBinaryPath = "/usr/local/bin/rmm-updater" configPath = "/etc/rmm/config.yaml" agentIDPath = "/etc/rmm/agent_id" platformName = "linux" + agentZipName = "rmm-agent-linux" + updaterZipName = "rmm-updater-linux" } } @@ -65,6 +79,15 @@ func startAgent() error { } } +func restartUpdater() { + switch runtime.GOOS { + case "freebsd": + exec.Command("/usr/sbin/service", "rmm_updater", "restart").Run() + default: + exec.Command("/usr/bin/systemctl", "restart", "rmm-updater").Run() + } +} + const ( checkInterval = 60 * time.Second logPrefix = "[rmm-updater] " @@ -74,7 +97,7 @@ func main() { log.SetPrefix(logPrefix) log.SetFlags(log.LstdFlags) - log.Printf("RMM Updater gestartet (platform=%s, binary=%s)", platformName, agentBinaryPath) + log.Printf("RMM Updater gestartet (platform=%s, agent=%s, updater=%s)", platformName, agentBinaryPath, updaterBinaryPath) cfg, err := loadConfig() if err != nil { @@ -119,7 +142,7 @@ func loadAgentID() (string, error) { if err != nil { return "", fmt.Errorf("agent_id nicht gefunden: %v", err) } - id := string(data) + id := strings.TrimSpace(string(data)) if id == "" { return "", fmt.Errorf("agent_id leer") } @@ -153,9 +176,9 @@ func checkAndUpdate(client *http.Client, cfg *Config, agentID string) { return } - // Pruefen ob lokale Binary schon die richtige Version hat + // Pruefen ob lokaler Agent schon den gleichen Hash hat if localHash, err := hashFile(agentBinaryPath); err == nil && localHash == info.Hash { - return + return // Schon aktuell } // Update angefordert? @@ -206,24 +229,27 @@ func downloadAndApply(client *http.Client, cfg *Config, agentID, expectedHash st return fmt.Errorf("download HTTP %d", resp.StatusCode) } - binary, err := io.ReadAll(resp.Body) + bundle, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("download lesen fehlgeschlagen: %v", err) } - // Hash verifizieren - hash := sha256.Sum256(binary) + // Hash des gesamten Bundles verifizieren + hash := sha256.Sum256(bundle) hashStr := hex.EncodeToString(hash[:]) if hashStr != expectedHash { return fmt.Errorf("hash mismatch: erwartet %s, bekommen %s", expectedHash[:16], hashStr[:16]) } - log.Printf("Binary heruntergeladen (%d bytes, Hash OK)", len(binary)) + log.Printf("Bundle heruntergeladen (%d bytes, Hash OK)", len(bundle)) - // Temporaere Datei schreiben - tmpPath := agentBinaryPath + ".new" - if err := os.WriteFile(tmpPath, binary, 0755); err != nil { - return fmt.Errorf("tmp schreiben fehlgeschlagen: %v", err) + // Versuche als ZIP zu entpacken + agentBinary, updaterBinary, err := extractZipBundle(bundle) + if err != nil { + // Fallback: Kein ZIP — einzelne Agent-Binary (Abwaertskompatibilitaet) + log.Printf("Kein ZIP-Bundle, verwende als einzelne Agent-Binary") + agentBinary = bundle + updaterBinary = nil } // Agent stoppen @@ -231,38 +257,108 @@ func downloadAndApply(client *http.Client, cfg *Config, agentID, expectedHash st stopAgent() time.Sleep(2 * time.Second) - // Binary ersetzen mit Rollback - backupPath := agentBinaryPath + ".bak" - if _, err := os.Stat(agentBinaryPath); err == nil { - os.Rename(agentBinaryPath, backupPath) + // Agent-Binary ersetzen + if err := replaceBinary(agentBinaryPath, agentBinary); err != nil { + return fmt.Errorf("agent binary ersetzen fehlgeschlagen: %v", err) } - if err := os.Rename(tmpPath, agentBinaryPath); err != nil { - os.Rename(backupPath, agentBinaryPath) - return fmt.Errorf("binary ersetzen fehlgeschlagen: %v", err) - } - os.Chmod(agentBinaryPath, 0755) + log.Println("Agent-Binary aktualisiert") // Agent starten log.Println("Agent wird gestartet...") if err := startAgent(); err != nil { log.Printf("WARNUNG: Agent-Start fehlgeschlagen: %v — versuche Rollback", err) - os.Rename(backupPath, agentBinaryPath) + rollbackBinary(agentBinaryPath) startAgent() return fmt.Errorf("agent start fehlgeschlagen") } - // Backup aufraeumen - os.Remove(backupPath) - // Update-Flag loeschen clearURL := fmt.Sprintf("%s/api/v1/agents/%s/request-update", cfg.BackendURL, agentID) clearReq, _ := http.NewRequest("DELETE", clearURL, nil) clearReq.Header.Set("X-API-Key", cfg.APIKey) client.Do(clearReq) + // Updater-Binary ersetzen (wenn im Bundle enthalten) + if updaterBinary != nil { + log.Println("Updater-Binary wird aktualisiert...") + if err := replaceBinary(updaterBinaryPath, updaterBinary); err != nil { + log.Printf("WARNUNG: Updater-Binary ersetzen fehlgeschlagen: %v", err) + } else { + log.Println("Updater-Binary aktualisiert, starte neu...") + // Updater neustart — dieser Prozess wird beendet und neu gestartet + go func() { + time.Sleep(1 * time.Second) + restartUpdater() + }() + } + } + return nil } +func extractZipBundle(data []byte) (agentBin []byte, updaterBin []byte, err error) { + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, nil, fmt.Errorf("kein gueltiges ZIP: %v", err) + } + + for _, f := range reader.File { + rc, err := f.Open() + if err != nil { + continue + } + content, err := io.ReadAll(rc) + rc.Close() + if err != nil { + continue + } + + switch f.Name { + case agentZipName: + agentBin = content + log.Printf("ZIP: Agent-Binary gefunden (%s, %d bytes)", f.Name, len(content)) + case updaterZipName: + updaterBin = content + log.Printf("ZIP: Updater-Binary gefunden (%s, %d bytes)", f.Name, len(content)) + default: + log.Printf("ZIP: Unbekannte Datei ignoriert: %s", f.Name) + } + } + + if agentBin == nil { + return nil, nil, fmt.Errorf("agent binary nicht im ZIP gefunden (erwartet: %s)", agentZipName) + } + + return agentBin, updaterBin, nil +} + +func replaceBinary(path string, newContent []byte) error { + backupPath := path + ".bak" + + // Backup erstellen + if _, err := os.Stat(path); err == nil { + os.Rename(path, backupPath) + } + + // Neue Binary schreiben + if err := os.WriteFile(path, newContent, 0755); err != nil { + // Rollback + os.Rename(backupPath, path) + return err + } + + // Backup loeschen + os.Remove(backupPath) + return nil +} + +func rollbackBinary(path string) { + backupPath := path + ".bak" + if _, err := os.Stat(backupPath); err == nil { + os.Rename(backupPath, path) + } +} + func hashFile(path string) (string, error) { data, err := os.ReadFile(path) if err != nil {