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