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