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);
+ }
+}