Skip to content

Close the two firewall residues #2432 disclosed (#2436) - #2442

Merged
erikdarlingdata merged 5 commits into
devfrom
fix/2436-firewall-residues
Aug 21, 2026
Merged

erikdarlingdata merged 5 commits into
devfrom
fix/2436-firewall-residues

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Closes #2436.

Both residues 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 turns out to be destructive rather than merely untidy.

1. A rule for a surface that is switched off — closed

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. PlanFirewallRules 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 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:

  • 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 very verb.
  • The two verbs disagreed. --disable-mcp closed the port and the next --configure-firewall re-opened it — and install-darling.ps1 runs 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 convenience is deferred, not lost: the service's own start-up check reports the missing rule and prints the command.

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.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.

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-firewall sweeps a 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, 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_enabled may have been turned on with --enable-mcp or in the Viewer, which never write back to darling.json, so the file's enabled = false may 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 no config_service equivalent 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-Service on 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-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.

  • 70 passed / 0 failed on this branch.
  • 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 five that pin the sweep permission itself cannot be compiled against dev (FirewallRulePlan has no SweepOtherPorts there), 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 for Open plans, so a Remove that 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 be Remove because 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-only Get-NetFirewallRule probe 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

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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = nullOrigin = 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.)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

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 DarlingCliCommands.cs (line 2826, twin at 2846): the new SweepOtherPorts gate only withholds the wildcard sweep for Open plans whose port fell back to the file's seed. It does not withhold the sweep for Remove plans that resulted from the same fallback (Origin == File) — and since mcpOpen/webOpen now also factor in the Enabled toggle, a Remove can be produced by a stale, unreadable-store fallback just as easily as by a genuine "surface is off" state. Concretely: MCP/web enabled purely via --enable-mcp/the Viewer (store-only, by design) + an upgrade where install-darling.ps1 runs --configure-firewall with the service (and store) stopped → the plan falls back to the file's stale enabled = false, resolves to Remove, and unconditionally sweeps away the rule for the port the endpoint is actually serving on. The new step 5a re-reconcile after Start-Service usually self-heals this, but it's a best-effort, 10s-bounded store read, so it isn't guaranteed to. This looks like the same class of bug the PR is closing, just reached via the enable flag rather than the port — happy to be told I'm missing a guard elsewhere.

Everything else — the EndpointToggleOrigin split, the install script's $isUpgrade capture/placement, the new test coverage — looks sound and well-reasoned. No security issues (no new PowerShell string interpolation of untrusted input; Cidr still flows through the existing CanonicalCidrOrNull/ClassifyAllowFrom gate before reaching a command builder) and no missing-index-DMV suggestions to worry about here since this touches no T-SQL.

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>
Comment on lines +2888 to +2896
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)";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.
  2. 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewed. This PR is scoped entirely to Darling's firewall reconcile logic (DarlingCliCommands.PlanFirewallRules, its tests, and install-darling.ps1) — no T-SQL, no Lite-side files touched, and firewall management has no Lite counterpart, so there's no parity drift here.

The core logic — gating Open on the effective enable toggle, and withholding SweepOtherPorts only when a surface is both file-exposed and Origin == File — is internally consistent and well covered by the new tests, including the subtle "control plane already answered, sweep is authoritative again" and "loopback surfaces always sweep because exposure is file-only" cases.

Left one inline finding: DescribeDisabledSurface's File-origin message text assumes the untouched value is a pre-first-start seed, but Origin == File also covers a previously-configured box where the store is merely unreachable right now — and in that case the CLI prints that message immediately followed by the sweep-declined message, which says the opposite (the surface may still be serving). Worth aligning the wording the way DescribeFirewallPortAuthority already does for the port case.

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>
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewed the diff (DarlingCliCommands.cs, install-darling.ps1, DarlingFirewallCheckTests.cs). Notes below; no blocking findings.

