diff --git a/src/SQLInsight.Web/Api/ApiV1Endpoints.cs b/src/SQLInsight.Web/Api/ApiV1Endpoints.cs index 004123f..5d87fdd 100644 --- a/src/SQLInsight.Web/Api/ApiV1Endpoints.cs +++ b/src/SQLInsight.Web/Api/ApiV1Endpoints.cs @@ -170,7 +170,8 @@ public static class ApiV1Endpoints private static async Task RulesAsync(InsightRepository repository, CancellationToken ct) { - var builtIn = Rules.BuiltInRules.Create().Select(r => (r.Id, r.Title, Source: "indbygget")); + var builtIn = Rules.BuiltInRules.Create().Select(r => (r.Id, r.Title, Source: "indbygget")) + .Concat(Rules.Servers.ServerRuleCatalog.All.Select(r => (r.Id, r.Title, Source: "indbygget"))); var community = Rules.Lifecycle.RulePack.Embedded().CommunityRules.Select(r => (r.Id, r.Title, Source: "community")); var seen = (await repository.GetAllRuleStatesAsync(ct).ConfigureAwait(false)).Select(s => s.RuleId); var known = builtIn.Concat(community).ToList(); diff --git a/src/SQLInsight.Web/Api/ServerApiEndpoints.cs b/src/SQLInsight.Web/Api/ServerApiEndpoints.cs new file mode 100644 index 0000000..699166e --- /dev/null +++ b/src/SQLInsight.Web/Api/ServerApiEndpoints.cs @@ -0,0 +1,577 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using SQLInsight.Core.Configuration; +using SQLInsight.Core.Findings; +using SQLInsight.Core.Servers; +using SQLInsight.Core.SyntheticChecks; +using SQLInsight.Repository; + +namespace SQLInsight.Web.Api; + +/// Body of POST /api/v1/windows-servers. +public sealed record WindowsServerBody(string? Name, string? Host, string? Role, IReadOnlyList? WatchedServices, IReadOnlyList? IgnoredServices); + +/// Body of PATCH /api/v1/windows-servers/{id}. Omitted values are left as they are. +public sealed record WindowsServerPatch(bool? Paused, string? Role, IReadOnlyList? WatchedServices, IReadOnlyList? IgnoredServices); + +/// Body of POST and PUT /api/v1/synthetic-checks. The password is write-only (SQL checks with a SQL login). +public sealed record SyntheticCheckBody( + string? Name, + string? Kind, + int? ServerId, + int? IntervalSeconds, + int? TimeoutSeconds, + SyntheticCheckSettings? Settings, + string? Password); + +/// Body of PATCH /api/v1/synthetic-checks/{id}. +public sealed record SyntheticCheckPatch(bool? Paused); + +/// +/// Windows servers and synthetic checks through the API: everything the pages Windows-servere and Syntetiske tjek +/// show and do. Reading needs the scope read; changing needs config:write for tokens, or SQLInsight:Web:AdminGroups for +/// Windows users. Tests and check runs are queued for the agent (202 with a request id for /api/v1/agent-requests/{id}). +/// Passwords are write-only. Event messages, failure messages and evidence marked sensitive are "[udeladt]" unless +/// SQLInsight:Web:ApiIncludesSensitiveEvidence is on. +/// +public static class ServerApiEndpoints +{ + 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("/windows-servers", ListServersAsync).WithSummary("Windows-servere med status / Windows servers"); + api.MapPost("/windows-servers", AddServerAsync).WithSummary("Tilføj en Windows-server / Add a Windows server") + .WithDescription("201 Created. The agent tests the connection and rights at once (serverTestRequestId)." + write); + api.MapGet("/windows-servers/{id:int}", GetServerAsync).WithSummary("Én Windows-server / One Windows server"); + api.MapPatch("/windows-servers/{id:int}", PatchServerAsync) + .WithSummary("Pause, rolle, overvågede og ignorerede tjenester / Pause, role, watched and ignored services").WithDescription(write.Trim()); + api.MapDelete("/windows-servers/{id:int}", RemoveServerAsync).WithSummary("Stop overvågningen af en server / Remove a Windows server") + .WithDescription("204. Collected data is kept. 409 for a SQL Server host (it follows its instances; pause it instead)." + write); + api.MapPost("/windows-servers/{id:int}/test", TestServerAsync).WithSummary("Test forbindelse og rettigheder fra agenten / Test the connection and rights from the agent") + .WithDescription("202 Accepted with requestId." + write); + api.MapGet("/windows-servers/{id:int}/test", LatestTestAsync).WithSummary("Seneste test og manglende rettigheder / Latest test and missing rights"); + api.MapGet("/windows-servers/{id:int}/grant-script", GrantScriptAsync) + .WithSummary("PowerShell-script med least-privilege-rettigheder til agentens konto / Least-privilege grant script for the agent's account"); + api.MapGet("/windows-servers/{id:int}/performance", PerformanceAsync).WithSummary("CPU og hukommelse pr. minut (hours, standard 24) / CPU and memory per minute"); + api.MapGet("/windows-servers/{id:int}/disks", DisksAsync).WithSummary("Diske med ledig plads og prognose / Disks with free space and forecast"); + api.MapGet("/windows-servers/{id:int}/services", ServicesAsync).WithSummary("Tjenester / Services"); + api.MapGet("/windows-servers/{id:int}/events", EventsAsync).WithSummary("Grupperede fejl fra hændelsesloggen (days, standard 1) / Grouped event log errors"); + api.MapGet("/windows-servers/{id:int}/tasks", TasksAsync).WithSummary("Overvågede planlagte opgaver / Watched scheduled tasks"); + api.MapGet("/windows-servers/{id:int}/rule-states", (int id, InsightRepository repository, CancellationToken ct) => RuleStatesAsync(FindingScope.Server, id, repository, ct)) + .WithSummary("Reglernes udfald på serveren / Rule outcomes on the server"); + api.MapGet("/server-findings", FindingsAsync) + .WithSummary("Fund på Windows-servere og syntetiske tjek / Findings on Windows servers and synthetic checks") + .WithDescription("status=active|resolved|all (default active), scope=server|check, id (with scope)."); + api.MapGet("/server-findings/{id:long}", FindingAsync).WithSummary("Ét fund på en server eller et tjek / One server or check finding"); + + api.MapGet("/synthetic-checks", ListChecksAsync).WithSummary("Syntetiske tjek med seneste resultat / Synthetic checks"); + api.MapPost("/synthetic-checks", AddCheckAsync).WithSummary("Tilføj et syntetisk tjek / Add a synthetic check") + .WithDescription("201 Created; the agent runs it at once (runRequestId). Kinds: Tcp, Http, Sql, Dns, AxLogon." + write); + api.MapGet("/synthetic-checks/{id:int}", GetCheckAsync).WithSummary("Ét syntetisk tjek / One synthetic check"); + api.MapPut("/synthetic-checks/{id:int}", UpdateCheckAsync).WithSummary("Ret et syntetisk tjek / Change a synthetic check") + .WithDescription("The kind cannot change. A SQL login password must be given again when server, port or login change." + write); + api.MapPatch("/synthetic-checks/{id:int}", PatchCheckAsync).WithSummary("Sæt på pause eller genoptag / Pause or resume").WithDescription(write.Trim()); + api.MapDelete("/synthetic-checks/{id:int}", RemoveCheckAsync).WithSummary("Fjern et syntetisk tjek / Remove a synthetic check").WithDescription("204." + write); + api.MapPost("/synthetic-checks/{id:int}/run", RunCheckAsync).WithSummary("Kør tjekket nu fra agenten / Run the check now from the agent") + .WithDescription("202 Accepted with requestId." + write); + api.MapGet("/synthetic-checks/{id:int}/results", ResultsAsync).WithSummary("Seneste kørsler (hours, standard 24) / Latest runs"); + api.MapGet("/synthetic-checks/{id:int}/rule-states", (int id, InsightRepository repository, CancellationToken ct) => RuleStatesAsync(FindingScope.Check, id, repository, ct)) + .WithSummary("Reglernes udfald på tjekket / Rule outcomes on the check"); + } + + 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 string? Redact(string? value, IOptions options, bool sensitive = true) => + sensitive && !options.Value.ApiIncludesSensitiveEvidence && value is not null ? ApiV1Endpoints.Redacted : value; + + private static object Json(WindowsServerRow s) => new + { + Id = s.ServerId, + s.Name, + s.Host, + s.Role, + Source = s.IsSqlHost ? "sqlHost" : "web", + s.Paused, + WatchedServices = s.Watched, + IgnoredServices = s.Ignored, + s.Reachable, + s.LastContactAt, + s.LastErrorAt, + s.LastError, + s.Protocol, + s.OperatingSystem, + s.OsVersion, + s.LastBootUpTime, + s.TotalMemoryMb, + s.LogicalProcessors, + Areas = s.Areas.Select(a => new { a.Area, a.Ok, a.Detail, a.Right }), + s.CreatedAt, + s.CreatedBy, + s.UpdatedAt, + s.UpdatedBy, + }; + + private static object Json(SyntheticCheckRow c, IOptions options) => new + { + Id = c.CheckId, + c.Name, + c.Kind, + c.ServerId, + c.ServerName, + Target = c.Settings.Target(c.Kind), + c.IntervalSeconds, + c.TimeoutSeconds, + c.Paused, + Settings = c.Settings, + HasPassword = c.HasSecret, + c.SecretError, + c.LastRunAt, + c.LastSuccess, + c.LastDurationMs, + LastMessage = c.LastSuccess == false ? Redact(c.LastMessage, options) : c.LastMessage, + c.ConsecutiveFailures, + c.CreatedAt, + c.CreatedBy, + c.UpdatedAt, + c.UpdatedBy, + }; + + private static object Json(StoredServerFinding f, IOptions options) => new + { + Id = f.FindingId, + Scope = f.Scope == FindingScope.Server ? "server" : "check", + ScopeId = f.ScopeId, + Name = f.ScopeName, + f.RuleId, + f.Subject, + Severity = f.Severity.ToString(), + f.Title, + f.Explanation, + f.Recommendation, + Evidence = f.Evidence.Select(e => new { e.Name, Value = Redact(e.Value, options, e.Sensitive) }), + References = f.References.Select(r => new { r.Title, r.Url }), + f.FirstSeenAt, + f.LastSeenAt, + f.ResolvedAt, + }; + + private static object Json(AgentRequestRow r) => new + { + Id = r.RequestId, + r.Kind, + 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, + }; + + // ---- Windows servers -------------------------------------------------------------------------------------- + + private static async Task ListServersAsync(InsightRepository repository, CancellationToken ct) => + Results.Ok((await repository.GetWindowsServersAsync(ct).ConfigureAwait(false)).Select(Json)); + + private static async Task GetServerAsync(int id, InsightRepository repository, CancellationToken ct) => + await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is { } row ? Results.Ok(Json(row)) : Results.NotFound(); + + private static async Task AddServerAsync(WindowsServerBody body, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) + { + if (!InstanceSetup.MayChange(http.User, options.Value)) + { + return Forbidden(); + } + + var outcome = await ServerSetup.AddServerAsync(repository, new ServerInput(body.Name, body.Host, body.Role, body.WatchedServices, body.IgnoredServices), + http.User.Identity?.Name, ct).ConfigureAwait(false); + if (outcome.Error is { } error) + { + return Invalid(error); + } + + var row = (await repository.GetWindowsServerAsync(outcome.Id!.Value, ct).ConfigureAwait(false))!; + return Results.Created($"/api/v1/windows-servers/{row.ServerId}", new { Server = Json(row), ServerTestRequestId = outcome.RequestId }); + } + + private static async Task PatchServerAsync(int id, WindowsServerPatch body, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) + { + if (!InstanceSetup.MayChange(http.User, options.Value)) + { + return Forbidden(); + } + + var by = http.User.Identity?.Name; + var outcome = await ServerSetup.UpdateServerAsync(repository, id, new ServerInput(null, null, body.Role, body.WatchedServices, body.IgnoredServices), by, ct) + .ConfigureAwait(false); + if (outcome.NotFound) + { + return Results.NotFound(); + } + + if (outcome.Error is { } error) + { + return Invalid(error); + } + + if (body.Paused is { } paused) + { + await repository.SetWindowsServerPausedAsync(id, paused, by, DateTimeOffset.UtcNow, ct).ConfigureAwait(false); + } + + return Results.Ok(Json((await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false))!)); + } + + private static async Task RemoveServerAsync(int id, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) + { + if (!InstanceSetup.MayChange(http.User, options.Value)) + { + return Forbidden(); + } + + return await repository.RemoveWindowsServerAsync(id, http.User.Identity?.Name, DateTimeOffset.UtcNow, ct).ConfigureAwait(false) switch + { + ConfigurationChangeOutcome.Changed => Results.NoContent(), + ConfigurationChangeOutcome.FromConfigFile => Results.Problem(statusCode: StatusCodes.Status409Conflict, title: "SQL Server-host", + detail: "Serveren følger de overvågede SQL Server-instanser og fjernes sammen med dem. Den kan sættes på pause med PATCH."), + _ => Results.NotFound(), + }; + } + + private static async Task TestServerAsync(int id, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) + { + if (!InstanceSetup.MayChange(http.User, options.Value)) + { + return Forbidden(); + } + + var outcome = await ServerSetup.RequestServerTestAsync(repository, id, 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 LatestTestAsync(int id, InsightRepository repository, CancellationToken ct) + { + if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is null) + { + return Results.NotFound(); + } + + var test = await repository.GetLatestServerRequestAsync(ServerRequestKind.ServerTest, id, ct).ConfigureAwait(false); + if (test is null) + { + return Results.NotFound(); + } + + var result = AgentRequestJson.Deserialize(test.Result); + return Results.Ok(new { Request = Json(test), MissingAreas = result?.Missing.Select(a => new { a.Area, a.Right, a.Detail }).ToList() ?? [] }); + } + + private static async Task GrantScriptAsync(int id, InsightRepository repository, CancellationToken ct) + { + if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is not { } row) + { + return Results.NotFound(); + } + + var (account, script, note) = await ServerSetup.GrantScriptAsync(repository, row, ct).ConfigureAwait(false); + return Results.Ok(new { Account = account, Script = script, Note = note }); + } + + private static async Task PerformanceAsync(int id, InsightRepository repository, CancellationToken ct, int? hours = null) + { + if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is null) + { + return Results.NotFound(); + } + + var from = DateTimeOffset.UtcNow.AddHours(-Math.Clamp(hours ?? 24, 1, 24 * 31)); + return Results.Ok(await repository.GetServerPerformanceAsync(id, from, ct).ConfigureAwait(false)); + } + + private static async Task DisksAsync(int id, InsightRepository repository, CancellationToken ct) + { + if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is null) + { + return Results.NotFound(); + } + + var snapshot = await repository.GetServerSnapshotAsync(id, ServerArea.Disks, ct).ConfigureAwait(false); + if (snapshot is null) + { + return Results.Ok(new { CapturedAt = (DateTimeOffset?)null, Disks = Array.Empty() }); + } + + return Results.Ok(new + { + snapshot.CapturedAt, + Disks = snapshot.Disks.Select(d => + { + var forecast = snapshot.Forecasts.FirstOrDefault(f => string.Equals(f.Volume, d.Volume, StringComparison.OrdinalIgnoreCase)); + return new + { + d.Volume, + d.Label, + d.FileSystem, + d.SizeGb, + d.FreeGb, + d.FreePercent, + Forecast = forecast is null ? null : new + { + forecast.DaysUntilFull, + FullAt = forecast.FullAt(snapshot.CapturedAt), + forecast.GrowthGbPerDay, + forecast.Samples, + forecast.HistoryDays, + forecast.Fit, + forecast.Note, + Text = forecast.Describe(), + }, + }; + }), + }); + } + + private static async Task ServicesAsync(int id, InsightRepository repository, CancellationToken ct) + { + if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is not { } row) + { + return Results.NotFound(); + } + + var snapshot = await repository.GetServerSnapshotAsync(id, ServerArea.Services, ct).ConfigureAwait(false); + return Results.Ok(new + { + CapturedAt = snapshot?.CapturedAt, + WatchedServices = row.Watched, + IgnoredServices = row.Ignored, + Services = snapshot?.Services.Select(s => new { s.Name, s.DisplayName, s.State, s.StartMode, s.DelayedAutoStart, s.ExitCode }) ?? [], + }); + } + + private static async Task EventsAsync(int id, InsightRepository repository, IOptions options, CancellationToken ct, int? days = null) + { + if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is null) + { + return Results.NotFound(); + } + + var now = DateTimeOffset.UtcNow; + var events = await repository.GetServerEventsAsync(id, now.AddDays(-Math.Clamp(days ?? 1, 1, 90)), now, cancellationToken: ct).ConfigureAwait(false); + return Results.Ok(events.Select(e => new + { + e.CollectedAt, + Log = e.LogName, + e.Source, + e.EventId, + e.Level, + e.Category, + e.Count, + e.FirstAt, + e.LastAt, + Sample = Redact(e.Sample, options), + })); + } + + private static async Task TasksAsync(int id, InsightRepository repository, CancellationToken ct) + { + if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is null) + { + return Results.NotFound(); + } + + var snapshot = await repository.GetServerSnapshotAsync(id, ServerArea.Tasks, ct).ConfigureAwait(false); + return Results.Ok(new + { + CapturedAt = snapshot?.CapturedAt, + Visible = snapshot?.Visible ?? 0, + snapshot?.Coverage, + Tasks = snapshot?.Tasks.Select(t => new + { + Path = t.FullName, + t.State, + t.LastRunTime, + LastTaskResult = t.LastTaskResult is { } r ? TaskResults.Hex(r) : null, + LastResultText = t.LastTaskResult is { } text ? TaskResults.Describe(text) : null, + Failed = TaskResults.IsFailure(t.LastTaskResult), + t.NextRunTime, + t.MissedRuns, + }) ?? [], + }); + } + + private static async Task RuleStatesAsync(string scope, int id, InsightRepository repository, CancellationToken ct) + { + var exists = scope == FindingScope.Server + ? await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is not null + : await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false) is not null; + if (!exists) + { + return Results.NotFound(); + } + + return Results.Ok((await repository.GetServerRuleStatesAsync(scope, id, ct).ConfigureAwait(false)).Select(s => new + { + s.RuleId, + s.RuleTitle, + Outcome = char.ToLowerInvariant(s.Outcome.ToString()[0]) + s.Outcome.ToString()[1..], + s.Reason, + s.EvaluatedAt, + })); + } + + private static async Task FindingsAsync(InsightRepository repository, IOptions options, CancellationToken ct, string? status = null, string? scope = null, + int? id = null) + { + var findingStatus = status?.ToLowerInvariant() switch + { + null or "" or "active" => (FindingStatus?)FindingStatus.Active, + "resolved" => FindingStatus.Resolved, + "all" => FindingStatus.All, + _ => null, + }; + var findingScope = scope?.ToLowerInvariant() switch + { + null or "" => (string?)null, + "server" => FindingScope.Server, + "check" => FindingScope.Check, + _ => "?", + }; + if (findingStatus is null || findingScope == "?" || (id is not null && findingScope is null)) + { + return Results.BadRequest("status skal være active, resolved eller all; scope skal være server eller check (og angives sammen med id)."); + } + + var findings = await repository.GetServerFindingsAsync(findingScope, id, findingStatus.Value, cancellationToken: ct).ConfigureAwait(false); + return Results.Ok(findings.Select(f => Json(f, options))); + } + + private static async Task FindingAsync(long id, InsightRepository repository, IOptions options, CancellationToken ct) => + await repository.GetServerFindingAsync(id, ct).ConfigureAwait(false) is { } finding ? Results.Ok(Json(finding, options)) : Results.NotFound(); + + // ---- Synthetic checks ------------------------------------------------------------------------------------- + + private static CheckInput Input(SyntheticCheckBody body) => + new(body.Name, body.Kind, body.ServerId, body.Settings ?? new SyntheticCheckSettings(), body.Password, body.IntervalSeconds, body.TimeoutSeconds); + + private static async Task ListChecksAsync(InsightRepository repository, IOptions options, CancellationToken ct) => + Results.Ok((await repository.GetSyntheticChecksAsync(ct).ConfigureAwait(false)).Select(c => Json(c, options))); + + private static async Task GetCheckAsync(int id, InsightRepository repository, IOptions options, CancellationToken ct) => + await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false) is { } row ? Results.Ok(Json(row, options)) : Results.NotFound(); + + private static async Task AddCheckAsync(SyntheticCheckBody body, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) + { + if (!InstanceSetup.MayChange(http.User, options.Value)) + { + return Forbidden(); + } + + var outcome = await ServerSetup.AddCheckAsync(repository, Input(body), http.User.Identity?.Name, ct).ConfigureAwait(false); + if (outcome.Error is { } error) + { + return Invalid(error); + } + + var row = (await repository.GetSyntheticCheckAsync(outcome.Id!.Value, ct).ConfigureAwait(false))!; + return Results.Created($"/api/v1/synthetic-checks/{row.CheckId}", new { Check = Json(row, options), RunRequestId = outcome.RequestId }); + } + + private static async Task UpdateCheckAsync(int id, SyntheticCheckBody body, HttpContext http, InsightRepository repository, IOptions options, + CancellationToken ct) + { + if (!InstanceSetup.MayChange(http.User, options.Value)) + { + return Forbidden(); + } + + var outcome = await ServerSetup.UpdateCheckAsync(repository, id, Input(body), http.User.Identity?.Name, ct).ConfigureAwait(false); + if (outcome.NotFound) + { + return Results.NotFound(); + } + + return outcome.Error is { } error ? Invalid(error) : Results.Ok(Json((await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false))!, options)); + } + + private static async Task PatchCheckAsync(int id, SyntheticCheckPatch body, HttpContext http, InsightRepository repository, IOptions options, + CancellationToken ct) + { + if (!InstanceSetup.MayChange(http.User, options.Value)) + { + return Forbidden(); + } + + if (await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false) is null) + { + return Results.NotFound(); + } + + if (body.Paused is { } paused) + { + await repository.SetSyntheticCheckPausedAsync(id, paused, http.User.Identity?.Name, DateTimeOffset.UtcNow, ct).ConfigureAwait(false); + } + + return Results.Ok(Json((await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false))!, options)); + } + + private static async Task RemoveCheckAsync(int id, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) + { + if (!InstanceSetup.MayChange(http.User, options.Value)) + { + return Forbidden(); + } + + return await repository.RemoveSyntheticCheckAsync(id, http.User.Identity?.Name, DateTimeOffset.UtcNow, ct).ConfigureAwait(false) ? Results.NoContent() : Results.NotFound(); + } + + private static async Task RunCheckAsync(int id, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) + { + if (!InstanceSetup.MayChange(http.User, options.Value)) + { + return Forbidden(); + } + + var outcome = await ServerSetup.RequestCheckRunAsync(repository, id, 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 ResultsAsync(int id, InsightRepository repository, IOptions options, CancellationToken ct, int? hours = null) + { + if (await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false) is null) + { + return Results.NotFound(); + } + + var results = await repository.GetSyntheticCheckResultsAsync(id, 5000, DateTimeOffset.UtcNow.AddHours(-Math.Clamp(hours ?? 24, 1, 24 * 31)), ct).ConfigureAwait(false); + return Results.Ok(results.Select(r => new + { + r.RunAt, + r.Success, + r.DurationMs, + Message = r.Success ? r.Message : Redact(r.Message, options), + Details = r.Details.Select(d => new { d.Name, Value = Redact(d.Value, options, d.Sensitive) }), + r.CertificateExpiresAt, + r.CertificateSubject, + r.ServiceState, + r.Partial, + })); + } +} diff --git a/src/SQLInsight.Web/Components/Layout/MainNav.razor b/src/SQLInsight.Web/Components/Layout/MainNav.razor index 8e7fd61..661a4dc 100644 --- a/src/SQLInsight.Web/Components/Layout/MainNav.razor +++ b/src/SQLInsight.Web/Components/Layout/MainNav.razor @@ -45,6 +45,11 @@ new("Fejllog", "/vaelg/fejllog", "/fejllog"), new("Servere og databaser", "/opsaetning/instanser", "/opsaetning/"), ]), + new("servere", "Servere", "gauge", + [ + new("Windows-servere", "/servere"), + new("Syntetiske tjek", "/syntetiske-tjek"), + ]), new("analyse", "Analyse", "chart", [ new("Analyse af tidsrum", "/vaelg/analyse", "/analyse", "/sql"), diff --git a/src/SQLInsight.Web/Components/Pages/ServerFindingDetail.razor b/src/SQLInsight.Web/Components/Pages/ServerFindingDetail.razor new file mode 100644 index 0000000..a0e6e3e --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/ServerFindingDetail.razor @@ -0,0 +1,107 @@ +@page "/servere/fund/{Id:long}" +@inject InsightRepository Repository +@inject Display Display + +@if (_finding is null) +{ + Ukendt fund · SQLInsight Pro +

Ukendt fund

+ return; +} + +@_finding.Title · SQLInsight Pro + +

