using System.Diagnostics;
using System.Text.RegularExpressions;
using Microsoft.Data.SqlClient;
using SQLInsight.Connectivity;
using SQLInsight.Core.Configuration;
using SQLInsight.Repository;
namespace SQLInsight.Agent.Setup;
///
/// setup-repository [--agent-account <konto>] [--web-account <konto>] [--grant-monitored]
///
/// Prepares everything the services need, with the rights of whoever runs it (the MSI runs it as the installing user,
/// right after the services are created): the repository database if it is missing, logins and users for the agent
/// (db_owner) and web (db_datareader) accounts, the schema, and with --grant-monitored the least-privilege rights
/// on the configured instances (db/permissions/grant-least-privilege.sql). Safe to run again.
///
/// The connection string and instances are read from appsettings.json and sqlinsight.ini, like the service does.
///
public static partial class RepositorySetup
{
public const string Command = "setup-repository";
private const string EventSource = "SQLInsight Pro";
public static bool Handles(string[] args) => args.FirstOrDefault() == Command;
public static async Task RunAsync(string[] args, TextWriter output)
{
ArgumentNullException.ThrowIfNull(args);
ArgumentNullException.ThrowIfNull(output);
var log = new StringWriter();
var both = new TeeWriter(output, log);
var agentAccount = Option(args, "--agent-account") ?? @"NT SERVICE\SQLInsight Agent";
var webAccount = Option(args, "--web-account") ?? @"NT SERVICE\SQLInsight Web";
var options = CustomRules.RuleCommands.LoadAgentOptions();
try
{
await PrepareRepositoryAsync(options.Repository.ConnectionString, agentAccount, webAccount, both).ConfigureAwait(false);
}
catch (Exception ex) when (ex is SqlException or InvalidOperationException or ArgumentException)
{
both.WriteLine($"FEJL: {ex.Message}");
both.WriteLine(Hint(ex));
Report(log.ToString(), Outcome.Error);
return 1;
}
// The monitored instances are a convenience: an instance that cannot be reached now (or where the installing
// user is not sysadmin) is reported, but does not stop the installation; grant-least-privilege.sql can be run later.
var warnings = 0;
if (args.Contains("--grant-monitored"))
{
foreach (var instance in options.Instances.Where(i => !string.IsNullOrWhiteSpace(i.Server)))
{
try
{
await GrantMonitoredAsync(instance, agentAccount, both).ConfigureAwait(false);
}
catch (Exception ex) when (ex is SqlException or InvalidOperationException)
{
warnings++;
both.WriteLine($"ADVARSEL: rettigheder på {instance.Server} kunne ikke gives: {ex.Message}");
both.WriteLine(" Kør db/permissions/grant-least-privilege.sql på instansen som sysadmin.");
}
}
}
Report(log.ToString(), warnings > 0 ? Outcome.Warning : Outcome.Ok);
return 0;
}
private static async Task PrepareRepositoryAsync(string? connectionString, string agentAccount, string webAccount, TextWriter output)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException("SQLInsight:Repository:ConnectionString er ikke sat (sqlinsight.ini eller appsettings.json).");
}
var repository = new SqlConnectionStringBuilder(connectionString) { Pooling = false };
var database = repository.InitialCatalog;
if (string.IsNullOrWhiteSpace(database))
{
throw new ArgumentException("Forbindelsesstrengen mangler Database=…, fx Database=SQLInsight.");
}
var master = new SqlConnectionStringBuilder(repository.ConnectionString) { InitialCatalog = "master" };
await using (var connection = new SqlConnection(master.ConnectionString))
{
await connection.OpenAsync().ConfigureAwait(false);
output.WriteLine($"Forbundet til {repository.DataSource} som {await ScalarAsync(connection, "SELECT SUSER_SNAME()").ConfigureAwait(false)}.");
var created = await ScalarAsync(connection, """
IF DB_ID(@db) IS NULL
BEGIN
DECLARE @sql nvarchar(max) = N'CREATE DATABASE ' + QUOTENAME(@db);
EXEC (@sql);
SELECT N'oprettet';
END
ELSE SELECT N'findes';
""", ("@db", database)).ConfigureAwait(false);
output.WriteLine($"Database {database}: {created}.");
// A SQL login in the connection string is what the services use too; there are no Windows accounts to add.
if (repository.IntegratedSecurity)
{
foreach (var (login, _) in RepositoryLogins(agentAccount, webAccount, repository.DataSource))
{
output.WriteLine($"Login {login}: {await EnsureLoginAsync(connection, login).ConfigureAwait(false)}.");
}
}
}
if (repository.IntegratedSecurity)
{
await using var connection = new SqlConnection(repository.ConnectionString);
await connection.OpenAsync().ConfigureAwait(false);
foreach (var (login, role) in RepositoryLogins(agentAccount, webAccount, repository.DataSource))
{
var user = await ScalarAsync(connection, """
DECLARE @user sysname = (SELECT name FROM sys.database_principals WHERE sid = SUSER_SID(@login));
IF @user IS NULL
BEGIN
SET @user = @login;
DECLARE @create nvarchar(max) = N'CREATE USER ' + QUOTENAME(@user) + N' FOR LOGIN ' + QUOTENAME(@login);
EXEC (@create);
END
IF @user <> N'dbo' AND IS_ROLEMEMBER(@role, @user) <> 1
BEGIN
DECLARE @add nvarchar(max) = N'ALTER ROLE ' + QUOTENAME(@role) + N' ADD MEMBER ' + QUOTENAME(@user);
EXEC (@add);
END
SELECT @user;
""", ("@login", login), ("@role", role)).ConfigureAwait(false);
output.WriteLine($"Bruger {user} i {database}: {role}.");
}
}
var applied = await SchemaMigrator.MigrateAsync(repository.ConnectionString).ConfigureAwait(false);
output.WriteLine(applied > 0 ? $"Skema: {applied} migreringer kørt." : "Skema: opdateret.");
}
private static async Task GrantMonitoredAsync(MonitoredInstance instance, string agentAccount, TextWriter output)
{
if (instance.Authentication != AuthenticationMode.Windows)
{
output.WriteLine($"{instance.Server}: springes over ({instance.Authentication}); giv rettigheder med grant-least-privilege.sql.");
return;
}
var login = ServiceAccounts.LoginFor(agentAccount, instance.Server, Environment.MachineName, Environment.UserDomainName);
var target = new SqlConnectionStringBuilder
{
DataSource = instance.Server,
InitialCatalog = "master",
IntegratedSecurity = true,
Encrypt = instance.Encryption == EncryptionMode.Mandatory ? SqlConnectionEncryptOption.Mandatory : SqlConnectionEncryptOption.Optional,
TrustServerCertificate = instance.TrustServerCertificate,
ApplicationName = "SQLInsight Pro setup",
Pooling = false,
};
await using var connection = new SqlConnection(target.ConnectionString);
connection.InfoMessage += (_, e) => output.WriteLine($" {e.Message}");
await connection.OpenAsync().ConfigureAwait(false);
output.WriteLine($"Login {login} på {instance.Server}: {await EnsureLoginAsync(connection, login).ConfigureAwait(false)}.");
await using var command = new SqlCommand(GrantScript(), connection) { CommandTimeout = 300 };
command.Parameters.AddWithValue("@login", login);
await command.ExecuteNonQueryAsync().ConfigureAwait(false);
output.WriteLine($"Rettigheder givet på {instance.Server}.");
}
///
/// The logins the services connect as, with their role in the repository. Both services on another computer than
/// SQL Server arrive as the computer account, which then gets db_owner (it covers reading).
///
public static IReadOnlyList<(string Login, string Role)> RepositoryLogins(string agentAccount, string webAccount, string sqlServer)
{
var agent = ServiceAccounts.LoginFor(agentAccount, sqlServer, Environment.MachineName, Environment.UserDomainName);
var web = ServiceAccounts.LoginFor(webAccount, sqlServer, Environment.MachineName, Environment.UserDomainName);
return string.Equals(agent, web, StringComparison.OrdinalIgnoreCase)
? [(agent, "db_owner")]
: [(agent, "db_owner"), (web, "db_datareader")];
}
private static async Task EnsureLoginAsync(SqlConnection connection, string login) =>
await ScalarAsync(connection, """
IF SUSER_ID(@login) IS NULL
BEGIN
DECLARE @sql nvarchar(max) = N'CREATE LOGIN ' + QUOTENAME(@login) + N' FROM WINDOWS';
EXEC (@sql);
SELECT N'oprettet';
END
ELSE SELECT N'findes';
""", ("@login", login)).ConfigureAwait(false);
private static async Task ScalarAsync(SqlConnection connection, string sql, params (string Name, string Value)[] parameters)
{
await using var command = new SqlCommand(sql, connection) { CommandTimeout = 300 };
foreach (var (name, value) in parameters)
{
command.Parameters.AddWithValue(name, value);
}
return Convert.ToString(await command.ExecuteScalarAsync().ConfigureAwait(false), System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty;
}
/// db/permissions/grant-least-privilege.sql with the login as the parameter @login instead of the placeholder.
public static string GrantScript()
{
using var stream = typeof(RepositorySetup).Assembly.GetManifestResourceStream("grant-least-privilege.sql")
?? throw new InvalidOperationException("grant-least-privilege.sql mangler i programmet.");
using var reader = new StreamReader(stream);
var script = LoginPlaceholder().Replace(reader.ReadToEnd(), "SET @LoginName = @login;", 1);
return script.Contains("SET @LoginName = @login;", StringComparison.Ordinal)
? script
: throw new InvalidOperationException("grant-least-privilege.sql: linjen SET @LoginName = N'…' blev ikke fundet.");
}
private static string Hint(Exception ex) => ex switch
{
SqlException { Number: 18456 } => "Din Windows-konto kan ikke logge ind på SQL Server. Installér som en bruger, der er sysadmin på instansen.",
SqlException { Number: 262 or 1802 or 15247 } => "Din konto mangler rettighed til at oprette databaser eller logins (dbcreator og securityadmin, eller sysadmin).",
SqlException { Number: 15401 } => "Windows-kontoen findes ikke på SQL Server-maskinen. Ligger SQL Server på en anden computer, skal computeren være i samme domæne.",
SqlException { Number: -2146893019 } or SqlException { InnerException: System.Security.Authentication.AuthenticationException } =>
"SQL Server bruger et certifikat, der ikke er tillid til. Tilføj TrustServerCertificate=true i forbindelsesstrengen.",
SqlException { Number: 2 or 53 or -1 } => "SQL Server kunne ikke nås. Tjek servernavnet, at instansen kører, og firewallen (TCP 1433).",
_ => "Installér med SKIP_REPOSITORY_SETUP=1 og opret databasen selv (docs/installation.md, trin 2), hvis det ikke kan løses.",
};
private enum Outcome { Ok, Warning, Error }
private static void Report(string text, Outcome outcome)
{
if (!OperatingSystem.IsWindows())
{
return;
}
try
{
var type = outcome switch { Outcome.Error => EventLogEntryType.Error, Outcome.Warning => EventLogEntryType.Warning, _ => EventLogEntryType.Information };
EventLog.WriteEntry(EventSource, "Opsætning af repository (setup-repository):" + Environment.NewLine + text, type);
}
catch (Exception ex) when (ex is InvalidOperationException or System.Security.SecurityException or System.ComponentModel.Win32Exception)
{
// The event source is registered by the installer; outside it the console output is enough.
}
}
private static string? Option(string[] args, string name)
{
var i = Array.IndexOf(args, name);
return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
}
[GeneratedRegex(@"SET @LoginName = N'[^']*';[^\r\n]*")]
private static partial Regex LoginPlaceholder();
private sealed class TeeWriter(TextWriter first, TextWriter second) : TextWriter
{
public override System.Text.Encoding Encoding => first.Encoding;
public override void Write(char value)
{
first.Write(value);
second.Write(value);
}
public override void WriteLine(string? value)
{
first.WriteLine(value);
second.WriteLine(value);
}
}
}