From f29bd670e23a16f60847de3ca111638249b5e17d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:09:31 +0100 Subject: [PATCH 1/6] Stop the web docs promising the tokenless loopback #1649 took away Three places still describe the web dashboard as it behaved before the Custom Views composer landed, and all three are the ones an operator reads while deciding what to expose. DecideWebAuth exempts loopback from the CIDR check ONLY. In network mode a request from the box itself still has to carry a session cookie or a valid ?token= -- that changed when view create/update/delete and /api/compose/run made the surface writable, because a tokenless local process (a scheduled task, SSRF'd code, another user's session) could otherwise read the whole store and mutate views with no credential. The auth middleware is registered inside `if (networkMode)`, so a dashboard with no network block really is tokenless; the claim is only wrong for the exposed case, which is the case the sentence was about. "Hosts no write paths" is wrong for the same reason: POST/PUT/DELETE /api/views and POST /api/compose/run exist, and GET /api/session reports can_edit for any authenticated seat. That write IS the reason loopback authenticates, so the two corrections are one fact stated twice -- and the old text used the wrong half of it to justify the wrong conclusion. Also names the two responses a reader will actually see, because "an out-of-CIDR request is refused with 403" left the other arm unstated: an in-CIDR request with no credential gets the login form back as a 200, not a 401, and someone debugging by status code needs to know which is which. Co-Authored-By: Claude Opus 5 --- .../darling.sample.json | 10 ++++++---- Darling/README.md | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Darling/PerformanceMonitor.Darling.Service/darling.sample.json b/Darling/PerformanceMonitor.Darling.Service/darling.sample.json index 88b533d381..77ecbc0177 100644 --- a/Darling/PerformanceMonitor.Darling.Service/darling.sample.json +++ b/Darling/PerformanceMonitor.Darling.Service/darling.sample.json @@ -242,10 +242,12 @@ // (loopback-only HTTP, today's behavior). When present with a non-loopback "listen", the web host binds the // LAN interface behind an in-app CIDR check and a token gate: a browser presents the token ONCE via // ?token=, which is exchanged for an HMAC-signed session cookie (then the token is stripped from the URL). - // LOOPBACK stays TOKENLESS even while exposed (the surface is read-only). Fail-closed: any missing - // precondition (token / valid allowFrom / managed mode) keeps it loopback-only. There is deliberately NO - // TLS (a browser over plain HTTP on a trusted LAN); the MITM control is a TLS REVERSE PROXY in front of the - // port, same as MCP -- so this is a trusted-LAN opt-in, NEVER internet-exposed. + // LOOPBACK is exempt from the CIDR check ONLY -- while exposed it presents the token like anyone else + // (#1649: the Custom Views composer is a write path, so a tokenless local process could mutate views). + // A dashboard with NO "network" block registers no auth middleware at all and stays tokenless. + // Fail-closed: any missing precondition (token / valid allowFrom / managed mode) keeps it loopback-only. + // There is deliberately NO TLS (a browser over plain HTTP on a trusted LAN); the MITM control is a TLS + // REVERSE PROXY in front of the port, same as MCP -- a trusted-LAN opt-in, NEVER internet-exposed. // "enabled"/"port" here are the SEED; after first start they live in the control plane and the Viewer's // Settings toggles them LIVE (the service starts/stops/rebinds the endpoint within seconds - no restart). // The "network" exposure block below stays file-defined and restart-only; the Viewer toggle still stops an diff --git a/Darling/README.md b/Darling/README.md index 5c2390fe1f..ce3cdaf806 100644 --- a/Darling/README.md +++ b/Darling/README.md @@ -490,7 +490,7 @@ The embedded read-only **web dashboard** — a browser view of the monitoring st Once enabled, open `http://localhost:5153/` in a browser on the service host. Like the MCP server, `enabled`/`port` here are the file SEED; after first start they live in the control plane and the Viewer's Settings toggles them LIVE (the service starts/stops/rebinds the dashboard within seconds — no restart). If the port is already in use at startup, the web host logs an error and retries on a calm cadence; collection is unaffected. -**What you see.** The dashboard opens on a **Fleet Overview**: a card per enabled server with a status dot, six per-metric health bands (CPU, threads, memory, blocking, deadlocks, collectors), and its last collection time — all banded server-side, so the browser only renders (a server that has never reported shows an amber "Awaiting first collection", never a red offline). Above the cards a worst-first "Needs attention" list surfaces the servers to look at, or an all-healthy line when there is nothing to chase. Click a card to **drill into one server**: an overview, wait stats with a trend for the heaviest wait, active queries, a CPU chart, memory and file-I/O trends, and collection health — the same collected data the viewer shows, over inline charts. A fleet-wide **Alert History** page (with a server filter box) rounds out phase 1. It is a read-only view — no settings, no write paths, no live-server queries — and refreshes every 60 seconds (pausing while the tab is hidden). The frontend ships fully self-contained (no CDN, no fonts, no remote anything), so it works on an air-gapped host with no internet access. +**What you see.** The dashboard opens on a **Fleet Overview**: a card per enabled server with a status dot, six per-metric health bands (CPU, threads, memory, blocking, deadlocks, collectors), and its last collection time — all banded server-side, so the browser only renders (a server that has never reported shows an amber "Awaiting first collection", never a red offline). Above the cards a worst-first "Needs attention" list surfaces the servers to look at, or an all-healthy line when there is nothing to chase. Click a card to **drill into one server**: an overview, wait stats with a trend for the heaviest wait, active queries, a CPU chart, memory and file-I/O trends, and collection health — the same collected data the viewer shows, over inline charts. A fleet-wide **Alert History** page (with a server filter box) rounds out phase 1, and a **Custom Views** page hosts the composer for the saved dashboards/notebooks in `config.custom_views` — the dashboard's one write path. Otherwise it is a read-only view — no settings, no live-server queries — and it refreshes every 60 seconds (pausing while the tab is hidden). The frontend ships fully self-contained (no CDN, no fonts, no remote anything), so it works on an air-gapped host with no internet access. ### peers @@ -1167,12 +1167,12 @@ Add a `network` block to `web` (managed mode; `web.enabled` must be `true`): } ``` -When `listen` is a network address **and** a token is present **and** `allowFrom` is a valid CIDR, the web host binds that interface behind two gates: an **in-app CIDR check** on the remote address and an **access token**. A browser presents the token ONCE via `?token=` (open `http://192.168.1.205:5153/`, then paste it into the minimal login form, or append `?token=...` directly); the host validates it constant-time, sets an **HMAC-signed, HttpOnly, SameSite=Strict session cookie**, and 302-redirects to strip the token from the URL so it never lingers in history or a Referer header. Subsequent requests ride the cookie. **Loopback is always allowed tokenless, even while exposed** — unlike MCP, the read-only dashboard has no loopback-token requirement. An out-of-CIDR request is refused with **403**. The cookie signing key is per-process, so a service restart invalidates open sessions (just re-present the token). Prefer `encryptedToken` (a DPAPI blob from `--encrypt-password`); a plaintext `token` works for dev but is warned. Set the same scoped firewall rule for the web port: +When `listen` is a network address **and** a token is present **and** `allowFrom` is a valid CIDR, the web host binds that interface behind two gates: an **in-app CIDR check** on the remote address and an **access token**. A browser presents the token ONCE via `?token=` (open `http://192.168.1.205:5153/`, then paste it into the minimal login form, or append `?token=...` directly); the host validates it constant-time, sets an **HMAC-signed, HttpOnly, SameSite=Strict session cookie**, and 302-redirects to strip the token from the URL so it never lingers in history or a Referer header. Subsequent requests ride the cookie. **Loopback is exempt from the CIDR check only** — while the dashboard is exposed, a request from the box itself still has to present the token or a valid cookie. It used to pass tokenless on the grounds that the surface was read-only; the Custom Views composer's create / update / delete and `/api/compose/run` ended that, so in exposed mode the token *is* the loopback guard against SSRF and sandboxed local sockets ([#1649](https://github.com/erikdarlingdata/PerformanceMonitor/issues/1649)). A dashboard with **no** `network` block registers no auth middleware at all and stays genuinely tokenless. An out-of-CIDR request is refused with **403**, cookie or no cookie; an in-CIDR request with no credential gets the login form back as a **200**, not a 401. The cookie signing key is per-process, so a service restart invalidates open sessions (just re-present the token). Prefer `encryptedToken` (a DPAPI blob from `--encrypt-password`); a plaintext `token` works for dev but is warned. Set the same scoped firewall rule for the web port: ``` New-NetFirewallRule -DisplayName "Darling Web" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5153 -RemoteAddress 192.168.1.0/24 ``` -**What a web token can reach.** The web dashboard is **read-only over the collected store** — it connects as the least-privilege `viewer` role and hosts no write paths and no live-server queries (no `analyze_server`, no plan re-execution). A token-holder can view everything collected, change nothing, and reach no monitored server — which is why loopback stays tokenless here while MCP's does not. +**What a web token can reach.** The web dashboard is **read-only over the collected store** — it connects as the least-privilege `viewer` role and hosts no live-server queries (no `analyze_server`, no plan re-execution). Its one write path is the Custom Views composer, which is that role's one write grant: INSERT/UPDATE/DELETE on `config.custom_views` and nothing else. So a token-holder can read everything collected and author saved views, and reach no monitored server — but that write is why an exposed dashboard authenticates loopback too, exactly as MCP does. **Web has no TLS either — same reverse-proxy control as MCP.** The token/cookie travels cleartext on the segment, so the in-app CIDR bounds *who can route to* the port but does not protect the wire. On an untrusted segment, put a TLS-terminating reverse proxy in front of the web port. From eb3a93d334861e4d4c956107fb5cf7c468d2406f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:09:55 +0100 Subject: [PATCH 2/6] Give a first Darling install one ordered path instead of four references External UAT testers start shortly, and the only onboarding that exists is the root README's four-step Darling quick start, whose last step is "Open the viewer and point it at the store" with nothing about how. The web dashboard appears once, as a clause in a prose paragraph, with no procedure. Darling/README.md is 1,178 excellent lines of REFERENCE -- it answers "what does this setting do", which is a different question from "I have nothing, what do I type", and turning it into the second would cost it the first. So this adds the missing thing rather than growing the reference: one ordered procedure, docs/uat-onboarding.md, matching the shape postgres-first-target-runbook.md already established here -- a proof point at every step, quoting the log line, HTTP response or on-screen state that tells someone it worked without asking anyone. One file, not three. The three surfaces share most of a prerequisite: the install, the two .NET runtimes, the JSONC rule, --encrypt-password, the two-minute first start, the firewall verb. Split three ways that shared half gets written three times and goes stale in two of them. Each surface still stands alone -- Parts 2, 3 and 4 assume only Part 1 and never each other -- and the one fact that spans all three (which settings the file owns and which the store owns) is stated once, in Part 0, where both later parts can point at it instead of re-deriving it. Everything asserted was read out of the source rather than out of the docs, which is how the #1649 loopback drift fixed in the previous commit surfaced. The seven gotchas that cost real time get their own section with links back to where they bite, and Part 6 names the gaps -- no TLS on either endpoint, no verb that reprints a token, no runtime pre-check in the installer, no global read-only indicator -- because an admitted gap is cheaper than the support round that discovers it. README.md's step 4 becomes the two steps it was hiding (start the viewer, which needs no configuration on the service host; export a folder for a remote seat), plus a fifth for the two off-by-default surfaces, and both READMEs link into the new procedure. Co-Authored-By: Claude Opus 5 --- Darling/README.md | 2 + README.md | 7 +- docs/uat-onboarding.md | 779 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 786 insertions(+), 2 deletions(-) create mode 100644 docs/uat-onboarding.md diff --git a/Darling/README.md b/Darling/README.md index ce3cdaf806..776c5c88dd 100644 --- a/Darling/README.md +++ b/Darling/README.md @@ -32,6 +32,8 @@ Nothing is installed on the monitored SQL Servers by either edition beyond two l ## Quick Start +> **First time, on a box with nothing on it?** [**`docs/uat-onboarding.md`**](../docs/uat-onboarding.md) is the ordered procedure from a downloaded zip to a running service and whichever of the three surfaces you need — the WPF viewer, the web dashboard, or MCP — with a proof point at every step. This section, and the rest of this document, is the reference it links back into. + ### Prerequisites - **Windows** for the service host (Windows-service lifetime, DPAPI password protection) and for the viewer (WPF). Monitored servers can be SQL Server 2016–2025, Azure SQL Managed Instance, AWS RDS for SQL Server, or Azure SQL Database. diff --git a/README.md b/README.md index 9d2e6dcc2c..c823abb60f 100644 --- a/README.md +++ b/README.md @@ -186,9 +186,12 @@ When a second Windows user on the same machine launches Lite, they see the share 1. Download **`PerformanceMonitorDarling-.zip`** from the [latest release](https://github.com/erikdarlingdata/PerformanceMonitor/releases/latest) — the signed service and viewer with the bundled PostgreSQL + TimescaleDB runtime beside the service exe, so a from-zero install needs no database provisioning. 2. Copy `darling.sample.json` to `darling.json` and add your servers (and optional SMTP / webhook delivery). In managed mode the service unpacks and runs its own PostgreSQL — no external database to set up. 3. Run the service (console for a trial, or install it as a Windows service). It seeds the store and begins collecting on the same default cadences and retention horizons as a fresh Lite install. -4. Open the viewer and point it at the store to browse the fleet. +4. Start the **Darling Viewer**. It is in the same zip, under `viewer\`, and `install-darling.ps1` leaves a Desktop shortcut. On the service host there is nothing to point at anything: it finds the same `darling.json`, derives the store connection, and opens on the fleet. For a seat on another machine, run `--export-viewer-config` on the service host and copy the folder it writes. +5. Optionally turn on the two off-by-default surfaces: `--enable-web` serves the browser dashboard on port 5153, `--enable-mcp` serves the MCP endpoint on 5152. Both take effect live, no restart. -Configuration is a single JSON file with no schedule knobs. See the **[Darling operator guide](Darling/README.md)** for the full quick start, configuration reference, permissions, and operations. +**Never done this before?** [**docs/uat-onboarding.md**](docs/uat-onboarding.md) is the ordered path from a downloaded zip to all three surfaces — the WPF viewer, the web dashboard, and MCP — with the log line, HTTP response, or screen that proves each step worked, and the handful of things that reliably catch people out. + +Configuration is a single JSON file with no schedule knobs. See the **[Darling operator guide](Darling/README.md)** for the configuration reference, permissions, and operations. --- diff --git a/docs/uat-onboarding.md b/docs/uat-onboarding.md new file mode 100644 index 0000000000..ad192e4e8a --- /dev/null +++ b/docs/uat-onboarding.md @@ -0,0 +1,779 @@ +# Darling UAT onboarding: from nothing to all three surfaces + +You have a Windows box, a SQL Server or two you are allowed to monitor, and a Darling build. You have never +run it. This gets you to the three things you can actually look at: + +| Surface | What it is | Part | +|---|---|---| +| **WPF viewer** | The desktop app. Everything: plans, blocking chains, deadlock graphs, live active queries | [Part 2](#part-2--the-wpf-viewer) | +| **Web dashboard** | A browser view of the fleet, served by the service itself. Off by default | [Part 3](#part-3--the-web-dashboard) | +| **MCP** | The endpoint an LLM client talks to. Off by default | [Part 4](#part-4--mcp) | + +**Part 1 is shared** — all three read the same store, filled by the same service, so everyone does it. +Parts 2, 3 and 4 are independent of each other and of each other's order. Do the one you need. + +**This is the procedure. [`Darling/README.md`](../Darling/README.md) is the reference** — every config field, +every permission, every operational knob, in far more detail than belongs here. Where you need to know what a +setting *means*, this links there rather than restating it. Where you need to know what to *type*, it is here. + +Every step ends with a **proof point**: the log line, the HTTP response, the thing on screen that tells you it +worked. If a proof point does not appear, stop there — the next step will not fix it. + +--- + +## Part 0 — two facts that explain most of the surprises + +### `darling.json` is JSONC, not JSON + +It contains `//` comments — 200-plus of them in the shipped sample, and they are the field documentation. +**Edit it as text**, in Notepad or VS Code. + +PowerShell's `ConvertFrom-Json` fails on it outright, and even where a parser tolerates comments, a +`ConvertFrom-Json | ConvertTo-Json` round-trip silently strips every one of them and leaves the next person +with an undocumented file. There is no supported programmatic edit path; the one tool that rewrites the file +(`--configure-network`) preserves comments on purpose and takes a timestamped `.bak-` copy first. + +### The file is a first-run *seed*. After that, the store is authoritative + +On first start the service copies most of `darling.json` into control-plane tables in its own store. From then +on it reads its live configuration from there, and edits you make to the file for those sections are ignored. + +This trips people up most on the MCP and web blocks, where **the two halves of one JSON object have different +owners**: + +| Setting | Authority | Changed with | +|---|---|---| +| `mcp.enabled`, `mcp.port` | **the store** (`config.config_service.mcp_enabled` / `mcp_port`) | `--enable-mcp` / `--disable-mcp`, or the Viewer's Settings. Live, no restart | +| `web.enabled`, `web.port` | **the store** (`config.config_service.web_enabled` / `web_port`) | `--enable-web` / `--disable-web`, or the Viewer's Settings. Live, no restart | +| `mcp.network` / `web.network` (`listen`, `allowFrom`, `encryptedToken`) | **the file**, always. No store equivalent exists | editing `darling.json`, then **restarting the service** | +| `postgres.*` (including `postgres.network`) | **the file**, always | editing `darling.json`, then restarting the service | +| SQL-auth secrets (`encryptedPassword`) | **the file**, always — never written to the store | editing `darling.json` | +| `servers` | seeded once, then **the store** | the Viewer's Add Server, the `add_servers` MCP tool, or `--add-server` | + +So: changing `mcp.port` in the Viewer changes where MCP binds, and putting a different `mcp.port` in the file +will not override it. When the two disagree the service says so, once per start, naming both sides: + +> `MCP configuration disagrees across the two planes and the CONTROL PLANE WINS: port is 5152 in darling.json +> (mcp.port) but 5199 in config.config_service.mcp_port. After the first run darling.json's +> mcp.enabled/mcp.port are only the SEED — change them with --enable-mcp/--disable-mcp or the Viewer's +> Settings, or the file values will keep being ignored. The mcp.network block is the OPPOSITE: file-only, +> restart-only, no store equivalent — the control plane cannot change where this endpoint binds or what token +> it requires.` + +--- + +## Part 1 — get the service collecting + +### 1.1 Prerequisites + +- **Windows** on the service host. The bundled store, DPAPI credential protection and the Windows-service + lifetime are all Windows-only. (There is a Linux path — see + [Run on Linux](../Darling/README.md#run-on-linux-docker-compose-or-systemd-1804) — but it is not this + procedure, and the WPF viewer is Windows either way.) +- **Two .NET 10 runtimes**, from : + - **ASP.NET Core Runtime 10.0** — the service needs it whether or not you ever enable MCP or the web + dashboard, because the host framework is referenced unconditionally. + - **.NET Desktop Runtime 10.0** — the WPF viewer. + + A stock Windows Server image has neither, `install-darling.ps1` does **not** check for them, and a missing + runtime surfaces as the raw .NET host error ("You must install .NET…"), not as anything Darling says. + Install both first. +- **A monitored SQL Server** (2016–2025, Azure SQL MI, AWS RDS, or Azure SQL DB) and a login on it with + `VIEW SERVER STATE` and the rest of the [monitoring grants](../Darling/README.md#permissions-on-monitored-servers). +- **Nothing else.** In the shipped default (`postgres.managed = true`) the service runs its own bundled + PostgreSQL 18 + TimescaleDB. There is no database to provision. + +### 1.2 Download and verify + +Take `PerformanceMonitorDarling-.zip` and `SHA256SUMS.txt` from the release (or the nightly) you were +pointed at, then check the hash before you extract anything: + +```powershell +$zip = 'PerformanceMonitorDarling-3.5.0.zip' # whatever you actually downloaded +(Get-FileHash -Path $zip -Algorithm SHA256).Hash.ToLower() +Select-String -Path .\SHA256SUMS.txt -SimpleMatch -Pattern $zip +``` + +**Proof:** the two hex strings match. (`SHA256SUMS.txt` is LF-terminated on purpose so `shasum -c` works on +macOS and Linux too.) + +### 1.3 Extract to a machine-scoped path + +```powershell +Expand-Archive -Path $zip -DestinationPath C:\PerformanceMonitorDarling +``` + +**`C:\PerformanceMonitorDarling` is the documented location and you should use it.** Not your Desktop, not +Downloads, not anywhere under `C:\Users\`, not a UNC path or a mapped drive. The service runs as the +unprivileged virtual account `NT SERVICE\PerformanceMonitor Darling`, which is not you and not an +administrator, and a user profile grants access to nobody else — the service installs cleanly and then cannot +read its own program files. `install-darling.ps1` refuses a fresh install in those locations for exactly this +reason; the [full explanation is in the reference](../Darling/README.md#the-install-location-has-to-be-machine-scoped). + +**Proof:** the folder contains `PerformanceMonitor.Darling.Service.exe`, `darling.sample.json`, +`install-darling.ps1`, `pg-runtime.zip`, `wwwroot\`, and a `viewer\` subfolder holding +`PerformanceMonitor.Darling.Viewer.exe`. That is the whole product — the viewer is not a separate download. + +### 1.4 Write `darling.json` + +```powershell +Copy-Item C:\PerformanceMonitorDarling\darling.sample.json C:\PerformanceMonitorDarling\darling.json +notepad C:\PerformanceMonitorDarling\darling.json +``` + +The minimum is one entry in `servers`. Everything else in the sample has a working default: + +```json +{ + "postgres": { + "managed": true, + "port": 5641 + }, + "servers": [ + { + "name": "SQL2022", + "host": "SQL2022", + "auth": "integrated", + "excludedDatabases": [] + } + ] +} +``` + +`"auth": "integrated"` connects as the account the service runs under. The default virtual account reaches +*remote* servers as the machine account `DOMAIN\$`, so for integrated auth against a remote server you +usually want to [run the service as a domain account or gMSA](../Darling/README.md#run-the-service-as-a-domain-account-or-gmsa) +instead. For SQL auth, set `"auth": "sql"`, a `"username"`, and an `"encryptedPassword"`: + +```powershell +cd C:\PerformanceMonitorDarling +.\PerformanceMonitor.Darling.Service.exe --encrypt-password +``` + +It prints `Password: ` and waits. Type the password, press Enter, and it prints one line of base64 (it starts +`AQAAA`) — paste that into `"encryptedPassword"`. + +> **`--encrypt-password` writes its prompt and its confirmation to stderr; only the blob goes to stdout.** +> That is deliberate (`… --encrypt-password > blob.txt` captures exactly the blob and nothing else) but it is +> fatal under `$ErrorActionPreference = 'Stop'`: PowerShell treats the stderr write as a terminating error, +> and then echoes the offending source line — which, if you piped the password in, leaks it into your +> scrollback. In a script, do this instead: +> +> ```powershell +> $ErrorActionPreference = 'Continue' +> $blob = Read-Host -Prompt 'password' | +> & C:\PerformanceMonitorDarling\PerformanceMonitor.Darling.Service.exe --encrypt-password 2>$null | +> Select-String -Pattern '^AQAAA' | ForEach-Object { $_.Line } +> ``` + +The blob is DPAPI at **LocalMachine** scope with a fixed entropy string, so it decrypts **only on the machine +that produced it**. Run `--encrypt-password` on the service host, and re-run it if you ever move `darling.json` +to a different box. The same rule applies to the MCP and web tokens in Part 3 and Part 4 — do not copy a +`darling.json` between machines and expect its secrets to survive. + +### 1.5 Pre-flight + +```powershell +cd C:\PerformanceMonitorDarling +.\PerformanceMonitor.Darling.Service.exe --test-connection +``` + +This validates the file and then actually connects to every configured server. It exits `0` only when the +config is valid **and** every server is reachable, so it is usable as a gate. + +**Proof:** + +``` +Validating connectivity to 1 server(s)... + [PASS] SQL2022: SQL major version 16, Enterprise Edition (64-bit), msdb access: yes +All servers reachable. +``` + +`msdb access: NO (failed-job alerts unavailable)` is a `[PASS]` too — it means the login cannot read +`msdb.dbo.sysjobs`, so failed-job alerts are skipped. That is expected for a minimal monitoring login; add the +direct msdb `SELECT`s from the permissions section if you want them. + +A `Configuration is invalid:` block lists literal per-field problems — fix them and re-run. One caveat: this +verb connects as **you**, the console user, not as the service account. For `"auth": "integrated"` servers a +`[PASS]` proves reachability and a well-formed config; the grants that matter at runtime are the service +account's, and the per-server connect lines in step 1.7 are the real proof. + +### 1.6 Install the service + +From an **elevated** PowerShell, in the install folder: + +```powershell +cd C:\PerformanceMonitorDarling +.\install-darling.ps1 +``` + +It checks the install location, runs the pre-flight again, registers the Event Log source, creates the service +`PerformanceMonitor Darling` under the virtual account, reconciles firewall rules, starts it, and creates +Desktop and Start Menu **Darling Viewer** shortcuts. `.\uninstall-darling.ps1` reverses it and deliberately +leaves your store and config alone unless you pass `-PurgeData`. + +If you want the LAN endpoints set up in the same pass, use `.\install-darling.ps1 -Network` — it runs the +guided wizard at the end. Parts 3 and 4 cover doing it later. + +### 1.7 Wait about two minutes + +**The first start does real work: it unpacks `pg-runtime.zip`, runs `initdb`, migrates the store, provisions +the least-privilege roles, and completes a first collection cycle.** The installer says so on its last line: + +> `Service is Running. First start does real work (unpack pg-runtime, initdb, store migration, first +> collection cycle) - give it ~2 minutes.` + +**It has not hung. Do not kill it.** The internal `initdb` timeout alone is 180 seconds. Watch the log: + +```powershell +Get-Content "$env:ProgramData\PerformanceMonitorDarling\logs\darling-service_$(Get-Date -Format yyyyMMdd).log" -Wait -Tail 40 +``` + +**Proof** — these lines, in this order: + +``` +Loaded configuration from C:\PerformanceMonitorDarling\darling.json: 1 server(s) +Extracting the bundled Postgres runtime from ...\pg-runtime.zip (first run) +Initializing managed Postgres cluster in C:\ProgramData\PerformanceMonitorDarling\pg (first run) +Managed Postgres cluster initialized (scram-sha-256, data checksums, UTF8/C locale) +Managed Postgres started +Creating the 'darling' database +Postgres store ready (schema v79, 79 migration(s) applied) +Least-privilege roles ready (admin: ...; viewer: ...; mcp: ...) +TimescaleDB detected — hypertables, chunk-based retention, and compression enabled +TimescaleDB: 50/50 collector table(s) are hypertables +PerformanceMonitor Darling collection loop started +[SQL2022] Connected (major 16, edition Enterprise Edition, server_id 1) + [SQL2022] wait_stats => 412 rows (sql:31ms, pg:12ms) +``` + +`PerformanceMonitor Darling collection loop started` is "ready". The per-collector `=> N rows` lines repeating +every sweep is "working". On the hypertable line **both numbers should be equal**; a converted count below the +total means a table stayed plain, and the line above it names which. + +Two things that look wrong on a first start and are not: + +- **One-time `discarding and regenerating` warnings** about the role `.dpapi` credential files. A role password + can be re-asserted, so the service does; this is the ownership check working. +- **A collector reporting `YIELDED`.** That is the one-second lock-timeout guard firing on a busy target — the + design refusing to become the problem it is monitoring. + +If the service idles instead, the message is literal and named: `Cannot load configuration`, +`Configuration problem: `, or `Cannot reach or migrate the Postgres store`. See +[Troubleshooting](../Darling/README.md#troubleshooting). + +--- + +## Part 2 — the WPF viewer + +The viewer talks **only to the PostgreSQL store**. It never connects to your monitored SQL Servers; every +number it shows was collected by the service. That is why it needs no SQL Server credentials and why a +read-only seat is a real thing (see 2.3). + +### 2.1 On the service host + +Double-click the **Darling Viewer** shortcut the installer made, or run +`C:\PerformanceMonitorDarling\viewer\PerformanceMonitor.Darling.Viewer.exe`. + +**There is nothing to configure.** It looks for `darling.json` in four places, in order: an explicit +command-line path, `%DARLING_CONFIG%`, beside its own exe, and then **one directory up** — which is exactly +the zip's `viewer\`-under-the-service-root layout. It reads only the `postgres` section, derives +`Host=127.0.0.1;Port=5641;Database=darling`, and decrypts the store password from +`pg-admin-credential.dpapi` beside the data directory. + +**Proof:** the left sidebar lists your servers, and the Overview tab shows a health card per server. Double-click +a card (or the sidebar entry) to open that server's tab. + +**If it will not connect,** the failure window carries a **Configuration this viewer used** block naming the +`darling.json` it actually read, which of the four rules picked it, and the host/port/user/database/SSL mode it +parsed — with a **Copy details** button. Read that before changing anything: it separates *the viewer read a +different file than you edited* from *it read your file and a value in it is wrong*. It never contains a +password. The same lines are in `%APPDATA%\PerformanceMonitorDarling\logs\darling-viewer_yyyyMMdd.log`. + +Two specific failures worth naming: + +- `The Darling store is at schema v78, but this viewer needs v79. Update or restart the Darling service so it + migrates the store, then reopen the viewer.` — the viewer is newer than the service. Upgrade or restart the + service; it migrates on start. (The gate is `<`, not `≠`: a store *newer* than the viewer is not blocked.) +- `pg-admin-credential.dpapi ... does not exist yet.` — the service's first run never got far enough to + provision the roles. The reason is in the **service** log, not in `darling.json`. + +### 2.2 On another machine (a remote seat) + +Two things have to be true, and the service-side one is the one people miss. + +**Service side — the store has to be reachable off the box.** By default it listens on `127.0.0.1` and accepts +no remote viewer at all; no amount of viewer-side configuration changes that. On the service host, from an +**elevated** PowerShell: + +```powershell +cd C:\PerformanceMonitorDarling +.\PerformanceMonitor.Darling.Service.exe --configure-network +``` + +Choose the **store** option. The wizard validates every answer against the same resolvers the running service +fail-closes on, edits `darling.json` in place preserving its comments (behind a timestamped `.bak-` backup), +prints the scoped firewall command, and offers to restart the service. On that restart the service adds the +bind IP to `listen_addresses`, generates a self-signed TLS certificate with an IP SAN, and writes a +`hostssl darling scram-sha-256` line into `pg_hba.conf`. + +Then, still elevated: + +```powershell +.\PerformanceMonitor.Darling.Service.exe --configure-firewall +``` + +**Proof:** `Get-NetFirewallRule -DisplayName 'PerformanceMonitor Darling store (port 5641)'` returns a rule +with `Enabled=True, Action=Allow`. Reading firewall rules needs no elevation, so this check works from any +shell. + +**Then export the seat:** + +```powershell +.\PerformanceMonitor.Darling.Service.exe --export-viewer-config +``` + +It writes a `viewer-config\` folder holding three files — `darling.json` (connection string resolved, +`"managed": false` already set, every field explained in comments inside the file), `server.crt` (the +certificate the connection pins), and `README.txt`. **That `darling.json` contains a live database password**; +the verb ACLs it, says so, and exits non-zero if the ACL did not take. Copy the folder over a channel you +trust. + +**Viewer side — two ways to get the app onto the remote machine:** + +| | Where the viewer lives | Where to put the three exported files | +|---|---|---| +| Copy `viewer\` out of `PerformanceMonitorDarling-.zip` | wherever you unpacked it | next to `PerformanceMonitor.Darling.Viewer.exe` — works unedited | +| Run the standalone `PerformanceMonitorDarlingViewer--Setup.exe` (Velopack; auto-updating) | a versioned folder the installer manages | **anywhere**, then point `%DARLING_CONFIG%` at the exported `darling.json` | + +Prefer `DARLING_CONFIG` with the Setup.exe: its install directory changes on every update, so files dropped +beside the exe do not survive. Either way keep the three files **together** — a bare `Root Certificate=server.crt` +resolves against the folder holding `darling.json`, not the working directory, so the folder can live anywhere +as long as it stays intact. + +Re-run `--export-viewer-config` and re-copy after a credential rotation or a `listen` change — the store's +certificate regenerates when its bind IP changes, and an old copy stops matching. + +### 2.3 Read-only versus read-write seats + +**This is the single most-asked question about the viewer, and the answer is not what the wording suggests.** + +Some viewer actions are not reads. "Current Active Queries" → Refresh, "Get Actual Plan", "Purge Now", +Recommendations → "Generate now", alert Dismiss, mute-rule editing, Add Server, schedule editing: all of these +work by **enqueueing a command for the service** — literally an `INSERT` into `config_command` **in the +monitoring store**. The service picks the row up, does the work over its own connection, and writes the result +back. A read-only seat cannot write that row. + +**Nothing is written to your monitored SQL Server. Ever.** The monitored server is only read, by the service, +under the same least-privilege login the collectors use. The refusal says so out loud: + +> `Read-only viewer — the live fetch queues a request for the service in the MONITORING STORE, which a +> read-only seat can't write to. Nothing is sent to or written on the monitored server. Reconnect with a +> read-write store profile to fetch live active queries.` + +**Which seat you get, by default:** + +| Seat | Setting | Default | Result | +|---|---|---|---| +| Local, on the service host | `postgres.connectAs` | `"admin"` | **read-write** | +| Remote, over the LAN | `postgres.network.role` | `"viewer"` | **read-only** | + +Those defaults are deliberately opposite: the local seat is the operator's, the remote seat is a laptop. **So +a UAT tester on a remote seat should expect read-only** and should not read it as a fault. + +**How the viewer decides.** On connect it runs one probe: + +```sql +SELECT has_table_privilege('config_mute_rules', 'INSERT') +``` + +True means read-write. False means read-only. **Anything else also means read-only** — the probe fails safe, so +a permission quirk or a mis-provisioned store hides the write affordances rather than letting you dead-click +into a permission error. (One nuance: a failure to *reach* the store is not a read-only verdict; that surfaces +as "Can't reach the Darling store — is the Darling service running?" instead.) + +So **if you see an unexpected read-only seat, run that probe yourself** against the store as the role the +viewer is using. If it returns `true` and the viewer still says read-only, that is a bug worth reporting. + +**How to tell which seat you have.** There is no global banner — it shows up per surface. The quickest check +is to open **Settings**: a read-only seat shows + +> `This viewer is connected with a read-only role. Monitoring settings below are shown for reference but can't +> be changed from here.` + +Elsewhere you will see disabled buttons with tooltips, a `— read-only seat` window-title suffix, or a status +line saying which write is blocked. + +**How to change it.** On a managed store this is a config change, not a `GRANT` — the `admin` role and its +credential already exist, provisioned on every service start: + +- Local seat: set `"connectAs": "admin"` in the `postgres` block and restart the viewer. +- Remote seat: set `postgres.network.role` to `"admin"` on the **service host**, restart the service, and + re-run `--export-viewer-config`. The service logs a warning when you do, because `admin` holds the + `config_command` / `config_monitored_servers` / `config_notification` pivot — that is the trade, made + knowingly. +- Bring-your-own PostgreSQL: run [`Darling/tools/provision-roles.sql`](../Darling/tools/provision-roles.sql) + as the store owner; it is the authoritative grant list. + +--- + +## Part 3 — the web dashboard + +### 3.1 Why it is off + +A browser surface has no TLS here and, on loopback, no token. Shipping it on by default would mean every +install serves its monitoring data to anything that can open a socket on the box. So it is off, and turning it +on is one command. + +### 3.2 Turn it on + +**Headless**, from an **elevated** PowerShell on the service host: + +```powershell +cd C:\PerformanceMonitorDarling +.\PerformanceMonitor.Darling.Service.exe --enable-web +``` + +Or tick **Enable web dashboard** in the Viewer's **Settings**. Both write the same control-plane flag, and the +running service applies it **within one collection sweep — no restart**. + +Both routes write the *store*, so **the service must have completed Part 1 at least once**. On an unseeded box +the verb refuses and tells you to start the service first — it will not fall back to editing the file. + +**Proof:** + +``` +web dashboard endpoint ENABLED in the control-plane store (config.config_service.web_enabled = true). +The running service applies this LIVE within one collection sweep (the write self-bumps the reload beacon) — no restart needed. +``` + +followed by, in the service log: + +``` +Starting web dashboard on http://localhost:5153 (loopback only) — enabled/port from the control plane (config.config_service) +``` + +### 3.3 Look at it + +On the service host, open `http://localhost:5153/`. **A loopback-only dashboard — one with no `web.network` +block — is tokenless**; the auth middleware is not installed at all in that mode. (That changes once you +expose it on the LAN. See 3.5, and do not assume loopback keeps a free pass.) + +**Proof:** a sidebar titled **Darling Web** with **Fleet Overview**, **Alert History** and **Custom Views**, a +list of your servers under them, and fleet cards in the main pane. (**Availability Groups** appears only once +the store holds AG data; the `#/ag` route works regardless.) Or, scriptably: + +```powershell +Invoke-RestMethod http://localhost:5153/api/ping +``` + +**Proof:** `status : ok`. + +### 3.4 What it will not do + +Be clear about this before you file it as a bug. The web dashboard reads the store as the least-privilege +`viewer` role. Its `/api/read/*` surface is machine-derived from the MCP tool catalog minus an explicit +exclusion list — 82 read endpoints out of 101 tools — and these are the exclusions that matter to you: + +- **No plan analysis.** The whole `analyze_query_plan` / `analyze_procedure_plan` / `analyze_query_store_plan` / + `analyze_plan_xml` family is excluded. **Use the WPF viewer for plans** — it has the graphical plan viewer, + the block-chain reconstruction and the deadlock-graph renderer. +- **No `analyze_server`.** That makes a live outbound connection to a monitored server; the web host does not. +- **No live "Current Active Queries" fetch, no actual-plan capture, no purge** — all of those are the + `config_command` enqueue from 2.3, and the web host has no write path to it. +- **No alert tuning, no mute-rule writes, no adding or removing servers.** + +And the pages simply do not exist for most of what the WPF viewer shows: the web side is Fleet Overview, +per-server, Alert History, Availability Groups and Custom Views, against the viewer's fourteen per-server inner +tabs. No wait-type picker, no query heatmap or period compare, no tempdb page, no perfmon picker, no Running +Jobs, no configuration grids, no Recommendations cards. + +What it *can* write is one thing: **Custom Views** — the saved dashboards and notebooks in +`config.custom_views`, created and edited through the built-in composer, plus `POST /api/compose/run` to +preview one. That is the `viewer` role's single narrow write grant, and it is available to **any authenticated +seat**, not only a local one. It is also the reason the loopback token exemption went away (see 3.5). + +### 3.5 Reach it from a browser on another machine + +Add a `network` block to `web` in `darling.json` (managed mode only), or let `--configure-network` write it: + +```json +"web": { + "enabled": true, + "port": 5153, + "network": { + "listen": "192.168.1.205", + "allowFrom": "192.168.1.0/24", + "encryptedToken": "" + } +} +``` + +`--configure-network` generates a 32-character token for you and prints the plaintext **once** — save it then; +`darling.json` keeps only the DPAPI blob and there is no verb that prints it back. Bring your own instead if +you prefer, and encrypt it with `--encrypt-password`. A plaintext `"token"` works and is warned about. + +**The block is file-authoritative and read once at service start.** After editing it: + +```powershell +Restart-Service 'PerformanceMonitor Darling' +C:\PerformanceMonitorDarling\PerformanceMonitor.Darling.Service.exe --configure-firewall +``` + +**How the auth actually works in a browser.** Open `http://192.168.1.205:5153/` and paste the token into the +minimal login form, or go straight to `http://192.168.1.205:5153/?token=`. The host validates it +constant-time, sets an **HMAC-signed, HttpOnly, SameSite=Strict cookie** named `darling_web_session` with a +12-hour lifetime, and 302-redirects to strip the token out of the URL so it never lands in history or a +`Referer`. Every later request rides the cookie. The signing key is per-process, so **a service restart logs +everyone out** — present the token again. + +Two gates, and the order matters: + +1. **CIDR**, checked in-app on the remote address. Outside `allowFrom` is **403** with an empty body, cookie or + no cookie. A remote address the host cannot determine fails closed to 403 as well. **Loopback skips this + check** — otherwise `127.0.0.1` would 403 the operator's own browser. +2. **Credential.** A valid cookie passes; a valid `?token=` is exchanged for one; anything else gets the login + form back as **HTTP 200**, not a 401. + +> **Loopback does *not* skip step 2.** Once a `web.network` block exposes the dashboard, **every** request +> authenticates, including one from the box itself. It used to pass tokenless on the grounds that the surface +> was read-only; that stopped being true when the Custom Views composer gained create/update/delete and +> `/api/compose/run`, so a local process — a scheduled task, SSRF'd code, another user's session — could read +> the whole store and mutate views with no credential. In exposed mode the token *is* the loopback guard. + +**Proof:** the dashboard renders in the remote browser, and the address bar shows no `?token=`. + +**It is fail-closed.** An unparseable `listen`, an invalid `allowFrom`, a missing token, or bring-your-own +PostgreSQL all degrade the endpoint back to loopback and log a critical line saying which one — the endpoint +never half-exposes. + +**There is no TLS.** The token and cookie travel in clear on the segment. The CIDR bounds *who can route to* +the port; it does not protect the wire. On a segment you do not fully trust, put a TLS-terminating reverse +proxy in front of the port. **Never expose this to the internet.** + +--- + +## Part 4 — MCP + +### 4.1 Why it is off + +The MCP token gates the entire read surface plus `analyze_server`, which opens live outbound connections to +your monitored SQL Servers, plus the write tools that change the monitor's own configuration (custom views, +alert tuning, adding and removing monitored servers). That is a bigger blast radius than the web dashboard's, +which is why the two are separate surfaces with separate flags, ports and tokens. + +Worth stating plainly, because it is the first question anyone asks: **no MCP tool runs SQL that an AI client +wrote against your monitored servers.** No such tool exists. Every tool answers from the monitoring store, and +the only live contact with a monitored server — `analyze_server`'s plan fetch and `add_servers`' connection +probe — runs the product's own fixed, read-only queries under the collectors' least-privilege login. + +### 4.2 Turn it on and connect from the same box + +From an **elevated** PowerShell on the service host: + +```powershell +cd C:\PerformanceMonitorDarling +.\PerformanceMonitor.Darling.Service.exe --enable-mcp +``` + +Or tick **Enable MCP server** in the Viewer's **Settings**. Live within one sweep, no restart. + +**Proof**, in the service log: + +``` +Starting MCP server on http://localhost:5152 (loopback only) — enabled/port from the control plane (config.config_service) +``` + +and: + +```powershell +Get-NetTCPConnection -State Listen | Where-Object LocalPort -eq 5152 +``` + +Then register it. **On loopback there is no token** — the bearer requirement only applies once you expose the +endpoint on the LAN: + +```powershell +claude mcp add --transport http --scope user sql-monitor-darling http://localhost:5152/ +``` + +(The Viewer's Settings has a **Copy Setup Command** button that puts exactly this on your clipboard with the +port you configured.) + +**Proof:** `claude mcp list` shows `sql-monitor-darling` as connected, and `/mcp` in a new Claude Code session +lists **101 tools** — `list_servers`, `get_fleet_overview`, `get_wait_stats`, `analyze_server` and the rest. +Ask it *"what servers are being monitored?"* and it should answer from your store. + +Two details that save a round of guessing: the endpoint is the **root path** (`http://localhost:5152/`, not +`/mcp` and not `/sse`), and the transport is **stateless Streamable HTTP** — each request is self-contained, so +a client that does not echo an `Mcp-Session-Id` still works. + +### 4.3 Connect from another machine + +Add a `network` block to `mcp` (managed mode only, and `mcp.enabled` must be on): + +```json +"mcp": { + "enabled": true, + "port": 5152, + "network": { + "listen": "192.168.1.205", + "allowFrom": "192.168.1.0/24", + "encryptedToken": "" + } +} +``` + +Same rules as the web block: file-authoritative, **read once at service start**, fail-closed, and the token is +DPAPI at LocalMachine scope so it must be produced on that box. `--configure-network` will generate and encrypt +one for you and print the plaintext once. + +```powershell +Restart-Service 'PerformanceMonitor Darling' +C:\PerformanceMonitorDarling\PerformanceMonitor.Darling.Service.exe --configure-firewall +``` + +**Proof**, in the service log: + +``` +Starting MCP server on http://192.168.1.205:5152 (LAN-exposed to 192.168.1.0/24 behind a bearer token + in-app CIDR; loopback also bound) — enabled/port from the control plane (config.config_service); listen/allowFrom/token from darling.json mcp.network (file-only, restart-only) +``` + +Then the client. **Point it at the box's LAN IP, never `localhost`** — an off-box client aimed at localhost is +the number-one "MCP won't connect" cause, and it fails without an obvious error: + +```powershell +claude mcp add --transport http --scope user sql-monitor-darling http://192.168.1.205:5152/ ` + --header "Authorization: Bearer " +``` + +Two gates apply, in this order: the **bearer token**, checked constant-time with **no loopback exemption**, and +then the **in-app CIDR check** on the remote address (loopback always allowed). A remote client with a bad or +missing token gets **401**; one from outside `allowFrom` is refused before any response. + +**There is no TLS on MCP** — a self-signed certificate breaks real MCP clients, so the bearer token travels in +clear on the segment. The MITM control is a TLS-terminating reverse proxy in front of the port. **Never expose +this to the internet.** + +### 4.4 When it says enabled and still will not connect + +Both common failures leave the store flag reading `true`, so "it says enabled" is not proof. In order: + +1. **Is it bound to the LAN address, or still loopback?** + `Get-NetTCPConnection -State Listen | Where-Object LocalPort -eq 5152` must show the box's LAN IP, not only + `127.0.0.1` / `::1`. *Enabled but still loopback-bound* means the service loaded `darling.json` before the + `network` block existed — the enable toggle stops and starts the endpoint with the already-loaded config and + does **not** re-read the file. `Restart-Service 'PerformanceMonitor Darling'`, then re-check. +2. **Does the firewall rule exist, on the right port?** + `Get-NetFirewallRule -DisplayName 'PerformanceMonitor Darling MCP (port 5152)'`. See the port gotcha in + Part 5. +3. **Is the client pointed at the box's IP, with the header?** And do a fresh `initialize` + `tools/list` + rather than trusting a cached tool list from a previous version. + +**A rejected request leaves no trace in the service log.** 401 (bad or missing bearer), 403 (outside +`allowFrom`) and 400 (Host-header guard) are all returned silently, on both the MCP and web endpoints — +Kestrel's own logging is suppressed. Diagnose those from the client side, not by grepping the service log. + +If the block itself is bad the service *is* loud, and it fails closed to loopback rather than half-exposing: + +``` +MCP network exposure requested (mcp.network.listen is non-loopback) but no bearer token is set — refusing to expose; binding loopback-only. Set mcp.network.encryptedToken (via --encrypt-password) or mcp.network.token. +MCP network token could not be decrypted (...) — refusing to expose; binding loopback-only. +MCP network exposure requested but mcp.network.allowFrom '...' is not a valid CIDR or its address family does not match mcp.network.listen — refusing to expose; binding loopback-only. +mcp.network.* is set but postgres.managed = false — MCP network exposure is managed-mode (or container) only and is ignored ... +``` + +A "could not be decrypted" line on a box you copied `darling.json` onto is gotcha 4 in Part 5, not a bug. + +--- + +## Part 5 — the things that will actually bite you + +Every one of these has cost somebody real time. + +1. **`darling.json` is JSONC.** `ConvertFrom-Json` fails on it; a round-trip strips the comments that are its + documentation. Edit it as text. ([Part 0](#darlingjson-is-jsonc-not-json)) +2. **First start takes about two minutes** and looks like a hang. It is unpacking PostgreSQL and running + `initdb`. Do not kill it. ([1.7](#17-wait-about-two-minutes)) +3. **`--encrypt-password` prompts on stderr**, which is a terminating error under + `$ErrorActionPreference = 'Stop'` — and PowerShell then echoes the source line, leaking a piped-in password. + ([1.4](#14-write-darlingjson)) +4. **Every DPAPI blob is LocalMachine-scoped** — SQL passwords, the MCP token, the web token. They decrypt only + on the machine that encrypted them. Copying `darling.json` to another box gives you an undecryptable file, + not a portable config. +5. **A remote viewer seat is read-only by default**, because `postgres.network.role` defaults to `"viewer"` + while the local `connectAs` defaults to `"admin"`. What a read-only seat cannot do is `INSERT` into + `config_command` **in the monitoring store** — it is not a write to your SQL Server. The probe fails safe, so a store that answers oddly also + reads as read-only. ([2.3](#23-read-only-versus-read-write-seats)) +6. **`mcp`/`web` `enabled` and `port` are store-authoritative; the `network` block is file-authoritative and + restart-only.** Changing the port in the Viewer changes where the endpoint binds and the file cannot + override it; changing the `network` block in the file does nothing until you restart the service. + ([Part 0](#the-file-is-a-first-run-seed-after-that-the-store-is-authoritative)) +7. **The firewall rule is named for its port**, so changing a port does not move a rule — it creates a + different one and leaves the old one standing as an inbound allow with nothing behind it. **Re-run + `--configure-firewall` elevated after any port change.** It sweeps `PerformanceMonitor Darling + (port *)` so the stale rule goes with it. + + One narrow window remains on an **upgrade of a box whose port was moved in the Viewer**: the installer's + first `--configure-firewall` runs before the service starts, when the managed store (a child of the service) + is down, so the verb falls back to `darling.json`'s seed port. It says so rather than falling back silently, + and since #2436 the installer re-runs the verb *after* the service is up, which normally closes it. If the + store still cannot answer inside the verb's ten-second budget, the running service's own start-up check + logs the exact command to fix it. Fresh installs are unaffected — there, the file *is* the control plane's + future answer. +8. **`--configure-firewall`, `--enable-*` and `--disable-*` want an elevated shell.** The service account + cannot create firewall rules by design. Run unelevated and the store toggle still succeeds, the rule does + not change, and the exact elevated command is printed for you to run by hand. +9. **An off-box MCP client pointed at `localhost` fails quietly.** Use the box's LAN IP. + ([4.4](#44-when-it-says-enabled-and-still-will-not-connect)) +10. **Exposing the web dashboard on the LAN takes loopback's free pass away.** A loopback-only dashboard is + tokenless; the moment a `web.network` block exposes it, the browser on the service host has to present the + token too, because the Custom Views composer made the surface writable. + ([3.5](#35-reach-it-from-a-browser-on-another-machine)) +11. **A screen of `Warning` server statuses is not an incident.** Status is *collection freshness*, not health — + the viewer never pings your servers. `Warning` means the newest collection is older than twice the fastest + collector's cadence, which at fleet scale is routine. +12. **The Recommendations tab is empty for the first day.** Analysis runs every 30 minutes per server but only + once the store holds 24 hours of history for that server. A fresh install has not earned findings yet. + +--- + +## Part 6 — honest gaps, so you do not file them twice + +These are known, current, and not bugs: + +- **No TLS on the MCP or web endpoints.** Tokens and cookies travel in clear on the segment. The named control + is a TLS-terminating reverse proxy. Both are trusted-LAN opt-ins. +- **No client-side secret store for the viewer.** A remote seat's `darling.json` holds the store role password + in cleartext; ACL it. Fine for the read-only `viewer` role, worth thinking about for `admin`. +- **No verb prints an existing MCP or web token.** `--configure-network` shows the plaintext once at + generation. Lose it and you regenerate. +- **`install-darling.ps1` does not check for the .NET runtimes.** A missing one surfaces as the raw .NET host + error, not as a Darling message. +- **The viewer has no global read-only indicator.** You discover your seat one surface at a time. +- **The web dashboard has no plan analysis and no live-server actions** — that is the WPF viewer's job — and + its page set is a deliberate subset of the viewer's. +- **Rejected HTTP requests are not logged.** A 401, 403 or 400 from the MCP or web endpoint leaves nothing in + the service log, so "is my token wrong or is my CIDR wrong?" has to be answered from the client's response + code. +- **`network` blocks need a service restart.** There is no hot reload for exposure. +- **PostgreSQL monitored targets** collect and are readable, but apart from the three Tier 0 outage predictors + they do not raise alerts or produce analysis findings yet, and there is no plan capture or blocking-chain + read for them. See [PostgreSQL Targets](../Darling/README.md#postgresql-targets). + +If you hit something that is *not* on this list and *not* in +[Troubleshooting](../Darling/README.md#troubleshooting), that is the interesting kind — please report it with +the service log lines around the failure. + +--- + +## Where to go next + +| For | Read | +|---|---| +| Every config field, in detail | [Darling operator guide](../Darling/README.md) | +| Permissions on monitored servers | [Permissions](../Darling/README.md#permissions-on-monitored-servers) | +| The LAN endpoints in full | [Opt-in Network Endpoints](../Darling/README.md#opt-in-network-endpoints-lan) | +| The store, retention, TimescaleDB | [Operations](../Darling/README.md#operations) | +| What each collector gathers and when | [How collection works](how-collection-works.md) | +| Pointing Darling at a PostgreSQL target | [PostgreSQL first-target runbook](postgres-first-target-runbook.md) | From 83abf4615dfb57ad5e59715e9c03e5122e6b4048 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:12:38 +0100 Subject: [PATCH 3/6] Say which shell the restart-and-reopen-the-firewall pair needs Restart-Service and --configure-firewall both need elevation, and the two LAN sections showed them in a bare code fence after prose that had moved on to talking about the config block. Every other elevated step in the document says so at the point of typing; these two did not. Co-Authored-By: Claude Opus 5 --- docs/uat-onboarding.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/uat-onboarding.md b/docs/uat-onboarding.md index ad192e4e8a..92f6556dc3 100644 --- a/docs/uat-onboarding.md +++ b/docs/uat-onboarding.md @@ -513,7 +513,8 @@ Add a `network` block to `web` in `darling.json` (managed mode only), or let `-- `darling.json` keeps only the DPAPI blob and there is no verb that prints it back. Bring your own instead if you prefer, and encrypt it with `--encrypt-password`. A plaintext `"token"` works and is warned about. -**The block is file-authoritative and read once at service start.** After editing it: +**The block is file-authoritative and read once at service start.** After editing it, from an **elevated** +PowerShell: ```powershell Restart-Service 'PerformanceMonitor Darling' @@ -626,7 +627,7 @@ Add a `network` block to `mcp` (managed mode only, and `mcp.enabled` must be on) Same rules as the web block: file-authoritative, **read once at service start**, fail-closed, and the token is DPAPI at LocalMachine scope so it must be produced on that box. `--configure-network` will generate and encrypt -one for you and print the plaintext once. +one for you and print the plaintext once. Then, from an **elevated** PowerShell: ```powershell Restart-Service 'PerformanceMonitor Darling' From 6c035136764937d4cc5d3df1c8fa443fc478b0f9 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:17:37 +0100 Subject: [PATCH 4/6] The main window has no read-only suffix, so stop saying it does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat section listed "a `— read-only seat` window-title suffix" among the ways to tell which seat you have. No such suffix exists on the main window. What exists is one DIALOG title -- DatabaseStateOverridesWindow appends "(read-only seat — changes cannot be saved)" -- which a reader looking at the main title bar will not find. Sending a UAT tester to check a title bar that says nothing is worse than telling them there is nowhere central to look, so it now says the latter and points at Part 6, where the missing global indicator is already listed as a known gap. That gap is filed as #2479 item 3. Co-Authored-By: Claude Opus 5 --- docs/uat-onboarding.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/uat-onboarding.md b/docs/uat-onboarding.md index 92f6556dc3..aee80c8766 100644 --- a/docs/uat-onboarding.md +++ b/docs/uat-onboarding.md @@ -401,8 +401,10 @@ is to open **Settings**: a read-only seat shows > `This viewer is connected with a read-only role. Monitoring settings below are shown for reference but can't > be changed from here.` -Elsewhere you will see disabled buttons with tooltips, a `— read-only seat` window-title suffix, or a status -line saying which write is blocked. +Elsewhere you will see disabled buttons with tooltips, or a status line saying which write is blocked. Some +dialogs say it in their own title bar — Database State Overrides appends `(read-only seat — changes cannot +be saved)` — but **the main window does not**, so there is no one place to look. That is a known gap rather +than something you are missing: see Part 6. **How to change it.** On a managed store this is a config change, not a `GRANT` — the `admin` role and its credential already exist, provisioned on every service start: From 27531e1fb08e8ea2b20118a0199de69de40382a2 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:18:55 +0100 Subject: [PATCH 5/6] Count the viewer role's write grants, not the ones the web host uses Review caught both new sentences claiming config.custom_views is the viewer role's only write grant. It is not: DarlingManagedRoles.cs and provision-roles.sql also grant INSERT/UPDATE/DELETE on config.database_state_expected, for the Viewer's per-database state-override editor (#1986). The claim I wanted was about the WEB HOST's reachable surface, which really is custom views alone -- no route in DarlingWebEndpoints and no reference in wwwroot touches database_state_expected. Saying that as a fact about the ROLE overstated the least-privilege boundary in the one direction a reader would act on, which is the whole reason the previous commit exists. Worth stating rather than just narrowing, because the second grant is currently spent by nobody: GetDatabaseStateExpectationsAsync gates its writes on IsReadOnly, and IsReadOnly is the has_table_privilege probe on config_mute_rules -- which the viewer role fails by design. So a viewer-role seat holds the grant and cannot reach the editor that uses it, on either surface. Co-Authored-By: Claude Opus 5 --- Darling/README.md | 2 +- docs/uat-onboarding.md | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Darling/README.md b/Darling/README.md index 776c5c88dd..a97d95fcc6 100644 --- a/Darling/README.md +++ b/Darling/README.md @@ -1175,6 +1175,6 @@ When `listen` is a network address **and** a token is present **and** `allowFrom New-NetFirewallRule -DisplayName "Darling Web" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5153 -RemoteAddress 192.168.1.0/24 ``` -**What a web token can reach.** The web dashboard is **read-only over the collected store** — it connects as the least-privilege `viewer` role and hosts no live-server queries (no `analyze_server`, no plan re-execution). Its one write path is the Custom Views composer, which is that role's one write grant: INSERT/UPDATE/DELETE on `config.custom_views` and nothing else. So a token-holder can read everything collected and author saved views, and reach no monitored server — but that write is why an exposed dashboard authenticates loopback too, exactly as MCP does. +**What a web token can reach.** The web dashboard is **read-only over the collected store** — it connects as the least-privilege `viewer` role and hosts no live-server queries (no `analyze_server`, no plan re-execution). Its one write path is the Custom Views composer: INSERT/UPDATE/DELETE on `config.custom_views`, and the web host maps no other write route. (The `viewer` role holds one further narrow grant — INSERT/UPDATE/DELETE on `config.database_state_expected`, for the Viewer's per-database state-override editor ([#1986](https://github.com/erikdarlingdata/PerformanceMonitor/issues/1986)) — which no web route reaches, and which the WPF editor itself gates on the `has_table_privilege` read-only probe a `viewer`-role seat fails.) So a token-holder can read everything collected and author saved views, and reach no monitored server — but that write is why an exposed dashboard authenticates loopback too, exactly as MCP does. **Web has no TLS either — same reverse-proxy control as MCP.** The token/cookie travels cleartext on the segment, so the in-app CIDR bounds *who can route to* the port but does not protect the wire. On an untrusted segment, put a TLS-terminating reverse proxy in front of the web port. diff --git a/docs/uat-onboarding.md b/docs/uat-onboarding.md index aee80c8766..ef26f83cfa 100644 --- a/docs/uat-onboarding.md +++ b/docs/uat-onboarding.md @@ -492,9 +492,14 @@ Jobs, no configuration grids, no Recommendations cards. What it *can* write is one thing: **Custom Views** — the saved dashboards and notebooks in `config.custom_views`, created and edited through the built-in composer, plus `POST /api/compose/run` to -preview one. That is the `viewer` role's single narrow write grant, and it is available to **any authenticated +preview one. That is the only write the web host exposes at all, and it is available to **any authenticated seat**, not only a local one. It is also the reason the loopback token exemption went away (see 3.5). +(The `viewer` role itself carries one more narrow grant — `config.database_state_expected`, behind the WPF +viewer's per-database state-override editor — but no web route reaches it, and the WPF editor gates itself on +the same read-only probe from 2.3, which a `viewer`-role seat fails. So it is a grant nothing on either +surface currently spends.) + ### 3.5 Reach it from a browser on another machine Add a `network` block to `web` in `darling.json` (managed mode only), or let `--configure-network` write it: From 8b8cbd1185d79a4c466545ad252d33e025763970 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:20:30 +0100 Subject: [PATCH 6/6] Warning is not only freshness, and saying so teaches the wrong reflex Gotcha 11 said a screen of Warning statuses "is not an incident" because status is "collection freshness, not health". That is wrong, and wrong in the worst available direction for a monitoring tool: it tells a new user their first reflex on seeing amber should be to disregard it. ServerHealthBands.ClassifyBand returns Warning for three unrelated causes. A metric at warning severity -- CPU, blocking, deadlocks, memory, threads -- returns Warning straight out of the overallMetricSeverity switch, and that IS a real condition on the server. Awaiting-first-collection returns Warning before that switch is reached. Stale collection returns it after, through the hasCollectorErrors arm the viewer re-uses to carry staleness. The advice survives in narrowed form, because the original observation was not baseless: on a fresh install every server is awaiting first collection at once, and at fleet scale staleness is routine. So an opening screen of amber usually is not an incident. What it must not become is a rule for every screen after that one. It now names the three causes and points at the tooltip #2429 added, which is built from the same metric rows shown underneath the status word, so it cannot disagree with them. Co-Authored-By: Claude Opus 5 --- docs/uat-onboarding.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/uat-onboarding.md b/docs/uat-onboarding.md index ef26f83cfa..4985392198 100644 --- a/docs/uat-onboarding.md +++ b/docs/uat-onboarding.md @@ -738,9 +738,14 @@ Every one of these has cost somebody real time. tokenless; the moment a `web.network` block exposes it, the browser on the service host has to present the token too, because the Custom Views composer made the surface writable. ([3.5](#35-reach-it-from-a-browser-on-another-machine)) -11. **A screen of `Warning` server statuses is not an incident.** Status is *collection freshness*, not health — - the viewer never pings your servers. `Warning` means the newest collection is older than twice the fastest - collector's cadence, which at fleet scale is routine. +11. **`Warning` means three different things, and you have to look at the card to tell which.** + `ClassifyBand` returns `Warning` for any of: a metric at warning severity (CPU, blocking, deadlocks, + memory, threads — a real condition on the server), a server still awaiting its first collection, or a + **stale collection** — the viewer re-uses its collector-errors flag to carry staleness. Only the first is + an incident. On a fresh install the second is normal for every server at once, and at fleet scale the + third is routine, so an opening screen of amber usually is not one — but do not generalise that into + ignoring it. Hover the status word: since #2429 the tooltip names the reason, and it is built from the + same metric rows shown underneath so it cannot disagree with them. 12. **The Recommendations tab is empty for the first day.** Analysis runs every 30 minutes per server but only once the store holds 24 hours of history for that server. A fresh install has not earned findings yet.