Skip to content

The firewall verbs name the rule from darling.json's port while the endpoint binds the store's #2414

Closed

Description

@erikdarlingdata

Found while working #2389, which fixed the diagnostic for the same split. This one is a functional consequence and deserves its own lane.

mcp.port / web.port are control-plane-authoritative once the store is seeded — the supervisor resolves config_service.mcp_port and binds Kestrel there. But every firewall surface derives the rule name and the port from darling.json:

  • DarlingCliCommands.ReconcileEndpointFirewallAsync (the firewall half of --enable-mcp / --disable-mcp / --enable-web / --disable-web) — McpFirewallRuleName(config.Mcp.Port), config.Mcp.Port
  • --configure-firewall's plan builder — same two, for MCP and web alike

The runtime side does not. DarlingMcpHostService calls CheckMcpFirewallAsync(effectivePort, …), where effectivePort is the store's port.

The failure

Toggle the MCP port in the Viewer's Settings (5152 → 5199) on a box with an mcp.network block:

  • the endpoint rebinds to 5199 within one poll interval, correctly, no restart
  • the service then looks for PerformanceMonitor Darling MCP (port 5199) and reports it missing
  • an admin runs --configure-firewall as instructed, and it creates/keeps PerformanceMonitor Darling MCP (port 5152) for port 5152 — the wrong rule, from the stale file value, and it will keep creating that one however many times it is run
  • the exposed endpoint has no firewall path; LAN clients are blocked, loopback still works, so it reads as a client or CIDR problem

The stale rule for 5152 also stays open on a port nothing is listening on, which is the opposite of what the scoping is for.

Same shape for the web dashboard.

Why it survived

Nothing agrees or disagrees until someone changes the port in the store, which is rare — and when it happens the symptom (LAN clients can't connect, loopback can) points at the CIDR or the token, not at a rule name. #2389 now warns when the two ports disagree, so the precondition is at least visible, but the elevated verb still writes the wrong rule.

Options

  1. Give the firewall verbs the store's port. ToggleEndpointAsync already builds an owner connection from the DPAPI credential a few lines earlier, so for --enable-mcp/--disable-mcp the read is nearly free — resolve the effective port the same way the supervisor does and name the rule for it. --configure-firewall would need the same connection added, and a defined answer for an unreachable store (fall back to the file value and say so, rather than silently).
  2. Reconcile by prefix rather than by exact name — enumerate PerformanceMonitor Darling MCP (port *) and remove the ones that do not match the effective port. Fixes the stale-rule half regardless of which port the verb was told about.
  3. Refuse and explain when the file port and the store port disagree, pointing at the store value — cheapest, and consistent with the fail-closed posture elsewhere, but it leaves the operator to run something else.

What should decide

Whether --configure-firewall is allowed to require a reachable store. It runs elevated at install time, possibly before the store exists, so option 1 alone may not be available to it — which argues for 1 + 2 together (authoritative port when the store answers, prefix cleanup either way), or 3 as the floor.

Not a security hole: it fails closed (the port is unreachable rather than over-exposed), and the token + in-app CIDR are the boundary, not the firewall. It is a correctness and wasted-afternoon problem.

Activity

  1. added a commit that references this issue on Aug 21, 2026
  2. erikdarlingdata commented on Aug 21, 2026

    @erikdarlingdata
    OwnerAuthor

    Fixed on dev by #2432. Both firewall surfaces now resolve their port through DarlingHostBinding.ResolveEndpointToggle — the same call both supervisors bind on — rather than a second copy of published?.Port ?? filePort. No new resolution path, which was the point: a second path is how these drifted apart.

    The store-unreachable case got two different answers, and the reasoning is the good part.

    The four toggle verbs effectively refuse: they take the port back from a RETURNING mcp_port / web_port clause on the config_service UPDATE they already perform — free, atomic with the toggle, under the same row lock, and doubling as the unseeded check. A store that cannot be written has already failed the verb with exit 1 before any firewall command is built, so those verbs structurally cannot open a port on a guessed number.

    --configure-firewall falls back, loudly, because it cannot require a store: install-darling.ps1 calls it elevated at line 521, before Start-Service at line 534, and it is correct there — config_service's row is seeded FROM darling.json's ports, so at that moment the file is the control plane's future answer. Requiring a store would fail every fresh networked install to close a window in which the file cannot be wrong. So it reads best-effort with a 10 s bound and prints the port it used, the specific reason, the one state that would make that port wrong, and the command to re-run.

    Stale-rule cleanup went in on the half that lacked it. --configure-firewall has swept the per-surface wildcard since #1771; the toggle verbs reconciled one exact DisplayName, which by construction cannot reach a rule left on a port they no longer know about — and those are exactly the verbs an operator reaches for after moving a port. They sweep the same wildcard now, through a helper that returns an unrecognized name unchanged rather than inventing a pattern, so the widening cannot touch a rule this product did not create. --disable-mcp now closes every MCP port rather than only the one it resolved.

    web had the same defect on the same two lines and is fixed in the same shape. A third surface turned up: --configure-network's PrintNextSteps printed a paste-ready New-NetFirewallRule built from the file port; that wizard holds no store connection and honestly cannot do better, so it now names which port the printed rule is for and points at --configure-firewall.

    Fifteen tests, four of them expressible against dev and all four red there — the two source drift guards and the two RETURNING shape pins. The other eleven cannot compile against dev at all, which is its own kind of evidence.

    Two residues split out as #2435.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions