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