+ @if (_finding.Scope == FindingScope.Server) + { + Windows-servere / @_finding.ScopeName + } + else + { + Syntetiske tjek / @_finding.ScopeName + } + / @_finding.RuleId +

+

@_finding.Title

+

+ @if (_finding.ResolvedAt is { } resolved) + { + Løst @Display.Time(resolved) · + } + Set første gang @Display.Time(_finding.FirstSeenAt) · senest @Display.Time(_finding.LastSeenAt) + @if (!string.IsNullOrEmpty(_finding.Subject)) { · @_finding.Subject } +

+ +
+

Hvorfor er det et problem?

+

@_finding.Explanation

+
+ +@if (_finding.Evidence.Count > 0) +{ +
+

Målinger

+ + @foreach (var e in _finding.Evidence) + { + + } +
@e.Name@e.Value
+
+} + +
+

Hvad skal jeg gøre?

+

@_finding.Recommendation

+

SQLInsight ændrer aldrig noget på serveren: den starter ikke tjenester og rydder ikke op.

+
+ +@if (_history.Count > 0) +{ +
+

Tidligere

+ +
+} + +@if (_finding.References.Count > 0) +{ +
+

Læs mere

+
    + @foreach (var r in _finding.References) + { +
  • @r.Title
  • + } +
+
+} + +@code { + [Parameter] public long Id { get; set; } + [CascadingParameter] private HttpContext? Http { get; set; } + + private StoredServerFinding? _finding; + private IReadOnlyList _history = []; + + protected override async Task OnInitializedAsync() + { + _finding = await Repository.GetServerFindingAsync(Id); + if (_finding is null) + { + if (Http is not null) + { + Http.Response.StatusCode = StatusCodes.Status404NotFound; + } + + return; + } + + _history = await Repository.GetServerFindingHistoryAsync(_finding); + } +} diff --git a/src/SQLInsight.Web/Components/Pages/SyntheticCheckDetail.razor b/src/SQLInsight.Web/Components/Pages/SyntheticCheckDetail.razor new file mode 100644 index 0000000..b28c60e --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/SyntheticCheckDetail.razor @@ -0,0 +1,194 @@ +@page "/syntetiske-tjek/{Id:int}" +@using SQLInsight.Core.Configuration +@using SQLInsight.Core.Servers +@using SQLInsight.Core.SyntheticChecks +@using Microsoft.AspNetCore.Components.Forms +@inject InsightRepository Repository +@inject Display Display +@inject Microsoft.Extensions.Options.IOptions Options + +@if (_check is null) +{ + Ukendt tjek · SQLInsight Pro +

Tjekket findes ikke

+

Tilbage til syntetiske tjek

+ return; +} + +@_check.Name · Syntetiske tjek · SQLInsight Pro +@if (_run is { IsFinished: false }) +{ + +} + +

