Agent: instances from the repository, request queue, sealed passwords, AX hub opt-in
The agent mirrors SQLInsight:Instances into the repository at start (read-only "fra konfigurationsfil" rows, every database monitored as before) and reads the configuration on every tick: instances added, paused, removed or changed in SQLInsight Web, and database choices, take effect without a restart. If the repository cannot be read before the first load, the configuration file alone is used. AgentRequestService runs connection tests (with the permission check), database discovery and instance discovery (SQL Server Browser, registry) as the agent's own identity, and refreshes every instance's database list hourly. The agent keeps an RSA key (DPAPI LocalMachine, restricted folder) and publishes its public key and identity; SQL login passwords sealed by the web UI are opened only here. Dynamics AX findings and AX rule outcomes are sent to SQLInsight Hub only for databases where it is turned on (default off). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b2fa78411f
commit
81b8376026
13 changed files with 1201 additions and 24 deletions
|
|
@ -23,6 +23,7 @@
|
|||
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.12" />
|
||||
<PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.12" />
|
||||
<PackageVersion Include="System.DirectoryServices.AccountManagement" Version="10.0.12" />
|
||||
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="10.0.12" />
|
||||
<PackageVersion Include="PDFsharp-MigraDoc" Version="6.2.4" />
|
||||
<PackageVersion Include="Markdig" Version="1.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="10.0.12" />
|
||||
|
|
|
|||
170
src/SQLInsight.Agent/Configuration/AgentCredentialKey.cs
Normal file
170
src/SQLInsight.Agent/Configuration/AgentCredentialKey.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The agent's RSA key pair for SQL login passwords entered in SQLInsight Web (see <see cref="CredentialEnvelope"/>).
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>SubjectPublicKeyInfo (DER): what SQLInsight Web seals passwords with.</summary>
|
||||
public byte[] PublicKey { get; }
|
||||
|
||||
/// <summary>The key in <paramref name="directory"/>, created the first time. A file that cannot be read is replaced.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Opens a password sealed for this agent and the given server and login.</summary>
|
||||
public string Open(byte[] envelope, string server, string? userName) =>
|
||||
CredentialEnvelope.Open(envelope, KeyId, _rsa, CredentialEnvelope.Context(server, userName));
|
||||
|
||||
/// <summary>What the agent publishes in insight.AgentKeys: the key, and the account and computer it runs as.</summary>
|
||||
public AgentKeyRow Describe(DateTimeOffset now) => new()
|
||||
{
|
||||
KeyId = KeyId,
|
||||
PublicKey = PublicKey,
|
||||
MachineName = Environment.MachineName,
|
||||
ServiceAccount = CurrentAccount(),
|
||||
Domain = Environment.UserDomainName,
|
||||
CreatedAt = now.UtcDateTime,
|
||||
LastSeenAt = now.UtcDateTime,
|
||||
};
|
||||
|
||||
/// <summary>DOMAIN\user, or NT SERVICE\SQLInsight Agent for the virtual service account.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>The key folder, readable only by SYSTEM, Administrators and the agent's own account (no inherited rights).</summary>
|
||||
[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);
|
||||
}
|
||||
250
src/SQLInsight.Agent/Configuration/AgentRequestService.cs
Normal file
250
src/SQLInsight.Agent/Configuration/AgentRequestService.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>Set by the monitoring service when the repository is migrated and the configuration file mirrored.</summary>
|
||||
public sealed class RepositoryReadiness
|
||||
{
|
||||
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task Ready => _ready.Task;
|
||||
|
||||
public void MarkReady() => _ready.TrySetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed partial class AgentRequestService(
|
||||
InsightRepository repository,
|
||||
InstanceConfigurationStore store,
|
||||
AgentCredentialKey key,
|
||||
ISqlConnectionFactory connectionFactory,
|
||||
RepositoryReadiness readiness,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AgentRequestService> logger) : BackgroundService
|
||||
{
|
||||
/// <summary>How often the queue is looked at. Short, so the web UI gets an answer within seconds.</summary>
|
||||
public static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>How often every instance's databases are listed again without being asked.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Runs one request and stores its result. A failure is the request's result, never the service's.</summary>
|
||||
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<InstanceDiscoveryParameters>(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.");
|
||||
|
||||
/// <summary>Connects as the agent and checks its rights. Not reaching the instance is a result, with a hint, not an error.</summary>
|
||||
public async Task<ConnectionTestResult> 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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lists the instance's databases and stores them. New ones are monitored when the instance monitors new databases.</summary>
|
||||
public async Task<DatabaseDiscoveryResult> DiscoverAsync(int instanceId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (_, instance) = await store.GetAsync(instanceId, cancellationToken).ConfigureAwait(false);
|
||||
List<DiscoveredDatabase> 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);
|
||||
}
|
||||
|
||||
/// <summary>The routine discovery of the active instance that waited longest, if one is due.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What to do when the agent could not connect (Danish).</summary>
|
||||
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<string> databases, bool monitored);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "Handling configuration requests failed; retrying.")]
|
||||
private static partial void LogRoundFailed(ILogger logger, Exception exception);
|
||||
}
|
||||
|
||||
/// <summary>A discovery that could not reach the instance; the message says why and what to do (Danish).</summary>
|
||||
public sealed class DiscoveryFailedException : Exception
|
||||
{
|
||||
public DiscoveryFailedException()
|
||||
{
|
||||
}
|
||||
|
||||
public DiscoveryFailedException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DiscoveryFailedException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
170
src/SQLInsight.Agent/Configuration/InstanceConfigurationStore.cs
Normal file
170
src/SQLInsight.Agent/Configuration/InstanceConfigurationStore.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>An instance as the agent monitors it right now: how to connect, whether it is paused, and which databases.</summary>
|
||||
/// <param name="Fingerprint">Changes when anything about the connection changes, so the monitor is built again.</param>
|
||||
/// <param name="CredentialError">The stored password could not be opened (Danish); the instance is not monitored until it is entered again.</param>
|
||||
public sealed record EffectiveInstance(
|
||||
int Id,
|
||||
MonitoredInstance Instance,
|
||||
bool FromConfigFile,
|
||||
bool Paused,
|
||||
DatabaseSelection Databases,
|
||||
string Fingerprint,
|
||||
string? CredentialError = null);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed partial class InstanceConfigurationStore(IOptions<AgentOptions> options, InsightRepository repository, AgentCredentialKey key, ILogger<InstanceConfigurationStore> logger)
|
||||
{
|
||||
/// <summary>Mirrors the configuration file's instances into the repository. Called at start, after the migration.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>What the configuration file alone says: used when the repository cannot be read, so monitoring goes on as before 1.3.</summary>
|
||||
public IReadOnlyList<EffectiveInstance> 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();
|
||||
|
||||
/// <summary>Every configured instance (paused ones too), with its database selection.</summary>
|
||||
public async Task<IReadOnlyList<EffectiveInstance>> 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<EffectiveInstance>(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;
|
||||
}
|
||||
|
||||
/// <summary>How to connect to a configured instance, for a connection test or a discovery.</summary>
|
||||
/// <exception cref="CredentialException">The stored password cannot be opened.</exception>
|
||||
/// <exception cref="KeyNotFoundException">The instance does not exist (any more).</exception>
|
||||
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<AuthenticationMode>(row.Authentication, out var authentication) ? authentication : AuthenticationMode.Windows,
|
||||
UserName = row.UserName,
|
||||
Encryption = Enum.TryParse<EncryptionMode>(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<ConfiguredDatabaseRow> 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);
|
||||
}
|
||||
151
src/SQLInsight.Agent/Configuration/InstanceFinder.cs
Normal file
151
src/SQLInsight.Agent/Configuration/InstanceFinder.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class InstanceFinder
|
||||
{
|
||||
public const int BrowserPort = 1434;
|
||||
|
||||
/// <summary>CLNT_UCAST_EX: "tell me about all your instances" (MC-SQLR 2.2.2).</summary>
|
||||
private static readonly byte[] AllInstancesRequest = [0x03];
|
||||
|
||||
public static async Task<InstanceDiscoveryResult> FindAsync(string? host, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
var target = string.IsNullOrWhiteSpace(host) ? Environment.MachineName : host.Trim();
|
||||
var notes = new List<string>();
|
||||
var found = new List<DiscoveredInstance>();
|
||||
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<List<DiscoveredInstance>> 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SVR_RESP: 0x05, a two-byte length, then "ServerName;X;InstanceName;Y;IsClustered;No;Version;15.0.2000.5;tcp;1433;;" per instance.
|
||||
/// </summary>
|
||||
public static List<DiscoveredInstance> 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<DiscoveredInstance>();
|
||||
foreach (var record in text.Split(";;", StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var parts = record.Split(';');
|
||||
var values = new Dictionary<string, string>(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);
|
||||
|
||||
/// <summary>Instances installed on this computer (HKLM\SOFTWARE\Microsoft\Microsoft SQL Server\Instance Names\SQL).</summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static List<DiscoveredInstance> FromRegistry()
|
||||
{
|
||||
var found = new List<DiscoveredInstance>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,9 @@ public sealed class HubOutbox(IOptions<HubOptions> 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<RuleResult> results, bool changed, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
/// <param name="databases">Which databases may send Dynamics AX findings; none when null (the default is off).</param>
|
||||
public async Task QueueAsync(string instanceName, int instanceId, InstanceInfo info, IReadOnlyList<RuleResult> results, bool changed, DateTimeOffset now, CancellationToken cancellationToken,
|
||||
Core.Configuration.DatabaseSelection? databases = null)
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
|
|
@ -36,7 +38,7 @@ public sealed class HubOutbox(IOptions<HubOptions> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,22 @@ public static class SubmissionBuilder
|
|||
public static readonly string AgentVersion =
|
||||
typeof(SubmissionBuilder).Assembly.GetName().Version?.ToString() ?? "0.0.0";
|
||||
|
||||
/// <param name="databases">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public static FindingsSubmission Build(
|
||||
string instanceName, InstanceInfo info, IReadOnlyList<RuleResult> results, IReadOnlyList<StoredFinding> 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,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ public sealed partial class InstanceMonitor(
|
|||
|
||||
public string Name => instance.Name;
|
||||
|
||||
/// <summary>The databases chosen for monitoring; changed by the monitoring service when the choice changes.</summary>
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ namespace SQLInsight.Agent.Monitoring;
|
|||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed partial class MonitoringService(
|
||||
IOptions<AgentOptions> options,
|
||||
|
|
@ -21,44 +22,49 @@ public sealed partial class MonitoringService(
|
|||
IOptions<NotificationOptions> 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<MonitoringService>();
|
||||
private readonly Dictionary<string, string> _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<string, TimeSpan>(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<string, (InstanceMonitor Monitor, string Fingerprint)>(StringComparer.OrdinalIgnoreCase);
|
||||
using var throttle = new SemaphoreSlim(Math.Max(1, settings.MaxParallelInstances));
|
||||
var running = new Dictionary<InstanceMonitor, Task>();
|
||||
var running = new Dictionary<string, Task>(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<string, TimeSpan> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task RefreshMonitorsAsync(Dictionary<string, (InstanceMonitor Monitor, string Fingerprint)> monitors, IReadOnlyDictionary<string, TimeSpan> intervals,
|
||||
AgentOptions settings, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<Configuration.EffectiveInstance> 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<string>(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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,13 @@ builder.Services.AddSingleton<SQLInsight.Core.Data.IGroupMemberResolver>(sp =>
|
|||
sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<ILogger<SQLInsight.Agent.Security.ActiveDirectoryGroupResolver>>())
|
||||
: 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<IOptions<UpdateOptions>>().Value.DataDirectory ?? SQLInsight.Updates.UpdateDirectories.DefaultRoot, "keys"),
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger("SQLInsight.Agent.CredentialKey")));
|
||||
builder.Services.AddSingleton<SQLInsight.Agent.Configuration.RepositoryReadiness>();
|
||||
builder.Services.AddSingleton<SQLInsight.Agent.Configuration.InstanceConfigurationStore>();
|
||||
builder.Services.AddHostedService<SQLInsight.Agent.Configuration.AgentRequestService>();
|
||||
builder.Services.AddHostedService<MonitoringService>();
|
||||
|
||||
var host = builder.Build();
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
<PackageReference Include="Microsoft.Extensions.Configuration.Ini" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" />
|
||||
<PackageReference Include="System.DirectoryServices.AccountManagement" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- setup-repository gives the agent these rights on the monitored instances (Setup/RepositorySetup.cs). -->
|
||||
|
|
|
|||
147
tests/SQLInsight.Agent.Tests/InstanceConfigurationAgentTests.cs
Normal file
147
tests/SQLInsight.Agent.Tests/InstanceConfigurationAgentTests.cs
Normal file
|
|
@ -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<CredentialException>(() => 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");
|
||||
}
|
||||
}
|
||||
149
tests/SQLInsight.IntegrationTests/AgentRequestTests.cs
Normal file
149
tests/SQLInsight.IntegrationTests/AgentRequestTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<InstanceConfigurationStore>.Instance);
|
||||
|
||||
private AgentRequestService Service() =>
|
||||
new(_repository, Store(), _key, new TrustingFactory(), new RepositoryReadiness(), TimeProvider.System, NullLogger<AgentRequestService>.Instance);
|
||||
|
||||
/// <summary>What SQLInsight Web does: seal the password with the agent's published key.</summary>
|
||||
private async Task<int> 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<AgentRequestRow> 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<ConnectionTestResult>(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<ConnectionTestResult>((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<InstanceDiscoveryResult>(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"));
|
||||
}
|
||||
|
||||
/// <summary>The test container's certificate is self-signed; the instance rows ask to trust it, and so does this.</summary>
|
||||
private sealed class TrustingFactory : ISqlConnectionFactory
|
||||
{
|
||||
private readonly SqlConnectionFactory _inner = new();
|
||||
|
||||
public Task<OpenedConnection> OpenAsync(ConnectionTarget target, CancellationToken cancellationToken = default) =>
|
||||
_inner.OpenAsync(target with { Provider = ConnectionProvider.SqlClient, ConnectTimeout = TimeSpan.FromSeconds(5) }, cancellationToken);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue