mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
A host carrying orphaned WFP filters from a ctrld build that predates session-scoped ownership cannot recover on its own. In Firewall Mode those filters block all non-allowlisted outbound traffic machine-wide, which denies the replacement ctrld's own API bootstrap. API-managed startup then sits in the resolver-config retry loop forever and never reaches startWFPFilters, where the only stale-sublayer cleanup lived. Cleanup needs startup, startup needs the network, the network needs cleanup - the host stays locked out until a reboot. Add cleanupStaleDNSInterceptState() and call it early in run(), before the network-up wait and before the API preflight. On Windows it deletes ctrld's WFP sublayer, which takes its child filters with it, so a previous process's enforcement is gone before this process makes its first connection. Objects owned by a live session cannot be deleted, so a running ctrld is unaffected and "nothing to clean up" stays at debug level; an actual removal logs a warning, since it means a previous ctrld left machine-wide enforcement installed. macOS and the other platforms get no-op implementations: pf enforcement does not outlive the process, and startDNSIntercept already flushes the anchor and removes a stale anchor file before loading rules. The cleanup inside startWFPFilters stays as a second line of defense. The cleanup session is deliberately NOT dynamic. FwpmSubLayerDeleteByKey0 is documented to fail with FWP_E_DYNAMIC_SESSION_IN_PROGRESS when called from a dynamic session for an object that was not added in one, and the only orphans that can exist are exactly those: a ctrld predating session-scoped ownership added its sublayer statically. Anything a newer ctrld leaves behind is removed by the OS when its session ends. Opening this session dynamically would have made the cleanup a no-op in the one case it exists for, while still logging "no stale WFP state". A concurrently running ctrld is protected by documented ownership rather than by the child-filter question below: a session-scoped ctrld's sublayer belongs to a different dynamic session, so the delete fails with FWP_E_WRONG_SESSION. That matters because this call is made unconditionally at startup, in every intercept mode, so an interactive "ctrld run" alongside a healthy service reaches it. A ctrld predating session scoping has no such protection: it holds a non-dynamic sublayer, which is exactly what this targets and is indistinguishable from an orphan. Two guards cover that instead. Elevation, because opening a WFP engine and deleting ctrld's sublayer must not be reachable from an unprivileged local process - FwpmEngineOpen0 is expected to fail without elevation, but that is a property of the API rather than something this code checked, and it was the only barrier. And interactive invocation, since a service start is not interactive: the deadlock case still gets cleaned, while a hand-run "ctrld run" beside a live service does not strip its enforcement. That second guard requires positive evidence of absence - ctrldServiceLiveness answers unknown for an unreachable SCM or a service mid-stop, and unknown skips the cleanup exactly as running does, because "could not be observed" is not "not there". Both are asserted against the deletion itself, not only against the predicate: a caller-level test substitutes the WFP delete and the guard inputs, so a future change that stops consulting the guard, or consults it and deletes anyway, fails rather than staying green. Delete failures are no longer collapsed into "nothing to clean up". Only FWP_E_SUBLAYER_NOT_FOUND means that; any other code means state exists under our GUID that we could not remove, which is the lockout condition itself, so it is logged as a warning naming the code. Whether deleting the sublayer is sufficient is left explicitly UNRESOLVED rather than asserted. It is sufficient only if the delete also removes the filters inside it. FwpmSubLayerDeleteByKey0's Remarks say nothing about child filters either way, while object management states that an object cannot be deleted until everything referencing it has been - and FWP_E_IN_USE exists for that. Whether a filter's subLayerKey counts as such a reference is not documented, and this code cannot be exercised off-Windows, so the earlier claim that the delete "takes its child filters with it" is removed from the comment here and from docs/wfp-dns-intercept.md, which carried it from before this branch. The behaviour is safe under both readings: the delete is attempted, and FWP_E_IN_USE is reported rather than counted as success, so a support log distinguishes "cleared it" from "could not clear it". If a live Windows check shows FWP_E_IN_USE against orphaned filters, the cleanup must enumerate and delete those filters first. That is deliberately not written blind: FWPM_FILTER_ENUM_TEMPLATE0 has no sublayer field, so selecting ctrld's own filters means reading subLayerKey at a computed offset in FWPM_FILTER0, and getting that offset wrong would delete other software's filters - a worse failure than not cleaning up. Refs: https://learn.microsoft.com/en-us/windows/win32/api/fwpmu/nf-fwpmu-fwpmsublayerdeletebykey0 Refs: https://learn.microsoft.com/en-us/windows/win32/fwp/object-management
224 lines
6.7 KiB
Go
224 lines
6.7 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"runtime"
|
|
"syscall"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"golang.org/x/sys/windows"
|
|
"golang.org/x/sys/windows/svc"
|
|
"golang.org/x/sys/windows/svc/mgr"
|
|
)
|
|
|
|
// hasElevatedPrivilege checks if the current process has elevated privileges on Windows
|
|
func hasElevatedPrivilege() (bool, error) {
|
|
var sid *windows.SID
|
|
if err := windows.AllocateAndInitializeSid(
|
|
&windows.SECURITY_NT_AUTHORITY,
|
|
2,
|
|
windows.SECURITY_BUILTIN_DOMAIN_RID,
|
|
windows.DOMAIN_ALIAS_RID_ADMINS,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
&sid,
|
|
); err != nil {
|
|
return false, err
|
|
}
|
|
token := windows.Token(0)
|
|
return token.IsMember(sid)
|
|
}
|
|
|
|
// serviceLiveness is what could be established about the installed ctrld service. The
|
|
// three states are distinct because a caller that must not disturb a live service has to
|
|
// treat "could not tell" like "live", not like "stopped".
|
|
type serviceLiveness int
|
|
|
|
const (
|
|
// serviceLivenessUnknown means the question could not be answered: the SCM was
|
|
// unreachable, the caller lacked rights, or the query failed.
|
|
serviceLivenessUnknown serviceLiveness = iota
|
|
// serviceLivenessRunning means the service is running, starting, or paused - in every
|
|
// case a process that owns state.
|
|
serviceLivenessRunning
|
|
// serviceLivenessStopped means the service is installed and stopped, or not installed
|
|
// at all. Nothing of ctrld's is live.
|
|
serviceLivenessStopped
|
|
)
|
|
|
|
// ctrldServiceLiveness reports what can be established about the installed ctrld service.
|
|
//
|
|
// Only serviceLivenessStopped is positive evidence that nothing is live. Every failure
|
|
// answers serviceLivenessUnknown rather than folding into "stopped": the SCM being
|
|
// unreachable says nothing about whether a service is running, and a caller that acts on
|
|
// that as absence would strip a live service's state.
|
|
//
|
|
// "Not installed" is deliberately stopped, not unknown: that is the answer, and it is
|
|
// exactly the host that needs stale state cleaned - an uninstall that left filters behind
|
|
// has no service left to protect.
|
|
func ctrldServiceLiveness() serviceLiveness {
|
|
m, err := mgr.Connect()
|
|
if err != nil {
|
|
return serviceLivenessUnknown
|
|
}
|
|
defer m.Disconnect()
|
|
|
|
s, err := m.OpenService(ctrldServiceName)
|
|
if err != nil {
|
|
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
|
|
return serviceLivenessStopped
|
|
}
|
|
return serviceLivenessUnknown
|
|
}
|
|
defer s.Close()
|
|
|
|
status, err := s.Query()
|
|
if err != nil {
|
|
return serviceLivenessUnknown
|
|
}
|
|
switch status.State {
|
|
case svc.Running, svc.StartPending, svc.ContinuePending, svc.PausePending, svc.Paused:
|
|
return serviceLivenessRunning
|
|
case svc.Stopped:
|
|
return serviceLivenessStopped
|
|
default:
|
|
// StopPending, and any state a later Windows adds: a process may still be
|
|
// holding its state, so this is no answer.
|
|
return serviceLivenessUnknown
|
|
}
|
|
}
|
|
|
|
// ConfigureWindowsServiceFailureActions checks if the given service
|
|
// has the correct failure actions configured, and updates them if not.
|
|
func ConfigureWindowsServiceFailureActions(serviceName string) error {
|
|
if runtime.GOOS != "windows" {
|
|
return nil // no-op on non-Windows
|
|
}
|
|
|
|
m, err := mgr.Connect()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer m.Disconnect()
|
|
|
|
s, err := m.OpenService(serviceName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
|
|
// 1. Retrieve the current config
|
|
cfg, err := s.Config()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 2. Update the Description
|
|
cfg.Description = "A highly configurable, multi-protocol DNS forwarding proxy"
|
|
|
|
// 3. Apply the updated config
|
|
if err := s.UpdateConfig(cfg); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 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},
|
|
{Type: mgr.ServiceRestart, Delay: time.Second * 30},
|
|
{Type: mgr.ServiceRestart, Delay: time.Minute * 2},
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Ensure that failure actions are NOT triggered on user-initiated stops.
|
|
var failureActionsFlag windows.SERVICE_FAILURE_ACTIONS_FLAG
|
|
failureActionsFlag.FailureActionsOnNonCrashFailures = 0
|
|
|
|
if err := windows.ChangeServiceConfig2(
|
|
s.Handle,
|
|
windows.SERVICE_CONFIG_FAILURE_ACTIONS_FLAG,
|
|
(*byte)(unsafe.Pointer(&failureActionsFlag)),
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// openLogFile opens a log file with the specified mode on Windows
|
|
func openLogFile(path string, mode int) (*os.File, error) {
|
|
if len(path) == 0 {
|
|
return nil, &os.PathError{Path: path, Op: "open", Err: syscall.ERROR_FILE_NOT_FOUND}
|
|
}
|
|
|
|
pathP, err := syscall.UTF16PtrFromString(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var access uint32
|
|
switch mode & (os.O_RDONLY | os.O_WRONLY | os.O_RDWR) {
|
|
case os.O_RDONLY:
|
|
access = windows.GENERIC_READ
|
|
case os.O_WRONLY:
|
|
access = windows.GENERIC_WRITE
|
|
case os.O_RDWR:
|
|
access = windows.GENERIC_READ | windows.GENERIC_WRITE
|
|
}
|
|
if mode&os.O_CREATE != 0 {
|
|
access |= windows.GENERIC_WRITE
|
|
}
|
|
if mode&os.O_APPEND != 0 {
|
|
access &^= windows.GENERIC_WRITE
|
|
access |= windows.FILE_APPEND_DATA
|
|
}
|
|
|
|
shareMode := uint32(syscall.FILE_SHARE_READ | syscall.FILE_SHARE_WRITE | syscall.FILE_SHARE_DELETE)
|
|
|
|
var sa *syscall.SecurityAttributes
|
|
|
|
var createMode uint32
|
|
switch {
|
|
case mode&(os.O_CREATE|os.O_EXCL) == (os.O_CREATE | os.O_EXCL):
|
|
createMode = windows.CREATE_NEW
|
|
case mode&(os.O_CREATE|os.O_TRUNC) == (os.O_CREATE | os.O_TRUNC):
|
|
createMode = windows.CREATE_ALWAYS
|
|
case mode&os.O_CREATE == os.O_CREATE:
|
|
createMode = windows.OPEN_ALWAYS
|
|
case mode&os.O_TRUNC == os.O_TRUNC:
|
|
createMode = windows.TRUNCATE_EXISTING
|
|
default:
|
|
createMode = windows.OPEN_EXISTING
|
|
}
|
|
|
|
handle, err := syscall.CreateFile(pathP, access, shareMode, sa, createMode, syscall.FILE_ATTRIBUTE_NORMAL, 0)
|
|
if err != nil {
|
|
return nil, &os.PathError{Path: path, Op: "open", Err: err}
|
|
}
|
|
|
|
return os.NewFile(uintptr(handle), path), nil
|
|
}
|