Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Darling MCP: bulk add/remove monitored servers — an MCP client can now stand up FLEET monitoring conversationally** ([#1609]) - the Darling MCP server exposed read tools plus the [#1600] Custom Views and [#1608] alert-tuning writes, but no way for an MCP client (or Claude, over MCP) to add or remove the MONITORED SERVERS themselves - onboarding was WPF-Viewer-only (the Add / Manage Servers dialogs). Two new tools close that gap, the direct sibling of the alert-tuning slice: `add_servers` (BULK) takes a JSON ARRAY of server objects and, IN ORDER (sequential, mirroring [#1549]'s bulk-probe, to avoid a probe storm), validates each, connection-tests it IN the service, and saves the new+reachable ones - so "monitor these twenty servers with this login" stands up fleet monitoring in one call; `remove_server` removes one by name. **No divergent second implementation:** the probe is `DarlingServerConnector.ProbeAsync` run IN-PROCESS (the MCP host lives inside the service, which holds the network path + credentials - unlike the Viewer's dialogs, which enqueue a `test_connect` command for the service to run), the case-folded dedupe gate is the shared `ServerIdHelper.BuildStorageName` identity in a `HashSet<string>(OrdinalIgnoreCase)` exactly as the [#1549] bulk dialog uses, the SQL password is DPAPI-encrypted through the SAME `DarlingSecrets.Protect` the service decrypts with at collection time (so it round-trips), and the INSERT mirrors `StoreConfigProvider.SeedMonitoredServersAsync`'s exact column set + `server_id` identity (so a tool-written row JOINs the collected data and the service's reconcile matches it, picking it up within one sweep - no restart). Per server, `add_servers` returns `status` `added` / `duplicate` (a case-variant or exact dup of an existing or earlier-in-batch server, skipped WITHOUT a probe) / `connection_failed` (recorded, the batch CONTINUES) / `invalid` (a bad field, or Entra/MFA/Service-Principal/Managed-Identity auth - interactive MFA is nonsensical headless, the same belt [#1549] applies), and the whole call returns `{added, skipped, failed, results:[...]}`; per Erik, the TLS options `encrypt_mode` (Optional/Mandatory/Strict) and `trust_server_certificate` are EXPOSED so a headless caller sets the connection posture explicitly. **Security (deliberate, scoped):** the tools connect as the least-privilege `mcp` role, now granted - via role PROVISIONING, not a migration - INSERT/UPDATE/DELETE on `config.config_monitored_servers` (mirroring how [#1600]/[#1608] granted their single tables), a single non-secret-KEY table: the `encrypted_password` column stays in the fail-closed secret carve, so `mcp` can WRITE a credential blob (onboarding) but can never READ one back, and it still cannot reach the `config_command` service-credential pivot or a schema-wide config write. **The #1608 beacon grant already covers this:** a `config_monitored_servers` write fires the existing `trg_bump_monitored_servers → config_bump_version` trigger (SECURITY INVOKER, UPDATEs `config_service.config_version` AS `mcp`), and [#1608] already granted `mcp` UPDATE on the two `config_service` beacon columns - verified, no new `config_service` grant is added. **Credential on the wire:** the SQL password travels to the MCP endpoint inside `add_servers`' request JSON (DPAPI-encrypted at rest, never returned by any read tool), which is one more reason a LAN deployment should front the endpoint with the documented TLS reverse proxy - the README's MCP blast-radius section now states this and recommends Windows/integrated auth for onboarded servers where possible. The MCP server instructions (now eighty-seven tools), the cross-app tool-inventory ratchet (Lite is a single-instance app with no central monitored-server store, so these are Darling-only), the `/api/read` write-exclusion set, and the Darling README are updated. Verified: the Darling service + `Darling.Tests` build clean (0 errors, no new warnings in the changed source), full `Darling.Tests` **2847 passed / 0 failed / 146 gated-live skipped** (with `DARLING_TEST_PG` cleared), and `Lite.Tests` `CrossAppMcpToolInventoryPinTests` **2 passed** - the ungated pins cover the two-tool surface, the Gemini-clean schema + required-params, validate-before-write (a malformed payload or a bad/MFA entry returns `invalid` WITHOUT probing or opening a connection), and the pure case-folded dedupe partition; one gated-live test (`DARLING_TEST_PG`, own-scoped + cleaned up, the SQL probe stubbed to success - no live SQL Server touched in CI) proves the store INSERT (the SQL secret is DPAPI-encrypted at rest and round-trips, the Windows-auth server is secret-free), the duplicate skip, the `config_version` self-bump, and `remove_server` (removed, then not_found). The REAL end-to-end probe is what the human dogfoods (remove sql2016, re-add via MCP).

- **Darling MCP: alert-tuning write tools — an MCP client can now tune thresholds and manage mute rules conversationally** ([#1608]) - the Darling MCP server exposed alert READS (`get_alert_history` / `get_alert_settings` / `get_mute_rules`) but no way for an MCP client (or Claude, over MCP) to CHANGE the alerting - thresholds and mute rules could only be edited in the WPF Viewer's Settings window. Three new tools close that gap, the direct sibling of the [#1600] Custom Views MCP write tools: `update_alert_settings` (a PARTIAL update of the single global alert-settings row - the agent reads via `get_alert_settings`, changes fields, and sends only those back in the SAME nested shape, e.g. `{"cpu":{"threshold_percent":90},"cooldown_minutes":10}`), and `create_mute_rule` / `delete_mute_rule` (add/remove the mute rules the delivery paths honor). **No divergent second implementation:** `update_alert_settings` validates EVERY provided field against the SAME ranges/enums the Viewer's Settings window enforces (`SettingsWindow.BuildAlertRowFromControls` - thresholds in range, `cpu.mode` `sql`/`total`, `delivery.mode` `Summary`/`PerEvent`, counts within bounds) BEFORE any write - an out-of-range value or an unknown field (top-level or nested) returns `{status:"invalid"}` and writes nothing - then applies ONLY the provided columns via a targeted parameterized `UPDATE ... WHERE id = 1` and re-reads the merged state; `create_mute_rule` / `delete_mute_rule` reuse the SAME `PgMuteRuleStore` `get_mute_rules` reads through, with the same GUID id-generation the Viewer's mute-create path uses. SMTP/webhook delivery credentials are out of scope (the `mcp` role cannot read or write the secret columns). A `config_alert_settings` write self-bumps `config_version` via the existing config-table trigger, so the running service HOT-RELOADS the change within one collection sweep (the tool never writes `config_version` itself). **Security (deliberate, scoped):** the tools connect as the least-privilege `mcp` role, now granted - via role PROVISIONING, not a migration - INSERT/UPDATE/DELETE on `config.config_mute_rules` and UPDATE on the singleton `config.config_alert_settings` (mirroring how [#1600] granted `config.custom_views`), so a token-holder can tune alerting but still cannot reach the `config_command` service-credential pivot or the carved secret columns. **One non-obvious grant, called out for review:** the `config_alert_settings` bump trigger (`config_bump_version`) is SECURITY INVOKER and UPDATEs `config_service` AS the writing role, so the `mcp` role ALSO needs a COLUMN-level UPDATE on just the two `config_service` beacon columns (`config_version`, `updated_at`) or every `update_alert_settings` write would fail 42501 in production - and the superuser-run gated-live tests would never catch it. The column grant lets `mcp` bump the reload beacon but NOT flip `paused` / `capture_plans` / `mcp_enabled` / `mcp_port`; the live security test now proves this end-to-end as the real `mcp` role (a `config_alert_settings` UPDATE succeeds and fires the beacon; a `paused` UPDATE still 42501s). The MCP server instructions, the cross-app tool-inventory ratchet (Lite has no central alert store, so these are Darling-only), and the Darling README's MCP blast-radius section are updated. Verified: the Darling service + `Darling.Tests` build clean (0 errors, 0 warnings in the changed source), full `Darling.Tests` **2821 passed / 0 failed / 145 gated-live skipped** (with `DARLING_TEST_PG` cleared), and `Lite.Tests` `CrossAppMcpToolInventoryPinTests` **2 passed** - the ungated pins cover the six-tool surface, the Gemini-clean schema + required-params, and validate-before-write (a bad/unknown partial update returns `invalid` WITHOUT opening a connection); one gated-live test (`DARLING_TEST_PG`, own-scoped + restored) proves `update_alert_settings` flips a threshold AND self-bumps `config_version`, and `create_mute_rule`→`get_mute_rules`→`delete_mute_rule` round-trips, and it skips in the normal unit run like the others.

- **Darling: headless `--enable-mcp` / `--disable-mcp` / `--enable-web` / `--disable-web` CLI verbs — bring an endpoint up (store + firewall) without the Viewer** ([#1601]) - a headless Darling box had no supported way to (a) turn the MCP or web-dashboard endpoint on/off or (b) open its firewall. Two structural reasons: `mcp.enabled`/`web.enabled` in `darling.json` are only a FIRST-RUN seed - after the first run the store (`config.config_service.mcp_enabled`/`web_enabled`) is authoritative and is normally toggled only by the WPF Viewer's Settings, which a headless deployment does not have; and the service runs as a virtual service account (`NT SERVICE\PerformanceMonitor Darling`) that CANNOT modify Windows Firewall, so its best-effort self-reconcile silently fails. Each verb closes both gaps in one elevated action. **(store)** a TARGETED `UPDATE config.config_service SET <flag> = ..., updated_by = 'cli' WHERE id = 1` flips ONLY that endpoint's flag; the existing BEFORE-UPDATE self-bump trigger increments `config_version`, so the worker HOT-RELOADS within one collection sweep - no restart - and the write deliberately never touches `config_version` itself, `paused`, or the other endpoint's flag (0 rows affected ⇒ the store isn't seeded yet, reported as such; the owner credential missing ⇒ the service has never initialized the store, reported as such). **(firewall)** only when the endpoint's `darling.json` network block opts into LAN exposure (a non-loopback `listen`, decided via the shared `DarlingNetwork.IsExposedListenAddress`): run ELEVATED, it opens/removes the SAME scoped, idempotent-by-DisplayName rule the host self-reconciles (the two `McpFirewallRuleName`/`WebFirewallRuleName` builders are now `internal` so the CLI and host act on the EXACT same rule, through the shared `BuildFirewallEnableCommand`/`BuildFirewallDisableCommand` builders); run NON-elevated, the store toggle still succeeds and the exact elevated command is printed as a HANDOFF (never a failure); a loopback-only endpoint gets a note pointing at `--configure-network` and takes no firewall action. A firewall failure is non-fatal. Managed-mode only (BYO governs its own `config_service` + exposure) and Windows-only (DPAPI credential decrypt + `WindowsPrincipal` + firewall), the same guard shape as `--print-viewer-connection`; wired into `IsKnownVerb`, `UsageText`, and the `Program` dispatch (allow-list and dispatch kept in sync per the existing anti-drift comments). The output states the store change, that the running service applies it live within one sweep, the firewall outcome/handoff, and a reminder that `darling.json`'s `enabled` is only the seed - the store is the live switch. No store schema change. Verified: `Darling.Tests` **2789 passed / 0 failed / 144 gated-live skipped** (with `DARLING_TEST_PG` cleared) and 0 new build warnings - the pure tests pin the four store-write SQL strings (right flag, `updated_by='cli'`, `WHERE id=1`, never `config_version`/`paused`/the other endpoint's flag), the verb recognition + classify wiring, the pure firewall-step classifier (exposed × elevated), and the shared rule names; one gated-live test (`DARLING_TEST_PG`, transaction-rolled-back) proves enable then disable flip the flag AND self-bump `config_version` against a throwaway Postgres, and it skips in the normal unit run exactly like the other `*_AgainstDevPostgres` tests (CI's `darling-pg` job runs it live).
Expand Down Expand Up @@ -490,6 +492,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#1598]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1598
[#1599]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1599
[#1608]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1608
[#1609]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1609
[#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601
[#1604]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1604
[#1602]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1602
Expand Down
23 changes: 23 additions & 0 deletions Darling/Darling.Tests/DarlingManagedRolesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,29 @@ public void BuildProvisioningSql_McpRole_GrantsAlertTuningWrites_NarrowlyWithBea
}
}

[Fact]
public void BuildProvisioningSql_McpRole_GrantsMonitoredServersWrite_Narrowly()
{
var sql = DarlingManagedRoles.BuildProvisioningSql("AdminPassword01", "ViewerPassword02", "McpPassword03");

/* The MCP server-onboarding write tools (add_servers / remove_server): full CRUD on the single
config_monitored_servers table — an EXPLICIT single-table statement, its own 'TO mcp' line. */
Assert.Contains("GRANT INSERT, UPDATE, DELETE ON config.config_monitored_servers TO mcp;", sql, StringComparison.Ordinal);

/* Still NARROW: no schema-wide config write for mcp, and NO ALTER DEFAULT PRIVILEGES names mcp (either
would broaden it to all of config). No NEW config_service grant — section 8's beacon column-grant
already covers the monitored-servers bump trigger. */
Assert.DoesNotContain("INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA config TO mcp", sql, StringComparison.Ordinal);
foreach (var adpLine in sql.Split('\n').Where(l => l.Contains("ALTER DEFAULT PRIVILEGES", StringComparison.Ordinal)))
{
Assert.DoesNotContain("mcp", adpLine, StringComparison.Ordinal);
}

/* The credential column stays SELECT-carved from mcp (section 6) — mcp WRITEs a password blob but never
READs one back — so config_monitored_servers still appears in the mcp REVOKE/GRANT-column carve. */
Assert.Contains("REVOKE SELECT ON config.config_monitored_servers FROM mcp;", sql, StringComparison.Ordinal);
}

[Fact]
public void BuildProvisioningSql_McpRole_CarvesSecretColumns_LikeViewer()
{
Expand Down
Loading
Loading