Close the two firewall residues #2432 disclosed (#2436) - #2442
Conversation
Both were filed as "arguably deliberate, decide it explicitly." Reading the code decided them: each is a case where --configure-firewall contradicts a posture the rest of the product already holds, and one of them is destructive rather than merely untidy. ## A rule for a surface that is switched off darling.json's mcp.network says HOW an endpoint would be exposed; whether it runs at all is config_service.mcp_enabled, and the supervisor stops the server outright when that is false. The planner read only the network block, so the elevated verb opened an inbound allow rule on a port with nothing behind it -- the same shape #2414 was filed about, one field over. The defence for leaving it was that the rule is ready for the day the surface is enabled, since enabling it from the Viewer is a store write with no elevated step in it. That is a real convenience and it loses to three things. The product's posture everywhere else is that no listener means no rule: --disable-mcp sweeps the surface, and DarlingMcpHostService's stop path already documents an admin removing the rule with THIS verb. The two verbs actually disagreed -- --disable-mcp closed the port and the next --configure-firewall re-opened it, and every upgrade runs that verb, so a deliberately disabled endpoint got its port re-opened by an upgrade and the disable did not stay done. And the convenience is deferred rather than lost, to one elevated action the service already asks for by name when its start-up check finds the rule missing. The rejected alternative is written down beside the decision, because it is the argument the next reader will have again. The install-time half is the same rule and not merely symmetry: before the store exists, mcp.enabled IS the value config_service.mcp_enabled is seeded with, so a network block beside enabled = false describes an endpoint that will not start on the first run either. Opening its port was never "ready for later", it was ready for nothing. ## The upgrade that lands on the pre-change port The issue described this as the rule landing on the old port for one cycle. It is worse than that, and the sharper reading is what decided the fix over "keep warning". --configure-firewall sweeps the surface's rules for EVERY port before opening the current one, and install-darling.ps1 runs it with the service STOPPED -- which on a managed install means the store is down too, it being a child of the service. So on an upgrade of a box whose port was moved in the Viewer, the rule matching that wildcard is the WORKING one. The elevated verb an operator was told to run is what closes the live LAN surface. So the sweep is now conditional on the run being able to vouch for the port. Its entire justification is knowing which port is live; a run that fell back to darling.json's seed does not know, and there a rule on another port is as likely to be the one being served as it is to be stale. The rule for the file's port is still created -- that is the fresh-install case, where the file cannot be wrong -- and the enable command removes its own exact DisplayName first, so declining the sweep costs nothing in idempotence. It defers the cleanup to a run that can see the control plane. When the store DID answer, the sweep is authoritative again and nothing changes, or this would trade one residue for the one #2432 was filed to remove. The installer then makes that later run happen: an upgrade re-reconciles once the service is up and the store can be asked. Only an upgrade, and that is the point rather than an optimisation -- a fresh install's first start spends about two minutes on initdb and the first migration, so a second call there could not read the store either and would re-print the fallback disclosure about a port that is correct by definition. Skipping it by construction beats skipping it by hoping the timing works out. It is not a guarantee, and the comment says so: the store answers when it answers, the verb bounds its read to ten seconds, and if it still cannot the operator has exactly the remedy they had before. The window narrows; it does not close. ## Verification The suite targets net10.0-windows and cannot run on macOS, so the real test file was compiled into a net10.0 xUnit-shim harness and run against both branches. 68 passed / 0 failed here. Five of the new assertions are expressible against dev and ALL FIVE are red there -- the two control-plane-disabled cases, the file-seed-disabled case, the installer's post-start guard, and the source pin that the sweep step is gated at all. The three that pin the sweep permission itself cannot be compiled against dev, which is the stronger statement; with those removed dev runs 60 passed / 5 failed against the same file. Five existing fixtures gained mcp.enabled = true. That is the fixtures becoming faithful rather than the assertions weakening: they meant "this endpoint is serving the LAN", which now has to say both halves. All five stay green against dev with that line added, which is what shows it changed nothing about what they were testing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| ? DarlingHostBinding.DescribeFirewallPortAuthority(mcpToggle, "mcp", "MCP", config.Mcp.Port, storeUnavailableReason) | ||
| : null)); | ||
| : null, | ||
| SweepOtherPorts: !mcpOpen || mcpToggle.Origin == DarlingHostBinding.EndpointToggleOrigin.ControlPlane)); |
There was a problem hiding this comment.
Correctness: the new SweepOtherPorts guard only covers the Open half of the ambiguity it's meant to close — the Remove half can still delete a live rule.
mcpOpen = mcpExposed && mcpToggle.Enabled (line 2813) means the plan now flips to Remove whenever mcpToggle.Enabled is false — including when that false came from darling.json's file value under Origin == File (store unreadable this run), not from a confirmed control-plane read. But SweepOtherPorts here is !mcpOpen || Origin == ControlPlane, i.e. every Remove plan sweeps unconditionally, regardless of why it's a Remove.
Concretely: an operator enables MCP via --enable-mcp or the Viewer's Settings — by design (EndpointToggleOrigin/#2389) this is a store-only write; darling.json's mcp.enabled stays false forever after. On the next upgrade, install-darling.ps1 step 4c runs --configure-firewall with the service (and therefore the managed store) stopped, so TryReadEndpointTogglesAsync returns mcpStore = null → Origin = File. mcpToggle.Enabled falls back to the file's stale false, mcpOpen becomes false, the plan becomes Remove, and — because SweepOtherPorts is forced true for every Remove — the wildcard sweep deletes the rule for the port the endpoint is actually (per the control plane) serving on.
This is the same class of bug the PR is fixing (an elevated verb the operator runs closing a live LAN surface), just reached through the enable/disable flag instead of the port. It's currently masked in the common case by the new step 5a re-reconcile after Start-Service, but 5a is itself best-effort and bounded to a 10s store read (per TryReadEndpointTogglesAsync's own doc comment) — if the store is still coming up, the rule stays removed on an endpoint that IS running, until a manual re-run. Pre-PR, Action never depended on Enabled at all, so this specific failure mode didn't exist before this change.
The FirewallRulePlan.SweepOtherPorts doc comment (line ~277) explicitly scopes the guard to "exactly one combination: an Open plan whose port came from darling.json's seed" — but the same uncertainty (file vs. control-plane truth) applies just as much when it's the enabled flag, not just the port, that came from the unread file. Consider withholding the sweep whenever Origin == File regardless of mcpOpen/webOpen, not just on the Open side. (Same issue at line 2846 for the web dashboard twin.)
There was a problem hiding this comment.
Correct, and thank you — that is the same outage arriving through the other field, and I had talked myself into "a Remove needs no opinion about which port is live", which is true of the port and false of the flag.
Fixed in 055ddbe. The gate is now !mcpExposed || Origin == ControlPlane rather than !mcpOpen || .... Not "never sweep on a fallback", because that would stop the installer collecting rules left behind by an exposure that was removed from darling.json: whether a surface is exposed at all is mcp.network / web.network, which are file-only with no config_service equivalent by the #2389 design, so the control plane can switch an exposed surface off but can never switch an unexposed one on. A bind that resolves loopback-only is loopback-only whatever the store would have said, and its sweep needs nothing this run could not read. The sweep is withheld for exactly the surfaces the FILE exposes on a run that could not reach the control plane — where the port and the enable flag are both guesses, and either way of guessing wrong deletes a live rule.
Two tests added, one per direction: PlanFirewallRules_StoreUnreadable_DoesNotSweepAwayARuleTheControlPlaneMayBeServingOn (your exact scenario, MCP and web) and PlanFirewallRules_ALoopbackSurfaceAlwaysSweeps_BecauseNetworkExposureIsFileOnly (the half that must keep sweeping).
|
Reviewed. This is Darling-only service/installer infrastructure (firewall rule planning + install script), no T-SQL and no Lite counterpart — Lite has nothing analogous to the Postgres-store-backed firewall reconcile, so parity doesn't apply here. One correctness issue posted inline on Everything else — the |
The first cut let every Remove plan sweep unconditionally, on the reasoning that "no rule on any port" needs no opinion about which port is live. That is true of the port and false of the flag, and review caught the difference. Enabling MCP is a store-only write by design (#2389): --enable-mcp and the Viewer never write back to darling.json, so on a long-lived box mcp.enabled = false is not a statement about anything, it is the seed nobody edited. Read as "off" on a run that could not reach the control plane, it now produced a Remove - which this change had just made possible - and the wildcard deleted the rule for the port the endpoint is actually serving on. install-darling.ps1 runs this verb with the service stopped, so that is the upgrade path rather than a corner, and it is the same outage this PR exists to prevent arriving through the other field. The fix is not "never sweep on a fallback", which would stop the installer collecting rules left behind by an exposure that was turned off. Whether a surface is exposed at ALL is mcp.network / web.network, which are file-only and have no config_service equivalent precisely so that exposure requires touching the host - the control plane can switch an exposed surface off, never switch an unexposed one on. So a bind that resolves loopback-only is loopback-only whatever the store would have said, and its sweep needs no knowledge this run is missing. The sweep is withheld for exactly the surfaces the FILE exposes on a run that could not read the control plane, where both store-backed values are a guess and either way of guessing wrong deletes a live rule. Nothing is exposed by deferring, and the message says so: the store is unreadable because the service is stopped, so no Darling endpoint is listening on any port until a run that can read the control plane is possible again. Two tests, one per direction. 70 pass / 0 fail on this branch through the net10.0 xUnit-shim harness; the same file with the five sweep-permission tests removed runs 60 / 5 against dev. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| private static string DescribeDisabledSurface(DarlingHostBinding.EndpointToggle toggle, string section) | ||
| => toggle.Origin == DarlingHostBinding.EndpointToggleOrigin.ControlPlane | ||
| ? $"{section}.network exposes this endpoint, but the CONTROL PLANE has it off " | ||
| + $"(config.config_service.{section}_enabled = false), so the service does not start it and no rule " | ||
| + $"belongs on that port — turn it on with --enable-{section} or in the Viewer's Settings, then " | ||
| + "re-run --configure-firewall from an elevated prompt" | ||
| : $"{section}.network exposes this endpoint, but {section}.enabled = false, which is the value the " | ||
| + "store is SEEDED from, so the service will not start it and no rule belongs on that port — turn " | ||
| + $"it on with --enable-{section} (or set {section}.enabled before the first start)"; |
There was a problem hiding this comment.
DescribeDisabledSurface's File-origin branch assumes the file's enabled = false is a pre-first-start seed ("which is the value the store is SEEDED from ... or set {section}.enabled before the first start"). But Origin == File doesn't only mean "fresh install" — TryReadEndpointTogglesAsync (line ~3283) collapses every failure mode into the same null/File result: BYO postgres, a missing stored credential, a 10s timeout, or any other connection exception, all on a box that has already had its first start and whose store may already hold mcp_enabled = true.
Concretely: an established, previously-configured box where the store is just transiently unreachable (e.g. mid-restart, DPAPI credential hiccup) hits this exact branch. The plan becomes Remove with SweepOtherPorts = false, and the CLI loop (~line 3186) prints both messages back to back:
plan.Note(this function): "...so the service will not start it and no rule belongs on that port — turn it on with --enable-mcp (or set mcp.enabled before the first start)" — flatly asserts the endpoint is off and frames it as pre-first-start.- The sweep-declined message a few lines later (~3229): "...so that off may be stale and the rule may be the one the endpoint is being served on" — the opposite: uncertain, possibly currently serving.
These two lines contradict each other in the same run's output, and the first one actively misleads an operator on a long-lived box into thinking the surface is definitely off when the whole point of the SweepOtherPorts = false branch is that it might not be. DescribeFirewallPortAuthority right above already solves this correctly for the port case by taking storeUnavailableReason and phrasing it as "could NOT read the control plane" without asserting a pre-first-start seed; DescribeDisabledSurface doesn't take that parameter and can't make the same distinction.
There was a problem hiding this comment.
Right on both counts — Origin == File is every store-read failure, not a fresh install, and the two lines really did contradict each other in the same run with the wrong one first.
Fixed in 0b3fb8e. DescribeDisabledSurface now takes storeUnavailableReason and says the same three-state thing DescribeFirewallPortAuthority says about the port: which plane answered, why it could do no better, and both readings of the value it fell back on — correct on a box whose store has never been written, possibly stale on one that has run before, because --enable-mcp and the Viewer write only the control plane and never back to the file. The control-plane branch keeps its flat wording, because there it really is a fact, and PlanFirewallRules_ControlPlaneAnsweredOff_SaysSoWithoutAStalenessCaveat pins that the two do not collapse into one hedged message that under-states a certain answer.
|
Reviewed. This PR is scoped entirely to Darling's firewall reconcile logic ( The core logic — gating Left one inline finding: No other correctness, security, or performance issues found. |
The File-origin half of DescribeDisabledSurface was written as though a File origin meant "fresh install". It does not: TryReadEndpointTogglesAsync collapses BYO, a missing credential, a ten-second timeout and every other connection failure into the same answer, so that branch is also reached on a long-lived box whose store is merely unreachable this minute - one that may already hold mcp_enabled = true. So the same run printed two lines that contradicted each other. The note said the endpoint is off and framed the value as something to set before the first start; the sweep-declined line a few lines later said the off may be stale and the rule may be the one the endpoint is being served on. The second line is the one that is right - it is the whole reason the sweep is being withheld - and the first actively misled an operator into believing a surface was definitely off in exactly the case where the code had just concluded it might not be. DescribeDisabledSurface now takes storeUnavailableReason and says the same three-state thing DescribeFirewallPortAuthority already says about the port: which plane answered, why it could do no better, and BOTH readings of the value it fell back on - correct on a box whose store has never been written, possibly stale on one that has run before, because --enable-mcp and the Viewer write only the control plane and never back to the file. The control-plane branch keeps its flat wording, because there it really is a fact, and a test pins that the two do not collapse into one hedged message that under-states a certain answer. 71 pass / 0 fail through the net10.0 xUnit-shim harness. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed the diff (DarlingCliCommands.cs, install-darling.ps1, DarlingFirewallCheckTests.cs). Notes below; no blocking findings. Correctness
Lite/Darling parity: No counterpart needed. Lite has no Windows Firewall management surface (it's a standalone desktop app with no installed network-exposed service); grepped for Security: This is a net-positive change — it closes an inbound-allow rule that previously stayed open for a control-plane-disabled endpoint (dead port exposure), and closes the upgrade-time race where Performance: Negligible — one extra best-effort store read (already bounded to 10s, same as the existing #2414 path) on upgrade installs only. One pre-existing (not introduced by this PR) observation, not a blocker: in the |
"Every endpoint is loopback-only, so no firewall rule is needed" was true when the only way to reach that line was a bind that resolved loopback. This change added a second way: a surface fully configured for the LAN whose control plane has it switched off. The line then tells an operator their exposure config did not take, which is the opposite of what happened. It now says no endpoint wants an open port, and prints the plans' own notes - the same lines the elevated path already prints, which are where "the CONTROL PLANE has it off" or "allowFrom is not a valid CIDR" actually gets said. An operator who cannot elevate could not see either of those before, and they are the whole explanation for the summary above them. The remedy half stays out of scope and is filed instead: the not-elevated branch prints commands only for Open plans, so a Remove that wants its sweep - now including a deliberate --disable-mcp - still hands over nothing to run. That predates this change; what this change did was make it reachable more often. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| if (toOpen == 0) | ||
| { | ||
| /* #2436: "every endpoint is loopback-only" stopped being the only way to get here — a surface | ||
| the control plane has switched off is fully configured for the LAN and still wants no rule. | ||
| The plans' own notes say which it is, and they are the same lines the elevated path prints; | ||
| printing them here as well is what keeps an operator who cannot elevate from reading "no | ||
| rule is needed" as "your exposure config did not take". */ | ||
| output.WriteLine( | ||
| "Every endpoint is loopback-only, so no firewall rule is needed and none was changed. " + | ||
| "(This shell is not elevated, but there was nothing to do.)"); | ||
| "No endpoint wants an open port, so no rule was opened. (This shell is not elevated, but " + | ||
| "there was nothing to open.)"); | ||
| foreach (var plan in plans.Where(p => p.Note is not null)) | ||
| { | ||
| output.WriteLine($" {plan.Surface}: {plan.Note}."); | ||
| } | ||
|
|
||
| return 0; |
There was a problem hiding this comment.
This loop covers the toOpen == 0 case, but the sibling branch just below (the toOpen > 0 mixed case, error.WriteLine("This shell is not elevated...") + the foreach (var plan in plans.Where(p => p.Action == FirewallRuleAction.Open)) loop) only ever surfaces Open plans. A Remove plan's note — including the new DescribeDisabledSurface message this PR adds — is silently dropped there. An operator with one surface to open and another to close (e.g. MCP newly exposed, web just disabled in the Viewer) only ever learns about the first.
More generally: neither non-elevated branch tells the operator that removal also requires elevation. The Open case gets a concrete command to paste into an elevated shell; the Remove case gets nothing actionable in either branch — at best a note here saying "no rule was opened," which reads as "nothing to do" when actually a LAN-exposed rule for a now-disabled surface is still sitting open.
This is more than a pre-existing gap now: this PR's own rationale is that surfaces get disabled via the Viewer with no elevated step, so "toggle off in the Viewer, then run --configure-firewall from a non-elevated shell to check" is now a realistic sequence — and it silently leaves the stale open rule in place, which is a variant of the exact "disable didn't stay done" bug this PR closes elsewhere.
Suggest at minimum printing Remove plans' notes in the mixed-case branch too (matching the toOpen == 0 branch), and making explicit that a Remove also needs an elevated re-run.
There was a problem hiding this comment.
Fair — the previous commit fixed the wrong half. The mixed case is exactly where the two surfaces disagree and the operator most needs to be told which is which, and it was still dropping the disabled surface entirely.
The notes now print for every plan before either branch, so this path shows the same per-surface lines the elevated path prints as it works.
The remedy half is filed as #2445 rather than folded in: printing a sweep command for Remove plans and exiting non-zero would fire the installer's re-run banner on every non-elevated loopback install, and telling "a stale rule exists" from "nothing to do" needs a read-only Get-NetFirewallRule probe this branch currently does not make. The issue lays out that trade and the measurement that should decide it.
On the CHANGELOG note: checked, and it does not apply here. [3.5.0] is a shipped, dated section, and there is no [Unreleased] on dev — #2432 and #2428 both merged after the release and added no entry either. Adding to [3.5.0] would attribute this fix to a release that does not contain it.
|
Reviewed the change (Darling firewall reconciliation for #2436: the ENABLE flag now gates rule creation, and the sweep is withheld when the run can't tell whether a fallback port/toggle is stale). This is C#/PowerShell, not T-SQL, so the T-SQL style rules don't apply; Lite has no equivalent installer-managed Windows Firewall logic, so there's no Lite/Darling parity concern here. Overall: the core logic ( One real gap, left as an inline comment on Minor/non-blocking: |
The previous commit printed them only where every plan was a removal, which left the mixed case exactly as it was: an exposed surface got a command, its disabled sibling got nothing, and the note explaining why - the only place a surface says it is fully LAN-configured and switched OFF - was dropped silently. That is the wrong half to have fixed, since the mixed case is the one where the two surfaces disagree and the operator most needs to be told which is which. The notes now print for every plan, before either branch. They are the same lines the elevated path already prints as it works; the person who could not act on them yet was simply the one who never saw them. The remedy half is still out of scope and filed as #2445: this path prints commands only for Open plans, so a Remove that wants its sweep still hands over nothing to run, and closing that honestly needs a read-only firewall probe here - which this branch currently makes none of - or an admitted approximation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| foreach (var plan in plans.Where(p => p.Note is not null)) | ||
| { | ||
| output.WriteLine($"{plan.Surface}: {plan.Note}."); | ||
| } | ||
|
|
||
| if (toOpen == 0) | ||
| { | ||
| output.WriteLine( | ||
| "Every endpoint is loopback-only, so no firewall rule is needed and none was changed. " + | ||
| "(This shell is not elevated, but there was nothing to do.)"); | ||
| "No endpoint wants an open port, so no rule was opened. (This shell is not elevated, but " + | ||
| "there was nothing to open.)"); | ||
| return 0; |
There was a problem hiding this comment.
This still leaves a gap that's been raised in earlier review rounds and only half-fixed: the not-elevated branch now explains why via plan.Note, but it never surfaces a remediation command for a Remove plan — only Open plans get a command printed a few lines below (plans.Where(p => p.Action == FirewallRuleAction.Open)); BuildFirewallSweepCommand is never called anywhere in this not-elevated branch.
Concretely: a surface gets disabled from the Viewer (control-plane write, no elevated step), the plan becomes Remove with SweepOtherPorts = true (Origin is ControlPlane, so it's certain — not the withheld case). An operator then runs --configure-firewall from a non-elevated prompt to check status:
- If every other surface is also non-
Open(toOpen == 0), this branch prints the disabled-surface note, then "No endpoint wants an open port, so no rule was opened" and returns 0 — implying success/nothing-to-do, even though the stale LAN-facing allow rule from before the surface was disabled is still sitting open and needs an elevated sweep to remove. - If some other surface does need
Open(toOpen > 0, falls into thereturn 1branch a few lines further down), the printed elevated-command list only contains that surface's enable command — the disabled surface's sweep command is silently omitted from the remediation list, even though the exit code correctly signals "action needed."
Either way the operator is never given the actual command (DarlingManagedPostgres.BuildFirewallSweepCommand(wildcard), as used in the elevated branch further down) to remove the stranded rule — which is exactly the class of residue this PR exists to close. Since sweeping is idempotent/harmless whether or not a rule currently exists, it seems safe to also print a sweep command for every plan with Action == Remove && SweepOtherPorts, and to only report "nothing to do" when literally nothing — open or sweep — is pending.
|
Reviewed the current state of the diff (post b002dc8, after four prior automated review rounds already tightened Traced the core logic and it holds up:
One remaining gap, posted inline on Minor, non-blocking: |
…the opening This branch printed a runnable command for every Open plan and nothing at all for a Remove, so a run whose entire work was a removal said "no endpoint wants an open port", exited 0, and left a stale inbound allow rule sitting there with no command offered and no signal that anything was wrong. #2442 made that shape ordinary rather than exotic: a surface is now a Remove when the control plane has it switched OFF, which covers an admin who just ran --disable-mcp and re-ran --configure-firewall from a normal prompt. The issue named the trade and it is really one question, not three. Exit code and output noise both turn on the same unknown -- whether a rule is actually there -- and every answer that guesses at it is wrong in one direction. Always printing a sweep means three commands on every default loopback install. Gating on `Note is not null` quiets that, but it is a proxy for "a rule might exist" and it is wrong BOTH ways: it prints a sweep for a fail-closed surface that never had a rule, and stays silent about a plain-loopback surface holding a stale rule from an exposure someone turned off by hand -- which is exactly the state DarlingFirewallCheck already has a verdict name for. So the branch measures. Get-NetFirewallRule needs no elevation -- reads succeed under a restricted token where writes return PermissionDenied, which is the whole basis of the runtime check -- so this branch could always have looked and simply never did. DarlingFirewallCheck.BuildProbeCommand is already shaped to exit 0 whether or not a rule matches and to never throw, and it takes the surface wildcard the sweep would delete by, so probe and remedy share both builders and cannot come to disagree about scope. A narrower question would report "nothing to do" about rules the sweep would still collect; a wider one would offer a command covering rules this product does not own. With the measurement in hand all three behaviours are honest at once. A clean box prints nothing extra and exits 0, exactly as before. A box with a stale rule gets that rule's own sweep command and a non-zero exit. Cost is at most one bounded PowerShell round trip per Remove plan that is allowed to sweep, and install-darling.ps1 cannot reach this path at all -- it fails outright when not elevated -- so the re-run banner the issue worried about was never on it. An Open plan is deliberately unchanged. It already knows the verb's whole job, the enable command is idempotent, and a probe could at best say a rule with that name exists, not that its port, direction and RemoteAddress are the ones this config asks for. Spending a probe there buys nothing. The part that must not regress is #2442's withheld sweep, and it hands over NOTHING on any probe answer including "a rule is there". That surface is one darling.json exposes on a run that could not read the control plane, so the file's "switched off" may be years stale and the matching rule may be the one the endpoint is being served on. Printing that command would hand the operator, by copy and paste, the very outage the elevated path just declined to cause -- and they would have no way to see that it had been declined. The plan's note already says why; the remedy is a re-run with the store up. An unverifiable probe prints the command but does not fail the verb. A non-zero exit is a claim about the firewall and "the probe did not answer" is not one. Under-reporting is the safe direction here, because the running service re-probes the same rule on every start and WARNs a stale one by name, whereas a banner that fires without cause teaches operators to ignore the one that has cause. The elevated path's closing line loses "every endpoint is loopback-only" for the same reason #2442 changed its twin: since a surface can be a Remove because it is switched off, that sentence became false in the case this issue is about. Verification: the suite targets net10.0-windows and cannot run on macOS, so the real test file was compiled into a net10.0 xUnit-shim harness and run against both branches. 84 passed / 0 failed here. Against dev the eleven cases that pin the new policy cannot compile at all, which is the stronger statement; with them removed dev runs 72 passed / 1 failed, and the one red is the source pin that this branch offers a removal remedy. That pin needed its own fix first: anchored only on `if (!IsElevated())` it matched --configure-network's copy several hundred lines above and swallowed the whole file, so every assertion in it passed against dev. It now anchors from the verb's signature and asserts the slice's size, because the way a source pin fails is by quietly widening. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the opening This branch printed a runnable command for every Open plan and nothing at all for a Remove, so a run whose entire work was a removal said "no endpoint wants an open port", exited 0, and left a stale inbound allow rule sitting there with no command offered and no signal that anything was wrong. #2442 made that shape ordinary rather than exotic: a surface is now a Remove when the control plane has it switched OFF, which covers an admin who just ran --disable-mcp and re-ran --configure-firewall from a normal prompt. The issue named the trade and it is really one question, not three. Exit code and output noise both turn on the same unknown -- whether a rule is actually there -- and every answer that guesses at it is wrong in one direction. Always printing a sweep means three commands on every default loopback install. Gating on `Note is not null` quiets that, but it is a proxy for "a rule might exist" and it is wrong BOTH ways: it prints a sweep for a fail-closed surface that never had a rule, and stays silent about a plain-loopback surface holding a stale rule from an exposure someone turned off by hand -- which is exactly the state DarlingFirewallCheck already has a verdict name for. So the branch measures. Get-NetFirewallRule needs no elevation -- reads succeed under a restricted token where writes return PermissionDenied, which is the whole basis of the runtime check -- so this branch could always have looked and simply never did. DarlingFirewallCheck.BuildProbeCommand is already shaped to exit 0 whether or not a rule matches and to never throw, and it takes the surface wildcard the sweep would delete by, so probe and remedy share both builders and cannot come to disagree about scope. A narrower question would report "nothing to do" about rules the sweep would still collect; a wider one would offer a command covering rules this product does not own. With the measurement in hand all three behaviours are honest at once. A clean box prints nothing extra and exits 0, exactly as before. A box with a stale rule gets that rule's own sweep command and a non-zero exit. Cost is at most one bounded PowerShell round trip per Remove plan that is allowed to sweep, and install-darling.ps1 cannot reach this path at all -- it fails outright when not elevated -- so the re-run banner the issue worried about was never on it. An Open plan is deliberately unchanged. It already knows the verb's whole job, the enable command is idempotent, and a probe could at best say a rule with that name exists, not that its port, direction and RemoteAddress are the ones this config asks for. Spending a probe there buys nothing. The part that must not regress is #2442's withheld sweep, and it hands over NOTHING on any probe answer including "a rule is there". That surface is one darling.json exposes on a run that could not read the control plane, so the file's "switched off" may be years stale and the matching rule may be the one the endpoint is being served on. Printing that command would hand the operator, by copy and paste, the very outage the elevated path just declined to cause -- and they would have no way to see that it had been declined. The plan's note already says why; the remedy is a re-run with the store up. An unverifiable probe prints the command but does not fail the verb. A non-zero exit is a claim about the firewall and "the probe did not answer" is not one. Under-reporting is the safe direction here, because the running service re-probes the same rule on every start and WARNs a stale one by name, whereas a banner that fires without cause teaches operators to ignore the one that has cause. The elevated path's closing line loses "every endpoint is loopback-only" for the same reason #2442 changed its twin: since a surface can be a Remove because it is switched off, that sentence became false in the case this issue is about. Verification: the suite targets net10.0-windows and cannot run on macOS, so the real test file was compiled into a net10.0 xUnit-shim harness and run against both branches. 84 passed / 0 failed here. Against dev the eleven cases that pin the new policy cannot compile at all, which is the stronger statement; with them removed dev runs 72 passed / 1 failed, and the one red is the source pin that this branch offers a removal remedy. That pin needed its own fix first: anchored only on `if (!IsElevated())` it matched --configure-network's copy several hundred lines above and swallowed the whole file, so every assertion in it passed against dev. It now anchors from the verb's signature and asserts the slice's size, because the way a source pin fails is by quietly widening. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #2436.
Both residues were filed as "arguably deliberate, decide it explicitly." Reading the code decided them: each is a case where
--configure-firewallcontradicts a posture the rest of the product already holds, and one of them turns out to be destructive rather than merely untidy.1. A rule for a surface that is switched off — closed
mcp.networksays HOW an endpoint would be exposed; whether it runs at all isconfig_service.mcp_enabled, and the supervisor stops the server outright when that is false.PlanFirewallRulesread only the network block, so the elevated verb opened an inbound allow rule on a port with nothing behind it — the same shape #2414 was filed about, one field over.The defence for keeping it — the rule is ready for the day the surface is enabled, and enabling it from the Viewer is a store write with no elevated step in it — is real, and loses to three things:
--disable-mcpsweeps the surface, andDarlingMcpHostService's stop path already documents an admin removing the rule with this very verb.--disable-mcpclosed the port and the next--configure-firewallre-opened it — andinstall-darling.ps1runs that verb on every upgrade, so a deliberately disabled endpoint had its port re-opened by an upgrade and the disable did not stay done.The rejected alternative is written down beside the decision (
DescribeDisabledSurface), because it is the argument the next reader will have again.The install-time half is the same rule and not just symmetry: before the store exists,
mcp.enabledis the valueconfig_service.mcp_enabledis seeded with, so a network block besideenabled = falsedescribes an endpoint that will not start on the first run either. Opening its port was never "ready for later", it was ready for nothing.2. The upgrade that lands on the pre-change port — closed, and it was worse than the issue said
The issue described the rule landing on the old port "for one cycle".
--configure-firewallsweeps a surface's rules for every port before opening the current one, andinstall-darling.ps1runs it with the service stopped — which on a managed install means the store is down too, being a child of the service. So on an upgrade of a box whose port was moved in the Viewer, the rule matching that wildcard is the working one. The elevated verb the operator was told to run is what closes the live LAN surface. That is what tipped this away from "keep warning".Two changes, and they need each other:
The sweep is now conditional on the run being able to vouch for what the surface is doing. Its whole justification is knowing that; a run that fell back to darling.json's seed does not. The port may have moved, so another port's rule may be the live one — and
mcp_enabledmay have been turned on with--enable-mcpor in the Viewer, which never write back to darling.json, so the file'senabled = falsemay be years stale and the surface may be serving right now. The rule for the file's port is still created (the fresh-install case, where the file cannot be wrong), and the enable command removes its own exact DisplayName first, so declining the sweep costs nothing in idempotence — it defers the cleanup to a run that can see the control plane. When the store did answer the sweep is authoritative again and nothing changes, or this would trade one residue for the one #2432 was filed to remove.The withholding is scoped precisely, and the scoping is the interesting part (it was the review finding on the first cut — #2442 (comment)). It is not "never sweep on a fallback", which would stop the installer collecting rules left behind by an exposure that was removed from darling.json. Whether a surface is exposed at all is
mcp.network/web.network, which are file-only with noconfig_serviceequivalent by the #2389 design — the control plane can switch an exposed surface off, never switch an unexposed one on. So a bind that resolves loopback-only is loopback-only whatever the store would have said, and its sweep needs nothing the run is missing. The sweep is withheld for exactly the surfaces the file exposes on a run that could not read the control plane.The installer makes that later run happen, re-reconciling after
Start-Serviceon an upgrade. Only on an upgrade, and that is the point rather than an optimisation: a fresh install's first start spends ~2 minutes on initdb and the first migration, so a second call there could not read the store either and would re-print the fallback disclosure about a port that is correct by definition. Skipping it by construction beats skipping it by hoping the timing works out.It is not a guarantee and the comment says so — the store answers when it answers, the verb bounds its read to ten seconds, and if it still cannot the operator has exactly the remedy they had before. The window narrows; it does not close.
Verification
The suite targets
net10.0-windowsand cannot run on macOS, so the real test file was compiled into anet10.0xUnit-shim harness and run against both branches.FirewallRulePlanhas noSweepOtherPortsthere), which is the stronger statement. With those removed, dev runs 60 passed / 5 failed against the same file.Five existing fixtures gained
config.Mcp.Enabled = true. That is the fixtures becoming faithful rather than the assertions weakening — they meant "this endpoint is serving the LAN", which now has to say both halves. All five stay green against dev with that line added, which is what shows it changed nothing about what they were testing.Deferred
--configure-firewall's not-elevated branch prints a runnable command only forOpenplans, so aRemovethat wants its sweep hands the operator nothing to run — and when every plan is a Remove it returns 0 saying nothing needs opening, even if a stale rule is still sitting open. That predates this PR; what this PR did was make it reachable more often, since a surface can now beRemovebecause it is disabled rather than only because it is loopback-only. The explanatory notes are now printed on that path so the operator at least learns why, but the remedy half is filed as #2445 rather than folded in: printing sweep commands and exiting non-zero would fire the installer's re-run banner on every non-elevated loopback install, and telling "a stale rule exists" from "nothing to do" needs a read-onlyGet-NetFirewallRuleprobe this branch currently does not make. The issue carries that trade and the measurement that should decide it.No CHANGELOG entry, deliberately:
[3.5.0]is a shipped, dated section and there is no[Unreleased]on dev — #2432 and #2428 both merged after the release and added none either, so an entry here would attribute the fix to a release that does not contain it.🤖 Generated with Claude Code