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:
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
11
Backend/src/NexusRMM.Api/Services/MeshCentralOptions.cs
Normal file
11
Backend/src/NexusRMM.Api/Services/MeshCentralOptions.cs
Normal 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;
|
||||
}
|
||||
124
Backend/src/NexusRMM.Api/Services/MeshCentralService.cs
Normal file
124
Backend/src/NexusRMM.Api/Services/MeshCentralService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -74,7 +74,7 @@ namespace NexusRMM.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("MacAddress");
|
||||
|
||||
b.ToTable("Agents");
|
||||
b.ToTable("Agents", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusRMM.Core.Models.AgentMetric", b =>
|
||||
@@ -100,7 +100,7 @@ namespace NexusRMM.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.ToTable("AgentMetrics");
|
||||
b.ToTable("AgentMetrics", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusRMM.Core.Models.Alert", b =>
|
||||
@@ -138,7 +138,7 @@ namespace NexusRMM.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("RuleId");
|
||||
|
||||
b.ToTable("Alerts");
|
||||
b.ToTable("Alerts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusRMM.Core.Models.AlertRule", b =>
|
||||
@@ -172,7 +172,7 @@ namespace NexusRMM.Infrastructure.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AlertRules");
|
||||
b.ToTable("AlertRules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusRMM.Core.Models.SoftwarePackage", b =>
|
||||
@@ -219,7 +219,7 @@ namespace NexusRMM.Infrastructure.Migrations
|
||||
b.HasIndex("Name", "Version", "OsType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SoftwarePackages");
|
||||
b.ToTable("SoftwarePackages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusRMM.Core.Models.TaskItem", b =>
|
||||
@@ -253,7 +253,7 @@ namespace NexusRMM.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("AgentId");
|
||||
|
||||
b.ToTable("Tasks");
|
||||
b.ToTable("Tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusRMM.Core.Models.Ticket", b =>
|
||||
@@ -291,7 +291,7 @@ namespace NexusRMM.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("AgentId");
|
||||
|
||||
b.ToTable("Tickets");
|
||||
b.ToTable("Tickets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusRMM.Core.Models.AgentMetric", b =>
|
||||
|
||||
Reference in New Issue
Block a user