Collection: Windows servers over CIM (WS-Man, DCOM fallback) and synthetic check runners

New project SQLInsight.Servers:
- CimWindowsServerSource (Microsoft.Management.Infrastructure, the API behind
  Get-CimInstance): WS-Man first, DCOM if that fails, a local session for the
  agent's own computer, as the agent's identity. Only SELECT queries (CimQueries)
  and Task Scheduler's read-only GetInfo. CPU and paging from raw counters, disks
  (Win32_Volume, Win32_LogicalDisk fallback), services, scheduled tasks, and the
  System/Application logs through the Event Log service (MS-EVEN6, XPath filter on
  the server) with Win32_NTLogEvent as fallback.
- ServerErrors: access denied, missing namespace, RPC/WinRM unreachable and
  timeouts become "ikke målbar" reasons that name the missing right and the grant
  script; never local administrator.
- WindowsServerTester: tries every area once for the web UI's test.
- SyntheticCheckRunner: TCP, HTTP(S) with expected status/text and TLS certificate
  expiry (also for refused certificates), SQL login + read-only query (re-validated,
  in a transaction that is always rolled back), DNS, and the Dynamics AX logon:
  AOS port, then a logon through the .NET Business Connector in a separate Windows
  PowerShell process with a timeout when it is installed; otherwise the result
  says clearly that only the port was tested.

Tests: error mapping, queries, AX command/parsing, runners against local TCP and
HTTP(S) servers with a self-signed certificate, the CIM source against this
computer, and the SQL check against the test instance.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 13:16:23 +02:00
parent 25ecdbc3e7
commit 7082b278e3
17 changed files with 2000 additions and 0 deletions

View file

@ -22,6 +22,7 @@
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.12" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.12" />
<PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.12" />
<PackageVersion Include="Microsoft.Management.Infrastructure" Version="3.0.0" />
<PackageVersion Include="System.DirectoryServices.AccountManagement" Version="10.0.12" />
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="10.0.12" />
<PackageVersion Include="PDFsharp-MigraDoc" Version="6.2.4" />

View file

@ -9,6 +9,7 @@
<Project Path="src/SQLInsight.Hub/SQLInsight.Hub.csproj" />
<Project Path="src/SQLInsight.Licensing/SQLInsight.Licensing.csproj" />
<Project Path="src/SQLInsight.Reports/SQLInsight.Reports.csproj" />
<Project Path="src/SQLInsight.Servers/SQLInsight.Servers.csproj" />
<Project Path="src/SQLInsight.Updates/SQLInsight.Updates.csproj" />
<Project Path="src/SQLInsight.Notifications/SQLInsight.Notifications.csproj" />
<Project Path="src/SQLInsight.Repository/SQLInsight.Repository.csproj" />
@ -25,6 +26,7 @@
<Project Path="tests/SQLInsight.Licensing.Tests/SQLInsight.Licensing.Tests.csproj" />
<Project Path="tests/SQLInsight.Notifications.Tests/SQLInsight.Notifications.Tests.csproj" />
<Project Path="tests/SQLInsight.Rules.Tests/SQLInsight.Rules.Tests.csproj" />
<Project Path="tests/SQLInsight.Servers.Tests/SQLInsight.Servers.Tests.csproj" />
<Project Path="tests/SQLInsight.Updates.Tests/SQLInsight.Updates.Tests.csproj" />
</Folder>
<Folder Name="/tools/">

View file

@ -0,0 +1,46 @@
using System.Globalization;
namespace SQLInsight.Servers;
/// <summary>
/// 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.
/// </summary>
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";
/// <summary>The XPath for the event log: errors and critical events after a time (ISO 8601, UTC).</summary>
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) + "']]]";
/// <summary>The CIM fallback for the event log (EventType 1 = error; critical events are reported as errors).</summary>
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)}'";
/// <summary>A WQL string literal's content: backslash and quote escaped.</summary>
public static string Wql(string value)
{
ArgumentNullException.ThrowIfNull(value);
return value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("'", "\\'", StringComparison.Ordinal);
}
/// <summary>CIM datetime (DMTF), UTC: yyyymmddHHMMSS.mmmmmm+000.</summary>
public static string Dmtf(DateTimeOffset value) => value.UtcDateTime.ToString("yyyyMMddHHmmss.ffffff", CultureInfo.InvariantCulture) + "+000";
}

View file

