mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-22 14:49:10 +02:00
[client] Remove the empty GPO DNS policy store on Windows teardown (#7563)
This commit is contained in:
@@ -124,19 +124,9 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var useGPO bool
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
log.Debugf("failed to open GPO DNS policy root: %v", err)
|
||||
} else {
|
||||
closer(k)
|
||||
useGPO = true
|
||||
log.Infof("detected GPO DNS policy configuration, using policy store")
|
||||
}
|
||||
|
||||
configurator := ®istryConfigurator{
|
||||
guid: guid,
|
||||
gpo: useGPO,
|
||||
gpo: useGPOPolicyStore(),
|
||||
}
|
||||
|
||||
origNameservers, err := configurator.captureOriginalNameservers()
|
||||
@@ -576,14 +566,22 @@ func (r *registryConfigurator) setInterfaceRegistryKeyStringValue(key, value str
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteInterfaceRegistryKeyProperty removes a value from the interface key.
|
||||
// A value that is already gone, or an interface key that is, is not an error:
|
||||
// the caller asked for the value not to be there, and a cleanup that runs twice
|
||||
// has to reach its later steps on the second run as well.
|
||||
func (r *registryConfigurator) deleteInterfaceRegistryKeyProperty(propertyKey string) error {
|
||||
regKey, err := r.getInterfaceRegistryKey()
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND):
|
||||
log.Debugf("interface key of %s does not exist, nothing to delete %s from", r.guid, propertyKey)
|
||||
return nil
|
||||
case err != nil:
|
||||
return fmt.Errorf("get interface registry key: %w", err)
|
||||
}
|
||||
defer closer(regKey)
|
||||
|
||||
if err := regKey.DeleteValue(propertyKey); err != nil {
|
||||
if err := regKey.DeleteValue(propertyKey); err != nil && !errors.Is(err, registry.ErrNotExist) {
|
||||
return fmt.Errorf("delete registry key %s: %w", propertyKey, err)
|
||||
}
|
||||
return nil
|
||||
@@ -612,7 +610,12 @@ func (r *registryConfigurator) restoreHostDNS() error {
|
||||
|
||||
go r.flushDNSCache()
|
||||
|
||||
return nil
|
||||
// Last, and only on the way out, once no rule of ours is left: during a
|
||||
// session the store is where the rules of this run live, and emptying it
|
||||
// mid-session would have the next rule recreate it anyway. Propagated so a
|
||||
// failure keeps the shutdown state for the next run to retry, rather than
|
||||
// leaving the store to hold up every rule change from here on.
|
||||
return removeEmptyGPOPolicyStore()
|
||||
}
|
||||
|
||||
// removeDNSMatchPolicies deletes every NRPT rule this client may have created,
|
||||
@@ -651,6 +654,73 @@ func (r *registryConfigurator) restoreUncleanShutdownDNS() error {
|
||||
return r.restoreHostDNS()
|
||||
}
|
||||
|
||||
// useGPOPolicyStore reports whether NRPT rules have to go into the group policy
|
||||
// store, and clears an empty one out of the way first.
|
||||
//
|
||||
// The order is the point. A store left empty by an earlier run would otherwise
|
||||
// decide this run too, sending its rules somewhere the resolver only reads when
|
||||
// the policy engine next applies DNS client policy. Removing it before the
|
||||
// choice is made leaves the local store authoritative for the whole session,
|
||||
// including the first one after an upgrade.
|
||||
func useGPOPolicyStore() bool {
|
||||
if err := removeEmptyGPOPolicyStore(); err != nil {
|
||||
// Nothing to retry against here: the worst case is the run going
|
||||
// through the group policy store, which is where it would have gone
|
||||
// before this check existed.
|
||||
log.Warnf("%v", err)
|
||||
}
|
||||
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
log.Debugf("failed to open GPO DNS policy root: %v", err)
|
||||
return false
|
||||
}
|
||||
closer(k)
|
||||
|
||||
log.Infof("detected GPO DNS policy configuration, using policy store")
|
||||
return true
|
||||
}
|
||||
|
||||
// removeEmptyGPOPolicyStore deletes the group policy DnsPolicyConfig key once
|
||||
// nothing is left in it. The key survives the deletion of the last rule it
|
||||
// held, and the client treats its presence as "group policy configures the
|
||||
// NRPT", so an empty one left behind keeps every later run writing rules there.
|
||||
// Rules in that store reach the resolver only when the policy engine next
|
||||
// applies DNS client policy, and a rule this client writes belongs to no GPO,
|
||||
// so nothing schedules that application: both adding and removing a rule are
|
||||
// held up by a minute or more, and for a removal that is a catch-all rule
|
||||
// resolving every name over an interface that no longer exists. With the store
|
||||
// absent the local one is authoritative and a change applies at once.
|
||||
//
|
||||
// A store that still holds rules, values or subkeys of somebody else's is left
|
||||
// alone.
|
||||
func removeEmptyGPOPolicyStore() error {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE)
|
||||
switch {
|
||||
case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND):
|
||||
return nil
|
||||
case err != nil:
|
||||
return fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", GPODNSPolicyConfigRoot, err)
|
||||
}
|
||||
|
||||
info, err := k.Stat()
|
||||
closer(k)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat HKEY_LOCAL_MACHINE\\%s: %w", GPODNSPolicyConfigRoot, err)
|
||||
}
|
||||
|
||||
if info.SubKeyCount != 0 || info.ValueCount != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := registry.DeleteKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot); err != nil {
|
||||
return fmt.Errorf("delete empty HKEY_LOCAL_MACHINE\\%s: %w", GPODNSPolicyConfigRoot, err)
|
||||
}
|
||||
|
||||
log.Infof("removed the empty GPO DNS policy store, leaving the local one authoritative")
|
||||
return nil
|
||||
}
|
||||
|
||||
// listNRPTRuleKeys returns the names of our NRPT rule keys under a policy store
|
||||
// root. An absent root holds nothing to clean up, which is the normal state of
|
||||
// the GPO store on a machine without DNS Client policy.
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/winregistry"
|
||||
)
|
||||
|
||||
// TestNRPTEntriesCleanupOnConfigChange tests that old NRPT entries are properly cleaned up
|
||||
@@ -405,3 +407,130 @@ func TestNRPTDomainBatching(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveEmptyGPOPolicyStore verifies that cleanup takes the GPO policy
|
||||
// store itself with it once our rules are gone, since the store existing keeps
|
||||
// the local one from being applied, and that a store with somebody else's rule
|
||||
// in it is left alone.
|
||||
func TestRemoveEmptyGPOPolicyStore(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping registry integration test in short mode")
|
||||
}
|
||||
|
||||
t.Cleanup(func() { cleanupRegistryKeys(t) })
|
||||
cleanupRegistryKeys(t)
|
||||
|
||||
testIP := netip.MustParseAddr("100.64.0.1")
|
||||
cfg := ®istryConfigurator{gpo: true}
|
||||
|
||||
// a store holding a rule of ours is kept, because the rule is still applied
|
||||
require.NoError(t, cfg.addDNSMatchPolicy([]string{".example.com"}, testIP))
|
||||
exists, err := registryKeyExists(gpoDnsPolicyConfigMatchPath + "-0")
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists, "Should write the rule to the GPO policy store")
|
||||
|
||||
require.NoError(t, removeEmptyGPOPolicyStore())
|
||||
exists, err = registryKeyExists(GPODNSPolicyConfigRoot)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "Should keep a policy store that still holds a rule")
|
||||
|
||||
// once the rules are gone the store goes with them
|
||||
require.NoError(t, cfg.removeDNSMatchPolicies())
|
||||
require.NoError(t, removeEmptyGPOPolicyStore())
|
||||
|
||||
exists, err = registryKeyExists(GPODNSPolicyConfigRoot)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists, "Should remove the GPO policy store once it is empty")
|
||||
|
||||
// A store is not ours to remove while somebody else has a rule in it. The
|
||||
// rule is written volatile like our own: the rules above created the parent
|
||||
// chain volatile, and Windows refuses a stable subkey under a volatile
|
||||
// parent.
|
||||
foreignRule := GPODNSPolicyConfigRoot + `\{2A3B4C5D-6E7F-4041-8283-84858687888A}`
|
||||
foreignKey, _, err := winregistry.CreateVolatileKey(registry.LOCAL_MACHINE, foreignRule, registry.SET_VALUE)
|
||||
require.NoError(t, err, "Should create a foreign GPO rule")
|
||||
foreignKey.Close()
|
||||
t.Cleanup(func() {
|
||||
_ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignRule)
|
||||
_ = registry.DeleteKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot)
|
||||
})
|
||||
|
||||
require.NoError(t, cfg.removeDNSMatchPolicies())
|
||||
require.NoError(t, removeEmptyGPOPolicyStore())
|
||||
|
||||
exists, err = registryKeyExists(foreignRule)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "Should not remove a foreign rule")
|
||||
exists, err = registryKeyExists(GPODNSPolicyConfigRoot)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "Should keep a policy store that still holds a foreign rule")
|
||||
}
|
||||
|
||||
// TestDeleteInterfaceRegistryKeyPropertyTwice verifies that removing a value
|
||||
// that is already gone, or one on an interface key that is, reports success.
|
||||
// Teardown runs again after a failed cleanup, and the steps that follow this
|
||||
// one have to be reached on that second run.
|
||||
func TestDeleteInterfaceRegistryKeyPropertyTwice(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping registry integration test in short mode")
|
||||
}
|
||||
|
||||
testGUID := "{12345678-1234-1234-1234-123456789ABC}"
|
||||
interfacePath := InterfaceConfigPath + `\` + testGUID
|
||||
testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
|
||||
require.NoError(t, err, "Should create test interface registry key")
|
||||
testKey.Close()
|
||||
t.Cleanup(func() {
|
||||
_ = registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath)
|
||||
})
|
||||
|
||||
cfg := ®istryConfigurator{guid: testGUID}
|
||||
|
||||
require.NoError(t, cfg.setInterfaceRegistryKeyStringValue(interfaceConfigSearchListKey, "example.com"))
|
||||
require.NoError(t, cfg.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey))
|
||||
assert.NoError(t, cfg.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey),
|
||||
"Should report success for a value that is already gone")
|
||||
|
||||
// and with the interface key itself gone, as it is once the adapter is
|
||||
require.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath))
|
||||
assert.NoError(t, cfg.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey),
|
||||
"Should report success when the interface key does not exist")
|
||||
}
|
||||
|
||||
// TestUseGPOPolicyStoreClearsEmptyStore verifies that the store is cleared
|
||||
// before it is consulted, so an empty one left by an earlier run does not send
|
||||
// this run's rules to the group policy store. A store somebody else has a rule
|
||||
// in still decides where the rules go.
|
||||
func TestUseGPOPolicyStoreClearsEmptyStore(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping registry integration test in short mode")
|
||||
}
|
||||
|
||||
t.Cleanup(func() { cleanupRegistryKeys(t) })
|
||||
cleanupRegistryKeys(t)
|
||||
|
||||
// the leftover an earlier run used to keep, which the client read as
|
||||
// "group policy configures the NRPT" for every run after it
|
||||
emptyStore, _, err := winregistry.CreateVolatileKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.SET_VALUE)
|
||||
require.NoError(t, err, "Should create the GPO policy store")
|
||||
emptyStore.Close()
|
||||
|
||||
assert.False(t, useGPOPolicyStore(), "An empty store should not decide where the rules go")
|
||||
exists, err := registryKeyExists(GPODNSPolicyConfigRoot)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists, "Should clear the empty store before consulting it")
|
||||
|
||||
foreignRule := GPODNSPolicyConfigRoot + `\{2A3B4C5D-6E7F-4041-8283-84858687888A}`
|
||||
foreignKey, _, err := winregistry.CreateVolatileKey(registry.LOCAL_MACHINE, foreignRule, registry.SET_VALUE)
|
||||
require.NoError(t, err, "Should create a foreign GPO rule")
|
||||
foreignKey.Close()
|
||||
t.Cleanup(func() {
|
||||
_ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignRule)
|
||||
_ = registry.DeleteKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot)
|
||||
})
|
||||
|
||||
assert.True(t, useGPOPolicyStore(), "A store holding a rule should decide where the rules go")
|
||||
exists, err = registryKeyExists(GPODNSPolicyConfigRoot)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "Should keep a store that holds a rule")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user