Web: Servere og databaser, and the same in API v1 (scope config:write)

A page under Overvågning lists the monitored servers (from SQLInsight Web and,
read-only, from the agent's configuration file), adds a server (name\instance,
optional port, Windows via the agent's account or a SQL login, encryption),
pauses, resumes and removes it, and finds instances via the agent (SQL Server
Browser). Each server's page shows the agent's connection test with the rights
it has and lacks, the least-privilege GRANT script for the login the agent
arrives as, and the discovered databases (state, size, recovery model,
compatibility level, Dynamics AX) to tick for monitoring, with "overvåg
automatisk nye databaser" and AX findings to the hub per database. Pages
refresh while the agent works on a request.

Only SQLInsight:Web:AdminGroups (new) may change anything; others see it.
SQL login passwords are sealed with the agent's public key on arrival and
never shown or returned; a new server or login needs the password again.

API v1: /monitored-instances (list, add, get, patch pause/monitor-new,
connection, delete), …/connection-test (start, latest with missing
permissions), …/discovery, …/databases (read, choose), …/grant-script,
/instance-discovery and /agent-requests/{id}. Changes need the new token scope
config:write, or AdminGroups for Windows users.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 12:08:38 +02:00
parent 81b8376026
commit f3ef0cbec7
13 changed files with 1807 additions and 2 deletions

View file

@ -16,7 +16,10 @@ public sealed record ApiToken(string Name, string Hash, IReadOnlyList<string> Sc
/// <summary>Create and close support cases.</summary>
public const string SupportWrite = "support:write";
public static readonly IReadOnlyList<string> KnownScopes = [Read, SupportWrite];
/// <summary>Change which instances and databases are monitored, and start connection tests and discovery.</summary>
public const string ConfigWrite = "config:write";
public static readonly IReadOnlyList<string> KnownScopes = [Read, SupportWrite, ConfigWrite];
/// <summary>
/// SQLInsight:Web:ApiTokens: either the old form <c>"name": "sha256:…"</c> (read only) or

View file

@ -0,0 +1,338 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using SQLInsight.Core.Configuration;
using SQLInsight.Repository;
namespace SQLInsight.Web.Api;
/// <summary>Body of POST /api/v1/monitored-instances and PUT …/{id}/connection. The password is write-only.</summary>
public sealed record MonitoredInstanceBody(
string? Name,
string? Server,
int? Port,
string? Authentication,
string? UserName,
string? Password,
string? Encryption,
bool? TrustServerCertificate,
bool? MonitorNewDatabases);
/// <summary>Body of PATCH /api/v1/monitored-instances/{id}.</summary>
public sealed record MonitoredInstancePatch(bool? Paused, bool? MonitorNewDatabases);
/// <summary>One database's choice in PUT …/{id}/databases. Omitted values are left as they are.</summary>
public sealed record DatabaseChoiceBody(string? Name, bool? Monitored, bool? SendDynamicsAxToHub);
/// <summary>Body of PUT /api/v1/monitored-instances/{id}/databases.</summary>
public sealed record DatabaseChoicesBody(bool? MonitorNewDatabases, IReadOnlyList<DatabaseChoiceBody>? Databases);
/// <summary>Body of POST /api/v1/instance-discovery.</summary>
public sealed record InstanceDiscoveryBody(string? Host);
/// <summary>
/// Which instances and databases are monitored, through the API: everything the page Servere og databaser does.
/// Reading needs the scope read; changing needs config:write for tokens, or membership of SQLInsight:Web:AdminGroups
/// for Windows users. Connection tests and discovery are queued for the agent (202 Accepted with a request id to
/// follow at /api/v1/agent-requests/{id}). Passwords are write-only and never returned.
/// </summary>
public static class ConfigurationApiEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
var api = app.MapGroup("/api/v1").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead();
const string write = " Needs scope config:write for tokens, or SQLInsight:Web:AdminGroups for Windows users.";
api.MapGet("/monitored-instances", ListAsync).WithSummary("Overvågede instanser / Monitored instances (configuration)");
api.MapPost("/monitored-instances", AddAsync)
.WithSummary("Tilføj en instans / Add an instance")
.WithDescription("201 Created. The agent tests the connection and lists the databases at once (connectionTestRequestId)." + write);
api.MapGet("/monitored-instances/{id:int}", GetAsync).WithSummary("Én overvåget instans / One monitored instance");
api.MapPatch("/monitored-instances/{id:int}", PatchAsync)
.WithSummary("Sæt på pause, genoptag, overvåg nye databaser / Pause, resume, monitor new databases").WithDescription(write.Trim());
api.MapPut("/monitored-instances/{id:int}/connection", UpdateConnectionAsync)
.WithSummary("Ret forbindelsen / Change the connection")
.WithDescription("Instances added in SQLInsight Web only (409 for the configuration file). The password must be given again when server, port or login change." + write);
api.MapDelete("/monitored-instances/{id:int}", RemoveAsync)
.WithSummary("Stop overvågningen af en instans / Remove an instance")
.WithDescription("204. Collected data is kept. 409 for an instance from the agent's configuration file (pause it instead)." + write);
api.MapPost("/monitored-instances/{id:int}/connection-test", (int id, HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct) =>
QueueAsync(id, AgentRequestKind.ConnectionTest, http, repository, options, ct))
.WithSummary("Test forbindelsen fra agenten / Test the connection from the agent").WithDescription("202 Accepted with requestId." + write);
api.MapGet("/monitored-instances/{id:int}/connection-test", LatestTestAsync)
.WithSummary("Seneste forbindelsestest med manglende rettigheder / Latest connection test and missing permissions");
api.MapPost("/monitored-instances/{id:int}/discovery", (int id, HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct) =>
QueueAsync(id, AgentRequestKind.DatabaseDiscovery, http, repository, options, ct))
.WithSummary("Søg efter databaser / Discover databases").WithDescription("202 Accepted with requestId; the result is in GET …/databases." + write);
api.MapGet("/monitored-instances/{id:int}/databases", DatabasesAsync).WithSummary("Fundne databaser og valg / Discovered databases and choices");
api.MapPut("/monitored-instances/{id:int}/databases", ChooseAsync)
.WithSummary("Vælg databaser og AX-fund til hubben / Choose databases and AX findings to the hub")
.WithDescription("Databases not named keep their choice. 422 with the names that were not found." + write);
api.MapGet("/monitored-instances/{id:int}/grant-script", GrantScriptAsync)
.WithSummary("Least-privilege GRANT-script til agentens login / Least-privilege GRANT script for the agent's login");
api.MapPost("/instance-discovery", FindAsync)
.WithSummary("Find SQL Server-instanser på en computer / Find SQL Server instances on a computer")
.WithDescription("202 Accepted with requestId. The agent asks SQL Server Browser (UDP 1434) and, on its own computer, the registry." + write);
api.MapGet("/agent-requests/{id:long}", RequestAsync).WithSummary("Status og resultat af en anmodning til agenten / Status and result of an agent request");
}
private static IResult Forbidden() => Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Ikke tilladt",
detail: "Ændringer kræver scope \"config:write\" (API-token) eller medlemskab af en gruppe i SQLInsight:Web:AdminGroups.");
private static IResult Invalid(string detail) => Results.Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: "Ugyldig", detail: detail);
private static object Json(ConfiguredInstanceRow i) => new
{
Id = i.ConfiguredInstanceId,
i.Name,
i.Server,
i.Port,
i.DataSource,
i.Authentication,
i.UserName,
i.HasPassword,
i.Encryption,
i.TrustServerCertificate,
Source = i.FromConfigFile ? "configFile" : "web",
i.Paused,
i.MonitorNewDatabases,
i.CreatedAt,
i.CreatedBy,
i.UpdatedAt,
i.UpdatedBy,
i.LastDiscoveryAt,
i.LastDiscoveryError,
i.CredentialError,
};
private static object Json(ConfiguredDatabaseRow d) => new
{
Name = d.DatabaseName,
d.Monitored,
d.SendDynamicsAxToHub,
d.State,
d.SizeMb,
d.RecoveryModel,
d.CompatibilityLevel,
d.IsReadOnly,
d.IsSystem,
d.IsSnapshot,
d.HasAccess,
d.IsDynamicsAx,
d.DynamicsAxVersion,
d.Gone,
d.FirstSeenAt,
d.LastSeenAt,
d.SelectionChangedAt,
d.SelectionChangedBy,
};
private static object Json(AgentRequestRow r) => new
{
Id = r.RequestId,
r.Kind,
InstanceId = r.ConfiguredInstanceId,
r.Status,
r.RequestedBy,
r.RequestedAt,
r.StartedAt,
r.CompletedAt,
r.Error,
Result = r.Result is { Length: > 0 } result ? JsonDocument.Parse(result).RootElement.Clone() : (JsonElement?)null,
};
private static InstanceInput Input(MonitoredInstanceBody body) => new(body.Name, body.Server, body.Port, body.Authentication, body.UserName, body.Password,
body.Encryption, body.TrustServerCertificate ?? false, body.MonitorNewDatabases ?? false);
private static async Task<IResult> ListAsync(InsightRepository repository, CancellationToken ct) =>
Results.Ok((await repository.GetConfiguredInstancesAsync(ct).ConfigureAwait(false)).Select(Json));
private static async Task<IResult> GetAsync(int id, InsightRepository repository, CancellationToken ct) =>
await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false) is { } row ? Results.Ok(Json(row)) : Results.NotFound();
private static async Task<IResult> AddAsync(MonitoredInstanceBody body, HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (!InstanceSetup.MayChange(http.User, options.Value))
{
return Forbidden();
}
var outcome = await InstanceSetup.AddAsync(repository, Input(body), http.User.Identity?.Name, ct).ConfigureAwait(false);
if (outcome.Error is { } error)
{
return Invalid(error);
}
var row = (await repository.GetConfiguredInstanceAsync(outcome.Id!.Value, ct).ConfigureAwait(false))!;
return Results.Created($"/api/v1/monitored-instances/{row.ConfiguredInstanceId}", new { Instance = Json(row), ConnectionTestRequestId = outcome.RequestId });
}
private static async Task<IResult> PatchAsync(int id, MonitoredInstancePatch body, HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (!InstanceSetup.MayChange(http.User, options.Value))
{
return Forbidden();
}
if (await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false) is null)
{
return Results.NotFound();
}
var by = http.User.Identity?.Name;
if (body.Paused is { } paused)
{
await repository.SetInstancePausedAsync(id, paused, by, DateTimeOffset.UtcNow, ct).ConfigureAwait(false);
}
if (body.MonitorNewDatabases is { } monitorNew)
{
await repository.SetMonitorNewDatabasesAsync(id, monitorNew, by, DateTimeOffset.UtcNow, ct).ConfigureAwait(false);
}
return Results.Ok(Json((await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false))!));
}
private static async Task<IResult> UpdateConnectionAsync(int id, MonitoredInstanceBody body, HttpContext http, InsightRepository repository, IOptions<WebOptions> options,
CancellationToken ct)
{
if (!InstanceSetup.MayChange(http.User, options.Value))
{
return Forbidden();
}
var outcome = await InstanceSetup.UpdateAsync(repository, id, Input(body), http.User.Identity?.Name, ct).ConfigureAwait(false);
if (outcome.NotFound)
{
return Results.NotFound();
}
if (outcome.Error is { } error)
{
return (await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false))?.FromConfigFile == true
? Results.Problem(statusCode: StatusCodes.Status409Conflict, title: "Fra konfigurationsfilen", detail: error)
: Invalid(error);
}
return Results.Ok(new { Instance = Json((await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false))!), ConnectionTestRequestId = outcome.RequestId });
}
private static async Task<IResult> RemoveAsync(int id, HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (!InstanceSetup.MayChange(http.User, options.Value))
{
return Forbidden();
}
return await repository.RemoveConfiguredInstanceAsync(id, http.User.Identity?.Name, DateTimeOffset.UtcNow, ct).ConfigureAwait(false) switch
{
ConfigurationChangeOutcome.Changed => Results.NoContent(),
ConfigurationChangeOutcome.FromConfigFile => Results.Problem(statusCode: StatusCodes.Status409Conflict, title: "Fra konfigurationsfilen",
detail: "Instansen kommer fra agentens konfigurationsfil og fjernes dér. Den kan sættes på pause med PATCH."),
_ => Results.NotFound(),
};
}
private static async Task<IResult> QueueAsync(int id, string kind, HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (!InstanceSetup.MayChange(http.User, options.Value))
{
return Forbidden();
}
var outcome = await InstanceSetup.RequestAsync(repository, id, kind, http.User.Identity?.Name, ct).ConfigureAwait(false);
if (outcome.NotFound)
{
return Results.NotFound();
}
var request = (await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!;
return Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", Json(request));
}
private static async Task<IResult> LatestTestAsync(int id, InsightRepository repository, CancellationToken ct)
{
if (await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false) is null)
{
return Results.NotFound();
}
var test = await repository.GetLatestAgentRequestAsync(id, AgentRequestKind.ConnectionTest, ct).ConfigureAwait(false);
if (test is null)
{
return Results.NotFound();
}
var result = AgentRequestJson.Deserialize<ConnectionTestResult>(test.Result);
return Results.Ok(new { Request = Json(test), MissingPermissions = result?.MissingRequired.Select(p => p.Name).ToList() ?? [] });
}
private static async Task<IResult> DatabasesAsync(int id, InsightRepository repository, CancellationToken ct)
{
if (await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false) is null)
{
return Results.NotFound();
}
return Results.Ok((await repository.GetConfiguredDatabasesAsync(id, ct).ConfigureAwait(false)).Select(Json));
}
private static async Task<IResult> ChooseAsync(int id, DatabaseChoicesBody body, HttpContext http, InsightRepository repository, IOptions<WebOptions> options,
CancellationToken ct)
{
if (!InstanceSetup.MayChange(http.User, options.Value))
{
return Forbidden();
}
var known = (await repository.GetConfiguredDatabasesAsync(id, ct).ConfigureAwait(false)).ToDictionary(d => d.DatabaseName, StringComparer.OrdinalIgnoreCase);
var requested = body.Databases ?? [];
if (requested.Any(d => string.IsNullOrWhiteSpace(d.Name)))
{
return Invalid("Hver database skal have et navn.");
}
var unknown = requested.Where(d => !known.ContainsKey(d.Name!)).Select(d => d.Name!).ToList();
if (unknown.Count > 0)
{
return Invalid("Agenten har ikke fundet disse databaser: " + string.Join(", ", unknown) + ". Start en søgning (POST …/discovery), og prøv igen.");
}
var choices = requested.Select(d => (Body: d, Current: known[d.Name!]))
.Select(x => new DatabaseChoice(x.Current.DatabaseName, x.Body.Monitored ?? x.Current.Monitored, x.Body.SendDynamicsAxToHub ?? x.Current.SendDynamicsAxToHub))
.ToList();
var (_, found) = await InstanceSetup.ChooseDatabasesAsync(repository, id, choices, body.MonitorNewDatabases, http.User.Identity?.Name, ct).ConfigureAwait(false);
return found ? Results.Ok((await repository.GetConfiguredDatabasesAsync(id, ct).ConfigureAwait(false)).Select(Json)) : Results.NotFound();
}
private static async Task<IResult> GrantScriptAsync(int id, InsightRepository repository, CancellationToken ct)
{
if (await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false) is not { } row)
{
return Results.NotFound();
}
var (login, script, note) = await InstanceSetup.GrantScriptAsync(repository, row, ct).ConfigureAwait(false);
return Results.Ok(new { Login = login, Script = script, Note = note });
}
private static async Task<IResult> FindAsync(InstanceDiscoveryBody body, HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (!InstanceSetup.MayChange(http.User, options.Value))
{
return Forbidden();
}
var outcome = await InstanceSetup.FindInstancesAsync(repository, body.Host, http.User.Identity?.Name, ct).ConfigureAwait(false);
if (outcome.Error is { } error)
{
return Invalid(error);
}
var request = (await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!;
return Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", Json(request));
}
private static async Task<IResult> RequestAsync(long id, InsightRepository repository, CancellationToken ct) =>
await repository.GetAgentRequestAsync(id, ct).ConfigureAwait(false) is { } request ? Results.Ok(Json(request)) : Results.NotFound();
}

