cmd/cli: scope ctrld's WFP objects to a dynamic session on Windows

ctrld opened the WFP engine with a plain FWPM_SESSION0, so every filter and
sublayer it installed was persistent for the engine's boot lifetime: the kernel
kept enforcing them after the installing process was gone. Any exit that did
not run the shutdown path - kill, crash, or a service stop during upgrade -
left them behind.

In hard intercept mode that orphaned the DNS block filters. With Firewall Mode
enabled it orphaned machine-wide block-all filters that carry no process or SID
condition, so the entire host lost outbound traffic: browsers, other users, and
a replacement ctrld's own API bootstrap alike, with no way back short of a
reboot.

Set FWPM_SESSION_FLAG_DYNAMIC on both engine sessions (hard intercept and
loopback protect). Windows then deletes everything the session owns when the
handle closes, including on abnormal termination, so ctrld's enforcement can no
longer outlive the process that installed it.

This removes the cause. The next commit adds startup self-heal for hosts
already carrying orphaned filters from a build that predates this change.
This commit is contained in:
Cuong Manh Le
2026-08-14 15:28:16 +07:00
parent 52b7aaab87
commit 7de6298fa4
3 changed files with 87 additions and 13 deletions
+20 -6
View File
@@ -66,15 +66,29 @@ func ConfigureWindowsServiceFailureActions(serviceName string) error {
return err
}
// Then proceed with existing actions, e.g. setting failure actions
// Recovery policy for a service that carries enforcement.
//
// ctrld's WFP session is dynamic, so Windows removes its filters when the process
// dies - a host with no ctrld is unfiltered rather than locked out. That makes the
// restart budget part of the enforcement story: three restarts five seconds apart
// with a two-minute reset window could be spent inside fifteen seconds, after which
// the service stays stopped and the host stays unfiltered until an operator acts.
//
// The delays back off instead, and the reset window is long enough that a burst
// cannot exhaust the budget faster than the backoff allows. A genuine crash loop
// still ends in a stopped service - that is the point of a bounded policy - but it
// takes minutes rather than seconds, and the third restart survives a transient
// failure that repeats.
actions := []mgr.RecoveryAction{
{Type: mgr.ServiceRestart, Delay: time.Second * 5}, // 5 seconds
{Type: mgr.ServiceRestart, Delay: time.Second * 5}, // 5 seconds
{Type: mgr.ServiceRestart, Delay: time.Second * 5}, // 5 seconds
{Type: mgr.ServiceRestart, Delay: time.Second * 5},
{Type: mgr.ServiceRestart, Delay: time.Second * 30},
{Type: mgr.ServiceRestart, Delay: time.Minute * 2},
}
// Set the recovery actions (3 restarts, reset period = 120).
err = s.SetRecoveryActions(actions, 120)
// Reset the failure count only after the service has stayed up longer than the whole
// backoff schedule, so repeated failures keep escalating instead of restarting the
// count from the first five-second delay.
err = s.SetRecoveryActions(actions, uint32((10 * time.Minute).Seconds()))
if err != nil {
return err
}