diff --git a/Directory.Packages.props b/Directory.Packages.props
index a854004..06ae78a 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -22,6 +22,7 @@
+
diff --git a/SQLInsight.slnx b/SQLInsight.slnx
index 9466406..e712b92 100644
--- a/SQLInsight.slnx
+++ b/SQLInsight.slnx
@@ -9,6 +9,7 @@
+
@@ -25,6 +26,7 @@
+
diff --git a/src/SQLInsight.Servers/CimQueries.cs b/src/SQLInsight.Servers/CimQueries.cs
new file mode 100644
index 0000000..d15c0cf
--- /dev/null
+++ b/src/SQLInsight.Servers/CimQueries.cs
@@ -0,0 +1,46 @@
+using System.Globalization;
+
+namespace SQLInsight.Servers;
+
+///
+/// The exact CIM/WQL queries and event log filters the agent uses (documented per rule in SQL Scripts/SQLI-WIN-*.sql).
+/// All are SELECTs: nothing is changed on the server.
+///
+public static class CimQueries
+{
+ public const string CimV2 = @"root\cimv2";
+ public const string TaskSchedulerNamespace = @"root\Microsoft\Windows\TaskScheduler";
+
+ // The exact queries (documented per rule in SQL Scripts/SQLI-WIN-*.sql).
+ public const string OperatingSystemQuery = "SELECT Caption, Version, BuildNumber, LastBootUpTime, TotalVisibleMemorySize, CSName FROM Win32_OperatingSystem";
+ public const string ComputerSystemQuery = "SELECT NumberOfLogicalProcessors FROM Win32_ComputerSystem";
+ public const string ProcessorQuery = "SELECT PercentProcessorTime, Timestamp_Sys100NS FROM Win32_PerfRawData_PerfOS_Processor WHERE Name = '_Total'";
+ public const string MemoryQuery = "SELECT AvailableMBytes, PagesPersec, CommittedBytes, CommitLimit, Timestamp_PerfTime, Frequency_PerfTime FROM Win32_PerfRawData_PerfOS_Memory";
+ public const string VolumeQuery = "SELECT Name, Label, FileSystem, Capacity, FreeSpace FROM Win32_Volume WHERE DriveType = 3";
+ public const string LogicalDiskQuery = "SELECT DeviceID, VolumeName, FileSystem, Size, FreeSpace FROM Win32_LogicalDisk WHERE DriveType = 3";
+ public const string ServiceQuery = "SELECT Name, DisplayName, State, StartMode, DelayedAutoStart, ExitCode FROM Win32_Service";
+ public const string ServiceQueryWithoutDelayed = "SELECT Name, DisplayName, State, StartMode, ExitCode FROM Win32_Service";
+ public const string TaskQuery = "SELECT TaskName, TaskPath, State FROM MSFT_ScheduledTask";
+
+ /// The XPath for the event log: errors and critical events after a time (ISO 8601, UTC).
+ public static string EventXPath(DateTimeOffset since) =>
+ "*[System[(Level=1 or Level=2) and TimeCreated[@SystemTime>'" + since.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture) + "']]]";
+
+ /// The CIM fallback for the event log (EventType 1 = error; critical events are reported as errors).
+ public static string EventQuery(string log, DateTimeOffset since) =>
+ $"SELECT Logfile, SourceName, EventCode, EventType, TimeGenerated, RecordNumber, Message FROM Win32_NTLogEvent WHERE Logfile = '{Wql(log)}' AND EventType = 1 AND TimeGenerated > '{Dmtf(since)}'";
+
+ public static string SingleServiceQuery(string name) =>
+ $"SELECT Name, DisplayName, State, StartMode, ExitCode FROM Win32_Service WHERE Name = '{Wql(name)}'";
+
+ /// A WQL string literal's content: backslash and quote escaped.
+ public static string Wql(string value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ return value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("'", "\\'", StringComparison.Ordinal);
+ }
+
+ /// CIM datetime (DMTF), UTC: yyyymmddHHMMSS.mmmmmm+000.
+ public static string Dmtf(DateTimeOffset value) => value.UtcDateTime.ToString("yyyyMMddHHmmss.ffffff", CultureInfo.InvariantCulture) + "+000";
+
+}
diff --git a/src/SQLInsight.Servers/CimWindowsServerSource.cs b/src/SQLInsight.Servers/CimWindowsServerSource.cs
new file mode 100644
index 0000000..cd2cfdc
--- /dev/null
+++ b/src/SQLInsight.Servers/CimWindowsServerSource.cs
@@ -0,0 +1,416 @@
+using System.Diagnostics.Eventing.Reader;
+using static SQLInsight.Servers.CimQueries;
+using System.Globalization;
+using System.Runtime.Versioning;
+using System.Security.Principal;
+using Microsoft.Management.Infrastructure;
+using Microsoft.Management.Infrastructure.Options;
+using SQLInsight.Core.Servers;
+
+namespace SQLInsight.Servers;
+
+/// How the agent connects to Windows servers (SQLInsight:Servers in the agent's appsettings.json).
+public sealed class ServerConnectionOptions
+{
+ /// Auto (WS-Man, then DCOM), WSMan or DCOM.
+ public string Protocol { get; set; } = "Auto";
+
+ /// How long one CIM operation may take.
+ public TimeSpan OperationTimeout { get; set; } = TimeSpan.FromSeconds(30);
+
+ /// Read event logs with the Event Log service's own protocol (MS-EVEN6), which filters on the server; CIM (Win32_NTLogEvent) is the fallback.
+ public bool EventLogRpc { get; set; } = true;
+}
+
+///
+/// Reads Windows servers with CIM: WS-Man (WinRM, TCP 5985) first, DCOM (RPC, TCP 135 + dynamic ports) if that fails,
+/// as the agent's own Windows identity. Only queries (and Task Scheduler's GetInfo) are used: nothing is changed.
+///
+[SupportedOSPlatform("windows")]
+public sealed class CimWindowsServerSource(ServerConnectionOptions options) : IWindowsServerSource
+{
+ public async Task ConnectAsync(string host, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(host);
+ var account = AgentAccount(host);
+ if (ServerHost.IsLocal(host, Environment.MachineName))
+ {
+ var local = CimSession.Create(null);
+ var session = new Session(local, host, "lokal", null, account, options);
+ try
+ {
+ await session.ProbeAsync(cancellationToken).ConfigureAwait(false);
+ return session;
+ }
+ catch
+ {
+ session.Dispose();
+ throw;
+ }
+ }
+
+ var protocols = options.Protocol.ToUpperInvariant() switch
+ {
+ "WSMAN" or "WS-MAN" => new[] { "WS-Man" },
+ "DCOM" => ["DCOM"],
+ _ => ["WS-Man", "DCOM"],
+ };
+ var failures = new List();
+ foreach (var protocol in protocols)
+ {
+ CimSessionOptions sessionOptions = protocol == "WS-Man"
+ ? new WSManSessionOptions { Timeout = options.OperationTimeout }
+ : new DComSessionOptions { Timeout = options.OperationTimeout, PacketPrivacy = true, PacketIntegrity = true };
+ var note = failures.Count == 0 ? null : $"{failures[0].Message} Agenten bruger derfor {protocol}.";
+ var session = new Session(CimSession.Create(host, sessionOptions), host, protocol, note, account, options);
+ try
+ {
+ await session.ProbeAsync(cancellationToken).ConfigureAwait(false);
+ return session;
+ }
+ catch (ServerAccessException ex)
+ {
+ session.Dispose();
+ failures.Add(ex);
+ }
+ }
+
+ // Access denied says more than "does not answer": report that one first.
+ var best = failures.OrderBy(f => f.Problem switch { ServerAccessProblem.AccessDenied => 0, ServerAccessProblem.Timeout => 1, _ => 2 }).First();
+ var others = failures.Where(f => !ReferenceEquals(f, best)).Select(f => f.Message);
+ throw new ServerAccessException(best.Problem, string.Join(" ", others.Prepend(best.Message)), best) { Code = best.Code };
+ }
+
+ /// The account the agent arrives as on (the computer account for a virtual service account).
+ public static string AgentAccount(string host) =>
+ ServerGrantScript.AccountFor(WindowsIdentity.GetCurrent().Name, host, Environment.MachineName, Environment.UserDomainName);
+
+ internal static DateTimeOffset? ToOffset(object? value)
+ {
+ if (value is not DateTime time || time.Year < 2000)
+ {
+ return null;
+ }
+
+ return time.Kind == DateTimeKind.Utc ? new DateTimeOffset(time) : new DateTimeOffset(DateTime.SpecifyKind(time, DateTimeKind.Local));
+ }
+
+ internal static ulong? UInt64(CimInstance instance, string name) => instance.CimInstanceProperties[name]?.Value switch
+ {
+ null => null,
+ IConvertible v => Convert.ToUInt64(v, CultureInfo.InvariantCulture),
+ _ => null,
+ };
+
+ internal static string? Text(CimInstance instance, string name) => instance.CimInstanceProperties[name]?.Value?.ToString();
+
+ private sealed class Session(CimSession session, string host, string protocol, string? note, string account, ServerConnectionOptions options) : IWindowsServerSession
+ {
+ public string Protocol => protocol;
+
+ public string? ProtocolNote => note;
+
+ public void Dispose() => session.Dispose();
+
+ /// The cheapest query that proves the connection (and the right to root\cimv2).
+ public async Task ProbeAsync(CancellationToken cancellationToken) =>
+ await QueryAsync(CimV2, "SELECT CSName FROM Win32_OperatingSystem", null, cancellationToken).ConfigureAwait(false);
+
+ private CimOperationOptions Options(CancellationToken cancellationToken) => new() { Timeout = options.OperationTimeout, CancellationToken = cancellationToken };
+
+ private Task> QueryAsync(string ns, string query, string? area, CancellationToken cancellationToken) =>
+ Task.Run(() =>
+ {
+ try
+ {
+ return session.QueryInstances(ns, "WQL", query, Options(cancellationToken)).ToList();
+ }
+ catch (CimException ex)
+ {
+ throw Describe(ex, area);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ throw ServerErrors.Describe(ServerErrors.AccessDenied, "AccessDenied", ex.Message, area, host, account, protocol);
+ }
+ }, cancellationToken);
+
+ private ServerAccessException Describe(CimException ex, string? area)
+ {
+ uint? code = ex.ErrorData?.CimInstanceProperties["error_Code"]?.Value is IConvertible c ? Convert.ToUInt32(c, CultureInfo.InvariantCulture) : null;
+ var error = ServerErrors.Describe(code, ex.NativeErrorCode.ToString(), ex.Message, area, host, account, protocol);
+ return new ServerAccessException(error.Problem, error.Message, ex) { Code = error.Code };
+ }
+
+ public async Task GetOperatingSystemAsync(CancellationToken cancellationToken)
+ {
+ var os = (await QueryAsync(CimV2, OperatingSystemQuery, ServerArea.Performance, cancellationToken).ConfigureAwait(false)).FirstOrDefault()
+ ?? throw new ServerAccessException(ServerAccessProblem.Other, $"{host} returnerede intet styresystem (Win32_OperatingSystem).");
+ int? processors = null;
+ try
+ {
+ processors = (await QueryAsync(CimV2, ComputerSystemQuery, ServerArea.Performance, cancellationToken).ConfigureAwait(false))
+ .Select(i => (int?)UInt64(i, "NumberOfLogicalProcessors")).FirstOrDefault();
+ }
+ catch (ServerAccessException)
+ {
+ // The number of processors is a detail; the rest still counts.
+ }
+
+ return new ServerOsInfo
+ {
+ Caption = Text(os, "Caption")?.Trim(),
+ Version = Text(os, "Version"),
+ BuildNumber = Text(os, "BuildNumber"),
+ LastBootUpTime = ToOffset(os.CimInstanceProperties["LastBootUpTime"]?.Value),
+ TotalMemoryMb = UInt64(os, "TotalVisibleMemorySize") is { } kb ? kb / 1024d : null,
+ LogicalProcessors = processors,
+ ComputerName = Text(os, "CSName"),
+ };
+ }
+
+ public async Task GetPerformanceAsync(DateTimeOffset now, CancellationToken cancellationToken)
+ {
+ var processor = (await QueryAsync(CimV2, ProcessorQuery, ServerArea.Performance, cancellationToken).ConfigureAwait(false)).FirstOrDefault();
+ var memory = (await QueryAsync(CimV2, MemoryQuery, ServerArea.Performance, cancellationToken).ConfigureAwait(false)).FirstOrDefault();
+ if (processor is null && memory is null)
+ {
+ // Performance counters come back empty, rather than denied, without Performance Monitor Users.
+ throw new ServerAccessException(ServerAccessProblem.AccessDenied,
+ $"{host} returnerede ingen ydelsestællere: agentens konto ({account}) er sandsynligvis ikke medlem af Performance Monitor Users. Kør tildelingsscriptet på serveren.");
+ }
+
+ return new ServerPerformanceRaw
+ {
+ CapturedAt = now,
+ ProcessorIdle100Ns = processor is null ? null : UInt64(processor, "PercentProcessorTime"),
+ ProcessorTimestamp100Ns = processor is null ? null : UInt64(processor, "Timestamp_Sys100NS"),
+ Pages = memory is null ? null : UInt64(memory, "PagesPersec"),
+ MemoryPerfTime = memory is null ? null : UInt64(memory, "Timestamp_PerfTime"),
+ MemoryPerfFrequency = memory is null ? null : UInt64(memory, "Frequency_PerfTime"),
+ AvailableMb = memory is not null && UInt64(memory, "AvailableMBytes") is { } available ? available : null,
+ CommittedBytes = memory is not null && UInt64(memory, "CommittedBytes") is { } committed ? committed : null,
+ CommitLimit = memory is not null && UInt64(memory, "CommitLimit") is { } limit ? limit : null,
+ };
+ }
+
+ public async Task> GetDisksAsync(CancellationToken cancellationToken)
+ {
+ List volumes;
+ try
+ {
+ volumes = await QueryAsync(CimV2, VolumeQuery, ServerArea.Disks, cancellationToken).ConfigureAwait(false);
+ }
+ catch (ServerAccessException ex) when (ex.Problem is ServerAccessProblem.AccessDenied or ServerAccessProblem.NotAvailable)
+ {
+ volumes = [];
+ }
+
+ var disks = volumes
+ .Select(v => (Name: Text(v, "Name") ?? string.Empty, Volume: v))
+ .Where(v => v.Name.Length > 0 && !v.Name.StartsWith(@"\\?\", StringComparison.Ordinal)) // no drive letter or mount point: system/recovery partitions
+ .Select(v => new ServerDisk(v.Name, Text(v.Volume, "Label"), Text(v.Volume, "FileSystem"), Gb(UInt64(v.Volume, "Capacity")), Gb(UInt64(v.Volume, "FreeSpace"))))
+ .ToList();
+ if (disks.Count > 0)
+ {
+ return disks;
+ }
+
+ // Older or locked-down servers: the logical disks (drive letters only).
+ return (await QueryAsync(CimV2, LogicalDiskQuery, ServerArea.Disks, cancellationToken).ConfigureAwait(false))
+ .Select(d => new ServerDisk((Text(d, "DeviceID") ?? "?") + "\\", Text(d, "VolumeName"), Text(d, "FileSystem"), Gb(UInt64(d, "Size")), Gb(UInt64(d, "FreeSpace"))))
+ .ToList();
+
+ static double Gb(ulong? bytes) => bytes is { } b ? b / 1073741824d : 0;
+ }
+
+ public async Task> GetServicesAsync(CancellationToken cancellationToken)
+ {
+ List services;
+ try
+ {
+ services = await QueryAsync(CimV2, ServiceQuery, ServerArea.Services, cancellationToken).ConfigureAwait(false);
+ }
+ catch (ServerAccessException ex) when (ex.Problem == ServerAccessProblem.Other)
+ {
+ // Windows before Server 2012 has no DelayedAutoStart property.
+ services = await QueryAsync(CimV2, ServiceQueryWithoutDelayed, ServerArea.Services, cancellationToken).ConfigureAwait(false);
+ }
+
+ if (services.Count == 0)
+ {
+ throw new ServerAccessException(ServerAccessProblem.AccessDenied,
+ $"{host} viste ingen tjenester: over netværket må kun administratorer liste tjenester, medmindre agentens konto ({account}) har fået læseret på "
+ + "Service Control Manager. Kør tildelingsscriptet på serveren.");
+ }
+
+ return services.Select(Service).ToList();
+ }
+
+ public async Task GetServiceAsync(string name, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ return (await QueryAsync(CimV2, SingleServiceQuery(name), ServerArea.Services, cancellationToken).ConfigureAwait(false)).Select(Service).FirstOrDefault();
+ }
+
+ private static ServerService Service(CimInstance s) => new(
+ Text(s, "Name") ?? string.Empty,
+ Text(s, "DisplayName") ?? Text(s, "Name") ?? string.Empty,
+ Text(s, "State") ?? "Unknown",
+ Text(s, "StartMode") ?? "Unknown",
+ s.CimInstanceProperties["DelayedAutoStart"]?.Value is true,
+ UInt64(s, "ExitCode") is { } code ? (int)Math.Min(code, int.MaxValue) : null);
+
+ public async Task GetEventsAsync(DateTimeOffset since, int max, CancellationToken cancellationToken)
+ {
+ string? note = null;
+ if (options.EventLogRpc)
+ {
+ try
+ {
+ return await Task.Run(() => ReadWithEventLogService(since, max, cancellationToken), cancellationToken).ConfigureAwait(false);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ throw ServerErrors.Describe(ServerErrors.AccessDenied, "AccessDenied", ex.Message, ServerArea.Events, host, account, protocol);
+ }
+ catch (EventLogException ex)
+ {
+ note = $"Hændelsesloggens egen protokol (RPC) virkede ikke ({ex.Message.Trim()}); loggen læses over CIM i stedet, hvilket er langsommere.";
+ }
+ }
+
+ var entries = new List();
+ var truncated = false;
+ foreach (var log in ServerEventAnalysis.Logs)
+ {
+ var rows = await QueryAsync(CimV2, EventQuery(log, since), ServerArea.Events, cancellationToken).ConfigureAwait(false);
+ truncated |= rows.Count > max;
+ entries.AddRange(rows.Take(max).Select(e => new ServerEventEntry(
+ Text(e, "Logfile") ?? log,
+ Text(e, "SourceName") ?? string.Empty,
+ (long)(UInt64(e, "EventCode") ?? 0),
+ 2,
+ ToOffset(e.CimInstanceProperties["TimeGenerated"]?.Value) ?? since,
+ Text(e, "Message"),
+ (long?)UInt64(e, "RecordNumber"))));
+ }
+
+ return new ServerEventRead(entries.OrderBy(e => e.At).ToList(), "CIM (Win32_NTLogEvent)", truncated, note);
+ }
+
+ private ServerEventRead ReadWithEventLogService(DateTimeOffset since, int max, CancellationToken cancellationToken)
+ {
+ using var logSession = protocol == "lokal" ? new EventLogSession() : new EventLogSession(host);
+ var entries = new List();
+ var truncated = false;
+ foreach (var log in ServerEventAnalysis.Logs)
+ {
+ var query = new EventLogQuery(log, PathType.LogName, EventXPath(since)) { Session = logSession };
+ using var reader = new EventLogReader(query);
+ var newest = new Dictionary<(string, int), EventRecord>();
+ var read = new List<(EventRecord Record, ServerEventEntry Entry)>();
+ var count = 0;
+ for (var record = reader.ReadEvent(); record is not null; record = reader.ReadEvent())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (++count > max)
+ {
+ record.Dispose();
+ truncated = true;
+ break;
+ }
+
+ var entry = new ServerEventEntry(log, record.ProviderName ?? string.Empty, record.Id, record.Level ?? 2,
+ record.TimeCreated is { } created ? new DateTimeOffset(created.ToUniversalTime(), TimeSpan.Zero) : since, null, record.RecordId);
+ read.Add((record, entry));
+ newest[(entry.Source, record.Id)] = record;
+ }
+
+ // The text is only needed once per source and id (the sample): formatting asks the server for the publisher's messages.
+ foreach (var (record, entry) in read)
+ {
+ string? message = null;
+ if (newest.TryGetValue((entry.Source, record.Id), out var sample) && ReferenceEquals(sample, record))
+ {
+ try
+ {
+ message = record.FormatDescription();
+ }
+ catch (EventLogException)
+ {
+ message = null;
+ }
+ }
+
+ entries.Add(entry with { Message = message });
+ record.Dispose();
+ }
+ }
+
+ return new ServerEventRead(entries.OrderBy(e => e.At).ToList(), "Event Log (RPC)", truncated);
+ }
+
+ public async Task GetTasksAsync(Func watch, int maxWatched, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(watch);
+ var all = (await QueryAsync(TaskSchedulerNamespace, TaskQuery, ServerArea.Tasks, cancellationToken).ConfigureAwait(false))
+ .Select(t => new ServerTask
+ {
+ Path = Text(t, "TaskPath") ?? "\\",
+ Name = Text(t, "TaskName") ?? string.Empty,
+ State = (UInt64(t, "State") ?? 0) switch { 1 => "Disabled", 2 => "Queued", 3 => "Ready", 4 => "Running", _ => "Unknown" },
+ })
+ .ToList();
+ var watched = all.Where(t => watch(t.FullName)).ToList();
+ var note = watched.Count > maxWatched ? $"Kun de første {maxWatched} af {watched.Count} opgaver overvåges." : null;
+ var result = new List();
+ foreach (var task in watched.Take(maxWatched))
+ {
+ result.Add(await GetInfoAsync(task, cancellationToken).ConfigureAwait(false));
+ }
+
+ return new ServerTaskRead(result, all.Count, note);
+ }
+
+ /// Last and next run of a task: Task Scheduler's GetInfo, which only reads (as Get-ScheduledTaskInfo).
+ private Task GetInfoAsync(ServerTask task, CancellationToken cancellationToken) =>
+ Task.Run(() =>
+ {
+ try
+ {
+ using var parameters = new CimMethodParametersCollection
+ {
+ CimMethodParameter.Create("TaskName", task.Name, CimType.String, CimFlags.In),
+ CimMethodParameter.Create("TaskPath", task.Path, CimType.String, CimFlags.In),
+ };
+ var result = session.InvokeMethod(TaskSchedulerNamespace, "PS_ScheduledTask", "GetInfoByName", parameters, Options(cancellationToken));
+ if (result.OutParameters["cmdletOutput"]?.Value is not CimInstance info)
+ {
+ return task;
+ }
+
+ return task with
+ {
+ LastRunTime = ToOffset(info.CimInstanceProperties["LastRunTime"]?.Value),
+ LastTaskResult = UInt64(info, "LastTaskResult") is { } r ? (uint)r : null,
+ NextRunTime = ToOffset(info.CimInstanceProperties["NextRunTime"]?.Value),
+ MissedRuns = (int)(UInt64(info, "NumberOfMissedRuns") ?? 0),
+ };
+ }
+ catch (CimException ex)
+ {
+ var error = Describe(ex, ServerArea.Tasks);
+ if (error.Problem == ServerAccessProblem.AccessDenied && protocol == "WS-Man")
+ {
+ throw new ServerAccessException(ServerAccessProblem.AccessDenied,
+ error.Message + " Opgavernes seneste resultat læses med Task Schedulers GetInfo (et CIM-metodekald), som WinRM kun tillader for kontoen, "
+ + "når den også har Execute-ret i WinRM. Uden den: vælg DCOM (SQLInsight:Servers:Protocol), eller giv kontoen ret med winrm configSDDL default.", ex)
+ { Code = error.Code };
+ }
+
+ throw error;
+ }
+ }, cancellationToken);
+ }
+}
diff --git a/src/SQLInsight.Servers/SQLInsight.Servers.csproj b/src/SQLInsight.Servers/SQLInsight.Servers.csproj
new file mode 100644
index 0000000..c070010
--- /dev/null
+++ b/src/SQLInsight.Servers/SQLInsight.Servers.csproj
@@ -0,0 +1,22 @@
+
+
+ SQLInsight.Servers
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SQLInsight.Servers/ServerErrors.cs b/src/SQLInsight.Servers/ServerErrors.cs
new file mode 100644
index 0000000..2bbfc92
--- /dev/null
+++ b/src/SQLInsight.Servers/ServerErrors.cs
@@ -0,0 +1,92 @@
+using System.Globalization;
+using SQLInsight.Core.Servers;
+
+namespace SQLInsight.Servers;
+
+///
+/// Windows' error codes from CIM/WMI, WinRM and the event log, turned into what the user must do (Danish). A missing
+/// right is never just "Access denied": it says which right, and that the grant script gives it.
+///
+public static class ServerErrors
+{
+ public const uint AccessDenied = 0x80070005;
+ public const uint WbemAccessDenied = 0x80041003;
+ public const uint InvalidNamespace = 0x8004100E;
+ public const uint InvalidClass = 0x80041010;
+ public const uint InvalidQuery = 0x80041017;
+ public const uint RpcUnavailable = 0x800706BA;
+ public const uint RpcCallFailed = 0x800706BE;
+ public const uint NetworkPathNotFound = 0x80070035;
+ public const uint LogonFailure = 0x8007052E;
+ public const uint WinRmCannotConnect = 0x80338012;
+ public const uint WinRmAccessDenied = 0x803381A5;
+
+ /// MI_RESULT for an operation that timed out (as MMI reports it).
+ public const uint MiTimeout = 0x40004;
+
+ /// The HRESULT, when known.
+ /// MMI's NativeErrorCode name (AccessDenied, InvalidNamespace …), when known.
+ /// Windows' own message, kept at the end for support.
+ /// The being read, or null for connecting.
+ public static ServerAccessException Describe(uint? code, string? kind, string message, string? area, string host, string? account, string protocol)
+ {
+ ArgumentNullException.ThrowIfNull(host);
+ var hex = code is { } c ? " (0x" + c.ToString("X8", CultureInfo.InvariantCulture) + ")" : string.Empty;
+ var windows = string.IsNullOrWhiteSpace(message) ? string.Empty : " Windows svarede: " + message.Trim() + hex;
+ var who = string.IsNullOrWhiteSpace(account) ? "Agentens konto" : $"Agentens konto ({account})";
+ var what = area is null ? "serveren" : ServerArea.Display(area).ToLowerInvariant();
+
+ if (code is AccessDenied or WbemAccessDenied or WinRmAccessDenied || kind == "AccessDenied" || message.Contains("Access is denied", StringComparison.OrdinalIgnoreCase)
+ || message.Contains("Adgang nægtet", StringComparison.OrdinalIgnoreCase))
+ {
+ var right = area is null
+ ? (protocol == "WS-Man" ? "Remote Management Users og WMI Remote Enable på root\\cimv2" : "Distributed COM Users og WMI Remote Enable på root\\cimv2")
+ : ServerArea.Right(area);
+ return new ServerAccessException(ServerAccessProblem.AccessDenied,
+ $"Adgang nægtet til {what} på {host} over {protocol}: {who} mangler {right}. Kør tildelingsscriptet (grant-windows-server.ps1) som administrator på serveren, "
+ + "og test igen. Agenten skal ikke være lokal administrator." + windows)
+ { Code = code };
+ }
+
+ if (code is InvalidNamespace or InvalidClass || kind is "InvalidNamespace" or "InvalidClass")
+ {
+ return new ServerAccessException(ServerAccessProblem.NotAvailable,
+ $"{Capitalize(what)} kan ikke måles på {host}: Windows-versionen har ikke den WMI-klasse eller det navnerum, SQLInsight bruger "
+ + (area == ServerArea.Tasks ? "(root\\Microsoft\\Windows\\TaskScheduler findes fra Windows Server 2012)." : ".") + windows)
+ { Code = code };
+ }
+
+ if (code is MiTimeout || kind == "Timeout" || message.Contains("timed out", StringComparison.OrdinalIgnoreCase))
+ {
+ return new ServerAccessException(ServerAccessProblem.Timeout,
+ $"{host} svarede ikke i tide over {protocol}. Serveren kan være overbelastet, eller en firewall taber pakkerne." + windows)
+ { Code = code };
+ }
+
+ if (code is RpcUnavailable or RpcCallFailed or NetworkPathNotFound)
+ {
+ return new ServerAccessException(ServerAccessProblem.Unreachable,
+ $"{host} svarer ikke på RPC/DCOM: serveren er slukket, navnet findes ikke, eller firewallen blokerer TCP 135 og de dynamiske RPC-porte "
+ + "(reglerne \"Windows Management Instrumentation (WMI)\" og for hændelsesloggen \"Remote Event Log Management\")." + windows)
+ { Code = code };
+ }
+
+ if (code is WinRmCannotConnect || message.Contains("WinRM", StringComparison.OrdinalIgnoreCase) || message.Contains("WS-Management", StringComparison.OrdinalIgnoreCase))
+ {
+ return new ServerAccessException(ServerAccessProblem.Unreachable,
+ $"{host} svarer ikke på WinRM (WS-Man, TCP 5985): tjenesten Windows Remote Management kører ikke, eller firewallen blokerer porten." + windows)
+ { Code = code };
+ }
+
+ if (code is LogonFailure)
+ {
+ return new ServerAccessException(ServerAccessProblem.AccessDenied,
+ $"{host} afviste agentens logon (Kerberos/NTLM). Er serveren i samme domæne, eller i et domæne med tillid til agentens?" + windows)
+ { Code = code };
+ }
+
+ return new ServerAccessException(ServerAccessProblem.Other, $"{Capitalize(what)} på {host} kunne ikke læses over {protocol}." + windows) { Code = code };
+ }
+
+ private static string Capitalize(string text) => text.Length == 0 ? text : char.ToUpper(text[0], CultureInfo.GetCultureInfo("da-DK")) + text[1..];
+}
diff --git a/src/SQLInsight.Servers/Synthetic/AxBusinessConnector.cs b/src/SQLInsight.Servers/Synthetic/AxBusinessConnector.cs
new file mode 100644
index 0000000..ec3c897
--- /dev/null
+++ b/src/SQLInsight.Servers/Synthetic/AxBusinessConnector.cs
@@ -0,0 +1,191 @@
+using System.Diagnostics;
+using System.Globalization;
+using System.Text;
+
+namespace SQLInsight.Servers.Synthetic;
+
+/// A Dynamics AX .NET Business Connector found on the agent's computer.
+/// "AX 2012" or "AX 2009".
+/// Installed under Program Files (x86): needs the 32-bit Windows PowerShell.
+/// AX 2009's Business Connector is built for .NET 2.0: needs Windows PowerShell's 2.0 engine.
+public sealed record BusinessConnectorInstallation(string Path, string Version, bool Is32Bit, bool NeedsClr2);
+
+public interface IBusinessConnectorLocator
+{
+ BusinessConnectorInstallation? Find();
+}
+
+/// Looks for the Business Connector where the AX 2009 and AX 2012 installers put it (or where SQLInsight:SyntheticChecks:BusinessConnectorPath says).
+public sealed class BusinessConnectorLocator(string? configuredPath = null, Func? exists = null) : IBusinessConnectorLocator
+{
+ public const string FileName = "Microsoft.Dynamics.BusinessConnectorNet.dll";
+
+ private readonly Func _exists = exists ?? File.Exists;
+
+ /// The places looked in, most likely first.
+ public static IReadOnlyList Candidates()
+ {
+ var programFiles = Environment.GetEnvironmentVariable("ProgramW6432") ?? Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
+ var programFilesX86 = Environment.GetEnvironmentVariable("ProgramFiles(x86)") ?? Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
+ return
+ [
+ Path.Combine(programFiles, @"Microsoft Dynamics AX\60\BusinessConnector\Bin", FileName),
+ Path.Combine(programFilesX86, @"Microsoft Dynamics AX\60\BusinessConnector\Bin", FileName),
+ Path.Combine(programFilesX86, @"Microsoft Dynamics AX\60\Client\Bin", FileName),
+ Path.Combine(programFilesX86, @"Microsoft Dynamics AX\50\Client\Bin", FileName),
+ Path.Combine(programFiles, @"Microsoft Dynamics AX\50\Client\Bin", FileName),
+ ];
+ }
+
+ public BusinessConnectorInstallation? Find()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return null;
+ }
+
+ var path = new[] { configuredPath }.Concat(Candidates()).FirstOrDefault(p => !string.IsNullOrWhiteSpace(p) && _exists(p!));
+ return path is null ? null : Describe(path);
+ }
+
+ public static BusinessConnectorInstallation Describe(string path)
+ {
+ ArgumentNullException.ThrowIfNull(path);
+ var ax2009 = path.Contains(@"\50\", StringComparison.OrdinalIgnoreCase);
+ return new BusinessConnectorInstallation(path, ax2009 ? "AX 2009" : "AX 2012", path.Contains("(x86)", StringComparison.OrdinalIgnoreCase), ax2009);
+ }
+}
+
+public sealed record ProcessResult(int ExitCode, string Output, string Error, bool TimedOut);
+
+public interface IProcessRunner
+{
+ Task RunAsync(string fileName, IReadOnlyList arguments, IReadOnlyDictionary environment, TimeSpan timeout,
+ CancellationToken cancellationToken);
+}
+
+/// Runs a process and kills it (with its children) when it takes too long.
+public sealed class ProcessRunner : IProcessRunner
+{
+ public async Task RunAsync(string fileName, IReadOnlyList arguments, IReadOnlyDictionary environment, TimeSpan timeout,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(arguments);
+ ArgumentNullException.ThrowIfNull(environment);
+ var start = new ProcessStartInfo(fileName)
+ {
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8,
+ };
+ foreach (var argument in arguments)
+ {
+ start.ArgumentList.Add(argument);
+ }
+
+ foreach (var (key, value) in environment)
+ {
+ start.Environment[key] = value;
+ }
+
+ using var process = Process.Start(start) ?? throw new InvalidOperationException($"{fileName} kunne ikke startes.");
+ var output = process.StandardOutput.ReadToEndAsync(cancellationToken);
+ var error = process.StandardError.ReadToEndAsync(cancellationToken);
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ deadline.CancelAfter(timeout);
+ try
+ {
+ await process.WaitForExitAsync(deadline.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ process.Kill(entireProcessTree: true);
+ return new ProcessResult(-1, string.Empty, string.Empty, TimedOut: true);
+ }
+
+ return new ProcessResult(process.ExitCode, await output.ConfigureAwait(false), await error.ConfigureAwait(false), TimedOut: false);
+ }
+}
+
+///
+/// Logs on to Dynamics AX through the .NET Business Connector, in a separate Windows PowerShell process (the Business
+/// Connector is a .NET Framework assembly and cannot be loaded into the agent). Logs on as the agent's Windows account,
+/// which must be an AX user, and logs off at once. No password is involved.
+///
+public static class AxLogonClient
+{
+ public const string OkPrefix = "SQLINSIGHT-AX-OK ";
+ public const string ErrorPrefix = "SQLINSIGHT-AX-ERROR ";
+
+ public static string Script()
+ {
+ using var stream = typeof(AxLogonClient).Assembly.GetManifestResourceStream("SQLInsight.Servers.ax-logon.ps1")
+ ?? throw new InvalidOperationException("ax-logon.ps1 mangler i programmet.");
+ using var reader = new StreamReader(stream);
+ return reader.ReadToEnd();
+ }
+
+ /// The PowerShell to start and its arguments: 32-bit for a 32-bit Business Connector, the 2.0 engine for AX 2009.
+ public static (string FileName, IReadOnlyList Arguments) Command(BusinessConnectorInstallation installation, string windowsDirectory)
+ {
+ ArgumentNullException.ThrowIfNull(installation);
+ var folder = installation.Is32Bit && System.Environment.Is64BitOperatingSystem ? "SysWOW64" : "System32";
+ var powershell = Path.Combine(windowsDirectory, folder, @"WindowsPowerShell\v1.0\powershell.exe");
+ var arguments = new List();
+ if (installation.NeedsClr2)
+ {
+ arguments.AddRange(["-Version", "2"]);
+ }
+
+ arguments.AddRange(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", Convert.ToBase64String(Encoding.Unicode.GetBytes(Script()))]);
+ return (powershell, arguments);
+ }
+
+ public static IReadOnlyDictionary Environment(BusinessConnectorInstallation installation, string? company, string objectServer, string? configuration)
+ {
+ ArgumentNullException.ThrowIfNull(installation);
+ return new Dictionary(StringComparer.Ordinal)
+ {
+ ["SQLINSIGHT_AX_DLL"] = installation.Path,
+ ["SQLINSIGHT_AX_COMPANY"] = company ?? string.Empty,
+ ["SQLINSIGHT_AX_SERVER"] = objectServer,
+ ["SQLINSIGHT_AX_CONFIG"] = configuration ?? string.Empty,
+ };
+ }
+
+ /// What the helper reported: ok with the logon time, or the error (Danish).
+ public static (bool Ok, int? LogonMs, string Message) Parse(ProcessResult result, BusinessConnectorInstallation installation)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ ArgumentNullException.ThrowIfNull(installation);
+ if (result.TimedOut)
+ {
+ return (false, null, "Logon gennem Business Connector svarede ikke i tide; processen blev stoppet.");
+ }
+
+ var lines = result.Output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (lines.FirstOrDefault(l => l.StartsWith(OkPrefix, StringComparison.Ordinal)) is { } ok)
+ {
+ return (true, int.TryParse(ok[OkPrefix.Length..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ms) ? ms : null, "Logon i AX lykkedes.");
+ }
+
+ var error = lines.FirstOrDefault(l => l.StartsWith(ErrorPrefix, StringComparison.Ordinal))?[ErrorPrefix.Length..]
+ ?? (string.IsNullOrWhiteSpace(result.Error) ? $"Business Connector-processen sluttede med kode {result.ExitCode}." : result.Error.Trim());
+ if (installation.NeedsClr2 && (error.Contains("v2.0.50727", StringComparison.OrdinalIgnoreCase) || error.Contains("Version 2", StringComparison.OrdinalIgnoreCase)
+ || error.Contains("mixed mode", StringComparison.OrdinalIgnoreCase)))
+ {
+ return (false, null, "AX 2009's Business Connector kræver Windows PowerShell 2.0-motoren (.NET Framework 3.5), som ikke er installeret på agentens server: " + error);
+ }
+
+ if (error.Contains("not a recognized user", StringComparison.OrdinalIgnoreCase) || error.Contains("ikke en kendt bruger", StringComparison.OrdinalIgnoreCase)
+ || error.Contains("no access", StringComparison.OrdinalIgnoreCase))
+ {
+ return (false, null, "AOS'en svarer, men agentens konto er ikke bruger i AX (eller ikke i firmaet). Opret kontoen som AX-bruger, eller slå logon-testen fra. AX svarede: " + error);
+ }
+
+ return (false, null, "Logon i AX fejlede: " + error);
+ }
+}
diff --git a/src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs b/src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs
new file mode 100644
index 0000000..4533566
--- /dev/null
+++ b/src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs
@@ -0,0 +1,460 @@
+using System.Data.Common;
+using System.Globalization;
+using System.Net;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Authentication;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using SQLInsight.Connectivity;
+using SQLInsight.Core.Configuration;
+using SQLInsight.Core.CustomRules;
+using SQLInsight.Core.SyntheticChecks;
+
+namespace SQLInsight.Servers.Synthetic;
+
+/// One run of a check: its settings, the opened SQL login password (if any) and how long it may take.
+public sealed record SyntheticCheckRun(string Kind, SyntheticCheckSettings Settings, TimeSpan Timeout, string? Password = null);
+
+///
+/// Runs synthetic checks the way a user or an application would: connect to a port, fetch a page, log on to SQL Server
+/// and run a read-only query, resolve a name, log on to Dynamics AX. Nothing is changed anywhere; a failure is the
+/// check's result (with a Danish explanation), never an exception.
+///
+public sealed class SyntheticCheckRunner(ISqlConnectionFactory sqlConnections, IBusinessConnectorLocator businessConnector, IProcessRunner processes, TimeProvider time)
+{
+ public const string UserAgent = "SQLInsight-Pro-SyntheticCheck/1.5";
+
+ /// At most this much of an HTTP answer is read when looking for the expected text.
+ public const int MaxBodyBytes = 1024 * 1024;
+
+ public async Task RunAsync(SyntheticCheckRun run, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(run);
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ deadline.CancelAfter(run.Timeout);
+ var at = time.GetUtcNow();
+ var started = time.GetTimestamp();
+ try
+ {
+ return run.Kind switch
+ {
+ SyntheticCheckKind.Tcp => await TcpAsync(run, at, started, deadline.Token).ConfigureAwait(false),
+ SyntheticCheckKind.Http => await HttpAsync(run, at, started, deadline.Token).ConfigureAwait(false),
+ SyntheticCheckKind.Sql => await SqlAsync(run, at, started, deadline.Token).ConfigureAwait(false),
+ SyntheticCheckKind.Dns => await DnsAsync(run, at, started, deadline.Token).ConfigureAwait(false),
+ SyntheticCheckKind.AxLogon => await AxAsync(run, at, started, deadline.Token).ConfigureAwait(false),
+ _ => Failed(at, 0, $"Ukendt type tjek: {run.Kind}."),
+ };
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return Failed(at, Elapsed(started), $"Intet svar inden for {run.Timeout.TotalSeconds.ToString("0", CultureInfo.InvariantCulture)} sekunder.");
+ }
+ }
+
+ private int Elapsed(long started) => (int)Math.Min(int.MaxValue, time.GetElapsedTime(started).TotalMilliseconds);
+
+ private static SyntheticCheckResult Failed(DateTimeOffset at, int ms, string message, IReadOnlyList? details = null) =>
+ new() { At = at, Success = false, DurationMs = ms, Message = message, Details = details ?? [] };
+
+ // ---- TCP --------------------------------------------------------------------------------------------------
+
+ private async Task TcpAsync(SyntheticCheckRun run, DateTimeOffset at, long started, CancellationToken cancellationToken)
+ {
+ var host = run.Settings.Host!;
+ var port = run.Settings.Port!.Value;
+ var (ok, ms, message) = await ConnectAsync(host, port, started, cancellationToken).ConfigureAwait(false);
+ return new SyntheticCheckResult
+ {
+ At = at,
+ Success = ok,
+ DurationMs = ms,
+ Message = message,
+ Details = [new SyntheticCheckDetail("Mål", $"{host}:{port.ToString(CultureInfo.InvariantCulture)}")],
+ };
+ }
+
+ private async Task<(bool Ok, int Ms, string Message)> ConnectAsync(string host, int port, long started, CancellationToken cancellationToken)
+ {
+ using var client = new TcpClient();
+ try
+ {
+ await client.ConnectAsync(host, port, cancellationToken).ConfigureAwait(false);
+ var ms = Elapsed(started);
+ return (true, ms, $"Port {port.ToString(CultureInfo.InvariantCulture)} på {host} svarede på {ms.ToString(CultureInfo.InvariantCulture)} ms.");
+ }
+ catch (SocketException ex)
+ {
+ return (false, Elapsed(started), SocketText(ex, host, port));
+ }
+ }
+
+ internal static string SocketText(SocketException exception, string host, int port) => exception.SocketErrorCode switch
+ {
+ SocketError.ConnectionRefused =>
+ $"Forbindelsen til {host}:{port.ToString(CultureInfo.InvariantCulture)} blev afvist: intet lytter på porten (tjenesten kører ikke eller lytter et andet sted), eller en firewall afviser.",
+ SocketError.TimedOut => $"{host}:{port.ToString(CultureInfo.InvariantCulture)} svarede ikke: serveren er nede, eller en firewall taber pakkerne.",
+ SocketError.HostNotFound or SocketError.NoData or SocketError.TryAgain => $"Navnet {host} kunne ikke slås op i DNS.",
+ SocketError.HostUnreachable or SocketError.NetworkUnreachable => $"Der er ingen netværksvej til {host}.",
+ _ => $"{host}:{port.ToString(CultureInfo.InvariantCulture)} kunne ikke nås: {exception.Message}",
+ };
+
+ // ---- HTTP -------------------------------------------------------------------------------------------------
+
+ private async Task HttpAsync(SyntheticCheckRun run, DateTimeOffset at, long started, CancellationToken cancellationToken)
+ {
+ var settings = run.Settings;
+ var url = settings.Url!;
+ X509Certificate2? certificate = null;
+ SslPolicyErrors certificateErrors = SslPolicyErrors.None;
+ using var handler = new SocketsHttpHandler
+ {
+ AllowAutoRedirect = true,
+ MaxAutomaticRedirections = 5,
+ ConnectTimeout = run.Timeout,
+ PooledConnectionLifetime = TimeSpan.Zero,
+ UseCookies = false,
+ Credentials = settings.UseWindowsAuthentication ? CredentialCache.DefaultCredentials : null,
+ SslOptions = new SslClientAuthenticationOptions
+ {
+ RemoteCertificateValidationCallback = (_, cert, _, errors) =>
+ {
+ if (cert is not null && certificate is null)
+ {
+ certificate = new X509Certificate2(cert);
+ }
+
+ certificateErrors |= errors;
+ return errors == SslPolicyErrors.None || settings.AllowInvalidCertificate;
+ },
+ },
+ };
+ using var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ request.Headers.UserAgent.ParseAdd(UserAgent);
+ var details = new List { new("Adresse", url.ToString()) };
+ try
+ {
+ using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ var status = (int)response.StatusCode;
+ details.Add(new SyntheticCheckDetail("HTTP-status", status.ToString(CultureInfo.InvariantCulture) + " " + response.ReasonPhrase));
+ var expected = settings.ExpectedStatus.Count > 0 ? settings.ExpectedStatus.Contains(status) : status is >= 200 and < 400;
+ string? missingText = null;
+ if (expected && !string.IsNullOrEmpty(settings.ExpectedText))
+ {
+ var body = await ReadBodyAsync(response, cancellationToken).ConfigureAwait(false);
+ if (!body.Contains(settings.ExpectedText, StringComparison.OrdinalIgnoreCase))
+ {
+ missingText = settings.ExpectedText;
+ }
+ }
+
+ var ms = Elapsed(started);
+ details.Add(new SyntheticCheckDetail("Svartid", ms.ToString(CultureInfo.InvariantCulture) + " ms"));
+ AddCertificate(details, certificate, certificateErrors);
+ var message = !expected
+ ? $"Svarede {status.ToString(CultureInfo.InvariantCulture)} {response.ReasonPhrase}, forventede {(settings.ExpectedStatus.Count > 0 ? string.Join(" eller ", settings.ExpectedStatus) : "200–399")}."
+ : missingText is not null
+ ? $"Svarede {status.ToString(CultureInfo.InvariantCulture)}, men siden indeholdt ikke \"{missingText}\" (de første {MaxBodyBytes / 1024} KB blev læst)."
+ : $"Svarede {status.ToString(CultureInfo.InvariantCulture)} på {ms.ToString(CultureInfo.InvariantCulture)} ms.";
+ return new SyntheticCheckResult
+ {
+ At = at,
+ Success = expected && missingText is null,
+ DurationMs = ms,
+ Message = message,
+ Details = details,
+ CertificateExpiresAt = certificate is null ? null : new DateTimeOffset(certificate.NotAfter.ToUniversalTime(), TimeSpan.Zero),
+ CertificateSubject = certificate?.Subject,
+ };
+ }
+ catch (HttpRequestException ex)
+ {
+ AddCertificate(details, certificate, certificateErrors);
+ var message = ex.InnerException is AuthenticationException || certificateErrors != SslPolicyErrors.None
+ ? $"TLS-forbindelsen blev afvist: certifikatet er ikke gyldigt ({CertificateText(certificateErrors)}). Er det et selvsigneret certifikat, kan \"Accepter ugyldigt certifikat\" slås til; udløbsdatoen tjekkes stadig."
+ : ex.InnerException is SocketException socket ? SocketText(socket, url.Host, url.Port) : $"Forespørgslen fejlede: {ex.Message}";
+ return new SyntheticCheckResult
+ {
+ At = at,
+ Success = false,
+ DurationMs = Elapsed(started),
+ Message = message,
+ Details = details,
+ CertificateExpiresAt = certificate is null ? null : new DateTimeOffset(certificate.NotAfter.ToUniversalTime(), TimeSpan.Zero),
+ CertificateSubject = certificate?.Subject,
+ };
+ }
+ finally
+ {
+ certificate?.Dispose();
+ }
+ }
+
+ private static async Task ReadBodyAsync(HttpResponseMessage response, CancellationToken cancellationToken)
+ {
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ var buffer = new byte[MaxBodyBytes];
+ var total = 0;
+ int read;
+ while (total < buffer.Length && (read = await stream.ReadAsync(buffer.AsMemory(total), cancellationToken).ConfigureAwait(false)) > 0)
+ {
+ total += read;
+ }
+
+ var charset = response.Content.Headers.ContentType?.CharSet;
+ Encoding encoding;
+ try
+ {
+ encoding = string.IsNullOrWhiteSpace(charset) ? Encoding.UTF8 : Encoding.GetEncoding(charset.Trim('"'));
+ }
+ catch (ArgumentException)
+ {
+ encoding = Encoding.UTF8;
+ }
+
+ return encoding.GetString(buffer, 0, total);
+ }
+
+ private static void AddCertificate(List details, X509Certificate2? certificate, SslPolicyErrors errors)
+ {
+ if (certificate is null)
+ {
+ return;
+ }
+
+ details.Add(new SyntheticCheckDetail("Certifikat", certificate.Subject));
+ details.Add(new SyntheticCheckDetail("Certifikat udløber", certificate.NotAfter.ToUniversalTime().ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture)));
+ if (errors != SslPolicyErrors.None)
+ {
+ details.Add(new SyntheticCheckDetail("Certifikatfejl", CertificateText(errors)));
+ }
+ }
+
+ internal static string CertificateText(SslPolicyErrors errors)
+ {
+ var problems = new List();
+ if (errors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch))
+ {
+ problems.Add("navnet passer ikke til adressen");
+ }
+
+ if (errors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors))
+ {
+ problems.Add("udstederen er ikke betroet, eller certifikatet er udløbet eller tilbagekaldt");
+ }
+
+ if (errors.HasFlag(SslPolicyErrors.RemoteCertificateNotAvailable))
+ {
+ problems.Add("serveren sendte intet certifikat");
+ }
+
+ return problems.Count == 0 ? "ukendt fejl" : string.Join(", ", problems);
+ }
+
+ // ---- SQL Server -------------------------------------------------------------------------------------------
+
+ private async Task SqlAsync(SyntheticCheckRun run, DateTimeOffset at, long started, CancellationToken cancellationToken)
+ {
+ var settings = run.Settings;
+ var query = string.IsNullOrWhiteSpace(settings.Query) ? SyntheticCheckSettings.DefaultQuery : settings.Query.Trim();
+
+ // Checked when saved; checked again here, so nothing but a single read-only SELECT ever runs.
+ if (CustomRuleDefinition.QueryProblems(query).FirstOrDefault() is { } problem)
+ {
+ return Failed(at, 0, "Forespørgslen køres ikke: " + problem);
+ }
+
+ var dataSource = ServerAddress.DataSource(settings.Server!, settings.Port);
+ var details = new List { new("Server", dataSource), new("Login", settings.Authentication == "SqlLogin" ? settings.UserName! : "agentens Windows-konto") };
+ if (settings.Authentication == "SqlLogin" && string.IsNullOrEmpty(run.Password))
+ {
+ return Failed(at, 0, "Der er ikke gemt en adgangskode til SQL-login'et.", details);
+ }
+
+ OpenedConnection opened;
+ try
+ {
+ opened = await sqlConnections.OpenAsync(new ConnectionTarget
+ {
+ Server = dataSource,
+ Authentication = settings.Authentication == "SqlLogin" ? AuthenticationMode.SqlLogin : AuthenticationMode.Windows,
+ UserName = settings.Authentication == "SqlLogin" ? settings.UserName : null,
+ Password = run.Password,
+ Encryption = settings.Encryption == "Optional" ? EncryptionMode.Optional : EncryptionMode.Mandatory,
+ TrustServerCertificate = settings.TrustServerCertificate,
+ ConnectTimeout = run.Timeout,
+ }, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is DbException or InvalidOperationException or AuthenticationException)
+ {
+ return Failed(at, Elapsed(started), "Login på SQL Server fejlede: " + ex.Message, details);
+ }
+
+ await using (opened.ConfigureAwait(false))
+ {
+ var connectMs = Elapsed(started);
+ details.Add(new SyntheticCheckDetail("Login tog", connectMs.ToString(CultureInfo.InvariantCulture) + " ms"));
+ try
+ {
+ var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(run.Timeout.TotalSeconds));
+ if (!string.IsNullOrWhiteSpace(settings.Database))
+ {
+ await ExecuteAsync(opened.Connection, "USE [" + settings.Database.Replace("]", "]]", StringComparison.Ordinal) + "];", timeoutSeconds, cancellationToken).ConfigureAwait(false);
+ }
+
+ // A transaction that is always rolled back: a second guard on top of the read-only check of the query.
+ await ExecuteAsync(opened.Connection, "BEGIN TRANSACTION;", timeoutSeconds, cancellationToken).ConfigureAwait(false);
+ var queryStarted = time.GetTimestamp();
+ var rows = 0;
+ try
+ {
+ await using var command = opened.Connection.CreateCommand();
+ command.CommandText = query;
+ command.CommandTimeout = timeoutSeconds;
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+ while (rows < 10000 && await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ rows++;
+ }
+ }
+ finally
+ {
+ await ExecuteAsync(opened.Connection, "IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;", timeoutSeconds, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ var queryMs = (int)time.GetElapsedTime(queryStarted).TotalMilliseconds;
+ var ms = Elapsed(started);
+ details.Add(new SyntheticCheckDetail("Forespørgsel tog", queryMs.ToString(CultureInfo.InvariantCulture) + " ms"));
+ details.Add(new SyntheticCheckDetail("Rækker", rows.ToString(CultureInfo.InvariantCulture)));
+ return new SyntheticCheckResult
+ {
+ At = at,
+ Success = true,
+ DurationMs = ms,
+ Message = $"Login og forespørgsel lykkedes på {ms.ToString(CultureInfo.InvariantCulture)} ms (forespørgslen {queryMs.ToString(CultureInfo.InvariantCulture)} ms).",
+ Details = details,
+ };
+ }
+ catch (DbException ex)
+ {
+ return Failed(at, Elapsed(started), "Forespørgslen fejlede: " + ex.Message, details);
+ }
+ }
+ }
+
+ private static async Task ExecuteAsync(DbConnection connection, string sql, int timeoutSeconds, CancellationToken cancellationToken)
+ {
+ await using var command = connection.CreateCommand();
+ command.CommandText = sql;
+ command.CommandTimeout = timeoutSeconds;
+ await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ // ---- DNS --------------------------------------------------------------------------------------------------
+
+ private async Task DnsAsync(SyntheticCheckRun run, DateTimeOffset at, long started, CancellationToken cancellationToken)
+ {
+ var host = run.Settings.Host!;
+ IPAddress[] addresses;
+ try
+ {
+ addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false);
+ }
+ catch (SocketException ex)
+ {
+ return Failed(at, Elapsed(started), $"Navnet {host} kunne ikke slås op: {ex.Message}");
+ }
+
+ var ms = Elapsed(started);
+ var found = addresses.Select(a => a.ToString()).ToList();
+ var details = new List { new("Navn", host), new("Adresser", found.Count == 0 ? "ingen" : string.Join(", ", found)) };
+ var expected = run.Settings.ExpectedAddresses;
+ if (found.Count == 0)
+ {
+ return Failed(at, ms, $"Navnet {host} gav ingen adresser.", details);
+ }
+
+ if (expected.Count > 0 && !found.Any(f => expected.Any(e => IPAddress.TryParse(e, out var x) && x.ToString() == f)))
+ {
+ return Failed(at, ms, $"{host} peger på {string.Join(", ", found)}, forventede {string.Join(" eller ", expected)}.", details);
+ }
+
+ return new SyntheticCheckResult { At = at, Success = true, DurationMs = ms, Message = $"{host} blev slået op på {ms.ToString(CultureInfo.InvariantCulture)} ms.", Details = details };
+ }
+
+ // ---- Dynamics AX ------------------------------------------------------------------------------------------
+
+ private async Task AxAsync(SyntheticCheckRun run, DateTimeOffset at, long started, CancellationToken cancellationToken)
+ {
+ var settings = run.Settings;
+ var host = settings.Host!;
+ var port = settings.Port ?? SyntheticCheckSettings.DefaultAosPort;
+ var objectServer = string.IsNullOrWhiteSpace(settings.ObjectServer) ? $"{host}:{port.ToString(CultureInfo.InvariantCulture)}" : settings.ObjectServer.Trim();
+ var details = new List { new("AOS", $"{host}:{port.ToString(CultureInfo.InvariantCulture)}") };
+ var (portOk, portMs, portMessage) = await ConnectAsync(host, port, started, cancellationToken).ConfigureAwait(false);
+ details.Add(new SyntheticCheckDetail("AOS-port", portOk ? $"svarer ({portMs.ToString(CultureInfo.InvariantCulture)} ms)" : "svarer ikke"));
+ if (!portOk)
+ {
+ return Failed(at, portMs, "AOS-porten svarer ikke. " + portMessage, details);
+ }
+
+ if (!settings.BusinessConnectorLogon)
+ {
+ return new SyntheticCheckResult
+ {
+ At = at, Success = true, DurationMs = portMs, Partial = true, Details = details,
+ Message = $"AOS-porten svarer ({portMs.ToString(CultureInfo.InvariantCulture)} ms). Kun porten testes: logon gennem Business Connector er slået fra for tjekket.",
+ };
+ }
+
+ var installation = businessConnector.Find();
+ if (installation is null)
+ {
+ details.Add(new SyntheticCheckDetail("Business Connector", "ikke installeret på agentens server"));
+ return new SyntheticCheckResult
+ {
+ At = at, Success = true, DurationMs = portMs, Partial = true, Details = details,
+ Message = $"AOS-porten svarer ({portMs.ToString(CultureInfo.InvariantCulture)} ms). Kun porten er testet: .NET Business Connector (AX 2009/2012) er ikke installeret "
+ + "på agentens server, så et AX-logon kan ikke prøves.",
+ };
+ }
+
+ details.Add(new SyntheticCheckDetail("Business Connector", $"{installation.Version} ({installation.Path})"));
+ details.Add(new SyntheticCheckDetail("Object server", objectServer));
+ if (!string.IsNullOrWhiteSpace(settings.Company))
+ {
+ details.Add(new SyntheticCheckDetail("Firma", settings.Company));
+ }
+
+ var (file, arguments) = AxLogonClient.Command(installation, Environment.GetFolderPath(Environment.SpecialFolder.Windows));
+ var remaining = run.Timeout - time.GetElapsedTime(started);
+ ProcessResult result;
+ try
+ {
+ result = await processes.RunAsync(file, arguments, AxLogonClient.Environment(installation, settings.Company, objectServer, settings.Configuration),
+ remaining > TimeSpan.FromSeconds(1) ? remaining : TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
+ {
+ return Failed(at, Elapsed(started), "Windows PowerShell til Business Connector kunne ikke startes: " + ex.Message, details);
+ }
+
+ var (ok, logonMs, message) = AxLogonClient.Parse(result, installation);
+ if (logonMs is { } m)
+ {
+ details.Add(new SyntheticCheckDetail("Logon tog", m.ToString(CultureInfo.InvariantCulture) + " ms"));
+ }
+
+ var ms = Elapsed(started);
+ return new SyntheticCheckResult
+ {
+ At = at,
+ Success = ok,
+ DurationMs = ms,
+ Message = ok ? $"AOS-porten svarer, og logon i AX lykkedes på {(logonMs ?? ms).ToString(CultureInfo.InvariantCulture)} ms." : message,
+ Details = details,
+ };
+ }
+}
diff --git a/src/SQLInsight.Servers/Synthetic/ax-logon.ps1 b/src/SQLInsight.Servers/Synthetic/ax-logon.ps1
new file mode 100644
index 0000000..bf1ce54
--- /dev/null
+++ b/src/SQLInsight.Servers/Synthetic/ax-logon.ps1
@@ -0,0 +1,24 @@
+# SQLInsight Pro: logon to Dynamics AX through the .NET Business Connector, run by the agent in its own process.
+# Logs on as the agent's Windows account, logs off again, and writes one line: SQLINSIGHT-AX-OK or SQLINSIGHT-AX-ERROR .
+# Reads nothing in AX and changes nothing. Parameters come in environment variables (no quoting problems):
+# SQLINSIGHT_AX_DLL path to Microsoft.Dynamics.BusinessConnectorNet.dll
+# SQLINSIGHT_AX_COMPANY company (DataAreaId), or empty for the user's default
+# SQLINSIGHT_AX_SERVER object server, e.g. 01@aos01:2712 or aos01:2712
+# SQLINSIGHT_AX_CONFIG AX client configuration, or empty
+$ErrorActionPreference = 'Stop'
+function Value([string] $name) { $v = [Environment]::GetEnvironmentVariable($name); if ([string]::IsNullOrEmpty($v)) { return $null } else { return $v } }
+try {
+ Add-Type -Path (Value 'SQLINSIGHT_AX_DLL')
+ $ax = New-Object Microsoft.Dynamics.BusinessConnectorNet.Axapta
+ $watch = [System.Diagnostics.Stopwatch]::StartNew()
+ $ax.Logon((Value 'SQLINSIGHT_AX_COMPANY'), $null, (Value 'SQLINSIGHT_AX_SERVER'), (Value 'SQLINSIGHT_AX_CONFIG'))
+ $ms = $watch.ElapsedMilliseconds
+ try { [void] $ax.Logoff() } catch { }
+ Write-Output ('SQLINSIGHT-AX-OK ' + $ms)
+ exit 0
+} catch {
+ $e = $_.Exception
+ while ($e.InnerException) { $e = $e.InnerException }
+ Write-Output ('SQLINSIGHT-AX-ERROR ' + ($e.Message -replace '\s+', ' '))
+ exit 1
+}
diff --git a/src/SQLInsight.Servers/WindowsServerAccess.cs b/src/SQLInsight.Servers/WindowsServerAccess.cs
new file mode 100644
index 0000000..106c5ee
--- /dev/null
+++ b/src/SQLInsight.Servers/WindowsServerAccess.cs
@@ -0,0 +1,106 @@
+using SQLInsight.Core.Servers;
+
+namespace SQLInsight.Servers;
+
+/// Opens read-only sessions to Windows servers, as the agent's own identity.
+public interface IWindowsServerSource
+{
+ /// Connects to : WS-Man (WinRM) first, DCOM if that fails, or locally for the agent's own computer.
+ /// The server could not be reached; the message says why (Danish).
+ Task ConnectAsync(string host, CancellationToken cancellationToken);
+}
+
+///
+/// One connection to a Windows server. Every method only reads: no method changes, starts or stops anything. Each
+/// throws with the exact reason when its area cannot be read.
+///
+public interface IWindowsServerSession : IDisposable
+{
+ /// "WS-Man", "DCOM" or "lokal".
+ string Protocol { get; }
+
+ /// Why the preferred protocol was not used (Danish), when it was not.
+ string? ProtocolNote { get; }
+
+ Task GetOperatingSystemAsync(CancellationToken cancellationToken);
+
+ Task GetPerformanceAsync(DateTimeOffset now, CancellationToken cancellationToken);
+
+ Task> GetDisksAsync(CancellationToken cancellationToken);
+
+ Task> GetServicesAsync(CancellationToken cancellationToken);
+
+ /// One service, or null when it does not exist (or cannot be seen).
+ Task GetServiceAsync(string name, CancellationToken cancellationToken);
+
+ /// Errors and critical events in the System and Application logs since , oldest first.
+ Task GetEventsAsync(DateTimeOffset since, int max, CancellationToken cancellationToken);
+
+ /// Scheduled tasks; run information only for the ones selects (at most ).
+ Task GetTasksAsync(Func watch, int maxWatched, CancellationToken cancellationToken);
+}
+
+/// How the logs were read: "Event Log (RPC)" or "CIM (Win32_NTLogEvent)".
+/// More events than max: the rest is read next time.
+public sealed record ServerEventRead(IReadOnlyList Entries, string Method, bool Truncated, string? Note = null);
+
+/// Every task the agent could see.
+public sealed record ServerTaskRead(IReadOnlyList Watched, int Visible, string? Note = null);
+
+public enum ServerAccessProblem
+{
+ /// The server does not answer (name, network, firewall, WinRM/RPC not running).
+ Unreachable,
+
+ /// The server answers, but the agent's account may not read this.
+ AccessDenied,
+
+ /// The server does not have it (an older Windows without the class or namespace).
+ NotAvailable,
+
+ /// The server did not answer in time.
+ Timeout,
+
+ /// The agent cannot do it at all (not on Windows).
+ NotSupported,
+
+ Other,
+}
+
+/// A Windows server or one of its areas could not be read. The message is Danish and says exactly why and what to do.
+public sealed class ServerAccessException : Exception
+{
+ public ServerAccessException()
+ {
+ }
+
+ public ServerAccessException(string message)
+ : base(message)
+ {
+ }
+
+ public ServerAccessException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+
+ public ServerAccessException(ServerAccessProblem problem, string message, Exception? innerException = null)
+ : base(message, innerException)
+ {
+ Problem = problem;
+ }
+
+ public ServerAccessProblem Problem { get; } = ServerAccessProblem.Other;
+
+ /// The Windows error code (HRESULT) when there was one, for support.
+ public uint? Code { get; init; }
+}
+
+/// For an agent that does not run on Windows: Windows servers cannot be read from Linux.
+public sealed class UnsupportedWindowsServerSource : IWindowsServerSource
+{
+ public const string Reason = "Windows-servere kan kun overvåges, når SQLInsight Agent kører på Windows (CIM/WMI og hændelseslog er Windows-protokoller).";
+
+ public Task ConnectAsync(string host, CancellationToken cancellationToken) =>
+ throw new ServerAccessException(ServerAccessProblem.NotSupported, Reason);
+}
diff --git a/src/SQLInsight.Servers/WindowsServerTester.cs b/src/SQLInsight.Servers/WindowsServerTester.cs
new file mode 100644
index 0000000..f65ce8c
--- /dev/null
+++ b/src/SQLInsight.Servers/WindowsServerTester.cs
@@ -0,0 +1,99 @@
+using SQLInsight.Core.Servers;
+
+namespace SQLInsight.Servers;
+
+///
+/// Tests a Windows server the way the agent will read it: connects as the agent's identity and tries every area once,
+/// so the web UI can show exactly which rights are missing. Reads only.
+///
+public static class WindowsServerTester
+{
+ public static async Task TestAsync(IWindowsServerSource source, string host, string? account, DateTimeOffset now, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+ ArgumentException.ThrowIfNullOrWhiteSpace(host);
+ IWindowsServerSession session;
+ try
+ {
+ session = await source.ConnectAsync(host, cancellationToken).ConfigureAwait(false);
+ }
+ catch (ServerAccessException ex)
+ {
+ return new WindowsServerTestResult { Reachable = false, Error = ex.Message, Hint = Hint(ex.Problem), Account = account };
+ }
+
+ using (session)
+ {
+ var notes = new List();
+ if (session.ProtocolNote is { } note)
+ {
+ notes.Add(note);
+ }
+
+ var areas = new List();
+ ServerOsInfo? os = null;
+ await TryAsync(ServerArea.Performance, async () =>
+ {
+ os = await session.GetOperatingSystemAsync(cancellationToken).ConfigureAwait(false);
+ await session.GetPerformanceAsync(now, cancellationToken).ConfigureAwait(false);
+ return null;
+ }).ConfigureAwait(false);
+ await TryAsync(ServerArea.Disks, async () =>
+ {
+ var disks = await session.GetDisksAsync(cancellationToken).ConfigureAwait(false);
+ return disks.Count == 0 ? "Ingen lokale diske fundet." : $"{disks.Count} drev: " + string.Join(", ", disks.Select(d => d.Volume));
+ }).ConfigureAwait(false);
+ await TryAsync(ServerArea.Services, async () =>
+ $"{(await session.GetServicesAsync(cancellationToken).ConfigureAwait(false)).Count} tjenester synlige.").ConfigureAwait(false);
+ await TryAsync(ServerArea.Events, async () =>
+ {
+ var read = await session.GetEventsAsync(now.AddHours(-24), 500, cancellationToken).ConfigureAwait(false);
+ if (read.Note is { } eventNote)
+ {
+ notes.Add(eventNote);
+ }
+
+ return $"{read.Entries.Count} fejl og kritiske hændelser det seneste døgn (læst med {read.Method}).";
+ }).ConfigureAwait(false);
+ await TryAsync(ServerArea.Tasks, async () =>
+ {
+ var tasks = await session.GetTasksAsync(name => !TaskResults.IsMicrosoftTask(name), 1, cancellationToken).ConfigureAwait(false);
+ return $"{tasks.Visible} opgaver synlige.";
+ }).ConfigureAwait(false);
+
+ return new WindowsServerTestResult
+ {
+ Reachable = true,
+ Protocol = session.Protocol,
+ Account = account,
+ ComputerName = os?.ComputerName,
+ OperatingSystem = os?.Caption,
+ Version = os?.Version,
+ LastBootUpTime = os?.LastBootUpTime,
+ Areas = areas,
+ Notes = notes,
+ };
+
+ async Task TryAsync(string area, Func> read)
+ {
+ try
+ {
+ areas.Add(new ServerAreaStatus(area, true, await read().ConfigureAwait(false), ServerArea.Right(area)));
+ }
+ catch (ServerAccessException ex)
+ {
+ areas.Add(new ServerAreaStatus(area, false, ex.Message, ServerArea.Right(area)));
+ }
+ }
+ }
+ }
+
+ public static string Hint(ServerAccessProblem problem) => problem switch
+ {
+ ServerAccessProblem.AccessDenied => "Kør tildelingsscriptet nedenfor som administrator på serveren, og test igen.",
+ ServerAccessProblem.Unreachable or ServerAccessProblem.Timeout =>
+ "Tjek navnet, at serveren kører, og firewallen fra agentens server: WinRM (TCP 5985) eller DCOM/RPC (TCP 135 og dynamiske porte). Tildelingsscriptet kan slå Windows' egne regler til (-EnableFirewall).",
+ ServerAccessProblem.NotSupported => UnsupportedWindowsServerSource.Reason,
+ _ => "Se fejlteksten. Forbindelsen blev forsøgt fra agentens server som agentens konto.",
+ };
+}
diff --git a/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj b/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj
index 1954723..ba5f2e1 100644
--- a/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj
+++ b/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj
@@ -17,6 +17,7 @@
+
diff --git a/tests/SQLInsight.IntegrationTests/SyntheticSqlCheckTests.cs b/tests/SQLInsight.IntegrationTests/SyntheticSqlCheckTests.cs
new file mode 100644
index 0000000..eb2df9f
--- /dev/null
+++ b/tests/SQLInsight.IntegrationTests/SyntheticSqlCheckTests.cs
@@ -0,0 +1,51 @@
+using SQLInsight.Connectivity;
+using SQLInsight.Core.SyntheticChecks;
+using SQLInsight.Servers.Synthetic;
+
+namespace SQLInsight.IntegrationTests;
+
+/// The SQL login + query check against a real instance, with the least-privilege agent login.
+[Collection(SqlServerTestGroup.Name)]
+[Trait("Category", "Integration")]
+public class SyntheticSqlCheckTests(SqlServerFixture sql)
+{
+ private sealed class NoBusinessConnector : IBusinessConnectorLocator
+ {
+ public BusinessConnectorInstallation? Find() => null;
+ }
+
+ private static SyntheticCheckRunner Runner() => new(new SqlConnectionFactory(), new NoBusinessConnector(), new ProcessRunner(), TimeProvider.System);
+
+ private SyntheticCheckSettings Settings(string? query = null, string? database = null) => new()
+ {
+ Server = sql.Server, Authentication = "SqlLogin", UserName = SqlServerFixture.AgentLogin, TrustServerCertificate = true, Query = query, Database = database,
+ };
+
+ [Fact]
+ public async Task Login_and_query_with_response_time()
+ {
+ var result = await Runner().RunAsync(new SyntheticCheckRun(SyntheticCheckKind.Sql, Settings(), TimeSpan.FromSeconds(30), SqlServerFixture.AgentPassword), CancellationToken.None);
+ var inDatabase = await Runner().RunAsync(new SyntheticCheckRun(SyntheticCheckKind.Sql,
+ Settings("SELECT name FROM sys.objects", SqlServerFixture.TestDatabase), TimeSpan.FromSeconds(30), SqlServerFixture.AgentPassword), CancellationToken.None);
+
+ Assert.True(result.Success, result.Message);
+ Assert.Contains("Login og forespørgsel lykkedes", result.Message, StringComparison.Ordinal);
+ Assert.Contains(result.Details, d => d.Name == "Rækker" && d.Value == "1");
+ Assert.True(inDatabase.Success, inDatabase.Message);
+ }
+
+ [Fact]
+ public async Task A_wrong_password_and_a_query_without_rights_fail_with_the_reason()
+ {
+ var login = await Runner().RunAsync(new SyntheticCheckRun(SyntheticCheckKind.Sql, Settings(), TimeSpan.FromSeconds(30), "forkert"), CancellationToken.None);
+ var denied = await Runner().RunAsync(new SyntheticCheckRun(SyntheticCheckKind.Sql, Settings("SELECT Secret FROM dbo.CustomerData", SqlServerFixture.TestDatabase),
+ TimeSpan.FromSeconds(30), SqlServerFixture.AgentPassword), CancellationToken.None);
+ var noPassword = await Runner().RunAsync(new SyntheticCheckRun(SyntheticCheckKind.Sql, Settings(), TimeSpan.FromSeconds(30)), CancellationToken.None);
+
+ Assert.False(login.Success);
+ Assert.StartsWith("Login på SQL Server fejlede", login.Message, StringComparison.Ordinal);
+ Assert.False(denied.Success); // the least-privilege login can not read customer data
+ Assert.StartsWith("Forespørgslen fejlede", denied.Message, StringComparison.Ordinal);
+ Assert.False(noPassword.Success);
+ }
+}
diff --git a/tests/SQLInsight.Servers.Tests/LocalCimTests.cs b/tests/SQLInsight.Servers.Tests/LocalCimTests.cs
new file mode 100644
index 0000000..4626b36
--- /dev/null
+++ b/tests/SQLInsight.Servers.Tests/LocalCimTests.cs
@@ -0,0 +1,93 @@
+using SQLInsight.Core.Servers;
+using SQLInsight.Servers;
+
+namespace SQLInsight.Servers.Tests;
+
+///
+/// The CIM implementation against this computer (a local CIM session, as for the agent's own server). Remote access over
+/// WS-Man/DCOM with least privilege cannot be tested here; it needs a domain with a second server.
+///
+[Trait("Category", "Windows")]
+[System.Runtime.Versioning.SupportedOSPlatform("windows")]
+public class LocalCimTests
+{
+ private static async Task ConnectAsync()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return null;
+ }
+
+ return await new CimWindowsServerSource(new ServerConnectionOptions()).ConnectAsync(Environment.MachineName, CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Reads_os_performance_disks_services_events_and_tasks()
+ {
+ using var session = await ConnectAsync();
+ if (session is null)
+ {
+ return;
+ }
+
+ var os = await session.GetOperatingSystemAsync(CancellationToken.None);
+ var first = await session.GetPerformanceAsync(DateTimeOffset.UtcNow, CancellationToken.None);
+ await Task.Delay(1500);
+ var second = await session.GetPerformanceAsync(DateTimeOffset.UtcNow, CancellationToken.None);
+ var sample = ServerPerformanceMath.Sample(first, second, os);
+ var disks = await session.GetDisksAsync(CancellationToken.None);
+ var services = await session.GetServicesAsync(CancellationToken.None);
+ var service = await session.GetServiceAsync(services[0].Name, CancellationToken.None);
+ var events = await session.GetEventsAsync(DateTimeOffset.UtcNow.AddDays(-7), 200, CancellationToken.None);
+ var tasks = await session.GetTasksAsync(name => !TaskResults.IsMicrosoftTask(name), 3, CancellationToken.None);
+
+ Assert.Equal("lokal", session.Protocol);
+ Assert.Contains("Windows", os.Caption, StringComparison.OrdinalIgnoreCase);
+ Assert.True(os.LastBootUpTime < DateTimeOffset.UtcNow);
+ Assert.True(os.TotalMemoryMb > 0);
+ Assert.True(os.LogicalProcessors > 0);
+ Assert.InRange(sample.CpuPercent!.Value, 0, 100);
+ Assert.True(sample.AvailableMb > 0);
+ Assert.InRange(sample.CommitPercent!.Value, 0, 100);
+ Assert.NotEmpty(disks);
+ Assert.All(disks, d => Assert.False(d.Volume.StartsWith(@"\\?\", StringComparison.Ordinal)));
+ Assert.All(disks, d => Assert.InRange(d.FreeGb, 0, d.SizeGb));
+ Assert.Contains(services, s => s.IsRunning && s.IsAutomatic);
+ Assert.Equal(services[0].Name, service!.Name);
+ Assert.Null(await session.GetServiceAsync("SQLInsight-findes-ikke", CancellationToken.None));
+ Assert.Equal("Event Log (RPC)", events.Method);
+ Assert.All(events.Entries, e => Assert.InRange(e.Level, 1, 2));
+ Assert.True(tasks.Visible > 0);
+ Assert.All(tasks.Watched, t => Assert.False(TaskResults.IsMicrosoftTask(t.FullName)));
+ }
+
+ [Fact]
+ public async Task The_test_reports_every_area()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ var result = await WindowsServerTester.TestAsync(new CimWindowsServerSource(new ServerConnectionOptions()), "localhost", "test", DateTimeOffset.UtcNow, CancellationToken.None);
+
+ Assert.True(result.Reachable, result.Error);
+ Assert.Equal(ServerArea.All, result.Areas.Select(a => a.Area));
+ Assert.All(result.Areas, a => Assert.True(a.Ok, a.Detail));
+ }
+
+ [Fact]
+ public async Task An_unknown_host_is_unreachable_with_a_reason()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ var source = new CimWindowsServerSource(new ServerConnectionOptions { OperationTimeout = TimeSpan.FromSeconds(5) });
+ var error = await Assert.ThrowsAsync(() => source.ConnectAsync("sqlinsight-findes-ikke.invalid", CancellationToken.None));
+
+ Assert.Equal(ServerAccessProblem.Unreachable, error.Problem);
+ Assert.Contains("sqlinsight-findes-ikke.invalid", error.Message, StringComparison.Ordinal);
+ }
+}
diff --git a/tests/SQLInsight.Servers.Tests/SQLInsight.Servers.Tests.csproj b/tests/SQLInsight.Servers.Tests/SQLInsight.Servers.Tests.csproj
new file mode 100644
index 0000000..9f31995
--- /dev/null
+++ b/tests/SQLInsight.Servers.Tests/SQLInsight.Servers.Tests.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/SQLInsight.Servers.Tests/ServerAccessTests.cs b/tests/SQLInsight.Servers.Tests/ServerAccessTests.cs
new file mode 100644
index 0000000..cc1caf7
--- /dev/null
+++ b/tests/SQLInsight.Servers.Tests/ServerAccessTests.cs
@@ -0,0 +1,150 @@
+using SQLInsight.Core.Servers;
+using SQLInsight.Servers;
+using SQLInsight.Servers.Synthetic;
+
+namespace SQLInsight.Servers.Tests;
+
+public class ServerErrorTests
+{
+ private static ServerAccessException Describe(uint? code, string? kind = null, string message = "Windows' tekst", string? area = ServerArea.Services, string protocol = "WS-Man") =>
+ ServerErrors.Describe(code, kind, message, area, "aos01", @"FIRMA\AGENT01$", protocol);
+
+ [Fact]
+ public void Access_denied_names_the_right_and_the_script_never_local_admin()
+ {
+ var error = Describe(ServerErrors.AccessDenied);
+
+ Assert.Equal(ServerAccessProblem.AccessDenied, error.Problem);
+ Assert.Contains(@"FIRMA\AGENT01$", error.Message, StringComparison.Ordinal);
+ Assert.Contains("Service Control Manager", error.Message, StringComparison.Ordinal);
+ Assert.Contains("grant-windows-server.ps1", error.Message, StringComparison.Ordinal);
+ Assert.Contains("ikke være lokal administrator", error.Message, StringComparison.Ordinal);
+ Assert.Contains("0x80070005", error.Message, StringComparison.Ordinal);
+ Assert.Contains("Event Log Readers", Describe(ServerErrors.WbemAccessDenied, area: ServerArea.Events).Message, StringComparison.Ordinal);
+ Assert.Contains("Distributed COM Users", Describe(null, "AccessDenied", area: null, protocol: "DCOM").Message, StringComparison.Ordinal);
+ Assert.Contains("Remote Management Users", Describe(null, "AccessDenied", area: null, protocol: "WS-Man").Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Missing_namespace_unreachable_and_timeouts()
+ {
+ var tasks = Describe(ServerErrors.InvalidNamespace, "InvalidNamespace", area: ServerArea.Tasks);
+ Assert.Equal(ServerAccessProblem.NotAvailable, tasks.Problem);
+ Assert.Contains("Windows Server 2012", tasks.Message, StringComparison.Ordinal);
+
+ var rpc = Describe(ServerErrors.RpcUnavailable, "Failed", "The RPC server is unavailable.", area: null, protocol: "DCOM");
+ Assert.Equal(ServerAccessProblem.Unreachable, rpc.Problem);
+ Assert.Contains("TCP 135", rpc.Message, StringComparison.Ordinal);
+
+ var winrm = Describe(null, "Failed", "The client cannot connect to the destination specified in the request. ... WinRM ...", area: null);
+ Assert.Equal(ServerAccessProblem.Unreachable, winrm.Problem);
+ Assert.Contains("5985", winrm.Message, StringComparison.Ordinal);
+
+ Assert.Equal(ServerAccessProblem.Timeout, Describe(ServerErrors.MiTimeout, "Failed", "Timed out").Problem);
+ Assert.Equal(ServerAccessProblem.Other, Describe(0x80004005, "Failed", "Unspecified error").Problem);
+ }
+
+ [Fact]
+ public void Hints_by_problem()
+ {
+ Assert.Contains("tildelingsscriptet", WindowsServerTester.Hint(ServerAccessProblem.AccessDenied), StringComparison.Ordinal);
+ Assert.Contains("5985", WindowsServerTester.Hint(ServerAccessProblem.Unreachable), StringComparison.Ordinal);
+ Assert.Equal(UnsupportedWindowsServerSource.Reason, WindowsServerTester.Hint(ServerAccessProblem.NotSupported));
+ }
+
+ [Fact]
+ public async Task An_agent_not_on_windows_says_so()
+ {
+ var error = await Assert.ThrowsAsync(() => new UnsupportedWindowsServerSource().ConnectAsync("aos01", CancellationToken.None));
+ var test = await WindowsServerTester.TestAsync(new UnsupportedWindowsServerSource(), "aos01", null, DateTimeOffset.UtcNow, CancellationToken.None);
+
+ Assert.Equal(ServerAccessProblem.NotSupported, error.Problem);
+ Assert.False(test.Reachable);
+ Assert.Equal(UnsupportedWindowsServerSource.Reason, test.Error);
+ }
+}
+
+public class CimQueryTests
+{
+ [Fact]
+ public void Wql_literals_and_times()
+ {
+ Assert.Equal(@"AOS60$01", CimQueries.Wql("AOS60$01"));
+ Assert.Equal(@"a\\b\'c", CimQueries.Wql(@"a\b'c"));
+ Assert.Equal("SELECT Name, DisplayName, State, StartMode, ExitCode FROM Win32_Service WHERE Name = 'O\\'Brien'", CimQueries.SingleServiceQuery("O'Brien"));
+ var at = new DateTimeOffset(2026, 10, 2, 14, 5, 6, TimeSpan.FromHours(2));
+ Assert.Equal("20261002120506.000000+000", CimQueries.Dmtf(at));
+ Assert.Equal("*[System[(Level=1 or Level=2) and TimeCreated[@SystemTime>'2026-10-02T12:05:06.000Z']]]", CimQueries.EventXPath(at));
+ Assert.Contains("Logfile = 'System' AND EventType = 1 AND TimeGenerated > '20261002120506.000000+000'", CimQueries.EventQuery("System", at), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Queries_only_read()
+ {
+ var queries = new[]
+ {
+ CimQueries.OperatingSystemQuery, CimQueries.ComputerSystemQuery, CimQueries.ProcessorQuery, CimQueries.MemoryQuery,
+ CimQueries.VolumeQuery, CimQueries.LogicalDiskQuery, CimQueries.ServiceQuery, CimQueries.TaskQuery,
+ };
+
+ Assert.All(queries, q => Assert.StartsWith("SELECT ", q, StringComparison.Ordinal));
+ }
+}
+
+public class AxBusinessConnectorTests
+{
+ private static readonly BusinessConnectorInstallation Ax2012 = BusinessConnectorLocator.Describe(@"C:\Program Files\Microsoft Dynamics AX\60\BusinessConnector\Bin\Microsoft.Dynamics.BusinessConnectorNet.dll");
+ private static readonly BusinessConnectorInstallation Ax2009 = BusinessConnectorLocator.Describe(@"C:\Program Files (x86)\Microsoft Dynamics AX\50\Client\Bin\Microsoft.Dynamics.BusinessConnectorNet.dll");
+
+ [Fact]
+ public void Versions_and_bitness_from_the_path()
+ {
+ Assert.Equal(("AX 2012", false, false), (Ax2012.Version, Ax2012.Is32Bit, Ax2012.NeedsClr2));
+ Assert.Equal(("AX 2009", true, true), (Ax2009.Version, Ax2009.Is32Bit, Ax2009.NeedsClr2));
+ }
+
+ [Fact]
+ public void Locator_uses_the_configured_path_first()
+ {
+ var configured = @"D:\AX\Microsoft.Dynamics.BusinessConnectorNet.dll";
+
+ if (OperatingSystem.IsWindows())
+ {
+ Assert.Equal(configured, new BusinessConnectorLocator(configured, p => p == configured).Find()!.Path);
+ }
+
+ Assert.Null(new BusinessConnectorLocator(null, _ => false).Find());
+ }
+
+ [Fact]
+ public void Command_picks_powershell_and_never_puts_parameters_on_the_command_line()
+ {
+ var (file2012, args2012) = AxLogonClient.Command(Ax2012, @"C:\Windows");
+ var (file2009, args2009) = AxLogonClient.Command(Ax2009, @"C:\Windows");
+ var environment = AxLogonClient.Environment(Ax2012, "DAT", "01@aos01:2712", null);
+
+ Assert.Equal(@"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", file2012);
+ Assert.DoesNotContain("-Version", args2012);
+ Assert.EndsWith(@"WindowsPowerShell\v1.0\powershell.exe", file2009, StringComparison.Ordinal);
+ Assert.Equal(["-Version", "2"], args2009.Take(2));
+ Assert.Contains("-EncodedCommand", args2012);
+ Assert.DoesNotContain(args2012, a => a.Contains("aos01", StringComparison.Ordinal));
+ Assert.Equal(("DAT", "01@aos01:2712", string.Empty), (environment["SQLINSIGHT_AX_COMPANY"], environment["SQLINSIGHT_AX_SERVER"], environment["SQLINSIGHT_AX_CONFIG"]));
+ var script = System.Text.Encoding.Unicode.GetString(Convert.FromBase64String(args2012[^1]));
+ Assert.Contains("Logon(", script, StringComparison.Ordinal);
+ Assert.Contains("Logoff()", script, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Parse_the_helpers_answer()
+ {
+ Assert.Equal((true, 812, "Logon i AX lykkedes."), AxLogonClient.Parse(new ProcessResult(0, "noise\r\nSQLINSIGHT-AX-OK 812\r\n", string.Empty, false), Ax2012));
+ var notUser = AxLogonClient.Parse(new ProcessResult(1, "SQLINSIGHT-AX-ERROR You are not a recognized user of Microsoft Dynamics AX.", string.Empty, false), Ax2012);
+ Assert.False(notUser.Ok);
+ Assert.Contains("ikke bruger i AX", notUser.Message, StringComparison.Ordinal);
+ Assert.Contains("PowerShell 2.0", AxLogonClient.Parse(new ProcessResult(1, string.Empty, "Version v2.0.50727 of the .NET Framework is not installed", false), Ax2009).Message,
+ StringComparison.Ordinal);
+ Assert.Contains("i tide", AxLogonClient.Parse(new ProcessResult(-1, string.Empty, string.Empty, true), Ax2012).Message, StringComparison.Ordinal);
+ Assert.Contains("kode 3", AxLogonClient.Parse(new ProcessResult(3, string.Empty, string.Empty, false), Ax2012).Message, StringComparison.Ordinal);
+ }
+}
diff --git a/tests/SQLInsight.Servers.Tests/SyntheticCheckRunnerTests.cs b/tests/SQLInsight.Servers.Tests/SyntheticCheckRunnerTests.cs
new file mode 100644
index 0000000..1137f44
--- /dev/null
+++ b/tests/SQLInsight.Servers.Tests/SyntheticCheckRunnerTests.cs
@@ -0,0 +1,232 @@
+using System.Net;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using SQLInsight.Connectivity;
+using SQLInsight.Core.SyntheticChecks;
+using SQLInsight.Servers.Synthetic;
+
+namespace SQLInsight.Servers.Tests;
+
+/// The checks against small local servers: a TCP listener, a raw HTTP(S) server with a self-signed certificate.
+public sealed class SyntheticCheckRunnerTests : IDisposable
+{
+ private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
+ private readonly CancellationTokenSource _stop = new();
+
+ public SyntheticCheckRunnerTests() => _listener.Start();
+
+ private int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
+
+ public void Dispose()
+ {
+ _stop.Cancel();
+ _listener.Stop();
+ _listener.Dispose();
+ _stop.Dispose();
+ }
+
+ private sealed class NoSql : ISqlConnectionFactory
+ {
+ public Task OpenAsync(ConnectionTarget target, CancellationToken cancellationToken = default) => throw new InvalidOperationException("ingen SQL her");
+ }
+
+ private sealed class FakeLocator(BusinessConnectorInstallation? installation) : IBusinessConnectorLocator
+ {
+ public BusinessConnectorInstallation? Find() => installation;
+ }
+
+ private sealed class FakeProcesses(ProcessResult result) : IProcessRunner
+ {
+ public IReadOnlyDictionary? Environment { get; private set; }
+
+ public Task RunAsync(string fileName, IReadOnlyList arguments, IReadOnlyDictionary environment, TimeSpan timeout,
+ CancellationToken cancellationToken)
+ {
+ Environment = environment;
+ return Task.FromResult(result);
+ }
+ }
+
+ private static SyntheticCheckRunner Runner(BusinessConnectorInstallation? bc = null, IProcessRunner? processes = null) =>
+ new(new NoSql(), new FakeLocator(bc), processes ?? new FakeProcesses(new ProcessResult(0, string.Empty, string.Empty, false)), TimeProvider.System);
+
+ private static int ClosedPort()
+ {
+ using var probe = new TcpListener(IPAddress.Loopback, 0);
+ probe.Start();
+ return ((IPEndPoint)probe.LocalEndpoint).Port;
+ }
+
+ /// Answers every connection with one HTTP response, over TLS when a certificate is given.
+ private void Serve(string status, string body, X509Certificate2? certificate = null) => _ = Task.Run(async () =>
+ {
+ while (!_stop.IsCancellationRequested)
+ {
+ TcpClient client;
+ try
+ {
+ client = await _listener.AcceptTcpClientAsync(_stop.Token);
+ }
+ catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or SocketException)
+ {
+ return;
+ }
+
+ using (client)
+ {
+ Stream stream = client.GetStream();
+ try
+ {
+ if (certificate is not null)
+ {
+ var ssl = new SslStream(stream);
+ await ssl.AuthenticateAsServerAsync(certificate);
+ stream = ssl;
+ }
+
+ var buffer = new byte[8192];
+ _ = await stream.ReadAsync(buffer);
+ var bytes = Encoding.UTF8.GetBytes(body);
+ await stream.WriteAsync(Encoding.ASCII.GetBytes($"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {bytes.Length}\r\nConnection: close\r\n\r\n"));
+ await stream.WriteAsync(bytes);
+ await stream.FlushAsync();
+ }
+ catch (Exception ex) when (ex is IOException or System.Security.Authentication.AuthenticationException)
+ {
+ // The client refused the certificate.
+ }
+ }
+ }
+ });
+
+ private static X509Certificate2 SelfSigned(DateTimeOffset notAfter)
+ {
+ using var key = RSA.Create(2048);
+ var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ var san = new SubjectAlternativeNameBuilder();
+ san.AddDnsName("localhost");
+ san.AddIpAddress(IPAddress.Loopback);
+ request.CertificateExtensions.Add(san.Build());
+ using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), notAfter);
+ return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), null);
+ }
+
+ private static SyntheticCheckRun Run(string kind, SyntheticCheckSettings settings, int timeoutSeconds = 10) => new(kind, settings, TimeSpan.FromSeconds(timeoutSeconds));
+
+ [Fact]
+ public async Task Tcp_open_and_refused()
+ {
+ var open = await Runner().RunAsync(Run(SyntheticCheckKind.Tcp, new() { Host = "127.0.0.1", Port = Port }), CancellationToken.None);
+ var closed = await Runner().RunAsync(Run(SyntheticCheckKind.Tcp, new() { Host = "127.0.0.1", Port = ClosedPort() }), CancellationToken.None);
+
+ Assert.True(open.Success, open.Message);
+ Assert.Contains("svarede på", open.Message, StringComparison.Ordinal);
+ Assert.False(closed.Success);
+ Assert.Contains("afvist", closed.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Http_status_and_text()
+ {
+ Serve("200 OK", "Microsoft Dynamics AX Enterprise Portal");
+ var url = new Uri($"http://127.0.0.1:{Port}/sites/DynamicsAx");
+
+ var ok = await Runner().RunAsync(Run(SyntheticCheckKind.Http, new() { Url = url, ExpectedText = "enterprise portal" }), CancellationToken.None);
+ var missing = await Runner().RunAsync(Run(SyntheticCheckKind.Http, new() { Url = url, ExpectedText = "Logget ind" }), CancellationToken.None);
+ var wrongStatus = await Runner().RunAsync(Run(SyntheticCheckKind.Http, new() { Url = url, ExpectedStatus = [401] }), CancellationToken.None);
+
+ Assert.True(ok.Success, ok.Message);
+ Assert.Contains(ok.Details, d => d.Name == "HTTP-status" && d.Value.StartsWith("200", StringComparison.Ordinal));
+ Assert.False(missing.Success);
+ Assert.Contains("indeholdt ikke", missing.Message, StringComparison.Ordinal);
+ Assert.False(wrongStatus.Success);
+ Assert.Contains("forventede 401", wrongStatus.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Http_server_errors_fail()
+ {
+ Serve("503 Service Unavailable", "nede");
+
+ var result = await Runner().RunAsync(Run(SyntheticCheckKind.Http, new() { Url = new Uri($"http://127.0.0.1:{Port}/") }), CancellationToken.None);
+
+ Assert.False(result.Success);
+ Assert.Contains("503", result.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Https_with_a_self_signed_certificate_fails_unless_allowed_and_the_expiry_is_seen_either_way()
+ {
+ var expires = DateTimeOffset.UtcNow.AddDays(10);
+ using var certificate = SelfSigned(expires);
+ Serve("200 OK", "ok", certificate);
+ var url = new Uri($"https://localhost:{Port}/");
+
+ var refused = await Runner().RunAsync(Run(SyntheticCheckKind.Http, new() { Url = url }), CancellationToken.None);
+ var allowed = await Runner().RunAsync(Run(SyntheticCheckKind.Http, new() { Url = url, AllowInvalidCertificate = true }), CancellationToken.None);
+
+ Assert.False(refused.Success);
+ Assert.Contains("certifikatet er ikke gyldigt", refused.Message, StringComparison.Ordinal);
+ Assert.True(allowed.Success, allowed.Message);
+ Assert.Equal(expires.UtcDateTime, allowed.CertificateExpiresAt!.Value.UtcDateTime, TimeSpan.FromSeconds(2));
+ Assert.Equal(expires.UtcDateTime, refused.CertificateExpiresAt!.Value.UtcDateTime, TimeSpan.FromSeconds(2));
+ Assert.Equal("CN=localhost", allowed.CertificateSubject);
+ }
+
+ [Fact]
+ public async Task Dns_resolves_and_compares_addresses()
+ {
+ var ok = await Runner().RunAsync(Run(SyntheticCheckKind.Dns, new() { Host = "localhost" }), CancellationToken.None);
+ var wrong = await Runner().RunAsync(Run(SyntheticCheckKind.Dns, new() { Host = "localhost", ExpectedAddresses = ["10.1.2.3"] }), CancellationToken.None);
+ var unknown = await Runner().RunAsync(Run(SyntheticCheckKind.Dns, new() { Host = "findes-ikke.invalid" }), CancellationToken.None);
+
+ Assert.True(ok.Success, ok.Message);
+ Assert.False(wrong.Success);
+ Assert.Contains("forventede 10.1.2.3", wrong.Message, StringComparison.Ordinal);
+ Assert.False(unknown.Success);
+ }
+
+ [Fact]
+ public async Task Ax_without_business_connector_only_tests_the_port_and_says_so()
+ {
+ var result = await Runner().RunAsync(Run(SyntheticCheckKind.AxLogon, new() { Host = "127.0.0.1", Port = Port, Company = "DAT" }), CancellationToken.None);
+ var down = await Runner().RunAsync(Run(SyntheticCheckKind.AxLogon, new() { Host = "127.0.0.1", Port = ClosedPort() }), CancellationToken.None);
+
+ Assert.True(result.Success);
+ Assert.True(result.Partial);
+ Assert.Contains("Kun porten er testet", result.Message, StringComparison.Ordinal);
+ Assert.False(down.Success);
+ Assert.Contains("AOS-porten svarer ikke", down.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Ax_with_business_connector_logs_on_in_a_separate_process()
+ {
+ var installation = BusinessConnectorLocator.Describe(@"C:\Program Files\Microsoft Dynamics AX\60\BusinessConnector\Bin\Microsoft.Dynamics.BusinessConnectorNet.dll");
+ var processes = new FakeProcesses(new ProcessResult(0, "SQLINSIGHT-AX-OK 640", string.Empty, false));
+ var failing = new FakeProcesses(new ProcessResult(1, "SQLINSIGHT-AX-ERROR Object Server 01: The database reported an error.", string.Empty, false));
+
+ var ok = await Runner(installation, processes).RunAsync(Run(SyntheticCheckKind.AxLogon, new() { Host = "127.0.0.1", Port = Port, Company = "DAT" }), CancellationToken.None);
+ var error = await Runner(installation, failing).RunAsync(Run(SyntheticCheckKind.AxLogon, new() { Host = "127.0.0.1", Port = Port }), CancellationToken.None);
+
+ Assert.True(ok.Success, ok.Message);
+ Assert.False(ok.Partial);
+ Assert.Contains("640 ms", ok.Message, StringComparison.Ordinal);
+ Assert.Equal($"127.0.0.1:{Port}", processes.Environment!["SQLINSIGHT_AX_SERVER"]);
+ Assert.Equal("DAT", processes.Environment["SQLINSIGHT_AX_COMPANY"]);
+ Assert.False(error.Success);
+ Assert.Contains("The database reported an error", error.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task A_query_that_writes_is_never_run_even_if_it_was_stored()
+ {
+ var result = await Runner().RunAsync(Run(SyntheticCheckKind.Sql, new() { Server = "sql01", Query = "DELETE FROM dbo.CustTable" }), CancellationToken.None);
+
+ Assert.False(result.Success);
+ Assert.Contains("køres ikke", result.Message, StringComparison.Ordinal);
+ }
+}