feat: Phase 7 — MeshCentral Remote Desktop Integration

Backend:
- MeshCentralOptions + MeshCentralService: Node-Lookup via Hostname, Remote-Desktop-URL-Generierung
- RemoteDesktopController: GET /api/v1/agents/{id}/remote-session mit 3 Status-Zuständen (nicht konfiguriert / Agent fehlt / bereit)
- Program.cs: HttpClient + MeshCentralService registriert, appsettings.json mit Konfigurationsblock

Go Agent:
- config.go: MeshCentralUrl + MeshEnabled Felder
- internal/meshagent/installer.go: MeshAgent Download + Installation (Windows Service / Linux systemd)
- main.go: Automatische MeshAgent-Installation nach Enrollment wenn aktiviert

Frontend:
- RemoteDesktopButton: Modales Dialog mit 3 Zustandsanzeigen (Setup nötig / Agent installieren / Remote Desktop öffnen)
- AgentDetailPage: RemoteDesktopButton im Header integriert
- api/types.ts + api/client.ts: RemoteSessionInfo Typ + remoteDesktopApi

docker-compose.yml: MeshCentral Service (ghcr.io/ylianst/meshcentral:latest, Ports 4430/4431)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-03-19 14:39:49 +01:00
parent 84629dfbcf
commit 55e016c07d
14 changed files with 579 additions and 7 deletions

View File

@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NexusRMM.Api.Services;
using NexusRMM.Infrastructure.Data;
namespace NexusRMM.Api.Controllers;
[ApiController]
[Route("api/v1/agents/{agentId:guid}/remote-session")]
public class RemoteDesktopController : ControllerBase
{
private readonly RmmDbContext _db;
private readonly MeshCentralService _meshCentral;
public RemoteDesktopController(RmmDbContext db, MeshCentralService meshCentral)
{
_db = db;
_meshCentral = meshCentral;
}
/// <summary>
/// Gibt Remote-Desktop-Informationen für einen Agent zurück.
/// Falls MeshCentral nicht konfiguriert ist, wird configured=false zurückgegeben.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetRemoteSession(Guid agentId)
{
var agent = await _db.Agents.FindAsync(agentId);
if (agent is null) return NotFound();
if (!_meshCentral.IsEnabled)
{
return Ok(new
{
configured = false,
message = "MeshCentral ist nicht konfiguriert. Setze 'MeshCentral:Enabled=true' in appsettings.json.",
setupUrl = "https://localhost:4430",
});
}
if (string.IsNullOrEmpty(agent.MeshAgentId))
{
// Versuche Node zu finden
var nodeId = await _meshCentral.FindNodeByHostnameAsync(agent.Hostname);
if (nodeId is not null)
{
agent.MeshAgentId = nodeId;
await _db.SaveChangesAsync();
}
}
if (string.IsNullOrEmpty(agent.MeshAgentId))
{
return Ok(new
{
configured = true,
agentInstalled = false,
message = $"MeshAgent nicht auf '{agent.Hostname}' gefunden. Agent muss MeshAgent installieren.",
meshAgentDownloadUrl = _meshCentral.GetMeshAgentDownloadUrl(agent.OsType.ToString()),
});
}
return Ok(new
{
configured = true,
agentInstalled = true,
meshNodeId = agent.MeshAgentId,
sessionUrl = _meshCentral.GetRemoteDesktopUrl(agent.MeshAgentId),
meshCentralBaseUrl = "https://localhost:4430",
});
}
}

View File

@@ -25,6 +25,20 @@ builder.Services.AddSwaggerGen();
builder.Services.AddScoped<AlertEvaluationService>();
// MeshCentral Konfiguration
builder.Services.Configure<MeshCentralOptions>(
builder.Configuration.GetSection(MeshCentralOptions.SectionName));
// HttpClient für MeshCentral (mit optionalem SSL-Bypass für Entwicklung)
builder.Services.AddHttpClient("MeshCentral")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true, // Dev: SSL bypass
UseCookies = false,
});
builder.Services.AddScoped<MeshCentralService>();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>

View File

