diff --git a/src/SQLInsight.Web/Api/ConfigurationApiEndpoints.cs b/src/SQLInsight.Web/Api/ConfigurationApiEndpoints.cs
index 0d1fea4..0ed1d05 100644
--- a/src/SQLInsight.Web/Api/ConfigurationApiEndpoints.cs
+++ b/src/SQLInsight.Web/Api/ConfigurationApiEndpoints.cs
@@ -1,7 +1,8 @@
using System.Text.Json;
-using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
using SQLInsight.Core.Configuration;
+using SQLInsight.Core.Servers;
+using SQLInsight.Core.SyntheticChecks;
using SQLInsight.Repository;
namespace SQLInsight.Web.Api;
@@ -82,25 +83,11 @@ public static partial class ConfigurationApiEndpoints
.WithSummary("Find SQL Server-instanser på en computer / Find SQL Server instances on a computer")
.WithDescription("202 Accepted with requestId. The agent asks SQL Server Browser (UDP 1434) and, on its own computer, the registry." + write);
api.MapGet("/agent-requests/{id:long}", RequestAsync)
- .WithSummary("Status og resultat af en anmodning til agenten / Status and result of an agent request").WithDescription(redacted.Trim());
+ .WithSummary("Status og resultat af en anmodning til agenten / Status and result of an agent request")
+ .WithDescription("Kinds: ConnectionTest, DatabaseDiscovery, InstanceDiscovery (instanceId), ServerTest (serverId), SyntheticCheckRun (checkId). "
+ + "Logins, accounts, user names, a failed check run's message and details marked sensitive are \"[udeladt]\" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on.");
}
- /// Logins and people's names, "[udeladt]" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on.
- internal sealed class Sensitive(bool include)
- {
- public static Sensitive For(IOptions options) => new(options.Value.ApiIncludesSensitiveEvidence);
-
- public bool Include => include;
-
- public string? Value(string? value) => include || value is null ? value : ApiV1Endpoints.Redacted;
-
- /// An error text from SQL Server or the agent, with the login in it (Login failed for user '…') left out.
- public string? Text(string? value) => include || value is null ? value : LoginInText().Replace(value, m => m.Groups[1].Value + "'" + ApiV1Endpoints.Redacted + "'");
- }
-
- [GeneratedRegex(@"(\b(?:user|login|bruger|login'et)\s+)'[^'\r\n]*'", RegexOptions.IgnoreCase)]
- private static partial Regex LoginInText();
-
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 (er den tom: den lokale gruppe Administratorer på webserveren).");
@@ -152,21 +139,28 @@ public static partial class ConfigurationApiEndpoints
SelectionChangedBy = s.Value(d.SelectionChangedBy),
};
- private static object Json(AgentRequestRow r, Sensitive s) => new
+ /// A request to the agent, the same in every endpoint (also the Windows server tests and check runs).
+ internal static object Json(AgentRequestRow r, Sensitive s) => new
{
Id = r.RequestId,
r.Kind,
InstanceId = r.ConfiguredInstanceId,
+ ServerId = r.Kind == ServerRequestKind.ServerTest ? r.TargetId : null,
+ CheckId = r.Kind == ServerRequestKind.SyntheticCheckRun ? r.TargetId : null,
r.Status,
RequestedBy = s.Value(r.RequestedBy),
r.RequestedAt,
r.StartedAt,
r.CompletedAt,
- Error = s.Text(r.Error),
+ Error = s.ServerText(r.Error),
Result = ResultJson(r, s),
};
- /// The agent's result; a connection test's login and error text are redacted like the rest.
+ ///
+ /// The agent's result, redacted like the rest: a connection test's login and error text, a Windows server test's
+ /// account and the account in its texts, a check run's failure message and the details marked sensitive. A result
+ /// that cannot be read is left out rather than returned unredacted.
+ ///
private static JsonElement? ResultJson(AgentRequestRow r, Sensitive s)
{
if (r.Result is not { Length: > 0 } result)
@@ -174,13 +168,50 @@ public static partial class ConfigurationApiEndpoints
return null;
}
- if (!s.Include && r.Kind == AgentRequestKind.ConnectionTest && AgentRequestJson.Deserialize(result) is { } test)
+ if (!s.Include)
{
- result = AgentRequestJson.Serialize(test with { Login = s.Value(test.Login), Error = s.Text(test.Error) });
+ string? redacted = r.Kind switch
+ {
+ AgentRequestKind.ConnectionTest => AgentRequestJson.Deserialize(result) is { } test
+ ? AgentRequestJson.Serialize(test with { Login = s.Value(test.Login), Error = s.Text(test.Error) })
+ : result,
+ ServerRequestKind.ServerTest => AgentRequestJson.Deserialize(result) is { } server
+ ? AgentRequestJson.Serialize(s.ServerTest(server))
+ : null,
+ ServerRequestKind.SyntheticCheckRun => Deserialize(result) is { } run ? AgentRequestJson.Serialize(s.CheckResult(run)) : null,
+ _ => result,
+ };
+ if (redacted is null)
+ {
+ return null;
+ }
+
+ result = redacted;
}
- using var document = JsonDocument.Parse(result);
- return document.RootElement.Clone();
+ try
+ {
+ using var document = JsonDocument.Parse(result);
+ return document.RootElement.Clone();
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ /// As , also for a type with required members the JSON lacks.
+ private static T? Deserialize(string json)
+ where T : class
+ {
+ try
+ {
+ return JsonSerializer.Deserialize(json, AgentRequestJson.Options);
+ }
+ catch (Exception ex) when (ex is JsonException or NotSupportedException)
+ {
+ return null;
+ }
}
private static InstanceInput Input(MonitoredInstanceBody body) => new(body.Name, body.Server, body.Port, body.Authentication, body.UserName, body.Password,
diff --git a/src/SQLInsight.Web/Api/Sensitive.cs b/src/SQLInsight.Web/Api/Sensitive.cs
new file mode 100644
index 0000000..b9aca07
--- /dev/null
+++ b/src/SQLInsight.Web/Api/Sensitive.cs
@@ -0,0 +1,103 @@
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Options;
+using SQLInsight.Core.Servers;
+using SQLInsight.Core.SyntheticChecks;
+
+namespace SQLInsight.Web.Api;
+
+///
+/// Logins, accounts and people's names in API v1: customer data, returned as "[udeladt]" unless
+/// SQLInsight:Web:ApiIncludesSensitiveEvidence is on, as evidence marked sensitive is.
+///
+internal sealed partial class Sensitive(bool include)
+{
+ public static Sensitive For(IOptions options) => new(options.Value.ApiIncludesSensitiveEvidence);
+
+ public bool Include => include;
+
+ public string? Value(string? value) => include || value is null ? value : ApiV1Endpoints.Redacted;
+
+ /// A value that is only redacted when (evidence and details marked so).
+ public string? Value(string? value, bool sensitive) => sensitive ? Value(value) : value;
+
+ /// An error text from SQL Server or the agent, with the login in it (Login failed for user '…') left out.
+ public string? Text(string? value) => include || value is null ? value : LoginInText().Replace(value, m => m.Groups[1].Value + "'" + ApiV1Endpoints.Redacted + "'");
+
+ ///
+ /// A text about a Windows server (the agent's errors, area details, rule reasons): as , and the
+ /// agent's account in "Agentens konto (FIRMA\gmsa$)" and, when known, wherever occurs.
+ ///
+ public string? ServerText(string? value, string? account = null)
+ {
+ if (include || value is null)
+ {
+ return value;
+ }
+
+ var text = AccountInText().Replace(Text(value)!, ApiV1Endpoints.Redacted);
+ return account is { Length: > 2 } ? text.Replace(account, ApiV1Endpoints.Redacted, StringComparison.OrdinalIgnoreCase) : text;
+ }
+
+ /// An HTTP address without its query (it may carry a token or a key): https://host/path?[udeladt].
+ public string? Url(Uri? url)
+ {
+ if (url is null)
+ {
+ return null;
+ }
+
+ if (include || !url.IsAbsoluteUri || (url.Query.Length == 0 && url.Fragment.Length == 0))
+ {
+ return url.ToString();
+ }
+
+ return url.GetLeftPart(UriPartial.Path) + (url.Query.Length > 0 ? "?" + ApiV1Endpoints.Redacted : string.Empty);
+ }
+
+ /// What a check points at, as , with an HTTP address's query left out.
+ public string Target(string kind, SyntheticCheckSettings settings)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+ return kind == SyntheticCheckKind.Http ? Url(settings.Url) ?? string.Empty : settings.Target(kind);
+ }
+
+ /// A Windows server test as the agent stored it, with the agent's account and its traces in the texts left out.
+ public WindowsServerTestResult ServerTest(WindowsServerTestResult test)
+ {
+ ArgumentNullException.ThrowIfNull(test);
+ if (include)
+ {
+ return test;
+ }
+
+ var account = test.Account;
+ return test with
+ {
+ Account = Value(account),
+ Error = ServerText(test.Error, account),
+ Hint = ServerText(test.Hint, account),
+ Notes = test.Notes.Select(n => ServerText(n, account)!).ToList(),
+ Areas = test.Areas.Select(a => a with { Detail = ServerText(a.Detail, account) }).ToList(),
+ };
+ }
+
+ /// A check run as the agent stored it: a failure's message and details marked sensitive left out, as in …/results.
+ public SyntheticCheckResult CheckResult(SyntheticCheckResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ return include
+ ? result
+ : result with
+ {
+ Message = result.Success ? result.Message : ApiV1Endpoints.Redacted,
+ Details = result.Details.Select(d => d.Sensitive ? d with { Value = ApiV1Endpoints.Redacted } : d).ToList(),
+ };
+ }
+
+ [GeneratedRegex(@"(\b(?:user|login|bruger|login'et)\s+)'[^'\r\n]*'", RegexOptions.IgnoreCase)]
+ private static partial Regex LoginInText();
+
+ /// "Agentens konto (FIRMA\gmsa$)", as SQLInsight.Servers.ServerErrors and the connection test write it: the part in brackets.
+ [GeneratedRegex(@"(?<=\bagentens konto \()[^)\r\n]+(?=\))", RegexOptions.IgnoreCase)]
+ private static partial Regex AccountInText();
+}
diff --git a/src/SQLInsight.Web/Api/ServerApiEndpoints.cs b/src/SQLInsight.Web/Api/ServerApiEndpoints.cs
index 699166e..4329f0f 100644
--- a/src/SQLInsight.Web/Api/ServerApiEndpoints.cs
+++ b/src/SQLInsight.Web/Api/ServerApiEndpoints.cs
@@ -1,4 +1,4 @@
-using System.Text.Json;
+using System.Globalization;
using Microsoft.Extensions.Options;
using SQLInsight.Core.Configuration;
using SQLInsight.Core.Findings;
@@ -29,158 +29,238 @@ 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.
+/// show and do. Reading needs the scope read; changing needs config:write for tokens, or for Windows users membership of
+/// SQLInsight:Web:AdminGroups (the web server's local Administrators while that is empty). Tests and check runs are
+/// queued for the agent (202 with a request id for /api/v1/agent-requests/{id}); one already waiting is reused.
+/// Passwords are write-only. The agent's account, logins, the names of the people who made changes, the query of an
+/// HTTP address, event messages, failure messages and evidence marked sensitive are "[udeladt]" unless
+/// SQLInsight:Web:ApiIncludesSensitiveEvidence is on, as in the rest of API v1.
///
public static class ServerApiEndpoints
{
+ /// The most findings one page of /api/v1/server-findings returns.
+ public const int MaxFindingPage = 500;
+
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.";
+ const string write = " Needs scope config:write for tokens, or for Windows users SQLInsight:Web:AdminGroups (empty: the web server's local Administrators).";
+ const string redacted = " The agent's account, logins and user names are \"[udeladt]\" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on.";
+ const string overview = "With the latest CPU and memory (last 10 minutes), the fullest disk, the disk that will be full first and the active findings per severity "
+ + "(none for a paused server), as on the page Windows-servere.";
- api.MapGet("/windows-servers", ListServersAsync).WithSummary("Windows-servere med status / Windows servers");
+ api.MapGet("/windows-servers", ListServersAsync).WithSummary("Windows-servere med status / Windows servers").WithDescription(overview + redacted);
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");
+ .WithDescription("201 Created. The agent tests the connection and rights at once (serverTestRequestId). Service lists are trimmed and duplicates removed." + write);
+ api.MapGet("/windows-servers/{id:int}", GetServerAsync).WithSummary("Én Windows-server / One Windows server").WithDescription(overview + redacted);
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);
+ .WithDescription("204. Collected data is kept. The server's synthetic checks are kept without the server (and its service). "
+ + "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");
+ .WithDescription("202 Accepted with requestId. A test already waiting for the agent is returned instead of a new one." + write);
+ api.MapGet("/windows-servers/{id:int}/test", LatestTestAsync).WithSummary("Seneste test og manglende rettigheder / Latest test and missing rights")
+ .WithDescription(redacted.Trim());
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");
+ .WithSummary("PowerShell-script med least-privilege-rettigheder til agentens konto / Least-privilege grant script for the agent's account")
+ .WithDescription("With the agent's account filled in only when SQLInsight:Web:ApiIncludesSensitiveEvidence is on; otherwise the shipped script with its placeholder "
+ + "(give the account with -Account). Run it with -WhatIf first.");
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}/disks", DisksAsync).WithSummary("Diske med ledig plads og prognose / Disks with free space and forecast")
+ .WithDescription("forecast.historyDays: how many days of SQLInsight's own samples the forecast is computed from.");
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}/events", EventsAsync).WithSummary("Grupperede fejl fra hændelsesloggen (days, standard 1) / Grouped event log errors")
+ .WithDescription("{ capturedAt, method, ignored, coverage, events }: how the latest collection read the logs, how many events it left out as known noise, "
+ + "why less may be seen, and the groups last seen in the period (days 1–90). Samples are \"[udeladt]\" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on.");
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("/windows-servers/{id:int}/rule-states", (int id, InsightRepository repository, IOptions options, CancellationToken ct) =>
+ RuleStatesAsync(FindingScope.Server, id, repository, options, ct))
+ .WithSummary("Reglernes udfald på serveren / Rule outcomes on the server").WithDescription(redacted.Trim());
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");
+ .WithDescription("status=active|resolved|all (default active), scope=server|check, id (with scope), limit (1–500, default 100), continuation. "
+ + "Active leaves out removed servers and checks, and paused ones unless scope and id name one (as on the pages). "
+ + "Returns an array. More pages: the response header X-Continuation holds the value for ?continuation=.");
+ api.MapGet("/server-findings/{id:long}", FindingAsync).WithSummary("Ét fund på en server eller et tjek, med historik / One server or check finding with history")
+ .WithDescription("history: the earlier occurrences of the same finding (same server or check, rule and subject), newest first.");
- api.MapGet("/synthetic-checks", ListChecksAsync).WithSummary("Syntetiske tjek med seneste resultat / Synthetic checks");
+ api.MapGet("/synthetic-checks", ListChecksAsync).WithSummary("Syntetiske tjek med seneste resultat / Synthetic checks")
+ .WithDescription("With the active findings per severity (none for a paused check). The query of an HTTP address, SQL logins and user names are \"[udeladt]\" "
+ + "unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on.");
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.MapGet("/synthetic-checks/{id:int}", GetCheckAsync).WithSummary("Ét syntetisk tjek / One synthetic check")
+ .WithDescription("The query of an HTTP address, SQL logins and user names are \"[udeladt]\" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on.");
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);
+ .WithDescription("The kind cannot change. A SQL login password must be given again when server, port or login change. "
+ + "Give the full address and login again: the redacted values from GET are not real ones." + 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.MapDelete("/synthetic-checks/{id:int}", RemoveCheckAsync).WithSummary("Fjern et syntetisk tjek / Remove a synthetic check")
+ .WithDescription("204. The sealed password is deleted at once; results and findings follow retention." + 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");
+ .WithDescription("202 Accepted with requestId. A run already waiting for the agent is returned instead of a new one. 409 for a paused check." + write);
+ api.MapGet("/synthetic-checks/{id:int}/results", ResultsAsync).WithSummary("Seneste kørsler (hours, standard 24) / Latest runs")
+ .WithDescription("A failed run's message and details marked sensitive are \"[udeladt]\" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on.");
+ api.MapGet("/synthetic-checks/{id:int}/rule-states", (int id, InsightRepository repository, IOptions options, CancellationToken ct) =>
+ RuleStatesAsync(FindingScope.Check, id, repository, options, ct))
+ .WithSummary("Reglernes udfald på tjekket / Rule outcomes on the check").WithDescription(redacted.Trim());
}
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.");
+ detail: "Ændringer kræver scope \"config:write\" (API-token) eller medlemskab af en gruppe i SQLInsight:Web:AdminGroups (er den tom: den lokale gruppe Administratorer på webserveren).");
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
+ private static object Counts(IReadOnlyDictionary? counts) => 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,
+ Critical = counts?.GetValueOrDefault(Severity.Critical) ?? 0,
+ High = counts?.GetValueOrDefault(Severity.High) ?? 0,
+ Medium = counts?.GetValueOrDefault(Severity.Medium) ?? 0,
+ Low = counts?.GetValueOrDefault(Severity.Low) ?? 0,
+ Info = counts?.GetValueOrDefault(Severity.Info) ?? 0,
};
- private static object Json(SyntheticCheckRow c, IOptions options) => new
+ private static object Json(WindowsServerRow w, Sensitive s, WindowsServerOverview? overview, IReadOnlyDictionary? counts) => 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,
+ Id = w.ServerId,
+ w.Name,
+ w.Host,
+ w.Role,
+ Source = w.IsSqlHost ? "sqlHost" : "web",
+ w.Paused,
+ WatchedServices = w.Watched,
+ IgnoredServices = w.Ignored,
+ w.Reachable,
+ w.LastContactAt,
+ w.LastErrorAt,
+ LastError = s.ServerText(w.LastError),
+ w.Protocol,
+ w.OperatingSystem,
+ w.OsVersion,
+ w.LastBootUpTime,
+ w.TotalMemoryMb,
+ w.LogicalProcessors,
+ Areas = w.Areas.Select(a => new { a.Area, a.Ok, Detail = s.ServerText(a.Detail), a.Right }),
+ Latest = overview?.Latest is { } p ? new { p.SampledAt, p.CpuPercent, p.AvailableMb, p.AvailablePct } : null,
+ WorstDisk = overview?.WorstDisk is { } d ? new { d.Volume, d.Label, d.SizeGb, d.FreeGb, d.FreePercent } : null,
+ SoonestFull = overview is { SoonestFull: { } f, Disks: { } disks }
+ ? new { f.Volume, f.DaysUntilFull, FullAt = f.FullAt(disks.CapturedAt), Text = f.Describe() }
+ : null,
+ Findings = Counts(counts),
+ w.CreatedAt,
+ CreatedBy = s.Value(w.CreatedBy),
+ w.UpdatedAt,
+ UpdatedBy = s.Value(w.UpdatedBy),
};
- private static object Json(StoredServerFinding f, IOptions options) => new
+ /// A check's settings, with the SQL login and an HTTP address's query redacted.
+ private static object Json(SyntheticCheckSettings c, Sensitive s) => new
+ {
+ c.Host,
+ c.Port,
+ Url = s.Url(c.Url),
+ c.ExpectedStatus,
+ c.ExpectedText,
+ c.UseWindowsAuthentication,
+ c.AllowInvalidCertificate,
+ c.Server,
+ c.Database,
+ c.Authentication,
+ UserName = s.Value(c.UserName),
+ c.Encryption,
+ c.TrustServerCertificate,
+ c.Query,
+ c.ExpectedAddresses,
+ c.Company,
+ c.ObjectServer,
+ c.Configuration,
+ c.BusinessConnectorLogon,
+ c.ServiceName,
+ c.WarnMilliseconds,
+ };
+
+ private static object Json(SyntheticCheckRow c, Sensitive s, IReadOnlyDictionary? counts)
+ {
+ var settings = c.Settings;
+ return new
+ {
+ Id = c.CheckId,
+ c.Name,
+ c.Kind,
+ c.ServerId,
+ c.ServerName,
+ Target = s.Target(c.Kind, settings),
+ c.IntervalSeconds,
+ c.TimeoutSeconds,
+ c.Paused,
+ Settings = Json(settings, s),
+ HasPassword = c.HasSecret,
+ SecretError = s.ServerText(c.SecretError),
+ c.LastRunAt,
+ c.LastSuccess,
+ c.LastDurationMs,
+ LastMessage = c.LastSuccess == false ? s.Value(c.LastMessage) : c.LastMessage,
+ c.ConsecutiveFailures,
+ Findings = Counts(counts),
+ c.CreatedAt,
+ CreatedBy = s.Value(c.CreatedBy),
+ c.UpdatedAt,
+ UpdatedBy = s.Value(c.UpdatedBy),
+ };
+ }
+
+ private static object Json(StoredServerFinding f, Sensitive s) => new
{
Id = f.FindingId,
Scope = f.Scope == FindingScope.Server ? "server" : "check",
ScopeId = f.ScopeId,
Name = f.ScopeName,
+ f.ScopePaused,
+ f.ScopeRemoved,
+ Status = f.ResolvedAt is null ? "active" : "resolved",
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) }),
+ Title = s.ServerText(f.Title),
+ Explanation = s.ServerText(f.Explanation),
+ Recommendation = s.ServerText(f.Recommendation),
+ Evidence = f.Evidence.Select(e => new { e.Name, Value = s.Value(e.Value, e.Sensitive), 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,
- };
+ private static IResult Accepted(AgentRequestRow request, Sensitive s) =>
+ Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", ConfigurationApiEndpoints.Json(request, s));
// ---- 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<(IReadOnlyDictionary Overviews, IReadOnlyDictionary<(string Scope, int ScopeId), IReadOnlyDictionary> Counts)>
+ OverviewAsync(InsightRepository repository, CancellationToken ct) =>
+ (await repository.GetWindowsServerOverviewsAsync(DateTimeOffset.UtcNow.AddMinutes(-10), ct).ConfigureAwait(false),
+ await repository.GetServerFindingCountsAsync(ct).ConfigureAwait(false));
- 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