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:
@@ -15,6 +15,7 @@ import (
|
||||
"nexusrmm.local/agent/internal/connection"
|
||||
"nexusrmm.local/agent/internal/deployer"
|
||||
"nexusrmm.local/agent/internal/executor"
|
||||
"nexusrmm.local/agent/internal/meshagent"
|
||||
pb "nexusrmm.local/agent/pkg/proto"
|
||||
)
|
||||
|
||||
@@ -62,6 +63,14 @@ func main() {
|
||||
log.Printf("Enrolled with ID: %s", cfg.AgentID)
|
||||
}
|
||||
|
||||
// MeshAgent installieren falls konfiguriert
|
||||
if cfg.MeshEnabled && cfg.MeshCentralUrl != "" {
|
||||
log.Printf("Installiere MeshAgent von %s...", cfg.MeshCentralUrl)
|
||||
if err := meshagent.Install(context.Background(), cfg.MeshCentralUrl); err != nil {
|
||||
log.Printf("MeshAgent-Installation fehlgeschlagen (nicht kritisch): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
|
||||
@@ -11,6 +11,8 @@ type Config struct {
|
||||
AgentID string `yaml:"agent_id"`
|
||||
HeartbeatInterval int `yaml:"heartbeat_interval"`
|
||||
TLSEnabled bool `yaml:"tls_enabled"`
|
||||
MeshCentralUrl string `yaml:"mesh_central_url"`
|
||||
MeshEnabled bool `yaml:"mesh_enabled"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
|
||||
149
Agent/internal/meshagent/installer.go
Normal file
149
Agent/internal/meshagent/installer.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package meshagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IsInstalled prüft ob der MeshAgent-Prozess läuft oder das Binary vorhanden ist.
|
||||
func IsInstalled() bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Prüfe ob MeshAgent Service läuft
|
||||
cmd := exec.Command("sc", "query", "Mesh Agent")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
// Linux: prüfe ob meshagent Prozess läuft
|
||||
cmd := exec.Command("pgrep", "-x", "meshagent")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
// Install lädt den MeshAgent von MeshCentral herunter und installiert ihn.
|
||||
// meshCentralUrl: z.B. "https://192.168.1.100:4430"
|
||||
func Install(ctx context.Context, meshCentralUrl string) error {
|
||||
if IsInstalled() {
|
||||
log.Println("MeshAgent ist bereits installiert")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("MeshAgent wird von %s heruntergeladen...", meshCentralUrl)
|
||||
|
||||
// Agent-ID je nach OS
|
||||
agentID := "6" // Linux x64
|
||||
if runtime.GOOS == "windows" {
|
||||
agentID = "3" // Windows x64
|
||||
}
|
||||
|
||||
downloadUrl := fmt.Sprintf("%s/meshagents?id=%s", strings.TrimRight(meshCentralUrl, "/"), agentID)
|
||||
|
||||
// SSL-Fehler ignorieren (selbstsigniertes Zertifikat in Dev)
|
||||
httpClient := &http.Client{
|
||||
Timeout: 5 * time.Minute,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadUrl, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("HTTP Request konnte nicht erstellt werden: %w", err)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Download fehlgeschlagen: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("MeshCentral antwortete mit Status %d — ist MeshCentral gestartet?", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Temp-Datei speichern
|
||||
var tmpPath string
|
||||
if runtime.GOOS == "windows" {
|
||||
tmpPath = filepath.Join(os.TempDir(), "meshagent.exe")
|
||||
} else {
|
||||
tmpPath = filepath.Join(os.TempDir(), "meshagent")
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Temp-Datei konnte nicht erstellt werden: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("Fehler beim Schreiben: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
log.Printf("MeshAgent heruntergeladen nach %s", tmpPath)
|
||||
|
||||
// Installieren
|
||||
return installBinary(ctx, tmpPath, meshCentralUrl)
|
||||
}
|
||||
|
||||
func installBinary(ctx context.Context, binaryPath, meshCentralUrl string) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows: als Service installieren
|
||||
cmd := exec.CommandContext(ctx, binaryPath, "-install", "-url", meshCentralUrl)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("MeshAgent Windows-Installation fehlgeschlagen: %w", err)
|
||||
}
|
||||
log.Println("MeshAgent als Windows Service installiert")
|
||||
} else {
|
||||
// Linux: in /usr/local/bin installieren und als Service starten
|
||||
installPath := "/usr/local/bin/meshagent"
|
||||
if err := os.Rename(binaryPath, installPath); err != nil {
|
||||
// Falls rename scheitert (cross-device), kopieren
|
||||
if err2 := copyFile(binaryPath, installPath); err2 != nil {
|
||||
return fmt.Errorf("MeshAgent konnte nicht nach %s verschoben werden: %w", installPath, err2)
|
||||
}
|
||||
}
|
||||
if err := os.Chmod(installPath, 0755); err != nil {
|
||||
return fmt.Errorf("chmod fehlgeschlagen: %w", err)
|
||||
}
|
||||
|
||||
// Als Service starten (systemd oder direkt)
|
||||
cmd := exec.CommandContext(ctx, installPath, "-install", "-url", meshCentralUrl)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Direkt starten als Fallback
|
||||
log.Printf("Service-Installation fehlgeschlagen, starte direkt: %v", err)
|
||||
go func() {
|
||||
exec.Command(installPath, "-url", meshCentralUrl).Run()
|
||||
}()
|
||||
}
|
||||
log.Println("MeshAgent auf Linux installiert")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
CreateSoftwarePackageRequest,
|
||||
DeployRequest,
|
||||
DeployResponse,
|
||||
RemoteSessionInfo,
|
||||
} from './types'
|
||||
|
||||
const BASE_URL = '/api/v1'
|
||||
@@ -97,3 +98,9 @@ export const deployApi = {
|
||||
deploy: (data: DeployRequest) =>
|
||||
request<DeployResponse>('/deploy', { method: 'POST', body: JSON.stringify(data) }),
|
||||
}
|
||||
|
||||
// Remote Desktop
|
||||
export const remoteDesktopApi = {
|
||||
getSession: (agentId: string) =>
|
||||
request<RemoteSessionInfo>(`/agents/${agentId}/remote-session`),
|
||||
}
|
||||
|
||||
@@ -173,3 +173,14 @@ export interface DeployResponse {
|
||||
packageName: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface RemoteSessionInfo {
|
||||
configured: boolean
|
||||
agentInstalled?: boolean
|
||||
message?: string
|
||||
setupUrl?: string
|
||||
meshAgentDownloadUrl?: string
|
||||
meshNodeId?: string
|
||||
sessionUrl?: string
|
||||
meshCentralBaseUrl?: string
|
||||
}
|
||||
|
||||
147
Frontend/src/components/RemoteDesktopButton.tsx
Normal file
147
Frontend/src/components/RemoteDesktopButton.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react'
|
||||
import { Monitor, Loader2, AlertTriangle, ExternalLink } from 'lucide-react'
|
||||
import { remoteDesktopApi } from '../api/client'
|
||||
import type { RemoteSessionInfo } from '../api/types'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
interface RemoteDesktopButtonProps {
|
||||
agentId: string
|
||||
agentHostname: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function RemoteDesktopButton({ agentId, agentHostname, className }: RemoteDesktopButtonProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [sessionInfo, setSessionInfo] = useState<RemoteSessionInfo | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const info = await remoteDesktopApi.getSession(agentId)
|
||||
setSessionInfo(info)
|
||||
setShowModal(true)
|
||||
} catch (e) {
|
||||
setError('Remote-Session konnte nicht geladen werden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleConnect() {
|
||||
if (sessionInfo?.sessionUrl) {
|
||||
window.open(sessionInfo.sessionUrl, `rmm-remote-${agentId}`,
|
||||
'width=1280,height=800,scrollbars=no,toolbar=no,menubar=no')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
'bg-purple-500/20 text-purple-400 border border-purple-500/30 hover:bg-purple-500/30',
|
||||
loading && 'opacity-50 cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <Monitor size={16} />}
|
||||
Remote Desktop
|
||||
</button>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||
onClick={() => setShowModal(false)}>
|
||||
<div className="bg-card border border-border rounded-xl p-6 w-full max-w-md shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-9 h-9 rounded-lg bg-purple-500/20 flex items-center justify-center">
|
||||
<Monitor size={18} className="text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">Remote Desktop</h3>
|
||||
<p className="text-xs text-muted-foreground">{agentHostname}</p>
|
||||
</div>
|
||||
<button onClick={() => setShowModal(false)}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{/* Status: nicht konfiguriert */}
|
||||
{sessionInfo && !sessionInfo.configured && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<AlertTriangle size={16} className="text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-yellow-300">{sessionInfo.message}</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
MeshCentral läuft unter{' '}
|
||||
<a href={sessionInfo.setupUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="text-primary hover:underline">{sessionInfo.setupUrl}</a>.
|
||||
Richte MeshCentral ein und setze <code className="text-xs bg-muted px-1 rounded">MeshCentral:Enabled=true</code> in der appsettings.json.
|
||||
</p>
|
||||
<button onClick={() => window.open(sessionInfo.setupUrl, '_blank')}
|
||||
className="flex items-center gap-2 w-full justify-center px-4 py-2 bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 rounded-lg text-sm hover:bg-yellow-500/30">
|
||||
<ExternalLink size={14} />
|
||||
MeshCentral öffnen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status: Agent nicht installiert */}
|
||||
{sessionInfo?.configured && !sessionInfo.agentInstalled && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2 p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg">
|
||||
<AlertTriangle size={16} className="text-orange-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-orange-300">{sessionInfo.message}</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Der NexusRMM-Agent installiert MeshAgent automatisch wenn{' '}
|
||||
<code className="text-xs bg-muted px-1 rounded">mesh_enabled: true</code> in der Agent-Config gesetzt ist.
|
||||
</p>
|
||||
{sessionInfo.meshAgentDownloadUrl && (
|
||||
<button onClick={() => window.open(sessionInfo.meshAgentDownloadUrl, '_blank')}
|
||||
className="flex items-center gap-2 w-full justify-center px-4 py-2 bg-muted border border-border rounded-lg text-sm hover:bg-accent">
|
||||
<ExternalLink size={14} />
|
||||
MeshAgent manuell herunterladen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status: bereit */}
|
||||
{sessionInfo?.configured && sessionInfo.agentInstalled && (
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<p className="text-sm text-green-400 font-medium">✓ MeshAgent verbunden</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Node ID: {sessionInfo.meshNodeId}</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Remote Desktop öffnet sich in einem neuen Fenster. Falls du nach einem Login gefragt wirst, melde dich bei MeshCentral an.
|
||||
</p>
|
||||
<button onClick={handleConnect}
|
||||
className="flex items-center gap-2 w-full justify-center px-4 py-2 bg-purple-500/20 text-purple-400 border border-purple-500/30 rounded-lg text-sm hover:bg-purple-500/30">
|
||||
<Monitor size={14} />
|
||||
Remote Desktop öffnen
|
||||
</button>
|
||||
<button onClick={() => window.open(sessionInfo.meshCentralBaseUrl, '_blank')}
|
||||
className="flex items-center gap-2 w-full justify-center px-4 py-2 bg-muted border border-border rounded-lg text-sm hover:bg-accent text-xs">
|
||||
<ExternalLink size={12} />
|
||||
MeshCentral Dashboard
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-400 mt-2">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
} from 'lucide-react'
|
||||
import { RemoteDesktopButton } from '../components/RemoteDesktopButton'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
@@ -170,6 +171,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
)}>
|
||||
{signalRStatus === 'connected' ? 'Live' : signalRStatus === 'reconnecting' ? 'Verbindet...' : 'Offline'}
|
||||
</span>
|
||||
<RemoteDesktopButton agentId={agentId} agentHostname={agent.hostname} className="ml-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,23 @@ services:
|
||||
- nexusrmm_pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
meshcentral:
|
||||
image: ghcr.io/ylianst/meshcentral:latest
|
||||
container_name: nexusrmm-meshcentral
|
||||
ports:
|
||||
- "4430:4430"
|
||||
- "4431:4431"
|
||||
volumes:
|
||||
- nexusrmm_meshdata:/opt/meshcentral/meshcentral-data
|
||||
- nexusrmm_meshfiles:/opt/meshcentral/meshcentral-files
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- nexusrmm-postgres
|
||||
|
||||
volumes:
|
||||
nexusrmm_pgdata:
|
||||
name: nexusrmm_pgdata
|
||||
nexusrmm_meshdata:
|
||||
name: nexusrmm_meshdata
|
||||
nexusrmm_meshfiles:
|
||||
name: nexusrmm_meshfiles
|
||||
|
||||
Reference in New Issue
Block a user