Installer creates the repository database, logins and rights itself
Some checks failed
deploy community / deploy (push) Successful in 1s
deploy hub / deploy (push) Successful in 19s
CI / Build og unit tests (push) Successful in 8s
CI / MSI-installer (push) Successful in 15s
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Has been cancelled

Entering a connection string in the installer is now enough: the new
command `SQLInsight.Agent.exe setup-repository` runs as a deferred custom
action after the services exist and sqlinsight.ini is written, and before
they start. It runs as the installing user on purpose (SQL Server must see
a person with rights, not SYSTEM) and:

- creates the database from Database=... if it is missing
- creates logins/users for the service accounts: agent db_owner, web
  db_datareader. NT SERVICE\... accounts arrive at another computer as
  DOMAIN\COMPUTER$, which then gets the login instead
- runs the schema migrations
- with MONITOR_SERVER: creates the agent's login there and applies
  grant-least-privilege.sql (embedded). A failure there is a warning only

A repository failure stops the installation; the reason goes to the
Application event log with a Danish hint (login, rights, certificate,
unreachable). SKIP_REPOSITORY_SETUP=1 leaves it to a DBA. Safe to rerun.

verify-msi.py allows this one action to impersonate and checks its
placement, Return=check and condition. Tested against a local SQL
Express (twice, idempotent) and with an unreachable server. Version 1.0.1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-01 08:18:26 +02:00
parent 3da6f338b7
commit 6d755aa3d0
9 changed files with 415 additions and 5 deletions

View file

@ -14,7 +14,7 @@
<Product>SQLInsight Pro</Product>
<Copyright>Copyright (c) SEOSoft</Copyright>
<!-- Release version; set with -p:Version=… when publishing. Update checks compare against it. -->
<Version>1.0.0</Version>
<Version>1.0.1</Version>
</PropertyGroup>
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">

View file