Correctness

  • Traced mcpOpen = mcpExposed && mcpToggle.Enabled end to end: mcpExposed comes purely from ResolveMcpBind (listen/allowFrom/token/managed/container — confirmed it never reads mcp.Enabled), so gating on the toggle's Enabled is genuinely new information, not a double-count. That's the core Firewall residues: an upgrade opens the pre-change port for one cycle, and a disabled surface still gets a rule #2436 fix and it's correctly wired for both MCP and web.
  • SweepOtherPorts: !mcpExposed || Origin == ControlPlane (and the web twin) checked against all four quadrants (exposed×origin, open×origin) — matches the accompanying tests in each case, including the two review-driven tightenings in commits 2 and 3 (withholding the sweep on the enable flag being a File-origin guess, not just the port; and softening DescribeDisabledSurface's File-branch wording from a flat fact to a "may be stale" reading). Good catch on both, and the fix is correct.
  • Verified the Remove+SweepOtherPorts=false combination (disabled surface, unreadable store) is a deliberate true no-op — no sweep, no open, existing rule (if any) untouched — which matches the printed "leaving alone" message and the design rationale in the comments.
  • install-darling.ps1: $isUpgrade is captured before sc.exe create/config runs (correctly, since it stops being observable after), and the 5a re-reconcile is gated to run only post-Start-Service on upgrades, guarded from the fresh-install ~2min initdb window. TryReadEndpointTogglesAsync's 10s bound plus the fallback disclosure means a slow-starting store on the upgrade path degrades to the pre-existing warn-and-remedy behavior rather than hanging or silently guessing — reasonable.
  • Confirmed CheckMcpFirewallAsync/CheckWebFirewallAsync (the running service's own read-only verification) are only invoked from inside TryStartServerAsync, i.e. only when the supervisor already decided to start the surface — so a control-plane-disabled surface doesn't spuriously warn about a "missing" rule that was deliberately removed. No inconsistency introduced there.

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 NetFirewallRule/netsh advfirewall across Lite and found only unrelated Azure SQL firewall code. Not a drift case.

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 --configure-firewall could sweep away the live rule when the store was unreadable. No injection surface changes (ReadEndpointTogglesSql is a static parameterless query, untouched by this diff).

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 --configure-firewall not-elevated branch, only Open-action plans get a printed remedy command; a Remove-action plan needing its sweep (including the new control-plane-confirmed-off case) prints nothing actionable when not elevated. That gap predates this diff and is outside its scope, but now applies to a more common case (an admin disabling MCP/web) than before.

"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>
Comment on lines 3185 to 3200
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

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 (mcpOpen/webOpen gating, SweepOtherPorts derivation, DescribeDisabledSurface, the install script's upgrade-only re-reconcile at 5a) checks out against the EndpointToggle/ResolveEndpointToggle/DescribeFirewallPortAuthority machinery it builds on, and the new tests in DarlingFirewallCheckTests.cs exercise every branch I traced by hand (control-plane-off, file-seed-off, store-unreadable+exposed, store-unreadable+loopback, sweep-withheld vs sweep-authoritative). $isUpgrade is captured once from $existing before the service is (re)created and used once at 5a — no reassignment hazard. BuildFirewallEnableCommand's exact-DisplayName removal confirms the "declining the sweep costs nothing in idempotence" claim in the comments.

One real gap, left as an inline comment on DarlingCliCommands.cs: the non-elevated branch of ConfigureFirewallAsync never surfaces Remove actions actionably. In the mixed case (some surfaces need Open, others Remove), only Open plans get a note/command printed — a disabled surface's stale LAN-exposed rule (exactly what DescribeDisabledSurface describes) is silently dropped. Since this PR's own premise is that surfaces now get disabled via the Viewer with no elevated step, "toggle off in the Viewer → run --configure-firewall unelevated to check" is a realistic sequence that leaves a stale open rule with no indication a follow-up elevated run is needed.

Minor/non-blocking: CHANGELOG.md doesn't have an entry for this fix under [3.5.0] — every other fix in that file (including the #2432/#2414 predecessors this PR references) gets one with the issue number and a long-form rationale, so this may just be pending before merge.

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>
Comment on lines +3192 to 3202
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the return 1 branch 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.

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewed the current state of the diff (post b002dc8, after four prior automated review rounds already tightened SweepOtherPorts, the DescribeDisabledSurface wording, and the not-elevated summary text). This is Darling-only service/installer code (DarlingCliCommands.cs, install-darling.ps1, tests) — no T-SQL, and no Lite counterpart exists (Lite has no installed Windows service or firewall management surface), so parity doesn't apply here.

Traced the core logic and it holds up:

  • mcpOpen/webOpen = exposed (file-only, per mcp.enabled is store-authoritative but mcp.network is file-authoritative, and the disagreement logs as success #2389 design) AND enabled (control-plane-authoritative when readable) — correctly gates rule creation on both axes now.
  • SweepOtherPorts: !exposed || Origin == ControlPlane is applied uniformly regardless of Action, so a Remove reached via a stale File-origin fallback is correctly withheld from sweeping too (this was the issue flagged and fixed in the 055ddbeb commit) — confirmed this isn't reintroduced.
  • install-darling.ps1's $isUpgrade is captured from $existing before sc.exe create/config runs (the one place it's observable), and 5a's re-reconcile is correctly gated to run only post-Start-Service on upgrades, guarded from the fresh-install initdb window. The new InstallScript_ReReconcilesAfterTheFirstStart_ButOnlyOnAnUpgrade test pins both the presence and the ordering correctly.
  • No injection surface changes — Cidr still flows through CanonicalCidrOrNull/ClassifyAllowFrom before reaching a command builder, ReadEndpointTogglesSql is untouched and parameterless.

One remaining gap, posted inline on DarlingCliCommands.cs (~line 3192): the not-elevated branch of ConfigureFirewallAsync now prints why a Remove is needed (the plan.Note) but still never prints the actual sweep command for the operator to run elevated — only Open plans get a command. When toOpen == 0, it returns 0 with a "nothing to open" message even if a Remove+SweepOtherPorts plan means a stale rule is still sitting open. This is the same gap the 3rd and 4th review rounds flagged; the most recent commit added the explanatory note but didn't add the missing remediation command, so the underlying gap (no way to learn/run the sweep command without elevation) is still open.

Minor, non-blocking: CHANGELOG.md has no entry for this fix under a version heading, unlike its #2432/#2414 predecessors it references — may just be pending before merge.

@erikdarlingdata
erikdarlingdata merged commit 0c20ba5 into dev Aug 21, 2026
7 checks passed
erikdarlingdata added a commit that referenced this pull request Aug 21, 2026
…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>
erikdarlingdata added a commit that referenced this pull request Aug 21, 2026
…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>
@erikdarlingdata
erikdarlingdata deleted the fix/2436-firewall-residues branch September 12, 2026 20:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant