================================================================================ RMM (Remote Monitoring & Management) - Complete Project Reference Built with ASP.NET Core 8, SignalR, SQLite, Entity Framework Core ================================================================================ SOLUTION STRUCTURE ================== RmmSolution.sln src/RmmAgent/ - Console agent (runs on managed machines) src/RmmServer/ - ASP.NET Core web server + dashboard Controllers/ - REST API controllers Data/ - EF Core DbContext Hubs/ - SignalR hub Models/ - Entity models wwwroot/ - Static dashboard (index.html) FEATURES ======== - Real-time metrics (CPU, RAM, disk, network, uptime) via SignalR - Remote command execution (terminal in browser) - File browser with file download - Remote desktop (Windows agents) - JPEG screen streaming + mouse/keyboard - Alerts (auto-generated on high CPU/RAM/disk) - Device management (add, remove, remove offline) - Agent runs silently (WinExe, no console window, auto-starts on login) - Self-contained agent binaries for Linux/Windows/macOS (~66MB each) NuGet PACKAGES ============== RmmServer: - Microsoft.EntityFrameworkCore.Sqlite - Microsoft.EntityFrameworkCore.Design - Microsoft.AspNetCore.SignalR (built-in to ASP.NET Core 8) RmmAgent: - Microsoft.AspNetCore.SignalR.Client 8.0.11 - System.Drawing.Common 8.0.0 (screen capture on Windows) ================================================================================ FILE: RmmSolution.sln ================================================================================ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RmmServer", "src\RmmServer\RmmServer.csproj", "{SERVER-GUID}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RmmAgent", "src\RmmAgent\RmmAgent.csproj", "{AGENT-GUID}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection EndGlobal ================================================================================ FILE: src/RmmAgent/RmmAgent.csproj ================================================================================ WinExe net8.0 enable enable Build self-contained single-file binaries: dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o publish/win dotnet publish -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -o publish/linux dotnet publish -c Release -r osx-x64 --self-contained true -p:PublishSingleFile=true -o publish/osx ================================================================================ FILE: src/RmmAgent/Program.cs ================================================================================ using System.Diagnostics; using System.Net.NetworkInformation; using System.Runtime.InteropServices; using Microsoft.AspNetCore.SignalR.Client; string serverUrl; var configFile = Path.Combine(AppContext.BaseDirectory, "rmm-agent.conf"); // Priority: command-line arg > saved config > first-run interactive prompt if (args.Length > 0) { serverUrl = args[0].Trim(); File.WriteAllText(configFile, serverUrl); RegisterStartup(); } else if (File.Exists(configFile)) { serverUrl = File.ReadAllText(configFile).Trim(); RegisterStartup(); } else { // First run: temporarily create a console for URL input, then hide it if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) NativeWin32.AllocConsole(); Console.WriteLine("==========================================="); Console.WriteLine(" RMM Agent - First Time Setup"); Console.WriteLine("==========================================="); Console.WriteLine(); Console.Write("Enter your RMM dashboard URL (e.g. https://your-dashboard.replit.app): "); serverUrl = Console.ReadLine()?.Trim() ?? ""; while (string.IsNullOrEmpty(serverUrl)) { Console.Write("URL cannot be empty: "); serverUrl = Console.ReadLine()?.Trim() ?? ""; } File.WriteAllText(configFile, serverUrl); RegisterStartup(); Console.WriteLine("Agent configured. Running silently in background..."); await Task.Delay(1500); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) NativeWin32.FreeConsole(); } var agentId = Environment.GetEnvironmentVariable("RMM_AGENT_ID") ?? Guid.NewGuid().ToString("N")[..12]; var connection = new HubConnectionBuilder() .WithUrl($"{serverUrl}/hubs/agent") .WithAutomaticReconnect() .Build(); CancellationTokenSource? rdCts = null; // ---- Handler: Execute shell command sent from dashboard ---- connection.On("ExecuteCommand", async (commandId, commandText) => { try { var (shell, args2) = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ("cmd.exe", $"/c {commandText}") : ("/bin/sh", $"-c \"{commandText.Replace("\"", "\\\"")}\""); var psi = new ProcessStartInfo(shell, args2) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using var proc = Process.Start(psi)!; var output = await proc.StandardOutput.ReadToEndAsync(); var error = await proc.StandardError.ReadToEndAsync(); await proc.WaitForExitAsync(); await connection.InvokeAsync("SendCommandResult", new { CommandId = commandId, Output = output, Error = error, ExitCode = proc.ExitCode }); } catch (Exception ex) { await connection.InvokeAsync("SendCommandResult", new { CommandId = commandId, Output = "", Error = ex.Message, ExitCode = -1 }); } }); // ---- Handler: Browse directory ---- connection.On("BrowseDirectory", async (requestId, path) => { try { var target = string.IsNullOrEmpty(path) ? GetDefaultRoot() : path; var entries = new List(); if (Directory.Exists(target)) { foreach (var dir in Directory.GetDirectories(target).OrderBy(x => x).Take(250)) { try { var info = new DirectoryInfo(dir); entries.Add(new { name = info.Name, fullPath = dir, type = "directory", size = (long)0, modified = info.LastWriteTimeUtc }); } catch { } } foreach (var file in Directory.GetFiles(target).OrderBy(x => x).Take(500 - entries.Count)) { try { var info = new FileInfo(file); entries.Add(new { name = info.Name, fullPath = file, type = "file", size = info.Length, modified = info.LastWriteTimeUtc }); } catch { } } await connection.InvokeAsync("SendFileListing", requestId, target, entries); } else { await connection.InvokeAsync("SendFileListingError", requestId, $"Path not found: {target}"); } } catch (Exception ex) { await connection.InvokeAsync("SendFileListingError", requestId, ex.Message); } }); // ---- Handler: Download file (max 10MB) ---- connection.On("DownloadFile", async (requestId, path) => { try { if (!File.Exists(path)) { await connection.InvokeAsync("SendFileContent", requestId, "", "", $"File not found: {path}"); return; } var info = new FileInfo(path); if (info.Length > 10 * 1024 * 1024) { await connection.InvokeAsync("SendFileContent", requestId, "", "", "File too large to download (max 10 MB)"); return; } var bytes = await File.ReadAllBytesAsync(path); var base64 = Convert.ToBase64String(bytes); await connection.InvokeAsync("SendFileContent", requestId, info.Name, base64, ""); } catch (Exception ex) { await connection.InvokeAsync("SendFileContent", requestId, "", "", ex.Message); } }); // ---- Handler: Start remote desktop streaming (~12 FPS JPEG) ---- connection.On("StartRemoteDesktop", (quality) => { rdCts?.Cancel(); rdCts = new CancellationTokenSource(); var token = rdCts.Token; _ = Task.Run(async () => { while (!token.IsCancellationRequested) { try { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { await connection.InvokeAsync("RemoteDesktopError", "Remote desktop requires Windows"); break; } var (w, h) = NativeWin32.GetScreenSize(); var frame = CaptureScreen(quality); await connection.InvokeAsync("SendRemoteFrame", frame, w, h); await Task.Delay(80, token); } catch (OperationCanceledException) { break; } catch (Exception ex) { try { await connection.InvokeAsync("RemoteDesktopError", ex.Message); } catch { } await Task.Delay(2000, token).ContinueWith(_ => { }); } } }, token); }); connection.On("StopRemoteDesktop", () => { rdCts?.Cancel(); rdCts = null; }); // ---- Handler: Mouse events from dashboard ---- connection.On("RemoteMouseEvent", (eventType, x, y, button, delta) => { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; try { NativeWin32.SetCursorPos(x, y); if (eventType == "down") NativeWin32.SendMouseButton(button, true); else if (eventType == "up") NativeWin32.SendMouseButton(button, false); else if (eventType == "scroll") NativeWin32.SendMouseWheel(delta); } catch { } }); // ---- Handler: Keyboard events from dashboard ---- connection.On("RemoteKeyEvent", (vkCode, down) => { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; try { NativeWin32.SendKey((ushort)vkCode, down); } catch { } }); connection.Reconnected += async _ => { rdCts?.Cancel(); rdCts = null; await RegisterAgent(connection, agentId); }; // Connect with retry loop while (true) { try { await connection.StartAsync(); await RegisterAgent(connection, agentId); break; } catch { await Task.Delay(5000); } } var cts = new CancellationTokenSource(); // Main metrics loop (every 5 seconds) while (!cts.Token.IsCancellationRequested) { try { if (connection.State == HubConnectionState.Connected) { var metrics = CollectMetrics(agentId); await connection.InvokeAsync("SendMetrics", metrics, cts.Token); } } catch { } await Task.Delay(5000, cts.Token).ContinueWith(_ => { }); } await connection.StopAsync(); // ---- Helper: Register agent with server ---- static async Task RegisterAgent(HubConnection conn, string agentId) { var hostname = System.Net.Dns.GetHostName(); var os = RuntimeInformation.OSDescription; var arch = RuntimeInformation.OSArchitecture.ToString(); var cpuCount = Environment.ProcessorCount; var totalMem = GetTotalMemoryMb(); var ip = GetLocalIp(); await conn.InvokeAsync("RegisterAgent", new { AgentId = agentId, Hostname = hostname, OS = os, IpAddress = ip, Architecture = arch, ProcessorCount = cpuCount, TotalMemoryMb = totalMem }); } // ---- Helper: Collect all metrics ---- static object CollectMetrics(string agentId) { var cpu = GetCpuPercent(); var (memUsed, memTotal) = GetMemoryMb(); var (diskUsed, diskTotal) = GetDiskGb(); var (netSent, netRecv) = GetNetworkBytes(); var uptime = GetUptimeSeconds(); double memPercent = memTotal > 0 ? (double)memUsed / memTotal * 100 : 0; double diskPercent = diskTotal > 0 ? (double)diskUsed / diskTotal * 100 : 0; return new { AgentId = agentId, CpuPercent = cpu, MemoryUsedMb = memUsed, MemoryTotalMb = memTotal, MemoryPercent = Math.Round(memPercent, 1), DiskUsedGb = diskUsed, DiskTotalGb = diskTotal, DiskPercent = Math.Round(diskPercent, 1), NetworkBytesSentTotal = netSent, NetworkBytesRecvTotal = netRecv, UptimeSeconds = uptime }; } static double GetCpuPercent() { try { if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { var lines1 = File.ReadAllLines("/proc/stat"); var parts1 = lines1[0].Split(' ', StringSplitOptions.RemoveEmptyEntries); long idle1 = long.Parse(parts1[4]); long total1 = parts1.Skip(1).Take(8).Sum(long.Parse); Thread.Sleep(200); var lines2 = File.ReadAllLines("/proc/stat"); var parts2 = lines2[0].Split(' ', StringSplitOptions.RemoveEmptyEntries); long idle2 = long.Parse(parts2[4]); long total2 = parts2.Skip(1).Take(8).Sum(long.Parse); long dIdle = idle2 - idle1; long dTotal = total2 - total1; return dTotal == 0 ? 0 : Math.Round((1.0 - (double)dIdle / dTotal) * 100, 1); } } catch { } return 0; } static (long used, long total) GetMemoryMb() { try { if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { var lines = File.ReadAllLines("/proc/meminfo"); long Parse(string key) => long.Parse(lines.First(l => l.StartsWith(key)) .Split(':', StringSplitOptions.TrimEntries)[1] .Split(' ')[0]); long total = Parse("MemTotal") / 1024; long avail = Parse("MemAvailable") / 1024; return (total - avail, total); } } catch { } var gcInfo = GC.GetGCMemoryInfo(); long t = gcInfo.TotalAvailableMemoryBytes / 1024 / 1024; long u = Process.GetCurrentProcess().WorkingSet64 / 1024 / 1024; return (u, t); } static (long used, long total) GetDiskGb() { try { var drive = DriveInfo.GetDrives().FirstOrDefault(d => d.IsReady && d.DriveType == DriveType.Fixed); if (drive != null) { long total = drive.TotalSize / 1024 / 1024 / 1024; long free = drive.TotalFreeSpace / 1024 / 1024 / 1024; return (total - free, total); } } catch { } return (0, 0); } static (long sent, long recv) GetNetworkBytes() { try { long sent = 0, recv = 0; foreach (var iface in NetworkInterface.GetAllNetworkInterfaces()) { if (iface.OperationalStatus == OperationalStatus.Up) { var stats = iface.GetIPv4Statistics(); sent += stats.BytesSent; recv += stats.BytesReceived; } } return (sent, recv); } catch { } return (0, 0); } static double GetUptimeSeconds() { try { if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { var content = File.ReadAllText("/proc/uptime").Split(' ')[0]; return double.Parse(content, System.Globalization.CultureInfo.InvariantCulture); } return Environment.TickCount64 / 1000.0; } catch { } return 0; } static long GetTotalMemoryMb() { try { if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { var lines = File.ReadAllLines("/proc/meminfo"); var line = lines.First(l => l.StartsWith("MemTotal")); return long.Parse(line.Split(':', StringSplitOptions.TrimEntries)[1].Split(' ')[0]) / 1024; } } catch { } return GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / 1024 / 1024; } static string GetDefaultRoot() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "C:\\"; return "/"; } // ---- Screen capture (Windows only) ---- static string CaptureScreen(int quality) { int w = NativeWin32.GetSystemMetrics(0); int h = NativeWin32.GetSystemMetrics(1); using var bmp = new System.Drawing.Bitmap(w, h, System.Drawing.Imaging.PixelFormat.Format32bppArgb); using var g = System.Drawing.Graphics.FromImage(bmp); g.CopyFromScreen(0, 0, 0, 0, new System.Drawing.Size(w, h), System.Drawing.CopyPixelOperation.SourceCopy); using var ms = new MemoryStream(); var codec = System.Drawing.Imaging.ImageCodecInfo.GetImageEncoders().First(c => c.MimeType == "image/jpeg"); var ep = new System.Drawing.Imaging.EncoderParameters(1); ep.Param[0] = new System.Drawing.Imaging.EncoderParameter(System.Drawing.Imaging.Encoder.Quality, (long)quality); bmp.Save(ms, codec, ep); return Convert.ToBase64String(ms.ToArray()); } // ---- Register in Windows startup (runs on user login) ---- static void RegisterStartup() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; try { var exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName ?? ""; if (string.IsNullOrEmpty(exePath)) return; using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey( @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", writable: true); key?.SetValue("RmmAgent", $"\"{exePath}\""); } catch { } } static string GetLocalIp() { try { foreach (var iface in NetworkInterface.GetAllNetworkInterfaces()) { if (iface.OperationalStatus == OperationalStatus.Up && iface.NetworkInterfaceType != NetworkInterfaceType.Loopback) { foreach (var addr in iface.GetIPProperties().UnicastAddresses) { if (addr.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) return addr.Address.ToString(); } } } } catch { } return "unknown"; } // ---- Windows P/Invoke (mouse, keyboard, screen, console) ---- static class NativeWin32 { const uint INPUT_MOUSE = 0; const uint INPUT_KEYBOARD = 1; const uint KEYEVENTF_KEYUP = 0x0002; const uint MOUSEEVENTF_LEFTDOWN = 0x0002; const uint MOUSEEVENTF_LEFTUP = 0x0004; const uint MOUSEEVENTF_RIGHTDOWN = 0x0008; const uint MOUSEEVENTF_RIGHTUP = 0x0010; const uint MOUSEEVENTF_MIDDLEDOWN = 0x0020; const uint MOUSEEVENTF_MIDDLEUP = 0x0040; const uint MOUSEEVENTF_WHEEL = 0x0800; [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] struct MOUSEINPUT { public int dx, dy; public uint mouseData, dwFlags, time; public IntPtr dwExtraInfo; } [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] struct KEYBDINPUT { public ushort wVk, wScan; public uint dwFlags, time; public IntPtr dwExtraInfo; } [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)] struct InputUnion { [System.Runtime.InteropServices.FieldOffset(0)] public MOUSEINPUT mi; [System.Runtime.InteropServices.FieldOffset(0)] public KEYBDINPUT ki; } [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] struct INPUT { public uint type; public InputUnion U; } [System.Runtime.InteropServices.DllImport("user32.dll")] static extern uint SendInput(uint n, INPUT[] i, int cb); [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern int GetSystemMetrics(int n); [System.Runtime.InteropServices.DllImport("kernel32.dll")] public static extern bool AllocConsole(); [System.Runtime.InteropServices.DllImport("kernel32.dll")] public static extern bool FreeConsole(); public static (int w, int h) GetScreenSize() => (GetSystemMetrics(0), GetSystemMetrics(1)); public static void SendMouseButton(int button, bool down) { uint df = button switch { 0 => down ? MOUSEEVENTF_LEFTDOWN : MOUSEEVENTF_LEFTUP, 2 => down ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_RIGHTUP, 1 => down ? MOUSEEVENTF_MIDDLEDOWN : MOUSEEVENTF_MIDDLEUP, _ => 0 }; if (df == 0) return; var inp = new INPUT { type = INPUT_MOUSE }; inp.U.mi.dwFlags = df; SendInput(1, new[] { inp }, System.Runtime.InteropServices.Marshal.SizeOf()); } public static void SendMouseWheel(int delta) { var inp = new INPUT { type = INPUT_MOUSE }; inp.U.mi.dwFlags = MOUSEEVENTF_WHEEL; inp.U.mi.mouseData = (uint)delta; SendInput(1, new[] { inp }, System.Runtime.InteropServices.Marshal.SizeOf()); } public static void SendKey(ushort vkCode, bool down) { var inp = new INPUT { type = INPUT_KEYBOARD }; inp.U.ki.wVk = vkCode; inp.U.ki.dwFlags = down ? 0u : KEYEVENTF_KEYUP; SendInput(1, new[] { inp }, System.Runtime.InteropServices.Marshal.SizeOf()); } } ================================================================================ FILE: src/RmmServer/RmmServer.csproj ================================================================================ net8.0 enable enable ================================================================================ FILE: src/RmmServer/Program.cs ================================================================================ using Microsoft.EntityFrameworkCore; using RmmServer.Data; using RmmServer.Hubs; var builder = WebApplication.CreateBuilder(args); builder.WebHost.UseUrls("http://0.0.0.0:5000"); builder.Services.AddDbContext(opt => opt.UseSqlite("Data Source=rmm.db")); builder.Services.AddSignalR(o => { o.EnableDetailedErrors = true; o.MaximumReceiveMessageSize = 10 * 1024 * 1024; // 10MB for file transfers }); builder.Services.AddControllers(); var app = builder.Build(); using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.EnsureCreated(); } app.UseDefaultFiles(); app.UseStaticFiles(); app.MapControllers(); app.MapHub("/hubs/agent"); app.Run(); ================================================================================ FILE: src/RmmServer/Data/RmmDbContext.cs ================================================================================ using Microsoft.EntityFrameworkCore; using RmmServer.Models; namespace RmmServer.Data; public class RmmDbContext : DbContext { public RmmDbContext(DbContextOptions options) : base(options) { } public DbSet Devices => Set(); public DbSet Metrics => Set(); public DbSet Commands => Set(); public DbSet Alerts => Set(); protected override void OnModelCreating(ModelBuilder mb) { mb.Entity().HasIndex(d => d.AgentId).IsUnique(); mb.Entity().HasOne(m => m.Device).WithMany(d => d.Metrics).HasForeignKey(m => m.DeviceId); mb.Entity().HasOne(c => c.Device).WithMany(d => d.Commands).HasForeignKey(c => c.DeviceId); mb.Entity().HasOne(a => a.Device).WithMany(d => d.Alerts).HasForeignKey(a => a.DeviceId); } } ================================================================================ FILE: src/RmmServer/Models/Device.cs ================================================================================ using System.ComponentModel.DataAnnotations; namespace RmmServer.Models; public class Device { public int Id { get; set; } [Required] public string AgentId { get; set; } = ""; public string Hostname { get; set; } = ""; public string OS { get; set; } = ""; public string IpAddress { get; set; } = ""; public string Architecture { get; set; } = ""; public int ProcessorCount { get; set; } public long TotalMemoryMb { get; set; } public bool IsOnline { get; set; } public DateTime LastSeen { get; set; } public DateTime RegisteredAt { get; set; } public ICollection Metrics { get; set; } = new List(); public ICollection Commands { get; set; } = new List(); public ICollection Alerts { get; set; } = new List(); } ================================================================================ FILE: src/RmmServer/Models/MetricSnapshot.cs ================================================================================ namespace RmmServer.Models; public class MetricSnapshot { public int Id { get; set; } public int DeviceId { get; set; } public Device Device { get; set; } = null!; public DateTime Timestamp { get; set; } public double CpuPercent { get; set; } public long MemoryUsedMb { get; set; } public long MemoryTotalMb { get; set; } public double MemoryPercent { get; set; } public long DiskUsedGb { get; set; } public long DiskTotalGb { get; set; } public double DiskPercent { get; set; } public long NetworkBytesSentTotal { get; set; } public long NetworkBytesRecvTotal { get; set; } public double UptimeSeconds { get; set; } } ================================================================================ FILE: src/RmmServer/Models/Alert.cs ================================================================================ namespace RmmServer.Models; public class Alert { public int Id { get; set; } public int DeviceId { get; set; } public Device Device { get; set; } = null!; public string Severity { get; set; } = "info"; // "info", "warning", "critical" public string Message { get; set; } = ""; public bool IsRead { get; set; } public DateTime CreatedAt { get; set; } } ================================================================================ FILE: src/RmmServer/Models/CommandRecord.cs ================================================================================ namespace RmmServer.Models; public class CommandRecord { public int Id { get; set; } public int DeviceId { get; set; } public Device Device { get; set; } = null!; public string CommandText { get; set; } = ""; public string? Output { get; set; } public string? ErrorOutput { get; set; } public int? ExitCode { get; set; } public string Status { get; set; } = "pending"; // "pending", "completed" public DateTime CreatedAt { get; set; } public DateTime? CompletedAt { get; set; } } ================================================================================ FILE: src/RmmServer/Hubs/AgentHub.cs ================================================================================ using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using RmmServer.Data; using RmmServer.Models; namespace RmmServer.Hubs; public class AgentHub : Hub { private readonly RmmDbContext _db; private readonly ILogger _log; private static readonly Dictionary _agentConnections = new(); private static readonly Dictionary _rdSessions = new(); // agentId -> dashboardConnId public AgentHub(RmmDbContext db, ILogger log) { _db = db; _log = log; } public override async Task OnDisconnectedAsync(Exception? exception) { var agentId = _agentConnections.FirstOrDefault(kv => kv.Value == Context.ConnectionId).Key; if (agentId != null) { _agentConnections.Remove(agentId); var device = await _db.Devices.FirstOrDefaultAsync(d => d.AgentId == agentId); if (device != null) { device.IsOnline = false; await _db.SaveChangesAsync(); } await Clients.Group("dashboard").SendAsync("DeviceOffline", agentId); } await base.OnDisconnectedAsync(exception); } public async Task RegisterAgent(AgentRegistration reg) { var device = await _db.Devices.FirstOrDefaultAsync(d => d.AgentId == reg.AgentId); if (device == null) { device = new Device { AgentId = reg.AgentId, RegisteredAt = DateTime.UtcNow }; _db.Devices.Add(device); } device.Hostname = reg.Hostname; device.OS = reg.OS; device.IpAddress = reg.IpAddress; device.Architecture = reg.Architecture; device.ProcessorCount = reg.ProcessorCount; device.TotalMemoryMb = reg.TotalMemoryMb; device.IsOnline = true; device.LastSeen = DateTime.UtcNow; await _db.SaveChangesAsync(); _agentConnections[reg.AgentId] = Context.ConnectionId; await Groups.AddToGroupAsync(Context.ConnectionId, $"agent_{reg.AgentId}"); await Clients.Group("dashboard").SendAsync("DeviceOnline", device.AgentId, device.Hostname, device.OS, device.IpAddress); } public async Task SendMetrics(MetricPayload payload) { var device = await _db.Devices.FirstOrDefaultAsync(d => d.AgentId == payload.AgentId); if (device == null) return; device.LastSeen = DateTime.UtcNow; device.IsOnline = true; var snap = new MetricSnapshot { DeviceId = device.Id, Timestamp = DateTime.UtcNow, CpuPercent = payload.CpuPercent, MemoryUsedMb = payload.MemoryUsedMb, MemoryTotalMb = payload.MemoryTotalMb, MemoryPercent = payload.MemoryPercent, DiskUsedGb = payload.DiskUsedGb, DiskTotalGb = payload.DiskTotalGb, DiskPercent = payload.DiskPercent, NetworkBytesSentTotal = payload.NetworkBytesSentTotal, NetworkBytesRecvTotal = payload.NetworkBytesRecvTotal, UptimeSeconds = payload.UptimeSeconds }; _db.Metrics.Add(snap); // Keep only 30 minutes of history var cutoff = DateTime.UtcNow.AddMinutes(-30); var old = _db.Metrics.Where(m => m.DeviceId == device.Id && m.Timestamp < cutoff); _db.Metrics.RemoveRange(old); await CheckAlerts(device, snap); await _db.SaveChangesAsync(); await Clients.Group("dashboard").SendAsync("MetricsUpdate", payload); } public async Task SendCommandResult(CommandResultPayload result) { var cmd = await _db.Commands.FirstOrDefaultAsync(c => c.Id == result.CommandId); if (cmd == null) return; cmd.Output = result.Output; cmd.ErrorOutput = result.Error; cmd.ExitCode = result.ExitCode; cmd.Status = "completed"; cmd.CompletedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); await Clients.Group("dashboard").SendAsync("CommandResult", result); } public async Task JoinDashboard() { await Groups.AddToGroupAsync(Context.ConnectionId, "dashboard"); } public async Task SendCommandToAgent(string agentId, int commandId, string commandText) { if (_agentConnections.TryGetValue(agentId, out var connId)) await Clients.Client(connId).SendAsync("ExecuteCommand", commandId, commandText); } // ---- File browser relay methods ---- public async Task RequestFileListing(string agentId, string requestId, string path) { if (_agentConnections.TryGetValue(agentId, out var connId)) await Clients.Client(connId).SendAsync("BrowseDirectory", requestId, path); else await Clients.Caller.SendAsync("FileListingError", requestId, "Agent is offline"); } public async Task SendFileListing(string requestId, string path, object entries) => await Clients.Group("dashboard").SendAsync("FileListing", requestId, path, entries); public async Task SendFileListingError(string requestId, string error) => await Clients.Group("dashboard").SendAsync("FileListingError", requestId, error); public async Task RequestFileDownload(string agentId, string requestId, string path) { if (_agentConnections.TryGetValue(agentId, out var connId)) await Clients.Client(connId).SendAsync("DownloadFile", requestId, path); else await Clients.Caller.SendAsync("FileDownloadError", requestId, "Agent is offline"); } public async Task SendFileContent(string requestId, string fileName, string base64Content, string error) => await Clients.Group("dashboard").SendAsync("FileContent", requestId, fileName, base64Content, error); // ---- Remote desktop relay methods ---- public async Task StartRemoteDesktop(string agentId, int quality) { if (_agentConnections.TryGetValue(agentId, out var connId)) { _rdSessions[agentId] = Context.ConnectionId; await Clients.Client(connId).SendAsync("StartRemoteDesktop", quality); } else { await Clients.Caller.SendAsync("RemoteDesktopError", agentId, "Agent is offline"); } } public async Task StopRemoteDesktop(string agentId) { _rdSessions.Remove(agentId); if (_agentConnections.TryGetValue(agentId, out var connId)) await Clients.Client(connId).SendAsync("StopRemoteDesktop"); } public async Task SendRemoteFrame(string frameData, int width, int height) { var agentId = _agentConnections.FirstOrDefault(kv => kv.Value == Context.ConnectionId).Key; if (agentId != null && _rdSessions.TryGetValue(agentId, out var dashConnId)) await Clients.Client(dashConnId).SendAsync("RemoteFrame", frameData, width, height); } public async Task RemoteDesktopError(string error) { var agentId = _agentConnections.FirstOrDefault(kv => kv.Value == Context.ConnectionId).Key; if (agentId != null && _rdSessions.TryGetValue(agentId, out var dashConnId)) await Clients.Client(dashConnId).SendAsync("RemoteDesktopError", agentId, error); } public async Task SendRemoteMouseEvent(string agentId, string eventType, int x, int y, int button, int delta) { if (_agentConnections.TryGetValue(agentId, out var connId)) await Clients.Client(connId).SendAsync("RemoteMouseEvent", eventType, x, y, button, delta); } public async Task SendRemoteKeyEvent(string agentId, int vkCode, bool down) { if (_agentConnections.TryGetValue(agentId, out var connId)) await Clients.Client(connId).SendAsync("RemoteKeyEvent", vkCode, down); } // ---- Alert generation ---- private async Task CheckAlerts(Device device, MetricSnapshot snap) { if (snap.CpuPercent >= 90) _db.Alerts.Add(new Alert { DeviceId = device.Id, Severity = "critical", Message = $"CPU usage is {snap.CpuPercent:F1}% on {device.Hostname}", CreatedAt = DateTime.UtcNow }); else if (snap.CpuPercent >= 75) _db.Alerts.Add(new Alert { DeviceId = device.Id, Severity = "warning", Message = $"High CPU usage: {snap.CpuPercent:F1}% on {device.Hostname}", CreatedAt = DateTime.UtcNow }); if (snap.MemoryPercent >= 90) _db.Alerts.Add(new Alert { DeviceId = device.Id, Severity = "critical", Message = $"Memory usage is {snap.MemoryPercent:F1}% on {device.Hostname}", CreatedAt = DateTime.UtcNow }); if (snap.DiskPercent >= 90) _db.Alerts.Add(new Alert { DeviceId = device.Id, Severity = "warning", Message = $"Disk usage is {snap.DiskPercent:F1}% on {device.Hostname}", CreatedAt = DateTime.UtcNow }); await Task.CompletedTask; } } // ---- Hub payload classes ---- public class AgentRegistration { public string AgentId { get; set; } = ""; public string Hostname { get; set; } = ""; public string OS { get; set; } = ""; public string IpAddress { get; set; } = ""; public string Architecture { get; set; } = ""; public int ProcessorCount { get; set; } public long TotalMemoryMb { get; set; } } public class MetricPayload { public string AgentId { get; set; } = ""; public double CpuPercent { get; set; } public long MemoryUsedMb { get; set; } public long MemoryTotalMb { get; set; } public double MemoryPercent { get; set; } public long DiskUsedGb { get; set; } public long DiskTotalGb { get; set; } public double DiskPercent { get; set; } public long NetworkBytesSentTotal { get; set; } public long NetworkBytesRecvTotal { get; set; } public double UptimeSeconds { get; set; } } public class CommandResultPayload { public int CommandId { get; set; } public string Output { get; set; } = ""; public string Error { get; set; } = ""; public int ExitCode { get; set; } } ================================================================================ FILE: src/RmmServer/Controllers/DevicesController.cs ================================================================================ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RmmServer.Data; namespace RmmServer.Controllers; [ApiController] [Route("api/[controller]")] public class DevicesController : ControllerBase { private readonly RmmDbContext _db; public DevicesController(RmmDbContext db) => _db = db; [HttpGet] public async Task GetAll() { var devices = await _db.Devices.Select(d => new { d.Id, d.AgentId, d.Hostname, d.OS, d.IpAddress, d.Architecture, d.ProcessorCount, d.TotalMemoryMb, d.IsOnline, d.LastSeen, d.RegisteredAt }).ToListAsync(); return Ok(devices); } [HttpGet("{id}")] public async Task GetById(int id) { var device = await _db.Devices.Select(d => new { d.Id, d.AgentId, d.Hostname, d.OS, d.IpAddress, d.Architecture, d.ProcessorCount, d.TotalMemoryMb, d.IsOnline, d.LastSeen, d.RegisteredAt }).FirstOrDefaultAsync(d => d.Id == id); if (device == null) return NotFound(); return Ok(device); } [HttpDelete("{id}")] public async Task Delete(int id) { var device = await _db.Devices.FindAsync(id); if (device == null) return NotFound(); _db.Devices.Remove(device); await _db.SaveChangesAsync(); return NoContent(); } [HttpDelete("offline")] public async Task DeleteOffline() { var offline = await _db.Devices.Where(d => !d.IsOnline).ToListAsync(); _db.Devices.RemoveRange(offline); await _db.SaveChangesAsync(); return Ok(new { removed = offline.Count }); } } ================================================================================ FILE: src/RmmServer/Controllers/AlertsController.cs ================================================================================ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RmmServer.Data; namespace RmmServer.Controllers; [ApiController] [Route("api/[controller]")] public class AlertsController : ControllerBase { private readonly RmmDbContext _db; public AlertsController(RmmDbContext db) => _db = db; [HttpGet] public async Task GetAll([FromQuery] bool unreadOnly = false) { var query = _db.Alerts.Include(a => a.Device).AsQueryable(); if (unreadOnly) query = query.Where(a => !a.IsRead); var alerts = await query .OrderByDescending(a => a.CreatedAt) .Take(100) .Select(a => new { a.Id, a.Severity, a.Message, a.IsRead, a.CreatedAt, DeviceHostname = a.Device.Hostname, AgentId = a.Device.AgentId }) .ToListAsync(); return Ok(alerts); } [HttpPatch("{id}/read")] public async Task MarkRead(int id) { var alert = await _db.Alerts.FindAsync(id); if (alert == null) return NotFound(); alert.IsRead = true; await _db.SaveChangesAsync(); return NoContent(); } [HttpPost("mark-all-read")] public async Task MarkAllRead() { await _db.Alerts.Where(a => !a.IsRead).ExecuteUpdateAsync(s => s.SetProperty(a => a.IsRead, true)); return NoContent(); } [HttpDelete("{id}")] public async Task Delete(int id) { var alert = await _db.Alerts.FindAsync(id); if (alert == null) return NotFound(); _db.Alerts.Remove(alert); await _db.SaveChangesAsync(); return NoContent(); } } ================================================================================ FILE: src/RmmServer/Controllers/CommandsController.cs ================================================================================ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using RmmServer.Data; using RmmServer.Hubs; using RmmServer.Models; namespace RmmServer.Controllers; [ApiController] [Route("api/[controller]")] public class CommandsController : ControllerBase { private readonly RmmDbContext _db; private readonly IHubContext _hub; public CommandsController(RmmDbContext db, IHubContext hub) { _db = db; _hub = hub; } [HttpPost] public async Task Send([FromBody] SendCommandRequest req) { var device = await _db.Devices.FirstOrDefaultAsync(d => d.AgentId == req.AgentId); if (device == null) return NotFound("Device not found"); if (!device.IsOnline) return BadRequest("Device is offline"); var cmd = new CommandRecord { DeviceId = device.Id, CommandText = req.Command, Status = "pending", CreatedAt = DateTime.UtcNow }; _db.Commands.Add(cmd); await _db.SaveChangesAsync(); await _hub.Clients.Group($"agent_{req.AgentId}").SendAsync("ExecuteCommand", cmd.Id, req.Command); return Ok(new { cmd.Id, cmd.Status, cmd.CreatedAt }); } [HttpGet("{agentId}")] public async Task GetHistory(string agentId, [FromQuery] int count = 20) { var device = await _db.Devices.FirstOrDefaultAsync(d => d.AgentId == agentId); if (device == null) return NotFound(); var commands = await _db.Commands .Where(c => c.DeviceId == device.Id) .OrderByDescending(c => c.CreatedAt) .Take(count) .Select(c => new { c.Id, c.CommandText, c.Output, c.ErrorOutput, c.ExitCode, c.Status, c.CreatedAt, c.CompletedAt }) .ToListAsync(); return Ok(commands); } } public class SendCommandRequest { public string AgentId { get; set; } = ""; public string Command { get; set; } = ""; } ================================================================================ FILE: src/RmmServer/Controllers/MetricsController.cs ================================================================================ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RmmServer.Data; namespace RmmServer.Controllers; [ApiController] [Route("api/[controller]")] public class MetricsController : ControllerBase { private readonly RmmDbContext _db; public MetricsController(RmmDbContext db) => _db = db; [HttpGet("{agentId}")] public async Task GetLatest(string agentId, [FromQuery] int count = 60) { var device = await _db.Devices.FirstOrDefaultAsync(d => d.AgentId == agentId); if (device == null) return NotFound(); var metrics = await _db.Metrics .Where(m => m.DeviceId == device.Id) .OrderByDescending(m => m.Timestamp) .Take(count) .Select(m => new { m.Timestamp, m.CpuPercent, m.MemoryUsedMb, m.MemoryTotalMb, m.MemoryPercent, m.DiskUsedGb, m.DiskTotalGb, m.DiskPercent, m.NetworkBytesSentTotal, m.NetworkBytesRecvTotal, m.UptimeSeconds }) .ToListAsync(); return Ok(metrics.OrderBy(m => m.Timestamp)); } } ================================================================================ API ENDPOINTS SUMMARY ================================================================================ Devices: GET /api/devices - list all devices GET /api/devices/{id} - get one device DELETE /api/devices/{id} - remove one device DELETE /api/devices/offline - remove all offline devices Alerts: GET /api/alerts - list alerts (?unreadOnly=true) PATCH /api/alerts/{id}/read - mark one as read POST /api/alerts/mark-all-read - mark all as read DELETE /api/alerts/{id} - delete one alert Commands: POST /api/commands - send command to agent (body: {agentId, command}) GET /api/commands/{agentId} - get command history Metrics: GET /api/metrics/{agentId} - get metric history (?count=60) SignalR Hub: /hubs/agent ================================================================================ SIGNALR MESSAGES - Server -> Dashboard ================================================================================ DeviceOnline(agentId, hostname, os, ipAddress) DeviceOffline(agentId) MetricsUpdate(payload) CommandResult(payload) FileListing(requestId, path, entries) FileListingError(requestId, error) FileContent(requestId, fileName, base64Content, error) RemoteFrame(base64JpegData, width, height) RemoteDesktopError(agentId, errorMessage) ================================================================================ SIGNALR MESSAGES - Server -> Agent ================================================================================ ExecuteCommand(commandId, commandText) BrowseDirectory(requestId, path) DownloadFile(requestId, path) StartRemoteDesktop(quality) -- quality: 25/45/70 StopRemoteDesktop() RemoteMouseEvent(eventType, x, y, button, delta) RemoteKeyEvent(vkCode, down) ================================================================================ DASHBOARD JAVASCRIPT KEY FUNCTIONS (index.html) ================================================================================ SignalR connection setup: const connection = new signalR.HubConnectionBuilder() .withUrl("/hubs/agent") .withAutomaticReconnect() .build(); connection.on("MetricsUpdate", (payload) => { ... }); connection.on("DeviceOnline", (agentId, hostname, os, ip) => { ... }); connection.on("DeviceOffline", (agentId) => { ... }); connection.on("CommandResult", (result) => { ... }); connection.on("FileListing", (requestId, path, entries) => { ... }); connection.on("FileContent", (requestId, fileName, base64, error) => { ... }); connection.on("RemoteFrame", (frameData, width, height) => { ... }); await connection.start(); await connection.invoke("JoinDashboard"); Remote desktop canvas setup: - Canvas draws JPEG frames decoded from base64 - Mouse events are translated to screen coordinates (canvas size vs screen resolution) - Keyboard events captured on canvas focus - Quality selector: 25 (low), 45 (medium), 70 (high) ================================================================================ HOW AGENT RUNS SILENTLY (Windows) ================================================================================ 1. OutputType = WinExe -> no console window when double-clicked 2. First run (no config): - AllocConsole() creates temporary console - User types server URL - URL saved to rmm-agent.conf next to the exe - FreeConsole() hides the window 3. All subsequent runs: completely invisible 4. RegisterStartup() writes to: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run Value: "RmmAgent" = "path\to\rmm-agent.exe" -> Agent auto-starts every time the user logs in Silent install (pre-configured, no prompt): rmm-agent.exe https://your-server.com NOTE: HKCU startup runs when user logs in. For true system service (no user needed), implement as Windows Service using Microsoft.Extensions.Hosting.WindowsServices package. ================================================================================ IMPORTANT NOTES FOR VS COPILOT ================================================================================ 1. The SignalR hub uses static dictionaries for connection tracking. In production with multiple server instances, use Redis backplane: builder.Services.AddSignalR().AddStackExchangeRedis(connectionString); 2. Remote desktop (CaptureScreen) uses System.Drawing which only works on Windows. The agent checks RuntimeInformation.IsOSPlatform(Windows) before calling any screen capture or P/Invoke methods. 3. Metrics are kept for 30 minutes only (pruned on each write). For longer retention, add a background service that archives to a separate table. 4. SignalR MaximumReceiveMessageSize is set to 10MB to allow file transfers and screen frames. Tune this based on your network. 5. The dashboard is a single HTML file with vanilla JS (no framework). Uses Chart.js for graphs, SignalR JS client from CDN. 6. To add authentication: add ASP.NET Core Identity or JWT middleware. Dashboard hub calls can be protected with [Authorize] attribute. ================================================================================ END OF REFERENCE ================================================================================