@@ -0,0 +1,11 @@
namespace NexusRMM.Api.Services;
public class MeshCentralOptions
{
public const string SectionName = "MeshCentral";
public string BaseUrl { get; set; } = "https://localhost:4430";
public string Username { get; set; } = "admin";
public string Password { get; set; } = "admin";
public bool IgnoreSslErrors { get; set; } = true;
public bool Enabled { get; set; } = false;
}

View File

@@ -0,0 +1,124 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
namespace NexusRMM.Api.Services;
/// <summary>
/// HTTP-Client für die MeshCentral REST API.
/// Authentifizierung via Cookie-Session, dann Node-Lookup und Remote-Session-URL-Generierung.
/// </summary>
public class MeshCentralService
{
private readonly MeshCentralOptions _options;
private readonly ILogger<MeshCentralService> _logger;
private readonly HttpClient _httpClient;
public MeshCentralService(
IOptions<MeshCentralOptions> options,
ILogger<MeshCentralService> logger,
IHttpClientFactory httpClientFactory)
{
_options = options.Value;
_logger = logger;
_httpClient = httpClientFactory.CreateClient("MeshCentral");
}
public bool IsEnabled => _options.Enabled;
/// <summary>
/// Gibt die Remote-Desktop-URL für einen MeshCentral-Node zurück.
/// Öffnet MeshCentral auf der Geräteseite — User muss im Browser eingeloggt sein.
/// </summary>
public string GetRemoteDesktopUrl(string meshNodeId)
{
// URL-Format für direkte Geräteansicht in MeshCentral
// viewmode=11 = Remote Desktop, hide=31 = UI Elemente verstecken
return $"{_options.BaseUrl.TrimEnd('/')}/?viewmode=11&hide=31#{Uri.EscapeDataString(meshNodeId)}";
}
/// <summary>
/// Sucht einen MeshCentral-Node anhand des Hostnamens.
/// Gibt die Node-ID zurück oder null wenn nicht gefunden.
/// </summary>
public async Task<string?> FindNodeByHostnameAsync(string hostname)
{
if (!_options.Enabled) return null;
try
{
var loginCookie = await LoginAsync();
if (loginCookie is null) return null;
using var request = new HttpRequestMessage(HttpMethod.Get,
$"{_options.BaseUrl}/api/v1/devices");
request.Headers.Add("Cookie", loginCookie);
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode) return null;
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
// MeshCentral API gibt { devices: { nodeId: { name: "...", ... } } } zurück
if (doc.RootElement.TryGetProperty("devices", out var devices))
{
foreach (var device in devices.EnumerateObject())
{
if (device.Value.TryGetProperty("name", out var name) &&
name.GetString()?.Equals(hostname, StringComparison.OrdinalIgnoreCase) == true)
{
return device.Name; // Node-ID
}
}
}
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "MeshCentral Node-Lookup für {Hostname} fehlgeschlagen", hostname);
return null;
}
}
/// <summary>
/// Gibt die Download-URL für den MeshAgent zurück.
/// Windows x64: id=3, Linux x64: id=6
/// </summary>
public string GetMeshAgentDownloadUrl(string osType)
{
var agentId = osType.ToLower() == "windows" ? 3 : 6;
return $"{_options.BaseUrl.TrimEnd('/')}/meshagents?id={agentId}";
}
private async Task<string?> LoginAsync()
{
try
{
var loginData = new { username = _options.Username, password = _options.Password };
var json = JsonSerializer.Serialize(loginData);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(
$"{_options.BaseUrl}/api/v1/authToken", content);
// MeshCentral gibt Set-Cookie zurück
if (response.Headers.TryGetValues("Set-Cookie", out var cookies))
{
var sessionCookie = cookies.FirstOrDefault(c => c.StartsWith("meshcentral.sid"));
if (sessionCookie is not null)
return sessionCookie.Split(';')[0]; // Nur Name=Wert Teil
}
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "MeshCentral Login fehlgeschlagen");
return null;
}
}
}

View File

@@ -5,6 +5,13 @@
"Cors": {
"Origins": ["http://localhost:5173"]
},
"MeshCentral": {
"BaseUrl": "https://localhost:4430",
"Username": "admin",
"Password": "admin",
"IgnoreSslErrors": true,
"Enabled": false
},
"Logging": {
"LogLevel": {
"Default": "Information",