View file

@ -43,6 +43,7 @@
new("Overblik", "/"),
new("Alle fund", "/fund", "/fund/"),
new("Fejllog", "/vaelg/fejllog", "/fejllog"),
new("Servere og databaser", "/opsaetning/instanser", "/opsaetning/"),
]),
new("analyse", "Analyse", "chart",
[

View file

@ -10,7 +10,7 @@
@if (_instances.Count == 0)
{
<div class="empty">
<p>Der er endnu ingen data. Tjek, at SQLInsight Agent kører og har instanser under <code>SQLInsight:Instances</code> i sin konfiguration.</p>
<p>Der er endnu ingen data. Tilføj en server under <a href="/opsaetning/instanser">Servere og databaser</a>, og tjek, at SQLInsight Agent kører.</p>
</div>
}
else

View file

@ -0,0 +1,376 @@
@page "/opsaetning/instanser/{Id:int}"
@using SQLInsight.Core.Configuration
@using Microsoft.AspNetCore.Components.Forms
@inject InsightRepository Repository
@inject Display Display
@inject Microsoft.Extensions.Options.IOptions<WebOptions> Options
@if (_instance is null)
{
<PageTitle>Ukendt server · SQLInsight Pro</PageTitle>
<h1>Serveren findes ikke</h1>
<p><a href="/opsaetning/instanser">Tilbage til servere og databaser</a></p>
return;
}
<PageTitle>@_instance.Name · Servere og databaser · SQLInsight Pro</PageTitle>
@if (_test is { IsFinished: false } || _discovery is { IsFinished: false })
{
@* The agent works on it: look again in two seconds (no script needed). *@
<HeadContent><meta http-equiv="refresh" content="2" /></HeadContent>
}
<p class="crumbs"><a href="/opsaetning/instanser">Servere og databaser</a> › @_instance.Name</p>
<h1>@_instance.Name</h1>
<p class="lead">
<span class="mono">@_instance.DataSource</span> ·
@(_instance.FromConfigFile ? "fra konfigurationsfil" : "tilføjet i SQLInsight Web") ·
@if (_instance.Paused)
{
<span class="badge sev-medium">På pause</span>
}
else
{
<span class="badge sev-ok">Overvåges</span>
}
@if (!_instance.Paused)
{
<text> · </text><a href="/instanser/@Uri.EscapeDataString(_instance.Name)">Se fund</a>
}
</p>
@if (Tilfoejet == 1)
{
<p class="notice ok" role="status">Serveren er tilføjet. Agenten tester nu forbindelsen og søger efter databaser; resultatet vises herunder om et øjeblik.</p>
}
@if (Gemt == 1)
{
<p class="notice ok" role="status">Forbindelsen er gemt, og agenten tester den igen.</p>
}
@if (Pause == 1)
{
<p class="notice ok" role="status">Overvågningen er sat på pause. Agenten stopper inden for et minut; det indsamlede er bevaret.</p>
}
@if (Genoptaget == 1)
{
<p class="notice ok" role="status">Overvågningen er genoptaget.</p>
}
@if (Databaser == 1)
{
<p class="notice ok" role="status">Valget er gemt. Agenten bruger det fra næste runde (inden for et minut).</p>
}
@if (!string.IsNullOrEmpty(Fejl))
{
<p class="notice bad" role="alert">@Fejl</p>
}
@if (_instance.CredentialError is { } credentialError)
{
<div class="alert"><strong>Agenten kan ikke bruge den gemte adgangskode.</strong><p>@credentialError</p></div>
}
<section class="panel" id="forbindelse">
<h2>Forbindelse</h2>
<table class="evidence">
<tr><th>Server</th><td class="mono">@_instance.Server</td></tr>
<tr><th>Port</th><td>@(_instance.Port?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "standard (eller via SQL Server Browser)")</td></tr>
<tr><th>Godkendelse</th><td>@InstanceSetup.AuthenticationText(_instance)@(_instance.Authentication == "SqlLogin" ? (_instance.HasPassword ? " · adgangskode gemt krypteret" : " · ingen adgangskode") : string.Empty)</td></tr>
<tr><th>Kryptering</th><td>@(_instance.Encryption == "Optional" ? "Valgfri" : "Påkrævet")@(_instance.TrustServerCertificate ? " · stoler på servercertifikatet" : string.Empty)</td></tr>
<tr><th>Oprettet</th><td>@Display.Time(_instance.CreatedAt)@(_instance.CreatedBy is { } createdBy ? " af " + createdBy : string.Empty)</td></tr>
<tr><th>Senest ændret</th><td>@Display.Time(_instance.UpdatedAt)@(_instance.UpdatedBy is { } updatedBy ? " af " + updatedBy : string.Empty)</td></tr>
</table>
@if (_instance.FromConfigFile)
{
<p class="muted">Forbindelsen står i agentens konfigurationsfil (<code>SQLInsight:Instances</code>) og ændres eller fjernes dér. Her kan den sættes på pause, og databaserne vælges.</p>
}
@if (_mayChange)
{
<div class="report-links">
<form method="post" action="/opsaetning/instanser/@Id/test"><AntiforgeryToken /><button type="submit" class="button">Test forbindelsen</button></form>
<form method="post" action="/opsaetning/instanser/@Id/pause">
<AntiforgeryToken />
<input type="hidden" name="paused" value="@(_instance.Paused ? "false" : "true")" />
<button type="submit" class="button secondary">@(_instance.Paused ? "Genoptag overvågningen" : "Sæt på pause")</button>
</form>
@if (!_instance.FromConfigFile)
{
<form method="post" action="/opsaetning/instanser/@Id/fjern">
<AntiforgeryToken />
<button type="submit" class="button danger">Fjern serveren</button>
</form>
}
</div>
@if (!_instance.FromConfigFile)
{
<details>
<summary>Ret forbindelsen</summary>
<form method="post" action="/opsaetning/instanser/@Id/forbindelse" class="support-form">
<AntiforgeryToken />
<div class="support-contact">
<label>Server <input name="server" required maxlength="256" value="@_instance.Server" /></label>
<label>Port (valgfri) <input name="port" inputmode="numeric" pattern="[0-9]*" maxlength="5" value="@_instance.Port" /></label>
</div>
<div class="support-contact">
<label>Godkendelse
<select name="authentication">
<option value="Windows" selected="@(_instance.Authentication == "Windows")">Windows (agentens tjenestekonto)</option>
<option value="SqlLogin" selected="@(_instance.Authentication == "SqlLogin")">SQL-login</option>
</select>
</label>
<label>SQL-login <input name="userName" maxlength="128" value="@_instance.UserName" autocomplete="off" /></label>
<label>Ny adgangskode (tom: behold den gemte) <input name="password" type="password" maxlength="256" autocomplete="new-password" /></label>
</div>
<div class="support-contact">
<label>Kryptering
<select name="encryption">
<option value="Mandatory" selected="@(_instance.Encryption == "Mandatory")">Påkrævet</option>
<option value="Optional" selected="@(_instance.Encryption == "Optional")">Valgfri (kun til gamle instanser)</option>
</select>
</label>
<label class="check"><span><input type="checkbox" name="trustServerCertificate" value="true" checked="@_instance.TrustServerCertificate" /> Stol på servercertifikatet</span></label>
</div>
<p class="muted">Ændres server, port eller login, skal adgangskoden indtastes igen: den er krypteret til netop den server og det login.</p>
<button type="submit" class="button">Gem og test</button>
</form>
</details>
}
}
<h3>Seneste forbindelsestest</h3>
@if (_test is null)
{
<p class="muted">Der er ikke testet endnu.</p>
}
else
{
<p class="muted">@InstanceSetup.StatusText(_test, DateTime.UtcNow) · bestilt @Display.Time(_test.RequestedAt)@(_test.RequestedBy is { } who ? " af " + who : string.Empty)@(_test.CompletedAt is { } completed ? " · udført " + Display.Time(completed) + " af agenten" : string.Empty)</p>
@if (_test.Error is { } testError)
{
<div class="alert"><strong>Testen kunne ikke udføres.</strong><p>@testError</p></div>
}
@if (_testResult is { Connected: false } failed)
{
<div class="alert">
<strong>Agenten kunne ikke forbinde.</strong>
<pre>@failed.Error</pre>
@if (failed.Hint is { } hint)
{
<p>@hint</p>
}
</div>
}
else if (_testResult is { } ok)
{
<table class="evidence">
<tr><th>Version</th><td>@ok.Release (@ok.ProductVersion) · @ok.Edition</td></tr>
<tr><th>Servernavn</th><td>@ok.ServerName</td></tr>
<tr><th>Forbundet med</th><td>@ok.Provider@(ok.UsedLegacyFallback ? " (ældre ODBC-driver)" : string.Empty)</td></tr>
<tr><th>Agentens login</th><td class="mono">@ok.Login</td></tr>
</table>
@foreach (var note in ok.Notes)
{
<p class="alert warn">@note</p>
}
@if (ok.PermissionsChecked)
{
var missing = ok.MissingRequired.Count();
@if (missing == 0)
{
<p class="notice ok">Agenten har alle de rettigheder, den skal bruge.</p>
}
else
{
<div class="alert"><strong>Agenten mangler @missing @(missing == 1 ? "rettighed" : "rettigheder").</strong> Kør GRANT-scriptet nedenfor som sysadmin på instansen, og test igen.</div>
}
<table>
<thead><tr><th>Rettighed</th><th>Status</th><th>Bruges til</th></tr></thead>
<tbody>
@foreach (var p in ok.Permissions.OrderBy(p => p.Granted).ThenByDescending(p => p.Required))
{
<tr>
<td class="mono">@p.Name</td>
<td>
@if (p.Granted)
{
<span class="badge sev-ok">Har</span>
}
else if (p.Required)
{
<span class="badge sev-critical">Mangler</span>
}
else
{
<span class="badge sev-info">Valgfri, mangler</span>
}
</td>
<td>@p.Purpose</td>
</tr>
}
</tbody>
</table>
}
}
}
</section>
<section class="panel" id="grant">
<h2>Rettigheder til agentens login</h2>
<p>Agenten skal aldrig være sysadmin. Scriptet giver præcis de læserettigheder, overvågningen bruger (DMV'er, metadata, backup- og jobhistorik i msdb), og ingen adgang til data. Det er det samme som <code>db/permissions/grant-least-privilege.sql</code>, udfyldt med agentens login. SQLInsight kører det ikke selv: en sysadmin kører det på instansen, og scriptet kan køres igen uden skade.</p>
@if (_grant.Script is { } script)
{
<p>Login: <span class="mono">@_grant.Login</span></p>
@if (_grant.Note is { } grantNote)
{
<p class="muted">@grantNote</p>
}
<div class="script">
<button type="button" class="copy" data-copy="grant-script">Kopiér</button>
<pre id="grant-script">@script</pre>
</div>
}
else
{
<p class="muted">@_grant.Note</p>
}
</section>
<section class="panel" id="databaser">
<h2>Databaser</h2>
<p>Agenten søger efter alle de databaser, den kan se, når serveren tilføjes og derefter hver time. Vælg, hvilke der skal overvåges. Instansdækkende målinger (ventetyper, CPU, blokeringer, job, fejllog …) gælder altid hele instansen; valget styrer de databasespecifikke tjek: indstillinger, backup, indexes, Query Store og community-regler. Systemdatabaserne overvåges altid.</p>
<p class="muted">
@if (_instance.LastDiscoveryAt is { } discovered)
{
<text>Senest søgt @Display.Time(discovered).</text>
}
else
{
<text>Der er ikke søgt endnu.</text>
}
@if (_discovery is { IsFinished: false } running)
{
<text> @InstanceSetup.StatusText(running, DateTime.UtcNow)</text>
}
</p>
@if (_instance.LastDiscoveryError is { } discoveryError)
{
<div class="alert warn"><strong>Seneste søgning fejlede.</strong><p>@discoveryError</p></div>
}
@if (_mayChange)
{
<form method="post" action="/opsaetning/instanser/@Id/soeg"><AntiforgeryToken /><button type="submit" class="button secondary">Søg efter databaser nu</button></form>
}
@if (_databases.Count > 0)
{
<form method="post" action="/opsaetning/instanser/@Id/databaser">
<AntiforgeryToken />
<p>
<label class="check"><input type="checkbox" name="monitorNew" value="true" checked="@_instance.MonitorNewDatabases" disabled="@(!_mayChange)" /> Overvåg automatisk nye databaser</label>
<span class="muted">· ellers vises nye databaser her, og overvåges først, når de vælges.</span>
</p>
<table>
<thead>
<tr><th>Overvåg</th><th>Database</th><th>Tilstand</th><th class="num">Størrelse</th><th>Recovery model</th><th class="num">Compat. level</th><th>Dynamics AX</th><th>AX-fund til SQLInsight Hub</th></tr>
</thead>
<tbody>
@foreach (var d in _databases)
{
<tr class="@(d.Gone ? "muted" : string.Empty)">
<td>
@if (d.IsSystem)
{
<span class="muted">altid</span>
}
else if (!d.Gone)
{
<input type="checkbox" name="monitor" value="@d.DatabaseName" checked="@d.Monitored" disabled="@(!_mayChange)" aria-label="Overvåg @d.DatabaseName" />
}
</td>
<td class="mono">@d.DatabaseName@(d.IsSnapshot ? " (snapshot)" : string.Empty)</td>
<td>
@(d.Gone ? "findes ikke længere" : StateText(d.State))@(d.IsReadOnly ? ", skrivebeskyttet" : string.Empty)
@if (!d.Gone && !d.HasAccess && !d.IsSystem)
{
<div class="muted">Agenten kan ikke åbne databasen; kør GRANT-scriptet.</div>
}
</td>
<td class="num">@(d.SizeMb is { } mb ? Size(mb) : "–")</td>
<td>@(d.RecoveryModel ?? "–")</td>
<td class="num">@(d.CompatibilityLevel?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "–")</td>
<td>@(d.IsDynamicsAx ? "Ja, AX " + d.DynamicsAxVersion : string.Empty)</td>
<td>
@if (d.IsDynamicsAx && !d.Gone)
{
<input type="checkbox" name="ax" value="@d.DatabaseName" checked="@d.SendDynamicsAxToHub" disabled="@(!_mayChange)" aria-label="Send AX-fund fra @d.DatabaseName til SQLInsight Hub" />
}
</td>
</tr>
}
</tbody>
</table>
<p class="muted">AX-fund sendes som standard ikke til SQLInsight Hub. Slås det til for en database, sendes AX-fundene derfra, når deling med hubben er slået til (se <a href="/system">System</a>).</p>
@if (_mayChange)
{
<button type="submit" class="button">Gem valg</button>
}
</form>
}
</section>
@code {
[Parameter] public int Id { get; set; }
[CascadingParameter] private HttpContext? Http { get; set; }
[SupplyParameterFromQuery(Name = "fejl")] public string? Fejl { get; set; }
[SupplyParameterFromQuery(Name = "tilfoejet")] public int? Tilfoejet { get; set; }
[SupplyParameterFromQuery(Name = "gemt")] public int? Gemt { get; set; }
[SupplyParameterFromQuery(Name = "pause")] public int? Pause { get; set; }
[SupplyParameterFromQuery(Name = "genoptaget")] public int? Genoptaget { get; set; }
[SupplyParameterFromQuery(Name = "databaser")] public int? Databaser { get; set; }
private ConfiguredInstanceRow? _instance;
private IReadOnlyList<ConfiguredDatabaseRow> _databases = [];
private AgentRequestRow? _test;
private AgentRequestRow? _discovery;
private ConnectionTestResult? _testResult;
private (string? Login, string? Script, string? Note) _grant;
private bool _mayChange;
protected override async Task OnInitializedAsync()
{
_instance = await Repository.GetConfiguredInstanceAsync(Id);
if (_instance is null)
{
if (Http is not null)
{
Http.Response.StatusCode = StatusCodes.Status404NotFound;
}
return;
}
_databases = await Repository.GetConfiguredDatabasesAsync(Id);
_test = await Repository.GetLatestAgentRequestAsync(Id, AgentRequestKind.ConnectionTest);
_discovery = await Repository.GetLatestAgentRequestAsync(Id, AgentRequestKind.DatabaseDiscovery);
_testResult = AgentRequestJson.Deserialize<ConnectionTestResult>(_test?.Result);
_grant = await InstanceSetup.GrantScriptAsync(Repository, _instance, CancellationToken.None);
_mayChange = Http is not null && InstanceSetup.MayChange(Http.User, Options.Value);
}
private static string StateText(string? state) => state switch
{
"ONLINE" => "Online",
"OFFLINE" => "Offline",
"RESTORING" => "Gendannes",
"RECOVERING" => "Gendannes efter genstart",
"RECOVERY_PENDING" => "Afventer gendannelse",
"SUSPECT" => "Mistænkt (beskadiget)",
"EMERGENCY" => "Nødtilstand",
null => "–",
_ => state,
};
private static string Size(long megabytes) => megabytes >= 1024
? Display.Number(megabytes / 1024d, 1) + " GB"
: Display.Number(megabytes) + " MB";
}

View file

@ -0,0 +1,212 @@
@page "/opsaetning/instanser"
@using SQLInsight.Core.Configuration
@using Microsoft.AspNetCore.Components.Forms
@inject InsightRepository Repository
@inject Display Display
@inject Microsoft.Extensions.Options.IOptions<WebOptions> Options
<PageTitle>Servere og databaser · SQLInsight Pro</PageTitle>
@if (_search is { IsFinished: false })
{
<HeadContent><meta http-equiv="refresh" content="2" /></HeadContent>
}
<h1>Servere og databaser</h1>
<p class="lead">De SQL Server-instanser, SQLInsight overvåger. Tilføj en server, så finder agenten alle de databaser, den kan se, og I vælger, hvilke der skal overvåges. Forbindelsestest og søgning udføres af agenten med dens egen konto, præcis som overvågningen.</p>
@if (Fjernet == 1)
{
<p class="notice ok" role="status">Serveren overvåges ikke længere. Det, der allerede er indsamlet, er bevaret.</p>
}
@if (!string.IsNullOrEmpty(Fejl))
{
<p class="notice bad" role="alert">@Fejl</p>
}
@if (!_mayChange)
{
<p class="notice" role="note">
Du kan se opsætningen, men kun administratorer kan ændre den.
@if (Options.Value.AdminGroups.Count == 0)
{
<span>Der er ikke sat nogen administratorgruppe op endnu: tilføj en Windows-gruppe under <code>SQLInsight:Web:AdminGroups</code> i SQLInsight Webs <code>appsettings.json</code>.</span>
}
</p>
}
@if (_agentKey is null)
{
<div class="alert warn">
<strong>Agenten har ikke meldt sig endnu.</strong>
<p>Forbindelsestest, søgning efter databaser og SQL-login-adgangskoder kræver, at SQLInsight Agent version 1.3 eller nyere kører mod samme repository.</p>
</div>
}
<section class="card">
<div class="card-head"><h2>Overvågede servere</h2><span class="muted">@_instances.Count(i => !i.Paused) aktive</span></div>
@if (_instances.Count == 0)
{
<p class="empty">Der er ingen servere endnu. @(_mayChange ? "Tilføj den første nedenfor." : string.Empty)</p>
}
else
{
<table>
<thead><tr><th>Navn</th><th>Server</th><th>Godkendelse</th><th>Databaser</th><th>Status</th><th>Kilde</th></tr></thead>
<tbody>
@foreach (var i in _instances)
{
<tr>
<td><a href="@InstanceSetupEndpoints.DetailPage(i.ConfiguredInstanceId)">@i.Name</a></td>
<td class="mono">@i.DataSource</td>
<td>@InstanceSetup.AuthenticationText(i)</td>
<td>@DatabaseSummary(i)</td>
<td>
@if (i.CredentialError is not null)
{
<span class="badge sev-critical" title="@i.CredentialError">Adgangskode skal indtastes igen</span>
}
else if (i.Paused)
{
<span class="badge sev-medium">På pause</span>
}
else
{
<span class="badge sev-ok">Overvåges</span>
}
</td>
<td>@(i.FromConfigFile ? "fra konfigurationsfil" : "SQLInsight Web")</td>
</tr>
}
</tbody>
</table>
}
</section>
@if (_mayChange)
{
<section class="panel" id="tilfoej">
<h2>Tilføj server</h2>
<form method="post" action="/opsaetning/instanser/tilfoej" class="support-form">
<AntiforgeryToken />
<div class="support-contact">
<label>Server <input name="server" required maxlength="256" value="@Server" placeholder="sql01 eller sql01\PROD" autocomplete="off" /></label>
<label>Port (valgfri) <input name="port" inputmode="numeric" pattern="[0-9]*" maxlength="5" placeholder="1433" /></label>
<label>Navn i SQLInsight (valgfrit) <input name="name" maxlength="128" placeholder="som servernavnet" /></label>
</div>
<div class="support-contact">
<label>Godkendelse
<select name="authentication">
<option value="Windows" selected>Windows (agentens tjenestekonto)</option>
<option value="SqlLogin">SQL-login</option>
</select>
</label>
<label>SQL-login (kun ved SQL-login) <input name="userName" maxlength="128" autocomplete="off" /></label>
<label>Adgangskode (kun ved SQL-login) <input name="password" type="password" maxlength="256" autocomplete="new-password" /></label>
</div>
<div class="support-contact">
<label>Kryptering
<select name="encryption">
<option value="Mandatory" selected>Påkrævet</option>
<option value="Optional">Valgfri (kun til gamle instanser, fx SQL Server 2005)</option>
</select>
</label>
<label class="check"><span><input type="checkbox" name="trustServerCertificate" value="true" /> Stol på servercertifikatet (selvsigneret)</span></label>
<label class="check"><span><input type="checkbox" name="monitorNew" value="true" /> Overvåg automatisk nye databaser</span></label>
</div>
<p class="muted">Adgangskoden krypteres med agentens offentlige nøgle, så kun agenten kan læse den. Den vises aldrig igen, heller ikke i API'et. Windows-godkendelse er at foretrække: så bruges agentens tjenestekonto, og der gemmes ingen adgangskode.</p>
<button type="submit" class="button">Tilføj og test forbindelsen</button>
</form>
</section>
<section class="panel" id="find">
<h2>Find instanser</h2>
<p class="muted">Agenten spørger SQL Server Browser på computeren (UDP 1434) og, på sin egen computer, registreringsdatabasen. Instanser, der er skjult eller bag en firewall, findes ikke; dem kan I tilføje manuelt.</p>
<form method="post" action="/opsaetning/find-instanser" class="window-form">
<AntiforgeryToken />
<label>Computer (tom: agentens egen) <input name="host" maxlength="256" placeholder="sql01.firma.local" /></label>
<button type="submit" class="button">Find instanser</button>
</form>
@if (_search is { } search)
{
<p class="muted">@InstanceSetup.StatusText(search, DateTime.UtcNow)</p>
@if (search.Error is { } searchError)
{
<p class="notice bad">@searchError</p>
}
@if (_found is { } found)
{
@foreach (var note in found.Notes)
{
<p class="muted">@note</p>
}
@if (found.Instances.Count > 0)
{
<table>
<thead><tr><th>Server</th><th>Version</th><th>TCP-port</th><th>Fundet via</th><th></th></tr></thead>
<tbody>
@foreach (var f in found.Instances)
{
<tr>
<td class="mono">@f.Server@(f.IsClustered ? " (cluster)" : "")</td>
<td>@(f.Version is { } v ? Display.Release(v) + " (" + v + ")" : "–")</td>
<td class="num">@(f.TcpPort?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "–")</td>
<td>@f.Source</td>
<td>
@if (_instances.Any(i => string.Equals(i.Server, f.Server, StringComparison.OrdinalIgnoreCase)))
{
<span class="muted">Overvåges allerede</span>
}
else
{
<a href="/opsaetning/instanser?server=@Uri.EscapeDataString(f.Server)#tilfoej">Tilføj</a>
}
</td>
</tr>
}
</tbody>
</table>
}
}
}
</section>
}
@code {
[CascadingParameter] private HttpContext? Http { get; set; }
[SupplyParameterFromQuery(Name = "fejl")] public string? Fejl { get; set; }
[SupplyParameterFromQuery(Name = "fjernet")] public int? Fjernet { get; set; }
[SupplyParameterFromQuery(Name = "server")] public string? Server { get; set; }
[SupplyParameterFromQuery(Name = "soegning")] public long? Soegning { get; set; }
private IReadOnlyList<ConfiguredInstanceRow> _instances = [];
private ILookup<int, ConfiguredDatabaseRow> _databases = Array.Empty<ConfiguredDatabaseRow>().ToLookup(d => d.ConfiguredInstanceId);
private AgentKeyRow? _agentKey;
private AgentRequestRow? _search;
private InstanceDiscoveryResult? _found;
private bool _mayChange;
protected override async Task OnInitializedAsync()
{
_instances = await Repository.GetConfiguredInstancesAsync();
_databases = (await Repository.GetAllDatabaseChoicesAsync()).ToLookup(d => d.ConfiguredInstanceId);
_agentKey = await Repository.GetCurrentAgentKeyAsync();
_mayChange = Http is not null && InstanceSetup.MayChange(Http.User, Options.Value);
if (Soegning is { } id && await Repository.GetAgentRequestAsync(id) is { Kind: AgentRequestKind.InstanceDiscovery } search)
{
_search = search;
_found = AgentRequestJson.Deserialize<InstanceDiscoveryResult>(search.Result);
}
}
private string DatabaseSummary(ConfiguredInstanceRow instance)
{
var databases = _databases[instance.ConfiguredInstanceId].Where(d => !d.Gone && !d.IsSystem).ToList();
if (databases.Count == 0)
{
return instance.MonitorNewDatabases ? "Alle (også nye)" : instance.LastDiscoveryAt is null ? "Ikke søgt endnu" : "Ingen brugerdatabaser";
}
var monitored = databases.Count(d => d.Monitored);
return monitored == databases.Count && instance.MonitorNewDatabases
? $"Alle {databases.Count} (også nye)"
: $"{monitored} af {databases.Count}" + (instance.MonitorNewDatabases ? " (og nye)" : string.Empty);
}
}

View file

@ -0,0 +1,299 @@
using System.Security.Claims;
using Microsoft.Data.SqlClient;
using SQLInsight.Core.Configuration;
using SQLInsight.Repository;
using SQLInsight.Web.Api;
namespace SQLInsight.Web;
/// <summary>An instance as entered in the form or the API. The password is only used to seal it for the agent.</summary>
public sealed record InstanceInput(
string? Name,
string? Server,
int? Port,
string? Authentication,
string? UserName,
string? Password,
string? Encryption,
bool TrustServerCertificate,
bool MonitorNewDatabases);
/// <summary>
/// Which servers and databases are monitored, as changed in SQLInsight Web or API v1: the same checks and rules for
/// both. The web UI only writes the configuration and queues requests in the repository; connection tests and
/// discovery run in the agent, as the agent's identity. A SQL login password is sealed with the agent's public key at
/// once, and never stored, shown or returned in clear text.
/// </summary>
public static class InstanceSetup
{
public const string NotAdminText =
"Kun administratorer kan ændre, hvilke servere og databaser der overvåges. Bed en administrator om at tilføje jeres gruppe under SQLInsight:Web:AdminGroups.";
public const string NoAgentKeyText =
"Agenten har ikke meldt sig endnu, så adgangskoden kan ikke krypteres til den. Start tjenesten SQLInsight Agent (version 1.3 eller nyere), og prøv igen om lidt.";
/// <summary>A request older than this without an answer is shown as "agenten svarer ikke".</summary>
public static readonly TimeSpan AgentSilence = TimeSpan.FromMinutes(2);
public sealed record Outcome(int? Id = null, long? RequestId = null, string? Error = null, bool NotFound = false);
/// <summary>
/// Whether the user may change the configuration: an API token needs the scope config:write; a Windows user must be
/// in one of SQLInsight:Web:AdminGroups. With authentication off (development only) everyone may.
/// </summary>
public static bool MayChange(ClaimsPrincipal user, WebOptions options)
{
ArgumentNullException.ThrowIfNull(user);
ArgumentNullException.ThrowIfNull(options);
if (user.HasClaim(c => c.Type == ApiTokenHandler.ClaimType))
{
return user.HasClaim(ApiTokenHandler.ScopeClaimType, ApiToken.ConfigWrite);
}
if (string.Equals(options.Authentication, "None", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return options.AdminGroups.Any(user.IsInRole);
}
private static string? Validate(InstanceInput input)
{
if (ServerAddress.Validate(input.Server, input.Port) is { } server)
{
return server;
}
if (input.Authentication is not ("Windows" or "SqlLogin"))
{
return "Godkendelse skal være Windows (agentens tjenestekonto) eller SqlLogin.";
}
if (input.Encryption is not (null or "Mandatory" or "Optional"))
{
return "Kryptering skal være Mandatory eller Optional.";
}
if (input.Authentication == "SqlLogin" && (string.IsNullOrWhiteSpace(input.UserName) || input.UserName.Trim().Length > 128))
{
return "Angiv SQL-login'et (højst 128 tegn).";
}
return input.Password?.Length > 256 ? "Adgangskoden er for lang." : null;
}
/// <summary>Adds an instance and asks the agent to test the connection and list the databases.</summary>
public static async Task<Outcome> AddAsync(InsightRepository repository, InstanceInput input, string? by, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(input);
input = input with { Authentication = input.Authentication ?? "Windows", Encryption = input.Encryption ?? "Mandatory" };
var name = string.IsNullOrWhiteSpace(input.Name) ? input.Server?.Trim() : input.Name.Trim();
if ((Validate(input) ?? ServerAddress.ValidateName(name)) is { } invalid)
{
return new Outcome(Error: invalid);
}
var dataSource = ServerAddress.DataSource(input.Server!, input.Port);
byte[]? cipher = null;
string? keyId = null;
if (input.Authentication == "SqlLogin")
{
if (string.IsNullOrEmpty(input.Password))
{
return new Outcome(Error: "Angiv adgangskoden til SQL-login'et.");
}
var key = await repository.GetCurrentAgentKeyAsync(cancellationToken).ConfigureAwait(false);
if (key is null)
{
return new Outcome(Error: NoAgentKeyText);
}
cipher = CredentialEnvelope.Seal(input.Password, key.KeyId, key.PublicKey, CredentialEnvelope.Context(dataSource, input.UserName));
keyId = key.KeyId;
}
int id;
try
{
id = await repository.AddConfiguredInstanceAsync(new NewConfiguredInstance(name!, input.Server!.Trim(), input.Port, input.Authentication!,
input.Authentication == "SqlLogin" ? input.UserName!.Trim() : null, cipher, keyId, input.Encryption!, input.TrustServerCertificate,
input.MonitorNewDatabases), by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false);
}
catch (SqlException ex) when (ex.Number is 2601 or 2627)
{
return new Outcome(Error: $"Navnet {name} bruges allerede af en anden instans. Vælg et andet navn.");
}
var test = await repository.AddAgentRequestAsync(AgentRequestKind.ConnectionTest, id, null, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false);
await repository.AddAgentRequestAsync(AgentRequestKind.DatabaseDiscovery, id, null, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false);
return new Outcome(id, test);
}
/// <summary>
/// New connection details for an instance added in SQLInsight Web. The password must be typed again when the
/// server, port or login changes: it is sealed to them, so it cannot follow to another server unseen.
/// </summary>
public static async Task<Outcome> UpdateAsync(InsightRepository repository, int id, InstanceInput input, string? by, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(input);
var row = await repository.GetConfiguredInstanceAsync(id, cancellationToken).ConfigureAwait(false);
if (row is null)
{
return new Outcome(NotFound: true);
}
if (row.FromConfigFile)
{
return new Outcome(id, Error: "Instansen kommer fra agentens konfigurationsfil. Forbindelsen ændres dér.");
}
input = input with { Authentication = input.Authentication ?? row.Authentication, Encryption = input.Encryption ?? row.Encryption };
if (Validate(input) is { } invalid)
{
return new Outcome(id, Error: invalid);
}
var dataSource = ServerAddress.DataSource(input.Server!, input.Port);
var userName = input.Authentication == "SqlLogin" ? input.UserName!.Trim() : null;
byte[]? cipher = null;
string? keyId = null;
if (input.Authentication == "SqlLogin")
{
var moved = !string.Equals(dataSource, row.DataSource, StringComparison.OrdinalIgnoreCase) || !string.Equals(userName, row.UserName, StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(input.Password) && (moved || !row.HasPassword))
{
return new Outcome(id, Error: "Indtast adgangskoden igen: den følger ikke med, når server, port eller login ændres.");
}
if (!string.IsNullOrEmpty(input.Password))
{
var key = await repository.GetCurrentAgentKeyAsync(cancellationToken).ConfigureAwait(false);
if (key is null)
{
return new Outcome(id, Error: NoAgentKeyText);
}
cipher = CredentialEnvelope.Seal(input.Password, key.KeyId, key.PublicKey, CredentialEnvelope.Context(dataSource, userName));
keyId = key.KeyId;
}
}
var outcome = await repository.UpdateConfiguredInstanceAsync(id, new ConfiguredInstanceChange(input.Server!.Trim(), input.Port, input.Authentication!, userName,
cipher, keyId, ClearPassword: input.Authentication != "SqlLogin", input.Encryption!, input.TrustServerCertificate), by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false);
if (outcome != ConfigurationChangeOutcome.Changed)
{
return new Outcome(id, NotFound: outcome == ConfigurationChangeOutcome.NotFound, Error: "Instansen kunne ikke ændres.");
}
var test =await repository.AddAgentRequestAsync(AgentRequestKind.ConnectionTest, id, null, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false);
return new Outcome(id, test);
}
/// <summary>Asks the agent for a connection test or a database discovery of a configured instance.</summary>
public static async Task<Outcome> RequestAsync(InsightRepository repository, int id, string kind, string? by, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(repository);
if (await repository.GetConfiguredInstanceAsync(id, cancellationToken).ConfigureAwait(false) is null)
{
return new Outcome(NotFound: true);
}
return new Outcome(id, await repository.AddAgentRequestAsync(kind, id, null, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false));
}
/// <summary>Asks the agent which SQL Server instances it can find on a computer (its own when empty).</summary>
public static async Task<Outcome> FindInstancesAsync(InsightRepository repository, string? host, string? by, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(repository);
host = string.IsNullOrWhiteSpace(host) ? null : host.Trim();
if (host is not null && (host.Contains('\\', StringComparison.Ordinal) || ServerAddress.Validate(host, null) is not null))
{
return new Outcome(Error: "Angiv et computernavn uden instans, fx sql01 eller sql01.firma.local.");
}
return new Outcome(RequestId: await repository.AddAgentRequestAsync(AgentRequestKind.InstanceDiscovery, null,
AgentRequestJson.Serialize(new InstanceDiscoveryParameters(host)), by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false));
}
/// <summary>
/// Sets which of the found databases are monitored and send Dynamics AX findings to the hub, and whether new
/// databases are monitored automatically (null: unchanged). Databases not named keep their choice; the form names
/// them all, the API may name only some.
/// </summary>
public static async Task<(IReadOnlyList<string> Unknown, bool Found)> ChooseDatabasesAsync(InsightRepository repository, int id, IReadOnlyCollection<DatabaseChoice> choices,
bool? monitorNew, string? by, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(repository);
if (await repository.GetConfiguredInstanceAsync(id, cancellationToken).ConfigureAwait(false) is null)
{
return ([], false);
}
var unknown = await repository.SetDatabaseChoicesAsync(id, choices, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false);
if (monitorNew is { } automatic)
{
await repository.SetMonitorNewDatabasesAsync(id, automatic, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false);
}
return (unknown, true);
}
/// <summary>
/// The least-privilege GRANT script for the agent's login on an instance: the login the last connection test saw,
/// or else the one the agent's account will arrive as (computed from what the agent published), or the SQL login.
/// </summary>
public static async Task<(string? Login, string? Script, string? Note)> GrantScriptAsync(InsightRepository repository, ConfiguredInstanceRow row, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(row);
var test = await repository.GetLatestAgentRequestAsync(row.ConfiguredInstanceId, AgentRequestKind.ConnectionTest, cancellationToken).ConfigureAwait(false);
var seen = AgentRequestJson.Deserialize<ConnectionTestResult>(test?.Result)?.Login;
var windows = row.Authentication == "Windows";
string? login = seen;
string? note = null;
if (login is null && windows)
{
var key = await repository.GetCurrentAgentKeyAsync(cancellationToken).ConfigureAwait(false);
if (key is null)
{
return (null, null, "Agenten har ikke meldt sig endnu, så dens login kendes ikke. Start SQLInsight Agent, eller brug db/permissions/grant-least-privilege.sql og ret login'et.");
}
login = ServiceAccounts.LoginFor(key.ServiceAccount, row.DataSource, key.MachineName, key.Domain ?? key.MachineName);
note = $"Beregnet ud fra agentens konto ({key.ServiceAccount} på {key.MachineName}). Forbindelsestesten viser det login, agenten faktisk ankommer som.";
}
login ??= row.UserName;
return login is null ? (null, null, "Angiv et login.") : (login, GrantScript.For(login, windows && !login.Contains('@', StringComparison.Ordinal) && login.Contains('\\', StringComparison.Ordinal)), note);
}
/// <summary>Danish text for a request's state.</summary>
public static string StatusText(AgentRequestRow request, DateTime utcNow)
{
ArgumentNullException.ThrowIfNull(request);
return request.Status switch
{
AgentRequestStatus.Pending when utcNow - request.RequestedAt > AgentSilence =>
"Venter stadig på agenten. Kører tjenesten SQLInsight Agent (version 1.3 eller nyere)?",
AgentRequestStatus.Pending => "Venter på agenten …",
AgentRequestStatus.Running => "Agenten arbejder …",
AgentRequestStatus.Done => "Færdig",
_ => "Fejlede",
};
}
public static string AuthenticationText(ConfiguredInstanceRow row)
{
ArgumentNullException.ThrowIfNull(row);
return row.Authentication switch
{
"Windows" => "Windows (agentens tjenestekonto)",
"SqlLogin" => $"SQL-login {row.UserName}",
_ => row.Authentication,
};
}
}

View file

@ -0,0 +1,167 @@
using System.Globalization;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.Extensions.Options;
using SQLInsight.Core.Configuration;
using SQLInsight.Repository;
namespace SQLInsight.Web;
/// <summary>
/// The forms on /opsaetning/instanser: each posts here, is checked (antiforgery, administrator), changes the
/// configuration in the repository or queues a request for the agent, and redirects back with a message.
/// </summary>
public static class InstanceSetupEndpoints
{
public const string ListPage = "/opsaetning/instanser";
public static string DetailPage(int id) => ListPage + "/" + id.ToString(CultureInfo.InvariantCulture);
public static void Map(IEndpointRouteBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
app.MapPost(ListPage + "/tilfoej", AddAsync);
app.MapPost(ListPage + "/{id:int}/forbindelse", UpdateAsync);
app.MapPost(ListPage + "/{id:int}/pause", PauseAsync);
app.MapPost(ListPage + "/{id:int}/fjern", RemoveAsync);
app.MapPost(ListPage + "/{id:int}/test", (int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> options) =>
RequestAsync(id, AgentRequestKind.ConnectionTest, http, repository, antiforgery, options));
app.MapPost(ListPage + "/{id:int}/soeg", (int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> options) =>
RequestAsync(id, AgentRequestKind.DatabaseDiscovery, http, repository, antiforgery, options));
app.MapPost(ListPage + "/{id:int}/databaser", DatabasesAsync);
app.MapPost("/opsaetning/find-instanser", FindAsync);
}
private static string WithMessage(string page, string key, string value) =>
page + (page.Contains('?', StringComparison.Ordinal) ? "&" : "?") + key + "=" + Uri.EscapeDataString(value);
/// <summary>Antiforgery and the administrator check; null when the request may go on.</summary>
private static async Task<IResult?> RefuseAsync(HttpContext http, IAntiforgery antiforgery, WebOptions options, string back)
{
if (!await antiforgery.IsRequestValidAsync(http).ConfigureAwait(false))
{
return Results.BadRequest("Formularen er udløbet. Genindlæs siden, og prøv igen.");
}
return InstanceSetup.MayChange(http.User, options) ? null : Results.Redirect(WithMessage(back, "fejl", InstanceSetup.NotAdminText));
}
private static InstanceInput Input(IFormCollection form) => new(
form["name"].ToString(),
form["server"].ToString(),
int.TryParse(form["port"].ToString(), NumberStyles.None, CultureInfo.InvariantCulture, out var port) ? port : null,
form["authentication"].ToString() is { Length: > 0 } authentication ? authentication : null,
form["userName"].ToString(),
form["password"].ToString(),
form["encryption"].ToString() is { Length: > 0 } encryption ? encryption : null,
form["trustServerCertificate"] == "true",
form["monitorNew"] == "true");
private static async Task<IResult> AddAsync(HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> options)
{
if (await RefuseAsync(http, antiforgery, options.Value, ListPage).ConfigureAwait(false) is { } refused)
{
return refused;
}
var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false);
if (form["port"].ToString() is { Length: > 0 } port && !int.TryParse(port, NumberStyles.None, CultureInfo.InvariantCulture, out _))
{
return Results.Redirect(WithMessage(ListPage, "fejl", "Porten skal være et tal."));
}
var outcome = await InstanceSetup.AddAsync(repository, Input(form), http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false);
return outcome.Error is { } error
? Results.Redirect(WithMessage(ListPage + "?server=" + Uri.EscapeDataString(form["server"].ToString()), "fejl", error))
: Results.Redirect(DetailPage(outcome.Id!.Value) + "?tilfoejet=1");
}
private static async Task<IResult> UpdateAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> options)
{
if (await RefuseAsync(http, antiforgery, options.Value, DetailPage(id)).ConfigureAwait(false) is { } refused)
{
return refused;
}
var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false);
var outcome = await InstanceSetup.UpdateAsync(repository, id, Input(form), http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false);
if (outcome.NotFound)
{
return Results.NotFound();
}
return Results.Redirect(outcome.Error is { } error ? WithMessage(DetailPage(id), "fejl", error) : DetailPage(id) + "?gemt=1");
}
private static async Task<IResult> PauseAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> options)
{
if (await RefuseAsync(http, antiforgery, options.Value, DetailPage(id)).ConfigureAwait(false) is { } refused)
{
return refused;
}
var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false);
var paused = form["paused"] == "true";
return await repository.SetInstancePausedAsync(id, paused, http.User.Identity?.Name, DateTimeOffset.UtcNow, http.RequestAborted).ConfigureAwait(false)
== ConfigurationChangeOutcome.Changed
? Results.Redirect(DetailPage(id) + (paused ? "?pause=1" : "?genoptaget=1"))
: Results.NotFound();
}
private static async Task<IResult> RemoveAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> options)
{
if (await RefuseAsync(http, antiforgery, options.Value, DetailPage(id)).ConfigureAwait(false) is { } refused)
{
return refused;
}
return await repository.RemoveConfiguredInstanceAsync(id, http.User.Identity?.Name, DateTimeOffset.UtcNow, http.RequestAborted).ConfigureAwait(false) switch
{
ConfigurationChangeOutcome.Changed => Results.Redirect(ListPage + "?fjernet=1"),
ConfigurationChangeOutcome.FromConfigFile => Results.Redirect(WithMessage(DetailPage(id), "fejl",
"Instansen kommer fra agentens konfigurationsfil og fjernes dér (SQLInsight:Instances). Den kan sættes på pause her.")),
_ => Results.NotFound(),
};
}
private static async Task<IResult> RequestAsync(int id, string kind, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> options)
{
if (await RefuseAsync(http, antiforgery, options.Value, DetailPage(id)).ConfigureAwait(false) is { } refused)
{
return refused;
}
var outcome = await InstanceSetup.RequestAsync(repository, id, kind, http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false);
return outcome.NotFound ? Results.NotFound() : Results.Redirect(DetailPage(id) + (kind == AgentRequestKind.ConnectionTest ? "#forbindelse" : "#databaser"));
}
private static async Task<IResult> DatabasesAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> options)
{
if (await RefuseAsync(http, antiforgery, options.Value, DetailPage(id)).ConfigureAwait(false) is { } refused)
{
return refused;
}
var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false);
var monitored = new HashSet<string>(form["monitor"].OfType<string>(), StringComparer.OrdinalIgnoreCase);
var ax = new HashSet<string>(form["ax"].OfType<string>(), StringComparer.OrdinalIgnoreCase);
var known = await repository.GetConfiguredDatabasesAsync(id, http.RequestAborted).ConfigureAwait(false);
var choices = known.Where(d => !d.Gone).Select(d => new DatabaseChoice(d.DatabaseName, monitored.Contains(d.DatabaseName), ax.Contains(d.DatabaseName))).ToList();
var (_, found) = await InstanceSetup.ChooseDatabasesAsync(repository, id, choices, form["monitorNew"] == "true", http.User.Identity?.Name, http.RequestAborted)
.ConfigureAwait(false);
return found ? Results.Redirect(DetailPage(id) + "?databaser=1#databaser") : Results.NotFound();
}
private static async Task<IResult> FindAsync(HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> options)
{
if (await RefuseAsync(http, antiforgery, options.Value, ListPage).ConfigureAwait(false) is { } refused)
{
return refused;
}
var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false);
var outcome = await InstanceSetup.FindInstancesAsync(repository, form["host"].ToString(), http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false);
return Results.Redirect(outcome.Error is { } error
? WithMessage(ListPage, "fejl", error)
: ListPage + "?soegning=" + outcome.RequestId!.Value.ToString(CultureInfo.InvariantCulture) + "#find");
}
}

View file

@ -116,8 +116,10 @@ app.MapGet("/deadlocks/{id:long}.xdl", async (long id, InsightRepository reposit
IntegrationEndpoints.Map(app);
ApiV1Endpoints.Map(app);
SupportApiEndpoints.Map(app);
ConfigurationApiEndpoints.Map(app);
app.MapOpenApi("/api/v1/openapi.json").RequireRead();
Support.Map(app);
InstanceSetupEndpoints.Map(app);
// Management and compliance report as PDF, for the last N days.
app.MapGet("/rapporter/driftsrapport.pdf", async (int? dage, InsightRepository repository, Microsoft.Extensions.Options.IOptions<WebOptions> options, CancellationToken ct) =>

View file

@ -10,6 +10,12 @@ public sealed class WebOptions
/// <summary>Windows groups allowed to use the UI (e.g. DOMAIN\SQL-Admins). Empty: any authenticated user.</summary>
public IList<string> AllowedGroups { get; } = [];
/// <summary>
/// Windows groups that may change which servers and databases are monitored (Konfiguration og sikkerhed → Servere
/// og databaser), e.g. DOMAIN\SQL-Admins. Empty: nobody can change it in the UI; everyone with access can see it.
/// </summary>
public IList<string> AdminGroups { get; } = [];
/// <summary>Time zone for display, e.g. Europe/Copenhagen. Defaults to the server's local time zone.</summary>
public string? TimeZone { get; set; }

View file

@ -226,3 +226,14 @@ details.not-applicable > table { margin-top: .75rem; }
.nav-toggle:focus-visible + .nav-toggle-label { outline: 2px solid var(--accent); }
.nav-toggle:not(:checked) ~ .nav, .nav-toggle:not(:checked) ~ .support-card { display: none; }
}
/* Servere og databaser (/opsaetning/instanser): forms, actions and the database choice. */
.support-form select { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .65rem; }
label.check { display: flex; align-items: center; gap: .45rem; font-size: .9rem; color: var(--text); }
label.check input { width: auto; margin: 0; }
.report-links form { margin: 0; }
button.button { border: 0; cursor: pointer; font: inherit; background: var(--accent); color: #fff; padding: .5rem 1rem; border-radius: 8px; font-weight: 600; }
button.button:hover { filter: brightness(1.08); }
button.button.secondary { background: var(--surface-2); color: var(--text); border: 1px solid var(--border); }
button.button.danger { background: var(--critical); }
tr.muted td { color: var(--text-2); }

View file

@ -0,0 +1,138 @@
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using SQLInsight.Core.Configuration;
using SQLInsight.Repository;
using SQLInsight.Web.Api;
namespace SQLInsight.IntegrationTests;
/// <summary>The configuration through API v1 with Windows authentication on and bearer tokens with and without config:write.</summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public sealed class InstanceSetupApiTests(SqlServerFixture sql) : IAsyncLifetime
{
private const string ReadToken = "sqli_test-config-read";
private const string WriteToken = "sqli_test-config-write";
private readonly string _database = "SQLInsightCfgApi_" + Guid.NewGuid().ToString("N")[..8];
private WebApplicationFactory<Program> _factory = null!;
private InsightRepository _repository = null!;
private int _port;
public async Task InitializeAsync()
{
using (var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0))
{
probe.Start();
_port = ((IPEndPoint)probe.LocalEndpoint).Port;
}
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
await _repository.MigrateAsync();
using var rsa = RSA.Create(3072);
await _repository.PublishAgentKeyAsync(new AgentKeyRow
{
KeyId = "agent-api", PublicKey = rsa.ExportSubjectPublicKeyInfo(), MachineName = "AGENT01", ServiceAccount = @"FIRMA\svc-sqlinsight$", Domain = "FIRMA",
CreatedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow,
});
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseEnvironment("Development");
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
b.UseSetting("SQLInsight:Web:Authentication", "Windows");
b.UseSetting("SQLInsight:Web:AdminGroups:0", @"FIRMA\SQL-Admins");
b.UseSetting("SQLInsight:Web:ApiTokens:grafana", "sha256:" + ApiTokenHandler.Hash(ReadToken));
b.UseSetting("SQLInsight:Web:ApiTokens:automation:hash", "sha256:" + ApiTokenHandler.Hash(WriteToken));
b.UseSetting("SQLInsight:Web:ApiTokens:automation:scopes:0", "read");
b.UseSetting("SQLInsight:Web:ApiTokens:automation:scopes:1", "config:write");
b.UseSetting("urls", $"http://127.0.0.1:{_port}");
});
_factory.UseKestrel();
_factory.StartServer();
}
public async Task DisposeAsync()
{
await _factory.DisposeAsync();
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
}
private HttpClient Client(string? token)
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri($"http://127.0.0.1:{_port}") });
if (token is not null)
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
return client;
}
private static StringContent Body(object value) => new(JsonSerializer.Serialize(value), Encoding.UTF8, "application/json");
private static async Task<JsonElement> JsonAsync(HttpResponseMessage response) => JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
[Fact]
public async Task Everything_on_the_page_is_in_the_api_and_changing_needs_config_write()
{
var write = Client(WriteToken);
var read = Client(ReadToken);
var instance = new { server = @"sql01\PROD", authentication = "SqlLogin", userName = "mon", password = "Api-Hemmelig-1!", trustServerCertificate = true };
Assert.Equal(HttpStatusCode.Unauthorized, (await Client(null).GetAsync("/api/v1/monitored-instances")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await read.PostAsync("/api/v1/monitored-instances", Body(instance))).StatusCode);
var created = await write.PostAsync("/api/v1/monitored-instances", Body(instance));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var body = await JsonAsync(created);
var id = body.GetProperty("instance").GetProperty("id").GetInt32();
var requestId = body.GetProperty("connectionTestRequestId").GetInt64();
Assert.True(body.GetProperty("instance").GetProperty("hasPassword").GetBoolean());
Assert.DoesNotContain("Api-Hemmelig", body.ToString(), StringComparison.Ordinal);
var list = await JsonAsync(await read.GetAsync("/api/v1/monitored-instances"));
Assert.Equal(@"sql01\PROD", Assert.Single(list.EnumerateArray()).GetProperty("name").GetString());
Assert.Equal("Pending", (await JsonAsync(await read.GetAsync($"/api/v1/agent-requests/{requestId}"))).GetProperty("status").GetString());
Assert.Equal(HttpStatusCode.Forbidden, (await read.PatchAsync($"/api/v1/monitored-instances/{id}", Body(new { paused = true }))).StatusCode);
Assert.True((await JsonAsync(await write.PatchAsync($"/api/v1/monitored-instances/{id}", Body(new { paused = true })))).GetProperty("paused").GetBoolean());
Assert.Equal(HttpStatusCode.Accepted, (await write.PostAsync($"/api/v1/monitored-instances/{id}/connection-test", Body(new { }))).StatusCode);
Assert.Equal(HttpStatusCode.Accepted, (await write.PostAsync($"/api/v1/monitored-instances/{id}/discovery", Body(new { }))).StatusCode);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await write.PutAsync($"/api/v1/monitored-instances/{id}/databases",
Body(new { databases = new[] { new { name = "Sales", monitored = true } } }))).StatusCode);
await _repository.SaveDiscoveredDatabasesAsync(id, [new DiscoveredDatabase { Name = "Sales", HasAccess = true }, new DiscoveredDatabase { Name = "AX30", HasAccess = true, IsDynamicsAx = true }],
DateTimeOffset.UtcNow);
var chosen = await JsonAsync(await write.PutAsync($"/api/v1/monitored-instances/{id}/databases",
Body(new { monitorNewDatabases = true, databases = new object[] { new { name = "Sales", monitored = true }, new { name = "AX30", monitored = true, sendDynamicsAxToHub = true } } })));
Assert.Contains(chosen.EnumerateArray(), d => d.GetProperty("name").GetString() == "AX30" && d.GetProperty("sendDynamicsAxToHub").GetBoolean());
Assert.True((await _repository.GetConfiguredInstanceAsync(id))!.MonitorNewDatabases);
Assert.Equal(2, (await JsonAsync(await read.GetAsync($"/api/v1/monitored-instances/{id}/databases"))).GetArrayLength());
var grant = await JsonAsync(await read.GetAsync($"/api/v1/monitored-instances/{id}/grant-script"));
Assert.Equal("mon", grant.GetProperty("login").GetString());
Assert.Contains("SET @LoginName = N'mon';", grant.GetProperty("script").GetString(), StringComparison.Ordinal);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await write.PostAsync("/api/v1/instance-discovery", Body(new { host = @"sql01\PROD" }))).StatusCode);
Assert.Equal(HttpStatusCode.Accepted, (await write.PostAsync("/api/v1/instance-discovery", Body(new { host = "sql01.firma.local" }))).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await read.DeleteAsync($"/api/v1/monitored-instances/{id}")).StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await write.DeleteAsync($"/api/v1/monitored-instances/{id}")).StatusCode);
Assert.Empty((await JsonAsync(await read.GetAsync("/api/v1/monitored-instances"))).EnumerateArray());
}
[Fact]
public async Task A_configuration_file_instance_cannot_be_changed_or_removed_through_the_api()
{
var id = (await _repository.SyncConfigFileInstancesAsync([new ConfigFileInstance("prod-sql01", "sql01", "Windows", null, "Mandatory", false)], DateTimeOffset.UtcNow))["prod-sql01"];
var write = Client(WriteToken);
Assert.Equal(HttpStatusCode.Conflict, (await write.DeleteAsync($"/api/v1/monitored-instances/{id}")).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await write.PutAsync($"/api/v1/monitored-instances/{id}/connection", Body(new { server = "evil", authentication = "Windows" }))).StatusCode);
Assert.Equal("configFile", (await JsonAsync(await write.GetAsync($"/api/v1/monitored-instances/{id}"))).GetProperty("source").GetString());
}
}

