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:
@@ -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 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<AlertEvaluationService>();
|
||||
builder.Services.AddHostedService<AgentOfflineDetectorService>();
|
||||
|
||||
// MeshCentral Konfiguration
|
||||
builder.Services.Configure<MeshCentralOptions>(
|
||||
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)
|
||||
builder.Services.AddHttpClient("MeshCentral")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
@@ -59,6 +65,7 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.UseCors();
|
||||
app.UseMiddleware<ApiKeyMiddleware>();
|
||||
app.MapGrpcService<AgentGrpcService>();
|
||||
app.MapControllers();
|
||||
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",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Security": {
|
||||
"ApiKey": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,5 +18,13 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"Security": {
|
||||
"ApiKey": ""
|
||||
},
|
||||
"AgentRelease": {
|
||||
"LatestVersion": "",
|
||||
"ReleasePath": "",
|
||||
"Checksum": ""
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user