Syntetiske tjek › @_check.Name

+

@_check.Name

+

+ @SyntheticCheckKind.Display(_check.Kind) · @_settings.Target(_check.Kind) · + · hvert @(_check.IntervalSeconds / 60). minut, timeout @_check.TimeoutSeconds s + @if (_check.ServerId is { } serverId) + { + · @_check.ServerName@(_settings.ServiceName is { } svc ? " (" + svc + ")" : string.Empty) + } +

+ +@if (Tilfoejet == 1) +{ +

Tjekket er tilføjet, og agenten kører det nu. Resultatet vises herunder om et øjeblik.

+} +@if (Gemt == 1) +{ +

Tjekket er gemt. Agenten bruger ændringen ved næste kørsel.

+} +@if (Pause == 1) +{ +

Tjekket er sat på pause, og dets fund løses inden for en time.

+} +@if (Genoptaget == 1) +{ +

Tjekket er genoptaget.

+} +@if (!string.IsNullOrEmpty(Fejl)) +{ + +} +@if (_check.SecretError is { } secretError) +{ +
Agenten kan ikke bruge den gemte adgangskode.

@secretError

+} +@if (_run is { } run && Koersel is not null) +{ +

Kør nu: @InstanceSetup.StatusText(run, DateTime.UtcNow)@(run.Error is { } runError ? " – " + runError : string.Empty)

+} + +
+

Seneste resultat

+ @if (_results.Count == 0) + { +

Tjekket har ikke kørt endnu.

+ } + else + { + var latest = _results[0]; +

@(latest.Success ? "✓" : "✗") @latest.Message

+ @if (latest.Partial) + { +

Kun en del af tjekket kunne udføres (se ovenfor).

+ } + + + + @if (latest.ServiceState is { } state) + { + + } + @if (latest.CertificateExpiresAt is { } expires) + { + + } + @foreach (var d in latest.Details) + { + + } +
Tidspunkt@Display.Time(latest.RunAt)
Varighed@Display.Number(latest.DurationMs) ms
Tjenesten@ServerSetup.StateText(state)
Certifikatet udløber@Display.Time(expires) (@latest.CertificateSubject)
@d.Name@d.Value
+ } + @if (_mayChange) + { +
+ } +
+ +
+

Fund

+ +
+ +
+

Svartid (seneste døgn)

+ +

Seneste kørsler

+ @if (_results.Count == 0) + { +

Ingen.

+ } + else + { + + + + @foreach (var r in _results) + { + + + + + + + } + +
TidspunktResultatVarighedBesked
@Display.Time(r.RunAt)@if (r.Success) { OK } else { Fejl }@Display.Number(r.DurationMs) ms@r.Message
+ } +
+ +
+

Indstillinger

+ @if (_mayChange) + { +
+ Ret tjekket + +
+ + } +

Oprettet @Display.Time(_check.CreatedAt)@(_check.CreatedBy is { } by ? " af " + by : string.Empty) · senest ændret @Display.Time(_check.UpdatedAt)@(_check.UpdatedBy is { } updatedBy ? " af " + updatedBy : string.Empty)@(_check.Kind == SyntheticCheckKind.Sql && _settings.Authentication == "SqlLogin" ? (_check.HasSecret ? " · adgangskode gemt krypteret til agenten" : " · ingen adgangskode") : string.Empty).

+
+ +@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 = "koersel")] public long? Koersel { get; set; } + + private SyntheticCheckRow? _check; + private SyntheticCheckSettings _settings = new(); + private IReadOnlyList _results = []; + private IReadOnlyList _durations = []; + private IReadOnlyList _findings = []; + private IReadOnlyList _servers = []; + private AgentRequestRow? _run; + private bool _mayChange; + + protected override async Task OnInitializedAsync() + { + _check = await Repository.GetSyntheticCheckAsync(Id); + if (_check is null) + { + if (Http is not null) + { + Http.Response.StatusCode = StatusCodes.Status404NotFound; + } + + return; + } + + _settings = _check.Settings; + _results = await Repository.GetSyntheticCheckResultsAsync(Id, 50); + _durations = (await Repository.GetSyntheticCheckResultsAsync(Id, 2000, DateTimeOffset.UtcNow.AddDays(-1))) + .Where(r => r.Success).OrderBy(r => r.RunAt).Select(r => new MetricPoint(r.RunAt, r.DurationMs)).ToList(); + _findings = await Repository.GetServerFindingsAsync(FindingScope.Check, Id); + _servers = await Repository.GetWindowsServersAsync(); + _run = Koersel is not null || Tilfoejet == 1 ? await Repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, Id) : null; + _mayChange = Http is not null && InstanceSetup.MayChange(Http.User, Options.Value); + } +} diff --git a/src/SQLInsight.Web/Components/Pages/SyntheticChecksPage.razor b/src/SQLInsight.Web/Components/Pages/SyntheticChecksPage.razor new file mode 100644 index 0000000..01faeda --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/SyntheticChecksPage.razor @@ -0,0 +1,92 @@ +@page "/syntetiske-tjek" +@using SQLInsight.Core.SyntheticChecks +@inject InsightRepository Repository +@inject Display Display +@inject Microsoft.Extensions.Options.IOptions Options + +Syntetiske tjek · SQLInsight Pro + +

Syntetiske tjek

+

Agenten gør det, en bruger eller en applikation gør – forbinder til AOS-porten, henter en side, logger på SQL Server, slår et navn op, logger på Dynamics AX – med jævne mellemrum og med sin egen konto. Så opdages det, når en applikationsserver ikke svarer, selv om tjenesten kører.

+ +@if (Fjernet == 1) +{ +

Tjekket er fjernet. Resultaterne er bevaret, indtil de udløber.

+} +@if (!string.IsNullOrEmpty(Fejl)) +{ + +} +@if (!_mayChange) +{ +

Du kan se tjekkene, men kun administratorer kan tilføje og ændre dem (SQLInsight:Web:AdminGroups).

+} + +
+

Tjek

@_checks.Count(c => !c.Paused) aktive · Windows-servere
+ @if (_checks.Count == 0) + { +

Der er ingen syntetiske tjek endnu.@(_mayChange ? " Tilføj det første nedenfor." : string.Empty)

+ } + else + { + + + + @foreach (var c in _checks) + { + var counts = _counts.GetValueOrDefault((FindingScope.Check, c.CheckId)); + + + + + + + + + + } + +
TjekTypeMålStatusSvartidSenesteFund
@c.Name@if (c.ServerName is { } server) {
@server@(c.Settings.ServiceName is { } svc ? " · " + svc : string.Empty)
}
@SyntheticCheckKind.Display(c.Kind)@c.Settings.Target(c.Kind)@(c.LastDurationMs is { } ms ? Display.Number(ms) + " ms" : "–")@Display.Ago(c.LastRunAt) + @if (counts is null || counts.Count == 0) + { + + } + else + { + @foreach (var (severity, count) in counts.OrderByDescending(x => x.Key)) + { + + } + } +
+ } +
+ +@if (_mayChange) +{ +
+

Tilføj tjek

+

Udfyld felterne for den valgte type; de andre ignoreres. Efter tilføjelsen kører agenten tjekket med det samme.

+ +
+} + +@code { + [CascadingParameter] private HttpContext? Http { get; set; } + [SupplyParameterFromQuery(Name = "fejl")] public string? Fejl { get; set; } + [SupplyParameterFromQuery(Name = "fjernet")] public int? Fjernet { get; set; } + + private IReadOnlyList _checks = []; + private IReadOnlyList _servers = []; + private IReadOnlyDictionary<(string, int), IReadOnlyDictionary> _counts = new Dictionary<(string, int), IReadOnlyDictionary>(); + private bool _mayChange; + + protected override async Task OnInitializedAsync() + { + _checks = await Repository.GetSyntheticChecksAsync(); + _servers = await Repository.GetWindowsServersAsync(); + _counts = await Repository.GetServerFindingCountsAsync(); + _mayChange = Http is not null && InstanceSetup.MayChange(Http.User, Options.Value); + } +} diff --git a/src/SQLInsight.Web/Components/Pages/WindowsServerDetail.razor b/src/SQLInsight.Web/Components/Pages/WindowsServerDetail.razor new file mode 100644 index 0000000..a452d89 --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/WindowsServerDetail.razor @@ -0,0 +1,458 @@ +@page "/servere/{Id:int}" +@using SQLInsight.Core.Configuration +@using SQLInsight.Core.Servers +@using SQLInsight.Core.SyntheticChecks +@using Microsoft.AspNetCore.Components.Forms +@inject InsightRepository Repository +@inject Display Display +@inject Microsoft.Extensions.Options.IOptions Options + +@if (_server is null) +{ + Ukendt server · SQLInsight Pro +

Serveren findes ikke

+

Tilbage til Windows-servere

+ return; +} + +@_server.Name · Windows-servere · SQLInsight Pro +@if (_test is { IsFinished: false }) +{ + +} + +

Windows-servere › @_server.Name

+

@_server.Name

+

+ @_server.Host · @ServerRoles.Display(_server.Role)@(_server.IsSqlHost ? " (fra SQL Server-instanserne)" : string.Empty) · + + @if (_server.OperatingSystem is { } os) + { + · @os + } + @if (_server.LastBootUpTime is { } boot) + { + · startet @Display.Time(boot) + } + @if (_server.Protocol is { } protocol) + { + · læses over @protocol + } +

+ +@if (Tilfoejet == 1) +{ +

Serveren er tilføjet. Agenten tester nu forbindelsen og rettighederne; resultatet vises under Rettigheder om et øjeblik.

+} +@if (Gemt == 1) +{ +

Indstillingerne er gemt. Agenten bruger dem fra næste runde (inden for et minut).

+} +@if (Pause == 1) +{ +

Overvågningen er sat på pause. Agenten stopper inden for et minut, og serverens fund løses; det indsamlede er bevaret.

+} +@if (Genoptaget == 1) +{ +

Overvågningen er genoptaget.

+} +@if (!string.IsNullOrEmpty(Fejl)) +{ + +} +@if (!_server.Reachable && _server.LastError is { } lastError) +{ +
Agenten kunne ikke læse serveren @Display.Time(_server.LastErrorAt).

@lastError

+} + +
+

Fund

+ + @{ + var notMeasured = _states.Where(s => s.Outcome is RuleOutcome.NotMeasurable or RuleOutcome.Error).ToList(); + } + @if (notMeasured.Count > 0) + { +

Ikke målbart

+ + + + @foreach (var s in notMeasured) + { + + } + +
RegelHvorfor
@s.RuleId @s.RuleTitle@s.Reason
+ } +
+ +
+

CPU og hukommelse (seneste døgn)

+ + + @if (_server.TotalMemoryMb is { } total) + { +

@Display.Number(total / 1024, 1) GB hukommelse · @(_server.LogicalProcessors?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "–") logiske processorer.

+ } +
+ +
+

Diske

+ @if (_disks is null || _disks.Disks.Count == 0) + { +

Ingen målinger endnu.

+ } + else + { + + + + @foreach (var d in _disks.Disks) + { + var forecast = _disks.Forecasts.FirstOrDefault(f => string.Equals(f.Volume, d.Volume, StringComparison.OrdinalIgnoreCase)); + + + + + + + + + } + +
DrevNavnStørrelseLedigLedig %Prognose
@d.Volume@d.Label@ServerSetup.Size(d.SizeGb)@ServerSetup.Size(d.FreeGb)@Display.Number(d.FreePercent, 1) % + @(forecast?.Describe() ?? "–") + @if (forecast?.DaysUntilFull is not null) + { + (@Display.Number(forecast.GrowthGbPerDay, 2) GB/dag) + } +
+

Prognosen er en ret linje gennem SQLInsights egne målinger af ledig plads de seneste @(_forecastDays) dage, siden drevet sidst skiftede størrelse. Svinger pladsen meget, gives ingen prognose. Målt @Display.Time(_disks.CapturedAt.UtcDateTime).

+ } +
+ +
+

Tjenester

+ @if (_services is null) + { +

Ingen målinger endnu.

+ } + else + { + var watched = _server.Watched; + var stopped = _services.Services.Where(s => s.IsAutomatic && !s.IsRunning).ToList(); + @if (watched.Count > 0) + { +

Overvågede tjenester

+ + + + @foreach (var name in watched) + { + var s = _services.Services.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase) || x.DisplayName.Equals(name, StringComparison.OrdinalIgnoreCase)); + + + + + + } + +
TjenesteStatusStart
@(s?.DisplayName ?? name) @name + @if (s is null) + { + Findes ikke + } + else if (s.IsRunning) + { + Kører + } + else + { + @ServerSetup.StateText(s.State) + } + @ServerSetup.StartModeText(s?.StartMode)
+ } +

Automatiske tjenester, der ikke kører

+ @if (stopped.Count == 0) + { +

Ingen.

+ } + else + { + + + + @foreach (var s in stopped) + { + + } + +
TjenesteStatusAfslutningskode
@s.DisplayName @s.Name@ServerSetup.StateText(s.State)@s.ExitCode
+

Nogle automatiske tjenester stopper af sig selv (fx sppsvc og opdateringstjenester); dem giver SQLInsight ikke fund for. Andre kan ignoreres under Indstillinger.

+ } +

