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:
parent
25ecdbc3e7
commit
7082b278e3
17 changed files with 2000 additions and 0 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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/">
|
||||
|
|
|
|||
46
src/SQLInsight.Servers/CimQueries.cs
Normal file
46
src/SQLInsight.Servers/CimQueries.cs
Normal 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";
|
||||
|
||||
}
|
||||
416
src/SQLInsight.Servers/CimWindowsServerSource.cs
Normal file
416
src/SQLInsight.Servers/CimWindowsServerSource.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
22
src/SQLInsight.Servers/SQLInsight.Servers.csproj
Normal file
22
src/SQLInsight.Servers/SQLInsight.Servers.csproj
Normal 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>
|
||||
92
src/SQLInsight.Servers/ServerErrors.cs
Normal file
92
src/SQLInsight.Servers/ServerErrors.cs
Normal 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..];
|
||||
}
|
||||
191
src/SQLInsight.Servers/Synthetic/AxBusinessConnector.cs
Normal file
191
src/SQLInsight.Servers/Synthetic/AxBusinessConnector.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
460
src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs
Normal file
460
src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
24
src/SQLInsight.Servers/Synthetic/ax-logon.ps1
Normal file
24
src/SQLInsight.Servers/Synthetic/ax-logon.ps1
Normal 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
|
||||
}
|
||||
106
src/SQLInsight.Servers/WindowsServerAccess.cs
Normal file
106
src/SQLInsight.Servers/WindowsServerAccess.cs
Normal 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);
|
||||
}
|
||||
99
src/SQLInsight.Servers/WindowsServerTester.cs
Normal file
99
src/SQLInsight.Servers/WindowsServerTester.cs
Normal 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.",
|
||||
};
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
51
tests/SQLInsight.IntegrationTests/SyntheticSqlCheckTests.cs
Normal file
51
tests/SQLInsight.IntegrationTests/SyntheticSqlCheckTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
93
tests/SQLInsight.Servers.Tests/LocalCimTests.cs
Normal file
93
tests/SQLInsight.Servers.Tests/LocalCimTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
150
tests/SQLInsight.Servers.Tests/ServerAccessTests.cs
Normal file
150
tests/SQLInsight.Servers.Tests/ServerAccessTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
232
tests/SQLInsight.Servers.Tests/SyntheticCheckRunnerTests.cs
Normal file
232
tests/SQLInsight.Servers.Tests/SyntheticCheckRunnerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue