From ae20954a91eca297f69da287ce95835f2cb8432c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Fri, 2 Oct 2026 12:49:07 +0200 Subject: [PATCH] Windows servers and synthetic checks core: data, disk forecast, event dedup, grant script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Core/Servers: what the agent collects from a Windows server (CPU and memory from raw counters, disks, services, events, scheduled tasks), roles and sources, host validation and the computer behind a SQL Server data source. - DiskForecaster: "fuld om ca. N dage" from a least-squares line over 14 days of SQLInsight's own samples, only since the volume last changed size, with sanity bounds (history, samples, R², max one year). - ServerEventAnalysis: noise filter, grouping and a 24-hour window for System and Application events, like the SQL Server error log in 1.2. - db/permissions/grant-windows-server.ps1: least-privilege rights for the agent's account (groups, WMI namespaces, SCM read), never local administrator; filled in by ServerGrantScript. - Core/SyntheticChecks: kinds, settings, validation (read-only SQL), results. Co-Authored-By: Claude Opus 5.5 (1M context) --- db/permissions/grant-windows-server.ps1 | 148 ++++++++ src/SQLInsight.Core/SQLInsight.Core.csproj | 2 + src/SQLInsight.Core/Servers/DiskForecast.cs | 135 +++++++ .../Servers/ServerConfiguration.cs | 162 +++++++++ src/SQLInsight.Core/Servers/ServerEvents.cs | 233 ++++++++++++ .../Servers/ServerGrantScript.cs | 51 +++ .../Servers/ServerPerformance.cs | 76 ++++ src/SQLInsight.Core/Servers/ServerRequests.cs | 51 +++ src/SQLInsight.Core/Servers/ServerTasks.cs | 51 +++ .../Servers/WindowsServerData.cs | 188 ++++++++++ .../SyntheticCheckDefinitions.cs | 334 ++++++++++++++++++ .../SyntheticCheckTests.cs | 72 ++++ .../WindowsServerTests.cs | 331 +++++++++++++++++ 13 files changed, 1834 insertions(+) create mode 100644 db/permissions/grant-windows-server.ps1 create mode 100644 src/SQLInsight.Core/Servers/DiskForecast.cs create mode 100644 src/SQLInsight.Core/Servers/ServerConfiguration.cs create mode 100644 src/SQLInsight.Core/Servers/ServerEvents.cs create mode 100644 src/SQLInsight.Core/Servers/ServerGrantScript.cs create mode 100644 src/SQLInsight.Core/Servers/ServerPerformance.cs create mode 100644 src/SQLInsight.Core/Servers/ServerRequests.cs create mode 100644 src/SQLInsight.Core/Servers/ServerTasks.cs create mode 100644 src/SQLInsight.Core/Servers/WindowsServerData.cs create mode 100644 src/SQLInsight.Core/SyntheticChecks/SyntheticCheckDefinitions.cs create mode 100644 tests/SQLInsight.Core.Tests/SyntheticCheckTests.cs create mode 100644 tests/SQLInsight.Core.Tests/WindowsServerTests.cs diff --git a/db/permissions/grant-windows-server.ps1 b/db/permissions/grant-windows-server.ps1 new file mode 100644 index 0000000..7ad104a --- /dev/null +++ b/db/permissions/grant-windows-server.ps1 @@ -0,0 +1,148 @@ +<# + SQLInsight Pro: least-privilege rettigheder til agentens konto på en Windows-server + ===================================================================================== + + Giver SQLInsight-agentens konto præcis den læseadgang, den skal bruge for at overvåge denne server: + CPU, hukommelse, diske, tjenester, hændelseslog og planlagte opgaver. Aldrig lokal administrator. + Scriptet kan køres igen uden skade. Det ændrer kun adgangsrettigheder: det starter, stopper eller + ændrer ingen tjenester, opgaver eller indstillinger, og det læser ingen data. + + Kør som administrator på den server, der skal overvåges (Windows PowerShell 2.0 eller nyere): + .\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' + .\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -EnableFirewall -AgentAddress 10.0.0.15 + Fjern rettighederne igen: + .\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -Remove + + Agentens konto: kører agenten som NT SERVICE\SQLInsight Agent, LocalSystem eller NETWORK SERVICE, kommer den + til andre servere som computerkontoen DOMÆNE\COMPUTERNAVN$. En gMSA eller domænekonto er den samme overalt. + + Hvad der gives, og hvorfor: + Performance Monitor Users ydelsestællere: CPU, hukommelse, paging + Event Log Readers System- og Application-loggen (aldrig Security-loggen) + Distributed COM Users WMI/CIM over DCOM (RPC) + Remote Management Users WMI/CIM over WinRM (WS-Man), Windows Server 2012 og nyere + WMI: Enable Account + Remote Enable på root\cimv2 og root\Microsoft\Windows\TaskScheduler (ikke arvet) + Service Control Manager: læse og liste tjenester (CC LC RP RC). Den oprindelige SDDL gemmes første gang i + %ProgramData%\SQLInsight\scmanager-sddl-backup.txt + + Hvad der IKKE gives: lokal administrator, ret til at starte/stoppe tjenester, skrive i WMI, læse filer + eller Security-loggen. + + Firewall (kun med -EnableFirewall, Windows Server 2012 og nyere): slår Windows' indbyggede regler til for + WMI (DCOM), WinRM (TCP 5985) og Remote Event Log Management, eventuelt kun fra agentens adresse. +#> +param( + [string] $Account = 'DOMÆNE\AGENTSERVER$', # <== agentens konto + [switch] $Remove, + [switch] $EnableFirewall, + [string] $AgentAddress +) + +$ErrorActionPreference = 'Stop' +$sid = (New-Object System.Security.Principal.NTAccount($Account)).Translate([System.Security.Principal.SecurityIdentifier]).Value +if ($Remove) { $verb = 'Fjerner' } else { $verb = 'Giver' } +Write-Host "$verb rettigheder til $Account ($sid) på $env:COMPUTERNAME" + +# 1. Lokale grupper. Fundet via deres SID, så det virker på dansk, engelsk og andre sprog. +$groups = @( + @('S-1-5-32-558', 'ydelsestællere (CPU, hukommelse, paging)'), + @('S-1-5-32-573', 'System- og Application-loggen'), + @('S-1-5-32-562', 'WMI over DCOM'), + @('S-1-5-32-580', 'WMI over WinRM (WS-Man)') +) +foreach ($g in $groups) { + try { + $groupName = (New-Object System.Security.Principal.SecurityIdentifier($g[0])).Translate([System.Security.Principal.NTAccount]).Value.Split('\')[1] + } catch { + Write-Host " - gruppen $($g[0]) ($($g[1])) findes ikke på denne Windows-version; springes over" + continue + } + $group = [ADSI]("WinNT://$env:COMPUTERNAME/$groupName,group") + $members = @($group.Invoke('Members') | ForEach-Object { + $bytes = $_.GetType().InvokeMember('objectSid', 'GetProperty', $null, $_, $null) + (New-Object System.Security.Principal.SecurityIdentifier($bytes, 0)).Value + }) + $isMember = $members -contains $sid + $path = 'WinNT://' + $Account.Replace('\', '/') + if ($Remove -and $isMember) { + $group.Remove($path) + Write-Host " - fjernet fra $groupName" + } elseif (-not $Remove -and -not $isMember) { + $group.Add($path) + Write-Host " + medlem af $groupName ($($g[1]))" + } else { + Write-Host " = ${groupName}: uændret" + } +} + +# 2. WMI-navnerum: Enable Account (0x1) + Remote Enable (0x20), kun på selve navnerummet. +function Set-NamespaceAccess([string] $Namespace) { + $security = Get-WmiObject -Namespace $Namespace -Class __SystemSecurity + $descriptor = $security.PsBase.InvokeMethod('GetSecurityDescriptor', $null).Descriptor + $dacl = @($descriptor.DACL | Where-Object { $_.Trustee.SidString -ne $sid }) + if (-not $Remove) { + $trustee = ([WMIClass]'Win32_Trustee').CreateInstance() + $trustee.SidString = $sid + $ace = ([WMIClass]'Win32_ACE').CreateInstance() + $ace.AccessMask = 0x21 + $ace.AceFlags = 0 + $ace.AceType = 0 + $ace.Trustee = $trustee + $dacl += $ace.PsObject.ImmediateBaseObject + } + $descriptor.DACL = $dacl + $result = $security.PsBase.InvokeMethod('SetSecurityDescriptor', @($descriptor)) + if ($result.ReturnValue -ne 0) { throw "SetSecurityDescriptor på $Namespace fejlede ($($result.ReturnValue))" } + if ($Remove) { Write-Host " - WMI $Namespace" } else { Write-Host " + WMI $Namespace (Enable Account, Remote Enable)" } +} +Set-NamespaceAccess 'root\cimv2' +if (Get-WmiObject -Namespace 'root\Microsoft\Windows' -Class __Namespace -Filter "Name='TaskScheduler'" -ErrorAction SilentlyContinue) { + Set-NamespaceAccess 'root\Microsoft\Windows\TaskScheduler' +} else { + Write-Host ' - root\Microsoft\Windows\TaskScheduler findes ikke (før Windows Server 2012): planlagte opgaver kan ikke måles' +} + +# 3. Service Control Manager: læse og liste tjenester. Over netværket må ellers kun administratorer liste tjenester. +$scmAce = "(A;;CCLCRPRC;;;$sid)" +$sddl = ((& sc.exe sdshow scmanager) | Where-Object { $_ -match '\S' }) -join '' +if ($sddl -notmatch '^D:') { throw "Kunne ikke læse Service Control Managers sikkerhed: $sddl" } +$backup = Join-Path $env:ProgramData 'SQLInsight\scmanager-sddl-backup.txt' +if (-not (Test-Path $backup)) { + New-Item -ItemType Directory -Force -Path (Split-Path $backup) | Out-Null + Set-Content -Path $backup -Value $sddl +} +$clean = $sddl.Replace($scmAce, '') +if ($Remove) { + $new = $clean +} else { + $at = $clean.IndexOf('S:') + if ($at -lt 0) { $new = $clean + $scmAce } else { $new = $clean.Substring(0, $at) + $scmAce + $clean.Substring($at) } +} +if ($new -ne $sddl) { + & sc.exe sdset scmanager $new | Out-Null + if ($LASTEXITCODE -ne 0) { throw "sc.exe sdset scmanager fejlede ($LASTEXITCODE). Den oprindelige SDDL står i $backup" } + if ($Remove) { Write-Host ' - Service Control Manager' } else { Write-Host ' + Service Control Manager (CC LC RP RC: forbinde, liste, læse)' } +} else { + Write-Host ' = Service Control Manager: uændret' +} + +# 4. Firewall (valgfrit): Windows' indbyggede regler, fundet på deres faste (ikke oversatte) navne. +if ($EnableFirewall -and -not $Remove) { + if (-not (Get-Command Enable-NetFirewallRule -ErrorAction SilentlyContinue)) { + Write-Host ' - Enable-NetFirewallRule findes ikke (før Windows Server 2012): slå reglerne for WMI og Remote Event Log Management til manuelt' + } else { + foreach ($rule in 'WMI-WINMGMT-In-TCP', 'WMI-RPCSS-In-TCP', 'WMI-ASYNC-In-TCP', 'WINRM-HTTP-In-TCP', + 'RemoteEventLogSvc-In-TCP', 'RemoteEventLogSvc-NP-In-TCP', 'RemoteEventLogSvc-RPCSS-In-TCP') { + $found = Get-NetFirewallRule -Name $rule -ErrorAction SilentlyContinue + if (-not $found) { + Write-Host " - firewallreglen $rule findes ikke; springes over" + continue + } + if ($AgentAddress) { $found | Set-NetFirewallRule -RemoteAddress $AgentAddress } + $found | Enable-NetFirewallRule + if ($AgentAddress) { Write-Host " + firewall: $rule (kun fra $AgentAddress)" } else { Write-Host " + firewall: $rule" } + } + } +} + +Write-Host 'Færdig. Test forbindelsen fra SQLInsight (Servere > serveren > Test forbindelsen).' diff --git a/src/SQLInsight.Core/SQLInsight.Core.csproj b/src/SQLInsight.Core/SQLInsight.Core.csproj index 6133836..bebfc95 100644 --- a/src/SQLInsight.Core/SQLInsight.Core.csproj +++ b/src/SQLInsight.Core/SQLInsight.Core.csproj @@ -7,5 +7,7 @@ + + diff --git a/src/SQLInsight.Core/Servers/DiskForecast.cs b/src/SQLInsight.Core/Servers/DiskForecast.cs new file mode 100644 index 0000000..f6fdc27 --- /dev/null +++ b/src/SQLInsight.Core/Servers/DiskForecast.cs @@ -0,0 +1,135 @@ +using System.Globalization; + +namespace SQLInsight.Core.Servers; + +/// A volume's free space at one time, from SQLInsight's own samples. +public sealed record DiskHistoryPoint(DateTimeOffset At, double SizeGb, double FreeGb); + +/// When a volume will be full if it keeps filling at the rate of the last weeks. +/// Null when the volume is not filling up (or the trend is too unclear to say). +/// How fast the used space grows (positive: filling up). +/// R² of the trend line: 1 is a straight line, near 0 no trend. +/// Why there is no forecast (Danish), when there is none. +public sealed record DiskForecast(string Volume, double? DaysUntilFull, double GrowthGbPerDay, int Samples, double HistoryDays, double Fit, string? Note = null) +{ + public DateTimeOffset? FullAt(DateTimeOffset now) => DaysUntilFull is { } days ? now.AddDays(days) : null; + + /// "fuld om ca. 12 dage" (Danish), or the note. + public string Describe() => DaysUntilFull switch + { + null => Note ?? "fyldes ikke op", + < 1 => "fuld inden for et døgn", + { } d => string.Create(CultureInfo.InvariantCulture, $"fuld om ca. {Math.Round(d):0} dage"), + }; +} + +public sealed record DiskForecastSettings +{ + /// How far back the trend is computed. + public int HistoryDays { get; init; } = 14; + + /// Less history than this gives no forecast: a few hours say nothing about weeks. + public double MinimumHistoryDays { get; init; } = 2; + + public int MinimumSamples { get; init; } = 12; + + /// R² below this: the free space jumps up and down (backups written and deleted …) and a straight line says nothing. + public double MinimumFit { get; init; } = 0.5; + + /// A forecast further away than this is not one. + public double MaximumDays { get; init; } = 365; + + /// Growth slower than this (GB per day) is noise. + public double MinimumGrowthGbPerDay { get; init; } = 0.01; +} + +/// +/// "Disken er fuld om ca. N dage": a least-squares line through the free space of the last two weeks. Only the history +/// since the volume last changed size counts (an extended disk starts a new trend). The line must fit reasonably well +/// (R²), cover at least a couple of days, and point down; otherwise there is no forecast, rather than a scary guess. +/// +public static class DiskForecaster +{ + public static DiskForecast Forecast(string volume, IReadOnlyList history, DateTimeOffset now, DiskForecastSettings? settings = null) + { + ArgumentNullException.ThrowIfNull(volume); + ArgumentNullException.ThrowIfNull(history); + settings ??= new DiskForecastSettings(); + var points = history.Where(p => p.At <= now && p.At >= now.AddDays(-settings.HistoryDays) && p.SizeGb > 0) + .OrderBy(p => p.At).ToList(); + if (points.Count == 0) + { + return new DiskForecast(volume, null, 0, 0, 0, 0, "ingen målinger endnu"); + } + + // A volume that was extended (or shrunk) starts a new trend. + var latestSize = points[^1].SizeGb; + var start = points.FindLastIndex(p => Math.Abs(p.SizeGb - latestSize) > Math.Max(0.5, latestSize * 0.01)) + 1; + points = points.Skip(start).ToList(); + var span = (points[^1].At - points[0].At).TotalDays; + if (points.Count < settings.MinimumSamples || span < settings.MinimumHistoryDays) + { + return new DiskForecast(volume, null, 0, points.Count, span, 0, + string.Create(CultureInfo.InvariantCulture, $"for lidt historik til en prognose ({span:0.#} af {settings.MinimumHistoryDays:0.#} dage)")); + } + + var (slope, fit) = Regression(points); + var growth = -slope; + var free = points[^1].FreeGb; + if (free <= 0) + { + return new DiskForecast(volume, 0, growth, points.Count, span, fit); + } + + if (growth < settings.MinimumGrowthGbPerDay) + { + return new DiskForecast(volume, null, growth, points.Count, span, fit, "fyldes ikke op"); + } + + if (fit < settings.MinimumFit) + { + return new DiskForecast(volume, null, growth, points.Count, span, fit, "ingen tydelig tendens (pladsen svinger)"); + } + + var days = free / growth; + return days > settings.MaximumDays + ? new DiskForecast(volume, null, growth, points.Count, span, fit, "fuld om mere end et år") + : new DiskForecast(volume, days, growth, points.Count, span, fit); + } + + /// Slope (GB per day) and R² of free space over time. + public static (double Slope, double Fit) Regression(IReadOnlyList points) + { + ArgumentNullException.ThrowIfNull(points); + if (points.Count < 2) + { + return (0, 0); + } + + var origin = points[0].At; + var xs = points.Select(p => (p.At - origin).TotalDays).ToArray(); + var ys = points.Select(p => p.FreeGb).ToArray(); + var meanX = xs.Average(); + var meanY = ys.Average(); + double sxy = 0, sxx = 0, syy = 0; + for (var i = 0; i < xs.Length; i++) + { + var dx = xs[i] - meanX; + var dy = ys[i] - meanY; + sxy += dx * dy; + sxx += dx * dx; + syy += dy * dy; + } + + if (sxx <= 0) + { + return (0, 0); + } + + var slope = sxy / sxx; + + // A perfectly flat line explains everything there is to explain. + var fit = syy <= 1e-12 ? 1 : (sxy * sxy) / (sxx * syy); + return (slope, fit); + } +} diff --git a/src/SQLInsight.Core/Servers/ServerConfiguration.cs b/src/SQLInsight.Core/Servers/ServerConfiguration.cs new file mode 100644 index 0000000..4f3ecf8 --- /dev/null +++ b/src/SQLInsight.Core/Servers/ServerConfiguration.cs @@ -0,0 +1,162 @@ +using System.Globalization; +using System.Net; +using System.Text.RegularExpressions; + +namespace SQLInsight.Core.Servers; + +/// What a Windows server is used for: shown in the UI and used to pick sensible defaults (e.g. the AOS service). +public static class ServerRoles +{ + public const string Sql = "Sql"; + public const string Application = "Application"; + public const string File = "File"; + public const string Other = "Other"; + + public static readonly IReadOnlyList All = [Sql, Application, File, Other]; + + public static bool IsKnown(string? role) => role is not null && All.Contains(role, StringComparer.Ordinal); + + public static string Display(string? role) => role switch + { + Sql => "SQL Server", + Application => "Applikationsserver (AOS)", + File => "Filserver", + _ => "Anden server", + }; +} + +/// Where a Windows server in insight.WindowsServers comes from. +public static class ServerSources +{ + /// Added in SQLInsight Web or API v1. + public const string Web = "Web"; + + /// The computer behind a monitored SQL Server instance: added and removed by the agent. + public const string SqlHost = "SqlHost"; +} + +/// How a Windows server is addressed: a computer name, a DNS name or an IP address. No instance, no port. +public static partial class ServerHost +{ + public const int MaxLength = 255; + + /// Why cannot be used (Danish), or null when it can. + public static string? Validate(string? host) + { + if (string.IsNullOrWhiteSpace(host)) + { + return "Angiv serverens navn, fx aos01 eller fil01.firma.local."; + } + + var trimmed = host.Trim(); + if (trimmed.Length > MaxLength) + { + return $"Servernavnet må højst være {MaxLength} tegn."; + } + + if (IPAddress.TryParse(trimmed, out _) && trimmed.Contains(':', StringComparison.Ordinal)) + { + return null; + } + + return HostPattern().IsMatch(trimmed) + ? null + : "Servernavnet må kun indeholde bogstaver, tal, punktum og bindestreg (fx aos01.firma.local) – uden instansnavn og port."; + } + + /// The form hosts are compared and stored in: trimmed, lower case. + public static string Normalize(string host) + { + ArgumentNullException.ThrowIfNull(host); + return host.Trim().TrimEnd('.').ToLowerInvariant(); + } + + /// The short computer name of a host (aos01.firma.local → AOS01); an IP address stays as it is. + public static string ShortName(string host) + { + ArgumentNullException.ThrowIfNull(host); + var trimmed = host.Trim(); + return IPAddress.TryParse(trimmed, out _) ? trimmed : trimmed.Split('.')[0].ToUpperInvariant(); + } + + /// + /// The computer behind a SQL Server data source (tcp:sql01.firma.local\PROD,1433 → sql01.firma.local), + /// or why it is not a Windows server the agent can look at (Danish). Aliases for the local computer become + /// . + /// + public static (string? Host, string? Skipped) FromDataSource(string dataSource, string localMachineName) + { + ArgumentNullException.ThrowIfNull(dataSource); + ArgumentException.ThrowIfNullOrWhiteSpace(localMachineName); + var host = ProtocolPrefix().Replace(dataSource.Trim(), string.Empty).Split('\\', ',')[0].Trim(); + if (host.Length == 0 || host is "." or "(local)" || host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || host is "127.0.0.1" or "::1") + { + return (Normalize(localMachineName), null); + } + + if (host.StartsWith("(localdb)", StringComparison.OrdinalIgnoreCase)) + { + return (null, "LocalDB er ikke en server."); + } + + if (IsAzure(host)) + { + return (null, "Azure SQL Database og Managed Instance har ingen Windows-server, SQLInsight kan se."); + } + + return Validate(host) is null ? (Normalize(host), null) : (null, "Servernavnet kan ikke bruges som Windows-server."); + } + + public static bool IsAzure(string host) + { + ArgumentNullException.ThrowIfNull(host); + var h = host.Trim().TrimEnd('.'); + return h.EndsWith(".database.windows.net", StringComparison.OrdinalIgnoreCase) + || h.EndsWith(".database.azure.com", StringComparison.OrdinalIgnoreCase) + || h.EndsWith(".database.usgovcloudapi.net", StringComparison.OrdinalIgnoreCase) + || h.EndsWith(".sql.azuresynapse.net", StringComparison.OrdinalIgnoreCase); + } + + /// Whether is the agent's own computer (then collection needs no network). + public static bool IsLocal(string host, string machineName) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(machineName); + var h = host.Trim(); + return h is "." or "127.0.0.1" or "::1" + || h.Equals("localhost", StringComparison.OrdinalIgnoreCase) + || h.Equals(machineName, StringComparison.OrdinalIgnoreCase) + || h.StartsWith(machineName + ".", StringComparison.OrdinalIgnoreCase); + } + + /// A comma- or line-separated list of service or task names, trimmed and without duplicates. + public static IReadOnlyList ParseList(string? text) => + string.IsNullOrWhiteSpace(text) + ? [] + : text.Split([',', ';', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + /// Why a list of service names cannot be used (Danish), or null. + public static string? ValidateServiceNames(IEnumerable names) + { + ArgumentNullException.ThrowIfNull(names); + var list = names.ToList(); + if (list.Count > 100) + { + return "Højst 100 tjenester i en liste."; + } + + return list.FirstOrDefault(n => n.Length > 256 || !ServiceNamePattern().IsMatch(n)) is { } bad + ? string.Create(CultureInfo.InvariantCulture, $"\"{bad}\" er ikke et gyldigt tjenestenavn (brug tjenestens navn, fx MSSQLSERVER eller AOS60$01).") + : null; + } + + [GeneratedRegex(@"^[A-Za-z0-9]([A-Za-z0-9\-]{0,62})(\.[A-Za-z0-9]([A-Za-z0-9\-]{0,62}))*\.?$")] + private static partial Regex HostPattern(); + + [GeneratedRegex(@"^(tcp|np|lpc|admin):", RegexOptions.IgnoreCase)] + private static partial Regex ProtocolPrefix(); + + [GeneratedRegex(@"^[^\\/""\x00-\x1f]+$")] + private static partial Regex ServiceNamePattern(); +} diff --git a/src/SQLInsight.Core/Servers/ServerEvents.cs b/src/SQLInsight.Core/Servers/ServerEvents.cs new file mode 100644 index 0000000..26aabab --- /dev/null +++ b/src/SQLInsight.Core/Servers/ServerEvents.cs @@ -0,0 +1,233 @@ +namespace SQLInsight.Core.Servers; + +/// What kind of Windows event this is, for the rules. Ordered by how much it matters (most last). +public enum ServerEventCategory +{ + /// An error with nothing special about it: only reported when it repeats. + Error, + + /// An application or the AOS crashed or hung (Application Error 1000, .NET Runtime 1026 …). + ApplicationCrash, + + /// Errors from Dynamics AX's Application Object Server. + DynamicsAx, + + /// A service stopped unexpectedly or could not start (Service Control Manager 7031, 7034, 7000 …). + ServiceFailure, + + /// Level Critical with no more specific category. + Critical, + + /// Out of memory or another resource (srv 2019/2020, Resource-Exhaustion-Detector 2004). + Resources, + + /// Disk, controller or file system errors (disk 7/11/51/153, storport 129, Ntfs 55/98). + Disk, + + /// The server went down without being shut down (Kernel-Power 41, EventLog 6008, a blue screen). + UnexpectedShutdown, +} + +/// An event from the System or Application log. Level: 1 critical, 2 error. +/// Can name users, computers and paths: sensitive. +public sealed record ServerEventEntry(string Log, string Source, long EventId, int Level, DateTimeOffset At, string? Message = null, long? RecordId = null); + +/// The same event repeated, summed: one line per log, source and event id. +public sealed record ServerEventGroup(string Log, string Source, long EventId, int Level, ServerEventCategory Category, int Count, DateTimeOffset FirstAt, + DateTimeOffset LastAt, string? Sample) +{ + public string Key => Log + "/" + Source + "/" + EventId.ToString(System.Globalization.CultureInfo.InvariantCulture); +} + +public sealed record ServerEventSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + + /// The newest event read; the next collection continues after it. + public required DateTimeOffset ReadUntil { get; init; } + + /// How the log was read: "Event Log (RPC)" or "CIM (Win32_NTLogEvent)". + public string? Method { get; init; } + + /// What was found since the previous collection, grouped (noise left out). + public IReadOnlyList Groups { get; init; } = []; + + /// The groups of the last : they keep findings open. + public IReadOnlyList Recent { get; init; } = []; + + /// Events left out as known noise in this collection. + public int Ignored { get; init; } + + /// Why less is seen than the logs have (Danish), e.g. the collection was capped. + public string? Coverage { get; init; } +} + +/// +/// Windows' event logs, summed like SQL Server's error log (1.2): known noise is left out, the rest is grouped per +/// source and event id, and a group keeps its finding open for a day after it was last seen. +/// +public static class ServerEventAnalysis +{ + public static readonly TimeSpan RecentWindow = TimeSpan.FromHours(24); + + /// The logs that are read. + public static readonly IReadOnlyList Logs = ["System", "Application"]; + + /// + /// Noise left out unless SQLInsight:Servers:EventLog:Ignore says otherwise: well-known events that are errors in name only, + /// and SQL Server's own events, which the SQL Server error log monitoring already covers. "Kilde:id", "Kilde" or "Kilde*". + /// + public static readonly IReadOnlyList DefaultIgnore = + [ + "Microsoft-Windows-DistributedCOM:10016", + "DCOM:10016", + "Microsoft-Windows-DistributedCOM:10010", + "DCOM:10010", + "Schannel:36874", + "Schannel:36887", + "Schannel:36888", + "Microsoft-Windows-TPM-WMI:1796", + "Microsoft-Windows-TPM-WMI:1801", + "Microsoft-Windows-WMI:10", + "Microsoft-Windows-Security-SPP:8198", + "MSSQLSERVER", + "MSSQL$*", + "SQLSERVERAGENT", + "SQLAgent$*", + "SQLISPackage*", + ]; + + public static ServerEventCategory Categorize(ServerEventEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + var source = entry.Source; + bool Is(params string[] names) => names.Any(n => source.Equals(n, StringComparison.OrdinalIgnoreCase)); + return entry.EventId switch + { + 41 when Is("Microsoft-Windows-Kernel-Power") => ServerEventCategory.UnexpectedShutdown, + 6008 when Is("EventLog") => ServerEventCategory.UnexpectedShutdown, + 1001 when Is("BugCheck", "Microsoft-Windows-WER-SystemErrorReporting") => ServerEventCategory.UnexpectedShutdown, + 7 or 11 or 51 or 153 when Is("disk", "Disk") => ServerEventCategory.Disk, + 129 when source.Contains("stor", StringComparison.OrdinalIgnoreCase) || Is("iaStorA", "LSI_SAS", "vhdmp") => ServerEventCategory.Disk, + 55 or 98 or 137 when Is("Ntfs", "Microsoft-Windows-Ntfs") => ServerEventCategory.Disk, + 157 when Is("disk", "Disk") => ServerEventCategory.Disk, + 2019 or 2020 when Is("Srv") => ServerEventCategory.Resources, + 2004 when Is("Microsoft-Windows-Resource-Exhaustion-Detector") => ServerEventCategory.Resources, + 7000 or 7001 or 7009 or 7011 or 7022 or 7023 or 7024 or 7031 or 7032 or 7034 when Is("Service Control Manager") => ServerEventCategory.ServiceFailure, + 1000 or 1002 when Is("Application Error", "Application Hang") => ServerEventCategory.ApplicationCrash, + 1026 when Is(".NET Runtime") => ServerEventCategory.ApplicationCrash, + _ when source.StartsWith("Microsoft Dynamics AX", StringComparison.OrdinalIgnoreCase) + || source.StartsWith("Dynamics Server", StringComparison.OrdinalIgnoreCase) + || source.StartsWith("Dynamics AX", StringComparison.OrdinalIgnoreCase) => ServerEventCategory.DynamicsAx, + _ when entry.Level == 1 => ServerEventCategory.Critical, + _ => ServerEventCategory.Error, + }; + } + + /// Whether matches an ignore entry ("Kilde:id", "Kilde", "Kilde*", "Kilde*:id"). + public static bool IsIgnored(ServerEventEntry entry, IEnumerable ignore) + { + ArgumentNullException.ThrowIfNull(entry); + ArgumentNullException.ThrowIfNull(ignore); + foreach (var raw in ignore) + { + var item = raw?.Trim(); + if (string.IsNullOrEmpty(item)) + { + continue; + } + + var colon = item.LastIndexOf(':'); + var source = colon > 0 ? item[..colon] : item; + var id = colon > 0 ? item[(colon + 1)..] : null; + var sourceMatches = source.EndsWith('*') + ? entry.Source.StartsWith(source[..^1], StringComparison.OrdinalIgnoreCase) + : entry.Source.Equals(source, StringComparison.OrdinalIgnoreCase); + if (sourceMatches && (id is null or "*" || long.TryParse(id, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var number) && number == entry.EventId)) + { + return true; + } + } + + return false; + } + + /// Sums repeated events: one group per log, source and event id, with the newest message as a sample. Noise is counted, not grouped. + public static (IReadOnlyList Groups, int Ignored) Group(IEnumerable entries, IEnumerable ignore) + { + ArgumentNullException.ThrowIfNull(entries); + ArgumentNullException.ThrowIfNull(ignore); + var ignoreList = ignore.ToList(); + var ignored = 0; + var kept = new List(); + foreach (var entry in entries.Where(e => e.Level is 1 or 2)) + { + if (IsIgnored(entry, ignoreList)) + { + ignored++; + } + else + { + kept.Add(entry); + } + } + + var groups = kept + .GroupBy(e => (Log: e.Log.ToUpperInvariant(), Source: e.Source.ToUpperInvariant(), e.EventId)) + .Select(g => + { + var newest = g.OrderByDescending(e => e.At).First(); + return new ServerEventGroup(newest.Log, newest.Source, newest.EventId, g.Min(e => e.Level), Categorize(newest), g.Count(), + g.Min(e => e.At), g.Max(e => e.At), Cut(g.OrderByDescending(e => e.At).Select(e => e.Message).FirstOrDefault(m => !string.IsNullOrWhiteSpace(m)))); + }) + .OrderByDescending(g => g.Category).ThenByDescending(g => g.Count) + .ToList(); + return (groups, ignored); + } + + /// The groups of the last day: this collection's plus the earlier ones still in the window, merged. + public static IReadOnlyList Recent(IReadOnlyList previous, IReadOnlyList current, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(previous); + ArgumentNullException.ThrowIfNull(current); + return previous.Where(g => now - g.LastAt < RecentWindow).Concat(current) + .GroupBy(g => g.Key, StringComparer.OrdinalIgnoreCase) + .Select(g => + { + var newest = g.OrderByDescending(x => x.LastAt).First(); + return newest with + { + Level = g.Min(x => x.Level), + Count = g.Sum(x => x.Count), + FirstAt = g.Min(x => x.FirstAt), + LastAt = g.Max(x => x.LastAt), + Sample = g.OrderByDescending(x => x.LastAt).Select(x => x.Sample).FirstOrDefault(s => s is not null), + }; + }) + .OrderByDescending(g => g.Category).ThenByDescending(g => g.Count) + .ToList(); + } + + public static string Display(ServerEventCategory category) => category switch + { + ServerEventCategory.UnexpectedShutdown => "Uventet nedlukning", + ServerEventCategory.Disk => "Disk og filsystem", + ServerEventCategory.Resources => "Ressourcer opbrugt", + ServerEventCategory.Critical => "Kritisk hændelse", + ServerEventCategory.ServiceFailure => "Tjeneste stoppet eller startede ikke", + ServerEventCategory.DynamicsAx => "Dynamics AX (AOS)", + ServerEventCategory.ApplicationCrash => "Program gik ned", + _ => "Fejl", + }; + + private static string? Cut(string? text) + { + if (text is null) + { + return null; + } + + var trimmed = text.Trim(); + return trimmed.Length > 500 ? trimmed[..500] : trimmed; + } +} diff --git a/src/SQLInsight.Core/Servers/ServerGrantScript.cs b/src/SQLInsight.Core/Servers/ServerGrantScript.cs new file mode 100644 index 0000000..774e0d4 --- /dev/null +++ b/src/SQLInsight.Core/Servers/ServerGrantScript.cs @@ -0,0 +1,51 @@ +using System.Text.RegularExpressions; + +namespace SQLInsight.Core.Servers; + +/// +/// db/permissions/grant-windows-server.ps1, ready to run for one account: what SQLInsight Web shows for a Windows +/// server. SQLInsight never runs it itself; an administrator on the server does. +/// +public static partial class ServerGrantScript +{ + /// The script with as the default for -Account. + public static string For(string account) + { + ArgumentException.ThrowIfNullOrWhiteSpace(account); + var literal = "'" + account.Trim().Replace("'", "''", StringComparison.Ordinal) + "'"; + return AccountPlaceholder().Replace(Template(), _ => "[string] $Account = " + literal + ", # <== agentens konto", 1); + } + + /// The script as shipped, with the placeholder account. + public static string Template() + { + using var stream = typeof(ServerGrantScript).Assembly.GetManifestResourceStream("SQLInsight.Core.grant-windows-server.ps1") + ?? throw new InvalidOperationException("grant-windows-server.ps1 mangler i programmet."); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + /// + /// The account the agent arrives as on : a virtual or built-in machine account becomes the + /// computer account DOMAIN\COMPUTER$ on any other server; a domain account or gMSA is the same everywhere. + /// + public static string AccountFor(string serviceAccount, string host, string machineName, string domain) + { + ArgumentException.ThrowIfNullOrWhiteSpace(serviceAccount); + ArgumentNullException.ThrowIfNull(host); + ArgumentException.ThrowIfNullOrWhiteSpace(machineName); + var account = serviceAccount.Trim(); + if (account.Equals("LocalSystem", StringComparison.OrdinalIgnoreCase)) + { + account = @"NT AUTHORITY\SYSTEM"; + } + + var machineIdentity = account.StartsWith(@"NT SERVICE\", StringComparison.OrdinalIgnoreCase) || account.StartsWith(@"NT AUTHORITY\", StringComparison.OrdinalIgnoreCase); + return machineIdentity && !ServerHost.IsLocal(host, machineName) + ? $@"{(string.IsNullOrWhiteSpace(domain) ? machineName : domain)}\{machineName}$" + : account; + } + + [GeneratedRegex(@"\[string\] \$Account = '[^']*',[^\r\n]*")] + private static partial Regex AccountPlaceholder(); +} diff --git a/src/SQLInsight.Core/Servers/ServerPerformance.cs b/src/SQLInsight.Core/Servers/ServerPerformance.cs new file mode 100644 index 0000000..6e8fc51 --- /dev/null +++ b/src/SQLInsight.Core/Servers/ServerPerformance.cs @@ -0,0 +1,76 @@ +namespace SQLInsight.Core.Servers; + +/// Rates from two raw counter samples, as Performance Monitor computes them. +public static class ServerPerformanceMath +{ + /// How long the per-minute samples are kept in memory for the rules. + public static readonly TimeSpan Window = TimeSpan.FromHours(1); + + /// The interval's sample. CPU and pages/s need the previous raw sample; without it (or after a reboot) they are null. + public static ServerPerformanceSample Sample(ServerPerformanceRaw? previous, ServerPerformanceRaw current, ServerOsInfo os) + { + ArgumentNullException.ThrowIfNull(current); + ArgumentNullException.ThrowIfNull(os); + return new ServerPerformanceSample( + current.CapturedAt, + Cpu(previous, current), + current.AvailableMb, + os.TotalMemoryMb, + PagesPerSecond(previous, current), + current.CommittedBytes is { } committed && current.CommitLimit is > 0 ? Math.Clamp(100 * committed / current.CommitLimit.Value, 0, 100) : null); + } + + /// + /// PERF_100NSEC_TIMER_INV: 100 × (1 − Δidle / Δtime). The counter is the processors' idle time (averaged over all + /// of them for _Total), so the result is the average CPU over the interval. + /// + public static double? Cpu(ServerPerformanceRaw? previous, ServerPerformanceRaw current) + { + ArgumentNullException.ThrowIfNull(current); + if (previous?.ProcessorIdle100Ns is not { } idle0 || previous.ProcessorTimestamp100Ns is not { } time0 + || current.ProcessorIdle100Ns is not { } idle1 || current.ProcessorTimestamp100Ns is not { } time1 + || time1 <= time0 || idle1 < idle0) + { + return null; + } + + var busy = 100 * (1 - ((double)(idle1 - idle0) / (time1 - time0))); + return Math.Clamp(busy, 0, 100); + } + + /// PERF_COUNTER_COUNTER: Δcount / (Δtime / frequency). + public static double? PagesPerSecond(ServerPerformanceRaw? previous, ServerPerformanceRaw current) + { + ArgumentNullException.ThrowIfNull(current); + if (previous?.Pages is not { } pages0 || previous.MemoryPerfTime is not { } time0 + || current.Pages is not { } pages1 || current.MemoryPerfTime is not { } time1 || current.MemoryPerfFrequency is not ({ } frequency and > 0) + || time1 <= time0 || pages1 < pages0) + { + return null; + } + + return (pages1 - pages0) / ((double)(time1 - time0) / frequency); + } + + /// The window's samples after adding ; older ones fall out. + public static IReadOnlyList Append(IReadOnlyList samples, ServerPerformanceSample sample) + { + ArgumentNullException.ThrowIfNull(samples); + ArgumentNullException.ThrowIfNull(sample); + return samples.Where(s => s.At > sample.At - Window && s.At < sample.At).Append(sample).ToList(); + } + + /// + /// The average over the last , when the samples cover at least 80 % of it (so a short + /// burst after a gap is not "sustained"). Null otherwise. + /// + public static double? SustainedAverage(IReadOnlyList samples, Func value, TimeSpan period, DateTimeOffset now, + TimeSpan interval) + { + ArgumentNullException.ThrowIfNull(samples); + ArgumentNullException.ThrowIfNull(value); + var values = samples.Where(s => s.At > now - period && s.At <= now).Select(value).OfType().ToList(); + var expected = Math.Max(1, (int)Math.Floor(period / (interval <= TimeSpan.Zero ? TimeSpan.FromMinutes(1) : interval))); + return values.Count >= Math.Ceiling(expected * 0.8) ? values.Average() : null; + } +} diff --git a/src/SQLInsight.Core/Servers/ServerRequests.cs b/src/SQLInsight.Core/Servers/ServerRequests.cs new file mode 100644 index 0000000..53572bc --- /dev/null +++ b/src/SQLInsight.Core/Servers/ServerRequests.cs @@ -0,0 +1,51 @@ +namespace SQLInsight.Core.Servers; + +/// +/// What SQLInsight Web asks the agent to do about Windows servers and synthetic checks (insight.AgentRequests, with the +/// server or check in TargetId). They run in the agent, as the agent's identity, like the SQL Server connection tests. +/// +public static class ServerRequestKind +{ + /// Connect to a Windows server and check which areas the agent may read. + public const string ServerTest = "ServerTest"; + + /// Run a synthetic check now and store the result. + public const string SyntheticCheckRun = "SyntheticCheckRun"; + + public static readonly IReadOnlyList All = [ServerTest, SyntheticCheckRun]; + + public static bool Handles(string kind) => All.Contains(kind, StringComparer.Ordinal); +} + +/// Whether the agent can read one area of a Windows server, and if not, exactly why (Danish). +/// What gives the agent the area (from the grant script). +public sealed record ServerAreaStatus(string Area, bool Ok, string? Detail, string Right); + +/// The outcome of a Windows server test, as the agent saw it. +public sealed record WindowsServerTestResult +{ + public bool Reachable { get; init; } + + /// "WS-Man", "DCOM" or "lokal". + public string? Protocol { get; init; } + + public string? Error { get; init; } + + /// What to do about (Danish). + public string? Hint { get; init; } + + /// The account the agent connected as (the one the grant script is for). + public string? Account { get; init; } + + public string? ComputerName { get; init; } + public string? OperatingSystem { get; init; } + public string? Version { get; init; } + public DateTimeOffset? LastBootUpTime { get; init; } + + public IReadOnlyList Areas { get; init; } = []; + + /// Notes about the result (Danish), e.g. why WS-Man was not used. + public IReadOnlyList Notes { get; init; } = []; + + public IEnumerable Missing => Areas.Where(a => !a.Ok); +} diff --git a/src/SQLInsight.Core/Servers/ServerTasks.cs b/src/SQLInsight.Core/Servers/ServerTasks.cs new file mode 100644 index 0000000..933c45a --- /dev/null +++ b/src/SQLInsight.Core/Servers/ServerTasks.cs @@ -0,0 +1,51 @@ +using System.Globalization; + +namespace SQLInsight.Core.Servers; + +/// Task Scheduler's result codes (LastTaskResult) in Danish, and which of them mean the task failed. +public static class TaskResults +{ + public const uint Success = 0; + public const uint Ready = 0x41300; + public const uint Running = 0x41301; + public const uint Disabled = 0x41302; + public const uint HasNotRun = 0x41303; + public const uint NoMoreRuns = 0x41304; + public const uint TerminatedByUser = 0x41306; + public const uint Queued = 0x41325; + public const uint AlreadyRunning = 0x8004131F; + + /// Whether the last run ended badly. Running, queued, never run and "already running" are not failures. + public static bool IsFailure(uint? result) => result is { } r && r is not (Success or Ready or Running or Disabled or HasNotRun or NoMoreRuns or Queued or AlreadyRunning); + + public static string Hex(uint result) => "0x" + result.ToString("X", CultureInfo.InvariantCulture); + + public static string Describe(uint result) => result switch + { + Success => "Fuldført (0x0)", + Ready => "Klar (0x41300)", + Running => "Kører nu (0x41301)", + Disabled => "Deaktiveret (0x41302)", + HasNotRun => "Har ikke kørt endnu (0x41303)", + NoMoreRuns => "Ingen flere planlagte kørsler (0x41304)", + TerminatedByUser => "Afbrudt af en bruger eller fordi den kørte for længe (0x41306)", + Queued => "I kø (0x41325)", + AlreadyRunning => "Kørte allerede, en ny kørsel blev ikke startet (0x8004131F)", + 0x1 => "Programmet sluttede med fejlkode 1 (typisk en fejl i scriptet eller programmet) (0x1)", + 0x2 => "Filen blev ikke fundet (0x2)", + 0xC000013A => "Programmet blev afbrudt (Ctrl+C eller nedlukning) (0xC000013A)", + 0x80070002 => "Filen blev ikke fundet (0x80070002)", + 0x80070005 => "Adgang nægtet: kontoen, opgaven kører som, mangler rettigheder (0x80070005)", + 0x8007052E => "Logon fejlede: forkert brugernavn eller adgangskode til opgavens konto (0x8007052E)", + 0x800710E0 => "Opgaven blev afvist (fx fordi computeren kørte på batteri eller kontoen ikke var logget på) (0x800710E0)", + 0x80041326 => "Opgaven er deaktiveret (0x80041326)", + _ => "Sluttede med fejlkode " + Hex(result), + }; + + /// Whether a task is Microsoft's own (\Microsoft\…), which is not watched unless asked for. + public static bool IsMicrosoftTask(string path) + { + ArgumentNullException.ThrowIfNull(path); + return path.StartsWith(@"\Microsoft\", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/SQLInsight.Core/Servers/WindowsServerData.cs b/src/SQLInsight.Core/Servers/WindowsServerData.cs new file mode 100644 index 0000000..92db927 --- /dev/null +++ b/src/SQLInsight.Core/Servers/WindowsServerData.cs @@ -0,0 +1,188 @@ +namespace SQLInsight.Core.Servers; + +/// +/// What the agent collects from a Windows server, and how often by default. Each area is collected on its own, so a +/// missing right only makes that area "ikke målbar". +/// +public static class ServerArea +{ + /// CPU, memory, paging and uptime (Win32_PerfRawData_PerfOS_*, Win32_OperatingSystem). + public const string Performance = "performance"; + + /// Free space per volume (Win32_Volume, or Win32_LogicalDisk). + public const string Disks = "disks"; + + /// Services and their state (Win32_Service). + public const string Services = "services"; + + /// Errors and critical events in the System and Application logs. + public const string Events = "events"; + + /// Scheduled tasks and their last result (root\Microsoft\Windows\TaskScheduler). + public const string Tasks = "tasks"; + + public static readonly IReadOnlyList All = [Performance, Disks, Services, Events, Tasks]; + + public static TimeSpan DefaultInterval(string area) => area switch + { + Performance => TimeSpan.FromMinutes(1), + Disks => TimeSpan.FromMinutes(15), + Services => TimeSpan.FromMinutes(5), + Events => TimeSpan.FromMinutes(5), + Tasks => TimeSpan.FromMinutes(15), + _ => TimeSpan.FromMinutes(15), + }; + + public static string Display(string area) => area switch + { + Performance => "CPU, hukommelse og oppetid", + Disks => "Diske", + Services => "Tjenester", + Events => "Hændelseslog", + Tasks => "Planlagte opgaver", + _ => area, + }; + + /// The Windows right that opens the area to the agent (Danish), as in the grant script. + public static string Right(string area) => area switch + { + Performance => "Performance Monitor Users + WMI Remote Enable på root\\cimv2", + Disks => "WMI Remote Enable på root\\cimv2", + Services => "WMI Remote Enable på root\\cimv2 + læseret på Service Control Manager", + Events => "Event Log Readers", + Tasks => "WMI Remote Enable på root\\Microsoft\\Windows\\TaskScheduler", + _ => string.Empty, + }; +} + +/// The operating system as Win32_OperatingSystem reports it. +public sealed record ServerOsInfo +{ + public string? Caption { get; init; } + public string? Version { get; init; } + public string? BuildNumber { get; init; } + public DateTimeOffset? LastBootUpTime { get; init; } + public double? TotalMemoryMb { get; init; } + public int? LogicalProcessors { get; init; } + + /// The computer's own name (CSName). + public string? ComputerName { get; init; } +} + +/// +/// Raw performance counters as one CIM query returns them. Rates (CPU, pages/s) are computed between two of these, +/// so a value always covers the whole interval, not the instant of the query. +/// +public sealed record ServerPerformanceRaw +{ + public required DateTimeOffset CapturedAt { get; init; } + + /// Win32_PerfRawData_PerfOS_Processor(_Total).PercentProcessorTime: idle time in 100 ns (PERF_100NSEC_TIMER_INV). + public ulong? ProcessorIdle100Ns { get; init; } + + /// The processor counter's Timestamp_Sys100NS. + public ulong? ProcessorTimestamp100Ns { get; init; } + + /// Win32_PerfRawData_PerfOS_Memory.PagesPersec (PERF_COUNTER_COUNTER). + public ulong? Pages { get; init; } + + public ulong? MemoryPerfTime { get; init; } + public ulong? MemoryPerfFrequency { get; init; } + public double? AvailableMb { get; init; } + public double? CommittedBytes { get; init; } + public double? CommitLimit { get; init; } +} + +/// One minute (or interval) of a server's CPU and memory. +/// Average CPU over the interval since the previous raw sample; null for the first one. +public sealed record ServerPerformanceSample(DateTimeOffset At, double? CpuPercent, double? AvailableMb, double? TotalMb, double? PagesPerSecond, double? CommitPercent) +{ + public double? AvailablePercent => AvailableMb is { } a && TotalMb is > 0 ? 100 * a / TotalMb.Value : null; +} + +public sealed record ServerPerformanceSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + public required ServerOsInfo Os { get; init; } + public ServerPerformanceRaw? Raw { get; init; } + + /// The samples of the last hour, oldest first (sustained CPU, low memory, paging). + public IReadOnlyList Samples { get; init; } = []; + + /// The boot time seen at the previous collection, when it was different: the server has restarted since. + public DateTimeOffset? PreviousBootUpTime { get; init; } + + /// When the agent last saw the server restart (kept for a day, so the finding stays visible). + public DateTimeOffset? RestartSeenAt { get; init; } + + /// "WS-Man" or "DCOM" (or "lokal"). + public string? Protocol { get; init; } +} + +/// C:\, or the mount point path. +public sealed record ServerDisk(string Volume, string? Label, string? FileSystem, double SizeGb, double FreeGb) +{ + public double FreePercent => SizeGb > 0 ? 100 * FreeGb / SizeGb : 0; +} + +public sealed record ServerDiskSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + public IReadOnlyList Disks { get; init; } = []; + + /// "fuld om ca. N dage" per volume, from SQLInsight's own history of the free space. + public IReadOnlyList Forecasts { get; init; } = []; +} + +public sealed record ServerService(string Name, string DisplayName, string State, string StartMode, bool DelayedAutoStart = false, int? ExitCode = null) +{ + public bool IsRunning => string.Equals(State, "Running", StringComparison.OrdinalIgnoreCase); + + public bool IsAutomatic => string.Equals(StartMode, "Auto", StringComparison.OrdinalIgnoreCase); +} + +public sealed record ServerServiceSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + public IReadOnlyList Services { get; init; } = []; +} + +/// A scheduled task as Task Scheduler reports it. +public sealed record ServerTask +{ + public required string Path { get; init; } + public required string Name { get; init; } + + /// Ready, Running, Disabled, Queued or Unknown. + public string State { get; init; } = "Unknown"; + + public DateTimeOffset? LastRunTime { get; init; } + public uint? LastTaskResult { get; init; } + public DateTimeOffset? NextRunTime { get; init; } + public int MissedRuns { get; init; } + + public string FullName => Path.EndsWith('\\') ? Path + Name : Path + "\\" + Name; + + public bool Enabled => !string.Equals(State, "Disabled", StringComparison.OrdinalIgnoreCase); +} + +public sealed record ServerTaskSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + + /// The tasks that are watched (by default the ones not under \Microsoft\). + public IReadOnlyList Tasks { get; init; } = []; + + /// Every task the agent could see, Microsoft's own included. + public int Visible { get; init; } + + /// Why less is seen than there is (Danish), e.g. tasks the agent may not read. + public string? Coverage { get; init; } +} + +/// A configured Windows server as the rules see it. +public sealed record ServerIdentity(int ServerId, string Name, string Host, string Role) +{ + public IReadOnlyList WatchedServices { get; init; } = []; + public IReadOnlyList IgnoredServices { get; init; } = []; +} diff --git a/src/SQLInsight.Core/SyntheticChecks/SyntheticCheckDefinitions.cs b/src/SQLInsight.Core/SyntheticChecks/SyntheticCheckDefinitions.cs new file mode 100644 index 0000000..ec441f4 --- /dev/null +++ b/src/SQLInsight.Core/SyntheticChecks/SyntheticCheckDefinitions.cs @@ -0,0 +1,334 @@ +using System.Globalization; +using System.Net; +using System.Text.Json; +using SQLInsight.Core.Configuration; +using SQLInsight.Core.CustomRules; +using SQLInsight.Core.Servers; + +namespace SQLInsight.Core.SyntheticChecks; + +/// The kinds of synthetic check: what the agent does, the way a user or an application would. +public static class SyntheticCheckKind +{ + /// A TCP port answers (e.g. the AOS on 2712). + public const string Tcp = "Tcp"; + + /// An HTTP(S) GET returns the expected status (and text); the TLS certificate is checked. + public const string Http = "Http"; + + /// A SQL Server login works and a simple query answers in time. + public const string Sql = "Sql"; + + /// A name resolves (to the expected addresses). + public const string Dns = "Dns"; + + /// Dynamics AX: the AOS port answers, and a logon through the .NET Business Connector works when it is installed. + public const string AxLogon = "AxLogon"; + + public static readonly IReadOnlyList All = [Tcp, Http, Sql, Dns, AxLogon]; + + public static bool IsKnown(string? kind) => kind is not null && All.Contains(kind, StringComparer.Ordinal); + + public static string Display(string? kind) => kind switch + { + Tcp => "TCP-port", + Http => "HTTP(S)", + Sql => "SQL-login og forespørgsel", + Dns => "DNS-opslag", + AxLogon => "Dynamics AX-logon", + _ => kind ?? string.Empty, + }; +} + +/// +/// What a synthetic check does: the fields for its kind (stored as JSON in insight.SyntheticChecks.SettingsJson). A +/// SQL login password is never here; it is sealed for the agent in the check's own row. +/// +public sealed record SyntheticCheckSettings +{ + /// Tcp, Dns and AxLogon: the computer (for Dns: the name to resolve). + public string? Host { get; init; } + + /// Tcp and AxLogon (default 2712), Sql (optional). + public int? Port { get; init; } + + /// Http: http:// or https://. + public Uri? Url { get; init; } + + /// Http: the status codes that count as success; empty: 200–399. + public IReadOnlyList ExpectedStatus { get; init; } = []; + + /// Http: text the answer must contain. + public string? ExpectedText { get; init; } + + /// Http: log on as the agent's account (Windows authentication), e.g. Enterprise Portal or Reporting Services. + public bool UseWindowsAuthentication { get; init; } + + /// Http: accept a certificate that is not valid (self-signed …); its expiry is still checked. + public bool AllowInvalidCertificate { get; init; } + + /// Sql: server or server\instance. + public string? Server { get; init; } + + public string? Database { get; init; } + + /// Sql: Windows (the agent's account) or SqlLogin. + public string Authentication { get; init; } = "Windows"; + + public string? UserName { get; init; } + + /// Sql: Mandatory or Optional. + public string Encryption { get; init; } = "Mandatory"; + + public bool TrustServerCertificate { get; init; } + + /// Sql: one read-only SELECT; default SELECT 1. Runs on SQL Server 2005 and later. + public string? Query { get; init; } + + /// Dns: addresses the name must resolve to (any of them); empty: any answer. + public IReadOnlyList ExpectedAddresses { get; init; } = []; + + /// AxLogon: the company to log on to (DataAreaId), e.g. DAT. + public string? Company { get; init; } + + /// AxLogon: the Business Connector's object server, e.g. 01@aos01:2712; default host:port. + public string? ObjectServer { get; init; } + + /// AxLogon: the AX client configuration to use; default the Business Connector's own. + public string? Configuration { get; init; } + + /// AxLogon: try a logon through the .NET Business Connector when it is installed on the agent's computer. + public bool BusinessConnectorLogon { get; init; } = true; + + /// The Windows service behind what is checked, on the check's server (e.g. AOS60$01, W3SVC). + public string? ServiceName { get; init; } + + /// An answer slower than this (ms) three times in a row is a finding. + public int? WarnMilliseconds { get; init; } + + public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { WriteIndented = false }; + + public string ToJson() => JsonSerializer.Serialize(this, Json); + + public static SyntheticCheckSettings FromJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return new SyntheticCheckSettings(); + } + + try + { + return JsonSerializer.Deserialize(json, Json) ?? new SyntheticCheckSettings(); + } + catch (JsonException) + { + return new SyntheticCheckSettings(); + } + } + + /// The AOS port when none is given. + public const int DefaultAosPort = 2712; + + public const string DefaultQuery = "SELECT 1"; + + /// What the check points at, for lists (Danish-neutral). + public string Target(string kind) => kind switch + { + SyntheticCheckKind.Tcp => $"{Host}:{Port?.ToString(CultureInfo.InvariantCulture)}", + SyntheticCheckKind.AxLogon => ObjectServer is { Length: > 0 } os ? os : $"{Host}:{(Port ?? DefaultAosPort).ToString(CultureInfo.InvariantCulture)}", + SyntheticCheckKind.Http => Url?.ToString() ?? string.Empty, + SyntheticCheckKind.Sql => ServerAddress.DataSource(Server ?? string.Empty, Port) + (Database is { Length: > 0 } db ? " / " + db : string.Empty), + SyntheticCheckKind.Dns => Host ?? string.Empty, + _ => string.Empty, + }; + + /// The context a SQL login password is sealed to: the server and login of the check (see ). + public string SecretContext() => CredentialEnvelope.Context("synthetic-sql:" + ServerAddress.DataSource(Server ?? string.Empty, Port), UserName); +} + +/// How often and how long a check may run. +public static class SyntheticCheckLimits +{ + public const int DefaultIntervalSeconds = 300; + public const int MinIntervalSeconds = 60; + public const int MaxIntervalSeconds = 86400; + public const int DefaultTimeoutSeconds = 15; + public const int MaxTimeoutSeconds = 120; + + /// The AX logon starts a separate process and logs on to the AOS: give it longer. + public const int DefaultAxTimeoutSeconds = 60; +} + +public static class SyntheticCheckValidation +{ + /// Why the check cannot be saved (Danish), or null. : the check is tied to a Windows server. + public static string? Validate(string? name, string? kind, SyntheticCheckSettings settings, int intervalSeconds, int timeoutSeconds, bool hasServer) + { + ArgumentNullException.ThrowIfNull(settings); + if (ServerAddress.ValidateName(name) is { } badName) + { + return badName; + } + + if (!SyntheticCheckKind.IsKnown(kind)) + { + return "Typen skal være " + string.Join(", ", SyntheticCheckKind.All) + "."; + } + + if (intervalSeconds is < SyntheticCheckLimits.MinIntervalSeconds or > SyntheticCheckLimits.MaxIntervalSeconds) + { + return "Intervallet skal være mellem 1 minut og 24 timer."; + } + + if (timeoutSeconds is < 1 or > SyntheticCheckLimits.MaxTimeoutSeconds) + { + return $"Timeout skal være mellem 1 og {SyntheticCheckLimits.MaxTimeoutSeconds} sekunder."; + } + + if (timeoutSeconds >= intervalSeconds) + { + return "Timeout skal være kortere end intervallet."; + } + + if (settings.WarnMilliseconds is < 1 or > 600_000) + { + return "Grænsen for langsomt svar skal være mellem 1 og 600.000 ms."; + } + + if (settings.ServiceName is { Length: > 0 } service) + { + if (!hasServer) + { + return "Vælg den Windows-server, tjenesten kører på."; + } + + if (ServerHost.ValidateServiceNames([service]) is { } badService) + { + return badService; + } + } + + return kind switch + { + SyntheticCheckKind.Tcp => ServerHost.Validate(settings.Host) ?? ValidatePort(settings.Port, required: true), + SyntheticCheckKind.AxLogon => ServerHost.Validate(settings.Host) ?? ValidatePort(settings.Port, required: false) ?? ValidateAx(settings), + SyntheticCheckKind.Http => ValidateHttp(settings), + SyntheticCheckKind.Sql => ValidateSql(settings), + SyntheticCheckKind.Dns => ServerHost.Validate(settings.Host) ?? ValidateAddresses(settings.ExpectedAddresses), + _ => null, + }; + } + + private static string? ValidatePort(int? port, bool required) => + port is null ? (required ? "Angiv porten." : null) : port is < 1 or > 65535 ? "Porten skal være mellem 1 og 65535." : null; + + private static string? ValidateAx(SyntheticCheckSettings settings) + { + if (settings.Company is { } company && (company.Length > 10 || company.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '_'))) + { + return "Firmaet skal være AX' firma-id (DataAreaId), fx DAT."; + } + + if (settings.ObjectServer is { Length: > 256 } || settings.ObjectServer?.Any(char.IsControl) == true) + { + return "Object server er ugyldig (fx 01@aos01:2712)."; + } + + return settings.Configuration is { Length: > 128 } || settings.Configuration?.Any(char.IsControl) == true ? "Konfigurationens navn er ugyldigt." : null; + } + + private static string? ValidateHttp(SyntheticCheckSettings settings) + { + if (settings.Url is not { IsAbsoluteUri: true } url || (url.Scheme != Uri.UriSchemeHttp && url.Scheme != Uri.UriSchemeHttps)) + { + return "Angiv en adresse, der starter med http:// eller https://."; + } + + if (!string.IsNullOrEmpty(url.UserInfo)) + { + return "Adressen må ikke indeholde brugernavn eller adgangskode."; + } + + if (settings.ExpectedStatus.Any(s => s is < 100 or > 599) || settings.ExpectedStatus.Count > 20) + { + return "Forventede statuskoder skal være mellem 100 og 599."; + } + + return settings.ExpectedText is { Length: > 500 } ? "Den forventede tekst må højst være 500 tegn." : null; + } + + private static string? ValidateSql(SyntheticCheckSettings settings) + { + if (ServerAddress.Validate(settings.Server, settings.Port) is { } server) + { + return server; + } + + if (settings.Authentication is not ("Windows" or "SqlLogin")) + { + return "Godkendelse skal være Windows (agentens tjenestekonto) eller SqlLogin."; + } + + if (settings.Authentication == "SqlLogin" && (string.IsNullOrWhiteSpace(settings.UserName) || settings.UserName.Length > 128)) + { + return "Angiv SQL-login'et (højst 128 tegn)."; + } + + if (settings.Encryption is not ("Mandatory" or "Optional")) + { + return "Kryptering skal være Mandatory eller Optional."; + } + + if (settings.Database is { } database && (database.Length > 128 || database.Any(char.IsControl))) + { + return "Databasenavnet er ugyldigt."; + } + + if (settings.Query is { Length: > 0 } query) + { + if (query.Length > 4000) + { + return "Forespørgslen må højst være 4000 tegn."; + } + + return CustomRuleDefinition.QueryProblems(query).FirstOrDefault(); + } + + return null; + } + + private static string? ValidateAddresses(IReadOnlyList addresses) => + addresses.Count > 20 ? "Højst 20 forventede adresser." + : addresses.FirstOrDefault(a => !IPAddress.TryParse(a, out _)) is { } bad ? $"\"{bad}\" er ikke en IP-adresse." + : null; +} + +/// One measurement of a run, as shown and stored. Sensitive values stay on-prem and are redacted in the API. +public sealed record SyntheticCheckDetail(string Name, string Value, bool Sensitive = false); + +/// What one run of a synthetic check found. +public sealed record SyntheticCheckResult +{ + public required DateTimeOffset At { get; init; } + public required bool Success { get; init; } + + /// How long the check took (the answer's time for HTTP, SQL, TCP). + public int DurationMs { get; init; } + + /// What happened (Danish), e.g. "Porten svarede på 12 ms" or the error. + public required string Message { get; init; } + + public IReadOnlyList Details { get; init; } = []; + + /// Http over https: when the server's certificate expires. + public DateTimeOffset? CertificateExpiresAt { get; init; } + + public string? CertificateSubject { get; init; } + + /// The state of the linked Windows service when the check ran (Running, Stopped …), when there is one and it could be read. + public string? ServiceState { get; init; } + + /// Only part of the check could be done (AX: the port, because the Business Connector is not installed). + public bool Partial { get; init; } +} diff --git a/tests/SQLInsight.Core.Tests/SyntheticCheckTests.cs b/tests/SQLInsight.Core.Tests/SyntheticCheckTests.cs new file mode 100644 index 0000000..c717909 --- /dev/null +++ b/tests/SQLInsight.Core.Tests/SyntheticCheckTests.cs @@ -0,0 +1,72 @@ +using SQLInsight.Core.SyntheticChecks; + +namespace SQLInsight.Core.Tests; + +public class SyntheticCheckTests +{ + private static string? Validate(string kind, SyntheticCheckSettings settings, bool hasServer = false, int interval = 300, int timeout = 15) => + SyntheticCheckValidation.Validate("tjek", kind, settings, interval, timeout, hasServer); + + [Fact] + public void Valid_checks_of_each_kind() + { + Assert.Null(Validate(SyntheticCheckKind.Tcp, new() { Host = "aos01", Port = 2712 })); + Assert.Null(Validate(SyntheticCheckKind.Http, new() { Url = new Uri("https://ep.firma.local/sites/DynamicsAx"), ExpectedStatus = [200, 401], ExpectedText = "Dynamics" })); + Assert.Null(Validate(SyntheticCheckKind.Sql, new() { Server = @"sql01\AX", Database = "AX2012_PROD", Query = "SELECT COUNT(*) FROM dbo.DATAAREA" })); + Assert.Null(Validate(SyntheticCheckKind.Sql, new() { Server = "sql01", Authentication = "SqlLogin", UserName = "axapp" })); + Assert.Null(Validate(SyntheticCheckKind.Dns, new() { Host = "aos01.firma.local", ExpectedAddresses = ["10.0.0.5"] })); + Assert.Null(Validate(SyntheticCheckKind.AxLogon, new() { Host = "aos01", Company = "DAT", ServiceName = "AOS60$01" }, hasServer: true)); + } + + [Theory] + [InlineData(SyntheticCheckKind.Tcp, "Angiv porten")] + [InlineData(SyntheticCheckKind.Http, "http://")] + [InlineData(SyntheticCheckKind.Sql, "Angiv serveren")] + [InlineData(SyntheticCheckKind.Dns, "Angiv serverens navn")] + [InlineData(SyntheticCheckKind.AxLogon, "Angiv serverens navn")] + public void Missing_targets(string kind, string error) => + Assert.Contains(error, Validate(kind, kind == SyntheticCheckKind.Tcp ? new() { Host = "aos01" } : new()), StringComparison.Ordinal); + + [Fact] + public void A_sql_check_only_reads() + { + Assert.Contains("SELECT", Validate(SyntheticCheckKind.Sql, new() { Server = "sql01", Query = "UPDATE dbo.CustTable SET Name = 'x'" }), StringComparison.Ordinal); + Assert.NotNull(Validate(SyntheticCheckKind.Sql, new() { Server = "sql01", Query = "SELECT 1; DROP TABLE dbo.x" })); + Assert.NotNull(Validate(SyntheticCheckKind.Sql, new() { Server = "sql01", Query = "SELECT * INTO dbo.Copy FROM dbo.CustTable" })); + Assert.NotNull(Validate(SyntheticCheckKind.Sql, new() { Server = "sql01", Query = "EXEC sp_who" })); + } + + [Fact] + public void Limits_and_service_needs_a_server() + { + Assert.Contains("1 minut", Validate(SyntheticCheckKind.Tcp, new() { Host = "a", Port = 1 }, interval: 30), StringComparison.Ordinal); + Assert.Contains("kortere end intervallet", Validate(SyntheticCheckKind.Tcp, new() { Host = "a", Port = 1 }, interval: 60, timeout: 60), StringComparison.Ordinal); + Assert.Contains("Windows-server", Validate(SyntheticCheckKind.Tcp, new() { Host = "a", Port = 1, ServiceName = "W3SVC" }), StringComparison.Ordinal); + Assert.NotNull(Validate(SyntheticCheckKind.Http, new() { Url = new Uri("https://bruger:kode@ep/") })); + Assert.NotNull(Validate(SyntheticCheckKind.Http, new() { Url = new Uri("ftp://ep/") })); + Assert.NotNull(Validate(SyntheticCheckKind.AxLogon, new() { Host = "aos01", Company = "DAT; DROP" })); + Assert.NotNull(SyntheticCheckValidation.Validate("tjek", "Ping", new(), 300, 15, false)); + Assert.NotNull(SyntheticCheckValidation.Validate(" ", SyntheticCheckKind.Tcp, new() { Host = "a", Port = 1 }, 300, 15, false)); + } + + [Fact] + public void Settings_round_trip_as_json_and_bad_json_is_empty_settings() + { + var settings = new SyntheticCheckSettings { Url = new Uri("https://ep/"), ExpectedStatus = [200], UseWindowsAuthentication = true, ServiceName = "W3SVC" }; + + Assert.Equal(settings.ToJson(), SyntheticCheckSettings.FromJson(settings.ToJson()).ToJson()); + Assert.Null(SyntheticCheckSettings.FromJson("{not json").Url); + Assert.Equal("aos01:2712", new SyntheticCheckSettings { Host = "aos01" }.Target(SyntheticCheckKind.AxLogon)); + Assert.Equal(@"sql01\AX / AXDB", new SyntheticCheckSettings { Server = @"sql01\AX", Database = "AXDB" }.Target(SyntheticCheckKind.Sql)); + } + + [Fact] + public void A_sql_password_is_bound_to_the_server_and_login() + { + var a = new SyntheticCheckSettings { Server = "sql01", UserName = "axapp" }.SecretContext(); + + Assert.NotEqual(a, new SyntheticCheckSettings { Server = "sql02", UserName = "axapp" }.SecretContext()); + Assert.NotEqual(a, new SyntheticCheckSettings { Server = "sql01", UserName = "sa" }.SecretContext()); + Assert.NotEqual(a, Configuration.CredentialEnvelope.Context("sql01", "axapp")); + } +} diff --git a/tests/SQLInsight.Core.Tests/WindowsServerTests.cs b/tests/SQLInsight.Core.Tests/WindowsServerTests.cs new file mode 100644 index 0000000..bb9d33e --- /dev/null +++ b/tests/SQLInsight.Core.Tests/WindowsServerTests.cs @@ -0,0 +1,331 @@ +using SQLInsight.Core.Servers; + +namespace SQLInsight.Core.Tests; + +public class DiskForecastTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero); + + /// Hourly samples for days back, free space from a function of the age in days. + private static List History(int days, Func free, double size = 100) => + Enumerable.Range(0, (days * 24) + 1).Select(h => Now.AddHours(-h)).Select(at => new DiskHistoryPoint(at, size, free((Now - at).TotalDays))).Reverse().ToList(); + + [Fact] + public void Steady_growth_gives_days_until_full() + { + // 1 GB per day used, 20 GB free now: full in 20 days. + var forecast = DiskForecaster.Forecast(@"D:\", History(14, age => 20 + age), Now); + + Assert.NotNull(forecast.DaysUntilFull); + Assert.Equal(20, forecast.DaysUntilFull!.Value, 1); + Assert.Equal(1, forecast.GrowthGbPerDay, 3); + Assert.Equal(1, forecast.Fit, 3); + Assert.Equal("fuld om ca. 20 dage", forecast.Describe()); + Assert.Equal(Now.AddDays(20).Date, forecast.FullAt(Now)!.Value.Date); + } + + [Fact] + public void Noise_around_a_trend_still_forecasts() + { + var random = new Random(7); + var forecast = DiskForecaster.Forecast(@"D:\", History(14, age => 30 + (2 * age) + (random.NextDouble() - 0.5)), Now); + + Assert.InRange(forecast.DaysUntilFull!.Value, 14, 16); + } + + [Fact] + public void A_flat_or_shrinking_volume_has_no_forecast() + { + Assert.Null(DiskForecaster.Forecast(@"C:\", History(14, _ => 40), Now).DaysUntilFull); + var emptying = DiskForecaster.Forecast(@"C:\", History(14, age => 40 - age), Now); + Assert.Null(emptying.DaysUntilFull); + Assert.Equal("fyldes ikke op", emptying.Note); + } + + [Fact] + public void A_sawtooth_of_backups_written_and_deleted_is_not_a_trend() + { + // Fills 10 GB every night and is cleaned up every morning. + var forecast = DiskForecaster.Forecast(@"E:\", History(14, age => 50 - ((age % 1) * 10)), Now); + + Assert.Null(forecast.DaysUntilFull); + } + + [Fact] + public void Too_little_history_says_so() + { + var forecast = DiskForecaster.Forecast(@"D:\", History(1, age => 20 + (5 * age)), Now); + + Assert.Null(forecast.DaysUntilFull); + Assert.Contains("for lidt historik", forecast.Note, StringComparison.Ordinal); + } + + [Fact] + public void An_extended_volume_starts_a_new_trend() + { + // Was 100 GB and filling fast until 5 days ago; extended to 200 GB and now flat. + var history = History(14, age => age > 5 ? 5 + age : 100).Select(p => (Now - p.At).TotalDays > 5 ? p : p with { SizeGb = 200 }).ToList(); + + var forecast = DiskForecaster.Forecast(@"D:\", history, Now); + + Assert.Null(forecast.DaysUntilFull); + Assert.InRange(forecast.HistoryDays, 4.9, 5.1); + } + + [Fact] + public void A_full_volume_is_full_now_and_far_futures_are_not_forecasts() + { + Assert.Equal(0, DiskForecaster.Forecast(@"D:\", History(14, age => Math.Max(0, age - 1)), Now).DaysUntilFull); + var slow = DiskForecaster.Forecast(@"D:\", History(14, age => 500 + (0.5 * age)), Now, new DiskForecastSettings()); + Assert.Null(slow.DaysUntilFull); + Assert.Equal("fuld om mere end et år", slow.Note); + } + + [Fact] + public void Samples_older_than_the_history_window_are_ignored() + { + // Fast growth 20–40 days ago, flat for the last 14. + var history = Enumerable.Range(0, 40 * 24).Select(h => Now.AddHours(-h)) + .Select(at => new DiskHistoryPoint(at, 100, (Now - at).TotalDays > 20 ? (Now - at).TotalDays : 30)).Reverse().ToList(); + + Assert.Null(DiskForecaster.Forecast(@"D:\", history, Now).DaysUntilFull); + } +} + +public class ServerPerformanceTests +{ + private static readonly DateTimeOffset T0 = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void Cpu_is_one_minus_the_idle_share_of_the_interval() + { + var first = new ServerPerformanceRaw { CapturedAt = T0, ProcessorIdle100Ns = 1_000_000_000, ProcessorTimestamp100Ns = 5_000_000_000 }; + // One minute = 600,000,000 × 100 ns; idle 150,000,000 of it → 75 % busy. + var second = first with { CapturedAt = T0.AddMinutes(1), ProcessorIdle100Ns = 1_150_000_000, ProcessorTimestamp100Ns = 5_600_000_000 }; + + Assert.Equal(75, ServerPerformanceMath.Cpu(first, second)!.Value, 6); + Assert.Null(ServerPerformanceMath.Cpu(null, second)); + Assert.Null(ServerPerformanceMath.Cpu(second, first)); // counters went backwards: rebooted + } + + [Fact] + public void Pages_per_second_uses_the_performance_frequency() + { + var first = new ServerPerformanceRaw { CapturedAt = T0, Pages = 1000, MemoryPerfTime = 10_000_000, MemoryPerfFrequency = 10_000_000 }; + var second = first with { CapturedAt = T0.AddMinutes(1), Pages = 61_000, MemoryPerfTime = 610_000_000 }; + + Assert.Equal(1000, ServerPerformanceMath.PagesPerSecond(first, second)!.Value, 6); + } + + [Fact] + public void Sample_has_available_percent_and_commit() + { + var raw = new ServerPerformanceRaw { CapturedAt = T0, AvailableMb = 1024, CommittedBytes = 9, CommitLimit = 10 }; + var sample = ServerPerformanceMath.Sample(null, raw, new ServerOsInfo { TotalMemoryMb = 8192 }); + + Assert.Equal(12.5, sample.AvailablePercent!.Value, 6); + Assert.Equal(90, sample.CommitPercent!.Value, 6); + Assert.Null(sample.CpuPercent); + } + + [Fact] + public void Sustained_needs_most_of_the_period_covered() + { + var samples = Enumerable.Range(0, 15).Select(m => new ServerPerformanceSample(T0.AddMinutes(-m), 95, null, null, null, null)).Reverse().ToList(); + + Assert.Equal(95, ServerPerformanceMath.SustainedAverage(samples, s => s.CpuPercent, TimeSpan.FromMinutes(15), T0, TimeSpan.FromMinutes(1))); + Assert.Null(ServerPerformanceMath.SustainedAverage(samples.TakeLast(5).ToList(), s => s.CpuPercent, TimeSpan.FromMinutes(15), T0, TimeSpan.FromMinutes(1))); + } + + [Fact] + public void The_window_keeps_an_hour() + { + IReadOnlyList window = []; + for (var m = 0; m < 90; m++) + { + window = ServerPerformanceMath.Append(window, new ServerPerformanceSample(T0.AddMinutes(m), 10, null, null, null, null)); + } + + Assert.Equal(60, window.Count); + Assert.Equal(T0.AddMinutes(89), window[^1].At); + } +} + +public class ServerEventTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero); + + private static ServerEventEntry Entry(string source, long id, int level = 2, string log = "System", int minutesAgo = 1, string? message = "tekst") => + new(log, source, id, level, Now.AddMinutes(-minutesAgo), message); + + [Theory] + [InlineData("Microsoft-Windows-Kernel-Power", 41, 1, ServerEventCategory.UnexpectedShutdown)] + [InlineData("EventLog", 6008, 2, ServerEventCategory.UnexpectedShutdown)] + [InlineData("disk", 153, 3, ServerEventCategory.Disk)] + [InlineData("storvsc", 129, 3, ServerEventCategory.Disk)] + [InlineData("Ntfs", 55, 2, ServerEventCategory.Disk)] + [InlineData("Srv", 2019, 2, ServerEventCategory.Resources)] + [InlineData("Service Control Manager", 7031, 2, ServerEventCategory.ServiceFailure)] + [InlineData("Application Error", 1000, 2, ServerEventCategory.ApplicationCrash)] + [InlineData("Microsoft Dynamics AX Object Server 6.0$01", 110, 2, ServerEventCategory.DynamicsAx)] + [InlineData("Some-Driver", 5, 1, ServerEventCategory.Critical)] + [InlineData("Some-App", 5, 2, ServerEventCategory.Error)] + public void Categorize(string source, long id, int level, ServerEventCategory expected) => + Assert.Equal(expected, ServerEventAnalysis.Categorize(Entry(source, id, level))); + + [Fact] + public void Repeated_events_are_one_group_with_the_newest_sample() + { + var (groups, ignored) = ServerEventAnalysis.Group( + [ + Entry("Service Control Manager", 7031, minutesAgo: 30, message: "AOS stoppede (1)"), + Entry("Service Control Manager", 7031, minutesAgo: 5, message: "AOS stoppede (2)"), + Entry("service control manager", 7031, minutesAgo: 10, message: null), + Entry("Some-App", 9, log: "Application"), + Entry("Some-App", 9, level: 3, log: "Application"), // a warning: not collected + ], ServerEventAnalysis.DefaultIgnore); + + Assert.Equal(0, ignored); + Assert.Equal(2, groups.Count); + var scm = groups[0]; + Assert.Equal(ServerEventCategory.ServiceFailure, scm.Category); + Assert.Equal(3, scm.Count); + Assert.Equal("AOS stoppede (2)", scm.Sample); + Assert.Equal(Now.AddMinutes(-30), scm.FirstAt); + Assert.Equal(Now.AddMinutes(-5), scm.LastAt); + } + + [Fact] + public void Known_noise_and_sql_server_sources_are_left_out() + { + var (groups, ignored) = ServerEventAnalysis.Group( + [ + Entry("Microsoft-Windows-DistributedCOM", 10016), + Entry("Schannel", 36887), + Entry("MSSQL$PROD", 17053, log: "Application"), + Entry("MSSQLSERVER", 823, log: "Application"), + Entry("Schannel", 36871), + ], ServerEventAnalysis.DefaultIgnore); + + Assert.Equal(4, ignored); + Assert.Equal(36871, Assert.Single(groups).EventId); + } + + [Theory] + [InlineData("Some-App:9", true)] + [InlineData("Some-App", true)] + [InlineData("Some*", true)] + [InlineData("Some*:10", false)] + [InlineData("Some-App:*", true)] + [InlineData("Other:9", false)] + public void Ignore_patterns(string pattern, bool ignored) => + Assert.Equal(ignored, ServerEventAnalysis.IsIgnored(Entry("Some-App", 9), [pattern])); + + [Fact] + public void Recent_merges_groups_of_the_last_day_and_drops_older() + { + var (earlier, _) = ServerEventAnalysis.Group([Entry("disk", 153, minutesAgo: 60 * 20), Entry("Some-App", 9, minutesAgo: 60 * 30)], []); + var (current, _) = ServerEventAnalysis.Group([Entry("disk", 153, minutesAgo: 2)], []); + + var recent = ServerEventAnalysis.Recent(earlier, current, Now); + + var disk = Assert.Single(recent); + Assert.Equal(2, disk.Count); + Assert.Equal(Now.AddHours(-20), disk.FirstAt); + Assert.Equal(Now.AddMinutes(-2), disk.LastAt); + } + + [Fact] + public void Samples_are_cut() + { + var (groups, _) = ServerEventAnalysis.Group([Entry("Some-App", 9, message: new string('x', 2000))], []); + + Assert.Equal(500, groups[0].Sample!.Length); + } +} + +public class ServerConfigurationTests +{ + [Theory] + [InlineData("aos01", null)] + [InlineData("aos01.firma.local", null)] + [InlineData("10.0.0.5", null)] + [InlineData("fe80::1", null)] + [InlineData(@"sql01\PROD", "uden instansnavn")] + [InlineData("aos01:2712", "uden instansnavn")] + [InlineData("", "Angiv")] + public void Hosts(string host, string? error) + { + var result = ServerHost.Validate(host); + if (error is null) + { + Assert.Null(result); + } + else + { + Assert.Contains(error, result, StringComparison.Ordinal); + } + } + + [Theory] + [InlineData(@"tcp:SQL01.firma.local\PROD,1433", "sql01.firma.local", false)] + [InlineData("sql02,1500", "sql02", false)] + [InlineData(@".\SQLEXPRESS", "agent01", false)] + [InlineData("(local)", "agent01", false)] + [InlineData("localhost", "agent01", false)] + [InlineData("(localdb)\\MSSQLLocalDB", null, true)] + [InlineData("kunde.database.windows.net", null, true)] + public void The_computer_behind_a_sql_data_source(string dataSource, string? host, bool skipped) + { + var (found, reason) = ServerHost.FromDataSource(dataSource, "AGENT01"); + + Assert.Equal(host, found); + Assert.Equal(skipped, reason is not null); + } + + [Fact] + public void Lists_of_service_names() + { + Assert.Equal(["AOS60$01", "W3SVC"], ServerHost.ParseList("AOS60$01, W3SVC\nw3svc;")); + Assert.Null(ServerHost.ValidateServiceNames(["AOS60$01", "MSSQL$PROD"])); + Assert.NotNull(ServerHost.ValidateServiceNames([@"bad\name"])); + } + + [Theory] + [InlineData(@"NT SERVICE\SQLInsight Agent", "aos01.firma.local", @"FIRMA\AGENT01$")] + [InlineData("LocalSystem", "aos01", @"FIRMA\AGENT01$")] + [InlineData(@"NT SERVICE\SQLInsight Agent", "agent01.firma.local", @"NT SERVICE\SQLInsight Agent")] + [InlineData(@"FIRMA\gmsa-sqlinsight$", "aos01", @"FIRMA\gmsa-sqlinsight$")] + public void The_account_the_agent_arrives_as(string service, string host, string expected) => + Assert.Equal(expected, ServerGrantScript.AccountFor(service, host, "AGENT01", "FIRMA")); + + [Fact] + public void Grant_script_is_filled_in_and_never_makes_the_agent_administrator() + { + var script = ServerGrantScript.For(@"FIRMA\AGENT01$"); + + Assert.Contains(@"[string] $Account = 'FIRMA\AGENT01$',", script, StringComparison.Ordinal); + Assert.DoesNotContain("DOMÆNE\\AGENTSERVER$', #", script, StringComparison.Ordinal); + Assert.Contains("S-1-5-32-558", script, StringComparison.Ordinal); // Performance Monitor Users + Assert.Contains("S-1-5-32-573", script, StringComparison.Ordinal); // Event Log Readers + Assert.DoesNotContain("S-1-5-32-544", script, StringComparison.Ordinal); // Administrators + Assert.Contains("0x21", script, StringComparison.Ordinal); + Assert.Contains("CCLCRPRC", script, StringComparison.Ordinal); + Assert.Equal(script, ServerGrantScript.For(@"FIRMA\AGENT01$")); + Assert.Contains("'O''Brien'", ServerGrantScript.For("O'Brien"), StringComparison.Ordinal); + } + + [Theory] + [InlineData(0u, false)] + [InlineData(TaskResults.Running, false)] + [InlineData(TaskResults.HasNotRun, false)] + [InlineData(TaskResults.AlreadyRunning, false)] + [InlineData(1u, true)] + [InlineData(0x80070005u, true)] + [InlineData(TaskResults.TerminatedByUser, true)] + public void Task_results(uint result, bool failure) + { + Assert.Equal(failure, TaskResults.IsFailure(result)); + Assert.False(string.IsNullOrWhiteSpace(TaskResults.Describe(result))); + } +}