@_services.Services.Count tjenester · målt @Display.Time(_services.CapturedAt.UtcDateTime)

+ } +
+ +
+

Hændelseslog (seneste døgn)

+ @if (_events.Count == 0) + { +

Ingen fejl eller kritiske hændelser det seneste døgn (ud over kendt støj).

+ } + else + { + + + + @foreach (var e in _events.GroupBy(e => (e.LogName, e.Source, e.EventId)).Select(g => (Newest: g.First(), Count: g.Sum(x => x.Count))).Take(50)) + { + + + + + + + + + + } + +
SenestLogKildeIdSlagsAntalEksempel
@Display.Time(e.Newest.LastAt)@e.Newest.LogName@e.Newest.Source@e.Newest.EventId@(Enum.TryParse(e.Newest.Category, out var category) ? ServerEventAnalysis.Display(category) : e.Newest.Category)@(e.Newest.Level == 1 ? " (kritisk)" : string.Empty)@e.Count@e.Newest.Sample
+ } + @if (_eventSnapshot is { } snapshot) + { +

Læst med @snapshot.Method · @snapshot.Ignored hændelser sorteret fra som kendt støj ved seneste indsamling@(snapshot.Coverage is { } coverage ? " · " + coverage : string.Empty).

+ } +
+ +
+

Planlagte opgaver

+ @if (_tasks is null) + { +

Ingen målinger endnu.

+ } + else if (_tasks.Tasks.Count == 0) + { +

Ingen overvågede opgaver (@_tasks.Visible synlige i alt; Microsofts egne under \Microsoft\ overvåges ikke).

+ } + else + { + + + + @foreach (var t in _tasks.Tasks) + { + + + + + + + + } + +
OpgaveStatusSeneste kørselResultatNæste kørsel
@t.FullName@t.State@Display.Time(t.LastRunTime?.UtcDateTime)@(t.LastTaskResult is { } r ? TaskResults.Describe(r) : "–")@Display.Time(t.NextRunTime?.UtcDateTime)
+

@_tasks.Tasks.Count overvåget af @_tasks.Visible synlige opgaver@(_tasks.Coverage is { } c ? " · " + c : string.Empty).

+ } +
+ +@if (_checks.Count > 0) +{ +
+

Syntetiske tjek på serveren

+
    + @foreach (var c in _checks) + { +
  • @c.Name · @SyntheticCheckKind.Display(c.Kind) · @(c.LastSuccess switch { true => "OK", false => "fejler", _ => "ikke kørt endnu" })
  • + } +
+
+} + +
+

Rettigheder og forbindelse

+

Agenten læser serveren som sin egen konto og må aldrig være lokal administrator. Tabellen viser for hvert område, om agenten kan læse det, og ellers præcis hvorfor.

+ @if (_server.Areas.Count > 0) + { + + + + @foreach (var a in _server.Areas) + { + + + + + + + } + +
OmrådeStatusKræverDetaljer
@ServerArea.Display(a.Area)@if (a.Ok) { Kan læses } else { Ikke målbar }@a.Right@a.Detail
+ } + +

Seneste test

+ @if (_test is null) + { +

Der er ikke testet endnu.

+ } + else + { +

@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)

+ @if (_test.Error is { } testError) + { +
Testen kunne ikke udføres.

@testError

+ } + @if (_testResult is { Reachable: false } failed) + { +
Agenten kunne ikke læse serveren.

@failed.Error

@if (failed.Hint is { } hint) {

@hint

}
+ } + else if (_testResult is { } ok) + { + + + + +
Forbundet med@ok.Protocol
Agentens konto@ok.Account
Styresystem@ok.OperatingSystem @ok.Version
+ @foreach (var note in ok.Notes) + { +

@note

+ } + @if (!ok.Missing.Any()) + { +

Agenten kan læse alle områder.

+ } + else + { +
Agenten mangler adgang til @ok.Missing.Count() @(ok.Missing.Count() == 1 ? "område" : "områder"). Kør scriptet nedenfor som administrator på serveren, og test igen.
+ } + } + } + @if (_mayChange) + { +
+ } + +

Script, der giver rettighederne

+

Kør det som administrator på @_server.Host. Det giver agentens konto læseadgang (Performance Monitor Users, Event Log Readers, Distributed COM Users, Remote Management Users, WMI Remote Enable på root\cimv2 og TaskScheduler, læseret på Service Control Manager) og intet andet. SQLInsight kører det aldrig selv. -Remove fjerner rettighederne igen, og -EnableFirewall slår Windows' egne firewallregler til.

+ @if (_grant.Script is { } script) + { +

Konto: @_grant.Account · Hent som fil

+ @if (_grant.Note is { } grantNote) + { +

@grantNote

+ } +
+ Vis scriptet +
+ +
@script
+
+
+ } + else + { +

@_grant.Note

+ } +
+ +
+

Indstillinger

+ @if (_mayChange) + { +
+ + + + + + + + @if (_server.IsSqlHost) + { +

Serveren er med, fordi en overvåget SQL Server-instans kører på den. Den forsvinder, når instansen fjernes under Servere og databaser, og kan sættes på pause her.

+ } + } + else + { + + + +
Tjenester der skal køre@(_server.Watched.Count == 0 ? "–" : string.Join(", ", _server.Watched))
Tjenester der ignoreres@(_server.Ignored.Count == 0 ? "–" : string.Join(", ", _server.Ignored))
+ } +
+ +@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; } + + private WindowsServerRow? _server; + private IReadOnlyList _findings = []; + private IReadOnlyList _states = []; + private IReadOnlyList _cpu = []; + private IReadOnlyList _memory = []; + private ServerDiskSnapshot? _disks; + private ServerServiceSnapshot? _services; + private ServerTaskSnapshot? _tasks; + private ServerEventSnapshot? _eventSnapshot; + private IReadOnlyList _events = []; + private IReadOnlyList _checks = []; + private AgentRequestRow? _test; + private WindowsServerTestResult? _testResult; + private (string? Account, string? Script, string? Note) _grant; + private bool _mayChange; + private int _forecastDays = 14; + + protected override async Task OnInitializedAsync() + { + _server = await Repository.GetWindowsServerAsync(Id); + if (_server is null) + { + if (Http is not null) + { + Http.Response.StatusCode = StatusCodes.Status404NotFound; + } + + return; + } + + var now = DateTimeOffset.UtcNow; + _findings = await Repository.GetServerFindingsAsync(FindingScope.Server, Id); + _states = await Repository.GetServerRuleStatesAsync(FindingScope.Server, Id); + var performance = await Repository.GetServerPerformanceAsync(Id, now.AddDays(-1)); + _cpu = performance.Where(p => p.CpuPercent is not null).Select(p => new MetricPoint(p.SampledAt, p.CpuPercent!.Value)).ToList(); + _memory = performance.Where(p => p.AvailableMb is not null).Select(p => new MetricPoint(p.SampledAt, p.AvailableMb!.Value / 1024)).ToList(); + _disks = await Repository.GetServerSnapshotAsync(Id, ServerArea.Disks); + _services = await Repository.GetServerSnapshotAsync(Id, ServerArea.Services); + _tasks = await Repository.GetServerSnapshotAsync(Id, ServerArea.Tasks); + _eventSnapshot = await Repository.GetServerSnapshotAsync(Id, ServerArea.Events); + _events = await Repository.GetServerEventsAsync(Id, now.AddDays(-1), now); + _checks = (await Repository.GetSyntheticChecksAsync()).Where(c => c.ServerId == Id).ToList(); + _test = await Repository.GetLatestServerRequestAsync(ServerRequestKind.ServerTest, Id); + _testResult = AgentRequestJson.Deserialize(_test?.Result); + _grant = await ServerSetup.GrantScriptAsync(Repository, _server, CancellationToken.None); + _mayChange = Http is not null && InstanceSetup.MayChange(Http.User, Options.Value); + } +} diff --git a/src/SQLInsight.Web/Components/Pages/WindowsServersPage.razor b/src/SQLInsight.Web/Components/Pages/WindowsServersPage.razor new file mode 100644 index 0000000..909488a --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/WindowsServersPage.razor @@ -0,0 +1,149 @@ +@page "/servere" +@using SQLInsight.Core.Servers +@using Microsoft.AspNetCore.Components.Forms +@inject InsightRepository Repository +@inject Display Display +@inject Microsoft.Extensions.Options.IOptions Options + +Windows-servere · SQLInsight Pro + +

Windows-servere

+

CPU, hukommelse, diske, tjenester, hændelseslog og planlagte opgaver på SQL-, applikations-/AOS- og filservere – med varsel, før en disk løber fuld. Agenten læser serverne med sin egen konto over CIM/WMI (WinRM, ellers DCOM), med læseadgang og aldrig som lokal administrator. Serverne bag de overvågede SQL Server-instanser kommer med af sig selv.

+ +@if (Fjernet == 1) +{ +

Serveren overvåges ikke længere. Det, der allerede er indsamlet, er bevaret.

+} +@if (!string.IsNullOrEmpty(Fejl)) +{ + +} +@if (!_mayChange) +{ +

Du kan se serverne, men kun administratorer kan tilføje og ændre dem (SQLInsight:Web:AdminGroups).

+} + +
+

Servere

@_servers.Count(s => !s.Paused) overvåges · Syntetiske tjek
+ @if (_servers.Count == 0) + { +

Der er ingen Windows-servere endnu. SQL Server-hosts kommer med, når agenten har set instanserne under Servere og databaser.@(_mayChange ? " Tilføj applikations- og filservere nedenfor." : string.Empty)

+ } + else + { + + + + @foreach (var s in _servers) + { + var disks = _disks.GetValueOrDefault(s.ServerId); + var counts = _counts.GetValueOrDefault((FindingScope.Server, s.ServerId)); + var latest = _latest.GetValueOrDefault(s.ServerId); + + + + + + + + + + } + +
ServerRolleStatusCPULedig hukommelseDiskeFund
@s.Name
@s.Host
@ServerRoles.Display(s.Role)@(s.IsSqlHost ? " · fra SQL Server-instanser" : string.Empty)@(latest?.CpuPercent is { } cpu ? Display.Number(cpu) + " %" : "–")@(latest?.AvailableMb is { } mb ? Display.Number(mb / 1024, 1) + " GB" : "–") + @if (disks is null) + { + – + } + else + { + var worst = disks.Disks.OrderBy(d => d.FreePercent).FirstOrDefault(); + var soonest = disks.Forecasts.Where(f => f.DaysUntilFull is not null).OrderBy(f => f.DaysUntilFull).FirstOrDefault(); + @if (worst is not null) + { + @worst.Volume @Display.Number(worst.FreePercent) % ledig + } + @if (soonest is not null) + { +
@soonest.Volume @soonest.Describe()
+ } + } +
+ @if (counts is null || counts.Count == 0) + { + + } + else + { + @foreach (var (severity, count) in counts.OrderByDescending(c => c.Key)) + { + + } + } +
+ } +
+ +
+

Aktive fund på servere og syntetiske tjek

+ +
+ +@if (_mayChange) +{ +
+

Tilføj server

+

Fx en AOS-, applikations-, rapport- eller filserver. Efter tilføjelsen tester agenten forbindelsen og viser præcis, hvilke rettigheder der mangler, og scriptet der giver dem.

+
+ +
+ + + +
+ + + +
+} + +@code { + [CascadingParameter] private HttpContext? Http { get; set; } + [SupplyParameterFromQuery(Name = "fejl")] public string? Fejl { get; set; } + [SupplyParameterFromQuery(Name = "fjernet")] public int? Fjernet { get; set; } + + private IReadOnlyList _servers = []; + private IReadOnlyList _findings = []; + private IReadOnlyDictionary<(string, int), IReadOnlyDictionary> _counts = new Dictionary<(string, int), IReadOnlyDictionary>(); + private readonly Dictionary _disks = []; + private readonly Dictionary _latest = []; + private bool _mayChange; + + protected override async Task OnInitializedAsync() + { + _servers = await Repository.GetWindowsServersAsync(); + _findings = await Repository.GetServerFindingsAsync(); + _counts = await Repository.GetServerFindingCountsAsync(); + foreach (var s in _servers.Where(s => !s.Paused)) + { + if (await Repository.GetServerSnapshotAsync(s.ServerId, ServerArea.Disks) is { } disks) + { + _disks[s.ServerId] = disks; + } + + if ((await Repository.GetServerPerformanceAsync(s.ServerId, DateTimeOffset.UtcNow.AddMinutes(-10))).LastOrDefault() is { } latest) + { + _latest[s.ServerId] = latest; + } + } + + _mayChange = Http is not null && InstanceSetup.MayChange(Http.User, Options.Value); + } +} diff --git a/src/SQLInsight.Web/Components/Shared/CheckForm.razor b/src/SQLInsight.Web/Components/Shared/CheckForm.razor new file mode 100644 index 0000000..ebe52ef --- /dev/null +++ b/src/SQLInsight.Web/Components/Shared/CheckForm.razor @@ -0,0 +1,105 @@ +@using SQLInsight.Core.SyntheticChecks +@using Microsoft.AspNetCore.Components.Forms +@* The synthetic check form: the fields of every kind, grouped; only the chosen kind's fields are kept when saved. No script needed. *@ +
+ +
+ + @if (Check is null) + { + + } + else + { + + + } + + +
+
+ + + +
+

Med en server og en tjeneste ser SQLInsight forskel på "tjenesten er stoppet" og "tjenesten kører, men svarer ikke".

+ +
+ TCP-port, DNS-opslag og Dynamics AX-logon +
+ + + +
+
+ + + +
+ +
+ +
+ HTTP(S) +
+ + + +
+ + +
+ +
+ SQL-login og forespørgsel +
+ + + +
+
+ + + +
+ + +

Forespørgslen skal være én skrivebeskyttet SELECT (som community-reglerne) og køres i en transaktion, der altid rulles tilbage. Adgangskoden krypteres med agentens offentlige nøgle og kan kun læses af agenten; den vises aldrig igen.

+
+ + + + +@code { + [Parameter, EditorRequired] public string Action { get; set; } = string.Empty; + [Parameter] public SyntheticCheckRow? Check { get; set; } + [Parameter] public IReadOnlyList Servers { get; set; } = []; + [Parameter] public string SubmitText { get; set; } = "Gem"; + + private SyntheticCheckSettings Settings => Check?.Settings ?? new SyntheticCheckSettings(); +} diff --git a/src/SQLInsight.Web/Components/Shared/CheckStatus.razor b/src/SQLInsight.Web/Components/Shared/CheckStatus.razor new file mode 100644 index 0000000..b0fd66c --- /dev/null +++ b/src/SQLInsight.Web/Components/Shared/CheckStatus.razor @@ -0,0 +1,24 @@ +@if (Check.Paused) +{ + På pause +} +else if (Check.SecretError is { } secret) +{ + Adgangskode skal indtastes igen +} +else if (Check.LastSuccess is null) +{ + Ikke kørt endnu +} +else if (Check.LastSuccess == true) +{ + OK +} +else +{ + Fejler@(Check.ConsecutiveFailures > 1 ? $" ({Check.ConsecutiveFailures} i træk)" : string.Empty) +} + +@code { + [Parameter, EditorRequired] public SyntheticCheckRow Check { get; set; } = null!; +} diff --git a/src/SQLInsight.Web/Components/Shared/ServerFindingList.razor b/src/SQLInsight.Web/Components/Shared/ServerFindingList.razor new file mode 100644 index 0000000..5eaa6f1 --- /dev/null +++ b/src/SQLInsight.Web/Components/Shared/ServerFindingList.razor @@ -0,0 +1,35 @@ +@inject Display Display + +@if (Findings.Count == 0) +{ +

@Empty

+} +else +{ + + + @if (ShowScope) { } + + + @foreach (var f in Findings) + { + + + @if (ShowScope) + { + + } + + + + + } + +
AlvorServer / tjekFundRegelSet første gang
@f.ScopeName@f.Title@f.RuleId@Display.Ago(f.FirstSeenAt)
+} + +@code { + [Parameter, EditorRequired] public IReadOnlyList Findings { get; set; } = []; + [Parameter] public bool ShowScope { get; set; } + [Parameter] public string Empty { get; set; } = "Ingen fund."; +} diff --git a/src/SQLInsight.Web/Components/Shared/ServerStatus.razor b/src/SQLInsight.Web/Components/Shared/ServerStatus.razor new file mode 100644 index 0000000..4d73531 --- /dev/null +++ b/src/SQLInsight.Web/Components/Shared/ServerStatus.razor @@ -0,0 +1,26 @@ +@inject Display Display + +@if (Server.Paused) +{ + På pause +} +else if (Server.LastContactAt is null && Server.LastErrorAt is null) +{ + Venter på agenten +} +else if (!Server.Reachable) +{ + Kan ikke læses +} +else if (Server.Areas.Any(a => !a.Ok)) +{ + !a.Ok).Select(a => SQLInsight.Core.Servers.ServerArea.Display(a.Area)))">Delvist målbar +} +else +{ + Overvåges +} + +@code { + [Parameter, EditorRequired] public WindowsServerRow Server { get; set; } = null!; +} diff --git a/src/SQLInsight.Web/Program.cs b/src/SQLInsight.Web/Program.cs index ebde66e..03b9c1d 100644 --- a/src/SQLInsight.Web/Program.cs +++ b/src/SQLInsight.Web/Program.cs @@ -118,9 +118,11 @@ ApiV1Endpoints.Map(app); SupportApiEndpoints.Map(app); ConfigurationApiEndpoints.Map(app); DynamicsAxApiEndpoints.Map(app); +ServerApiEndpoints.Map(app); app.MapOpenApi("/api/v1/openapi.json").RequireRead(); Support.Map(app); InstanceSetupEndpoints.Map(app); +ServerSetupEndpoints.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 options, CancellationToken ct) => diff --git a/src/SQLInsight.Web/ServerSetup.cs b/src/SQLInsight.Web/ServerSetup.cs new file mode 100644 index 0000000..afa9d93 --- /dev/null +++ b/src/SQLInsight.Web/ServerSetup.cs @@ -0,0 +1,318 @@ +using System.Globalization; +using Microsoft.Data.SqlClient; +using SQLInsight.Core.Configuration; +using SQLInsight.Core.Servers; +using SQLInsight.Core.SyntheticChecks; +using SQLInsight.Repository; + +namespace SQLInsight.Web; + +/// A Windows server as entered in the form or the API. +public sealed record ServerInput(string? Name, string? Host, string? Role, IReadOnlyList? WatchedServices, IReadOnlyList? IgnoredServices); + +/// A synthetic check as entered in the form or the API. The password is only used to seal it for the agent. +public sealed record CheckInput( + string? Name, + string? Kind, + int? ServerId, + SyntheticCheckSettings Settings, + string? Password, + int? IntervalSeconds, + int? TimeoutSeconds); + +/// +/// Windows servers and synthetic checks 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; tests and checks run in the agent, as its identity. A SQL +/// login password for a check is sealed with the agent's public key at once and never stored, shown or returned. +/// +public static class ServerSetup +{ + public const string ServersPage = "/servere"; + public const string ChecksPage = "/syntetiske-tjek"; + + public static string ServerPage(int id) => ServersPage + "/" + id.ToString(CultureInfo.InvariantCulture); + + public static string CheckPage(int id) => ChecksPage + "/" + id.ToString(CultureInfo.InvariantCulture); + + public static string FindingPage(long id) => ServersPage + "/fund/" + id.ToString(CultureInfo.InvariantCulture); + + public const string NotAdminText = + "Kun administratorer kan ændre servere og syntetiske tjek. Bed en administrator om at tilføje jeres gruppe under SQLInsight:Web:AdminGroups."; + + public sealed record Outcome(int? Id = null, long? RequestId = null, string? Error = null, bool NotFound = false, bool Conflict = false); + + private static string? Validate(ServerInput input, bool creating) + { + if (creating && ServerHost.Validate(input.Host) is { } host) + { + return host; + } + + if (input.Role is not null && !ServerRoles.IsKnown(input.Role)) + { + return "Rollen skal være " + string.Join(", ", ServerRoles.All) + "."; + } + + return ServerHost.ValidateServiceNames(input.WatchedServices ?? []) ?? ServerHost.ValidateServiceNames(input.IgnoredServices ?? []); + } + + /// Adds a server and asks the agent to test it. + public static async Task AddServerAsync(InsightRepository repository, ServerInput input, string? by, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(input); + var name = string.IsNullOrWhiteSpace(input.Name) ? (input.Host is null ? null : ServerHost.ShortName(input.Host)) : input.Name.Trim(); + if ((Validate(input, creating: true) ?? ServerAddress.ValidateName(name)) is { } invalid) + { + return new Outcome(Error: invalid); + } + + int id; + try + { + id = await repository.AddWindowsServerAsync(new NewWindowsServer(name!, input.Host!, input.Role ?? ServerRoles.Other, input.WatchedServices ?? [], input.IgnoredServices ?? []), + by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); + } + catch (SqlException ex) when (ex.Number is 2601 or 2627) + { + return new Outcome(Error: $"Serveren {input.Host!.Trim()} eller navnet {name} er allerede tilføjet."); + } + + var test = await repository.AddServerRequestAsync(ServerRequestKind.ServerTest, id, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); + return new Outcome(id, test); + } + + /// Role and the services to watch and ignore; null leaves a value as it is. + public static async Task UpdateServerAsync(InsightRepository repository, int id, ServerInput input, string? by, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(input); + var row = await repository.GetWindowsServerAsync(id, cancellationToken).ConfigureAwait(false); + if (row is null) + { + return new Outcome(NotFound: true); + } + + if (Validate(input, creating: false) is { } invalid) + { + return new Outcome(id, Error: invalid); + } + + await repository.UpdateWindowsServerAsync(id, input.Role ?? row.Role, input.WatchedServices ?? row.Watched, input.IgnoredServices ?? row.Ignored, by, DateTimeOffset.UtcNow, + cancellationToken).ConfigureAwait(false); + return new Outcome(id); + } + + public static async Task RequestServerTestAsync(InsightRepository repository, int id, string? by, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + return await repository.GetWindowsServerAsync(id, cancellationToken).ConfigureAwait(false) is null + ? new Outcome(NotFound: true) + : new Outcome(id, await repository.AddServerRequestAsync(ServerRequestKind.ServerTest, id, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false)); + } + + /// + /// The grant script for the agent's account on a server: the account the last test saw, or the one the agent's + /// account arrives as (computed from what the agent published). + /// + public static async Task<(string? Account, string? Script, string? Note)> GrantScriptAsync(InsightRepository repository, WindowsServerRow row, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(row); + var test = await repository.GetLatestServerRequestAsync(ServerRequestKind.ServerTest, row.ServerId, cancellationToken).ConfigureAwait(false); + if (AgentRequestJson.Deserialize(test?.Result)?.Account is { Length: > 0 } seen) + { + return (seen, ServerGrantScript.For(seen), null); + } + + var key = await repository.GetCurrentAgentKeyAsync(cancellationToken).ConfigureAwait(false); + if (key is null) + { + return (null, null, "Agenten har ikke meldt sig endnu, så dens konto kendes ikke. Start SQLInsight Agent, eller brug db/permissions/grant-windows-server.ps1 med -Account."); + } + + var account = ServerGrantScript.AccountFor(key.ServiceAccount, row.Host, key.MachineName, key.Domain ?? key.MachineName); + return (account, ServerGrantScript.For(account), $"Beregnet ud fra agentens konto ({key.ServiceAccount} på {key.MachineName}). Testen viser den konto, agenten faktisk ankommer som."); + } + + // ---- Synthetic checks ----------------------------------------------------------------------------------- + + /// Adds a check, sealing a SQL login password for the agent, and asks the agent to run it at once. + public static async Task AddCheckAsync(InsightRepository repository, CheckInput input, string? by, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(input); + var (check, error) = await PrepareAsync(repository, input, existing: null, cancellationToken).ConfigureAwait(false); + if (error is not null) + { + return new Outcome(Error: error); + } + + int id; + try + { + id = await repository.AddSyntheticCheckAsync(check!, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); + } + catch (SqlException ex) when (ex.Number is 2601 or 2627) + { + return new Outcome(Error: $"Navnet {check!.Name} bruges allerede af et andet tjek."); + } + + var run = await repository.AddServerRequestAsync(ServerRequestKind.SyntheticCheckRun, id, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); + return new Outcome(id, run); + } + + /// Changes a check (not its kind). The SQL password must be typed again when the server or login changes. + public static async Task UpdateCheckAsync(InsightRepository repository, int id, CheckInput input, string? by, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(input); + var row = await repository.GetSyntheticCheckAsync(id, cancellationToken).ConfigureAwait(false); + if (row is null) + { + return new Outcome(NotFound: true); + } + + if (input.Kind is not null && input.Kind != row.Kind) + { + return new Outcome(id, Error: "Typen kan ikke ændres. Opret et nyt tjek."); + } + + var (check, error) = await PrepareAsync(repository, input with { Kind = row.Kind }, row, cancellationToken).ConfigureAwait(false); + if (error is not null) + { + return new Outcome(id, Error: error); + } + + try + { + await repository.UpdateSyntheticCheckAsync(id, check!, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); + } + catch (SqlException ex) when (ex.Number is 2601 or 2627) + { + return new Outcome(id, Error: $"Navnet {check!.Name} bruges allerede af et andet tjek."); + } + + return new Outcome(id); + } + + public static async Task RequestCheckRunAsync(InsightRepository repository, int id, string? by, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + return await repository.GetSyntheticCheckAsync(id, cancellationToken).ConfigureAwait(false) is null + ? new Outcome(NotFound: true) + : new Outcome(id, await repository.AddServerRequestAsync(ServerRequestKind.SyntheticCheckRun, id, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false)); + } + + private static async Task<(SyntheticCheckInput? Check, string? Error)> PrepareAsync(InsightRepository repository, CheckInput input, SyntheticCheckRow? existing, + CancellationToken cancellationToken) + { + var kind = input.Kind; + var settings = Normalize(kind, input.Settings); + var interval = input.IntervalSeconds ?? existing?.IntervalSeconds ?? SyntheticCheckLimits.DefaultIntervalSeconds; + var timeout = input.TimeoutSeconds ?? existing?.TimeoutSeconds + ?? (kind == SyntheticCheckKind.AxLogon ? SyntheticCheckLimits.DefaultAxTimeoutSeconds : SyntheticCheckLimits.DefaultTimeoutSeconds); + if (SyntheticCheckValidation.Validate(input.Name, kind, settings, interval, timeout, input.ServerId is not null) is { } invalid) + { + return (null, invalid); + } + + if (input.ServerId is { } serverId && await repository.GetWindowsServerAsync(serverId, cancellationToken).ConfigureAwait(false) is null) + { + return (null, "Den valgte Windows-server findes ikke."); + } + + if (input.Password?.Length > 256) + { + return (null, "Adgangskoden er for lang."); + } + + byte[]? cipher = null; + string? keyId = null; + var sqlLogin = kind == SyntheticCheckKind.Sql && settings.Authentication == "SqlLogin"; + if (sqlLogin) + { + var moved = existing is null || existing.Settings.SecretContext() != settings.SecretContext(); + if (string.IsNullOrEmpty(input.Password) && (moved || !existing!.HasSecret)) + { + return (null, existing is null ? "Angiv adgangskoden til SQL-login'et." : "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 (null, InstanceSetup.NoAgentKeyText); + } + + cipher = CredentialEnvelope.Seal(input.Password, key.KeyId, key.PublicKey, settings.SecretContext()); + keyId = key.KeyId; + } + } + + return (new SyntheticCheckInput(input.Name!.Trim(), kind!, input.ServerId, settings, cipher, keyId, interval, timeout, ClearSecret: !sqlLogin), null); + } + + /// Only the fields of the check's kind are kept (no stray SQL login on an HTTP check …). + public static SyntheticCheckSettings Normalize(string? kind, SyntheticCheckSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + static string? Clean(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + var common = new SyntheticCheckSettings { ServiceName = Clean(settings.ServiceName), WarnMilliseconds = settings.WarnMilliseconds }; + return kind switch + { + SyntheticCheckKind.Tcp => common with { Host = Clean(settings.Host), Port = settings.Port }, + SyntheticCheckKind.Http => common with + { + Url = settings.Url, ExpectedStatus = settings.ExpectedStatus, ExpectedText = Clean(settings.ExpectedText), + UseWindowsAuthentication = settings.UseWindowsAuthentication, AllowInvalidCertificate = settings.AllowInvalidCertificate, + }, + SyntheticCheckKind.Sql => common with + { + Server = Clean(settings.Server), Port = settings.Port, Database = Clean(settings.Database), Authentication = settings.Authentication, + UserName = settings.Authentication == "SqlLogin" ? Clean(settings.UserName) : null, Encryption = settings.Encryption, + TrustServerCertificate = settings.TrustServerCertificate, Query = Clean(settings.Query), + }, + SyntheticCheckKind.Dns => common with { Host = Clean(settings.Host), ExpectedAddresses = settings.ExpectedAddresses }, + SyntheticCheckKind.AxLogon => common with + { + Host = Clean(settings.Host), Port = settings.Port, Company = Clean(settings.Company), ObjectServer = Clean(settings.ObjectServer), + Configuration = Clean(settings.Configuration), BusinessConnectorLogon = settings.BusinessConnectorLogon, + }, + _ => settings, + }; + } + + // ---- Texts -------------------------------------------------------------------------------------------------- + + public static string StateText(string? state) => state switch + { + "Running" => "kører", + "Stopped" => "stoppet", + "Start Pending" => "starter", + "Stop Pending" => "stopper", + "Paused" => "på pause", + "NotFound" => "findes ikke", + null => "–", + _ => state, + }; + + public static string StartModeText(string? mode) => mode switch + { + "Auto" => "Automatisk", + "Manual" => "Manuel", + "Disabled" => "Deaktiveret", + _ => mode ?? "–", + }; + + public static string Size(double gigabytes) => gigabytes >= 1024 ? Display.Number(gigabytes / 1024, 1) + " TB" : Display.Number(gigabytes, gigabytes < 10 ? 1 : 0) + " GB"; + + /// The comma-separated status codes from a form field. + public static IReadOnlyList ParseStatusCodes(string? text) => + ServerHost.ParseList(text).Select(s => int.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out var code) ? code : -1).ToList(); + + /// A form's URL field: an absolute URL, or null (the validation then says what is wrong). + public static Uri? ParseUrl(string? text) => Uri.TryCreate(text?.Trim(), UriKind.Absolute, out var url) ? url : null; + +} diff --git a/src/SQLInsight.Web/ServerSetupEndpoints.cs b/src/SQLInsight.Web/ServerSetupEndpoints.cs new file mode 100644 index 0000000..da6994c --- /dev/null +++ b/src/SQLInsight.Web/ServerSetupEndpoints.cs @@ -0,0 +1,238 @@ +using System.Globalization; +using System.Text; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.Extensions.Options; +using SQLInsight.Core.Servers; +using SQLInsight.Core.SyntheticChecks; +using SQLInsight.Repository; + +namespace SQLInsight.Web; + +/// +/// The forms on /servere and /syntetiske-tjek: 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. +/// +public static class ServerSetupEndpoints +{ + public static void Map(IEndpointRouteBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + app.MapPost(ServerSetup.ServersPage + "/tilfoej", AddServerAsync); + app.MapPost(ServerSetup.ServersPage + "/{id:int}/indstillinger", UpdateServerAsync); + app.MapPost(ServerSetup.ServersPage + "/{id:int}/pause", PauseServerAsync); + app.MapPost(ServerSetup.ServersPage + "/{id:int}/fjern", RemoveServerAsync); + app.MapPost(ServerSetup.ServersPage + "/{id:int}/test", TestServerAsync); + app.MapGet(ServerSetup.ServersPage + "/{id:int}/grant-windows-server.ps1", GrantScriptAsync); + app.MapPost(ServerSetup.ChecksPage + "/tilfoej", AddCheckAsync); + app.MapPost(ServerSetup.ChecksPage + "/{id:int}/ret", UpdateCheckAsync); + app.MapPost(ServerSetup.ChecksPage + "/{id:int}/pause", PauseCheckAsync); + app.MapPost(ServerSetup.ChecksPage + "/{id:int}/fjern", RemoveCheckAsync); + app.MapPost(ServerSetup.ChecksPage + "/{id:int}/koer", RunCheckAsync); + } + + private static string WithMessage(string page, string key, string value) => + page + (page.Contains('?', StringComparison.Ordinal) ? "&" : "?") + key + "=" + Uri.EscapeDataString(value); + + private static async Task 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", ServerSetup.NotAdminText)); + } + + private static int? Int(IFormCollection form, string name) => + int.TryParse(form[name].ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : null; + + private static ServerInput ServerInput(IFormCollection form) => new( + form["name"].ToString(), + form["host"].ToString(), + form["role"].ToString() is { Length: > 0 } role ? role : null, + ServerHost.ParseList(form["watched"].ToString()), + ServerHost.ParseList(form["ignored"].ToString())); + + private static async Task AddServerAsync(HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, ServerSetup.ServersPage).ConfigureAwait(false) is { } refused) + { + return refused; + } + + var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false); + var outcome = await ServerSetup.AddServerAsync(repository, ServerInput(form), http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false); + return outcome.Error is { } error + ? Results.Redirect(WithMessage(ServerSetup.ServersPage, "fejl", error) + "#tilfoej") + : Results.Redirect(ServerSetup.ServerPage(outcome.Id!.Value) + "?tilfoejet=1"); + } + + private static async Task UpdateServerAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, ServerSetup.ServerPage(id)).ConfigureAwait(false) is { } refused) + { + return refused; + } + + var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false); + var outcome = await ServerSetup.UpdateServerAsync(repository, id, ServerInput(form) with { Host = null }, http.User.Identity?.Name, http.RequestAborted) + .ConfigureAwait(false); + return outcome.NotFound ? Results.NotFound() + : Results.Redirect(outcome.Error is { } error ? WithMessage(ServerSetup.ServerPage(id), "fejl", error) : ServerSetup.ServerPage(id) + "?gemt=1#indstillinger"); + } + + private static async Task PauseServerAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, ServerSetup.ServerPage(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.SetWindowsServerPausedAsync(id, paused, http.User.Identity?.Name, DateTimeOffset.UtcNow, http.RequestAborted).ConfigureAwait(false) + ? Results.Redirect(ServerSetup.ServerPage(id) + (paused ? "?pause=1" : "?genoptaget=1")) + : Results.NotFound(); + } + + private static async Task RemoveServerAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, ServerSetup.ServerPage(id)).ConfigureAwait(false) is { } refused) + { + return refused; + } + + return await repository.RemoveWindowsServerAsync(id, http.User.Identity?.Name, DateTimeOffset.UtcNow, http.RequestAborted).ConfigureAwait(false) switch + { + ConfigurationChangeOutcome.Changed => Results.Redirect(ServerSetup.ServersPage + "?fjernet=1"), + ConfigurationChangeOutcome.FromConfigFile => Results.Redirect(WithMessage(ServerSetup.ServerPage(id), "fejl", + "Serveren er en SQL Server-host og følger instanserne under Servere og databaser. Den kan sættes på pause her.")), + _ => Results.NotFound(), + }; + } + + private static async Task TestServerAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, ServerSetup.ServerPage(id)).ConfigureAwait(false) is { } refused) + { + return refused; + } + + var outcome = await ServerSetup.RequestServerTestAsync(repository, id, http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false); + return outcome.NotFound ? Results.NotFound() : Results.Redirect(ServerSetup.ServerPage(id) + "#rettigheder"); + } + + /// The grant script as a file (UTF-8 with BOM, so Windows PowerShell 5.1 reads æøå right). + private static async Task GrantScriptAsync(int id, InsightRepository repository, CancellationToken ct) + { + if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is not { } row) + { + return Results.NotFound(); + } + + var (_, script, _) = await ServerSetup.GrantScriptAsync(repository, row, ct).ConfigureAwait(false); + script ??= ServerGrantScript.Template(); + return Results.File(Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(script)).ToArray(), "text/plain; charset=utf-8", "grant-windows-server.ps1"); + } + + /// The check form: every kind's fields are there; only the chosen kind's are kept (). + private static CheckInput CheckInput(IFormCollection form) + { + var minutes = Int(form, "intervalMinutes"); + return new CheckInput( + form["name"].ToString(), + form["kind"].ToString() is { Length: > 0 } kind ? kind : null, + Int(form, "serverId") is > 0 and var server ? server : null, + new SyntheticCheckSettings + { + Host = form["host"].ToString(), + Port = Int(form, "port"), + Url = ServerSetup.ParseUrl(form["url"].ToString()), + ExpectedStatus = ServerSetup.ParseStatusCodes(form["expectedStatus"].ToString()), + ExpectedText = form["expectedText"].ToString(), + UseWindowsAuthentication = form["useWindowsAuthentication"] == "true", + AllowInvalidCertificate = form["allowInvalidCertificate"] == "true", + Server = form["server"].ToString(), + Database = form["database"].ToString(), + Authentication = form["authentication"].ToString() is { Length: > 0 } authentication ? authentication : "Windows", + UserName = form["userName"].ToString(), + Encryption = form["encryption"].ToString() is { Length: > 0 } encryption ? encryption : "Mandatory", + TrustServerCertificate = form["trustServerCertificate"] == "true", + Query = form["query"].ToString(), + ExpectedAddresses = ServerHost.ParseList(form["expectedAddresses"].ToString()), + Company = form["company"].ToString(), + ObjectServer = form["objectServer"].ToString(), + Configuration = form["configuration"].ToString(), + BusinessConnectorLogon = form["businessConnectorLogon"] == "true", + ServiceName = form["serviceName"].ToString(), + WarnMilliseconds = Int(form, "warnMilliseconds"), + }, + form["password"].ToString(), + minutes is { } m ? m * 60 : null, + Int(form, "timeoutSeconds")); + } + + private static async Task AddCheckAsync(HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, ServerSetup.ChecksPage).ConfigureAwait(false) is { } refused) + { + return refused; + } + + var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false); + var outcome = await ServerSetup.AddCheckAsync(repository, CheckInput(form), http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false); + return outcome.Error is { } error + ? Results.Redirect(WithMessage(ServerSetup.ChecksPage, "fejl", error) + "#tilfoej") + : Results.Redirect(ServerSetup.CheckPage(outcome.Id!.Value) + "?tilfoejet=1"); + } + + private static async Task UpdateCheckAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, ServerSetup.CheckPage(id)).ConfigureAwait(false) is { } refused) + { + return refused; + } + + var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false); + var outcome = await ServerSetup.UpdateCheckAsync(repository, id, CheckInput(form), http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false); + return outcome.NotFound ? Results.NotFound() + : Results.Redirect(outcome.Error is { } error ? WithMessage(ServerSetup.CheckPage(id), "fejl", error) + "#ret" : ServerSetup.CheckPage(id) + "?gemt=1"); + } + + private static async Task PauseCheckAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, ServerSetup.CheckPage(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.SetSyntheticCheckPausedAsync(id, paused, http.User.Identity?.Name, DateTimeOffset.UtcNow, http.RequestAborted).ConfigureAwait(false) + ? Results.Redirect(ServerSetup.CheckPage(id) + (paused ? "?pause=1" : "?genoptaget=1")) + : Results.NotFound(); + } + + private static async Task RemoveCheckAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, ServerSetup.CheckPage(id)).ConfigureAwait(false) is { } refused) + { + return refused; + } + + return await repository.RemoveSyntheticCheckAsync(id, http.User.Identity?.Name, DateTimeOffset.UtcNow, http.RequestAborted).ConfigureAwait(false) + ? Results.Redirect(ServerSetup.ChecksPage + "?fjernet=1") + : Results.NotFound(); + } + + private static async Task RunCheckAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, ServerSetup.CheckPage(id)).ConfigureAwait(false) is { } refused) + { + return refused; + } + + var outcome = await ServerSetup.RequestCheckRunAsync(repository, id, http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false); + return outcome.NotFound ? Results.NotFound() : Results.Redirect(ServerSetup.CheckPage(id) + "?koersel=" + outcome.RequestId!.Value.ToString(CultureInfo.InvariantCulture)); + } +} diff --git a/tests/SQLInsight.IntegrationTests/ServerApiTests.cs b/tests/SQLInsight.IntegrationTests/ServerApiTests.cs new file mode 100644 index 0000000..b1e67db --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/ServerApiTests.cs @@ -0,0 +1,232 @@ +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.Findings; +using SQLInsight.Core.Servers; +using SQLInsight.Core.SyntheticChecks; +using SQLInsight.Repository; +using SQLInsight.Rules.Engine; +using SQLInsight.Web.Api; + +namespace SQLInsight.IntegrationTests; + +/// Windows servers and synthetic checks through API v1, with Windows authentication on and tokens with and without config:write. +[Collection(SqlServerTestGroup.Name)] +[Trait("Category", "Integration")] +public sealed class ServerApiTests(SqlServerFixture sql) : IAsyncLifetime +{ + private const string ReadToken = "sqli_test-server-read"; + private const string WriteToken = "sqli_test-server-write"; + private readonly string _database = "SQLInsightSrvApi_" + Guid.NewGuid().ToString("N")[..8]; + private WebApplicationFactory _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\gmsa-sqlinsight$", Domain = "FIRMA", + CreatedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow, + }); + _factory = new WebApplicationFactory().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 JsonAsync(HttpResponseMessage response) => JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; + + [Fact] + public async Task Windows_servers_in_the_api_and_changing_needs_config_write() + { + var write = Client(WriteToken); + var read = Client(ReadToken); + var server = new { host = "aos01.firma.local", role = "Application", watchedServices = new[] { "AOS60$01" } }; + + Assert.Equal(HttpStatusCode.Unauthorized, (await Client(null).GetAsync("/api/v1/windows-servers")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await read.PostAsync("/api/v1/windows-servers", Body(server))).StatusCode); + Assert.Equal(HttpStatusCode.UnprocessableEntity, (await write.PostAsync("/api/v1/windows-servers", Body(new { host = @"aos01\x" }))).StatusCode); + var created = await write.PostAsync("/api/v1/windows-servers", Body(server)); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + var body = await JsonAsync(created); + var id = body.GetProperty("server").GetProperty("id").GetInt32(); + Assert.Equal("AOS01", body.GetProperty("server").GetProperty("name").GetString()); + Assert.True(body.GetProperty("serverTestRequestId").GetInt64() > 0); + + var now = DateTimeOffset.UtcNow; + await _repository.SaveServerPerformanceAsync(id, new ServerPerformanceSample(now.AddMinutes(-1), 42, 6000, 16384, 3, 50)); + await _repository.SaveServerSnapshotAsync(id, ServerArea.Disks, new ServerDiskSnapshot + { + CapturedAt = now, Disks = [new ServerDisk(@"D:\", "Data", "NTFS", 500, 40)], Forecasts = [new DiskForecast(@"D:\", 12, 3.3, 300, 14, 0.9)], + }, now); + await _repository.SaveServerSnapshotAsync(id, ServerArea.Services, new ServerServiceSnapshot { CapturedAt = now, Services = [new ServerService("AOS60$01", "AOS", "Running", "Auto")] }, now); + await _repository.SaveServerSnapshotAsync(id, ServerArea.Tasks, new ServerTaskSnapshot + { + CapturedAt = now, Visible = 120, Tasks = [new ServerTask { Path = @"\", Name = "AX export", State = "Ready", LastTaskResult = 1 }], + }, now); + var (groups, _) = ServerEventAnalysis.Group([new ServerEventEntry("System", "disk", 153, 2, now.AddMinutes(-3), @"Disk 1 på \\fil01\hemmeligt")], []); + await _repository.SaveServerEventsAsync(id, new ServerEventSnapshot { CapturedAt = now, ReadUntil = now, Groups = groups }); + await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, id, + [ + new RuleResult + { + RuleId = "SQLI-WIN-009", RuleTitle = "Fejl i Windows' hændelseslog", Outcome = RuleOutcome.Findings, + Findings = [new Finding + { + RuleId = "SQLI-WIN-009", Severity = Severity.High, Title = "Disk og filsystem: disk 153", Explanation = "e", Recommendation = "r", Subject = "event: System/disk/153", + Evidence = [new Evidence("Kilde", "disk"), new Evidence("Eksempel", @"Disk 1 på \\fil01\hemmeligt", Sensitive: true)], + }], + }, + ], now); + + var list = await JsonAsync(await read.GetAsync("/api/v1/windows-servers")); + Assert.Equal("aos01.firma.local", Assert.Single(list.EnumerateArray()).GetProperty("host").GetString()); + Assert.Equal(1, (await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/performance"))).GetArrayLength()); + var disks = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/disks")); + Assert.Equal("fuld om ca. 12 dage", disks.GetProperty("disks")[0].GetProperty("forecast").GetProperty("text").GetString()); + Assert.Equal("AOS60$01", (await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/services"))).GetProperty("services")[0].GetProperty("name").GetString()); + var tasks = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/tasks")); + Assert.True(tasks.GetProperty("tasks")[0].GetProperty("failed").GetBoolean()); + var events = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/events")); + Assert.Equal(ApiV1Endpoints.Redacted, events[0].GetProperty("sample").GetString()); + var findings = await JsonAsync(await read.GetAsync($"/api/v1/server-findings?scope=server&id={id}")); + var finding = Assert.Single(findings.EnumerateArray()); + Assert.DoesNotContain("hemmeligt", finding.ToString(), StringComparison.Ordinal); + var findingId = finding.GetProperty("id").GetInt64(); + Assert.Equal("SQLI-WIN-009", (await JsonAsync(await read.GetAsync($"/api/v1/server-findings/{findingId}"))).GetProperty("ruleId").GetString()); + Assert.Equal(HttpStatusCode.BadRequest, (await read.GetAsync("/api/v1/server-findings?id=1")).StatusCode); + var states = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/rule-states")); + Assert.Equal("findings", Assert.Single(states.EnumerateArray()).GetProperty("outcome").GetString()); + + var grant = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/grant-script")); + Assert.Equal(@"FIRMA\gmsa-sqlinsight$", grant.GetProperty("account").GetString()); + Assert.Contains(@"$Account = 'FIRMA\gmsa-sqlinsight$'", grant.GetProperty("script").GetString(), StringComparison.Ordinal); + + Assert.Equal(HttpStatusCode.Forbidden, (await read.PatchAsync($"/api/v1/windows-servers/{id}", Body(new { paused = true }))).StatusCode); + var patched = await JsonAsync(await write.PatchAsync($"/api/v1/windows-servers/{id}", Body(new { paused = true, ignoredServices = Enumerable.Repeat("MyBatch", 1) }))); + Assert.True(patched.GetProperty("paused").GetBoolean()); + Assert.Equal("MyBatch", patched.GetProperty("ignoredServices")[0].GetString()); + Assert.Equal("AOS60$01", patched.GetProperty("watchedServices")[0].GetString()); + Assert.Equal(HttpStatusCode.Accepted, (await write.PostAsync($"/api/v1/windows-servers/{id}/test", Body(new { }))).StatusCode); + Assert.Equal("Pending", (await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/test"))).GetProperty("request").GetProperty("status").GetString()); + + await _repository.SyncSqlHostServersAsync([("sql01.firma.local", "SQL01")], now); + var sqlHost = (await _repository.GetWindowsServersAsync()).Single(s => s.IsSqlHost).ServerId; + Assert.Equal(HttpStatusCode.Conflict, (await write.DeleteAsync($"/api/v1/windows-servers/{sqlHost}")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await write.DeleteAsync($"/api/v1/windows-servers/{id}")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await read.GetAsync($"/api/v1/windows-servers/{id}")).StatusCode); + } + + [Fact] + public async Task Synthetic_checks_in_the_api_with_write_only_passwords_and_redacted_failures() + { + var write = Client(WriteToken); + var read = Client(ReadToken); + var check = new + { + name = "SQL01 login", kind = "Sql", intervalSeconds = 120, + settings = new { server = @"sql01\AX", authentication = "SqlLogin", userName = "axapp", database = "AX2012" }, + password = "Api-Hemmelig-2!", + }; + + Assert.Equal(HttpStatusCode.Forbidden, (await read.PostAsync("/api/v1/synthetic-checks", Body(check))).StatusCode); + Assert.Equal(HttpStatusCode.UnprocessableEntity, (await write.PostAsync("/api/v1/synthetic-checks", + Body(new { name = "x", kind = "Sql", settings = new { server = "sql01", query = "DELETE FROM dbo.x" } }))).StatusCode); + var created = await write.PostAsync("/api/v1/synthetic-checks", Body(check)); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + var body = await JsonAsync(created); + Assert.DoesNotContain("Api-Hemmelig", body.ToString(), StringComparison.Ordinal); + var id = body.GetProperty("check").GetProperty("id").GetInt32(); + Assert.True(body.GetProperty("check").GetProperty("hasPassword").GetBoolean()); + Assert.Equal(@"sql01\AX / AX2012", body.GetProperty("check").GetProperty("target").GetString()); + Assert.True(body.GetProperty("runRequestId").GetInt64() > 0); + + await _repository.RecordSyntheticCheckResultAsync(id, new SyntheticCheckResult + { + At = DateTimeOffset.UtcNow, Success = false, DurationMs = 40, Message = "Login på SQL Server fejlede: Login failed for user 'axapp'.", + Details = [new SyntheticCheckDetail("Server", @"sql01\AX"), new SyntheticCheckDetail("Fejl", "axapp", Sensitive: true)], + }); + var results = await JsonAsync(await read.GetAsync($"/api/v1/synthetic-checks/{id}/results")); + var result = Assert.Single(results.EnumerateArray()); + Assert.Equal(ApiV1Endpoints.Redacted, result.GetProperty("message").GetString()); + Assert.Equal(@"sql01\AX", result.GetProperty("details")[0].GetProperty("value").GetString()); + Assert.Equal(ApiV1Endpoints.Redacted, result.GetProperty("details")[1].GetProperty("value").GetString()); + Assert.Equal(ApiV1Endpoints.Redacted, (await JsonAsync(await read.GetAsync($"/api/v1/synthetic-checks/{id}"))).GetProperty("lastMessage").GetString()); + + var changed = await write.PutAsync($"/api/v1/synthetic-checks/{id}", Body(new + { + name = "SQL01 login", settings = new { server = @"sql01\AX", authentication = "SqlLogin", userName = "axapp", database = "AX2012", warnMilliseconds = 500 }, + })); + Assert.Equal(HttpStatusCode.OK, changed.StatusCode); + Assert.Equal(500, (await JsonAsync(changed)).GetProperty("settings").GetProperty("warnMilliseconds").GetInt32()); + Assert.Equal(HttpStatusCode.UnprocessableEntity, (await write.PutAsync($"/api/v1/synthetic-checks/{id}", Body(new + { + name = "SQL01 login", settings = new { server = "sql02", authentication = "SqlLogin", userName = "axapp" }, + }))).StatusCode); + Assert.Equal(HttpStatusCode.UnprocessableEntity, (await write.PutAsync($"/api/v1/synthetic-checks/{id}", Body(new { name = "SQL01 login", kind = "Tcp" }))).StatusCode); + Assert.True((await JsonAsync(await write.PatchAsync($"/api/v1/synthetic-checks/{id}", Body(new { paused = true })))).GetProperty("paused").GetBoolean()); + Assert.Equal(HttpStatusCode.Accepted, (await write.PostAsync($"/api/v1/synthetic-checks/{id}/run", Body(new { }))).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await read.GetAsync($"/api/v1/synthetic-checks/{id}/rule-states")).StatusCode); + Assert.Single((await JsonAsync(await read.GetAsync("/api/v1/synthetic-checks"))).EnumerateArray()); + Assert.Contains((await JsonAsync(await read.GetAsync("/api/v1/rules"))).EnumerateArray(), r => r.GetProperty("id").GetString() == "SQLI-SYN-001"); + Assert.Equal(HttpStatusCode.Forbidden, (await read.DeleteAsync($"/api/v1/synthetic-checks/{id}")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await write.DeleteAsync($"/api/v1/synthetic-checks/{id}")).StatusCode); + } + + [Fact] + public async Task Openapi_describes_the_server_and_check_endpoints() + { + var document = await JsonAsync(await Client(ReadToken).GetAsync("/api/v1/openapi.json")); + var paths = document.GetProperty("paths").EnumerateObject().Select(p => p.Name).ToList(); + + foreach (var path in new[] { "/api/v1/windows-servers", "/api/v1/windows-servers/{id}/disks", "/api/v1/server-findings", "/api/v1/synthetic-checks/{id}/run", + "/api/v1/synthetic-checks/{id}/results" }) + { + Assert.Contains(path, paths); + } + } +} diff --git a/tests/SQLInsight.IntegrationTests/ServerWebTests.cs b/tests/SQLInsight.IntegrationTests/ServerWebTests.cs new file mode 100644 index 0000000..76eb98c --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/ServerWebTests.cs @@ -0,0 +1,230 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using SQLInsight.Core.Configuration; +using SQLInsight.Core.Findings; +using SQLInsight.Core.Servers; +using SQLInsight.Core.SyntheticChecks; +using SQLInsight.Repository; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.IntegrationTests; + +/// Windows-servere and Syntetiske tjek in SQLInsight Web: the menu, the pages, the forms, the grant script. +[Collection(SqlServerTestGroup.Name)] +[Trait("Category", "Integration")] +public sealed partial class ServerWebTests(SqlServerFixture sql) : IAsyncLifetime +{ + private readonly string _database = "SQLInsightSrvWeb_" + Guid.NewGuid().ToString("N")[..8]; + private readonly RSA _agentKey = RSA.Create(3072); + private WebApplicationFactory _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(); + await _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, + }); + _factory = new WebApplicationFactory().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 HttpClient Client() => _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + private static async Task PostAsync(HttpClient client, string page, string action, Dictionary fields) + { + var html = await client.GetStringAsync(page); + var values = new List> { new("__RequestVerificationToken", WebUtility.HtmlDecode(AntiforgeryField().Match(html).Groups[1].Value)) }; + values.AddRange(fields); + using var content = new FormUrlEncodedContent(values); + return await client.PostAsync(action, content); + } + + private static int IdFrom(HttpResponseMessage response, string page) => + int.Parse(Regex.Match(response.Headers.Location!.OriginalString, page + @"/(\d+)").Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture); + + [Fact] + public async Task The_menu_has_a_servers_group_that_opens_on_its_pages() + { + var page = await Client().GetStringAsync("/servere"); + var checks = await Client().GetStringAsync("/syntetiske-tjek"); + + Assert.Contains("Servere", page, StringComparison.Ordinal); + Assert.Contains("href=\"/servere\"", page, StringComparison.Ordinal); + Assert.Contains("href=\"/syntetiske-tjek\"", page, StringComparison.Ordinal); + Assert.Matches("
]*data-nav-group=\"servere\"", page); + Assert.Matches("
]*data-nav-group=\"servere\"", checks); + Assert.Contains("Der er ingen Windows-servere endnu", page, StringComparison.Ordinal); + } + + [Fact] + public async Task Admin_adds_a_server_sees_its_data_findings_rights_and_the_grant_script() + { + var client = Client(); + var posted = await PostAsync(client, "/servere", "/servere/tilfoej", new() { ["host"] = "AOS01.firma.local", ["role"] = "Application", ["watched"] = "AOS60$01, W3SVC" }); + + Assert.Equal(HttpStatusCode.Redirect, posted.StatusCode); + var id = IdFrom(posted, "/servere"); + var row = (await _repository.GetWindowsServerAsync(id))!; + Assert.Equal(("AOS01", "aos01.firma.local", ServerRoles.Application), (row.Name, row.Host, row.Role)); + Assert.Equal(["AOS60$01", "W3SVC"], row.Watched); + Assert.Equal(AgentRequestStatus.Pending, (await _repository.GetLatestServerRequestAsync(ServerRequestKind.ServerTest, id))!.Status); + + // What the agent would have collected. + var now = DateTimeOffset.UtcNow; + await _repository.RecordServerContactAsync(id, new ServerOsInfo { Caption = "Microsoft Windows Server 2019 Standard", LastBootUpTime = now.AddDays(-3), TotalMemoryMb = 16384 }, + "WS-Man", [new ServerAreaStatus(ServerArea.Events, false, "Adgang nægtet til hændelseslog: mangler Event Log Readers.", ServerArea.Right(ServerArea.Events))], now); + await _repository.SaveServerPerformanceAsync(id, new ServerPerformanceSample(now.AddMinutes(-2), 35, 6000, 16384, 1, 40)); + await _repository.SaveServerPerformanceAsync(id, new ServerPerformanceSample(now.AddMinutes(-1), 45, 5900, 16384, 1, 40)); + await _repository.SaveServerSnapshotAsync(id, ServerArea.Disks, new ServerDiskSnapshot + { + CapturedAt = now, Disks = [new ServerDisk(@"D:\", "Data", "NTFS", 500, 40)], Forecasts = [new DiskForecast(@"D:\", 12, 3.3, 300, 14, 0.9)], + }, now); + await _repository.SaveServerSnapshotAsync(id, ServerArea.Services, new ServerServiceSnapshot + { + CapturedAt = now, Services = [new ServerService("AOS60$01", "Microsoft Dynamics AX Object Server 6.0$01", "Stopped", "Auto", ExitCode: 1067)], + }, now); + await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, id, + [ + new RuleResult + { + RuleId = "SQLI-WIN-006", RuleTitle = "Et drev bliver fuldt om få dage", Outcome = RuleOutcome.Findings, + Findings = [new Finding { RuleId = "SQLI-WIN-006", Severity = Severity.Medium, Title = @"Disken D:\ er fuld om ca. 12 dage", Explanation = "e", Recommendation = "r", Subject = @"forecast: D:\" }], + }, + new RuleResult { RuleId = "SQLI-WIN-009", RuleTitle = "Fejl i Windows' hændelseslog", Outcome = RuleOutcome.NotMeasurable, Reason = "Adgang nægtet til hændelseslog: mangler Event Log Readers." }, + ], now); + var finding = Assert.Single(await _repository.GetServerFindingsAsync(FindingScope.Server, id)); + + var detail = await client.GetStringAsync($"/servere/{id}"); + var list = await client.GetStringAsync("/servere"); + var findingPage = await client.GetStringAsync($"/servere/fund/{finding.FindingId}"); + var script = await client.GetAsync($"/servere/{id}/grant-windows-server.ps1"); + + Assert.Contains("Microsoft Windows Server 2019 Standard", detail, StringComparison.Ordinal); + Assert.Contains("fuld om ca. 12 dage", detail, StringComparison.Ordinal); + Assert.Contains("Microsoft Dynamics AX Object Server 6.0$01", detail, StringComparison.Ordinal); + Assert.Contains("Ikke målbar", detail, StringComparison.Ordinal); + Assert.Contains("mangler Event Log Readers", detail, StringComparison.Ordinal); + Assert.Contains(@"[string] $Account = 'FIRMA\AGENT01$'", detail, StringComparison.Ordinal); + Assert.Contains("aldrig være lokal administrator", detail, StringComparison.Ordinal); + Assert.Contains("Delvist målbar", list, StringComparison.Ordinal); + Assert.Contains(@"D:\ fuld om ca. 12 dage", list, StringComparison.Ordinal); + Assert.Contains(@"Disken D:\ er fuld om ca. 12 dage", findingPage, StringComparison.Ordinal); + Assert.Equal("text/plain; charset=utf-8", script.Content.Headers.ContentType!.ToString()); + var bytes = await script.Content.ReadAsByteArrayAsync(); + Assert.Equal([0xEF, 0xBB, 0xBF], bytes[..3]); + Assert.Contains(@"$Account = 'FIRMA\AGENT01$'", System.Text.Encoding.UTF8.GetString(bytes), StringComparison.Ordinal); + + var saved = await PostAsync(client, $"/servere/{id}", $"/servere/{id}/indstillinger", new() { ["role"] = "File", ["watched"] = "LanmanServer", ["ignored"] = "MyBatch*" }); + Assert.Equal(HttpStatusCode.Redirect, saved.StatusCode); + Assert.Equal((ServerRoles.File, "LanmanServer", "MyBatch*"), ((await _repository.GetWindowsServerAsync(id))!.Role, (await _repository.GetWindowsServerAsync(id))!.Watched[0], + (await _repository.GetWindowsServerAsync(id))!.Ignored[0])); + await PostAsync(client, $"/servere/{id}", $"/servere/{id}/test", []); + Assert.Equal(2, (await _repository.GetLatestServerRequestAsync(ServerRequestKind.ServerTest, id))!.RequestId); + await PostAsync(client, $"/servere/{id}", $"/servere/{id}/fjern", []); + Assert.Null(await _repository.GetWindowsServerAsync(id)); + } + + [Fact] + public async Task Bad_input_is_refused_with_a_reason_and_a_sql_host_cannot_be_removed() + { + var client = Client(); + var bad = await PostAsync(client, "/servere", "/servere/tilfoej", new() { ["host"] = @"sql01\PROD" }); + Assert.Contains("uden instansnavn", Uri.UnescapeDataString(bad.Headers.Location!.OriginalString), StringComparison.Ordinal); + + await _repository.SyncSqlHostServersAsync([("sql01.firma.local", "SQL01")], DateTimeOffset.UtcNow); + var host = Assert.Single(await _repository.GetWindowsServersAsync()); + var removed = await PostAsync(client, $"/servere/{host.ServerId}", $"/servere/{host.ServerId}/fjern", []); + Assert.Contains("SQL Server-host", Uri.UnescapeDataString(removed.Headers.Location!.OriginalString), StringComparison.Ordinal); + Assert.NotNull(await _repository.GetWindowsServerAsync(host.ServerId)); + await PostAsync(client, $"/servere/{host.ServerId}", $"/servere/{host.ServerId}/pause", new() { ["paused"] = "true" }); + Assert.True((await _repository.GetWindowsServerAsync(host.ServerId))!.Paused); + } + + [Fact] + public async Task Admin_adds_checks_of_each_kind_and_a_sql_password_is_sealed_for_the_agent() + { + var client = Client(); + var server = await _repository.AddWindowsServerAsync(new NewWindowsServer("AOS01", "aos01", ServerRoles.Application, [], []), null, DateTimeOffset.UtcNow); + var ax = await PostAsync(client, "/syntetiske-tjek", "/syntetiske-tjek/tilfoej", new() + { + ["name"] = "AOS01 AX-logon", ["kind"] = "AxLogon", ["serverId"] = server.ToString(System.Globalization.CultureInfo.InvariantCulture), ["serviceName"] = "AOS60$01", + ["host"] = "aos01", ["company"] = "DAT", ["businessConnectorLogon"] = "true", ["intervalMinutes"] = "5", ["url"] = "https://ignoreres/", + }); + var sqlCheck = await PostAsync(client, "/syntetiske-tjek", "/syntetiske-tjek/tilfoej", new() + { + ["name"] = "SQL01 AX-login", ["kind"] = "Sql", ["server"] = @"sql01\AX", ["database"] = "AX2012", ["authentication"] = "SqlLogin", ["userName"] = "axapp", + ["password"] = "Hemmelig-AX-1!", ["query"] = "SELECT COUNT(*) FROM dbo.DATAAREA", ["intervalMinutes"] = "10", + }); + var http = await PostAsync(client, "/syntetiske-tjek", "/syntetiske-tjek/tilfoej", new() + { + ["name"] = "Enterprise Portal", ["kind"] = "Http", ["url"] = "https://ep.firma.local/", ["expectedStatus"] = "200, 401", ["warnMilliseconds"] = "2000", + }); + var writes = await PostAsync(client, "/syntetiske-tjek", "/syntetiske-tjek/tilfoej", new() + { + ["name"] = "Skriver", ["kind"] = "Sql", ["server"] = "sql01", ["query"] = "UPDATE dbo.CustTable SET Name = 'x'", + }); + + var axId = IdFrom(ax, "/syntetiske-tjek"); + var axRow = (await _repository.GetSyntheticCheckAsync(axId))!; + Assert.Equal((SyntheticCheckKind.AxLogon, (int?)server, "AOS60$01", "DAT", (Uri?)null, 300), (axRow.Kind, axRow.ServerId, axRow.Settings.ServiceName, axRow.Settings.Company, + axRow.Settings.Url, axRow.IntervalSeconds)); + Assert.Equal(SyntheticCheckLimits.DefaultAxTimeoutSeconds, axRow.TimeoutSeconds); + Assert.Equal(AgentRequestStatus.Pending, (await _repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, axId))!.Status); + var sqlRow = (await _repository.GetSyntheticCheckAsync(IdFrom(sqlCheck, "/syntetiske-tjek")))!; + Assert.True(sqlRow.HasSecret); + Assert.Equal("Hemmelig-AX-1!", CredentialEnvelope.Open(sqlRow.SecretCipher!, "agent-test", _agentKey, sqlRow.Settings.SecretContext())); + Assert.DoesNotContain("Hemmelig-AX-1!", sqlRow.SettingsJson, StringComparison.Ordinal); + var httpRow = (await _repository.GetSyntheticCheckAsync(IdFrom(http, "/syntetiske-tjek")))!; + Assert.Equal([200, 401], httpRow.Settings.ExpectedStatus); + Assert.Contains("SELECT", Uri.UnescapeDataString(writes.Headers.Location!.OriginalString), StringComparison.Ordinal); + + await _repository.RecordSyntheticCheckResultAsync(axId, new SyntheticCheckResult + { + At = DateTimeOffset.UtcNow, Success = true, DurationMs = 12, Partial = true, + Message = "AOS-porten svarer (12 ms). Kun porten er testet: .NET Business Connector (AX 2009/2012) er ikke installeret på agentens server, så et AX-logon kan ikke prøves.", + }); + var page = await client.GetStringAsync($"/syntetiske-tjek/{axId}"); + var list = await client.GetStringAsync("/syntetiske-tjek"); + var edit = await client.GetStringAsync($"/syntetiske-tjek/{sqlRow.CheckId}"); + Assert.Contains("Kun porten er testet", page, StringComparison.Ordinal); + Assert.Contains("AOS01 AX-logon", list, StringComparison.Ordinal); + Assert.Contains("aos01:2712", list, StringComparison.Ordinal); + Assert.DoesNotContain("Hemmelig-AX-1!", edit, StringComparison.Ordinal); + Assert.Contains("tom: behold den gemte", edit, StringComparison.Ordinal); + + // Moving the SQL check to another server needs the password again. + var moved = await PostAsync(client, $"/syntetiske-tjek/{sqlRow.CheckId}", $"/syntetiske-tjek/{sqlRow.CheckId}/ret", new() + { + ["name"] = "SQL01 AX-login", ["kind"] = "Sql", ["server"] = "evil.example", ["authentication"] = "SqlLogin", ["userName"] = "axapp", + }); + Assert.Contains("Indtast adgangskoden igen", Uri.UnescapeDataString(moved.Headers.Location!.OriginalString), StringComparison.Ordinal); + var firstRun = (await _repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, axId))!.RequestId; + var run = await PostAsync(client, $"/syntetiske-tjek/{axId}", $"/syntetiske-tjek/{axId}/koer", []); + Assert.Contains("koersel=", run.Headers.Location!.OriginalString, StringComparison.Ordinal); + Assert.True((await _repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, axId))!.RequestId > firstRun); + await PostAsync(client, $"/syntetiske-tjek/{axId}", $"/syntetiske-tjek/{axId}/fjern", []); + Assert.Null(await _repository.GetSyntheticCheckAsync(axId)); + } +}