@ -13,7 +13,7 @@ Begge bruger en **repository-database** på en SQL Server 2016+-instans, der hel
## Hurtigst: MSI-installeren
`SQLInsight-Pro-<version>-x64.msi` installerer agent og web som Windows-tjenester. .NET er indbygget, så serveren behøver ingen runtime. Hent den nyeste under [Releases](https://git.seosoft.dk/seo/sqlinsight-pro/releases), eller med `installer\get-latest-msi.ps1`. Opret først repository-databasen (trin 2) og giv rettigheder (trin 3). Kør derefter installeren som administrator:
`SQLInsight-Pro-<version>-x64.msi` installerer agent og web som Windows-tjenester. .NET er indbygget, så serveren behøver ingen runtime. Hent den nyeste under [Releases](https://git.seosoft.dk/seo/sqlinsight-pro/releases), eller med `installer\get-latest-msi.ps1`. Kør installeren som administrator, logget ind som en bruger, der er **sysadmin** på den SQL Server, repositoriet skal ligge på (eller har `dbcreator` og `securityadmin`). Installeren opretter selv repository-databasen, logins og rettigheder (trin 2 og 3), så de ikke skal laves i hånden:
- **Med installationsguiden:** Første side spørger om forbindelsesstrengen til repositoriet, den første SQL Server, tjenestekonti og port.
- **Stille, fx med SCCM/Intune:**
@ -34,11 +34,19 @@ msiexec /i SQLInsight-Pro-1.0.0-x64.msi /qn /l*v install.log `
| `WEB_ACCOUNT` | `NT SERVICE\SQLInsight Web` | Tjenestekonto til web. Skal have `db_datareader` på repositoriet |
| `WEB_PORT` | `5080` | Port til webbrugerfladen. Installeren åbner den i Windows Firewall |
| `ADDLOCAL` | `Agent,Web` | `Agent`, `Web` og `UpdateTask` (planlagt opgave, der installerer hentede opdateringer som SYSTEM, se [opdatering.md](opdatering.md)). `ALL` = alle tre |
| `SKIP_REPOSITORY_SETUP` | (ingen) | `1` = installeren opretter ikke database, logins og rettigheder. Brug det, når en DBA gør det (trin 2 og 3), eller når installationen kører som SYSTEM (SCCM/Intune) uden rettigheder på SQL Server |
Installeren gør følgende:
- Programmerne lægges i `C:\Program Files\SQLInsight\Agent` og `\Web`, og tjenesterne `SQLInsight Agent` og `SQLInsight Web` oprettes med automatisk start.
- Værdierne fra installationen skrives i `sqlinsight.ini` ved siden af programmerne. Den læses efter `appsettings.json`, så begge filer kan redigeres bagefter. Genstart tjenesten efter en ændring.
- **Repositoriet sættes op med din Windows-konto** (ikke tjenesternes), før tjenesterne starter:
- databasen fra `Database=…` oprettes, hvis den mangler;
- tjenestekontiene får login og bruger: agenten `db_owner`, web `db_datareader`. Ligger SQL Server på en anden computer, kommer `NT SERVICE\…`-kontiene dertil som computerkontoen (`DOMÆNE\COMPUTER$`), og den får login'et i stedet;
- skemaet oprettes;
- på `MONITOR_SERVER` oprettes agentens login, og den får rettighederne fra `grant-least-privilege.sql`. Lykkes det ikke (fx ingen sysadmin dér), fortsætter installationen, og scriptet kan køres senere.
Fejler opsætningen af repositoriet, stopper installationen og rulles tilbage. Årsagen står i hændelsesloggen (*Application*, kilden `SQLInsight Pro`). Opsætningen kan også køres igen bagefter, fx efter en ændret forbindelsesstreng: `"C:\Program Files\SQLInsight\Agent\SQLInsight.Agent.exe" setup-repository --grant-monitored` (med `--agent-account`/`--web-account`, hvis der ikke bruges standardkonti).
- `C:\ProgramData\SQLInsight` oprettes med adgang for kun SYSTEM, Administrators og agentens konto. Mappen indeholder lokale regler, regelpakker og ventende opdateringer.
- Kilden `SQLInsight Pro` registreres i hændelsesloggen (se [integration.md](integration.md#scom-og-windows-hændelseslog)).
@ -59,6 +67,8 @@ Web kører her på Kestrel over http med Windows-login. Skal den bruge https ell
## 2. Repository-databasen
MSI-installeren gør dette selv (medmindre `SKIP_REPOSITORY_SETUP=1`). Ved manuel installation, eller når en DBA skal gøre det:
```sql
CREATE DATABASE SQLInsight;
GO

View file

@ -10,6 +10,8 @@
which reaches other servers as the computer account (DOMAIN\SERVER$). A gMSA: DOMAIN\svc-sqlinsight$
WEB_ACCOUNT Account for SQLInsight Web. Default NT SERVICE\SQLInsight Web
WEB_PORT Port for the web UI (default 5080)
SKIP_REPOSITORY_SETUP 1 = do not create the repository database, logins and rights (the admin does it, see
docs/installation.md). Otherwise the installing user's Windows account does it.
Features: Agent, Web (both by default) and UpdateTask under Agent (ADDLOCAL=ALL: installs downloaded updates hourly as SYSTEM).
An upgrade keeps the features that were installed.
-->
@ -34,6 +36,7 @@
<Property Id="AGENT_ACCOUNT" Value="NT SERVICE\SQLInsight Agent" Secure="yes" />
<Property Id="WEB_ACCOUNT" Value="NT SERVICE\SQLInsight Web" Secure="yes" />
<Property Id="WEB_PORT" Value="5080" Secure="yes" />
<Property Id="SKIP_REPOSITORY_SETUP" Secure="yes" />
<!-- Set when the update task was installed by an earlier version, so an upgrade keeps it (see the UpdateTask feature). -->
<Property Id="UPDATETASK_INSTALLED">
<RegistrySearch Id="UpdateTaskInstalled" Root="HKLM" Key="SOFTWARE\SEOSoft\SQLInsight" Name="UpdateTask" Type="raw" Win64="yes" />
@ -143,6 +146,17 @@
<CustomAction Id="SetIcaclsPath" Property="ICACLS_EXE" Value="[System64Folder]icacls.exe" />
<CustomAction Id="SetNetshPath" Property="NETSH_EXE" Value="[System64Folder]netsh.exe" />
<CustomAction Id="SetSchtasksPath" Property="SCHTASKS_EXE" Value="[System64Folder]schtasks.exe" />
<!-- After CostFinalize, when AGENTDIR is resolved. -->
<CustomAction Id="SetAgentExePath" Property="AGENT_EXE" Value="[AGENTDIR]SQLInsight.Agent.exe" />
<!-- Repository: creates the database if it is missing, logins and users for both service accounts, the schema, and
the agent's least-privilege rights on MONITOR_SERVER. Runs as the INSTALLING USER (Impersonate="yes"), on
purpose: SQL Server must see a person with rights there (sysadmin, or dbcreator + securityadmin), not SYSTEM.
After InstallServices, because NT SERVICE\... accounts only exist once the services do, and after
WriteIniValues, because it reads the connection string from sqlinsight.ini. A failure stops the installation
(details in the Application event log, source SQLInsight Pro); SKIP_REPOSITORY_SETUP=1 leaves it to the admin. -->
<CustomAction Id="SetupRepository" Property="AGENT_EXE" Execute="deferred" Impersonate="yes" Return="check"
ExeCommand="setup-repository --agent-account &quot;[AGENT_ACCOUNT]&quot; --web-account &quot;[WEB_ACCOUNT]&quot; --grant-monitored" />
<!-- The data folder: only SYSTEM, Administrators and the agent's account; nobody else may drop rules or updates there. -->
<CustomAction Id="SetDataFolderAcl" Property="ICACLS_EXE" Execute="deferred" Impersonate="no" Return="ignore"
@ -167,6 +181,7 @@
<Custom Action="SetIcaclsPath" Before="LaunchConditions">1</Custom>
<Custom Action="SetNetshPath" After="SetIcaclsPath">1</Custom>
<Custom Action="SetSchtasksPath" After="SetNetshPath">1</Custom>
<Custom Action="SetAgentExePath" After="CostFinalize">1</Custom>
<!-- Removal: not when an upgrade removes the old version, or it would undo what the new one just set up. -->
<Custom Action="DeleteUpdateTask" Sequence="3400">&amp;UpdateTask=2 AND NOT UPGRADINGPRODUCTCODE</Custom>
<Custom Action="CloseFirewall" Sequence="3401">&amp;Web=2 AND NOT UPGRADINGPRODUCTCODE</Custom>
@ -175,6 +190,8 @@
<Custom Action="CreateUpdateTask" Sequence="5801">&amp;UpdateTask=3</Custom>
<Custom Action="ReplaceFirewallRule" Sequence="5802">&amp;Web=3</Custom>
<Custom Action="OpenFirewall" Sequence="5803">&amp;Web=3</Custom>
<!-- Before StartServices (5900), so the services find their database. -->
<Custom Action="SetupRepository" Sequence="5805">&amp;Agent=3 AND REPOSITORY_CONNECTION AND NOT SKIP_REPOSITORY_SETUP</Custom>
</InstallExecuteSequence>
<Feature Id="Agent" Title="SQLInsight Agent" Description="Monitors the SQL Server instances." Level="1" Absent="allow">

View file

@ -10,6 +10,9 @@ import tempfile
DEFERRED = 0x400
IN_SCRIPT_NO_IMPERSONATE = 0x800
CONTINUE_ON_ERROR = 0x40
# Deferred actions that deliberately run as the installing user: SQL Server must see a person with rights there.
AS_INSTALLING_USER = {"SetupRepository"}
def tables(msi):
@ -46,13 +49,28 @@ def main(msi):
errors.append(f"{action}: er ikke i InstallExecuteSequence")
elif kind & DEFERRED and not start < sequence[action] < end:
errors.append(f"{action}: deferred, men ligger uden for InstallInitialize..InstallFinalize ({sequence[action]})")
elif kind & DEFERRED and not kind & IN_SCRIPT_NO_IMPERSONATE:
elif kind & DEFERRED and not kind & IN_SCRIPT_NO_IMPERSONATE and action not in AS_INSTALLING_USER:
errors.append(f"{action}: deferred uden NoImpersonate (kører ikke som SYSTEM)")
elif action in AS_INSTALLING_USER and kind & IN_SCRIPT_NO_IMPERSONATE:
errors.append(f"{action}: skal køre som den installerende bruger, ikke SYSTEM")
def after(action, standard):
if sequence.get(action, 0) <= sequence[standard]:
errors.append(f"{action} skal ligge efter {standard}")
# Repository setup: needs the services' accounts and the written sqlinsight.ini, and must finish before the
# services start; a failure must stop the installation, and an admin must be able to skip it.
after("SetupRepository", "InstallServices")
after("SetupRepository", "WriteIniValues")
if sequence.get("SetupRepository", 0) >= sequence["StartServices"]:
errors.append("SetupRepository skal ligge før StartServices")
if actions.get("SetupRepository", 0) & CONTINUE_ON_ERROR:
errors.append("SetupRepository: fejl skal stoppe installationen (Return=check)")
condition = next((r["Condition"] for r in rows("InstallExecuteSequence") if r["Action"] == "SetupRepository"), "")
if "SKIP_REPOSITORY_SETUP" not in condition or "REPOSITORY_CONNECTION" not in condition:
errors.append("SetupRepository: betingelsen skal kræve REPOSITORY_CONNECTION og respektere SKIP_REPOSITORY_SETUP")
after("SetAgentExePath", "CostFinalize") # AGENTDIR is only resolved there
after("SetDataFolderAcl", "InstallServices") # the virtual service account must exist
after("CreateUpdateTask", "InstallFiles")
after("OpenFirewall", "ReplaceFirewallRule")

View file

@ -99,7 +99,7 @@ public static class RuleCommands
? CustomRuleLoader.LoadDirectory(path, origin: null)
: CustomRuleLoader.Parse(File.ReadAllText(path), Path.GetFileName(path), origin: null);
private static AgentOptions LoadAgentOptions()
internal static AgentOptions LoadAgentOptions()
{
var environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production";
var configuration = new ConfigurationBuilder()

View file

@ -8,12 +8,17 @@ using SQLInsight.Repository;
using SQLInsight.Rules;
using SQLInsight.Rules.Engine;
if (SQLInsight.Agent.CustomRules.RuleCommands.Handles(args) || UpdateCommands.Handles(args))
if (SQLInsight.Agent.CustomRules.RuleCommands.Handles(args) || UpdateCommands.Handles(args) || SQLInsight.Agent.Setup.RepositorySetup.Handles(args))
{
// Danish letters in a Windows console, which otherwise uses the OEM code page.
Console.OutputEncoding = System.Text.Encoding.UTF8;
}
if (SQLInsight.Agent.Setup.RepositorySetup.Handles(args))
{
return await SQLInsight.Agent.Setup.RepositorySetup.RunAsync(args, Console.Out);
}
if (SQLInsight.Agent.CustomRules.RuleCommands.Handles(args))
{
return await SQLInsight.Agent.CustomRules.RuleCommands.RunAsync(args, Console.Out);

View file

@ -22,4 +22,8 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Ini" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" />
</ItemGroup>
<ItemGroup>
<!-- setup-repository gives the agent these rights on the monitored instances (Setup/RepositorySetup.cs). -->
<EmbeddedResource Include="..\..\db\permissions\grant-least-privilege.sql" LogicalName="grant-least-privilege.sql" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,307 @@
using System.Diagnostics;
using System.Text.RegularExpressions;
using Microsoft.Data.SqlClient;
using SQLInsight.Connectivity;
using SQLInsight.Repository;
namespace SQLInsight.Agent.Setup;
/// <summary>
/// <c>setup-repository [--agent-account &lt;konto&gt;] [--web-account &lt;konto&gt;] [--grant-monitored]</c>
/// <para>
/// 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 <c>--grant-monitored</c> the least-privilege rights
/// on the configured instances (db/permissions/grant-least-privilege.sql). Safe to run again.
/// </para>
/// The connection string and instances are read from appsettings.json and sqlinsight.ini, like the service does.
/// </summary>
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<int> 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 = 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}.");
}
/// <summary>
/// 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).
/// </summary>
public static IReadOnlyList<(string Login, string Role)> RepositoryLogins(string agentAccount, string webAccount, string sqlServer)
{
var agent = LoginFor(agentAccount, sqlServer, Environment.MachineName, Environment.UserDomainName);
var web = LoginFor(webAccount, sqlServer, Environment.MachineName, Environment.UserDomainName);
return string.Equals(agent, web, StringComparison.OrdinalIgnoreCase)
? [(agent, "db_owner")]
: [(agent, "db_owner"), (web, "db_datareader")];
}
/// <summary>
/// The Windows login a service account has on <paramref name="sqlServer"/>. Virtual and built-in machine accounts
/// (NT SERVICE\…, LocalSystem, NETWORK SERVICE) only exist locally; on another server they arrive as the computer
/// account DOMAIN\COMPUTER$. Domain accounts and gMSAs are the same everywhere.
/// </summary>
public static string LoginFor(string account, string sqlServer, string machineName, string domain)
{
ArgumentException.ThrowIfNullOrWhiteSpace(account);
var normalized = account.Trim();
if (normalized.Equals("LocalSystem", StringComparison.OrdinalIgnoreCase))
{
normalized = @"NT AUTHORITY\SYSTEM";
}
var machineIdentity = normalized.StartsWith(@"NT SERVICE\", StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith(@"NT AUTHORITY\", StringComparison.OrdinalIgnoreCase);
return machineIdentity && !IsLocal(sqlServer, machineName) ? $@"{domain}\{machineName}$" : normalized;
}
/// <summary>Whether a SQL Server data source (server, server\instance, tcp:server,port …) is this computer.</summary>
public static bool IsLocal(string sqlServer, string machineName)
{
ArgumentNullException.ThrowIfNull(sqlServer);
var host = ProtocolPrefix().Replace(sqlServer.Trim(), string.Empty).Split('\\', ',')[0].Trim();
return host is "" or "." or "(local)" or "(localdb)" or "127.0.0.1" or "::1"
|| host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|| host.Equals(machineName, StringComparison.OrdinalIgnoreCase)
|| host.StartsWith(machineName + ".", StringComparison.OrdinalIgnoreCase);
}
private static async Task<string> 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<string> 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;
}
/// <summary>db/permissions/grant-least-privilege.sql with the login as the parameter @login instead of the placeholder.</summary>
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(@"^(tcp|np|lpc|admin):", RegexOptions.IgnoreCase)]
private static partial Regex ProtocolPrefix();
[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);
}
}
}

