install.sh + frontend: use external URL dsbmueller.spdns.org:8443, configurable via RMM_BACKEND env

This commit is contained in:
cynfo3000 2026-03-03 23:17:58 +01:00
parent 5f37e28046
commit eb52507c08
12 changed files with 593 additions and 108 deletions

View File

@ -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"

View File

@ -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"

65
backend/api/apikeys.go Normal file
View File

@ -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"})
}

View File

@ -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)

View File

@ -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 ")

View File

@ -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])
}
}
}

View File

@ -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)

View File

@ -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')

View File

@ -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'

View File

@ -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() {
</div>
</div>
{/* API Keys */}
<APIKeysSection />
{/* 2FA */}
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
<div className="px-4 py-3 border-b border-gray-800">
@ -211,10 +214,162 @@ export default function SettingsPage() {
</div>
<div className="flex justify-between">
<span className="text-gray-500">Authentifizierung</span>
<span className="text-gray-400">JWT (8h Token)</span>
<span className="text-gray-400">JWT (8h Token) + API-Keys</span>
</div>
</div>
</div>
</div>
)
}
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 (
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
<div className="px-4 py-3 border-b border-gray-800 flex items-center justify-between">
<div className="flex items-center gap-2">
<Key className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-300">API-Keys</span>
<span className="text-xs text-gray-600">({keys.length})</span>
</div>
<button
onClick={() => { setShowAdd(true); setNewKey(null); setNewName('') }}
className="flex items-center gap-1 text-sm text-orange-400 hover:text-orange-300"
>
<Plus className="w-4 h-4" />
Neuer Key
</button>
</div>
{/* Neuer Key Dialog */}
{showAdd && !newKey && (
<div className="px-4 py-3 border-b border-gray-800 flex gap-2 items-end">
<div className="flex-1">
<label className="text-xs text-gray-500">Bezeichnung</label>
<input
type="text"
value={newName}
onChange={(e) => 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()}
/>
</div>
<button onClick={handleCreate} className="px-3 py-1 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white">
Erstellen
</button>
<button onClick={() => setShowAdd(false)} className="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400">
Abbrechen
</button>
</div>
)}
{/* Neuer Key Anzeige */}
{newKey && (
<div className="px-4 py-3 border-b border-gray-800 bg-green-900/20">
<div className="text-sm text-green-400 mb-2">API-Key erstellt jetzt kopieren, er wird nur einmal angezeigt!</div>
<div className="flex items-center gap-2">
<code className="flex-1 bg-gray-950 rounded px-3 py-2 text-sm text-white font-mono select-all">
{newKey.key}
</code>
<button
onClick={() => copyToClipboard(newKey.key, 'new')}
className="px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded text-gray-300 transition-colors"
>
{copied === 'new' ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<button
onClick={() => { setNewKey(null); setShowAdd(false) }}
className="mt-2 text-xs text-gray-500 hover:text-gray-400"
>
Fertig
</button>
</div>
)}
{/* Key Liste */}
{loading ? (
<div className="px-4 py-3 text-gray-500 text-sm">Laden...</div>
) : keys.length === 0 ? (
<div className="px-4 py-6 text-center text-gray-500 text-sm">Keine API-Keys vorhanden</div>
) : (
<div className="divide-y divide-gray-800">
{keys.map((k) => (
<div key={k.id} className="px-4 py-2.5 flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-white">{k.name}</span>
</div>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-xs text-gray-600 font-mono">{k.key.substring(0, 8)}...{k.key.substring(k.key.length - 4)}</span>
<button
onClick={() => copyToClipboard(k.key, k.id)}
className="text-gray-600 hover:text-orange-400 transition-colors"
title="Key kopieren"
>
{copied === k.id ? <Check className="w-3 h-3 text-green-400" /> : <Copy className="w-3 h-3" />}
</button>
<span className="text-xs text-gray-600">
Erstellt: {new Date(k.created_at).toLocaleDateString('de-DE')}
</span>
{k.last_used && (
<span className="text-xs text-gray-600">
Letzter Zugriff: {new Date(k.last_used).toLocaleString('de-DE')}
</span>
)}
</div>
</div>
<button
onClick={() => handleDelete(k.id, k.name)}
className="text-gray-500 hover:text-red-400 p-1 rounded hover:bg-gray-800 transition-colors ml-2"
title="Key loeschen"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
)
}

View File

@ -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"

View File

@ -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 {