Compare commits

..

26 commits

Author SHA1 Message Date
a3af1334e7 Version 1.5.0
Some checks failed
CI / MSI-installer (push) Has been skipped
deploy community / deploy (push) Successful in 14s
deploy hub / deploy (push) Successful in 18s
CI / Build og unit tests (push) Failing after 39s
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:06:36 +02:00
b29ebb424d Review 1.5: docs and appsettings for the reviewed behaviour: timeouts and backoff, new keys, API paging, dedup and redaction
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:06:22 +02:00
b010f37dcf Review 1.5: synthetic checks: "Kør nu" joins the running check, errors are stored once, paused checks are left alone
- "Kør nu" (ServerRequestHandler) goes through SyntheticCheckService.RunNowAsync: it joins a
  run of the check that is going on, or runs it under the same MaxParallelChecks throttle
  and registers it, so it never overlaps a scheduled run. A paused check is not run: the
  answer is "Tjekket er sat på pause." and nothing is stored, evaluated or opened.
- Server requests (test, run now) may take 3 min (the check's max timeout 120 s + the
  30 s service lookup + 30 s margin) instead of AgentRequestService's 2 min, so the result
  is always stored; a server test gets 2.5 min. Stale Running requests are cleaned after
  the longest timeout + 1 min.
- An exception a check run did not foresee becomes a stored failed result ("Tjekket kunne
  ikke køres: …"), so LastRunAt advances and the check waits its interval; it is logged
  once per change. A run that cannot even be stored is not retried before its interval
  (the agent remembers when it started it).
- The service lookup of a failing check has its own 30 s deadline; its error detail
  (naming the agent's account) is Sensitive.
- The rules get max(10, FailuresBeforeFinding, SlowRunsBeforeFinding) + 1 recent results
  (at most 100), so "Fejlet i træk" and FailuresBeforeFinding > 10 work.
- The interval is never under SyntheticCheckLimits.MinIntervalSeconds agent-side; state
  for removed checks is pruned; shutdown waits for running checks before the throttle is
  disposed; a TaskCanceledException that is not shutdown no longer ends the service.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:02:17 +02:00
6d8a5d9ad7 Review 1.5: a Windows server that hangs no longer holds up the agent; reads are bounded and backed off
Deadlines
- Event log over RPC (MS-EVEN6): the whole read has a deadline (OperationTimeout per log);
  the token cancels the EventLogSession's operations and is checked before each
  FormatDescription, and the agent stops waiting even if the native call ignores it.
  Reported as ServerAccessProblem.Timeout ("svarede ikke i tide over hændelsesloggens
  protokol (RPC) inden for N sekunder").
- Every CIM call runs on the thread pool and is awaited with the caller's token; a call
  that ignores cancellation is abandoned and the CIM session is closed once it returns.
- Each server cycle has a deadline (new SQLInsight:Servers:CycleTimeout, default 5 min,
  1-30 min, at least 2 x OperationTimeout): the areas not read by then are "ikke målbar"
  ("Serveren svarede ikke inden for N minutter, så indsamlingen blev afbrudt; …"),
  logged once, and the slot is freed.
- Scheduled tasks: run information is read within a time budget (CycleTimeout / 3); the
  rest keep their state without the last run, and the coverage note says so.
- "Test server" has an overall deadline (WindowsServerTester, default 2 min); areas not
  done in time are reported as not answered, so the result is always stored.

Backoff and memory
- A server that does not answer (Unreachable/Timeout, or a timed-out cycle) is backed
  off 1, 2, 5, 10, then 15 minutes, reset when it answers.
- Protocol Auto: the protocol that worked (e.g. DCOM after WS-Man was refused, also for
  access denied) is tried first for an hour, then WS-Man is tried again; a server whose
  event log RPC failed is read over CIM for an hour.
- Over WS-Man with read-only WinRM rights Task Scheduler's GetInfoByName is denied: with
  Auto the tasks are read over DCOM instead (remembered for the server); with WSMan forced
  the reason says to choose Auto or DCOM. The advice "winrm configSDDL default" (broader
  WinRM rights) is gone; texts follow the grant script (-WinRM read right,
  -EnableFirewall -AgentAddress), not Remote Management Users.

Events
- Each log continues from its own mark (ServerEventSnapshot.LogReadUntil, a small additive
  field in Core): a truncated System log no longer loses the events between its last
  read event and the newest Application event.
- CIM fallback: Win32_NTLogEvent is streamed and only the oldest max per log are kept
  (memory bounded, deterministic order), consistent with the RPC read and the coverage
  text, which now names the logs that had more.

Other
- CimInstances (and method results) are mapped to SQLInsight's types and disposed at once.
- Options are clamped when bound (ServerOptions/SyntheticCheckOptions.Normalize):
  OperationTimeout 5 s-5 min, MaxEventsPerCollection 10-10000, MaxWatched 1-200,
  MaxParallelServers/Checks 1-32, retention days >= 1, ForecastHistoryDays 1-365,
  FailuresBeforeFinding/SlowRunsBeforeFinding 1-99.
- Shutdown: the timer's cancellation is caught, running cycles are awaited before the
  throttle is disposed; a cycle's time is taken when it gets its slot; a TaskCanceledException
  that is not shutdown no longer ends the service.
- Retention maintenance also runs (hourly) when SQLInsight:Servers:Enabled is false but
  synthetic checks are on.
- LocalCimTests run alone (collection without parallelization), share one warmed-up
  session, use the same 2 min operation timeout and a 5 min deadline per test, a smaller
  workload, and report timings in assertion messages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:02:17 +02:00
f980bd3a4a Review 1.5: synthetic check runner: network surprises are results, AX process never outlives the agent
- An HTTP answer that breaks off while it is read (HttpIOException/IOException) is a
  failed check ("Svaret blev afbrudt, mens det blev læst: …"), not an exception that
  made the check rerun every tick. Other unforeseen network errors (IOException,
  SocketException, HttpRequestException, AuthenticationException) become
  "Forbindelsen fejlede: …"; one that hits the check's deadline is "Intet svar inden
  for N sekunder". DNS: an invalid name (ArgumentException) is a failed lookup.
- The HTTP "Adresse" detail (a URL may carry query secrets) and the AX "Object server"
  detail are Sensitive, so the API redacts them.
- SyntheticCheckRun's compiler-generated ToString no longer prints the SQL password.
- ProcessRunner (AX Business Connector logon): the PowerShell process tree is killed in
  a finally whenever it has not exited, also when the agent stops (before: only on the
  check's timeout, leaving an orphaned process and AX session). Output and errors are
  capped at 64 K characters each; the rest is read and dropped so the process never
  blocks on a full pipe.
- Tests: broken body, password redaction, bounded output, killed on agent stop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:01:47 +02:00
37d3d4b5ec Review 1.5: a check sent back from API v1 with [udeladt] login or URL query is refused, not saved
GET returns the SQL login and the URL query redacted; a client that PUTs it back unchanged
would otherwise replace the real values.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:54:38 +02:00
18cd879dbb Review 1.5: server and check pages: one overview query, paused checks are not run, bad numbers refused, seconds kept
- /servere reads the overview in one query and says which paused servers and checks are left out of the active
  findings; admin notes use InstanceSetup.WhoMayChangeText as main.
- The check form refuses a port, interval, timeout or limit that is not a number (Danish message) and clamps
  minutes before multiplying; an interval set to part of a minute through the API is shown and kept in seconds.
- "Kør nu" is refused for a paused check (message, no button); ?koersel= shows that run.
- A finding of a removed server or check does not link to a missing page and says why it is not active.
- The forecast text uses the history the forecasts were computed from; the grant script text matches the new
  script (-WhatIf first, -WinRM, -EnableFirewall -AgentAddress, -Remove only what it added).
- HTTP addresses are at most 2000 characters.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:53:26 +02:00
20829890e9 Review 1.5: API v1 redacts the agent's account, logins, user names and URL queries; history, event log metadata, paging
- The Sensitive helper (Value/Text) is shared (Api/Sensitive.cs) and gains ServerText ("Agentens konto (…)" and the
  test's account), Url (query left out), ServerTest and CheckResult.
- Windows servers and checks: CreatedBy/UpdatedBy, RequestedBy, LastError, area details, rule-state reasons, the
  check's SQL login and an HTTP address's query (settings.url and target) are "[udeladt]" unless
  ApiIncludesSensitiveEvidence is on. The grant script endpoint then returns the shipped template, Account
  "[udeladt]" and a note without the service account.
- GET /agent-requests/{id} rewrites ServerTest results (account and texts) and SyntheticCheckRun results (failure
  message, sensitive details) like ResultJson does for connection tests, and names serverId/checkId.
- Tests and runs reuse a request already waiting (said in the descriptions); running a paused check is 409.
- GET /server-findings: limit (max 500) and continuation with X-Continuation, evidence's sensitive flag, status,
  scopePaused/scopeRemoved; GET /server-findings/{id} has history. GET /windows-servers/{id}/events returns
  { capturedAt, method, ignored, coverage, events }. The server list/one server has latest, worstDisk, soonestFull
  and findings per severity; checks have findings per severity.
- Admin texts as main (AdminGroups, else the local Administrators); service lists trimmed and deduplicated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:53:26 +02:00
1c9d24a6a2 Review 1.5: repository: reuse waiting agent requests, active findings only on monitored servers and checks, cleanup on removal
- AddServerRequestAsync returns a ServerTest/SyntheticCheckRun already waiting for the agent (Pending, same kind and
  target) instead of queueing another one, like AddAgentRequestAsync; AgentRequestRow carries TargetId.
- Active server findings and the overview counts leave out removed servers and checks, and paused ones across all
  (a paused one's own list keeps its latest findings, as for instances); findings say ScopeRemoved/ScopePaused.
  GetServerFindingsAsync pages with OFFSET/FETCH.
- Removing a server (in the web UI or a SQL Server host by the agent's sync) detaches its synthetic checks in the same
  transaction (ServerId NULL, serviceName out of the settings); check queries only join servers in use.
- Removing a check clears its sealed password, key id, secret error and last message; a late agent result does not
  write to a removed check. (0018: UPDATE on LastMessage for the web login.)
- 0018: settings and service lists as nvarchar(max) (no 500 on long æøå queries); indexes on SampledAt/LastAt/RunAt
  for the retention deletes. Run details that do not fit are trimmed from the end instead of all dropped.
- One query for the overview's latest CPU/memory and disk collection per server.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:53:26 +02:00
53ffc008f0 Review 1.5: SQL Scripts describe the reviewed Windows server and check rules
Headers of SQLI-WIN-001/006/007/008/009/010/012/013 and SQLI-SYN-001 follow the rule
changes (not measurable instead of passed, caps and summary findings, hourly 24-hour
counts, per-user and updater tasks, neutral check titles); SQLI-WIN-010's severity
gains Low when the event log cannot be read (also in README.md).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:45:44 +02:00
7e66dc94c4 Review 1.5: server and check rules never pass unmeasured, cap and stabilize findings, keep sensitive text out of titles
- SQLI-WIN-006 is "ikke målbar" (with the forecaster's note) until a volume has enough
  history; titles "Drev X er fuldt (om ca. N dage | inden for et døgn)", as SQLI-WIN-005.
- SQLI-WIN-007/008: the startup grace period is "ikke målbar" instead of passed, so
  open findings are not resolved and reopened at every restart. One matcher for both
  rules (name or display name, prefix*). SQLI-WIN-007 is capped by
  Services:MaxFindings (default 15, 1-100) with one summary finding for the rest.
- SQLI-WIN-009: stable order (category, oldest first, key) and one summary finding
  over MaxFindings (now bounded 1-100).
- SQLI-WIN-010 only says "uden tegn på at det skete uventet" when the event log was
  read past the boot; before that "ikke målbar", and Low "årsagen kan ikke ses" when
  the log cannot be read.
- SQLI-WIN-012/013: Tasks:IgnoreUserAndUpdaterTasks (default true) leaves out per-user
  and updater tasks; TaskThresholds.Watches is the one filter for the agent and the
  rules. A SID is never in a title or subject, only in sensitive evidence.
- SQLI-SYN-001: neutral title "{navn} fejler ({n} gange i træk)", the failure message
  only in sensitive evidence; "mindst N" and "... eller tidligere" when every result
  given failed; "findes ikke" for a missing service; evidence "Tjenestens tilstand".
- SQLI-SYN-003: "udløber inden for et døgn" / "om 1 dag".
- Agent (minimal): the task filter is TaskThresholds.Watches, and performance samples
  get the configured interval.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:45:44 +02:00
2345b199fa Review 1.5: disk forecasts, 24-hour event counts, performance gaps and per-user tasks in Core
- DiskForecast.EnoughHistory: false for "ingen målinger endnu" and "for lidt historik",
  so a rule can say "ikke målbar" instead of "fyldes ikke op". Days are counted from
  now (a last point older than its hour is subtracted); FullAt takes the time the
  forecast was made; Describe says "fuld nu" at 0 days.
- ServerEventGroup.Buckets: counts per clock hour, and Recent sums only the hours with
  events in the last 24 hours (FirstAt within the window), so a recurring error no
  longer adds up from when it was first seen. Older groups without buckets still merge.
- ServerPerformanceMath.Sample takes the interval: after a gap of more than three
  intervals CPU and pages/s are null rather than the gap's average stamped now.
  Commit percent is computed in floating point.
- TaskResults.IsUserOrUpdaterTask (a user's SID in the name, OneDrive/Edge/Google
  updaters, User_Feed_Synchronization), HasUserSid and Display (SID replaced by a
  short hash for titles and subjects).
- Tests: forecast from real history, irregular sampling, identical timestamps,
  a shrunk volume, days from now; an error every ten hours over three days; a
  sample after a gap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:45:44 +02:00
f27a060e4f Review 1.5: docs: what Windows servers and synthetic checks store, the stricter SQL check, no hub or PDF yet
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:33:04 +02:00
697d243658 Review 1.5: grant-windows-server.ps1 backs up, removes exactly what it gave, and never more than read
The grant script changes SCM and WMI security on customer servers. Before, a re-run
replaced every ACE of the account on a WMI namespace (also a customer's Deny), -Remove
took the account out of groups and ACEs it had before, only the SCM SDDL was backed up
(and never used), Remote Management Users also opened PowerShell Remoting, and
-EnableFirewall narrowed Windows' built-in rules for every other tool and was never undone.

- Checks everything before changing anything: elevation, the account resolves (broad
  groups refused), Deny ACEs stop the script, -EnableFirewall needs -AgentAddress.
- Backs up the original SDDLs (SCM, WMI namespaces, WinRM), group memberships and
  firewall rules before the first change; records what it added in a state file in an
  administrators-only folder; -Remove removes only that.
- SDDL handled with RawSecurityDescriptor (GetSD/SetSD, sc sdshow/sdset): exactly one
  ACE added in canonical order, verified before writing and after; aliases (NS) work.
- No Remote Management Users: -WinRM adds GENERIC_READ (no Execute) to WinRM's RootSDDL;
  DCOM is the default.
- Firewall: its own "SQLInsight Agent: ..." rules only from the agent's address,
  removed again with -Remove; Windows' rules untouched.
- -WhatIf, groups by SID, Danish messages, PowerShell 2.0 syntax.
- ServerGrantScript.For does not fill in an account that could end the PowerShell
  string (typographic quotes, control characters); tests run the SDDL logic in
  Windows PowerShell against real default SDDLs.
- docs/windows-servere.md: rights, safety net, firewall.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:31:49 +02:00
cb99778a3f Review 1.5: SQL Scripts for Windows servers and synthetic checks follow main's structure and registry
After the rebase onto 1.4 the 16 SQLI-WIN/SQLI-SYN scripts had their own /* */ format,
so SqlScriptTests (rule ids, review header, -- Kilde: blocks) failed.

- The scripts have main's header (Regel-id, Titel, Alvor, Versioner, Vurdering,
  Grænseværdier, Rettigheder, Data kommer fra, Regelkode, Read-only); the Windows
  WQL/XPath stays word for word in comments.
- The synthetic SQL check's SQL is registered (SyntheticCheckRunner.UseDatabaseSql,
  BeginTransactionSql, RollbackSql, SyntheticCheckSettings.DefaultQuery) and shown
  as -- Kilde: blocks in SQLI-SYN-001/002 and _collectors/SyntheticCheckRunner.sql.
- SqlScriptTests know ServerRuleCatalog; only those rules may be without SQL blocks.
- ServerScriptTests: every CimQueries query/filter is in a SQLI-WIN script word for word.
- The SQL check cancels the rest of a large answer instead of draining it on close.
- Rule counts: README (108 built-in: 92 SQL Server + 13 Windows + 3 synthetic),
  SQL Scripts/README (rule table, 124 rules, Windows/synthetic section), RuleCatalogTests,
  CONTRIBUTING (how to add a server rule).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:18:24 +02:00
8d092b6a35 Review 1.5: the read-only check of typed queries can no longer be bypassed
The synthetic SQL check (and custom rules) used a regex that ended an [identifier]
at the first ] and a block comment at the first */, so "[x]]'] DELETE ..." hid a
DELETE inside what it took for a string. T-SQL also needs no semicolon between
statements, so "SELECT 1 COMMIT" or "SELECT 1 WRITETEXT ..." passed.

- ReadOnlyQuery lexes T-SQL as SQL Server does (nested comments, '' ]] ""),
  refuses unterminated comments/strings/identifiers, every statement keyword
  (COMMIT, ROLLBACK, SET, DECLARE, WRITETEXT, UPDATETEXT, RAISERROR, CHECKPOINT,
  ENABLE/DISABLE, RECEIVE/SEND ...), NEXT VALUE FOR (sequences are not rolled
  back) and update/exclusive lock hints, and a second SELECT unless joined by
  UNION/EXCEPT/INTERSECT.
- CustomRuleDefinition.QueryProblems uses it, so the synthetic check validates the
  same way when saved and before it runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:08:34 +02:00
3edc69b48f Servers tests: give the local CIM test room when the whole suite runs at once
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:59:22 +02:00
4c2d5252ad Docs: Windows-servere og syntetiske tjek (1.5)
- docs/windows-servere.md: what is collected and how often, how the agent reads
  servers (WS-Man, DCOM fallback, event log over MS-EVEN6), the exact least-privilege
  rights and grant-windows-server.ps1 (never local administrator), known limits,
  "ikke målbar" with the exact reason, firewall ports, the disk forecast, event
  noise filtering, rules with their thresholds, retention, configuration and API.
- docs/syntetiske-tjek.md: the kinds, the AX logon (port, then .NET Business
  Connector in a separate process, or clearly only the port), service running but
  not answering, sealed SQL passwords, rules, firewall, configuration and API.
- Linked from installation, integration (API table) and databehandling (retention,
  nothing sent to the hub); on the website in the documentation index, and the rule
  list shows the Windows server and synthetic check rules in their own areas.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:59:22 +02:00
063243532d Web: style the field groups of the synthetic check form
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:59:03 +02:00
1c18bcfab8 Web: Windows-servere and Syntetiske tjek pages, and the same in API v1 (scope config:write)
- Menu group "Servere" with Windows-servere (/servere) and Syntetiske tjek
  (/syntetiske-tjek).
- /servere: every server with status (overvåges, delvist målbar, kan ikke læses),
  CPU, free memory, the fullest disk and the nearest "fuld om ca. N dage", findings;
  add application/AOS/file servers. /servere/{id}: findings and what is not
  measurable and why, CPU/memory for the last day, disks with forecasts, watched
  and stopped services, grouped event log errors, scheduled tasks, rights per area
  with the latest test, the least-privilege PowerShell script (copy or download,
  UTF-8 with BOM), role and service lists, pause and remove. /servere/fund/{id}.
- /syntetiske-tjek: checks with status and response time; one form for every kind
  (TCP, HTTP(S), SQL login + query, DNS, AX logon) with an optional server and
  service; a SQL login password is sealed for the agent at once and never shown.
  /syntetiske-tjek/{id}: latest result (incl. "kun porten er testet"), findings,
  response times, history, edit, run now, pause, remove.
- API v1: /windows-servers (+ test, grant-script, performance, disks, services,
  events, tasks, rule-states), /server-findings, /synthetic-checks (+ run, results,
  rule-states); all in OpenAPI. Passwords are write-only; event samples, failure
  messages and sensitive evidence are "[udeladt]" unless
  SQLInsight:Web:ApiIncludesSensitiveEvidence is on. /api/v1/rules lists the new rules.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:58:52 +02:00
842286d57d SQL Scripts for the Windows server and synthetic check rules (SQLI-WIN-001..013, SQLI-SYN-001..003)
One reviewable file per rule id with a Danish header: what and why, thresholds
with config keys and defaults, severity, required rights (never local
administrator), supported versions, collector file and member, and the
evaluation in plain Danish. The Windows rules use no SQL: their exact CIM/WQL
queries, event log XPath and a read-only PowerShell snippet to verify them are in
a comment block. SQLI-SYN-001/002 carry the exact SQL a SQL check runs (session
settings, transaction that is always rolled back, SELECT 1). A test checks every
server and check rule has its file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:58:36 +02:00
6eae719e95 Agent: monitor Windows servers and run synthetic checks; server tests and check runs on request
- ServerMonitoringService keeps the SQL Server hosts in step with the monitored
  instances (SQLInsight:Servers:IncludeSqlHosts), picks up servers added, paused or
  changed in SQLInsight Web within a minute, and runs each server's due areas with
  a limit on parallelism. Default intervals: 1 min CPU/memory, 15 min disks and
  tasks, 5 min services and events (SQLInsight:Servers:Intervals).
- ServerMonitor: one CIM session per cycle; per-minute samples, disk samples and
  the "fuld om ca. N dage" forecast from the stored history, grouped events (read
  from where the last read stopped), services and tasks. A missing right makes
  only that area "ikke målbar" with the exact reason, logged once per change; a
  server that does not answer is notified once, and again when it answers.
  Survives an agent restart (samples, boot time, event position).
- SyntheticCheckService runs checks on their interval, opens a SQL login password
  sealed for the agent (AgentCredentialKey.OpenContext), looks up the linked
  Windows service when a check fails, stores results and findings, notifies with
  links to /servere/fund/{id}.
- AgentRequestService hands ServerTest and SyntheticCheckRun to ServerRequestHandler.
- appsettings.json: SQLInsight:Servers and SQLInsight:SyntheticChecks with every
  threshold, interval and retention.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:58:36 +02:00
7082b278e3 Collection: Windows servers over CIM (WS-Man, DCOM fallback) and synthetic check runners
New project SQLInsight.Servers:
- CimWindowsServerSource (Microsoft.Management.Infrastructure, the API behind
  Get-CimInstance): WS-Man first, DCOM if that fails, a local session for the
  agent's own computer, as the agent's identity. Only SELECT queries (CimQueries)
  and Task Scheduler's read-only GetInfo. CPU and paging from raw counters, disks
  (Win32_Volume, Win32_LogicalDisk fallback), services, scheduled tasks, and the
  System/Application logs through the Event Log service (MS-EVEN6, XPath filter on
  the server) with Win32_NTLogEvent as fallback.
- ServerErrors: access denied, missing namespace, RPC/WinRM unreachable and
  timeouts become "ikke målbar" reasons that name the missing right and the grant
  script; never local administrator.
- WindowsServerTester: tries every area once for the web UI's test.
- SyntheticCheckRunner: TCP, HTTP(S) with expected status/text and TLS certificate
  expiry (also for refused certificates), SQL login + read-only query (re-validated,
  in a transaction that is always rolled back), DNS, and the Dynamics AX logon:
  AOS port, then a logon through the .NET Business Connector in a separate Windows
  PowerShell process with a timeout when it is installed; otherwise the result
  says clearly that only the port was tested.

Tests: error mapping, queries, AX command/parsing, runners against local TCP and
HTTP(S) servers with a self-signed certificate, the CIM source against this
computer, and the SQL check against the test instance.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:58:18 +02:00
25ecdbc3e7 Repository: Windows servers, samples, events, synthetic checks and their findings (0018)
Migration 0018_windows_servers_synthetic_checks:
- insight.WindowsServers (SQL Server hosts kept in step by the agent, servers added
  in SQLInsight Web), ServerPerformance (per minute, one row per sample),
  ServerDiskSamples (forecast history), ServerEvents (grouped like ErrorLogEvents),
  ServerSnapshots (latest per area).
- insight.SyntheticChecks (SQL login password sealed for the agent, as in 1.3) and
  SyntheticCheckResults.
- insight.ServerFindings and ServerRuleStates, scoped to a server or a check.
- AgentRequests.TargetId and the kinds ServerTest and SyntheticCheckRun.
- SQLInsight Web gets INSERT and column UPDATE only; host, name and kind are fixed,
  nothing the agent records can be written, no DELETE.

Findings are opened, updated and resolved like instance findings; a round where a
rule is not measurable keeps what was found open. Retention for samples, disk
history, events, check results and resolved findings (RunServerMaintenanceAsync).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:58:18 +02:00
ba7c751b5c Rules: Windows servers (SQLI-WIN-001..013) and synthetic checks (SQLI-SYN-001..003)
Scoped rules with their own context (a Windows server, a synthetic check) that
produce the same findings and outcomes as the instance rules; missing data is
"ikke målbar" with the collector's reason. Thresholds are settings
(SQLInsight:Servers, SQLInsight:SyntheticChecks).

- Sustained CPU, low memory (share and MB), paging with low memory, commit charge.
- Free disk space and "fuld om ca. N dage" forecasts.
- Stopped automatic services (minus those that stop by design, and a grace
  period after a restart) and watched services that must run.
- Windows event log groups: important kinds at once, ordinary errors when they
  repeat, capped per server; sample messages are sensitive.
- Unexpected restart (Kernel-Power 41, EventLog 6008, BugCheck), optional uptime.
- Scheduled tasks that failed or did not run as planned.
- Synthetic checks failing (twice in a row), "svarer ikke, selv om tjenesten
  kører", slow answers, TLS certificates about to expire.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:58:18 +02:00
ae20954a91 Windows servers and synthetic checks core: data, disk forecast, event dedup, grant script
- Core/Servers: what the agent collects from a Windows server (CPU and memory from
  raw counters, disks, services, events, scheduled tasks), roles and sources, host
  validation and the computer behind a SQL Server data source.
- DiskForecaster: "fuld om ca. N dage" from a least-squares line over 14 days of
  SQLInsight's own samples, only since the volume last changed size, with sanity
  bounds (history, samples, R², max one year).
- ServerEventAnalysis: noise filter, grouping and a 24-hour window for System and
  Application events, like the SQL Server error log in 1.2.
- db/permissions/grant-windows-server.ps1: least-privilege rights for the agent's
  account (groups, WMI namespaces, SCM read), never local administrator; filled in
  by ServerGrantScript.
- Core/SyntheticChecks: kinds, settings, validation (read-only SQL), results.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:58:17 +02:00
118 changed files with 15779 additions and 77 deletions

View file

@ -40,6 +40,8 @@ Warnings behandles som fejl, og .NET-analyzers kører på niveauet `latest-recom
5. Registrér reglen i `BuiltInRules`.
6. Tilføj `SQL Scripts/<id>.sql` med den danske header (hvad, vurdering, grænser, alvor, rettigheder, versioner, collector) og collectorens SQL, ordret og med en `-- Kilde:`-linje pr. variant. Brug en eksisterende fil som skabelon. `SqlScriptTests` fejler, hvis filen mangler, eller hvis SQL'en ikke længere er den, koden sender.
En regel om en Windows-server eller et syntetisk tjek (fra 1.5, `SQLI-WIN-…`/`SQLI-SYN-…`) implementerer `IScopedRule<TContext>` i `src/SQLInsight.Rules/Servers/` og registreres i `WindowsServerRules.Create()` eller `SyntheticCheckRules.Create()` (dermed i `ServerRuleCatalog`). Dens `SQL Scripts/<id>.sql` har samme header; forespørgslerne mod Windows står ordret i kommentarer og skal stå i `src/SQLInsight.Servers/CimQueries.cs` (`ServerScriptTests` tjekker det). Agenten må kun læse: CIM-`SELECT`, hændelsesloggen og skrivebeskyttede metoder som Task Schedulers `GetInfoByName`, aldrig start/stop eller ændringer.
### Uden C#: community-regler
Mange regler kan skrives som en SQL-forespørgsel i en JSON-fil under `rules/community/`. De kræver ingen C# og kan afprøves direkte mod en instans med `SQLInsight.Agent rules test`. Se [docs/regler.md](docs/regler.md). Også en community-regel skal have sin `SQL Scripts/<id>.sql` med forespørgslen ordret (se fx `SQL Scripts/COM-SEC-001.sql`).

View file

@ -14,7 +14,7 @@
<Product>SQLInsight Pro</Product>
<Copyright>Copyright (c) SEOSoft</Copyright>
<!-- Release version; set with -p:Version=… when publishing. Update checks compare against it. -->
<Version>1.4.0</Version>
<Version>1.5.0</Version>
</PropertyGroup>
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">

View file

@ -22,6 +22,7 @@
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.12" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.12" />
<PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.12" />
<PackageVersion Include="Microsoft.Management.Infrastructure" Version="3.0.0" />
<PackageVersion Include="System.DirectoryServices.AccountManagement" Version="10.0.12" />
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="10.0.12" />
<PackageVersion Include="PDFsharp-MigraDoc" Version="6.2.4" />

View file

@ -4,7 +4,7 @@
SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA.
> **Status:** Fase 1–4 er implementeret: agent, 92 indbyggede regler plus community-regler, notifikationer, lokalt repository og web-UI, licenser, signerede opdateringer, SQLInsight Hub med portal, erfaring på tværs af kunder, PDF-rapporter og integration med Prometheus, Grafana og SCOM, plus MSI-installer, community-websted og Discourse-forum. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [integration](docs/integration.md), [rapporter](docs/rapporter.md), [egne regler](docs/regler.md), [Dynamics AX](docs/dynamics-ax.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md).
> **Status:** Fase 1–4 er implementeret: agent, 108 indbyggede regler (92 for SQL Server; fra 1.5 også 13 for Windows-servere og 3 for syntetiske tjek) plus community-regler, notifikationer, lokalt repository og web-UI, licenser, signerede opdateringer, SQLInsight Hub med portal, erfaring på tværs af kunder, PDF-rapporter og integration med Prometheus, Grafana og SCOM, plus MSI-installer, community-websted og Discourse-forum. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [integration](docs/integration.md), [rapporter](docs/rapporter.md), [egne regler](docs/regler.md), [Dynamics AX](docs/dynamics-ax.md), [Windows-servere](docs/windows-servere.md), [syntetiske tjek](docs/syntetiske-tjek.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md).
---

View file

@ -7,6 +7,10 @@ rettigheder, på hvilke versioner, og hvordan reglen bagefter vurderer data.
Agenten kører **ikke** filerne her. De er en læsbar kopi af SQL'en i koden, og unit tests sikrer, at kopien er
identisk med koden (se [Hold mappen i sync](#hold-mappen-i-sync)).
Fra 1.5 har også reglerne for Windows-servere (`SQLI-WIN-…`) og syntetiske tjek (`SQLI-SYN-…`) en fil her, med samme
header. De fleste af dem sender ingen SQL: forespørgslerne mod Windows (CIM/WQL og hændelseslogsfilteret) står ordret i
kommentarer, så de kan gennemgås på samme måde (se [Windows-servere og syntetiske tjek](#windows-servere-og-syntetiske-tjek-fra-15)).
## Navngivning
| Fil | Indhold |
@ -16,6 +20,7 @@ identisk med koden (se [Hold mappen i sync](#hold-mappen-i-sync)).
| `_collectors/InstanceProbe.sql` | Registreringen af instansen (version, udgave, Always On) og de sessionsindstillinger, agenten sætter på hver forbindelse. |
| `_collectors/AxDatabaseDetector.sql`, `_collectors/AxBatchCollector.sql`, `_collectors/AxSequenceCollector.sql` | Dynamics AX (fra 1.4): den fælles AX-genkendelse og læsningen af batchjob, nummerserier og RecId i AX-databasen. AX-forespørgslerne er vist for hver AX-version, SQLInsight har et skema for (Axapta 3.0, AX 4.0, AX 2009 og AX 2012), og for databasen `AXDB`. |
| `_collectors/DatabaseDiscovery.sql`, `_collectors/PermissionCheck.sql` | Søgningen efter databaser (med Dynamics AX-genkendelse) og forbindelsestesten med rettighedstjek under *Servere og databaser* (fra 1.3). De køres kun, når der bedes om dem i webbrugerfladen, og søgningen desuden hver time. |
| `_collectors/SyntheticCheckRunner.sql` | Det syntetiske SQL-tjek (fra 1.5): sessionsindstillingerne, `USE`, transaktionen og standardforespørgslen. Tjekkets egen forespørgsel køres i stedet for `SELECT 1`, og transaktionen rulles altid tilbage. |
## Opbygning af en regelfil
@ -75,15 +80,28 @@ Bemærk:
(fx baseline-historikken bag `SQLI-ANOM-001`, som ikke hentes fra den overvågede server) og kundens lokale regler
(`LOCAL-…`).
## Windows-servere og syntetiske tjek (fra 1.5)
- **`SQLI-WIN-…`** sender ingen SQL. Filen har samme header som de andre regler (rettighederne er dem, som
`db/permissions/grant-windows-server.ps1` giver agentens konto, aldrig lokal administrator) og viser derefter, i
kommentarer, de præcise CIM/WQL-forespørgsler (kun `SELECT`), XPath-filteret til hændelsesloggen og det eneste
metodekald, Task Schedulers skrivebeskyttede `GetInfoByName`, samt PowerShell til at efterprøve dem. Agenten starter,
stopper eller ændrer intet på serveren. Filerne kan åbnes i SSMS, men gør intet.
- **`SQLI-SYN-001` og `SQLI-SYN-002`** viser præcis det, et syntetisk SQL-tjek sender: sessionsindstillingerne, `USE`
(kun med en database), `BEGIN TRANSACTION`, forespørgslen (standard `SELECT 1`) og `IF @@TRANCOUNT > 0 ROLLBACK
TRANSACTION`, der altid køres bagefter. En egen forespørgsel skal være én skrivebeskyttet SELECT
(`src/SQLInsight.Core/CustomRules/ReadOnlyQuery.cs`). TCP-, HTTP-, DNS- og AX-tjek sender ingen SQL.
- **`SQLI-SYN-003`** (TLS-certifikatet) sender ingen SQL.
## Hold mappen i sync
`tests/SQLInsight.Collectors.Tests/SqlScripts/SqlScriptTests.cs` fejler, hvis mappen og koden ikke stemmer overens:
- `Every_registered_rule_has_a_script_and_every_script_a_rule`: hver indbygget regel (`BuiltInRules`) og hver
community-regel i regelpakken har en `<id>.sql`, og der er ingen filer for ukendte id'er.
- `Every_registered_rule_has_a_script_and_every_script_a_rule`: hver indbygget regel (`BuiltInRules` og, fra 1.5,
`ServerRuleCatalog`) og hver community-regel i regelpakken har en `<id>.sql`, og der er ingen filer for ukendte id'er.
- `Scripts_contain_the_current_sql_from_the_code`: hver blok med `-- Kilde: <nøgle>` indeholder SQL'en, som koden
bygger den i dag, ordret (nøglerne står i `SqlScriptSources.cs`). Kun værdier, agenten beregner ved kørsel, må stå
anderledes.
anderledes. Kun Windows-server- og syntetiske-tjek-reglerne må være uden SQL-blokke.
- `Every_collector_query_is_registered_and_in_a_collector_script`: hver SQL-konstant og hver `Build…Sql`-metode i
collectorerne er registreret, og hver registreret forespørgsel står i et `_collectors/`-script.
- `Community_rule_scripts_contain_the_rule_query_verbatim`: community-reglens `query` fra JSON står ordret i filen.
@ -91,7 +109,11 @@ Bemærk:
`EXEC [db].sys.sp_executesql N'…'`.
- `tests/SQLInsight.Collectors.Tests/SqlScriptTests.cs` (Dynamics AX): hver AX-regel og AX-collector viser SQL'en for alle
AX-versioner, og headeren siger, hvad der ikke er verificeret mod kundedata.
en collectors SQL ændret, kan blokkene skrives om fra koden: kør testene med `SQLINSIGHT_UPDATE_SQL_SCRIPTS=1`
- `tests/SQLInsight.Servers.Tests/ServerScriptTests.cs` (fra 1.5): hver CIM/WQL-forespørgsel og hvert
hændelseslogsfilter i `src/SQLInsight.Servers/CimQueries.cs` står ordret i en `SQLI-WIN-…`-fil, og hver af filerne viser
mindst én af dem. `tests/SQLInsight.Rules.Tests/ServerRuleScriptTests.cs`: regel-id og titel i headeren er reglens.
Er en collectors SQL ændret, kan blokkene skrives om fra koden: kør testene med `SQLINSIGHT_UPDATE_SQL_SCRIPTS=1`
(kommentarlinjerne bevares). Blokke med værdier, agenten beregner ved kørsel (⟪…⟫), udfyldes kun, når de er nye; værdien så sættes ind i hånden.
En ny regel skal derfor have sin `SQL Scripts/<id>.sql` (se `CONTRIBUTING.md`). Ændres en collectors SQL, peger
@ -99,7 +121,7 @@ testen på de filer, der skal opdateres.
## Regler
108 regler: 92 indbyggede (C#) og 16 community-regler (JSON).
124 regler: 108 indbyggede (C#: 92 for SQL Server, 13 for Windows-servere og 3 for syntetiske tjek, fra 1.5) og 16 community-regler (JSON).
| Regel-id | Fil | Titel | Collector(s) | Alvor |
|---|---|---|---|---|
@ -181,6 +203,9 @@ testen på de filer, der skal opdateres.
| SQLI-STAT-001 | [SQLI-STAT-001.sql](SQLI-STAT-001.sql) | Forældede statistikker på store tabeller | IndexCollector | Low |
| SQLI-STAT-002 | [SQLI-STAT-002.sql](SQLI-STAT-002.sql) | Automatisk statistik er slået fra | DatabaseCollector | Medium |
| SQLI-SVC-001 | [SQLI-SVC-001.sql](SQLI-SVC-001.sql) | En SQL Server-tjeneste, der skal starte automatisk, kører ikke | InstanceConfigurationCollector | High |
| SQLI-SYN-001 | [SQLI-SYN-001.sql](SQLI-SYN-001.sql) | Et syntetisk tjek fejler | SyntheticCheckRunner | High. |
| SQLI-SYN-002 | [SQLI-SYN-002.sql](SQLI-SYN-002.sql) | Et syntetisk tjek svarer langsomt | SyntheticCheckRunner | Medium. |
| SQLI-SYN-003 | [SQLI-SYN-003.sql](SQLI-SYN-003.sql) | TLS-certifikatet udløber snart | SyntheticCheckRunner (HTTPS, ingen SQL) | Medium; High inden for CertificateCriticalDays; Critical når det er udløbet. |
| SQLI-TEMP-001 | [SQLI-TEMP-001.sql](SQLI-TEMP-001.sql) | Version store i tempdb er stor eller vokser | TempdbCollector | Medium |
| SQLI-TEMP-002 | [SQLI-TEMP-002.sql](SQLI-TEMP-002.sql) | tempdb er næsten fuld | TempdbCollector | High |
| SQLI-TEMP-003 | [SQLI-TEMP-003.sql](SQLI-TEMP-003.sql) | Session bruger meget tempdb i lang tid | TempdbCollector | Medium |
@ -195,6 +220,19 @@ testen på de filer, der skal opdateres.
| SQLI-WAIT-005 | [SQLI-WAIT-005.sql](SQLI-WAIT-005.sql) | Commits venter på langsom skrivning til transaktionsloggen | WaitStatsCollector | Medium; High fra 15 ms i gennemsnit. |
| SQLI-WAIT-006 | [SQLI-WAIT-006.sql](SQLI-WAIT-006.sql) | Parallelle forespørgsler venter meget på hinanden | WaitStatsCollector | Medium; High fra 16 ventende tasks i gennemsnit. |
| SQLI-WAIT-007 | [SQLI-WAIT-007.sql](SQLI-WAIT-007.sql) | Contention på sider i hukommelsen (PAGELATCH) | WaitStatsCollector | Medium; High fra 4 ventende tasks i gennemsnit. |
| SQLI-WIN-001 | [SQLI-WIN-001.sql](SQLI-WIN-001.sql) | CPU-forbruget er højt i længere tid | CimWindowsServerSource (Windows, ingen SQL) | High; Critical når gennemsnittet er 98 % eller derover. |
| SQLI-WIN-002 | [SQLI-WIN-002.sql](SQLI-WIN-002.sql) | Serveren har næsten ingen ledig hukommelse | CimWindowsServerSource (Windows, ingen SQL) | Medium; High under 2 % og under 256 MB. |
| SQLI-WIN-003 | [SQLI-WIN-003.sql](SQLI-WIN-003.sql) | Serveren pager hukommelse til disk | CimWindowsServerSource (Windows, ingen SQL) | Medium. |
| SQLI-WIN-004 | [SQLI-WIN-004.sql](SQLI-WIN-004.sql) | Den reserverede hukommelse (commit) er tæt på grænsen | CimWindowsServerSource (Windows, ingen SQL) | Medium; High ved 97 % eller derover. |
| SQLI-WIN-005 | [SQLI-WIN-005.sql](SQLI-WIN-005.sql) | Lidt ledig plads på et drev | CimWindowsServerSource (Windows, ingen SQL) | Medium under FreePercent, High under CriticalFreePercent, Critical under 1 GB eller 1 %. |
| SQLI-WIN-006 | [SQLI-WIN-006.sql](SQLI-WIN-006.sql) | Et drev bliver fuldt om få dage | CimWindowsServerSource (Windows, ingen SQL) | Medium; High inden for 7 dage; Critical inden for 2 dage. |
| SQLI-WIN-007 | [SQLI-WIN-007.sql](SQLI-WIN-007.sql) | En tjeneste, der skal starte automatisk, kører ikke | CimWindowsServerSource (Windows, ingen SQL) | Medium. |
| SQLI-WIN-008 | [SQLI-WIN-008.sql](SQLI-WIN-008.sql) | En overvåget tjeneste kører ikke | CimWindowsServerSource (Windows, ingen SQL) | High når tjenesten ikke kører; Medium når den ikke findes (eller ikke kan ses). |
| SQLI-WIN-009 | [SQLI-WIN-009.sql](SQLI-WIN-009.sql) | Fejl i Windows' hændelseslog | CimWindowsServerSource (Windows, ingen SQL) | High: disk/filsystem, ressourcer, niveau Critical, tjeneste stoppet 3+ gange. Medium: tjeneste stoppet, program gik ned, Dynamics AX. Low: almindelige fejl der gentager sig. (Uventet nedlukning: se SQLI-WIN-010.) |
| SQLI-WIN-010 | [SQLI-WIN-010.sql](SQLI-WIN-010.sql) | Serveren er genstartet | CimWindowsServerSource (Windows, ingen SQL) | High ved uventet genstart; Info ved genstart uden tegn på at den var uventet; Low når hændelsesloggen ikke kan læses. |
| SQLI-WIN-011 | [SQLI-WIN-011.sql](SQLI-WIN-011.sql) | Serveren er ikke genstartet i lang tid | CimWindowsServerSource (Windows, ingen SQL) | Low. |
| SQLI-WIN-012 | [SQLI-WIN-012.sql](SQLI-WIN-012.sql) | En planlagt opgave fejlede sidste gang, den kørte | CimWindowsServerSource (Windows, ingen SQL) | Medium. |
| SQLI-WIN-013 | [SQLI-WIN-013.sql](SQLI-WIN-013.sql) | En planlagt opgave kørte ikke, da den skulle | CimWindowsServerSource (Windows, ingen SQL) | Medium. |
| COM-AX-001 | [COM-AX-001.sql](COM-AX-001.sql) | Dynamics AX 3.0 er uden support fra Microsoft | CustomQueryCollector | Medium |
| COM-AX-002 | [COM-AX-002.sql](COM-AX-002.sql) | Dynamics AX 3.0 kører på en SQL Server-version, der ikke var understøttet til AX 3.0 | CustomQueryCollector | Medium |
| COM-AX-003 | [COM-AX-003.sql](COM-AX-003.sql) | Dynamics AX-database på en instans, hvor MAXDOP ikke er 1 (SQL Server 2005-2014) | CustomQueryCollector | Medium |
@ -247,3 +285,4 @@ testen på de filer, der skal opdateres.
| [AxDatabaseDetector](_collectors/AxDatabaseDetector.sql) | `src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs` | 1 time som standard (SQLInsight:CollectorIntervals:dynamics-ax); uden AX-læseadgang hvert 5. minut | SQL Server 2005 og nyere | 17 regler (alle AX-regler) |
| [AxBatchCollector](_collectors/AxBatchCollector.sql) | `src/SQLInsight.Collectors/DynamicsAx/AxBatchCollector.cs` | 5 min som standard (SQLInsight:CollectorIntervals:dynamics-ax-batch) | SQL Server 2005 og nyere; kræver valgfri AX-læseadgang | 4 regler |
| [AxSequenceCollector](_collectors/AxSequenceCollector.sql) | `src/SQLInsight.Collectors/DynamicsAx/AxSequenceCollector.cs` | 1 time som standard (SQLInsight:CollectorIntervals:dynamics-ax-sequences) | SQL Server 2005 og nyere; kræver valgfri AX-læseadgang | 5 regler |
| [SyntheticCheckRunner](_collectors/SyntheticCheckRunner.sql) | `src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs` | tjekkets eget interval (standard 300 sek, mindst 60) og "Kør nu" | SQL Server 2005 og nyere | 2 regler (SQLI-SYN-001, SQLI-SYN-002) |

View file

@ -0,0 +1,91 @@
-- ===================================================================================================================
-- SQLI-SYN-001: Et syntetisk tjek fejler
-- ===================================================================================================================
-- Regel-id: SQLI-SYN-001
-- Titel: Et syntetisk tjek fejler
-- Alvor: High.
-- Versioner: SQL: SQL Server 2005 og nyere (SqlClient, ODBC-fallback for 2005). AX-logon: AX 2009 (kræver Windows PowerShell
-- 2.0-motoren) og AX 2012. Windows-tjenester: Windows Server 2008 R2 og nyere.
--
-- Hvad tjekkes, og hvorfor:
-- Agenten gør det, en bruger eller applikation ville gøre – forbinder til en port, henter en side, logger på SQL Server
-- og kører en forespørgsel, slår et navn op, logger på Dynamics AX – og det fejler. Er tjekket knyttet til en tjeneste
-- på en Windows-server, og kører tjenesten, siges det direkte: "… svarer ikke, selv om tjenesten kører" – den fejl,
-- en tjenesteovervågning alene ikke ser.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke på den overvågede server):
-- Har de seneste FailuresBeforeFinding kørsler alle fejlet, er det et fund. Titlen siger, om den tilknyttede tjeneste
-- kører, er stoppet (eller i en anden tilstand) eller ikke findes, eller at tjekket fejler N gange i træk. Fejlteksten
-- står aldrig i titlen (den sendes uredigeret til API og notifikationer), kun som måling (følsom). Er alle de
-- resultater, agenten har givet reglen, fejl, siges "mindst N" gange, og "Fejler siden" er "… eller tidligere".
--
-- Grænseværdier (agentens appsettings.json og tjekkets indstillinger):
-- SQLInsight:SyntheticChecks:FailuresBeforeFinding standard 2 (fejl i træk før et fund: ét tabt svar er ingen alarm)
-- Pr. tjek: interval (standard 300 s, 60–86400) og timeout (standard 15 s, AX 60 s, højst 120).
--
-- Rettigheder:
-- TCP, HTTP(S), DNS: ingen ud over netværksadgang fra agentens server. HTTP med Windows-godkendelse: agentens konto.
-- SQL: login'et i tjekket (agentens Windows-konto eller et SQL-login, hvis adgangskode krypteres til agenten som i 1.3)
-- skal kunne logge ind og køre forespørgslen; least-privilege-login'et fra grant-least-privilege.sql er nok til standardforespørgslen.
-- AX-logon: agentens Windows-konto skal være AX-bruger (i firmaet), og .NET Business Connector skal være installeret på
-- agentens server; ellers testes kun AOS-porten (og resultatet siger det).
-- Tjenestens tilstand: WMI Remote Enable på root\cimv2 + læseret på Service Control Manager på tjenestens server.
--
-- Data kommer fra (collector, fil – medlemmer, interval):
-- src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs – SyntheticCheckRunner.RunAsync (TcpAsync, HttpAsync, SqlAsync,
-- DnsAsync, AxAsync), AX-logon: src/SQLInsight.Servers/Synthetic/AxBusinessConnector.cs og ax-logon.ps1. Køres efter
-- tjekkets interval af src/SQLInsight.Agent/Servers/SyntheticCheckService.cs – RunCheckAsync, som også slår den tilknyttede
-- tjenestes tilstand op (CimQueries.SingleServiceQuery).
-- Regelkode: src/SQLInsight.Rules/Servers/SyntheticCheckRules.cs : SyntheticCheckFailingRule
--
-- Read-only: Ja. Ved et SQL-tjek valideres forespørgslen som én skrivebeskyttet SELECT, både når tjekket gemmes og
-- før hver kørsel (src/SQLInsight.Core/CustomRules/ReadOnlyQuery.cs), og den køres i en transaktion, der
-- altid rulles tilbage. Andre typer tjek sender ingen SQL og ændrer intet.
-- Kørsel: Kør i SSMS som tjekkets login. Hver GO er en separat kommando på samme forbindelse; ret [msdb] til
-- tjekkets database, eller udelad USE, hvis tjekket ingen database har.
-- ===================================================================================================================
-- SQL: Ved et SQL-tjek kører agenten præcis dette på den instans, tjekket peger på (ændrer intet: forespørgslen
-- valideres som én skrivebeskyttet SELECT, både når den gemmes og før den køres, og køres i en transaktion, der altid
-- rulles tilbage). Hver GO er en separat kommando på samme forbindelse.
-- -------------------------------------------------------------------------------------------------------------------
-- SyntheticCheckRunner: SQL-tjekket (login, sessionsindstillinger, forespørgslen i en transaktion, der rulles tilbage)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner: sættes på hver forbindelse, agenten åbner (også tjekkets)
-- Kilde: SqlConnectionFactory.SessionSettings
-- Kode: src/SQLInsight.Connectivity/SqlConnectionFactory.cs : SqlConnectionFactory.SessionSettings
SET LOCK_TIMEOUT 5000; SET DEADLOCK_PRIORITY LOW; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: Kun når tjekket har en database (vist for msdb)
-- Kilde: SyntheticCheckRunner.UseDatabaseSql
-- Kode: src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs : SyntheticCheckRunner.UseDatabaseSql(database)
USE [msdb];
GO
-- Variant: Alle versioner fra SQL Server 2005: før forespørgslen
-- Kilde: SyntheticCheckRunner.BeginTransactionSql
-- Kode: src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs : SyntheticCheckRunner.BeginTransactionSql
BEGIN TRANSACTION;
GO
-- Variant: Standardforespørgslen, når tjekket ikke har sin egen (en egen forespørgsel køres her i stedet, højst 10.000 rækker læses)
-- Kilde: SyntheticCheckSettings.DefaultQuery
-- Kode: src/SQLInsight.Core/SyntheticChecks/SyntheticCheckDefinitions.cs : SyntheticCheckSettings.DefaultQuery
SELECT 1
GO
-- Variant: Alle versioner fra SQL Server 2005: altid efter forespørgslen, også når den fejler
-- Kilde: SyntheticCheckRunner.RollbackSql
-- Kode: src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs : SyntheticCheckRunner.RollbackSql
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
GO
-- Ikke-SQL-tjek, efterprøv med PowerShell (skrivebeskyttet; kør på agentens server som agentens konto):
-- TCP: Test-NetConnection -ComputerName aos01 -Port 2712
-- HTTP: Invoke-WebRequest -Uri https://ep.firma.local/ -UseDefaultCredentials -UseBasicParsing | Select-Object StatusCode
-- DNS: Resolve-DnsName aos01.firma.local
-- AX: Test-NetConnection -ComputerName aos01 -Port 2712 (logon: se src/SQLInsight.Servers/Synthetic/ax-logon.ps1)
-- Tjeneste: Get-CimInstance -ComputerName aos01 -Query "SELECT Name, DisplayName, State, StartMode, ExitCode FROM Win32_Service WHERE Name = 'AOS60`$01'"

View file

@ -0,0 +1,73 @@
-- ===================================================================================================================
-- SQLI-SYN-002: Et syntetisk tjek svarer langsomt
-- ===================================================================================================================
-- Regel-id: SQLI-SYN-002
-- Titel: Et syntetisk tjek svarer langsomt
-- Alvor: Medium.
-- Versioner: Som SQLI-SYN-001.
--
-- Hvad tjekkes, og hvorfor:
-- Tjekket lykkes, men svaret har de seneste gange taget længere end tjekkets grænse. Brugerne oplever det som en langsom
-- applikation, længe før den holder op med at svare.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke på den overvågede server):
-- Er de seneste SlowRunsBeforeFinding kørsler alle lykkedes og alle langsommere end grænsen, er det et fund.
--
-- Grænseværdier (agentens appsettings.json og tjekkets indstillinger):
-- Pr. tjek: "Langsomt svar over (ms)" (insight.SyntheticChecks.SettingsJson: warnMilliseconds). Uden grænse: "ikke relevant".
-- SQLInsight:SyntheticChecks:SlowRunsBeforeFinding standard 3 (langsomme svar i træk før et fund)
--
-- Rettigheder:
-- Som SQLI-SYN-001.
--
-- Data kommer fra (collector, fil – medlemmer, interval):
-- src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs – SyntheticCheckRunner.RunAsync (DurationMs: hele tjekket, for
-- SQL login + forespørgsel), fra src/SQLInsight.Agent/Servers/SyntheticCheckService.cs – RunCheckAsync.
-- Regelkode: src/SQLInsight.Rules/Servers/SyntheticCheckRules.cs : SyntheticCheckSlowRule
--
-- Read-only: Ja. Ved et SQL-tjek valideres forespørgslen som én skrivebeskyttet SELECT, både når tjekket gemmes og
-- før hver kørsel (src/SQLInsight.Core/CustomRules/ReadOnlyQuery.cs), og den køres i en transaktion, der
-- altid rulles tilbage. Andre typer tjek sender ingen SQL og ændrer intet.
-- Kørsel: Kør i SSMS som tjekkets login. Hver GO er en separat kommando på samme forbindelse; ret [msdb] til
-- tjekkets database, eller udelad USE, hvis tjekket ingen database har.
-- ===================================================================================================================
-- SQL: Et SQL-tjek kører præcis det samme som i SQLI-SYN-001 (svartiden er login plus forespørgsel):
-- -------------------------------------------------------------------------------------------------------------------
-- SyntheticCheckRunner: SQL-tjekket (login, sessionsindstillinger, forespørgslen i en transaktion, der rulles tilbage)
-- -------------------------------------------------------------------------------------------------------------------
-- Variant: Alle versioner: sættes på hver forbindelse, agenten åbner (også tjekkets)
-- Kilde: SqlConnectionFactory.SessionSettings
-- Kode: src/SQLInsight.Connectivity/SqlConnectionFactory.cs : SqlConnectionFactory.SessionSettings
SET LOCK_TIMEOUT 5000; SET DEADLOCK_PRIORITY LOW; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: Kun når tjekket har en database (vist for msdb)
-- Kilde: SyntheticCheckRunner.UseDatabaseSql
-- Kode: src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs : SyntheticCheckRunner.UseDatabaseSql(database)
USE [msdb];
GO
-- Variant: Alle versioner fra SQL Server 2005: før forespørgslen
-- Kilde: SyntheticCheckRunner.BeginTransactionSql
-- Kode: src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs : SyntheticCheckRunner.BeginTransactionSql
BEGIN TRANSACTION;
GO
-- Variant: Standardforespørgslen, når tjekket ikke har sin egen (en egen forespørgsel køres her i stedet, højst 10.000 rækker læses)
-- Kilde: SyntheticCheckSettings.DefaultQuery
-- Kode: src/SQLInsight.Core/SyntheticChecks/SyntheticCheckDefinitions.cs : SyntheticCheckSettings.DefaultQuery
SELECT 1
GO
-- Variant: Alle versioner fra SQL Server 2005: altid efter forespørgslen, også når den fejler
-- Kilde: SyntheticCheckRunner.RollbackSql
-- Kode: src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs : SyntheticCheckRunner.RollbackSql
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
GO
-- Ikke-SQL-tjek, efterprøv svartiden med PowerShell (skrivebeskyttet):
-- Measure-Command { Invoke-WebRequest -Uri https://ep.firma.local/ -UseDefaultCredentials -UseBasicParsing } | Select-Object TotalMilliseconds
-- Measure-Command { Test-NetConnection -ComputerName aos01 -Port 2712 } | Select-Object TotalMilliseconds

View file

@ -0,0 +1,38 @@
-- ===================================================================================================================
-- SQLI-SYN-003: TLS-certifikatet udløber snart
-- ===================================================================================================================
-- Regel-id: SQLI-SYN-003
-- Titel: TLS-certifikatet udløber snart
-- Alvor: Medium; High inden for CertificateCriticalDays; Critical når det er udløbet.
-- Versioner: Alle HTTPS-servere (TLS 1.2 og nyere fra agentens server).
--
-- Hvad tjekkes, og hvorfor:
-- Når certifikatet på en HTTPS-adresse udløber, afviser browsere og integrationer forbindelsen (eller viser en
-- sikkerhedsadvarsel), og siden er i praksis nede. Udløbsdatoen læses ved hvert HTTPS-tjek – også når certifikatet
-- afvises (fx selvsigneret).
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke på den overvågede server):
-- Kun HTTP-tjek med https://. Den nyeste kørsel, der har set et certifikat, bruges: dage til udløb = NotAfter − nu.
-- Uden set certifikat endnu: "ikke målbar". Andre tjek: "ikke relevant".
--
-- Grænseværdier (agentens appsettings.json):
-- SQLInsight:SyntheticChecks:CertificateWarningDays standard 21 (fund når certifikatet udløber inden for så mange dage)
-- SQLInsight:SyntheticChecks:CertificateCriticalDays standard 7 (Høj inden for så mange dage)
--
-- Rettigheder:
-- Ingen ud over netværksadgang fra agentens server til adressen.
--
-- Data kommer fra (collector, fil – medlemmer, interval):
-- src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs – HttpAsync (RemoteCertificateValidationCallback gemmer
-- certifikatets NotAfter og Subject), fra src/SQLInsight.Agent/Servers/SyntheticCheckService.cs – RunCheckAsync.
-- Regelkode: src/SQLInsight.Rules/Servers/SyntheticCheckRules.cs : CertificateExpiryRule
--
-- Read-only: Ja. Reglen sender ingen SQL og ændrer intet; filen indeholder kun kommentarer.
-- ===================================================================================================================
-- Reglen bruger ikke SQL. Tjekket er en HTTPS GET; filen indeholder kun kommentarer og ændrer intet.
--
-- Efterprøv med PowerShell (skrivebeskyttet; kør på agentens server):
-- $c = New-Object System.Net.Sockets.TcpClient('ep.firma.local', 443)
-- $s = New-Object System.Net.Security.SslStream($c.GetStream(), $false, { $true })
-- $s.AuthenticateAsClient('ep.firma.local'); $s.RemoteCertificate.GetExpirationDateString(); $c.Close()

View file

@ -0,0 +1,50 @@
-- ===================================================================================================================
-- SQLI-WIN-001: CPU-forbruget er højt i længere tid
-- ===================================================================================================================
-- Regel-id: SQLI-WIN-001
-- Titel: CPU-forbruget er højt i længere tid
-- Alvor: High; Critical når gennemsnittet er 98 % eller derover.
-- Versioner: Windows Server 2008 R2 og nyere (WS-Man fra Windows Server 2012 / WinRM 3.0; DCOM på alle).
--
-- Hvad tjekkes, og hvorfor:
-- Serverens processorer har i gennemsnit været næsten fuldt belastet i en længere periode. Alt på serveren bliver
-- langsommere (forespørgsler, AOS-kald, fildeling, logon). Korte toppe er normale og giver intet fund.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke på den overvågede server):
-- Hvert minut læses tælleren Processor(_Total)\% Processor Time som rå værdi. CPU for minuttet er
-- 100 × (1 − Δ tomgangstid / Δ tid) mellem to læsninger (PERF_100NSEC_TIMER_INV), altså gennemsnittet over hele
-- minuttet, ikke et øjebliksbillede. Er gennemsnittet af minutterne i de seneste SustainedMinutes minutter mindst
-- HighPercent, er det et fund. Uden to læsninger endnu: "ikke målbar". Er den forrige læsning mere end tre intervaller
-- gammel (serveren kunne ikke nås et stykke tid), giver minuttet ingen CPU-værdi frem for et gennemsnit af hele hullet.
--
-- Grænseværdier (agentens appsettings.json):
-- SQLInsight:Servers:Cpu:HighPercent standard 90 (gennemsnitlig CPU i procent)
-- SQLInsight:Servers:Cpu:SustainedMinutes standard 15 (perioden gennemsnittet tages over)
-- Målingerne skal dække mindst 80 % af perioden (en kort top efter et hul tæller ikke).
--
-- Rettigheder på den overvågede server (db/permissions/grant-windows-server.ps1, aldrig lokal administrator):
-- Performance Monitor Users (ydelsestællere) + WMI Enable Account/Remote Enable på root\cimv2
-- WS-Man: Remote Management Users · DCOM: Distributed COM Users
--
-- Data kommer fra (collector, fil – medlemmer, interval):
-- src/SQLInsight.Servers/CimWindowsServerSource.cs – Session.GetPerformanceAsync (CimQueries.ProcessorQuery),
-- hvert minut (SQLInsight:Servers:Intervals:performance) fra src/SQLInsight.Agent/Servers/ServerMonitor.cs – CollectPerformanceAsync.
-- Beregning: src/SQLInsight.Core/Servers/ServerPerformance.cs – ServerPerformanceMath.Cpu.
-- Regelkode: src/SQLInsight.Rules/Servers/WindowsServerRules.cs : ServerCpuRule
--
-- Read-only: Ja. Reglen sender ingen SQL. Agenten læser kun fra Windows: CIM/WQL-forespørgsler (SELECT),
-- hændelsesloggen og for planlagte opgaver Task Schedulers skrivebeskyttede GetInfo. Den starter, stopper
-- eller ændrer intet på serveren. Filen indeholder kun kommentarer; forespørgslerne mod Windows står herunder.
-- ===================================================================================================================
-- Reglen bruger ikke SQL. Forespørgslerne mod Windows står herunder; filen indeholder kun kommentarer og ændrer intet.
--
-- CIM/WQL (namespace root\cimv2):
-- SELECT PercentProcessorTime, Timestamp_Sys100NS FROM Win32_PerfRawData_PerfOS_Processor WHERE Name = '_Total'
-- SELECT NumberOfLogicalProcessors FROM Win32_ComputerSystem
--
-- Efterprøv med PowerShell (skrivebeskyttet; kør på agentens server som agentens konto):
-- $s = New-CimSession -ComputerName SERVER # DCOM: -SessionOption (New-CimSessionOption -Protocol Dcom)
-- $q = "SELECT PercentProcessorTime, Timestamp_Sys100NS FROM Win32_PerfRawData_PerfOS_Processor WHERE Name = '_Total'"
-- $a = Get-CimInstance -CimSession $s -Query $q; Start-Sleep -Seconds 60; $b = Get-CimInstance -CimSession $s -Query $q
-- 100 * (1 - ($b.PercentProcessorTime - $a.PercentProcessorTime) / ($b.Timestamp_Sys100NS - $a.Timestamp_Sys100NS))

View file

@ -0,0 +1,48 @@
-- ===================================================================================================================
-- SQLI-WIN-002: Serveren har næsten ingen ledig hukommelse
-- ===================================================================================================================
-- Regel-id: SQLI-WIN-002
-- Titel: Serveren har næsten ingen ledig hukommelse
-- Alvor: Medium; High under 2 % og under 256 MB.
-- Versioner: Windows Server 2008 R2 og nyere.
--
-- Hvad tjekkes, og hvorfor:
-- Når Windows løber tør for ledig hukommelse, flyttes data til sidefilen, alt bliver langsomt, og processer kan fejle
-- med "out of memory". SQL Server kan blive tvunget til at give hukommelse tilbage.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke på den overvågede server):
-- Ledig hukommelse er Memory\Available MBytes; andelen er den i forhold til Win32_OperatingSystem.TotalVisibleMemorySize.
-- Gennemsnittet over de seneste 5 minutter (mindst 80 % dækket) sammenlignes med begge grænser.
--
-- Grænseværdier (agentens appsettings.json):
-- SQLInsight:Servers:Memory:AvailablePercent standard 5 (andel ledig af den samlede hukommelse)
-- SQLInsight:Servers:Memory:AvailableMegabytes standard 1024 (ledig hukommelse i MB)
-- Begge skal være under grænsen (gennemsnit over 5 minutter), så store SQL Server-hosts, der bevidst har lidt
-- tilbage, men stadig flere GB, ikke giver fund.
--
-- Rettigheder på den overvågede server (db/permissions/grant-windows-server.ps1, aldrig lokal administrator):
-- Performance Monitor Users + WMI Enable Account/Remote Enable på root\cimv2
-- WS-Man: Remote Management Users · DCOM: Distributed COM Users
--
-- Data kommer fra (collector, fil – medlemmer, interval):
-- src/SQLInsight.Servers/CimWindowsServerSource.cs – Session.GetPerformanceAsync (CimQueries.MemoryQuery) og
-- Session.GetOperatingSystemAsync (CimQueries.OperatingSystemQuery), hvert minut fra
-- src/SQLInsight.Agent/Servers/ServerMonitor.cs – CollectPerformanceAsync.
-- Regelkode: src/SQLInsight.Rules/Servers/WindowsServerRules.cs : ServerMemoryRule
--
-- Read-only: Ja. Reglen sender ingen SQL. Agenten læser kun fra Windows: CIM/WQL-forespørgsler (SELECT),
-- hændelsesloggen og for planlagte opgaver Task Schedulers skrivebeskyttede GetInfo. Den starter, stopper
-- eller ændrer intet på serveren. Filen indeholder kun kommentarer; forespørgslerne mod Windows står herunder.
-- ===================================================================================================================
-- Reglen bruger ikke SQL. Forespørgslerne mod Windows står herunder; filen indeholder kun kommentarer og ændrer intet.
--
-- CIM/WQL (namespace root\cimv2):
-- SELECT AvailableMBytes, PagesPersec, CommittedBytes, CommitLimit, Timestamp_PerfTime, Frequency_PerfTime FROM Win32_PerfRawData_PerfOS_Memory
-- SELECT Caption, Version, BuildNumber, LastBootUpTime, TotalVisibleMemorySize, CSName FROM Win32_OperatingSystem
--
-- Efterprøv med PowerShell (skrivebeskyttet; kør på agentens server som agentens konto):
-- $s = New-CimSession -ComputerName SERVER
-- $m = Get-CimInstance -CimSession $s -Query "SELECT AvailableMBytes FROM Win32_PerfRawData_PerfOS_Memory"
-- $o = Get-CimInstance -CimSession $s -Query "SELECT TotalVisibleMemorySize FROM Win32_OperatingSystem"
-- "{0} MB ledig ({1:N1} %)" -f $m.AvailableMBytes, (100 * $m.AvailableMBytes * 1024 / $o.TotalVisibleMemorySize)

View file

@ -0,0 +1,46 @@
-- ===================================================================================================================
-- SQLI-WIN-003: Serveren pager hukommelse til disk
-- ===================================================================================================================
-- Regel-id: SQLI-WIN-003
-- Titel: Serveren pager hukommelse til disk
-- Alvor: Medium.
-- Versioner: Windows Server 2008 R2 og nyere.
--
-- Hvad tjekkes, og hvorfor:
-- Windows læser og skriver hukommelsessider til sidefilen, fordi der ikke er plads i RAM. Det er mange gange
-- langsommere end hukommelse og belaster disken, som andre også bruger.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke på den overvågede server):
-- Pages/sec er en PERF_COUNTER_COUNTER: (Δ PagesPersec) / (Δ Timestamp_PerfTime / Frequency_PerfTime) mellem to
-- læsninger. Er gennemsnittet over 15 minutter mindst grænsen, og den ledige andel samtidig under 10 %, er det et fund.
--
-- Grænseværdier (agentens appsettings.json):
-- SQLInsight:Servers:Memory:PagesPerSecond standard 1000 (Memory\Pages/sec, gennemsnit over 15 minutter)
-- Kun sammen med lidt ledig hukommelse (under 10 % i samme periode): Pages/sec alene er også fil-I/O gennem
-- memory-mapped filer og er ikke i sig selv et problem.
--
-- Rettigheder på den overvågede server (db/permissions/grant-windows-server.ps1, aldrig lokal administrator):
-- Performance Monitor Users + WMI Enable Account/Remote Enable på root\cimv2
-- WS-Man: Remote Management Users · DCOM: Distributed COM Users
--
-- Data kommer fra (collector, fil – medlemmer, interval):
-- src/SQLInsight.Servers/CimWindowsServerSource.cs – Session.GetPerformanceAsync (CimQueries.MemoryQuery), hvert minut
-- fra src/SQLInsight.Agent/Servers/ServerMonitor.cs – CollectPerformanceAsync.
-- Beregning: src/SQLInsight.Core/Servers/ServerPerformance.cs – ServerPerformanceMath.PagesPerSecond.
-- Regelkode: src/SQLInsight.Rules/Servers/WindowsServerRules.cs : ServerPagingRule
--
-- Read-only: Ja. Reglen sender ingen SQL. Agenten læser kun fra Windows: CIM/WQL-forespørgsler (SELECT),
-- hændelsesloggen og for planlagte opgaver Task Schedulers skrivebeskyttede GetInfo. Den starter, stopper
-- eller ændrer intet på serveren. Filen indeholder kun kommentarer; forespørgslerne mod Windows står herunder.
-- ===================================================================================================================
-- Reglen bruger ikke SQL. Forespørgslerne mod Windows står herunder; filen indeholder kun kommentarer og ændrer intet.
--
-- CIM/WQL (namespace root\cimv2):
-- SELECT AvailableMBytes, PagesPersec, CommittedBytes, CommitLimit, Timestamp_PerfTime, Frequency_PerfTime FROM Win32_PerfRawData_PerfOS_Memory
--
-- Efterprøv med PowerShell (skrivebeskyttet; kør på agentens server som agentens konto):
-- $s = New-CimSession -ComputerName SERVER
-- $q = "SELECT PagesPersec, Timestamp_PerfTime, Frequency_PerfTime FROM Win32_PerfRawData_PerfOS_Memory"
-- $a = Get-CimInstance -CimSession $s -Query $q; Start-Sleep -Seconds 60; $b = Get-CimInstance -CimSession $s -Query $q
-- ($b.PagesPersec - $a.PagesPersec) / (($b.Timestamp_PerfTime - $a.Timestamp_PerfTime) / $b.Frequency_PerfTime)

View file

@ -0,0 +1,41 @@
-- ===================================================================================================================
-- SQLI-WIN-004: Den reserverede hukommelse (commit) er tæt på grænsen
-- ===================================================================================================================
-- Regel-id: SQLI-WIN-004
-- Titel: Den reserverede hukommelse (commit) er tæt på grænsen
-- Alvor: Medium; High ved 97 % eller derover.
-- Versioner: Windows Server 2008 R2 og nyere.
--
-- Hvad tjekkes, og hvorfor:
-- Commit charge er den hukommelse, programmerne har reserveret (RAM plus sidefil). Når grænsen nås, kan Windows ikke
-- give mere hukommelse ud, og programmer og tjenester fejler, selv om der måske er ledig RAM.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke på den overvågede server):
-- 100 × CommittedBytes / CommitLimit pr. minut; gennemsnittet over 5 minutter (mindst 80 % dækket) mod grænsen.
--
-- Grænseværdier (agentens appsettings.json):
-- SQLInsight:Servers:Memory:CommitPercent standard 90 (CommittedBytes / CommitLimit, gennemsnit over 5 minutter)
--
-- Rettigheder på den overvågede server (db/permissions/grant-windows-server.ps1, aldrig lokal administrator):
-- Performance Monitor Users + WMI Enable Account/Remote Enable på root\cimv2
-- WS-Man: Remote Management Users · DCOM: Distributed COM Users
--
-- Data kommer fra (collector, fil – medlemmer, interval):
-- src/SQLInsight.Servers/CimWindowsServerSource.cs – Session.GetPerformanceAsync (CimQueries.MemoryQuery), hvert minut
-- fra src/SQLInsight.Agent/Servers/ServerMonitor.cs – CollectPerformanceAsync.
-- Regelkode: src/SQLInsight.Rules/Servers/WindowsServerRules.cs : ServerCommitRule
--
-- Read-only: Ja. Reglen sender ingen SQL. Agenten læser kun fra Windows: CIM/WQL-forespørgsler (SELECT),
-- hændelsesloggen og for planlagte opgaver Task Schedulers skrivebeskyttede GetInfo. Den starter, stopper
-- eller ændrer intet på serveren. Filen indeholder kun kommentarer; forespørgslerne mod Windows står herunder.
-- ===================================================================================================================
-- Reglen bruger ikke SQL. Forespørgslerne mod Windows står herunder; filen indeholder kun kommentarer og ændrer intet.
--
-- CIM/WQL (namespace root\cimv2):
-- SELECT AvailableMBytes, PagesPersec, CommittedBytes, CommitLimit, Timestamp_PerfTime, Frequency_PerfTime FROM Win32_PerfRawData_PerfOS_Memory
--
-- Efterprøv med PowerShell (skrivebeskyttet; kør på agentens server som agentens konto):
-- $s = New-CimSession -ComputerName SERVER
-- $m = Get-CimInstance -CimSession $s -Query "SELECT CommittedBytes, CommitLimit FROM Win32_PerfRawData_PerfOS_Memory"
-- 100 * $m.CommittedBytes / $m.CommitLimit

View file

@ -0,0 +1,49 @@
-- ===================================================================================================================
-- SQLI-WIN-005: Lidt ledig plads på et drev
-- ===================================================================================================================
-- Regel-id: SQLI-WIN-005
-- Titel: Lidt ledig plads på et drev
-- Alvor: Medium under FreePercent, High under CriticalFreePercent, Critical under 1 GB eller 1 %.
-- Versioner: Windows Server 2008 R2 og nyere.
--
-- Hvad tjekkes, og hvorfor:
-- Når et drev løber fuldt, stopper alt, der skriver til det: SQL Server kan ikke vokse data- og logfiler, backup
-- fejler, AOS og Windows kan ikke skrive logfiler og midlertidige filer, og brugere kan ikke gemme.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke på den overvågede server):
-- Lokale diske (DriveType 3) med drevbogstav eller monteringssti; partitioner uden (system/recovery, navn \\?\Volume{…})
-- springes over. Ledig andel = FreeSpace / Capacity. Et fund pr. drev, der er under FreePercent og samtidig har
-- mindre end EnoughFreeGigabytes ledig.
--
-- Grænseværdier (agentens appsettings.json):
-- SQLInsight:Servers:Disks:FreePercent standard 10 (ledig andel, under: Middel)
-- SQLInsight:Servers:Disks:CriticalFreePercent standard 5 (under: Høj)
-- SQLInsight:Servers:Disks:EnoughFreeGigabytes standard 50 (er der mindst så mange GB ledige, er der intet fund,
-- uanset andelen: store drev med lille andel ledig kan være fine)
--
-- Rettigheder på den overvågede server (db/permissions/grant-windows-server.ps1, aldrig lokal administrator):
-- WMI Enable Account/Remote Enable på root\cimv2
-- WS-Man: Remote Management Users · DCOM: Distributed COM Users
--
-- Data kommer fra (collector, fil – medlemmer, interval):
-- src/SQLInsight.Servers/CimWindowsServerSource.cs – Session.GetDisksAsync (CimQueries.VolumeQuery, ellers
-- CimQueries.LogicalDiskQuery), hvert 15. minut (SQLInsight:Servers:Intervals:disks) fra
-- src/SQLInsight.Agent/Servers/ServerMonitor.cs – CollectDisksAsync.
-- Regelkode: src/SQLInsight.Rules/Servers/WindowsServerRules.cs : DiskFreeSpaceRule
--
-- Read-only: Ja. Reglen sender ingen SQL. Agenten læser kun fra Windows: CIM/WQL-forespørgsler (SELECT),
-- hændelsesloggen og for planlagte opgaver Task Schedulers skrivebeskyttede GetInfo. Den starter, stopper
-- eller ændrer intet på serveren. Filen indeholder kun kommentarer; forespørgslerne mod Windows står herunder.
-- ===================================================================================================================
-- Reglen bruger ikke SQL. Forespørgslerne mod Windows står herunder; filen indeholder kun kommentarer og ændrer intet.
--
-- CIM/WQL (namespace root\cimv2):
-- SELECT Name, Label, FileSystem, Capacity, FreeSpace FROM Win32_Volume WHERE DriveType = 3
-- SELECT DeviceID, VolumeName, FileSystem, Size, FreeSpace FROM Win32_LogicalDisk WHERE DriveType = 3 (fallback)
--
-- Efterprøv med PowerShell (skrivebeskyttet; kør på agentens server som agentens konto):
-- $s = New-CimSession -ComputerName SERVER
-- Get-CimInstance -CimSession $s -Query "SELECT Name, Label, FileSystem, Capacity, FreeSpace FROM Win32_Volume WHERE DriveType = 3" |
-- Where-Object { $_.Name -notlike '\\?\*' } |
-- Select-Object Name, Label, @{n='LedigGB';e={[math]::Round($_.FreeSpace / 1GB, 1)}}, @{n='Ledig%';e={[math]::Round(100 * $_.FreeSpace / $_.Capacity, 1)}}

View file

@ -0,0 +1,58 @@
-- ===================================================================================================================
-- SQLI-WIN-006: Et drev bliver fuldt om få dage
-- ===================================================================================================================
-- Regel-id: SQLI-WIN-006
-- Titel: Et drev bliver fuldt om få dage
-- Alvor: Medium; High inden for 7 dage; Critical inden for 2 dage.
-- Versioner: Windows Server 2008 R2 og nyere.
--
-- Hvad tjekkes, og hvorfor:
-- Varsler, at disken løber fuld om et antal dage, før det sker: pladsen på drevet er de seneste uger brugt i et jævnt
-- tempo, og fortsætter det, er drevet fuldt inden for prognosegrænsen.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke på den overvågede server):
-- Ledig plads pr. drev hver 15. minut gemmes i repositoriet. Prognosen tager timegennemsnit af de seneste
-- ForecastHistoryDays dage – kun siden drevet sidst skiftede størrelse (et udvidet drev starter en ny tendens) – og
-- lægger en ret linje (mindste kvadraters metode) gennem den ledige plads. Hælder linjen nedad, passer den rimeligt
-- (R²), og er der nok historik, er "fuld om ca. N dage" = ledig plads ved seneste måling / forbrug pr. dag, talt fra nu
-- (er seneste timegennemsnit mere end en time gammelt, trækkes resten fra). Svinger pladsen op og ned (backup skrevet og
-- slettet), er der ingen prognose frem for et skræmmende gæt. Har intet drev nok historik endnu (en ny server de første
-- par dage), er reglen "ikke målbar" med grunden – ikke "bestået".
--
-- Grænseværdier (agentens appsettings.json):
-- SQLInsight:Servers:Disks:ForecastDays standard 30 (fund når drevet forventes fuldt inden for så mange dage)
-- SQLInsight:Servers:Disks:ForecastHistoryDays standard 14 (dage med SQLInsights egne målinger, linjen beregnes over)
-- SQLInsight:Servers:DiskHistoryDays standard 35 (hvor længe målingerne gemmes i insight.ServerDiskSamples)
-- Faste fornuftsgrænser (src/SQLInsight.Core/Servers/DiskForecast.cs – DiskForecastSettings): mindst 2 dages historik og
-- 12 målinger, R² mindst 0,5, vækst mindst 0,01 GB pr. dag, højst 365 dage frem.
--
-- Rettigheder på den overvågede server (db/permissions/grant-windows-server.ps1, aldrig lokal administrator):
-- WMI Enable Account/Remote Enable på root\cimv2
-- WS-Man: Remote Management Users · DCOM: Distributed COM Users
--
-- Data kommer fra (collector, fil – medlemmer, interval):
-- src/SQLInsight.Servers/CimWindowsServerSource.cs – Session.GetDisksAsync (CimQueries.VolumeQuery), hvert 15. minut fra
-- src/SQLInsight.Agent/Servers/ServerMonitor.cs – CollectDisksAsync, som gemmer målingen og beregner prognosen.
-- Prognose: src/SQLInsight.Core/Servers/DiskForecast.cs – DiskForecaster.Forecast.
-- Regelkode: src/SQLInsight.Rules/Servers/WindowsServerRules.cs : DiskForecastRule
--
-- Read-only: Ja. Reglen sender ingen SQL. Agenten læser kun fra Windows: CIM/WQL-forespørgsler (SELECT),
-- hændelsesloggen og for planlagte opgaver Task Schedulers skrivebeskyttede GetInfo. Den starter, stopper
-- eller ændrer intet på serveren. Filen indeholder kun kommentarer; forespørgslerne mod Windows står herunder.
-- ===================================================================================================================
-- Reglen bruger ikke SQL mod den overvågede server. Forespørgslen mod Windows står herunder, og den forespørgsel, agenten
-- bruger mod sit eget repository (ikke den overvågede server), står til sidst. Filen indeholder kun kommentarer.
--
-- CIM/WQL (namespace root\cimv2):
-- SELECT Name, Label, FileSystem, Capacity, FreeSpace FROM Win32_Volume WHERE DriveType = 3
--
-- Historikken i SQLInsight-repositoriet (src/SQLInsight.Repository/InsightRepository.WindowsServers.cs – GetServerDiskHistoryAsync):
-- SELECT Volume, DATEADD(hour, DATEDIFF(hour, 0, SampledAt), 0) AS [Hour], MAX(SizeGb), AVG(FreeGb)
-- FROM insight.ServerDiskSamples WHERE ServerId = @id AND SampledAt >= @from
-- GROUP BY Volume, DATEADD(hour, DATEDIFF(hour, 0, SampledAt), 0)
-- ORDER BY Volume, [Hour];
--
-- Efterprøv med PowerShell (skrivebeskyttet; ledig plads nu):
-- Get-CimInstance -ComputerName SERVER -Query "SELECT Name, Capacity, FreeSpace FROM Win32_Volume WHERE DriveType = 3" |
-- Select-Object Name, @{n='LedigGB';e={[math]::Round($_.FreeSpace / 1GB, 1)}}

View file

@ -0,0 +1,54 @@
-- ===================================================================================================================
-- SQLI-WIN-007: En tjeneste, der skal starte automatisk, kører ikke
-- ===================================================================================================================
-- Regel-id: SQLI-WIN-007
-- Titel: En tjeneste, der skal starte automatisk, kører ikke
-- Alvor: Medium.
-- Versioner: Windows Server 2008 R2 og nyere (DelayedAutoStart læses fra Windows Server 2012; før det uden).
--
-- Hvad tjekkes, og hvorfor:
-- En tjeneste med starttypen Automatisk står ikke som kørende. Det, tjenesten gør, sker ikke, og en automatisk
-- tjeneste, der er stoppet, er oftest gået ned. SQLInsight starter aldrig selv tjenester.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke på den overvågede server):
-- Et fund pr. tjeneste med StartMode = 'Auto' og State <> 'Running', som hverken står på den indbyggede liste, i
-- Ignore, i serverens ignoreringsliste eller i serverens liste over overvågede tjenester. Listerne matcher tjenestens
-- navn eller visningsnavn, præcist eller med præfiks* – på samme måde som SQLI-WIN-008. Højst MaxFindings fund (efter
-- tjenestenavn); resten nævnes i ét samlet fund. Inden for StartupGraceMinutes efter LastBootUpTime er reglen
-- "ikke målbar" (åbne fund forbliver åbne i stedet for at blive løst og genåbnet ved hver genstart). Viser serveren
-- slet ingen tjenester, er området "ikke målbar" (manglende læseret på Service Control Manager).
--
-- Grænseværdier (agentens appsettings.json og serverens indstillinger i SQLInsight Web):
-- SQLInsight:Servers:Services:Ignore ekstra tjenester, der må være stoppet (navn, visningsnavn eller præfiks*)
-- SQLInsight:Servers:Services:StartupGraceMinutes standard 15 (ikke målbar de første minutter efter en genstart)
-- SQLInsight:Servers:Services:MaxFindings standard 15 (1–100; fund pr. tjeneste, resten i ét samlet fund)
-- Indbygget liste over automatiske tjenester, der stopper af sig selv (StoppedAutomaticServiceRule.StopByDesign),
-- fx sppsvc, gupdate, edgeupdate, MapsBroker, RemoteRegistry, clr_optimization_*, CDPUserSvc_*.
-- Pr. server: "Tjenester der ignoreres" (insight.WindowsServers.IgnoredServices). Overvågede tjenester (SQLI-WIN-008)
-- rapporteres dér og ikke her.
--
-- Rettigheder på den overvågede server (db/permissions/grant-windows-server.ps1, aldrig lokal administrator):
-- WMI Enable Account/Remote Enable på root\cimv2 + læseret (CC LC RP RC) på Service Control Manager
-- (over netværket må ellers kun administratorer liste tjenester).
-- WS-Man: Remote Management Users · DCOM: Distributed COM Users
--
-- Data kommer fra (collector, fil – medlemmer, interval):
-- src/SQLInsight.Servers/CimWindowsServerSource.cs – Session.GetServicesAsync (CimQueries.ServiceQuery, ellers
-- CimQueries.ServiceQueryWithoutDelayed), hvert 5. minut (SQLInsight:Servers:Intervals:services) fra
-- src/SQLInsight.Agent/Servers/ServerMonitor.cs – CollectServicesAsync.
-- Regelkode: src/SQLInsight.Rules/Servers/WindowsServerRules.cs : StoppedAutomaticServiceRule
--
-- Read-only: Ja. Reglen sender ingen SQL. Agenten læser kun fra Windows: CIM/WQL-forespørgsler (SELECT),
-- hændelsesloggen og for planlagte opgaver Task Schedulers skrivebeskyttede GetInfo. Den starter, stopper
-- eller ændrer intet på serveren. Filen indeholder kun kommentarer; forespørgslerne mod Windows står herunder.
-- ===================================================================================================================
-- Reglen bruger ikke SQL. Forespørgslerne mod Windows står herunder; filen indeholder kun kommentarer og ændrer intet.
--
-- CIM/WQL (namespace root\cimv2):
-- SELECT Name, DisplayName, State, StartMode, DelayedAutoStart, ExitCode FROM Win32_Service
-- SELECT Name, DisplayName, State, StartMode, ExitCode FROM Win32_Service (før Windows Server 2012)
--
-- Efterprøv med PowerShell (skrivebeskyttet; kør på agentens server som agentens konto):
-- Get-CimInstance -ComputerName SERVER -Query "SELECT Name, DisplayName, State, StartMode, DelayedAutoStart, ExitCode FROM Win32_Service" |
-- Where-Object { $_.StartMode -eq 'Auto' -and $_.State -ne 'Running' } | Select-Object Name, DisplayName, State, ExitCode

View file

@ -0,0 +1,43 @@
-- ===================================================================================================================
-- SQLI-WIN-008: En overvåget tjeneste kører ikke
-- ===================================================================================================================
-- Regel-id: SQLI-WIN-008
-- Titel: En overvåget tjeneste kører ikke
-- Alvor: High når tjenesten ikke kører; Medium når den ikke findes (eller ikke kan ses).
-- Versioner: Windows Server 2008 R2 og nyere.
--
-- Hvad tjekkes, og hvorfor:
-- Tjenester, der er valgt for serveren i SQLInsight Web (fx AOS60$01 for Dynamics AX, W3SVC for IIS, MSSQLSERVER),
-- skal køre uanset starttype. Når de ikke gør, er det, de leverer, ikke tilgængeligt. SQLInsight starter aldrig selv tjenester.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke på den overvågede server):
-- For hvert navn på listen findes tjenesterne på navn eller visningsnavn, præcist eller med præfiks* (samme matchning
-- som SQLI-WIN-007). Findes ingen: fund (Middel). Er State ikke 'Running': fund (Høj) pr. tjeneste. Uden liste:
-- "ikke relevant". Inden for StartupGraceMinutes efter en genstart: "ikke målbar" (åbne fund forbliver åbne).
--
-- Grænseværdier (fast i koden):
-- Listen pr. server: "Overvågede tjenester" (insight.WindowsServers.WatchedServices), sat i SQLInsight Web eller API v1.
-- SQLInsight:Servers:Services:StartupGraceMinutes standard 15 (ikke målbar de første minutter efter en genstart)
--
-- Rettigheder på den overvågede server (db/permissions/grant-windows-server.ps1, aldrig lokal administrator):
-- WMI Enable Account/Remote Enable på root\cimv2 + læseret (CC LC RP RC) på Service Control Manager
-- WS-Man: Remote Management Users · DCOM: Distributed COM Users
--
-- Data kommer fra (collector, fil – medlemmer, interval):
-- src/SQLInsight.Servers/CimWindowsServerSource.cs – Session.GetServicesAsync (CimQueries.ServiceQuery), hvert 5. minut fra
-- src/SQLInsight.Agent/Servers/ServerMonitor.cs – CollectServicesAsync.
-- Regelkode: src/SQLInsight.Rules/Servers/WindowsServerRules.cs : WatchedServiceRule
--
-- Read-only: Ja. Reglen sender ingen SQL. Agenten læser kun fra Windows: CIM/WQL-forespørgsler (SELECT),
-- hændelsesloggen og for planlagte opgaver Task Schedulers skrivebeskyttede GetInfo. Den starter, stopper
-- eller ændrer intet på serveren. Filen indeholder kun kommentarer; forespørgslerne mod Windows står herunder.
-- ===================================================================================================================
-- Reglen bruger ikke SQL. Forespørgslerne mod Windows står herunder; filen indeholder kun kommentarer og ændrer intet.
--
-- CIM/WQL (namespace root\cimv2):
-- SELECT Name, DisplayName, State, StartMode, DelayedAutoStart, ExitCode FROM Win32_Service
-- SELECT Name, DisplayName, State, StartMode, ExitCode FROM Win32_Service WHERE Name = '<tjeneste>' (syntetiske tjek)
--
-- Efterprøv med PowerShell (skrivebeskyttet; kør på agentens server som agentens konto):
-- Get-CimInstance -ComputerName SERVER -Query "SELECT Name, DisplayName, State, StartMode, ExitCode FROM Win32_Service WHERE Name = 'AOS60`$01'"

View file

@ -0,0 +1,59 @@
-- ===================================================================================================================
-- SQLI-WIN-009: Fejl i Windows' hændelseslog
-- ===================================================================================================================
-- Regel-id: SQLI-WIN-009
-- Titel: Fejl i Windows' hændelseslog
-- Alvor: High: disk/filsystem, ressourcer, niveau Critical, tjeneste stoppet 3+ gange. Medium: tjeneste stoppet, program gik ned,
-- Dynamics AX. Low: almindelige fejl der gentager sig. (Uventet nedlukning: se SQLI-WIN-010.)
-- Versioner: Windows Server 2008 R2 og nyere.
--
-- Hvad tjekkes, og hvorfor:
-- Fejl og kritiske hændelser i System- og Application-loggen: disk- og filsystemfejl, opbrugte ressourcer, tjenester
-- der stopper eller ikke starter, programmer der går ned, fejl fra Dynamics AX' AOS – og almindelige fejl, der gentager sig.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke på den overvågede server):
-- Hændelser med niveau 1 (kritisk) og 2 (fejl) siden sidste indsamling læses fra System og Application. Støj tælles,
-- men grupperes ikke. Resten grupperes pr. log, kilde og hændelses-id med antal, første og seneste tidspunkt og den
-- nyeste tekst som eksempel (følsom: vises kun lokalt, [udeladt] i API'et). Antallet holdes pr. klokketime, og kun de
-- timer, der har hændelser inden for de seneste 24 timer, lægges sammen – en fejl, der kommer hver 10. time, tæller
-- altså aldrig mere end de 2–3 gange, den faktisk er set på et døgn. Vigtige kategorier giver fund med det samme,
-- almindelige fejl først ved RepeatThreshold gentagelser. Højst MaxFindings fund i en fast rækkefølge (kategori, så den
-- ældste først, så nøglen), så de samme hændelser beholder deres fund; resten nævnes i ét samlet fund med den højeste
-- alvor blandt dem.
--
-- Grænseværdier (agentens appsettings.json):
-- SQLInsight:Servers:EventLog:RepeatThreshold standard 5 (en almindelig fejl skal ses så mange gange på 24 timer)
-- SQLInsight:Servers:EventLog:Ignore ekstra støj: "Kilde:id", "Kilde" eller "Kilde*"
-- SQLInsight:Servers:EventLog:MaxFindings standard 15 (1–100; fund pr. hændelse, vigtigste først, resten i ét samlet fund)
-- SQLInsight:Servers:EventLog:MaxEventsPerCollection standard 2000 (hændelser pr. log pr. indsamling)
-- Indbygget støjliste (ServerEventAnalysis.DefaultIgnore): DCOM 10016/10010, Schannel 36874/36887/36888, TPM-WMI 1796/1801,
-- WMI 10, Security-SPP 8198, og SQL Server's egne kilder (MSSQLSERVER, MSSQL$*, SQLSERVERAGENT, SQLAgent$*,
-- SQLISPackage*), som SQL Server-fejlloggen allerede dækker. Et fund holdes åbent 24 timer efter hændelsen sidst blev set.
--
-- Rettigheder på den overvågede server (db/permissions/grant-windows-server.ps1, aldrig lokal administrator):
-- Event Log Readers (System- og Application-loggen; aldrig Security-loggen).
-- Over RPC (MS-EVEN6): firewallreglerne "Remote Event Log Management". Fallback over CIM: WMI Remote Enable på root\cimv2.
--
-- Data kommer fra (collector, fil – medlemmer, interval):
-- src/SQLInsight.Servers/CimWindowsServerSource.cs – Session.GetEventsAsync: EventLogReader med CimQueries.EventXPath,
-- ellers CimQueries.EventQuery; hvert 5. minut (SQLInsight:Servers:Intervals:events) fra
-- src/SQLInsight.Agent/Servers/ServerMonitor.cs – CollectEventsAsync. Gruppering og støjfilter:
-- src/SQLInsight.Core/Servers/ServerEvents.cs – ServerEventAnalysis.Group/Recent/Categorize.
-- Regelkode: src/SQLInsight.Rules/Servers/WindowsServerRules.cs : WindowsEventLogRule
--
-- Read-only: Ja. Reglen sender ingen SQL. Agenten læser kun fra Windows: CIM/WQL-forespørgsler (SELECT),
-- hændelsesloggen og for planlagte opgaver Task Schedulers skrivebeskyttede GetInfo. Den starter, stopper
-- eller ændrer intet på serveren. Filen indeholder kun kommentarer; forespørgslerne mod Windows står herunder.
-- ===================================================================================================================
-- Reglen bruger ikke SQL. Forespørgslerne mod Windows står herunder; filen indeholder kun kommentarer og ændrer intet.
--
-- Event Log (MS-EVEN6), XPath pr. log ('System' og 'Application'):
-- *[System[(Level=1 or Level=2) and TimeCreated[@SystemTime>'2026-10-02T12:00:00.000Z']]]
-- CIM/WQL fallback (namespace root\cimv2; EventType 1 = fejl, kritiske hændelser rapporteres som fejl):
-- SELECT Logfile, SourceName, EventCode, EventType, TimeGenerated, RecordNumber, Message FROM Win32_NTLogEvent
-- WHERE Logfile = 'System' AND EventType = 1 AND TimeGenerated > '20261002120000.000000+000'
--
-- Efterprøv med PowerShell (skrivebeskyttet; kør på agentens server som agentens konto):
-- Get-WinEvent -ComputerName SERVER -LogName System -FilterXPath "*[System[(Level=1 or Level=2) and TimeCreated[timediff(@SystemTime) <= 86400000]]]" |
-- Group-Object ProviderName, Id | Sort-Object Count -Descending | Select-Object Count, Name

View file

@ -0,0 +1,45 @@
-- ===================================================================================================================
-- SQLI-WIN-010: Serveren er genstartet
-- ===================================================================================================================
-- Regel-id: SQLI-WIN-010
-- Titel: Serveren er genstartet
-- Alvor: High ved uventet genstart; Info ved genstart uden tegn på at den var uventet; Low når hændelsesloggen ikke kan læses.
-- Versioner: Windows Server 2008 R2 og nyere.
--
-- Hvad tjekkes, og hvorfor:
-- Serveren er genstartet det seneste døgn. Uventet (strøm, blå skærm, hård nulstilling) afbryder alt midt i det, og
-- databaser og AX skal gendanne sig selv; en planlagt genstart (fx Windows-opdateringer) er kun til orientering.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke på den overvågede server):
-- Er LastBootUpTime inden for 24 timer, er serveren genstartet. Er der i hændelsesloggen (seneste 24 timer) en hændelse i
-- kategorien "uventet nedlukning" – Microsoft-Windows-Kernel-Power 41, EventLog 6008, BugCheck/WER-SystemErrorReporting 1001 –
-- senest set tidligst en time før starttidspunktet, er genstarten uventet (Høj), ellers Info. "Planlagt" siges kun, når
-- hændelsesloggen er læst efter starttidspunktet: er den læst sidst før genstarten, er reglen "ikke målbar", indtil den
-- er indsamlet igen; kan den slet ikke læses, er fundet Low og siger, at årsagen ikke kan ses.
--
-- Grænseværdier (fast i koden):
-- Fast: 24 timer efter LastBootUpTime (ServerEventAnalysis.RecentWindow), derefter forsvinder fundet af sig selv.
--
-- Rettigheder på den overvågede server (db/permissions/grant-windows-server.ps1, aldrig lokal administrator):
-- WMI Enable Account/Remote Enable på root\cimv2 (starttidspunkt); Event Log Readers (hvorfor).
--
-- Data kommer fra (collector, fil – medlemmer, interval):
-- src/SQLInsight.Servers/CimWindowsServerSource.cs – Session.GetOperatingSystemAsync (CimQueries.OperatingSystemQuery) og
-- Session.GetEventsAsync, fra src/SQLInsight.Agent/Servers/ServerMonitor.cs – CollectPerformanceAsync og CollectEventsAsync.
-- Regelkode: src/SQLInsight.Rules/Servers/WindowsServerRules.cs : ServerRestartRule
--
-- Read-only: Ja. Reglen sender ingen SQL. Agenten læser kun fra Windows: CIM/WQL-forespørgsler (SELECT),
-- hændelsesloggen og for planlagte opgaver Task Schedulers skrivebeskyttede GetInfo. Den starter, stopper
-- eller ændrer intet på serveren. Filen indeholder kun kommentarer; forespørgslerne mod Windows står herunder.
-- ===================================================================================================================
-- Reglen bruger ikke SQL. Forespørgslerne mod Windows står herunder; filen indeholder kun kommentarer og ændrer intet.
--
-- CIM/WQL (namespace root\cimv2):
-- SELECT Caption, Version, BuildNumber, LastBootUpTime, TotalVisibleMemorySize, CSName FROM Win32_OperatingSystem
-- Event Log (MS-EVEN6), XPath for System:
-- *[System[(Level=1 or Level=2) and TimeCreated[@SystemTime>'2026-10-02T12:00:00.000Z']]]
--
-- Efterprøv med PowerShell (skrivebeskyttet; kør på agentens server som agentens konto):
-- (Get-CimInstance -ComputerName SERVER -Query "SELECT LastBootUpTime FROM Win32_OperatingSystem").LastBootUpTime
-- Get-WinEvent -ComputerName SERVER -FilterHashtable @{ LogName = 'System'; Id = 41, 6008, 1001, 1074; StartTime = (Get-Date).AddDays(-1) }

View file

@ -0,0 +1,40 @@
-- ===================================================================================================================
-- SQLI-WIN-011: Serveren er ikke genstartet i lang tid
-- ===================================================================================================================
-- Regel-id: SQLI-WIN-011
-- Titel: Serveren er ikke genstartet i lang tid
-- Alvor: Low.
-- Versioner: Windows Server 2008 R2 og nyere.
--
-- Hvad tjekkes, og hvorfor:
-- De fleste Windows-opdateringer kræver en genstart. En server, der ikke er genstartet i flere måneder, mangler derfor
-- næsten sikkert sikkerhedsopdateringer.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke på den overvågede server):
-- Oppetid = nu − LastBootUpTime. Er UptimeDays større end 0, og oppetiden mindst så mange dage, er det et fund.
-- Med UptimeDays = 0 er reglen "ikke relevant".
--
-- Grænseværdier (agentens appsettings.json):
-- SQLInsight:Servers:UptimeDays standard 0 = slået fra (fx 120 for et fund efter 120 dage uden genstart)
--
-- Rettigheder på den overvågede server (db/permissions/grant-windows-server.ps1, aldrig lokal administrator):
-- WMI Enable Account/Remote Enable på root\cimv2
-- WS-Man: Remote Management Users · DCOM: Distributed COM Users
--
-- Data kommer fra (collector, fil – medlemmer, interval):
-- src/SQLInsight.Servers/CimWindowsServerSource.cs – Session.GetOperatingSystemAsync (CimQueries.OperatingSystemQuery),
-- fra src/SQLInsight.Agent/Servers/ServerMonitor.cs – CollectPerformanceAsync.
-- Regelkode: src/SQLInsight.Rules/Servers/WindowsServerRules.cs : ServerUptimeRule
--
-- Read-only: Ja. Reglen sender ingen SQL. Agenten læser kun fra Windows: CIM/WQL-forespørgsler (SELECT),
-- hændelsesloggen og for planlagte opgaver Task Schedulers skrivebeskyttede GetInfo. Den starter, stopper
-- eller ændrer intet på serveren. Filen indeholder kun kommentarer; forespørgslerne mod Windows står herunder.
-- ===================================================================================================================
-- Reglen bruger ikke SQL. Forespørgslen mod Windows står herunder; filen indeholder kun kommentarer og ændrer intet.
--
-- CIM/WQL (namespace root\cimv2):
-- SELECT Caption, Version, BuildNumber, LastBootUpTime, TotalVisibleMemorySize, CSName FROM Win32_OperatingSystem
--
-- Efterprøv med PowerShell (skrivebeskyttet; kør på agentens server som agentens konto):
-- (Get-Date) - (Get-CimInstance -ComputerName SERVER -Query "SELECT LastBootUpTime FROM Win32_OperatingSystem").LastBootUpTime

View file

@ -0,0 +1,55 @@
-- ===================================================================================================================
-- SQLI-WIN-012: En planlagt opgave fejlede sidste gang, den kørte
-- ===================================================================================================================
-- Regel-id: SQLI-WIN-012
-- Titel: En planlagt opgave fejlede sidste gang, den kørte
-- Alvor: Medium.
-- Versioner: Windows Server 2012 og nyere (navnerummet TaskScheduler findes ikke på 2008 R2: "ikke målbar").
--
-- Hvad tjekkes, og hvorfor:
-- En opgave i Opgavestyring (Task Scheduler) sluttede med en fejlkode. Planlagte opgaver er ofte oprydning, eksport,
-- integrationer eller filbackup: når de fejler, opdager ingen det, før noget mangler.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke på den overvågede server):
-- Et fund pr. aktiveret, overvåget opgave, hvis LastTaskResult er en fejl. Indeholder opgavens navn en brugers SID
-- (S-1-5-21-…), står den i titlen som "bruger-" og en kort hash; det fulde navn kun som følsom måling.
--
-- Grænseværdier (agentens appsettings.json):
-- SQLInsight:Servers:Tasks:IncludeMicrosoftTasks standard false (Microsofts egne opgaver under \Microsoft\ overvåges ikke)
-- SQLInsight:Servers:Tasks:IgnoreUserAndUpdaterTasks standard true (opgaver pr. bruger – en SID i navnet – og programmers
-- egne opdateringsopgaver: OneDrive, MicrosoftEdgeUpdate, GoogleUpdate,
-- User_Feed_Synchronization; TaskResults.DefaultIgnoredNames)
-- SQLInsight:Servers:Tasks:Ignore opgaver der aldrig overvåges (sti\navn eller præfiks*)
-- SQLInsight:Servers:Tasks:MaxWatched standard 50 (højst så mange opgaver pr. server)
-- Resultater, der ikke er fejl: 0x0, 0x41300 (klar), 0x41301 (kører), 0x41302 (deaktiveret), 0x41303 (har ikke kørt),
-- 0x41304 (ingen flere kørsler), 0x41325 (i kø), 0x8004131F (kørte allerede) – src/SQLInsight.Core/Servers/ServerTasks.cs.
--
-- Rettigheder på den overvågede server (db/permissions/grant-windows-server.ps1, aldrig lokal administrator):
-- WMI Enable Account/Remote Enable på root\Microsoft\Windows\TaskScheduler. Agenten ser kun opgaver, hvis egne
-- sikkerhedsindstillinger tillader kontoen at læse dem. Seneste resultat læses med Task Schedulers GetInfo (et
-- skrivebeskyttet CIM-metodekald, som Get-ScheduledTaskInfo); over WinRM kræver metodekald, at kontoen også har
-- Execute-ret i WinRM – ellers bruges DCOM.
--
-- Data kommer fra (collector, fil – medlemmer, interval):
-- src/SQLInsight.Servers/CimWindowsServerSource.cs – Session.GetTasksAsync (CimQueries.TaskQuery og
-- PS_ScheduledTask.GetInfoByName), hvert 15. minut (SQLInsight:Servers:Intervals:tasks) fra
-- src/SQLInsight.Agent/Servers/ServerMonitor.cs – CollectTasksAsync.
-- Regelkode: src/SQLInsight.Rules/Servers/WindowsServerRules.cs : FailedScheduledTaskRule
--
-- Read-only: Ja. Reglen sender ingen SQL. Agenten læser kun fra Windows: CIM/WQL-forespørgsler (SELECT),
-- hændelsesloggen og for planlagte opgaver Task Schedulers skrivebeskyttede GetInfo. Den starter, stopper
-- eller ændrer intet på serveren. Filen indeholder kun kommentarer; forespørgslerne mod Windows står herunder.
-- ===================================================================================================================
-- Reglen bruger ikke SQL. Forespørgslerne mod Windows står herunder; filen indeholder kun kommentarer og ændrer intet.
--
-- CIM/WQL (namespace root\Microsoft\Windows\TaskScheduler):
-- SELECT TaskName, TaskPath, State FROM MSFT_ScheduledTask
-- CIM-metode (skrivebeskyttet) pr. overvåget opgave:
-- PS_ScheduledTask.GetInfoByName(TaskName, TaskPath) -> cmdletOutput: MSFT_TaskDynamicInfo
-- (LastRunTime, LastTaskResult, NextRunTime, NumberOfMissedRuns)
--
-- Efterprøv med PowerShell (skrivebeskyttet; kør på agentens server som agentens konto):
-- $s = New-CimSession -ComputerName SERVER
-- Get-ScheduledTask -CimSession $s | Where-Object TaskPath -notlike '\Microsoft\*' | Get-ScheduledTaskInfo -CimSession $s |
-- Select-Object TaskPath, TaskName, LastRunTime, @{n='Resultat';e={'0x{0:X}' -f $_.LastTaskResult}}, NextRunTime, NumberOfMissedRuns

View file

@ -0,0 +1,47 @@
-- ===================================================================================================================
-- SQLI-WIN-013: En planlagt opgave kørte ikke, da den skulle
-- ===================================================================================================================
-- Regel-id: SQLI-WIN-013
-- Titel: En planlagt opgave kørte ikke, da den skulle
-- Alvor: Medium.
-- Versioner: Windows Server 2012 og nyere.
--
-- Hvad tjekkes, og hvorfor:
-- Opgaven skulle have kørt, men gjorde det ikke: Opgavestyring kørte ikke, betingelserne var ikke opfyldt (fx "kun når
-- brugeren er logget på"), eller serveren var slukket. Det, opgaven gør, er ikke sket.
--
-- Vurdering (sker i C# ud fra de indsamlede data, ikke på den overvågede server):
-- Et fund pr. aktiveret, overvåget opgave, der ikke kører nu, og hvor enten NumberOfMissedRuns > 0, eller NextRunTime
-- ligger mere end LateMinutes i fortiden, uden at LastRunTime er efter det tidspunkt.
--
-- Grænseværdier (agentens appsettings.json):
-- SQLInsight:Servers:Tasks:LateMinutes standard 60 (næste kørsel mere end så mange minutter i fortiden)
-- SQLInsight:Servers:Tasks:IncludeMicrosoftTasks standard false
-- SQLInsight:Servers:Tasks:IgnoreUserAndUpdaterTasks standard true (opgaver pr. bruger og opdateringsopgaver, se SQLI-WIN-012)
-- SQLInsight:Servers:Tasks:Ignore opgaver der aldrig overvåges
-- En brugers SID i opgavens navn står aldrig i titlen ("bruger-" og en kort hash), kun i den følsomme måling.
--
-- Rettigheder på den overvågede server (db/permissions/grant-windows-server.ps1, aldrig lokal administrator):
-- WMI Enable Account/Remote Enable på root\Microsoft\Windows\TaskScheduler (se SQLI-WIN-012 om GetInfo over WinRM).
--
-- Data kommer fra (collector, fil – medlemmer, interval):
-- src/SQLInsight.Servers/CimWindowsServerSource.cs – Session.GetTasksAsync (CimQueries.TaskQuery og
-- PS_ScheduledTask.GetInfoByName), hvert 15. minut fra src/SQLInsight.Agent/Servers/ServerMonitor.cs – CollectTasksAsync.
-- Regelkode: src/SQLInsight.Rules/Servers/WindowsServerRules.cs : MissedScheduledTaskRule
--
-- Read-only: Ja. Reglen sender ingen SQL. Agenten læser kun fra Windows: CIM/WQL-forespørgsler (SELECT),
-- hændelsesloggen og for planlagte opgaver Task Schedulers skrivebeskyttede GetInfo. Den starter, stopper
-- eller ændrer intet på serveren. Filen indeholder kun kommentarer; forespørgslerne mod Windows står herunder.
-- ===================================================================================================================
-- Reglen bruger ikke SQL. Forespørgslerne mod Windows står herunder; filen indeholder kun kommentarer og ændrer intet.
--
-- CIM/WQL (namespace root\Microsoft\Windows\TaskScheduler):
-- SELECT TaskName, TaskPath, State FROM MSFT_ScheduledTask
-- CIM-metode (skrivebeskyttet) pr. overvåget opgave:
-- PS_ScheduledTask.GetInfoByName(TaskName, TaskPath) -> cmdletOutput: MSFT_TaskDynamicInfo
--
-- Efterprøv med PowerShell (skrivebeskyttet; kør på agentens server som agentens konto):
-- $s = New-CimSession -ComputerName SERVER
-- Get-ScheduledTask -CimSession $s | Where-Object { $_.TaskPath -notlike '\Microsoft\*' -and $_.State -ne 'Disabled' } |
-- Get-ScheduledTaskInfo -CimSession $s | Where-Object { $_.NumberOfMissedRuns -gt 0 -or ($_.NextRunTime -and $_.NextRunTime -lt (Get-Date).AddMinutes(-60)) }

View file

@ -0,0 +1,50 @@
-- ===================================================================================================================
-- Collector: SyntheticCheckRunner (syntetiske SQL-tjek, fra 1.5)
-- ===================================================================================================================
-- Hvad: Et syntetisk SQL-tjek logger på den instans, tjekket peger på, som tjekkets login (agentens Windows-konto
-- eller et SQL-login, hvis adgangskode er krypteret til agenten), og kører tjekkets forespørgsel i en
-- transaktion, der altid rulles tilbage. Svartiden og om det lykkedes bruges af SQLI-SYN-001 og SQLI-SYN-002.
-- Fil: src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs (kaldt af src/SQLInsight.Agent/Servers/SyntheticCheckService.cs)
-- Interval: Tjekkets eget interval (standard 300 sek, mindst 60), og når der bedes om "Kør nu"
-- Versioner: SQL Server 2005 og nyere.
--
-- Rettigheder: Login'et i tjekket skal kunne logge ind og køre forespørgslen. Til standardforespørgslen (SELECT 1) er
-- det nok at kunne logge ind (og CONNECT til databasen, hvis tjekket har en).
--
-- Bruges af: SQLI-SYN-001, SQLI-SYN-002.
-- Read-only: Ja. Forespørgslen valideres som én skrivebeskyttet SELECT, når tjekket gemmes og før hver kørsel
-- (src/SQLInsight.Core/CustomRules/ReadOnlyQuery.cs: ingen andre sætninger, ingen INTO, EXEC, sekvenser
-- eller låse-hints), og køres i en transaktion, der altid rulles tilbage. Højst 10.000 rækker læses.
-- Kørsel: Kør i SSMS som tjekkets login. Hver GO er en separat kommando på samme forbindelse; ret [msdb] til
-- tjekkets database, eller udelad USE, hvis tjekket ingen database har.
-- ===================================================================================================================
-- Variant: Alle versioner: sættes på hver forbindelse, agenten åbner (også tjekkets)
-- Kilde: SqlConnectionFactory.SessionSettings
-- Kode: src/SQLInsight.Connectivity/SqlConnectionFactory.cs : SqlConnectionFactory.SessionSettings
SET LOCK_TIMEOUT 5000; SET DEADLOCK_PRIORITY LOW; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
GO
-- Variant: Kun når tjekket har en database (vist for msdb)
-- Kilde: SyntheticCheckRunner.UseDatabaseSql
-- Kode: src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs : SyntheticCheckRunner.UseDatabaseSql(database)
USE [msdb];
GO
-- Variant: Alle versioner fra SQL Server 2005: før forespørgslen
-- Kilde: SyntheticCheckRunner.BeginTransactionSql
-- Kode: src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs : SyntheticCheckRunner.BeginTransactionSql
BEGIN TRANSACTION;
GO
-- Variant: Standardforespørgslen, når tjekket ikke har sin egen (en egen forespørgsel køres her i stedet, højst 10.000 rækker læses)
-- Kilde: SyntheticCheckSettings.DefaultQuery
-- Kode: src/SQLInsight.Core/SyntheticChecks/SyntheticCheckDefinitions.cs : SyntheticCheckSettings.DefaultQuery
SELECT 1
GO
-- Variant: Alle versioner fra SQL Server 2005: altid efter forespørgslen, også når den fejler
-- Kilde: SyntheticCheckRunner.RollbackSql
-- Kode: src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs : SyntheticCheckRunner.RollbackSql
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
GO

View file

@ -9,6 +9,7 @@
<Project Path="src/SQLInsight.Hub/SQLInsight.Hub.csproj" />
<Project Path="src/SQLInsight.Licensing/SQLInsight.Licensing.csproj" />
<Project Path="src/SQLInsight.Reports/SQLInsight.Reports.csproj" />
<Project Path="src/SQLInsight.Servers/SQLInsight.Servers.csproj" />
<Project Path="src/SQLInsight.Updates/SQLInsight.Updates.csproj" />
<Project Path="src/SQLInsight.Notifications/SQLInsight.Notifications.csproj" />
<Project Path="src/SQLInsight.Repository/SQLInsight.Repository.csproj" />
@ -25,6 +26,7 @@
<Project Path="tests/SQLInsight.Licensing.Tests/SQLInsight.Licensing.Tests.csproj" />
<Project Path="tests/SQLInsight.Notifications.Tests/SQLInsight.Notifications.Tests.csproj" />
<Project Path="tests/SQLInsight.Rules.Tests/SQLInsight.Rules.Tests.csproj" />
<Project Path="tests/SQLInsight.Servers.Tests/SQLInsight.Servers.Tests.csproj" />
<Project Path="tests/SQLInsight.Updates.Tests/SQLInsight.Updates.Tests.csproj" />
</Folder>
<Folder Name="/tools/">

View file

@ -0,0 +1,467 @@
<#
SQLInsight Pro: least-privilege rettigheder til agentens konto på en Windows-server
=====================================================================================
Giver SQLInsight-agentens konto præcis den læseadgang, den skal bruge for at overvåge denne server:
CPU, hukommelse, diske, tjenester, hændelseslog og planlagte opgaver. Aldrig lokal administrator, og
aldrig ret til at køre kode på serveren (heller ikke PowerShell Remoting).
Scriptet ændrer kun adgangsrettigheder: det starter, stopper eller ændrer ingen tjenester, opgaver eller
indstillinger, og det læser ingen data.
Afprøv det først på en server, der ikke er i produktion.
Kør som administrator på den server, der skal overvåges (Windows PowerShell 2.0 eller nyere):
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -WhatIf # vis, hvad der ville ske; ændrer intet
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$'
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -WinRM -EnableFirewall -AgentAddress 10.0.0.15
Fjern præcis det, scriptet har givet, igen:
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -Remove
Agentens konto: kører agenten som NT SERVICE\SQLInsight Agent, LocalSystem eller NETWORK SERVICE, kommer den
til andre servere som computerkontoen DOMÆNE\COMPUTERNAVN$. En gMSA eller domænekonto er den samme overalt.
Hvad der gives, og hvorfor:
Performance Monitor Users ydelsestællere: CPU, hukommelse, paging
Event Log Readers hændelseslogge, der tillader gruppen (System, Application m.fl.; aldrig Security)
Distributed COM Users WMI/CIM over DCOM (RPC)
WMI: Enable Account + Remote Enable (0x21) på root\cimv2 og root\Microsoft\Windows\TaskScheduler, kun på
selve navnerummet (ikke arvet). Ingen skrive- eller metoderet ud over Windows'
standard.
Service Control Manager forbinde, liste tjenester, læse låsestatus og sikkerhed (CC LC RP RC) - det
samme, som en lokalt logget på bruger har. Ingen ret til at starte eller stoppe.
Kun med -WinRM læseret (GENERIC_READ, ingen Execute) i WinRM's RootSDDL, så WMI kan læses over
WinRM (TCP 5985). Kontoen kommer IKKE i Remote Management Users, for den gruppe
giver også PowerShell Remoting. Uden -WinRM bruger agenten DCOM.
Hvad der IKKE gives: lokal administrator, PowerShell Remoting/WinRS, ret til at starte/stoppe tjenester,
skrive i WMI, læse filer eller Security-loggen.
Firewall (kun med -EnableFirewall og -AgentAddress, Windows Server 2012 og nyere): opretter scriptets egne regler
"SQLInsight Agent: ..." for RPC (WMI over DCOM og hændelseslog) og med -WinRM for TCP 5985, kun fra agentens
adresse. Windows' indbyggede regler røres ikke.
Sikkerhedsnet:
- Intet ændres, før alt er læst og kontrolleret (konto, grupper, sikkerhed, firewall). Fejler kontrollen, er
intet ændret.
- Før første ændring gemmes de oprindelige indstillinger (SDDL for Service Control Manager, WMI-navnerummene og
WinRM, gruppemedlemskaber og firewallregler) i %ProgramData%\SQLInsight\grant-windows-server\<SID>-<tid>-foer.xml.
- Hvad scriptet selv har tilføjet, står i %ProgramData%\SQLInsight\grant-windows-server\<SID>.xml (kun
administratorer har adgang til mappen). -Remove fjerner kun det; intet, der fandtes før.
- Har kontoen allerede en rettighed, røres den ikke (og fjernes ikke med -Remove). Har den en "Nægt"-regel,
stopper scriptet uden at ændre noget.
- Hvert skridt kontrolleres bagefter. Fejler et skridt, står det, der allerede er gjort, i tilstandsfilen, så
scriptet kan køres igen eller med -Remove.
- Manuel gendannelse af Service Control Manager: sc.exe sdset scmanager "<SDDL fra -foer.xml>".
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[string] $Account = 'DOMÆNE\AGENTSERVER$', # <== agentens konto
[switch] $Remove,
[switch] $WinRM,
[switch] $EnableFirewall,
[string] $AgentAddress
)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
$placeholder = 'DOMÆNE\AGENTSERVER$'
$whatIf = [bool] $WhatIfPreference
# ---- 1. Kontrol: intet ændres, før alt her er i orden ---------------------------------------------------------
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Kør scriptet i en PowerShell, der er startet som administrator ("Kør som administrator"). Intet er ændret.'
}
if ([string]::IsNullOrEmpty($Account) -or $Account -eq $placeholder) {
throw "Angiv agentens konto, fx -Account 'DOMÆNE\AGENTSERVER`$' (SQLInsight Web viser den under serveren). Intet er ændret."
}
foreach ($c in $Account.ToCharArray()) {
if ([char]::IsControl($c)) { throw 'Kontonavnet indeholder et kontroltegn. Intet er ændret.' }
}
try {
$sidObj = (New-Object Security.Principal.NTAccount($Account)).Translate([Security.Principal.SecurityIdentifier])
} catch {
throw "Kontoen '$Account' kunne ikke slås op (findes den, og kan serveren nå domænet?). Intet er ændret."
}
$sid = $sidObj.Value
if ($sid -eq 'S-1-5-18') {
Write-Host 'Agenten kører som LocalSystem på denne server og har allerede adgang. Intet at gøre.'
return
}
# Grupper med mange medlemmer: rettighederne skal gives til agentens egen konto, aldrig til alle.
$broad = @('S-1-1-0', 'S-1-5-7', 'S-1-5-2', 'S-1-5-4', 'S-1-5-11', 'S-1-5-32-544', 'S-1-5-32-545', 'S-1-5-32-546')
if (($broad -contains $sid) -or ($sid -match '^S-1-5-21-.+-(512|513|514|515|516|519)$')) {
throw "'$Account' er en bred gruppe. Giv rettighederne til agentens egen konto (gMSA, domænekonto eller computerkonto). Intet er ændret."
}
if ($EnableFirewall -and -not $Remove) {
if ([string]::IsNullOrEmpty($AgentAddress)) {
throw 'Med -EnableFirewall skal -AgentAddress angives (agentens IP-adresse eller net, fx 10.0.0.15 eller 10.0.0.0/24), så portene kun åbnes for agenten. Intet er ændret.'
}
foreach ($a in $AgentAddress.Split(',')) {
$ip = $null
if (-not [Net.IPAddress]::TryParse($a.Trim().Split('/')[0], [ref] $ip)) {
throw "-AgentAddress '$a' er ikke en IP-adresse eller et net (fx 10.0.0.15 eller 10.0.0.0/24). Intet er ændret."
}
}
if (-not (Get-Command New-NetFirewallRule -ErrorAction SilentlyContinue)) {
throw '-EnableFirewall kræver Windows Server 2012 eller nyere. Åbn portene manuelt (se docs/windows-servere.md). Intet er ændret.'
}
} elseif ($AgentAddress -and -not $Remove) {
Write-Host ' (-AgentAddress bruges kun sammen med -EnableFirewall)'
}
$stateDir = Join-Path $env:ProgramData 'SQLInsight\grant-windows-server'
$statePath = Join-Path $stateDir "$sid.xml"
if (Test-Path $statePath) {
$state = Import-Clixml $statePath
} else {
$state = @{ Sid = $sid; Account = $Account; Groups = @(); Wmi = @(); Scm = $false; WinRMRoot = $false; Firewall = @() }
}
if ($Remove) { $verb = 'Fjerner det, scriptet har givet' } else { $verb = 'Giver rettigheder til' }
Write-Host "$verb $Account ($sid) på $env:COMPUTERNAME"
if ($Remove -and -not (Test-Path $statePath)) {
Write-Host "Intet at fjerne: scriptet har ikke givet $Account noget på denne server ($statePath findes ikke)."
return
}
# ---- Hjælpefunktioner (sikkerhedsbeskrivelser som SDDL, ændret via .NET's RawSecurityDescriptor) ---------------
$accessAllowed = [Security.AccessControl.AceQualifier]::AccessAllowed
$accessDenied = [Security.AccessControl.AceQualifier]::AccessDenied
$allSections = [Security.AccessControl.AccessControlSections]::All
function Get-Aces([string] $Sddl) {
$raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList $Sddl
if ($raw.DiscretionaryAcl -eq $null) { return @() }
return @($raw.DiscretionaryAcl | Where-Object { $_ -is [Security.AccessControl.CommonAce] -and $_.SecurityIdentifier -eq $sidObj })
}
# Har kontoen en Nægt-regel? Så stopper scriptet: den er sat med vilje.
function Test-Denied([string] $Sddl) {
return @(Get-Aces $Sddl | Where-Object { $_.AceQualifier -eq $accessDenied }).Count -gt 0
}
# Har kontoen allerede (mindst) disse rettigheder fra en Tillad-regel?
function Test-Allowed([string] $Sddl, [int] $Mask) {
return @(Get-Aces $Sddl | Where-Object { $_.AceQualifier -eq $accessAllowed -and (($_.AccessMask -band $Mask) -eq $Mask) }).Count -gt 0
}
# Fjerner præcis én Tillad-regel for kontoen med netop denne maske, ikke arvet og uden flag (den, scriptet satte).
function Remove-Ace([string] $Sddl, [int] $Mask) {
$raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList $Sddl
if ($raw.DiscretionaryAcl -eq $null) { return $Sddl }
for ($i = 0; $i -lt $raw.DiscretionaryAcl.Count; $i++) {
$ace = $raw.DiscretionaryAcl[$i]
if ($ace -is [Security.AccessControl.CommonAce] -and $ace.SecurityIdentifier -eq $sidObj -and $ace.AceQualifier -eq $accessAllowed `
-and $ace.AccessMask -eq $Mask -and $ace.AceFlags -eq [Security.AccessControl.AceFlags]::None) {
$raw.DiscretionaryAcl.RemoveAce($i)
return $raw.GetSddlForm($allSections)
}
}
return $Sddl
}
# Indsætter præcis én Tillad-regel (ikke arvet) efter de eksplicitte regler og før de arvede (kanonisk orden).
function Add-Ace([string] $Sddl, [int] $Mask) {
$raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList $Sddl
if ($raw.DiscretionaryAcl -eq $null) { throw 'Sikkerhedsbeskrivelsen har ingen DACL; den ændres ikke.' }
$ace = New-Object Security.AccessControl.CommonAce -ArgumentList @([Security.AccessControl.AceFlags]::None, $accessAllowed, $Mask, $sidObj, $false, $null)
$at = 0
while ($at -lt $raw.DiscretionaryAcl.Count -and -not $raw.DiscretionaryAcl[$at].IsInherited) { $at++ }
$raw.DiscretionaryAcl.InsertAce($at, $ace)
$new = $raw.GetSddlForm($allSections)
# Kontrol: fjernes den nye regel igen, er resten præcis som før.
$before = (New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList $Sddl).GetSddlForm($allSections)
if ((Remove-Ace $new $Mask) -ne $before) {
throw 'Intern kontrol fejlede: den nye sikkerhedsbeskrivelse adskiller sig fra den gamle med mere end én regel.'
}
return $new
}
function Read-NamespaceSddl([string] $Namespace) {
$security = Get-WmiObject -Namespace $Namespace -Class __SystemSecurity
$binary = @($null)
$rc = $security.PsBase.InvokeMethod('GetSD', $binary)
if ($rc -ne 0) { throw "Kunne ikke læse sikkerheden på WMI-navnerummet $Namespace ($rc)." }
return (New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList @($binary[0], 0)).GetSddlForm($allSections)
}
function Write-NamespaceSddl([string] $Namespace, [string] $Sddl) {
$raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList $Sddl
$bytes = New-Object byte[] $raw.BinaryLength
$raw.GetBinaryForm($bytes, 0)
$security = Get-WmiObject -Namespace $Namespace -Class __SystemSecurity
$rc = $security.PsBase.InvokeMethod('SetSD', @(, $bytes))
if ($rc -ne 0) { throw "Kunne ikke sætte sikkerheden på WMI-navnerummet $Namespace ($rc)." }
}
function Read-ScmSddl {
$sddl = ((& sc.exe sdshow scmanager) | Where-Object { $_ -match '\S' }) -join ''
if ($LASTEXITCODE -ne 0 -or $sddl -notmatch '^D:') { throw "Kunne ikke læse Service Control Managers sikkerhed: $sddl" }
return $sddl
}
function Write-ScmSddl([string] $Sddl) {
$output = & sc.exe sdset scmanager $Sddl
if ($LASTEXITCODE -ne 0) { throw "sc.exe sdset scmanager fejlede ($LASTEXITCODE): $output" }
}
function Get-GroupMembers($Group) {
return @($Group.Invoke('Members') | ForEach-Object {
$bytes = $_.GetType().InvokeMember('objectSid', 'GetProperty', $null, $_, $null)
(New-Object Security.Principal.SecurityIdentifier($bytes, 0)).Value
})
}
# Medlemmer tilføjes og fjernes via deres SID (virker også for NT SERVICE\..., gMSA og computerkonti), ellers via navnet.
function Set-GroupMember($Group, [bool] $Add) {
$paths = @("WinNT://$sid", 'WinNT://' + $Account.Replace('\', '/'))
foreach ($path in $paths) {
try {
if ($Add) { $Group.Add($path) } else { $Group.Remove($path) }
return
} catch {
if ($path -eq $paths[-1]) { throw }
}
}
}
function Save-State {
if ($whatIf) { return }
if (-not (Test-Path $stateDir)) { New-Item -ItemType Directory -Force -Path $stateDir | Out-Null }
# Kun administratorer og SYSTEM: filen styrer, hvad -Remove fjerner.
$acl = New-Object Security.AccessControl.DirectorySecurity
$acl.SetAccessRuleProtection($true, $false)
foreach ($who in 'S-1-5-32-544', 'S-1-5-18') {
$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule((New-Object Security.Principal.SecurityIdentifier($who)), 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow')))
}
Set-Acl -Path $stateDir -AclObject $acl
$state | Export-Clixml -Path $statePath
}
# ---- 2. Læs nuværende tilstand og læg en plan (ændrer intet) ---------------------------------------------------
$wmiMask = 0x21 # Enable Account (0x1) + Remote Enable (0x20)
$scmMask = 0x20015 # CC (0x1) + LC (0x4) + RP (0x10) + RC (READ_CONTROL 0x20000)
$winrmMask = [int] 0x80000000 # GENERIC_READ (GR), ingen Execute (GX)
$plan = New-Object Collections.ArrayList
$original = @{ Time = (Get-Date).ToString('o'); Account = $Account; Sid = $sid; Groups = @{}; Wmi = @{}; Scm = $null; WinRMRoot = $null; Firewall = @{} }
# Lokale grupper, fundet via deres SID, så det virker på dansk, engelsk og andre sprog.
$groups = @(
@('S-1-5-32-558', 'ydelsestællere (CPU, hukommelse, paging)'),
@('S-1-5-32-573', 'hændelseslogge (aldrig Security)'),
@('S-1-5-32-562', 'WMI over DCOM')
)
foreach ($g in $groups) {
try {
$groupName = (New-Object Security.Principal.SecurityIdentifier($g[0])).Translate([Security.Principal.NTAccount]).Value.Split('\')[1]
} catch {
Write-Host " - gruppen $($g[0]) ($($g[1])) findes ikke på denne Windows-version; springes over"
continue
}
$group = [ADSI]("WinNT://$env:COMPUTERNAME/$groupName,group")
$isMember = (Get-GroupMembers $group) -contains $sid
$original.Groups[$g[0]] = $isMember
$added = @($state.Groups) -contains $g[0]
if ($Remove) {
if ($added -and $isMember) { [void] $plan.Add(@{ Kind = 'GroupRemove'; Sid = $g[0]; Name = $groupName; Text = "fjern fra gruppen $groupName" }) }
elseif ($added) { $state.Groups = @($state.Groups | Where-Object { $_ -ne $g[0] }); Write-Host " = ${groupName}: allerede fjernet" }
elseif ($isMember) { Write-Host " = ${groupName}: var medlem før scriptet; bevares" }
} elseif ($isMember) {
Write-Host " = ${groupName}: allerede medlem"
} else {
[void] $plan.Add(@{ Kind = 'GroupAdd'; Sid = $g[0]; Name = $groupName; Text = "medlem af $groupName ($($g[1]))" })
}
}
# WMI-navnerum.
$namespaces = @('root\cimv2')
if (Get-WmiObject -Namespace 'root\Microsoft\Windows' -Class __Namespace -Filter "Name='TaskScheduler'" -ErrorAction SilentlyContinue) {
$namespaces += 'root\Microsoft\Windows\TaskScheduler'
} elseif (-not $Remove) {
Write-Host ' - root\Microsoft\Windows\TaskScheduler findes ikke (før Windows Server 2012): planlagte opgaver kan ikke måles'
}
foreach ($ns in $namespaces) {
$sddl = Read-NamespaceSddl $ns
$original.Wmi[$ns] = $sddl
if ($Remove) {
if (@($state.Wmi) -contains $ns) { [void] $plan.Add(@{ Kind = 'WmiRemove'; Namespace = $ns; Text = "fjern WMI-adgang på $ns" }) }
} elseif (Test-Denied $sddl) {
throw "Kontoen har en Nægt-regel på WMI-navnerummet $ns. Den er sat med vilje; fjern den først, hvis agenten skal læse herfra. Intet er ændret."
} elseif (Test-Allowed $sddl $wmiMask) {
Write-Host " = WMI ${ns}: har allerede adgang"
} else {
[void] (Add-Ace $sddl $wmiMask) # kontrollerer, at ændringen er præcis én regel
[void] $plan.Add(@{ Kind = 'WmiAdd'; Namespace = $ns; Text = "WMI $ns (Enable Account, Remote Enable)" })
}
}
# Service Control Manager: over netværket må ellers kun administratorer liste tjenester.
$scm = Read-ScmSddl
$original.Scm = $scm
if ($Remove) {
if ($state.Scm) { [void] $plan.Add(@{ Kind = 'ScmRemove'; Text = 'fjern læseadgang til Service Control Manager' }) }
} elseif (Test-Denied $scm) {
throw 'Kontoen har en Nægt-regel på Service Control Manager. Den er sat med vilje; fjern den først, hvis agenten skal se tjenester. Intet er ændret.'
} elseif (Test-Allowed $scm $scmMask) {
Write-Host ' = Service Control Manager: har allerede læseadgang'
} else {
[void] (Add-Ace $scm $scmMask)
[void] $plan.Add(@{ Kind = 'ScmAdd'; Text = 'Service Control Manager (CC LC RP RC: forbinde, liste, læse)' })
}
# WinRM (kun med -WinRM, eller når -Remove skal fjerne det, scriptet gav).
if (($WinRM -and -not $Remove) -or ($Remove -and $state.WinRMRoot)) {
$service = Get-Service -Name WinRM -ErrorAction SilentlyContinue
if (-not $service -or $service.Status -ne 'Running') {
throw 'WinRM-tjenesten kører ikke. Start den (winrm quickconfig), eller kør uden -WinRM, så agenten bruger DCOM. Intet er ændret.'
}
$root = (Get-Item WSMan:\localhost\Service\RootSDDL).Value
$original.WinRMRoot = $root
if ($Remove) {
[void] $plan.Add(@{ Kind = 'WinRMRemove'; Text = 'fjern læseadgang i WinRM (RootSDDL)' })
} elseif (Test-Denied $root) {
throw 'Kontoen har en Nægt-regel i WinRM (RootSDDL). Kør uden -WinRM, så agenten bruger DCOM. Intet er ændret.'
} elseif (Test-Allowed $root $winrmMask) {
Write-Host ' = WinRM: har allerede læseadgang'
} else {
[void] (Add-Ace $root $winrmMask)
[void] $plan.Add(@{ Kind = 'WinRMAdd'; Text = 'WinRM (RootSDDL): læseret (GR), ingen Execute' })
}
}
# Firewall: scriptets egne regler, kun fra agentens adresse. Windows' indbyggede regler røres ikke.
$rules = @(
@{ Name = 'SQLInsight-Agent-RPC-EPMAP'; Display = 'SQLInsight Agent: RPC Endpoint Mapper (WMI over DCOM, hændelseslog)'; Port = 'RPCEPMap'; Service = 'rpcss' },
@{ Name = 'SQLInsight-Agent-WMI-DCOM'; Display = 'SQLInsight Agent: WMI over DCOM'; Port = 'RPC'; Service = 'winmgmt' },
@{ Name = 'SQLInsight-Agent-EventLog'; Display = 'SQLInsight Agent: hændelseslog (RPC)'; Port = 'RPC'; Service = 'eventlog' }
)
if ($WinRM) { $rules += @{ Name = 'SQLInsight-Agent-WinRM'; Display = 'SQLInsight Agent: WinRM (HTTP 5985)'; Port = '5985'; Service = $null } }
if ($Remove -and @($state.Firewall).Count -gt 0 -and (Get-Command Get-NetFirewallRule -ErrorAction SilentlyContinue)) {
foreach ($name in @($state.Firewall)) {
$existing = Get-NetFirewallRule -Name $name -ErrorAction SilentlyContinue
if ($existing) { $original.Firewall[$name] = @(($existing | Get-NetFirewallAddressFilter).RemoteAddress) }
if ($existing) { [void] $plan.Add(@{ Kind = 'FirewallRemove'; Name = $name; Text = "fjern firewallreglen $name" }) }
}
} elseif ($EnableFirewall -and -not $Remove) {
$wanted = @($AgentAddress.Split(',') | ForEach-Object { $_.Trim() })
foreach ($r in $rules) {
$existing = Get-NetFirewallRule -Name $r.Name -ErrorAction SilentlyContinue
if ($existing) {
$current = @(($existing | Get-NetFirewallAddressFilter).RemoteAddress)
$original.Firewall[$r.Name] = $current
if (($current -join ',') -eq ($wanted -join ',')) { Write-Host " = firewall $($r.Name): uændret"; continue }
[void] $plan.Add(@{ Kind = 'FirewallUpdate'; Rule = $r; Address = $wanted; Text = "firewallreglen $($r.Name): kun fra $AgentAddress" })
} else {
[void] $plan.Add(@{ Kind = 'FirewallAdd'; Rule = $r; Address = $wanted; Text = "firewall: $($r.Display) (TCP $($r.Port)), kun fra $AgentAddress" })
}
}
}
if ($plan.Count -eq 0) {
if ($Remove -and -not $whatIf) { Save-State }
Write-Host 'Alt er allerede på plads. Intet er ændret.'
return
}
# ---- 3. Gem det oprindelige, og udfør planen ét skridt ad gangen -----------------------------------------------
$backupPath = Join-Path $stateDir ("$sid-" + (Get-Date).ToString('yyyyMMdd-HHmmss') + '-foer.xml')
if (-not $whatIf) {
Save-State
$original | Export-Clixml -Path $backupPath
Write-Host " Oprindelige indstillinger gemt i $backupPath"
}
foreach ($step in $plan) {
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, $step.Text)) { continue }
try {
switch ($step.Kind) {
'GroupAdd' {
$group = [ADSI]("WinNT://$env:COMPUTERNAME/$($step.Name),group")
Set-GroupMember $group $true
if (-not ((Get-GroupMembers $group) -contains $sid)) { throw "kontoen kom ikke i gruppen $($step.Name)" }
$state.Groups = @($state.Groups) + $step.Sid
}
'GroupRemove' {
$group = [ADSI]("WinNT://$env:COMPUTERNAME/$($step.Name),group")
Set-GroupMember $group $false
$state.Groups = @($state.Groups | Where-Object { $_ -ne $step.Sid })
}
'WmiAdd' {
Write-NamespaceSddl $step.Namespace (Add-Ace (Read-NamespaceSddl $step.Namespace) $wmiMask)
if (-not (Test-Allowed (Read-NamespaceSddl $step.Namespace) $wmiMask)) { throw "adgangen på $($step.Namespace) blev ikke sat" }
$state.Wmi = @($state.Wmi) + $step.Namespace
}
'WmiRemove' {
$sddl = Read-NamespaceSddl $step.Namespace
$new = Remove-Ace $sddl $wmiMask
if ($new -ne $sddl) { Write-NamespaceSddl $step.Namespace $new }
$state.Wmi = @($state.Wmi | Where-Object { $_ -ne $step.Namespace })
}
'ScmAdd' {
Write-ScmSddl (Add-Ace (Read-ScmSddl) $scmMask)
if (-not (Test-Allowed (Read-ScmSddl) $scmMask)) { throw 'læseadgangen til Service Control Manager blev ikke sat' }
$state.Scm = $true
}
'ScmRemove' {
$sddl = Read-ScmSddl
$new = Remove-Ace $sddl $scmMask
if ($new -ne $sddl) { Write-ScmSddl $new }
$state.Scm = $false
}
'WinRMAdd' {
$new = Add-Ace (Get-Item WSMan:\localhost\Service\RootSDDL).Value $winrmMask
Set-Item -Path WSMan:\localhost\Service\RootSDDL -Value $new -Force -WhatIf:$false -Confirm:$false
if (-not (Test-Allowed (Get-Item WSMan:\localhost\Service\RootSDDL).Value $winrmMask)) { throw 'læseretten i WinRM blev ikke sat' }
$state.WinRMRoot = $true
}
'WinRMRemove' {
$sddl = (Get-Item WSMan:\localhost\Service\RootSDDL).Value
$new = Remove-Ace $sddl $winrmMask
if ($new -ne $sddl) { Set-Item -Path WSMan:\localhost\Service\RootSDDL -Value $new -Force -WhatIf:$false -Confirm:$false }
$state.WinRMRoot = $false
}
'FirewallAdd' {
$r = $step.Rule
$arguments = @{ Name = $r.Name; DisplayName = $r.Display; Direction = 'Inbound'; Action = 'Allow'; Protocol = 'TCP'; LocalPort = $r.Port; RemoteAddress = $step.Address; Profile = 'Any' }
if ($r.Service) { $arguments.Program = '%SystemRoot%\system32\svchost.exe'; $arguments.Service = $r.Service }
New-NetFirewallRule @arguments -WhatIf:$false -Confirm:$false | Out-Null
$state.Firewall = @($state.Firewall) + $r.Name
}
'FirewallUpdate' {
Set-NetFirewallRule -Name $step.Rule.Name -RemoteAddress $step.Address -WhatIf:$false -Confirm:$false
if (-not (@($state.Firewall) -contains $step.Rule.Name)) { $state.Firewall = @($state.Firewall) + $step.Rule.Name }
}
'FirewallRemove' {
Remove-NetFirewallRule -Name $step.Name -WhatIf:$false -Confirm:$false
$state.Firewall = @($state.Firewall | Where-Object { $_ -ne $step.Name })
}
}
} catch {
Save-State
throw "Fejlede ved '$($step.Text)': $($_.Exception.Message) Det, der allerede er gjort, står i $statePath; ret fejlen og kør scriptet igen, eller kør det med -Remove. De oprindelige indstillinger står i $backupPath."
}
Save-State
if ($Remove) { Write-Host " - $($step.Text)" } else { Write-Host " + $($step.Text)" }
}
if ($whatIf) { return }
if ($Remove) {
$left = @($state.Groups).Count + @($state.Wmi).Count + @($state.Firewall).Count
if ($state.Scm) { $left++ }
if ($state.WinRMRoot) { $left++ }
if ($left -eq 0) {
Move-Item -Path $statePath -Destination (Join-Path $stateDir ("$sid-" + (Get-Date).ToString('yyyyMMdd-HHmmss') + '-fjernet.xml'))
}
Write-Host 'Færdig. Det, scriptet gav, er fjernet; det, der fandtes før, er bevaret.'
} else {
Write-Host 'Færdig. Test forbindelsen fra SQLInsight (Servere > Windows-servere > serveren > Test forbindelsen).'
}

View file

@ -67,7 +67,7 @@ Der deles aldrig kundenavne, instans- eller servernavne, databasenavne, målinge
| Instanser, der ikke har rapporteret | 180 dage, med deres fund og versionshistorik |
| Alt om en kunde | Når aftalen ophører, eller kunden beder om det (`delete-customer`) |
Fristerne kan ændres i hubbens konfiguration (se [hub.md](hub.md)). I kundens eget repository styres opbevaringen af `SQLInsight:Retention`.
Fristerne kan ændres i hubbens konfiguration (se [hub.md](hub.md)). I kundens eget repository styres opbevaringen af `SQLInsight:Retention`, for Windows-servere og syntetiske tjek af `SQLInsight:Servers` og `SQLInsight:SyntheticChecks` (se [windows-servere.md](windows-servere.md#opbevaring)). Fund på Windows-servere og syntetiske tjek sendes ikke til SQLInsight Hub; hændelseslogtekster og fejlbeskeder bliver i kundens repository.
## Hvor ligger data?
@ -104,3 +104,17 @@ Deling er slået fra som standard og er ikke en betingelse for Professional elle
Agent og webbrugerflade gemmer data i kundens eget repository hos kunden. Her er kunden selv ansvarlig. Repositoriet indeholder de samme typer data som ovenfor, inklusive følsomme målinger (forespørgselstekst, logins, deadlock-grafer), fordi de er nødvendige for at løse problemerne lokalt. Til [analyse af tidsrum](analyse.md) gemmes desuden de tungeste forespørgsler med tekst og forbrug hvert 5. minut i `RawDays` (standard 14 dage); den tekst bruges kun lokalt og indgår aldrig i det, der sendes til SQLInsight Hub. Adgang til webbrugerfladen styres med Windows-login, og databasebrugeren har kun læserettigheder.
Til [rettighedsrevisionen](rettigheder.md) gemmes navne på personer og konti med forhøjede serverrettigheder (også personer i Windows-grupper, slået op i kundens Active Directory af agenten), hvem der har fået eller mistet dem, og hvem der foretog ændringen. Det er personoplysninger. De bliver i kundens repository: ændringsloggen slettes efter `ResolvedFindingDays` (standard 365 dage), og i fund er navnene følsomme målinger, som sendes til SQLInsight Hub og integrations-API'et som `[udeladt]`. Fundets titel og emne indeholder ingen navne. Revisionsrapporten (PDF) dannes kun lokalt.
Til [Windows-servere](windows-servere.md) og [syntetiske tjek](syntetiske-tjek.md) (fra 1.5) gemmes i kundens repository:
| Data | Kan indeholde | Slettes |
|---|---|---|
| CPU og hukommelse pr. minut | – | efter `SQLInsight:Servers:SampleDays` (standard 14 dage) |
| Ledig plads pr. drev | drevbogstaver og etiketter | efter `SQLInsight:Servers:DiskHistoryDays` (standard 35 dage) |
| Grupperede fejl fra hændelsesloggen med den nyeste tekst som eksempel (højst 500 tegn) | brugernavne, computernavne, stier og IP-adresser | efter `SQLInsight:Servers:EventDays` (standard 90 dage) |
| Tjenester og planlagte opgaver (navne, status, seneste resultat) | navne på tjenestekonti er ikke med; opgaver pr. bruger kan have brugerens SID i navnet | erstattes ved hver indsamling |
| Syntetiske tjeks resultater med besked og detaljer | logins i SQL-fejltekster, adresser | efter `SQLInsight:SyntheticChecks:ResultDays` (standard 14 dage) |
| Et SQL-tjeks login og adgangskode | login'et; adgangskoden kun krypteret til agentens nøgle (som i 1.3) | når tjekket fjernes (login og krypteret adgangskode slettes med det samme) |
| Fund på servere og tjek | følsomme målinger som ovenfor | når de er løst, og derefter efter `ResolvedFindingDays` (standard 365 dage) |
Fundenes titel og emne indeholder ingen hændelsestekster, fejlbeskeder eller logins; de står kun i følsomme målinger, som integrations-API'et viser som `[udeladt]` (også agentens konto, brugernavne og adresser med forespørgselsparametre). Notifikationer (fx Teams og webhooks) får titel og emne. Fund på servere og syntetiske tjek sendes ikke til SQLInsight Hub og indgår endnu ikke i PDF-rapporterne.

View file

@ -92,6 +92,8 @@ Kør [`db/permissions/grant-least-privilege.sql`](../db/permissions/grant-least-
Overvåges Dynamics AX, kan agenten desuden få læseadgang til nogle få AX-systemtabeller (batchjob, nummerserier, RecId) med [`db/permissions/grant-dynamics-ax-read.sql`](../db/permissions/grant-dynamics-ax-read.sql). Det er et tilvalg pr. database; se [dynamics-ax.md](dynamics-ax.md).
Windows-serverne (CPU, diske, tjenester, hændelseslog, opgaver) læses med [`db/permissions/grant-windows-server.ps1`](../db/permissions/grant-windows-server.ps1), som giver agentens konto læseadgang uden lokal administrator; se [windows-servere.md](windows-servere.md#rettigheder-aldrig-lokal-administrator).
## 4. Agenten som Windows-tjeneste
```powershell
@ -186,4 +188,4 @@ Se [opdatering.md](opdatering.md): regelpakken opdateres automatisk, og nye vers
## Menuen i webbrugerfladen
Menuen er delt i grupper, der kan foldes ud og sammen: **Overvågning** (med *Servere og databaser*, se [servere-og-databaser.md](servere-og-databaser.md)), **Analyse**, **Konfiguration og sikkerhed**, **Rapporter**, **Dynamics AX** (*AX-databaser*, *Batchjob*, *Nummerserier*, *RecId* og *AX-brugere bag blokering*; kun når en database er genkendt som AX, se [dynamics-ax.md](dynamics-ax.md)), **Support**, **System** og **Community**. Gruppen med den side, man står på, er åben. Sider, der findes pr. instans (fx Analyse af tidsrum og Konfiguration), åbner et valg af instans; er der kun én instans, går de direkte til den. På en smal skærm (mobil) folder hele menuen sig sammen bag knappen **Menu**. Alt virker uden JavaScript.
Menuen er delt i grupper, der kan foldes ud og sammen: **Overvågning** (med *Servere og databaser*, se [servere-og-databaser.md](servere-og-databaser.md)), **Servere** (*Windows-servere* og *Syntetiske tjek*, se [windows-servere.md](windows-servere.md) og [syntetiske-tjek.md](syntetiske-tjek.md)), **Analyse**, **Konfiguration og sikkerhed**, **Rapporter**, **Dynamics AX** (*AX-databaser*, *Batchjob*, *Nummerserier*, *RecId* og *AX-brugere bag blokering*; kun når en database er genkendt som AX, se [dynamics-ax.md](dynamics-ax.md)), **Support**, **System** og **Community**. Gruppen med den side, man står på, er åben. Sider, der findes pr. instans (fx Analyse af tidsrum og Konfiguration), åbner et valg af instans; er der kun én instans, går de direkte til den. På en smal skærm (mobil) folder hele menuen sig sammen bag knappen **Menu**. Alt virker uden JavaScript.

View file

@ -91,9 +91,11 @@ Alt, hvad webbrugerfladen viser, kan hentes som JSON under `/api/v1/` med de sam
| `POST /api/v1/support-cases`, `GET /api/v1/support-cases[/{id}]`, `POST /api/v1/support-cases/{id}/close` | Supportsager: se [support.md](support.md) (kræver scope `support:write` for at skrive) |
| `GET /api/v1/dynamics-ax/databases`, `…/grant-script`, `…/batch-jobs`, `…/batch-runs`, `…/number-sequences`, `…/recids`, `…/sequence-samples`, `…/blocking` | Dynamics AX: genkendte AX-databaser med læseadgang og GRANT, batchjob og kørsler, nummerserier, RecId, målingerne bag prognoserne og AX-brugere bag blokering. AX-brugere og logins er `[udeladt]`; se [dynamics-ax.md](dynamics-ax.md#api) |
| `GET`/`POST /api/v1/monitored-instances`, `…/{id}`, `…/{id}/connection-test`, `…/{id}/discovery`, `…/{id}/databases`, `…/{id}/grant-script`, `POST /api/v1/instance-discovery`, `GET /api/v1/agent-requests/{id}` | Hvilke servere og databaser der overvåges, forbindelsestest, søgning og GRANT-script: se [servere-og-databaser.md](servere-og-databaser.md#api) (kræver scope `config:write` for at ændre) |
| `GET`/`POST /api/v1/windows-servers`, `GET`/`PATCH`/`DELETE …/{id}`, `…/{id}/test`, `…/{id}/grant-script`, `…/{id}/performance`, `…/{id}/disks`, `…/{id}/services`, `…/{id}/events`, `…/{id}/tasks`, `…/{id}/rule-states`, `GET /api/v1/server-findings[/{id}]` | Windows-servere: CPU, hukommelse, diske med prognose, tjenester, hændelseslog, planlagte opgaver og deres fund: se [windows-servere.md](windows-servere.md#api) (kræver scope `config:write` for at ændre) |
| `GET`/`POST /api/v1/synthetic-checks`, `GET`/`PUT`/`PATCH`/`DELETE …/{id}`, `…/{id}/run`, `…/{id}/results`, `…/{id}/rule-states` | Syntetiske tjek: se [syntetiske-tjek.md](syntetiske-tjek.md#api) (kræver scope `config:write` for at ændre) |
| `GET /metrics` | Prometheus-format |
**Følsomme data:** forespørgselstekster, logins, personnavne, værts- og programnavne og tjenestekonti vises som `[udeladt]`, medmindre `SQLInsight:Web:ApiIncludesSensitiveEvidence` er `true`. Det gælder både fund, rapporter i JSON og sidernes data. PDF-rapporterne indeholder de samme data som i webbrugerfladen.
**Følsomme data:** forespørgselstekster, logins, personnavne, værts- og programnavne og tjenestekonti vises som `[udeladt]`, medmindre `SQLInsight:Web:ApiIncludesSensitiveEvidence` er `true`. Det gælder både fund, rapporter i JSON og sidernes data, fra 1.5 også agentens konto i fejltekster, hændelseslogtekster, syntetiske tjeks fejlbeskeder og SQL-logins og forespørgselsparametre i adresser. PDF-rapporterne indeholder de samme data som i webbrugerfladen.
**Tokens og scopes:** et token i den gamle form (`"navn": "sha256:…"`) må kun læse (`read`). Et token med flere rettigheder skrives som

View file

@ -13,6 +13,16 @@ Begge kommer fra et **opdateringsfeed**: en https-adresse eller en mappe/et fild
Læs dette, før en ny version installeres. Repository-skemaet opgraderes af agenten ved første start.
### 1.5: Windows-servere og syntetiske tjek
- **Ny menu – Servere:** *Windows-servere* (CPU, hukommelse, diske med prognose for, hvornår de er fulde, tjenester, hændelseslog og planlagte opgaver) og *Syntetiske tjek* (TCP-port, HTTP(S) med certifikatets udløb, SQL-login og forespørgsel, DNS og Dynamics AX-logon), og det samme i API v1 under `/api/v1/windows-servers`, `/api/v1/synthetic-checks` og `/api/v1/server-findings` (ændringer kræver scope `config:write`, som i 1.3). Nye regler `SQLI-WIN-001` … `SQLI-WIN-013` og `SQLI-SYN-001` … `SQLI-SYN-003`. Se [windows-servere.md](windows-servere.md) og [syntetiske-tjek.md](syntetiske-tjek.md).
- **SQL Server-hosts kommer med af sig selv** som Windows-servere med rollen *SQL Server* (slås fra med `SQLInsight:Servers:IncludeSqlHosts`). Uden de nye rettigheder er deres områder blot *ikke målbare* med årsagen; SQL Server-overvågningen er uændret.
- **Nye rettigheder pr. server:** agentens konto skal have læseadgang på hver Windows-server, der skal overvåges. Den gives med [db/permissions/grant-windows-server.ps1](../db/permissions/grant-windows-server.ps1), som webbrugerfladen viser udfyldt med agentens konto under serveren. Aldrig lokal administrator og aldrig ret til at køre kode: Performance Monitor Users, Event Log Readers, Distributed COM Users, WMI *Enable Account* + *Remote Enable* på `root\cimv2` og `root\Microsoft\Windows\TaskScheduler`, læseret på Service Control Manager og, kun med `-WinRM`, læseret i WinRM. Scriptet ændrer sikkerheden på kundens server: **afprøv det først på en server, der ikke er i produktion**, og kør det med `-WhatIf` for at se planen. Det gemmer de oprindelige indstillinger, før det ændrer noget, og `-Remove` fjerner præcis det, det selv har givet. SQLInsight kører aldrig selv scriptet.
- **Firewall** fra agentens server til de overvågede servere: TCP 135 og dynamiske RPC-porte (WMI over DCOM og hændelsesloggen) og, med `-WinRM`, TCP 5985. `grant-windows-server.ps1 -EnableFirewall -AgentAddress <agentens IP>` opretter regler kun for agentens adresse; Windows' egne regler røres ikke. Syntetiske tjek bruger de porte, de tjekker (fx 2712, 443, 1433, 53).
- **Kun læsning:** agenten læser med CIM-forespørgsler, hændelsesloggen og Task Schedulers skrivebeskyttede `GetInfoByName`; den starter, stopper eller ændrer intet på serverne. Et syntetisk SQL-tjek kører én skrivebeskyttet `SELECT` i en transaktion, der altid rulles tilbage (fra 1.5 tjekkes også egne regler og community-regler strengere: ingen ekstra sætninger uden semikolon, sekvenser eller låse-hints).
- **Ikke med endnu:** fund på servere og syntetiske tjek sendes **ikke** til SQLInsight Hub og indgår **ikke** i PDF-rapporterne. De vises i webbrugerfladen, i API v1 og som notifikationer.
- **Repository:** migration 0018 (Windows-servere, målinger, hændelser, syntetiske tjek og deres fund) køres af agenten ved første start; data ryddes op efter `SQLInsight:Servers` og `SQLInsight:SyntheticChecks` (se [windows-servere.md](windows-servere.md#opbevaring)).
### 1.4: Dynamics AX
- **Ny menu – Dynamics AX:** vises, så snart en overvåget database er genkendt som Dynamics AX, med siderne AX-databaser, Batchjob, Nummerserier, RecId og AX-brugere bag blokering, og de samme data i API v1 under `/api/v1/dynamics-ax/…` (scope `read`). Nye regler `SQLI-AX-001` … `SQLI-AX-010`. Se [dynamics-ax.md](dynamics-ax.md).
@ -148,18 +158,18 @@ Kører web i IIS, angives `"AppOffline": true` i stedet for `Service`. Så lægg
dotnet run --project tools/SQLInsight.LicenseTool -- rulepack --version 2026.10.14 --out rulepack-2026.10.14.json
# MSI til installationer lavet med MSI'en (bygges på Linux)
installer/build-msi.sh 1.4.0
installer/build-msi.sh 1.5.0
# Programpakker: én zip pr. platform med mapperne Agent/ og Web/
# Samme opsætning som MSI'en (indbygget .NET), så en opdatering ikke kræver en runtime på serveren
dotnet publish src/SQLInsight.Agent -c Release -r win-x64 --self-contained true -p:Version=1.4.0 -o pkg/win-x64/Agent
dotnet publish src/SQLInsight.Web -c Release -r win-x64 --self-contained true -p:Version=1.4.0 -o pkg/win-x64/Web
dotnet publish src/SQLInsight.Agent -c Release -r win-x64 --self-contained true -p:Version=1.5.0 -o pkg/win-x64/Agent
dotnet publish src/SQLInsight.Web -c Release -r win-x64 --self-contained true -p:Version=1.5.0 -o pkg/win-x64/Web
# Signér kanalens manifest (det, der ikke angives, overtages fra det eksisterende manifest)
dotnet run --project tools/SQLInsight.LicenseTool -- publish --key sqlinsight-k20260929.private.pem --kid k20260929 \
--feed feed --channel stable --rulepack rulepack-2026.10.14.json \
--release 1.4.0 --asset win-x64=sqlinsight-1.4.0-win-x64.zip --asset win-x64-msi=dist/SQLInsight-Pro-1.4.0-x64.msi \
--asset linux-x64=sqlinsight-1.4.0-linux-x64.zip \
--release 1.5.0 --asset win-x64=sqlinsight-1.5.0-win-x64.zip --asset win-x64-msi=dist/SQLInsight-Pro-1.5.0-x64.msi \
--asset linux-x64=sqlinsight-1.5.0-linux-x64.zip \
--notes https://git.seosoft.dk/seo/sqlinsight-pro/releases
```

96
docs/syntetiske-tjek.md Normal file
View file

@ -0,0 +1,96 @@
# Syntetiske tjek
Et syntetisk tjek gør det, en bruger eller en applikation gør – forbinder til AOS-porten, henter en side, logger på SQL Server og kører en forespørgsel, slår et navn op, logger på Dynamics AX – med jævne mellemrum, fra agentens server og som agentens konto. Så opdages det også, når **en applikationsserver ikke svarer på det syntetiske tjek, selv om tjenesten kører**: noget, en tjenesteovervågning alene aldrig ser.
Tjekkene oprettes under **Servere → Syntetiske tjek** i webbrugerfladen eller med API'et (se [nedenfor](#api)). Agenten kører dem inden for et minut efter oprettelsen og derefter efter tjekkets interval (standard hvert 5. minut, mindst hvert minut). **Kør nu** kører et tjek med det samme. Intet tjek ændrer noget på de servere, det tjekker.
## Typer
| Type | Hvad agenten gør | Succes |
|---|---|---|
| **TCP-port** | Åbner en TCP-forbindelse til server:port (fx AOS'en på 2712) | Porten svarer |
| **HTTP(S)** | `GET` på adressen, følger op til 5 omdirigeringer, eventuelt som agentens Windows-konto (Enterprise Portal, Reporting Services) | Status 200–399 eller de forventede koder, og eventuelt en forventet tekst i de første 1 MB |
| **SQL-login og forespørgsel** | Logger på SQL Server (agentens Windows-konto eller et SQL-login), skifter eventuelt database og kører forespørgslen (standard `SELECT 1`) | Login og forespørgsel lykkes; svartiden måles for login og forespørgsel hver for sig |
| **DNS-opslag** | Slår navnet op | Navnet giver adresser (og en af de forventede, hvis de er angivet) |
| **Dynamics AX-logon** | Tester AOS-porten, og logger derefter på AX gennem .NET Business Connector, hvis den er installeret på agentens server | Se nedenfor |
**HTTPS:** certifikatets udløbsdato læses ved hvert tjek – også når certifikatet afvises. Et selvsigneret certifikat kan accepteres med *Accepter ugyldigt certifikat*; udløbsdatoen tjekkes stadig.
**SQL:** forespørgslen skal være én skrivebeskyttet `SELECT` (samme regler som community-reglerne, se [regler.md](regler.md)): ingen `INSERT`, `UPDATE`, `EXEC`, `INTO` osv., ingen andre sætninger (T-SQL kræver intet semikolon mellem dem, så fx `COMMIT`, `SET`, `DECLARE`, `WRITETEXT` afvises), ingen `NEXT VALUE FOR` (sekvenser rulles ikke tilbage) og ingen låse-hints som `UPDLOCK`, `XLOCK` eller `TABLOCKX`; flere `SELECT` kun med `UNION`, `EXCEPT` eller `INTERSECT`. Kommentarer, tekster og `[navne]` læses, som SQL Server læser dem, så intet kan gemmes i dem. Højst 10.000 rækker læses. Det tjekkes, når tjekket gemmes, og igen, før det køres, og forespørgslen køres i en transaktion, der altid rulles tilbage, med `READ UNCOMMITTED` og `LOCK_TIMEOUT 5000`. Standardforespørgslen virker på SQL Server 2005 og nyere. Det præcise SQL står i [`SQL Scripts/SQLI-SYN-001.sql`](../SQL%20Scripts/SQLI-SYN-001.sql).
### Dynamics AX-logon
Tjekket gør sit bedste:
1. **AOS-porten** (standard 2712) skal svare. Gør den ikke, fejler tjekket.
2. Er **.NET Business Connector** (AX 2009 eller AX 2012) installeret på agentens server, logger agenten på AX gennem den – **i en separat Windows PowerShell-proces med timeout** (Business Connector er en .NET Framework-komponent og kan ikke indlæses i agenten) – og logger straks af igen. Der logges på som **agentens Windows-konto**, som derfor skal være oprettet som bruger i AX (i det angivne firma); der gemmes ingen adgangskode. Agenten læser intet i AX.
3. Er Business Connector **ikke** installeret (eller logon-testen slået fra), testes **kun porten**, og resultatet siger det tydeligt: *"Kun porten er testet: .NET Business Connector (AX 2009/2012) er ikke installeret på agentens server, så et AX-logon kan ikke prøves."*
Business Connector findes, hvor AX-installeren lægger den (`…\Microsoft Dynamics AX\60\BusinessConnector\Bin`, `…\50\Client\Bin`), eller hvor `SQLInsight:SyntheticChecks:BusinessConnectorPath` peger. AX 2009's Business Connector er bygget til .NET 2.0 og kræver Windows PowerShell 2.0-motoren (.NET Framework 3.5) på agentens server; mangler den, siger resultatet det. Object server angives som i AX-klientens konfiguration (fx `01@aos01:2712`); standard er server:port. Et logon optager kortvarigt en AX-session.
Kør agenten som en gMSA eller domænekonto, hvis AX-logon skal testes: computerkontoen `DOMÆNE\AGENTSERVER$` kan normalt ikke oprettes som AX-bruger.
## Tjeneste bag tjekket
Et tjek kan knyttes til en Windows-server (se [windows-servere.md](windows-servere.md)) og en tjeneste på den, fx AOS'en `AOS60$01` eller IIS `W3SVC`. Fejler tjekket, slår agenten tjenestens tilstand op, og fundet siger forskellen:
- *"AOS01 AX-logon svarer ikke, selv om tjenesten AOS60$01 kører"* – tjenesten hænger, er overbelastet, venter på databasen eller er blokeret af en firewall.
- *"AOS01 AX-logon svarer ikke: tjenesten AOS60$01 er stoppet"*.
Tjenesten læses med de samme rettigheder som Windows-serverne (Service Control Manager).
## Adgangskoder
Et SQL-logins adgangskode krypteres i webbrugerfladen i det øjeblik, den modtages, med **agentens offentlige nøgle** – præcis som i 1.3 (se [servere-og-databaser.md](servere-og-databaser.md#sql-login-adgangskoder)). Kun agenten kan læse den; den vises aldrig igen og returneres aldrig af API'et (kun `hasPassword`). Den er bundet til server, port og login: ændres de, skal den indtastes igen. Kan agenten ikke bruge den (fx efter en geninstallation), siger tjekket det, og den indtastes igen. Fjernes tjekket, slettes login og krypteret adgangskode med det samme. HTTP-, TCP-, DNS- og AX-tjek bruger ingen adgangskoder.
## Regler
| Regel | Fund | Tærskel |
|---|---|---|
| SQLI-SYN-001 | Tjekket fejler (Høj) – med tjenestens tilstand, når den kendes | `SQLInsight:SyntheticChecks:FailuresBeforeFinding` 2 fejl i træk |
| SQLI-SYN-002 | Tjekket svarer langsomt (Middel) | tjekkets *Langsomt svar over (ms)*; `SlowRunsBeforeFinding` 3 gange i træk |
| SQLI-SYN-003 | TLS-certifikatet udløber (Middel; Høj inden for `CertificateCriticalDays` 7; Kritisk når udløbet) | `CertificateWarningDays` 21 |
Ét tabt svar giver ingen alarm; to i træk gør. Fundet løses, når tjekket lykkes igen. Fund vises på tjekkets side og sammen med serverfundene, sendes som notifikationer og findes i API'et. Fundets titel nævner tjekket og tjenestens tilstand, aldrig fejlbeskeden (den står som følsom måling). Fund sendes ikke til SQLInsight Hub og indgår endnu ikke i PDF-rapporterne.
## Firewall
Fra agentens server: de porte, tjekkene bruger (fx TCP 2712 til AOS'en, 80/443 til HTTP(S), 1433 eller instansens port til SQL Server, 53 til DNS). Tjenestens tilstand: som Windows-serverne (DCOM/RPC, eller WinRM TCP 5985 med `-WinRM`).
## Opbevaring og konfiguration
Hver kørsel gemmes i 14 dage (`SQLInsight:SyntheticChecks:ResultDays`). Højst `MaxParallelChecks` (standard 4, 1–32) tjek kører samtidig, og et tjek kører aldrig to gange samtidig. Fejler et tjek uventet, gemmes det som en fejlet kørsel, og det prøves først igen efter sit interval. Timeout pr. tjek er standard 15 sekunder (AX-logon 60), højst 120.
```json
"SyntheticChecks": {
"Enabled": true,
"MaxParallelChecks": 4,
"FailuresBeforeFinding": 2,
"SlowRunsBeforeFinding": 3,
"CertificateWarningDays": 21,
"CertificateCriticalDays": 7,
"ResultDays": 14,
"BusinessConnectorPath": null
}
```
## API
| Kald | Svar |
|---|---|
| `GET /api/v1/synthetic-checks` | Tjek med seneste resultat |
| `POST /api/v1/synthetic-checks` | Opret: `name`, `kind` (`Tcp`, `Http`, `Sql`, `Dns`, `AxLogon`), `serverId`, `intervalSeconds`, `timeoutSeconds`, `settings`, `password`; 201 med `runRequestId` |
| `GET`, `PUT`, `PATCH`, `DELETE /api/v1/synthetic-checks/{id}` | Ét tjek; ret (typen kan ikke ændres); pause; fjern |
| `POST /api/v1/synthetic-checks/{id}/run` | Kør nu (202, følg `GET /api/v1/agent-requests/{id}`; venter en kørsel allerede, gives den samme; 409 for et tjek på pause). Kører tjekket allerede, venter agenten på den kørsel i stedet for at starte en til |
| `GET /api/v1/synthetic-checks/{id}/results?hours=24` | Kørsler med svartid, certifikat og tjenestens tilstand |
| `GET /api/v1/synthetic-checks/{id}/rule-states` | Reglernes udfald |
`settings` har felterne for typen: `host`, `port`, `url`, `expectedStatus`, `expectedText`, `useWindowsAuthentication`, `allowInvalidCertificate`, `server`, `database`, `authentication` (`Windows`/`SqlLogin`), `userName`, `encryption`, `trustServerCertificate`, `query`, `expectedAddresses`, `company`, `objectServer`, `configuration`, `businessConnectorLogon`, `serviceName`, `warnMilliseconds`.
```powershell
$h = @{ Authorization = "Bearer $token" }
$body = @{ name = 'AOS01 AX-logon'; kind = 'AxLogon'; serverId = 3; settings = @{ host = 'aos01'; company = 'DAT'; serviceName = 'AOS60$01' } } | ConvertTo-Json
Invoke-RestMethod https://sqlinsight.domæne.local/api/v1/synthetic-checks -Method Post -Headers $h -Body $body -ContentType 'application/json'
```
Fejlbeskeder kan nævne logins og servere: for fejlede kørsler er beskeden og følsomme målinger `[udeladt]` i API'et, medmindre `SQLInsight:Web:ApiIncludesSensitiveEvidence` er slået til. Det samme gælder `settings.userName` og en adresses forespørgselsparametre (`https://server/side?[udeladt]`): sendes et tjek tilbage med `PUT`, skal det rigtige login og den hele adresse angives igen (`[udeladt]` afvises). Adresser må højst være 2000 tegn. At ændre kræver scope `config:write` eller `SQLInsight:Web:AdminGroups`.

155
docs/windows-servere.md Normal file
View file

@ -0,0 +1,155 @@
# Windows-servere
Fra version 1.5 overvåger SQLInsight også selve Windows-serverne: **CPU, hukommelse, diske, tjenester, hændelseslog og planlagte opgaver** på SQL-, applikations-/AOS- og filservere – med varsel om, at en disk løber fuld om et antal dage, før det sker. Det findes under **Servere → Windows-servere** i webbrugerfladen og i API'et (se [nedenfor](#api)).
- **SQL Server-hosts kommer med af sig selv.** Serveren bag hver instans under *Servere og databaser* (se [servere-og-databaser.md](servere-og-databaser.md)) bliver en Windows-server med rollen *SQL Server*. Den forsvinder igen, når instansen fjernes, og kan sættes på pause. Azure SQL og LocalDB springes over. Slås fra med `SQLInsight:Servers:IncludeSqlHosts`.
- **Andre servere tilføjes** i webbrugerfladen: AOS- og applikationsservere, rapport- og filservere. Angiv servernavnet (uden instans og port), rollen og eventuelt de tjenester, der altid skal køre (fx `AOS60$01`, `W3SVC`).
SQLInsight **ændrer aldrig noget** på serverne: agenten læser kun. Den starter, stopper eller genstarter ingen tjenester og rydder ikke op – fundene siger, hvad der bør gøres, og en driftsansvarlig gør det.
## Sådan læser agenten serverne
Agenten læser serverne **som sin egen konto** (den samme som til SQL Server) med CIM – det samme, PowerShells `Get-CimInstance` bruger:
1. **WinRM (WS-Man)**, TCP 5985, når kontoen har læseret i WinRM (tildelingsscriptet med `-WinRM`, Windows Server 2012 og nyere).
2. **DCOM (RPC)**, TCP 135 og dynamiske porte, hvis WinRM ikke svarer eller afviser kontoen. Det er standard med tildelingsscriptet.
3. Agentens egen server læses lokalt, uden netværk.
`SQLInsight:Servers:Protocol` kan låse det til `WSMan` eller `DCOM` (standard `Auto`). WinRM over HTTPS (TCP 5986) understøttes ikke i 1.5. Hændelsesloggen læses med hændelseslogtjenestens egen protokol (MS-EVEN6, over RPC), som filtrerer på serveren og derfor er hurtig; virker den ikke, bruges CIM (`Win32_NTLogEvent`), som er langsommere. Siden for serveren viser, hvordan den blev læst.
Kører agenten som den virtuelle konto `NT SERVICE\SQLInsight Agent` (standard), LocalSystem eller NETWORK SERVICE, kommer den til andre servere som **computerkontoen** `DOMÆNE\AGENTSERVER$`. En gMSA eller domænekonto er den samme overalt. Siden viser den konto, scriptet nedenfor skal give rettigheder til.
## Rettigheder (aldrig lokal administrator)
Agenten skal **ikke** være lokal administrator og får ingen ret til at køre kode på serverne. Den har brug for præcis dette, og intet andet:
| Rettighed | Bruges til |
|---|---|
| Lokal gruppe **Performance Monitor Users** | Ydelsestællere: CPU, hukommelse, paging, commit |
| Lokal gruppe **Event Log Readers** | Hændelseslogge, der tillader gruppen: System, Application m.fl. (aldrig Security-loggen) |
| Lokal gruppe **Distributed COM Users** | CIM over DCOM |
| WMI: **Enable Account** + **Remote Enable** på `root\cimv2` | Styresystem, oppetid, diske, tjenester |
| WMI: **Enable Account** + **Remote Enable** på `root\Microsoft\Windows\TaskScheduler` | Planlagte opgaver (Windows Server 2012 og nyere) |
| **Service Control Manager**: forbinde, liste og læse (`CC LC RP RC`) | Over netværket må ellers kun administratorer liste tjenester. Det er det samme, en lokalt logget på bruger har; ingen ret til at starte eller stoppe |
| Kun med `-WinRM`: **læseret i WinRM** (`GENERIC_READ` i RootSDDL, ingen Execute) | CIM over WinRM. Kontoen kommer *ikke* i Remote Management Users, for den gruppe giver også adgang til PowerShell Remoting |
Det hele gives af [`grant-windows-server.ps1`](../db/permissions/grant-windows-server.ps1). Webbrugerfladen viser scriptet udfyldt med agentens konto under **Rettigheder og forbindelse** på serverens side (kopiér eller hent som fil; API: `GET /api/v1/windows-servers/{id}/grant-script`). Scriptet ændrer sikkerheden på kundens server, så **afprøv det først på en server, der ikke er i produktion**, og kør det med `-WhatIf` for at se, hvad det vil gøre. En administrator kører det på serveren:
```powershell
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -WhatIf # viser planen, ændrer intet
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' # DCOM (standard)
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -WinRM # også læseret i WinRM
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -EnableFirewall -AgentAddress 10.0.0.15 # egne firewallregler, kun fra agenten
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -Remove # fjerner præcis det, scriptet gav
```
Scriptet virker på Windows PowerShell 2.0 og nyere og skal køres som administrator. Grupperne findes via deres SID, så det virker på dansk og engelsk Windows. Sikkerhedsnettet:
- **Intet ændres, før alt er kontrolleret:** kontoen skal kunne slås op (brede grupper som Everyone, Authenticated Users eller Domain Users afvises), og har kontoen en *Nægt*-regel på WMI, Service Control Manager eller WinRM, stopper scriptet uden at ændre noget.
- **Kun det nødvendige:** har kontoen allerede en rettighed, røres den ikke. Hver ændring er præcis én ny regel (kontrolleret, før den skrives), og hvert skridt kontrolleres bagefter.
- **Backup:** før første ændring gemmes de oprindelige indstillinger (SDDL for Service Control Manager, WMI-navnerummene og WinRM, gruppemedlemskaber og firewallregler) i `%ProgramData%\SQLInsight\grant-windows-server\<SID>-<tid>-foer.xml`.
- **Præcis fjernelse:** hvad scriptet selv har tilføjet, står i `%ProgramData%\SQLInsight\grant-windows-server\<SID>.xml` (kun administratorer har adgang til mappen). `-Remove` fjerner kun det – et gruppemedlemskab eller en rettighed, kontoen havde før, bevares. Fejler et skridt undervejs, står det, der allerede er gjort, i filen, så scriptet kan køres igen eller med `-Remove`.
- Service Control Manager kan gendannes manuelt med `sc.exe sdset scmanager "<SDDL fra -foer.xml>"`.
Kendte begrænsninger med mindst mulige rettigheder:
- **Tjenester:** en tjeneste, hvis egen sikkerhed ikke lader kontoen læse den, ses ikke. Ser agenten slet ingen tjenester, er området "ikke målbart" med årsagen.
- **Planlagte opgaver:** agenten ser kun opgaver, kontoen må læse. Seneste resultat læses med Task Schedulers `GetInfoByName` (et skrivebeskyttet CIM-metodekald, som `Get-ScheduledTaskInfo`). Det kræver Windows' standardret *Execute Methods* på navnerummet (som Authenticated Users arver fra `root`); scriptet giver ikke mere. Over WinRM med kun læseret er metodekald ikke tilladt, så agenten læser opgaverne over DCOM (med `Protocol` = `Auto`); er protokollen låst til `WSMan`, er området "ikke målbart" med årsagen.
- **Windows Server 2008 R2:** opgave-navnerummet findes ikke, så planlagte opgaver er "ikke målbare"; brug DCOM.
### Når en rettighed mangler
Testen (**Test forbindelsen** på serverens side, eller `POST /api/v1/windows-servers/{id}/test`) udføres af agenten, som dens konto, og prøver hvert område én gang. Tabellen viser for hvert område *Kan læses* eller *Ikke målbar* med den præcise årsag, fx
> Adgang nægtet til hændelseslog på aos01.firma.local over WS-Man: Agentens konto (FIRMA\AGENT01$) mangler Event Log Readers. Kør tildelingsscriptet (grant-windows-server.ps1) som administrator på serveren, og test igen. Agenten skal ikke være lokal administrator.
Under overvågningen gælder det samme: et område, der ikke kan læses, gør kun dets regler "ikke målbare" med årsagen (under *Ikke målbart* på siden), og fund fra før forbliver åbne, indtil området kan læses igen. Problemet skrives én gang i agentens log, når det opstår eller ændrer sig – ikke hvert minut. Svarer serveren slet ikke, sendes én notifikation ("kan ikke nås") og én, når den svarer igen.
## Firewall
Fra agentens server til de overvågede servere:
| Port | Hvad | Tildelingsscriptets regel (`-EnableFirewall`) |
|---|---|---|
| TCP 135 + dynamiske RPC-porte (49152–65535) | DCOM/WMI (standard) | *SQLInsight Agent: RPC Endpoint Mapper* og *SQLInsight Agent: WMI over DCOM* |
| TCP 135 + dynamiske RPC-porte | Hændelsesloggen (MS-EVEN6) | *SQLInsight Agent: hændelseslog (RPC)* |
| TCP 5985 | WinRM (WS-Man), kun med `-WinRM` | *SQLInsight Agent: WinRM (HTTP 5985)* |
`grant-windows-server.ps1 -EnableFirewall -AgentAddress <agentens IP eller net>` opretter scriptets egne regler, kun for agentens adresse (Windows Server 2012 og nyere); `-AgentAddress` er påkrævet, så portene aldrig åbnes for alle. Windows' indbyggede regler røres ikke, og `-Remove` fjerner scriptets egne regler igen. Syntetiske tjek bruger de porte, de tjekker (se [syntetiske-tjek.md](syntetiske-tjek.md)).
## Hvad måles, og hvor ofte
| Område | Interval | Hvad |
|---|---|---|
| CPU, hukommelse, oppetid | 1 minut | Gennemsnitlig CPU pr. minut (rå tællere, ikke et øjebliksbillede), ledig hukommelse, paging, commit, starttidspunkt |
| Diske | 15 minutter | Ledig plads pr. drev og monteringspunkt; prognose for, hvornår drevet er fuldt |
| Tjenester | 5 minutter | Alle tjenester med status og starttype |
| Hændelseslog | 5 minutter | Fejl og kritiske hændelser i System og Application siden sidst (første gang det seneste døgn) |
| Planlagte opgaver | 15 minutter | Opgaver uden for `\Microsoft\` med seneste kørsel, resultat og næste kørsel |
Intervallerne ændres under `SQLInsight:Servers:Intervals` (`performance`, `disks`, `services`, `events`, `tasks`). Højst `MaxParallelServers` (standard 4, 1–32) servere læses ad gangen. Hver CIM-operation har en timeout (`OperationTimeout`, 5 sekunder–5 minutter), og en hel indsamling for én server højst `CycleTimeout` (standard 5 minutter, 1–30); når den er brugt, er resten "ikke målbart" til næste gang, og planlagte opgaver har en tredjedel af tiden. En server, der hænger eller ikke svarer, kan derfor aldrig holde agenten op. Svarer den ikke, prøves den igen efter 1, 2, 5, 10 og derefter hvert 15. minut, og den protokol, der virkede, huskes en time.
### "Disken er fuld om ca. N dage"
Ledig plads pr. drev gemmes hvert 15. minut i SQLInsights repository. Prognosen lægger en ret linje (mindste kvadraters metode) gennem timegennemsnittene for de seneste 14 dage (`Disks:ForecastHistoryDays`) – kun siden drevet sidst skiftede størrelse, så et udvidet drev starter forfra. Der gives kun en prognose, når der er mindst to dages historik og 12 målinger, linjen passer rimeligt (R² mindst 0,5), drevet faktisk fyldes (mindst 0,01 GB om dagen), og den rammer inden for et år. Et backupdrev, der fyldes om natten og ryddes om morgenen, giver derfor ingen alarm. Fundet kommer, når drevet forventes fuldt inden for 30 dage (`Disks:ForecastDays`): Middel, inden for 7 dage Høj, inden for 2 dage Kritisk.
### Hændelsesloggen
Som SQL Servers fejllog (se [fejllog.md](fejllog.md)) grupperes hændelserne pr. log, kilde og hændelses-id med antal, første og seneste tidspunkt og den nyeste tekst som eksempel. Kendt støj sorteres fra (DCOM 10016/10010, Schannel-alarmer, TPM-WMI, WMI 10 m.fl.), og det samme gør SQL Servers egne kilder (`MSSQLSERVER`, `MSSQL$…`, `SQLSERVERAGENT` …), som SQL Server-overvågningen allerede dækker. Egen støj tilføjes under `EventLog:Ignore` som `"Kilde:id"`, `"Kilde"` eller `"Kilde*"`. Disk- og filsystemfejl, opbrugte ressourcer og kritiske hændelser giver fund med det samme; tjenester der går ned, programnedbrud og Dynamics AX' AOS-fejl også; almindelige fejl først, når de er set 5 gange på et døgn (`EventLog:RepeatThreshold`). Et fund forbliver åbent et døgn efter hændelsen sidst blev set. Eksempelteksten kan nævne brugere, computere og stier: den vises i webbrugerfladen, men er `[udeladt]` i API'et.
## Regler
| Regel | Fund | Tærskel (`SQLInsight:Servers:…`) |
|---|---|---|
| SQLI-WIN-001 | CPU højt i længere tid (Høj; Kritisk fra 98 %) | `Cpu:HighPercent` 90 % i `Cpu:SustainedMinutes` 15 min. |
| SQLI-WIN-002 | Næsten ingen ledig hukommelse (Middel; Høj under 2 % og 256 MB) | under `Memory:AvailablePercent` 5 % **og** `Memory:AvailableMegabytes` 1024 MB (5 min.) |
| SQLI-WIN-003 | Paging med lidt ledig hukommelse | `Memory:PagesPerSecond` 1000 (15 min.) og under 10 % ledig (fast i koden) |
| SQLI-WIN-004 | Commit tæt på grænsen (Middel; Høj fra 97 %) | `Memory:CommitPercent` 90 % |
| SQLI-WIN-005 | Lidt ledig plads på et drev | under `Disks:FreePercent` 10 % (Høj under `CriticalFreePercent` 5 %, Kritisk under 1 GB eller 1 %), medmindre mindst `EnoughFreeGigabytes` 50 GB er ledig |
| SQLI-WIN-006 | Drevet er fuldt om ca. N dage ("ikke målbar", indtil der er historik nok) | `Disks:ForecastDays` 30 |
| SQLI-WIN-007 | Automatisk tjeneste kører ikke | `Services:Ignore` (navn eller visningsnavn, `*` til sidst), `Services:StartupGraceMinutes` 15 ("ikke målbar" lige efter en genstart, så åbne fund bliver stående), `Services:MaxFindings` 15 (resten samles i ét fund); tjenester der stopper af sig selv (sppsvc, opdateringstjenester …) giver ingen fund |
| SQLI-WIN-008 | Overvåget tjeneste kører ikke (eller findes ikke) | listen pr. server |
| SQLI-WIN-009 | Fejl i hændelsesloggen | `EventLog:RepeatThreshold` 5 inden for de seneste 24 timer (talt pr. time), `EventLog:MaxFindings` 15 (resten samles i ét fund) |
| SQLI-WIN-010 | Serveren er genstartet (uventet: Høj; planlagt: Info i et døgn; Lav, hvis hændelsesloggen ikke kan læses; "ikke målbar", til loggen er læst efter genstarten) | Kernel-Power 41, EventLog 6008, BugCheck 1001 |
| SQLI-WIN-011 | Ikke genstartet i lang tid | `UptimeDays` (standard 0 = slået fra) |
| SQLI-WIN-012 | Planlagt opgave fejlede | `Tasks:IncludeMicrosoftTasks`, `Tasks:Ignore`, `Tasks:MaxWatched` 50, `Tasks:IgnoreUserAndUpdaterTasks` (standard til: opgaver pr. bruger med SID i navnet, OneDrive-, Edge- og Google-opdatering) |
| SQLI-WIN-013 | Planlagt opgave kørte ikke som planlagt | `Tasks:LateMinutes` 60 |
Hver regel har sit eget script i [`SQL Scripts/`](../SQL%20Scripts/) (fx `SQLI-WIN-006.sql`) med de præcise CIM/WQL-forespørgsler og en skrivebeskyttet PowerShell-linje, der efterprøver dem.
Fund på servere vises på serverens side, under **Aktive fund på servere og syntetiske tjek** på siden Windows-servere, sendes som notifikationer (med link til fundet) og findes i API'et. De sendes ikke til SQLInsight Hub og indgår endnu ikke i PDF-rapporterne.
## Opbevaring
| Data | Standard | Indstilling |
|---|---|---|
| CPU og hukommelse pr. minut | 14 dage | `SQLInsight:Servers:SampleDays` |
| Ledig plads pr. drev | 35 dage (mindst prognosens historik) | `SQLInsight:Servers:DiskHistoryDays` |
| Grupperede hændelser | 90 dage | `SQLInsight:Servers:EventDays` |
| Løste fund | 365 dage | `SQLInsight:Retention:ResolvedFindingDays` |
En server med 5 drev fylder omkring 1.440 rækker om dagen for CPU/hukommelse og 480 for diskene. Agenten sletter i små portioner hver time.
## Konfiguration
Alle tærskler, intervaller og opbevaringstider står i agentens `appsettings.json` under `SQLInsight:Servers`. Ændringer kræver en genstart af agenten. Servere, roller og tjenestelister ændres i webbrugerfladen og bruges inden for et minut.
## API
| Kald | Svar |
|---|---|
| `GET /api/v1/windows-servers` | Servere med status, styresystem, protokol, områdernes målbarhed, seneste CPU/hukommelse, fyldigste drev, nærmeste "fuld"-prognose og antal fund |
| `POST /api/v1/windows-servers` | Tilføj (`host`, `name`, `role`, `watchedServices`, `ignoredServices`); 201 med `serverTestRequestId` |
| `GET`, `PATCH`, `DELETE /api/v1/windows-servers/{id}` | Én server; pause, rolle og tjenestelister; fjern (409 for SQL Server-hosts) |
| `POST`, `GET /api/v1/windows-servers/{id}/test` | Test forbindelse og rettigheder (202, følg `GET /api/v1/agent-requests/{id}`; venter en test allerede, gives den samme); seneste test med manglende områder |
| `GET /api/v1/windows-servers/{id}/grant-script` | Scriptet udfyldt med agentens konto (kontoen er `[udeladt]`, og scriptet er skabelonen, medmindre følsomme data er slået til) |
| `GET /api/v1/windows-servers/{id}/performance?hours=24` | CPU og hukommelse pr. minut |
| `GET /api/v1/windows-servers/{id}/disks` | Drev med ledig plads og prognose |
| `GET /api/v1/windows-servers/{id}/services` | Tjenester og serverens lister |
| `GET /api/v1/windows-servers/{id}/events?days=1` | `capturedAt`, `method`, `ignored`, `coverage` og de grupperede hændelser (eksemplet er `[udeladt]`) |
| `GET /api/v1/windows-servers/{id}/tasks` | Overvågede planlagte opgaver |
| `GET /api/v1/windows-servers/{id}/rule-states` | Reglernes udfald med årsag |
| `GET /api/v1/server-findings?status=&scope=server&id=&limit=&continuation=` | Fund på servere og syntetiske tjek, højst `limit` (standard 100, højst 500) ad gangen, næste side via `X-Continuation`. Aktive fund på servere og tjek på pause eller fjernede er ikke med, medmindre `scope` og `id` angives |
| `GET /api/v1/server-findings/{id}` | Ét fund med tidligere forekomster (`history`) |
At ændre kræver scope `config:write` (API-token) eller medlemskab af `SQLInsight:Web:AdminGroups` (Windows-brugere; er den tom, den lokale gruppe Administratorer på webserveren), som for *Servere og databaser*. Hvem der har oprettet og ændret, og agentens konto i fejltekster, er `[udeladt]`, medmindre `SQLInsight:Web:ApiIncludesSensitiveEvidence` er slået til. Fjernes en server, beholdes dens syntetiske tjek, men uden server og tjeneste.

View file

@ -87,6 +87,9 @@ public sealed partial class AgentCredentialKey : IDisposable
public string Open(byte[] envelope, string server, string? userName) =>
CredentialEnvelope.Open(envelope, KeyId, _rsa, CredentialEnvelope.Context(server, userName));
/// <summary>Opens a secret sealed for this agent and the given context (e.g. a synthetic check's server and login).</summary>
public string OpenContext(byte[] envelope, string context) => CredentialEnvelope.Open(envelope, KeyId, _rsa, context);
/// <summary>What the agent publishes in insight.AgentKeys: the key, and the account and computer it runs as.</summary>
public AgentKeyRow Describe(DateTimeOffset now) => new()
{

View file

@ -31,7 +31,8 @@ public sealed partial class AgentRequestService(
ISqlConnectionFactory connectionFactory,
RepositoryReadiness readiness,
TimeProvider timeProvider,
ILogger<AgentRequestService> logger) : BackgroundService
ILogger<AgentRequestService> logger,
Servers.ServerRequestHandler? serverRequests = null) : BackgroundService
{
/// <summary>How often the queue is looked at. Short, so the web UI gets an answer within seconds.</summary>
public static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2);
@ -60,7 +61,7 @@ public sealed partial class AgentRequestService(
{
nextHousekeeping = now.AddMinutes(10);
await repository.PublishAgentKeyAsync(key.Describe(now), stoppingToken).ConfigureAwait(false);
await repository.CleanUpAgentRequestsAsync(now, RequestTimeout + TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(30), TimeSpan.FromDays(30), stoppingToken)
await repository.CleanUpAgentRequestsAsync(now, StaleAfter, TimeSpan.FromMinutes(30), TimeSpan.FromDays(30), stoppingToken)
.ConfigureAwait(false);
}
@ -85,8 +86,9 @@ public sealed partial class AgentRequestService(
public async Task HandleAsync(AgentRequestRow request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var limit = TimeoutOf(request.Kind);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(RequestTimeout);
timeout.CancelAfter(limit);
string? result = null;
string? error = null;
try
@ -97,12 +99,13 @@ public sealed partial class AgentRequestService(
AgentRequestKind.DatabaseDiscovery => AgentRequestJson.Serialize(await DiscoverAsync(Require(request), timeout.Token).ConfigureAwait(false)),
AgentRequestKind.InstanceDiscovery => AgentRequestJson.Serialize(await InstanceFinder.FindAsync(
AgentRequestJson.Deserialize<InstanceDiscoveryParameters>(request.Parameters)?.Host, TimeSpan.FromSeconds(3), timeout.Token).ConfigureAwait(false)),
_ when serverRequests is not null && Servers.ServerRequestHandler.Handles(request.Kind) => await serverRequests.HandleAsync(request, timeout.Token).ConfigureAwait(false),
_ => throw new InvalidOperationException($"Ukendt anmodning: {request.Kind}."),
};
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
error = $"Agenten fik ikke svar inden for {RequestTimeout.TotalMinutes:0} minutter.";
error = $"Agenten fik ikke svar inden for {limit.TotalMinutes:0} minutter.";
}
// Anything else (a lock timeout, a repository error while storing a discovery …) is the request's result too, so it
// is never left as Running for the web UI to wait on.
@ -115,6 +118,15 @@ public sealed partial class AgentRequestService(
await repository.CompleteAgentRequestAsync(request.RequestId, error is null, result, error, timeProvider.GetUtcNow(), CancellationToken.None).ConfigureAwait(false);
}
/// <summary>How long a request may take: a synthetic check run now may take its own timeout plus the lookup of its service.</summary>
public TimeSpan TimeoutOf(string kind) =>
serverRequests is not null && Servers.ServerRequestHandler.Handles(kind) ? Longest(RequestTimeout, Servers.ServerRequestHandler.RequestTimeout) : RequestTimeout;
/// <summary>A request still Running after this was lost (the agent stopped): the longest request plus a minute.</summary>
private static TimeSpan StaleAfter => Longest(RequestTimeout, Servers.ServerRequestHandler.RequestTimeout) + TimeSpan.FromMinutes(1);
private static TimeSpan Longest(TimeSpan a, TimeSpan b) => a > b ? a : b;
private static int Require(AgentRequestRow request) =>
request.ConfiguredInstanceId ?? throw new InvalidOperationException("Anmodningen mangler en instans.");

View file

@ -116,6 +116,8 @@ builder.Services.AddSingleton<SQLInsight.Agent.Configuration.RepositoryReadiness
builder.Services.AddSingleton<SQLInsight.Agent.Configuration.InstanceConfigurationStore>();
builder.Services.AddHostedService<SQLInsight.Agent.Configuration.AgentRequestService>();
builder.Services.AddHostedService<MonitoringService>();
// 1.5: Windows servers (CIM, as the agent's identity) and synthetic checks.
SQLInsight.Agent.Servers.ServerServices.Add(builder.Services, builder.Configuration);
var host = builder.Build();
await host.RunAsync();

View file

@ -14,6 +14,7 @@
<ProjectReference Include="..\SQLInsight.Licensing\SQLInsight.Licensing.csproj" />
<ProjectReference Include="..\SQLInsight.Updates\SQLInsight.Updates.csproj" />
<ProjectReference Include="..\SQLInsight.Reports\SQLInsight.Reports.csproj" />
<ProjectReference Include="..\SQLInsight.Servers\SQLInsight.Servers.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />

View file

@ -0,0 +1,467 @@
using SQLInsight.Core.Collection;
using SQLInsight.Core.Servers;
using SQLInsight.Notifications;
using SQLInsight.Repository;
using SQLInsight.Rules.Engine;
using SQLInsight.Rules.Servers;
using SQLInsight.Servers;
namespace SQLInsight.Agent.Servers;
/// <summary>
/// Looks at one Windows server: reads the areas that are due over one CIM session, keeps the latest data for the rules,
/// evaluates them and stores findings. Only reads; a missing right makes that area "ikke målbar" with the exact reason
/// (logged once per change, never every round). Not thread-safe; one cycle at a time.
/// </summary>
public sealed partial class ServerMonitor(
WindowsServerRow server,
IWindowsServerSource source,
InsightRepository repository,
ServerOptions options,
ILogger logger,
NotificationDispatcher? notifications = null,
Uri? webUrl = null)
{
/// <summary>Rules are evaluated at least this often even when only performance was collected.</summary>
public static readonly TimeSpan EvaluationInterval = TimeSpan.FromMinutes(5);
private readonly CollectedData _data = new();
private readonly Dictionary<string, DateTimeOffset> _nextDue = new(StringComparer.Ordinal);
private readonly Dictionary<string, ServerAreaStatus> _areas = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _reported = new(StringComparer.Ordinal);
private ServerPerformanceRaw? _previousRaw;
private IReadOnlyList<ServerPerformanceSample> _samples = [];
private DateTimeOffset? _lastBoot;
private DateTimeOffset? _restartSeenAt;
private ServerEventSnapshot? _events;
private DateTimeOffset _nextEvaluation = DateTimeOffset.MinValue;
private bool _seeded;
private bool? _wasReachable;
private int _failures;
private DateTimeOffset _retryAt = DateTimeOffset.MinValue;
public WindowsServerRow Server { get; private set; } = server;
public int ServerId => Server.ServerId;
/// <summary>A server that did not answer is not tried again before this (backing off: 1, 2, 5, 10, then every 15 minutes).</summary>
public DateTimeOffset RetryAt => _retryAt;
/// <summary>The configuration changed in SQLInsight Web (role, watch lists): used from the next cycle.</summary>
public void Update(WindowsServerRow row) => Server = row;
public bool IsDue(DateTimeOffset now) => now >= _retryAt && ServerArea.All.Any(a => !_nextDue.TryGetValue(a, out var due) || due <= now);
/// <summary>How long to wait after the <paramref name="failures"/>th failure in a row to reach a server.</summary>
public static TimeSpan Backoff(int failures) => TimeSpan.FromMinutes(failures switch
{
<= 1 => 1,
2 => 2,
3 => 5,
4 => 10,
_ => 15,
});
/// <summary>
/// Reads the due areas over one session, within <see cref="ServerOptions.CycleTimeout"/>: what is not done by then is
/// "ikke målbar" this time (logged once), and the server is backed off like one that does not answer.
/// </summary>
public async Task RunCycleAsync(DateTimeOffset now, CancellationToken cancellationToken)
{
if (now < _retryAt)
{
return;
}
var due = ServerArea.All.Where(a => !_nextDue.TryGetValue(a, out var at) || at <= now).ToList();
if (due.Count == 0)
{
return;
}
foreach (var area in due)
{
_nextDue[area] = now + options.IntervalOf(area);
}
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(options.CycleTimeout);
var pending = new List<string>(due);
try
{
await RunDueAsync(due, pending, now, deadline.Token).ConfigureAwait(false);
}
catch (Exception) when (deadline.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
// The deadline, not the agent stopping (the repository may also report it as its own error).
await CycleTimedOutAsync(pending, now, cancellationToken).ConfigureAwait(false);
}
}
private async Task RunDueAsync(IReadOnlyList<string> due, List<string> pending, DateTimeOffset now, CancellationToken cancellationToken)
{
if (!_seeded)
{
await SeedAsync(now, cancellationToken).ConfigureAwait(false);
_seeded = true;
}
IWindowsServerSession session;
try
{
session = await source.ConnectAsync(Server.Host, cancellationToken).ConfigureAwait(false);
}
catch (ServerAccessException ex)
{
pending.Clear();
await NotReachableAsync(ex, due, now, cancellationToken).ConfigureAwait(false);
return;
}
using (session)
{
_failures = 0;
_retryAt = DateTimeOffset.MinValue;
if (_wasReachable == false)
{
await NotifyAsync([NotificationFactory.Recovered(Server.Name, webUrl, now) with { Url = ServerNotifications.ServerPage(webUrl, ServerId) }], cancellationToken)
.ConfigureAwait(false);
}
_wasReachable = true;
_reported.Remove("connect");
foreach (var area in due)
{
await CollectAsync(session, area, now, cancellationToken).ConfigureAwait(false);
pending.Remove(area);
}
_reported.Remove("cycle");
if (due.Any(a => a != ServerArea.Performance) || now >= _nextEvaluation)
{
_nextEvaluation = now + EvaluationInterval;
await EvaluateAsync(now, cancellationToken).ConfigureAwait(false);
}
}
}
/// <summary>The round ran out of time: the areas not read are "ikke målbar" with the reason, and the server is backed off.</summary>
private async Task CycleTimedOutAsync(List<string> pending, DateTimeOffset now, CancellationToken cancellationToken)
{
var minutes = options.CycleTimeout.TotalMinutes.ToString("0.#", System.Globalization.CultureInfo.GetCultureInfo("da-DK"));
var reason = $"Serveren svarede ikke inden for {minutes} minutter, så indsamlingen blev afbrudt; området læses igen ved næste indsamling.";
if (Report("cycle", reason + string.Join(",", pending)))
{
LogCycleTimedOut(logger, Server.Name, options.CycleTimeout, string.Join(", ", pending));
}
if (pending.Count == 0)
{
return;
}
Failed(now);
foreach (var area in pending)
{
Unavailable(area, reason);
}
_nextEvaluation = now + EvaluationInterval;
await EvaluateAsync(now, cancellationToken).ConfigureAwait(false);
}
private void Failed(DateTimeOffset now)
{
_failures++;
_retryAt = now + Backoff(_failures);
}
/// <summary>Unreachable: recorded and notified once, and backed off. Refused by rights at the door: every area is "ikke målbar" with the reason.</summary>
private async Task NotReachableAsync(ServerAccessException ex, IReadOnlyList<string> due, DateTimeOffset now, CancellationToken cancellationToken)
{
if (Report("connect", ex.Message))
{
LogNotReachable(logger, Server.Name, ex.Message);
}
if (ex.Problem is ServerAccessProblem.Unreachable or ServerAccessProblem.Timeout)
{
// A server that does not answer costs a timeout per protocol: not every minute.
Failed(now);
}
await repository.RecordServerErrorAsync(ServerId, ex.Message, now, cancellationToken).ConfigureAwait(false);
if (ex.Problem is ServerAccessProblem.AccessDenied or ServerAccessProblem.NotSupported)
{
foreach (var area in ServerArea.All)
{
Unavailable(area, ex.Message);
}
await repository.RecordServerAreasAsync(ServerId, _areas.Values.ToList(), cancellationToken).ConfigureAwait(false);
if (due.Any(a => a != ServerArea.Performance) || now >= _nextEvaluation)
{
_nextEvaluation = now + EvaluationInterval;
await EvaluateAsync(now, cancellationToken).ConfigureAwait(false);
}
return;
}
if (_wasReachable != false)
{
await NotifyAsync([NotificationFactory.Unreachable(Server.Name, ex.Message, webUrl, now) with { Url = ServerNotifications.ServerPage(webUrl, ServerId) }],
cancellationToken).ConfigureAwait(false);
}
_wasReachable = false;
}
private async Task CollectAsync(IWindowsServerSession session, string area, DateTimeOffset now, CancellationToken cancellationToken)
{
try
{
var detail = area switch
{
ServerArea.Performance => await CollectPerformanceAsync(session, now, cancellationToken).ConfigureAwait(false),
ServerArea.Disks => await CollectDisksAsync(session, now, cancellationToken).ConfigureAwait(false),
ServerArea.Services => await CollectServicesAsync(session, now, cancellationToken).ConfigureAwait(false),
ServerArea.Events => await CollectEventsAsync(session, now, cancellationToken).ConfigureAwait(false),
ServerArea.Tasks => await CollectTasksAsync(session, now, cancellationToken).ConfigureAwait(false),
_ => null,
};
_areas[area] = new ServerAreaStatus(area, true, detail, ServerArea.Right(area));
if (_reported.Remove(area))
{
LogAreaRecovered(logger, Server.Name, area);
}
}
catch (ServerAccessException ex)
{
Unavailable(area, ex.Message);
if (Report(area, ex.Message))
{
LogAreaUnavailable(logger, Server.Name, area, ex.Message);
}
}
}
private void Unavailable(string area, string reason)
{
_areas[area] = new ServerAreaStatus(area, false, reason, ServerArea.Right(area));
switch (area)
{
case ServerArea.Performance:
_data.MarkUnavailable<ServerPerformanceSnapshot>(reason);
break;
case ServerArea.Disks:
_data.MarkUnavailable<ServerDiskSnapshot>(reason);
break;
case ServerArea.Services:
_data.MarkUnavailable<ServerServiceSnapshot>(reason);
break;
case ServerArea.Events:
_data.MarkUnavailable<ServerEventSnapshot>(reason);
break;
case ServerArea.Tasks:
_data.MarkUnavailable<ServerTaskSnapshot>(reason);
break;
}
}
/// <summary>Whether a problem is new (or changed) and should be logged: the same problem every minute is not news.</summary>
private bool Report(string key, string message)
{
if (_reported.TryGetValue(key, out var previous) && previous == message)
{
return false;
}
_reported[key] = message;
return true;
}
public async Task<string?> CollectPerformanceAsync(IWindowsServerSession session, DateTimeOffset now, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(session);
var os = await session.GetOperatingSystemAsync(cancellationToken).ConfigureAwait(false);
var raw = await session.GetPerformanceAsync(now, cancellationToken).ConfigureAwait(false);
DateTimeOffset? previousBoot = null;
if (os.LastBootUpTime is { } boot && _lastBoot is { } before && Math.Abs((boot - before).TotalMinutes) > 2)
{
// Restarted since the last look: the raw counters started over too.
previousBoot = before;
_restartSeenAt = now;
_previousRaw = null;
}
_lastBoot = os.LastBootUpTime ?? _lastBoot;
var sample = ServerPerformanceMath.Sample(_previousRaw, raw, os, options.IntervalOf(ServerArea.Performance));
_previousRaw = raw;
_samples = ServerPerformanceMath.Append(_samples, sample);
var snapshot = new ServerPerformanceSnapshot
{
CapturedAt = now,
Os = os,
Raw = raw,
Samples = _samples,
PreviousBootUpTime = previousBoot,
RestartSeenAt = _restartSeenAt,
Protocol = session.Protocol,
};
_data.Set(snapshot);
await repository.SaveServerPerformanceAsync(ServerId, sample, cancellationToken).ConfigureAwait(false);
await repository.RecordServerContactAsync(ServerId, os, session.Protocol, null, now, cancellationToken).ConfigureAwait(false);
await repository.SaveServerSnapshotAsync(ServerId, ServerArea.Performance, snapshot with { Samples = [] }, now, cancellationToken).ConfigureAwait(false);
return sample.CpuPercent is { } cpu ? $"CPU {cpu:0} %" : null;
}
public async Task<string?> CollectDisksAsync(IWindowsServerSession session, DateTimeOffset now, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(session);
var disks = await session.GetDisksAsync(cancellationToken).ConfigureAwait(false);
await repository.SaveServerDisksAsync(ServerId, now, disks, cancellationToken).ConfigureAwait(false);
var historyDays = Math.Max(1, options.Disks.ForecastHistoryDays);
var history = await repository.GetServerDiskHistoryAsync(ServerId, now.AddDays(-historyDays), cancellationToken).ConfigureAwait(false);
var settings = new DiskForecastSettings { HistoryDays = historyDays };
var forecasts = disks.Select(d => DiskForecaster.Forecast(d.Volume, history.TryGetValue(d.Volume, out var points) ? points : [], now, settings)).ToList();
var snapshot = new ServerDiskSnapshot { CapturedAt = now, Disks = disks, Forecasts = forecasts };
_data.Set(snapshot);
await repository.SaveServerSnapshotAsync(ServerId, ServerArea.Disks, snapshot, now, cancellationToken).ConfigureAwait(false);
return $"{disks.Count} drev";
}
public async Task<string?> CollectServicesAsync(IWindowsServerSession session, DateTimeOffset now, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(session);
var services = await session.GetServicesAsync(cancellationToken).ConfigureAwait(false);
var snapshot = new ServerServiceSnapshot { CapturedAt = now, Services = services };
_data.Set(snapshot);
await repository.SaveServerSnapshotAsync(ServerId, ServerArea.Services, snapshot, now, cancellationToken).ConfigureAwait(false);
return $"{services.Count} tjenester";
}
/// <summary>
/// The first time, the last day is read, so recent problems show at once; then each log from where its last read
/// stopped, so a log that had more than could be read loses nothing while the other log keeps up.
/// </summary>
public async Task<string?> CollectEventsAsync(IWindowsServerSession session, DateTimeOffset now, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(session);
var max = Math.Max(10, options.EventLog.MaxEventsPerCollection);
var since = ServerEventAnalysis.Logs.ToDictionary(log => log, log => EventsReadUntil(_events, log, now), StringComparer.OrdinalIgnoreCase);
var read = await session.GetEventsAsync(since, max, cancellationToken).ConfigureAwait(false);
var (groups, ignored) = ServerEventAnalysis.Group(read.Entries, ServerEventAnalysis.DefaultIgnore.Concat(options.EventLog.Ignore));
var logReadUntil = NextReadUntil(read, since, now);
var truncated = ServerEventAnalysis.Logs.Where(read.IsTruncated).ToList();
var coverage = new List<string>();
if (truncated.Count > 0)
{
coverage.Add($"Der var flere end {max} hændelser i {string.Join(" og ", truncated)} siden sidst; resten læses ved næste indsamling.");
}
if (read.Note is { } note)
{
coverage.Add(note);
}
var snapshot = new ServerEventSnapshot
{
CapturedAt = now,
ReadUntil = logReadUntil.Values.Min(),
LogReadUntil = logReadUntil,
Method = read.Method,
Groups = groups,
Recent = ServerEventAnalysis.Recent(_events?.Recent ?? [], groups, now),
Ignored = ignored,
Coverage = coverage.Count == 0 ? null : string.Join(" ", coverage),
};
_events = snapshot;
_data.Set(snapshot);
await repository.SaveServerEventsAsync(ServerId, snapshot, cancellationToken).ConfigureAwait(false);
await repository.SaveServerSnapshotAsync(ServerId, ServerArea.Events, snapshot with { Groups = [] }, now, cancellationToken).ConfigureAwait(false);
return $"{read.Entries.Count} hændelser ({read.Method})";
}
/// <summary>Where a log's next read starts: its own mark, else the snapshot's (from before per-log marks), else a day back.</summary>
public static DateTimeOffset EventsReadUntil(ServerEventSnapshot? previous, string log, DateTimeOffset now) =>
previous?.LogReadUntil?.FirstOrDefault(m => string.Equals(m.Key, log, StringComparison.OrdinalIgnoreCase)) is { Key: not null } mark ? mark.Value
: previous?.ReadUntil ?? now - ServerEventAnalysis.RecentWindow;
/// <summary>
/// Per log: strictly after the newest event read (for a log that had more, that is where its rest starts); without
/// events, ten minutes' margin for events written late.
/// </summary>
public static Dictionary<string, DateTimeOffset> NextReadUntil(ServerEventRead read, IReadOnlyDictionary<string, DateTimeOffset> since, DateTimeOffset now) =>
ServerEventAnalysis.Logs.ToDictionary(log => log, log =>
{
var from = since[log];
var newest = read.Entries.Where(e => string.Equals(e.Log, log, StringComparison.OrdinalIgnoreCase)).Select(e => (DateTimeOffset?)e.At).Max();
return newest is { } at ? Max(at, from) : Max(from, now.AddMinutes(-10));
}, StringComparer.Ordinal);
private static DateTimeOffset Max(DateTimeOffset a, DateTimeOffset b) => a > b ? a : b;
public async Task<string?> CollectTasksAsync(IWindowsServerSession session, DateTimeOffset now, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(session);
var tasks = options.Tasks;
var read = await session.GetTasksAsync(
tasks.Watches,
Math.Max(1, tasks.MaxWatched), options.TaskBudget, cancellationToken).ConfigureAwait(false);
var snapshot = new ServerTaskSnapshot { CapturedAt = now, Tasks = read.Watched, Visible = read.Visible, Coverage = read.Note };
_data.Set(snapshot);
await repository.SaveServerSnapshotAsync(ServerId, ServerArea.Tasks, snapshot, now, cancellationToken).ConfigureAwait(false);
return $"{read.Watched.Count} af {read.Visible} opgaver overvåges";
}
private async Task EvaluateAsync(DateTimeOffset now, CancellationToken cancellationToken)
{
await repository.RecordServerAreasAsync(ServerId, ServerArea.All.Where(_areas.ContainsKey).Select(a => _areas[a]).ToList(), cancellationToken).ConfigureAwait(false);
var context = new ServerRuleContext(Server.ToIdentity(), now, _data, options) { PerformanceInterval = options.IntervalOf(ServerArea.Performance) };
var results = ScopedRuleEngine.Evaluate(WindowsServerRules.Create(), context);
var changes = await repository.SaveScopedRuleResultsAsync(FindingScope.Server, ServerId, results, now, cancellationToken).ConfigureAwait(false);
await NotifyAsync(ServerNotifications.For(Server.Name, changes, webUrl, now).ToList(), cancellationToken).ConfigureAwait(false);
if (logger.IsEnabled(LogLevel.Debug))
{
var findings = results.Sum(r => r.Findings.Count);
var notMeasurable = results.Count(r => r.Outcome is RuleOutcome.NotMeasurable or RuleOutcome.Error);
LogCycle(logger, Server.Name, findings, notMeasurable);
}
}
/// <summary>What a restart of the agent would lose: the last hour's samples, the boot time and where the event log was read to.</summary>
private async Task SeedAsync(DateTimeOffset now, CancellationToken cancellationToken)
{
var stored = await repository.GetServerPerformanceAsync(ServerId, now - ServerPerformanceMath.Window, cancellationToken).ConfigureAwait(false);
_samples = stored.Select(s => new ServerPerformanceSample(new DateTimeOffset(DateTime.SpecifyKind(s.SampledAt, DateTimeKind.Utc)), s.CpuPercent, s.AvailableMb,
s.AvailableMb is { } mb && s.AvailablePct is > 0 ? mb * 100 / s.AvailablePct.Value : null, s.PagesPerSec, s.CommitPct)).ToList();
var performance = await repository.GetServerSnapshotAsync<ServerPerformanceSnapshot>(ServerId, ServerArea.Performance, cancellationToken).ConfigureAwait(false);
_lastBoot = performance?.Os.LastBootUpTime;
_restartSeenAt = performance?.RestartSeenAt;
_events = await repository.GetServerSnapshotAsync<ServerEventSnapshot>(ServerId, ServerArea.Events, cancellationToken).ConfigureAwait(false);
}
private async Task NotifyAsync(List<Notification> events, CancellationToken cancellationToken)
{
if (notifications is not null && events.Count > 0)
{
await notifications.DispatchAsync(events, cancellationToken).ConfigureAwait(false);
}
}
[LoggerMessage(Level = LogLevel.Warning, Message = "Windows server {Server} did not finish within {Timeout}; not read this time: {Areas}")]
private static partial void LogCycleTimedOut(ILogger logger, string server, TimeSpan timeout, string areas);
[LoggerMessage(Level = LogLevel.Warning, Message = "Windows server {Server} cannot be read: {Reason}")]
private static partial void LogNotReachable(ILogger logger, string server, string reason);
[LoggerMessage(Level = LogLevel.Information, Message = "Windows server {Server}: {Area} is not measurable: {Reason}")]
private static partial void LogAreaUnavailable(ILogger logger, string server, string area, string reason);
[LoggerMessage(Level = LogLevel.Information, Message = "Windows server {Server}: {Area} can be read again")]
private static partial void LogAreaRecovered(ILogger logger, string server, string area);
[LoggerMessage(Level = LogLevel.Debug, Message = "Windows server {Server}: {Findings} findings, {NotMeasurable} rules not measurable")]
private static partial void LogCycle(ILogger logger, string server, int findings, int notMeasurable);
}

View file

@ -0,0 +1,231 @@
using Microsoft.Extensions.Options;
using SQLInsight.Core.Servers;
using SQLInsight.Notifications;
using SQLInsight.Repository;
using SQLInsight.Servers;
namespace SQLInsight.Agent.Servers;
/// <summary>
/// The agent's loop for Windows servers: keeps the SQL Server hosts in step with the monitored instances, reads the
/// configured servers (changes in SQLInsight Web apply within a minute, without a restart), runs each server's due
/// areas with a limit on how many at once, and deletes old samples and check results hourly (also when only the
/// synthetic checks are on).
/// </summary>
public sealed partial class ServerMonitoringService(
IOptions<ServerOptions> options,
IOptions<SyntheticCheckOptions> checkOptions,
IWindowsServerSource source,
InsightRepository repository,
TimeProvider time,
ILoggerFactory loggerFactory,
NotificationDispatcher notifications,
IOptions<NotificationOptions> notificationOptions,
IOptions<AgentOptions> agentOptions,
Configuration.RepositoryReadiness readiness) : BackgroundService
{
public static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(1);
public static readonly TimeSpan MaintenanceInterval = TimeSpan.FromHours(1);
private readonly ILogger _logger = loggerFactory.CreateLogger<ServerMonitoringService>();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var settings = options.Value;
if (!settings.Enabled)
{
LogDisabled(_logger);
if (checkOptions.Value.Enabled)
{
// The synthetic checks keep writing results: their retention still applies.
await MaintainOnlyAsync(settings, stoppingToken).ConfigureAwait(false);
}
return;
}
await readiness.Ready.WaitAsync(stoppingToken).ConfigureAwait(false);
var monitors = new Dictionary<int, ServerMonitor>();
var running = new Dictionary<int, Task>();
using var throttle = new SemaphoreSlim(Math.Clamp(settings.MaxParallelServers, 1, 32));
var nextRefresh = DateTimeOffset.MinValue;
var nextMaintenance = time.GetUtcNow().AddMinutes(5);
using var timer = new PeriodicTimer(agentOptions.Value.Tick, time);
try
{
do
{
var now = time.GetUtcNow();
try
{
if (now >= nextRefresh)
{
nextRefresh = now + RefreshInterval;
await RefreshAsync(monitors, settings, now, stoppingToken).ConfigureAwait(false);
}
foreach (var (id, monitor) in monitors)
{
if (running.TryGetValue(id, out var task) && !task.IsCompleted || !monitor.IsDue(now))
{
continue;
}
running[id] = RunThrottledAsync(monitor, throttle, stoppingToken);
}
foreach (var done in running.Where(r => r.Value.IsCompleted && !monitors.ContainsKey(r.Key)).Select(r => r.Key).ToList())
{
running.Remove(done);
}
if (now >= nextMaintenance)
{
nextMaintenance = now + MaintenanceInterval;
await MaintainAsync(settings, now, stoppingToken).ConfigureAwait(false);
}
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
// Typically the repository is unreachable. Try again next round.
LogRoundFailed(_logger, ex);
}
}
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Stopping: the running cycles end below, before the throttle they release is disposed.
}
await WaitQuietlyAsync(running.Values).ConfigureAwait(false);
}
/// <summary>Servers off, synthetic checks on: only the hourly maintenance.</summary>
private async Task MaintainOnlyAsync(ServerOptions settings, CancellationToken stoppingToken)
{
try
{
await readiness.Ready.WaitAsync(stoppingToken).ConfigureAwait(false);
using var timer = new PeriodicTimer(MaintenanceInterval, time);
do
{
try
{
await MaintainAsync(settings, time.GetUtcNow(), stoppingToken).ConfigureAwait(false);
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
LogRoundFailed(_logger, ex);
}
}
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Stopping.
}
}
/// <summary>Deletes what is past retention, in small batches; every period is at least a day (see <see cref="ServerOptions.Normalize"/>).</summary>
private Task MaintainAsync(ServerOptions settings, DateTimeOffset now, CancellationToken cancellationToken) =>
repository.RunServerMaintenanceAsync(new ServerRetention
{
SampleDays = Math.Max(1, settings.SampleDays),
DiskHistoryDays = Math.Max(Math.Max(1, settings.DiskHistoryDays), settings.Disks.ForecastHistoryDays + 1),
EventDays = Math.Max(1, settings.EventDays),
CheckResultDays = Math.Max(1, checkOptions.Value.ResultDays),
ResolvedFindingDays = Math.Max(1, agentOptions.Value.Retention.ResolvedFindingDays),
}, now, cancellationToken);
/// <summary>
/// Waits, when the agent stops, for tasks that end with it: a cancellation (or the error a cancelled database call
/// reports instead) is how they end, not news.
/// </summary>
internal static async Task WaitQuietlyAsync(IEnumerable<Task> tasks)
{
foreach (var task in tasks)
{
try
{
await task.ConfigureAwait(false);
}
catch (Exception ex) when (ex is OperationCanceledException or System.Data.Common.DbException)
{
// Stopping.
}
}
}
/// <summary>The SQL Server hosts first, then the servers to look at (not paused).</summary>
private async Task RefreshAsync(Dictionary<int, ServerMonitor> monitors, ServerOptions settings, DateTimeOffset now, CancellationToken cancellationToken)
{
if (settings.IncludeSqlHosts)
{
var hosts = (await repository.GetConfiguredInstancesAsync(cancellationToken).ConfigureAwait(false))
.Select(i => ServerHost.FromDataSource(i.DataSource, Environment.MachineName).Host)
.OfType<string>()
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(h => (h, ServerHost.ShortName(h)))
.ToList();
await repository.SyncSqlHostServersAsync(hosts, now, cancellationToken).ConfigureAwait(false);
}
var servers = (await repository.GetWindowsServersAsync(cancellationToken).ConfigureAwait(false)).Where(s => !s.Paused).ToList();
foreach (var row in servers)
{
if (monitors.TryGetValue(row.ServerId, out var monitor) && string.Equals(monitor.Server.Host, row.Host, StringComparison.OrdinalIgnoreCase))
{
monitor.Update(row);
continue;
}
monitors[row.ServerId] = new ServerMonitor(row, source, repository, settings, loggerFactory.CreateLogger($"SQLInsight.Server.{row.Name}"), notifications,
notificationOptions.Value.WebUrl);
LogStarted(_logger, row.Name, row.Host);
}
foreach (var gone in monitors.Keys.Where(id => servers.All(s => s.ServerId != id)).ToList())
{
LogStopped(_logger, monitors[gone].Server.Name);
monitors.Remove(gone);
}
}
/// <summary>
/// One server's cycle when a slot is free, with the time taken then (not when it was queued). The cycle bounds
/// itself (<see cref="ServerOptions.CycleTimeout"/>), so a hung server gives its slot back.
/// </summary>
private async Task RunThrottledAsync(ServerMonitor monitor, SemaphoreSlim throttle, CancellationToken cancellationToken)
{
await throttle.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await monitor.RunCycleAsync(time.GetUtcNow(), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
LogCycleFailed(_logger, ex, monitor.Server.Name);
}
finally
{
throttle.Release();
}
}
[LoggerMessage(Level = LogLevel.Information, Message = "Windows servers are not monitored (SQLInsight:Servers:Enabled is false).")]
private static partial void LogDisabled(ILogger logger);
[LoggerMessage(Level = LogLevel.Information, Message = "Windows server {Server} ({Host}): monitoring started")]
private static partial void LogStarted(ILogger logger, string server, string host);
[LoggerMessage(Level = LogLevel.Information, Message = "Windows server {Server}: monitoring stopped (paused or removed)")]
private static partial void LogStopped(ILogger logger, string server);
[LoggerMessage(Level = LogLevel.Error, Message = "Windows server {Server}: cycle failed")]
private static partial void LogCycleFailed(ILogger logger, Exception exception, string server);
[LoggerMessage(Level = LogLevel.Error, Message = "Monitoring Windows servers failed this round; retrying.")]
private static partial void LogRoundFailed(ILogger logger, Exception exception);
}

View file

@ -0,0 +1,139 @@
using SQLInsight.Core.Servers;
using SQLInsight.Core.SyntheticChecks;
using SQLInsight.Notifications;
using SQLInsight.Repository;
using SQLInsight.Rules.Servers;
using SQLInsight.Servers;
namespace SQLInsight.Agent.Servers;
/// <summary>Windows servers (SQLInsight:Servers): how and how often to collect, how long to keep it, and the rules' thresholds.</summary>
public sealed class ServerOptions : ServerRuleSettings
{
public const string SectionName = "SQLInsight:Servers";
public static readonly TimeSpan MinCycleTimeout = TimeSpan.FromMinutes(1);
public static readonly TimeSpan MaxCycleTimeout = TimeSpan.FromMinutes(30);
/// <summary>Look at Windows servers at all.</summary>
public bool Enabled { get; set; } = true;
/// <summary>The computers behind the monitored SQL Server instances are Windows servers too, without being added.</summary>
public bool IncludeSqlHosts { get; set; } = true;
/// <summary>How many servers are read at the same time (1 to 32).</summary>
public int MaxParallelServers { get; set; } = 4;
/// <summary>Auto (WS-Man, then DCOM), WSMan or DCOM.</summary>
public string Protocol { get; set; } = "Auto";
/// <summary>How long one CIM operation may take (5 seconds to 5 minutes).</summary>
public TimeSpan OperationTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// How long one server's round may take at most (1 to 30 minutes, at least twice <see cref="OperationTimeout"/>): what is
/// not read by then is "ikke målbar" until the next round, so a server that hangs never holds up the others.
/// </summary>
public TimeSpan CycleTimeout { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>Read event logs with the Event Log service's own protocol (filters on the server); off: CIM only.</summary>
public bool EventLogRpc { get; set; } = true;
/// <summary>Overrides of the collection intervals by area: performance, disks, services, events, tasks.</summary>
public IDictionary<string, TimeSpan> Intervals { get; } = new Dictionary<string, TimeSpan>(StringComparer.OrdinalIgnoreCase);
/// <summary>Per-minute CPU and memory samples (at least 1 day).</summary>
public int SampleDays { get; set; } = 14;
/// <summary>Free space per volume: at least the forecast's history.</summary>
public int DiskHistoryDays { get; set; } = 35;
/// <summary>Grouped Windows events (at least 1 day).</summary>
public int EventDays { get; set; } = 90;
/// <summary>The share of <see cref="CycleTimeout"/> the scheduled tasks' run information may take; the rest is read next time.</summary>
public TimeSpan TaskBudget => CycleTimeout / 3;
public TimeSpan IntervalOf(string area) =>
Intervals.TryGetValue(area, out var interval) && interval >= TimeSpan.FromSeconds(30) ? interval : ServerArea.DefaultInterval(area);
public ServerConnectionOptions Connection => new() { Protocol = Protocol, OperationTimeout = OperationTimeout, EventLogRpc = EventLogRpc };
/// <summary>
/// Keeps the settings within what works: a timeout of 0 or hours, retention of 0 days (maintenance would delete
/// everything) or unbounded parallelism are moved to the nearest sensible value. Applied when the options are bound.
/// </summary>
public ServerOptions Normalize()
{
MaxParallelServers = Math.Clamp(MaxParallelServers, 1, 32);
OperationTimeout = ServerConnectionOptions.BoundTimeout(OperationTimeout);
var cycle = CycleTimeout < OperationTimeout * 2 ? OperationTimeout * 2 : CycleTimeout;
CycleTimeout = cycle < MinCycleTimeout ? MinCycleTimeout : cycle > MaxCycleTimeout ? MaxCycleTimeout : cycle;
SampleDays = Math.Max(1, SampleDays);
DiskHistoryDays = Math.Max(1, DiskHistoryDays);
EventDays = Math.Max(1, EventDays);
Disks.ForecastHistoryDays = Math.Clamp(Disks.ForecastHistoryDays, 1, 365);
EventLog.MaxEventsPerCollection = Math.Clamp(EventLog.MaxEventsPerCollection, 10, 10000);
Tasks.MaxWatched = Math.Clamp(Tasks.MaxWatched, 1, 200);
return this;
}
}
/// <summary>Synthetic checks (SQLInsight:SyntheticChecks): how many run at once, how long results are kept, and the rules' thresholds.</summary>
public sealed class SyntheticCheckOptions : SyntheticCheckRuleSettings
{
public const string SectionName = "SQLInsight:SyntheticChecks";
/// <summary>The most recent results the rules look at: enough for "fejlet i træk", never unbounded.</summary>
public const int MaxRecentResults = 100;
public bool Enabled { get; set; } = true;
/// <summary>How many checks run at the same time (1 to 32).</summary>
public int MaxParallelChecks { get; set; } = 4;
/// <summary>Every run's result (at least 1 day).</summary>
public int ResultDays { get; set; } = 14;
/// <summary>Where the Dynamics AX .NET Business Connector is, when not where the AX installer puts it.</summary>
public string? BusinessConnectorPath { get; set; }
/// <summary>How many recent results the rules are given: more than they need to count failures or slow runs in a row.</summary>
public int RecentResults => Math.Clamp(Math.Max(10, Math.Max(FailuresBeforeFinding, SlowRunsBeforeFinding)) + 1, 11, MaxRecentResults);
/// <summary>Keeps the settings within what works (see <see cref="ServerOptions.Normalize"/>). Applied when the options are bound.</summary>
public SyntheticCheckOptions Normalize()
{
MaxParallelChecks = Math.Clamp(MaxParallelChecks, 1, 32);
ResultDays = Math.Max(1, ResultDays);
FailuresBeforeFinding = Math.Clamp(FailuresBeforeFinding, 1, MaxRecentResults - 1);
SlowRunsBeforeFinding = Math.Clamp(SlowRunsBeforeFinding, 1, MaxRecentResults - 1);
return this;
}
/// <summary>A check's interval as the agent runs it: never more often than <see cref="SyntheticCheckLimits.MinIntervalSeconds"/>.</summary>
public static TimeSpan IntervalOf(int intervalSeconds) =>
TimeSpan.FromSeconds(Math.Clamp(intervalSeconds, SyntheticCheckLimits.MinIntervalSeconds, SyntheticCheckLimits.MaxIntervalSeconds));
}
/// <summary>Notifications about Windows servers and synthetic checks, with links to their pages in the web UI.</summary>
internal static class ServerNotifications
{
public static IEnumerable<Notification> For(string name, FindingChanges changes, Uri? webUrl, DateTimeOffset now)
{
foreach (var (id, finding) in changes.Opened)
{
yield return NotificationFactory.Opened(name, id, finding, webUrl, now) with { Url = Link(webUrl, id) };
}
foreach (var resolved in changes.Resolved)
{
yield return NotificationFactory.Resolved(name, resolved.FindingId, resolved.RuleId, resolved.Title, (Core.Findings.Severity)resolved.Severity, webUrl, now)
with { Url = Link(webUrl, resolved.FindingId) };
}
}
public static Uri? Link(Uri? webUrl, long findingId) => webUrl is null ? null : new Uri(webUrl, $"servere/fund/{findingId}");
public static Uri? ServerPage(Uri? webUrl, int serverId) => webUrl is null ? null : new Uri(webUrl, $"servere/{serverId}");
}

View file

@ -0,0 +1,65 @@
using SQLInsight.Core.Configuration;
using SQLInsight.Core.Servers;
using SQLInsight.Core.SyntheticChecks;
using SQLInsight.Repository;
using SQLInsight.Servers;
namespace SQLInsight.Agent.Servers;
/// <summary>
/// Requests from SQLInsight Web about Windows servers and synthetic checks (insight.AgentRequests with a TargetId):
/// test a server's rights as the agent, or run a check now. Called by the agent's request service.
/// </summary>
public sealed class ServerRequestHandler(InsightRepository repository, IWindowsServerSource source, SyntheticCheckService checks, TimeProvider time)
{
/// <summary>Margin for reading the request and storing its result.</summary>
public static readonly TimeSpan Margin = TimeSpan.FromSeconds(30);
/// <summary>
/// How long such a request may take: a check's longest timeout, the lookup of its Windows service and storing the
/// result, so "Kør nu" always gets its result stored.
/// </summary>
public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(SyntheticCheckLimits.MaxTimeoutSeconds) + SyntheticCheckService.ServiceLookupTimeout + Margin;
/// <summary>How long a server test may take: what is not done by then is reported as not answered, in time to be stored.</summary>
public static readonly TimeSpan TestTimeout = RequestTimeout - Margin;
public static bool Handles(string kind) => ServerRequestKind.Handles(kind);
/// <summary>The request's result as JSON. Throws <see cref="KeyNotFoundException"/> for a server or check that does not exist.</summary>
public async Task<string> HandleAsync(AgentRequestRow request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var target = await repository.GetRequestTargetAsync(request.RequestId, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Anmodningen mangler en server eller et tjek.");
return request.Kind switch
{
ServerRequestKind.ServerTest => AgentRequestJson.Serialize(await TestAsync(target, cancellationToken).ConfigureAwait(false)),
ServerRequestKind.SyntheticCheckRun => AgentRequestJson.Serialize(await RunAsync(target, cancellationToken).ConfigureAwait(false)),
_ => throw new InvalidOperationException($"Ukendt anmodning: {request.Kind}."),
};
}
/// <summary>Connects as the agent and tries every area; what could and could not be read is also stored on the server.</summary>
public async Task<WindowsServerTestResult> TestAsync(int serverId, CancellationToken cancellationToken)
{
var server = await repository.GetWindowsServerAsync(serverId, cancellationToken).ConfigureAwait(false)
?? throw new KeyNotFoundException($"Server {serverId} findes ikke.");
var account = OperatingSystem.IsWindows() ? CimWindowsServerSource.AgentAccount(server.Host) : null;
var result = await WindowsServerTester.TestAsync(source, server.Host, account, time.GetUtcNow(), TestTimeout, cancellationToken).ConfigureAwait(false);
if (result.Reachable)
{
await repository.RecordServerAreasAsync(serverId, result.Areas, cancellationToken).ConfigureAwait(false);
}
return result;
}
/// <summary>"Kør nu": joins a run of the check that is going on, or runs it within the limit on parallel checks; a paused check is not run.</summary>
public async Task<SyntheticCheckResult> RunAsync(int checkId, CancellationToken cancellationToken)
{
var check = await repository.GetSyntheticCheckAsync(checkId, cancellationToken).ConfigureAwait(false)
?? throw new KeyNotFoundException($"Tjek {checkId} findes ikke.");
return await checks.RunNowAsync(check, cancellationToken).ConfigureAwait(false);
}
}

View file

@ -0,0 +1,31 @@
using Microsoft.Extensions.Options;
using SQLInsight.Connectivity;
using SQLInsight.Servers;
using SQLInsight.Servers.Synthetic;
namespace SQLInsight.Agent.Servers;
/// <summary>1.5: Windows servers and synthetic checks in the agent.</summary>
public static class ServerServices
{
public static void Add(IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.Configure<ServerOptions>(configuration.GetSection(ServerOptions.SectionName));
services.Configure<SyntheticCheckOptions>(configuration.GetSection(SyntheticCheckOptions.SectionName));
services.PostConfigure<ServerOptions>(o => o.Normalize());
services.PostConfigure<SyntheticCheckOptions>(o => o.Normalize());
services.AddSingleton<IWindowsServerSource>(sp => OperatingSystem.IsWindows()
? new CimWindowsServerSource(sp.GetRequiredService<IOptions<ServerOptions>>().Value.Connection, sp.GetRequiredService<TimeProvider>())
: new UnsupportedWindowsServerSource());
services.AddSingleton<IBusinessConnectorLocator>(sp => new BusinessConnectorLocator(sp.GetRequiredService<IOptions<SyntheticCheckOptions>>().Value.BusinessConnectorPath));
services.AddSingleton<IProcessRunner, ProcessRunner>();
services.AddSingleton(sp => new SyntheticCheckRunner(sp.GetRequiredService<ISqlConnectionFactory>(), sp.GetRequiredService<IBusinessConnectorLocator>(),
sp.GetRequiredService<IProcessRunner>(), sp.GetRequiredService<TimeProvider>()));
services.AddSingleton<SyntheticCheckService>();
services.AddHostedService(sp => sp.GetRequiredService<SyntheticCheckService>());
services.AddSingleton<ServerRequestHandler>();
services.AddHostedService<ServerMonitoringService>();
}
}

View file

@ -0,0 +1,367 @@
using Microsoft.Extensions.Options;
using SQLInsight.Core.Configuration;
using SQLInsight.Core.SyntheticChecks;
using SQLInsight.Notifications;
using SQLInsight.Repository;
using SQLInsight.Rules.Servers;
using SQLInsight.Servers;
using SQLInsight.Servers.Synthetic;
namespace SQLInsight.Agent.Servers;
/// <summary>
/// Runs the synthetic checks on their schedule (and on request from SQLInsight Web), as the agent's identity: stores
/// each result, looks up the linked Windows service when a check fails ("svarer ikke, selv om tjenesten kører"),
/// evaluates the rules and notifies. A check runs once at a time, and at most MaxParallelChecks run together, also when
/// asked to run now. Never changes anything on the servers it checks.
/// </summary>
public sealed partial class SyntheticCheckService(
IOptions<SyntheticCheckOptions> options,
SyntheticCheckRunner runner,
IWindowsServerSource servers,
InsightRepository repository,
Configuration.AgentCredentialKey key,
TimeProvider time,
ILogger<SyntheticCheckService> logger,
NotificationDispatcher notifications,
IOptions<NotificationOptions> notificationOptions,
IOptions<AgentOptions> agentOptions,
Configuration.RepositoryReadiness readiness) : BackgroundService
{
/// <summary>The answer to "Kør nu" for a paused check: it is not run.</summary>
public const string PausedMessage = "Tjekket er sat på pause.";
/// <summary>How long looking up the state of a failing check's Windows service may take.</summary>
public static readonly TimeSpan ServiceLookupTimeout = TimeSpan.FromSeconds(30);
private readonly Dictionary<int, Task<SyntheticCheckResult?>> _running = [];
private readonly Dictionary<int, DateTimeOffset> _attempts = [];
private readonly Dictionary<int, string> _reported = [];
private readonly Lock _lock = new();
private readonly SemaphoreSlim _throttle = new(Math.Clamp(options.Value.MaxParallelChecks, 1, 32));
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var settings = options.Value;
if (!settings.Enabled)
{
return;
}
await readiness.Ready.WaitAsync(stoppingToken).ConfigureAwait(false);
using var timer = new PeriodicTimer(agentOptions.Value.Tick, time);
try
{
do
{
try
{
var now = time.GetUtcNow();
var checks = await repository.GetSyntheticChecksAsync(stoppingToken).ConfigureAwait(false);
Prune(checks);
foreach (var check in checks.Where(c => IsDue(c, now)))
{
lock (_lock)
{
_attempts[check.CheckId] = now;
}
_ = Start(check, stoppingToken);
}
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
LogRoundFailed(logger, ex);
}
}
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Stopping: the running checks end below.
}
Task[] remaining;
lock (_lock)
{
remaining = [.. _running.Values];
}
await ServerMonitoringService.WaitQuietlyAsync(remaining).ConfigureAwait(false);
}
public override void Dispose()
{
base.Dispose();
_throttle.Dispose();
}
/// <summary>
/// Due when its interval (never under a minute) has passed since the last stored result and since the agent last
/// started it: a run that could not be stored is not repeated every few seconds.
/// </summary>
private bool IsDue(SyntheticCheckRow check, DateTimeOffset now)
{
if (check.Paused)
{
return false;
}
var interval = SyntheticCheckOptions.IntervalOf(check.IntervalSeconds);
if (check.LastRunAt is { } last && new DateTimeOffset(DateTime.SpecifyKind(last, DateTimeKind.Utc)) + interval > now)
{
return false;
}
lock (_lock)
{
return !(_attempts.TryGetValue(check.CheckId, out var attempted) && attempted + interval > now)
&& !(_running.TryGetValue(check.CheckId, out var running) && !running.IsCompleted);
}
}
/// <summary>Forgets checks that were removed.</summary>
private void Prune(IReadOnlyList<SyntheticCheckRow> checks)
{
var ids = checks.Select(c => c.CheckId).ToHashSet();
lock (_lock)
{
foreach (var id in _running.Where(r => !ids.Contains(r.Key) && r.Value.IsCompleted).Select(r => r.Key).ToList())
{
_running.Remove(id);
}
foreach (var id in _attempts.Keys.Where(id => !ids.Contains(id)).ToList())
{
_attempts.Remove(id);
}
foreach (var id in _reported.Keys.Where(id => !ids.Contains(id)).ToList())
{
_reported.Remove(id);
}
}
}
/// <summary>The check's run that is going on, or a new one under the limit on parallel checks.</summary>
private Task<SyntheticCheckResult?> Start(SyntheticCheckRow check, CancellationToken cancellationToken)
{
lock (_lock)
{
if (_running.TryGetValue(check.CheckId, out var running) && !running.IsCompleted)
{
return running;
}
var task = RunThrottledAsync(check, cancellationToken);
_running[check.CheckId] = task;
return task;
}
}
/// <summary>
/// "Kør nu" from SQLInsight Web: joins the check's run if one is going on, or runs it under the same limit as the
/// scheduled runs. A paused check is not run (and nothing is stored or evaluated).
/// </summary>
public async Task<SyntheticCheckResult> RunNowAsync(SyntheticCheckRow check, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(check);
if (check.Paused)
{
return new SyntheticCheckResult { At = time.GetUtcNow(), Success = false, Message = PausedMessage };
}
var result = await Start(check, cancellationToken).WaitAsync(cancellationToken).ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Tjekket kunne ikke køres eller resultatet ikke gemmes. Se agentens log.");
}
/// <summary>One run; null when it could not be stored (logged once per change).</summary>
private async Task<SyntheticCheckResult?> RunThrottledAsync(SyntheticCheckRow check, CancellationToken cancellationToken)
{
await _throttle.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await RunCheckAsync(check, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
if (IsNews(check.CheckId, "fejl: " + ex.Message, remember: true))
{
LogCheckFailed(logger, ex, check.Name);
}
return null;
}
finally
{
_throttle.Release();
}
}
/// <summary>Runs a check once, stores the result and its findings. Used on schedule and for "Kør nu" (through <see cref="RunNowAsync"/>).</summary>
public async Task<SyntheticCheckResult> RunCheckAsync(SyntheticCheckRow check, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(check);
var settings = check.Settings;
var now = time.GetUtcNow();
string? password = null;
if (check.Kind == SyntheticCheckKind.Sql && settings.Authentication == "SqlLogin")
{
(password, var error) = await OpenSecretAsync(check, settings, cancellationToken).ConfigureAwait(false);
if (error is not null)
{
return await StoreAsync(check, new SyntheticCheckResult { At = now, Success = false, Message = error }, cancellationToken).ConfigureAwait(false);
}
}
var timeout = TimeSpan.FromSeconds(Math.Clamp(check.TimeoutSeconds, 1, SyntheticCheckLimits.MaxTimeoutSeconds));
SyntheticCheckResult result;
try
{
result = await runner.RunAsync(new SyntheticCheckRun(check.Kind, settings, timeout, password), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
// Not a failure the runner foresaw: still the check's result, so it is stored, waits its interval, and is logged once.
var message = "Tjekket kunne ikke køres: " + ex.Message;
if (IsNews(check.CheckId, message, remember: false))
{
LogCheckFailed(logger, ex, check.Name);
}
result = new SyntheticCheckResult { At = now, Success = false, DurationMs = (int)Math.Min(int.MaxValue, (time.GetUtcNow() - now).TotalMilliseconds), Message = message };
}
if (!result.Success && check.ServerId is { } serverId && settings.ServiceName is { Length: > 0 } service)
{
result = await WithServiceStateAsync(result, serverId, service, cancellationToken).ConfigureAwait(false);
}
return await StoreAsync(check, result, cancellationToken).ConfigureAwait(false);
}
private async Task<(string? Password, string? Error)> OpenSecretAsync(SyntheticCheckRow check, SyntheticCheckSettings settings, CancellationToken cancellationToken)
{
string? error = null;
string? password = null;
if (check.SecretCipher is not { Length: > 0 } cipher)
{
error = "Der er ikke gemt en adgangskode til SQL-login'et.";
}
else
{
try
{
password = key.OpenContext(cipher, settings.SecretContext());
}
catch (CredentialException ex)
{
error = "Adgangskoden kan ikke bruges: " + ex.Message;
}
}
if (!string.Equals(error, check.SecretError, StringComparison.Ordinal))
{
await repository.SetSyntheticCheckSecretErrorAsync(check.CheckId, error, cancellationToken).ConfigureAwait(false);
}
return (password, error);
}
/// <summary>
/// The state of the Windows service behind a failing check: the difference between "nede" and "kører, men svarer
/// ikke". Takes at most <see cref="ServiceLookupTimeout"/>.
/// </summary>
private async Task<SyntheticCheckResult> WithServiceStateAsync(SyntheticCheckResult result, int serverId, string service, CancellationToken cancellationToken)
{
var server = await repository.GetWindowsServerAsync(serverId, cancellationToken).ConfigureAwait(false);
if (server is null)
{
return result;
}
using var lookup = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
lookup.CancelAfter(ServiceLookupTimeout);
try
{
using var session = await servers.ConnectAsync(server.Host, lookup.Token).ConfigureAwait(false);
var state = await session.GetServiceAsync(service, lookup.Token).ConfigureAwait(false);
return result with
{
ServiceState = state?.State ?? "NotFound",
Details = [.. result.Details, new SyntheticCheckDetail("Tjeneste", $"{service} på {server.Name}: {state?.State ?? "findes ikke"}")],
};
}
catch (ServerAccessException ex)
{
// The reason names the agent's account: kept on-prem.
return result with { Details = [.. result.Details, new SyntheticCheckDetail("Tjeneste", $"{service} på {server.Name} kunne ikke aflæses: {ex.Message}", Sensitive: true)] };
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return result with
{
Details = [.. result.Details, new SyntheticCheckDetail("Tjeneste",
$"{service} på {server.Name} kunne ikke aflæses: serveren svarede ikke inden for {ServiceLookupTimeout.TotalSeconds:0} sekunder.")],
};
}
}
private async Task<SyntheticCheckResult> StoreAsync(SyntheticCheckRow check, SyntheticCheckResult result, CancellationToken cancellationToken)
{
await repository.RecordSyntheticCheckResultAsync(check.CheckId, result, cancellationToken).ConfigureAwait(false);
var settings = options.Value;
var recent = (await repository.GetSyntheticCheckResultsAsync(check.CheckId, settings.RecentResults, cancellationToken: cancellationToken).ConfigureAwait(false))
.Select(r => r.ToResult()).ToList();
var checkSettings = check.Settings;
var identity = new SyntheticCheckIdentity(check.CheckId, check.Name, check.Kind, checkSettings.Target(check.Kind))
{
ServerName = check.ServerName,
ServiceName = checkSettings.ServiceName,
WarnMilliseconds = checkSettings.WarnMilliseconds,
Https = checkSettings.Url?.Scheme == Uri.UriSchemeHttps,
};
var results = ScopedRuleEngine.Evaluate(SyntheticCheckRules.Create(), new SyntheticCheckRuleContext(identity, result.At, recent, settings));
var changes = await repository.SaveScopedRuleResultsAsync(FindingScope.Check, check.CheckId, results, result.At, cancellationToken).ConfigureAwait(false);
var events = ServerNotifications.For(check.Name, changes, notificationOptions.Value.WebUrl, result.At).ToList();
if (events.Count > 0)
{
await notifications.DispatchAsync(events, cancellationToken).ConfigureAwait(false);
}
if (IsNews(check.CheckId, result.Success ? "ok" : result.Message, remember: true))
{
LogResult(logger, check.Name, result.Success, result.Message);
}
return result;
}
/// <summary>Whether a check's state differs from the last one logged (the same failure every run is not news).</summary>
private bool IsNews(int checkId, string state, bool remember)
{
lock (_lock)
{
if (_reported.TryGetValue(checkId, out var previous) && previous == state)
{
return false;
}
if (remember)
{
_reported[checkId] = state;
}
return true;
}
}
[LoggerMessage(Level = LogLevel.Information, Message = "Synthetic check {Check}: success {Success}: {Message}")]
private static partial void LogResult(ILogger logger, string check, bool success, string message);
[LoggerMessage(Level = LogLevel.Error, Message = "Synthetic check {Check} could not be run")]
private static partial void LogCheckFailed(ILogger logger, Exception exception, string check);
[LoggerMessage(Level = LogLevel.Error, Message = "Running synthetic checks failed this round; retrying.")]
private static partial void LogRoundFailed(ILogger logger, Exception exception);
}

View file

@ -62,6 +62,37 @@
"RecId32WarnPercent": 50
// "LocalTimeZone": "Romance Standard Time" // AOS'ens tidszone for Axapta 3.0/4.0's lokale tider; tom: agentens egen
},
"Servers": {
// Windows-servere (se docs/windows-servere.md). Rettigheder: db/permissions/grant-windows-server.ps1 – aldrig lokal administrator.
"Enabled": true,
"IncludeSqlHosts": true,
"Protocol": "Auto",
"MaxParallelServers": 4,
"OperationTimeout": "00:00:30",
"CycleTimeout": "00:05:00",
"Intervals": { "performance": "00:01:00", "disks": "00:15:00", "services": "00:05:00", "events": "00:05:00", "tasks": "00:15:00" },
"Cpu": { "HighPercent": 90, "SustainedMinutes": 15 },
"Memory": { "AvailablePercent": 5, "AvailableMegabytes": 1024, "PagesPerSecond": 1000, "CommitPercent": 90 },
"Disks": { "FreePercent": 10, "CriticalFreePercent": 5, "EnoughFreeGigabytes": 50, "ForecastDays": 30, "ForecastHistoryDays": 14 },
"Services": { "Ignore": [], "StartupGraceMinutes": 15, "MaxFindings": 15 },
"EventLog": { "RepeatThreshold": 5, "Ignore": [], "MaxFindings": 15 },
"Tasks": { "IncludeMicrosoftTasks": false, "IgnoreUserAndUpdaterTasks": true, "LateMinutes": 60, "MaxWatched": 50, "Ignore": [] },
"UptimeDays": 0,
"SampleDays": 14,
"DiskHistoryDays": 35,
"EventDays": 90
},
"SyntheticChecks": {
// Syntetiske tjek (se docs/syntetiske-tjek.md). Tjekkene selv oprettes i webbrugerfladen eller API'et.
"Enabled": true,
"MaxParallelChecks": 4,
"FailuresBeforeFinding": 2,
"SlowRunsBeforeFinding": 3,
"CertificateWarningDays": 21,
"CertificateCriticalDays": 7,
"ResultDays": 14,
"BusinessConnectorPath": null
},
"Instances": [
// Servere tilføjes nemmest i webbrugerfladen (Servere og databaser). Her kan de også stå, fx (se docs/installation.md):
// { "Name": "prod-sql01", "Server": "sql01.domæne.local", "Authentication": "Windows", "Provider": "Auto", "Encryption": "Mandatory" }

View file

@ -25,6 +25,8 @@ public sealed class DocsService
("analyse", "Hvad skete der i et bestemt tidsrum: belastning, ventetider mod normalen, tungeste forespørgsler, blocking og deadlocks."),
("regler", "Skriv egne regler i SQL, og bidrag med community-regler."),
("servere-og-databaser", "Tilmeld servere i webbrugerfladen, vælg databaser, test forbindelsen og få GRANT-scriptet."),
("windows-servere", "CPU, hukommelse, diske med prognose, tjenester, hændelseslog og planlagte opgaver på Windows-servere – uden lokal administrator."),
("syntetiske-tjek", "TCP, HTTP(S), SQL-login, DNS og Dynamics AX-logon som en bruger: opdag en server, der ikke svarer, selv om tjenesten kører."),
("support", "Opret supportsager hos SEOSoft fra webbrugerfladen og følg deres status."),
("databehandling", "Hvilke data der behandles, hvor de ligger, og hvad der deles."),
("legacy-versions", "SQL Server 2000, 2005 og 2008: hvad der kan måles, og hvordan der forbindes."),

View file

@ -28,12 +28,15 @@ public static class RuleCatalog
["BLK"] = "Blocking og deadlocks",
["DLK"] = "Blocking og deadlocks",
["SEC"] = "Sikkerhed",
["WIN"] = "Windows-servere",
["SYN"] = "Syntetiske tjek",
};
public static IReadOnlyList<RuleEntry> All(IReadOnlyList<CustomRuleDefinition> community)
{
ArgumentNullException.ThrowIfNull(community);
IEnumerable<RuleEntry> builtIn = BuiltInRules.Create().Select(r => new RuleEntry(r.Id, r.Title, Area(r.Id), null, false));
IEnumerable<RuleEntry> builtIn = BuiltInRules.Create().Select(r => new RuleEntry(r.Id, r.Title, Area(r.Id), null, false))
.Concat(Rules.Servers.ServerRuleCatalog.All.Select(r => new RuleEntry(r.Id, r.Title, Area(r.Id), null, false)));
return builtIn.Concat(community.Select(r => new RuleEntry(r.Id, r.Title, Area(r.Id), r.Author, true))).ToList();
}

View file

@ -112,28 +112,10 @@ public sealed partial record CustomRuleDefinition
}
/// <summary>
/// The query must be a single read-only SELECT. This is a second line of defence: the agent's login has no write
/// permissions to begin with (grant-least-privilege.sql).
/// The query must be a single read-only SELECT (<see cref="ReadOnlyQuery"/>). This is a second line of defence: the
/// agent's login has no write permissions to begin with (grant-least-privilege.sql).
/// </summary>
public static IEnumerable<string> QueryProblems(string query)
{
ArgumentNullException.ThrowIfNull(query);
var code = StringsAndComments().Replace(query, " ");
if (!StartsWithSelect().IsMatch(code))
{
yield return "Forespørgslen skal starte med SELECT eller WITH.";
}
foreach (Match forbidden in ForbiddenKeywords().Matches(code))
{
yield return $"Forespørgslen må ikke indeholde {forbidden.Value.ToUpperInvariant()}: regler må kun læse.";
}
if (code.TrimEnd().TrimEnd(';').Contains(';', StringComparison.Ordinal))
{
yield return "Forespørgslen må kun være én sætning (intet semikolon undervejs).";
}
}
public static IEnumerable<string> QueryProblems(string query) => ReadOnlyQuery.Problems(query);
/// <summary>Replaces {column} with the row's value; unknown placeholders are left as they are.</summary>
public static string Fill(string template, IReadOnlyDictionary<string, string?> row)
@ -146,15 +128,6 @@ public sealed partial record CustomRuleDefinition
[GeneratedRegex(@"^(COM|LOCAL)-[A-Z]{2,10}-[0-9]{3}$")]
private static partial Regex IdPattern();
[GeneratedRegex(@"'(?:[^']|'')*'|\[[^\]]*\]|""[^""]*""|--[^\n]*|/\*.*?\*/", RegexOptions.Singleline)]
private static partial Regex StringsAndComments();
[GeneratedRegex(@"^\s*(SELECT|WITH)\b", RegexOptions.IgnoreCase)]
private static partial Regex StartsWithSelect();
[GeneratedRegex(@"\b(INSERT|UPDATE|DELETE|MERGE|DROP|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE|GRANT|REVOKE|DENY|SHUTDOWN|KILL|BACKUP|RESTORE|DBCC|RECONFIGURE|OPENROWSET|OPENDATASOURCE|OPENQUERY|INTO|WAITFOR|USE|BULK|SETUSER)\b|\b(sp|xp)_\w+", RegexOptions.IgnoreCase)]
private static partial Regex ForbiddenKeywords();
[GeneratedRegex(@"\{([A-Za-z0-9_ ]+)\}")]
private static partial Regex Placeholder();
}

View file

@ -0,0 +1,186 @@
using System.Text;
using System.Text.RegularExpressions;
namespace SQLInsight.Core.CustomRules;
/// <summary>
/// Whether a query someone typed (a custom rule, a synthetic SQL check) is one read-only SELECT. T-SQL is lexed the
/// way SQL Server reads it: nested block comments, <c>''</c> in strings, <c>]]</c> in [identifiers] and <c>""</c> in
/// "identifiers", so a keyword cannot hide in what only looks like a comment or a string. T-SQL needs no semicolon
/// between statements, so besides the writing keywords every statement keyword is refused, and a second SELECT is
/// only accepted after UNION, EXCEPT or INTERSECT.
/// This is a second line of defence: the login has no write permissions to begin with (grant-least-privilege.sql), and
/// a synthetic check also runs the query in a transaction that is always rolled back.
/// </summary>
public static partial class ReadOnlyQuery
{
public static IEnumerable<string> Problems(string query)
{
ArgumentNullException.ThrowIfNull(query);
var (code, unterminated) = Code(query);
if (unterminated is not null)
{
yield return $"Forespørgslen har {unterminated}, der ikke er afsluttet.";
yield break;
}
if (!StartsWithSelect().IsMatch(code))
{
yield return "Forespørgslen skal starte med SELECT eller WITH.";
}
foreach (var forbidden in ForbiddenKeywords().Matches(code).Select(m => Regex.Replace(m.Value.ToUpperInvariant(), @"\s+", " ")).Distinct(StringComparer.Ordinal))
{
yield return $"Forespørgslen må ikke indeholde {forbidden}: forespørgslen må kun læse (sæt [ ] om et kolonnenavn, der hedder sådan).";
}
if (code.TrimEnd().TrimEnd(';').Contains(';', StringComparison.Ordinal) || !OneStatement(code))
{
yield return "Forespørgslen må kun være én sætning (intet semikolon undervejs, og flere SELECT kun med UNION, EXCEPT eller INTERSECT).";
}
}
/// <summary>
/// The query with comments, strings and quoted identifiers replaced by a space, and what was left open (a comment,
/// a string, an identifier) or null.
/// </summary>
internal static (string Code, string? Unterminated) Code(string query)
{
var code = new StringBuilder(query.Length);
var i = 0;
while (i < query.Length)
{
var c = query[i];
var next = i + 1 < query.Length ? query[i + 1] : '\0';
if (c == '-' && next == '-')
{
var end = query.IndexOf('\n', i);
i = end < 0 ? query.Length : end;
code.Append(' ');
}
else if (c == '/' && next == '*')
{
// SQL Server nests block comments: /* /* */ is still inside the comment.
var depth = 0;
do
{
if (i + 1 < query.Length && query[i] == '/' && query[i + 1] == '*')
{
depth++;
i += 2;
}
else if (i + 1 < query.Length && query[i] == '*' && query[i + 1] == '/')
{
depth--;
i += 2;
}
else
{
i++;
}
}
while (depth > 0 && i < query.Length);
if (depth > 0)
{
return (code.ToString(), "en kommentar (/* … */)");
}
code.Append(' ');
}
else if (c is '\'' or '[' or '"')
{
var close = c == '[' ? ']' : c;
var j = i + 1;
while (true)
{
j = query.IndexOf(close, j);
if (j < 0)
{
return (code.ToString(), c == '\'' ? "en tekst ('…')" : "et navn i " + c + close);
}
if (j + 1 < query.Length && query[j + 1] == close)
{
j += 2; // '' or ]] or "": the quote itself, still inside
continue;
}
break;
}
i = j + 1;
code.Append(' ');
}
else
{
code.Append(c);
i++;
}
}
return (code.ToString(), null);
}
/// <summary>
/// One statement: the SELECTs outside parentheses are the query's own SELECT (for WITH, the one right after the
/// common table expressions) and those joined to it by UNION, EXCEPT or INTERSECT. "SELECT 1 SELECT 2" is two.
/// </summary>
private static bool OneStatement(string code)
{
var depth = 0;
var mainSeen = false;
var startsWithWith = StartsWithWith().IsMatch(code);
string? previous = null;
foreach (Match token in Tokens().Matches(code))
{
var value = token.Value;
if (value == "(")
{
depth++;
}
else if (value == ")")
{
depth = Math.Max(0, depth - 1);
}
else if (depth == 0 && value.Equals("SELECT", StringComparison.OrdinalIgnoreCase))
{
var joined = previous is not null && (previous.Equals("UNION", StringComparison.OrdinalIgnoreCase) || previous.Equals("ALL", StringComparison.OrdinalIgnoreCase)
|| previous.Equals("EXCEPT", StringComparison.OrdinalIgnoreCase) || previous.Equals("INTERSECT", StringComparison.OrdinalIgnoreCase));
var first = !mainSeen && (startsWithWith ? previous == ")" : previous is null);
if (!joined && !first)
{
return false;
}
mainSeen = true;
}
previous = value;
}
return true;
}
[GeneratedRegex(@"^\s*(SELECT|WITH)\b", RegexOptions.IgnoreCase)]
private static partial Regex StartsWithSelect();
[GeneratedRegex(@"^\s*WITH\b", RegexOptions.IgnoreCase)]
private static partial Regex StartsWithWith();
[GeneratedRegex(@"[()]|[A-Za-z_@#][A-Za-z0-9_@#$]*|[^\sA-Za-z_@#()]")]
private static partial Regex Tokens();
/// <summary>
/// Writing keywords, every other statement keyword (T-SQL needs no semicolon between statements), sequences
/// (NEXT VALUE FOR is not rolled back) and table hints that take update or exclusive locks.
/// </summary>
[GeneratedRegex(
@"\b(INSERT|UPDATE|DELETE|MERGE|DROP|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE|GRANT|REVOKE|DENY|SHUTDOWN|KILL|BACKUP|RESTORE|DBCC|RECONFIGURE"
+ @"|OPENROWSET|OPENDATASOURCE|OPENQUERY|INTO|WAITFOR|USE|BULK|SETUSER"
+ @"|WRITETEXT|UPDATETEXT|READTEXT|CHECKPOINT|COMMIT|ROLLBACK|SAVE|BEGIN|TRAN|TRANSACTION|DECLARE|SET|PRINT|RAISERROR|THROW|IF|WHILE|GOTO|RETURN"
+ @"|BREAK|CONTINUE|OPEN|CLOSE|DEALLOCATE|REVERT|ADD|ENABLE|DISABLE|RECEIVE|SEND|CONVERSATION|DIALOG|LOAD|DUMP"
+ @"|UPDLOCK|XLOCK|TABLOCK|TABLOCKX|HOLDLOCK|PAGLOCK|SERIALIZABLE|REPEATABLEREAD)\b|\bNEXT\s+VALUE\s+FOR\b|\b(sp|xp)_\w+",
RegexOptions.IgnoreCase)]
private static partial Regex ForbiddenKeywords();
}

View file

@ -7,5 +7,7 @@
<EmbeddedResource Include="..\..\db\permissions\grant-least-privilege.sql" LogicalName="SQLInsight.Core.grant-least-privilege.sql" />
<!-- The optional Dynamics AX read access, shown under Dynamics AX → AX-databaser (DynamicsAx/AxReadAccess.cs). -->
<EmbeddedResource Include="..\..\db\permissions\grant-dynamics-ax-read.sql" LogicalName="SQLInsight.Core.grant-dynamics-ax-read.sql" />
<!-- Shown in SQLInsight Web for a Windows server (Servers/ServerGrantScript.cs). -->
<EmbeddedResource Include="..\..\db\permissions\grant-windows-server.ps1" LogicalName="SQLInsight.Core.grant-windows-server.ps1" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,153 @@
using System.Globalization;
namespace SQLInsight.Core.Servers;
/// <summary>A volume's free space at one time, from SQLInsight's own samples.</summary>
public sealed record DiskHistoryPoint(DateTimeOffset At, double SizeGb, double FreeGb);
/// <summary>When a volume will be full if it keeps filling at the rate of the last weeks.</summary>
/// <param name="DaysUntilFull">
/// Counted from when the forecast was made (the disk snapshot's CapturedAt, which <see cref="FullAt"/> takes). Null when
/// the volume is not filling up, the trend is too unclear to say, or there is too little history.
/// </param>
/// <param name="GrowthGbPerDay">How fast the used space grows (positive: filling up).</param>
/// <param name="Fit">R² of the trend line: 1 is a straight line, near 0 no trend.</param>
/// <param name="Note">Why there is no forecast (Danish), when there is none.</param>
/// <param name="EnoughHistory">
/// Whether there was enough history to judge the trend at all. False: the volume could not be judged yet, so a rule
/// must say "ikke målbar" rather than "fyldes ikke op".
/// </param>
public sealed record DiskForecast(string Volume, double? DaysUntilFull, double GrowthGbPerDay, int Samples, double HistoryDays, double Fit, string? Note = null,
bool EnoughHistory = true)
{
/// <param name="madeAt">When the forecast was made (the disk snapshot's CapturedAt).</param>
public DateTimeOffset? FullAt(DateTimeOffset madeAt) => DaysUntilFull is { } days ? madeAt.AddDays(days) : null;
/// <summary>"fuld om ca. 12 dage" (Danish), or the note.</summary>
public string Describe() => DaysUntilFull switch
{
null => Note ?? "fyldes ikke op",
<= 0 => "fuld nu",
< 1 => "fuld inden for et døgn",
{ } d => string.Create(CultureInfo.InvariantCulture, $"fuld om ca. {Math.Round(d):0} dage"),
};
}
public sealed record DiskForecastSettings
{
/// <summary>How far back the trend is computed.</summary>
public int HistoryDays { get; init; } = 14;
/// <summary>Less history than this gives no forecast: a few hours say nothing about weeks.</summary>
public double MinimumHistoryDays { get; init; } = 2;
public int MinimumSamples { get; init; } = 12;
/// <summary>R² below this: the free space jumps up and down (backups written and deleted …) and a straight line says nothing.</summary>
public double MinimumFit { get; init; } = 0.5;
/// <summary>A forecast further away than this is not one.</summary>
public double MaximumDays { get; init; } = 365;
/// <summary>Growth slower than this (GB per day) is noise.</summary>
public double MinimumGrowthGbPerDay { get; init; } = 0.01;
/// <summary>What one history point stands for: the repository averages the samples per clock hour, stamped at the hour's start.</summary>
public TimeSpan Resolution { get; init; } = TimeSpan.FromHours(1);
}
/// <summary>
/// "Disken er fuld om ca. N dage": a least-squares line through the free space of the last two weeks. Only the history
/// since the volume last changed size counts (an extended disk starts a new trend). The line must fit reasonably well
/// (R²), cover at least a couple of days, and point down; otherwise there is no forecast, rather than a scary guess.
/// </summary>
public static class DiskForecaster
{
public static DiskForecast Forecast(string volume, IReadOnlyList<DiskHistoryPoint> history, DateTimeOffset now, DiskForecastSettings? settings = null)
{
ArgumentNullException.ThrowIfNull(volume);
ArgumentNullException.ThrowIfNull(history);
settings ??= new DiskForecastSettings();
var points = history.Where(p => p.At <= now && p.At >= now.AddDays(-settings.HistoryDays) && p.SizeGb > 0)
.OrderBy(p => p.At).ToList();
if (points.Count == 0)
{
return new DiskForecast(volume, null, 0, 0, 0, 0, "ingen målinger endnu", EnoughHistory: false);
}
// A volume that was extended (or shrunk) starts a new trend.
var latestSize = points[^1].SizeGb;
var start = points.FindLastIndex(p => Math.Abs(p.SizeGb - latestSize) > Math.Max(0.5, latestSize * 0.01)) + 1;
points = points.Skip(start).ToList();
var span = (points[^1].At - points[0].At).TotalDays;
if (points.Count < settings.MinimumSamples || span < settings.MinimumHistoryDays)
{
return new DiskForecast(volume, null, 0, points.Count, span, 0,
string.Create(CultureInfo.InvariantCulture, $"for lidt historik til en prognose ({span:0.#} af {settings.MinimumHistoryDays:0.#} dage)"),
EnoughHistory: false);
}
var (slope, fit) = Regression(points);
var growth = -slope;
var free = points[^1].FreeGb;
// Counted from now, not from the last point, when that is older than its own resolution (the agent could not reach
// the server for a while). A point stands for the period that starts at its time (the repository's hourly averages).
var sinceLastSample = Math.Max(0, (now - points[^1].At - settings.Resolution).TotalDays);
if (free <= 0)
{
return new DiskForecast(volume, 0, growth, points.Count, span, fit);
}
if (growth < settings.MinimumGrowthGbPerDay)
{
return new DiskForecast(volume, null, growth, points.Count, span, fit, "fyldes ikke op");
}
if (fit < settings.MinimumFit)
{
return new DiskForecast(volume, null, growth, points.Count, span, fit, "ingen tydelig tendens (pladsen svinger)");
}
var days = Math.Max(0, (free / growth) - sinceLastSample);
return days > settings.MaximumDays
? new DiskForecast(volume, null, growth, points.Count, span, fit, "fuld om mere end et år")
: new DiskForecast(volume, days, growth, points.Count, span, fit);
}
/// <summary>Slope (GB per day) and R² of free space over time.</summary>
public static (double Slope, double Fit) Regression(IReadOnlyList<DiskHistoryPoint> points)
{
ArgumentNullException.ThrowIfNull(points);
if (points.Count < 2)
{
return (0, 0);
}
var origin = points[0].At;
var xs = points.Select(p => (p.At - origin).TotalDays).ToArray();
var ys = points.Select(p => p.FreeGb).ToArray();
var meanX = xs.Average();
var meanY = ys.Average();
double sxy = 0, sxx = 0, syy = 0;
for (var i = 0; i < xs.Length; i++)
{
var dx = xs[i] - meanX;
var dy = ys[i] - meanY;
sxy += dx * dy;
sxx += dx * dx;
syy += dy * dy;
}
if (sxx <= 0)
{
return (0, 0);
}
var slope = sxy / sxx;
// A perfectly flat line explains everything there is to explain.
var fit = syy <= 1e-12 ? 1 : (sxy * sxy) / (sxx * syy);
return (slope, fit);
}
}

View file

@ -0,0 +1,162 @@
using System.Globalization;
using System.Net;
using System.Text.RegularExpressions;
namespace SQLInsight.Core.Servers;
/// <summary>What a Windows server is used for: shown in the UI and used to pick sensible defaults (e.g. the AOS service).</summary>
public static class ServerRoles
{
public const string Sql = "Sql";
public const string Application = "Application";
public const string File = "File";
public const string Other = "Other";
public static readonly IReadOnlyList<string> All = [Sql, Application, File, Other];
public static bool IsKnown(string? role) => role is not null && All.Contains(role, StringComparer.Ordinal);
public static string Display(string? role) => role switch
{
Sql => "SQL Server",
Application => "Applikationsserver (AOS)",
File => "Filserver",
_ => "Anden server",
};
}
/// <summary>Where a Windows server in insight.WindowsServers comes from.</summary>
public static class ServerSources
{
/// <summary>Added in SQLInsight Web or API v1.</summary>
public const string Web = "Web";
/// <summary>The computer behind a monitored SQL Server instance: added and removed by the agent.</summary>
public const string SqlHost = "SqlHost";
}
/// <summary>How a Windows server is addressed: a computer name, a DNS name or an IP address. No instance, no port.</summary>
public static partial class ServerHost
{
public const int MaxLength = 255;
/// <summary>Why <paramref name="host"/> cannot be used (Danish), or null when it can.</summary>
public static string? Validate(string? host)
{
if (string.IsNullOrWhiteSpace(host))
{
return "Angiv serverens navn, fx aos01 eller fil01.firma.local.";
}
var trimmed = host.Trim();
if (trimmed.Length > MaxLength)
{
return $"Servernavnet må højst være {MaxLength} tegn.";
}
if (IPAddress.TryParse(trimmed, out _) && trimmed.Contains(':', StringComparison.Ordinal))
{
return null;
}
return HostPattern().IsMatch(trimmed)
? null
: "Servernavnet må kun indeholde bogstaver, tal, punktum og bindestreg (fx aos01.firma.local) – uden instansnavn og port.";
}
/// <summary>The form hosts are compared and stored in: trimmed, lower case.</summary>
public static string Normalize(string host)
{
ArgumentNullException.ThrowIfNull(host);
return host.Trim().TrimEnd('.').ToLowerInvariant();
}
/// <summary>The short computer name of a host (aos01.firma.local → AOS01); an IP address stays as it is.</summary>
public static string ShortName(string host)
{
ArgumentNullException.ThrowIfNull(host);
var trimmed = host.Trim();
return IPAddress.TryParse(trimmed, out _) ? trimmed : trimmed.Split('.')[0].ToUpperInvariant();
}
/// <summary>
/// The computer behind a SQL Server data source (<c>tcp:sql01.firma.local\PROD,1433</c> → <c>sql01.firma.local</c>),
/// or why it is not a Windows server the agent can look at (Danish). Aliases for the local computer become
/// <paramref name="localMachineName"/>.
/// </summary>
public static (string? Host, string? Skipped) FromDataSource(string dataSource, string localMachineName)
{
ArgumentNullException.ThrowIfNull(dataSource);
ArgumentException.ThrowIfNullOrWhiteSpace(localMachineName);
var host = ProtocolPrefix().Replace(dataSource.Trim(), string.Empty).Split('\\', ',')[0].Trim();
if (host.Length == 0 || host is "." or "(local)" || host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || host is "127.0.0.1" or "::1")
{
return (Normalize(localMachineName), null);
}
if (host.StartsWith("(localdb)", StringComparison.OrdinalIgnoreCase))
{
return (null, "LocalDB er ikke en server.");
}
if (IsAzure(host))
{
return (null, "Azure SQL Database og Managed Instance har ingen Windows-server, SQLInsight kan se.");
}
return Validate(host) is null ? (Normalize(host), null) : (null, "Servernavnet kan ikke bruges som Windows-server.");
}
public static bool IsAzure(string host)
{
ArgumentNullException.ThrowIfNull(host);
var h = host.Trim().TrimEnd('.');
return h.EndsWith(".database.windows.net", StringComparison.OrdinalIgnoreCase)
|| h.EndsWith(".database.azure.com", StringComparison.OrdinalIgnoreCase)
|| h.EndsWith(".database.usgovcloudapi.net", StringComparison.OrdinalIgnoreCase)
|| h.EndsWith(".sql.azuresynapse.net", StringComparison.OrdinalIgnoreCase);
}
/// <summary>Whether <paramref name="host"/> is the agent's own computer (then collection needs no network).</summary>
public static bool IsLocal(string host, string machineName)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(machineName);
var h = host.Trim();
return h is "." or "127.0.0.1" or "::1"
|| h.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|| h.Equals(machineName, StringComparison.OrdinalIgnoreCase)
|| h.StartsWith(machineName + ".", StringComparison.OrdinalIgnoreCase);
}
/// <summary>A comma- or line-separated list of service or task names, trimmed and without duplicates.</summary>
public static IReadOnlyList<string> ParseList(string? text) =>
string.IsNullOrWhiteSpace(text)
? []
: text.Split([',', ';', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
/// <summary>Why a list of service names cannot be used (Danish), or null.</summary>
public static string? ValidateServiceNames(IEnumerable<string> names)
{
ArgumentNullException.ThrowIfNull(names);
var list = names.ToList();
if (list.Count > 100)
{
return "Højst 100 tjenester i en liste.";
}
return list.FirstOrDefault(n => n.Length > 256 || !ServiceNamePattern().IsMatch(n)) is { } bad
? string.Create(CultureInfo.InvariantCulture, $"\"{bad}\" er ikke et gyldigt tjenestenavn (brug tjenestens navn, fx MSSQLSERVER eller AOS60$01).")
: null;
}
[GeneratedRegex(@"^[A-Za-z0-9]([A-Za-z0-9\-]{0,62})(\.[A-Za-z0-9]([A-Za-z0-9\-]{0,62}))*\.?$")]
private static partial Regex HostPattern();
[GeneratedRegex(@"^(tcp|np|lpc|admin):", RegexOptions.IgnoreCase)]
private static partial Regex ProtocolPrefix();
[GeneratedRegex(@"^[^\\/""\x00-\x1f]+$")]
private static partial Regex ServiceNamePattern();
}

View file

@ -0,0 +1,274 @@
namespace SQLInsight.Core.Servers;
/// <summary>What kind of Windows event this is, for the rules. Ordered by how much it matters (most last).</summary>
public enum ServerEventCategory
{
/// <summary>An error with nothing special about it: only reported when it repeats.</summary>
Error,
/// <summary>An application or the AOS crashed or hung (Application Error 1000, .NET Runtime 1026 …).</summary>
ApplicationCrash,
/// <summary>Errors from Dynamics AX's Application Object Server.</summary>
DynamicsAx,
/// <summary>A service stopped unexpectedly or could not start (Service Control Manager 7031, 7034, 7000 …).</summary>
ServiceFailure,
/// <summary>Level Critical with no more specific category.</summary>
Critical,
/// <summary>Out of memory or another resource (srv 2019/2020, Resource-Exhaustion-Detector 2004).</summary>
Resources,
/// <summary>Disk, controller or file system errors (disk 7/11/51/153, storport 129, Ntfs 55/98).</summary>
Disk,
/// <summary>The server went down without being shut down (Kernel-Power 41, EventLog 6008, a blue screen).</summary>
UnexpectedShutdown,
}
/// <summary>An event from the System or Application log. Level: 1 critical, 2 error.</summary>
/// <param name="Message">Can name users, computers and paths: sensitive.</param>
public sealed record ServerEventEntry(string Log, string Source, long EventId, int Level, DateTimeOffset At, string? Message = null, long? RecordId = null);
/// <summary>The events of one group within one clock hour: what the count of the last 24 hours is summed from.</summary>
public sealed record ServerEventBucket(DateTimeOffset FirstAt, DateTimeOffset LastAt, int Count);
/// <summary>The same event repeated, summed: one line per log, source and event id.</summary>
/// <param name="Count">In <see cref="ServerEventSnapshot.Recent"/>: the events of the last 24 hours (to the hour), not since first seen.</param>
public sealed record ServerEventGroup(string Log, string Source, long EventId, int Level, ServerEventCategory Category, int Count, DateTimeOffset FirstAt,
DateTimeOffset LastAt, string? Sample)
{
public string Key => Log + "/" + Source + "/" + EventId.ToString(System.Globalization.CultureInfo.InvariantCulture);
/// <summary>The counts per clock hour (at most 25 in <see cref="ServerEventSnapshot.Recent"/>), so events that fall out of the window stop counting.</summary>
public IReadOnlyList<ServerEventBucket> Buckets { get; init; } = [];
}
public sealed record ServerEventSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>The newest event read; the next collection continues after it.</summary>
public required DateTimeOffset ReadUntil { get; init; }
/// <summary>Per log, where the next collection continues (a log that had more than could be read continues from its own last event).</summary>
public IReadOnlyDictionary<string, DateTimeOffset>? LogReadUntil { get; init; }
/// <summary>How the log was read: "Event Log (RPC)" or "CIM (Win32_NTLogEvent)".</summary>
public string? Method { get; init; }
/// <summary>What was found since the previous collection, grouped (noise left out).</summary>
public IReadOnlyList<ServerEventGroup> Groups { get; init; } = [];
/// <summary>The groups of the last <see cref="ServerEventAnalysis.RecentWindow"/>: they keep findings open.</summary>
public IReadOnlyList<ServerEventGroup> Recent { get; init; } = [];
/// <summary>Events left out as known noise in this collection.</summary>
public int Ignored { get; init; }
/// <summary>Why less is seen than the logs have (Danish), e.g. the collection was capped.</summary>
public string? Coverage { get; init; }
}
/// <summary>
/// Windows' event logs, summed like SQL Server's error log (1.2): known noise is left out, the rest is grouped per
/// source and event id, and a group keeps its finding open for a day after it was last seen.
/// </summary>
public static class ServerEventAnalysis
{
public static readonly TimeSpan RecentWindow = TimeSpan.FromHours(24);
/// <summary>The logs that are read.</summary>
public static readonly IReadOnlyList<string> Logs = ["System", "Application"];
/// <summary>
/// Noise left out unless SQLInsight:Servers:EventLog:Ignore says otherwise: well-known events that are errors in name only,
/// and SQL Server's own events, which the SQL Server error log monitoring already covers. "Kilde:id", "Kilde" or "Kilde*".
/// </summary>
public static readonly IReadOnlyList<string> DefaultIgnore =
[
"Microsoft-Windows-DistributedCOM:10016",
"DCOM:10016",
"Microsoft-Windows-DistributedCOM:10010",
"DCOM:10010",
"Schannel:36874",
"Schannel:36887",
"Schannel:36888",
"Microsoft-Windows-TPM-WMI:1796",
"Microsoft-Windows-TPM-WMI:1801",
"Microsoft-Windows-WMI:10",
"Microsoft-Windows-Security-SPP:8198",
"MSSQLSERVER",
"MSSQL$*",
"SQLSERVERAGENT",
"SQLAgent$*",
"SQLISPackage*",
];
public static ServerEventCategory Categorize(ServerEventEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
var source = entry.Source;
bool Is(params string[] names) => names.Any(n => source.Equals(n, StringComparison.OrdinalIgnoreCase));
return entry.EventId switch
{
41 when Is("Microsoft-Windows-Kernel-Power") => ServerEventCategory.UnexpectedShutdown,
6008 when Is("EventLog") => ServerEventCategory.UnexpectedShutdown,
1001 when Is("BugCheck", "Microsoft-Windows-WER-SystemErrorReporting") => ServerEventCategory.UnexpectedShutdown,
7 or 11 or 51 or 153 when Is("disk", "Disk") => ServerEventCategory.Disk,
129 when source.Contains("stor", StringComparison.OrdinalIgnoreCase) || Is("iaStorA", "LSI_SAS", "vhdmp") => ServerEventCategory.Disk,
55 or 98 or 137 when Is("Ntfs", "Microsoft-Windows-Ntfs") => ServerEventCategory.Disk,
157 when Is("disk", "Disk") => ServerEventCategory.Disk,
2019 or 2020 when Is("Srv") => ServerEventCategory.Resources,
2004 when Is("Microsoft-Windows-Resource-Exhaustion-Detector") => ServerEventCategory.Resources,
7000 or 7001 or 7009 or 7011 or 7022 or 7023 or 7024 or 7031 or 7032 or 7034 when Is("Service Control Manager") => ServerEventCategory.ServiceFailure,
1000 or 1002 when Is("Application Error", "Application Hang") => ServerEventCategory.ApplicationCrash,
1026 when Is(".NET Runtime") => ServerEventCategory.ApplicationCrash,
_ when source.StartsWith("Microsoft Dynamics AX", StringComparison.OrdinalIgnoreCase)
|| source.StartsWith("Dynamics Server", StringComparison.OrdinalIgnoreCase)
|| source.StartsWith("Dynamics AX", StringComparison.OrdinalIgnoreCase) => ServerEventCategory.DynamicsAx,
_ when entry.Level == 1 => ServerEventCategory.Critical,
_ => ServerEventCategory.Error,
};
}
/// <summary>Whether <paramref name="entry"/> matches an ignore entry ("Kilde:id", "Kilde", "Kilde*", "Kilde*:id").</summary>
public static bool IsIgnored(ServerEventEntry entry, IEnumerable<string> ignore)
{
ArgumentNullException.ThrowIfNull(entry);
ArgumentNullException.ThrowIfNull(ignore);
foreach (var raw in ignore)
{
var item = raw?.Trim();
if (string.IsNullOrEmpty(item))
{
continue;
}
var colon = item.LastIndexOf(':');
var source = colon > 0 ? item[..colon] : item;
var id = colon > 0 ? item[(colon + 1)..] : null;
var sourceMatches = source.EndsWith('*')
? entry.Source.StartsWith(source[..^1], StringComparison.OrdinalIgnoreCase)
: entry.Source.Equals(source, StringComparison.OrdinalIgnoreCase);
if (sourceMatches && (id is null or "*" || long.TryParse(id, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var number) && number == entry.EventId))
{
return true;
}
}
return false;
}
/// <summary>Sums repeated events: one group per log, source and event id, with the newest message as a sample. Noise is counted, not grouped.</summary>
public static (IReadOnlyList<ServerEventGroup> Groups, int Ignored) Group(IEnumerable<ServerEventEntry> entries, IEnumerable<string> ignore)
{
ArgumentNullException.ThrowIfNull(entries);
ArgumentNullException.ThrowIfNull(ignore);
var ignoreList = ignore.ToList();
var ignored = 0;
var kept = new List<ServerEventEntry>();
foreach (var entry in entries.Where(e => e.Level is 1 or 2))
{
if (IsIgnored(entry, ignoreList))
{
ignored++;
}
else
{
kept.Add(entry);
}
}
var groups = kept
.GroupBy(e => (Log: e.Log.ToUpperInvariant(), Source: e.Source.ToUpperInvariant(), e.EventId))
.Select(g =>
{
var newest = g.OrderByDescending(e => e.At).First();
return new ServerEventGroup(newest.Log, newest.Source, newest.EventId, g.Min(e => e.Level), Categorize(newest), g.Count(),
g.Min(e => e.At), g.Max(e => e.At), Cut(g.OrderByDescending(e => e.At).Select(e => e.Message).FirstOrDefault(m => !string.IsNullOrWhiteSpace(m))))
{
Buckets = g.GroupBy(e => Hour(e.At)).Select(h => new ServerEventBucket(h.Min(e => e.At), h.Max(e => e.At), h.Count())).OrderBy(b => b.FirstAt).ToList(),
};
})
.OrderByDescending(g => g.Category).ThenByDescending(g => g.Count)
.ToList();
return (groups, ignored);
}
/// <summary>
/// The groups of the last day: this collection's plus the earlier ones, merged. Counts are kept per clock hour, and
/// only the hours with events in the last <see cref="RecentWindow"/> are summed, so an error that recurs every ten
/// hours never adds up to more than the few times it was actually seen in a day.
/// </summary>
public static IReadOnlyList<ServerEventGroup> Recent(IReadOnlyList<ServerEventGroup> previous, IReadOnlyList<ServerEventGroup> current, DateTimeOffset now)
{
ArgumentNullException.ThrowIfNull(previous);
ArgumentNullException.ThrowIfNull(current);
var since = now - RecentWindow;
return previous.Concat(current)
.GroupBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
.Select(ServerEventGroup? (g) =>
{
var buckets = g.SelectMany(BucketsOf)
.Where(b => b.LastAt > since)
.GroupBy(b => Hour(b.FirstAt))
.Select(h => new ServerEventBucket(h.Min(b => b.FirstAt), h.Max(b => b.LastAt), h.Sum(b => b.Count)))
.OrderBy(b => b.FirstAt)
.ToList();
if (buckets.Count == 0)
{
return null;
}
var newest = g.OrderByDescending(x => x.LastAt).First();
return newest with
{
Level = g.Where(x => x.LastAt > since).Min(x => x.Level),
Count = buckets.Sum(b => b.Count),
FirstAt = buckets[0].FirstAt,
LastAt = buckets.Max(b => b.LastAt),
Sample = g.OrderByDescending(x => x.LastAt).Select(x => x.Sample).FirstOrDefault(s => s is not null),
Buckets = buckets,
};
})
.OfType<ServerEventGroup>()
.OrderByDescending(g => g.Category).ThenByDescending(g => g.Count)
.ToList();
}
/// <summary>A group saved before the counts were kept per hour has one bucket for all of it.</summary>
private static IReadOnlyList<ServerEventBucket> BucketsOf(ServerEventGroup group) =>
group.Buckets is { Count: > 0 } buckets ? buckets : [new ServerEventBucket(group.FirstAt, group.LastAt, group.Count)];
private static DateTimeOffset Hour(DateTimeOffset at)
{
var ticks = at.UtcTicks;
return new DateTimeOffset(ticks - (ticks % TimeSpan.TicksPerHour), TimeSpan.Zero);
}
public static string Display(ServerEventCategory category) => category switch
{
ServerEventCategory.UnexpectedShutdown => "Uventet nedlukning",
ServerEventCategory.Disk => "Disk og filsystem",
ServerEventCategory.Resources => "Ressourcer opbrugt",
ServerEventCategory.Critical => "Kritisk hændelse",
ServerEventCategory.ServiceFailure => "Tjeneste stoppet eller startede ikke",
ServerEventCategory.DynamicsAx => "Dynamics AX (AOS)",
ServerEventCategory.ApplicationCrash => "Program gik ned",
_ => "Fejl",
};
private static string? Cut(string? text)
{
if (text is null)
{
return null;
}
var trimmed = text.Trim();
return trimmed.Length > 500 ? trimmed[..500] : trimmed;
}
}

View file

@ -0,0 +1,71 @@
using System.Text.RegularExpressions;
namespace SQLInsight.Core.Servers;
/// <summary>
/// db/permissions/grant-windows-server.ps1, ready to run for one account: what SQLInsight Web shows for a Windows
/// server. SQLInsight never runs it itself; an administrator on the server does.
/// </summary>
public static partial class ServerGrantScript
{
/// <summary>
/// The script with <paramref name="account"/> as the default for -Account. The account comes from what the agent
/// reports, so it is put in a PowerShell single-quoted string safely: ' is doubled, and an account with a control
/// character or a typographic single quote (which PowerShell also ends a string at) is not filled in at all; the
/// script is then returned with its placeholder, which it refuses to run with.
/// </summary>
public static string For(string account)
{
ArgumentException.ThrowIfNullOrWhiteSpace(account);
var template = Template();
if (!IsSafeAccount(account))
{
return template;
}
var literal = "'" + account.Trim().Replace("'", "''", StringComparison.Ordinal) + "'";
if (!AccountPlaceholder().IsMatch(template))
{
throw new InvalidOperationException("grant-windows-server.ps1 har ikke linjen med -Account, der skal udfyldes.");
}
return AccountPlaceholder().Replace(template, _ => "[string] $Account = " + literal + ", # <== agentens konto", 1);
}
/// <summary>Whether <paramref name="account"/> can be filled in (see <see cref="For"/>).</summary>
public static bool IsSafeAccount(string? account) =>
!string.IsNullOrWhiteSpace(account) && account.Length <= 256 && !account.Any(c => char.IsControl(c) || c is '‘' or '’' or '‚' or '‛');
/// <summary>The script as shipped, with the placeholder account.</summary>
public static string Template()
{
using var stream = typeof(ServerGrantScript).Assembly.GetManifestResourceStream("SQLInsight.Core.grant-windows-server.ps1")
?? throw new InvalidOperationException("grant-windows-server.ps1 mangler i programmet.");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
/// <summary>
/// The account the agent arrives as on <paramref name="host"/>: a virtual or built-in machine account becomes the
/// computer account DOMAIN\COMPUTER$ on any other server; a domain account or gMSA is the same everywhere.
/// </summary>
public static string AccountFor(string serviceAccount, string host, string machineName, string domain)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serviceAccount);
ArgumentNullException.ThrowIfNull(host);
ArgumentException.ThrowIfNullOrWhiteSpace(machineName);
var account = serviceAccount.Trim();
if (account.Equals("LocalSystem", StringComparison.OrdinalIgnoreCase))
{
account = @"NT AUTHORITY\SYSTEM";
}
var machineIdentity = account.StartsWith(@"NT SERVICE\", StringComparison.OrdinalIgnoreCase) || account.StartsWith(@"NT AUTHORITY\", StringComparison.OrdinalIgnoreCase);
return machineIdentity && !ServerHost.IsLocal(host, machineName)
? $@"{(string.IsNullOrWhiteSpace(domain) ? machineName : domain)}\{machineName}$"
: account;
}
[GeneratedRegex(@"\[string\] \$Account = '[^']*',[^\r\n]*")]
private static partial Regex AccountPlaceholder();
}

View file

@ -0,0 +1,85 @@
namespace SQLInsight.Core.Servers;
/// <summary>Rates from two raw counter samples, as Performance Monitor computes them.</summary>
public static class ServerPerformanceMath
{
/// <summary>How long the per-minute samples are kept in memory for the rules.</summary>
public static readonly TimeSpan Window = TimeSpan.FromHours(1);
/// <summary>
/// The interval's sample. CPU and pages/s need the previous raw sample; without it (or after a reboot) they are null.
/// With <paramref name="interval"/> they are also null when the previous sample is more than three intervals old (the
/// agent could not reach the server for a while): the rate would be the average of the whole gap, stamped now.
/// </summary>
public static ServerPerformanceSample Sample(ServerPerformanceRaw? previous, ServerPerformanceRaw current, ServerOsInfo os, TimeSpan? interval = null)
{
ArgumentNullException.ThrowIfNull(current);
ArgumentNullException.ThrowIfNull(os);
if (previous is not null && interval is { } expected && expected > TimeSpan.Zero && current.CapturedAt - previous.CapturedAt > 3 * expected)
{
previous = null;
}
return new ServerPerformanceSample(
current.CapturedAt,
Cpu(previous, current),
current.AvailableMb,
os.TotalMemoryMb,
PagesPerSecond(previous, current),
current.CommittedBytes is { } committed && current.CommitLimit is { } limit and > 0 ? Math.Clamp(100.0 * committed / limit, 0, 100) : null);
}
/// <summary>
/// PERF_100NSEC_TIMER_INV: 100 × (1 − Δidle / Δtime). The counter is the processors' idle time (averaged over all
/// of them for _Total), so the result is the average CPU over the interval.
/// </summary>
public static double? Cpu(ServerPerformanceRaw? previous, ServerPerformanceRaw current)
{
ArgumentNullException.ThrowIfNull(current);
if (previous?.ProcessorIdle100Ns is not { } idle0 || previous.ProcessorTimestamp100Ns is not { } time0
|| current.ProcessorIdle100Ns is not { } idle1 || current.ProcessorTimestamp100Ns is not { } time1
|| time1 <= time0 || idle1 < idle0)
{
return null;
}
var busy = 100 * (1 - ((double)(idle1 - idle0) / (time1 - time0)));
return Math.Clamp(busy, 0, 100);
}
/// <summary>PERF_COUNTER_COUNTER: Δcount / (Δtime / frequency).</summary>
public static double? PagesPerSecond(ServerPerformanceRaw? previous, ServerPerformanceRaw current)
{
ArgumentNullException.ThrowIfNull(current);
if (previous?.Pages is not { } pages0 || previous.MemoryPerfTime is not { } time0
|| current.Pages is not { } pages1 || current.MemoryPerfTime is not { } time1 || current.MemoryPerfFrequency is not ({ } frequency and > 0)
|| time1 <= time0 || pages1 < pages0)
{
return null;
}
return (pages1 - pages0) / ((double)(time1 - time0) / frequency);
}
/// <summary>The window's samples after adding <paramref name="sample"/>; older ones fall out.</summary>
public static IReadOnlyList<ServerPerformanceSample> Append(IReadOnlyList<ServerPerformanceSample> samples, ServerPerformanceSample sample)
{
ArgumentNullException.ThrowIfNull(samples);
ArgumentNullException.ThrowIfNull(sample);
return samples.Where(s => s.At > sample.At - Window && s.At < sample.At).Append(sample).ToList();
}
/// <summary>
/// The average over the last <paramref name="period"/>, when the samples cover at least 80 % of it (so a short
/// burst after a gap is not "sustained"). Null otherwise.
/// </summary>
public static double? SustainedAverage(IReadOnlyList<ServerPerformanceSample> samples, Func<ServerPerformanceSample, double?> value, TimeSpan period, DateTimeOffset now,
TimeSpan interval)
{
ArgumentNullException.ThrowIfNull(samples);
ArgumentNullException.ThrowIfNull(value);
var values = samples.Where(s => s.At > now - period && s.At <= now).Select(value).OfType<double>().ToList();
var expected = Math.Max(1, (int)Math.Floor(period / (interval <= TimeSpan.Zero ? TimeSpan.FromMinutes(1) : interval)));
return values.Count >= Math.Ceiling(expected * 0.8) ? values.Average() : null;
}
}

View file

@ -0,0 +1,51 @@
namespace SQLInsight.Core.Servers;
/// <summary>
/// What SQLInsight Web asks the agent to do about Windows servers and synthetic checks (insight.AgentRequests, with the
/// server or check in TargetId). They run in the agent, as the agent's identity, like the SQL Server connection tests.
/// </summary>
public static class ServerRequestKind
{
/// <summary>Connect to a Windows server and check which areas the agent may read.</summary>
public const string ServerTest = "ServerTest";
/// <summary>Run a synthetic check now and store the result.</summary>
public const string SyntheticCheckRun = "SyntheticCheckRun";
public static readonly IReadOnlyList<string> All = [ServerTest, SyntheticCheckRun];
public static bool Handles(string kind) => All.Contains(kind, StringComparer.Ordinal);
}
/// <summary>Whether the agent can read one area of a Windows server, and if not, exactly why (Danish).</summary>
/// <param name="Right">What gives the agent the area (from the grant script).</param>
public sealed record ServerAreaStatus(string Area, bool Ok, string? Detail, string Right);
/// <summary>The outcome of a Windows server test, as the agent saw it.</summary>
public sealed record WindowsServerTestResult
{
public bool Reachable { get; init; }
/// <summary>"WS-Man", "DCOM" or "lokal".</summary>
public string? Protocol { get; init; }
public string? Error { get; init; }
/// <summary>What to do about <see cref="Error"/> (Danish).</summary>
public string? Hint { get; init; }
/// <summary>The account the agent connected as (the one the grant script is for).</summary>
public string? Account { get; init; }
public string? ComputerName { get; init; }
public string? OperatingSystem { get; init; }
public string? Version { get; init; }
public DateTimeOffset? LastBootUpTime { get; init; }
public IReadOnlyList<ServerAreaStatus> Areas { get; init; } = [];
/// <summary>Notes about the result (Danish), e.g. why WS-Man was not used.</summary>
public IReadOnlyList<string> Notes { get; init; } = [];
public IEnumerable<ServerAreaStatus> Missing => Areas.Where(a => !a.Ok);
}

View file

@ -0,0 +1,108 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace SQLInsight.Core.Servers;
/// <summary>Task Scheduler's result codes (LastTaskResult) in Danish, and which of them mean the task failed.</summary>
public static partial class TaskResults
{
public const uint Success = 0;
public const uint Ready = 0x41300;
public const uint Running = 0x41301;
public const uint Disabled = 0x41302;
public const uint HasNotRun = 0x41303;
public const uint NoMoreRuns = 0x41304;
public const uint TerminatedByUser = 0x41306;
public const uint Queued = 0x41325;
public const uint AlreadyRunning = 0x8004131F;
/// <summary>Whether the last run ended badly. Running, queued, never run and "already running" are not failures.</summary>
public static bool IsFailure(uint? result) => result is { } r && r is not (Success or Ready or Running or Disabled or HasNotRun or NoMoreRuns or Queued or AlreadyRunning);
public static string Hex(uint result) => "0x" + result.ToString("X", CultureInfo.InvariantCulture);
public static string Describe(uint result) => result switch
{
Success => "Fuldført (0x0)",
Ready => "Klar (0x41300)",
Running => "Kører nu (0x41301)",
Disabled => "Deaktiveret (0x41302)",
HasNotRun => "Har ikke kørt endnu (0x41303)",
NoMoreRuns => "Ingen flere planlagte kørsler (0x41304)",
TerminatedByUser => "Afbrudt af en bruger eller fordi den kørte for længe (0x41306)",
Queued => "I kø (0x41325)",
AlreadyRunning => "Kørte allerede, en ny kørsel blev ikke startet (0x8004131F)",
0x1 => "Programmet sluttede med fejlkode 1 (typisk en fejl i scriptet eller programmet) (0x1)",
0x2 => "Filen blev ikke fundet (0x2)",
0xC000013A => "Programmet blev afbrudt (Ctrl+C eller nedlukning) (0xC000013A)",
0x80070002 => "Filen blev ikke fundet (0x80070002)",
0x80070005 => "Adgang nægtet: kontoen, opgaven kører som, mangler rettigheder (0x80070005)",
0x8007052E => "Logon fejlede: forkert brugernavn eller adgangskode til opgavens konto (0x8007052E)",
0x800710E0 => "Opgaven blev afvist (fx fordi computeren kørte på batteri eller kontoen ikke var logget på) (0x800710E0)",
0x80041326 => "Opgaven er deaktiveret (0x80041326)",
_ => "Sluttede med fejlkode " + Hex(result),
};
/// <summary>Whether a task is Microsoft's own (\Microsoft\…), which is not watched unless asked for.</summary>
public static bool IsMicrosoftTask(string path)
{
ArgumentNullException.ThrowIfNull(path);
return path.StartsWith(@"\Microsoft\", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Tasks that programs create per user or to update themselves, in the root folder rather than under \Microsoft\:
/// they are not the server's own work, and their failures are rarely anyone's business. A name, or a prefix ending in *.
/// </summary>
public static readonly IReadOnlyList<string> DefaultIgnoredNames =
[
"OneDrive Standalone Update Task*",
"OneDrive Per-Machine Standalone Update Task*",
"OneDrive Reporting Task*",
"OneDrive Startup Task*",
"MicrosoftEdgeUpdateTask*",
"GoogleUpdateTask*",
"GoogleUpdaterTask*",
"User_Feed_Synchronization-*",
];
/// <summary>
/// Whether a task belongs to one user (its name carries the user's SID, S-1-5-21-…) or is a program's own updater
/// (<see cref="DefaultIgnoredNames"/>). Such tasks are not watched unless asked for.
/// </summary>
public static bool IsUserOrUpdaterTask(string path)
{
ArgumentNullException.ThrowIfNull(path);
if (path.Contains("S-1-5-21-", StringComparison.OrdinalIgnoreCase))
{
return true;
}
var name = path[(path.LastIndexOf('\\') + 1)..];
return DefaultIgnoredNames.Any(p => p.EndsWith('*')
? name.StartsWith(p[..^1], StringComparison.OrdinalIgnoreCase)
: name.Equals(p, StringComparison.OrdinalIgnoreCase));
}
/// <summary>Whether the task's path names a user by SID.</summary>
public static bool HasUserSid(string path)
{
ArgumentNullException.ThrowIfNull(path);
return Sid().IsMatch(path);
}
/// <summary>
/// The task's path for titles and subjects: a user's SID is replaced by "bruger-" and a short hash of it, which tells
/// two users' tasks apart without naming the user.
/// </summary>
public static string Display(string path)
{
ArgumentNullException.ThrowIfNull(path);
return Sid().Replace(path, m => "bruger-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(m.Value.ToUpperInvariant())))[..8].ToLowerInvariant());
}
[GeneratedRegex(@"S-1-5-21(-[0-9]+)+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex Sid();
}

View file

@ -0,0 +1,188 @@
namespace SQLInsight.Core.Servers;
/// <summary>
/// What the agent collects from a Windows server, and how often by default. Each area is collected on its own, so a
/// missing right only makes that area "ikke målbar".
/// </summary>
public static class ServerArea
{
/// <summary>CPU, memory, paging and uptime (Win32_PerfRawData_PerfOS_*, Win32_OperatingSystem).</summary>
public const string Performance = "performance";
/// <summary>Free space per volume (Win32_Volume, or Win32_LogicalDisk).</summary>
public const string Disks = "disks";
/// <summary>Services and their state (Win32_Service).</summary>
public const string Services = "services";
/// <summary>Errors and critical events in the System and Application logs.</summary>
public const string Events = "events";
/// <summary>Scheduled tasks and their last result (root\Microsoft\Windows\TaskScheduler).</summary>
public const string Tasks = "tasks";
public static readonly IReadOnlyList<string> All = [Performance, Disks, Services, Events, Tasks];
public static TimeSpan DefaultInterval(string area) => area switch
{
Performance => TimeSpan.FromMinutes(1),
Disks => TimeSpan.FromMinutes(15),
Services => TimeSpan.FromMinutes(5),
Events => TimeSpan.FromMinutes(5),
Tasks => TimeSpan.FromMinutes(15),
_ => TimeSpan.FromMinutes(15),
};
public static string Display(string area) => area switch
{
Performance => "CPU, hukommelse og oppetid",
Disks => "Diske",
Services => "Tjenester",
Events => "Hændelseslog",
Tasks => "Planlagte opgaver",
_ => area,
};
/// <summary>The Windows right that opens the area to the agent (Danish), as in the grant script.</summary>
public static string Right(string area) => area switch
{
Performance => "Performance Monitor Users + WMI Remote Enable på root\\cimv2",
Disks => "WMI Remote Enable på root\\cimv2",
Services => "WMI Remote Enable på root\\cimv2 + læseret på Service Control Manager",
Events => "Event Log Readers",
Tasks => "WMI Remote Enable på root\\Microsoft\\Windows\\TaskScheduler",
_ => string.Empty,
};
}
/// <summary>The operating system as Win32_OperatingSystem reports it.</summary>
public sealed record ServerOsInfo
{
public string? Caption { get; init; }
public string? Version { get; init; }
public string? BuildNumber { get; init; }
public DateTimeOffset? LastBootUpTime { get; init; }
public double? TotalMemoryMb { get; init; }
public int? LogicalProcessors { get; init; }
/// <summary>The computer's own name (CSName).</summary>
public string? ComputerName { get; init; }
}
/// <summary>
/// Raw performance counters as one CIM query returns them. Rates (CPU, pages/s) are computed between two of these,
/// so a value always covers the whole interval, not the instant of the query.
/// </summary>
public sealed record ServerPerformanceRaw
{
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>Win32_PerfRawData_PerfOS_Processor(_Total).PercentProcessorTime: idle time in 100 ns (PERF_100NSEC_TIMER_INV).</summary>
public ulong? ProcessorIdle100Ns { get; init; }
/// <summary>The processor counter's Timestamp_Sys100NS.</summary>
public ulong? ProcessorTimestamp100Ns { get; init; }
/// <summary>Win32_PerfRawData_PerfOS_Memory.PagesPersec (PERF_COUNTER_COUNTER).</summary>
public ulong? Pages { get; init; }
public ulong? MemoryPerfTime { get; init; }
public ulong? MemoryPerfFrequency { get; init; }
public double? AvailableMb { get; init; }
public double? CommittedBytes { get; init; }
public double? CommitLimit { get; init; }
}
/// <summary>One minute (or interval) of a server's CPU and memory.</summary>
/// <param name="CpuPercent">Average CPU over the interval since the previous raw sample; null for the first one.</param>
public sealed record ServerPerformanceSample(DateTimeOffset At, double? CpuPercent, double? AvailableMb, double? TotalMb, double? PagesPerSecond, double? CommitPercent)
{
public double? AvailablePercent => AvailableMb is { } a && TotalMb is > 0 ? 100 * a / TotalMb.Value : null;
}
public sealed record ServerPerformanceSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required ServerOsInfo Os { get; init; }
public ServerPerformanceRaw? Raw { get; init; }
/// <summary>The samples of the last hour, oldest first (sustained CPU, low memory, paging).</summary>
public IReadOnlyList<ServerPerformanceSample> Samples { get; init; } = [];
/// <summary>The boot time seen at the previous collection, when it was different: the server has restarted since.</summary>
public DateTimeOffset? PreviousBootUpTime { get; init; }
/// <summary>When the agent last saw the server restart (kept for a day, so the finding stays visible).</summary>
public DateTimeOffset? RestartSeenAt { get; init; }
/// <summary>"WS-Man" or "DCOM" (or "lokal").</summary>
public string? Protocol { get; init; }
}
/// <param name="Volume">C:\, or the mount point path.</param>
public sealed record ServerDisk(string Volume, string? Label, string? FileSystem, double SizeGb, double FreeGb)
{
public double FreePercent => SizeGb > 0 ? 100 * FreeGb / SizeGb : 0;
}
public sealed record ServerDiskSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public IReadOnlyList<ServerDisk> Disks { get; init; } = [];
/// <summary>"fuld om ca. N dage" per volume, from SQLInsight's own history of the free space.</summary>
public IReadOnlyList<DiskForecast> Forecasts { get; init; } = [];
}
public sealed record ServerService(string Name, string DisplayName, string State, string StartMode, bool DelayedAutoStart = false, int? ExitCode = null)
{
public bool IsRunning => string.Equals(State, "Running", StringComparison.OrdinalIgnoreCase);
public bool IsAutomatic => string.Equals(StartMode, "Auto", StringComparison.OrdinalIgnoreCase);
}
public sealed record ServerServiceSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public IReadOnlyList<ServerService> Services { get; init; } = [];
}
/// <summary>A scheduled task as Task Scheduler reports it.</summary>
public sealed record ServerTask
{
public required string Path { get; init; }
public required string Name { get; init; }
/// <summary>Ready, Running, Disabled, Queued or Unknown.</summary>
public string State { get; init; } = "Unknown";
public DateTimeOffset? LastRunTime { get; init; }
public uint? LastTaskResult { get; init; }
public DateTimeOffset? NextRunTime { get; init; }
public int MissedRuns { get; init; }
public string FullName => Path.EndsWith('\\') ? Path + Name : Path + "\\" + Name;
public bool Enabled => !string.Equals(State, "Disabled", StringComparison.OrdinalIgnoreCase);
}
public sealed record ServerTaskSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>The tasks that are watched (by default the ones not under \Microsoft\).</summary>
public IReadOnlyList<ServerTask> Tasks { get; init; } = [];
/// <summary>Every task the agent could see, Microsoft's own included.</summary>
public int Visible { get; init; }
/// <summary>Why less is seen than there is (Danish), e.g. tasks the agent may not read.</summary>
public string? Coverage { get; init; }
}
/// <summary>A configured Windows server as the rules see it.</summary>
public sealed record ServerIdentity(int ServerId, string Name, string Host, string Role)
{
public IReadOnlyList<string> WatchedServices { get; init; } = [];
public IReadOnlyList<string> IgnoredServices { get; init; } = [];
}

View file

@ -0,0 +1,356 @@
using System.Globalization;
using System.Net;
using System.Text.Json;
using SQLInsight.Core.Configuration;
using SQLInsight.Core.CustomRules;
using SQLInsight.Core.Servers;
namespace SQLInsight.Core.SyntheticChecks;
/// <summary>The kinds of synthetic check: what the agent does, the way a user or an application would.</summary>
public static class SyntheticCheckKind
{
/// <summary>A TCP port answers (e.g. the AOS on 2712).</summary>
public const string Tcp = "Tcp";
/// <summary>An HTTP(S) GET returns the expected status (and text); the TLS certificate is checked.</summary>
public const string Http = "Http";
/// <summary>A SQL Server login works and a simple query answers in time.</summary>
public const string Sql = "Sql";
/// <summary>A name resolves (to the expected addresses).</summary>
public const string Dns = "Dns";
/// <summary>Dynamics AX: the AOS port answers, and a logon through the .NET Business Connector works when it is installed.</summary>
public const string AxLogon = "AxLogon";
public static readonly IReadOnlyList<string> All = [Tcp, Http, Sql, Dns, AxLogon];
public static bool IsKnown(string? kind) => kind is not null && All.Contains(kind, StringComparer.Ordinal);
public static string Display(string? kind) => kind switch
{
Tcp => "TCP-port",
Http => "HTTP(S)",
Sql => "SQL-login og forespørgsel",
Dns => "DNS-opslag",
AxLogon => "Dynamics AX-logon",
_ => kind ?? string.Empty,
};
}
/// <summary>
/// What a synthetic check does: the fields for its kind (stored as JSON in insight.SyntheticChecks.SettingsJson). A
/// SQL login password is never here; it is sealed for the agent in the check's own row.
/// </summary>
public sealed record SyntheticCheckSettings
{
/// <summary>Tcp, Dns and AxLogon: the computer (for Dns: the name to resolve).</summary>
public string? Host { get; init; }
/// <summary>Tcp and AxLogon (default 2712), Sql (optional).</summary>
public int? Port { get; init; }
/// <summary>Http: http:// or https://.</summary>
public Uri? Url { get; init; }
/// <summary>Http: the status codes that count as success; empty: 200–399.</summary>
public IReadOnlyList<int> ExpectedStatus { get; init; } = [];
/// <summary>Http: text the answer must contain.</summary>
public string? ExpectedText { get; init; }
/// <summary>Http: log on as the agent's account (Windows authentication), e.g. Enterprise Portal or Reporting Services.</summary>
public bool UseWindowsAuthentication { get; init; }
/// <summary>Http: accept a certificate that is not valid (self-signed …); its expiry is still checked.</summary>
public bool AllowInvalidCertificate { get; init; }
/// <summary>Sql: server or server\instance.</summary>
public string? Server { get; init; }
public string? Database { get; init; }
/// <summary>Sql: Windows (the agent's account) or SqlLogin.</summary>
public string Authentication { get; init; } = "Windows";
public string? UserName { get; init; }
/// <summary>Sql: Mandatory or Optional.</summary>
public string Encryption { get; init; } = "Mandatory";
public bool TrustServerCertificate { get; init; }
/// <summary>Sql: one read-only SELECT; default <c>SELECT 1</c>. Runs on SQL Server 2005 and later.</summary>
public string? Query { get; init; }
/// <summary>Dns: addresses the name must resolve to (any of them); empty: any answer.</summary>
public IReadOnlyList<string> ExpectedAddresses { get; init; } = [];
/// <summary>AxLogon: the company to log on to (DataAreaId), e.g. DAT.</summary>
public string? Company { get; init; }
/// <summary>AxLogon: the Business Connector's object server, e.g. 01@aos01:2712; default host:port.</summary>
public string? ObjectServer { get; init; }
/// <summary>AxLogon: the AX client configuration to use; default the Business Connector's own.</summary>
public string? Configuration { get; init; }
/// <summary>AxLogon: try a logon through the .NET Business Connector when it is installed on the agent's computer.</summary>
public bool BusinessConnectorLogon { get; init; } = true;
/// <summary>The Windows service behind what is checked, on the check's server (e.g. AOS60$01, W3SVC).</summary>
public string? ServiceName { get; init; }
/// <summary>An answer slower than this (ms) three times in a row is a finding.</summary>
public int? WarnMilliseconds { get; init; }
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { WriteIndented = false };
public string ToJson() => JsonSerializer.Serialize(this, Json);
public static SyntheticCheckSettings FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return new SyntheticCheckSettings();
}
try
{
return JsonSerializer.Deserialize<SyntheticCheckSettings>(json, Json) ?? new SyntheticCheckSettings();
}
catch (JsonException)
{
return new SyntheticCheckSettings();
}
}
/// <summary>The AOS port when none is given.</summary>
public const int DefaultAosPort = 2712;
public const string DefaultQuery = "SELECT 1";
/// <summary>What the check points at, for lists (Danish-neutral).</summary>
public string Target(string kind) => kind switch
{
SyntheticCheckKind.Tcp => $"{Host}:{Port?.ToString(CultureInfo.InvariantCulture)}",
SyntheticCheckKind.AxLogon => ObjectServer is { Length: > 0 } os ? os : $"{Host}:{(Port ?? DefaultAosPort).ToString(CultureInfo.InvariantCulture)}",
SyntheticCheckKind.Http => Url?.ToString() ?? string.Empty,
SyntheticCheckKind.Sql => ServerAddress.DataSource(Server ?? string.Empty, Port) + (Database is { Length: > 0 } db ? " / " + db : string.Empty),
SyntheticCheckKind.Dns => Host ?? string.Empty,
_ => string.Empty,
};
/// <summary>The context a SQL login password is sealed to: the server and login of the check (see <see cref="CredentialEnvelope"/>).</summary>
public string SecretContext() => CredentialEnvelope.Context("synthetic-sql:" + ServerAddress.DataSource(Server ?? string.Empty, Port), UserName);
}
/// <summary>How often and how long a check may run.</summary>
public static class SyntheticCheckLimits
{
public const int DefaultIntervalSeconds = 300;
public const int MinIntervalSeconds = 60;
public const int MaxIntervalSeconds = 86400;
public const int DefaultTimeoutSeconds = 15;
public const int MaxTimeoutSeconds = 120;
/// <summary>The AX logon starts a separate process and logs on to the AOS: give it longer.</summary>
public const int DefaultAxTimeoutSeconds = 60;
}
public static class SyntheticCheckValidation
{
/// <summary>What API v1 shows instead of a login or a URL query (a value sent back unchanged is refused).</summary>
public const string RedactedValue = "[udeladt]";
/// <summary>The longest HTTP address a check may have.</summary>
public const int MaxUrlLength = 2000;
/// <summary>Why the check cannot be saved (Danish), or null. <paramref name="hasServer"/>: the check is tied to a Windows server.</summary>
public static string? Validate(string? name, string? kind, SyntheticCheckSettings settings, int intervalSeconds, int timeoutSeconds, bool hasServer)
{
ArgumentNullException.ThrowIfNull(settings);
if (ServerAddress.ValidateName(name) is { } badName)
{
return badName;
}
if (!SyntheticCheckKind.IsKnown(kind))
{
return "Typen skal være " + string.Join(", ", SyntheticCheckKind.All) + ".";
}
if (intervalSeconds is < SyntheticCheckLimits.MinIntervalSeconds or > SyntheticCheckLimits.MaxIntervalSeconds)
{
return "Intervallet skal være mellem 1 minut og 24 timer.";
}
if (timeoutSeconds is < 1 or > SyntheticCheckLimits.MaxTimeoutSeconds)
{
return $"Timeout skal være mellem 1 og {SyntheticCheckLimits.MaxTimeoutSeconds} sekunder.";
}
if (timeoutSeconds >= intervalSeconds)
{
return "Timeout skal være kortere end intervallet.";
}
if (settings.WarnMilliseconds is < 1 or > 600_000)
{
return "Grænsen for langsomt svar skal være mellem 1 og 600.000 ms.";
}
if (settings.ServiceName is { Length: > 0 } service)
{
if (!hasServer)
{
return "Vælg den Windows-server, tjenesten kører på.";
}
if (ServerHost.ValidateServiceNames([service]) is { } badService)
{
return badService;
}
}
return kind switch
{
SyntheticCheckKind.Tcp => ServerHost.Validate(settings.Host) ?? ValidatePort(settings.Port, required: true),
SyntheticCheckKind.AxLogon => ServerHost.Validate(settings.Host) ?? ValidatePort(settings.Port, required: false) ?? ValidateAx(settings),
SyntheticCheckKind.Http => ValidateHttp(settings),
SyntheticCheckKind.Sql => ValidateSql(settings),
SyntheticCheckKind.Dns => ServerHost.Validate(settings.Host) ?? ValidateAddresses(settings.ExpectedAddresses),
_ => null,
};
}
private static string? ValidatePort(int? port, bool required) =>
port is null ? (required ? "Angiv porten." : null) : port is < 1 or > 65535 ? "Porten skal være mellem 1 og 65535." : null;
private static string? ValidateAx(SyntheticCheckSettings settings)
{
if (settings.Company is { } company && (company.Length > 10 || company.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '_')))
{
return "Firmaet skal være AX' firma-id (DataAreaId), fx DAT.";
}
if (settings.ObjectServer is { Length: > 256 } || settings.ObjectServer?.Any(char.IsControl) == true)
{
return "Object server er ugyldig (fx 01@aos01:2712).";
}
return settings.Configuration is { Length: > 128 } || settings.Configuration?.Any(char.IsControl) == true ? "Konfigurationens navn er ugyldigt." : null;
}
private static string? ValidateHttp(SyntheticCheckSettings settings)
{
if (settings.Url is not { IsAbsoluteUri: true } url || (url.Scheme != Uri.UriSchemeHttp && url.Scheme != Uri.UriSchemeHttps))
{
return "Angiv en adresse, der starter med http:// eller https://.";
}
if (url.AbsoluteUri.Length > MaxUrlLength || url.OriginalString.Length > MaxUrlLength)
{
return $"Adressen må højst være {MaxUrlLength} tegn.";
}
// API v1 shows the query as [udeladt]: a GET sent back as it is must not replace the real address.
if (Uri.UnescapeDataString(url.Query).Contains(RedactedValue, StringComparison.Ordinal))
{
return "Adressen indeholder [udeladt] fra API'et. Angiv hele adressen igen.";
}
if (!string.IsNullOrEmpty(url.UserInfo))
{
return "Adressen må ikke indeholde brugernavn eller adgangskode.";
}
if (settings.ExpectedStatus.Any(s => s is < 100 or > 599) || settings.ExpectedStatus.Count > 20)
{
return "Forventede statuskoder skal være mellem 100 og 599.";
}
return settings.ExpectedText is { Length: > 500 } ? "Den forventede tekst må højst være 500 tegn." : null;
}
private static string? ValidateSql(SyntheticCheckSettings settings)
{
if (ServerAddress.Validate(settings.Server, settings.Port) is { } server)
{
return server;
}
if (settings.Authentication is not ("Windows" or "SqlLogin"))
{
return "Godkendelse skal være Windows (agentens tjenestekonto) eller SqlLogin.";
}
if (settings.Authentication == "SqlLogin" && (string.IsNullOrWhiteSpace(settings.UserName) || settings.UserName.Length > 128))
{
return "Angiv SQL-login'et (højst 128 tegn).";
}
if (settings.UserName == RedactedValue)
{
return "Login'et er [udeladt] fra API'et. Angiv det rigtige login igen.";
}
if (settings.Encryption is not ("Mandatory" or "Optional"))
{
return "Kryptering skal være Mandatory eller Optional.";
}
if (settings.Database is { } database && (database.Length > 128 || database.Any(char.IsControl)))
{
return "Databasenavnet er ugyldigt.";
}
if (settings.Query is { Length: > 0 } query)
{
if (query.Length > 4000)
{
return "Forespørgslen må højst være 4000 tegn.";
}
return CustomRuleDefinition.QueryProblems(query).FirstOrDefault();
}
return null;
}
private static string? ValidateAddresses(IReadOnlyList<string> addresses) =>
addresses.Count > 20 ? "Højst 20 forventede adresser."
: addresses.FirstOrDefault(a => !IPAddress.TryParse(a, out _)) is { } bad ? $"\"{bad}\" er ikke en IP-adresse."
: null;
}
/// <summary>One measurement of a run, as shown and stored. Sensitive values stay on-prem and are redacted in the API.</summary>
public sealed record SyntheticCheckDetail(string Name, string Value, bool Sensitive = false);
/// <summary>What one run of a synthetic check found.</summary>
public sealed record SyntheticCheckResult
{
public required DateTimeOffset At { get; init; }
public required bool Success { get; init; }
/// <summary>How long the check took (the answer's time for HTTP, SQL, TCP).</summary>
public int DurationMs { get; init; }
/// <summary>What happened (Danish), e.g. "Porten svarede på 12 ms" or the error.</summary>
public required string Message { get; init; }
public IReadOnlyList<SyntheticCheckDetail> Details { get; init; } = [];
/// <summary>Http over https: when the server's certificate expires.</summary>
public DateTimeOffset? CertificateExpiresAt { get; init; }
public string? CertificateSubject { get; init; }
/// <summary>The state of the linked Windows service when the check ran (Running, Stopped …), when there is one and it could be read.</summary>
public string? ServiceState { get; init; }
/// <summary>Only part of the check could be done (AX: the port, because the Business Connector is not installed).</summary>
public bool Partial { get; init; }
}

View file

@ -79,6 +79,10 @@ public sealed record AgentRequestRow
public long RequestId { get; init; }
public string Kind { get; init; } = string.Empty;
public int? ConfiguredInstanceId { get; init; }
/// <summary>The Windows server or synthetic check a ServerTest or SyntheticCheckRun is about (1.5).</summary>
public int? TargetId { get; init; }
public string? Parameters { get; init; }
public string Status { get; init; } = AgentRequestStatus.Pending;
public string? RequestedBy { get; init; }

View file

@ -0,0 +1,297 @@
using System.Text.Json;
using Dapper;
using Microsoft.Data.SqlClient;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Servers;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Repository;
/// <summary>What a server finding is about: a Windows server or a synthetic check (insight.ServerFindings.Scope).</summary>
public static class FindingScope
{
public const string Server = "Server";
public const string Check = "Check";
}
/// <summary>A finding about a Windows server or a synthetic check, as stored.</summary>
public sealed record StoredServerFinding
{
public long FindingId { get; init; }
public string Scope { get; init; } = FindingScope.Server;
public int ScopeId { get; init; }
/// <summary>The server's or the check's name.</summary>
public string ScopeName { get; init; } = string.Empty;
public string RuleId { get; init; } = string.Empty;
public string Subject { get; init; } = string.Empty;
public Severity Severity { get; init; }
public string Title { get; init; } = string.Empty;
public string Explanation { get; init; } = string.Empty;
public string Recommendation { get; init; } = string.Empty;
public IReadOnlyList<Evidence> Evidence { get; init; } = [];
public IReadOnlyList<Reference> References { get; init; } = [];
public DateTime FirstSeenAt { get; init; }
public DateTime LastSeenAt { get; init; }
public DateTime? ResolvedAt { get; init; }
/// <summary>The server or check has been removed: the finding is history, not something to act on (maintenance resolves it).</summary>
public bool ScopeRemoved { get; init; }
/// <summary>The server or check is paused: its findings are not counted as active on the overviews.</summary>
public bool ScopePaused { get; init; }
/// <summary>The server or check is looked at now (not paused, not removed).</summary>
public bool ScopeMonitored => !ScopeRemoved && !ScopePaused;
}
public sealed partial class InsightRepository
{
private sealed record ServerFindingRow(long FindingId, string Scope, int ScopeId, string? ScopeName, string RuleId, string Subject, byte Severity, string Title,
string Explanation, string Recommendation, string EvidenceJson, string ReferencesJson, DateTime FirstSeenAt, DateTime LastSeenAt, DateTime? ResolvedAt,
bool ScopeRemoved, bool ScopePaused);
private const string ServerFindingColumns = """
f.FindingId, f.Scope, f.ScopeId, COALESCE(w.Name, c.Name) AS ScopeName, f.RuleId, f.Subject, f.Severity, f.Title, f.Explanation, f.Recommendation,
f.EvidenceJson, f.ReferencesJson, f.FirstSeenAt, f.LastSeenAt, f.ResolvedAt,
CAST(CASE WHEN (w.ServerId IS NULL AND c.CheckId IS NULL) OR w.RemovedAt IS NOT NULL OR c.RemovedAt IS NOT NULL THEN 1 ELSE 0 END AS bit) AS ScopeRemoved,
CAST(COALESCE(w.Paused, c.Paused, 0) AS bit) AS ScopePaused
""";
/// <summary>The finding's server or check is looked at (exists, not removed; paused only counts where noted).</summary>
private const string ScopeInUse = "(w.ServerId IS NOT NULL OR c.CheckId IS NOT NULL) AND w.RemovedAt IS NULL AND c.RemovedAt IS NULL";
private const string ServerFindingFrom = """
insight.ServerFindings f
LEFT JOIN insight.WindowsServers w ON f.Scope = 'Server' AND w.ServerId = f.ScopeId
LEFT JOIN insight.SyntheticChecks c ON f.Scope = 'Check' AND c.CheckId = f.ScopeId
""";
/// <summary>
/// Stores the rule outcomes of one server or check and opens, updates and resolves its findings, like
/// <see cref="SaveRuleResultsAsync"/> for instances. Returns what changed, for notifications.
/// </summary>
public async Task<FindingChanges> SaveScopedRuleResultsAsync(string scope, int scopeId, IReadOnlyList<RuleResult> results, DateTimeOffset evaluatedAt,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(scope);
ArgumentNullException.ThrowIfNull(results);
for (var attempt = 1; ; attempt++)
{
try
{
return await SaveScopedRuleResultsOnceAsync(scope, scopeId, results, evaluatedAt, cancellationToken).ConfigureAwait(false);
}
catch (SqlException ex) when (ex.Number == DeadlockVictim && attempt < 3)
{
await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), cancellationToken).ConfigureAwait(false);
}
}
}
private async Task<FindingChanges> SaveScopedRuleResultsOnceAsync(string scope, int scopeId, IReadOnlyList<RuleResult> results, DateTimeOffset evaluatedAt,
CancellationToken cancellationToken)
{
var now = Utc(evaluatedAt);
var opened = new List<(long, Finding)>();
var resolved = new List<ResolvedFinding>();
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = (SqlTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync(
"EXEC sp_getapplock @Resource = N'SQLInsight:ServerFindings', @LockMode = N'Exclusive', @LockOwner = N'Transaction', @LockTimeout = 60000;",
transaction: transaction).ConfigureAwait(false);
foreach (var result in results)
{
await connection.ExecuteAsync("""
MERGE insight.ServerRuleStates AS t
USING (SELECT @scope AS Scope, @scopeId AS ScopeId, @ruleId AS RuleId) AS s ON t.Scope = s.Scope AND t.ScopeId = s.ScopeId AND t.RuleId = s.RuleId
WHEN MATCHED THEN UPDATE SET RuleTitle = @title, Outcome = @outcome, Reason = @reason, EvaluatedAt = @now
WHEN NOT MATCHED THEN INSERT (Scope, ScopeId, RuleId, RuleTitle, Outcome, Reason, EvaluatedAt) VALUES (@scope, @scopeId, @ruleId, @title, @outcome, @reason, @now);
""", new { scope, scopeId, ruleId = result.RuleId, title = result.RuleTitle, outcome = (byte)result.Outcome, reason = result.Reason, now }, transaction)
.ConfigureAwait(false);
// Not measured this time: whatever was found before stays open (a missing right should not "resolve" a full disk).
if (result.Outcome is not (RuleOutcome.Passed or RuleOutcome.Findings or RuleOutcome.NotApplicable))
{
continue;
}
var current = result.Findings.Select(f => f.Subject ?? string.Empty).ToList();
resolved.AddRange(await connection.QueryAsync<ResolvedFinding>("""
UPDATE insight.ServerFindings SET ResolvedAt = @now
OUTPUT inserted.FindingId, inserted.RuleId, inserted.Subject, inserted.Title, inserted.Severity
WHERE Scope = @scope AND ScopeId = @scopeId AND RuleId = @ruleId AND ResolvedAt IS NULL
AND Subject NOT IN (SELECT value FROM OPENJSON(@current));
""", new { scope, scopeId, ruleId = result.RuleId, now, current = JsonSerializer.Serialize(current, Json) }, transaction).ConfigureAwait(false));
foreach (var finding in result.Findings)
{
var newId = await connection.QuerySingleOrDefaultAsync<long?>("""
UPDATE insight.ServerFindings
SET Severity = @severity, Title = @title, Explanation = @explanation, Recommendation = @recommendation,
EvidenceJson = @evidence, ReferencesJson = @references, LastSeenAt = @now
WHERE Scope = @scope AND ScopeId = @scopeId AND RuleId = @ruleId AND Subject = @subject AND ResolvedAt IS NULL;
IF @@ROWCOUNT = 0
INSERT insight.ServerFindings (Scope, ScopeId, RuleId, Subject, Severity, Title, Explanation, Recommendation, EvidenceJson, ReferencesJson, FirstSeenAt, LastSeenAt)
OUTPUT inserted.FindingId
VALUES (@scope, @scopeId, @ruleId, @subject, @severity, @title, @explanation, @recommendation, @evidence, @references, @now, @now);
""", new
{
scope,
scopeId,
ruleId = finding.RuleId,
subject = finding.Subject ?? string.Empty,
severity = (byte)finding.Severity,
title = finding.Title,
explanation = finding.Explanation,
recommendation = finding.Recommendation,
evidence = JsonSerializer.Serialize(finding.Evidence, Json),
references = JsonSerializer.Serialize(finding.References, Json),
now,
}, transaction).ConfigureAwait(false);
if (newId is { } id)
{
opened.Add((id, finding));
}
}
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return new FindingChanges(opened, resolved);
}
/// <summary>
/// Server and check findings: active, resolved or all; for one server or check, or all of them, a page at a time.
/// Active means open on a server or check that is looked at: never on a removed one, and across all servers and
/// checks not on a paused one either (like the instances' "Alle fund"); a paused server's or check's own list still
/// shows its latest findings. "All" is everything stored.
/// </summary>
public async Task<IReadOnlyList<StoredServerFinding>> GetServerFindingsAsync(string? scope = null, int? scopeId = null, FindingStatus status = FindingStatus.Active,
int top = 500, int offset = 0, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<ServerFindingRow>($"""
SELECT {ServerFindingColumns}
FROM {ServerFindingFrom}
WHERE (@scope IS NULL OR f.Scope = @scope) AND (@scopeId IS NULL OR f.ScopeId = @scopeId)
AND (@status = 2
OR (@status = 1 AND f.ResolvedAt IS NOT NULL)
OR (@status = 0 AND f.ResolvedAt IS NULL AND {ScopeInUse} AND (@scopeId IS NOT NULL OR COALESCE(w.Paused, c.Paused, 0) = 0)))
ORDER BY CASE WHEN f.ResolvedAt IS NULL THEN 0 ELSE 1 END, f.Severity DESC, f.FirstSeenAt, f.FindingId
OFFSET @offset ROWS FETCH NEXT @top ROWS ONLY;
""", new { scope, scopeId, status = (int)status, top = Math.Max(1, top), offset = Math.Max(0, offset) }).ConfigureAwait(false);
return rows.Select(Map).ToList();
}
public async Task<StoredServerFinding?> GetServerFindingAsync(long id, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var row = await connection.QuerySingleOrDefaultAsync<ServerFindingRow>(
$"SELECT {ServerFindingColumns} FROM {ServerFindingFrom} WHERE f.FindingId = @id;", new { id }).ConfigureAwait(false);
return row is null ? null : Map(row);
}
/// <summary>The earlier occurrences of the same finding (same server or check, rule and subject).</summary>
public async Task<IReadOnlyList<StoredServerFinding>> GetServerFindingHistoryAsync(StoredServerFinding finding, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(finding);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<ServerFindingRow>($"""
SELECT TOP (20) {ServerFindingColumns} FROM {ServerFindingFrom}
WHERE f.Scope = @Scope AND f.ScopeId = @ScopeId AND f.RuleId = @RuleId AND f.Subject = @Subject AND f.FindingId <> @FindingId
ORDER BY f.FirstSeenAt DESC;
""", new { finding.Scope, finding.ScopeId, finding.RuleId, finding.Subject, finding.FindingId }).ConfigureAwait(false);
return rows.Select(Map).ToList();
}
public async Task<IReadOnlyList<StoredRuleState>> GetServerRuleStatesAsync(string scope, int scopeId, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(string RuleId, string RuleTitle, byte Outcome, string? Reason, DateTime EvaluatedAt)>(
"SELECT RuleId, RuleTitle, Outcome, Reason, EvaluatedAt FROM insight.ServerRuleStates WHERE Scope = @scope AND ScopeId = @scopeId ORDER BY RuleId;",
new { scope, scopeId }).ConfigureAwait(false);
return rows.Select(r => new StoredRuleState(r.RuleId, r.RuleTitle, (RuleOutcome)r.Outcome, r.Reason, r.EvaluatedAt)).ToList();
}
/// <summary>Active findings per server or check and severity, for overviews: only servers and checks looked at now (not paused or removed).</summary>
public async Task<IReadOnlyDictionary<(string Scope, int ScopeId), IReadOnlyDictionary<Severity, int>>> GetServerFindingCountsAsync(CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(string Scope, int ScopeId, byte Severity, int Count)>($"""
SELECT f.Scope, f.ScopeId, f.Severity, COUNT(*)
FROM {ServerFindingFrom}
WHERE f.ResolvedAt IS NULL AND {ScopeInUse} AND COALESCE(w.Paused, c.Paused, 0) = 0
GROUP BY f.Scope, f.ScopeId, f.Severity;
""").ConfigureAwait(false);
return rows.GroupBy(r => (r.Scope, r.ScopeId)).ToDictionary(
g => g.Key,
g => (IReadOnlyDictionary<Severity, int>)g.ToDictionary(r => (Severity)r.Severity, r => r.Count));
}
private static StoredServerFinding Map(ServerFindingRow r) => new()
{
FindingId = r.FindingId,
Scope = r.Scope,
ScopeId = r.ScopeId,
ScopeName = r.ScopeName ?? string.Empty,
RuleId = r.RuleId,
Subject = r.Subject,
Severity = (Severity)r.Severity,
Title = r.Title,
Explanation = r.Explanation,
Recommendation = r.Recommendation,
Evidence = JsonSerializer.Deserialize<List<Evidence>>(r.EvidenceJson, Json) ?? [],
References = JsonSerializer.Deserialize<List<Reference>>(r.ReferencesJson, Json) ?? [],
FirstSeenAt = r.FirstSeenAt,
LastSeenAt = r.LastSeenAt,
ResolvedAt = r.ResolvedAt,
ScopeRemoved = r.ScopeRemoved,
ScopePaused = r.ScopePaused,
};
// ---- Requests about servers and checks ---------------------------------------------------------------------
/// <summary>
/// Queues a server test or a check run for the agent (SQLInsight Web: INSERT only). One already waiting for the agent
/// (Pending, same kind and server or check) is returned instead of a new one, so repeated clicks and API calls do not
/// fill the agent's queue, as for the connection tests (<see cref="AddAgentRequestAsync"/>).
/// </summary>
public async Task<long> AddServerRequestAsync(string kind, int targetId, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
{
if (!ServerRequestKind.Handles(kind))
{
throw new ArgumentException($"Ukendt anmodning: {kind}.", nameof(kind));
}
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteScalarAsync<long>("""
DECLARE @id bigint = (SELECT TOP (1) RequestId FROM insight.AgentRequests
WHERE Status = 'Pending' AND Kind = @kind AND TargetId = @targetId
ORDER BY RequestId DESC);
IF @id IS NULL
BEGIN
INSERT insight.AgentRequests (Kind, TargetId, Status, RequestedBy, RequestedAt)
VALUES (@kind, @targetId, 'Pending', @by, @now);
SET @id = SCOPE_IDENTITY();
END
SELECT @id;
""", new { kind, targetId, by, now = Utc(now) }).ConfigureAwait(false);
}
/// <summary>The newest request of a kind about a server or check.</summary>
public async Task<AgentRequestRow?> GetLatestServerRequestAsync(string kind, int targetId, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<AgentRequestRow>(
"SELECT TOP 1 * FROM insight.AgentRequests WHERE Kind = @kind AND TargetId = @targetId ORDER BY RequestId DESC;", new { kind, targetId }).ConfigureAwait(false);
}
/// <summary>The server or check a request is about (insight.AgentRequests.TargetId).</summary>
public async Task<int?> GetRequestTargetAsync(long requestId, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteScalarAsync<int?>("SELECT TargetId FROM insight.AgentRequests WHERE RequestId = @requestId;", new { requestId }).ConfigureAwait(false);
}
}

View file

@ -0,0 +1,263 @@
using System.Text.Json;
using Dapper;
using SQLInsight.Core.SyntheticChecks;
namespace SQLInsight.Repository;
/// <summary>A synthetic check (insight.SyntheticChecks).</summary>
public sealed record SyntheticCheckRow
{
public int CheckId { get; init; }
public string Name { get; init; } = string.Empty;
public string Kind { get; init; } = SyntheticCheckKind.Tcp;
/// <summary>The Windows server the check belongs to (and whose service it looks at), if any.</summary>
public int? ServerId { get; init; }
public string? ServerName { get; init; }
public string SettingsJson { get; init; } = "{}";
/// <summary>The sealed SQL login password. Only the agent can open it; never shown or returned.</summary>
public byte[]? SecretCipher { get; init; }
public string? SecretKeyId { get; init; }
public int IntervalSeconds { get; init; } = SyntheticCheckLimits.DefaultIntervalSeconds;
public int TimeoutSeconds { get; init; } = SyntheticCheckLimits.DefaultTimeoutSeconds;
public bool Paused { get; init; }
public DateTime CreatedAt { get; init; }
public string? CreatedBy { get; init; }
public DateTime UpdatedAt { get; init; }
public string? UpdatedBy { get; init; }
public DateTime? RemovedAt { get; init; }
/// <summary>The agent could not open the stored password (Danish).</summary>
public string? SecretError { get; init; }
public DateTime? LastRunAt { get; init; }
public bool? LastSuccess { get; init; }
public int? LastDurationMs { get; init; }
public string? LastMessage { get; init; }
public int ConsecutiveFailures { get; init; }
public SyntheticCheckSettings Settings => SyntheticCheckSettings.FromJson(SettingsJson);
public bool HasSecret => SecretCipher is { Length: > 0 };
}
/// <summary>A new or changed check from SQLInsight Web. A null <see cref="SecretCipher"/> on a change keeps the stored password.</summary>
public sealed record SyntheticCheckInput(
string Name,
string Kind,
int? ServerId,
SyntheticCheckSettings Settings,
byte[]? SecretCipher,
string? SecretKeyId,
int IntervalSeconds,
int TimeoutSeconds,
bool ClearSecret = false,
bool Paused = false);
/// <summary>One stored run of a check.</summary>
public sealed record StoredSyntheticCheckResult
{
public long ResultId { get; init; }
public int CheckId { get; init; }
public DateTime RunAt { get; init; }
public bool Success { get; init; }
public int DurationMs { get; init; }
public string Message { get; init; } = string.Empty;
public string? DetailsJson { get; init; }
public DateTime? CertificateExpiresAt { get; init; }
public string? CertificateSubject { get; init; }
public string? ServiceState { get; init; }
public bool Partial { get; init; }
public IReadOnlyList<SyntheticCheckDetail> Details
{
get
{
if (string.IsNullOrWhiteSpace(DetailsJson))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<SyntheticCheckDetail>>(DetailsJson, InsightRepository.Json) ?? [];
}
catch (JsonException)
{
return [];
}
}
}
public SyntheticCheckResult ToResult() => new()
{
At = new DateTimeOffset(DateTime.SpecifyKind(RunAt, DateTimeKind.Utc)),
Success = Success,
DurationMs = DurationMs,
Message = Message,
Details = Details,
CertificateExpiresAt = CertificateExpiresAt is { } c ? new DateTimeOffset(DateTime.SpecifyKind(c, DateTimeKind.Utc)) : null,
CertificateSubject = CertificateSubject,
ServiceState = ServiceState,
Partial = Partial,
};
}
public sealed partial class InsightRepository
{
private const string CheckColumns = """
c.CheckId, c.Name, c.Kind, w.ServerId, w.Name AS ServerName, c.SettingsJson, c.SecretCipher, c.SecretKeyId, c.IntervalSeconds, c.TimeoutSeconds, c.Paused,
c.CreatedAt, c.CreatedBy, c.UpdatedAt, c.UpdatedBy, c.RemovedAt, c.SecretError, c.LastRunAt, c.LastSuccess, c.LastDurationMs, c.LastMessage, c.ConsecutiveFailures
""";
/// <summary>The checks (not removed), by name.</summary>
public async Task<IReadOnlyList<SyntheticCheckRow>> GetSyntheticChecksAsync(CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<SyntheticCheckRow>($"""
SELECT {CheckColumns} FROM insight.SyntheticChecks c LEFT JOIN insight.WindowsServers w ON w.ServerId = c.ServerId AND w.RemovedAt IS NULL
WHERE c.RemovedAt IS NULL ORDER BY c.Name;
""").ConfigureAwait(false);
return rows.ToList();
}
public async Task<SyntheticCheckRow?> GetSyntheticCheckAsync(int id, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<SyntheticCheckRow>($"""
SELECT {CheckColumns} FROM insight.SyntheticChecks c LEFT JOIN insight.WindowsServers w ON w.ServerId = c.ServerId AND w.RemovedAt IS NULL
WHERE c.CheckId = @id AND c.RemovedAt IS NULL;
""", new { id }).ConfigureAwait(false);
}
/// <summary>Adds a check (SQLInsight Web: INSERT only). A name in use throws SqlException 2601.</summary>
public async Task<int> AddSyntheticCheckAsync(SyntheticCheckInput check, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(check);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteScalarAsync<int>("""
INSERT insight.SyntheticChecks (Name, Kind, ServerId, SettingsJson, SecretCipher, SecretKeyId, IntervalSeconds, TimeoutSeconds, Paused,
CreatedAt, CreatedBy, UpdatedAt, UpdatedBy)
OUTPUT INSERTED.CheckId
VALUES (@name, @Kind, @ServerId, @settings, @SecretCipher, @SecretKeyId, @IntervalSeconds, @TimeoutSeconds, @Paused, @now, @by, @now, @by);
""", new
{
name = check.Name.Trim(), check.Kind, check.ServerId, settings = check.Settings.ToJson(), check.SecretCipher, check.SecretKeyId,
check.IntervalSeconds, check.TimeoutSeconds, check.Paused, by, now = Utc(now),
}).ConfigureAwait(false);
}
/// <summary>Changes a check (not its kind). A name in use throws SqlException 2601.</summary>
public async Task<bool> UpdateSyntheticCheckAsync(int id, SyntheticCheckInput check, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(check);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteAsync("""
UPDATE insight.SyntheticChecks
SET Name = @name, ServerId = @ServerId, SettingsJson = @settings, IntervalSeconds = @IntervalSeconds, TimeoutSeconds = @TimeoutSeconds,
SecretCipher = CASE WHEN @ClearSecret = 1 THEN NULL WHEN @SecretCipher IS NOT NULL THEN @SecretCipher ELSE SecretCipher END,
SecretKeyId = CASE WHEN @ClearSecret = 1 THEN NULL WHEN @SecretCipher IS NOT NULL THEN @SecretKeyId ELSE SecretKeyId END,
SecretError = CASE WHEN @ClearSecret = 1 OR @SecretCipher IS NOT NULL THEN NULL ELSE SecretError END,
UpdatedAt = @now, UpdatedBy = @by
WHERE CheckId = @id AND RemovedAt IS NULL AND Kind = @Kind;
""", new
{
id, name = check.Name.Trim(), check.Kind, check.ServerId, settings = check.Settings.ToJson(), check.SecretCipher, check.SecretKeyId, check.ClearSecret,
check.IntervalSeconds, check.TimeoutSeconds, by, now = Utc(now),
}).ConfigureAwait(false) > 0;
}
public async Task<bool> SetSyntheticCheckPausedAsync(int id, bool paused, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteAsync(
"UPDATE insight.SyntheticChecks SET Paused = @paused, UpdatedAt = @now, UpdatedBy = @by WHERE CheckId = @id AND RemovedAt IS NULL;",
new { id, paused, by, now = Utc(now) }).ConfigureAwait(false) > 0;
}
/// <summary>
/// Removes a check (RemovedAt; its results and findings follow retention). The sealed SQL password and the last
/// message (which may hold a login or an error text) go at once: a removed check needs neither.
/// </summary>
public async Task<bool> RemoveSyntheticCheckAsync(int id, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteAsync("""
UPDATE insight.SyntheticChecks
SET RemovedAt = @now, RemovedBy = @by, UpdatedAt = @now, UpdatedBy = @by, SecretCipher = NULL, SecretKeyId = NULL, SecretError = NULL, LastMessage = NULL
WHERE CheckId = @id AND RemovedAt IS NULL;
""", new { id, by, now = Utc(now) }).ConfigureAwait(false) > 0;
}
/// <summary>The latest runs of a check, newest first.</summary>
public async Task<IReadOnlyList<StoredSyntheticCheckResult>> GetSyntheticCheckResultsAsync(int id, int top = 50, DateTimeOffset? from = null,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<StoredSyntheticCheckResult>("""
SELECT TOP (@top) ResultId, CheckId, RunAt, Success, DurationMs, Message, DetailsJson, CertificateExpiresAt, CertificateSubject, ServiceState, [Partial]
FROM insight.SyntheticCheckResults WHERE CheckId = @id AND (@from IS NULL OR RunAt >= @from)
ORDER BY RunAt DESC, ResultId DESC;
""", new { id, top, from = from?.UtcDateTime }).ConfigureAwait(false);
return rows.ToList();
}
// ---- The agent ----------------------------------------------------------------------------------------------
/// <summary>Stores a run and the check's latest state (and how many times in a row it has failed).</summary>
public async Task RecordSyntheticCheckResultAsync(int id, SyntheticCheckResult result, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(result);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
INSERT insight.SyntheticCheckResults (CheckId, RunAt, Success, DurationMs, Message, DetailsJson, CertificateExpiresAt, CertificateSubject, ServiceState, [Partial])
VALUES (@id, @at, @Success, @DurationMs, LEFT(@Message, 1000), @details, @certificate, LEFT(@CertificateSubject, 400), LEFT(@ServiceState, 32), @Partial);
UPDATE insight.SyntheticChecks
SET LastRunAt = @at, LastSuccess = @Success, LastDurationMs = @DurationMs, LastMessage = LEFT(@Message, 1000),
ConsecutiveFailures = CASE WHEN @Success = 1 THEN 0 ELSE ConsecutiveFailures + 1 END
WHERE CheckId = @id AND RemovedAt IS NULL;
""", new
{
id, at = Utc(result.At), result.Success, result.DurationMs, result.Message,
details = SerializeDetails(result.Details),
certificate = result.CertificateExpiresAt is { } c ? Utc(c) : (DateTime?)null, result.CertificateSubject, result.ServiceState, result.Partial,
}).ConfigureAwait(false);
}
/// <summary>
/// At most 20 details of at most 400 characters, so the JSON fits DetailsJson (never cut in the middle): when the
/// escaped JSON is still too long, the last details are left out, not all of them.
/// </summary>
internal static string? SerializeDetails(IReadOnlyList<SyntheticCheckDetail> details)
{
var kept = details.Take(20).Select(d => d with { Name = Cut(d.Name, 100), Value = Cut(d.Value, 400) }).ToList();
while (kept.Count > 0)
{
var json = JsonSerializer.Serialize(kept, Json);
if (json.Length <= MaxDetailsJson)
{
return json;
}
kept.RemoveAt(kept.Count - 1);
}
return null;
}
/// <summary>The size of insight.SyntheticCheckResults.DetailsJson.</summary>
internal const int MaxDetailsJson = 4000;
/// <summary>The agent could (null) or could not open a check's stored password.</summary>
public async Task SetSyntheticCheckSecretErrorAsync(int id, string? error, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
UPDATE insight.SyntheticChecks SET SecretError = LEFT(@error, 1000)
WHERE CheckId = @id AND ISNULL(SecretError, N'') <> ISNULL(@error, N'');
""", new { id, error }).ConfigureAwait(false);
}
}

View file

@ -0,0 +1,490 @@
using System.Globalization;
using System.Text.Json;
using Dapper;
using SQLInsight.Core.Servers;
namespace SQLInsight.Repository;
/// <summary>A Windows server the agent looks at (insight.WindowsServers).</summary>
public sealed record WindowsServerRow
{
public int ServerId { get; init; }
public string Name { get; init; } = string.Empty;
public string Host { get; init; } = string.Empty;
public string Role { get; init; } = ServerRoles.Other;
public string Source { get; init; } = ServerSources.Web;
public bool Paused { get; init; }
public string? WatchedServices { get; init; }
public string? IgnoredServices { get; init; }
public DateTime CreatedAt { get; init; }
public string? CreatedBy { get; init; }
public DateTime UpdatedAt { get; init; }
public string? UpdatedBy { get; init; }
public DateTime? RemovedAt { get; init; }
public DateTime? LastContactAt { get; init; }
public DateTime? LastErrorAt { get; init; }
/// <summary>Why the agent could not reach the server last time (Danish), when that is newer than the last contact.</summary>
public string? LastError { get; init; }
public string? Protocol { get; init; }
public string? OperatingSystem { get; init; }
public string? OsVersion { get; init; }
public DateTime? LastBootUpTime { get; init; }
public double? TotalMemoryMb { get; init; }
public int? LogicalProcessors { get; init; }
public string? AreasJson { get; init; }
public bool IsSqlHost => Source == ServerSources.SqlHost;
public IReadOnlyList<string> Watched => InsightRepository.ReadList(WatchedServices);
public IReadOnlyList<string> Ignored => InsightRepository.ReadList(IgnoredServices);
/// <summary>Which areas the agent could read at the last collection, and why not.</summary>
public IReadOnlyList<ServerAreaStatus> Areas =>
string.IsNullOrWhiteSpace(AreasJson) ? [] : JsonSerializer.Deserialize<List<ServerAreaStatus>>(AreasJson, InsightRepository.Json) ?? [];
/// <summary>The agent reached the server at its last attempt.</summary>
public bool Reachable => LastContactAt is { } contact && (LastErrorAt is null || LastErrorAt <= contact);
public ServerIdentity ToIdentity() => new(ServerId, Name, Host, Role) { WatchedServices = Watched, IgnoredServices = Ignored };
}
/// <summary>A new Windows server from SQLInsight Web.</summary>
public sealed record NewWindowsServer(string Name, string Host, string Role, IReadOnlyList<string> WatchedServices, IReadOnlyList<string> IgnoredServices, bool Paused = false);
/// <summary>A server's CPU and memory over time.</summary>
public sealed record StoredServerPerformance(DateTime SampledAt, double? CpuPercent, double? AvailableMb, double? AvailablePct, double? PagesPerSec, double? CommitPct);
/// <summary>
/// What the overview shows per server: the latest CPU and memory sample (within the last minutes) and the latest disk
/// collection with its forecasts.
/// </summary>
public sealed record WindowsServerOverview(int ServerId, StoredServerPerformance? Latest, ServerDiskSnapshot? Disks)
{
/// <summary>The volume with the least free space (percent).</summary>
public ServerDisk? WorstDisk => Disks?.Disks.OrderBy(d => d.FreePercent).FirstOrDefault();
/// <summary>The volume that will be full first, when any is filling up.</summary>
public DiskForecast? SoonestFull => Disks?.Forecasts.Where(f => f.DaysUntilFull is not null).OrderBy(f => f.DaysUntilFull).FirstOrDefault();
}
/// <summary>A Windows event group as stored. <see cref="Sample"/> is sensitive (local UI only).</summary>
public sealed record StoredServerEvent(long EventRowId, DateTime CollectedAt, string LogName, string Source, long EventId, byte Level, string Category, int Count,
DateTime FirstAt, DateTime LastAt, string? Sample);
public sealed partial class InsightRepository
{
private const string ServerColumns = """
ServerId, Name, Host, Role, Source, Paused, WatchedServices, IgnoredServices, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy, RemovedAt,
LastContactAt, LastErrorAt, LastError, Protocol, OperatingSystem, OsVersion, LastBootUpTime, TotalMemoryMb, LogicalProcessors, AreasJson
""";
internal static IReadOnlyList<string> ReadList(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<string>>(json, Json) ?? [];
}
catch (JsonException)
{
return [];
}
}
private static string? WriteList(IReadOnlyList<string>? list) => list is null || list.Count == 0 ? null : JsonSerializer.Serialize(list, Json);
/// <summary>The Windows servers (not removed), by name.</summary>
public async Task<IReadOnlyList<WindowsServerRow>> GetWindowsServersAsync(CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<WindowsServerRow>(
$"SELECT {ServerColumns} FROM insight.WindowsServers WHERE RemovedAt IS NULL ORDER BY Name;").ConfigureAwait(false);
return rows.ToList();
}
/// <summary>
/// The overview's figures for all servers that are looked at (not paused or removed), in one query: the newest
/// sample since <paramref name="sampledSince"/> and the latest disk collection.
/// </summary>
public async Task<IReadOnlyDictionary<int, WindowsServerOverview>> GetWindowsServerOverviewsAsync(DateTimeOffset sampledSince, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<OverviewRow>("""
SELECT w.ServerId, p.SampledAt, p.CpuPercent, p.AvailableMb, p.AvailablePct, p.PagesPerSec, p.CommitPct, s.Json AS DisksJson
FROM insight.WindowsServers w
OUTER APPLY (SELECT TOP (1) SampledAt, CAST(CpuPercent AS float) AS CpuPercent, CAST(AvailableMb AS float) AS AvailableMb,
CAST(AvailablePct AS float) AS AvailablePct, CAST(PagesPerSec AS float) AS PagesPerSec, CAST(CommitPct AS float) AS CommitPct
FROM insight.ServerPerformance WHERE ServerId = w.ServerId AND SampledAt >= @since ORDER BY SampledAt DESC) p
LEFT JOIN insight.ServerSnapshots s ON s.ServerId = w.ServerId AND s.Area = @area
WHERE w.RemovedAt IS NULL AND w.Paused = 0;
""", new { since = sampledSince.UtcDateTime, area = ServerArea.Disks }).ConfigureAwait(false);
return rows.ToDictionary(r => r.ServerId, r => new WindowsServerOverview(
r.ServerId,
r.SampledAt is { } at ? new StoredServerPerformance(at, r.CpuPercent, r.AvailableMb, r.AvailablePct, r.PagesPerSec, r.CommitPct) : null,
ReadSnapshot<ServerDiskSnapshot>(r.DisksJson)));
}
private sealed record OverviewRow(int ServerId, DateTime? SampledAt, double? CpuPercent, double? AvailableMb, double? AvailablePct, double? PagesPerSec, double? CommitPct,
string? DisksJson);
private static T? ReadSnapshot<T>(string? json)
where T : class
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
return JsonSerializer.Deserialize<T>(json, Json);
}
catch (JsonException)
{
return null;
}
}
public async Task<WindowsServerRow?> GetWindowsServerAsync(int id, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<WindowsServerRow>(
$"SELECT {ServerColumns} FROM insight.WindowsServers WHERE ServerId = @id AND RemovedAt IS NULL;", new { id }).ConfigureAwait(false);
}
/// <summary>Adds a server (SQLInsight Web: INSERT only). A name or host in use throws SqlException 2601.</summary>
public async Task<int> AddWindowsServerAsync(NewWindowsServer server, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(server);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteScalarAsync<int>("""
INSERT insight.WindowsServers (Name, Host, Role, Source, Paused, WatchedServices, IgnoredServices, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy)
OUTPUT INSERTED.ServerId
VALUES (@name, @host, @Role, 'Web', @Paused, @watched, @ignored, @now, @by, @now, @by);
""", new
{
name = server.Name.Trim(), host = ServerHost.Normalize(server.Host), server.Role, server.Paused,
watched = WriteList(server.WatchedServices), ignored = WriteList(server.IgnoredServices), by, now = Utc(now),
}).ConfigureAwait(false);
}
/// <summary>Role and the services to watch and ignore. Allowed for SQL Server hosts too.</summary>
public async Task<bool> UpdateWindowsServerAsync(int id, string role, IReadOnlyList<string> watched, IReadOnlyList<string> ignored, string? by, DateTimeOffset now,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteAsync("""
UPDATE insight.WindowsServers SET Role = @role, WatchedServices = @watched, IgnoredServices = @ignored, UpdatedAt = @now, UpdatedBy = @by
WHERE ServerId = @id AND RemovedAt IS NULL;
""", new { id, role, watched = WriteList(watched), ignored = WriteList(ignored), by, now = Utc(now) }).ConfigureAwait(false) > 0;
}
public async Task<bool> SetWindowsServerPausedAsync(int id, bool paused, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteAsync(
"UPDATE insight.WindowsServers SET Paused = @paused, UpdatedAt = @now, UpdatedBy = @by WHERE ServerId = @id AND RemovedAt IS NULL;",
new { id, paused, by, now = Utc(now) }).ConfigureAwait(false) > 0;
}
/// <summary>
/// The synthetic checks of servers just removed (in <c>@removed</c>) no longer belong to a server: they keep running,
/// without the server's service (its name is taken out of the settings, as the form and the API require a server for it).
/// </summary>
private const string DetachChecksFromRemovedServers = """
UPDATE c SET ServerId = NULL, SettingsJson = JSON_MODIFY(c.SettingsJson, '$.serviceName', NULL), UpdatedAt = @at, UpdatedBy = @checkBy
FROM insight.SyntheticChecks c JOIN @removed r ON r.ServerId = c.ServerId;
""";
/// <summary>
/// Stops looking at a server added in SQLInsight Web; its synthetic checks are kept without the server (same
/// transaction). A SQL Server host follows its instances; it can be paused.
/// </summary>
public async Task<ConfigurationChangeOutcome> RemoveWindowsServerAsync(int id, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var removed = await connection.ExecuteScalarAsync<int>($"""
SET XACT_ABORT ON;
BEGIN TRANSACTION;
DECLARE @removed TABLE (ServerId int PRIMARY KEY);
UPDATE insight.WindowsServers SET RemovedAt = @at, RemovedBy = @by, UpdatedAt = @at, UpdatedBy = @by
OUTPUT inserted.ServerId INTO @removed
WHERE ServerId = @id AND RemovedAt IS NULL AND Source = 'Web';
{DetachChecksFromRemovedServers}
COMMIT;
SELECT COUNT(*) FROM @removed;
""", new { id, by, checkBy = by, at = Utc(now) }).ConfigureAwait(false);
if (removed > 0)
{
return ConfigurationChangeOutcome.Changed;
}
return await GetWindowsServerAsync(id, cancellationToken).ConfigureAwait(false) is { IsSqlHost: true }
? ConfigurationChangeOutcome.FromConfigFile
: ConfigurationChangeOutcome.NotFound;
}
// ---- The agent ----------------------------------------------------------------------------------------------
/// <summary>
/// Brings the SQL Server hosts in step with the monitored instances: a host not yet known (from any source) is
/// added, one no instance uses any more is removed. Pause and service lists of the rows that stay are kept.
/// </summary>
public async Task SyncSqlHostServersAsync(IReadOnlyList<(string Host, string Name)> hosts, DateTimeOffset now, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(hosts);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
var at = Utc(now);
var normalized = hosts.Select(h => (Host: ServerHost.Normalize(h.Host), h.Name)).DistinctBy(h => h.Host, StringComparer.OrdinalIgnoreCase).ToList();
await connection.ExecuteAsync($"""
DECLARE @removed TABLE (ServerId int PRIMARY KEY);
UPDATE insight.WindowsServers SET RemovedAt = @at, RemovedBy = N'SQLInsight Agent: ingen overvåget instans på serveren', UpdatedAt = @at
OUTPUT inserted.ServerId INTO @removed
WHERE Source = 'SqlHost' AND RemovedAt IS NULL AND Host NOT IN (SELECT value FROM OPENJSON(@hosts));
{DetachChecksFromRemovedServers}
""", new { hosts = JsonSerializer.Serialize(normalized.Select(h => h.Host)), at, checkBy = "SQLInsight Agent (SQL Server-host fjernet)" }, transaction)
.ConfigureAwait(false);
foreach (var (host, name) in normalized)
{
await connection.ExecuteAsync("""
IF NOT EXISTS (SELECT 1 FROM insight.WindowsServers WITH (UPDLOCK, HOLDLOCK) WHERE Host = @host AND RemovedAt IS NULL)
INSERT insight.WindowsServers (Name, Host, Role, Source, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy)
VALUES (CASE WHEN EXISTS (SELECT 1 FROM insight.WindowsServers WHERE Name = @name AND RemovedAt IS NULL) THEN @host ELSE @name END,
@host, 'Sql', 'SqlHost', @at, N'SQLInsight Agent (SQL Server-host)', @at, N'SQLInsight Agent (SQL Server-host)');
""", new { host, name, at }, transaction).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>Records a successful contact: what the server is, and which areas could be read.</summary>
public async Task RecordServerContactAsync(int id, ServerOsInfo os, string? protocol, IReadOnlyList<ServerAreaStatus>? areas, DateTimeOffset now,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(os);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
UPDATE insight.WindowsServers
SET LastContactAt = @now, Protocol = @protocol, OperatingSystem = LEFT(@caption, 256), OsVersion = LEFT(@version, 64), LastBootUpTime = @boot,
TotalMemoryMb = @memory, LogicalProcessors = @processors, AreasJson = COALESCE(@areas, AreasJson)
WHERE ServerId = @id;
""", new
{
id, now = Utc(now), protocol, caption = os.Caption, version = os.Version, boot = os.LastBootUpTime is { } b ? Utc(b) : (DateTime?)null,
memory = os.TotalMemoryMb, processors = os.LogicalProcessors, areas = areas is null ? null : JsonSerializer.Serialize(areas, Json),
}).ConfigureAwait(false);
}
/// <summary>Which areas could be read (and why not), after any area's collection.</summary>
public async Task RecordServerAreasAsync(int id, IReadOnlyList<ServerAreaStatus> areas, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(areas);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("UPDATE insight.WindowsServers SET AreasJson = @areas WHERE ServerId = @id;",
new { id, areas = JsonSerializer.Serialize(areas, Json) }).ConfigureAwait(false);
}
public async Task RecordServerErrorAsync(int id, string error, DateTimeOffset now, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("UPDATE insight.WindowsServers SET LastErrorAt = @now, LastError = LEFT(@error, 1000) WHERE ServerId = @id;",
new { id, error, now = Utc(now) }).ConfigureAwait(false);
}
public async Task SaveServerPerformanceAsync(int id, ServerPerformanceSample sample, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(sample);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
IF NOT EXISTS (SELECT 1 FROM insight.ServerPerformance WHERE ServerId = @id AND SampledAt = @at)
INSERT insight.ServerPerformance (ServerId, SampledAt, CpuPercent, AvailableMb, AvailablePct, PagesPerSec, CommitPct)
VALUES (@id, @at, @cpu, @available, @availablePct, @pages, @commit);
""", new
{
id, at = Utc(sample.At), cpu = sample.CpuPercent, available = sample.AvailableMb, availablePct = sample.AvailablePercent, pages = sample.PagesPerSecond,
commit = sample.CommitPercent,
}).ConfigureAwait(false);
}
/// <summary>The samples since <paramref name="from"/>, oldest first: the agent's window after a restart, and the charts.</summary>
public async Task<IReadOnlyList<StoredServerPerformance>> GetServerPerformanceAsync(int id, DateTimeOffset from, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<StoredServerPerformance>("""
SELECT SampledAt, CAST(CpuPercent AS float) AS CpuPercent, CAST(AvailableMb AS float) AS AvailableMb, CAST(AvailablePct AS float) AS AvailablePct,
CAST(PagesPerSec AS float) AS PagesPerSec, CAST(CommitPct AS float) AS CommitPct
FROM insight.ServerPerformance WHERE ServerId = @id AND SampledAt >= @from ORDER BY SampledAt;
""", new { id, from = from.UtcDateTime }).ConfigureAwait(false);
return rows.ToList();
}
public async Task SaveServerDisksAsync(int id, DateTimeOffset at, IReadOnlyList<ServerDisk> disks, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(disks);
if (disks.Count == 0)
{
return;
}
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
INSERT insight.ServerDiskSamples (ServerId, Volume, SampledAt, SizeGb, FreeGb)
SELECT @id, Volume, @at, SizeGb, FreeGb
FROM OPENJSON(@json) WITH (Volume nvarchar(260), SizeGb float, FreeGb float) j
WHERE NOT EXISTS (SELECT 1 FROM insight.ServerDiskSamples s WHERE s.ServerId = @id AND s.Volume = j.Volume AND s.SampledAt = @at);
""", new
{
id, at = Utc(at),
json = JsonSerializer.Serialize(disks.GroupBy(d => d.Volume, StringComparer.OrdinalIgnoreCase).Select(g => g.First())
.Select(d => new { Volume = d.Volume.Length > 260 ? d.Volume[..260] : d.Volume, d.SizeGb, d.FreeGb }), Json),
}).ConfigureAwait(false);
}
/// <summary>Free space per volume since <paramref name="from"/>, averaged per hour: what the forecast is computed from.</summary>
public async Task<IReadOnlyDictionary<string, IReadOnlyList<DiskHistoryPoint>>> GetServerDiskHistoryAsync(int id, DateTimeOffset from, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(string Volume, DateTime Hour, double SizeGb, double FreeGb)>("""
SELECT Volume, DATEADD(hour, DATEDIFF(hour, 0, SampledAt), 0) AS [Hour], MAX(SizeGb), AVG(FreeGb)
FROM insight.ServerDiskSamples WHERE ServerId = @id AND SampledAt >= @from
GROUP BY Volume, DATEADD(hour, DATEDIFF(hour, 0, SampledAt), 0)
ORDER BY Volume, [Hour];
""", new { id, from = from.UtcDateTime }).ConfigureAwait(false);
return rows.GroupBy(r => r.Volume, StringComparer.OrdinalIgnoreCase).ToDictionary(
g => g.Key,
g => (IReadOnlyList<DiskHistoryPoint>)g.Select(r => new DiskHistoryPoint(new DateTimeOffset(DateTime.SpecifyKind(r.Hour, DateTimeKind.Utc)), r.SizeGb, r.FreeGb)).ToList(),
StringComparer.OrdinalIgnoreCase);
}
public async Task SaveServerEventsAsync(int id, ServerEventSnapshot snapshot, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(snapshot);
if (snapshot.Groups.Count == 0)
{
return;
}
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
INSERT insight.ServerEvents (ServerId, CollectedAt, LogName, Source, EventId, [Level], Category, [Count], FirstAt, LastAt, Sample)
SELECT @id, @at, LogName, Source, EventId, [Level], Category, [Count], FirstAt, LastAt, Sample
FROM OPENJSON(@json) WITH (LogName nvarchar(64), Source nvarchar(256), EventId bigint, [Level] tinyint, Category varchar(32), [Count] int,
FirstAt datetime2(0), LastAt datetime2(0), Sample nvarchar(500));
""", new
{
id,
at = Utc(snapshot.CapturedAt),
json = JsonSerializer.Serialize(snapshot.Groups.Select(g => new
{
LogName = Cut(g.Log, 64),
Source = Cut(g.Source, 256),
g.EventId,
Level = (byte)Math.Clamp(g.Level, 0, 255),
Category = g.Category.ToString(),
g.Count,
FirstAt = Iso(g.FirstAt),
LastAt = Iso(g.LastAt),
Sample = g.Sample is null ? null : Cut(g.Sample, 500),
}), Json),
}).ConfigureAwait(false);
static string Iso(DateTimeOffset value) => Utc(value).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture);
}
private static string Cut(string value, int length) => value.Length <= length ? value : value[..length];
/// <summary>Event groups last seen in [from, to], newest first.</summary>
public async Task<IReadOnlyList<StoredServerEvent>> GetServerEventsAsync(int id, DateTimeOffset from, DateTimeOffset to, int top = 500,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<StoredServerEvent>("""
SELECT TOP (@top) EventRowId, CollectedAt, LogName, Source, EventId, [Level], Category, [Count], FirstAt, LastAt, Sample
FROM insight.ServerEvents WHERE ServerId = @id AND LastAt >= @from AND FirstAt <= @to
ORDER BY LastAt DESC, EventRowId DESC;
""", new { id, from = from.UtcDateTime, to = to.UtcDateTime, top }).ConfigureAwait(false);
return rows.ToList();
}
/// <summary>The latest collection of an area, as JSON.</summary>
public async Task SaveServerSnapshotAsync(int id, string area, object data, DateTimeOffset capturedAt, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(data);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
MERGE insight.ServerSnapshots AS t
USING (SELECT @id AS ServerId, @area AS Area) AS s ON t.ServerId = s.ServerId AND t.Area = s.Area
WHEN MATCHED THEN UPDATE SET CapturedAt = @at, Json = @json
WHEN NOT MATCHED THEN INSERT (ServerId, Area, CapturedAt, Json) VALUES (@id, @area, @at, @json);
""", new { id, area, at = Utc(capturedAt), json = JsonSerializer.Serialize(data, data.GetType(), Json) }).ConfigureAwait(false);
}
public async Task<T?> GetServerSnapshotAsync<T>(int id, string area, CancellationToken cancellationToken = default)
where T : class
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var json = await connection.QuerySingleOrDefaultAsync<string>(
"SELECT Json FROM insight.ServerSnapshots WHERE ServerId = @id AND Area = @area;", new { id, area }).ConfigureAwait(false);
if (json is null)
{
return null;
}
try
{
return JsonSerializer.Deserialize<T>(json, Json);
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Deletes server samples, events and check results past retention, and resolved server findings, in small batches.
/// Called hourly by the agent.
/// </summary>
public async Task RunServerMaintenanceAsync(ServerRetention retention, DateTimeOffset now, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(retention);
var utc = now.UtcDateTime;
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.ServerPerformance", "SampledAt < @cutoff", utc.AddDays(-retention.SampleDays)).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.ServerDiskSamples", "SampledAt < @cutoff", utc.AddDays(-retention.DiskHistoryDays)).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.ServerEvents", "LastAt < @cutoff", utc.AddDays(-retention.EventDays)).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.SyntheticCheckResults", "RunAt < @cutoff", utc.AddDays(-retention.CheckResultDays)).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.ServerFindings", "ResolvedAt < @cutoff", utc.AddDays(-retention.ResolvedFindingDays)).ConfigureAwait(false);
// Removed servers and checks: their samples and snapshots go at once; findings and history follow retention.
await connection.ExecuteAsync("""
DELETE s FROM insight.ServerSnapshots s JOIN insight.WindowsServers w ON w.ServerId = s.ServerId WHERE w.RemovedAt IS NOT NULL;
UPDATE f SET ResolvedAt = @now FROM insight.ServerFindings f
WHERE f.ResolvedAt IS NULL AND ((f.Scope = 'Server' AND EXISTS (SELECT 1 FROM insight.WindowsServers w WHERE w.ServerId = f.ScopeId AND (w.RemovedAt IS NOT NULL OR w.Paused = 1)))
OR (f.Scope = 'Check' AND EXISTS (SELECT 1 FROM insight.SyntheticChecks c WHERE c.CheckId = f.ScopeId AND (c.RemovedAt IS NOT NULL OR c.Paused = 1))));
""", new { now = Utc(now) }, commandTimeout: 300).ConfigureAwait(false);
}
}
/// <summary>How long Windows server data is kept (SQLInsight:Servers and SQLInsight:SyntheticChecks).</summary>
public sealed record ServerRetention
{
/// <summary>Per-minute CPU and memory.</summary>
public int SampleDays { get; init; } = 14;
/// <summary>Free space per volume: must cover the forecast's history.</summary>
public int DiskHistoryDays { get; init; } = 35;
public int EventDays { get; init; } = 90;
public int CheckResultDays { get; init; } = 14;
public int ResolvedFindingDays { get; init; } = 365;
}

View file

@ -0,0 +1,202 @@
/* 1.5: Windows servers and synthetic checks.
WindowsServers the Windows servers the agent looks at: the computers behind the monitored SQL Server
instances (Source 'SqlHost', kept in step by the agent) and servers added in SQLInsight Web
(AOS and other application servers, file servers). The agent reads them remotely, as its own
identity, with least privilege (db/permissions/grant-windows-server.ps1); it never changes them.
ServerPerformance CPU, available memory, paging and commit per minute. Kept SQLInsight:Servers:SampleDays.
ServerDiskSamples free space per volume every 15 minutes: the history the "fuld om ca. N dage" forecast is
computed from. Kept SQLInsight:Servers:DiskHistoryDays.
ServerEvents errors and critical events from the System and Application logs, summed per collection,
source and event id like insight.ErrorLogEvents. Sample is a message: local only. Kept EventDays.
ServerSnapshots the latest collection per area (services, tasks, disks with forecasts …) for display, and so a
restarted agent continues where it stopped.
SyntheticChecks checks the agent runs on a schedule: TCP port, HTTP(S), SQL login + query, DNS, Dynamics AX
logon. A SQL login password is sealed with the agent's public key (insight.AgentKeys), as in 1.3.
SyntheticCheckResults every run, kept SQLInsight:SyntheticChecks:ResultDays.
ServerFindings, findings and rule outcomes of the Windows server rules (Scope 'Server') and synthetic check rules
ServerRuleStates (Scope 'Check'), like insight.Findings and insight.RuleStates for instances. */
CREATE TABLE insight.WindowsServers
(
ServerId int IDENTITY(1, 1) NOT NULL CONSTRAINT PK_WindowsServers PRIMARY KEY,
Name nvarchar(128) NOT NULL,
Host nvarchar(255) NOT NULL,
Role varchar(16) NOT NULL CONSTRAINT DF_WindowsServers_Role DEFAULT ('Other')
CONSTRAINT CK_WindowsServers_Role CHECK (Role IN ('Sql', 'Application', 'File', 'Other')),
Source varchar(16) NOT NULL CONSTRAINT DF_WindowsServers_Source DEFAULT ('Web')
CONSTRAINT CK_WindowsServers_Source CHECK (Source IN ('Web', 'SqlHost')),
Paused bit NOT NULL CONSTRAINT DF_WindowsServers_Paused DEFAULT (0),
WatchedServices nvarchar(max) NULL,
IgnoredServices nvarchar(max) NULL,
CreatedAt datetime2(0) NOT NULL CONSTRAINT DF_WindowsServers_CreatedAt DEFAULT (SYSUTCDATETIME()),
CreatedBy nvarchar(256) NULL,
UpdatedAt datetime2(0) NOT NULL CONSTRAINT DF_WindowsServers_UpdatedAt DEFAULT (SYSUTCDATETIME()),
UpdatedBy nvarchar(256) NULL,
RemovedAt datetime2(0) NULL,
RemovedBy nvarchar(256) NULL,
/* What the agent saw. */
LastContactAt datetime2(0) NULL,
LastErrorAt datetime2(0) NULL,
LastError nvarchar(1000) NULL,
Protocol varchar(16) NULL,
OperatingSystem nvarchar(256) NULL,
OsVersion nvarchar(64) NULL,
LastBootUpTime datetime2(0) NULL,
TotalMemoryMb float NULL,
LogicalProcessors int NULL,
AreasJson nvarchar(max) NULL
);
CREATE UNIQUE INDEX UQ_WindowsServers_Host ON insight.WindowsServers (Host) WHERE RemovedAt IS NULL;
CREATE UNIQUE INDEX UQ_WindowsServers_Name ON insight.WindowsServers (Name) WHERE RemovedAt IS NULL;
CREATE TABLE insight.ServerPerformance
(
ServerId int NOT NULL,
SampledAt datetime2(0) NOT NULL,
CpuPercent real NULL,
AvailableMb real NULL,
AvailablePct real NULL,
PagesPerSec real NULL,
CommitPct real NULL,
CONSTRAINT PK_ServerPerformance PRIMARY KEY (ServerId, SampledAt)
);
/* Retention deletes by time across all servers. */
CREATE INDEX IX_ServerPerformance_SampledAt ON insight.ServerPerformance (SampledAt);
CREATE TABLE insight.ServerDiskSamples
(
ServerId int NOT NULL,
Volume nvarchar(260) NOT NULL,
SampledAt datetime2(0) NOT NULL,
SizeGb float NOT NULL,
FreeGb float NOT NULL,
CONSTRAINT PK_ServerDiskSamples PRIMARY KEY (ServerId, Volume, SampledAt)
);
CREATE INDEX IX_ServerDiskSamples_SampledAt ON insight.ServerDiskSamples (SampledAt);
CREATE TABLE insight.ServerEvents
(
EventRowId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_ServerEvents PRIMARY KEY NONCLUSTERED,
ServerId int NOT NULL,
CollectedAt datetime2(0) NOT NULL,
LogName nvarchar(64) NOT NULL,
Source nvarchar(256) NOT NULL,
EventId bigint NOT NULL,
[Level] tinyint NOT NULL,
Category varchar(32) NOT NULL,
[Count] int NOT NULL,
FirstAt datetime2(0) NOT NULL,
LastAt datetime2(0) NOT NULL,
Sample nvarchar(500) NULL
);
CREATE CLUSTERED INDEX CX_ServerEvents ON insight.ServerEvents (ServerId, LastAt);
CREATE INDEX IX_ServerEvents_LastAt ON insight.ServerEvents (LastAt);
CREATE TABLE insight.ServerSnapshots
(
ServerId int NOT NULL,
Area varchar(32) NOT NULL,
CapturedAt datetime2(0) NOT NULL,
Json nvarchar(max) NOT NULL,
CONSTRAINT PK_ServerSnapshots PRIMARY KEY (ServerId, Area)
);
CREATE TABLE insight.SyntheticChecks
(
CheckId int IDENTITY(1, 1) NOT NULL CONSTRAINT PK_SyntheticChecks PRIMARY KEY,
Name nvarchar(128) NOT NULL,
Kind varchar(16) NOT NULL CONSTRAINT CK_SyntheticChecks_Kind CHECK (Kind IN ('Tcp', 'Http', 'Sql', 'Dns', 'AxLogon')),
ServerId int NULL CONSTRAINT FK_SyntheticChecks_Server REFERENCES insight.WindowsServers (ServerId),
SettingsJson nvarchar(max) NOT NULL,
SecretCipher varbinary(2048) NULL,
SecretKeyId varchar(64) NULL,
IntervalSeconds int NOT NULL CONSTRAINT DF_SyntheticChecks_Interval DEFAULT (300)
CONSTRAINT CK_SyntheticChecks_Interval CHECK (IntervalSeconds BETWEEN 60 AND 86400),
TimeoutSeconds int NOT NULL CONSTRAINT DF_SyntheticChecks_Timeout DEFAULT (15)
CONSTRAINT CK_SyntheticChecks_Timeout CHECK (TimeoutSeconds BETWEEN 1 AND 120),
Paused bit NOT NULL CONSTRAINT DF_SyntheticChecks_Paused DEFAULT (0),
CreatedAt datetime2(0) NOT NULL CONSTRAINT DF_SyntheticChecks_CreatedAt DEFAULT (SYSUTCDATETIME()),
CreatedBy nvarchar(256) NULL,
UpdatedAt datetime2(0) NOT NULL CONSTRAINT DF_SyntheticChecks_UpdatedAt DEFAULT (SYSUTCDATETIME()),
UpdatedBy nvarchar(256) NULL,
RemovedAt datetime2(0) NULL,
RemovedBy nvarchar(256) NULL,
SecretError nvarchar(1000) NULL,
/* What the agent saw. */
LastRunAt datetime2(0) NULL,
LastSuccess bit NULL,
LastDurationMs int NULL,
LastMessage nvarchar(1000) NULL,
ConsecutiveFailures int NOT NULL CONSTRAINT DF_SyntheticChecks_Failures DEFAULT (0)
);
CREATE UNIQUE INDEX UQ_SyntheticChecks_Name ON insight.SyntheticChecks (Name) WHERE RemovedAt IS NULL;
CREATE TABLE insight.SyntheticCheckResults
(
ResultId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_SyntheticCheckResults PRIMARY KEY NONCLUSTERED,
CheckId int NOT NULL,
RunAt datetime2(0) NOT NULL,
Success bit NOT NULL,
DurationMs int NOT NULL,
Message nvarchar(1000) NOT NULL,
DetailsJson nvarchar(4000) NULL,
CertificateExpiresAt datetime2(0) NULL,
CertificateSubject nvarchar(400) NULL,
ServiceState varchar(32) NULL,
[Partial] bit NOT NULL CONSTRAINT DF_SyntheticCheckResults_Partial DEFAULT (0)
);
CREATE CLUSTERED INDEX CX_SyntheticCheckResults ON insight.SyntheticCheckResults (CheckId, RunAt);
CREATE INDEX IX_SyntheticCheckResults_RunAt ON insight.SyntheticCheckResults (RunAt);
CREATE TABLE insight.ServerFindings
(
FindingId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_ServerFindings PRIMARY KEY,
Scope varchar(8) NOT NULL CONSTRAINT CK_ServerFindings_Scope CHECK (Scope IN ('Server', 'Check')),
ScopeId int NOT NULL,
RuleId varchar(40) NOT NULL,
Subject nvarchar(400) NOT NULL,
Severity tinyint NOT NULL,
Title nvarchar(1000) NOT NULL,
Explanation nvarchar(max) NOT NULL,
Recommendation nvarchar(max) NOT NULL,
EvidenceJson nvarchar(max) NOT NULL,
ReferencesJson nvarchar(max) NOT NULL,
FirstSeenAt datetime2(0) NOT NULL,
LastSeenAt datetime2(0) NOT NULL,
ResolvedAt datetime2(0) NULL
);
CREATE UNIQUE INDEX UX_ServerFindings_Active ON insight.ServerFindings (Scope, ScopeId, RuleId, Subject) WHERE ResolvedAt IS NULL;
CREATE INDEX IX_ServerFindings_Resolved ON insight.ServerFindings (ResolvedAt) WHERE ResolvedAt IS NOT NULL;
CREATE TABLE insight.ServerRuleStates
(
Scope varchar(8) NOT NULL,
ScopeId int NOT NULL,
RuleId varchar(40) NOT NULL,
RuleTitle nvarchar(400) NOT NULL,
Outcome tinyint NOT NULL,
Reason nvarchar(max) NULL,
EvaluatedAt datetime2(0) NOT NULL,
CONSTRAINT PK_ServerRuleStates PRIMARY KEY (Scope, ScopeId, RuleId)
);
/* Requests about a Windows server or a synthetic check name it in TargetId. */
ALTER TABLE insight.AgentRequests ADD TargetId int NULL;
ALTER TABLE insight.AgentRequests DROP CONSTRAINT CK_AgentRequests_Kind;
ALTER TABLE insight.AgentRequests ADD CONSTRAINT CK_AgentRequests_Kind
CHECK (Kind IN ('ConnectionTest', 'DatabaseDiscovery', 'InstanceDiscovery', 'ServerTest', 'SyntheticCheckRun'));
GO
CREATE INDEX IX_AgentRequests_Target ON insight.AgentRequests (Kind, TargetId, RequestId) WHERE TargetId IS NOT NULL;
GO
/* SQLInsight Web only has db_datareader. As in 0016 it gets exactly what it needs to change the configuration: add
servers and checks, change what may be changed, and remove (RemovedAt, no DELETE; a removed check's sealed password
and last message are cleared, and checks of a removed server lose the server). Nothing else the agent records. Host,
Name of a server and Kind of a check are fixed once created. Who may change it in the web UI is decided by
SQLInsight:Web:AdminGroups. */
GRANT INSERT ON insight.WindowsServers TO public;
GRANT UPDATE ON insight.WindowsServers (Role, Paused, WatchedServices, IgnoredServices, UpdatedAt, UpdatedBy, RemovedAt, RemovedBy) TO public;
GRANT INSERT ON insight.SyntheticChecks TO public;
GRANT UPDATE ON insight.SyntheticChecks (Name, ServerId, SettingsJson, SecretCipher, SecretKeyId, IntervalSeconds, TimeoutSeconds, Paused,
UpdatedAt, UpdatedBy, RemovedAt, RemovedBy, SecretError, LastMessage) TO public;

View file

@ -0,0 +1,90 @@
using SQLInsight.Core.Collection;
using SQLInsight.Core.Findings;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Servers;
/// <summary>
/// A rule about something that is not a SQL Server instance (a Windows server, a synthetic check). It produces the
/// same findings, explanations and outcomes as the instance rules, against its own kind of context.
/// </summary>
public interface IScopedRule<in TContext>
{
/// <summary>Stable identifier, e.g. <c>SQLI-WIN-001</c>. Never reused for a different check.</summary>
string Id { get; }
string Title { get; }
RuleEvaluation Evaluate(TContext context);
}
public abstract class ScopedRule<TContext> : IScopedRule<TContext>
{
public abstract string Id { get; }
public abstract string Title { get; }
public abstract RuleEvaluation Evaluate(TContext context);
protected Finding CreateFinding(Severity severity, string title, string explanation, string recommendation, IEnumerable<Evidence>? evidence = null,
string? subject = null, params Reference[] references) => new()
{
RuleId = Id,
Severity = severity,
Title = title,
Subject = subject,
Explanation = explanation,
Recommendation = recommendation,
Evidence = evidence?.ToList() ?? [],
References = references,
};
protected static RuleEvaluation Result(IEnumerable<Finding> findings)
{
var list = findings.ToArray();
return list.Length == 0 ? RuleEvaluation.Passed() : RuleEvaluation.Found(list);
}
}
/// <summary>Runs scoped rules. Like <see cref="RuleEngine"/>: a rule that cannot run is not measurable (with the reason) or an error, never silently passed.</summary>
public static class ScopedRuleEngine
{
public static IReadOnlyList<RuleResult> Evaluate<TContext>(IEnumerable<IScopedRule<TContext>> rules, TContext context)
{
ArgumentNullException.ThrowIfNull(rules);
return rules.Select(rule => Evaluate(rule, context)).ToList();
}
private static RuleResult Evaluate<TContext>(IScopedRule<TContext> rule, TContext context)
{
try
{
var evaluation = rule.Evaluate(context);
return new RuleResult { RuleId = rule.Id, RuleTitle = rule.Title, Outcome = evaluation.Outcome, Findings = evaluation.Findings, Reason = evaluation.Reason };
}
catch (DataUnavailableException ex)
{
return new RuleResult
{
RuleId = rule.Id,
RuleTitle = rule.Title,
Outcome = ex.NotApplicable ? RuleOutcome.NotApplicable : RuleOutcome.NotMeasurable,
Reason = ex.Message,
};
}
#pragma warning disable CA1031 // One faulty rule must not stop the others; the failure is reported, not hidden.
catch (Exception ex)
#pragma warning restore CA1031
{
return new RuleResult { RuleId = rule.Id, RuleTitle = rule.Title, Outcome = RuleOutcome.Error, Reason = $"{ex.GetType().Name}: {ex.Message}" };
}
}
}
/// <summary>Every rule about Windows servers and synthetic checks, for rule listings.</summary>
public static class ServerRuleCatalog
{
public static IReadOnlyList<(string Id, string Title)> All =>
WindowsServerRules.Create().Select(r => (r.Id, r.Title))
.Concat(SyntheticCheckRules.Create().Select(r => (r.Id, r.Title)))
.ToList();
}

View file

@ -0,0 +1,133 @@
using SQLInsight.Core.Collection;
using SQLInsight.Core.Servers;
namespace SQLInsight.Rules.Servers;
/// <summary>
/// Thresholds of the Windows server rules (SQLInsight:Servers in the agent's appsettings.json). Not sealed: the agent's
/// options add how and how often to collect.
/// </summary>
public class ServerRuleSettings
{
public CpuThresholds Cpu { get; set; } = new();
public MemoryThresholds Memory { get; set; } = new();
public DiskThresholds Disks { get; set; } = new();
public ServiceThresholds Services { get; set; } = new();
public EventLogThresholds EventLog { get; set; } = new();
public TaskThresholds Tasks { get; set; } = new();
/// <summary>A server not restarted for more than this many days is a (low) finding: it most likely lacks Windows updates. 0: off.</summary>
public int UptimeDays { get; set; }
}
public sealed class CpuThresholds
{
/// <summary>Average CPU at or above this (percent) …</summary>
public double HighPercent { get; set; } = 90;
/// <summary>… for this many minutes is a finding (SQLI-WIN-001).</summary>
public int SustainedMinutes { get; set; } = 15;
}
public sealed class MemoryThresholds
{
/// <summary>Available memory below this share (percent) …</summary>
public double AvailablePercent { get; set; } = 5;
/// <summary>… and below this many MB is a finding (SQLI-WIN-002). Both, so big SQL Server hosts with little left by design are not flagged.</summary>
public double AvailableMegabytes { get; set; } = 1024;
/// <summary>Hard page faults per second above this, while memory is low, is paging (SQLI-WIN-003).</summary>
public double PagesPerSecond { get; set; } = 1000;
/// <summary>Committed memory at or above this share of the commit limit (percent) is a finding (SQLI-WIN-004).</summary>
public double CommitPercent { get; set; } = 90;
}
public sealed class DiskThresholds
{
/// <summary>Free space below this (percent) is a finding (SQLI-WIN-005) …</summary>
public double FreePercent { get; set; } = 10;
/// <summary>… High below this …</summary>
public double CriticalFreePercent { get; set; } = 5;
/// <summary>… unless there is at least this much free (GB): a big volume with a small share free can be fine.</summary>
public double EnoughFreeGigabytes { get; set; } = 50;
/// <summary>A volume forecast to be full within this many days is a finding (SQLI-WIN-006); within 7 days High, 2 days Critical.</summary>
public int ForecastDays { get; set; } = 30;
/// <summary>How many days of SQLInsight's own samples the forecast is computed from.</summary>
public int ForecastHistoryDays { get; set; } = 14;
}
public sealed class ServiceThresholds
{
/// <summary>
/// Services that may be stopped although they start automatically (they stop by design), in addition to the
/// built-in list. Name, or a prefix ending in *.
/// </summary>
public IList<string> Ignore { get; } = [];
/// <summary>Right after a restart services are still starting: no finding for this many minutes.</summary>
public int StartupGraceMinutes { get; set; } = 15;
/// <summary>
/// At most this many findings for stopped automatic services per server (SQLI-WIN-007), by name; the rest are named in
/// one summary finding. Between 1 and 100.
/// </summary>
public int MaxFindings { get; set; } = 15;
}
public sealed class EventLogThresholds
{
/// <summary>An ordinary error must repeat this many times in 24 hours before it is a finding; the important kinds count at once.</summary>
public int RepeatThreshold { get; set; } = 5;
/// <summary>Events left out as noise, in addition to the built-in list: "Kilde:id", "Kilde" or "Kilde*".</summary>
public IList<string> Ignore { get; } = [];
/// <summary>At most this many event findings per server, most important first; the rest are named in one summary finding. Between 1 and 100.</summary>
public int MaxFindings { get; set; } = 15;
/// <summary>At most this many events per log are read in one collection; the rest is read next time.</summary>
public int MaxEventsPerCollection { get; set; } = 2000;
}
public sealed class TaskThresholds
{
/// <summary>Also watch Microsoft's own tasks (\Microsoft\…). Off: there are hundreds, and their failures are rarely yours.</summary>
public bool IncludeMicrosoftTasks { get; set; }
/// <summary>A task whose next run time is more than this many minutes in the past has not run as planned (SQLI-WIN-013).</summary>
public int LateMinutes { get; set; } = 60;
/// <summary>At most this many tasks per server are watched (their run information is read one at a time).</summary>
public int MaxWatched { get; set; } = 50;
/// <summary>
/// Leave out the tasks programs create per user (a user's SID in the name) or to update themselves (OneDrive, Edge,
/// Google, User_Feed_Synchronization; <see cref="TaskResults.DefaultIgnoredNames"/>). On by default.
/// </summary>
public bool IgnoreUserAndUpdaterTasks { get; set; } = true;
/// <summary>Tasks never watched (path\name, or a prefix ending in *).</summary>
public IList<string> Ignore { get; } = [];
/// <summary>Whether the task (its full path) is watched: the agent's filter when it reads tasks, and the rules' too.</summary>
public bool Watches(string fullName)
{
ArgumentNullException.ThrowIfNull(fullName);
return (IncludeMicrosoftTasks || !TaskResults.IsMicrosoftTask(fullName))
&& !(IgnoreUserAndUpdaterTasks && TaskResults.IsUserOrUpdaterTask(fullName))
&& !WindowsServerRules.Matches(fullName, Ignore);
}
}
/// <summary>What the Windows server rules see: the server, the latest data per area, and the thresholds.</summary>
public sealed record ServerRuleContext(ServerIdentity Server, DateTimeOffset Now, CollectedData Data, ServerRuleSettings Settings)
{
/// <summary>The interval performance samples are taken at, for "sustained" averages.</summary>
public TimeSpan PerformanceInterval { get; init; } = ServerArea.DefaultInterval(ServerArea.Performance);
}

View file

@ -0,0 +1,229 @@
using System.Globalization;
using SQLInsight.Core.Findings;
using SQLInsight.Core.SyntheticChecks;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Servers;
/// <summary>Thresholds of the synthetic check rules (SQLInsight:SyntheticChecks in the agent's appsettings.json).</summary>
public class SyntheticCheckRuleSettings
{
/// <summary>A check must fail this many times in a row before it is a finding, so one lost packet is not an alarm.</summary>
public int FailuresBeforeFinding { get; set; } = 2;
/// <summary>A slow answer (above the check's limit) this many times in a row is a finding.</summary>
public int SlowRunsBeforeFinding { get; set; } = 3;
/// <summary>A TLS certificate expiring within this many days is a finding …</summary>
public int CertificateWarningDays { get; set; } = 21;
/// <summary>… High within this many days.</summary>
public int CertificateCriticalDays { get; set; } = 7;
}
/// <summary>A configured check as the rules see it.</summary>
public sealed record SyntheticCheckIdentity(int CheckId, string Name, string Kind, string Target)
{
public string? ServerName { get; init; }
public string? ServiceName { get; init; }
public int? WarnMilliseconds { get; init; }
public bool Https { get; init; }
}
/// <param name="Recent">The latest results, newest first.</param>
public sealed record SyntheticCheckRuleContext(SyntheticCheckIdentity Check, DateTimeOffset Now, IReadOnlyList<SyntheticCheckResult> Recent, SyntheticCheckRuleSettings Settings);
public static class SyntheticCheckRules
{
public static IReadOnlyList<IScopedRule<SyntheticCheckRuleContext>> Create() => [new SyntheticCheckFailingRule(), new SyntheticCheckSlowRule(), new CertificateExpiryRule()];
internal static string Time(DateTimeOffset value) => value.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture);
}
public sealed class SyntheticCheckFailingRule : ScopedRule<SyntheticCheckRuleContext>
{
public override string Id => "SQLI-SYN-001";
public override string Title => "Et syntetisk tjek fejler";
public override RuleEvaluation Evaluate(SyntheticCheckRuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (context.Recent.Count == 0)
{
return RuleEvaluation.NotMeasurable("Tjekket har ikke kørt endnu.");
}
var needed = Math.Max(1, context.Settings.FailuresBeforeFinding);
var failures = context.Recent.TakeWhile(r => !r.Success).ToList();
if (failures.Count < needed)
{
return RuleEvaluation.Passed();
}
var check = context.Check;
var latest = failures[0];
// Only the results the agent passed are known: when every one of them failed, the run of failures may be longer.
var all = failures.Count == context.Recent.Count;
var times = (all ? "mindst " : string.Empty) + failures.Count.ToString(CultureInfo.InvariantCulture);
var serviceRunning = string.Equals(latest.ServiceState, "Running", StringComparison.OrdinalIgnoreCase);
var serviceMissing = string.Equals(latest.ServiceState, NotFound, StringComparison.OrdinalIgnoreCase);
var serviceKnown = check.ServiceName is not null && latest.ServiceState is not null;
// The title goes to the API and to notifications (Teams, webhooks, e-mail) as it is: never the failure message,
// which can name hosts, logins and paths. That is in the evidence, marked sensitive.
var title = !serviceKnown ? $"{check.Name} fejler ({times} gange i træk)"
: serviceRunning ? $"{check.Name} svarer ikke, selv om tjenesten {check.ServiceName} kører"
: serviceMissing ? $"{check.Name} svarer ikke: tjenesten {check.ServiceName} findes ikke"
: $"{check.Name} svarer ikke: tjenesten {check.ServiceName} er {StateText(latest.ServiceState!)}";
var explanation = What(check.Kind)
+ (!serviceKnown ? string.Empty
: serviceRunning
? $" Tjenesten {check.ServiceName} på {check.ServerName} kører, så Windows ser alt som i orden, men den svarer ikke: den kan hænge, være overbelastet, "
+ "vente på databasen eller være blokeret af en firewall. Det er netop den slags fejl, en tjenesteovervågning alene ikke ser."
: serviceMissing
? $" Tjenesten {check.ServiceName} findes ikke på {check.ServerName} (eller agenten må ikke se den): den kan være afinstalleret eller omdøbt, "
+ "eller tjekket er knyttet til et forkert tjenestenavn."
: $" Tjenesten {check.ServiceName} på {check.ServerName} kører ikke.");
var evidence = new List<Evidence>
{
new("Fejl", latest.Message, Sensitive: true),
new("Mål", check.Target),
new("Fejlet i træk", times),
new("Fejler siden", all
? $"{SyntheticCheckRules.Time(failures[^1].At)} eller tidligere (ældre resultater er ikke med i vurderingen)"
: SyntheticCheckRules.Time(failures[^1].At)),
new("Seneste forsøg", SyntheticCheckRules.Time(latest.At)),
};
if (check.ServiceName is not null)
{
// Not "Tjeneste": the agent adds its own detail by that name.
evidence.Add(new Evidence("Tjenestens tilstand", $"{check.ServiceName}: {(latest.ServiceState is { } state ? StateText(state) : "kunne ikke aflæses")}"));
}
evidence.AddRange(latest.Details.Select(d => new Evidence(d.Name, d.Value, d.Sensitive)));
return RuleEvaluation.Found(CreateFinding(
Severity.High,
title,
explanation,
Recommendation(check.Kind, serviceKnown && serviceRunning),
evidence,
subject: "check"));
}
/// <summary>The service state the agent records when the check's service does not exist on the server.</summary>
private const string NotFound = "NotFound";
private static string StateText(string state) => state switch
{
"Running" => "kører",
"Stopped" => "stoppet",
"Start Pending" => "ved at starte",
"Stop Pending" => "ved at stoppe",
"Continue Pending" => "ved at fortsætte",
"Pause Pending" => "ved at gå på pause",
"Paused" => "sat på pause",
NotFound => "findes ikke",
_ => $"i tilstanden \"{state}\"",
};
private static string What(string kind) => kind switch
{
SyntheticCheckKind.Tcp => "Porten svarer ikke: de programmer og brugere, der forbinder til den, kan heller ikke.",
SyntheticCheckKind.Http => "Siden svarer ikke som forventet: brugere og integrationer, der bruger den, får fejl eller venter.",
SyntheticCheckKind.Sql => "Login eller forespørgsel mod SQL Server fejler: applikationer, der bruger samme vej ind, kan heller ikke arbejde.",
SyntheticCheckKind.Dns => "Navnet kan ikke slås op (eller peger forkert hen): alt, der forbinder via navnet, fejler eller rammer den forkerte server.",
SyntheticCheckKind.AxLogon => "Dynamics AX kan ikke nås: AOS-porten svarer ikke, eller et logon gennem Business Connector fejler. Brugere og batchjob kan ikke arbejde.",
_ => "Tjekket fejler.",
};
private static string Recommendation(string kind, bool serviceRunning) =>
(serviceRunning ? "Se tjenestens egen log og Windows' hændelseslog på serveren; en tjeneste, der kører men ikke svarer, skal ofte genstartes af en driftsansvarlig. " : string.Empty)
+ kind switch
{
SyntheticCheckKind.Tcp => "Tjek at serveren og tjenesten kører, og at firewallen tillader porten fra agentens server.",
SyntheticCheckKind.Http => "Åbn adressen i en browser fra agentens server, og se IIS-/webserverens log og applikationspuljen.",
SyntheticCheckKind.Sql => "Prøv at logge ind med samme login fra agentens server, og se SQL Server-fundene og fejlloggen (fx fejl 18456).",
SyntheticCheckKind.Dns => "Slå navnet op med nslookup fra agentens server, og tjek DNS-serverne og posterne.",
SyntheticCheckKind.AxLogon => "Tjek AOS-tjenesten og dens hændelseslog, at AX-databasen svarer, og at agentens konto er AX-bruger, hvis logon testes.",
_ => string.Empty,
} + " SQLInsight genstarter aldrig selv noget.";
}
public sealed class SyntheticCheckSlowRule : ScopedRule<SyntheticCheckRuleContext>
{
public override string Id => "SQLI-SYN-002";
public override string Title => "Et syntetisk tjek svarer langsomt";
public override RuleEvaluation Evaluate(SyntheticCheckRuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (context.Check.WarnMilliseconds is not { } limit)
{
return RuleEvaluation.NotApplicable("Der er ikke sat en grænse for svartid på tjekket.");
}
var needed = Math.Max(1, context.Settings.SlowRunsBeforeFinding);
var latest = context.Recent.Take(needed).ToList();
if (latest.Count < needed || latest.Any(r => !r.Success || r.DurationMs <= limit))
{
return context.Recent.Count == 0 ? RuleEvaluation.NotMeasurable("Tjekket har ikke kørt endnu.") : RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
Severity.Medium,
$"{context.Check.Name} svarer langsomt ({latest.Min(r => r.DurationMs).ToString("N0", CultureInfo.GetCultureInfo("da-DK"))}–{latest.Max(r => r.DurationMs).ToString("N0", CultureInfo.GetCultureInfo("da-DK"))} ms)",
"Tjekket lykkes, men svaret har de seneste gange taget længere end grænsen. Brugerne oplever det som en langsom applikation, længe før den holder op med at svare.",
"Se belastningen på serveren (CPU, hukommelse, disk) og på databasen bag i samme tidsrum, fx med Analyse af tidsrum.",
[
new Evidence("Svartider", string.Join(", ", latest.Select(r => r.DurationMs.ToString(CultureInfo.InvariantCulture) + " ms"))),
new Evidence("Grænse", limit.ToString(CultureInfo.InvariantCulture) + " ms"),
new Evidence("Mål", context.Check.Target),
],
subject: "slow"));
}
}
public sealed class CertificateExpiryRule : ScopedRule<SyntheticCheckRuleContext>
{
public override string Id => "SQLI-SYN-003";
public override string Title => "TLS-certifikatet udløber snart";
public override RuleEvaluation Evaluate(SyntheticCheckRuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (context.Check.Kind != SyntheticCheckKind.Http || !context.Check.Https)
{
return RuleEvaluation.NotApplicable("Kun for HTTPS-tjek.");
}
var latest = context.Recent.FirstOrDefault(r => r.CertificateExpiresAt is not null);
if (latest?.CertificateExpiresAt is not { } expires)
{
return RuleEvaluation.NotMeasurable("Certifikatet er ikke set endnu (tjekket har ikke fået forbindelse).");
}
var days = (expires - context.Now).TotalDays;
if (days > context.Settings.CertificateWarningDays)
{
return RuleEvaluation.Passed();
}
var severity = days <= 0 ? Severity.Critical : days <= context.Settings.CertificateCriticalDays ? Severity.High : Severity.Medium;
return RuleEvaluation.Found(CreateFinding(
severity,
days <= 0
? $"Certifikatet for {context.Check.Name} er udløbet"
: days < 1 ? $"Certifikatet for {context.Check.Name} udløber inden for et døgn"
: days < 2 ? $"Certifikatet for {context.Check.Name} udløber om 1 dag"
: $"Certifikatet for {context.Check.Name} udløber om {Math.Floor(days).ToString(CultureInfo.InvariantCulture)} dage",
"Når certifikatet udløber, afviser browsere og integrationer forbindelsen (eller viser en sikkerhedsadvarsel), og siden er i praksis nede.",
"Forny certifikatet og bind det nye i webserveren (IIS: Bindinger på webstedet). Tjek også, om andre servere bruger samme certifikat.",
[
new Evidence("Udløber", SyntheticCheckRules.Time(expires)),
new Evidence("Certifikat", latest.CertificateSubject ?? "–"),
new Evidence("Adresse", context.Check.Target),
],
subject: "certificate"));
}
}

View file

@ -0,0 +1,697 @@
using System.Globalization;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Servers;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Servers;
/// <summary>The rules about Windows servers (SQL Server hosts, AOS and other application servers, file servers).</summary>
public static class WindowsServerRules
{
public static IReadOnlyList<IScopedRule<ServerRuleContext>> Create() =>
[
new ServerCpuRule(),
new ServerMemoryRule(),
new ServerPagingRule(),
new ServerCommitRule(),
new DiskFreeSpaceRule(),
new DiskForecastRule(),
new StoppedAutomaticServiceRule(),
new WatchedServiceRule(),
new WindowsEventLogRule(),
new ServerRestartRule(),
new ServerUptimeRule(),
new FailedScheduledTaskRule(),
new MissedScheduledTaskRule(),
];
internal static string Number(double value, int decimals = 0) => value.ToString("N" + decimals.ToString(CultureInfo.InvariantCulture), CultureInfo.GetCultureInfo("da-DK"));
internal static string Time(DateTimeOffset? value) => value is { } v ? v.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) : "–";
internal static Reference Learn(string title, string path) => new(title, new Uri("https://learn.microsoft.com/" + path));
/// <summary>A name in a list where entries may end in * (prefix).</summary>
internal static bool Matches(string name, IEnumerable<string> patterns) => patterns.Any(p => Matches(name, p));
private static bool Matches(string name, string pattern) =>
pattern.EndsWith('*') ? name.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase) : name.Equals(pattern, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Whether a service is meant by a name in a service list (watched, ignored): its name or its display name, either
/// one exactly or by a prefix ending in *. SQLI-WIN-007 and SQLI-WIN-008 match the same way, so a service is never
/// reported by both or "not found" by one and found by the other.
/// </summary>
internal static bool IsService(ServerService service, string pattern) => Matches(service.Name, pattern) || Matches(service.DisplayName, pattern);
internal static bool IsService(ServerService service, IEnumerable<string> patterns) => patterns.Any(p => IsService(service, p));
/// <summary>A "max findings" setting, kept between 1 and 100.</summary>
internal static int Bounded(int maxFindings) => Math.Clamp(maxFindings, 1, 100);
/// <summary>Names for the summary of the findings over the cap: at most 50, then "og N flere".</summary>
internal static string List(IReadOnlyCollection<string> names) =>
string.Join(", ", names.Take(50)) + (names.Count > 50 ? $" og {names.Count - 50} flere" : string.Empty);
}
public sealed class ServerCpuRule : ScopedRule<ServerRuleContext>
{
public override string Id => "SQLI-WIN-001";
public override string Title => "CPU-forbruget er højt i længere tid";
public override RuleEvaluation Evaluate(ServerRuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var performance = context.Data.Get<ServerPerformanceSnapshot>();
var settings = context.Settings.Cpu;
var period = TimeSpan.FromMinutes(Math.Max(1, settings.SustainedMinutes));
var average = ServerPerformanceMath.SustainedAverage(performance.Samples, s => s.CpuPercent, period, context.Now, context.PerformanceInterval);
if (average is null)
{
return !performance.Samples.Any(s => s.CpuPercent is not null)
? RuleEvaluation.NotMeasurable("CPU måles som forskellen mellem to målinger; der er endnu ikke to.")
: RuleEvaluation.Passed();
}
if (average < settings.HighPercent)
{
return RuleEvaluation.Passed();
}
var max = performance.Samples.Where(s => s.At > context.Now - period).Max(s => s.CpuPercent ?? 0);
return RuleEvaluation.Found(CreateFinding(
average >= 98 ? Severity.Critical : Severity.High,
$"CPU har været {WindowsServerRules.Number(average.Value)} % i gennemsnit de seneste {settings.SustainedMinutes} minutter",
"Serverens processorer har været næsten fuldt belastet i længere tid. Alt på serveren bliver langsommere: forespørgsler, AOS-kald, "
+ "fildeling og logon. Ved kortvarige toppe er det normalt; vedvarende høj CPU er det ikke.",
"Find processen med Jobliste/Ressourceovervågning eller Performance Monitor (Process(*)\\% Processor Time). På en SQL Server-host: se Top-SQL for "
+ "instansen i samme tidsrum. På en AOS: se batchjob og brugere i AX. Tjek også, om antivirus scanner database- eller AX-filer.",
[
new Evidence("Gennemsnit", WindowsServerRules.Number(average.Value, 1) + " %"),
new Evidence("Højeste minut", WindowsServerRules.Number(max, 1) + " %"),
new Evidence("Periode", $"{settings.SustainedMinutes} minutter"),
new Evidence("Grænse", WindowsServerRules.Number(settings.HighPercent) + " %"),
new Evidence("Logiske processorer", performance.Os.LogicalProcessors?.ToString(CultureInfo.InvariantCulture) ?? "–"),
],
subject: "cpu"));
}
}
public sealed class ServerMemoryRule : ScopedRule<ServerRuleContext>
{
public override string Id => "SQLI-WIN-002";
public override string Title => "Serveren har næsten ingen ledig hukommelse";
public override RuleEvaluation Evaluate(ServerRuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var performance = context.Data.Get<ServerPerformanceSnapshot>();
var settings = context.Settings.Memory;
var period = TimeSpan.FromMinutes(5);
var availableMb = ServerPerformanceMath.SustainedAverage(performance.Samples, s => s.AvailableMb, period, context.Now, context.PerformanceInterval);
var availablePercent = ServerPerformanceMath.SustainedAverage(performance.Samples, s => s.AvailablePercent, period, context.Now, context.PerformanceInterval);
if (availableMb is null || availablePercent is null)
{
return performance.Samples.Any(s => s.AvailableMb is not null) ? RuleEvaluation.Passed() : RuleEvaluation.NotMeasurable("Ledig hukommelse kunne ikke aflæses.");
}
if (availablePercent >= settings.AvailablePercent || availableMb >= settings.AvailableMegabytes)
{
return RuleEvaluation.Passed();
}
var severe = availablePercent < 2 && availableMb < 256;
return RuleEvaluation.Found(CreateFinding(
severe ? Severity.High : Severity.Medium,
$"Kun {WindowsServerRules.Number(availableMb.Value)} MB ledig hukommelse ({WindowsServerRules.Number(availablePercent.Value, 1)} %)",
"Når Windows løber tør for ledig hukommelse, flyttes data til sidefilen på disken, og alt bliver langsomt. Processer kan fejle med "
+ "\"out of memory\", og SQL Server kan blive tvunget til at give hukommelse tilbage.",
"Find den proces, der bruger hukommelsen (Jobliste, kolonnen Hukommelse/Commit). På en SQL Server-host: sæt max server memory, så Windows har "
+ "mindst 2–4 GB (mere på store servere). På en AOS eller terminalserver: se antal sessioner og brugere. Overvej mere RAM.",
[
new Evidence("Ledig hukommelse (5 min.)", WindowsServerRules.Number(availableMb.Value) + " MB"),
new Evidence("Andel ledig", WindowsServerRules.Number(availablePercent.Value, 1) + " %"),
new Evidence("Hukommelse i alt", performance.Os.TotalMemoryMb is { } total ? WindowsServerRules.Number(total) + " MB" : "–"),
new Evidence("Grænse", $"under {WindowsServerRules.Number(settings.AvailablePercent)} % og {WindowsServerRules.Number(settings.AvailableMegabytes)} MB"),
],
subject: "memory",
WindowsServerRules.Learn("Server memory configuration options", "sql/database-engine/configure-windows/server-memory-server-configuration-options")));
}
}
public sealed class ServerPagingRule : ScopedRule<ServerRuleContext>
{
public override string Id => "SQLI-WIN-003";
public override string Title => "Serveren pager hukommelse til disk";
public override RuleEvaluation Evaluate(ServerRuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var performance = context.Data.Get<ServerPerformanceSnapshot>();
var settings = context.Settings.Memory;
var period = TimeSpan.FromMinutes(15);
var pages = ServerPerformanceMath.SustainedAverage(performance.Samples, s => s.PagesPerSecond, period, context.Now, context.PerformanceInterval);
var available = ServerPerformanceMath.SustainedAverage(performance.Samples, s => s.AvailablePercent, period, context.Now, context.PerformanceInterval);
if (pages is null)
{
return performance.Samples.Any(s => s.PagesPerSecond is not null)
? RuleEvaluation.Passed()
: RuleEvaluation.NotMeasurable("Paging måles som forskellen mellem to målinger af Memory\\Pages/sec; der er endnu ikke to.");
}
// Pages/sec alone is also file I/O through memory-mapped files; together with little free memory it is paging.
if (pages < settings.PagesPerSecond || available is null or >= 10)
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
Severity.Medium,
$"Serveren pager {WindowsServerRules.Number(pages.Value)} sider pr. sekund med lidt ledig hukommelse",
"Windows læser og skriver hukommelsessider til sidefilen, fordi der ikke er plads i RAM. Det er mange gange langsommere end hukommelse og "
+ "belaster disken, som andre også bruger.",
"Se SQLI-WIN-002 for hvem der bruger hukommelsen. Reducér forbruget (max server memory, færre sessioner) eller tilføj RAM.",
[
new Evidence("Pages/sec (15 min.)", WindowsServerRules.Number(pages.Value)),
new Evidence("Andel ledig hukommelse", WindowsServerRules.Number(available.Value, 1) + " %"),
new Evidence("Grænse", WindowsServerRules.Number(settings.PagesPerSecond) + " pages/sec"),
],
subject: "paging"));
}
}
public sealed class ServerCommitRule : ScopedRule<ServerRuleContext>
{
public override string Id => "SQLI-WIN-004";
public override string Title => "Den reserverede hukommelse (commit) er tæt på grænsen";
public override RuleEvaluation Evaluate(ServerRuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var performance = context.Data.Get<ServerPerformanceSnapshot>();
var commit = ServerPerformanceMath.SustainedAverage(performance.Samples, s => s.CommitPercent, TimeSpan.FromMinutes(5), context.Now, context.PerformanceInterval);
if (commit is null)
{
return performance.Samples.Any(s => s.CommitPercent is not null) ? RuleEvaluation.Passed() : RuleEvaluation.NotMeasurable("Commit-grænsen kunne ikke aflæses.");
}
if (commit < context.Settings.Memory.CommitPercent)
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
commit >= 97 ? Severity.High : Severity.Medium,
$"{WindowsServerRules.Number(commit.Value)} % af commit-grænsen er brugt",
"Commit charge er den hukommelse, programmerne har reserveret (RAM plus sidefil). Når grænsen nås, kan Windows ikke give mere hukommelse ud: "
+ "programmer og tjenester fejler eller går ned, selv om der måske er ledig RAM.",
"Find processen med størst commit (Jobliste, kolonnen Commit-størrelse). Et program, der vokser støt, lækker hukommelse. Øg sidefilen eller RAM, "
+ "hvis forbruget er forventet.",
[new Evidence("Commit (5 min.)", WindowsServerRules.Number(commit.Value, 1) + " %"), new Evidence("Grænse", WindowsServerRules.Number(context.Settings.Memory.CommitPercent) + " %")],
subject: "commit"));
}
}
public sealed class DiskFreeSpaceRule : ScopedRule<ServerRuleContext>
{
public override string Id => "SQLI-WIN-005";
public override string Title => "Lidt ledig plads på et drev";
public override RuleEvaluation Evaluate(ServerRuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var disks = context.Data.Get<ServerDiskSnapshot>();
var settings = context.Settings.Disks;
return Result(disks.Disks
.Where(d => d.SizeGb > 0 && d.FreePercent < settings.FreePercent && d.FreeGb < settings.EnoughFreeGigabytes)
.Select(d =>
{
var severity = d.FreeGb < 1 || d.FreePercent < 1 ? Severity.Critical : d.FreePercent < settings.CriticalFreePercent ? Severity.High : Severity.Medium;
var forecast = disks.Forecasts.FirstOrDefault(f => string.Equals(f.Volume, d.Volume, StringComparison.OrdinalIgnoreCase));
return CreateFinding(
severity,
$"Drev {d.Volume} har kun {WindowsServerRules.Number(d.FreeGb, 1)} GB ledig ({WindowsServerRules.Number(d.FreePercent, 1)} %)",
"Når et drev løber fuldt, stopper alt, der skriver til det: SQL Server kan ikke vokse data- og logfiler (databasen bliver skrivebeskyttet "
+ "i praksis), backup fejler, AOS og Windows kan ikke skrive logfiler og midlertidige filer, og brugere kan ikke gemme.",
"Find hvad der fylder (fx med WinDirStat eller TreeSize), ryd op i gamle backupfiler, logfiler og midlertidige filer, eller udvid drevet. "
+ "Flyt ikke database- eller logfiler uden at planlægge det.",
[
new Evidence("Ledig", WindowsServerRules.Number(d.FreeGb, 1) + " GB"),
new Evidence("Størrelse", WindowsServerRules.Number(d.SizeGb, 1) + " GB"),
new Evidence("Andel ledig", WindowsServerRules.Number(d.FreePercent, 1) + " %"),
new Evidence("Prognose", forecast?.Describe() ?? "–"),
new Evidence("Navn", d.Label ?? "–"),
],
subject: "disk: " + d.Volume);
}));
}
}
public sealed class DiskForecastRule : ScopedRule<ServerRuleContext>
{
public override string Id => "SQLI-WIN-006";
public override string Title => "Et drev bliver fuldt om få dage";
public override RuleEvaluation Evaluate(ServerRuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var disks = context.Data.Get<ServerDiskSnapshot>();
var settings = context.Settings.Disks;
if (disks.Disks.Count > 0 && !disks.Forecasts.Any(f => f.EnoughHistory))
{
var note = disks.Forecasts.Select(f => f.Note).FirstOrDefault(n => n is not null);
return RuleEvaluation.NotMeasurable("Prognosen beregnes ud fra SQLInsights egne målinger af ledig plads over mindst et par dage; "
+ (note is null ? "der er endnu ingen historik." : $"der er ikke nok endnu ({note})."));
}
return Result(disks.Forecasts
.Where(f => f.DaysUntilFull is { } days && days <= settings.ForecastDays)
.Select(f =>
{
var days = f.DaysUntilFull!.Value;
var disk = disks.Disks.FirstOrDefault(d => string.Equals(d.Volume, f.Volume, StringComparison.OrdinalIgnoreCase));
return CreateFinding(
days <= 2 ? Severity.Critical : days <= 7 ? Severity.High : Severity.Medium,
days <= 0 ? $"Drev {f.Volume} er fuldt"
: days < 1 ? $"Drev {f.Volume} er fuldt inden for et døgn"
: $"Drev {f.Volume} er fuldt om ca. {WindowsServerRules.Number(Math.Round(days))} dage",
$"Pladsen på drevet er de seneste {WindowsServerRules.Number(f.HistoryDays)} dage brugt med ca. {WindowsServerRules.Number(f.GrowthGbPerDay, 1)} GB "
+ "om dagen. Fortsætter det, er drevet fuldt, og så stopper alt, der skriver til det (databaser, backup, logfiler). Prognosen er en ret linje "
+ "gennem SQLInsights egne målinger og tager ikke højde for oprydning, der ikke er sket endnu.",
"Find hvad der vokser (databasefiler, backup, logfiler, brugerdata), og ryd op, flyt eller udvid drevet i god tid. Tjek at oprydningsjob og "
+ "backup-rotation faktisk kører.",
[
new Evidence("Fuldt om ca.", WindowsServerRules.Number(days, days < 10 ? 1 : 0) + " dage"),
new Evidence("Forventet fuld", f.FullAt(disks.CapturedAt) is { } at ? at.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) : "–"),
new Evidence("Vækst pr. dag", WindowsServerRules.Number(f.GrowthGbPerDay, 2) + " GB"),
new Evidence("Ledig nu", disk is null ? "–" : WindowsServerRules.Number(disk.FreeGb, 1) + " GB af " + WindowsServerRules.Number(disk.SizeGb, 1) + " GB"),
new Evidence("Historik", $"{WindowsServerRules.Number(f.HistoryDays, 1)} dage, {f.Samples} målinger, R² {WindowsServerRules.Number(f.Fit, 2)}"),
],
subject: "forecast: " + f.Volume);
}));
}
}
/// <summary>Services set to start automatically that are not running, except those that stop by design.</summary>
public sealed class StoppedAutomaticServiceRule : ScopedRule<ServerRuleContext>
{
/// <summary>Automatic services that stop by themselves when they have nothing to do.</summary>
public static readonly IReadOnlyList<string> StopByDesign =
[
"sppsvc", "gupdate", "gupdatem", "edgeupdate", "edgeupdatem", "MapsBroker", "RemoteRegistry", "tiledatamodelsvc", "WbioSrvc", "DoSvc", "BITS",
"wuauserv", "TrustedInstaller", "ShellHWDetection", "clr_optimization_*", "OneSyncSvc_*", "CDPUserSvc_*", "GoogleUpdater*", "MozillaMaintenance",
"WaaSMedicSvc", "UsoSvc", "sedsvc", "AGMService", "AdobeARMservice", "Intel(R) TPM Provisioning Service", "SysmonLog", "TabletInputService",
];
public override string Id => "SQLI-WIN-007";
public override string Title => "En tjeneste, der skal starte automatisk, kører ikke";
public override RuleEvaluation Evaluate(ServerRuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var services = context.Data.Get<ServerServiceSnapshot>();
if (GraceReason(context) is { } grace)
{
return RuleEvaluation.NotMeasurable(grace);
}
var ignore = StopByDesign.Concat(context.Settings.Services.Ignore).Concat(context.Server.IgnoredServices).ToList();
var stopped = services.Services
.Where(s => s.IsAutomatic && !s.IsRunning && !WindowsServerRules.IsService(s, ignore) && !WindowsServerRules.IsService(s, context.Server.WatchedServices))
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var max = WindowsServerRules.Bounded(context.Settings.Services.MaxFindings);
var findings = stopped.Take(max).Select(One).ToList();
if (stopped.Count > max)
{
var rest = stopped.Skip(max).Select(s => s.Name).ToList();
findings.Add(CreateFinding(
Severity.Medium,
$"{rest.Count} automatiske tjenester mere kører ikke",
$"Ud over de {max} tjenester med egne fund står yderligere {rest.Count} tjenester, der er sat til at starte automatisk, som ikke kørende. "
+ "Så mange stoppede tjenester på én gang skyldes ofte en fælles årsag: en tjeneste, de afhænger af, en konto med udløbet adgangskode, "
+ "eller en genstart, der ikke blev fuldført.",
"Se Windows' hændelseslog (System, Service Control Manager) og listen her. Tjenester, der skal være stoppet, sættes til Manuel eller "
+ "Deaktiveret eller tilføjes til listen over tjenester, der ignoreres for serveren. SQLInsight starter aldrig selv tjenester.",
[
new Evidence("Tjenester", WindowsServerRules.List(rest)),
new Evidence("Fund pr. tjeneste", $"højst {max} (SQLInsight:Servers:Services:MaxFindings)"),
],
subject: "service: (flere)"));
}
return Result(findings);
Finding One(ServerService s) => CreateFinding(
Severity.Medium,
$"Tjenesten {s.DisplayName} ({s.Name}) kører ikke",
$"Tjenesten er sat til at starte automatisk, men står som \"{s.State}\"" + (s.ExitCode is { } code and not 0 and not 1077 ? $" (afslutningskode {code})" : string.Empty)
+ ". Det, tjenesten gør, sker ikke, og en automatisk tjeneste, der er stoppet, er oftest gået ned.",
"Se Windows' hændelseslog (System, Service Control Manager) for hvorfor tjenesten stoppede, og start den. SQLInsight starter aldrig selv tjenester. "
+ "Skal tjenesten ikke køre, så sæt den til Manuel eller Deaktiveret, eller tilføj den til listen over tjenester, der ignoreres for serveren.",
[
new Evidence("Tjeneste", s.Name),
new Evidence("Visningsnavn", s.DisplayName),
new Evidence("Status", s.State),
new Evidence("Start", s.StartMode + (s.DelayedAutoStart ? " (forsinket)" : string.Empty)),
new Evidence("Afslutningskode", s.ExitCode?.ToString(CultureInfo.InvariantCulture) ?? "–"),
],
subject: "service: " + s.Name);
}
/// <summary>
/// Right after a restart services are still starting. Not "passed": that would resolve open findings at every restart
/// and open them again a quarter of an hour later. Not measurable keeps them as they are.
/// </summary>
internal static string? GraceReason(ServerRuleContext context)
{
if (!context.Data.TryGet<ServerPerformanceSnapshot>(out var performance) || performance.Os.LastBootUpTime is not { } boot)
{
return null;
}
var left = TimeSpan.FromMinutes(context.Settings.Services.StartupGraceMinutes) - (context.Now - boot);
return left > TimeSpan.Zero
? $"Serveren er lige startet; tjenester vurderes igen efter {Math.Max(1, (int)Math.Ceiling(left.TotalMinutes)).ToString(CultureInfo.InvariantCulture)} minutter."
: null;
}
}
/// <summary>Services chosen for the server (the AOS, IIS, SQL Server …) that must run, whatever their start mode.</summary>
public sealed class WatchedServiceRule : ScopedRule<ServerRuleContext>
{
public override string Id => "SQLI-WIN-008";
public override string Title => "En overvåget tjeneste kører ikke";
public override RuleEvaluation Evaluate(ServerRuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (context.Server.WatchedServices.Count == 0)
{
return RuleEvaluation.NotApplicable("Der er ikke valgt tjenester at overvåge på serveren.");
}
var services = context.Data.Get<ServerServiceSnapshot>();
if (StoppedAutomaticServiceRule.GraceReason(context) is { } grace)
{
return RuleEvaluation.NotMeasurable(grace);
}
var findings = new List<Finding>();
var reported = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in context.Server.WatchedServices)
{
var matching = services.Services.Where(s => WindowsServerRules.IsService(s, name)).OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).ToList();
if (matching.Count == 0)
{
findings.Add(CreateFinding(
Severity.Medium,
$"Den overvågede tjeneste {name} findes ikke eller kan ikke ses",
"Tjenesten står på listen over tjenester, der skal køre på serveren, men Windows viser den ikke. Den kan være afinstalleret eller omdøbt, "
+ "eller agenten må ikke se den (tjenestens egne sikkerhedsindstillinger).",
"Tjek tjenestens navn eller visningsnavn i services.msc (en * til sidst matcher alle, der begynder sådan), og ret listen. Kør tildelingsscriptet igen, hvis agenten mangler rettigheder.",
[new Evidence("Tjeneste", name), new Evidence("Tjenester agenten kan se", services.Services.Count.ToString(CultureInfo.InvariantCulture))],
subject: "watched: " + name));
continue;
}
foreach (var service in matching.Where(s => !s.IsRunning && reported.Add(s.Name)))
{
findings.Add(CreateFinding(
Severity.High,
$"Tjenesten {service.DisplayName} ({service.Name}) kører ikke",
$"Tjenesten er valgt som en, der skal køre på serveren, men står som \"{service.State}\". Det den leverer (fx AOS'en for AX-brugerne, IIS, "
+ "SQL Server) er ikke tilgængeligt.",
"Se Windows' hændelseslog (System og Application) for hvorfor den stoppede, og start den igen. SQLInsight starter aldrig selv tjenester.",
[
new Evidence("Tjeneste", service.Name),
new Evidence("Status", service.State),
new Evidence("Start", service.StartMode),
new Evidence("Afslutningskode", service.ExitCode?.ToString(CultureInfo.InvariantCulture) ?? "–"),
],
subject: "watched: " + service.Name));
}
}
return Result(findings);
}
}
public sealed class WindowsEventLogRule : ScopedRule<ServerRuleContext>
{
public override string Id => "SQLI-WIN-009";
public override string Title => "Fejl i Windows' hændelseslog";
public override RuleEvaluation Evaluate(ServerRuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var events = context.Data.Get<ServerEventSnapshot>();
var settings = context.Settings.EventLog;
// A stable order (kind, then the oldest first, then the key), so the same groups keep their findings from one
// evaluation to the next instead of trading places as counts change.
var relevant = events.Recent
.Where(g => g.Category != ServerEventCategory.UnexpectedShutdown)
.Where(g => g.Category != ServerEventCategory.Error || g.Count >= Math.Max(1, settings.RepeatThreshold))
.OrderByDescending(g => g.Category).ThenBy(g => g.FirstAt).ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
.ToList();
var max = WindowsServerRules.Bounded(settings.MaxFindings);
var findings = relevant.Take(max).Select(One).ToList();
if (relevant.Count > max)
{
var rest = relevant.Skip(max).ToList();
findings.Add(CreateFinding(
rest.Max(SeverityOf),
$"{rest.Count} hændelser mere i Windows' hændelseslog",
$"Ud over de {max} hændelser med egne fund er der yderligere {rest.Count} slags fejl i hændelsesloggen det seneste døgn. "
+ "Så mange forskellige fejl på én gang har ofte en fælles årsag (en disk, netværket, en tjeneste mange andre afhænger af).",
"Se hændelserne i Logbog (Event Viewer) på serveren, de vigtigste først. Harmløse kilder kan ignoreres under SQLInsight:Servers:EventLog:Ignore.",
[
new Evidence("Hændelser", WindowsServerRules.List(rest.Select(g => $"{g.Source} {g.EventId} ({g.Count})").ToList())),
new Evidence("Fund pr. hændelse", $"højst {max} (SQLInsight:Servers:EventLog:MaxFindings)"),
],
subject: "event: (flere)"));
}
return Result(findings);
Finding One(ServerEventGroup g) => CreateFinding(
SeverityOf(g),
$"{ServerEventAnalysis.Display(g.Category)}: {g.Source} {g.EventId} ({g.Count} {(g.Count == 1 ? "gang" : "gange")} i {g.Log}-loggen)",
Explanation(g.Category),
Recommendation(g.Category),
[
new Evidence("Log", g.Log),
new Evidence("Kilde", g.Source),
new Evidence("Hændelses-id", g.EventId.ToString(CultureInfo.InvariantCulture)),
new Evidence("Niveau", g.Level == 1 ? "Kritisk" : "Fejl"),
new Evidence("Antal (24 timer)", g.Count.ToString(CultureInfo.InvariantCulture)),
new Evidence("Første", WindowsServerRules.Time(g.FirstAt)),
new Evidence("Seneste", WindowsServerRules.Time(g.LastAt)),
new Evidence("Eksempel", g.Sample ?? "(ingen tekst)", Sensitive: true),
],
subject: "event: " + g.Key);
}
private static Severity SeverityOf(ServerEventGroup group) => group.Category switch
{
ServerEventCategory.Disk or ServerEventCategory.Resources or ServerEventCategory.Critical => Severity.High,
ServerEventCategory.ServiceFailure => group.Count >= 3 ? Severity.High : Severity.Medium,
ServerEventCategory.DynamicsAx or ServerEventCategory.ApplicationCrash => Severity.Medium,
_ => Severity.Low,
};
private static string Explanation(ServerEventCategory category) => category switch
{
ServerEventCategory.Disk =>
"Windows har haft fejl mod en disk, controller eller filsystemet: timeouts, nulstillinger eller sider, der ikke kunne læses eller skrives. "
+ "Det går forud for datatab og meget langsom I/O, også i SQL Server (fejl 823/824/833).",
ServerEventCategory.Resources =>
"Windows er løbet tør for en ressource (hukommelse, nonpaged pool, virtuel hukommelse). Programmer og tjenester fejler, når de ikke kan få mere.",
ServerEventCategory.Critical => "Windows har logget en kritisk hændelse: den højeste alvorsgrad, som bruges, når noget vigtigt ikke virker.",
ServerEventCategory.ServiceFailure =>
"En tjeneste stoppede uventet eller kunne ikke starte. Det, tjenesten leverer, er væk, indtil den startes igen (eller Windows genstarter den).",
ServerEventCategory.DynamicsAx =>
"Dynamics AX' Application Object Server (AOS) har logget fejl. De kan betyde, at brugere eller batchjob får fejl, eller at AOS'en har problemer "
+ "med databasen.",
ServerEventCategory.ApplicationCrash => "Et program eller en tjeneste er gået ned eller holdt op med at svare. Det, programmet lavede, blev afbrudt.",
_ => "Den samme fejl er logget gentagne gange det seneste døgn. Gentagne fejl er sjældent tilfældige.",
};
private static string Recommendation(ServerEventCategory category) => category switch
{
ServerEventCategory.Disk =>
"Se hændelsen i Logbog (Event Viewer) på serveren, og tjek disk, controller, drivere og SAN/hypervisor. Kør CHECKDB på databaser på drevet, "
+ "og sørg for gyldige backups.",
ServerEventCategory.Resources => "Find processen, der bruger ressourcen (Jobliste, poolmon for nonpaged pool), og se SQLI-WIN-002 og -004.",
ServerEventCategory.ServiceFailure => "Se den næste hændelse fra tjenesten selv for årsagen, og tjek tjenestekontoens adgangskode og afhængigheder.",
ServerEventCategory.DynamicsAx => "Se hele hændelsen i Logbog på AOS-serveren og AOS'ens egen log. Gentagne databasefejl: se også SQL Server-fundene for AX-databasen.",
ServerEventCategory.ApplicationCrash => "Hændelsen nævner programmet og modulet, der fejlede. Søg på dem, installér opdateringer, og se om nedbruddet gentager sig.",
_ => "Se hændelsen i Logbog (Event Viewer) og slå kilden og hændelses-id'et op. Er den harmløs, kan den ignoreres under SQLInsight:Servers:EventLog:Ignore.",
};
}
/// <summary>A restart in the last day: unexpected (crash, power, blue screen) or planned.</summary>
public sealed class ServerRestartRule : ScopedRule<ServerRuleContext>
{
public override string Id => "SQLI-WIN-010";
public override string Title => "Serveren er genstartet";
public override RuleEvaluation Evaluate(ServerRuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var performance = context.Data.Get<ServerPerformanceSnapshot>();
if (performance.Os.LastBootUpTime is not { } boot)
{
return RuleEvaluation.NotMeasurable("Starttidspunktet kunne ikke aflæses (Win32_OperatingSystem.LastBootUpTime).");
}
if (context.Now - boot >= ServerEventAnalysis.RecentWindow)
{
return RuleEvaluation.Passed();
}
context.Data.TryGet<ServerEventSnapshot>(out var events);
var crash = events?.Recent.FirstOrDefault(g => g.Category == ServerEventCategory.UnexpectedShutdown && g.LastAt >= boot.AddHours(-1));
var evidence = new List<Evidence>
{
new("Startet", WindowsServerRules.Time(boot)),
new("Oppe i", Engine.Format.Age(context.Now - boot)),
};
if (crash is not null)
{
evidence.Add(new Evidence("Hændelse", $"{crash.Source} {crash.EventId}"));
evidence.Add(new Evidence("Tekst", crash.Sample ?? "–", Sensitive: true));
return RuleEvaluation.Found(CreateFinding(
Severity.High,
$"Serveren genstartede uventet {WindowsServerRules.Time(boot)}",
"Windows blev ikke lukket ned ordentligt: strømmen forsvandt, serveren gik ned (blå skærm) eller blev nulstillet hårdt, fx af hypervisoren. "
+ "Alt, der kørte, blev afbrudt midt i det, og databaser og AX skal gendanne sig selv ved start.",
"Se Logbog (System) omkring tidspunktet: Kernel-Power 41, EventLog 6008 og BugCheck 1001 fortæller hvorfor. Ved blå skærm: se dumpfilen i "
+ "C:\\Windows\\Minidump. Tjek hypervisor- og strømlogs, og at SQL Server-databaser og AOS kom korrekt op.",
evidence,
subject: "restart"));
}
if (events is null)
{
// The event log cannot be read: say that the cause is unknown rather than call it planned.
return RuleEvaluation.Found(CreateFinding(
Severity.Low,
$"Serveren er genstartet {WindowsServerRules.Time(boot)} (årsagen kan ikke ses)",
"Serveren er genstartet det seneste døgn. Agenten kan ikke læse Windows' hændelseslog på serveren, så det vides ikke, om genstarten var "
+ "planlagt (fx Windows-opdateringer) eller uventet (strøm, blå skærm, hård nulstilling). Fundet forsvinder af sig selv efter et døgn.",
"Se Logbog (System) omkring tidspunktet: User32 1074 er en planlagt genstart; Kernel-Power 41, EventLog 6008 og BugCheck 1001 en uventet. "
+ "Giv agenten læseret til hændelsesloggen (Event Log Readers, se tildelingsscriptet), så SQLInsight selv kan skelne.",
evidence,
subject: "restart"));
}
if (events.ReadUntil < boot || events.CapturedAt <= boot)
{
// Kernel-Power 41 and EventLog 6008 are written while Windows starts; until the log is read past the start they cannot have been seen.
return RuleEvaluation.NotMeasurable("Hændelsesloggen er ikke læst efter genstarten endnu, så det vides ikke, om den var planlagt eller uventet. "
+ "Det vurderes, når hændelsesloggen er indsamlet igen.");
}
return RuleEvaluation.Found(CreateFinding(
Severity.Info,
$"Serveren er genstartet {WindowsServerRules.Time(boot)}",
"Serveren er genstartet det seneste døgn, uden tegn på at det skete uventet (fx planlagte Windows-opdateringer). Fundet er til orientering og "
+ "forsvinder af sig selv efter et døgn.",
"Ingen handling, hvis genstarten var planlagt. Var den ikke, så se Logbog (System, User32 1074) for hvem eller hvad der genstartede serveren.",
evidence,
subject: "restart"));
}
}
public sealed class ServerUptimeRule : ScopedRule<ServerRuleContext>
{
public override string Id => "SQLI-WIN-011";
public override string Title => "Serveren er ikke genstartet i lang tid";
public override RuleEvaluation Evaluate(ServerRuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (context.Settings.UptimeDays <= 0)
{
return RuleEvaluation.NotApplicable("Slået fra (SQLInsight:Servers:UptimeDays er 0).");
}
var performance = context.Data.Get<ServerPerformanceSnapshot>();
if (performance.Os.LastBootUpTime is not { } boot)
{
return RuleEvaluation.NotMeasurable("Starttidspunktet kunne ikke aflæses.");
}
var uptime = context.Now - boot;
return uptime.TotalDays < context.Settings.UptimeDays
? RuleEvaluation.Passed()
: RuleEvaluation.Found(CreateFinding(
Severity.Low,
$"Serveren har kørt i {WindowsServerRules.Number(uptime.TotalDays)} dage uden genstart",
"De fleste Windows-opdateringer kræver en genstart for at virke. En server, der ikke er genstartet i flere måneder, mangler derfor næsten "
+ "sikkert sikkerhedsopdateringer.",
"Planlæg et servicevindue og installér Windows-opdateringer. Tjek at opdateringsværktøjet (WSUS, Intune, …) faktisk når serveren.",
[new Evidence("Startet", WindowsServerRules.Time(boot)), new Evidence("Grænse", $"{context.Settings.UptimeDays} dage")],
subject: "uptime"));
}
}
public sealed class FailedScheduledTaskRule : ScopedRule<ServerRuleContext>
{
public override string Id => "SQLI-WIN-012";
public override string Title => "En planlagt opgave fejlede sidste gang, den kørte";
public override RuleEvaluation Evaluate(ServerRuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var tasks = context.Data.Get<ServerTaskSnapshot>();
return Result(tasks.Tasks
.Where(t => t.Enabled && TaskResults.IsFailure(t.LastTaskResult) && context.Settings.Tasks.Watches(t.FullName))
.Select(t => CreateFinding(
Severity.Medium,
$"Opgaven {TaskResults.Display(t.FullName)} fejlede: {TaskResults.Describe(t.LastTaskResult!.Value)}",
"Opgaven i Opgavestyring (Task Scheduler) sluttede med en fejlkode sidste gang, den kørte. Planlagte opgaver er ofte oprydning, eksport, "
+ "integrationer eller backup af filer: når de fejler, opdager ingen det, før noget mangler.",
"Åbn opgaven i Opgavestyring, se fanen Historik og opgavens egen log. Tjek kontoen, opgaven kører som (adgangskode, rettigheder), og at "
+ "programmet eller scriptet findes.",
[
new Evidence("Opgave", t.FullName, Sensitive: TaskResults.HasUserSid(t.FullName)),
new Evidence("Resultat", TaskResults.Describe(t.LastTaskResult.Value)),
new Evidence("Seneste kørsel", WindowsServerRules.Time(t.LastRunTime)),
new Evidence("Næste kørsel", WindowsServerRules.Time(t.NextRunTime)),
],
subject: "task: " + TaskResults.Display(t.FullName))));
}
}
public sealed class MissedScheduledTaskRule : ScopedRule<ServerRuleContext>
{
public override string Id => "SQLI-WIN-013";
public override string Title => "En planlagt opgave kørte ikke, da den skulle";
public override RuleEvaluation Evaluate(ServerRuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var tasks = context.Data.Get<ServerTaskSnapshot>();
var late = TimeSpan.FromMinutes(Math.Max(1, context.Settings.Tasks.LateMinutes));
return Result(tasks.Tasks
.Where(t => t.Enabled && !string.Equals(t.State, "Running", StringComparison.OrdinalIgnoreCase)
&& context.Settings.Tasks.Watches(t.FullName)
&& (t.MissedRuns > 0 || (t.NextRunTime is { } next && next < context.Now - late && (t.LastRunTime is null || t.LastRunTime < next))))
.Select(t => CreateFinding(
Severity.Medium,
t.MissedRuns > 0 ? $"Opgaven {TaskResults.Display(t.FullName)} har sprunget {t.MissedRuns} kørsler over" : $"Opgaven {TaskResults.Display(t.FullName)} kørte ikke {WindowsServerRules.Time(t.NextRunTime)}",
"Opgaven skulle have kørt, men gjorde det ikke. Det sker, når Opgavestyring ikke kører, når opgavens betingelser ikke er opfyldt (fx \"kun når "
+ "brugeren er logget på\"), eller når serveren var slukket. Det, opgaven gør, er ikke sket.",
"Se opgavens Historik i Opgavestyring og dens betingelser og indstillinger (\"Kør opgaven hurtigst muligt efter en mistet start\").",
[
new Evidence("Opgave", t.FullName, Sensitive: TaskResults.HasUserSid(t.FullName)),
new Evidence("Planlagt", WindowsServerRules.Time(t.NextRunTime)),
new Evidence("Seneste kørsel", WindowsServerRules.Time(t.LastRunTime)),
new Evidence("Mistede kørsler", t.MissedRuns.ToString(CultureInfo.InvariantCulture)),
new Evidence("Status", t.State),
],
subject: "task-missed: " + TaskResults.Display(t.FullName))));
}
}

View file

@ -0,0 +1,46 @@
using System.Globalization;
namespace SQLInsight.Servers;
/// <summary>
/// The exact CIM/WQL queries and event log filters the agent uses (documented per rule in SQL Scripts/SQLI-WIN-*.sql).
/// All are SELECTs: nothing is changed on the server.
/// </summary>
public static class CimQueries
{
public const string CimV2 = @"root\cimv2";
public const string TaskSchedulerNamespace = @"root\Microsoft\Windows\TaskScheduler";
// The exact queries (documented per rule in SQL Scripts/SQLI-WIN-*.sql).
public const string OperatingSystemQuery = "SELECT Caption, Version, BuildNumber, LastBootUpTime, TotalVisibleMemorySize, CSName FROM Win32_OperatingSystem";
public const string ComputerSystemQuery = "SELECT NumberOfLogicalProcessors FROM Win32_ComputerSystem";
public const string ProcessorQuery = "SELECT PercentProcessorTime, Timestamp_Sys100NS FROM Win32_PerfRawData_PerfOS_Processor WHERE Name = '_Total'";
public const string MemoryQuery = "SELECT AvailableMBytes, PagesPersec, CommittedBytes, CommitLimit, Timestamp_PerfTime, Frequency_PerfTime FROM Win32_PerfRawData_PerfOS_Memory";
public const string VolumeQuery = "SELECT Name, Label, FileSystem, Capacity, FreeSpace FROM Win32_Volume WHERE DriveType = 3";
public const string LogicalDiskQuery = "SELECT DeviceID, VolumeName, FileSystem, Size, FreeSpace FROM Win32_LogicalDisk WHERE DriveType = 3";
public const string ServiceQuery = "SELECT Name, DisplayName, State, StartMode, DelayedAutoStart, ExitCode FROM Win32_Service";
public const string ServiceQueryWithoutDelayed = "SELECT Name, DisplayName, State, StartMode, ExitCode FROM Win32_Service";
public const string TaskQuery = "SELECT TaskName, TaskPath, State FROM MSFT_ScheduledTask";
/// <summary>The XPath for the event log: errors and critical events after a time (ISO 8601, UTC).</summary>
public static string EventXPath(DateTimeOffset since) =>
"*[System[(Level=1 or Level=2) and TimeCreated[@SystemTime>'" + since.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture) + "']]]";
/// <summary>The CIM fallback for the event log (EventType 1 = error; critical events are reported as errors).</summary>
public static string EventQuery(string log, DateTimeOffset since) =>
$"SELECT Logfile, SourceName, EventCode, EventType, TimeGenerated, RecordNumber, Message FROM Win32_NTLogEvent WHERE Logfile = '{Wql(log)}' AND EventType = 1 AND TimeGenerated > '{Dmtf(since)}'";
public static string SingleServiceQuery(string name) =>
$"SELECT Name, DisplayName, State, StartMode, ExitCode FROM Win32_Service WHERE Name = '{Wql(name)}'";
/// <summary>A WQL string literal's content: backslash and quote escaped.</summary>
public static string Wql(string value)
{
ArgumentNullException.ThrowIfNull(value);
return value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("'", "\\'", StringComparison.Ordinal);
}
/// <summary>CIM datetime (DMTF), UTC: yyyymmddHHMMSS.mmmmmm+000.</summary>
public static string Dmtf(DateTimeOffset value) => value.UtcDateTime.ToString("yyyyMMddHHmmss.ffffff", CultureInfo.InvariantCulture) + "+000";
}

View file

@ -0,0 +1,734 @@
using System.Diagnostics.Eventing.Reader;
using static SQLInsight.Servers.CimQueries;
using System.Globalization;
using System.Runtime.Versioning;
using System.Security.Principal;
using Microsoft.Management.Infrastructure;
using Microsoft.Management.Infrastructure.Options;
using SQLInsight.Core.Servers;
namespace SQLInsight.Servers;
/// <summary>How the agent connects to Windows servers (SQLInsight:Servers in the agent's appsettings.json).</summary>
public sealed class ServerConnectionOptions
{
public static readonly TimeSpan MinOperationTimeout = TimeSpan.FromSeconds(5);
public static readonly TimeSpan MaxOperationTimeout = TimeSpan.FromMinutes(5);
/// <summary>Auto (WS-Man, then DCOM), WSMan or DCOM.</summary>
public string Protocol { get; set; } = "Auto";
/// <summary>How long one CIM operation may take (5 seconds to 5 minutes).</summary>
public TimeSpan OperationTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>Read event logs with the Event Log service's own protocol (MS-EVEN6), which filters on the server; CIM (Win32_NTLogEvent) is the fallback.</summary>
public bool EventLogRpc { get; set; } = true;
/// <summary><paramref name="timeout"/> within 5 seconds and 5 minutes: 0, negative or hours would fail or hang every read.</summary>
public static TimeSpan BoundTimeout(TimeSpan timeout) =>
timeout < MinOperationTimeout ? MinOperationTimeout : timeout > MaxOperationTimeout ? MaxOperationTimeout : timeout;
}
/// <summary>
/// Reads Windows servers with CIM: WS-Man (WinRM, TCP 5985) first, DCOM (RPC, TCP 135 + dynamic ports) if that fails,
/// as the agent's own Windows identity. Only queries (and Task Scheduler's GetInfo) are used: nothing is changed.
/// What worked for a server (DCOM instead of WS-Man, CIM instead of the event log's RPC protocol) is remembered for an
/// hour, so a refused protocol is not tried again every cycle.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class CimWindowsServerSource : IWindowsServerSource
{
public const string WsMan = "WS-Man";
public const string Dcom = "DCOM";
private const string ProtocolFact = "protocol";
private const string EventLogRpcFact = "eventlog-rpc";
private const string TasksOverDcomFact = "tasks-dcom";
private readonly ServerConnectionOptions _options;
private readonly string[] _protocols;
private readonly TimeProvider _time;
public CimWindowsServerSource(ServerConnectionOptions options, TimeProvider? time = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = new ServerConnectionOptions
{
Protocol = options.Protocol,
OperationTimeout = ServerConnectionOptions.BoundTimeout(options.OperationTimeout),
EventLogRpc = options.EventLogRpc,
};
_protocols = (options.Protocol ?? "Auto").ToUpperInvariant() switch
{
"WSMAN" or "WS-MAN" => [WsMan],
"DCOM" => [Dcom],
_ => [WsMan, Dcom],
};
_time = time ?? TimeProvider.System;
Memory = new ServerHostMemory(_time, ServerHostMemory.DefaultFor);
}
/// <summary>What was learned per server (the protocol that works, a blocked event log protocol).</summary>
public ServerHostMemory Memory { get; }
/// <summary>Both protocols may be used (Protocol Auto).</summary>
private bool Auto => _protocols.Length > 1;
/// <summary>The longest one read of the event logs may take: one operation per log.</summary>
private TimeSpan EventReadTimeout => _options.OperationTimeout * ServerEventAnalysis.Logs.Count;
private sealed record WorkingProtocol(string Protocol, string? Note);
public async Task<IWindowsServerSession> ConnectAsync(string host, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(host);
var account = AgentAccount(host);
if (ServerHost.IsLocal(host, Environment.MachineName))
{
var session = new Session(this, CimSession.Create(null), host, "lokal", null, account);
try
{
await session.ProbeAsync(cancellationToken).ConfigureAwait(false);
return session;
}
catch
{
session.Dispose();
throw;
}
}
// The protocol that worked last time first (for an hour), so a refused WS-Man is not waited on every cycle.
var remembered = Auto ? Memory.Recall<WorkingProtocol>(host, ProtocolFact) : null;
var protocols = remembered is null ? _protocols : [remembered.Protocol, .. _protocols.Where(p => p != remembered.Protocol)];
var failures = new List<ServerAccessException>();
foreach (var protocol in protocols)
{
var note = failures.Count > 0 ? $"{failures[0].Message} Agenten bruger derfor {protocol}."
: protocol == remembered?.Protocol ? remembered.Note
: null;
var session = new Session(this, CreateSession(host, protocol), host, protocol, note, account);
try
{
await session.ProbeAsync(cancellationToken).ConfigureAwait(false);
if (Auto && protocol == _protocols[0])
{
Memory.Forget(host, ProtocolFact);
}
else if (Auto && failures.Count > 0)
{
Memory.Remember(host, ProtocolFact, new WorkingProtocol(protocol, note));
}
return session;
}
catch (ServerAccessException ex)
{
session.Dispose();
failures.Add(ex);
}
catch
{
session.Dispose();
throw;
}
}
// Access denied says more than "does not answer": report that one first.
var best = failures.OrderBy(f => f.Problem switch { ServerAccessProblem.AccessDenied => 0, ServerAccessProblem.Timeout => 1, _ => 2 }).First();
var others = failures.Where(f => !ReferenceEquals(f, best)).Select(f => f.Message);
throw new ServerAccessException(best.Problem, string.Join(" ", others.Prepend(best.Message)), best) { Code = best.Code };
}
private CimSession CreateSession(string host, string protocol)
{
using CimSessionOptions sessionOptions = protocol == WsMan
? new WSManSessionOptions { Timeout = _options.OperationTimeout }
: new DComSessionOptions { Timeout = _options.OperationTimeout, PacketPrivacy = true, PacketIntegrity = true };
return CimSession.Create(host, sessionOptions);
}
/// <summary>The account the agent arrives as on <paramref name="host"/> (the computer account for a virtual service account).</summary>
public static string AgentAccount(string host) =>
ServerGrantScript.AccountFor(WindowsIdentity.GetCurrent().Name, host, Environment.MachineName, Environment.UserDomainName);
internal static DateTimeOffset? ToOffset(object? value)
{
if (value is not DateTime time || time.Year < 2000)
{
return null;
}
return time.Kind == DateTimeKind.Utc ? new DateTimeOffset(time) : new DateTimeOffset(DateTime.SpecifyKind(time, DateTimeKind.Local));
}
internal static ulong? UInt64(CimInstance instance, string name) => instance.CimInstanceProperties[name]?.Value switch
{
null => null,
IConvertible v => Convert.ToUInt64(v, CultureInfo.InvariantCulture),
_ => null,
};
internal static string? Text(CimInstance instance, string name) => instance.CimInstanceProperties[name]?.Value?.ToString();
/// <summary>
/// One server. Every CIM instance is mapped to SQLInsight's own types while it is read, and disposed at once. Native
/// calls run on the thread pool and are awaited with the caller's token, so a server that hangs costs the caller its
/// deadline, never more; the CIM session is closed once such a call has returned.
/// </summary>
private sealed class Session(CimWindowsServerSource source, CimSession session, string host, string protocol, string? note, string account) : IWindowsServerSession
{
private readonly Lock _lock = new();
private readonly List<Task> _abandoned = [];
private CimSession? _taskSession;
private bool _disposed;
public string Protocol => protocol;
public string? ProtocolNote => note;
private TimeSpan Timeout => source._options.OperationTimeout;
public void Dispose()
{
Task[] pending;
lock (_lock)
{
if (_disposed)
{
return;
}
_disposed = true;
pending = [.. _abandoned.Where(t => !t.IsCompleted)];
}
if (pending.Length == 0)
{
Close();
}
else
{
// A call that ignored its cancellation still uses the session: closing it now could block on the call.
_ = Task.WhenAll(pending).ContinueWith(_ => Close(), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default);
}
void Close()
{
session.Dispose();
_taskSession?.Dispose();
}
}
/// <summary>Runs a blocking native call on the thread pool; stops waiting when <paramref name="cancellationToken"/> is cancelled, even if the call does not.</summary>
private async Task<T> RunAsync<T>(Func<T> work, CancellationToken cancellationToken)
{
var task = Task.Run(work, cancellationToken);
try
{
return await task.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!task.IsCompleted)
{
Abandon(task);
throw;
}
}
/// <summary>A call no longer waited for: its end is observed (a late error is not left unobserved) and the session outlives it.</summary>
private void Abandon(Task task)
{
_ = task.ContinueWith(static t => _ = t.Exception, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
lock (_lock)
{
_abandoned.Add(task);
}
}
/// <summary>The cheapest query that proves the connection (and the right to root\cimv2).</summary>
public async Task ProbeAsync(CancellationToken cancellationToken) =>
await QueryAsync(CimV2, "SELECT CSName FROM Win32_OperatingSystem", null, _ => true, cancellationToken, limit: 1).ConfigureAwait(false);
private CimOperationOptions Options(CancellationToken cancellationToken) => new() { Timeout = Timeout, CancellationToken = cancellationToken };
/// <summary>The query's instances mapped by <paramref name="map"/>; at most <paramref name="limit"/> are read.</summary>
private Task<List<T>> QueryAsync<T>(string ns, string query, string? area, Func<CimInstance, T> map, CancellationToken cancellationToken, int? limit = null) =>
RunAsync(() =>
{
var list = new List<T>();
Read(ns, query, area, instance =>
{
list.Add(map(instance));
return limit is not { } max || list.Count < max;
}, cancellationToken);
return list;
}, cancellationToken);
/// <summary>Streams a query: each instance is handed to <paramref name="each"/> (false: stop) and disposed. Blocking.</summary>
private void Read(string ns, string query, string? area, Func<CimInstance, bool> each, CancellationToken cancellationToken)
{
try
{
using var operation = Options(cancellationToken);
foreach (var instance in session.QueryInstances(ns, "WQL", query, operation))
{
using (instance)
{
if (!each(instance))
{
break;
}
}
}
}
catch (CimException ex)
{
throw Describe(ex, area, protocol);
}
catch (UnauthorizedAccessException ex)
{
throw ServerErrors.Describe(ServerErrors.AccessDenied, "AccessDenied", ex.Message, area, host, account, protocol);
}
}
private ServerAccessException Describe(CimException ex, string? area, string over)
{
uint? code = ex.ErrorData?.CimInstanceProperties["error_Code"]?.Value is IConvertible c ? Convert.ToUInt32(c, CultureInfo.InvariantCulture) : null;
var error = ServerErrors.Describe(code, ex.NativeErrorCode.ToString(), ex.Message, area, host, account, over);
return new ServerAccessException(error.Problem, error.Message, ex) { Code = error.Code };
}
public async Task<ServerOsInfo> GetOperatingSystemAsync(CancellationToken cancellationToken)
{
var os = (await QueryAsync(CimV2, OperatingSystemQuery, ServerArea.Performance, i => new ServerOsInfo
{
Caption = Text(i, "Caption")?.Trim(),
Version = Text(i, "Version"),
BuildNumber = Text(i, "BuildNumber"),
LastBootUpTime = ToOffset(i.CimInstanceProperties["LastBootUpTime"]?.Value),
TotalMemoryMb = UInt64(i, "TotalVisibleMemorySize") is { } kb ? kb / 1024d : null,
ComputerName = Text(i, "CSName"),
}, cancellationToken, limit: 1).ConfigureAwait(false)).FirstOrDefault()
?? throw new ServerAccessException(ServerAccessProblem.Other, $"{host} returnerede intet styresystem (Win32_OperatingSystem).");
int? processors = null;
try
{
processors = (await QueryAsync(CimV2, ComputerSystemQuery, ServerArea.Performance, i => (int?)UInt64(i, "NumberOfLogicalProcessors"), cancellationToken, limit: 1)
.ConfigureAwait(false)).FirstOrDefault();
}
catch (ServerAccessException)
{
// The number of processors is a detail; the rest still counts.
}
return os with { LogicalProcessors = processors };
}
private sealed record ProcessorCounters(ulong? Idle, ulong? Timestamp);
private sealed record MemoryCounters(ulong? Pages, ulong? PerfTime, ulong? PerfFrequency, ulong? AvailableMb, ulong? Committed, ulong? CommitLimit);
public async Task<ServerPerformanceRaw> GetPerformanceAsync(DateTimeOffset now, CancellationToken cancellationToken)
{
var processor = (await QueryAsync(CimV2, ProcessorQuery, ServerArea.Performance,
i => new ProcessorCounters(UInt64(i, "PercentProcessorTime"), UInt64(i, "Timestamp_Sys100NS")), cancellationToken, limit: 1).ConfigureAwait(false)).FirstOrDefault();
var memory = (await QueryAsync(CimV2, MemoryQuery, ServerArea.Performance,
i => new MemoryCounters(UInt64(i, "PagesPersec"), UInt64(i, "Timestamp_PerfTime"), UInt64(i, "Frequency_PerfTime"), UInt64(i, "AvailableMBytes"),
UInt64(i, "CommittedBytes"), UInt64(i, "CommitLimit")), cancellationToken, limit: 1).ConfigureAwait(false)).FirstOrDefault();
if (processor is null && memory is null)
{
// Performance counters come back empty, rather than denied, without Performance Monitor Users.
throw new ServerAccessException(ServerAccessProblem.AccessDenied,
$"{host} returnerede ingen ydelsestællere: agentens konto ({account}) er sandsynligvis ikke medlem af Performance Monitor Users. Kør tildelingsscriptet på serveren.");
}
return new ServerPerformanceRaw
{
CapturedAt = now,
ProcessorIdle100Ns = processor?.Idle,
ProcessorTimestamp100Ns = processor?.Timestamp,
Pages = memory?.Pages,
MemoryPerfTime = memory?.PerfTime,
MemoryPerfFrequency = memory?.PerfFrequency,
AvailableMb = memory?.AvailableMb,
CommittedBytes = memory?.Committed,
CommitLimit = memory?.CommitLimit,
};
}
public async Task<IReadOnlyList<ServerDisk>> GetDisksAsync(CancellationToken cancellationToken)
{
List<ServerDisk> disks;
try
{
disks = (await QueryAsync(CimV2, VolumeQuery, ServerArea.Disks,
v => new ServerDisk(Text(v, "Name") ?? string.Empty, Text(v, "Label"), Text(v, "FileSystem"), Gb(UInt64(v, "Capacity")), Gb(UInt64(v, "FreeSpace"))),
cancellationToken).ConfigureAwait(false))
.Where(d => d.Volume.Length > 0 && !d.Volume.StartsWith(@"\\?\", StringComparison.Ordinal)) // no drive letter or mount point: system/recovery partitions
.ToList();
}
catch (ServerAccessException ex) when (ex.Problem is ServerAccessProblem.AccessDenied or ServerAccessProblem.NotAvailable)
{
disks = [];
}
if (disks.Count > 0)
{
return disks;
}
// Older or locked-down servers: the logical disks (drive letters only).
return await QueryAsync(CimV2, LogicalDiskQuery, ServerArea.Disks,
d => new ServerDisk((Text(d, "DeviceID") ?? "?") + "\\", Text(d, "VolumeName"), Text(d, "FileSystem"), Gb(UInt64(d, "Size")), Gb(UInt64(d, "FreeSpace"))),
cancellationToken).ConfigureAwait(false);
static double Gb(ulong? bytes) => bytes is { } b ? b / 1073741824d : 0;
}
public async Task<IReadOnlyList<ServerService>> GetServicesAsync(CancellationToken cancellationToken)
{
List<ServerService> services;
try
{
services = await QueryAsync(CimV2, ServiceQuery, ServerArea.Services, Service, cancellationToken).ConfigureAwait(false);
}
catch (ServerAccessException ex) when (ex.Problem == ServerAccessProblem.Other)
{
// Windows before Server 2012 has no DelayedAutoStart property.
services = await QueryAsync(CimV2, ServiceQueryWithoutDelayed, ServerArea.Services, Service, cancellationToken).ConfigureAwait(false);
}
if (services.Count == 0)
{
throw new ServerAccessException(ServerAccessProblem.AccessDenied,
$"{host} viste ingen tjenester: over netværket må kun administratorer liste tjenester, medmindre agentens konto ({account}) har fået læseret på "
+ "Service Control Manager. Kør tildelingsscriptet på serveren.");
}
return services;
}
public async Task<ServerService?> GetServiceAsync(string name, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
return (await QueryAsync(CimV2, SingleServiceQuery(name), ServerArea.Services, Service, cancellationToken, limit: 1).ConfigureAwait(false)).FirstOrDefault();
}
private static ServerService Service(CimInstance s) => new(
Text(s, "Name") ?? string.Empty,
Text(s, "DisplayName") ?? Text(s, "Name") ?? string.Empty,
Text(s, "State") ?? "Unknown",
Text(s, "StartMode") ?? "Unknown",
s.CimInstanceProperties["DelayedAutoStart"]?.Value is true,
UInt64(s, "ExitCode") is { } code ? (int)Math.Min(code, int.MaxValue) : null);
public async Task<ServerEventRead> GetEventsAsync(IReadOnlyDictionary<string, DateTimeOffset> since, int max, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(since);
ArgumentOutOfRangeException.ThrowIfLessThan(max, 1);
string? note = null;
if (source._options.EventLogRpc)
{
// A server whose event log protocol was blocked within the hour is read over CIM at once.
note = source.Memory.Recall<string>(host, EventLogRpcFact);
if (note is null)
{
try
{
return await ReadWithEventLogServiceAsync(since, max, cancellationToken).ConfigureAwait(false);
}
catch (UnauthorizedAccessException ex)
{
throw ServerErrors.Describe(ServerErrors.AccessDenied, "AccessDenied", ex.Message, ServerArea.Events, host, account, protocol);
}
catch (EventLogException ex)
{
note = $"Hændelsesloggens egen protokol (RPC) virkede ikke ({ex.Message.Trim()}); loggen læses over CIM i stedet, hvilket er langsommere.";
source.Memory.Remember(host, EventLogRpcFact, note);
}
}
}
var entries = new List<ServerEventEntry>();
var truncated = new List<string>();
foreach (var log in ServerEventAnalysis.Logs)
{
var from = Since(since, log);
// Win32_NTLogEvent cannot be ordered: the oldest max are kept while streaming (memory stays bounded), as the RPC read does.
var (oldest, more) = await RunAsync(() =>
{
var kept = new PriorityQueue<ServerEventEntry, (DateTimeOffset, long)>(Comparer<(DateTimeOffset, long)>.Create((a, b) => b.CompareTo(a)));
var seen = 0;
Read(CimV2, EventQuery(log, from), ServerArea.Events, e =>
{
var entry = new ServerEventEntry(
Text(e, "Logfile") ?? log,
Text(e, "SourceName") ?? string.Empty,
(long)(UInt64(e, "EventCode") ?? 0),
2,
ToOffset(e.CimInstanceProperties["TimeGenerated"]?.Value) ?? from,
Text(e, "Message"),
(long?)UInt64(e, "RecordNumber"));
var key = (entry.At, entry.RecordId ?? 0);
seen++;
if (kept.Count < max)
{
kept.Enqueue(entry, key);
}
else if (kept.TryPeek(out _, out var newest) && key.CompareTo(newest) < 0)
{
kept.EnqueueDequeue(entry, key);
}
return true;
}, cancellationToken);
return (kept.UnorderedItems.Select(i => i.Element).ToList(), seen > max);
}, cancellationToken).ConfigureAwait(false);
entries.AddRange(oldest);
if (more)
{
truncated.Add(log);
}
}
return new ServerEventRead(Ordered(entries), "CIM (Win32_NTLogEvent)", truncated.Count > 0, note, truncated);
}
private static DateTimeOffset Since(IReadOnlyDictionary<string, DateTimeOffset> since, string log) =>
since.TryGetValue(log, out var from) ? from : since.Count > 0 ? since.Values.Min() : DateTimeOffset.UtcNow - ServerEventAnalysis.RecentWindow;
private static List<ServerEventEntry> Ordered(IEnumerable<ServerEventEntry> entries) =>
entries.OrderBy(e => e.At).ThenBy(e => e.Log, StringComparer.Ordinal).ThenBy(e => e.RecordId).ToList();
/// <summary>
/// The event log's own protocol, with a deadline for the whole read: a server that does not answer cancels the
/// session's operations, and the agent stops waiting even if the native call ignores that.
/// </summary>
private async Task<ServerEventRead> ReadWithEventLogServiceAsync(IReadOnlyDictionary<string, DateTimeOffset> since, int max, CancellationToken cancellationToken)
{
var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(source.EventReadTimeout);
var token = deadline.Token;
var read = Task.Run(() => ReadWithEventLogService(since, max, token), token);
try
{
return await read.WaitAsync(token).ConfigureAwait(false);
}
catch (Exception ex) when (ex is OperationCanceledException or EventLogException && token.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
throw ServerErrors.Describe(ServerErrors.MiTimeout, "Timeout", string.Empty, ServerArea.Events, host, account,
$"hændelsesloggens protokol (RPC) inden for {source.EventReadTimeout.TotalSeconds.ToString("0", CultureInfo.InvariantCulture)} sekunder");
}
finally
{
if (read.IsCompleted)
{
deadline.Dispose();
}
else
{
_ = read.ContinueWith(_ => deadline.Dispose(), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
Abandon(read);
}
}
}
private ServerEventRead ReadWithEventLogService(IReadOnlyDictionary<string, DateTimeOffset> since, int max, CancellationToken cancellationToken)
{
using var logSession = protocol == "lokal" ? new EventLogSession() : new EventLogSession(host);
using var cancel = cancellationToken.Register(logSession.CancelCurrentOperations);
var entries = new List<ServerEventEntry>();
var truncated = new List<string>();
foreach (var log in ServerEventAnalysis.Logs)
{
cancellationToken.ThrowIfCancellationRequested();
var from = Since(since, log);
var query = new EventLogQuery(log, PathType.LogName, EventXPath(from)) { Session = logSession };
using var reader = new EventLogReader(query);
var newest = new Dictionary<(string, int), EventRecord>();
var read = new List<(EventRecord Record, ServerEventEntry Entry)>();
try
{
var count = 0;
for (var record = reader.ReadEvent(); record is not null; record = reader.ReadEvent())
{
if (++count > max)
{
record.Dispose();
truncated.Add(log);
break;
}
var entry = new ServerEventEntry(log, record.ProviderName ?? string.Empty, record.Id, record.Level ?? 2,
record.TimeCreated is { } created ? new DateTimeOffset(created.ToUniversalTime(), TimeSpan.Zero) : from, null, record.RecordId);
read.Add((record, entry));
newest[(entry.Source, record.Id)] = record;
cancellationToken.ThrowIfCancellationRequested();
}
// The text is only needed once per source and id (the sample): formatting asks the server for the publisher's messages.
foreach (var (record, entry) in read)
{
string? message = null;
if (newest.TryGetValue((entry.Source, record.Id), out var sample) && ReferenceEquals(sample, record))
{
cancellationToken.ThrowIfCancellationRequested();
try
{
message = record.FormatDescription();
}
catch (EventLogException)
{
message = null;
}
}
entries.Add(entry with { Message = message });
}
}
finally
{
foreach (var (record, _) in read)
{
record.Dispose();
}
}
}
return new ServerEventRead(Ordered(entries), "Event Log (RPC)", truncated.Count > 0, null, truncated);
}
public async Task<ServerTaskRead> GetTasksAsync(Func<string, bool> watch, int maxWatched, TimeSpan budget, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(watch);
var all = await QueryAsync(TaskSchedulerNamespace, TaskQuery, ServerArea.Tasks, t => new ServerTask
{
Path = Text(t, "TaskPath") ?? "\\",
Name = Text(t, "TaskName") ?? string.Empty,
State = (UInt64(t, "State") ?? 0) switch { 1 => "Disabled", 2 => "Queued", 3 => "Ready", 4 => "Running", _ => "Unknown" },
}, cancellationToken).ConfigureAwait(false);
var watched = all.Where(t => watch(t.FullName)).ToList();
var notes = new List<string>();
if (watched.Count > maxWatched)
{
notes.Add($"Kun de første {maxWatched} af {watched.Count} opgaver overvåges.");
}
var started = source._time.GetTimestamp();
var result = new List<ServerTask>();
var unread = 0;
foreach (var task in watched.Take(maxWatched))
{
if (source._time.GetElapsedTime(started) >= budget)
{
// The time for the tasks is spent: the rest keep their state, without the last run, until next time.
result.Add(task);
unread++;
continue;
}
result.Add(await GetInfoAsync(task, cancellationToken).ConfigureAwait(false));
}
if (unread > 0)
{
notes.Add($"Seneste kørsel blev ikke læst for {unread} af {result.Count} opgaver: tiden til opgaverne "
+ $"({budget.TotalSeconds.ToString("0", CultureInfo.InvariantCulture)} sekunder) var brugt. De læses ved næste indsamling.");
}
return new ServerTaskRead(result, all.Count, notes.Count == 0 ? null : string.Join(" ", notes));
}
/// <summary>
/// Last and next run of a task. Over WS-Man an account with read-only WinRM rights may not call Task Scheduler's
/// GetInfo; with Protocol Auto the tasks are then read over DCOM (and that is remembered for the server).
/// </summary>
private async Task<ServerTask> GetInfoAsync(ServerTask task, CancellationToken cancellationToken)
{
if (_taskSession is null && protocol == WsMan && source.Auto && source.Memory.Recall<string>(host, TasksOverDcomFact) is not null)
{
_taskSession = source.CreateSession(host, Dcom);
}
if (_taskSession is not null)
{
try
{
return await GetInfoAsync(_taskSession, Dcom, task, cancellationToken).ConfigureAwait(false);
}
catch (ServerAccessException)
{
source.Memory.Forget(host, TasksOverDcomFact);
throw;
}
}
try
{
return await GetInfoAsync(session, protocol, task, cancellationToken).ConfigureAwait(false);
}
catch (ServerAccessException ex) when (ex.Problem == ServerAccessProblem.AccessDenied && protocol == WsMan)
{
const string why = " Opgavernes seneste resultat læses med Task Schedulers GetInfo, et CIM-metodekald, og over WinRM må agentens konto kun læse.";
if (!source.Auto)
{
throw new ServerAccessException(ServerAccessProblem.AccessDenied,
ex.Message + why + " Vælg protokollen Auto eller DCOM (SQLInsight:Servers:Protocol), så læses opgaverne over DCOM.", ex)
{ Code = ex.Code };
}
_taskSession = source.CreateSession(host, Dcom);
try
{
var info = await GetInfoAsync(_taskSession, Dcom, task, cancellationToken).ConfigureAwait(false);
source.Memory.Remember(host, TasksOverDcomFact, Dcom);
return info;
}
catch (ServerAccessException dcom)
{
throw new ServerAccessException(dcom.Problem, ex.Message + why + " Agenten prøvede derfor DCOM: " + dcom.Message, dcom) { Code = dcom.Code };
}
}
}
private Task<ServerTask> GetInfoAsync(CimSession over, string overProtocol, ServerTask task, CancellationToken cancellationToken) =>
RunAsync(() =>
{
try
{
using var parameters = new CimMethodParametersCollection
{
CimMethodParameter.Create("TaskName", task.Name, CimType.String, CimFlags.In),
CimMethodParameter.Create("TaskPath", task.Path, CimType.String, CimFlags.In),
};
using var operation = Options(cancellationToken);
using var result = over.InvokeMethod(TaskSchedulerNamespace, "PS_ScheduledTask", "GetInfoByName", parameters, operation);
if (result.OutParameters["cmdletOutput"]?.Value is not CimInstance info)
{
return task;
}
using (info)
{
return task with
{
LastRunTime = ToOffset(info.CimInstanceProperties["LastRunTime"]?.Value),
LastTaskResult = UInt64(info, "LastTaskResult") is { } r ? (uint)r : null,
NextRunTime = ToOffset(info.CimInstanceProperties["NextRunTime"]?.Value),
MissedRuns = (int)(UInt64(info, "NumberOfMissedRuns") ?? 0),
};
}
}
catch (CimException ex)
{
throw Describe(ex, ServerArea.Tasks, overProtocol);
}
catch (UnauthorizedAccessException ex)
{
throw ServerErrors.Describe(ServerErrors.AccessDenied, "AccessDenied", ex.Message, ServerArea.Tasks, host, account, overProtocol);
}
}, cancellationToken);
}
}

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>SQLInsight.Servers</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" />
<ProjectReference Include="..\SQLInsight.Connectivity\SQLInsight.Connectivity.csproj" />
</ItemGroup>
<ItemGroup>
<!-- CIM over WS-Man (WinRM) or DCOM: the same API PowerShell's Get-CimInstance uses. Windows only. -->
<PackageReference Include="Microsoft.Management.Infrastructure" />
<!-- Remote event log reading (MS-EVEN6) with an XPath filter on the server. Windows only. -->
<PackageReference Include="System.Diagnostics.EventLog" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Synthetic\ax-logon.ps1" LogicalName="SQLInsight.Servers.ax-logon.ps1" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SQLInsight.Servers.Tests" />
<InternalsVisibleTo Include="SQLInsight.IntegrationTests" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,94 @@
using System.Globalization;
using SQLInsight.Core.Servers;
namespace SQLInsight.Servers;
/// <summary>
/// Windows' error codes from CIM/WMI, WinRM and the event log, turned into what the user must do (Danish). A missing
/// right is never just "Access denied": it says which right, and that the grant script gives it.
/// </summary>
public static class ServerErrors
{
public const uint AccessDenied = 0x80070005;
public const uint WbemAccessDenied = 0x80041003;
public const uint InvalidNamespace = 0x8004100E;
public const uint InvalidClass = 0x80041010;
public const uint InvalidQuery = 0x80041017;
public const uint RpcUnavailable = 0x800706BA;
public const uint RpcCallFailed = 0x800706BE;
public const uint NetworkPathNotFound = 0x80070035;
public const uint LogonFailure = 0x8007052E;
public const uint WinRmCannotConnect = 0x80338012;
public const uint WinRmAccessDenied = 0x803381A5;
/// <summary>MI_RESULT for an operation that timed out (as MMI reports it).</summary>
public const uint MiTimeout = 0x40004;
/// <param name="code">The HRESULT, when known.</param>
/// <param name="kind">MMI's NativeErrorCode name (AccessDenied, InvalidNamespace …), when known.</param>
/// <param name="message">Windows' own message, kept at the end for support.</param>
/// <param name="area">The <see cref="ServerArea"/> being read, or null for connecting.</param>
public static ServerAccessException Describe(uint? code, string? kind, string message, string? area, string host, string? account, string protocol)
{
ArgumentNullException.ThrowIfNull(host);
var hex = code is { } c ? " (0x" + c.ToString("X8", CultureInfo.InvariantCulture) + ")" : string.Empty;
var windows = string.IsNullOrWhiteSpace(message) ? string.Empty : " Windows svarede: " + message.Trim() + hex;
var who = string.IsNullOrWhiteSpace(account) ? "Agentens konto" : $"Agentens konto ({account})";
var what = area is null ? "serveren" : ServerArea.Display(area).ToLowerInvariant();
if (code is AccessDenied or WbemAccessDenied or WinRmAccessDenied || kind == "AccessDenied" || message.Contains("Access is denied", StringComparison.OrdinalIgnoreCase)
|| message.Contains("Adgang nægtet", StringComparison.OrdinalIgnoreCase))
{
var right = area is null
? (protocol == "WS-Man"
? "læseret i WinRM (grant-windows-server.ps1 -WinRM) og WMI Remote Enable på root\\cimv2; uden WinRM-retten bruges DCOM (SQLInsight:Servers:Protocol Auto eller DCOM)"
: "Distributed COM Users og WMI Remote Enable på root\\cimv2")
: ServerArea.Right(area);
return new ServerAccessException(ServerAccessProblem.AccessDenied,
$"Adgang nægtet til {what} på {host} over {protocol}: {who} mangler {right}. Kør tildelingsscriptet (grant-windows-server.ps1) som administrator på serveren, "
+ "og test igen. Agenten skal ikke være lokal administrator." + windows)
{ Code = code };
}
if (code is InvalidNamespace or InvalidClass || kind is "InvalidNamespace" or "InvalidClass")
{
return new ServerAccessException(ServerAccessProblem.NotAvailable,
$"{Capitalize(what)} kan ikke måles på {host}: Windows-versionen har ikke den WMI-klasse eller det navnerum, SQLInsight bruger "
+ (area == ServerArea.Tasks ? "(root\\Microsoft\\Windows\\TaskScheduler findes fra Windows Server 2012)." : ".") + windows)
{ Code = code };
}
if (code is MiTimeout || kind == "Timeout" || message.Contains("timed out", StringComparison.OrdinalIgnoreCase))
{
return new ServerAccessException(ServerAccessProblem.Timeout,
$"{host} svarede ikke i tide over {protocol}. Serveren kan være overbelastet, eller en firewall taber pakkerne." + windows)
{ Code = code };
}
if (code is RpcUnavailable or RpcCallFailed or NetworkPathNotFound)
{
return new ServerAccessException(ServerAccessProblem.Unreachable,
$"{host} svarer ikke på RPC/DCOM: serveren er slukket, navnet findes ikke, eller firewallen blokerer TCP 135 og de dynamiske RPC-porte "
+ "fra agentens server. Tildelingsscriptet kan oprette regler kun for agentens adresse (-EnableFirewall -AgentAddress …)." + windows)
{ Code = code };
}
if (code is WinRmCannotConnect || message.Contains("WinRM", StringComparison.OrdinalIgnoreCase) || message.Contains("WS-Management", StringComparison.OrdinalIgnoreCase))
{
return new ServerAccessException(ServerAccessProblem.Unreachable,
$"{host} svarer ikke på WinRM (WS-Man, TCP 5985): tjenesten Windows Remote Management kører ikke, eller firewallen blokerer porten. Agenten kan bruge DCOM i stedet (SQLInsight:Servers:Protocol Auto)." + windows)
{ Code = code };
}
if (code is LogonFailure)
{
return new ServerAccessException(ServerAccessProblem.AccessDenied,
$"{host} afviste agentens logon (Kerberos/NTLM). Er serveren i samme domæne, eller i et domæne med tillid til agentens?" + windows)
{ Code = code };
}
return new ServerAccessException(ServerAccessProblem.Other, $"{Capitalize(what)} på {host} kunne ikke læses over {protocol}." + windows) { Code = code };
}
private static string Capitalize(string text) => text.Length == 0 ? text : char.ToUpper(text[0], CultureInfo.GetCultureInfo("da-DK")) + text[1..];
}

View file

@ -0,0 +1,49 @@
using System.Collections.Concurrent;
namespace SQLInsight.Servers;
/// <summary>
/// What the agent learned about a server for a while (e.g. "WS-Man is refused, DCOM works", "the event log's RPC protocol
/// is blocked"), so it is not tried again every cycle; it is forgotten after <see cref="For"/>, so it is tried again then.
/// Thread-safe.
/// </summary>
public sealed class ServerHostMemory(TimeProvider time, TimeSpan remember)
{
public static readonly TimeSpan DefaultFor = TimeSpan.FromHours(1);
private readonly ConcurrentDictionary<(string Host, string Key), (object Value, DateTimeOffset Until)> _facts = new();
public TimeSpan For => remember;
public void Remember(string host, string key, object value)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(value);
_facts[(host.ToUpperInvariant(), key)] = (value, time.GetUtcNow() + remember);
}
public T? Recall<T>(string host, string key)
where T : class
{
ArgumentNullException.ThrowIfNull(host);
var id = (host.ToUpperInvariant(), key);
if (!_facts.TryGetValue(id, out var fact))
{
return null;
}
if (fact.Until <= time.GetUtcNow())
{
_facts.TryRemove(id, out _);
return null;
}
return fact.Value as T;
}
public void Forget(string host, string key)
{
ArgumentNullException.ThrowIfNull(host);
_facts.TryRemove((host.ToUpperInvariant(), key), out _);
}
}

View file

@ -0,0 +1,240 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
namespace SQLInsight.Servers.Synthetic;
/// <summary>A Dynamics AX .NET Business Connector found on the agent's computer.</summary>
/// <param name="Version">"AX 2012" or "AX 2009".</param>
/// <param name="Is32Bit">Installed under Program Files (x86): needs the 32-bit Windows PowerShell.</param>
/// <param name="NeedsClr2">AX 2009's Business Connector is built for .NET 2.0: needs Windows PowerShell's 2.0 engine.</param>
public sealed record BusinessConnectorInstallation(string Path, string Version, bool Is32Bit, bool NeedsClr2);
public interface IBusinessConnectorLocator
{
BusinessConnectorInstallation? Find();
}
/// <summary>Looks for the Business Connector where the AX 2009 and AX 2012 installers put it (or where SQLInsight:SyntheticChecks:BusinessConnectorPath says).</summary>
public sealed class BusinessConnectorLocator(string? configuredPath = null, Func<string, bool>? exists = null) : IBusinessConnectorLocator
{
public const string FileName = "Microsoft.Dynamics.BusinessConnectorNet.dll";
private readonly Func<string, bool> _exists = exists ?? File.Exists;
/// <summary>The places looked in, most likely first.</summary>
public static IReadOnlyList<string> Candidates()
{
var programFiles = Environment.GetEnvironmentVariable("ProgramW6432") ?? Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var programFilesX86 = Environment.GetEnvironmentVariable("ProgramFiles(x86)") ?? Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
return
[
Path.Combine(programFiles, @"Microsoft Dynamics AX\60\BusinessConnector\Bin", FileName),
Path.Combine(programFilesX86, @"Microsoft Dynamics AX\60\BusinessConnector\Bin", FileName),
Path.Combine(programFilesX86, @"Microsoft Dynamics AX\60\Client\Bin", FileName),
Path.Combine(programFilesX86, @"Microsoft Dynamics AX\50\Client\Bin", FileName),
Path.Combine(programFiles, @"Microsoft Dynamics AX\50\Client\Bin", FileName),
];
}
public BusinessConnectorInstallation? Find()
{
if (!OperatingSystem.IsWindows())
{
return null;
}
var path = new[] { configuredPath }.Concat(Candidates()).FirstOrDefault(p => !string.IsNullOrWhiteSpace(p) && _exists(p!));
return path is null ? null : Describe(path);
}
public static BusinessConnectorInstallation Describe(string path)
{
ArgumentNullException.ThrowIfNull(path);
var ax2009 = path.Contains(@"\50\", StringComparison.OrdinalIgnoreCase);
return new BusinessConnectorInstallation(path, ax2009 ? "AX 2009" : "AX 2012", path.Contains("(x86)", StringComparison.OrdinalIgnoreCase), ax2009);
}
}
public sealed record ProcessResult(int ExitCode, string Output, string Error, bool TimedOut);
public interface IProcessRunner
{
Task<ProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments, IReadOnlyDictionary<string, string> environment, TimeSpan timeout,
CancellationToken cancellationToken);
}
/// <summary>
/// Runs a process and kills it (with its children) when it takes too long or the agent stops; never leaves one behind.
/// At most <see cref="MaxOutputChars"/> of its output and of its errors are kept.
/// </summary>
public sealed class ProcessRunner : IProcessRunner
{
public const int MaxOutputChars = 64 * 1024;
public async Task<ProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments, IReadOnlyDictionary<string, string> environment, TimeSpan timeout,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(arguments);
ArgumentNullException.ThrowIfNull(environment);
var start = new ProcessStartInfo(fileName)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
foreach (var argument in arguments)
{
start.ArgumentList.Add(argument);
}
foreach (var (key, value) in environment)
{
start.Environment[key] = value;
}
using var process = Process.Start(start) ?? throw new InvalidOperationException($"{fileName} kunne ikke startes.");
var output = ReadBoundedAsync(process.StandardOutput, MaxOutputChars);
var error = ReadBoundedAsync(process.StandardError, MaxOutputChars);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(timeout);
try
{
await process.WaitForExitAsync(deadline.Token).ConfigureAwait(false);
return new ProcessResult(process.ExitCode, await output.WaitAsync(deadline.Token).ConfigureAwait(false), await error.WaitAsync(deadline.Token).ConfigureAwait(false),
TimedOut: false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return new ProcessResult(-1, string.Empty, string.Empty, TimedOut: true);
}
finally
{
// Timed out, or the agent stops: a Business Connector process left running would keep its AX session.
Kill(process);
Observe(output);
Observe(error);
}
}
private static void Kill(Process process)
{
try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
}
}
catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or NotSupportedException)
{
// It ended on its own in the meantime, or cannot be killed: nothing more to do.
}
}
/// <summary>A reader no longer waited for ends when the process does; its error (the stream was closed) is not left unobserved.</summary>
private static void Observe(Task task) =>
_ = task.ContinueWith(static t => _ = t.Exception, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
/// <summary>Reads to the end, keeping at most <paramref name="maxChars"/>: the rest is read and dropped, so the process never blocks on a full pipe.</summary>
internal static async Task<string> ReadBoundedAsync(TextReader reader, int maxChars)
{
var builder = new StringBuilder();
var buffer = new char[4096];
int read;
while ((read = await reader.ReadAsync(buffer.AsMemory()).ConfigureAwait(false)) > 0)
{
var keep = Math.Min(read, maxChars - builder.Length);
if (keep > 0)
{
builder.Append(buffer, 0, keep);
}
}
return builder.ToString();
}
}
/// <summary>
/// Logs on to Dynamics AX through the .NET Business Connector, in a separate Windows PowerShell process (the Business
/// Connector is a .NET Framework assembly and cannot be loaded into the agent). Logs on as the agent's Windows account,
/// which must be an AX user, and logs off at once. No password is involved.
/// </summary>
public static class AxLogonClient
{
public const string OkPrefix = "SQLINSIGHT-AX-OK ";
public const string ErrorPrefix = "SQLINSIGHT-AX-ERROR ";
public static string Script()
{
using var stream = typeof(AxLogonClient).Assembly.GetManifestResourceStream("SQLInsight.Servers.ax-logon.ps1")
?? throw new InvalidOperationException("ax-logon.ps1 mangler i programmet.");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
/// <summary>The PowerShell to start and its arguments: 32-bit for a 32-bit Business Connector, the 2.0 engine for AX 2009.</summary>
public static (string FileName, IReadOnlyList<string> Arguments) Command(BusinessConnectorInstallation installation, string windowsDirectory)
{
ArgumentNullException.ThrowIfNull(installation);
var folder = installation.Is32Bit && System.Environment.Is64BitOperatingSystem ? "SysWOW64" : "System32";
var powershell = Path.Combine(windowsDirectory, folder, @"WindowsPowerShell\v1.0\powershell.exe");
var arguments = new List<string>();
if (installation.NeedsClr2)
{
arguments.AddRange(["-Version", "2"]);
}
arguments.AddRange(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", Convert.ToBase64String(Encoding.Unicode.GetBytes(Script()))]);
return (powershell, arguments);
}
public static IReadOnlyDictionary<string, string> Environment(BusinessConnectorInstallation installation, string? company, string objectServer, string? configuration)
{
ArgumentNullException.ThrowIfNull(installation);
return new Dictionary<string, string>(StringComparer.Ordinal)
{
["SQLINSIGHT_AX_DLL"] = installation.Path,
["SQLINSIGHT_AX_COMPANY"] = company ?? string.Empty,
["SQLINSIGHT_AX_SERVER"] = objectServer,
["SQLINSIGHT_AX_CONFIG"] = configuration ?? string.Empty,
};
}
/// <summary>What the helper reported: ok with the logon time, or the error (Danish).</summary>
public static (bool Ok, int? LogonMs, string Message) Parse(ProcessResult result, BusinessConnectorInstallation installation)
{
ArgumentNullException.ThrowIfNull(result);
ArgumentNullException.ThrowIfNull(installation);
if (result.TimedOut)
{
return (false, null, "Logon gennem Business Connector svarede ikke i tide; processen blev stoppet.");
}
var lines = result.Output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (lines.FirstOrDefault(l => l.StartsWith(OkPrefix, StringComparison.Ordinal)) is { } ok)
{
return (true, int.TryParse(ok[OkPrefix.Length..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ms) ? ms : null, "Logon i AX lykkedes.");
}
var error = lines.FirstOrDefault(l => l.StartsWith(ErrorPrefix, StringComparison.Ordinal))?[ErrorPrefix.Length..]
?? (string.IsNullOrWhiteSpace(result.Error) ? $"Business Connector-processen sluttede med kode {result.ExitCode}." : result.Error.Trim());
if (installation.NeedsClr2 && (error.Contains("v2.0.50727", StringComparison.OrdinalIgnoreCase) || error.Contains("Version 2", StringComparison.OrdinalIgnoreCase)
|| error.Contains("mixed mode", StringComparison.OrdinalIgnoreCase)))
{
return (false, null, "AX 2009's Business Connector kræver Windows PowerShell 2.0-motoren (.NET Framework 3.5), som ikke er installeret på agentens server: " + error);
}
if (error.Contains("not a recognized user", StringComparison.OrdinalIgnoreCase) || error.Contains("ikke en kendt bruger", StringComparison.OrdinalIgnoreCase)
|| error.Contains("no access", StringComparison.OrdinalIgnoreCase))
{
return (false, null, "AOS'en svarer, men agentens konto er ikke bruger i AX (eller ikke i firmaet). Opret kontoen som AX-bruger, eller slå logon-testen fra. AX svarede: " + error);
}
return (false, null, "Logon i AX fejlede: " + error);
}
}

View file

@ -0,0 +1,522 @@
using System.Data.Common;
using System.Globalization;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using SQLInsight.Connectivity;
using SQLInsight.Core.Configuration;
using SQLInsight.Core.CustomRules;
using SQLInsight.Core.SyntheticChecks;
namespace SQLInsight.Servers.Synthetic;
/// <summary>One run of a check: its settings, the opened SQL login password (if any) and how long it may take.</summary>
public sealed record SyntheticCheckRun(string Kind, SyntheticCheckSettings Settings, TimeSpan Timeout, string? Password = null)
{
/// <summary>The password never ends up in a log or an exception through ToString.</summary>
private bool PrintMembers(StringBuilder builder)
{
builder.Append(CultureInfo.InvariantCulture, $"Kind = {Kind}, Settings = {Settings}, Timeout = {Timeout}, Password = {(Password is null ? "null" : "***")}");
return true;
}
}
/// <summary>
/// Runs synthetic checks the way a user or an application would: connect to a port, fetch a page, log on to SQL Server
/// and run a read-only query, resolve a name, log on to Dynamics AX. Nothing is changed anywhere; a failure is the
/// check's result (with a Danish explanation), never an exception.
/// </summary>
public sealed class SyntheticCheckRunner(ISqlConnectionFactory sqlConnections, IBusinessConnectorLocator businessConnector, IProcessRunner processes, TimeProvider time)
{
public const string UserAgent = "SQLInsight-Pro-SyntheticCheck/1.5";
/// <summary>At most this much of an HTTP answer is read when looking for the expected text.</summary>
public const int MaxBodyBytes = 1024 * 1024;
/// <summary>At most this many rows of a SQL check's answer are read.</summary>
public const int MaxRows = 10000;
// What a SQL check sends besides the session settings of every connection and its query (SQL Scripts/SQLI-SYN-001.sql).
public const string BeginTransactionSql = "BEGIN TRANSACTION;";
public const string RollbackSql = "IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;";
public static string UseDatabaseSql(string database)
{
ArgumentNullException.ThrowIfNull(database);
return "USE [" + database.Replace("]", "]]", StringComparison.Ordinal) + "];";
}
public async Task<SyntheticCheckResult> RunAsync(SyntheticCheckRun run, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(run);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(run.Timeout);
var at = time.GetUtcNow();
var started = time.GetTimestamp();
try
{
return run.Kind switch
{
SyntheticCheckKind.Tcp => await TcpAsync(run, at, started, deadline.Token).ConfigureAwait(false),
SyntheticCheckKind.Http => await HttpAsync(run, at, started, deadline.Token).ConfigureAwait(false),
SyntheticCheckKind.Sql => await SqlAsync(run, at, started, deadline.Token).ConfigureAwait(false),
SyntheticCheckKind.Dns => await DnsAsync(run, at, started, deadline.Token).ConfigureAwait(false),
SyntheticCheckKind.AxLogon => await AxAsync(run, at, started, deadline.Token).ConfigureAwait(false),
_ => Failed(at, 0, $"Ukendt type tjek: {run.Kind}."),
};
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested
&& (ex is OperationCanceledException || (deadline.IsCancellationRequested && ex is IOException or SocketException or HttpRequestException)))
{
return Failed(at, Elapsed(started), $"Intet svar inden for {run.Timeout.TotalSeconds.ToString("0", CultureInfo.InvariantCulture)} sekunder.");
}
catch (Exception ex) when (ex is IOException or SocketException or HttpRequestException or AuthenticationException)
{
// The network failed in a way the check's own handling did not foresee: still the check's result, not an error.
return Failed(at, Elapsed(started), "Forbindelsen fejlede: " + ex.Message);
}
}
private int Elapsed(long started) => (int)Math.Min(int.MaxValue, time.GetElapsedTime(started).TotalMilliseconds);
private static SyntheticCheckResult Failed(DateTimeOffset at, int ms, string message, IReadOnlyList<SyntheticCheckDetail>? details = null) =>
new() { At = at, Success = false, DurationMs = ms, Message = message, Details = details ?? [] };
// ---- TCP --------------------------------------------------------------------------------------------------
private async Task<SyntheticCheckResult> TcpAsync(SyntheticCheckRun run, DateTimeOffset at, long started, CancellationToken cancellationToken)
{
var host = run.Settings.Host!;
var port = run.Settings.Port!.Value;
var (ok, ms, message) = await ConnectAsync(host, port, started, cancellationToken).ConfigureAwait(false);
return new SyntheticCheckResult
{
At = at,
Success = ok,
DurationMs = ms,
Message = message,
Details = [new SyntheticCheckDetail("Mål", $"{host}:{port.ToString(CultureInfo.InvariantCulture)}")],
};
}
private async Task<(bool Ok, int Ms, string Message)> ConnectAsync(string host, int port, long started, CancellationToken cancellationToken)
{
using var client = new TcpClient();
try
{
await client.ConnectAsync(host, port, cancellationToken).ConfigureAwait(false);
var ms = Elapsed(started);
return (true, ms, $"Port {port.ToString(CultureInfo.InvariantCulture)} på {host} svarede på {ms.ToString(CultureInfo.InvariantCulture)} ms.");
}
catch (SocketException ex)
{
return (false, Elapsed(started), SocketText(ex, host, port));
}
}
internal static string SocketText(SocketException exception, string host, int port) => exception.SocketErrorCode switch
{
SocketError.ConnectionRefused =>
$"Forbindelsen til {host}:{port.ToString(CultureInfo.InvariantCulture)} blev afvist: intet lytter på porten (tjenesten kører ikke eller lytter et andet sted), eller en firewall afviser.",
SocketError.TimedOut => $"{host}:{port.ToString(CultureInfo.InvariantCulture)} svarede ikke: serveren er nede, eller en firewall taber pakkerne.",
SocketError.HostNotFound or SocketError.NoData or SocketError.TryAgain => $"Navnet {host} kunne ikke slås op i DNS.",
SocketError.HostUnreachable or SocketError.NetworkUnreachable => $"Der er ingen netværksvej til {host}.",
_ => $"{host}:{port.ToString(CultureInfo.InvariantCulture)} kunne ikke nås: {exception.Message}",
};
// ---- HTTP -------------------------------------------------------------------------------------------------
private async Task<SyntheticCheckResult> HttpAsync(SyntheticCheckRun run, DateTimeOffset at, long started, CancellationToken cancellationToken)
{
var settings = run.Settings;
var url = settings.Url!;
X509Certificate2? certificate = null;
SslPolicyErrors certificateErrors = SslPolicyErrors.None;
using var handler = new SocketsHttpHandler
{
AllowAutoRedirect = true,
MaxAutomaticRedirections = 5,
ConnectTimeout = run.Timeout,
PooledConnectionLifetime = TimeSpan.Zero,
UseCookies = false,
Credentials = settings.UseWindowsAuthentication ? CredentialCache.DefaultCredentials : null,
SslOptions = new SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = (_, cert, _, errors) =>
{
if (cert is not null && certificate is null)
{
certificate = new X509Certificate2(cert);
}
certificateErrors |= errors;
return errors == SslPolicyErrors.None || settings.AllowInvalidCertificate;
},
},
};
using var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.UserAgent.ParseAdd(UserAgent);
var details = new List<SyntheticCheckDetail> { new("Adresse", url.ToString(), Sensitive: true) };
try
{
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
var status = (int)response.StatusCode;
details.Add(new SyntheticCheckDetail("HTTP-status", status.ToString(CultureInfo.InvariantCulture) + " " + response.ReasonPhrase));
var expected = settings.ExpectedStatus.Count > 0 ? settings.ExpectedStatus.Contains(status) : status is >= 200 and < 400;
string? missingText = null;
if (expected && !string.IsNullOrEmpty(settings.ExpectedText))
{
var body = await ReadBodyAsync(response, cancellationToken).ConfigureAwait(false);
if (!body.Contains(settings.ExpectedText, StringComparison.OrdinalIgnoreCase))
{
missingText = settings.ExpectedText;
}
}
var ms = Elapsed(started);
details.Add(new SyntheticCheckDetail("Svartid", ms.ToString(CultureInfo.InvariantCulture) + " ms"));
AddCertificate(details, certificate, certificateErrors);
var message = !expected
? $"Svarede {status.ToString(CultureInfo.InvariantCulture)} {response.ReasonPhrase}, forventede {(settings.ExpectedStatus.Count > 0 ? string.Join(" eller ", settings.ExpectedStatus) : "200–399")}."
: missingText is not null
? $"Svarede {status.ToString(CultureInfo.InvariantCulture)}, men siden indeholdt ikke \"{missingText}\" (de første {MaxBodyBytes / 1024} KB blev læst)."
: $"Svarede {status.ToString(CultureInfo.InvariantCulture)} på {ms.ToString(CultureInfo.InvariantCulture)} ms.";
return new SyntheticCheckResult
{
At = at,
Success = expected && missingText is null,
DurationMs = ms,
Message = message,
Details = details,
CertificateExpiresAt = certificate is null ? null : new DateTimeOffset(certificate.NotAfter.ToUniversalTime(), TimeSpan.Zero),
CertificateSubject = certificate?.Subject,
};
}
catch (HttpRequestException ex)
{
AddCertificate(details, certificate, certificateErrors);
var message = ex.InnerException is AuthenticationException || certificateErrors != SslPolicyErrors.None
? $"TLS-forbindelsen blev afvist: certifikatet er ikke gyldigt ({CertificateText(certificateErrors)}). Er det et selvsigneret certifikat, kan \"Accepter ugyldigt certifikat\" slås til; udløbsdatoen tjekkes stadig."
: ex.InnerException is SocketException socket ? SocketText(socket, url.Host, url.Port) : $"Forespørgslen fejlede: {ex.Message}";
return new SyntheticCheckResult
{
At = at,
Success = false,
DurationMs = Elapsed(started),
Message = message,
Details = details,
CertificateExpiresAt = certificate is null ? null : new DateTimeOffset(certificate.NotAfter.ToUniversalTime(), TimeSpan.Zero),
CertificateSubject = certificate?.Subject,
};
}
catch (IOException ex) when (!cancellationToken.IsCancellationRequested)
{
// The connection broke while the answer was read (HttpIOException and the like).
AddCertificate(details, certificate, certificateErrors);
return new SyntheticCheckResult
{
At = at,
Success = false,
DurationMs = Elapsed(started),
Message = $"Svaret blev afbrudt, mens det blev læst: {ex.Message}",
Details = details,
CertificateExpiresAt = certificate is null ? null : new DateTimeOffset(certificate.NotAfter.ToUniversalTime(), TimeSpan.Zero),
CertificateSubject = certificate?.Subject,
};
}
finally
{
certificate?.Dispose();
}
}
private static async Task<string> ReadBodyAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var buffer = new byte[MaxBodyBytes];
var total = 0;
int read;
while (total < buffer.Length && (read = await stream.ReadAsync(buffer.AsMemory(total), cancellationToken).ConfigureAwait(false)) > 0)
{
total += read;
}
var charset = response.Content.Headers.ContentType?.CharSet;
Encoding encoding;
try
{
encoding = string.IsNullOrWhiteSpace(charset) ? Encoding.UTF8 : Encoding.GetEncoding(charset.Trim('"'));
}
catch (ArgumentException)
{
encoding = Encoding.UTF8;
}
return encoding.GetString(buffer, 0, total);
}
private static void AddCertificate(List<SyntheticCheckDetail> details, X509Certificate2? certificate, SslPolicyErrors errors)
{
if (certificate is null)
{
return;
}
details.Add(new SyntheticCheckDetail("Certifikat", certificate.Subject));
details.Add(new SyntheticCheckDetail("Certifikat udløber", certificate.NotAfter.ToUniversalTime().ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture)));
if (errors != SslPolicyErrors.None)
{
details.Add(new SyntheticCheckDetail("Certifikatfejl", CertificateText(errors)));
}
}
internal static string CertificateText(SslPolicyErrors errors)
{
var problems = new List<string>();
if (errors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch))
{
problems.Add("navnet passer ikke til adressen");
}
if (errors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors))
{
problems.Add("udstederen er ikke betroet, eller certifikatet er udløbet eller tilbagekaldt");
}
if (errors.HasFlag(SslPolicyErrors.RemoteCertificateNotAvailable))
{
problems.Add("serveren sendte intet certifikat");
}
return problems.Count == 0 ? "ukendt fejl" : string.Join(", ", problems);
}
// ---- SQL Server -------------------------------------------------------------------------------------------
private async Task<SyntheticCheckResult> SqlAsync(SyntheticCheckRun run, DateTimeOffset at, long started, CancellationToken cancellationToken)
{
var settings = run.Settings;
var query = string.IsNullOrWhiteSpace(settings.Query) ? SyntheticCheckSettings.DefaultQuery : settings.Query.Trim();
// Checked when saved; checked again here, so nothing but a single read-only SELECT ever runs.
if (CustomRuleDefinition.QueryProblems(query).FirstOrDefault() is { } problem)
{
return Failed(at, 0, "Forespørgslen køres ikke: " + problem);
}
var dataSource = ServerAddress.DataSource(settings.Server!, settings.Port);
var details = new List<SyntheticCheckDetail> { new("Server", dataSource), new("Login", settings.Authentication == "SqlLogin" ? settings.UserName! : "agentens Windows-konto") };
if (settings.Authentication == "SqlLogin" && string.IsNullOrEmpty(run.Password))
{
return Failed(at, 0, "Der er ikke gemt en adgangskode til SQL-login'et.", details);
}
OpenedConnection opened;
try
{
opened = await sqlConnections.OpenAsync(new ConnectionTarget
{
Server = dataSource,
Authentication = settings.Authentication == "SqlLogin" ? AuthenticationMode.SqlLogin : AuthenticationMode.Windows,
UserName = settings.Authentication == "SqlLogin" ? settings.UserName : null,
Password = run.Password,
Encryption = settings.Encryption == "Optional" ? EncryptionMode.Optional : EncryptionMode.Mandatory,
TrustServerCertificate = settings.TrustServerCertificate,
ConnectTimeout = run.Timeout,
}, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is DbException or InvalidOperationException or AuthenticationException)
{
return Failed(at, Elapsed(started), "Login på SQL Server fejlede: " + ex.Message, details);
}
await using (opened.ConfigureAwait(false))
{
var connectMs = Elapsed(started);
details.Add(new SyntheticCheckDetail("Login tog", connectMs.ToString(CultureInfo.InvariantCulture) + " ms"));
try
{
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(run.Timeout.TotalSeconds));
if (!string.IsNullOrWhiteSpace(settings.Database))
{
await ExecuteAsync(opened.Connection, UseDatabaseSql(settings.Database), timeoutSeconds, cancellationToken).ConfigureAwait(false);
}
// A transaction that is always rolled back: a second guard on top of the read-only check of the query.
await ExecuteAsync(opened.Connection, BeginTransactionSql, timeoutSeconds, cancellationToken).ConfigureAwait(false);
var queryStarted = time.GetTimestamp();
var rows = 0;
try
{
await using var command = opened.Connection.CreateCommand();
command.CommandText = query;
command.CommandTimeout = timeoutSeconds;
var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
try
{
while (rows < MaxRows && await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
rows++;
}
}
finally
{
// Closing a reader fetches the rest of the answer; past the limit it is cancelled instead.
if (rows == MaxRows)
{
command.Cancel();
}
try
{
await reader.DisposeAsync().ConfigureAwait(false);
}
catch (DbException) when (rows == MaxRows)
{
// The cancelled rest of the answer.
}
}
}
finally
{
await ExecuteAsync(opened.Connection, RollbackSql, timeoutSeconds, CancellationToken.None).ConfigureAwait(false);
}
var queryMs = (int)time.GetElapsedTime(queryStarted).TotalMilliseconds;
var ms = Elapsed(started);
details.Add(new SyntheticCheckDetail("Forespørgsel tog", queryMs.ToString(CultureInfo.InvariantCulture) + " ms"));
details.Add(new SyntheticCheckDetail("Rækker", rows.ToString(CultureInfo.InvariantCulture)));
return new SyntheticCheckResult
{
At = at,
Success = true,
DurationMs = ms,
Message = $"Login og forespørgsel lykkedes på {ms.ToString(CultureInfo.InvariantCulture)} ms (forespørgslen {queryMs.ToString(CultureInfo.InvariantCulture)} ms).",
Details = details,
};
}
catch (DbException ex)
{
return Failed(at, Elapsed(started), "Forespørgslen fejlede: " + ex.Message, details);
}
}
}
private static async Task ExecuteAsync(DbConnection connection, string sql, int timeoutSeconds, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.CommandTimeout = timeoutSeconds;
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
// ---- DNS --------------------------------------------------------------------------------------------------
private async Task<SyntheticCheckResult> DnsAsync(SyntheticCheckRun run, DateTimeOffset at, long started, CancellationToken cancellationToken)
{
var host = run.Settings.Host!;
IPAddress[] addresses;
try
{
addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is SocketException or ArgumentException)
{
return Failed(at, Elapsed(started), $"Navnet {host} kunne ikke slås op: {ex.Message}");
}
var ms = Elapsed(started);
var found = addresses.Select(a => a.ToString()).ToList();
var details = new List<SyntheticCheckDetail> { new("Navn", host), new("Adresser", found.Count == 0 ? "ingen" : string.Join(", ", found)) };
var expected = run.Settings.ExpectedAddresses;
if (found.Count == 0)
{
return Failed(at, ms, $"Navnet {host} gav ingen adresser.", details);
}
if (expected.Count > 0 && !found.Any(f => expected.Any(e => IPAddress.TryParse(e, out var x) && x.ToString() == f)))
{
return Failed(at, ms, $"{host} peger på {string.Join(", ", found)}, forventede {string.Join(" eller ", expected)}.", details);
}
return new SyntheticCheckResult { At = at, Success = true, DurationMs = ms, Message = $"{host} blev slået op på {ms.ToString(CultureInfo.InvariantCulture)} ms.", Details = details };
}
// ---- Dynamics AX ------------------------------------------------------------------------------------------
private async Task<SyntheticCheckResult> AxAsync(SyntheticCheckRun run, DateTimeOffset at, long started, CancellationToken cancellationToken)
{
var settings = run.Settings;
var host = settings.Host!;
var port = settings.Port ?? SyntheticCheckSettings.DefaultAosPort;
var objectServer = string.IsNullOrWhiteSpace(settings.ObjectServer) ? $"{host}:{port.ToString(CultureInfo.InvariantCulture)}" : settings.ObjectServer.Trim();
var details = new List<SyntheticCheckDetail> { new("AOS", $"{host}:{port.ToString(CultureInfo.InvariantCulture)}") };
var (portOk, portMs, portMessage) = await ConnectAsync(host, port, started, cancellationToken).ConfigureAwait(false);
details.Add(new SyntheticCheckDetail("AOS-port", portOk ? $"svarer ({portMs.ToString(CultureInfo.InvariantCulture)} ms)" : "svarer ikke"));
if (!portOk)
{
return Failed(at, portMs, "AOS-porten svarer ikke. " + portMessage, details);
}
if (!settings.BusinessConnectorLogon)
{
return new SyntheticCheckResult
{
At = at, Success = true, DurationMs = portMs, Partial = true, Details = details,
Message = $"AOS-porten svarer ({portMs.ToString(CultureInfo.InvariantCulture)} ms). Kun porten testes: logon gennem Business Connector er slået fra for tjekket.",
};
}
var installation = businessConnector.Find();
if (installation is null)
{
details.Add(new SyntheticCheckDetail("Business Connector", "ikke installeret på agentens server"));
return new SyntheticCheckResult
{
At = at, Success = true, DurationMs = portMs, Partial = true, Details = details,
Message = $"AOS-porten svarer ({portMs.ToString(CultureInfo.InvariantCulture)} 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.",
};
}
details.Add(new SyntheticCheckDetail("Business Connector", $"{installation.Version} ({installation.Path})"));
details.Add(new SyntheticCheckDetail("Object server", objectServer, Sensitive: true));
if (!string.IsNullOrWhiteSpace(settings.Company))
{
details.Add(new SyntheticCheckDetail("Firma", settings.Company));
}
var (file, arguments) = AxLogonClient.Command(installation, Environment.GetFolderPath(Environment.SpecialFolder.Windows));
var remaining = run.Timeout - time.GetElapsedTime(started);
ProcessResult result;
try
{
result = await processes.RunAsync(file, arguments, AxLogonClient.Environment(installation, settings.Company, objectServer, settings.Configuration),
remaining > TimeSpan.FromSeconds(1) ? remaining : TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
{
return Failed(at, Elapsed(started), "Windows PowerShell til Business Connector kunne ikke startes: " + ex.Message, details);
}
var (ok, logonMs, message) = AxLogonClient.Parse(result, installation);
if (logonMs is { } m)
{
details.Add(new SyntheticCheckDetail("Logon tog", m.ToString(CultureInfo.InvariantCulture) + " ms"));
}
var ms = Elapsed(started);
return new SyntheticCheckResult
{
At = at,
Success = ok,
DurationMs = ms,
Message = ok ? $"AOS-porten svarer, og logon i AX lykkedes på {(logonMs ?? ms).ToString(CultureInfo.InvariantCulture)} ms." : message,
Details = details,
};
}
}

View file

@ -0,0 +1,24 @@
# SQLInsight Pro: logon to Dynamics AX through the .NET Business Connector, run by the agent in its own process.
# Logs on as the agent's Windows account, logs off again, and writes one line: SQLINSIGHT-AX-OK <ms> or SQLINSIGHT-AX-ERROR <text>.
# Reads nothing in AX and changes nothing. Parameters come in environment variables (no quoting problems):
# SQLINSIGHT_AX_DLL path to Microsoft.Dynamics.BusinessConnectorNet.dll
# SQLINSIGHT_AX_COMPANY company (DataAreaId), or empty for the user's default
# SQLINSIGHT_AX_SERVER object server, e.g. 01@aos01:2712 or aos01:2712
# SQLINSIGHT_AX_CONFIG AX client configuration, or empty
$ErrorActionPreference = 'Stop'
function Value([string] $name) { $v = [Environment]::GetEnvironmentVariable($name); if ([string]::IsNullOrEmpty($v)) { return $null } else { return $v } }
try {
Add-Type -Path (Value 'SQLINSIGHT_AX_DLL')
$ax = New-Object Microsoft.Dynamics.BusinessConnectorNet.Axapta
$watch = [System.Diagnostics.Stopwatch]::StartNew()
$ax.Logon((Value 'SQLINSIGHT_AX_COMPANY'), $null, (Value 'SQLINSIGHT_AX_SERVER'), (Value 'SQLINSIGHT_AX_CONFIG'))
$ms = $watch.ElapsedMilliseconds
try { [void] $ax.Logoff() } catch { }
Write-Output ('SQLINSIGHT-AX-OK ' + $ms)
exit 0
} catch {
$e = $_.Exception
while ($e.InnerException) { $e = $e.InnerException }
Write-Output ('SQLINSIGHT-AX-ERROR ' + ($e.Message -replace '\s+', ' '))
exit 1
}

View file

@ -0,0 +1,125 @@
using SQLInsight.Core.Servers;
namespace SQLInsight.Servers;
/// <summary>Opens read-only sessions to Windows servers, as the agent's own identity.</summary>
public interface IWindowsServerSource
{
/// <summary>Connects to <paramref name="host"/>: WS-Man (WinRM) first, DCOM if that fails, or locally for the agent's own computer.</summary>
/// <exception cref="ServerAccessException">The server could not be reached; the message says why (Danish).</exception>
Task<IWindowsServerSession> ConnectAsync(string host, CancellationToken cancellationToken);
}
/// <summary>
/// One connection to a Windows server. Every method only reads: no method changes, starts or stops anything. Each
/// throws <see cref="ServerAccessException"/> with the exact reason when its area cannot be read.
/// </summary>
public interface IWindowsServerSession : IDisposable
{
/// <summary>"WS-Man", "DCOM" or "lokal".</summary>
string Protocol { get; }
/// <summary>Why the preferred protocol was not used (Danish), when it was not.</summary>
string? ProtocolNote { get; }
Task<ServerOsInfo> GetOperatingSystemAsync(CancellationToken cancellationToken);
Task<ServerPerformanceRaw> GetPerformanceAsync(DateTimeOffset now, CancellationToken cancellationToken);
Task<IReadOnlyList<ServerDisk>> GetDisksAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<ServerService>> GetServicesAsync(CancellationToken cancellationToken);
/// <summary>One service, or null when it does not exist (or cannot be seen).</summary>
Task<ServerService?> GetServiceAsync(string name, CancellationToken cancellationToken);
/// <summary>
/// Errors and critical events in the System and Application logs, each log after its own time in <paramref name="since"/>
/// (a log not in it: after the earliest), oldest first; at most <paramref name="max"/> per log, the oldest ones.
/// </summary>
Task<ServerEventRead> GetEventsAsync(IReadOnlyDictionary<string, DateTimeOffset> since, int max, CancellationToken cancellationToken);
/// <summary>Errors and critical events in the System and Application logs since <paramref name="since"/>, oldest first.</summary>
Task<ServerEventRead> GetEventsAsync(DateTimeOffset since, int max, CancellationToken cancellationToken) =>
GetEventsAsync(ServerEventAnalysis.Logs.ToDictionary(l => l, _ => since, StringComparer.OrdinalIgnoreCase), max, cancellationToken);
/// <summary>
/// Scheduled tasks; run information only for the ones <paramref name="watch"/> selects (at most <paramref name="maxWatched"/>),
/// read one at a time until <paramref name="budget"/> is spent: the rest come without run information, and the note says so.
/// </summary>
Task<ServerTaskRead> GetTasksAsync(Func<string, bool> watch, int maxWatched, TimeSpan budget, CancellationToken cancellationToken);
/// <summary>Scheduled tasks, without a time budget.</summary>
Task<ServerTaskRead> GetTasksAsync(Func<string, bool> watch, int maxWatched, CancellationToken cancellationToken) =>
GetTasksAsync(watch, maxWatched, TimeSpan.MaxValue, cancellationToken);
}
/// <param name="Method">How the logs were read: "Event Log (RPC)" or "CIM (Win32_NTLogEvent)".</param>
/// <param name="Truncated">More events than <c>max</c> in a log: the rest is read next time.</param>
/// <param name="TruncatedLogs">The logs that had more (null while <paramref name="Truncated"/>: every log).</param>
public sealed record ServerEventRead(IReadOnlyList<ServerEventEntry> Entries, string Method, bool Truncated, string? Note = null, IReadOnlyList<string>? TruncatedLogs = null)
{
/// <summary>Whether <paramref name="log"/> had more events than were read.</summary>
public bool IsTruncated(string log) => Truncated && (TruncatedLogs is null || TruncatedLogs.Contains(log, StringComparer.OrdinalIgnoreCase));
}
/// <param name="Visible">Every task the agent could see.</param>
public sealed record ServerTaskRead(IReadOnlyList<ServerTask> Watched, int Visible, string? Note = null);
public enum ServerAccessProblem
{
/// <summary>The server does not answer (name, network, firewall, WinRM/RPC not running).</summary>
Unreachable,
/// <summary>The server answers, but the agent's account may not read this.</summary>
AccessDenied,
/// <summary>The server does not have it (an older Windows without the class or namespace).</summary>
NotAvailable,
/// <summary>The server did not answer in time.</summary>
Timeout,
/// <summary>The agent cannot do it at all (not on Windows).</summary>
NotSupported,
Other,
}
/// <summary>A Windows server or one of its areas could not be read. The message is Danish and says exactly why and what to do.</summary>
public sealed class ServerAccessException : Exception
{
public ServerAccessException()
{
}
public ServerAccessException(string message)
: base(message)
{
}
public ServerAccessException(string message, Exception innerException)
: base(message, innerException)
{
}
public ServerAccessException(ServerAccessProblem problem, string message, Exception? innerException = null)
: base(message, innerException)
{
Problem = problem;
}
public ServerAccessProblem Problem { get; } = ServerAccessProblem.Other;
/// <summary>The Windows error code (HRESULT) when there was one, for support.</summary>
public uint? Code { get; init; }
}
/// <summary>For an agent that does not run on Windows: Windows servers cannot be read from Linux.</summary>
public sealed class UnsupportedWindowsServerSource : IWindowsServerSource
{
public const string Reason = "Windows-servere kan kun overvåges, når SQLInsight Agent kører på Windows (CIM/WMI og hændelseslog er Windows-protokoller).";
public Task<IWindowsServerSession> ConnectAsync(string host, CancellationToken cancellationToken) =>
throw new ServerAccessException(ServerAccessProblem.NotSupported, Reason);
}

View file

@ -0,0 +1,123 @@
using System.Globalization;
using SQLInsight.Core.Servers;
namespace SQLInsight.Servers;
/// <summary>
/// Tests a Windows server the way the agent will read it: connects as the agent's identity and tries every area once,
/// so the web UI can show exactly which rights are missing. Reads only.
/// </summary>
public static class WindowsServerTester
{
/// <summary>How long a whole test may take by default: a server that does not answer is a result, in time to be stored.</summary>
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromMinutes(2);
public static Task<WindowsServerTestResult> TestAsync(IWindowsServerSource source, string host, string? account, DateTimeOffset now, CancellationToken cancellationToken) =>
TestAsync(source, host, account, now, DefaultTimeout, cancellationToken);
/// <param name="timeout">For the whole test: what is not done by then is reported as not answered in time.</param>
public static async Task<WindowsServerTestResult> TestAsync(IWindowsServerSource source, string host, string? account, DateTimeOffset now, TimeSpan timeout,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentException.ThrowIfNullOrWhiteSpace(host);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(timeout);
var token = deadline.Token;
var late = $"{host} svarede ikke inden for {timeout.TotalSeconds.ToString("0", CultureInfo.InvariantCulture)} sekunder, så testen blev afbrudt. "
+ "Serveren kan være overbelastet, eller en firewall taber pakkerne.";
IWindowsServerSession session;
try
{
session = await source.ConnectAsync(host, token).ConfigureAwait(false);
}
catch (ServerAccessException ex)
{
return new WindowsServerTestResult { Reachable = false, Error = ex.Message, Hint = Hint(ex.Problem), Account = account };
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return new WindowsServerTestResult { Reachable = false, Error = late, Hint = Hint(ServerAccessProblem.Timeout), Account = account };
}
using (session)
{
var notes = new List<string>();
if (session.ProtocolNote is { } note)
{
notes.Add(note);
}
var areas = new List<ServerAreaStatus>();
ServerOsInfo? os = null;
await TryAsync(ServerArea.Performance, async () =>
{
os = await session.GetOperatingSystemAsync(token).ConfigureAwait(false);
await session.GetPerformanceAsync(now, token).ConfigureAwait(false);
return null;
}).ConfigureAwait(false);
await TryAsync(ServerArea.Disks, async () =>
{
var disks = await session.GetDisksAsync(token).ConfigureAwait(false);
return disks.Count == 0 ? "Ingen lokale diske fundet." : $"{disks.Count} drev: " + string.Join(", ", disks.Select(d => d.Volume));
}).ConfigureAwait(false);
await TryAsync(ServerArea.Services, async () =>
$"{(await session.GetServicesAsync(token).ConfigureAwait(false)).Count} tjenester synlige.").ConfigureAwait(false);
await TryAsync(ServerArea.Events, async () =>
{
var read = await session.GetEventsAsync(now.AddHours(-24), 500, token).ConfigureAwait(false);
if (read.Note is { } eventNote)
{
notes.Add(eventNote);
}
return $"{read.Entries.Count} fejl og kritiske hændelser det seneste døgn (læst med {read.Method}).";
}).ConfigureAwait(false);
await TryAsync(ServerArea.Tasks, async () =>
{
var tasks = await session.GetTasksAsync(name => !TaskResults.IsMicrosoftTask(name), 1, token).ConfigureAwait(false);
return $"{tasks.Visible} opgaver synlige.";
}).ConfigureAwait(false);
return new WindowsServerTestResult
{
Reachable = true,
Protocol = session.Protocol,
Account = account,
ComputerName = os?.ComputerName,
OperatingSystem = os?.Caption,
Version = os?.Version,
LastBootUpTime = os?.LastBootUpTime,
Areas = areas,
Notes = notes,
};
async Task TryAsync(string area, Func<Task<string?>> read)
{
try
{
token.ThrowIfCancellationRequested();
areas.Add(new ServerAreaStatus(area, true, await read().ConfigureAwait(false), ServerArea.Right(area)));
}
catch (ServerAccessException ex)
{
areas.Add(new ServerAreaStatus(area, false, ex.Message, ServerArea.Right(area)));
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
areas.Add(new ServerAreaStatus(area, false, late, ServerArea.Right(area)));
}
}
}
}
public static string Hint(ServerAccessProblem problem) => problem switch
{
ServerAccessProblem.AccessDenied => "Kør tildelingsscriptet nedenfor som administrator på serveren, og test igen.",
ServerAccessProblem.Unreachable or ServerAccessProblem.Timeout =>
"Tjek navnet, at serveren kører, og firewallen fra agentens server: DCOM/RPC (TCP 135 og dynamiske porte) eller WinRM (TCP 5985). "
+ "Tildelingsscriptet kan oprette regler kun for agentens adresse (-EnableFirewall -AgentAddress …).",
ServerAccessProblem.NotSupported => UnsupportedWindowsServerSource.Reason,
_ => "Se fejlteksten. Forbindelsen blev forsøgt fra agentens server som agentens konto.",
};
}

View file

@ -170,7 +170,8 @@ public static class ApiV1Endpoints
private static async Task<IResult> 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();

View file

@ -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.");
}
/// <summary>Logins and people's names, "[udeladt]" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on.</summary>
internal sealed class Sensitive(bool include)
{
public static Sensitive For(IOptions<WebOptions> options) => new(options.Value.ApiIncludesSensitiveEvidence);
public bool Include => include;
public string? Value(string? value) => include || value is null ? value : ApiV1Endpoints.Redacted;
/// <summary>An error text from SQL Server or the agent, with the login in it (Login failed for user '…') left out.</summary>
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
/// <summary>A request to the agent, the same in every endpoint (also the Windows server tests and check runs).</summary>
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),
};
/// <summary>The agent's result; a connection test's login and error text are redacted like the rest.</summary>
/// <summary>
/// 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.
/// </summary>
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<ConnectionTestResult>(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<ConnectionTestResult>(result) is { } test
? AgentRequestJson.Serialize(test with { Login = s.Value(test.Login), Error = s.Text(test.Error) })
: result,
ServerRequestKind.ServerTest => AgentRequestJson.Deserialize<WindowsServerTestResult>(result) is { } server
? AgentRequestJson.Serialize(s.ServerTest(server))
: null,
ServerRequestKind.SyntheticCheckRun => Deserialize<SyntheticCheckResult>(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;
}
}
/// <summary>As <see cref="AgentRequestJson.Deserialize{T}"/>, also for a type with required members the JSON lacks.</summary>
private static T? Deserialize<T>(string json)
where T : class
{
try
{
return JsonSerializer.Deserialize<T>(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,

View file

@ -0,0 +1,103 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
using SQLInsight.Core.Servers;
using SQLInsight.Core.SyntheticChecks;
namespace SQLInsight.Web.Api;
/// <summary>
/// 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.
/// </summary>
internal sealed partial class Sensitive(bool include)
{
public static Sensitive For(IOptions<WebOptions> options) => new(options.Value.ApiIncludesSensitiveEvidence);
public bool Include => include;
public string? Value(string? value) => include || value is null ? value : ApiV1Endpoints.Redacted;
/// <summary>A value that is only redacted when <paramref name="sensitive"/> (evidence and details marked so).</summary>
public string? Value(string? value, bool sensitive) => sensitive ? Value(value) : value;
/// <summary>An error text from SQL Server or the agent, with the login in it (Login failed for user '…') left out.</summary>
public string? Text(string? value) => include || value is null ? value : LoginInText().Replace(value, m => m.Groups[1].Value + "'" + ApiV1Endpoints.Redacted + "'");
/// <summary>
/// A text about a Windows server (the agent's errors, area details, rule reasons): as <see cref="Text"/>, and the
/// agent's account in "Agentens konto (FIRMA\gmsa$)" and, when known, wherever <paramref name="account"/> occurs.
/// </summary>
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;
}
/// <summary>An HTTP address without its query (it may carry a token or a key): https://host/path?[udeladt].</summary>
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);
}
/// <summary>What a check points at, as <see cref="SyntheticCheckSettings.Target"/>, with an HTTP address's query left out.</summary>
public string Target(string kind, SyntheticCheckSettings settings)
{
ArgumentNullException.ThrowIfNull(settings);
return kind == SyntheticCheckKind.Http ? Url(settings.Url) ?? string.Empty : settings.Target(kind);
}
/// <summary>A Windows server test as the agent stored it, with the agent's account and its traces in the texts left out.</summary>
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(),
};
}
/// <summary>A check run as the agent stored it: a failure's message and details marked sensitive left out, as in …/results.</summary>
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();
/// <summary>"Agentens konto (FIRMA\gmsa$)", as SQLInsight.Servers.ServerErrors and the connection test write it: the part in brackets.</summary>
[GeneratedRegex(@"(?<=\bagentens konto \()[^)\r\n]+(?=\))", RegexOptions.IgnoreCase)]
private static partial Regex AccountInText();
}

View file

@ -0,0 +1,754 @@
using System.Globalization;
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;
/// <summary>Body of POST /api/v1/windows-servers.</summary>
public sealed record WindowsServerBody(string? Name, string? Host, string? Role, IReadOnlyList<string>? WatchedServices, IReadOnlyList<string>? IgnoredServices);
/// <summary>Body of PATCH /api/v1/windows-servers/{id}. Omitted values are left as they are.</summary>
public sealed record WindowsServerPatch(bool? Paused, string? Role, IReadOnlyList<string>? WatchedServices, IReadOnlyList<string>? IgnoredServices);
/// <summary>Body of POST and PUT /api/v1/synthetic-checks. The password is write-only (SQL checks with a SQL login).</summary>
public sealed record SyntheticCheckBody(
string? Name,
string? Kind,
int? ServerId,
int? IntervalSeconds,
int? TimeoutSeconds,
SyntheticCheckSettings? Settings,
string? Password);
/// <summary>Body of PATCH /api/v1/synthetic-checks/{id}.</summary>
public sealed record SyntheticCheckPatch(bool? Paused);
/// <summary>
/// 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 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.
/// </summary>
public static class ServerApiEndpoints
{
/// <summary>The most findings one page of /api/v1/server-findings returns.</summary>
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 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").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). 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. 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. 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")
.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")
.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")
.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, IOptions<WebOptions> 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), 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")
.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")
.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. "
+ "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. 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. 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<WebOptions> 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 (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 object Counts(IReadOnlyDictionary<Severity, int>? counts) => new
{
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(WindowsServerRow w, Sensitive s, WindowsServerOverview? overview, IReadOnlyDictionary<Severity, int>? counts) => new
{
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),
};
/// <summary>A check's settings, with the SQL login and an HTTP address's query redacted.</summary>
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<Severity, int>? 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(),
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 IResult Accepted(AgentRequestRow request, Sensitive s) =>
Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", ConfigurationApiEndpoints.Json(request, s));
// ---- Windows servers --------------------------------------------------------------------------------------
private static async Task<(IReadOnlyDictionary<int, WindowsServerOverview> Overviews, IReadOnlyDictionary<(string Scope, int ScopeId), IReadOnlyDictionary<Severity, int>> 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<object> ServerJsonAsync(WindowsServerRow row, InsightRepository repository, Sensitive s, CancellationToken ct)
{
var (overviews, counts) = await OverviewAsync(repository, ct).ConfigureAwait(false);
return Json(row, s, overviews.GetValueOrDefault(row.ServerId), counts.GetValueOrDefault((FindingScope.Server, row.ServerId)));
}
private static async Task<IResult> ListServersAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
var s = Sensitive.For(options);
var servers = await repository.GetWindowsServersAsync(ct).ConfigureAwait(false);
var (overviews, counts) = await OverviewAsync(repository, ct).ConfigureAwait(false);
return Results.Ok(servers.Select(w => Json(w, s, overviews.GetValueOrDefault(w.ServerId), counts.GetValueOrDefault((FindingScope.Server, w.ServerId)))));
}
private static async Task<IResult> GetServerAsync(int id, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct) =>
await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is { } row
? Results.Ok(await ServerJsonAsync(row, repository, Sensitive.For(options), ct).ConfigureAwait(false))
: Results.NotFound();
private static async Task<IResult> AddServerAsync(WindowsServerBody body, HttpContext http, InsightRepository repository, IOptions<WebOptions> 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 = await ServerJsonAsync(row, repository, Sensitive.For(options), ct).ConfigureAwait(false), ServerTestRequestId = outcome.RequestId });
}
private static async Task<IResult> PatchServerAsync(int id, WindowsServerPatch body, HttpContext http, InsightRepository repository, IOptions<WebOptions> 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(await ServerJsonAsync((await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false))!, repository, Sensitive.For(options), ct).ConfigureAwait(false));
}
private static async Task<IResult> RemoveServerAsync(int id, HttpContext http, InsightRepository repository, IOptions<WebOptions> 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<IResult> TestServerAsync(int id, HttpContext http, InsightRepository repository, IOptions<WebOptions> 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();
}
return Accepted((await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!, Sensitive.For(options));
}
private static async Task<IResult> LatestTestAsync(int id, InsightRepository repository, IOptions<WebOptions> options, 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 s = Sensitive.For(options);
var result = AgentRequestJson.Deserialize<WindowsServerTestResult>(test.Result) is { } stored ? s.ServerTest(stored) : null;
return Results.Ok(new
{
Request = ConfigurationApiEndpoints.Json(test, s),
MissingAreas = result?.Missing.Select(a => new { a.Area, a.Right, a.Detail }).ToList() ?? [],
});
}
private static async Task<IResult> GrantScriptAsync(int id, InsightRepository repository, IOptions<WebOptions> options, 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);
if (!Sensitive.For(options).Include && account is not null)
{
// The script and the note name the agent's account; without sensitive data it is the shipped script with its placeholder.
return Results.Ok(new
{
Account = ApiV1Endpoints.Redacted,
Script = ServerGrantScript.Template(),
Note = "Agentens konto er udeladt (SQLInsight:Web:ApiIncludesSensitiveEvidence er slået fra). Angiv den med -Account, "
+ "eller hent det udfyldte script på serverens side under Windows-servere.",
});
}
return Results.Ok(new { Account = account, Script = script, Note = note });
}
private static async Task<IResult> 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<IResult> 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<ServerDiskSnapshot>(id, ServerArea.Disks, ct).ConfigureAwait(false);
if (snapshot is null)
{
return Results.Ok(new { CapturedAt = (DateTimeOffset?)null, Disks = Array.Empty<object>() });
}
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<IResult> 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<ServerServiceSnapshot>(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<IResult> EventsAsync(int id, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, int? days = null)
{
if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is null)
{
return Results.NotFound();
}
var now = DateTimeOffset.UtcNow;
var s = Sensitive.For(options);
var snapshot = await repository.GetServerSnapshotAsync<ServerEventSnapshot>(id, ServerArea.Events, ct).ConfigureAwait(false);
var events = await repository.GetServerEventsAsync(id, now.AddDays(-Math.Clamp(days ?? 1, 1, 90)), now, cancellationToken: ct).ConfigureAwait(false);
return Results.Ok(new
{
CapturedAt = snapshot?.CapturedAt,
snapshot?.Method,
snapshot?.Ignored,
snapshot?.Coverage,
Events = events.Select(e => new
{
e.CollectedAt,
Log = e.LogName,
e.Source,
e.EventId,
e.Level,
e.Category,
e.Count,
e.FirstAt,
e.LastAt,
Sample = s.Value(e.Sample),
}),
});
}
private static async Task<IResult> 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<ServerTaskSnapshot>(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<IResult> RuleStatesAsync(string scope, int id, InsightRepository repository, IOptions<WebOptions> options, 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();
}
var s = Sensitive.For(options);
return Results.Ok((await repository.GetServerRuleStatesAsync(scope, id, ct).ConfigureAwait(false)).Select(r => new
{
r.RuleId,
r.RuleTitle,
Outcome = char.ToLowerInvariant(r.Outcome.ToString()[0]) + r.Outcome.ToString()[1..],
// A reason may name the agent's account ("Agentens konto (…) mangler …"): redacted like accounts elsewhere.
Reason = s.ServerText(r.Reason),
r.EvaluatedAt,
}));
}
private static async Task<IResult> FindingsAsync(HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? status = null,
string? scope = null, int? id = null, int? limit = null, string? continuation = 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 offset = 0;
if (continuation is { Length: > 0 } && !int.TryParse(continuation, NumberStyles.None, CultureInfo.InvariantCulture, out offset))
{
return Results.BadRequest("Ugyldig continuation.");
}
var size = Math.Clamp(limit ?? 100, 1, MaxFindingPage);
var findings = await repository.GetServerFindingsAsync(findingScope, id, findingStatus.Value, size + 1, offset, ct).ConfigureAwait(false);
if (findings.Count > size)
{
http.Response.Headers["X-Continuation"] = ((long)offset + size).ToString(CultureInfo.InvariantCulture);
}
var s = Sensitive.For(options);
return Results.Ok(findings.Take(size).Select(f => Json(f, s)));
}
private static async Task<IResult> FindingAsync(long id, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (await repository.GetServerFindingAsync(id, ct).ConfigureAwait(false) is not { } f)
{
return Results.NotFound();
}
var s = Sensitive.For(options);
var history = await repository.GetServerFindingHistoryAsync(f, ct).ConfigureAwait(false);
return Results.Ok(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(),
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,
History = history.Select(h => new
{
Id = h.FindingId,
Severity = h.Severity.ToString(),
Title = s.ServerText(h.Title),
h.FirstSeenAt,
h.LastSeenAt,
h.ResolvedAt,
}),
});
}
// ---- 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<object> CheckJsonAsync(SyntheticCheckRow row, InsightRepository repository, Sensitive s, CancellationToken ct) =>
Json(row, s, (await repository.GetServerFindingCountsAsync(ct).ConfigureAwait(false)).GetValueOrDefault((FindingScope.Check, row.CheckId)));
private static async Task<IResult> ListChecksAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
var s = Sensitive.For(options);
var checks = await repository.GetSyntheticChecksAsync(ct).ConfigureAwait(false);
var counts = await repository.GetServerFindingCountsAsync(ct).ConfigureAwait(false);
return Results.Ok(checks.Select(c => Json(c, s, counts.GetValueOrDefault((FindingScope.Check, c.CheckId)))));
}
private static async Task<IResult> GetCheckAsync(int id, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct) =>
await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false) is { } row
? Results.Ok(await CheckJsonAsync(row, repository, Sensitive.For(options), ct).ConfigureAwait(false))
: Results.NotFound();
private static async Task<IResult> AddCheckAsync(SyntheticCheckBody body, HttpContext http, InsightRepository repository, IOptions<WebOptions> 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 = await CheckJsonAsync(row, repository, Sensitive.For(options), ct).ConfigureAwait(false), RunRequestId = outcome.RequestId });
}
private static async Task<IResult> UpdateCheckAsync(int id, SyntheticCheckBody body, HttpContext http, InsightRepository repository, IOptions<WebOptions> 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(await CheckJsonAsync((await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false))!, repository, Sensitive.For(options), ct).ConfigureAwait(false));
}
private static async Task<IResult> PatchCheckAsync(int id, SyntheticCheckPatch body, HttpContext http, InsightRepository repository, IOptions<WebOptions> 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(await CheckJsonAsync((await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false))!, repository, Sensitive.For(options), ct).ConfigureAwait(false));
}
private static async Task<IResult> RemoveCheckAsync(int id, HttpContext http, InsightRepository repository, IOptions<WebOptions> 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<IResult> RunCheckAsync(int id, HttpContext http, InsightRepository repository, IOptions<WebOptions> 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();
}
if (outcome.Conflict)
{
return Results.Problem(statusCode: StatusCodes.Status409Conflict, title: "På pause", detail: outcome.Error);
}
return Accepted((await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!, Sensitive.For(options));
}
private static async Task<IResult> ResultsAsync(int id, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, int? hours = null)
{
if (await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false) is null)
{
return Results.NotFound();
}
var s = Sensitive.For(options);
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 : s.Value(r.Message),
Details = r.Details.Select(d => new { d.Name, Value = s.Value(d.Value, d.Sensitive), d.Sensitive }),
r.CertificateExpiresAt,
r.CertificateSubject,
r.ServiceState,
r.Partial,
}));
}
}

View file

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

View file

@ -0,0 +1,123 @@
@page "/servere/fund/{Id:long}"
@inject InsightRepository Repository
@inject Display Display
@if (_finding is null)
{
<PageTitle>Ukendt fund · SQLInsight Pro</PageTitle>
<h1>Ukendt fund</h1>
return;
}
<PageTitle>@_finding.Title · SQLInsight Pro</PageTitle>
<p class="crumbs">
@if (_finding.Scope == FindingScope.Server)
{
<a href="@ServerSetup.ServersPage">Windows-servere</a> <text>/</text>
}
else
{
<a href="@ServerSetup.ChecksPage">Syntetiske tjek</a> <text>/</text>
}
@if (_finding.ScopeRemoved)
{
<span>@_finding.ScopeName</span>
}
else
{
<a href="@(_finding.Scope == FindingScope.Server ? ServerSetup.ServerPage(_finding.ScopeId) : ServerSetup.CheckPage(_finding.ScopeId))">@_finding.ScopeName</a>
}
/ @_finding.RuleId
</p>
@if (_finding.ResolvedAt is null && _finding.ScopeRemoved)
{
<p class="notice" role="note">@(_finding.Scope == FindingScope.Server ? "Serveren" : "Tjekket") er fjernet og overvåges ikke længere. Fundet er historik og løses inden for en time.</p>
}
else if (_finding.ResolvedAt is null && _finding.ScopePaused)
{
<p class="notice" role="note">@(_finding.Scope == FindingScope.Server ? "Serveren" : "Tjekket") er sat på pause, så fundet er ikke med blandt de aktive fund og løses inden for en time.</p>
}
<h1 data-finding-id="@_finding.FindingId"><SeverityBadge Severity="_finding.Severity" /> @_finding.Title</h1>
<p class="lead">
@if (_finding.ResolvedAt is { } resolved)
{
<span>Løst @Display.Time(resolved) · </span>
}
Set første gang @Display.Time(_finding.FirstSeenAt) · senest @Display.Time(_finding.LastSeenAt)
@if (!string.IsNullOrEmpty(_finding.Subject)) { <span> · @_finding.Subject</span> }
</p>
<section class="panel">
<h2>Hvorfor er det et problem?</h2>
<p>@_finding.Explanation</p>
</section>
@if (_finding.Evidence.Count > 0)
{
<section class="panel">
<h2>Målinger</h2>
<table class="evidence">
@foreach (var e in _finding.Evidence)
{
<tr><th>@e.Name</th><td>@e.Value</td></tr>
}
</table>
</section>
}
<section class="panel recommendation">
<h2>Hvad skal jeg gøre?</h2>
<p>@_finding.Recommendation</p>
<p class="muted">SQLInsight ændrer aldrig noget på serveren: den starter ikke tjenester og rydder ikke op.</p>
</section>
@if (_history.Count > 0)
{
<section class="panel">
<h2>Tidligere</h2>
<ul>
@foreach (var h in _history)
{
<li><a href="@ServerSetup.FindingPage(h.FindingId)">@Display.Time(h.FirstSeenAt) – @(h.ResolvedAt is { } r ? Display.Time(r) : "nu")</a>: @h.Title</li>
}
</ul>
</section>
}
@if (_finding.References.Count > 0)
{
<section class="panel">
<h2>Læs mere</h2>
<ul>
@foreach (var r in _finding.References)
{
<li><a href="@r.Url" rel="noopener noreferrer" target="_blank">@r.Title</a></li>
}
</ul>
</section>
}
@code {
[Parameter] public long Id { get; set; }
[CascadingParameter] private HttpContext? Http { get; set; }
private StoredServerFinding? _finding;
private IReadOnlyList<StoredServerFinding> _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);
}
}

View file

@ -0,0 +1,201 @@
@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<WebOptions> Options
@if (_check is null)
{
<PageTitle>Ukendt tjek · SQLInsight Pro</PageTitle>
<h1>Tjekket findes ikke</h1>
<p><a href="@ServerSetup.ChecksPage">Tilbage til syntetiske tjek</a></p>
return;
}
<PageTitle>@_check.Name · Syntetiske tjek · SQLInsight Pro</PageTitle>
@if (_run is { IsFinished: false })
{
<HeadContent><meta http-equiv="refresh" content="2" /></HeadContent>
}
<p class="crumbs"><a href="@ServerSetup.ChecksPage">Syntetiske tjek</a> › @_check.Name</p>
<h1>@_check.Name</h1>
<p class="lead">
@SyntheticCheckKind.Display(_check.Kind) · <span class="mono">@_settings.Target(_check.Kind)</span> · <CheckStatus Check="_check" />
· @ServerSetup.IntervalText(_check.IntervalSeconds), timeout @_check.TimeoutSeconds s
@if (_check.ServerId is { } serverId)
{
<text> · </text><a href="@ServerSetup.ServerPage(serverId)">@_check.ServerName</a>@(_settings.ServiceName is { } svc ? " (" + svc + ")" : string.Empty)
}
</p>
@if (Tilfoejet == 1)
{
<p class="notice ok" role="status">Tjekket er tilføjet, og agenten kører det nu. Resultatet vises herunder om et øjeblik.</p>
}
@if (Gemt == 1)
{
<p class="notice ok" role="status">Tjekket er gemt. Agenten bruger ændringen ved næste kørsel.</p>
}
@if (Pause == 1)
{
<p class="notice ok" role="status">Tjekket er sat på pause, og dets fund løses inden for en time.</p>
}
@if (Genoptaget == 1)
{
<p class="notice ok" role="status">Tjekket er genoptaget.</p>
}
@if (!string.IsNullOrEmpty(Fejl))
{
<p class="notice bad" role="alert">@Fejl</p>
}
@if (_check.SecretError is { } secretError)
{
<div class="alert"><strong>Agenten kan ikke bruge den gemte adgangskode.</strong><p>@secretError</p></div>
}
@if (_run is { } run && Koersel is not null)
{
<p class="muted">Kør nu: @InstanceSetup.StatusText(run, DateTime.UtcNow)@(run.Error is { } runError ? " – " + runError : string.Empty)</p>
}
<section class="panel">
<h2>Seneste resultat</h2>
@if (_results.Count == 0)
{
<p class="muted">Tjekket har ikke kørt endnu.</p>
}
else
{
var latest = _results[0];
<p>@(latest.Success ? "✓" : "✗") @latest.Message</p>
@if (latest.Partial)
{
<p class="alert warn">Kun en del af tjekket kunne udføres (se ovenfor).</p>
}
<table class="evidence">
<tr><th>Tidspunkt</th><td>@Display.Time(latest.RunAt)</td></tr>
<tr><th>Varighed</th><td>@Display.Number(latest.DurationMs) ms</td></tr>
@if (latest.ServiceState is { } state)
{
<tr><th>Tjenesten</th><td>@ServerSetup.StateText(state)</td></tr>
}
@if (latest.CertificateExpiresAt is { } expires)
{
<tr><th>Certifikatet udløber</th><td>@Display.Time(expires) (@latest.CertificateSubject)</td></tr>
}
@foreach (var d in latest.Details)
{
<tr><th>@d.Name</th><td>@d.Value</td></tr>
}
</table>
}
@if (_mayChange && _check.Paused)
{
<p class="muted">Tjekket er sat på pause og køres ikke, heller ikke på bestilling. Genoptag det under Indstillinger.</p>
}
else if (_mayChange)
{
<form method="post" action="/syntetiske-tjek/@Id/koer"><AntiforgeryToken /><button type="submit" class="button">Kør nu</button></form>
}
</section>
<section class="panel">
<h2>Fund</h2>
<ServerFindingList Findings="_findings" Empty="Ingen aktive fund." />
</section>
<section class="panel">
<h2>Svartid (seneste døgn)</h2>
<Sparkline Points="_durations" Label="Svartid" Unit=" ms" />
<h3>Seneste kørsler</h3>
@if (_results.Count == 0)
{
<p class="muted">Ingen.</p>
}
else
{
<table>
<thead><tr><th>Tidspunkt</th><th>Resultat</th><th class="num">Varighed</th><th>Besked</th></tr></thead>
<tbody>
@foreach (var r in _results)
{
<tr>
<td class="nowrap">@Display.Time(r.RunAt)</td>
<td>@if (r.Success) { <span class="badge sev-ok">OK</span> } else { <span class="badge sev-high">Fejl</span> }</td>
<td class="num">@Display.Number(r.DurationMs) ms</td>
<td class="small">@r.Message</td>
</tr>
}
</tbody>
</table>
}
</section>
<section class="panel" id="ret">
<h2>Indstillinger</h2>
@if (_mayChange)
{
<details open="@(!string.IsNullOrEmpty(Fejl))">
<summary>Ret tjekket</summary>
<CheckForm Action="@($"/syntetiske-tjek/{Id}/ret")" Check="_check" Servers="_servers" SubmitText="Gem" />
</details>
<div class="report-links">
<form method="post" action="/syntetiske-tjek/@Id/pause">
<AntiforgeryToken />
<input type="hidden" name="paused" value="@(_check.Paused ? "false" : "true")" />
<button type="submit" class="button secondary">@(_check.Paused ? "Genoptag tjekket" : "Sæt på pause")</button>
</form>
<form method="post" action="/syntetiske-tjek/@Id/fjern"><AntiforgeryToken /><button type="submit" class="button danger">Fjern tjekket</button></form>
</div>
}
<p class="muted">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).</p>
</section>
@code {
[Parameter] public int Id { get; set; }
[CascadingParameter] private HttpContext? Http { get; set; }
[SupplyParameterFromQuery(Name = "fejl")] public string? Fejl { get; set; }
[SupplyParameterFromQuery(Name = "tilfoejet")] public int? Tilfoejet { get; set; }
[SupplyParameterFromQuery(Name = "gemt")] public int? Gemt { get; set; }
[SupplyParameterFromQuery(Name = "pause")] public int? Pause { get; set; }
[SupplyParameterFromQuery(Name = "genoptaget")] public int? Genoptaget { get; set; }
[SupplyParameterFromQuery(Name = "koersel")] public long? Koersel { get; set; }
private SyntheticCheckRow? _check;
private SyntheticCheckSettings _settings = new();
private IReadOnlyList<StoredSyntheticCheckResult> _results = [];
private IReadOnlyList<MetricPoint> _durations = [];
private IReadOnlyList<StoredServerFinding> _findings = [];
private IReadOnlyList<WindowsServerRow> _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();
// The run asked for (?koersel=), if it is about this check; after adding, the first run.
_run = Koersel is { } run
? await Repository.GetAgentRequestAsync(run) is { Kind: ServerRequestKind.SyntheticCheckRun } request && request.TargetId == Id ? request : null
: Tilfoejet == 1 ? await Repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, Id) : null;
_mayChange = Http is not null && InstanceSetup.MayChange(Http.User, Options.Value);
}
}

View file

@ -0,0 +1,92 @@
@page "/syntetiske-tjek"
@using SQLInsight.Core.SyntheticChecks
@inject InsightRepository Repository
@inject Display Display
@inject Microsoft.Extensions.Options.IOptions<WebOptions> Options
<PageTitle>Syntetiske tjek · SQLInsight Pro</PageTitle>
<h1>Syntetiske tjek</h1>
<p class="lead">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.</p>
@if (Fjernet == 1)
{
<p class="notice ok" role="status">Tjekket er fjernet. Resultaterne er bevaret, indtil de udløber.</p>
}
@if (!string.IsNullOrEmpty(Fejl))
{
<p class="notice bad" role="alert">@Fejl</p>
}
@if (!_mayChange)
{
<p class="notice" role="note">Du kan se tjekkene, men kun administratorer kan tilføje og ændre dem. @InstanceSetup.WhoMayChangeText(Options.Value)</p>
}
<section class="card">
<div class="card-head"><h2>Tjek</h2><span class="muted">@_checks.Count(c => !c.Paused) aktive · <a href="@ServerSetup.ServersPage">Windows-servere</a></span></div>
@if (_checks.Count == 0)
{
<p class="empty">Der er ingen syntetiske tjek endnu.@(_mayChange ? " Tilføj det første nedenfor." : string.Empty)</p>
}
else
{
<table>
<thead><tr><th>Tjek</th><th>Type</th><th>Mål</th><th>Status</th><th class="num">Svartid</th><th>Seneste</th><th>Fund</th></tr></thead>
<tbody>
@foreach (var c in _checks)
{
var counts = _counts.GetValueOrDefault((FindingScope.Check, c.CheckId));
<tr>
<td><a href="@ServerSetup.CheckPage(c.CheckId)">@c.Name</a>@if (c.ServerName is { } server) { <div class="muted small">@server@(c.Settings.ServiceName is { } svc ? " · " + svc : string.Empty)</div> }</td>
<td>@SyntheticCheckKind.Display(c.Kind)</td>
<td class="mono small">@c.Settings.Target(c.Kind)</td>
<td><CheckStatus Check="c" /></td>
<td class="num">@(c.LastDurationMs is { } ms ? Display.Number(ms) + " ms" : "–")</td>
<td title="@c.LastMessage">@Display.Ago(c.LastRunAt)</td>
<td>
@if (counts is null || counts.Count == 0)
{
<SeverityBadge Severity="null" />
}
else
{
@foreach (var (severity, count) in counts.OrderByDescending(x => x.Key))
{
<SeverityBadge Severity="severity" Count="count" />
}
}
</td>
</tr>
}
</tbody>
</table>
}
</section>
@if (_mayChange)
{
<section class="panel" id="tilfoej">
<h2>Tilføj tjek</h2>
<p class="muted">Udfyld felterne for den valgte type; de andre ignoreres. Efter tilføjelsen kører agenten tjekket med det samme.</p>
<CheckForm Action="/syntetiske-tjek/tilfoej" Servers="_servers" SubmitText="Tilføj og kør tjekket" />
</section>
}
@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<SyntheticCheckRow> _checks = [];
private IReadOnlyList<WindowsServerRow> _servers = [];
private IReadOnlyDictionary<(string, int), IReadOnlyDictionary<Severity, int>> _counts = new Dictionary<(string, int), IReadOnlyDictionary<Severity, int>>();
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);
}
}

View file

@ -0,0 +1,471 @@
@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<WebOptions> Options
@if (_server is null)
{
<PageTitle>Ukendt server · SQLInsight Pro</PageTitle>
<h1>Serveren findes ikke</h1>
<p><a href="@ServerSetup.ServersPage">Tilbage til Windows-servere</a></p>
return;
}
<PageTitle>@_server.Name · Windows-servere · SQLInsight Pro</PageTitle>
@if (_test is { IsFinished: false })
{
<HeadContent><meta http-equiv="refresh" content="2" /></HeadContent>
}
<p class="crumbs"><a href="@ServerSetup.ServersPage">Windows-servere</a> › @_server.Name</p>
<h1>@_server.Name</h1>
<p class="lead">
<span class="mono">@_server.Host</span> · @ServerRoles.Display(_server.Role)@(_server.IsSqlHost ? " (fra SQL Server-instanserne)" : string.Empty) ·
<ServerStatus Server="_server" />
@if (_server.OperatingSystem is { } os)
{
<text> · @os</text>
}
@if (_server.LastBootUpTime is { } boot)
{
<text> · startet @Display.Time(boot)</text>
}
@if (_server.Protocol is { } protocol)
{
<text> · læses over @protocol</text>
}
</p>
@if (Tilfoejet == 1)
{
<p class="notice ok" role="status">Serveren er tilføjet. Agenten tester nu forbindelsen og rettighederne; resultatet vises under Rettigheder om et øjeblik.</p>
}
@if (Gemt == 1)
{
<p class="notice ok" role="status">Indstillingerne er gemt. Agenten bruger dem fra næste runde (inden for et minut).</p>
}
@if (Pause == 1)
{
<p class="notice ok" role="status">Overvågningen er sat på pause. Agenten stopper inden for et minut, og serverens fund løses; det indsamlede er bevaret.</p>
}
@if (Genoptaget == 1)
{
<p class="notice ok" role="status">Overvågningen er genoptaget.</p>
}
@if (!string.IsNullOrEmpty(Fejl))
{
<p class="notice bad" role="alert">@Fejl</p>
}
@if (!_server.Reachable && _server.LastError is { } lastError)
{
<div class="alert"><strong>Agenten kunne ikke læse serveren @Display.Time(_server.LastErrorAt).</strong><p>@lastError</p></div>
}
<section class="panel">
<h2>Fund</h2>
<ServerFindingList Findings="_findings" Empty="Ingen aktive fund på serveren." />
@{
var notMeasured = _states.Where(s => s.Outcome is RuleOutcome.NotMeasurable or RuleOutcome.Error).ToList();
}
@if (notMeasured.Count > 0)
{
<h3>Ikke målbart</h3>
<table>
<thead><tr><th>Regel</th><th>Hvorfor</th></tr></thead>
<tbody>
@foreach (var s in notMeasured)
{
<tr><td><span class="mono">@s.RuleId</span> @s.RuleTitle</td><td>@s.Reason</td></tr>
}
</tbody>
</table>
}
</section>
<section class="panel">
<h2>CPU og hukommelse (seneste døgn)</h2>
<Sparkline Points="_cpu" Label="CPU" Unit=" %" Max="100" />
<Sparkline Points="_memory" Label="Ledig hukommelse" Unit=" GB" Decimals="1" />
@if (_server.TotalMemoryMb is { } total)
{
<p class="muted">@Display.Number(total / 1024, 1) GB hukommelse · @(_server.LogicalProcessors?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "–") logiske processorer.</p>
}
</section>
<section class="panel">
<h2>Diske</h2>
@if (_disks is null || _disks.Disks.Count == 0)
{
<p class="muted">Ingen målinger endnu.</p>
}
else
{
<table>
<thead><tr><th>Drev</th><th>Navn</th><th class="num">Størrelse</th><th class="num">Ledig</th><th class="num">Ledig %</th><th>Prognose</th></tr></thead>
<tbody>
@foreach (var d in _disks.Disks)
{
var forecast = _disks.Forecasts.FirstOrDefault(f => string.Equals(f.Volume, d.Volume, StringComparison.OrdinalIgnoreCase));
<tr>
<td class="mono">@d.Volume</td>
<td>@d.Label</td>
<td class="num">@ServerSetup.Size(d.SizeGb)</td>
<td class="num">@ServerSetup.Size(d.FreeGb)</td>
<td class="num @(d.FreePercent < 10 ? "bad" : string.Empty)">@Display.Number(d.FreePercent, 1) %</td>
<td class="@(forecast?.DaysUntilFull <= 30 ? "bad" : "muted")">
@(forecast?.Describe() ?? "–")
@if (forecast?.DaysUntilFull is not null)
{
<span class="small"> (@Display.Number(forecast.GrowthGbPerDay, 2) GB/dag)</span>
}
</td>
</tr>
}
</tbody>
</table>
<p class="muted">Prognosen er en ret linje gennem SQLInsights egne målinger af ledig plads @ForecastPeriod, siden drevet sidst skiftede størrelse. Svinger pladsen meget, gives ingen prognose. Målt @Display.Time(_disks.CapturedAt.UtcDateTime).</p>
}
</section>
<section class="panel">
<h2>Tjenester</h2>
@if (_services is null)
{
<p class="muted">Ingen målinger endnu.</p>
}
else
{
var watched = _server.Watched;
var stopped = _services.Services.Where(s => s.IsAutomatic && !s.IsRunning).ToList();
@if (watched.Count > 0)
{
<h3>Overvågede tjenester</h3>
<table>
<thead><tr><th>Tjeneste</th><th>Status</th><th>Start</th></tr></thead>
<tbody>
@foreach (var name in watched)
{
var s = _services.Services.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase) || x.DisplayName.Equals(name, StringComparison.OrdinalIgnoreCase));
<tr>
<td>@(s?.DisplayName ?? name) <span class="mono small">@name</span></td>
<td>
@if (s is null)
{
<span class="badge sev-medium">Findes ikke</span>
}
else if (s.IsRunning)
{
<span class="badge sev-ok">Kører</span>
}
else
{
<span class="badge sev-high">@ServerSetup.StateText(s.State)</span>
}
</td>
<td>@ServerSetup.StartModeText(s?.StartMode)</td>
</tr>
}
</tbody>
</table>
}
<h3>Automatiske tjenester, der ikke kører</h3>
@if (stopped.Count == 0)
{
<p class="muted">Ingen.</p>
}
else
{
<table>
<thead><tr><th>Tjeneste</th><th>Status</th><th class="num">Afslutningskode</th></tr></thead>
<tbody>
@foreach (var s in stopped)
{
<tr><td>@s.DisplayName <span class="mono small">@s.Name</span></td><td>@ServerSetup.StateText(s.State)</td><td class="num">@s.ExitCode</td></tr>
}
</tbody>
</table>
<p class="muted">Nogle automatiske tjenester stopper af sig selv (fx sppsvc og opdateringstjenester); dem giver SQLInsight ikke fund for. Andre kan ignoreres under Indstillinger.</p>
}
<p class="muted">@_services.Services.Count tjenester · målt @Display.Time(_services.CapturedAt.UtcDateTime)</p>
}
</section>
<section class="panel">
<h2>Hændelseslog (seneste døgn)</h2>
@if (_events.Count == 0)
{
<p class="muted">Ingen fejl eller kritiske hændelser det seneste døgn (ud over kendt støj).</p>
}
else
{
<table>
<thead><tr><th>Senest</th><th>Log</th><th>Kilde</th><th class="num">Id</th><th>Slags</th><th class="num">Antal</th><th>Eksempel</th></tr></thead>
<tbody>
@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))
{
<tr>
<td class="nowrap">@Display.Time(e.Newest.LastAt)</td>
<td>@e.Newest.LogName</td>
<td>@e.Newest.Source</td>
<td class="num">@e.Newest.EventId</td>
<td>@(Enum.TryParse<ServerEventCategory>(e.Newest.Category, out var category) ? ServerEventAnalysis.Display(category) : e.Newest.Category)@(e.Newest.Level == 1 ? " (kritisk)" : string.Empty)</td>
<td class="num">@e.Count</td>
<td class="small">@e.Newest.Sample</td>
</tr>
}
</tbody>
</table>
}
@if (_eventSnapshot is { } snapshot)
{
<p class="muted">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).</p>
}
</section>
<section class="panel">
<h2>Planlagte opgaver</h2>
@if (_tasks is null)
{
<p class="muted">Ingen målinger endnu.</p>
}
else if (_tasks.Tasks.Count == 0)
{
<p class="muted">Ingen overvågede opgaver (@_tasks.Visible synlige i alt; Microsofts egne under \Microsoft\ overvåges ikke).</p>
}
else
{
<table>
<thead><tr><th>Opgave</th><th>Status</th><th>Seneste kørsel</th><th>Resultat</th><th>Næste kørsel</th></tr></thead>
<tbody>
@foreach (var t in _tasks.Tasks)
{
<tr>
<td class="mono">@t.FullName</td>
<td>@t.State</td>
<td>@Display.Time(t.LastRunTime?.UtcDateTime)</td>
<td class="@(TaskResults.IsFailure(t.LastTaskResult) ? "bad" : string.Empty)">@(t.LastTaskResult is { } r ? TaskResults.Describe(r) : "–")</td>
<td>@Display.Time(t.NextRunTime?.UtcDateTime)</td>
</tr>
}
</tbody>
</table>
<p class="muted">@_tasks.Tasks.Count overvåget af @_tasks.Visible synlige opgaver@(_tasks.Coverage is { } c ? " · " + c : string.Empty).</p>
}
</section>
@if (_checks.Count > 0)
{
<section class="panel">
<h2>Syntetiske tjek på serveren</h2>
<ul>
@foreach (var c in _checks)
{
<li><a href="@ServerSetup.CheckPage(c.CheckId)">@c.Name</a> · @SyntheticCheckKind.Display(c.Kind) · @(c.LastSuccess switch { true => "OK", false => "fejler", _ => "ikke kørt endnu" })</li>
}
</ul>
</section>
}
<section class="panel" id="rettigheder">
<h2>Rettigheder og forbindelse</h2>
<p>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.</p>
@if (_server.Areas.Count > 0)
{
<table>
<thead><tr><th>Område</th><th>Status</th><th>Kræver</th><th>Detaljer</th></tr></thead>
<tbody>
@foreach (var a in _server.Areas)
{
<tr>
<td>@ServerArea.Display(a.Area)</td>
<td>@if (a.Ok) { <span class="badge sev-ok">Kan læses</span> } else { <span class="badge sev-critical">Ikke målbar</span> }</td>
<td class="small">@a.Right</td>
<td class="small">@a.Detail</td>
</tr>
}
</tbody>
</table>
}
<h3>Seneste test</h3>
@if (_test is null)
{
<p class="muted">Der er ikke testet endnu.</p>
}
else
{
<p class="muted">@InstanceSetup.StatusText(_test, DateTime.UtcNow) · bestilt @Display.Time(_test.RequestedAt)@(_test.RequestedBy is { } who ? " af " + who : string.Empty)@(_test.CompletedAt is { } completed ? " · udført " + Display.Time(completed) + " af agenten" : string.Empty)</p>
@if (_test.Error is { } testError)
{
<div class="alert"><strong>Testen kunne ikke udføres.</strong><p>@testError</p></div>
}
@if (_testResult is { Reachable: false } failed)
{
<div class="alert"><strong>Agenten kunne ikke læse serveren.</strong><p>@failed.Error</p>@if (failed.Hint is { } hint) { <p>@hint</p> }</div>
}
else if (_testResult is { } ok)
{
<table class="evidence">
<tr><th>Forbundet med</th><td>@ok.Protocol</td></tr>
<tr><th>Agentens konto</th><td class="mono">@ok.Account</td></tr>
<tr><th>Styresystem</th><td>@ok.OperatingSystem @ok.Version</td></tr>
</table>
@foreach (var note in ok.Notes)
{
<p class="alert warn">@note</p>
}
@if (!ok.Missing.Any())
{
<p class="notice ok">Agenten kan læse alle områder.</p>
}
else
{
<div class="alert"><strong>Agenten mangler adgang til @ok.Missing.Count() @(ok.Missing.Count() == 1 ? "område" : "områder").</strong> Kør scriptet nedenfor som administrator på serveren, og test igen.</div>
}
}
}
@if (_mayChange)
{
<form method="post" action="/servere/@Id/test"><AntiforgeryToken /><button type="submit" class="button">Test forbindelsen</button></form>
}
<h3>Script, der giver rettighederne</h3>
<p>Kør det som administrator på <span class="mono">@_server.Host</span> – først med <code>-WhatIf</code>, der viser, hvad det vil gøre, uden at ændre noget, og gerne først på en server, der ikke er i produktion. Det giver agentens konto læseadgang (Performance Monitor Users, Event Log Readers, Distributed COM Users, WMI Enable Account og Remote Enable på root\cimv2 og TaskScheduler, læseret på Service Control Manager) og intet andet, og det gemmer de oprindelige indstillinger, før det ændrer noget. SQLInsight kører det aldrig selv.</p>
<ul class="small">
<li><code>-WinRM</code> giver også kontoen læseadgang til WinRM, så WMI kan læses over WinRM; uden den bruger agenten DCOM.</li>
<li><code>-EnableFirewall -AgentAddress &lt;ip&gt;</code> opretter scriptets egne firewallregler ("SQLInsight Agent: …") kun for agentens adresse. Windows' egne regler røres ikke.</li>
<li><code>-Remove</code> fjerner præcis det, scriptet selv har tilføjet. Rettigheder, kontoen havde i forvejen, bevares.</li>
</ul>
@if (_grant.Script is { } script)
{
<p>Konto: <span class="mono">@_grant.Account</span> · <a href="/servere/@Id/grant-windows-server.ps1">Hent som fil</a></p>
@if (_grant.Note is { } grantNote)
{
<p class="muted">@grantNote</p>
}
<details>
<summary>Vis scriptet</summary>
<div class="script">
<button type="button" class="copy" data-copy="grant-script">Kopiér</button>
<pre id="grant-script">@script</pre>
</div>
</details>
}
else
{
<p class="muted">@_grant.Note</p>
}
</section>
<section class="panel" id="indstillinger">
<h2>Indstillinger</h2>
@if (_mayChange)
{
<form method="post" action="/servere/@Id/indstillinger" class="support-form">
<AntiforgeryToken />
<label>Rolle
<select name="role">
@foreach (var role in ServerRoles.All)
{
<option value="@role" selected="@(role == _server.Role)">@ServerRoles.Display(role)</option>
}
</select>
</label>
<label>Tjenester der skal køre (adskilt med komma; fund hvis de ikke kører, uanset starttype) <input name="watched" maxlength="4000" value="@string.Join(", ", _server.Watched)" placeholder="AOS60$01, W3SVC" /></label>
<label>Tjenester der må være stoppet (ignoreres; navn eller præfiks*) <input name="ignored" maxlength="4000" value="@string.Join(", ", _server.Ignored)" /></label>
<button type="submit" class="button">Gem</button>
</form>
<div class="report-links">
<form method="post" action="/servere/@Id/pause">
<AntiforgeryToken />
<input type="hidden" name="paused" value="@(_server.Paused ? "false" : "true")" />
<button type="submit" class="button secondary">@(_server.Paused ? "Genoptag overvågningen" : "Sæt på pause")</button>
</form>
@if (!_server.IsSqlHost)
{
<form method="post" action="/servere/@Id/fjern"><AntiforgeryToken /><button type="submit" class="button danger">Fjern serveren</button></form>
}
</div>
@if (_server.IsSqlHost)
{
<p class="muted">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.</p>
}
}
else
{
<table class="evidence">
<tr><th>Tjenester der skal køre</th><td>@(_server.Watched.Count == 0 ? "–" : string.Join(", ", _server.Watched))</td></tr>
<tr><th>Tjenester der ignoreres</th><td>@(_server.Ignored.Count == 0 ? "–" : string.Join(", ", _server.Ignored))</td></tr>
</table>
}
</section>
@code {
[Parameter] public int Id { get; set; }
[CascadingParameter] private HttpContext? Http { get; set; }
[SupplyParameterFromQuery(Name = "fejl")] public string? Fejl { get; set; }
[SupplyParameterFromQuery(Name = "tilfoejet")] public int? Tilfoejet { get; set; }
[SupplyParameterFromQuery(Name = "gemt")] public int? Gemt { get; set; }
[SupplyParameterFromQuery(Name = "pause")] public int? Pause { get; set; }
[SupplyParameterFromQuery(Name = "genoptaget")] public int? Genoptaget { get; set; }
private WindowsServerRow? _server;
private IReadOnlyList<StoredServerFinding> _findings = [];
private IReadOnlyList<StoredRuleState> _states = [];
private IReadOnlyList<MetricPoint> _cpu = [];
private IReadOnlyList<MetricPoint> _memory = [];
private ServerDiskSnapshot? _disks;
private ServerServiceSnapshot? _services;
private ServerTaskSnapshot? _tasks;
private ServerEventSnapshot? _eventSnapshot;
private IReadOnlyList<StoredServerEvent> _events = [];
private IReadOnlyList<SyntheticCheckRow> _checks = [];
private AgentRequestRow? _test;
private WindowsServerTestResult? _testResult;
private (string? Account, string? Script, string? Note) _grant;
private bool _mayChange;
/// <summary>
/// The history the forecasts are computed from, as the agent used it (SQLInsight:Servers:Disks:ForecastHistoryDays,
/// capped by what has been collected): the longest history among the volumes.
/// </summary>
private string ForecastPeriod =>
_disks?.Forecasts.Count > 0 && _disks.Forecasts.Max(f => f.HistoryDays) is var days and >= 1
? "de seneste " + Display.Number(Math.Round(days), 0) + " dage"
: "de seneste dage";
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<ServerDiskSnapshot>(Id, ServerArea.Disks);
_services = await Repository.GetServerSnapshotAsync<ServerServiceSnapshot>(Id, ServerArea.Services);
_tasks = await Repository.GetServerSnapshotAsync<ServerTaskSnapshot>(Id, ServerArea.Tasks);
_eventSnapshot = await Repository.GetServerSnapshotAsync<ServerEventSnapshot>(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<WindowsServerTestResult>(_test?.Result);
_grant = await ServerSetup.GrantScriptAsync(Repository, _server, CancellationToken.None);
_mayChange = Http is not null && InstanceSetup.MayChange(Http.User, Options.Value);
}
}

View file

@ -0,0 +1,144 @@
@page "/servere"
@using SQLInsight.Core.Servers
@using Microsoft.AspNetCore.Components.Forms
@inject InsightRepository Repository
@inject Display Display
@inject Microsoft.Extensions.Options.IOptions<WebOptions> Options
<PageTitle>Windows-servere · SQLInsight Pro</PageTitle>
<h1>Windows-servere</h1>
<p class="lead">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.</p>
@if (Fjernet == 1)
{
<p class="notice ok" role="status">Serveren overvåges ikke længere. Det, der allerede er indsamlet, er bevaret.</p>
}
@if (!string.IsNullOrEmpty(Fejl))
{
<p class="notice bad" role="alert">@Fejl</p>
}
@if (!_mayChange)
{
<p class="notice" role="note">Du kan se serverne, men kun administratorer kan tilføje og ændre dem. @InstanceSetup.WhoMayChangeText(Options.Value)</p>
}
<section class="card">
<div class="card-head"><h2>Servere</h2><span class="muted">@_servers.Count(s => !s.Paused) overvåges · <a href="@ServerSetup.ChecksPage">Syntetiske tjek</a></span></div>
@if (_servers.Count == 0)
{
<p class="empty">Der er ingen Windows-servere endnu. SQL Server-hosts kommer med, når agenten har set instanserne under <a href="/opsaetning/instanser">Servere og databaser</a>.@(_mayChange ? " Tilføj applikations- og filservere nedenfor." : string.Empty)</p>
}
else
{
<table>
<thead><tr><th>Server</th><th>Rolle</th><th>Status</th><th class="num">CPU</th><th class="num">Ledig hukommelse</th><th>Diske</th><th>Fund</th></tr></thead>
<tbody>
@foreach (var s in _servers)
{
var overview = _overviews.GetValueOrDefault(s.ServerId);
var counts = _counts.GetValueOrDefault((FindingScope.Server, s.ServerId));
var latest = overview?.Latest;
<tr>
<td><a href="@ServerSetup.ServerPage(s.ServerId)">@s.Name</a><div class="muted mono small">@s.Host</div></td>
<td>@ServerRoles.Display(s.Role)@(s.IsSqlHost ? " · fra SQL Server-instanser" : string.Empty)</td>
<td><ServerStatus Server="s" /></td>
<td class="num">@(latest?.CpuPercent is { } cpu ? Display.Number(cpu) + " %" : "–")</td>
<td class="num">@(latest?.AvailableMb is { } mb ? Display.Number(mb / 1024, 1) + " GB" : "–")</td>
<td>
@if (overview?.Disks is null)
{
<span class="muted">–</span>
}
else
{
var worst = overview.WorstDisk;
var soonest = overview.SoonestFull;
@if (worst is not null)
{
<span>@worst.Volume @Display.Number(worst.FreePercent) % ledig</span>
}
@if (soonest is not null)
{
<div class="small @(soonest.DaysUntilFull <= 30 ? "bad" : "muted")">@soonest.Volume @soonest.Describe()</div>
}
}
</td>
<td>
@if (counts is null || counts.Count == 0)
{
<SeverityBadge Severity="null" />
}
else
{
@foreach (var (severity, count) in counts.OrderByDescending(c => c.Key))
{
<SeverityBadge Severity="severity" Count="count" />
}
}
</td>
</tr>
}
</tbody>
</table>
}
</section>
<section class="panel">
<h2>Aktive fund på servere og syntetiske tjek</h2>
@if (_paused.Count > 0)
{
<p class="muted">Servere og tjek sat på pause er ikke med: @string.Join(", ", _paused). Deres seneste fund står på deres egen side.</p>
}
<ServerFindingList Findings="_findings" ShowScope="true" Empty="Ingen aktive fund." />
</section>
@if (_mayChange)
{
<section class="panel" id="tilfoej">
<h2>Tilføj server</h2>
<p class="muted">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.</p>
<form method="post" action="/servere/tilfoej" class="support-form">
<AntiforgeryToken />
<div class="support-contact">
<label>Server <input name="host" required maxlength="255" placeholder="aos01.firma.local" autocomplete="off" /></label>
<label>Navn i SQLInsight (valgfrit) <input name="name" maxlength="128" placeholder="som servernavnet" /></label>
<label>Rolle
<select name="role">
<option value="Application" selected>@ServerRoles.Display(ServerRoles.Application)</option>
<option value="File">@ServerRoles.Display(ServerRoles.File)</option>
<option value="Sql">@ServerRoles.Display(ServerRoles.Sql)</option>
<option value="Other">@ServerRoles.Display(ServerRoles.Other)</option>
</select>
</label>
</div>
<label>Tjenester der skal køre (valgfrit, adskilt med komma) <input name="watched" maxlength="4000" placeholder="AOS60$01, W3SVC" /></label>
<button type="submit" class="button">Tilføj og test forbindelsen</button>
</form>
</section>
}
@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<WindowsServerRow> _servers = [];
private IReadOnlyList<StoredServerFinding> _findings = [];
private IReadOnlyDictionary<(string, int), IReadOnlyDictionary<Severity, int>> _counts = new Dictionary<(string, int), IReadOnlyDictionary<Severity, int>>();
private IReadOnlyDictionary<int, WindowsServerOverview> _overviews = new Dictionary<int, WindowsServerOverview>();
private IReadOnlyList<string> _paused = [];
private bool _mayChange;
protected override async Task OnInitializedAsync()
{
// Paused (and removed) servers and checks are not looked at now: their findings are not in the active list.
_servers = await Repository.GetWindowsServersAsync();
_findings = await Repository.GetServerFindingsAsync();
_counts = await Repository.GetServerFindingCountsAsync();
_overviews = await Repository.GetWindowsServerOverviewsAsync(DateTimeOffset.UtcNow.AddMinutes(-10));
_paused = _servers.Where(s => s.Paused).Select(s => s.Name)
.Concat((await Repository.GetSyntheticChecksAsync()).Where(c => c.Paused).Select(c => c.Name)).ToList();
_mayChange = Http is not null && InstanceSetup.MayChange(Http.User, Options.Value);
}
}

View file

@ -0,0 +1,113 @@
@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. *@
<form method="post" action="@Action" class="support-form">
<AntiforgeryToken />
<div class="support-contact">
<label>Navn <input name="name" required maxlength="128" value="@Check?.Name" placeholder="AOS01 AX-logon" /></label>
@if (Check is null)
{
<label>Type
<select name="kind">
@foreach (var kind in SyntheticCheckKind.All)
{
<option value="@kind" selected="@(kind == SyntheticCheckKind.Tcp)">@SyntheticCheckKind.Display(kind)</option>
}
</select>
</label>
}
else
{
<input type="hidden" name="kind" value="@Check.Kind" />
<label>Type <input value="@SyntheticCheckKind.Display(Check.Kind)" disabled /></label>
}
@if (Check is { IntervalSeconds: var seconds } && seconds % 60 != 0)
{
@* Set to a part of a minute through the API: kept in seconds, not rounded on the next save. *@
<label>Interval (sekunder) <input name="intervalSeconds" inputmode="numeric" pattern="[0-9]*" maxlength="5" value="@seconds" /></label>
}
else
{
<label>Interval (minutter) <input name="intervalMinutes" inputmode="numeric" pattern="[0-9]*" maxlength="4" value="@((Check?.IntervalSeconds ?? SyntheticCheckLimits.DefaultIntervalSeconds) / 60)" /></label>
}
<label>Timeout (sekunder, tom: standard) <input name="timeoutSeconds" inputmode="numeric" pattern="[0-9]*" maxlength="3" value="@Check?.TimeoutSeconds" /></label>
</div>
<div class="support-contact">
<label>Windows-server (valgfri)
<select name="serverId">
<option value="">Ingen</option>
@foreach (var s in Servers)
{
<option value="@s.ServerId" selected="@(Check?.ServerId == s.ServerId)">@s.Name</option>
}
</select>
</label>
<label>Tjeneste bag tjekket (valgfri) <input name="serviceName" maxlength="256" value="@Settings.ServiceName" placeholder="AOS60$01" /></label>
<label>Langsomt svar over (ms, valgfri) <input name="warnMilliseconds" inputmode="numeric" pattern="[0-9]*" maxlength="6" value="@Settings.WarnMilliseconds" /></label>
</div>
<p class="muted">Med en server og en tjeneste ser SQLInsight forskel på "tjenesten er stoppet" og "tjenesten kører, men svarer ikke".</p>
<fieldset>
<legend>TCP-port, DNS-opslag og Dynamics AX-logon</legend>
<div class="support-contact">
<label>Server / navn <input name="host" maxlength="255" value="@Settings.Host" placeholder="aos01.firma.local" /></label>
<label>Port (TCP; AX: standard 2712; SQL: valgfri) <input name="port" inputmode="numeric" pattern="[0-9]*" maxlength="5" value="@Settings.Port" /></label>
<label>Forventede adresser (DNS, valgfri) <input name="expectedAddresses" maxlength="400" value="@string.Join(", ", Settings.ExpectedAddresses)" placeholder="10.0.0.5" /></label>
</div>
<div class="support-contact">
<label>AX-firma (valgfrit) <input name="company" maxlength="10" value="@Settings.Company" placeholder="DAT" /></label>
<label>AX object server (valgfri) <input name="objectServer" maxlength="256" value="@Settings.ObjectServer" placeholder="01@aos01:2712" /></label>
<label>AX-konfiguration (valgfri) <input name="configuration" maxlength="128" value="@Settings.Configuration" /></label>
</div>
<label class="check"><span><input type="checkbox" name="businessConnectorLogon" value="true" checked="@Settings.BusinessConnectorLogon" /> AX: log på gennem .NET Business Connector, når den er installeret på agentens server (ellers testes kun porten)</span></label>
</fieldset>
<fieldset>
<legend>HTTP(S)</legend>
<div class="support-contact">
<label>Adresse <input name="url" maxlength="2000" value="@Settings.Url" placeholder="https://ep.firma.local/sites/DynamicsAx" /></label>
<label>Forventet status (valgfri, standard 200–399) <input name="expectedStatus" maxlength="100" value="@string.Join(", ", Settings.ExpectedStatus)" placeholder="200" /></label>
<label>Forventet tekst (valgfri) <input name="expectedText" maxlength="500" value="@Settings.ExpectedText" /></label>
</div>
<label class="check"><span><input type="checkbox" name="useWindowsAuthentication" value="true" checked="@Settings.UseWindowsAuthentication" /> Log på med agentens Windows-konto</span></label>
<label class="check"><span><input type="checkbox" name="allowInvalidCertificate" value="true" checked="@Settings.AllowInvalidCertificate" /> Accepter ugyldigt certifikat (fx selvsigneret); udløbsdatoen tjekkes stadig</span></label>
</fieldset>
<fieldset>
<legend>SQL-login og forespørgsel</legend>
<div class="support-contact">
<label>SQL Server <input name="server" maxlength="256" value="@Settings.Server" placeholder="sql01\AX" /></label>
<label>Database (valgfri) <input name="database" maxlength="128" value="@Settings.Database" /></label>
<label>Godkendelse
<select name="authentication">
<option value="Windows" selected="@(Settings.Authentication == "Windows")">Windows (agentens tjenestekonto)</option>
<option value="SqlLogin" selected="@(Settings.Authentication == "SqlLogin")">SQL-login</option>
</select>
</label>
</div>
<div class="support-contact">
<label>SQL-login <input name="userName" maxlength="128" value="@Settings.UserName" autocomplete="off" /></label>
<label>Adgangskode@(Check?.HasSecret == true ? " (tom: behold den gemte)" : string.Empty) <input name="password" type="password" maxlength="256" autocomplete="new-password" /></label>
<label>Kryptering
<select name="encryption">
<option value="Mandatory" selected="@(Settings.Encryption == "Mandatory")">Påkrævet</option>
<option value="Optional" selected="@(Settings.Encryption == "Optional")">Valgfri (kun gamle instanser)</option>
</select>
</label>
</div>
<label class="check"><span><input type="checkbox" name="trustServerCertificate" value="true" checked="@Settings.TrustServerCertificate" /> Stol på servercertifikatet</span></label>
<label>Forespørgsel (valgfri, én SELECT; standard <code>SELECT 1</code>) <textarea name="query" maxlength="4000" rows="3">@Settings.Query</textarea></label>
<p class="muted">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.</p>
</fieldset>
<button type="submit" class="button">@SubmitText</button>
</form>
@code {
[Parameter, EditorRequired] public string Action { get; set; } = string.Empty;
[Parameter] public SyntheticCheckRow? Check { get; set; }
[Parameter] public IReadOnlyList<WindowsServerRow> Servers { get; set; } = [];
[Parameter] public string SubmitText { get; set; } = "Gem";
private SyntheticCheckSettings Settings => Check?.Settings ?? new SyntheticCheckSettings();
}

View file

@ -0,0 +1,24 @@
@if (Check.Paused)
{
<span class="badge sev-medium">På pause</span>
}
else if (Check.SecretError is { } secret)
{
<span class="badge sev-critical" title="@secret">Adgangskode skal indtastes igen</span>
}
else if (Check.LastSuccess is null)
{
<span class="badge sev-info">Ikke kørt endnu</span>
}
else if (Check.LastSuccess == true)
{
<span class="badge sev-ok" title="@Check.LastMessage">OK</span>
}
else
{
<span class="badge sev-high" title="@Check.LastMessage">Fejler@(Check.ConsecutiveFailures > 1 ? $" ({Check.ConsecutiveFailures} i træk)" : string.Empty)</span>
}
@code {
[Parameter, EditorRequired] public SyntheticCheckRow Check { get; set; } = null!;
}

View file

@ -0,0 +1,35 @@
@inject Display Display
@if (Findings.Count == 0)
{
<p class="empty">@Empty</p>
}
else
{
<table class="findings">
<thead>
<tr><th>Alvor</th>@if (ShowScope) { <th>Server / tjek</th> }<th>Fund</th><th>Regel</th><th>Set første gang</th></tr>
</thead>
<tbody>
@foreach (var f in Findings)
{
<tr>
<td><SeverityBadge Severity="f.Severity" /></td>
@if (ShowScope)
{
<td><a href="@(f.Scope == FindingScope.Server ? ServerSetup.ServerPage(f.ScopeId) : ServerSetup.CheckPage(f.ScopeId))">@f.ScopeName</a></td>
}
<td><a href="@ServerSetup.FindingPage(f.FindingId)">@f.Title</a></td>
<td class="mono">@f.RuleId</td>
<td title="@Display.Time(f.FirstSeenAt)">@Display.Ago(f.FirstSeenAt)</td>
</tr>
}
</tbody>
</table>
}
@code {
[Parameter, EditorRequired] public IReadOnlyList<StoredServerFinding> Findings { get; set; } = [];
[Parameter] public bool ShowScope { get; set; }
[Parameter] public string Empty { get; set; } = "Ingen fund.";
}

View file

@ -0,0 +1,26 @@
@inject Display Display
@if (Server.Paused)
{
<span class="badge sev-medium">På pause</span>
}
else if (Server.LastContactAt is null && Server.LastErrorAt is null)
{
<span class="badge sev-info" title="Agenten har ikke set serveren endnu">Venter på agenten</span>
}
else if (!Server.Reachable)
{
<span class="badge sev-critical" title="@Server.LastError">Kan ikke læses</span>
}
else if (Server.Areas.Any(a => !a.Ok))
{
<span class="badge sev-medium" title="@string.Join(" · ", Server.Areas.Where(a => !a.Ok).Select(a => SQLInsight.Core.Servers.ServerArea.Display(a.Area)))">Delvist målbar</span>
}
else
{
<span class="badge sev-ok" title="Senest læst @Display.Time(Server.LastContactAt)">Overvåges</span>
}
@code {
[Parameter, EditorRequired] public WindowsServerRow Server { get; set; } = null!;
}

View file

@ -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<WebOptions> options, CancellationToken ct) =>

View file

@ -0,0 +1,342 @@
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;
/// <summary>A Windows server as entered in the form or the API.</summary>
public sealed record ServerInput(string? Name, string? Host, string? Role, IReadOnlyList<string>? WatchedServices, IReadOnlyList<string>? IgnoredServices);
/// <summary>A synthetic check as entered in the form or the API. The password is only used to seal it for the agent.</summary>
public sealed record CheckInput(
string? Name,
string? Kind,
int? ServerId,
SyntheticCheckSettings Settings,
string? Password,
int? IntervalSeconds,
int? TimeoutSeconds);
/// <summary>
/// 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.
/// </summary>
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 (SQLInsight:Web:AdminGroups, eller den lokale gruppe Administratorer på webserveren, når den er tom). Bed en administrator om at tilføje jeres gruppe.";
public const string PausedCheckText = "Tjekket er sat på pause og køres ikke. Genoptag det først.";
public sealed record Outcome(int? Id = null, long? RequestId = null, string? Error = null, bool NotFound = false, bool Conflict = false);
/// <summary>Service names as the form's list gives them: trimmed, without empty ones and duplicates (any case).</summary>
public static IReadOnlyList<string>? CleanList(IReadOnlyList<string>? names) =>
names?.Select(n => n?.Trim() ?? string.Empty).Where(n => n.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
private static ServerInput Clean(ServerInput input) =>
input with { WatchedServices = CleanList(input.WatchedServices), IgnoredServices = CleanList(input.IgnoredServices) };
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 ?? []);
}
/// <summary>Adds a server and asks the agent to test it.</summary>
public static async Task<Outcome> AddServerAsync(InsightRepository repository, ServerInput input, string? by, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(input);
input = Clean(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);
}
/// <summary>Role and the services to watch and ignore; null leaves a value as it is.</summary>
public static async Task<Outcome> UpdateServerAsync(InsightRepository repository, int id, ServerInput input, string? by, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(input);
input = Clean(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);
}
/// <summary>Asks the agent to test a server (a test already waiting is reused).</summary>
public static async Task<Outcome> 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));
}
/// <summary>
/// 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).
/// </summary>
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<WindowsServerTestResult>(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 -----------------------------------------------------------------------------------
/// <summary>Adds a check, sealing a SQL login password for the agent, and asks the agent to run it at once.</summary>
public static async Task<Outcome> 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);
}
/// <summary>Changes a check (not its kind). The SQL password must be typed again when the server or login changes.</summary>
public static async Task<Outcome> 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);
}
/// <summary>Asks the agent to run a check now (a run already waiting is reused). A paused check is not run: <see cref="Outcome.Conflict"/>.</summary>
public static async Task<Outcome> RequestCheckRunAsync(InsightRepository repository, int id, string? by, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(repository);
return await repository.GetSyntheticCheckAsync(id, cancellationToken).ConfigureAwait(false) switch
{
null => new Outcome(NotFound: true),
{ Paused: true } => new Outcome(id, Error: PausedCheckText, Conflict: 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);
}
/// <summary>Only the fields of the check's kind are kept (no stray SQL login on an HTTP check …).</summary>
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 ?? "–",
};
/// <summary>"hvert minut", "hvert 5. minut" or, when not whole minutes (set through the API), "hvert 90. sekund".</summary>
public static string IntervalText(int seconds) => seconds switch
{
60 => "hvert minut",
_ when seconds % 60 == 0 => string.Create(CultureInfo.InvariantCulture, $"hvert {seconds / 60}. minut"),
_ => string.Create(CultureInfo.InvariantCulture, $"hvert {seconds}. sekund"),
};
public static string Size(double gigabytes) => gigabytes >= 1024 ? Display.Number(gigabytes / 1024, 1) + " TB" : Display.Number(gigabytes, gigabytes < 10 ? 1 : 0) + " GB";
/// <summary>The comma-separated status codes from a form field.</summary>
public static IReadOnlyList<int> ParseStatusCodes(string? text) =>
ServerHost.ParseList(text).Select(s => int.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out var code) ? code : -1).ToList();
/// <summary>A form's URL field: an absolute URL, or null (the validation then says what is wrong).</summary>
public static Uri? ParseUrl(string? text) => Uri.TryCreate(text?.Trim(), UriKind.Absolute, out var url) ? url : null;
}

View file

@ -0,0 +1,283 @@
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;
/// <summary>
/// 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.
/// </summary>
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<IResult?> RefuseAsync(HttpContext http, IAntiforgery antiforgery, WebOptions options, string back)
{
if (!await antiforgery.IsRequestValidAsync(http).ConfigureAwait(false))
{
return Results.BadRequest("Formularen er udløbet. Genindlæs siden, og prøv igen.");
}
return InstanceSetup.MayChange(http.User, options) ? null : Results.Redirect(WithMessage(back, "fejl", ServerSetup.NotAdminText));
}
/// <summary>A whole number from the form: null when the field is empty, <c>Bad</c> when it is not a number.</summary>
private static (int? Value, bool Bad) Number(IFormCollection form, string name)
{
var text = form[name].ToString().Trim();
if (text.Length == 0)
{
return (null, false);
}
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? (value, false) : (null, true);
}
/// <summary>The check form's number fields and what to say when one is not a number (as for the instances' port).</summary>
private static readonly (string Field, string Message)[] NumberFields =
[
("intervalMinutes", "Intervallet skal være et helt antal minutter."),
("intervalSeconds", "Intervallet skal være et helt antal sekunder."),
("timeoutSeconds", "Timeout skal være et helt antal sekunder."),
("serverId", "Vælg Windows-serveren på listen."),
("port", "Porten skal være et tal."),
("warnMilliseconds", "Grænsen for langsomt svar skal være et tal (millisekunder)."),
];
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<IResult> AddServerAsync(HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> 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<IResult> UpdateServerAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> 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<IResult> PauseServerAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> 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<IResult> RemoveServerAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> 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<IResult> TestServerAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> 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");
}
/// <summary>The grant script as a file (UTF-8 with BOM, so Windows PowerShell 5.1 reads æøå right).</summary>
private static async Task<IResult> 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");
}
/// <summary>
/// The check form: every kind's fields are there; only the chosen kind's are kept (<see cref="ServerSetup.Normalize"/>).
/// A number field that is not a number is refused rather than ignored. The interval is in minutes, or in seconds when
/// it was set to a part of a minute through the API (the form then keeps it in seconds).
/// </summary>
private static (CheckInput? Input, string? Error) CheckInput(IFormCollection form)
{
foreach (var (field, message) in NumberFields)
{
if (Number(form, field).Bad)
{
return (null, message);
}
}
int? Int(string name) => Number(form, name).Value;
var interval = Int("intervalSeconds")
?? (Int("intervalMinutes") is { } minutes ? Math.Clamp(minutes, 0, (SyntheticCheckLimits.MaxIntervalSeconds / 60) + 1) * 60 : null);
return (new CheckInput(
form["name"].ToString(),
form["kind"].ToString() is { Length: > 0 } kind ? kind : null,
Int("serverId") is > 0 and var server ? server : null,
new SyntheticCheckSettings
{
Host = form["host"].ToString(),
Port = Int("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("warnMilliseconds"),
},
form["password"].ToString(),
interval,
Int("timeoutSeconds")), null);
}
private static async Task<IResult> AddCheckAsync(HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> 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 (input, invalid) = CheckInput(form);
var outcome = input is null
? new ServerSetup.Outcome(Error: invalid)
: await ServerSetup.AddCheckAsync(repository, input, 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<IResult> UpdateCheckAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> 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 (input, invalid) = CheckInput(form);
var outcome = input is null
? new ServerSetup.Outcome(id, Error: invalid)
: await ServerSetup.UpdateCheckAsync(repository, id, input, 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<IResult> PauseCheckAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> 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<IResult> RemoveCheckAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> 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<IResult> RunCheckAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> 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 switch
{
{ NotFound: true } => Results.NotFound(),
{ Error: { } error } => Results.Redirect(WithMessage(ServerSetup.CheckPage(id), "fejl", error)),
_ => Results.Redirect(ServerSetup.CheckPage(id) + "?koersel=" + outcome.RequestId!.Value.ToString(CultureInfo.InvariantCulture)),
};
}
}

View file

@ -238,3 +238,7 @@ button.button.secondary { background: var(--surface-2); color: var(--text); bord
button.button.danger { background: var(--critical); }
tr.muted td { color: var(--text-2); }
.instances tr.paused td { opacity: .7; }
/* 1.5: the synthetic check form groups the fields of each kind. */
.support-form fieldset { display: grid; gap: .9rem; border: 1px solid var(--border); border-radius: 8px; padding: .9rem 1rem; margin: 0; }
.support-form legend { font-weight: 600; color: var(--text); padding: 0 .3rem; }

View file

@ -0,0 +1,114 @@
using SQLInsight.Agent.Servers;
using SQLInsight.Core.Servers;
using SQLInsight.Core.SyntheticChecks;
using SQLInsight.Servers;
namespace SQLInsight.Agent.Tests;
public class ServerAgentOptionsTests
{
[Fact]
public void Server_options_are_kept_within_what_works()
{
var wild = new ServerOptions
{
MaxParallelServers = 0, OperationTimeout = TimeSpan.Zero, CycleTimeout = TimeSpan.FromHours(5), SampleDays = 0, DiskHistoryDays = -3, EventDays = 0,
};
wild.EventLog.MaxEventsPerCollection = 1_000_000;
wild.Tasks.MaxWatched = 0;
wild.Disks.ForecastHistoryDays = 0;
wild.Normalize();
Assert.Equal(1, wild.MaxParallelServers);
Assert.Equal(TimeSpan.FromSeconds(5), wild.OperationTimeout);
Assert.Equal(TimeSpan.FromMinutes(30), wild.CycleTimeout);
Assert.Equal((1, 1, 1), (wild.SampleDays, wild.DiskHistoryDays, wild.EventDays)); // 0 days would make maintenance delete everything
Assert.Equal(10000, wild.EventLog.MaxEventsPerCollection);
Assert.Equal(1, wild.Tasks.MaxWatched);
Assert.Equal(1, wild.Disks.ForecastHistoryDays);
var other = new ServerOptions { MaxParallelServers = 500, OperationTimeout = TimeSpan.FromHours(2), CycleTimeout = TimeSpan.FromSeconds(1) };
other.EventLog.MaxEventsPerCollection = 1;
other.Tasks.MaxWatched = 10_000;
other.Normalize();
Assert.Equal(32, other.MaxParallelServers);
Assert.Equal(TimeSpan.FromMinutes(5), other.OperationTimeout);
Assert.Equal(TimeSpan.FromMinutes(10), other.CycleTimeout); // at least two operations
Assert.Equal(10, other.EventLog.MaxEventsPerCollection);
Assert.Equal(200, other.Tasks.MaxWatched);
Assert.Equal(other.CycleTimeout / 3, other.TaskBudget);
}
[Fact]
public void Defaults_are_unchanged_by_normalizing()
{
var defaults = new ServerOptions().Normalize();
Assert.Equal((4, TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(5)), (defaults.MaxParallelServers, defaults.OperationTimeout, defaults.CycleTimeout));
Assert.Equal((14, 35, 90), (defaults.SampleDays, defaults.DiskHistoryDays, defaults.EventDays));
Assert.Equal((2000, 50), (defaults.EventLog.MaxEventsPerCollection, defaults.Tasks.MaxWatched));
Assert.Equal(TimeSpan.FromSeconds(30), ServerConnectionOptions.BoundTimeout(TimeSpan.FromSeconds(30)));
}
[Fact]
public void Synthetic_check_options_are_kept_within_what_works()
{
var wild = new SyntheticCheckOptions { MaxParallelChecks = -1, ResultDays = 0, FailuresBeforeFinding = 30, SlowRunsBeforeFinding = 0 }.Normalize();
Assert.Equal(1, wild.MaxParallelChecks);
Assert.Equal(1, wild.ResultDays);
Assert.Equal(1, wild.SlowRunsBeforeFinding);
Assert.Equal(31, wild.RecentResults); // enough to count 30 failures in a row
Assert.Equal(11, new SyntheticCheckOptions().RecentResults);
Assert.Equal(100, new SyntheticCheckOptions { FailuresBeforeFinding = 5000 }.Normalize().RecentResults);
Assert.Equal(32, new SyntheticCheckOptions { MaxParallelChecks = 1000 }.Normalize().MaxParallelChecks);
}
[Fact]
public void A_check_never_runs_more_often_than_the_minimum_interval()
{
Assert.Equal(TimeSpan.FromSeconds(SyntheticCheckLimits.MinIntervalSeconds), SyntheticCheckOptions.IntervalOf(0));
Assert.Equal(TimeSpan.FromSeconds(SyntheticCheckLimits.MinIntervalSeconds), SyntheticCheckOptions.IntervalOf(5));
Assert.Equal(TimeSpan.FromMinutes(5), SyntheticCheckOptions.IntervalOf(300));
Assert.Equal(TimeSpan.FromSeconds(SyntheticCheckLimits.MaxIntervalSeconds), SyntheticCheckOptions.IntervalOf(int.MaxValue));
}
[Fact]
public void An_unreachable_server_is_backed_off_1_2_5_10_then_15_minutes()
{
var waits = Enumerable.Range(1, 7).Select(ServerMonitor.Backoff).Select(t => (int)t.TotalMinutes);
Assert.Equal([1, 2, 5, 10, 15, 15, 15], waits);
}
[Fact]
public void Each_event_log_continues_from_its_own_mark()
{
var now = new DateTimeOffset(2026, 10, 2, 12, 0, 0, TimeSpan.Zero);
var since = new Dictionary<string, DateTimeOffset> { ["System"] = now.AddHours(-24), ["Application"] = now.AddHours(-24) };
// System had more than could be read (up to 08:00); Application was read to 11:59.
var read = new ServerEventRead(
[
new ServerEventEntry("System", "disk", 153, 2, now.AddHours(-6)),
new ServerEventEntry("System", "disk", 153, 2, now.AddHours(-4)),
new ServerEventEntry("Application", "app", 1000, 2, now.AddMinutes(-1)),
], "Event Log (RPC)", true, null, ["System"]);
var marks = ServerMonitor.NextReadUntil(read, since, now);
Assert.Equal(now.AddHours(-4), marks["System"]); // the rest of System is read next time, not skipped
Assert.Equal(now.AddMinutes(-1), marks["Application"]);
var snapshot = new ServerEventSnapshot { CapturedAt = now, ReadUntil = marks.Values.Min(), LogReadUntil = marks };
Assert.Equal(now.AddHours(-4), ServerMonitor.EventsReadUntil(snapshot, "System", now));
Assert.Equal(now.AddMinutes(-1), ServerMonitor.EventsReadUntil(snapshot, "application", now));
// A log without events keeps ten minutes' margin; a snapshot from before per-log marks continues from its single mark.
var quiet = ServerMonitor.NextReadUntil(new ServerEventRead([], "Event Log (RPC)", false), since, now);
Assert.Equal(now.AddMinutes(-10), quiet["System"]);
Assert.Equal(now.AddHours(-2), ServerMonitor.EventsReadUntil(new ServerEventSnapshot { CapturedAt = now, ReadUntil = now.AddHours(-2) }, "System", now));
Assert.Equal(now - ServerEventAnalysis.RecentWindow, ServerMonitor.EventsReadUntil(null, "System", now));
}
}

View file

@ -10,6 +10,8 @@
<!-- SqlScriptTests: rule ids, and the SQL the connection itself sends. -->
<ProjectReference Include="..\..\src\SQLInsight.Rules\SQLInsight.Rules.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Connectivity\SQLInsight.Connectivity.csproj" />
<!-- SqlScriptTests: the SQL of the synthetic SQL check (SQLI-SYN-001/002). -->
<ProjectReference Include="..\..\src\SQLInsight.Servers\SQLInsight.Servers.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Dynamics AX schemas (real and documented): the AX collectors' SQL is checked for every version adapter. -->

View file

@ -7,6 +7,8 @@ using SQLInsight.Collectors.Discovery;
using SQLInsight.Collectors.Instance;
using SQLInsight.Collectors.Performance;
using SQLInsight.Core.Instances;
using SQLInsight.Core.SyntheticChecks;
using SQLInsight.Servers.Synthetic;
namespace SQLInsight.Collectors.Tests.SqlScripts;
@ -346,9 +348,27 @@ internal static class SqlScriptSources
};
sources.AddRange(DynamicsAx());
sources.AddRange(SyntheticCheck());
return sources;
}
/// <summary>
/// The synthetic SQL check (1.5): the agent logs on as the check says, sets the session settings above, and runs the
/// check's query in a transaction that is always rolled back. Not a collector; registered so SQLI-SYN-001/002 show it.
/// </summary>
private static IEnumerable<Source> SyntheticCheck()
{
const string File = "src/SQLInsight.Servers/Synthetic/SyntheticCheckRunner.cs";
yield return new("SyntheticCheckRunner.UseDatabaseSql", "SyntheticCheckRunner", "Kun når tjekket har en database (vist for " + ExampleDatabase + ")",
File, "SyntheticCheckRunner.UseDatabaseSql(database)", () => SyntheticCheckRunner.UseDatabaseSql(ExampleDatabase));
yield return new("SyntheticCheckRunner.BeginTransactionSql", "SyntheticCheckRunner", "Alle versioner fra SQL Server 2005: før forespørgslen",
File, "SyntheticCheckRunner.BeginTransactionSql", () => SyntheticCheckRunner.BeginTransactionSql);
yield return new("SyntheticCheckSettings.DefaultQuery", "SyntheticCheckRunner", "Standardforespørgslen, når tjekket ikke har sin egen",
"src/SQLInsight.Core/SyntheticChecks/SyntheticCheckDefinitions.cs", "SyntheticCheckSettings.DefaultQuery", () => SyntheticCheckSettings.DefaultQuery);
yield return new("SyntheticCheckRunner.RollbackSql", "SyntheticCheckRunner", "Alle versioner fra SQL Server 2005: altid efter forespørgslen, også når den fejler",
File, "SyntheticCheckRunner.RollbackSql", () => SyntheticCheckRunner.RollbackSql);
}
/// <summary>The AX database the Dynamics AX scripts are shown for: there is no AX database on every instance, so the reader changes it.</summary>
public const string AxExampleDatabase = "AXDB";

View file

@ -2,6 +2,7 @@ using System.Reflection;
using System.Text.RegularExpressions;
using SQLInsight.Rules;
using SQLInsight.Rules.Lifecycle;
using SQLInsight.Rules.Servers;
namespace SQLInsight.Collectors.Tests.SqlScripts;
@ -13,10 +14,14 @@ public sealed partial class SqlScriptTests
{
private static readonly string Folder = Path.Combine(RepositoryRoot(), "SQL Scripts");
/// <summary>The Windows server and synthetic check rules (1.5): most send no SQL; their Windows queries are comments (see SQLInsight.Servers.Tests).</summary>
private static readonly HashSet<string> ServerRuleIds = ServerRuleCatalog.All.Select(r => r.Id).ToHashSet(StringComparer.Ordinal);
[Fact]
public void Every_registered_rule_has_a_script_and_every_script_a_rule()
{
var ids = BuiltInRules.Create().Select(r => r.Id)
.Concat(ServerRuleIds)
.Concat(RulePack.Embedded().CommunityRules.Select(r => r.Id))
.ToHashSet(StringComparer.Ordinal);
var files = Directory.EnumerateFiles(Folder, "*.sql", SearchOption.TopDirectoryOnly)
@ -66,7 +71,7 @@ public sealed partial class SqlScriptTests
}
var blocks = Blocks(Read(file)).ToList();
if (blocks.Count == 0)
if (blocks.Count == 0 && !ServerRuleIds.Contains(Path.GetFileNameWithoutExtension(file)))
{
problems.Add($"{name}: ingen '-- Kilde:'-blokke");
}

View file

@ -0,0 +1,33 @@
using Microsoft.Extensions.Options;
using SQLInsight.Community.Services;
namespace SQLInsight.Community.Tests;
/// <summary>1.5 on the website: the Windows server and synthetic check documentation and rules.</summary>
public sealed class ServerDocsTests
{
[Fact]
public void The_documentation_has_windows_servers_and_synthetic_checks()
{
var docs = new DocsService(Options.Create(new CommunityOptions()));
var servers = docs.Find("windows-servere");
var checks = docs.Find("syntetiske-tjek");
Assert.Equal("Windows-servere", servers!.Title);
Assert.Contains("aldrig lokal administrator", servers.Html, StringComparison.OrdinalIgnoreCase);
Assert.Contains("grant-windows-server.ps1", servers.Html, StringComparison.Ordinal);
Assert.Contains("5985", servers.Html, StringComparison.Ordinal);
Assert.Equal("Syntetiske tjek", checks!.Title);
Assert.Contains("Kun porten er testet", checks.Html, StringComparison.Ordinal);
}
[Fact]
public void The_rule_list_has_the_windows_server_and_check_rules_in_their_own_areas()
{
var rules = RuleCatalog.All([]);
Assert.Contains(rules, r => r.Id == "SQLI-WIN-006" && r.Area == "Windows-servere");
Assert.Contains(rules, r => r.Id == "SQLI-SYN-001" && r.Area == "Syntetiske tjek");
}
}

View file

@ -0,0 +1,55 @@
using SQLInsight.Core.CustomRules;
namespace SQLInsight.Core.Tests;
/// <summary>A typed query (custom rule, synthetic SQL check) is one read-only SELECT, also when someone tries to hide more.</summary>
public class ReadOnlyQueryTests
{
[Theory]
// A ]] in an [identifier] is not its end: the ' after it is no string, and the DELETE is code.
[InlineData("SELECT 1 AS [x]]'] ; DELETE FROM dbo.t; SELECT 'a'")]
[InlineData("SELECT 1 AS [x]]'] DELETE FROM dbo.t SELECT 'a'")]
// Block comments nest in T-SQL; an unterminated one is refused.
[InlineData("SELECT 1 /* /* */ DELETE FROM dbo.t")]
[InlineData("SELECT 1 AS \"a\"\"'\" DELETE FROM dbo.t --'")]
// No semicolon is needed between statements.
[InlineData("SELECT 1 COMMIT")]
[InlineData("SELECT 1 SELECT 2")]
[InlineData("SELECT 1 WRITETEXT dbo.t.c @p 'x'")]
[InlineData("SELECT 1 UPDATETEXT dbo.t.c @p 0 NULL 'x'")]
[InlineData("SELECT 1 DECLARE @x int")]
[InlineData("SELECT 1 SET CONTEXT_INFO 0x01")]
[InlineData("SELECT 1 RAISERROR('x', 16, 1) WITH LOG")]
[InlineData("SELECT 1 CHECKPOINT")]
[InlineData("SELECT 1 ROLLBACK")]
[InlineData("SELECT 1 BEGIN TRAN")]
[InlineData("SELECT 1 ENABLE TRIGGER trg ON dbo.t")]
[InlineData("SELECT 1 WHILE 1 = 1 SELECT 2")]
[InlineData("WITH q AS (SELECT 1 AS a) SELECT a FROM q SELECT 2")]
[InlineData("SELECT 1 x RECEIVE TOP (1) * FROM dbo.q")]
[InlineData("SELECT 1 ADD SIGNATURE TO dbo.p BY CERTIFICATE c")]
// Side effects inside one SELECT: sequences are not rolled back; update and exclusive locks block production.
[InlineData("SELECT NEXT VALUE FOR dbo.InvoiceNumbers")]
[InlineData("SELECT * FROM dbo.CustTable WITH (UPDLOCK, HOLDLOCK)")]
[InlineData("SELECT * FROM dbo.CustTable WITH (TABLOCKX)")]
[InlineData("SELECT * FROM dbo.CustTable WITH (XLOCK)")]
[InlineData("SELECT * INTO dbo.Copy FROM dbo.CustTable")]
[InlineData("SELECT 1 EXEC sp_who")]
[InlineData("SELECT * FROM OPENQUERY(LINKED, 'EXEC x')")]
[InlineData("select 1 exec('delete dbo.t')")]
[InlineData("SELECT 'unterminated")]
[InlineData("SELECT [unterminated")]
public void Hidden_or_extra_statements_are_refused(string query) => Assert.NotEmpty(ReadOnlyQuery.Problems(query));
[Theory]
[InlineData("SELECT 1")]
[InlineData("SELECT COUNT(*) FROM dbo.DATAAREA")]
[InlineData("SELECT [update], [into], [x]]y] FROM dbo.audit")]
[InlineData("SELECT 'DELETE; EXEC ''x''' AS text /* INSERT /* nested */ COMMIT */ -- DROP")]
[InlineData("WITH d AS (SELECT name FROM sys.databases), e (n) AS (SELECT 1) SELECT * FROM d;")]
[InlineData("SELECT name FROM sys.databases UNION ALL SELECT name FROM sys.objects EXCEPT SELECT N'x' INTERSECT SELECT N'x'")]
[InlineData("SELECT TOP (10) a FROM (SELECT 1 AS a) x WHERE a IN (SELECT 1) ORDER BY a OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY")]
[InlineData("SELECT * FROM dbo.CustTable WITH (NOLOCK) OPTION (MAXDOP 1)")]
[InlineData("SELECT IIF(1 = 1, GETDATE(), NULL), \"quoted\"\"name\" FROM sys.objects")]
public void Read_only_queries_are_accepted(string query) => Assert.Empty(ReadOnlyQuery.Problems(query));
}

View file

@ -0,0 +1,72 @@
using System.Text.RegularExpressions;
using SQLInsight.Core.Servers;
namespace SQLInsight.Core.Tests;
/// <summary>
/// db/permissions/grant-windows-server.ps1 as SQLInsight Web shows it: the agent's account filled in safely, and the
/// script itself only ever gives read rights (the SDDL logic is run in SQLInsight.Servers.Tests/GrantScriptTests).
/// </summary>
public sealed partial class ServerGrantScriptTests
{
[Fact]
public void The_account_is_filled_in()
{
var script = ServerGrantScript.For(@"FIRMA\AGENT01$");
Assert.Contains(@"[string] $Account = 'FIRMA\AGENT01$',", script, StringComparison.Ordinal);
Assert.DoesNotContain("DOMÆNE\\AGENTSERVER$', #", script, StringComparison.Ordinal);
Assert.Equal(script, ServerGrantScript.For(@"FIRMA\AGENT01$"));
Assert.Contains("'O''Brien'", ServerGrantScript.For("O'Brien"), StringComparison.Ordinal);
}
[Theory]
[InlineData("X’; Invoke-Evil; ’")] // PowerShell also ends a single-quoted string at ‘ ’ ‚ ‛
[InlineData("X‘")]
[InlineData("X‚")]
[InlineData("X‛")]
[InlineData("X\r\nInvoke-Evil")]
public void An_account_that_could_break_out_of_the_string_is_not_filled_in(string account)
{
Assert.False(ServerGrantScript.IsSafeAccount(account));
Assert.Equal(ServerGrantScript.Template(), ServerGrantScript.For(account));
Assert.DoesNotContain("Invoke-Evil", ServerGrantScript.For(account), StringComparison.Ordinal);
}
[Fact]
public void Only_read_rights_are_given()
{
var script = ServerGrantScript.Template();
// Groups: Performance Monitor Users, Event Log Readers, Distributed COM Users. Never Administrators or Remote
// Management Users (which also allows PowerShell Remoting).
var groups = GroupBlock().Match(script);
Assert.True(groups.Success);
Assert.Equal(["S-1-5-32-558", "S-1-5-32-573", "S-1-5-32-562"], Sid().Matches(groups.Value).Select(m => m.Value));
// WMI Enable Account + Remote Enable; SCM connect, list, lock status, read control; WinRM read only.
Assert.Contains("$wmiMask = 0x21 ", script, StringComparison.Ordinal);
Assert.Contains("$scmMask = 0x20015 ", script, StringComparison.Ordinal);
Assert.Contains("$winrmMask = [int] 0x80000000 ", script, StringComparison.Ordinal);
// Nothing that changes a service, a task, or runs WMI methods; Windows' own firewall rules are not touched.
foreach (var forbidden in new[]
{
"Start-Service", "Stop-Service", "Restart-Service", "Set-Service", "sc.exe config", "sc.exe start", "sc.exe stop",
"Invoke-WmiMethod", "Invoke-CimMethod", "Enable-NetFirewallRule", "Disable-NetFirewallRule", "Enable-PSRemoting",
"Set-PSSessionConfiguration", "S-1-5-32-580", "Add-LocalGroupMember",
})
{
Assert.DoesNotContain(forbidden, script, StringComparison.OrdinalIgnoreCase);
}
Assert.Contains("SupportsShouldProcess", script, StringComparison.Ordinal); // -WhatIf
Assert.Contains("IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)", script, StringComparison.Ordinal);
}
[GeneratedRegex(@"\$groups = @\((?:.|\n|\r)*?\n\)")]
private static partial Regex GroupBlock();
[GeneratedRegex(@"S-1-5-32-\d+")]
private static partial Regex Sid();
}

View file

@ -0,0 +1,88 @@
using SQLInsight.Core.SyntheticChecks;
namespace SQLInsight.Core.Tests;
public class SyntheticCheckTests
{
private static string? Validate(string kind, SyntheticCheckSettings settings, bool hasServer = false, int interval = 300, int timeout = 15) =>
SyntheticCheckValidation.Validate("tjek", kind, settings, interval, timeout, hasServer);
[Fact]
public void Valid_checks_of_each_kind()
{
Assert.Null(Validate(SyntheticCheckKind.Tcp, new() { Host = "aos01", Port = 2712 }));
Assert.Null(Validate(SyntheticCheckKind.Http, new() { Url = new Uri("https://ep.firma.local/sites/DynamicsAx"), ExpectedStatus = [200, 401], ExpectedText = "Dynamics" }));
Assert.Null(Validate(SyntheticCheckKind.Sql, new() { Server = @"sql01\AX", Database = "AX2012_PROD", Query = "SELECT COUNT(*) FROM dbo.DATAAREA" }));
Assert.Null(Validate(SyntheticCheckKind.Sql, new() { Server = "sql01", Authentication = "SqlLogin", UserName = "axapp" }));
Assert.Null(Validate(SyntheticCheckKind.Dns, new() { Host = "aos01.firma.local", ExpectedAddresses = ["10.0.0.5"] }));
Assert.Null(Validate(SyntheticCheckKind.AxLogon, new() { Host = "aos01", Company = "DAT", ServiceName = "AOS60$01" }, hasServer: true));
}
[Theory]
[InlineData(SyntheticCheckKind.Tcp, "Angiv porten")]
[InlineData(SyntheticCheckKind.Http, "http://")]
[InlineData(SyntheticCheckKind.Sql, "Angiv serveren")]
[InlineData(SyntheticCheckKind.Dns, "Angiv serverens navn")]
[InlineData(SyntheticCheckKind.AxLogon, "Angiv serverens navn")]
public void Missing_targets(string kind, string error) =>
Assert.Contains(error, Validate(kind, kind == SyntheticCheckKind.Tcp ? new() { Host = "aos01" } : new()), StringComparison.Ordinal);
[Fact]
public void A_sql_check_only_reads()
{
Assert.Contains("SELECT", Validate(SyntheticCheckKind.Sql, new() { Server = "sql01", Query = "UPDATE dbo.CustTable SET Name = 'x'" }), StringComparison.Ordinal);
Assert.NotNull(Validate(SyntheticCheckKind.Sql, new() { Server = "sql01", Query = "SELECT 1; DROP TABLE dbo.x" }));
Assert.NotNull(Validate(SyntheticCheckKind.Sql, new() { Server = "sql01", Query = "SELECT * INTO dbo.Copy FROM dbo.CustTable" }));
Assert.NotNull(Validate(SyntheticCheckKind.Sql, new() { Server = "sql01", Query = "EXEC sp_who" }));
}
[Fact]
public void Limits_and_service_needs_a_server()
{
Assert.Contains("1 minut", Validate(SyntheticCheckKind.Tcp, new() { Host = "a", Port = 1 }, interval: 30), StringComparison.Ordinal);
Assert.Contains("kortere end intervallet", Validate(SyntheticCheckKind.Tcp, new() { Host = "a", Port = 1 }, interval: 60, timeout: 60), StringComparison.Ordinal);
Assert.Contains("Windows-server", Validate(SyntheticCheckKind.Tcp, new() { Host = "a", Port = 1, ServiceName = "W3SVC" }), StringComparison.Ordinal);
Assert.NotNull(Validate(SyntheticCheckKind.Http, new() { Url = new Uri("https://bruger:kode@ep/") }));
Assert.NotNull(Validate(SyntheticCheckKind.Http, new() { Url = new Uri("ftp://ep/") }));
Assert.NotNull(Validate(SyntheticCheckKind.AxLogon, new() { Host = "aos01", Company = "DAT; DROP" }));
Assert.NotNull(SyntheticCheckValidation.Validate("tjek", "Ping", new(), 300, 15, false));
Assert.NotNull(SyntheticCheckValidation.Validate(" ", SyntheticCheckKind.Tcp, new() { Host = "a", Port = 1 }, 300, 15, false));
}
[Fact]
public void An_http_address_has_a_length_limit()
{
Assert.Contains("2000 tegn", Validate(SyntheticCheckKind.Http, new() { Url = new Uri("https://ep.firma.local/" + new string('a', SyntheticCheckValidation.MaxUrlLength)) }),
StringComparison.Ordinal);
Assert.Null(Validate(SyntheticCheckKind.Http, new() { Url = new Uri("https://ep.firma.local/" + new string('a', 1900)) }));
}
[Fact]
public void Settings_round_trip_as_json_and_bad_json_is_empty_settings()
{
var settings = new SyntheticCheckSettings { Url = new Uri("https://ep/"), ExpectedStatus = [200], UseWindowsAuthentication = true, ServiceName = "W3SVC" };
Assert.Equal(settings.ToJson(), SyntheticCheckSettings.FromJson(settings.ToJson()).ToJson());
Assert.Null(SyntheticCheckSettings.FromJson("{not json").Url);
Assert.Equal("aos01:2712", new SyntheticCheckSettings { Host = "aos01" }.Target(SyntheticCheckKind.AxLogon));
Assert.Equal(@"sql01\AX / AXDB", new SyntheticCheckSettings { Server = @"sql01\AX", Database = "AXDB" }.Target(SyntheticCheckKind.Sql));
}
[Fact]
public void Redacted_values_from_the_api_are_not_saved_back()
{
Assert.Contains("[udeladt]", Validate(SyntheticCheckKind.Sql, new() { Server = "sql01", Authentication = "SqlLogin", UserName = "[udeladt]" }), StringComparison.Ordinal);
Assert.Contains("[udeladt]", Validate(SyntheticCheckKind.Http, new() { Url = new Uri("https://ep/side?[udeladt]") }), StringComparison.Ordinal);
Assert.Null(Validate(SyntheticCheckKind.Http, new() { Url = new Uri("https://ep/side?a=1") }));
}
[Fact]
public void A_sql_password_is_bound_to_the_server_and_login()
{
var a = new SyntheticCheckSettings { Server = "sql01", UserName = "axapp" }.SecretContext();
Assert.NotEqual(a, new SyntheticCheckSettings { Server = "sql02", UserName = "axapp" }.SecretContext());
Assert.NotEqual(a, new SyntheticCheckSettings { Server = "sql01", UserName = "sa" }.SecretContext());
Assert.NotEqual(a, Configuration.CredentialEnvelope.Context("sql01", "axapp"));
}
}

Some files were not shown because too many files have changed in this diff Show more