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
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:
parent
3da6f338b7
commit
6d755aa3d0
9 changed files with 415 additions and 5 deletions
|
|
@ -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'))">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "[AGENT_ACCOUNT]" --web-account "[WEB_ACCOUNT]" --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">&UpdateTask=2 AND NOT UPGRADINGPRODUCTCODE</Custom>
|
||||
<Custom Action="CloseFirewall" Sequence="3401">&Web=2 AND NOT UPGRADINGPRODUCTCODE</Custom>
|
||||
|
|
@ -175,6 +190,8 @@
|
|||
<Custom Action="CreateUpdateTask" Sequence="5801">&UpdateTask=3</Custom>
|
||||
<Custom Action="ReplaceFirewallRule" Sequence="5802">&Web=3</Custom>
|
||||
<Custom Action="OpenFirewall" Sequence="5803">&Web=3</Custom>
|
||||
<!-- Before StartServices (5900), so the services find their database. -->
|
||||
<Custom Action="SetupRepository" Sequence="5805">&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">
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
307
src/SQLInsight.Agent/Setup/RepositorySetup.cs
Normal file
307
src/SQLInsight.Agent/Setup/RepositorySetup.cs
Normal 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 <konto>] [--web-account <konto>] [--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);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
tests/SQLInsight.Agent.Tests/RepositorySetupTests.cs
Normal file
49
tests/SQLInsight.Agent.Tests/RepositorySetupTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue