From 127b3ad5bf0171cd6ef00e366524d9fd656c5f78 Mon Sep 17 00:00:00 2001 From: James Getrost Date: Mon, 5 Jan 2026 23:23:55 -0600 Subject: [PATCH] Initial commit: JMon cross-platform monitoring system - gRPC client-server metrics collection - PeriodicTimer-based 5-second sampling - Delta encoding with configurable thresholds - Queue/retry mechanism for resilience - ProcessId tracking for duplicate detection - Server-side state reconstruction - Native AOT compilation support - Docker/Podman containerization - PostgreSQL + TimescaleDB persistence layer --- .copilot-instructions.md | 60 +++++ .gitignore | 91 +++++++ JMonAgent/JMonAgent.csproj | 30 +++ JMonAgent/MetricCollector.cs | 286 +++++++++++++++++++++ JMonAgent/MetricsQueue.cs | 32 +++ JMonAgent/Program.cs | 8 + JMonAgent/Properties/launchSettings.json | 12 + JMonAgent/SystemMetrics.cs | 44 ++++ JMonAgent/Worker.cs | 308 +++++++++++++++++++++++ JMonAgent/appsettings.Development.json | 8 + JMonAgent/appsettings.json | 10 + JMonServer/Containerfile | 35 +++ JMonServer/JMonServer.csproj | 27 ++ JMonServer/Program.cs | 205 +++++++++++++++ Protos/metrics.proto | 39 +++ compose.yaml | 46 ++++ 16 files changed, 1241 insertions(+) create mode 100644 .copilot-instructions.md create mode 100644 .gitignore create mode 100644 JMonAgent/JMonAgent.csproj create mode 100644 JMonAgent/MetricCollector.cs create mode 100644 JMonAgent/MetricsQueue.cs create mode 100644 JMonAgent/Program.cs create mode 100644 JMonAgent/Properties/launchSettings.json create mode 100644 JMonAgent/SystemMetrics.cs create mode 100644 JMonAgent/Worker.cs create mode 100644 JMonAgent/appsettings.Development.json create mode 100644 JMonAgent/appsettings.json create mode 100644 JMonServer/Containerfile create mode 100644 JMonServer/JMonServer.csproj create mode 100644 JMonServer/Program.cs create mode 100644 Protos/metrics.proto create mode 100644 compose.yaml diff --git a/.copilot-instructions.md b/.copilot-instructions.md new file mode 100644 index 0000000..8d8bcee --- /dev/null +++ b/.copilot-instructions.md @@ -0,0 +1,60 @@ +# JMon Project Guidelines + +## Package Management +**IMPORTANT**: Always use `dotnet` CLI commands to manage NuGet packages. Do NOT directly edit `.csproj` files for package management. + +Use these commands: +- `dotnet add package ` - Add a package +- `dotnet add package --version ` - Add specific version +- `dotnet remove package ` - Remove a package +- `dotnet nuget add source ` - Add NuGet source +- `dotnet nuget remove source ` - Remove NuGet source + +This ensures: +- Package versions are verified to exist before adding +- Compatibility checks are performed +- Project structure remains clean and consistent + +## Architecture +- **Agent**: Worker Service collecting metrics via `/proc` (Linux) or platform APIs +- **Server**: ASP.NET Core gRPC service receiving delta metrics +- **Protocol**: gRPC with Protobuf for binary efficiency +- **Communication**: Unary RPC pattern (request-response), agent initiates +- **Metrics**: CPU%, memory, swap, disk usage, IP address, HWID with delta encoding (~50% bandwidth reduction) +- **Response Commands**: ACK (normal) or SYNC (force full resync) + +## Code Style +- C# 12+ features (records, nullable references) +- `#nullable enable` required +- Async/await throughout +- ILogger for all logging +- Use DateTimeOffset for all timestamps (UTC) + +## Testing & Deployment + +### Agent Debug Mode + +To test the agent without sending to a server: + +```bash +cd JMonAgent && dotnet run -- --DEBUG=true +``` + +This prints formatted metrics to console every 5 seconds instead of sending them via gRPC. + +### Docker/Podman Setup +Run the full stack with: +```bash +podman compose up --build +``` + +This starts: +- PostgreSQL 16 with TimescaleDB on localhost:5432 +- JMonServer gRPC on localhost:5001 +- Metrics automatically persisted to DB with 90-day retention +- Data compressed after 7 days + +Connection string: `Host=localhost;Port=5432;Database=jmon;Username=jmon;Password=jmon_dev_password` + +- Suppress SignalR warnings by removing unused packages from project files +- Both projects should build with 0 errors diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78a50ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,91 @@ +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio cache/options +.vs/ +.vscode/ +*.user +*.userosscache +*.sln.docstates + +# NuGet +*.nupkg +*.snupkg +.nuget/ + +# Rider +.idea/ +*.sln.iml +.idea_modules/ + +# macOS +.DS_Store + +# Environment variables +.env +.env.local +.env.*.local + +# Runtime +*.dll +*.exe +*.so +*.dylib + +# Certificates/Keys +*.pfx +*.pem +*.key +*.crt + +# Local publish artifacts +/publish/ +/PublishProfiles/ + +# Node (if used for tooling) +node_modules/ + +# Temporary files +*.tmp +*.temp +*.log +*.swp +*.swo + +# Docker +.docker/ +docker-compose.override.yml + +# Podman +.podman/ + +# Tests +TestResults/ +.coverage/ +coverage/ + +# IDE Settings (keep only workspace settings, not user settings) +.vscode/settings.json +.vscode/launch.json +.vscode/tasks.json + +# Protocol Buffer generated files (keep source .proto files) +*.pb.go +*.pb.cs + +# OS files +Thumbs.db +.Thumbs.db diff --git a/JMonAgent/JMonAgent.csproj b/JMonAgent/JMonAgent.csproj new file mode 100644 index 0000000..f50b035 --- /dev/null +++ b/JMonAgent/JMonAgent.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + dotnet-JMonAgent-7f8df008-660b-45a2-b649-64956a63f63c + true + true + Size + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/JMonAgent/MetricCollector.cs b/JMonAgent/MetricCollector.cs new file mode 100644 index 0000000..a8115b1 --- /dev/null +++ b/JMonAgent/MetricCollector.cs @@ -0,0 +1,286 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; + +namespace JMonAgent; + +public interface IMetricCollector +{ + SystemMetrics Collect(); +} + +public class LinuxMetricCollector : IMetricCollector +{ + private long _prevJiffy; + private long _prevIdle; + private string? _cachedIpAddress; + private string? _cachedHwId; + + public SystemMetrics Collect() + { + var memInfo = ParseMemInfo(); + var swapInfo = ParseSwapInfo(); + var diskInfo = ParseDiskInfo(); + + // Cache IP and HWID on first call + _cachedIpAddress ??= GetIpAddress(); + _cachedHwId ??= GetHwId(); + + return new SystemMetrics( + Environment.MachineName, + RuntimeInformation.OSDescription, + GetCpuUsage(), + memInfo.UsedBytes, + memInfo.TotalBytes, + swapInfo.UsedBytes, + swapInfo.TotalBytes, + diskInfo.UsedBytes, + diskInfo.TotalBytes, + diskInfo.MountPoint, + DateTime.UtcNow, + _cachedIpAddress ?? "", + _cachedHwId ?? "", + Environment.ProcessId + ); + } + + private string GetIpAddress() + { + try + { + var psi = new ProcessStartInfo("hostname", "-I") + { + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) return ""; + + var output = process.StandardOutput.ReadToEnd().Trim(); + var addresses = output.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return addresses.FirstOrDefault() ?? ""; + } + catch + { + return ""; + } + } + + private string GetHwId() + { + try + { + // Try to read machine-id from Linux + if (File.Exists("/etc/machine-id")) + { + return File.ReadAllText("/etc/machine-id").Trim(); + } + + // Fallback: use MAC address from first interface + var interfaces = Directory.GetFiles("/sys/class/net/"); + if (interfaces.Length > 0) + { + var firstInterface = Path.GetFileName(interfaces[0]); + var macPath = $"/sys/class/net/{firstInterface}/address"; + if (File.Exists(macPath)) + { + return File.ReadAllText(macPath).Trim(); + } + } + + return ""; + } + catch + { + return ""; + } + } + + private (long UsedBytes, long TotalBytes) ParseMemInfo() + { + try + { + var lines = File.ReadAllLines("/proc/meminfo"); + var dict = new Dictionary(); + + foreach (var line in lines) + { + var parts = line.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 2 && long.TryParse(Regex.Replace(parts[1], @"\D+", ""), out var value)) + { + dict[parts[0]] = value * 1024; // Convert from KB to bytes + } + } + + long total = dict.TryGetValue("MemTotal", out var t) ? t : 0; + long available = dict.TryGetValue("MemAvailable", out var a) ? a : 0; + long used = total - available; + + return (used, total); + } + catch + { + return (0, 0); + } + } + + private (long UsedBytes, long TotalBytes) ParseSwapInfo() + { + try + { + var lines = File.ReadAllLines("/proc/meminfo"); + var dict = new Dictionary(); + + foreach (var line in lines) + { + var parts = line.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 2 && long.TryParse(Regex.Replace(parts[1], @"\D+", ""), out var value)) + { + dict[parts[0]] = value * 1024; // Convert from KB to bytes + } + } + + long total = dict.TryGetValue("SwapTotal", out var st) ? st : 0; + long free = dict.TryGetValue("SwapFree", out var sf) ? sf : 0; + long used = total - free; + + return (used, total); + } + catch + { + return (0, 0); + } + } + + private (long UsedBytes, long TotalBytes, string MountPoint) ParseDiskInfo() + { + try + { + // Use 'df' command to get root filesystem info + var psi = new ProcessStartInfo("df", "-B1 /") + { + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) return (0, 0, "/"); + + var output = process.StandardOutput.ReadToEnd(); + var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + // Skip header, parse second line + if (lines.Length >= 2) + { + var parts = lines[1].Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 4 + && long.TryParse(parts[1], out var total) + && long.TryParse(parts[2], out var used)) + { + return (used, total, parts[parts.Length - 1]); + } + } + + return (0, 0, "/"); + } + catch + { + return (0, 0, "/"); + } + } + + private double GetCpuUsage() + { + try + { + var firstLine = File.ReadLines("/proc/stat").First(); + var parts = firstLine.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 5) return 0.0; + + // /proc/stat format: cpu user nice system idle iowait irq softirq steal guest guest_nice + long user = long.Parse(parts[1]); + long nice = long.Parse(parts[2]); + long system = long.Parse(parts[3]); + long idle = long.Parse(parts[4]); + + long totalJiffy = user + nice + system + idle; + long activeJiffy = user + nice + system; + + // First call - initialize baseline + if (_prevJiffy == 0) + { + _prevJiffy = totalJiffy; + _prevIdle = idle; + return 0.0; + } + + // Calculate deltas + long diffTotal = totalJiffy - _prevJiffy; + long diffIdle = idle - _prevIdle; + + _prevJiffy = totalJiffy; + _prevIdle = idle; + + if (diffTotal == 0) return 0.0; + + double usage = 100.0 * (1.0 - ((double)diffIdle / diffTotal)); + return Math.Max(0, Math.Min(100, usage)); + } + catch + { + return 0.0; + } + } +} + +public class WindowsMetricCollector : IMetricCollector +{ + public SystemMetrics Collect() + { + // TODO: Use PerformanceCounters or WMI for Windows + return new SystemMetrics( + Environment.MachineName, + RuntimeInformation.OSDescription, + 0.0, + 0, + 0, + 0, + 0, + 0, + 0, + "C:\\", + DateTime.UtcNow, + "", + "", + Environment.ProcessId + ); + } +} + +public class MacMetricCollector : IMetricCollector +{ + public SystemMetrics Collect() + { + // TODO: Use sysctl for macOS metrics + return new SystemMetrics( + Environment.MachineName, + RuntimeInformation.OSDescription, + 0.0, + 0, + 0, + 0, + 0, + 0, + 0, + "/", + DateTime.UtcNow, + "", + "", + Environment.ProcessId + ); + } +} + diff --git a/JMonAgent/MetricsQueue.cs b/JMonAgent/MetricsQueue.cs new file mode 100644 index 0000000..a23a63a --- /dev/null +++ b/JMonAgent/MetricsQueue.cs @@ -0,0 +1,32 @@ +using System.Collections.Concurrent; + +namespace JMonAgent; + +public class MetricsQueue +{ + private readonly ConcurrentQueue _queue = new(); + private readonly ILogger _logger; + + public MetricsQueue(ILogger logger) + { + _logger = logger; + } + + public void Enqueue(JMon.Protos.DeltaMetrics metrics) + { + _queue.Enqueue(metrics); + _logger.LogWarning("Metrics queued (total: {count})", _queue.Count); + } + + public List DequeueAll() + { + var items = new List(); + while (_queue.TryDequeue(out var item)) + { + items.Add(item); + } + return items; + } + + public int Count => _queue.Count; +} diff --git a/JMonAgent/Program.cs b/JMonAgent/Program.cs new file mode 100644 index 0000000..8fe987f --- /dev/null +++ b/JMonAgent/Program.cs @@ -0,0 +1,8 @@ +using JMonAgent; + +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +var host = builder.Build(); +host.Run(); diff --git a/JMonAgent/Properties/launchSettings.json b/JMonAgent/Properties/launchSettings.json new file mode 100644 index 0000000..9e4b0a8 --- /dev/null +++ b/JMonAgent/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "JMonAgent": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/JMonAgent/SystemMetrics.cs b/JMonAgent/SystemMetrics.cs new file mode 100644 index 0000000..917c84c --- /dev/null +++ b/JMonAgent/SystemMetrics.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; + +namespace JMonAgent; + +public record SystemMetrics( + string MachineName, + string OSDescription, + double CpuUsagePercentage, + long MemoryUsedBytes, + long MemoryTotalBytes, + long SwapUsedBytes, + long SwapTotalBytes, + long DiskUsedBytes, + long DiskTotalBytes, + string DiskMountPoint, + DateTime Timestamp, + string IpAddress, + string HwId, + int ProcessId +); + +public record DeltaMetrics( + string MachineName, + string? OSDescription, + double? CpuUsagePercentage, + long? MemoryUsedBytes, + long? MemoryTotalBytes, + long? SwapUsedBytes, + long? SwapTotalBytes, + long? DiskUsedBytes, + long? DiskTotalBytes, + string? DiskMountPoint, + DateTime Timestamp, + string? IpAddress, + string? HwId, + int ProcessId +); + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(SystemMetrics))] +[JsonSerializable(typeof(DeltaMetrics))] +internal partial class MetricsJsonContext : JsonSerializerContext +{ +} diff --git a/JMonAgent/Worker.cs b/JMonAgent/Worker.cs new file mode 100644 index 0000000..d415e84 --- /dev/null +++ b/JMonAgent/Worker.cs @@ -0,0 +1,308 @@ +using System.Runtime.InteropServices; +using Grpc.Net.Client; +using JMon.Protos; + +namespace JMonAgent; + +public class Worker : BackgroundService +{ + private readonly ILogger _logger; + private readonly IMetricCollector _collector; + private readonly MetricsQueue _queue; + private readonly bool _debugMode; + private GrpcChannel? _channel; + private MetricsService.MetricsServiceClient? _client; + private SystemMetrics? _previousMetrics; + private readonly SemaphoreSlim _executionLock = new(1, 1); + + private const double CpuThreshold = 0.5; // Only send if change > 0.5% + private const long MemoryThreshold = 10 * 1024 * 1024; // 10 MB + private const long SwapThreshold = 10 * 1024 * 1024; // 10 MB + private const long DiskThreshold = 100 * 1024 * 1024; // 100 MB + + public Worker(ILogger logger, MetricsQueue queue, IConfiguration config) + { + _logger = logger; + _queue = queue; + _debugMode = config.GetValue("DEBUG", false); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + _collector = new LinuxMetricCollector(); + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + _collector = new WindowsMetricCollector(); + else + _collector = new MacMetricCollector(); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Agent started on {platform}", RuntimeInformation.OSDescription); + + if (_debugMode) + { + _logger.LogInformation("DEBUG MODE ENABLED - Metrics will be printed, not sent"); + await RunDebugMode(stoppingToken); + } + else + { + await RunNormalMode(stoppingToken); + } + } + + private async Task RunDebugMode(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5)); + + try + { + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + // Skip if previous execution is still running (non-blocking lock) + if (!_executionLock.Wait(0)) + { + _logger.LogDebug("Skipping tick: previous execution still running"); + continue; + } + + try + { + var metrics = _collector.Collect(); + var delta = CalculateDelta(metrics); + + Console.WriteLine("\n=== METRICS DEBUG OUTPUT ==="); + Console.WriteLine($"Machine: {delta.MachineName}"); + if (delta.OSDescription != null) + Console.WriteLine($"OS: {delta.OSDescription}"); + if (delta.IpAddress != null) + Console.WriteLine($"IP: {delta.IpAddress}"); + if (delta.HwId != null) + Console.WriteLine($"HWID: {delta.HwId}"); + if (delta.CpuUsagePercentage.HasValue) + Console.WriteLine($"CPU: {delta.CpuUsagePercentage:F2}%"); + if (delta.MemoryUsedBytes.HasValue) + Console.WriteLine($"Memory: {delta.MemoryUsedBytes.Value / (1024 * 1024)} MB / {delta.MemoryTotalBytes.GetValueOrDefault() / (1024 * 1024)} MB"); + if (delta.SwapUsedBytes.HasValue) + Console.WriteLine($"Swap: {delta.SwapUsedBytes.Value / (1024 * 1024)} MB / {delta.SwapTotalBytes.GetValueOrDefault() / (1024 * 1024)} MB"); + if (delta.DiskUsedBytes.HasValue) + Console.WriteLine($"Disk: {delta.DiskUsedBytes.Value / (1024 * 1024)} MB / {delta.DiskTotalBytes.GetValueOrDefault() / (1024 * 1024)} MB ({delta.DiskMountPoint})"); + Console.WriteLine($"Timestamp: {delta.Timestamp:O}"); + Console.WriteLine("===========================\n"); + + _previousMetrics = metrics; + } + catch (Exception ex) + { + _logger.LogError("Error in debug mode: {msg}", ex.Message); + } + finally + { + _executionLock.Release(); + } + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("Debug mode cancelled"); + } + } + + private async Task RunNormalMode(CancellationToken stoppingToken) + { + // Create gRPC channel (ignore self-signed certs for development) + var handler = new HttpClientHandler(); + handler.ServerCertificateCustomValidationCallback = (msg, cert, chain, errors) => true; + + _channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions + { + HttpHandler = handler + }); + + _client = new MetricsService.MetricsServiceClient(_channel); + + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5)); + + try + { + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + // Skip if previous execution is still running (non-blocking lock) + if (!_executionLock.Wait(0)) + { + _logger.LogWarning("Skipping tick: previous execution still running (>5s)"); + continue; + } + + try + { + // Try to flush queued metrics first + if (_queue.Count > 0) + { + var queuedMetrics = _queue.DequeueAll(); + _logger.LogInformation("Flushing {count} queued metrics", queuedMetrics.Count); + + foreach (var queued in queuedMetrics) + { + try + { + await _client.ReportMetricsAsync(queued, cancellationToken: stoppingToken); + } + catch (Exception ex) + { + _logger.LogError("Failed to flush queued metric: {msg}", ex.Message); + _queue.Enqueue(queued); // Re-queue on failure + // Continue trying other queued metrics instead of breaking + } + } + } + + var metrics = _collector.Collect(); + _logger.LogDebug("Collected metrics: CPU={cpu}%, Memory={mem}MB/{total}MB", + metrics.CpuUsagePercentage.ToString("F2"), + (metrics.MemoryUsedBytes / (1024 * 1024)), + (metrics.MemoryTotalBytes / (1024 * 1024))); + + var delta = CalculateDelta(metrics); + + // Convert to gRPC message using object initializer + var grpcDelta = new JMon.Protos.DeltaMetrics + { + MachineName = delta.MachineName, + Hwid = delta.HwId, + TimestampUnixMs = new DateTimeOffset(delta.Timestamp).ToUnixTimeMilliseconds(), + ProcessId = delta.ProcessId + }; + + if (!string.IsNullOrEmpty(delta.OSDescription)) + grpcDelta.OsDescription = delta.OSDescription; + if (delta.CpuUsagePercentage.HasValue) + grpcDelta.CpuUsagePercentage = delta.CpuUsagePercentage.Value; + if (delta.MemoryUsedBytes.HasValue) + grpcDelta.MemoryUsedBytes = delta.MemoryUsedBytes.Value; + if (delta.MemoryTotalBytes.HasValue) + grpcDelta.MemoryTotalBytes = delta.MemoryTotalBytes.Value; + if (delta.SwapUsedBytes.HasValue) + grpcDelta.SwapUsedBytes = delta.SwapUsedBytes.Value; + if (delta.SwapTotalBytes.HasValue) + grpcDelta.SwapTotalBytes = delta.SwapTotalBytes.Value; + if (delta.DiskUsedBytes.HasValue) + grpcDelta.DiskUsedBytes = delta.DiskUsedBytes.Value; + if (delta.DiskTotalBytes.HasValue) + grpcDelta.DiskTotalBytes = delta.DiskTotalBytes.Value; + if (!string.IsNullOrEmpty(delta.DiskMountPoint)) + grpcDelta.DiskMountPoint = delta.DiskMountPoint; + if (!string.IsNullOrEmpty(delta.IpAddress)) + grpcDelta.IpAddress = delta.IpAddress; + + var response = await _client.ReportMetricsAsync(grpcDelta, cancellationToken: stoppingToken); + + _logger.LogInformation("Metrics reported: {response}", response.Command); + + // Handle server commands + if (response.Command == MetricsResponse.Types.CommandType.Sync) + { + _logger.LogInformation("Server requested full resync: {msg}", response.Message); + _previousMetrics = null; // Reset to send full update next time + } + else if (response.Command == MetricsResponse.Types.CommandType.Sigterm) + { + _logger.LogInformation("Server terminating process: {msg}", response.Message); + Environment.Exit(0); + } + + _previousMetrics = metrics; + } + catch (Exception ex) + { + _logger.LogError("Error reporting metrics: {msg}", ex.Message); + + // On failure, queue the FULL metrics for retry (not delta) + // This ensures the server can reconstruct properly even if it was restarted + if (ex is not OperationCanceledException) + { + var metrics = _collector.Collect(); + + var grpcDelta = new JMon.Protos.DeltaMetrics + { + MachineName = metrics.MachineName, + Hwid = metrics.HwId, + TimestampUnixMs = new DateTimeOffset(metrics.Timestamp).ToUnixTimeMilliseconds(), + Retried = true, + ProcessId = metrics.ProcessId, + // Send FULL metrics (all fields) on retry + OsDescription = metrics.OSDescription, + CpuUsagePercentage = metrics.CpuUsagePercentage, + MemoryUsedBytes = metrics.MemoryUsedBytes, + MemoryTotalBytes = metrics.MemoryTotalBytes, + SwapUsedBytes = metrics.SwapUsedBytes, + SwapTotalBytes = metrics.SwapTotalBytes, + DiskUsedBytes = metrics.DiskUsedBytes, + DiskTotalBytes = metrics.DiskTotalBytes, + DiskMountPoint = metrics.DiskMountPoint, + IpAddress = metrics.IpAddress + }; + + _queue.Enqueue(grpcDelta); + } + } + finally + { + _executionLock.Release(); + } + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("Normal mode cancelled"); + } + } + + private DeltaMetrics CalculateDelta(SystemMetrics current) + { + // First time - send everything + if (_previousMetrics == null) + { + return new DeltaMetrics( + current.MachineName, + current.OSDescription, + current.CpuUsagePercentage, + current.MemoryUsedBytes, + current.MemoryTotalBytes, + current.SwapUsedBytes, + current.SwapTotalBytes, + current.DiskUsedBytes, + current.DiskTotalBytes, + current.DiskMountPoint, + current.Timestamp, + current.IpAddress, + current.HwId + ); + } + + // Calculate deltas and only include changed values + return new DeltaMetrics( + current.MachineName, + current.OSDescription != _previousMetrics.OSDescription ? current.OSDescription : null, + Math.Abs(current.CpuUsagePercentage - _previousMetrics.CpuUsagePercentage) > CpuThreshold ? current.CpuUsagePercentage : null, + Math.Abs(current.MemoryUsedBytes - _previousMetrics.MemoryUsedBytes) > MemoryThreshold ? current.MemoryUsedBytes : null, + current.MemoryTotalBytes != _previousMetrics.MemoryTotalBytes ? current.MemoryTotalBytes : null, + Math.Abs(current.SwapUsedBytes - _previousMetrics.SwapUsedBytes) > SwapThreshold ? current.SwapUsedBytes : null, + current.SwapTotalBytes != _previousMetrics.SwapTotalBytes ? current.SwapTotalBytes : null, + Math.Abs(current.DiskUsedBytes - _previousMetrics.DiskUsedBytes) > DiskThreshold ? current.DiskUsedBytes : null, + current.DiskTotalBytes != _previousMetrics.DiskTotalBytes ? current.DiskTotalBytes : null, + current.DiskMountPoint != _previousMetrics.DiskMountPoint ? current.DiskMountPoint : null, + current.Timestamp, + current.IpAddress != _previousMetrics.IpAddress ? current.IpAddress : null, + current.HwId // Always send HWID as it's the unique identifier + ); + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + if (_channel != null) + { + await _channel.ShutdownAsync(); + _channel.Dispose(); + } + await base.StopAsync(cancellationToken); + } +} diff --git a/JMonAgent/appsettings.Development.json b/JMonAgent/appsettings.Development.json new file mode 100644 index 0000000..b2dcdb6 --- /dev/null +++ b/JMonAgent/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/JMonAgent/appsettings.json b/JMonAgent/appsettings.json new file mode 100644 index 0000000..e921d03 --- /dev/null +++ b/JMonAgent/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.SignalR": "Debug", + "Microsoft.AspNetCore.Http.Connections": "Debug" + } + } +} diff --git a/JMonServer/Containerfile b/JMonServer/Containerfile new file mode 100644 index 0000000..a249fcf --- /dev/null +++ b/JMonServer/Containerfile @@ -0,0 +1,35 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +RUN apt-get update && apt-get install -y build-essential clang zlib1g-dev && rm -rf /var/lib/apt/lists/* +WORKDIR /src + +COPY ["JMonServer/JMonServer.csproj", "JMonServer/"] +COPY ["JMonAgent/JMonAgent.csproj", "JMonAgent/"] +COPY ["Protos/", "Protos/"] + +RUN dotnet restore "JMonServer/JMonServer.csproj" + +COPY . . +RUN dotnet build "JMonServer/JMonServer.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "JMonServer/JMonServer.csproj" -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble +RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=publish /app/publish . + +# Generate self-signed PFX certificate for HTTPS (no password) +RUN mkdir -p /root/.aspnet/https && \ + openssl req -x509 -newkey rsa:4096 -keyout /tmp/key.pem -out /tmp/cert.pem \ + -days 365 -nodes -subj "/CN=*" && \ + openssl pkcs12 -export -out /root/.aspnet/https/aspnetapp.pfx -inkey /tmp/key.pem -in /tmp/cert.pem -passout pass: && \ + rm /tmp/key.pem /tmp/cert.pem + +EXPOSE 5001 +ENV ASPNETCORE_URLS=https://0.0.0.0:5001 +ENV ASPNETCORE_Kestrel__Certificates__Default__Path=/root/.aspnet/https/aspnetapp.pfx +ENV ASPNETCORE_Kestrel__Certificates__Default__Password= +ENV ASPNETCORE_ENVIRONMENT=Development + +ENTRYPOINT ["./JMonServer"] diff --git a/JMonServer/JMonServer.csproj b/JMonServer/JMonServer.csproj new file mode 100644 index 0000000..59769a5 --- /dev/null +++ b/JMonServer/JMonServer.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/JMonServer/Program.cs b/JMonServer/Program.cs new file mode 100644 index 0000000..3d27ca6 --- /dev/null +++ b/JMonServer/Program.cs @@ -0,0 +1,205 @@ +using JMonAgent; +using JMon.Protos; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddGrpc(); + +// Track latest full metrics per machine (keyed by HWID:PID) +var latestMetrics = new Dictionary(); + +// Track pending metrics per machine for replay in timestamp order +var pendingMetrics = new Dictionary>(); + +// Track which process is active per HWID (for duplicate detection) +var activeProcesses = new Dictionary(); + +builder.Services.AddSingleton(latestMetrics); +builder.Services.AddSingleton(pendingMetrics); +builder.Services.AddSingleton(activeProcesses); + +var app = builder.Build(); +app.MapGrpcService(); +// Use environment variables for URL, fallback to HTTP on 0.0.0.0:5001 +var urls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://0.0.0.0:5001"; +app.Run(urls); + +public class MetricsServiceImpl : MetricsService.MetricsServiceBase +{ + private readonly Dictionary _latestMetrics; + private readonly Dictionary> _pendingMetrics; + private readonly Dictionary _activeProcesses; + + public MetricsServiceImpl(Dictionary latestMetrics, Dictionary> pendingMetrics, Dictionary activeProcesses) + { + _latestMetrics = latestMetrics; + _pendingMetrics = pendingMetrics; + _activeProcesses = activeProcesses; + } + + public override async Task ReportMetrics(JMon.Protos.DeltaMetrics request, Grpc.Core.ServerCallContext context) + { + var hwid = request.Hwid; + + // Check for duplicate processes on same HWID + var command = MetricsResponse.Types.CommandType.Ack; + var message = "Metrics received"; + + if (_activeProcesses.ContainsKey(hwid)) + { + var (activePid, lastSeen) = _activeProcesses[hwid]; + + if (activePid != request.ProcessId) + { + // Multiple processes detected - SIGTERM the older one, SYNC the newer + if (request.ProcessId > activePid) + { + // New process is newer - terminate the old one + command = MetricsResponse.Types.CommandType.Sigterm; + message = $"Duplicate process detected. Terminating PID {activePid}"; + _activeProcesses[hwid] = (request.ProcessId, DateTime.UtcNow); + } + else + { + // Current request is older - ask for full sync + command = MetricsResponse.Types.CommandType.Sync; + message = $"Duplicate process detected. Newer process (PID {activePid}) is active"; + } + } + else + { + // Same process, just update last seen + _activeProcesses[hwid] = (request.ProcessId, DateTime.UtcNow); + } + } + else + { + // First process for this HWID + _activeProcesses[hwid] = (request.ProcessId, DateTime.UtcNow); + } + + // If this is a retried metric, queue it for ordered replay + if (request.Retried) + { + if (!_pendingMetrics.ContainsKey(hwid)) + _pendingMetrics[hwid] = new List(); + _pendingMetrics[hwid].Add(request); + } + + // Process all pending metrics for this machine in timestamp order + if (_pendingMetrics.ContainsKey(hwid) && _pendingMetrics[hwid].Count > 0) + { + var pending = _pendingMetrics[hwid]; + // Sort by timestamp (oldest first) + var sorted = pending.OrderBy(m => m.TimestampUnixMs).ToList(); + + foreach (var metric in sorted) + { + ApplyMetric(hwid, metric); + } + + // Clear processed pending metrics + _pendingMetrics[hwid].Clear(); + } + + // Apply the current metric + if (!request.Retried) + { + ApplyMetric(hwid, request); + } + + var serverTime = DateTime.UtcNow; + var agentTime = DateTime.UnixEpoch.AddMilliseconds(request.TimestampUnixMs); + var timeDeltaMs = Math.Abs((serverTime - agentTime).TotalMilliseconds); + + var key = $"{hwid}:{request.ProcessId}"; + if (_latestMetrics.TryGetValue(key, out var latest)) + { + DisplayMetrics("[gRPC]", latest, timeDeltaMs, request.Retried); + } + + return await Task.FromResult(new JMon.Protos.MetricsResponse + { + Success = true, + Command = command, + Message = message + }); + } + + private void ApplyMetric(string hwid, JMon.Protos.DeltaMetrics delta) + { + var agentTime = DateTime.UnixEpoch.AddMilliseconds(delta.TimestampUnixMs); + + // Use HWID + PID as key + var key = $"{hwid}:{delta.ProcessId}"; + + // Reconstruct full metrics by merging delta with previous state + SystemMetrics full; + if (_latestMetrics.TryGetValue(key, out var previous)) + { + full = new SystemMetrics( + delta.MachineName, + delta.HasOsDescription ? delta.OsDescription : previous.OSDescription, + delta.HasCpuUsagePercentage ? delta.CpuUsagePercentage : previous.CpuUsagePercentage, + delta.HasMemoryUsedBytes ? delta.MemoryUsedBytes : previous.MemoryUsedBytes, + delta.HasMemoryTotalBytes ? delta.MemoryTotalBytes : previous.MemoryTotalBytes, + delta.HasSwapUsedBytes ? delta.SwapUsedBytes : previous.SwapUsedBytes, + delta.HasSwapTotalBytes ? delta.SwapTotalBytes : previous.SwapTotalBytes, + delta.HasDiskUsedBytes ? delta.DiskUsedBytes : previous.DiskUsedBytes, + delta.HasDiskTotalBytes ? delta.DiskTotalBytes : previous.DiskTotalBytes, + delta.HasDiskMountPoint ? delta.DiskMountPoint : previous.DiskMountPoint, + agentTime, + delta.HasIpAddress ? delta.IpAddress : previous.IpAddress, + hwid, + delta.ProcessId + ); + } + else + { + // First time - initialize with sent values or defaults + full = new SystemMetrics( + delta.MachineName, + delta.HasOsDescription ? delta.OsDescription : "Unknown", + delta.HasCpuUsagePercentage ? delta.CpuUsagePercentage : 0, + delta.HasMemoryUsedBytes ? delta.MemoryUsedBytes : 0, + delta.HasMemoryTotalBytes ? delta.MemoryTotalBytes : 0, + delta.HasSwapUsedBytes ? delta.SwapUsedBytes : 0, + delta.HasSwapTotalBytes ? delta.SwapTotalBytes : 0, + delta.HasDiskUsedBytes ? delta.DiskUsedBytes : 0, + delta.HasDiskTotalBytes ? delta.DiskTotalBytes : 0, + delta.HasDiskMountPoint ? delta.DiskMountPoint : "/", + agentTime, + delta.HasIpAddress ? delta.IpAddress : "", + hwid, + delta.ProcessId + ); + } + + _latestMetrics[key] = full; + } + + private void DisplayMetrics(string source, SystemMetrics full, double timeDeltaMs, bool retried) + { + var tag = retried ? "[RETRIED]" : ""; + Console.WriteLine($"{source} {tag} {full.MachineName} ({full.HwId}) [PID: {full.ProcessId}]:"); + Console.WriteLine($" Time Skew: {timeDeltaMs:F1}ms"); + + if (timeDeltaMs > 60000) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" ⚠️ ALERT: Clock skew detected! {timeDeltaMs:F0}ms difference"); + Console.ResetColor(); + } + + if (!string.IsNullOrEmpty(full.IpAddress)) + Console.WriteLine($" IP: {full.IpAddress}"); + Console.WriteLine($" CPU: {full.CpuUsagePercentage:F2}%"); + var memPercent = (full.MemoryTotalBytes > 0) ? (full.MemoryUsedBytes * 100.0 / full.MemoryTotalBytes).ToString("F1") : "0.0"; + Console.WriteLine($" Memory: {full.MemoryUsedBytes / (1024*1024)} MB / {full.MemoryTotalBytes / (1024*1024)} MB ({memPercent}%)"); + var swapPercent = (full.SwapTotalBytes > 0) ? (full.SwapUsedBytes * 100.0 / full.SwapTotalBytes).ToString("F1") : "0.0"; + Console.WriteLine($" Swap: {full.SwapUsedBytes / (1024*1024)} MB / {full.SwapTotalBytes / (1024*1024)} MB ({swapPercent}%)"); + var diskPercent = (full.DiskTotalBytes > 0) ? (full.DiskUsedBytes * 100.0 / full.DiskTotalBytes).ToString("F1") : "0.0"; + Console.WriteLine($" Disk ({full.DiskMountPoint}): {full.DiskUsedBytes / (1024*1024)} MB / {full.DiskTotalBytes / (1024*1024)} MB ({diskPercent}%)"); + Console.WriteLine($" OS: {full.OSDescription}\n"); + } +} diff --git a/Protos/metrics.proto b/Protos/metrics.proto new file mode 100644 index 0000000..7cf3931 --- /dev/null +++ b/Protos/metrics.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; + +package jmon; + +option csharp_namespace = "JMon.Protos"; + +service MetricsService { + rpc ReportMetrics (DeltaMetrics) returns (MetricsResponse); +} + +message DeltaMetrics { + string machine_name = 1; + string hwid = 2; + optional string os_description = 3; + optional double cpu_usage_percentage = 4; + optional int64 memory_used_bytes = 5; + optional int64 memory_total_bytes = 6; + optional int64 swap_used_bytes = 7; + optional int64 swap_total_bytes = 8; + optional int64 disk_used_bytes = 9; + optional int64 disk_total_bytes = 10; + optional string disk_mount_point = 11; + int64 timestamp_unix_ms = 12; + bool retried = 13; + optional string ip_address = 14; + int32 process_id = 15; +} + +message MetricsResponse { + enum CommandType { + ACK = 0; + SYNC = 1; + SIGTERM = 2; + } + + bool success = 1; + CommandType command = 2; + string message = 3; +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..6099b2b --- /dev/null +++ b/compose.yaml @@ -0,0 +1,46 @@ +version: '3.8' + +services: + postgres: + image: docker.io/library/postgres:16 + container_name: jmon-postgres + environment: + POSTGRES_PASSWORD: jmon_dev_password + POSTGRES_DB: jmon + POSTGRES_USER: jmon + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - /home/jgetrost/source/repos/jmon/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql:Z + healthcheck: + test: ["CMD-SHELL", "pg_isready -U jmon"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - jmon-net + + server: + build: + context: . + dockerfile: JMonServer/Containerfile + container_name: jmon-server + depends_on: + postgres: + condition: service_started + environment: + ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=jmon;Username=jmon;Password=jmon_dev_password" + ASPNETCORE_URLS: "https://+:5001" + ASPNETCORE_ENVIRONMENT: Development + ports: + - "5001:5001" + networks: + - jmon-net + +volumes: + postgres_data: + +networks: + jmon-net: + driver: bridge