View file

@ -0,0 +1,49 @@
using SQLInsight.Agent.Setup;
namespace SQLInsight.Agent.Tests;
public sealed class RepositorySetupTests
{
[Theory]
[InlineData(".")]
[InlineData(@".\SQLEXPRESS")]
[InlineData("(local)")]
[InlineData("localhost,1433")]
[InlineData(@"tcp:localhost\SQLEXPRESS")]
[InlineData("SQLMON01")]
[InlineData(@"sqlmon01\SQLEXPRESS")]
[InlineData("sqlmon01.kunde.local,1433")]
public void Recognises_this_computer(string server) => Assert.True(RepositorySetup.IsLocal(server, "SQLMON01"));
[Theory]
[InlineData("sql02")]
[InlineData(@"sql02\PROD")]
[InlineData("sqlmon011")]
[InlineData("tcp:10.0.0.5,1433")]
public void Recognises_other_computers(string server) => Assert.False(RepositorySetup.IsLocal(server, "SQLMON01"));
[Fact]
public void Virtual_account_keeps_its_name_on_the_same_computer() =>
Assert.Equal(@"NT SERVICE\SQLInsight Agent", RepositorySetup.LoginFor(@"NT SERVICE\SQLInsight Agent", @".\SQLEXPRESS", "SQLMON01", "KUNDE"));
[Theory]
[InlineData(@"NT SERVICE\SQLInsight Agent")]
[InlineData(@"NT AUTHORITY\NETWORK SERVICE")]
[InlineData("LocalSystem")]
public void Machine_accounts_arrive_as_the_computer_account_on_another_server(string account) =>
Assert.Equal(@"KUNDE\SQLMON01$", RepositorySetup.LoginFor(account, "sql02", "SQLMON01", "KUNDE"));
[Fact]
public void Domain_accounts_and_gmsas_are_the_same_everywhere() =>
Assert.Equal(@"KUNDE\svc-sqlinsight$", RepositorySetup.LoginFor(@"KUNDE\svc-sqlinsight$", "sql02", "SQLMON01", "KUNDE"));
[Fact]
public void Grant_script_takes_the_login_as_a_parameter()
{
var script = RepositorySetup.GrantScript();
Assert.Contains("SET @LoginName = @login;", script, StringComparison.Ordinal);
Assert.DoesNotContain("SET @LoginName = N'", script, StringComparison.Ordinal);
Assert.Contains("VIEW ANY DEFINITION", script, StringComparison.Ordinal);
}
}