rmm2/backend/api/scheduler.go
cynfo3000 10684dfc0a feat: TOTP 2FA + diverse Verbesserungen
2FA (TOTP):
- backend/api/auth.go: zweistufiger Login-Flow mit Pending-Token (5min),
  Endpunkte /2fa/validate, /2fa/setup, /2fa/confirm, /2fa/disable
- backend/db/users.go: GetUserByID, SetTOTPSecret, EnableTOTP, DisableTOTP
- backend/db/postgres.go: Migration totp_secret + totp_enabled Spalten
- backend/models/user.go: TOTPEnabled/TOTPSecret Felder, neue Request-Typen
- backend/main.go: neue Routen registriert (/2fa/validate ohne Auth, rest geschuetzt)
- backend/go.mod+sum: github.com/pquerna/otp hinzugefuegt
- frontend/src/pages/Login.jsx: zweistufiger Login (Passwort -> TOTP)
- frontend/src/pages/SettingsPage.jsx: TwoFactorSection mit Setup/Deaktivieren
- frontend/src/stores/auth.js: validateTOTP Action
- frontend/src/api/client.js: setupTOTP, confirmTOTP, disableTOTP, validateTOTP

Weitere Aenderungen (unstaged -> jetzt committed):
- frontend/src/components/ScriptModal.jsx: neu
- frontend/src/components/AgentPanel.jsx: Erweiterungen
- frontend/src/components/ProxmoxPanel.jsx: Erweiterungen
- agent-linux/ws/handler.go: Erweiterungen
- backend/api/scheduler.go + tasks.go: Erweiterungen
- backend/db/tasks.go + models/task.go: Erweiterungen
2026-03-08 19:17:43 +01:00

391 lines
12 KiB
Go

package api
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
"github.com/cynfo/rmm-backend/db"
"github.com/cynfo/rmm-backend/models"
"github.com/cynfo/rmm-backend/ws"
)
type TaskScheduler struct {
db *db.Database
hub *ws.Hub
}
func NewTaskScheduler(database *db.Database, hub *ws.Hub) *TaskScheduler {
return &TaskScheduler{db: database, hub: hub}
}
func (s *TaskScheduler) Run() {
log.Println("Task-Scheduler gestartet (30s Intervall)")
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
// Run once immediately
s.processDueTasks()
for range ticker.C {
s.processDueTasks()
}
}
func (s *TaskScheduler) processDueTasks() {
tasks, err := s.db.GetDueTasks()
if err != nil {
log.Printf("Scheduler: Fehler beim Laden faelliger Tasks: %v", err)
return
}
for _, task := range tasks {
go s.executeTask(task)
}
}
func (s *TaskScheduler) executeTask(task models.Task) {
log.Printf("Scheduler: Starte Task #%d (%s) fuer Agent %s", task.ID, task.Action, task.AgentID)
// Set status to running
s.db.UpdateTaskStatus(task.ID, "running", nil)
var result interface{}
var taskErr error
switch task.Action {
case "backup":
result, taskErr = s.executeBackup(task.AgentID)
case "update":
result, taskErr = s.executeUpdateWithHealthCheck(task.AgentID, task.Reboot, task.HealthCheck, task.HealthCheckTimeout, task.SecurityOnly)
case "zpoolscrub":
result, taskErr = s.executeSimpleCommand(task.AgentID, "zpoolscrub", map[string]interface{}{})
case "script":
timeout := task.ScriptTimeout
if timeout <= 0 {
timeout = 300
}
result, taskErr = s.executeSimpleCommand(task.AgentID, "exec", map[string]interface{}{
"command": task.Script,
"timeout": timeout,
})
default:
taskErr = fmt.Errorf("unbekannte Aktion: %s", task.Action)
}
success := taskErr == nil
healthCheckWarning := false
if !success {
// Check if it's a health check warning (update ran OK but agent didn't come back)
if resultMap, ok := result.(map[string]interface{}); ok {
if hc, ok := resultMap["health_check"].(map[string]interface{}); ok {
if online, ok := hc["agent_online"].(bool); ok && !online {
healthCheckWarning = true
}
}
}
if !healthCheckWarning {
result = map[string]string{"error": taskErr.Error()}
}
log.Printf("Scheduler: Task #%d fehlgeschlagen: %v", task.ID, taskErr)
} else {
log.Printf("Scheduler: Task #%d erfolgreich abgeschlossen", task.ID)
}
// Use CompleteRecurringTask for proper recurring handling
// Health check warnings count as success for recurring schedule, but status = "completed_with_warning"
scheduleSuccess := success || healthCheckWarning
s.db.CompleteRecurringTask(task.ID, task.ScheduleType, task.ScheduleTime,
task.ScheduleWeekday, task.ScheduleMonthday, scheduleSuccess, result)
// Override status for health check warnings
if healthCheckWarning {
s.db.UpdateTaskStatusDirect(task.ID, "completed_with_warning")
}
// Re-read task for webhook payload
fullTask, _ := s.db.GetTask(task.ID)
// Send webhook if configured
if task.WebhookURL != "" && fullTask != nil {
s.sendWebhook(task.ID, task.WebhookURL, fullTask)
}
}
// executeSimpleCommand schickt einen beliebigen Command an den Agenten und wartet auf Antwort
func (s *TaskScheduler) executeSimpleCommand(agentID, command string, params map[string]interface{}) (interface{}, error) {
if !s.hub.IsAgentConnected(agentID) {
return nil, fmt.Errorf("Agent nicht verbunden")
}
cmdID := generateID()
cmd := map[string]interface{}{
"type": "command",
"id": cmdID,
"command": command,
"params": params,
}
responseCh := s.hub.RegisterResponseWaiter(cmdID)
defer s.hub.UnregisterResponseWaiter(cmdID)
cmdJSON, _ := json.Marshal(cmd)
if err := s.hub.SendToAgent(agentID, cmdJSON); err != nil {
return nil, fmt.Errorf("Senden fehlgeschlagen: %w", err)
}
select {
case resp := <-responseCh:
if status, _ := resp["status"].(string); status == "error" {
errMsg, _ := resp["error"].(string)
return resp["data"], fmt.Errorf("%s", errMsg)
}
return resp["data"], nil
case <-time.After(120 * time.Second):
return nil, fmt.Errorf("Timeout nach 120s")
}
}
func (s *TaskScheduler) executeBackup(agentID string) (interface{}, error) {
if !s.hub.IsAgentConnected(agentID) {
return nil, fmt.Errorf("Agent nicht verbunden")
}
cmdID := generateID()
cmd := map[string]interface{}{
"type": "command",
"id": cmdID,
"command": "backup",
"params": map[string]interface{}{},
}
responseCh := s.hub.RegisterResponseWaiter(cmdID)
defer s.hub.UnregisterResponseWaiter(cmdID)
cmdJSON, _ := json.Marshal(cmd)
if err := s.hub.SendToAgent(agentID, cmdJSON); err != nil {
return nil, fmt.Errorf("Command senden fehlgeschlagen: %w", err)
}
select {
case resp := <-responseCh:
respStatus, _ := resp["status"].(string)
if respStatus != "ok" {
errMsg, _ := resp["error"].(string)
return resp, fmt.Errorf("Backup fehlgeschlagen: %s", errMsg)
}
data, ok := resp["data"].(map[string]interface{})
if !ok {
return resp, fmt.Errorf("ungueltiges Response-Format")
}
configB64, _ := data["config"].(string)
hash, _ := data["hash"].(string)
sizeFloat, _ := data["size"].(float64)
size := int(sizeFloat)
configXML, err := base64.StdEncoding.DecodeString(configB64)
if err != nil {
return nil, fmt.Errorf("Base64-Dekodierung fehlgeschlagen")
}
backupID, isNew, err := s.db.SaveConfigBackup(agentID, hash, size, string(configXML))
if err != nil {
return nil, fmt.Errorf("Backup speichern fehlgeschlagen: %w", err)
}
msg := "Backup gespeichert"
if !isNew {
msg = "Config unveraendert (Hash identisch)"
}
return map[string]interface{}{
"message": msg,
"backup_id": backupID,
"sha256": hash,
"size": size,
"new": isNew,
}, nil
case <-time.After(60 * time.Second):
return nil, fmt.Errorf("Timeout: Agent hat nicht rechtzeitig geantwortet")
}
}
func (s *TaskScheduler) executeUpdate(agentID string, reboot bool) (interface{}, error) {
return s.executeUpdateWithHealthCheck(agentID, reboot, false, 600, false)
}
func (s *TaskScheduler) executeUpdateWithHealthCheck(agentID string, reboot bool, healthCheck bool, healthCheckTimeout int, securityOnly bool) (interface{}, error) {
if !s.hub.IsAgentConnected(agentID) {
return nil, fmt.Errorf("Agent nicht verbunden")
}
// First: update_check
checkCmdID := generateID()
checkCmd := map[string]interface{}{
"type": "command",
"id": checkCmdID,
"command": "update_check",
}
checkCh := s.hub.RegisterResponseWaiter(checkCmdID)
defer s.hub.UnregisterResponseWaiter(checkCmdID)
checkJSON, _ := json.Marshal(checkCmd)
if err := s.hub.SendToAgent(agentID, checkJSON); err != nil {
return nil, fmt.Errorf("Update-Check senden fehlgeschlagen: %w", err)
}
var checkResult map[string]interface{}
select {
case resp := <-checkCh:
checkResult = resp
case <-time.After(120 * time.Second):
return nil, fmt.Errorf("Timeout beim Update-Check")
}
// Then: update
updateCmdID := generateID()
updateCmd := map[string]interface{}{
"type": "command",
"id": updateCmdID,
"command": "update",
"params": map[string]interface{}{"reboot": reboot, "security_only": securityOnly},
}
updateCh := s.hub.RegisterResponseWaiter(updateCmdID)
defer s.hub.UnregisterResponseWaiter(updateCmdID)
updateJSON, _ := json.Marshal(updateCmd)
if err := s.hub.SendToAgent(agentID, updateJSON); err != nil {
return nil, fmt.Errorf("Update-Command senden fehlgeschlagen: %w", err)
}
var updateResult map[string]interface{}
select {
case resp := <-updateCh:
respStatus, _ := resp["status"].(string)
if respStatus != "ok" {
errMsg, _ := resp["error"].(string)
return resp, fmt.Errorf("Update fehlgeschlagen: %s", errMsg)
}
updateResult = resp
case <-time.After(900 * time.Second):
return nil, fmt.Errorf("Timeout beim Update")
}
result := map[string]interface{}{
"message": "Update ausgefuehrt",
"check_result": checkResult,
"update_result": updateResult,
"reboot": reboot,
}
// Health Check: wait for agent to come back online
if healthCheck {
timeout := time.Duration(healthCheckTimeout) * time.Second
if timeout <= 0 {
timeout = 600 * time.Second
}
log.Printf("Scheduler: Health Check fuer Agent %s (Timeout: %v)", agentID, timeout)
// If no reboot, agent should still be connected — give it a moment for post-update restart
if !reboot {
time.Sleep(15 * time.Second)
}
checkedAt := time.Now().UTC().Format(time.RFC3339)
agentOnline := s.hub.WaitForAgentOnline(agentID, timeout)
result["health_check"] = map[string]interface{}{
"agent_online": agentOnline,
"checked_at": checkedAt,
}
if !agentOnline {
result["message"] = "Update ausgefuehrt, aber Agent nicht wieder online (Health Check fehlgeschlagen)"
log.Printf("Scheduler: Health Check fehlgeschlagen — Agent %s nach %v nicht online", agentID, timeout)
// Return result but signal warning via error
return result, fmt.Errorf("Health Check: Agent nach %v nicht online", timeout)
}
log.Printf("Scheduler: Health Check OK — Agent %s wieder online", agentID)
}
return result, nil
}
// WebhookPayload ist der strukturierte Payload fuer ERP-Serviceberichte
type WebhookPayload struct {
CustomerID string `json:"customer_id"`
Hostname string `json:"hostname"`
OPNsenseVersion string `json:"opnsense_version"`
Timestamp string `json:"timestamp"`
Trigger string `json:"trigger"`
Reachable bool `json:"reachable"`
UpdateStatus string `json:"update_status"`
}
func (s *TaskScheduler) sendWebhook(taskID int, url string, task *models.Task) {
// Agent-Daten aus DB holen (hostname, opnsense_version)
agent, _, err := s.db.GetAgent(task.AgentID)
if err != nil || agent == nil {
log.Printf("Scheduler: Webhook Task #%d — Agent %s nicht gefunden: %v", taskID, task.AgentID, err)
s.db.SetTaskWebhookResult(taskID, false, fmt.Sprintf("Agent nicht gefunden: %v", err))
return
}
// Erreichbarkeit aus Health-Check-Result extrahieren
reachable := true
if result, ok := task.Result.(map[string]interface{}); ok {
if hc, ok := result["health_check"].(map[string]interface{}); ok {
if online, ok := hc["agent_online"].(bool); ok {
reachable = online
}
}
}
// Trigger aus Action ableiten
trigger := task.Action + "_completed"
// Timestamp: FinishedAt oder jetzt
timestamp := time.Now().UTC().Format(time.RFC3339)
if task.FinishedAt != nil && *task.FinishedAt != "" {
timestamp = *task.FinishedAt
}
payload := WebhookPayload{
CustomerID: task.CustomerNumber,
Hostname: agent.Hostname,
OPNsenseVersion: agent.OPNsenseVersion,
Timestamp: timestamp,
Trigger: trigger,
Reachable: reachable,
UpdateStatus: task.Status,
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
log.Printf("Scheduler: Webhook-Payload fuer Task #%d serialisieren fehlgeschlagen: %v", taskID, err)
s.db.SetTaskWebhookResult(taskID, false, fmt.Sprintf("JSON-Fehler: %v", err))
return
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Post(url, "application/json", bytes.NewReader(payloadJSON))
if err != nil {
log.Printf("Scheduler: Webhook fuer Task #%d fehlgeschlagen: %v", taskID, err)
s.db.SetTaskWebhookResult(taskID, false, fmt.Sprintf("Fehler: %v", err))
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
response := fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body))
sent := resp.StatusCode >= 200 && resp.StatusCode < 300
s.db.SetTaskWebhookResult(taskID, sent, response)
log.Printf("Scheduler: Webhook fuer Task #%d gesendet (Status: %d)", taskID, resp.StatusCode)
}