feat: Phase 9 — Offline Detection, API Key Auth, Agent Self-Update
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 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
|||||||
"nexusrmm.local/agent/internal/executor"
|
"nexusrmm.local/agent/internal/executor"
|
||||||
"nexusrmm.local/agent/internal/meshagent"
|
"nexusrmm.local/agent/internal/meshagent"
|
||||||
"nexusrmm.local/agent/internal/scanner"
|
"nexusrmm.local/agent/internal/scanner"
|
||||||
|
"nexusrmm.local/agent/internal/updater"
|
||||||
pb "nexusrmm.local/agent/pkg/proto"
|
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 {
|
for _, cmd := range resp.PendingCommands {
|
||||||
log.Printf("Executing command %s (type: %v)", cmd.CommandId, cmd.Type)
|
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
|
var result *executor.Result
|
||||||
switch cmd.Type {
|
switch cmd.Type {
|
||||||
case pb.CommandType_COMMAND_TYPE_SHELL:
|
case pb.CommandType_COMMAND_TYPE_SHELL:
|
||||||
@@ -170,6 +172,28 @@ func executeCommand(ctx context.Context, client *connection.GrpcClient, agentID
|
|||||||
Success: true,
|
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:
|
default:
|
||||||
result = &executor.Result{ExitCode: -1, Stderr: fmt.Sprintf("unknown command type: %v", cmd.Type)}
|
result = &executor.Result{ExitCode: -1, Stderr: fmt.Sprintf("unknown command type: %v", cmd.Type)}
|
||||||
}
|
}
|
||||||
|
|||||||
155
Agent/internal/updater/updater.go
Normal file
155
Agent/internal/updater/updater.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -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<AgentReleaseOptions> options)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gibt Informationen zur aktuell verfügbaren Agent-Version zurück.
|
||||||
|
/// </summary>
|
||||||
|
[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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liefert das Agent-Binary für die angegebene Platform.
|
||||||
|
/// platform: windows-amd64, linux-amd64
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
Backend/src/NexusRMM.Api/Middleware/ApiKeyMiddleware.cs
Normal file
50
Backend/src/NexusRMM.Api/Middleware/ApiKeyMiddleware.cs
Normal file
@@ -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<ApiKeyMiddleware> _logger;
|
||||||
|
|
||||||
|
public ApiKeyMiddleware(RequestDelegate next, IConfiguration config, ILogger<ApiKeyMiddleware> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Server.Kestrel.Core;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NexusRMM.Api.GrpcServices;
|
using NexusRMM.Api.GrpcServices;
|
||||||
using NexusRMM.Api.Hubs;
|
using NexusRMM.Api.Hubs;
|
||||||
|
using NexusRMM.Api.Middleware;
|
||||||
using NexusRMM.Api.Services;
|
using NexusRMM.Api.Services;
|
||||||
using NexusRMM.Infrastructure.Data;
|
using NexusRMM.Infrastructure.Data;
|
||||||
|
|
||||||
@@ -24,11 +25,16 @@ builder.Services.AddEndpointsApiExplorer();
|
|||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
builder.Services.AddScoped<AlertEvaluationService>();
|
builder.Services.AddScoped<AlertEvaluationService>();
|
||||||
|
builder.Services.AddHostedService<AgentOfflineDetectorService>();
|
||||||
|
|
||||||
// MeshCentral Konfiguration
|
// MeshCentral Konfiguration
|
||||||
builder.Services.Configure<MeshCentralOptions>(
|
builder.Services.Configure<MeshCentralOptions>(
|
||||||
builder.Configuration.GetSection(MeshCentralOptions.SectionName));
|
builder.Configuration.GetSection(MeshCentralOptions.SectionName));
|
||||||
|
|
||||||
|
// AgentRelease Konfiguration
|
||||||
|
builder.Services.Configure<AgentReleaseOptions>(
|
||||||
|
builder.Configuration.GetSection(AgentReleaseOptions.SectionName));
|
||||||
|
|
||||||
// HttpClient für MeshCentral (mit optionalem SSL-Bypass für Entwicklung)
|
// HttpClient für MeshCentral (mit optionalem SSL-Bypass für Entwicklung)
|
||||||
builder.Services.AddHttpClient("MeshCentral")
|
builder.Services.AddHttpClient("MeshCentral")
|
||||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||||
@@ -59,6 +65,7 @@ if (app.Environment.IsDevelopment())
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
|
app.UseMiddleware<ApiKeyMiddleware>();
|
||||||
app.MapGrpcService<AgentGrpcService>();
|
app.MapGrpcService<AgentGrpcService>();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.MapHub<RmmHub>("/hubs/rmm");
|
app.MapHub<RmmHub>("/hubs/rmm");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class AgentOfflineDetectorService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<AgentOfflineDetectorService> _logger;
|
||||||
|
private readonly TimeSpan _offlineThreshold = TimeSpan.FromMinutes(5);
|
||||||
|
private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
public AgentOfflineDetectorService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<AgentOfflineDetectorService> 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<RmmDbContext>();
|
||||||
|
var hub = scope.ServiceProvider.GetRequiredService<IHubContext<RmmHub, IRmmHubClient>>();
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Backend/src/NexusRMM.Api/Services/AgentReleaseOptions.cs
Normal file
9
Backend/src/NexusRMM.Api/Services/AgentReleaseOptions.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,5 +4,8 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Security": {
|
||||||
|
"ApiKey": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,13 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*",
|
||||||
|
"Security": {
|
||||||
|
"ApiKey": ""
|
||||||
|
},
|
||||||
|
"AgentRelease": {
|
||||||
|
"LatestVersion": "",
|
||||||
|
"ReleasePath": "",
|
||||||
|
"Checksum": ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
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 { DashboardPage } from './pages/DashboardPage'
|
||||||
import { AgentDetailPage } from './pages/AgentDetailPage'
|
import { AgentDetailPage } from './pages/AgentDetailPage'
|
||||||
import TicketsPage from './pages/TicketsPage'
|
import TicketsPage from './pages/TicketsPage'
|
||||||
@@ -38,6 +38,19 @@ function AppContent() {
|
|||||||
const [page, setPage] = useState<Page>('dashboard')
|
const [page, setPage] = useState<Page>('dashboard')
|
||||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
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) {
|
function handleSelectAgent(agentId: string) {
|
||||||
setSelectedAgentId(agentId)
|
setSelectedAgentId(agentId)
|
||||||
@@ -96,14 +109,73 @@ function AppContent() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Version */}
|
{/* Settings + Version */}
|
||||||
|
<div className="border-t border-border px-2 py-2 flex flex-col gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setApiKeyInput(localStorage.getItem('nexusrmm_api_key') ?? '')
|
||||||
|
setSettingsOpen(true)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-3 px-2 py-2 rounded-md text-sm transition-colors w-full text-left text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0"><Settings size={18} /></span>
|
||||||
|
{sidebarOpen && <span>Einstellungen</span>}
|
||||||
|
</button>
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div className="px-4 py-3 text-xs text-muted-foreground border-t border-border">
|
<div className="px-2 py-1 text-xs text-muted-foreground">
|
||||||
NexusRMM v0.1.0
|
NexusRMM v0.1.0
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{/* Settings Modal */}
|
||||||
|
{settingsOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-card border border-border rounded-lg shadow-lg w-96 p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Einstellungen</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsOpen(false)}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">API-Key</label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Aktuell: <span className="font-mono">{maskedKey}</span>
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiKeyInput}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Leer lassen um Authentifizierung zu deaktivieren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsOpen(false)}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-border text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveApiKey}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 overflow-auto">
|
||||||
{page === 'dashboard' && (
|
{page === 'dashboard' && (
|
||||||
|
|||||||
@@ -20,9 +20,18 @@ import type {
|
|||||||
|
|
||||||
const BASE_URL = '/api/v1'
|
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<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${BASE_URL}${path}`, {
|
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,
|
...options,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user