View file

@ -0,0 +1,252 @@
using System.Net;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using SQLInsight.Core.Configuration;
using SQLInsight.Repository;
using SQLInsight.Web;
using SQLInsight.Web.Api;
namespace SQLInsight.IntegrationTests;
/// <summary>Servere og databaser in SQLInsight Web: adding, testing, choosing databases, the GRANT script and passwords.</summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public sealed partial class InstanceSetupWebTests(SqlServerFixture sql) : IAsyncLifetime
{
private const string Password = "Hemmelig-Kode-1!";
private readonly string _database = "SQLInsightSetup_" + Guid.NewGuid().ToString("N")[..8];
private readonly RSA _agentKey = RSA.Create(3072);
private WebApplicationFactory<Program> _factory = null!;
private InsightRepository _repository = null!;
[GeneratedRegex("name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"")]
private static partial Regex AntiforgeryField();
public async Task InitializeAsync()
{
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
await _repository.MigrateAsync();
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseEnvironment("Development");
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
b.UseSetting("SQLInsight:Web:Authentication", "None");
});
}
public async Task DisposeAsync()
{
_agentKey.Dispose();
await _factory.DisposeAsync();
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
}
private Task PublishAgentKeyAsync() => _repository.PublishAgentKeyAsync(new AgentKeyRow
{
KeyId = "agent-test", PublicKey = _agentKey.ExportSubjectPublicKeyInfo(), MachineName = "AGENT01", ServiceAccount = @"NT SERVICE\SQLInsight Agent",
Domain = "FIRMA", CreatedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow,
});
private HttpClient Client() => _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
/// <summary>Posts a form the way the browser does: with the antiforgery token from <paramref name="page"/>.</summary>
private static async Task<HttpResponseMessage> PostAsync(HttpClient client, string page, string action, Dictionary<string, string> fields, params (string Name, string Value)[] repeated)
{
var html = await client.GetStringAsync(page);
var values = new List<KeyValuePair<string, string>> { new("__RequestVerificationToken", WebUtility.HtmlDecode(AntiforgeryField().Match(html).Groups[1].Value)) };
values.AddRange(fields);
values.AddRange(repeated.Select(r => new KeyValuePair<string, string>(r.Name, r.Value)));
using var content = new FormUrlEncodedContent(values);
return await client.PostAsync(action, content);
}
private static int IdFrom(HttpResponseMessage response) =>
int.Parse(Regex.Match(response.Headers.Location!.OriginalString, @"/opsaetning/instanser/(\d+)").Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture);
[Fact]
public async Task Page_lists_servers_from_the_configuration_file_and_the_web_and_the_menu_links_to_it()
{
await _repository.SyncConfigFileInstancesAsync([new ConfigFileInstance("prod-sql01", "sql01", "Windows", null, "Mandatory", false)], DateTimeOffset.UtcNow);
await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance("ax-sql", "ax01\\AX30", null, "Windows", null, null, null, "Mandatory", false, false), "anna", DateTimeOffset.UtcNow);
var page = await Client().GetStringAsync("/opsaetning/instanser");
var home = await Client().GetStringAsync("/");
Assert.Contains("prod-sql01", page, StringComparison.Ordinal);
Assert.Contains("fra konfigurationsfil", page, StringComparison.Ordinal);
Assert.Contains(@"ax01\AX30", page, StringComparison.Ordinal);
Assert.Contains("Alle (også nye)", page, StringComparison.Ordinal);
Assert.Contains("Agenten har ikke meldt sig endnu", page, StringComparison.Ordinal);
Assert.Contains("href=\"/opsaetning/instanser\"", home, StringComparison.Ordinal);
Assert.Contains("Servere og databaser", home, StringComparison.Ordinal);
}
[Fact]
public async Task Admin_adds_a_server_and_the_agent_is_asked_to_test_it_and_find_its_databases()
{
var client = Client();
var posted = await PostAsync(client, "/opsaetning/instanser", "/opsaetning/instanser/tilfoej", new()
{
["server"] = @"sql01.firma.local\PROD", ["port"] = "1450", ["authentication"] = "Windows", ["encryption"] = "Mandatory",
});
Assert.Equal(HttpStatusCode.Redirect, posted.StatusCode);
var id = IdFrom(posted);
var row = (await _repository.GetConfiguredInstanceAsync(id))!;
Assert.Equal((@"sql01.firma.local\PROD", 1450, "Windows", false), (row.Name, row.Port, row.Authentication, row.MonitorNewDatabases));
Assert.Equal(AgentRequestStatus.Pending, (await _repository.GetLatestAgentRequestAsync(id, AgentRequestKind.ConnectionTest))!.Status);
Assert.NotNull(await _repository.GetLatestAgentRequestAsync(id, AgentRequestKind.DatabaseDiscovery));
var waiting = await client.GetStringAsync($"/opsaetning/instanser/{id}?tilfoejet=1");
Assert.Contains("Venter på agenten", waiting, StringComparison.Ordinal);
Assert.Contains("http-equiv=\"refresh\"", waiting, StringComparison.Ordinal);
// The agent has published who it is: the script is for the computer account it arrives as on another server.
await PublishAgentKeyAsync();
var page = await client.GetStringAsync($"/opsaetning/instanser/{id}");
Assert.Contains(@"SET @LoginName = N&#x27;FIRMA\AGENT01$&#x27;;", page, StringComparison.Ordinal);
Assert.Contains(@"CREATE LOGIN [FIRMA\AGENT01$] FROM WINDOWS", page, StringComparison.Ordinal);
var duplicate = await PostAsync(client, "/opsaetning/instanser", "/opsaetning/instanser/tilfoej", new() { ["server"] = @"sql01.firma.local\PROD", ["authentication"] = "Windows" });
Assert.Contains("bruges allerede", Uri.UnescapeDataString(duplicate.Headers.Location!.OriginalString), StringComparison.Ordinal);
}
[Fact]
public async Task A_sql_login_password_is_sealed_for_the_agent_and_never_shown_again()
{
var client = Client();
var fields = new Dictionary<string, string>
{
["server"] = "sql02", ["authentication"] = "SqlLogin", ["userName"] = "sqlinsight", ["password"] = Password, ["trustServerCertificate"] = "true",
};
var withoutAgent = await PostAsync(client, "/opsaetning/instanser", "/opsaetning/instanser/tilfoej", fields);
Assert.Contains("Agenten har ikke meldt sig endnu", Uri.UnescapeDataString(withoutAgent.Headers.Location!.OriginalString), StringComparison.Ordinal);
Assert.Empty(await _repository.GetConfiguredInstancesAsync());
await PublishAgentKeyAsync();
var id = IdFrom(await PostAsync(client, "/opsaetning/instanser", "/opsaetning/instanser/tilfoej", fields));
var row = (await _repository.GetConfiguredInstanceAsync(id))!;
Assert.True(row.HasPassword);
Assert.Equal(Password, CredentialEnvelope.Open(row.PasswordCipher!, "agent-test", _agentKey, CredentialEnvelope.Context("sql02", "sqlinsight")));
var page = await client.GetStringAsync($"/opsaetning/instanser/{id}");
var api = await client.GetStringAsync($"/api/v1/monitored-instances/{id}");
Assert.DoesNotContain(Password, page, StringComparison.Ordinal);
Assert.Contains("adgangskode gemt krypteret", page, StringComparison.Ordinal);
Assert.DoesNotContain(Password, api, StringComparison.Ordinal);
Assert.DoesNotContain("cipher", api, StringComparison.OrdinalIgnoreCase);
Assert.True(JsonDocument.Parse(api).RootElement.GetProperty("hasPassword").GetBoolean());
// Another server without the password again is refused: the password is sealed to sql02.
var moved = await PostAsync(client, $"/opsaetning/instanser/{id}", $"/opsaetning/instanser/{id}/forbindelse", new()
{
["server"] = "evil01", ["authentication"] = "SqlLogin", ["userName"] = "sqlinsight",
});
Assert.Contains("Indtast adgangskoden igen", Uri.UnescapeDataString(moved.Headers.Location!.OriginalString), StringComparison.Ordinal);
Assert.Equal("sql02", (await _repository.GetConfiguredInstanceAsync(id))!.Server);
}
[Fact]
public async Task Connection_test_shows_the_missing_rights_and_the_script_for_the_login_the_agent_used()
{
var id = await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance("prod", "sql01", null, "Windows", null, null, null, "Mandatory", false, false), null, DateTimeOffset.UtcNow);
var request = await _repository.AddAgentRequestAsync(AgentRequestKind.ConnectionTest, id, null, "anna", DateTimeOffset.UtcNow);
await _repository.ClaimAgentRequestAsync(DateTimeOffset.UtcNow);
await _repository.CompleteAgentRequestAsync(request, true, AgentRequestJson.Serialize(new ConnectionTestResult
{
Connected = true, Provider = "SqlClient", ProductVersion = "16.0.4135.4", Release = "SQL Server 2022", Edition = "Standard Edition", ServerName = "SQL01",
Login = @"FIRMA\AGENT01$", PermissionsChecked = true,
Permissions =
[
new PermissionStatus("VIEW SERVER PERFORMANCE STATE", false, true, "DMV'er"),
new PermissionStatus("VIEW ANY DEFINITION", true, true, "Metadata"),
new PermissionStatus("ALTER TRACE", false, false, "Valgfri"),
],
}), null, DateTimeOffset.UtcNow);
var page = await Client().GetStringAsync($"/opsaetning/instanser/{id}");
var api = JsonDocument.Parse(await Client().GetStringAsync($"/api/v1/monitored-instances/{id}/connection-test")).RootElement;
Assert.Contains("Agenten mangler 1 rettighed", page, StringComparison.Ordinal);
Assert.Contains("VIEW SERVER PERFORMANCE STATE", page, StringComparison.Ordinal);
Assert.Contains("Valgfri, mangler", page, StringComparison.Ordinal);
Assert.Contains(@"SET @LoginName = N&#x27;FIRMA\AGENT01$&#x27;;", page, StringComparison.Ordinal);
Assert.DoesNotContain("http-equiv=\"refresh\"", page, StringComparison.Ordinal);
Assert.Equal(["VIEW SERVER PERFORMANCE STATE"], api.GetProperty("missingPermissions").EnumerateArray().Select(e => e.GetString()));
Assert.True(api.GetProperty("request").GetProperty("result").GetProperty("connected").GetBoolean());
}
[Fact]
public async Task Databases_and_ax_findings_to_the_hub_are_chosen_with_the_form()
{
var id = await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance("ax", "ax01", null, "Windows", null, null, null, "Mandatory", false, false), null, DateTimeOffset.UtcNow);
await _repository.SaveDiscoveredDatabasesAsync(id,
[
new DiscoveredDatabase { Name = "master", IsSystem = true, HasAccess = true, State = "ONLINE" },
new DiscoveredDatabase { Name = "AX30", HasAccess = true, State = "ONLINE", IsDynamicsAx = true, DynamicsAxVersion = "3.0", SizeMb = 20480, RecoveryModel = "FULL", CompatibilityLevel = 90 },
new DiscoveredDatabase { Name = "Sales", HasAccess = true, State = "ONLINE" },
], DateTimeOffset.UtcNow);
var page = $"/opsaetning/instanser/{id}";
var html = await Client().GetStringAsync(page);
Assert.Contains("Ja, AX 3.0", html, StringComparison.Ordinal);
Assert.Contains("20,0 GB", html, StringComparison.Ordinal);
var posted = await PostAsync(Client(), page, page + "/databaser", new() { ["monitorNew"] = "true" }, ("monitor", "AX30"), ("ax", "AX30"), ("ax", "Sales"));
Assert.Equal(HttpStatusCode.Redirect, posted.StatusCode);
var databases = (await _repository.GetConfiguredDatabasesAsync(id)).ToDictionary(d => d.DatabaseName);
Assert.Equal((true, true), (databases["AX30"].Monitored, databases["AX30"].SendDynamicsAxToHub));
Assert.Equal((false, false), (databases["Sales"].Monitored, databases["Sales"].SendDynamicsAxToHub));
Assert.True((await _repository.GetConfiguredInstanceAsync(id))!.MonitorNewDatabases);
}
[Fact]
public async Task A_configuration_file_server_is_paused_here_but_removed_in_the_file()
{
var id = (await _repository.SyncConfigFileInstancesAsync([new ConfigFileInstance("prod-sql01", "sql01", "Windows", null, "Mandatory", false)], DateTimeOffset.UtcNow))["prod-sql01"];
var page = $"/opsaetning/instanser/{id}";
var removed = await PostAsync(Client(), page, page + "/fjern", []);
var paused = await PostAsync(Client(), page, page + "/pause", new() { ["paused"] = "true" });
Assert.Contains("konfigurationsfil", Uri.UnescapeDataString(removed.Headers.Location!.OriginalString), StringComparison.Ordinal);
Assert.Equal($"{page}?pause=1", paused.Headers.Location!.OriginalString);
Assert.True((await _repository.GetConfiguredInstanceAsync(id))!.Paused);
Assert.DoesNotContain("Fjern serveren", await Client().GetStringAsync(page), StringComparison.Ordinal);
}
[Fact]
public async Task A_form_without_the_antiforgery_token_is_refused()
{
using var content = new FormUrlEncodedContent(new Dictionary<string, string> { ["server"] = "sql09" });
var response = await Client().PostAsync("/opsaetning/instanser/tilfoej", content);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Empty(await _repository.GetConfiguredInstancesAsync());
}
[Fact]
public void Only_admin_groups_and_config_write_tokens_may_change_the_configuration()
{
var windows = new WebOptions { Authentication = "Windows" };
windows.AdminGroups.Add(@"FIRMA\SQL-Admins");
static ClaimsPrincipal User(params Claim[] claims) => new(new ClaimsIdentity(claims, "Negotiate", ClaimTypes.Name, ClaimTypes.Role));
Assert.True(InstanceSetup.MayChange(User(new Claim(ClaimTypes.Role, @"FIRMA\SQL-Admins")), windows));
Assert.False(InstanceSetup.MayChange(User(new Claim(ClaimTypes.Role, @"FIRMA\SQL-Readers")), windows));
Assert.False(InstanceSetup.MayChange(User(new Claim(ClaimTypes.Role, @"FIRMA\SQL-Admins")), new WebOptions { Authentication = "Windows" }));
Assert.True(InstanceSetup.MayChange(User(), new WebOptions { Authentication = "None" }));
Assert.True(InstanceSetup.MayChange(User(new Claim(ApiTokenHandler.ClaimType, "itsm"), new Claim(ApiTokenHandler.ScopeClaimType, ApiToken.ConfigWrite)), windows));
Assert.False(InstanceSetup.MayChange(User(new Claim(ApiTokenHandler.ClaimType, "grafana"), new Claim(ApiTokenHandler.ScopeClaimType, ApiToken.Read),
new Claim(ClaimTypes.Role, @"FIRMA\SQL-Admins")), windows));
}
}