From c401ea8f299e0699c51eb4f6c6a142d451582f7e Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 19 Mar 2026 15:41:24 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=209=20=E2=80=94=20Offline=20Detec?= =?UTF-8?q?tion,=20API=20Key=20Auth,=20Agent=20Self-Update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offline Detection (9.1): - AgentOfflineDetectorService: BackgroundService, prüft alle 60s ob Agents seit >5 min kein Heartbeat hatten → Status=Offline - IServiceScopeFactory für korrektes Scoped-DI im Singleton - SignalR-Push AgentStatusChanged bei jeder Offline-Markierung API Key Auth (9.2): - ApiKeyMiddleware: prüft X-Api-Key Header gegen Security:ApiKey Config - Deaktiviert wenn ApiKey leer (Dev-Modus), Swagger/hubs bypassed - Frontend: getApiKey() aus localStorage, automatisch in allen Requests - Settings-Modal in Sidebar: API-Key eingeben + maskiert anzeigen Agent Self-Update (9.3): - internal/updater/updater.go: CheckForUpdate() + Update() Download, SHA256-Verify, Windows Batch-Neustart / Linux Shell-Neustart - AgentReleasesController: GET /api/v1/agent/releases/latest, GET /api/v1/agent/releases/download/{platform} - AgentReleaseOptions: LatestVersion, ReleasePath, Checksum in appsettings - executeCommand() erhält cfg *Config statt agentID string (für ServerAddress-Ableitung im UpdateAgent-Case) Co-Authored-By: Claude Sonnet 4.6 --- Agent/cmd/agent/main.go | 28 +++- Agent/internal/updater/updater.go | 155 ++++++++++++++++++ .../Controllers/AgentReleasesController.cs | 56 +++++++ .../Middleware/ApiKeyMiddleware.cs | 50 ++++++ Backend/src/NexusRMM.Api/Program.cs | 7 + .../Services/AgentOfflineDetectorService.cs | 80 +++++++++ .../Services/AgentReleaseOptions.cs | 9 + .../NexusRMM.Api/appsettings.Development.json | 3 + Backend/src/NexusRMM.Api/appsettings.json | 10 +- Frontend/src/App.tsx | 86 +++++++++- Frontend/src/api/client.ts | 11 +- 11 files changed, 484 insertions(+), 11 deletions(-) create mode 100644 Agent/internal/updater/updater.go create mode 100644 Backend/src/NexusRMM.Api/Controllers/AgentReleasesController.cs create mode 100644 Backend/src/NexusRMM.Api/Middleware/ApiKeyMiddleware.cs create mode 100644 Backend/src/NexusRMM.Api/Services/AgentOfflineDetectorService.cs create mode 100644 Backend/src/NexusRMM.Api/Services/AgentReleaseOptions.cs diff --git a/Agent/cmd/agent/main.go b/Agent/cmd/agent/main.go index 44b7631..416c4cc 100644 --- a/Agent/cmd/agent/main.go +++ b/Agent/cmd/agent/main.go @@ -19,6 +19,7 @@ import ( "nexusrmm.local/agent/internal/executor" "nexusrmm.local/agent/internal/meshagent" "nexusrmm.local/agent/internal/scanner" + "nexusrmm.local/agent/internal/updater" pb "nexusrmm.local/agent/pkg/proto" ) @@ -142,11 +143,12 @@ func doHeartbeat(ctx context.Context, client *connection.GrpcClient, cfg *config for _, cmd := range resp.PendingCommands { log.Printf("Executing command %s (type: %v)", cmd.CommandId, cmd.Type) - go executeCommand(ctx, client, cfg.AgentID, cmd) + go executeCommand(ctx, client, cfg, cmd) } } -func executeCommand(ctx context.Context, client *connection.GrpcClient, agentID string, cmd *pb.AgentCommand) { +func executeCommand(ctx context.Context, client *connection.GrpcClient, cfg *config.Config, cmd *pb.AgentCommand) { + agentID := cfg.AgentID var result *executor.Result switch cmd.Type { case pb.CommandType_COMMAND_TYPE_SHELL: @@ -170,6 +172,28 @@ func executeCommand(ctx context.Context, client *connection.GrpcClient, agentID Success: true, } } + case pb.CommandType_COMMAND_TYPE_UPDATE_AGENT: + // Payload optional: {"serverAddress": "localhost:5000"} + var params struct { + ServerAddress string `json:"serverAddress"` + } + _ = json.Unmarshal([]byte(cmd.Payload), ¶ms) + if params.ServerAddress == "" { + // Aus gRPC-Adresse REST-Adresse ableiten (Port 5000 statt 5001) + params.ServerAddress = strings.Replace(cfg.ServerAddress, "5001", "5000", 1) + } + info, err := updater.CheckForUpdate(ctx, params.ServerAddress, version) + if err != nil { + result = &executor.Result{ExitCode: 1, Stderr: err.Error()} + } else if info == nil { + result = &executor.Result{ExitCode: 0, Stdout: "Bereits aktuell: " + version, Success: true} + } else { + if updateErr := updater.Update(ctx, info); updateErr != nil { + result = &executor.Result{ExitCode: 1, Stderr: updateErr.Error()} + } else { + result = &executor.Result{ExitCode: 0, Stdout: "Update auf " + info.Version + " gestartet", Success: true} + } + } default: result = &executor.Result{ExitCode: -1, Stderr: fmt.Sprintf("unknown command type: %v", cmd.Type)} } diff --git a/Agent/internal/updater/updater.go b/Agent/internal/updater/updater.go new file mode 100644 index 0000000..f5403dd --- /dev/null +++ b/Agent/internal/updater/updater.go @@ -0,0 +1,155 @@ +package updater + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +// ReleaseInfo enthält Informationen über die aktuell verfügbare Agent-Version. +type ReleaseInfo struct { + Version string `json:"version"` + DownloadUrl string `json:"downloadUrl"` + Checksum string `json:"checksum"` // SHA256 hex +} + +// CheckForUpdate fragt den Backend-Server nach der aktuellen Version. +// serverAddress: z.B. "localhost:5000" (REST Port, nicht gRPC) +func CheckForUpdate(ctx context.Context, serverAddress, currentVersion string) (*ReleaseInfo, error) { + url := fmt.Sprintf("http://%s/api/v1/agent/releases/latest?os=%s&arch=%s", + serverAddress, runtime.GOOS, runtime.GOARCH) + + httpCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(httpCtx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching release info: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d from release endpoint", resp.StatusCode) + } + + var info ReleaseInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return nil, fmt.Errorf("decoding release info: %w", err) + } + + if info.Version == currentVersion { + return nil, nil // bereits aktuell + } + + return &info, nil +} + +// Update lädt die neue Version herunter und startet einen Austausch-Prozess. +// Nach erfolgreichem Download wird das neue Binary neben das aktuelle gelegt +// und ein Neustartprozess gestartet. +func Update(ctx context.Context, info *ReleaseInfo) error { + binaryPath, err := os.Executable() + if err != nil { + return fmt.Errorf("determining executable path: %w", err) + } + + // Download der neuen Version + dlCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + req, err := http.NewRequestWithContext(dlCtx, http.MethodGet, info.DownloadUrl, nil) + if err != nil { + return fmt.Errorf("creating download request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("downloading new binary: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %d during download", resp.StatusCode) + } + + fileBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading download body: %w", err) + } + + // SHA256-Prüfung (überspringen wenn Checksum leer = Dev-Modus) + if info.Checksum != "" { + sum := sha256.Sum256(fileBytes) + got := hex.EncodeToString(sum[:]) + if got != info.Checksum { + return fmt.Errorf("checksum mismatch: expected %s, got %s", info.Checksum, got) + } + } + + newBinaryPath := binaryPath + ".new" + if err := os.WriteFile(newBinaryPath, fileBytes, 0755); err != nil { + return fmt.Errorf("writing new binary: %w", err) + } + + // Platform-spezifischer Neustart + if runtime.GOOS == "windows" { + return restartWindows(binaryPath, newBinaryPath) + } + return restartUnix(binaryPath, newBinaryPath) +} + +func restartWindows(binaryPath, newBinaryPath string) error { + batchContent := fmt.Sprintf(`@echo off +timeout /t 2 /nobreak > nul +move /y "%s" "%s" +start "" "%s" +del "%%~f0" +`, newBinaryPath, binaryPath, binaryPath) + + batchPath := filepath.Join(os.TempDir(), "nexus-agent-update.bat") + if err := os.WriteFile(batchPath, []byte(batchContent), 0644); err != nil { + return fmt.Errorf("writing update batch script: %w", err) + } + + if err := exec.Command("cmd", "/c", "start", "", batchPath).Start(); err != nil { + return fmt.Errorf("starting update batch script: %w", err) + } + + os.Exit(0) + return nil +} + +func restartUnix(binaryPath, newBinaryPath string) error { + scriptContent := fmt.Sprintf(`#!/bin/sh +sleep 2 +mv -f "%s" "%s" +"%s" & +rm -f "$0" +`, newBinaryPath, binaryPath, binaryPath) + + scriptPath := filepath.Join(os.TempDir(), "nexus-agent-update.sh") + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { + return fmt.Errorf("writing update shell script: %w", err) + } + + if err := exec.Command("sh", scriptPath).Start(); err != nil { + return fmt.Errorf("starting update shell script: %w", err) + } + + os.Exit(0) + return nil +} diff --git a/Backend/src/NexusRMM.Api/Controllers/AgentReleasesController.cs b/Backend/src/NexusRMM.Api/Controllers/AgentReleasesController.cs new file mode 100644 index 0000000..a6ea84b --- /dev/null +++ b/Backend/src/NexusRMM.Api/Controllers/AgentReleasesController.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using NexusRMM.Api.Services; + +namespace NexusRMM.Api.Controllers; + +[ApiController] +[Route("api/v1/agent/releases")] +public class AgentReleasesController : ControllerBase +{ + private readonly AgentReleaseOptions _options; + + public AgentReleasesController(IOptions options) + { + _options = options.Value; + } + + /// + /// Gibt Informationen zur aktuell verfügbaren Agent-Version zurück. + /// + [HttpGet("latest")] + public IActionResult GetLatest([FromQuery] string os = "windows", [FromQuery] string arch = "amd64") + { + if (string.IsNullOrEmpty(_options.LatestVersion)) + return Ok(new { version = "dev", downloadUrl = (string?)null, checksum = (string?)null }); + + var platform = $"{os}-{arch}"; + var downloadUrl = Url.Action("Download", "AgentReleases", new { platform }, Request.Scheme); + + return Ok(new + { + version = _options.LatestVersion, + downloadUrl, + checksum = _options.Checksum + }); + } + + /// + /// Liefert das Agent-Binary für die angegebene Platform. + /// platform: windows-amd64, linux-amd64 + /// + [HttpGet("download/{platform}")] + public IActionResult Download(string platform) + { + if (string.IsNullOrEmpty(_options.ReleasePath)) + return NotFound("Kein Release-Pfad konfiguriert."); + + var filename = platform.StartsWith("windows") ? "nexus-agent.exe" : "nexus-agent-linux"; + var filePath = Path.Combine(_options.ReleasePath, filename); + + if (!System.IO.File.Exists(filePath)) + return NotFound($"Binary {filename} nicht gefunden unter {_options.ReleasePath}"); + + return PhysicalFile(filePath, "application/octet-stream", filename); + } +} diff --git a/Backend/src/NexusRMM.Api/Middleware/ApiKeyMiddleware.cs b/Backend/src/NexusRMM.Api/Middleware/ApiKeyMiddleware.cs new file mode 100644 index 0000000..5736823 --- /dev/null +++ b/Backend/src/NexusRMM.Api/Middleware/ApiKeyMiddleware.cs @@ -0,0 +1,50 @@ +namespace NexusRMM.Api.Middleware; + +public class ApiKeyMiddleware +{ + private const string ApiKeyHeader = "X-Api-Key"; + private readonly RequestDelegate _next; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public ApiKeyMiddleware(RequestDelegate next, IConfiguration config, ILogger logger) + { + _next = next; + _config = config; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + // Wenn kein API-Key konfiguriert ist: Auth deaktiviert (Dev-Fallback) + var configuredKey = _config["Security:ApiKey"]; + if (string.IsNullOrWhiteSpace(configuredKey)) + { + await _next(context); + return; + } + + // Swagger und Health-Endpunkte überspringen + var path = context.Request.Path.Value ?? ""; + if (path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("/health", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("/hubs", StringComparison.OrdinalIgnoreCase)) + { + await _next(context); + return; + } + + // API-Key aus Header lesen + if (!context.Request.Headers.TryGetValue(ApiKeyHeader, out var receivedKey) || + receivedKey != configuredKey) + { + _logger.LogWarning("Ungültiger API-Key von {IP}", context.Connection.RemoteIpAddress); + context.Response.StatusCode = 401; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync("{\"error\":\"Unauthorized: Invalid or missing API key\"}"); + return; + } + + await _next(context); + } +} diff --git a/Backend/src/NexusRMM.Api/Program.cs b/Backend/src/NexusRMM.Api/Program.cs index 0ab5552..fccd90e 100644 --- a/Backend/src/NexusRMM.Api/Program.cs +++ b/Backend/src/NexusRMM.Api/Program.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.EntityFrameworkCore; using NexusRMM.Api.GrpcServices; using NexusRMM.Api.Hubs; +using NexusRMM.Api.Middleware; using NexusRMM.Api.Services; using NexusRMM.Infrastructure.Data; @@ -24,11 +25,16 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddScoped(); +builder.Services.AddHostedService(); // MeshCentral Konfiguration builder.Services.Configure( builder.Configuration.GetSection(MeshCentralOptions.SectionName)); +// AgentRelease Konfiguration +builder.Services.Configure( + builder.Configuration.GetSection(AgentReleaseOptions.SectionName)); + // HttpClient für MeshCentral (mit optionalem SSL-Bypass für Entwicklung) builder.Services.AddHttpClient("MeshCentral") .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler @@ -59,6 +65,7 @@ if (app.Environment.IsDevelopment()) } app.UseCors(); +app.UseMiddleware(); app.MapGrpcService(); app.MapControllers(); app.MapHub("/hubs/rmm"); diff --git a/Backend/src/NexusRMM.Api/Services/AgentOfflineDetectorService.cs b/Backend/src/NexusRMM.Api/Services/AgentOfflineDetectorService.cs new file mode 100644 index 0000000..76fd959 --- /dev/null +++ b/Backend/src/NexusRMM.Api/Services/AgentOfflineDetectorService.cs @@ -0,0 +1,80 @@ +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using NexusRMM.Api.Hubs; +using NexusRMM.Core.Models; +using NexusRMM.Infrastructure.Data; + +namespace NexusRMM.Api.Services; + +/// +/// Background Service: Markiert Agenten als Offline wenn sie länger als +/// OfflineThresholdMinutes (Standard: 5) kein Heartbeat gesendet haben. +/// Läuft alle CheckIntervalSeconds (Standard: 60) Sekunden. +/// +public class AgentOfflineDetectorService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly TimeSpan _offlineThreshold = TimeSpan.FromMinutes(5); + private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(60); + + public AgentOfflineDetectorService( + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("AgentOfflineDetector gestartet (Threshold: {Threshold}min, Intervall: {Interval}s)", + _offlineThreshold.TotalMinutes, _checkInterval.TotalSeconds); + + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(_checkInterval, stoppingToken); + await CheckAgentsAsync(stoppingToken); + } + } + + private async Task CheckAgentsAsync(CancellationToken ct) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var hub = scope.ServiceProvider.GetRequiredService>(); + + var cutoff = DateTime.UtcNow - _offlineThreshold; + + // Alle Agenten die als Online gelten aber zu lange keine Meldung hatten + var staleAgents = await db.Agents + .Where(a => a.Status == AgentStatus.Online && a.LastSeen < cutoff) + .ToListAsync(ct); + + if (staleAgents.Count == 0) return; + + foreach (var agent in staleAgents) + { + agent.Status = AgentStatus.Offline; + _logger.LogInformation("Agent {Id} ({Hostname}) als Offline markiert (LastSeen: {LastSeen})", + agent.Id, agent.Hostname, agent.LastSeen); + } + + await db.SaveChangesAsync(ct); + + // SignalR: Status-Änderung an alle Clients pushen + foreach (var agent in staleAgents) + { + await hub.Clients.All.AgentStatusChanged( + agent.Id.ToString(), "Offline", agent.LastSeen.ToString("O")); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.LogError(ex, "Fehler im AgentOfflineDetector"); + } + } +} diff --git a/Backend/src/NexusRMM.Api/Services/AgentReleaseOptions.cs b/Backend/src/NexusRMM.Api/Services/AgentReleaseOptions.cs new file mode 100644 index 0000000..6ad1d8e --- /dev/null +++ b/Backend/src/NexusRMM.Api/Services/AgentReleaseOptions.cs @@ -0,0 +1,9 @@ +namespace NexusRMM.Api.Services; + +public class AgentReleaseOptions +{ + public const string SectionName = "AgentRelease"; + public string LatestVersion { get; set; } = string.Empty; + public string ReleasePath { get; set; } = string.Empty; + public string Checksum { get; set; } = string.Empty; +} diff --git a/Backend/src/NexusRMM.Api/appsettings.Development.json b/Backend/src/NexusRMM.Api/appsettings.Development.json index 0c208ae..c463a71 100644 --- a/Backend/src/NexusRMM.Api/appsettings.Development.json +++ b/Backend/src/NexusRMM.Api/appsettings.Development.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Security": { + "ApiKey": "" } } diff --git a/Backend/src/NexusRMM.Api/appsettings.json b/Backend/src/NexusRMM.Api/appsettings.json index 9141615..fd96e44 100644 --- a/Backend/src/NexusRMM.Api/appsettings.json +++ b/Backend/src/NexusRMM.Api/appsettings.json @@ -18,5 +18,13 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Security": { + "ApiKey": "" + }, + "AgentRelease": { + "LatestVersion": "", + "ReleasePath": "", + "Checksum": "" + } } diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index c32db1a..ca303c1 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { LayoutDashboard, Ticket, Bell, Package, Network, Menu, X } from 'lucide-react' +import { LayoutDashboard, Ticket, Bell, Package, Network, Menu, X, Settings } from 'lucide-react' import { DashboardPage } from './pages/DashboardPage' import { AgentDetailPage } from './pages/AgentDetailPage' import TicketsPage from './pages/TicketsPage' @@ -38,6 +38,19 @@ function AppContent() { const [page, setPage] = useState('dashboard') const [selectedAgentId, setSelectedAgentId] = useState(null) const [sidebarOpen, setSidebarOpen] = useState(true) + const [settingsOpen, setSettingsOpen] = useState(false) + const [apiKeyInput, setApiKeyInput] = useState('') + + const storedKey = localStorage.getItem('nexusrmm_api_key') ?? '' + const maskedKey = storedKey.length > 0 + ? storedKey.substring(0, Math.min(8, storedKey.length)) + '...' + : '(nicht gesetzt)' + + function handleSaveApiKey() { + localStorage.setItem('nexusrmm_api_key', apiKeyInput) + setSettingsOpen(false) + setApiKeyInput('') + } function handleSelectAgent(agentId: string) { setSelectedAgentId(agentId) @@ -96,14 +109,73 @@ function AppContent() { ))} - {/* Version */} - {sidebarOpen && ( -
- NexusRMM v0.1.0 -
- )} + {/* Settings + Version */} +
+ + {sidebarOpen && ( +
+ NexusRMM v0.1.0 +
+ )} +
+ {/* Settings Modal */} + {settingsOpen && ( +
+
+
+

Einstellungen

+ +
+
+ +

+ Aktuell: {maskedKey} +

+ setApiKeyInput(e.target.value)} + placeholder="API-Key eingeben..." + className="w-full px-3 py-2 rounded-md border border-border bg-background text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" + /> +

+ Leer lassen um Authentifizierung zu deaktivieren. +

+
+
+ + +
+
+
+ )} + {/* Main content */}
{page === 'dashboard' && ( diff --git a/Frontend/src/api/client.ts b/Frontend/src/api/client.ts index de3ba60..5fe91b1 100644 --- a/Frontend/src/api/client.ts +++ b/Frontend/src/api/client.ts @@ -20,9 +20,18 @@ import type { const BASE_URL = '/api/v1' +// API-Key aus localStorage lesen (leer = Auth deaktiviert) +function getApiKey(): string { + return localStorage.getItem('nexusrmm_api_key') ?? '' +} + async function request(path: string, options?: RequestInit): Promise { const res = await fetch(`${BASE_URL}${path}`, { - headers: { 'Content-Type': 'application/json', ...options?.headers }, + headers: { + 'Content-Type': 'application/json', + ...(getApiKey() ? { 'X-Api-Key': getApiKey() } : {}), + ...options?.headers + }, ...options, }) if (!res.ok) {