diff --git a/cmd/cli/dns_intercept_windows.go b/cmd/cli/dns_intercept_windows.go index 6baaf32..6c9e40e 100644 --- a/cmd/cli/dns_intercept_windows.go +++ b/cmd/cli/dns_intercept_windows.go @@ -129,6 +129,21 @@ const ( // from overriding this filter's PERMIT action ("hard permit"). Used in DNS // mode to override third-party WFP blocks (e.g., OpenVPN's block-outside-dns). fwpmFilterFlagClearActionRight uint32 = 0x00000008 + + // fwpmSessionFlagDynamic is FWPM_SESSION_FLAG_DYNAMIC from fwpmtypes.h. + // + // Every WFP object added through a dynamic session is owned by that session and + // is deleted by the OS when the engine handle closes - including when the process + // exits, crashes, or is killed. ctrld relies on this so its filters can never + // outlive the process that installed them. + // + // This matters most in Firewall Mode: its block-all filters are machine-wide, so + // an orphaned set silently denies outbound traffic for every process on the host + // (browsers, other users, even a replacement ctrld's own API bootstrap) until a + // reboot. Session-scoped ownership makes the OS clean that up for us instead of + // depending on ctrld reaching its own shutdown or startup cleanup path. + // See: https://learn.microsoft.com/en-us/windows/win32/api/fwpmtypes/ns-fwpmtypes-fwpm_session0 + fwpmSessionFlagDynamic uint32 = 0x00000001 ) // WFP API structures. These mirror the C structures from fwpmtypes.h and fwptypes.h. @@ -1114,6 +1129,10 @@ func (p *prog) startWFPFilters(state *wfpState) error { session := fwpmSession0{} sessionName, _ := windows.UTF16PtrFromString("ctrld DNS Intercept") session.displayData.name = sessionName + // Session-scoped ownership: if this process dies without running its shutdown + // path, Windows removes our filters (including Firewall Mode's machine-wide + // block-all) instead of leaving the host enforced by a dead ctrld. + session.flags = fwpmSessionFlagDynamic // RPC_C_AUTHN_DEFAULT (0xFFFFFFFF) lets the system pick the appropriate // authentication service. RPC_C_AUTHN_NONE (0) returns ERROR_NOT_SUPPORTED @@ -1129,11 +1148,11 @@ func (p *prog) startWFPFilters(state *wfpState) error { if r1 != 0 { return fmt.Errorf("FwpmEngineOpen0 failed: HRESULT 0x%x", r1) } - mainLog.Load().Info().Msgf("DNS intercept: WFP engine opened (handle: 0x%x)", engineHandle) + mainLog.Load().Info().Msgf("DNS intercept: WFP engine opened (handle: 0x%x, session-scoped)", engineHandle) - // Clean up any stale sublayer from a previous unclean shutdown. - // If ctrld crashed or was killed, the non-dynamic WFP session may have left - // orphaned filters. Deleting the sublayer removes all its child filters. + // Clean up any sublayer left by an older ctrld that used a non-dynamic session + // (or by a build predating session-scoped ownership). Deleting the sublayer + // removes all its child filters. r1, _, _ = procFwpmSubLayerDeleteByKey0.Call( engineHandle, uintptr(unsafe.Pointer(&ctrldSubLayerGUID)), @@ -1141,7 +1160,12 @@ func (p *prog) startWFPFilters(state *wfpState) error { if r1 == 0 { mainLog.Load().Info().Msg("DNS intercept: cleaned up stale WFP sublayer from previous session") } - // r1 != 0 means sublayer didn't exist — that's fine, nothing to clean up. + // A non-zero r1 is not necessarily "nothing to clean up": it is also + // FWP_E_SUBLAYER_NOT_FOUND (the normal case), FWP_E_WRONG_SESSION for a sublayer a + // live ctrld owns, or FWP_E_DYNAMIC_SESSION_IN_PROGRESS for a non-dynamic one this + // dynamic session may not delete. None of them need handling here: the add below + // fails cleanly if the sublayer really is still present, and the non-dynamic case is + // what cleanupStaleDNSInterceptState handles at startup. sublayer := fwpmSublayer0{ subLayerKey: ctrldSubLayerGUID, @@ -1566,6 +1590,9 @@ func (p *prog) activateLoopbackWFPProtect(state *wfpState) error { session := fwpmSession0{} sessionName, _ := windows.UTF16PtrFromString("ctrld DNS Loopback Protect") session.displayData.name = sessionName + // Session-scoped, like the hard-intercept engine: no ctrld filter should + // outlive the process that installed it. + session.flags = fwpmSessionFlagDynamic const rpcCAuthnDefault = 0xFFFFFFFF r1, _, _ := procFwpmEngineOpen0.Call( diff --git a/cmd/cli/service_windows.go b/cmd/cli/service_windows.go index aa36bd8..0c6d521 100644 --- a/cmd/cli/service_windows.go +++ b/cmd/cli/service_windows.go @@ -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 } diff --git a/docs/firewall-mode.md b/docs/firewall-mode.md index 500653e..71ec9d6 100644 --- a/docs/firewall-mode.md +++ b/docs/firewall-mode.md @@ -1,7 +1,8 @@ # Firewall Mode -Firewall mode makes DNS policy unbypassable by blocking outbound connections to any -IP that wasn't resolved by ctrld. This closes the "DNS gap" - where apps use hardcoded +Firewall mode makes DNS policy unbypassable *while ctrld is running* by blocking outbound +connections to any IP that wasn't resolved by ctrld. On Windows, enforcement is tied to the +process lifetime - see [Enforcement lifetime](#enforcement-lifetime-what-happens-when-the-process-dies). This closes the "DNS gap" - where apps use hardcoded IPs, direct-IP fallbacks, or alternative DNS resolvers to bypass DNS-based filtering. ## How It Works @@ -63,6 +64,38 @@ sublayer with dynamic permit filters: Permit filters are added/removed dynamically as the allowlist changes. +#### Enforcement lifetime: what happens when the process dies + +ctrld opens its WFP session as a **dynamic** session, so Windows removes every filter it +added - including firewall mode's block-all - as soon as the process exits, however it +exits. That is a deliberate trade, recorded here because it changes what "unbypassable" +means on Windows: + +- **Before**: a hard kill (`taskkill /f`, a crash) left the filters installed with no ctrld + to manage them. The host was unusable rather than unfiltered, and only a reboot or a + manual WFP cleanup recovered it. A replacement ctrld could not even reach the API to + start, so it never got far enough to clean up - the deadlock this session change breaks. +- **Now**: the same kill leaves the host *unfiltered* until the service restarts. + +A clean stop or uninstall behaved this way already, and both need administrator rights, as +does killing a SYSTEM service - so the newly exposed case is specifically the hard kill of +an already-privileged process. What it costs is that an administrator can turn enforcement +off without uninstalling and without a trace beyond the service state. + +Compensating controls: + +1. **Restart policy backs off instead of burning out.** `ConfigureWindowsServiceFailureActions` + uses 5s / 30s / 2m restart delays with a 10-minute reset window, so three failures cannot + spend the whole budget inside 15 seconds and leave the host unfiltered. Repeated kills + still end in a stopped service - a bounded policy has to - but it takes minutes. +2. **Startup cleans up predecessors.** `cleanupStaleDNSInterceptState` removes filters left + by a build that predates session-scoped ownership, so an upgrade from such a build cannot + inherit the old lockout. + +Still open: enforcement stopping while policy should be active is visible only in the local +log. Reporting that state centrally is follow-up work, and is the control that would make +the hard-kill case detectable rather than merely bounded. + ### Linux and Unsupported Platforms Kernel enforcement is not implemented yet. On unsupported platforms, `firewall_mode = "on"` currently fails open: ctrld still records allowlist stats, but it does not block outbound traffic. A warning is logged at startup so this is visible. Future work: iptables/nftables rules or eBPF, and possibly a strict mode that fails closed when platform enforcement is unavailable.