Repository navigation
Move firewall rule management to the elevated install path (#1771) - #1773
Merged
Merged
Conversation
The service runs as an unprivileged NT SERVICE virtual account that cannot modify Windows Firewall by design, but every start tried anyway: both New-NetFirewallRule and Remove-NetFirewallRule failed "Access is denied" for MCP (5152) and web (5153) on a production field instance. That was harmless there only because the rules pre-existed from a manual setup - on a fresh networked install nothing ever creates them, so remote clients are blocked outright, and networked is the normal deployment mode. Rule creation moves to the elevated context that already exists. A new --configure-firewall verb reconciles all three scoped rules (store, MCP, web) from darling.json in one pass; install-darling.ps1 runs it before the first start and again after -Network, and uninstall-darling.ps1 removes the rules by their shared DisplayName prefix. The verb reads only darling.json - no store, no credentials - so it works before the store has ever booted, and it is idempotent so an upgrade re-running it is a no-op. Exposure is decided by the same resolvers the running service fail-closes on, not by re-reading listen/allowFrom, so a config the service degrades to loopback never gets an open port and the service's own check cannot contradict what the installer just did. At runtime the write degrades to a read. Get-NetFirewallRule needs no elevation (verified on Windows 11 26200 under a restricted token: reads succeed, writes return PermissionDenied), so the service probes and reports once per state change rather than once per supervisor retry. A loopback-only install with no rule stays silent; an exposed endpoint missing its rule gets one WARN naming the exact command, built by the same builder the elevated verb runs. The bundled store had the identical bug on its own port and is fixed the same way. A service stop no longer attempts to remove the rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
) The port is part of the rule DisplayName, so changing a port does not update a rule - it creates a different one and strands the old as an inbound allow rule on a port nothing serves. Reconciling by exact name could never reach that, so --configure-firewall now sweeps a per-surface "... (port *)" wildcard before ensuring the current rule. This is the "update port if the rule exists with a different port" half of the fix, which the first pass missed. The wildcard is derived from the rule name, never assembled from an operator string, and a name with no port suffix is returned UNCHANGED rather than gaining a "*": a wildcard built from a malformed name could match, and therefore delete, rules this product does not own. The sweep runs as its OWN step rather than concatenated ahead of the open. It ends in "exit 0", so concatenating would terminate the shell before the rule was created while still reporting success - written, caught, and now pinned by a call-shape test. Verified live: with a leftover "MCP (port 5152)" rule present and mcp.port moved to 5199, one run left exactly one rule, on 5199. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#1771) Review catch. The pin asserted "Invoke-FirewallReconcile occurs at least twice", but the design carries a definition plus TWO calls, so deleting the post-wizard call at install-darling.ps1:269 left two occurrences and the pin stayed green. Measured, not assumed: with that call removed the string still occurs 2 times. That call is not decorative. -Network runs --configure-network AFTER the pre-start reconcile, and the wizard rewrites the very exposure that reconcile acted on, so a newly exposed endpoint has no rule and one turned back off has a stale one. Losing it silently reproduces a subset of #1771 on every networked install. Both calls are now pinned by what they are FOR: - a call inside the if ($Network) branch, found by brace-matching the block - a call before Start-Service, so the first start already sees the rules Same vacuous-pin shape as the earlier Contains() that matched help text. Tests only, no production change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng (#1771) Both from the delta review. The M13 pin forbade two literal spellings of the concatenation bug, making it a tripwire for the regression I happened to hit rather than a proof. It now asserts POSITIVELY that the sweep sits in TryRunFirewallStepAsync's command position, over whitespace-normalized source so reformatting cannot break it, and does the same for the open. Measured against four spellings of the same silent regression: all four now go red, and two of them - no spaces around the +, and string.Concat - the old pin would have missed. string.Concat would also have defeated a trailing-comma-only assertion, which is why the pin proves the argument POSITION rather than the punctuation around it. SurfaceRuleWildcard's remark now states the consequence rather than just the intent: on the no-port-suffix path the sweep degrades to the exact-name removal the caller already performed, so the change is strictly additive in blast radius - it can only ever widen a delete to other ports of the SAME surface, never to anything else. That is provable from the two lines above it, and it is the claim a reviewer needs to be able to make about a delete path. Comment and tests only; no behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1771.
The defect
A production field instance logged both halves of the firewall self-heal failing on every service start, for both endpoints:
Harmless there only because the rules already existed from an earlier manual setup. The real defect is what happens without that accident: on a fresh networked install nothing ever creates the rule, so remote MCP and dashboard clients are firewalled off with only a log line to say why - and networked is the normal deployment mode for this product.
The cause is structural, not a bug in the reconcile. The service runs as
NT SERVICE\PerformanceMonitor Darling, which cannot modify Windows Firewall by design. Granting it that privilege is the wrong fix and is rejected in the issue.The elevated path (verified, not assumed)
Darling/tools/install-darling.ps1- it gates on elevation atinstall-darling.ps1:65-68and registers the service withsc.exe create ... obj= "NT SERVICE\$serviceName"atinstall-darling.ps1:127. Uninstall isDarling/tools/uninstall-darling.ps1:39(sc.exe delete). Neither touched the firewall before this change.What changed
Rules are created by the installer. New
--configure-firewallverb (DarlingCliCommands.ConfigureFirewallAsync) reconciles all three scoped rules - store, MCP, web - againstdarling.jsonin one pass.install-darling.ps1runs it as step 4c (before the first start) and again after the-Networkwizard, which rewrites the very exposure the first pass acted on.uninstall-darling.ps1removes them by their shared DisplayName prefix, so a rule left over from a previous port is cleared too.The verb reads only
darling.json- no store connection, no credentials - which is what lets it run at install time before the store has ever booted, unlike--enable-mcp/--enable-web, which write the control-plane store. It is idempotent, so an upgrade re-running it is a no-op.Exposure is delegated, not re-derived.
PlanFirewallRulesasks the same resolvers the running service fail-closes on (ResolveNetworkExposure,ResolveMcpBind,ResolveWebBind). Re-readinglistenwould be worse than duplicated logic: the installer would open a port for an endpoint the service binds loopback-only, and then the service's own start-up check would flag - every start - a rule the installer had just deliberately created. The CIDR that reaches the rule is the parser's canonical output, never the operator's raw string (IPNetwork.TryParsemasks host bits rather than rejecting them, so192.168.1.77/24opens192.168.1.0/24).The runtime write degrades to a read.
DarlingFirewallCheckprobes withGet-NetFirewallRuleand reports; it never writes. Reporting is gated on a state change, because the MCP and web hosts retry a failed start on a 30-second backoff - a check wired where the reconcile sat would have traded access-denied spam for warning spam.The bundled store had the identical bug on its own port and is fixed the same way, though the issue named only 5152/5153. Two behaviors deliberately end: a service stop no longer removes the rule (it could never succeed; the rule is install-managed now, scoped to a port nothing is listening on, and the next start reports it as stale), and
_runningNetworkMode, which existed only to drive that removal, is gone.A port change strands the old rule, so the verb sweeps per surface. The port is part of the rule name, so changing a port does not update a rule — it makes a different one and leaves the old behind as an inbound allow rule on a port nothing serves. Reconciling by exact name could never reach that, so each surface gets a
... (port *)wildcard sweep before its current rule is ensured. This is the "update port if the rule exists with a different port" half of the brief, which my first pass missed. The wildcard is derived from the rule name, never assembled from an operator string, and a name with no port suffix comes back UNCHANGED rather than gaining a*— a wildcard built from a malformed name could match, and therefore delete, rules this product does not own. The sweep runs as its own step rather than concatenated ahead of the open, because it ends inexit 0and would otherwise terminate the shell before the rule was created while still reporting success; I wrote that bug, caught it, and pinned it with a call-shape test.Known divergence, ruled: the verb reads the FILE port, the runtime checks the LIVE port
mcp.port/web.portare control-plane after the first run: the Viewer's Settings writesconfig_service, and the host bindspublished?.Port ?? config.Mcp.Port(DarlingMcpHostService.cs:143, feedingTryStartServerAsyncat:148/:163). The runtime check therefore probes the live port.--configure-firewallreadsconfig.Mcp.Port— the file port (DarlingCliCommands.cs:1874-1875,:1884-1885). So after a port change made from the Viewer's Settings on an exposed endpoint, the verb would open the file port while the service listens on the control-plane one.Disposition: documented, not changed. Not a regression — the old code could never create a rule for the changed port either, because every write was access-denied. And the degraded outcome is already self-healing in the right direction: the runtime check probes the LIVE port and prints the exact
New-NetFirewallRulefor the CORRECT one, so the operator has a working one-liner, and the per-surface sweep above guarantees the stale file-port rule dies rather than lingering as an open inbound allow. What remains imperfect is only that--configure-firewall's one-step fix re-creates the file-port rule in that specific scenario.The two alternatives were considered and rejected on their own costs. Reading the control plane would be correct in every case, but it would give the install-time verb a store dependency — a credential decrypt plus a connection — and "no store, no credentials" is exactly the property that lets install step 4c run before the store has ever booted; reintroducing that dependency defeats the architecture the verb exists to fit. Making the port file-only for firewall purposes would change product behavior on a niche path, a bigger blast radius than the problem it solves. If control-plane port changes ever turn out to be a common flow, that third option is the revisit — noted, not built.
Loopback-vs-networked gating
The warning is gated on the resolved bind, so it fires only where network exposure is real:
LoopbackNoRuleLoopbackStaleRuleExposedRulePresentExposedRuleMissingNew-NetFirewallRuleProbeFailedA default loopback install therefore logs nothing at all about the firewall, which is the constraint from the issue. A
ProbeFailedis its own verdict rather than collapsing into "absent", which would manufacture a missing-rule warning out of a broken probe.Field-verification signature (for the client re-run)
On the next upgrade of the field instance, the access-denied lines disappear entirely. Neither
Could not configure the firewall automaticallynorCould not remove the firewall rule automaticallyis emitted anywhere any more - the first string is gone from the codebase outright, and the second survives only inside a code comment describing the historical trap, never as a log call. The service issues no firewall write at all, so there is nothing left to be denied.That box has pre-existing rules for 5152 and 5153, so the expected replacement is one INFO line per exposed endpoint, once per start:
Compare NAMES, not just presence
The product owns these exact DisplayName templates, and the configured port is part of the name:
At default ports that is exactly:
To see what a box actually has (the read needs no elevation):
If a WARN naming a MISSING rule appears while a hand-made rule for that port plainly exists, that is a real finding rather than a regression: the manual rule's DisplayName does not match the scoped name the product uses, so the product cannot see it and does not manage it. The port in the expected name must match the port that endpoint is actually configured for.
--configure-firewallelevated is the one-step fix, and it also removes the superseded rules it does own.Live verification
Get-NetFirewallRuleneeding no elevation was verified on this OS (Windows 11 Pro 26200), not assumed: under arunas /trustlevel:0x20000restricted token, enumerate-all and exact-name reads succeed whileNew-NetFirewallRulereturnsPermissionDenied: Access is denied- the exact field signature, same token. Two shape facts that drove the probe design were verified the same way: an exact-DisplayNamematching nothing raisesObjectNotFound(so "absent" must not look like a failure), and a wildcard matching nothing does not (so the uninstall sweep is safe).The verb itself was run against a real firewall:
TCP 5152, inbound, RemoteAddress 192.168.1.0/255.255.255.0, other two absentallowFrom: "not-a-cidr") -> opened nothing, printed why, removed the stale rulePerformanceMonitor Darling MCP (port 5152)rule present andmcp.portmoved to 5199, one run left exactly one rule, on 5199 — the stale allow rule that the exact-name reconcile would have left open is gone1for a present rule and0for an absent one, clean exit state both timesThe runtime check's log lines themselves are the one behavior with no automated test (they need a live service plus a live firewall); the probe command they run, the verdict they map, and the remedy they print are all pinned purely, and the probe was exercised live as above.
Mutation table
Every mutation watched red. M8 came back GREEN on the first run and exposed a weak test - recorded here rather than quietly fixed.
New-NetFirewallRulewrite (MCP host)RuntimeHosts_VerifyTheFirewall_AndNeverWriteItlistenalone, not the resolverPlanFirewallRules_NeverOpensAPortTheServiceWillNotExposeexit 0BuildProbeCommand_TreatsAnAbsentRuleAsAnAnswer_NotAFailureProbeFailedTryParseProbeOutput_IsNullWhenItCannotTellShouldReport_OnlyWhenTheStateChangesBuildRemedyCommand_RefusesToBuildAnOpenCommandWithoutACidrUninstallScript_SweepsByTheSharedPrefixInstallScript_ReconcilesTheFirewallInstallScript_ActuallyInvokesTheFirewallVerbInstallScript_ActuallyInvokesTheFirewallVerbConfigureFirewallVerb_IsDispatchableTheStoreBootstrap_VerifiesTheFirewall_AndNeverWritesItAtRuntimeSurfaceRuleWildcard_CoversEveryPortOfOneSurfaceSurfaceRuleWildcard_NeverInventsAWildcardFromAnUnrecognizedNamesweep + "; " + enable(exit 0kills it; rule never created, still reports success)ConfigureFirewall_RunsTheSweepAsItsOwnStep_NeverConcatenatedAheadOfTheOpen+— the retired pin missed thisstring.Concat— the retired pin missed this, and so would a trailing-comma-only pinSilentlyContinue(access denied would reportexit 1:with an EMPTY message)BuildFirewallSweepCommand_IsStandalone_AndReportsRealFailuresLoudlyinstall-darling.ps1:269)InstallScript_ReReconcilesInsideTheNetworkWizardBranchStart-ServiceInstallScript_ReconcilesTheFirewallBeforeTheFirstStartA third pin upgraded, from tripwire to proof. The delta review flagged M13 as the weakest guard in the change: it blacklisted two literal spellings of the concatenation bug, so it caught the regression I happened to write rather than the class. It now asserts POSITIVELY that the sweep sits in
TryRunFirewallStepAsync's command position, over whitespace-normalized source so reformatting cannot break it. Measured against four spellings of the same silent regression — all four go red, and two of them (no spaces around the+, andstring.Concat) the retired pin would have missed.string.Concatwould also have defeated the trailing-comma-only assertion the reviewer suggested, which is why the pin proves the argument POSITION rather than the punctuation around it.One claim strengthened rather than added. The reviewer observed something about
SurfaceRuleWildcardthat I had not claimed and should have: on the no-port-suffix path it returns the name unchanged, so the sweep degrades to exactly the exact-name removal the caller already performed. The change is therefore strictly additive in blast radius — it can only ever widen a delete to other ports of the SAME surface, never to anything else. That is provable from two lines, and it is the claim worth making about a delete path, so it now says so in the code rather than merely "safe".A second vacuous pin, caught in review and replaced the same way. The strengthened M8 pin also asserted
Invoke-FirewallReconcileoccurs at least twice — but the design carries a definition plus TWO calls, so deleting the post-wizard call left two occurrences and the pin stayed green. Measured rather than assumed: with that call removed the string still occurs 2 times, so the count pin would have passed. Both calls are now pinned by what they are FOR — one inside the brace-matchedif ($Network)branch, one beforeStart-Service— and M15/M16 are the mutations that hold them. That second call is not decorative:-Networkruns the wizard AFTER the pre-start reconcile and rewrites the very exposure it acted on, so losing it silently reproduces a subset of #1771 on every networked install.M8 survived because
Contains("--configure-firewall")stayed green with the real call deleted - the script also names the verb in its help block and in its "re-run this by hand" error message. The pin now asserts the exact invocation form and proves the wrapper is called somewhere other than its own definition, and goes red both when the call is replaced (M8a) and when every call site is removed (M8b).Tests and build
Darling.Tests), 53 of them new inDarlingFirewallCheckTests-t:Rebuild:PerformanceMonitor.Darling.ServiceandDarling.Tests- 0 Warning(s), 0 Error(s) eachDocCommentHygieneTestscaught a doc block I stranded while reordering fields; fixed by moving the block back onto its member, not by deleting itNo schema change, no new NuGet packages, no changes outside Darling.
Auto-merge deliberately NOT enabled - an adversarial review gates this PR.
🤖 Generated with Claude Code