@ -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;
/// <summary>How the agent connects to Windows servers (SQLInsight:Servers in the agent's appsettings.json).</summary>
public sealed class ServerConnectionOptions
{
/// <summary>Auto (WS-Man, then DCOM), WSMan or DCOM.</summary>
public string Protocol { get; set; } = "Auto";
/// <summary>How long one CIM operation may take.</summary>
public TimeSpan OperationTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>Read event logs with the Event Log service's own protocol (MS-EVEN6), which filters on the server; CIM (Win32_NTLogEvent) is the fallback.</summary>
public bool EventLogRpc { get; set; } = true;
}
/// <summary>
/// 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.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class CimWindowsServerSource(ServerConnectionOptions options) : IWindowsServerSource
{
public async Task<IWindowsServerSession> 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<ServerAccessException>();
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 };
}
/// <summary>The account the agent arrives as on <paramref name="host"/> (the computer account for a virtual service account).</summary>
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();
/// <summary>The cheapest query that proves the connection (and the right to root\cimv2).</summary>
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<List<CimInstance>> 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<ServerOsInfo> 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<ServerPerformanceRaw> 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<IReadOnlyList<ServerDisk>> GetDisksAsync(CancellationToken cancellationToken)
{
List<CimInstance> 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<IReadOnlyList<ServerService>> GetServicesAsync(CancellationToken cancellationToken)
{
List<CimInstance> 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<ServerService?> 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<ServerEventRead> 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<ServerEventEntry>();
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<ServerEventEntry>();
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<ServerTaskRead> GetTasksAsync(Func<string, bool> 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<ServerTask>();
foreach (var task in watched.Take(maxWatched))
{
result.Add(await GetInfoAsync(task, cancellationToken).ConfigureAwait(false));
}
return new ServerTaskRead(result, all.Count, note);
}
/// <summary>Last and next run of a task: Task Scheduler's GetInfo, which only reads (as Get-ScheduledTaskInfo).</summary>
private Task<ServerTask> 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);
}
}

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>SQLInsight.Servers</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" />
<ProjectReference Include="..\SQLInsight.Connectivity\SQLInsight.Connectivity.csproj" />
</ItemGroup>
<ItemGroup>
<!-- CIM over WS-Man (WinRM) or DCOM: the same API PowerShell's Get-CimInstance uses. Windows only. -->
<PackageReference Include="Microsoft.Management.Infrastructure" />
<!-- Remote event log reading (MS-EVEN6) with an XPath filter on the server. Windows only. -->
<PackageReference Include="System.Diagnostics.EventLog" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Synthetic\ax-logon.ps1" LogicalName="SQLInsight.Servers.ax-logon.ps1" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SQLInsight.Servers.Tests" />
<InternalsVisibleTo Include="SQLInsight.IntegrationTests" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,92 @@
using System.Globalization;
using SQLInsight.Core.Servers;
namespace SQLInsight.Servers;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>MI_RESULT for an operation that timed out (as MMI reports it).</summary>
public const uint MiTimeout = 0x40004;
/// <param name="code">The HRESULT, when known.</param>
/// <param name="kind">MMI's NativeErrorCode name (AccessDenied, InvalidNamespace …), when known.</param>
/// <param name="message">Windows' own message, kept at the end for support.</param>
/// <param name="area">The <see cref="ServerArea"/> being read, or null for connecting.</param>
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..];
}

View file

@ -0,0 +1,191 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
namespace SQLInsight.Servers.Synthetic;
/// <summary>A Dynamics AX .NET Business Connector found on the agent's computer.</summary>
/// <param name="Version">"AX 2012" or "AX 2009".</param>
/// <param name="Is32Bit">Installed under Program Files (x86): needs the 32-bit Windows PowerShell.</param>
/// <param name="NeedsClr2">AX 2009's Business Connector is built for .NET 2.0: needs Windows PowerShell's 2.0 engine.</param>
public sealed record BusinessConnectorInstallation(string Path, string Version, bool Is32Bit, bool NeedsClr2);
public interface IBusinessConnectorLocator
{
BusinessConnectorInstallation? Find();
}
/// <summary>Looks for the Business Connector where the AX 2009 and AX 2012 installers put it (or where SQLInsight:SyntheticChecks:BusinessConnectorPath says).</summary>
public sealed class BusinessConnectorLocator(string? configuredPath = null, Func<string, bool>? exists = null) : IBusinessConnectorLocator
{
public const string FileName = "Microsoft.Dynamics.BusinessConnectorNet.dll";
private readonly Func<string, bool> _exists = exists ?? File.Exists;
/// <summary>The places looked in, most likely first.</summary>
public static IReadOnlyList<string> 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<ProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments, IReadOnlyDictionary<string, string> environment, TimeSpan timeout,
CancellationToken cancellationToken);
}
/// <summary>Runs a process and kills it (with its children) when it takes too long.</summary>
public sealed class ProcessRunner : IProcessRunner
{
public async Task<ProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments, IReadOnlyDictionary<string, string> 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);
}
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>The PowerShell to start and its arguments: 32-bit for a 32-bit Business Connector, the 2.0 engine for AX 2009.</summary>
public static (string FileName, IReadOnlyList<string> 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<string>();
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<string, string> Environment(BusinessConnectorInstallation installation, string? company, string objectServer, string? configuration)
{
ArgumentNullException.ThrowIfNull(installation);
return new Dictionary<string, string>(StringComparer.Ordinal)
{
["SQLINSIGHT_AX_DLL"] = installation.Path,
["SQLINSIGHT_AX_COMPANY"] = company ?? string.Empty,
["SQLINSIGHT_AX_SERVER"] = objectServer,
["SQLINSIGHT_AX_CONFIG"] = configuration ?? string.Empty,
};
}
/// <summary>What the helper reported: ok with the logon time, or the error (Danish).</summary>
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);
}
}

View file

@ -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;
/// <summary>One run of a check: its settings, the opened SQL login password (if any) and how long it may take.</summary>
public sealed record SyntheticCheckRun(string Kind, SyntheticCheckSettings Settings, TimeSpan Timeout, string? Password = null);
/// <summary>
/// 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.
/// </summary>
public sealed class SyntheticCheckRunner(ISqlConnectionFactory sqlConnections, IBusinessConnectorLocator businessConnector, IProcessRunner processes, TimeProvider time)
{
public const string UserAgent = "SQLInsight-Pro-SyntheticCheck/1.5";
/// <summary>At most this much of an HTTP answer is read when looking for the expected text.</summary>
public const int MaxBodyBytes = 1024 * 1024;
public async Task<SyntheticCheckResult> 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<SyntheticCheckDetail>? details = null) =>
new() { At = at, Success = false, DurationMs = ms, Message = message, Details = details ?? [] };
// ---- TCP --------------------------------------------------------------------------------------------------
private async Task<SyntheticCheckResult> 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<SyntheticCheckResult> 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<SyntheticCheckDetail> { 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<string> 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<SyntheticCheckDetail> 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<string>();
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<SyntheticCheckResult> 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<SyntheticCheckDetail> { 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<SyntheticCheckResult> 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<SyntheticCheckDetail> { 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<SyntheticCheckResult> 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<SyntheticCheckDetail> { 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,
};
}
}

View file

@ -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 <ms> or SQLINSIGHT-AX-ERROR <text>.
# 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
}

View file

@ -0,0 +1,106 @@
using SQLInsight.Core.Servers;
namespace SQLInsight.Servers;
/// <summary>Opens read-only sessions to Windows servers, as the agent's own identity.</summary>
public interface IWindowsServerSource
{
/// <summary>Connects to <paramref name="host"/>: WS-Man (WinRM) first, DCOM if that fails, or locally for the agent's own computer.</summary>
/// <exception cref="ServerAccessException">The server could not be reached; the message says why (Danish).</exception>
Task<IWindowsServerSession> ConnectAsync(string host, CancellationToken cancellationToken);
}
/// <summary>
/// One connection to a Windows server. Every method only reads: no method changes, starts or stops anything. Each
/// throws <see cref="ServerAccessException"/> with the exact reason when its area cannot be read.
/// </summary>
public interface IWindowsServerSession : IDisposable
{
/// <summary>"WS-Man", "DCOM" or "lokal".</summary>
string Protocol { get; }
/// <summary>Why the preferred protocol was not used (Danish), when it was not.</summary>
string? ProtocolNote { get; }
Task<ServerOsInfo> GetOperatingSystemAsync(CancellationToken cancellationToken);
Task<ServerPerformanceRaw> GetPerformanceAsync(DateTimeOffset now, CancellationToken cancellationToken);
Task<IReadOnlyList<ServerDisk>> GetDisksAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<ServerService>> GetServicesAsync(CancellationToken cancellationToken);
/// <summary>One service, or null when it does not exist (or cannot be seen).</summary>
Task<ServerService?> GetServiceAsync(string name, CancellationToken cancellationToken);
/// <summary>Errors and critical events in the System and Application logs since <paramref name="since"/>, oldest first.</summary>
Task<ServerEventRead> GetEventsAsync(DateTimeOffset since, int max, CancellationToken cancellationToken);
/// <summary>Scheduled tasks; run information only for the ones <paramref name="watch"/> selects (at most <paramref name="maxWatched"/>).</summary>
Task<ServerTaskRead> GetTasksAsync(Func<string, bool> watch, int maxWatched, CancellationToken cancellationToken);
}
/// <param name="Method">How the logs were read: "Event Log (RPC)" or "CIM (Win32_NTLogEvent)".</param>
/// <param name="Truncated">More events than <c>max</c>: the rest is read next time.</param>
public sealed record ServerEventRead(IReadOnlyList<ServerEventEntry> Entries, string Method, bool Truncated, string? Note = null);
/// <param name="Visible">Every task the agent could see.</param>
public sealed record ServerTaskRead(IReadOnlyList<ServerTask> Watched, int Visible, string? Note = null);
public enum ServerAccessProblem
{
/// <summary>The server does not answer (name, network, firewall, WinRM/RPC not running).</summary>
Unreachable,
/// <summary>The server answers, but the agent's account may not read this.</summary>
AccessDenied,
/// <summary>The server does not have it (an older Windows without the class or namespace).</summary>
NotAvailable,
/// <summary>The server did not answer in time.</summary>
Timeout,
/// <summary>The agent cannot do it at all (not on Windows).</summary>
NotSupported,
Other,
}
/// <summary>A Windows server or one of its areas could not be read. The message is Danish and says exactly why and what to do.</summary>
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;
/// <summary>The Windows error code (HRESULT) when there was one, for support.</summary>
public uint? Code { get; init; }
}
/// <summary>For an agent that does not run on Windows: Windows servers cannot be read from Linux.</summary>
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<IWindowsServerSession> ConnectAsync(string host, CancellationToken cancellationToken) =>
throw new ServerAccessException(ServerAccessProblem.NotSupported, Reason);
}

View file

@ -0,0 +1,99 @@
using SQLInsight.Core.Servers;
namespace SQLInsight.Servers;
/// <summary>
/// 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.
/// </summary>
public static class WindowsServerTester
{
public static async Task<WindowsServerTestResult> 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<string>();
if (session.ProtocolNote is { } note)
{
notes.Add(note);
}
var areas = new List<ServerAreaStatus>();
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<Task<string?>> 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.",
};
}

View file

@ -17,6 +17,7 @@
<ProjectReference Include="..\..\src\SQLInsight.Notifications\SQLInsight.Notifications.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Hub\SQLInsight.Hub.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Reports\SQLInsight.Reports.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Servers\SQLInsight.Servers.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\db\permissions\grant-least-privilege.sql" Link="Scripts\grant-least-privilege.sql" CopyToOutputDirectory="PreserveNewest" />

View file

@ -0,0 +1,51 @@
using SQLInsight.Connectivity;
using SQLInsight.Core.SyntheticChecks;
using SQLInsight.Servers.Synthetic;
namespace SQLInsight.IntegrationTests;
/// <summary>The SQL login + query check against a real instance, with the least-privilege agent login.</summary>
[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);
}
}

View file

@ -0,0 +1,93 @@
using SQLInsight.Core.Servers;
using SQLInsight.Servers;
namespace SQLInsight.Servers.Tests;
/// <summary>
/// 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.
/// </summary>
[Trait("Category", "Windows")]
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
public class LocalCimTests
{
private static async Task<IWindowsServerSession?> 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<ServerAccessException>(() => source.ConnectAsync("sqlinsight-findes-ikke.invalid", CancellationToken.None));
Assert.Equal(ServerAccessProblem.Unreachable, error.Problem);
Assert.Contains("sqlinsight-findes-ikke.invalid", error.Message, StringComparison.Ordinal);
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SQLInsight.Servers\SQLInsight.Servers.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -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<ServerAccessException>(() => 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);
}
}

View file

@ -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;
/// <summary>The checks against small local servers: a TCP listener, a raw HTTP(S) server with a self-signed certificate.</summary>
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<OpenedConnection> 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<string, string>? Environment { get; private set; }
public Task<ProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments, IReadOnlyDictionary<string, string> 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;
}
/// <summary>Answers every connection with one HTTP response, over TLS when a certificate is given.</summary>
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", "<html>Microsoft Dynamics AX Enterprise Portal</html>");
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);
}
}