From 87ba9aa4b8da10582e8dcbc0e05181da35c69540 Mon Sep 17 00:00:00 2001 From: Gregory Lirent Date: Wed, 2 Jul 2025 16:06:50 +0300 Subject: [PATCH] v0.1.1 --- .gitignore | 3 + Configuration/AppSettings.cs | 14 +- Controllers/ProcessController.cs | 82 +++ Exceptions/ProcessAccessDeniedException.cs | 8 - Exceptions/ProcessMonitorException.cs | 8 + Models/ConcurrentDTO.cs | 19 + Models/MemoryRegion.cs | 28 + Models/MemoryRegionInfo.cs | 30 +- Models/ProcessBaseInfo.cs | 85 +++ Models/ProcessInfo.cs | 31 +- Models/ProcessModuleInfo.cs | 25 - Program.cs | 61 +- Services/ProcessMonitor.cs | 674 ++++++++------------- Services/WinApi.cs | 122 ++-- appsettings.json | 6 +- webmr-api.csproj | 8 +- 16 files changed, 622 insertions(+), 582 deletions(-) create mode 100644 Controllers/ProcessController.cs delete mode 100644 Exceptions/ProcessAccessDeniedException.cs create mode 100644 Exceptions/ProcessMonitorException.cs create mode 100644 Models/ConcurrentDTO.cs create mode 100644 Models/MemoryRegion.cs create mode 100644 Models/ProcessBaseInfo.cs delete mode 100644 Models/ProcessModuleInfo.cs diff --git a/.gitignore b/.gitignore index 1736844..06879a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ /.vs/ /bin/ /obj/ +/Properties/ +/appsettings.Development.json +/webmr-api.csproj.user diff --git a/Configuration/AppSettings.cs b/Configuration/AppSettings.cs index d0ae6db..7a0552f 100644 --- a/Configuration/AppSettings.cs +++ b/Configuration/AppSettings.cs @@ -1,19 +1,21 @@ +// File: Configuration/AppSettings.cs + namespace WebmrAPI.Configuration { public class AppSettings { - public AgentSettings Agent { get; set; } = new AgentSettings(); - public WebServerSettings WebServer { get; set; } = new WebServerSettings(); + public MonitoringSettings Monitoring { get; set; } = new MonitoringSettings(); + public WebServerSettings WebServer { get; set; } = new WebServerSettings(); } - public class AgentSettings + public class MonitoringSettings { - public int ScanIntervalSeconds { get; set; } = 5; // Default scan every 5 seconds - public string TargetProcessName { get; set; } = "example.exe"; // Default target process + public int ProcessScanInterval { get; set; } = 5; + public int MemoryRegionScanTimeout { get; set; } = 30; } public class WebServerSettings { public string Url { get; set; } = "http://0.0.0.0:8080"; // Default listening URL } -} \ No newline at end of file +} diff --git a/Controllers/ProcessController.cs b/Controllers/ProcessController.cs new file mode 100644 index 0000000..b9b2d08 --- /dev/null +++ b/Controllers/ProcessController.cs @@ -0,0 +1,82 @@ +// File: Controllers/ProcessController.cs + +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; +using System.Text.Json.Serialization; +using WebmrAPI.Models; +using WebmrAPI.Services; + +namespace WebmrAPI.Controllers +{ + [ApiController] + [Route("api/v1/[controller]")] + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + public class ProcessController : ControllerBase + { + private readonly ProcessMonitor _monitor; + private readonly ILogger _logger; + + public ProcessController(ProcessMonitor monitor, ILogger logger) + { + _monitor = monitor; + _logger = logger; + } + private string GetFormattedJson(T data, bool pretty) + { + var options = new JsonSerializerOptions + { + WriteIndented = pretty, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + Converters = { new JsonStringEnumConverter() } + }; + return JsonSerializer.Serialize(data, options); + } + + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public IActionResult GetProcesses([FromQuery] bool pretty = false) + { + try + { + var processes = _monitor.GetBufferedProcesses(); + return Content(GetFormattedJson(processes, pretty), "application/json"); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred when getting the list of processes"); + return StatusCode(StatusCodes.Status500InternalServerError, "An internal server error occurred when receiving processes."); + } + } + + [HttpGet("{pid}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ProcessInfo))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public IActionResult GetProcessById(int pid, [FromQuery] bool pretty = false) + { + try + { + var data = _monitor.GetProcessDetails(pid); + if (data == null) + { + return NotFound($"The process with the PID {pid} was not found or its details could not be obtained."); + } + return Content(GetFormattedJson(data, pretty), "application/json"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"An error occurred while receiving process details for PID {pid}."); + return StatusCode(StatusCodes.Status500InternalServerError, $"An internal server error occurred while receiving process details for PID {pid}."); + } + } + + // TODO: Добавить эндпоинты для: + // - /api/Process/{pid}/memory_regions + // - /api/Process/{pid}/modules + // - /api/Process/{pid}/threads + // - Поиск по имени + // - Пагинация + // - Нагрузка на CPU + } +} diff --git a/Exceptions/ProcessAccessDeniedException.cs b/Exceptions/ProcessAccessDeniedException.cs deleted file mode 100644 index 9e1f4f7..0000000 --- a/Exceptions/ProcessAccessDeniedException.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace WebmrAPI.Exceptions -{ - public class ProcessAccessDeniedException : Exception - { - public ProcessAccessDeniedException(string message) : base(message) { } - public ProcessAccessDeniedException(string message, Exception innerException) : base(message, innerException) { } - } -} \ No newline at end of file diff --git a/Exceptions/ProcessMonitorException.cs b/Exceptions/ProcessMonitorException.cs new file mode 100644 index 0000000..a2943f4 --- /dev/null +++ b/Exceptions/ProcessMonitorException.cs @@ -0,0 +1,8 @@ +namespace WebmrAPI.Exceptions +{ + public class ProcessMonitorException : Exception + { + public ProcessMonitorException(string message) : base(message) { } + public ProcessMonitorException(string message, Exception innerException) : base(message, innerException) { } + } +} diff --git a/Models/ConcurrentDTO.cs b/Models/ConcurrentDTO.cs new file mode 100644 index 0000000..e4a4758 --- /dev/null +++ b/Models/ConcurrentDTO.cs @@ -0,0 +1,19 @@ +// File: Models/ConcurrentDTO.cs + +namespace WebmrAPI.Models +{ + public class ConcurrentDTO + { + internal readonly object _lock = new object(); + + public void LockedSet(ref T dst, T value) + { + lock (_lock) dst = value; + } + + public T LockedGet(ref T src) + { + lock (_lock) return src; + } + } +} diff --git a/Models/MemoryRegion.cs b/Models/MemoryRegion.cs new file mode 100644 index 0000000..05a60a4 --- /dev/null +++ b/Models/MemoryRegion.cs @@ -0,0 +1,28 @@ +// File: Models/MemoryRegion.cs + +using System.Text.Json.Serialization; + +namespace WebmrAPI.Models +{ + public class MemoryRegion : ConcurrentDTO + { + private long _addr = 0; + private ulong _size = 0; + + [JsonIgnore] + public long MemoryAddress + { + get => LockedGet(ref _addr); + set => LockedSet(ref _addr, value); + } + public ulong MemorySize + { + get => LockedGet(ref _size); + set => LockedSet(ref _size, value); + } + public string? BaseAddress + { + get => (MemoryAddress > 0) ? $"0x{MemoryAddress:X12}" : null; + } + } +} diff --git a/Models/MemoryRegionInfo.cs b/Models/MemoryRegionInfo.cs index d534c73..b012347 100644 --- a/Models/MemoryRegionInfo.cs +++ b/Models/MemoryRegionInfo.cs @@ -1,11 +1,27 @@ +// File: Models/MemoryRegionInfo.cs + namespace WebmrAPI.Models { - public class MemoryRegionInfo + public class MemoryRegionInfo : MemoryRegion { - public string BaseAddress { get; set; } = string.Empty; - public long RegionSize { get; set; } - public string State { get; set; } = string.Empty; - public string Protect { get; set; } = string.Empty; - public string Type { get; set; } = string.Empty; + private string _state = String.Empty; + private string _protect = String.Empty; + private string _type = String.Empty; + + public string State + { + get => LockedGet(ref _state); + set => LockedSet(ref _state, value); + } + public string Protect + { + get => LockedGet(ref _protect); + set => LockedSet(ref _protect, value); + } + public string Type + { + get => LockedGet(ref _type); + set => LockedSet(ref _type, value); + } } -} \ No newline at end of file +} diff --git a/Models/ProcessBaseInfo.cs b/Models/ProcessBaseInfo.cs new file mode 100644 index 0000000..5e4d8c2 --- /dev/null +++ b/Models/ProcessBaseInfo.cs @@ -0,0 +1,85 @@ +// File: Models/ProcessBaseInfo.cs + +using System.Text.Json.Serialization; + +namespace WebmrAPI.Models +{ + public enum ProcessStatus + { + Undefined, + Running, + NotResponding + } + + public class ProcessBaseInfo : MemoryRegion + { + private TimeSpan _lastPTime = TimeSpan.Zero; + private TimeSpan _curPTime = TimeSpan.Zero; + + private int _pid = 0; + private int _ppid = -1; + private int _tcount = 0; + private string? _name = null; + private string? _fname = null; + private string? _cmd = null; + private ProcessStatus _status = ProcessStatus.Undefined; + private DateTime? _startTime = null; + private double _cpuUsage = 0; + + [JsonIgnore] + public TimeSpan TotalProcessorTime + { + set { lock (_lock) { _lastPTime = _curPTime; _curPTime = value; } } + } + [JsonIgnore] + public double ProcessorTime + { + get { lock (_lock) return (_lastPTime - _curPTime).TotalMilliseconds; } + } + public int PID + { + get => LockedGet(ref _pid); + set => LockedSet(ref _pid, value); + } + public int ParentPID + { + get => LockedGet(ref _ppid); + set => LockedSet(ref _ppid, value); + } + public string? Name + { + get => LockedGet(ref _name); + set => LockedSet(ref _name, value); + } + public string? FileName + { + get => LockedGet(ref _fname); + set => LockedSet(ref _fname, value); + } + public string? CommandLine + { + get => LockedGet(ref _cmd); + set => LockedSet(ref _cmd, value); + } + public int ThreadCount + { + get => LockedGet(ref _tcount); + set => LockedSet(ref _tcount, value); + } + public ProcessStatus Status + { + get => LockedGet(ref _status); + set => LockedSet(ref _status, value); + } + public DateTime? StartTime + { + get => LockedGet(ref _startTime); + set => LockedSet(ref _startTime, value); + } + public double CpuUsage + { + get => LockedGet(ref _cpuUsage); + set => LockedSet(ref _cpuUsage, value); + } + } +} diff --git a/Models/ProcessInfo.cs b/Models/ProcessInfo.cs index fd29546..994a7ce 100644 --- a/Models/ProcessInfo.cs +++ b/Models/ProcessInfo.cs @@ -1,17 +1,26 @@ -using System.Collections.Concurrent; +// File: Models/ProcessInfo.cs + +using System.Runtime.Versioning; +using System.Text.Json.Serialization; namespace WebmrAPI.Models { - public class ProcessInfo + [SupportedOSPlatform("windows")] + public class ProcessInfo : ProcessBaseInfo { - private readonly object _lock = new object(); - - public int ProcessId { get; set; } - public string ProcessName { get; set; } = string.Empty; - public string? BaseAddress { get; set; } - public long VirtualMemorySize { get; set; } - public string? CommandLine { get; set; } - + private DateTime _lastUpdate = DateTime.MinValue; + private List _regions = new(); + [JsonIgnore] + public DateTime LastUpdate + { + get => LockedGet(ref _lastUpdate); + set => LockedSet(ref _lastUpdate, value); + } + public List MemoryRegions + { + get => LockedGet(ref _regions); + set => LockedSet(ref _regions, value); + } } -} \ No newline at end of file +} diff --git a/Models/ProcessModuleInfo.cs b/Models/ProcessModuleInfo.cs deleted file mode 100644 index 208a91a..0000000 --- a/Models/ProcessModuleInfo.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Diagnostics; - -namespace WebmrAPI.Services -{ - public class ProcessModuleInfo - { - public string? ModuleName { get; set; } - public string? FileName { get; set; } - public string? BaseAddress { get; set; } - public long MemorySize { get; set; } - public string? EntrypointAddress { get; set; } - - public static ProcessModuleInfo FromProcessModule(ProcessModule module) - { - return new ProcessModuleInfo - { - ModuleName = module.ModuleName, - FileName = module.FileName, - BaseAddress = "0x" + module.BaseAddress.ToInt64().ToString("X"), - MemorySize = module.ModuleMemorySize, - EntrypointAddress = "0x" + module.EntryPointAddress.ToInt64().ToString("X") - }; - } - } -} \ No newline at end of file diff --git a/Program.cs b/Program.cs index eb6f08c..a9aad57 100644 --- a/Program.cs +++ b/Program.cs @@ -1,59 +1,46 @@ -using Microsoft.AspNetCore.Mvc; +// File: Program.cs + using Microsoft.Extensions.Options; -using System.Diagnostics; -using System.Text.Json; +using System.Runtime.Versioning; using WebmrAPI.Configuration; -using WebmrAPI.Models; // using using WebmrAPI.Services; +[assembly: SupportedOSPlatform("windows")] + var builder = WebApplication.CreateBuilder(args); -// builder.Services.Configure(builder.Configuration); - -// builder.Services.AddLogging(config => { config.AddConsole(); config.AddDebug(); }); -// ProcessMonitor Singleton IHostedService +builder.Services.AddRouting(options => +{ + options.LowercaseUrls = true; + options.LowercaseQueryStrings = true; +}); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); -// WinApi Singleton ( , ) -// WinApi , : -// builder.Services.AddSingleton(); // , +// ---------------------------------------------------------------------------------------------------------------------------- \\ -var app = builder.Build(); +var app = builder.Build(); +var settings = app.Services.GetRequiredService>().Value; -// URL -var appSettings = app.Services.GetRequiredService>().Value; -app.Urls.Add(appSettings.WebServer.Url); +app.Urls.Add(settings.WebServer.Url); - -// GET /api/processes -app.MapGet("/api/processes", (ProcessMonitor monitor, [FromQuery] bool pretty = false) => +if (app.Environment.IsDevelopment()) { - return Results.Content(JsonSerializer.Serialize(monitor.Processes, new JsonSerializerOptions { WriteIndented = pretty }), "application/json"); -}); - -// GET /api/processes/{pid}/memory_regions -app.MapGet("/api/processes/{pid}/memory_regions", (int pid, ProcessMonitor monitor, [FromQuery] bool pretty = false) => -{ - var regions = monitor.GetBufferedMemoryRegions(pid); - if (regions == null) { - return Results.NotFound($"Process with PID {pid} or its memory regions not found."); - } - return Results.Content(JsonSerializer.Serialize(regions, new JsonSerializerOptions { WriteIndented = pretty }), "application/json"); -}); - -// GET /api/status/last_modified -app.MapGet("/api/status/last_modified", (ProcessMonitor monitor, [FromQuery] bool pretty = false) => -{ - var date = new { LastModified = monitor.LastModifiedTimestamp.ToString("o") }; - return Results.Content(JsonSerializer.Serialize(date, new JsonSerializerOptions { WriteIndented = pretty }), "application/json"); -}); + app.UseSwagger(); + app.UseSwaggerUI(); +} +app.MapControllers(); app.Run(); diff --git a/Services/ProcessMonitor.cs b/Services/ProcessMonitor.cs index 3cd0032..59d72e4 100644 --- a/Services/ProcessMonitor.cs +++ b/Services/ProcessMonitor.cs @@ -1,438 +1,74 @@ -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; +// File: Services/ProcessMonitor.cs + using Microsoft.Extensions.Options; using System.Collections.Concurrent; using System.Management; -using System.Runtime.InteropServices; +using System.Runtime.Versioning; using WebmrAPI.Configuration; -using WebmrAPI.Exceptions; using WebmrAPI.Models; namespace WebmrAPI.Services { + [SupportedOSPlatform("windows")] public class ProcessMonitor : IHostedService, IDisposable { - private DateTime _modifyTimestamp = DateTime.UtcNow; + private DateTime _lastScanTime = DateTime.MinValue; + private DateTime _currentScanTime = DateTime.MinValue; private Timer? _timer; private readonly ILogger _logger; - private readonly AgentSettings _agentSettings; + private readonly MonitoringSettings _config; private readonly object _lock = new object(); private ConcurrentDictionary _processesBuffer = new(); - private ConcurrentDictionary> _memoryRegionsBuffer = new(); - - public DateTime LastModifiedTimestamp + private DateTime ScanTime { - get - { - lock (_lock) - { - return _modifyTimestamp; - } - } - - private set + set { - _modifyTimestamp = value; + _lastScanTime = _currentScanTime; + _currentScanTime = value; } } - public IEnumerable Processes + private TimeSpan Elapsed { - get + get => _currentScanTime - _lastScanTime; + } + + public IEnumerable GetBufferedProcesses() + { + lock (_lock) { - lock (_lock) + return _processesBuffer.Values.ToList(); + } + } + + public ProcessInfo? GetProcessDetails(int pid) + { + lock (_lock) + { + if (_processesBuffer.TryGetValue(pid, out ProcessInfo? data)) { - return _processesBuffer.Values.ToList(); + return PopulateProcessInfo(data); } + return null; } } - public ProcessMonitor(ILogger logger, IOptions appSettings) + public ProcessMonitor(ILogger logger, IOptions settings) { _logger = logger; - _agentSettings = appSettings.Value.Agent; + _config = settings.Value.Monitoring; } - - public Task StartAsync(CancellationToken cancellationToken) { - _logger.LogInformation("ProcessMonitor started. Scan interval: {ScanIntervalSeconds} seconds.", _agentSettings.ScanIntervalSeconds); - // , - _timer = new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromSeconds(_agentSettings.ScanIntervalSeconds)); + _logger.LogInformation($"ProcessMonitor started. Scan interval: {_config.ProcessScanInterval} seconds."); + _timer = new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromSeconds(_config.ProcessScanInterval)); return Task.CompletedTask; } - private void DoWork(object? state) - { - _logger.LogDebug("Initiating process scan..."); - try - { - var currentProcesses = new Dictionary(); - var currentMemoryRegions = new Dictionary>(); - bool changedDetected = false; - - // --- 1. Get all running processes and their command lines using WMI --- - // WMI is more reliable for getting command line of arbitrary processes - var searcher = new ManagementObjectSearcher("SELECT ProcessId, Name, CommandLine FROM Win32_Process"); - Dictionary wmiCommandLines = new Dictionary(); - Dictionary wmiProcessNames = new Dictionary(); - - foreach (ManagementObject queryObj in searcher.Get()) - { - int pid = Convert.ToInt32(queryObj["ProcessId"]); - string? commandLine = queryObj["CommandLine"]?.ToString(); - string? processName = queryObj["Name"]?.ToString(); - - wmiCommandLines[pid] = commandLine ?? string.Empty; - wmiProcessNames[pid] = processName ?? string.Empty; - } - - // --- 2. Iterate through System.Diagnostics.Process objects --- - System.Diagnostics.Process[] allProcesses = System.Diagnostics.Process.GetProcesses(); - - foreach (var process in allProcesses) - { - // , / - if (process.Id == 0 || process.Id == 4) - { - _logger.LogDebug("Skipping special system process {ProcessName} (PID: {PID}).", process.ProcessName, process.Id); - process.Dispose(); // Process - continue; - } - - try - { - string processName = wmiProcessNames.GetValueOrDefault(process.Id, process.ProcessName); // Prefer WMI name if available - string commandLine = wmiCommandLines.GetValueOrDefault(process.Id, string.Empty); - - var processInfo = new ProcessInfo - { - ProcessId = process.Id, - ProcessName = processName, - VirtualMemorySize = process.VirtualMemorySize64, - CommandLine = commandLine - }; - - // Try to get BaseAddress from MainModule. This can still throw Access Denied. - try - { - // Some processes (e.g., system processes like Idle, System) might not have a MainModule - if (process.MainModule != null) - { - processInfo.BaseAddress = $"0x{process.MainModule.BaseAddress.ToInt64():X}"; - } - else - { - _logger.LogDebug("Process {ProcessName} (PID: {PID}) has no MainModule.", processName, process.Id); - processInfo.BaseAddress = null; // Ensure it's explicitly null if not found - } - } - catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 5) // Access Denied - { - // 5: Access Denied. 0x80004005 ( Win32Exception ) - if (ex.NativeErrorCode == 5) - { - _logger.LogWarning("Access denied to MainModule for process {ProcessName} (PID: {PID}). BaseAddress will be null. Error: {ErrorMessage}", processName, process.Id, ex.Message); - } - else - { - // Win32Exception, "Unable to enumerate the process modules." (0x80004005) - _logger.LogWarning(ex, "Win32Exception getting MainModule for process {ProcessName} (PID: {PID}). BaseAddress will be null.", processName, process.Id); - } - processInfo.BaseAddress = null; - } - catch (InvalidOperationException ex) - { - _logger.LogDebug(ex, "Could not get MainModule for process {ProcessName} (PID: {PID}). Process might have exited or has no MainModule.", processName, process.Id); - processInfo.BaseAddress = null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Unexpected error getting MainModule for process {ProcessName} (PID: {PID}).", processName, process.Id); - processInfo.BaseAddress = null; - } - - - currentProcesses.Add(process.Id, processInfo); - - // --- 3. Scan memory regions for the target process OR new processes --- - bool isTargetProcess = _agentSettings.TargetProcessName.Equals(processName, StringComparison.OrdinalIgnoreCase); - - // We scan memory regions for: - // 1. The specific target process (if _agentSettings.TargetProcessName is set) - // 2. ALL processes if _agentSettings.TargetProcessName is empty/null - // 3. Any newly detected process that wasn't in our previous buffer (so we get its initial memory layout) - if (isTargetProcess || string.IsNullOrEmpty(_agentSettings.TargetProcessName) || !_processesBuffer.ContainsKey(process.Id)) - { - try - { - var regions = ScanMemoryRegionsForProcess(process); - if (regions.Any()) - { - currentMemoryRegions.Add(process.Id, regions); - } - else - { - _logger.LogDebug("No memory regions found or accessible for process {ProcessName} (PID: {PID}).", processName, process.Id); - } - } - catch (ProcessAccessDeniedException ex) - { - // , - string[] commonSystemProcesses = { - "System", "System Idle Process", "Registry", "smss.exe", "csrss.exe", - "wininit.exe", "services.exe", "lsass.exe", "winlogon.exe", "fontdrvhost.exe", - "Memory Compression", "dwm.exe", "RuntimeBroker.exe", // Common UWP/System processes - "msrdc.exe", "svchost.exe", // svchost can be varied, but often restricted - "MsMpEng.exe", "MpDefenderCoreService.exe" // Windows Defender related - }; - - // , - if (commonSystemProcesses.Contains(processName, StringComparer.OrdinalIgnoreCase)) - { - _logger.LogDebug(ex, "Access denied to memory regions for known system process {ProcessName} (PID: {PID}). This is expected.", processName, process.Id); - } - else - { - _logger.LogWarning(ex, "Access denied to memory regions for process {ProcessName} (PID: {PID}). Skipping memory scan for it. Elevated privileges needed?", processName, process.Id); - } - } - // Other exceptions from ScanMemoryRegionsForProcess will be caught by outer catch - } - } - catch (InvalidOperationException ex) - { - // Process might have exited between GetProcesses and accessing its properties - _logger.LogDebug(ex, "Process {ProcessName} (PID: {PID}) exited during scan. Skipping.", process.ProcessName, process.Id); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error scanning process {ProcessName} (PID: {PID}) for basic info or WMI data.", process.ProcessName, process.Id); - } - finally - { - // Dispose the Process object to free resources associated with the handle - process.Dispose(); - } - } - - // --- 4. Compare with buffered data and update if changes detected --- - lock (_lock) - { - // Check for changes in processes (new, exited, basic info changed) - if (!AreProcessInfosEqual(_processesBuffer.Values, currentProcesses.Values)) - { - changedDetected = true; - _logger.LogInformation("Process list or basic info changed."); - } - - // Check for changes in memory regions for monitored processes - // Only compare if currentMemoryRegions contains the PID - foreach (var kvp in currentMemoryRegions) - { - // If process was previously monitored AND its regions are different OR if it's a new process with regions - if (!_memoryRegionsBuffer.ContainsKey(kvp.Key) || !AreMemoryRegionsEqual(_memoryRegionsBuffer[kvp.Key], kvp.Value)) - { - changedDetected = true; - _logger.LogInformation("Memory regions for PID {PID} changed.", kvp.Key); - break; // No need to check further, change detected - } - } - - // If a process disappeared (was in old buffer but not in new), it also counts as a change - if (_processesBuffer.Count != currentProcesses.Count) - { - // If counts are different, and we haven't already marked as changed by content, mark as changed - // This handles processes exiting. - if (!changedDetected) - { // Only set if not already set by content change - changedDetected = true; - _logger.LogInformation("Process count changed (some processes might have exited)."); - } - } - - if (changedDetected) - { - _processesBuffer = new ConcurrentDictionary(currentProcesses); - _memoryRegionsBuffer = new ConcurrentDictionary>(currentMemoryRegions); - LastModifiedTimestamp = DateTime.UtcNow; - _logger.LogInformation("Buffered data updated. New LastModifiedTimestamp: {Timestamp}", LastModifiedTimestamp); - } - else - { - _logger.LogDebug("No changes detected in processes or monitored memory regions."); - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Unhandled error during process monitoring cycle."); - } - } - - /// - /// . - /// - private List ScanMemoryRegionsForProcess(System.Diagnostics.Process process) - { - var regions = new List(); - IntPtr processHandle = IntPtr.Zero; - try - { - // Open process with necessary access rights - processHandle = WinApi.OpenProcess( - WinApi.PROCESS_QUERY_INFORMATION | WinApi.PROCESS_VM_OPERATION | WinApi.PROCESS_VM_READ, // We also need VM_READ for VirtualQueryEx if it needs to access pages - false, // Do not inherit handle - (uint)process.Id - ); - - if (processHandle == IntPtr.Zero) - { - int errorCode = Marshal.GetLastWin32Error(); - if (errorCode == 5) // ERROR_ACCESS_DENIED - { - throw new ProcessAccessDeniedException($"Access denied to process {process.ProcessName} (PID: {process.Id}). Error code: {errorCode}"); - } - WinApi.ThrowWinApiErrorIfAny($"OpenProcess for PID {process.Id}"); - return regions; // Should not reach here if ThrowWinApiErrorIfAny throws - } - - IntPtr currentAddress = IntPtr.Zero; - long maxAddress = long.MaxValue; // Scan full 64-bit address space - - if (Environment.Is64BitProcess) - { - // On 64-bit, the user-mode address space typically ends at 0x7FFFFFFFFFFF - // No, maxAddress should be dynamic if needed, but for iteration it means "until VirtualQueryEx returns 0" - // Or 0x7FFFFFFFFFFF (2^47 -1, which is the user-mode address space limit on 64-bit Windows) - // For now, let VirtualQueryEx drive it. - } - - while (true) - { - if (currentAddress.ToInt64() >= (long)0x7FFFFFFFFFFF) - { - _logger.LogDebug("Reached effective end of 64-bit user-mode address space (0x{Address:X}) for PID {PID}.", currentAddress.ToInt64(), process.Id); - break; - } - - WinApi.MEMORY_BASIC_INFORMATION mbi; - // dwLength is the size of the buffer we pass in - int result = WinApi.VirtualQueryEx(processHandle, currentAddress, out mbi, (uint)Marshal.SizeOf(typeof(WinApi.MEMORY_BASIC_INFORMATION))); - - if (result == 0) // VirtualQueryEx returns 0 on failure or end of address space - { - int errorCode = Marshal.GetLastWin32Error(); - // ERROR_NO_MORE_FILES (18) - - // ERROR_INVALID_PARAMETER (87) - - if (errorCode != 0 && errorCode != 18 && errorCode != 87) // 87 - { - _logger.LogWarning("VirtualQueryEx for PID {PID} at address 0x{Address:X} failed with unexpected error code: {ErrorCode}.", process.Id, currentAddress.ToInt64(), errorCode); - } - else if (errorCode == 87) - { - _logger.LogDebug("VirtualQueryEx for PID {PID} at address 0x{Address:X} failed with ERROR_INVALID_PARAMETER (87). End of scannable address space reached.", process.Id, currentAddress.ToInt64()); - } - break; // End of address space or an unrecoverable error - } - - // Only add committed or reserved regions - if (mbi.State == WinApi.MEM_COMMIT || mbi.State == WinApi.MEM_RESERVE) - { - regions.Add(new MemoryRegionInfo - { - BaseAddress = $"0x{mbi.BaseAddress.ToInt64():X}", - RegionSize = (long)mbi.RegionSize.ToUInt64(), - State = WinApi.GetMemoryStateString(mbi.State), - Protect = WinApi.GetMemoryProtectString(mbi.Protect), - Type = WinApi.GetMemoryTypeString(mbi.Type) - }); - } - - // Calculate next address to query from (BaseAddress + RegionSize) - // Ensure we don't overflow or loop indefinitely on bad data - long nextAddressLong = mbi.BaseAddress.ToInt64() + (long)mbi.RegionSize.ToUInt64(); - if (nextAddressLong <= currentAddress.ToInt64()) // Handle potential infinite loop if RegionSize is 0 or negative - { - _logger.LogWarning("VirtualQueryEx for PID {PID} advanced by non-positive amount. Breaking loop.", process.Id); - break; - } - currentAddress = new IntPtr(nextAddressLong); - - // Stop if we hit max address or overflow (which shouldn't happen with long.MaxValue for 64-bit) - if (nextAddressLong >= maxAddress && maxAddress != long.MaxValue) break; // In case we ever set a specific maxAddress - } - } - finally - { - if (processHandle != IntPtr.Zero) - { - WinApi.CloseHandle(processHandle); - } - } - return regions; - } - - /// - /// ProcessInfo . - /// - private bool AreProcessInfosEqual(ICollection oldList, ICollection newList) - { - if (oldList.Count != newList.Count) return false; - - var oldDict = oldList.ToDictionary(p => p.ProcessId); - var newDict = newList.ToDictionary(p => p.ProcessId); - - foreach (var newProc in newDict.Values) - { - if (!oldDict.TryGetValue(newProc.ProcessId, out var oldProc)) return false; // New process or process ID changed - - // Compare relevant fields. BaseAddress and CommandLine might change if process restarts or for some apps. - // We consider them part of "basic info" that signals a change. - if (oldProc.ProcessName != newProc.ProcessName || - oldProc.BaseAddress != newProc.BaseAddress || - oldProc.VirtualMemorySize != newProc.VirtualMemorySize || - oldProc.CommandLine != newProc.CommandLine) - { - return false; - } - } - return true; - } - - /// - /// MemoryRegionInfo . - /// - private bool AreMemoryRegionsEqual(List oldList, List newList) - { - if (oldList.Count != newList.Count) return false; - - // Sort by BaseAddress to ensure consistent comparison order - var oldSorted = oldList.OrderBy(r => r.BaseAddress).ToList(); - var newSorted = newList.OrderBy(r => r.BaseAddress).ToList(); - - for (int i = 0; i < oldSorted.Count; i++) - { - var oldRegion = oldSorted[i]; - var newRegion = newSorted[i]; - - if (oldRegion.BaseAddress != newRegion.BaseAddress || - oldRegion.RegionSize != newRegion.RegionSize || - oldRegion.State != newRegion.State || - oldRegion.Protect != newRegion.Protect || - oldRegion.Type != newRegion.Type) - { - return false; - } - } - return true; - } - - public Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("ProcessMonitor is stopping."); @@ -440,21 +76,239 @@ namespace WebmrAPI.Services return Task.CompletedTask; } + private bool OpenProcess(ProcessInfo process, out IntPtr ctx) + { + ctx = WinApi.OpenProcess(WinApi.PROCESS_QUERY_INFORMATION | WinApi.PROCESS_VM_OPERATION, false, (uint)process.PID); + if (ctx == IntPtr.Zero) + { + var errno = WinApi.LastError; + + if (errno == 5) + { + _logger.LogWarning($"Access denied to process {process.Name} (PID: {process.PID})"); + } + else + { + _logger.LogError($"WinAPI call 'OpenProcess for PID {process.PID}' failed", errno); + } + return false; + } + + return true; + } + + private ProcessInfo PopulateProcessInfo(ProcessInfo dst, List regions) + { + dst.MemoryRegions = regions; + dst.LastUpdate = DateTime.UtcNow; + return dst; + } + + private ProcessInfo PopulateProcessInfo(ProcessInfo dst) + { + if ((DateTime.UtcNow - dst.LastUpdate).TotalSeconds < _config.MemoryRegionScanTimeout) return dst; + + IntPtr cur = IntPtr.Zero; + IntPtr ctx = IntPtr.Zero; + long next = 0; + var regions = new List(); + + try + { + if (OpenProcess(dst, out ctx)) + while ((next > cur.ToInt64() && next < 0x7fffffffffff) || next == 0) + { + WinApi.MEMORY_BASIC_INFORMATION mbi; + + cur = new IntPtr(next); + + if (WinApi.VirtualQueryEx(ctx, cur, out mbi, WinApi.MBISize) == 0) + { + _logger.LogWarning($"VirtualQueryEx for PID {dst.PID} at address 0x{cur.ToInt64():X12} failed"); + break; + } + + if (mbi.State == WinApi.MEM_COMMIT || mbi.State == WinApi.MEM_RESERVE) + { + regions.Add(new MemoryRegionInfo + { + MemoryAddress = mbi.BaseAddress.ToInt64(), + MemorySize = mbi.RegionSize.ToUInt64(), + State = WinApi.GetStateString(mbi.State), + Protect = WinApi.GetProtectString(mbi.Protect), + Type = WinApi.GetTypeString(mbi.Type) + }); + } + + next = mbi.BaseAddress.ToInt64() + (long)mbi.RegionSize.ToUInt64(); + } + } + finally + { + if (ctx != IntPtr.Zero) WinApi.CloseHandle(ctx); + } + + return PopulateProcessInfo(dst, regions); + } + private void PopulateBaseProcessInfo(ProcessInfo dst, System.Diagnostics.Process process, Dictionary wmiData) + { + dst.PID = process.Id; + dst.MemorySize = (ulong)process.VirtualMemorySize64; + dst.ThreadCount = process.Threads.Count; + dst.TotalProcessorTime = process.TotalProcessorTime; + dst.MemoryAddress = 0x000000000000; + + if (wmiData.TryGetValue(process.Id, out var entry)) + { + dst.Name = entry.Name; + dst.CommandLine = entry.CommandLine; + dst.ParentPID = entry.ParentPID; + } + else + { + dst.Name = process.ProcessName; + _logger.LogDebug("No WMI data was found for PID {pid}.", process.Id); + } + + try + { + if (process.MainModule != null) + { + dst.FileName = process.MainModule.FileName; + dst.MemoryAddress = process.MainModule.BaseAddress.ToInt64(); + } + else + { + _logger.LogDebug($"Process {process.ProcessName} (PID: {process.Id}) process has no MainModule"); + } + } + catch (System.ComponentModel.Win32Exception ex) + { + _logger.LogDebug($"Process {process.ProcessName} (PID: {process.Id}), getting process address ends with message: {ex.Message}"); + } + catch (Exception ex) + { + _logger.LogError($"Process {process.ProcessName} (PID: {process.Id}), an error occurred while getting process address: {ex.Message}"); + } + + try + { + dst.StartTime = process.StartTime; + } + catch (Exception ex) + { + _logger.LogDebug("Could not get StartTime for PID {Pid}: {Message}", process.Id, ex.Message); + } + + try + { + process.Refresh(); + dst.Status = process.Responding ? ProcessStatus.Running : ProcessStatus.NotResponding; + } + catch (Exception ex) + { + dst.Status = ProcessStatus.Undefined; + _logger.LogDebug("Could not get Status for PID {Pid}: {Message}", process.Id, ex.Message); + } + } + + private void DoWork(object? state) + { + _logger.LogDebug("Initiating process scan..."); + try + { + var cur = new Dictionary(); + var wmiData = GetWmiProcessData(); + ScanTime = DateTime.UtcNow; + + foreach (var process in System.Diagnostics.Process.GetProcesses()) + { + ProcessInfo? processInfo; + + if (process.Id == 0 || process.Id == 4) + { + process.Dispose(); + continue; + } + + if (_processesBuffer.TryGetValue(process.Id, out var existingProcessInfo)) + { + processInfo = existingProcessInfo; + } + else + { + processInfo = new ProcessInfo(); + } + + try + { + PopulateBaseProcessInfo(processInfo, process, wmiData); + + if (_lastScanTime != DateTime.MinValue && Elapsed.TotalMilliseconds > 0) + { + processInfo.CpuUsage = (processInfo.ProcessorTime / Elapsed.TotalMilliseconds) / Environment.ProcessorCount * 100.0; + if (processInfo.CpuUsage > 100.0) processInfo.CpuUsage = 100.0; + } + else + { + processInfo.CpuUsage = 0.0; + } + + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get full information for process {ProcessId} ({ProcessName}).", + process.Id, process.ProcessName); + } + finally + { + cur.TryAdd(process.Id, processInfo); + process?.Dispose(); + } + + } + + lock (_lock) + { + _processesBuffer = new ConcurrentDictionary(cur); + } + _logger.LogInformation("Process buffer updated, contains {Count} processes.", _processesBuffer.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled error during process monitoring cycle."); + } + } + + private Dictionary GetWmiProcessData() + { + var data = new Dictionary(); + try + { + using (var searcher = new ManagementObjectSearcher("SELECT ProcessId, Name, CommandLine, ParentProcessId FROM Win32_Process")) + using (ManagementObjectCollection processes = searcher.Get()) + { + foreach (ManagementObject obj in processes) + { + int pid = Convert.ToInt32(obj["ProcessId"]); + int ppid = Convert.ToInt32(obj["ParentProcessId"]); + string? name = obj["Name"]?.ToString(); + string? cmd = obj["CommandLine"]?.ToString(); + + data[pid] = (name, cmd, ppid); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "The data of the WMI process could not be retrieved."); + } + return data; + } + public void Dispose() { _timer?.Dispose(); } - - public List? GetBufferedMemoryRegions(int pid) - { - lock (_lock) // Ensure thread-safe access - { - if (_memoryRegionsBuffer.TryGetValue(pid, out var regions)) - { - return new List(regions); // Return a copy - } - return null; - } - } } -} \ No newline at end of file +} diff --git a/Services/WinApi.cs b/Services/WinApi.cs index 1318e68..d82b9aa 100644 --- a/Services/WinApi.cs +++ b/Services/WinApi.cs @@ -1,28 +1,60 @@ +// File: Services/WinApi.cs + using System.Runtime.InteropServices; -using WebmrAPI.Exceptions; namespace WebmrAPI.Services { public static class WinApi { - // --- --- + public const uint PROCESS_QUERY_INFORMATION = 0x00000400; + public const uint PROCESS_VM_READ = 0x00000010; + public const uint PROCESS_VM_OPERATION = 0x00000008; - // OpenProcess - public const uint PROCESS_QUERY_INFORMATION = 0x0400; // Required to retrieve certain information about a process, such as its token, exit code, and priority class. - public const uint PROCESS_VM_READ = 0x0010; // Required to read memory in a process. - public const uint PROCESS_VM_OPERATION = 0x0008; // Required to perform an operation on the address space of a process (e.g., VirtualQueryEx). + public const uint MEM_COMMIT = 0x00001000; + public const uint MEM_FREE = 0x00010000; + public const uint MEM_RESERVE = 0x00002000; - // - public const uint MEM_COMMIT = 0x1000; // Allocated and committed - public const uint MEM_FREE = 0x10000; // Free (not reserved or committed) - public const uint MEM_RESERVE = 0x2000; // Reserved + public const uint MEM_IMAGE = 0x01000000; + public const uint MEM_MAPPED = 0x00040000; + public const uint MEM_PRIVATE = 0x00020000; - // - public const uint MEM_IMAGE = 0x1000000; // Mapped into the view of an image section - public const uint MEM_MAPPED = 0x40000; // Mapped into the view of a data section - public const uint MEM_PRIVATE = 0x20000; // Private (non-shared) + public static int LastError { get => Marshal.GetLastWin32Error(); } + public static uint MBISize { get => (uint)Marshal.SizeOf(typeof(MEMORY_BASIC_INFORMATION)); } - // --- --- + private static string GetHexValue(uint data) + { + return $"0x{data:X8}"; + } + + public static string GetStateString(uint state) + { + switch (state) + { + case MEM_COMMIT: return "MEM_COMMIT"; + case MEM_FREE: return "MEM_FREE"; + case MEM_RESERVE: return "MEM_RESERVE"; + default: return GetHexValue(state); + } + } + + public static string GetProtectString(uint protect) + { + switch (protect) + { + default: return GetHexValue(protect); + } + } + + public static string GetTypeString(uint type) + { + switch (type) + { + case MEM_IMAGE: return "MEM_IMAGE"; + case MEM_MAPPED: return "MEM_MAPPED"; + case MEM_PRIVATE: return "MEM_PRIVATE"; + default: return GetHexValue(type); + } + } // https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-memory_basic_information [StructLayout(LayoutKind.Sequential)] @@ -31,14 +63,13 @@ namespace WebmrAPI.Services public IntPtr BaseAddress; public IntPtr AllocationBase; public uint AllocationProtect; - public ushort PartitionId; // Added in Windows 10, version 1709 - public UIntPtr RegionSize; // Use UIntPtr for size, as it's pointer-sized + public ushort PartitionId; + public UIntPtr RegionSize; public uint State; public uint Protect; public uint Type; } - // --- P/Invoke --- // https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-openprocess [DllImport("kernel32.dll", SetLastError = true)] @@ -61,58 +92,5 @@ namespace WebmrAPI.Services out MEMORY_BASIC_INFORMATION lpBuffer, uint dwLength ); - - // --- --- - - /// - /// . - /// - public static string GetMemoryProtectString(uint protect) - { - // . - // , . - // PAGE_READONLY, PAGE_READWRITE .. - return $"0x{protect:X}"; - } - - /// - /// . - /// - public static string GetMemoryStateString(uint state) - { - switch (state) - { - case MEM_COMMIT: return "MEM_COMMIT"; - case MEM_FREE: return "MEM_FREE"; - case MEM_RESERVE: return "MEM_RESERVE"; - default: return $"Unknown (0x{state:X})"; - } - } - - /// - /// . - /// - public static string GetMemoryTypeString(uint type) - { - switch (type) - { - case MEM_IMAGE: return "MEM_IMAGE"; - case MEM_MAPPED: return "MEM_MAPPED"; - case MEM_PRIVATE: return "MEM_PRIVATE"; - default: return $"Unknown (0x{type:X})"; - } - } - - /// - /// WinAPI , . - /// - public static void ThrowWinApiErrorIfAny(string methodName) - { - int errorCode = Marshal.GetLastWin32Error(); - if (errorCode != 0) - { - throw new ExternalException($"WinAPI call {methodName} failed with error code {errorCode}."); - } - } } -} \ No newline at end of file +} diff --git a/appsettings.json b/appsettings.json index 4c5ca72..682c8ce 100644 --- a/appsettings.json +++ b/appsettings.json @@ -1,7 +1,7 @@ { - "Agent": { + "Monitoring": { "ScanIntervalSeconds": 5, - "TargetProcessName": "" + "MemoryRegionScanTimeout": 30 }, "WebServer": { "Url": "http://0.0.0.0:8080" @@ -13,4 +13,4 @@ "ProcessMemoryAgent.Services.ProcessMonitor": "Debug" } } -} \ No newline at end of file +} diff --git a/webmr-api.csproj b/webmr-api.csproj index d59a65f..53df987 100644 --- a/webmr-api.csproj +++ b/webmr-api.csproj @@ -1,7 +1,8 @@ - net8.0 + windows + enable enable true @@ -13,16 +14,17 @@ 1.0.0.0 1.0.0.0 - 0.1.0 + 0.1.1 OpenSource Process Monitoring Agent A service for detailed monitoring processes and memory regions. Copyright © 2024-2025 Gregory Lirent en-US - + +