diff --git a/Directory.Packages.props b/Directory.Packages.props index db6630a..a854004 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,6 +23,7 @@ + diff --git a/src/SQLInsight.Agent/Configuration/AgentCredentialKey.cs b/src/SQLInsight.Agent/Configuration/AgentCredentialKey.cs new file mode 100644 index 0000000..07bfe47 --- /dev/null +++ b/src/SQLInsight.Agent/Configuration/AgentCredentialKey.cs @@ -0,0 +1,170 @@ +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Principal; +using SQLInsight.Core.Configuration; +using SQLInsight.Repository; + +namespace SQLInsight.Agent.Configuration; + +/// +/// The agent's RSA key pair for SQL login passwords entered in SQLInsight Web (see ). +/// +/// Why this and not ASP.NET Data Protection or DPAPI alone: the agent and the web UI may run on different computers +/// and as different accounts, and the web UI must never be able to read a password back. With a key pair the web UI +/// only ever has the public key (published in insight.AgentKeys), and the private key stays on the agent's computer. +/// +/// The private key is stored in %ProgramData%\SQLInsight\keys\credential-key.bin, protected with DPAPI (LocalMachine +/// scope, with an application-specific entropy) and a folder ACL that only lets SYSTEM, Administrators and the agent's +/// own account in. On Linux it is a file only the agent's account can read (mode 600). Losing the file (a reinstalled +/// server) only means SQL login passwords must be entered again; Windows authentication is unaffected. +/// +public sealed partial class AgentCredentialKey : IDisposable +{ + public const string FileName = "credential-key.bin"; + private const int KeySize = 3072; + private static readonly byte[] Entropy = "SQLInsight Pro agent credential key v1"u8.ToArray(); + + private readonly RSA _rsa; + + private AgentCredentialKey(RSA rsa) + { + _rsa = rsa; + PublicKey = rsa.ExportSubjectPublicKeyInfo(); + KeyId = "agent-" + Convert.ToHexString(SHA256.HashData(PublicKey))[..24].ToLowerInvariant(); + } + + public string KeyId { get; } + + /// SubjectPublicKeyInfo (DER): what SQLInsight Web seals passwords with. + public byte[] PublicKey { get; } + + /// The key in , created the first time. A file that cannot be read is replaced. + public static AgentCredentialKey LoadOrCreate(string directory, ILogger? logger = null) + { + ArgumentException.ThrowIfNullOrEmpty(directory); + var path = Path.Combine(directory, FileName); + if (File.Exists(path)) + { + try + { + var rsa = RSA.Create(); + var pkcs8 = Unprotect(File.ReadAllBytes(path)); + try + { + rsa.ImportPkcs8PrivateKey(pkcs8, out _); + } + finally + { + CryptographicOperations.ZeroMemory(pkcs8); + } + + return new AgentCredentialKey(rsa); + } + catch (CryptographicException ex) + { + // Copied from another computer, or damaged: passwords sealed for it cannot be opened anyway. + if (logger is not null) + { + LogUnreadable(logger, ex, path); + } + } + } + + var created = RSA.Create(KeySize); + Save(directory, path, created); + return new AgentCredentialKey(created); + } + + /// Opens a password sealed for this agent and the given server and login. + public string Open(byte[] envelope, string server, string? userName) => + CredentialEnvelope.Open(envelope, KeyId, _rsa, CredentialEnvelope.Context(server, userName)); + + /// What the agent publishes in insight.AgentKeys: the key, and the account and computer it runs as. + public AgentKeyRow Describe(DateTimeOffset now) => new() + { + KeyId = KeyId, + PublicKey = PublicKey, + MachineName = Environment.MachineName, + ServiceAccount = CurrentAccount(), + Domain = Environment.UserDomainName, + CreatedAt = now.UtcDateTime, + LastSeenAt = now.UtcDateTime, + }; + + /// DOMAIN\user, or NT SERVICE\SQLInsight Agent for the virtual service account. + public static string CurrentAccount() => + OperatingSystem.IsWindows() ? WindowsIdentity.GetCurrent().Name : Environment.UserName; + + public void Dispose() => _rsa.Dispose(); + + private static void Save(string directory, string path, RSA rsa) + { + if (OperatingSystem.IsWindows()) + { + CreateRestrictedDirectory(directory); + } + else + { + Directory.CreateDirectory(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + var pkcs8 = rsa.ExportPkcs8PrivateKey(); + try + { + var bytes = Protect(pkcs8); + var temp = path + ".tmp"; + File.WriteAllBytes(temp, bytes); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(temp, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + File.Move(temp, path, overwrite: true); + } + finally + { + CryptographicOperations.ZeroMemory(pkcs8); + } + } + + private static byte[] Protect(byte[] data) => + OperatingSystem.IsWindows() ? ProtectedData.Protect(data, Entropy, DataProtectionScope.LocalMachine) : (byte[])data.Clone(); + + private static byte[] Unprotect(byte[] data) => + OperatingSystem.IsWindows() ? ProtectedData.Unprotect(data, Entropy, DataProtectionScope.LocalMachine) : data; + + /// The key folder, readable only by SYSTEM, Administrators and the agent's own account (no inherited rights). + [SupportedOSPlatform("windows")] + private static void CreateRestrictedDirectory(string directory) + { + var info = Directory.CreateDirectory(directory); + var security = new DirectorySecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + const InheritanceFlags inherit = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit; + foreach (var sid in new[] + { + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null), + WindowsIdentity.GetCurrent().User, + }) + { + if (sid is not null) + { + security.AddAccessRule(new FileSystemAccessRule(sid, FileSystemRights.FullControl, inherit, PropagationFlags.None, AccessControlType.Allow)); + } + } + + try + { + info.SetAccessControl(security); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or PrivilegeNotHeldException or IOException) + { + // The folder exists with rights someone else set (e.g. an administrator); DPAPI still protects the key. + } + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "The credential key {Path} could not be read; a new key is created. SQL login passwords entered before must be entered again.")] + private static partial void LogUnreadable(ILogger logger, Exception exception, string path); +} diff --git a/src/SQLInsight.Agent/Configuration/AgentRequestService.cs b/src/SQLInsight.Agent/Configuration/AgentRequestService.cs new file mode 100644 index 0000000..6f5d4f8 --- /dev/null +++ b/src/SQLInsight.Agent/Configuration/AgentRequestService.cs @@ -0,0 +1,250 @@ +using System.Data.Common; +using SQLInsight.Collectors.Discovery; +using SQLInsight.Connectivity; +using SQLInsight.Core.Configuration; +using SQLInsight.Core.Instances; +using SQLInsight.Repository; + +namespace SQLInsight.Agent.Configuration; + +/// Set by the monitoring service when the repository is migrated and the configuration file mirrored. +public sealed class RepositoryReadiness +{ + private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Ready => _ready.Task; + + public void MarkReady() => _ready.TrySetResult(); +} + +/// +/// Runs what SQLInsight Web asks for (insight.AgentRequests): connection tests, database discovery and instance +/// discovery. They run here, as the agent's own identity, because that is what connects to the instances in production; +/// a test from the web server would prove nothing. Also refreshes every instance's database list hourly, so new +/// databases are noticed (and monitored if the instance says so), and publishes the agent's public key. +/// Nothing is written to the monitored instances; results go to the repository only. +/// +public sealed partial class AgentRequestService( + InsightRepository repository, + InstanceConfigurationStore store, + AgentCredentialKey key, + ISqlConnectionFactory connectionFactory, + RepositoryReadiness readiness, + TimeProvider timeProvider, + ILogger logger) : BackgroundService +{ + /// How often the queue is looked at. Short, so the web UI gets an answer within seconds. + public static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2); + + /// How often every instance's databases are listed again without being asked. + public static readonly TimeSpan DiscoveryInterval = TimeSpan.FromHours(1); + + public static readonly TimeSpan RequestTimeout = TimeSpan.FromMinutes(2); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await readiness.Ready.WaitAsync(stoppingToken).ConfigureAwait(false); + var nextHousekeeping = DateTimeOffset.MinValue; + using var timer = new PeriodicTimer(PollInterval, timeProvider); + do + { + try + { + var now = timeProvider.GetUtcNow(); + if (now >= nextHousekeeping) + { + nextHousekeeping = now.AddMinutes(10); + await repository.PublishAgentKeyAsync(key.Describe(now), stoppingToken).ConfigureAwait(false); + await repository.CleanUpAgentRequestsAsync(now, RequestTimeout + TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(30), TimeSpan.FromDays(30), stoppingToken) + .ConfigureAwait(false); + } + + // Everything waiting first, then at most one routine discovery per round. + while (await repository.ClaimAgentRequestAsync(timeProvider.GetUtcNow(), stoppingToken).ConfigureAwait(false) is { } request) + { + await HandleAsync(request, stoppingToken).ConfigureAwait(false); + } + + await RefreshOneAsync(timeProvider.GetUtcNow(), stoppingToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Typically the repository is unreachable. Try again next round. + LogRoundFailed(logger, ex); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)); + } + + /// Runs one request and stores its result. A failure is the request's result, never the service's. + public async Task HandleAsync(AgentRequestRow request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(RequestTimeout); + string? result = null; + string? error = null; + try + { + result = request.Kind switch + { + AgentRequestKind.ConnectionTest => AgentRequestJson.Serialize(await TestAsync(Require(request), timeout.Token).ConfigureAwait(false)), + AgentRequestKind.DatabaseDiscovery => AgentRequestJson.Serialize(await DiscoverAsync(Require(request), timeout.Token).ConfigureAwait(false)), + AgentRequestKind.InstanceDiscovery => AgentRequestJson.Serialize(await InstanceFinder.FindAsync( + AgentRequestJson.Deserialize(request.Parameters)?.Host, TimeSpan.FromSeconds(3), timeout.Token).ConfigureAwait(false)), + _ => throw new InvalidOperationException($"Ukendt anmodning: {request.Kind}."), + }; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + error = $"Agenten fik ikke svar inden for {RequestTimeout.TotalMinutes:0} minutter."; + } + catch (Exception ex) when (ex is CredentialException or KeyNotFoundException or InvalidOperationException or DiscoveryFailedException or DbException) + { + error = ex.Message; + } + + LogHandled(logger, request.RequestId, request.Kind, error ?? "ok"); + await repository.CompleteAgentRequestAsync(request.RequestId, error is null, result, error, timeProvider.GetUtcNow(), CancellationToken.None).ConfigureAwait(false); + } + + private static int Require(AgentRequestRow request) => + request.ConfiguredInstanceId ?? throw new InvalidOperationException("Anmodningen mangler en instans."); + + /// Connects as the agent and checks its rights. Not reaching the instance is a result, with a hint, not an error. + public async Task TestAsync(int instanceId, CancellationToken cancellationToken) + { + var (_, instance) = await store.GetAsync(instanceId, cancellationToken).ConfigureAwait(false); + OpenedConnection opened; + try + { + opened = await connectionFactory.OpenAsync(instance.ToTarget(), cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return new ConnectionTestResult { Connected = false, Error = ex.Message, Hint = Hint(ex, instance) }; + } + + await using (opened.ConfigureAwait(false)) + { + var info = await InstanceProbe.DetectAsync(opened.Connection, cancellationToken).ConfigureAwait(false); + var notes = new List(); + if (opened.UsedLegacyFallback) + { + notes.Add("Forbindelsen bruger den ældre ODBC-driver, fordi instansen er for gammel til SqlClient. Krypteringen kan være svagere."); + } + + var result = new ConnectionTestResult + { + Connected = true, + Provider = opened.Provider.ToString(), + UsedLegacyFallback = opened.UsedLegacyFallback, + ProductVersion = info.Version.ToString(), + Release = info.Release.DisplayName(), + Edition = info.Edition, + ServerName = info.ServerName, + Notes = notes, + }; + return await PermissionCheck.CheckAsync(opened.Connection, info, result, cancellationToken).ConfigureAwait(false); + } + } + + /// Lists the instance's databases and stores them. New ones are monitored when the instance monitors new databases. + public async Task DiscoverAsync(int instanceId, CancellationToken cancellationToken) + { + var (_, instance) = await store.GetAsync(instanceId, cancellationToken).ConfigureAwait(false); + List databases; + try + { + await using var opened = await connectionFactory.OpenAsync(instance.ToTarget(), cancellationToken).ConfigureAwait(false); + var info = await InstanceProbe.DetectAsync(opened.Connection, cancellationToken).ConfigureAwait(false); + databases = await DatabaseDiscovery.DiscoverAsync(opened.Connection, info, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await repository.RecordDiscoveryErrorAsync(instanceId, ex.Message, timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false); + throw new DiscoveryFailedException(ex.Message + " " + Hint(ex, instance), ex); + } + + return await repository.SaveDiscoveredDatabasesAsync(instanceId, databases, timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false); + } + + /// The routine discovery of the active instance that waited longest, if one is due. + private async Task RefreshOneAsync(DateTimeOffset now, CancellationToken cancellationToken) + { + var due = (await repository.GetConfiguredInstancesAsync(cancellationToken).ConfigureAwait(false)) + .Where(i => !i.Paused && i.CredentialError is null && (i.LastDiscoveryAt is null || now.UtcDateTime - i.LastDiscoveryAt.Value >= DiscoveryInterval)) + .OrderBy(i => i.LastDiscoveryAt ?? DateTime.MinValue) + .FirstOrDefault(); + if (due is null) + { + return; + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(RequestTimeout); + try + { + var result = await DiscoverAsync(due.ConfiguredInstanceId, timeout.Token).ConfigureAwait(false); + if (result.New.Count > 0) + { + LogNewDatabases(logger, due.Name, result.New, result.NewMonitored); + } + } + catch (Exception ex) when (ex is DiscoveryFailedException or CredentialException or KeyNotFoundException + || ex is OperationCanceledException && !cancellationToken.IsCancellationRequested) + { + // Recorded on the instance (or the password is reported by the monitor); the next round tries the next instance. + await repository.RecordDiscoveryErrorAsync(due.ConfiguredInstanceId, ex.Message, now, cancellationToken).ConfigureAwait(false); + } + } + + /// What to do when the agent could not connect (Danish). + public static string Hint(Exception exception, MonitoredInstance instance) + { + ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(instance); + var number = exception.GetType().GetProperty("Number")?.GetValue(exception) as int?; + var account = AgentCredentialKey.CurrentAccount(); + return number switch + { + 18456 when instance.Authentication == AuthenticationMode.Windows => + $"Agentens konto ({account}, på en anden server typisk computerkontoen DOMÆNE\\{Environment.MachineName}$) kan ikke logge ind. Kør GRANT-scriptet nedenfor som sysadmin; det opretter login'et.", + 18456 => "SQL-login'et eller adgangskoden blev afvist. Tjek dem, og at instansen tillader SQL Server-godkendelse.", + 4060 => "Login'et findes, men kan ikke åbne master. Kør GRANT-scriptet nedenfor.", + 2 or 53 or -1 or 258 => + "Instansen kunne ikke nås fra agentens server. Tjek servernavnet, at instansen kører, og firewallen (TCP 1433, eller UDP 1434 og instansens port for en navngiven instans).", + -2146893019 => "SQL Server bruger et certifikat, agenten ikke har tillid til. Installér et betroet certifikat, eller slå \"Stol på servercertifikatet\" til.", + _ when exception.InnerException is System.Security.Authentication.AuthenticationException => + "TLS-forbindelsen fejlede. Gamle instanser kan kun TLS 1.0: vælg kryptering \"Valgfri\", eller installér et betroet certifikat.", + _ => "Se fejlteksten. Forbindelsen blev forsøgt fra agentens server som agentens konto.", + }; + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Request {RequestId} ({Kind}): {Outcome}")] + private static partial void LogHandled(ILogger logger, long requestId, string kind, string outcome); + + [LoggerMessage(Level = LogLevel.Information, Message = "{Instance}: new databases {Databases} (monitored at once: {Monitored})")] + private static partial void LogNewDatabases(ILogger logger, string instance, IReadOnlyList databases, bool monitored); + + [LoggerMessage(Level = LogLevel.Error, Message = "Handling configuration requests failed; retrying.")] + private static partial void LogRoundFailed(ILogger logger, Exception exception); +} + +/// A discovery that could not reach the instance; the message says why and what to do (Danish). +public sealed class DiscoveryFailedException : Exception +{ + public DiscoveryFailedException() + { + } + + public DiscoveryFailedException(string message) + : base(message) + { + } + + public DiscoveryFailedException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/SQLInsight.Agent/Configuration/InstanceConfigurationStore.cs b/src/SQLInsight.Agent/Configuration/InstanceConfigurationStore.cs new file mode 100644 index 0000000..38e4f96 --- /dev/null +++ b/src/SQLInsight.Agent/Configuration/InstanceConfigurationStore.cs @@ -0,0 +1,170 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; +using SQLInsight.Connectivity; +using SQLInsight.Core.Configuration; +using SQLInsight.Repository; + +namespace SQLInsight.Agent.Configuration; + +/// An instance as the agent monitors it right now: how to connect, whether it is paused, and which databases. +/// Changes when anything about the connection changes, so the monitor is built again. +/// The stored password could not be opened (Danish); the instance is not monitored until it is entered again. +public sealed record EffectiveInstance( + int Id, + MonitoredInstance Instance, + bool FromConfigFile, + bool Paused, + DatabaseSelection Databases, + string Fingerprint, + string? CredentialError = null); + +/// +/// The monitored instances: SQLInsight:Instances from the configuration file (mirrored into the repository as +/// read-only "fra konfigurationsfil" instances, so nobody loses monitoring on upgrade) and the instances added in +/// SQLInsight Web. Read again on every scheduler tick, so changes take effect without a restart. +/// +public sealed partial class InstanceConfigurationStore(IOptions options, InsightRepository repository, AgentCredentialKey key, ILogger logger) +{ + /// Mirrors the configuration file's instances into the repository. Called at start, after the migration. + public Task SyncConfigFileAsync(DateTimeOffset now, CancellationToken cancellationToken) + { + var file = options.Value.Instances + .Where(i => !string.IsNullOrWhiteSpace(i.Name) && !string.IsNullOrWhiteSpace(i.Server)) + .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .Select(i => new ConfigFileInstance(i.Name, i.Server, i.Authentication.ToString(), i.UserName, i.Encryption.ToString(), i.TrustServerCertificate)) + .ToList(); + return repository.SyncConfigFileInstancesAsync(file, now, cancellationToken); + } + + /// What the configuration file alone says: used when the repository cannot be read, so monitoring goes on as before 1.3. + public IReadOnlyList FromConfigFileOnly() => + options.Value.Instances + .Where(i => !string.IsNullOrWhiteSpace(i.Name) && !string.IsNullOrWhiteSpace(i.Server)) + .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .Select(g => new EffectiveInstance(0, g.First(), true, false, DatabaseSelection.All, Fingerprint(g.First(), null))) + .ToList(); + + /// Every configured instance (paused ones too), with its database selection. + public async Task> LoadAsync(CancellationToken cancellationToken) + { + var rows = await repository.GetConfiguredInstancesAsync(cancellationToken).ConfigureAwait(false); + var choices = (await repository.GetAllDatabaseChoicesAsync(cancellationToken).ConfigureAwait(false)).ToLookup(c => c.ConfiguredInstanceId); + var file = options.Value.Instances.GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + var result = new List(rows.Count); + foreach (var row in rows) + { + var databases = Selection(row, choices[row.ConfiguredInstanceId]); + if (row.FromConfigFile) + { + // The file is the source of the connection (and its password); the row only adds pause and database choices. + if (file.TryGetValue(row.Name, out var configured)) + { + result.Add(new EffectiveInstance(row.ConfiguredInstanceId, configured, true, row.Paused, databases, Fingerprint(configured, null))); + } + + continue; + } + + // The configuration file wins a name; the agent removes the web instance at its next start. + if (file.ContainsKey(row.Name)) + { + continue; + } + + var (instance, error) = await ToInstanceAsync(row, cancellationToken).ConfigureAwait(false); + result.Add(new EffectiveInstance(row.ConfiguredInstanceId, instance, false, row.Paused, databases, Fingerprint(instance, row.PasswordCipher), error)); + } + + // An instance in the file that is not mirrored yet (the mirroring failed) is still monitored, every database, as before 1.3. + foreach (var missing in FromConfigFileOnly().Where(f => !result.Any(r => r.FromConfigFile && string.Equals(r.Instance.Name, f.Instance.Name, StringComparison.OrdinalIgnoreCase)))) + { + result.Add(missing); + } + + return result; + } + + /// How to connect to a configured instance, for a connection test or a discovery. + /// The stored password cannot be opened. + /// The instance does not exist (any more). + public async Task<(ConfiguredInstanceRow Row, MonitoredInstance Instance)> GetAsync(int id, CancellationToken cancellationToken) + { + var row = await repository.GetConfiguredInstanceAsync(id, cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException($"Instans {id} findes ikke."); + if (row.FromConfigFile) + { + var configured = options.Value.Instances.FirstOrDefault(i => string.Equals(i.Name, row.Name, StringComparison.OrdinalIgnoreCase)) + ?? throw new KeyNotFoundException($"{row.Name} står ikke længere i agentens konfigurationsfil."); + return (row, configured); + } + + var (instance, error) = await ToInstanceAsync(row, cancellationToken).ConfigureAwait(false); + return error is null ? (row, instance) : throw new CredentialException(error); + } + + private async Task<(MonitoredInstance Instance, string? Error)> ToInstanceAsync(ConfiguredInstanceRow row, CancellationToken cancellationToken) + { + var instance = new MonitoredInstance + { + Name = row.Name, + Server = row.DataSource, + Authentication = Enum.TryParse(row.Authentication, out var authentication) ? authentication : AuthenticationMode.Windows, + UserName = row.UserName, + Encryption = Enum.TryParse(row.Encryption, out var encryption) ? encryption : EncryptionMode.Mandatory, + TrustServerCertificate = row.TrustServerCertificate, + }; + if (instance.Authentication is not (AuthenticationMode.SqlLogin or AuthenticationMode.EntraServicePrincipal)) + { + return (instance, null); + } + + string? error = null; + if (row.PasswordCipher is not { Length: > 0 } cipher) + { + error = "Der er ikke angivet en adgangskode til SQL-login'et."; + } + else + { + try + { + instance.Password = key.Open(cipher, row.DataSource, row.UserName); + } + catch (CredentialException ex) + { + error = ex.Message; + LogCredentialError(logger, row.Name, ex.Message); + } + } + + if (!string.Equals(error, row.CredentialError, StringComparison.Ordinal)) + { + await repository.SetCredentialErrorAsync(row.ConfiguredInstanceId, error, cancellationToken).ConfigureAwait(false); + } + + return (instance, error); + } + + private static DatabaseSelection Selection(ConfiguredInstanceRow row, IEnumerable databases) + { + var list = databases.Where(d => !d.Gone).ToList(); + return new DatabaseSelection( + row.MonitorNewDatabases, + list.Where(d => d.Monitored).Select(d => d.DatabaseName), + list.Where(d => !d.Monitored).Select(d => d.DatabaseName), + list.Where(d => d.SendDynamicsAxToHub && d.IsDynamicsAx).Select(d => d.DatabaseName)); + } + + private static string Fingerprint(MonitoredInstance instance, byte[]? cipher) + { + var text = string.Join('\n', instance.Name, instance.Server, instance.Authentication, instance.UserName, instance.Provider, instance.Encryption, + instance.TrustServerCertificate, instance.OdbcDriver, instance.TenantId, instance.Password is null ? string.Empty : "pw", + cipher is null ? string.Empty : Convert.ToHexString(SHA256.HashData(cipher)), + instance.Password is { } password && cipher is null ? Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password))) : string.Empty); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))); + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "{Instance}: the stored password cannot be used: {Error}")] + private static partial void LogCredentialError(ILogger logger, string instance, string error); +} diff --git a/src/SQLInsight.Agent/Configuration/InstanceFinder.cs b/src/SQLInsight.Agent/Configuration/InstanceFinder.cs new file mode 100644 index 0000000..151d4d5 --- /dev/null +++ b/src/SQLInsight.Agent/Configuration/InstanceFinder.cs @@ -0,0 +1,151 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Runtime.Versioning; +using System.Text; +using SQLInsight.Core.Configuration; + +namespace SQLInsight.Agent.Configuration; + +/// +/// Finds SQL Server instances on a computer, from the agent: the SQL Server Browser service (UDP 1434, one unicast +/// question) and, on the agent's own computer, the registry. Cheap and read-only; an instance hidden from the Browser +/// or behind a firewall is simply not found and can be entered by hand. +/// +public static class InstanceFinder +{ + public const int BrowserPort = 1434; + + /// CLNT_UCAST_EX: "tell me about all your instances" (MC-SQLR 2.2.2). + private static readonly byte[] AllInstancesRequest = [0x03]; + + public static async Task FindAsync(string? host, TimeSpan timeout, CancellationToken cancellationToken) + { + var target = string.IsNullOrWhiteSpace(host) ? Environment.MachineName : host.Trim(); + var notes = new List(); + var found = new List(); + try + { + found.AddRange(await AskBrowserAsync(target, timeout, cancellationToken).ConfigureAwait(false)); + if (found.Count == 0) + { + notes.Add($"SQL Server Browser på {target} svarede ikke (UDP {BrowserPort}). Tjenesten kan være stoppet, eller en firewall blokerer. Angiv serveren manuelt."); + } + } + catch (SocketException ex) + { + notes.Add($"SQL Server Browser på {target} kunne ikke spørges: {ex.Message}"); + } + + if (OperatingSystem.IsWindows() && Core.Configuration.ServiceAccounts.IsLocal(target, Environment.MachineName)) + { + foreach (var instance in FromRegistry()) + { + if (!found.Any(f => string.Equals(f.InstanceName, instance.InstanceName, StringComparison.OrdinalIgnoreCase))) + { + found.Add(instance); + } + } + } + + return new InstanceDiscoveryResult { Host = target, Instances = found.OrderBy(f => f.Server, StringComparer.OrdinalIgnoreCase).ToList(), Notes = notes }; + } + + private static async Task> AskBrowserAsync(string host, TimeSpan timeout, CancellationToken cancellationToken) + { + var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false); + var address = addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) ?? addresses.FirstOrDefault() + ?? throw new SocketException((int)SocketError.HostNotFound); + using var udp = new UdpClient(address.AddressFamily); + await udp.SendAsync(AllInstancesRequest, new IPEndPoint(address, BrowserPort), cancellationToken).ConfigureAwait(false); + using var wait = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + wait.CancelAfter(timeout); + try + { + var response = await udp.ReceiveAsync(wait.Token).ConfigureAwait(false); + return Parse(response.Buffer, host); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return []; + } + } + + /// + /// SVR_RESP: 0x05, a two-byte length, then "ServerName;X;InstanceName;Y;IsClustered;No;Version;15.0.2000.5;tcp;1433;;" per instance. + /// + public static List Parse(byte[] response, string host) + { + ArgumentNullException.ThrowIfNull(response); + if (response.Length < 3 || response[0] != 0x05) + { + return []; + } + + var text = Encoding.ASCII.GetString(response, 3, response.Length - 3); + var instances = new List(); + foreach (var record in text.Split(";;", StringSplitOptions.RemoveEmptyEntries)) + { + var parts = record.Split(';'); + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i + 1 < parts.Length; i += 2) + { + values.TryAdd(parts[i], parts[i + 1]); + } + + if (!values.TryGetValue("InstanceName", out var name) || name.Length == 0) + { + continue; + } + + var server = values.TryGetValue("ServerName", out var serverName) && serverName.Length > 0 ? serverName : host; + int? port = values.TryGetValue("tcp", out var tcp) && int.TryParse(tcp, NumberStyles.None, CultureInfo.InvariantCulture, out var p) ? p : null; + instances.Add(new DiscoveredInstance( + IsDefault(name) ? server : server + "\\" + name, + name, + values.GetValueOrDefault("Version"), + port, + string.Equals(values.GetValueOrDefault("IsClustered"), "Yes", StringComparison.OrdinalIgnoreCase), + "SQL Server Browser")); + } + + return instances; + } + + private static bool IsDefault(string instanceName) => string.Equals(instanceName, "MSSQLSERVER", StringComparison.OrdinalIgnoreCase); + + /// Instances installed on this computer (HKLM\SOFTWARE\Microsoft\Microsoft SQL Server\Instance Names\SQL). + [SupportedOSPlatform("windows")] + private static List FromRegistry() + { + var found = new List(); + try + { + using var hive = Microsoft.Win32.RegistryKey.OpenBaseKey(Microsoft.Win32.RegistryHive.LocalMachine, Microsoft.Win32.RegistryView.Registry64); + using var names = hive.OpenSubKey(@"SOFTWARE\Microsoft\Microsoft SQL Server\Instance Names\SQL"); + if (names is null) + { + return found; + } + + foreach (var name in names.GetValueNames()) + { + var id = names.GetValue(name) as string; + using var setup = id is null ? null : hive.OpenSubKey($@"SOFTWARE\Microsoft\Microsoft SQL Server\{id}\Setup"); + found.Add(new DiscoveredInstance( + IsDefault(name) ? Environment.MachineName : Environment.MachineName + "\\" + name, + name, + setup?.GetValue("Version") as string, + null, + false, + "registreringsdatabasen")); + } + } + catch (Exception ex) when (ex is System.Security.SecurityException or UnauthorizedAccessException or IOException) + { + // The service account may not read the key; the Browser answer is enough. + } + + return found; + } +} diff --git a/src/SQLInsight.Agent/Hub/HubOutbox.cs b/src/SQLInsight.Agent/Hub/HubOutbox.cs index 15c626f..9f4af3f 100644 --- a/src/SQLInsight.Agent/Hub/HubOutbox.cs +++ b/src/SQLInsight.Agent/Hub/HubOutbox.cs @@ -18,7 +18,9 @@ public sealed class HubOutbox(IOptions options, InsightRepository re public bool Enabled => options.Value.Enabled && options.Value.Url is not null; - public async Task QueueAsync(string instanceName, int instanceId, InstanceInfo info, IReadOnlyList results, bool changed, DateTimeOffset now, CancellationToken cancellationToken) + /// Which databases may send Dynamics AX findings; none when null (the default is off). + public async Task QueueAsync(string instanceName, int instanceId, InstanceInfo info, IReadOnlyList results, bool changed, DateTimeOffset now, CancellationToken cancellationToken, + Core.Configuration.DatabaseSelection? databases = null) { if (!Enabled) { @@ -36,7 +38,7 @@ public sealed class HubOutbox(IOptions options, InsightRepository re } var active = await repository.GetActiveFindingsAsync(instanceId, cancellationToken).ConfigureAwait(false); - var submission = SubmissionBuilder.Build(instanceName, info, results, active, options.Value, now); + var submission = SubmissionBuilder.Build(instanceName, info, results, active, options.Value, now, databases); await repository.EnqueueSubmissionAsync(instanceName, submission.SubmissionId, JsonSerializer.Serialize(submission, Json), now, cancellationToken).ConfigureAwait(false); } } diff --git a/src/SQLInsight.Agent/Hub/SubmissionBuilder.cs b/src/SQLInsight.Agent/Hub/SubmissionBuilder.cs index dbb6026..4767856 100644 --- a/src/SQLInsight.Agent/Hub/SubmissionBuilder.cs +++ b/src/SQLInsight.Agent/Hub/SubmissionBuilder.cs @@ -13,14 +13,22 @@ public static class SubmissionBuilder public static readonly string AgentVersion = typeof(SubmissionBuilder).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + /// + /// Which databases may send Dynamics AX findings (chosen per database in SQLInsight Web). AX findings and the + /// outcomes of AX rules stay in the network unless allowed; with null nothing about AX is sent. + /// public static FindingsSubmission Build( string instanceName, InstanceInfo info, IReadOnlyList results, IReadOnlyList activeFindings, - HubOptions options, DateTimeOffset now) + HubOptions options, DateTimeOffset now, Core.Configuration.DatabaseSelection? databases = null) { ArgumentNullException.ThrowIfNull(info); ArgumentNullException.ThrowIfNull(results); ArgumentNullException.ThrowIfNull(activeFindings); ArgumentNullException.ThrowIfNull(options); + databases ??= Core.Configuration.DatabaseSelection.All; + var axRulesAllowed = databases.AnyDynamicsAxToHub; + bool Allowed(string ruleId, string? subject) => + !Core.Configuration.DynamicsAxFindings.IsDynamicsAxRule(ruleId) || databases.SendsDynamicsAxToHub(Core.Configuration.DynamicsAxFindings.DatabaseOf(subject)); return new FindingsSubmission { @@ -37,8 +45,8 @@ public static class SubmissionBuilder Edition = info.Edition, ServerName = options.SendServerName ? info.ServerName : null, }, - Rules = results.Select(r => new RuleOutcomeDto { RuleId = r.RuleId, Outcome = r.Outcome.ToString(), Reason = r.Reason }).ToList(), - Findings = activeFindings.Select(f => new FindingDto + Rules = results.Where(r => axRulesAllowed || !Core.Configuration.DynamicsAxFindings.IsDynamicsAxRule(r.RuleId)).Select(r => new RuleOutcomeDto { RuleId = r.RuleId, Outcome = r.Outcome.ToString(), Reason = r.Reason }).ToList(), + Findings = activeFindings.Where(f => Allowed(f.RuleId, f.Subject)).Select(f => new FindingDto { RuleId = f.RuleId, Subject = f.Subject, diff --git a/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs b/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs index f9c254e..46d148a 100644 --- a/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs +++ b/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs @@ -37,6 +37,9 @@ public sealed partial class InstanceMonitor( public string Name => instance.Name; + /// The databases chosen for monitoring; changed by the monitoring service when the choice changes. + public Core.Configuration.DatabaseSelection Databases { get; set; } = Core.Configuration.DatabaseSelection.All; + public TimeSpan IntervalOf(ICollector collector) => intervalOverrides.TryGetValue(collector.Name, out var interval) ? interval : collector.DefaultInterval; @@ -115,7 +118,7 @@ public sealed partial class InstanceMonitor( await NotifyAsync(events, cancellationToken).ConfigureAwait(false); if (hubOutbox is not null) { - await hubOutbox.QueueAsync(instance.Name, instanceId, info, results, changes.Opened.Count + changes.Resolved.Count > 0, now, cancellationToken).ConfigureAwait(false); + await hubOutbox.QueueAsync(instance.Name, instanceId, info, results, changes.Opened.Count + changes.Resolved.Count > 0, now, cancellationToken, Databases).ConfigureAwait(false); } var findings = 0; var notMeasurable = 0; @@ -186,7 +189,7 @@ public sealed partial class InstanceMonitor( previous = await SeedAsync(collector, instanceId, now, cancellationToken).ConfigureAwait(false); } - var result = await collector.CollectAsync(new CollectorContext(opened.Connection, info, capabilities, now, previous, cancellationToken)).ConfigureAwait(false); + var result = await collector.CollectAsync(new CollectorContext(opened.Connection, info, capabilities, now, previous, cancellationToken, Databases)).ConfigureAwait(false); _previous[collector.Name] = result; _data.Set(collector.DataType, result); await repository.SaveCollectedAsync(instanceId, collector.Name, result, cancellationToken).ConfigureAwait(false); diff --git a/src/SQLInsight.Agent/Monitoring/MonitoringService.cs b/src/SQLInsight.Agent/Monitoring/MonitoringService.cs index 237ad62..00f488b 100644 --- a/src/SQLInsight.Agent/Monitoring/MonitoringService.cs +++ b/src/SQLInsight.Agent/Monitoring/MonitoringService.cs @@ -9,6 +9,7 @@ namespace SQLInsight.Agent.Monitoring; /// /// The agent's main loop: migrates the repository, then checks every instance on each tick and runs maintenance hourly. +/// The instances are read from the configuration (file and SQLInsight Web) on every tick, so changes need no restart. /// public sealed partial class MonitoringService( IOptions options, @@ -21,44 +22,49 @@ public sealed partial class MonitoringService( IOptions notificationOptions, Hub.HubOutbox hubOutbox, Core.CustomRules.CustomRuleCatalog customRules, - Core.Data.IGroupMemberResolver groupResolver) : BackgroundService + Core.Data.IGroupMemberResolver groupResolver, + Configuration.InstanceConfigurationStore store, + Configuration.RepositoryReadiness readiness) : BackgroundService { private readonly ILogger _logger = loggerFactory.CreateLogger(); + private readonly Dictionary _credentialErrors = new(StringComparer.OrdinalIgnoreCase); + private bool _configurationLoaded; + private bool _warnedNoInstances; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var settings = options.Value; await MigrateWithRetryAsync(stoppingToken).ConfigureAwait(false); + await SyncConfigFileAsync(stoppingToken).ConfigureAwait(false); + readiness.MarkReady(); var intervals = new Dictionary(settings.CollectorIntervals, StringComparer.OrdinalIgnoreCase); - var monitors = settings.Instances - .Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(customRules, new CollectorSettings(groupResolver, settings.Privileges.MaxSysadminAccounts, settings.TopSql.ActivityThresholdPercent, settings.ErrorLog.LoginFailureBurst, - settings.Activity.TransactionMinutes, settings.Activity.TransactionLogMegabytes, settings.Activity.RequestMinutes, - settings.Tempdb.VersionStoreMegabytes, settings.Tempdb.SessionMegabytes)), intervals, connectionFactory, ruleEngine, repository, - loggerFactory.CreateLogger($"SQLInsight.Instance.{i.Name}"), notifications, notificationOptions.Value.WebUrl, hubOutbox)) - .ToList(); - if (monitors.Count == 0) - { - LogNoInstances(_logger); - } - + var monitors = new Dictionary(StringComparer.OrdinalIgnoreCase); using var throttle = new SemaphoreSlim(Math.Max(1, settings.MaxParallelInstances)); - var running = new Dictionary(); + var running = new Dictionary(StringComparer.OrdinalIgnoreCase); var nextMaintenance = timeProvider.GetUtcNow(); using var timer = new PeriodicTimer(settings.Tick, timeProvider); do { var now = timeProvider.GetUtcNow(); - foreach (var monitor in monitors) + + // Instances added, paused, removed or changed in SQLInsight Web take effect here, without a restart. + await RefreshMonitorsAsync(monitors, intervals, settings, now, stoppingToken).ConfigureAwait(false); + foreach (var (name, (monitor, _)) in monitors) { - // A slow instance keeps its own cycle; it never delays the others. - if (running.TryGetValue(monitor, out var task) && !task.IsCompleted || !monitor.IsDue(now)) + // A slow instance keeps its own cycle; it never delays the others. A rebuilt monitor waits for the old cycle. + if (running.TryGetValue(name, out var task) && !task.IsCompleted || !monitor.IsDue(now)) { continue; } - running[monitor] = RunThrottledAsync(monitor, now, throttle, stoppingToken); + running[name] = RunThrottledAsync(monitor, now, throttle, stoppingToken); + } + + foreach (var done in running.Where(r => r.Value.IsCompleted && !monitors.ContainsKey(r.Key)).Select(r => r.Key).ToList()) + { + running.Remove(done); } if (now >= nextMaintenance) @@ -72,6 +78,109 @@ public sealed partial class MonitoringService( await Task.WhenAll(running.Values).ConfigureAwait(false); } + private InstanceMonitor CreateMonitor(MonitoredInstance instance, AgentOptions settings, IReadOnlyDictionary intervals) => + new(instance, BuiltInCollectors.Create(customRules, new CollectorSettings(groupResolver, settings.Privileges.MaxSysadminAccounts, settings.TopSql.ActivityThresholdPercent, settings.ErrorLog.LoginFailureBurst, + settings.Activity.TransactionMinutes, settings.Activity.TransactionLogMegabytes, settings.Activity.RequestMinutes, + settings.Tempdb.VersionStoreMegabytes, settings.Tempdb.SessionMegabytes)), intervals, connectionFactory, ruleEngine, repository, + loggerFactory.CreateLogger($"SQLInsight.Instance.{instance.Name}"), notifications, notificationOptions.Value.WebUrl, hubOutbox); + + /// + /// Brings the monitors in step with the configuration (the configuration file and SQLInsight Web). When the + /// repository cannot be read, the monitors keep running as they are; before the first successful read the + /// configuration file alone is used, as before 1.3. + /// + private async Task RefreshMonitorsAsync(Dictionary monitors, IReadOnlyDictionary intervals, + AgentOptions settings, DateTimeOffset now, CancellationToken cancellationToken) + { + IReadOnlyList configured; + try + { + configured = await store.LoadAsync(cancellationToken).ConfigureAwait(false); + _configurationLoaded = true; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogConfigurationFailed(_logger, ex); + if (_configurationLoaded) + { + return; + } + + configured = store.FromConfigFileOnly(); + } + + var active = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var instance in configured) + { + var name = instance.Instance.Name; + if (instance.Paused) + { + continue; + } + + if (instance.CredentialError is { } error) + { + // Shown on the overview like any other connection problem, once per change. + if (!_credentialErrors.TryGetValue(name, out var reported) || reported != error) + { + _credentialErrors[name] = error; + await RecordQuietlyAsync(name, "Adgangskoden kan ikke bruges: " + error, now, cancellationToken).ConfigureAwait(false); + } + + continue; + } + + _credentialErrors.Remove(name); + active.Add(name); + if (!monitors.TryGetValue(name, out var current) || current.Fingerprint != instance.Fingerprint) + { + current = (CreateMonitor(instance.Instance, settings, intervals), instance.Fingerprint); + monitors[name] = current; + LogMonitorStarted(_logger, name); + } + + current.Monitor.Databases = instance.Databases; + } + + foreach (var gone in monitors.Keys.Where(n => !active.Contains(n)).ToList()) + { + monitors.Remove(gone); + LogMonitorStopped(_logger, gone); + } + + if (monitors.Count == 0 && !_warnedNoInstances) + { + LogNoInstances(_logger); + } + + _warnedNoInstances = monitors.Count == 0; + } + + private async Task RecordQuietlyAsync(string name, string error, DateTimeOffset now, CancellationToken cancellationToken) + { + try + { + await repository.RecordInstanceErrorAsync(name, error, now, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogConfigurationFailed(_logger, ex); + } + } + + private async Task SyncConfigFileAsync(CancellationToken cancellationToken) + { + try + { + await store.SyncConfigFileAsync(timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // The instances in the file are monitored anyway (InstanceConfigurationStore.LoadAsync adds them). + LogConfigurationFailed(_logger, ex); + } + } + private async Task RunThrottledAsync(InstanceMonitor monitor, DateTimeOffset now, SemaphoreSlim throttle, CancellationToken cancellationToken) { await throttle.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -132,6 +241,15 @@ public sealed partial class MonitoringService( [LoggerMessage(Level = LogLevel.Error, Message = "{Instance}: cycle failed")] private static partial void LogCycleFailed(ILogger logger, Exception exception, string instance); + [LoggerMessage(Level = LogLevel.Information, Message = "{Instance}: monitoring started")] + private static partial void LogMonitorStarted(ILogger logger, string instance); + + [LoggerMessage(Level = LogLevel.Information, Message = "{Instance}: monitoring stopped (paused, removed or no longer configured)")] + private static partial void LogMonitorStopped(ILogger logger, string instance); + + [LoggerMessage(Level = LogLevel.Warning, Message = "The instance configuration could not be read from the repository.")] + private static partial void LogConfigurationFailed(ILogger logger, Exception exception); + [LoggerMessage(Level = LogLevel.Error, Message = "Repository maintenance failed.")] private static partial void LogMaintenanceFailed(ILogger logger, Exception exception); } diff --git a/src/SQLInsight.Agent/Program.cs b/src/SQLInsight.Agent/Program.cs index d24c1db..5241882 100644 --- a/src/SQLInsight.Agent/Program.cs +++ b/src/SQLInsight.Agent/Program.cs @@ -108,6 +108,13 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService(), sp.GetRequiredService>()) : new SQLInsight.Core.Data.NoGroupResolver("Agenten kører ikke på Windows og kan derfor ikke spørge Active Directory."); }); +// 1.3: instances and databases configured in SQLInsight Web; connection tests and discovery run here, as the agent. +builder.Services.AddSingleton(sp => SQLInsight.Agent.Configuration.AgentCredentialKey.LoadOrCreate( + Path.Combine(sp.GetRequiredService>().Value.DataDirectory ?? SQLInsight.Updates.UpdateDirectories.DefaultRoot, "keys"), + sp.GetRequiredService().CreateLogger("SQLInsight.Agent.CredentialKey"))); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); var host = builder.Build(); diff --git a/src/SQLInsight.Agent/SQLInsight.Agent.csproj b/src/SQLInsight.Agent/SQLInsight.Agent.csproj index 98def17..7e207bd 100644 --- a/src/SQLInsight.Agent/SQLInsight.Agent.csproj +++ b/src/SQLInsight.Agent/SQLInsight.Agent.csproj @@ -22,6 +22,7 @@ + diff --git a/tests/SQLInsight.Agent.Tests/InstanceConfigurationAgentTests.cs b/tests/SQLInsight.Agent.Tests/InstanceConfigurationAgentTests.cs new file mode 100644 index 0000000..da51669 --- /dev/null +++ b/tests/SQLInsight.Agent.Tests/InstanceConfigurationAgentTests.cs @@ -0,0 +1,147 @@ +using System.Text; +using SQLInsight.Agent.Configuration; +using SQLInsight.Agent.Hub; +using SQLInsight.Core.Configuration; +using SQLInsight.Core.Findings; +using SQLInsight.Core.Instances; +using SQLInsight.Repository; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Agent.Tests; + +public sealed class AgentCredentialKeyTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), "sqlinsight-key-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } + + [Fact] + public void Key_is_created_once_kept_and_opens_what_the_web_ui_sealed_with_its_public_key() + { + string keyId; + byte[] envelope; + using (var first = AgentCredentialKey.LoadOrCreate(_directory)) + { + keyId = first.KeyId; + envelope = CredentialEnvelope.Seal("Hemmelig-1!", first.KeyId, first.Describe(DateTimeOffset.UtcNow).PublicKey, CredentialEnvelope.Context("sql01,1450", "mon")); + } + + using var again = AgentCredentialKey.LoadOrCreate(_directory); + + Assert.Equal(keyId, again.KeyId); + Assert.StartsWith("agent-", again.KeyId, StringComparison.Ordinal); + Assert.Equal("Hemmelig-1!", again.Open(envelope, "sql01,1450", "mon")); + Assert.Throws(() => again.Open(envelope, "other", "mon")); + var stored = File.ReadAllBytes(Path.Combine(_directory, AgentCredentialKey.FileName)); + Assert.DoesNotContain("PRIVATE KEY", Encoding.ASCII.GetString(stored), StringComparison.Ordinal); + } + + [Fact] + public void An_unreadable_key_file_is_replaced_and_old_passwords_must_be_entered_again() + { + string oldId; + using (var first = AgentCredentialKey.LoadOrCreate(_directory)) + { + oldId = first.KeyId; + } + + File.WriteAllBytes(Path.Combine(_directory, AgentCredentialKey.FileName), [1, 2, 3, 4]); + using var replaced = AgentCredentialKey.LoadOrCreate(_directory); + + Assert.NotEqual(oldId, replaced.KeyId); + } + + [Fact] + public void The_published_identity_is_the_account_and_computer_the_agent_runs_as() + { + using var key = AgentCredentialKey.LoadOrCreate(_directory); + var row = key.Describe(DateTimeOffset.UtcNow); + + Assert.Equal(Environment.MachineName, row.MachineName); + Assert.False(string.IsNullOrEmpty(row.ServiceAccount)); + Assert.Equal(key.KeyId, row.KeyId); + } +} + +public sealed class InstanceFinderTests +{ + private static byte[] Response(string text) + { + var body = Encoding.ASCII.GetBytes(text); + return [0x05, (byte)(body.Length & 0xFF), (byte)(body.Length >> 8), .. body]; + } + + [Fact] + public void Browser_answer_lists_default_and_named_instances() + { + var instances = InstanceFinder.Parse(Response( + "ServerName;SQL01;InstanceName;MSSQLSERVER;IsClustered;No;Version;15.0.2000.5;tcp;1433;;" + + "ServerName;SQL01;InstanceName;AX30;IsClustered;Yes;Version;9.00.5000.00;tcp;1450;np;\\\\SQL01\\pipe\\MSSQL$AX30\\sql\\query;;"), "sql01"); + + Assert.Equal(2, instances.Count); + Assert.Equal(("SQL01", "MSSQLSERVER", 1433, false), (instances[0].Server, instances[0].InstanceName, instances[0].TcpPort, instances[0].IsClustered)); + Assert.Equal(("SQL01\\AX30", "9.00.5000.00", 1450, true), (instances[1].Server, instances[1].Version, instances[1].TcpPort, instances[1].IsClustered)); + Assert.All(instances, i => Assert.Equal("SQL Server Browser", i.Source)); + } + + [Fact] + public void Anything_else_is_ignored() + { + Assert.Empty(InstanceFinder.Parse([0x04, 0, 0], "x")); + Assert.Empty(InstanceFinder.Parse([0x05], "x")); + Assert.Empty(InstanceFinder.Parse(Response("ServerName;SQL01;;"), "x")); + } +} + +public sealed class DynamicsAxHubTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero); + + private static readonly InstanceInfo Info = new() + { + ServerName = "AX01", Version = SqlServerVersion.Parse("9.0.5000.0"), Edition = "Standard Edition", EngineEdition = EngineEdition.Standard, + }; + + private static StoredFinding Finding(string ruleId, string subject) => new() + { + FindingId = 1, RuleId = ruleId, Subject = subject, Severity = Severity.Medium, Title = "t", Explanation = "e", Recommendation = "r", + FirstSeenAt = Now.UtcDateTime, Evidence = [], + }; + + private static readonly RuleResult[] Results = + [ + new() { RuleId = "COM-AX-001", RuleTitle = "AX 3.0", Outcome = RuleOutcome.Findings }, + new() { RuleId = "SQLI-DB-001", RuleTitle = "Backup", Outcome = RuleOutcome.Findings }, + ]; + + private static readonly StoredFinding[] Findings = + [ + Finding("COM-AX-001", "database: AX30"), Finding("COM-AX-001", "database: AXTEST"), Finding("SQLI-DB-001", "database: AX30"), + ]; + + [Fact] + public void Dynamics_ax_findings_stay_in_the_network_by_default() + { + var submission = SubmissionBuilder.Build("ax", Info, Results, Findings, new HubOptions(), Now); + + Assert.Equal(["SQLI-DB-001"], submission.Findings.Select(f => f.RuleId)); + Assert.Equal(["SQLI-DB-001"], submission.Rules.Select(r => r.RuleId)); + } + + [Fact] + public void Only_the_databases_that_allow_it_send_dynamics_ax_findings() + { + var selection = new DatabaseSelection(true, ["AX30", "AXTEST"], [], ["AX30"]); + + var submission = SubmissionBuilder.Build("ax", Info, Results, Findings, new HubOptions(), Now, selection); + + Assert.Equal(["database: AX30", "database: AX30"], submission.Findings.Select(f => f.Subject)); + Assert.Contains(submission.Rules, r => r.RuleId == "COM-AX-001"); + } +} diff --git a/tests/SQLInsight.IntegrationTests/AgentRequestTests.cs b/tests/SQLInsight.IntegrationTests/AgentRequestTests.cs new file mode 100644 index 0000000..b0db8c7 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/AgentRequestTests.cs @@ -0,0 +1,149 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SQLInsight.Agent; +using SQLInsight.Agent.Configuration; +using SQLInsight.Connectivity; +using SQLInsight.Core.Configuration; +using SQLInsight.Repository; + +namespace SQLInsight.IntegrationTests; + +/// +/// The agent's side of the instance configuration: requests from SQLInsight Web run as the agent's login against a +/// real instance, passwords sealed by the web UI are opened only by the agent, and the configuration file keeps working. +/// +[Collection(SqlServerTestGroup.Name)] +[Trait("Category", "Integration")] +public sealed class AgentRequestTests(SqlServerFixture sql) : IAsyncLifetime +{ + private readonly string _database = "SQLInsightReq_" + Guid.NewGuid().ToString("N")[..8]; + private readonly string _keys = Path.Combine(Path.GetTempPath(), "sqlinsight-keys-" + Guid.NewGuid().ToString("N")); + private InsightRepository _repository = null!; + private AgentCredentialKey _key = null!; + private AgentOptions _options = new(); + + public async Task InitializeAsync() + { + await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];"); + _repository = new InsightRepository(sql.DatabaseConnectionString(_database)); + await _repository.MigrateAsync(); + _key = AgentCredentialKey.LoadOrCreate(_keys); + await _repository.PublishAgentKeyAsync(_key.Describe(DateTimeOffset.UtcNow)); + } + + public async Task DisposeAsync() + { + _key.Dispose(); + Directory.Delete(_keys, recursive: true); + await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];"); + } + + private InstanceConfigurationStore Store() => + new(Options.Create(_options), _repository, _key, NullLogger.Instance); + + private AgentRequestService Service() => + new(_repository, Store(), _key, new TrustingFactory(), new RepositoryReadiness(), TimeProvider.System, NullLogger.Instance); + + /// What SQLInsight Web does: seal the password with the agent's published key. + private async Task AddAsWebAsync(string name, string password, string? server = null) + { + var key = (await _repository.GetCurrentAgentKeyAsync())!; + server ??= sql.Server; + var cipher = CredentialEnvelope.Seal(password, key.KeyId, key.PublicKey, CredentialEnvelope.Context(server, SqlServerFixture.AgentLogin)); + return await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance(name, server, null, "SqlLogin", SqlServerFixture.AgentLogin, cipher, key.KeyId, + "Mandatory", TrustServerCertificate: true, MonitorNewDatabases: false), "anna", DateTimeOffset.UtcNow); + } + + private async Task RunAsync(string kind, int? instanceId, string? parameters = null) + { + var id = await _repository.AddAgentRequestAsync(kind, instanceId, parameters, "anna", DateTimeOffset.UtcNow); + var claimed = await _repository.ClaimAgentRequestAsync(DateTimeOffset.UtcNow); + Assert.Equal(id, claimed!.RequestId); + await Service().HandleAsync(claimed, CancellationToken.None); + return (await _repository.GetAgentRequestAsync(id))!; + } + + [Fact] + public async Task Connection_test_and_discovery_run_as_the_agent_with_the_password_only_it_can_open() + { + var id = await AddAsWebAsync("prod-sql01", SqlServerFixture.AgentPassword); + + var test = await RunAsync(AgentRequestKind.ConnectionTest, id); + var discovery = await RunAsync(AgentRequestKind.DatabaseDiscovery, id); + + Assert.Equal(AgentRequestStatus.Done, test.Status); + var result = AgentRequestJson.Deserialize(test.Result)!; + Assert.True(result.Connected); + Assert.Equal((SqlServerFixture.AgentLogin, false, true), (result.Login, result.IsSysadmin, result.PermissionsChecked)); + Assert.Empty(result.MissingRequired); + Assert.DoesNotContain(SqlServerFixture.AgentPassword, test.Result, StringComparison.Ordinal); + + Assert.Equal(AgentRequestStatus.Done, discovery.Status); + var databases = await _repository.GetConfiguredDatabasesAsync(id); + Assert.Contains(databases, d => d.DatabaseName == SqlServerFixture.TestDatabase && !d.Monitored && d.HasAccess); + Assert.Contains(databases, d => d.DatabaseName == "master" && d.Monitored); + } + + [Fact] + public async Task A_rejected_login_is_a_result_with_a_hint_and_a_moved_server_needs_the_password_again() + { + var wrong = await AddAsWebAsync("wrong-password", "Not-The-Password-1!"); + var test = AgentRequestJson.Deserialize((await RunAsync(AgentRequestKind.ConnectionTest, wrong)).Result)!; + Assert.False(test.Connected); + Assert.Contains("adgangskoden blev afvist", test.Hint, StringComparison.Ordinal); + + // Someone points the instance at another server without typing the password again: the agent will not send it. + var moved = await AddAsWebAsync("moved", SqlServerFixture.AgentPassword); + await _repository.UpdateConfiguredInstanceAsync(moved, new ConfiguredInstanceChange("evil.example", null, "SqlLogin", SqlServerFixture.AgentLogin, null, null, false, + "Mandatory", true), "mallory", DateTimeOffset.UtcNow); + var refused = await RunAsync(AgentRequestKind.ConnectionTest, moved); + + Assert.Equal(AgentRequestStatus.Failed, refused.Status); + Assert.Contains("kan ikke dekrypteres", refused.Error, StringComparison.Ordinal); + Assert.NotNull((await _repository.GetConfiguredInstanceAsync(moved))!.CredentialError); + } + + [Fact] + public async Task Instance_discovery_on_the_agent_computer_completes() + { + var request = await RunAsync(AgentRequestKind.InstanceDiscovery, null, AgentRequestJson.Serialize(new InstanceDiscoveryParameters(null))); + + Assert.Equal(AgentRequestStatus.Done, request.Status); + Assert.Equal(Environment.MachineName, AgentRequestJson.Deserialize(request.Result)!.Host); + } + + [Fact] + public async Task Configuration_file_instances_keep_monitoring_every_database_and_web_choices_take_effect() + { + _options = new AgentOptions(); + _options.Instances.Add(new MonitoredInstance { Name = "from-file", Server = sql.Server, Authentication = AuthenticationMode.SqlLogin, UserName = "u", Password = "file-secret" }); + var store = Store(); + await store.SyncConfigFileAsync(DateTimeOffset.UtcNow, CancellationToken.None); + var web = await AddAsWebAsync("from-web", SqlServerFixture.AgentPassword); + await _repository.SaveDiscoveredDatabasesAsync(web, [new DiscoveredDatabase { Name = "Sales", HasAccess = true }, new DiscoveredDatabase { Name = "Archive", HasAccess = true }], DateTimeOffset.UtcNow); + await _repository.SetDatabaseChoicesAsync(web, [new DatabaseChoice("Sales", true, false)], "anna", DateTimeOffset.UtcNow); + await _repository.SetInstancePausedAsync(web, true, "anna", DateTimeOffset.UtcNow); + + var loaded = (await store.LoadAsync(CancellationToken.None)).ToDictionary(i => i.Instance.Name); + + var file = loaded["from-file"]; + Assert.True(file.FromConfigFile && file.Databases.IsEverything && !file.Paused); + Assert.Equal("file-secret", file.Instance.Password); + Assert.DoesNotContain(await _repository.GetConfiguredInstancesAsync(), r => r.Name == "from-file" && r.HasPassword); + var fromWeb = loaded["from-web"]; + Assert.True(fromWeb.Paused); + Assert.Equal(SqlServerFixture.AgentPassword, fromWeb.Instance.Password); + Assert.True(fromWeb.Databases.Includes("Sales")); + Assert.False(fromWeb.Databases.Includes("Archive")); + Assert.True(fromWeb.Databases.Includes("msdb")); + } + + /// The test container's certificate is self-signed; the instance rows ask to trust it, and so does this. + private sealed class TrustingFactory : ISqlConnectionFactory + { + private readonly SqlConnectionFactory _inner = new(); + + public Task OpenAsync(ConnectionTarget target, CancellationToken cancellationToken = default) => + _inner.OpenAsync(target with { Provider = ConnectionProvider.SqlClient, ConnectTimeout = TimeSpan.FromSeconds(5) }, cancellationToken); + } +}