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