install.sh + frontend: use external URL dsbmueller.spdns.org:8443, configurable via RMM_BACKEND env
This commit is contained in:
parent
5f37e28046
commit
eb52507c08
86
Makefile
86
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
|
BACKEND_BIN = build/rmm-backend
|
||||||
AGENT_BIN = build/rmm-agent
|
AGENT_BIN = build/rmm-agent
|
||||||
AGENT_LINUX_BIN = build/rmm-agent-linux
|
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
|
BACKEND_HOST = 192.168.85.13
|
||||||
AGENT_HOST = 192.168.85.33
|
|
||||||
SSH_USER = root
|
SSH_USER = root
|
||||||
|
|
||||||
all: backend agent agent-linux updater
|
all: backend agent agent-linux updater-freebsd updater-linux
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
@echo "==> Building Backend (linux/amd64)..."
|
@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) .
|
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"
|
@echo "==> $(AGENT_LINUX_BIN) erstellt"
|
||||||
|
|
||||||
updater:
|
updater-freebsd:
|
||||||
@echo "==> Building Updater (freebsd/amd64)..."
|
@echo "==> Building Updater (freebsd/amd64)..."
|
||||||
cd updater && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -o ../$(UPDATER_BIN) .
|
cd updater && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -o ../$(UPDATER_FREEBSD_BIN) .
|
||||||
@echo "==> $(UPDATER_BIN) erstellt"
|
@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:
|
plugin:
|
||||||
@echo "==> Building OPNsense Plugin Package..."
|
@echo "==> Building OPNsense Plugin Package..."
|
||||||
@ -51,52 +72,7 @@ certs:
|
|||||||
|
|
||||||
deploy-backend: backend
|
deploy-backend: backend
|
||||||
@echo "==> Deploying Backend to $(BACKEND_HOST)..."
|
@echo "==> Deploying Backend to $(BACKEND_HOST)..."
|
||||||
ssh $(SSH_USER)@$(BACKEND_HOST) "mkdir -p /opt/rmm/certs"
|
ssh $(SSH_USER)@$(BACKEND_HOST) "systemctl stop rmm-backend || true"
|
||||||
scp $(BACKEND_BIN) $(SSH_USER)@$(BACKEND_HOST):/opt/rmm/rmm-backend
|
scp $(BACKEND_BIN) $(SSH_USER)@$(BACKEND_HOST):/home/cynfo/rmm/rmm-backend
|
||||||
scp backend/config.yaml $(SSH_USER)@$(BACKEND_HOST):/opt/rmm/config.yaml
|
ssh $(SSH_USER)@$(BACKEND_HOST) "systemctl start rmm-backend"
|
||||||
@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"
|
|
||||||
@echo "==> Backend deployed und gestartet"
|
@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"
|
|
||||||
|
|||||||
@ -114,12 +114,14 @@ func (h *Handler) handleUpdateCheck(msg Message) Message {
|
|||||||
result := map[string]interface{}{}
|
result := map[string]interface{}{}
|
||||||
|
|
||||||
// 1. OPNsense Core Update pruefen
|
// 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)
|
coreOutput, coreErr := h.runCommand("/usr/local/sbin/opnsense-update -c", 60)
|
||||||
if coreErr != nil {
|
coreInfo := strings.TrimSpace(coreOutput)
|
||||||
// Exit 1 = Update verfuegbar ODER Reboot noetig
|
if coreErr != nil && coreInfo != "" {
|
||||||
info := strings.TrimSpace(coreOutput)
|
// Exit 1 MIT Output = echtes Update verfuegbar
|
||||||
result["core_update_available"] = true
|
result["core_update_available"] = true
|
||||||
result["core_update_info"] = info
|
result["core_update_info"] = coreInfo
|
||||||
} else {
|
} else {
|
||||||
result["core_update_available"] = false
|
result["core_update_available"] = false
|
||||||
result["core_update_info"] = "Kein Core-Update verfuegbar"
|
result["core_update_info"] = "Kein Core-Update verfuegbar"
|
||||||
|
|||||||
65
backend/api/apikeys.go
Normal file
65
backend/api/apikeys.go
Normal 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"})
|
||||||
|
}
|
||||||
@ -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("PUT /api/v1/users/{id}/password", h.changePassword)
|
||||||
mux.HandleFunc("DELETE /api/v1/users/{id}", h.deleteUser)
|
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
|
// Firmware/Agent-Update Routes
|
||||||
mux.HandleFunc("GET /api/v1/firmware", h.getFirmwareInfo)
|
mux.HandleFunc("GET /api/v1/firmware", h.getFirmwareInfo)
|
||||||
mux.HandleFunc("POST /api/v1/firmware/upload", h.uploadFirmware)
|
mux.HandleFunc("POST /api/v1/firmware/upload", h.uploadFirmware)
|
||||||
|
|||||||
@ -5,34 +5,101 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/cynfo/rmm-backend/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
|
|
||||||
const UserContextKey contextKey = "user"
|
const UserContextKey contextKey = "user"
|
||||||
|
|
||||||
// APIKeyAuth - Middleware fuer API-Key Authentifizierung
|
// APIKeyCache cached DB-basierte API-Keys mit periodischem Refresh
|
||||||
func APIKeyAuth(validKeys []string, next http.Handler) http.Handler {
|
type APIKeyCache struct {
|
||||||
keySet := make(map[string]bool, len(validKeys))
|
db *db.Database
|
||||||
for _, k := range validKeys {
|
mu sync.RWMutex
|
||||||
keySet[k] = true
|
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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Try API key first
|
||||||
key := r.Header.Get("X-API-Key")
|
key := r.Header.Get("X-API-Key")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
key = r.URL.Query().Get("api_key")
|
key = r.URL.Query().Get("api_key")
|
||||||
}
|
}
|
||||||
if key == "" || !keySet[key] {
|
if key != "" && cache.IsValid(key) {
|
||||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
next.ServeHTTP(w, r)
|
||||||
return
|
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 {
|
func CombinedAuth(validKeys []string, auth *AuthHandler, next http.Handler) http.Handler {
|
||||||
keySet := make(map[string]bool, len(validKeys))
|
keySet := make(map[string]bool, len(validKeys))
|
||||||
for _, k := range 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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Try API key first
|
|
||||||
key := r.Header.Get("X-API-Key")
|
key := r.Header.Get("X-API-Key")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
key = r.URL.Query().Get("api_key")
|
key = r.URL.Query().Get("api_key")
|
||||||
@ -50,7 +116,6 @@ func CombinedAuth(validKeys []string, auth *AuthHandler, next http.Handler) http
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try JWT
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|||||||
@ -182,6 +182,15 @@ func (d *Database) migrate() error {
|
|||||||
// Indices fuer Metrics
|
// Indices fuer Metrics
|
||||||
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_metrics_agent_metric ON metrics(agent_id, metric, time DESC)`)
|
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)
|
// Retention Policy (90 Tage)
|
||||||
d.setupRetention()
|
d.setupRetention()
|
||||||
|
|
||||||
@ -954,3 +963,96 @@ func (d *Database) GetAgentStatus(agentID string) string {
|
|||||||
}
|
}
|
||||||
return models.CalculateAgentStatus(&lastHB.Time)
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -57,6 +57,12 @@ func main() {
|
|||||||
mon := monitor.New(database)
|
mon := monitor.New(database)
|
||||||
go mon.Run()
|
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
|
// Auth-Handler initialisieren
|
||||||
authHandler := api.NewAuthHandler(database, cfg.JWTSecret)
|
authHandler := api.NewAuthHandler(database, cfg.JWTSecret)
|
||||||
authHandler.EnsureDefaultAdmin()
|
authHandler.EnsureDefaultAdmin()
|
||||||
@ -72,10 +78,10 @@ func main() {
|
|||||||
// 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)
|
||||||
|
|
||||||
// Combined router: auth routes ungeschuetzt, rest mit CombinedAuth
|
// Combined router: auth routes ungeschuetzt, rest mit DB-basierter Auth
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("POST /api/v1/auth/login", authMux)
|
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
|
// Middleware-Chain: CORS -> Logging -> Handler
|
||||||
var chain http.Handler = mux
|
var chain http.Handler = mux
|
||||||
@ -83,7 +89,7 @@ func main() {
|
|||||||
chain = api.CORS(chain)
|
chain = api.CORS(chain)
|
||||||
|
|
||||||
log.Printf("RMM Backend startet auf %s (TLS)", cfg.ListenAddr)
|
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 {
|
if err := http.ListenAndServeTLS(cfg.ListenAddr, cfg.TLSCert, cfg.TLSKey, chain); err != nil {
|
||||||
log.Fatalf("Server-Fehler: %v", err)
|
log.Fatalf("Server-Fehler: %v", err)
|
||||||
|
|||||||
@ -223,6 +223,19 @@ class ApiClient {
|
|||||||
return this.put(`/api/v1/users/${userId}/password`, { password })
|
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
|
// Firmware
|
||||||
getFirmwareInfo() {
|
getFirmwareInfo() {
|
||||||
return this.get('/api/v1/firmware')
|
return this.get('/api/v1/firmware')
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Copy, Check, Terminal, Monitor } from 'lucide-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 API_KEY = '01532e5a7c9e70bf2757df77a2f5b9b9'
|
||||||
const INSTALL_SH_URL = 'https://git.cynfo.net/christian/rmm/raw/branch/main/opnsense-plugin/install.sh'
|
const INSTALL_SH_URL = 'https://git.cynfo.net/christian/rmm/raw/branch/main/opnsense-plugin/install.sh'
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import { useAuthStore } from '../stores/auth'
|
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() {
|
export default function SettingsPage() {
|
||||||
const [users, setUsers] = useState([])
|
const [users, setUsers] = useState([])
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
const [form, setForm] = useState({ username: '', password: '', display_name: '' })
|
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 [pwForm, setPwForm] = useState({ password: '', confirm: '' })
|
||||||
const [pwMsg, setPwMsg] = useState('')
|
const [pwMsg, setPwMsg] = useState('')
|
||||||
const { user: currentUser } = useAuthStore()
|
const { user: currentUser } = useAuthStore()
|
||||||
@ -185,6 +185,9 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* API Keys */}
|
||||||
|
<APIKeysSection />
|
||||||
|
|
||||||
{/* 2FA */}
|
{/* 2FA */}
|
||||||
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
|
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b border-gray-800">
|
<div className="px-4 py-3 border-b border-gray-800">
|
||||||
@ -211,10 +214,162 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Authentifizierung</span>
|
<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>
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -16,8 +16,8 @@ set -e
|
|||||||
BASEDIR="/tmp"
|
BASEDIR="/tmp"
|
||||||
BINARY="${BASEDIR}/rmm-agent"
|
BINARY="${BASEDIR}/rmm-agent"
|
||||||
UPDATER_BINARY="${BASEDIR}/rmm-updater"
|
UPDATER_BINARY="${BASEDIR}/rmm-updater"
|
||||||
BACKEND_URL="https://192.168.85.13:8443"
|
BACKEND_URL="${RMM_BACKEND:-https://dsbmueller.spdns.org:8443}"
|
||||||
API_KEY="01532e5a7c9e70bf2757df77a2f5b9b9"
|
API_KEY="${RMM_API_KEY:-01532e5a7c9e70bf2757df77a2f5b9b9}"
|
||||||
|
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
echo " RMM Agent Plugin Installer"
|
echo " RMM Agent Plugin Installer"
|
||||||
|
|||||||
146
updater/main.go
146
updater/main.go
@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@ -12,6 +14,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@ -27,23 +30,34 @@ type Config struct {
|
|||||||
// Plattform-spezifische Pfade und Befehle
|
// Plattform-spezifische Pfade und Befehle
|
||||||
var (
|
var (
|
||||||
agentBinaryPath string
|
agentBinaryPath string
|
||||||
|
updaterBinaryPath string
|
||||||
configPath string
|
configPath string
|
||||||
agentIDPath string
|
agentIDPath string
|
||||||
platformName string
|
platformName string
|
||||||
|
|
||||||
|
// Dateinamen im ZIP-Bundle
|
||||||
|
agentZipName string
|
||||||
|
updaterZipName string
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "freebsd":
|
case "freebsd":
|
||||||
agentBinaryPath = "/usr/local/rmm/rmm-agent"
|
agentBinaryPath = "/usr/local/rmm/rmm-agent"
|
||||||
|
updaterBinaryPath = "/usr/local/rmm/rmm-updater"
|
||||||
configPath = "/usr/local/rmm/config.yaml"
|
configPath = "/usr/local/rmm/config.yaml"
|
||||||
agentIDPath = "/usr/local/rmm/agent_id"
|
agentIDPath = "/usr/local/rmm/agent_id"
|
||||||
platformName = "freebsd"
|
platformName = "freebsd"
|
||||||
|
agentZipName = "rmm-agent"
|
||||||
|
updaterZipName = "rmm-updater-freebsd"
|
||||||
default: // linux
|
default: // linux
|
||||||
agentBinaryPath = "/usr/local/bin/rmm-agent"
|
agentBinaryPath = "/usr/local/bin/rmm-agent"
|
||||||
|
updaterBinaryPath = "/usr/local/bin/rmm-updater"
|
||||||
configPath = "/etc/rmm/config.yaml"
|
configPath = "/etc/rmm/config.yaml"
|
||||||
agentIDPath = "/etc/rmm/agent_id"
|
agentIDPath = "/etc/rmm/agent_id"
|
||||||
platformName = "linux"
|
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 (
|
const (
|
||||||
checkInterval = 60 * time.Second
|
checkInterval = 60 * time.Second
|
||||||
logPrefix = "[rmm-updater] "
|
logPrefix = "[rmm-updater] "
|
||||||
@ -74,7 +97,7 @@ func main() {
|
|||||||
log.SetPrefix(logPrefix)
|
log.SetPrefix(logPrefix)
|
||||||
log.SetFlags(log.LstdFlags)
|
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()
|
cfg, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -119,7 +142,7 @@ func loadAgentID() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("agent_id nicht gefunden: %v", err)
|
return "", fmt.Errorf("agent_id nicht gefunden: %v", err)
|
||||||
}
|
}
|
||||||
id := string(data)
|
id := strings.TrimSpace(string(data))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return "", fmt.Errorf("agent_id leer")
|
return "", fmt.Errorf("agent_id leer")
|
||||||
}
|
}
|
||||||
@ -153,9 +176,9 @@ func checkAndUpdate(client *http.Client, cfg *Config, agentID string) {
|
|||||||
return
|
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 {
|
if localHash, err := hashFile(agentBinaryPath); err == nil && localHash == info.Hash {
|
||||||
return
|
return // Schon aktuell
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update angefordert?
|
// Update angefordert?
|
||||||
@ -206,24 +229,27 @@ func downloadAndApply(client *http.Client, cfg *Config, agentID, expectedHash st
|
|||||||
return fmt.Errorf("download HTTP %d", resp.StatusCode)
|
return fmt.Errorf("download HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
binary, err := io.ReadAll(resp.Body)
|
bundle, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("download lesen fehlgeschlagen: %v", err)
|
return fmt.Errorf("download lesen fehlgeschlagen: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash verifizieren
|
// Hash des gesamten Bundles verifizieren
|
||||||
hash := sha256.Sum256(binary)
|
hash := sha256.Sum256(bundle)
|
||||||
hashStr := hex.EncodeToString(hash[:])
|
hashStr := hex.EncodeToString(hash[:])
|
||||||
if hashStr != expectedHash {
|
if hashStr != expectedHash {
|
||||||
return fmt.Errorf("hash mismatch: erwartet %s, bekommen %s", expectedHash[:16], hashStr[:16])
|
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
|
// Versuche als ZIP zu entpacken
|
||||||
tmpPath := agentBinaryPath + ".new"
|
agentBinary, updaterBinary, err := extractZipBundle(bundle)
|
||||||
if err := os.WriteFile(tmpPath, binary, 0755); err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("tmp schreiben fehlgeschlagen: %v", err)
|
// Fallback: Kein ZIP — einzelne Agent-Binary (Abwaertskompatibilitaet)
|
||||||
|
log.Printf("Kein ZIP-Bundle, verwende als einzelne Agent-Binary")
|
||||||
|
agentBinary = bundle
|
||||||
|
updaterBinary = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent stoppen
|
// Agent stoppen
|
||||||
@ -231,38 +257,108 @@ func downloadAndApply(client *http.Client, cfg *Config, agentID, expectedHash st
|
|||||||
stopAgent()
|
stopAgent()
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
// Binary ersetzen mit Rollback
|
// Agent-Binary ersetzen
|
||||||
backupPath := agentBinaryPath + ".bak"
|
if err := replaceBinary(agentBinaryPath, agentBinary); err != nil {
|
||||||
if _, err := os.Stat(agentBinaryPath); err == nil {
|
return fmt.Errorf("agent binary ersetzen fehlgeschlagen: %v", err)
|
||||||
os.Rename(agentBinaryPath, backupPath)
|
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmpPath, agentBinaryPath); err != nil {
|
log.Println("Agent-Binary aktualisiert")
|
||||||
os.Rename(backupPath, agentBinaryPath)
|
|
||||||
return fmt.Errorf("binary ersetzen fehlgeschlagen: %v", err)
|
|
||||||
}
|
|
||||||
os.Chmod(agentBinaryPath, 0755)
|
|
||||||
|
|
||||||
// Agent starten
|
// Agent starten
|
||||||
log.Println("Agent wird gestartet...")
|
log.Println("Agent wird gestartet...")
|
||||||
if err := startAgent(); err != nil {
|
if err := startAgent(); err != nil {
|
||||||
log.Printf("WARNUNG: Agent-Start fehlgeschlagen: %v — versuche Rollback", err)
|
log.Printf("WARNUNG: Agent-Start fehlgeschlagen: %v — versuche Rollback", err)
|
||||||
os.Rename(backupPath, agentBinaryPath)
|
rollbackBinary(agentBinaryPath)
|
||||||
startAgent()
|
startAgent()
|
||||||
return fmt.Errorf("agent start fehlgeschlagen")
|
return fmt.Errorf("agent start fehlgeschlagen")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup aufraeumen
|
|
||||||
os.Remove(backupPath)
|
|
||||||
|
|
||||||
// Update-Flag loeschen
|
// Update-Flag loeschen
|
||||||
clearURL := fmt.Sprintf("%s/api/v1/agents/%s/request-update", cfg.BackendURL, agentID)
|
clearURL := fmt.Sprintf("%s/api/v1/agents/%s/request-update", cfg.BackendURL, agentID)
|
||||||
clearReq, _ := http.NewRequest("DELETE", clearURL, nil)
|
clearReq, _ := http.NewRequest("DELETE", clearURL, nil)
|
||||||
clearReq.Header.Set("X-API-Key", cfg.APIKey)
|
clearReq.Header.Set("X-API-Key", cfg.APIKey)
|
||||||
client.Do(clearReq)
|
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
|
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) {
|
func hashFile(path string) (string, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user