Compare commits

...

3 Commits

Author SHA1 Message Date
Zoltan Papp
5a4d377066 [client] split config I/O functions into config_io.go 2026-03-11 22:06:03 +01:00
Zoltán Papp
92d5418c02 Add method to track and merge default interface blacklist entries
Introduce `mergeDefaultIFaceBlacklist` to ensure new defaults are appended to the interface blacklist while preserving user modifications. Add tests to verify behavior, including migration of old configs and handling of user-removed entries.
2026-03-09 16:48:14 +01:00
Zoltán Papp
09da089a90 [client] filter CGNAT and CNI addresses from ICE candidates
In Kubernetes environments using Cilium or similar CNI plugins, pod
 CIDR addresses (e.g. 100.65.x.x) from the RFC 6598 CGNAT range
 (100.64.0.0/10) were being gathered as valid ICE host candidates.
 This caused WireGuard endpoints to resolve to non-routable pod IPs,
 producing overlay-routed connections with degraded latency instead of
 true P2P paths between hosts.

 Add three layers of defense:
 - Expand the default interface blacklist with common Kubernetes CNI
   interface prefixes (cilium_, lxc, cali, flannel, cni, weave)
 - Filter local and remote ICE candidates whose addresses fall within
   the CGNAT range but outside the NetBird WireGuard network
 - Reject UDP mux writes to CGNAT addresses as a defense-in-depth
   fallback
2026-03-09 16:30:11 +01:00
5 changed files with 470 additions and 294 deletions

View File

@@ -22,6 +22,11 @@ import (
"github.com/netbirdio/netbird/client/iface/wgaddr"
)
// cgnatPrefix is the RFC 6598 Carrier-Grade NAT range (100.64.0.0/10).
// Addresses in this range are used by CNI plugins (Cilium, Calico, etc.) for pod networking
// and are not suitable for direct peer-to-peer connectivity between hosts.
var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10")
// FilterFn is a function that filters out candidates based on the address.
// If it returns true, the address is to be filtered. It also returns the prefix of matching route.
type FilterFn func(address netip.Addr) (bool, netip.Prefix, error)
@@ -175,6 +180,15 @@ func (u *UDPConn) performFilterCheck(addr net.Addr) error {
return fmt.Errorf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
}
// Filter addresses in the RFC 6598 CGNAT range (100.64.0.0/10) that are not part of the
// NetBird WireGuard network. These addresses are commonly assigned by Kubernetes CNI plugins
// (Cilium, Calico, etc.) for pod networking and are not routable between hosts.
if cgnatPrefix.Contains(a) && !u.address.Network.Contains(a) {
u.addrCache.Store(addr.String(), true)
log.Infof("Address %s is in the CGNAT range (%s), likely a CNI pod address, refusing to write", addr, cgnatPrefix)
return fmt.Errorf("address %s is in the CGNAT range (%s), refusing to write", addr, cgnatPrefix)
}
if isRouted, prefix, err := u.filterFn(a); err != nil {
log.Errorf("Failed to check if address %s is routed: %v", addr, err)
} else {

View File

@@ -165,6 +165,10 @@ func (w *WorkerICE) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HA
return
}
if candidateInCGNAT(candidate, w.config.WgConfig.WgInterface.Address().Network) {
return
}
if err := w.agent.AddRemoteCandidate(candidate); err != nil {
w.log.Errorf("error while handling remote candidate")
return
@@ -362,6 +366,10 @@ func (w *WorkerICE) onICECandidate(candidate ice.Candidate) {
return
}
if candidateInCGNAT(candidate, w.config.WgConfig.WgInterface.Address().Network) {
return
}
// TODO: reported port is incorrect for CandidateTypeHost, makes understanding ICE use via logs confusing as port is ignored
w.log.Debugf("discovered local candidate %s", candidate.String())
go func() {
@@ -496,6 +504,11 @@ func extraSrflxCandidate(candidate ice.Candidate) (*ice.CandidateServerReflexive
return ec, nil
}
// cgnatPrefix is the RFC 6598 Carrier-Grade NAT range (100.64.0.0/10).
// Addresses in this range are used by CNI plugins (Cilium, Calico, etc.) for pod networking
// and are not suitable for direct peer-to-peer connectivity between hosts.
var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10")
func candidateViaRoutes(candidate ice.Candidate, clientRoutes route.HAMap) bool {
addr, err := netip.ParseAddr(candidate.Address())
if err != nil {
@@ -524,6 +537,32 @@ func candidateViaRoutes(candidate ice.Candidate, clientRoutes route.HAMap) bool
return false
}
// candidateInCGNAT checks if a candidate address falls within the RFC 6598 CGNAT range (100.64.0.0/10).
// These addresses are commonly used by Kubernetes CNI plugins (Cilium, Calico) for pod networking
// and are not routable between hosts, making them unsuitable as ICE candidates.
// The wgNetwork parameter is the NetBird WireGuard network prefix — if the candidate address is within
// this network, it is not filtered here (it's handled separately by the NetBird network check).
func candidateInCGNAT(candidate ice.Candidate, wgNetwork netip.Prefix) bool {
addr, err := netip.ParseAddr(candidate.Address())
if err != nil {
return false
}
if !cgnatPrefix.Contains(addr) {
return false
}
// Don't filter if the address is within the WireGuard network itself —
// that's handled by the NetBird network membership check elsewhere.
if wgNetwork.IsValid() && wgNetwork.Contains(addr) {
return false
}
log.Debugf("Ignoring candidate [%s], its address %s is in the CGNAT range (%s) likely assigned by a CNI plugin",
candidate.String(), addr, cgnatPrefix)
return true
}
func isRelayCandidate(candidate ice.Candidate) bool {
return candidate.Type() == ice.CandidateTypeRelay
}

View File

@@ -1,9 +1,7 @@
package profilemanager
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/url"
"os"
@@ -22,7 +20,6 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
"github.com/netbirdio/netbird/client/ssh"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/util"
)
@@ -42,6 +39,8 @@ const (
var DefaultInterfaceBlacklist = []string{
iface.WgInterfaceDefault, "wt", "utun", "tun0", "zt", "ZeroTier", "wg", "ts",
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
// Kubernetes CNI interfaces
"cilium_", "cilium", "lxc", "cali", "flannel", "cni", "weave",
}
// ConfigInput carries configuration changes to the client
@@ -98,6 +97,7 @@ type Config struct {
WgPort int
NetworkMonitor *bool
IFaceBlackList []string
IFaceBlackListAppliedDefaults []string `json:",omitempty"`
DisableIPv6Discovery bool
RosenpassEnabled bool
RosenpassPermissive bool
@@ -357,10 +357,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
if len(config.IFaceBlackList) == 0 {
log.Infof("filling in interface blacklist with defaults: [ %s ]",
strings.Join(DefaultInterfaceBlacklist, " "))
config.IFaceBlackList = append(config.IFaceBlackList, DefaultInterfaceBlacklist...)
if changed := config.mergeDefaultIFaceBlacklist(); changed {
updated = true
}
@@ -594,6 +591,37 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
return updated, nil
}
// mergeDefaultIFaceBlacklist ensures that new entries added to DefaultInterfaceBlacklist
// are merged into an existing IFaceBlackList on upgrade, while respecting entries that
// the user deliberately removed. It tracks which defaults have been offered via
// IFaceBlackListAppliedDefaults so removals are not undone.
func (config *Config) mergeDefaultIFaceBlacklist() (updated bool) {
if len(config.IFaceBlackList) == 0 {
log.Infof("filling in interface blacklist with defaults: [ %s ]",
strings.Join(DefaultInterfaceBlacklist, " "))
config.IFaceBlackList = append(config.IFaceBlackList, DefaultInterfaceBlacklist...)
config.IFaceBlackListAppliedDefaults = append([]string{}, DefaultInterfaceBlacklist...)
return true
}
// Find defaults not yet tracked in AppliedDefaults — these are genuinely new.
// Entries already in AppliedDefaults were either kept or deliberately removed by the user.
newDefaults := util.SliceDiff(DefaultInterfaceBlacklist, config.IFaceBlackListAppliedDefaults)
if len(newDefaults) == 0 {
return false
}
// Only add entries not already present in the blacklist (avoid duplicates)
toAdd := util.SliceDiff(newDefaults, config.IFaceBlackList)
if len(toAdd) > 0 {
log.Infof("merging new default interface blacklist entries: [ %s ]",
strings.Join(toAdd, " "))
config.IFaceBlackList = append(config.IFaceBlackList, toAdd...)
}
config.IFaceBlackListAppliedDefaults = append(config.IFaceBlackListAppliedDefaults, newDefaults...)
return true
}
// parseURL parses and validates a service URL
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
@@ -639,290 +667,3 @@ func isPreSharedKeyHidden(preSharedKey *string) bool {
return false
}
// UpdateConfig update existing configuration according to input configuration and return with the configuration
func UpdateConfig(input ConfigInput) (*Config, error) {
configExists, err := fileExists(input.ConfigPath)
if err != nil {
return nil, fmt.Errorf("failed to check if config file exists: %w", err)
}
if !configExists {
return nil, fmt.Errorf("config file %s does not exist", input.ConfigPath)
}
return update(input)
}
// UpdateOrCreateConfig reads existing config or generates a new one
func UpdateOrCreateConfig(input ConfigInput) (*Config, error) {
configExists, err := fileExists(input.ConfigPath)
if err != nil {
return nil, fmt.Errorf("failed to check if config file exists: %w", err)
}
if !configExists {
log.Infof("generating new config %s", input.ConfigPath)
cfg, err := createNewConfig(input)
if err != nil {
return nil, err
}
err = util.WriteJsonWithRestrictedPermission(context.Background(), input.ConfigPath, cfg)
return cfg, err
}
if isPreSharedKeyHidden(input.PreSharedKey) {
input.PreSharedKey = nil
}
err = util.EnforcePermission(input.ConfigPath)
if err != nil {
log.Errorf("failed to enforce permission on config dir: %v", err)
}
return update(input)
}
func update(input ConfigInput) (*Config, error) {
config := &Config{}
if _, err := util.ReadJson(input.ConfigPath, config); err != nil {
return nil, err
}
updated, err := config.apply(input)
if err != nil {
return nil, err
}
if updated {
if err := util.WriteJson(context.Background(), input.ConfigPath, config); err != nil {
return nil, err
}
}
return config, nil
}
// GetConfig read config file and return with Config and if it was created. Errors out if it does not exist
func GetConfig(configPath string) (*Config, error) {
return readConfig(configPath, false)
}
// UpdateOldManagementURL checks whether client can switch to the new Management URL with port 443 and the management domain.
// If it can switch, then it updates the config and returns a new one. Otherwise, it returns the provided config.
// The check is performed only for the NetBird's managed version.
func UpdateOldManagementURL(ctx context.Context, config *Config, configPath string) (*Config, error) {
defaultManagementURL, err := parseURL("Management URL", DefaultManagementURL)
if err != nil {
return nil, err
}
parsedOldDefaultManagementURL, err := parseURL("Management URL", oldDefaultManagementURL)
if err != nil {
return nil, err
}
if config.ManagementURL.Hostname() != defaultManagementURL.Hostname() &&
config.ManagementURL.Hostname() != parsedOldDefaultManagementURL.Hostname() {
// only do the check for the NetBird's managed version
return config, nil
}
var mgmTlsEnabled bool
if config.ManagementURL.Scheme == "https" {
mgmTlsEnabled = true
}
if !mgmTlsEnabled {
// only do the check for HTTPs scheme (the hosted version of the Management service is always HTTPs)
return config, nil
}
if config.ManagementURL.Port() != managementLegacyPortString &&
config.ManagementURL.Hostname() == defaultManagementURL.Hostname() {
return config, nil
}
newURL, err := parseURL("Management URL", fmt.Sprintf("%s://%s:%d",
config.ManagementURL.Scheme, defaultManagementURL.Hostname(), 443))
if err != nil {
return nil, err
}
// here we check whether we could switch from the legacy 33073 port to the new 443
log.Infof("attempting to switch from the legacy Management URL %s to the new one %s",
config.ManagementURL.String(), newURL.String())
key, err := wgtypes.ParseKey(config.PrivateKey)
if err != nil {
log.Infof("couldn't switch to the new Management %s", newURL.String())
return config, err
}
client, err := mgm.NewClient(ctx, newURL.Host, key, mgmTlsEnabled)
if err != nil {
log.Infof("couldn't switch to the new Management %s", newURL.String())
return config, err
}
defer func() {
err = client.Close()
if err != nil {
log.Warnf("failed to close the Management service client %v", err)
}
}()
// gRPC check
_, err = client.GetServerPublicKey()
if err != nil {
log.Infof("couldn't switch to the new Management %s", newURL.String())
return nil, err
}
// everything is alright => update the config
newConfig, err := UpdateConfig(ConfigInput{
ManagementURL: newURL.String(),
ConfigPath: configPath,
})
if err != nil {
log.Infof("couldn't switch to the new Management %s", newURL.String())
return config, fmt.Errorf("failed updating config file: %v", err)
}
log.Infof("successfully switched to the new Management URL: %s", newURL.String())
return newConfig, nil
}
// CreateInMemoryConfig generate a new config but do not write out it to the store
func CreateInMemoryConfig(input ConfigInput) (*Config, error) {
return createNewConfig(input)
}
// ReadConfig read config file and return with Config. If it is not exists create a new with default values
func ReadConfig(configPath string) (*Config, error) {
return readConfig(configPath, true)
}
// ReadConfig read config file and return with Config. If it is not exists create a new with default values
func readConfig(configPath string, createIfMissing bool) (*Config, error) {
configExists, err := fileExists(configPath)
if err != nil {
return nil, fmt.Errorf("failed to check if config file exists: %w", err)
}
if configExists {
err := util.EnforcePermission(configPath)
if err != nil {
log.Errorf("failed to enforce permission on config dir: %v", err)
}
config := &Config{}
if _, err := util.ReadJson(configPath, config); err != nil {
return nil, err
}
// initialize through apply() without changes
if changed, err := config.apply(ConfigInput{}); err != nil {
return nil, err
} else if changed {
if err = WriteOutConfig(configPath, config); err != nil {
return nil, err
}
}
return config, nil
} else if !createIfMissing {
return nil, fmt.Errorf("config file %s does not exist", configPath)
}
cfg, err := createNewConfig(ConfigInput{ConfigPath: configPath})
if err != nil {
return nil, err
}
err = WriteOutConfig(configPath, cfg)
return cfg, err
}
// WriteOutConfig write put the prepared config to the given path
func WriteOutConfig(path string, config *Config) error {
return util.WriteJson(context.Background(), path, config)
}
// DirectWriteOutConfig writes config directly without atomic temp file operations.
// Use this on platforms where atomic writes are blocked (e.g., tvOS sandbox).
func DirectWriteOutConfig(path string, config *Config) error {
return util.DirectWriteJson(context.Background(), path, config)
}
// DirectUpdateOrCreateConfig is like UpdateOrCreateConfig but uses direct (non-atomic) writes.
// Use this on platforms where atomic writes are blocked (e.g., tvOS sandbox).
func DirectUpdateOrCreateConfig(input ConfigInput) (*Config, error) {
configExists, err := fileExists(input.ConfigPath)
if err != nil {
return nil, fmt.Errorf("failed to check if config file exists: %w", err)
}
if !configExists {
log.Infof("generating new config %s", input.ConfigPath)
cfg, err := createNewConfig(input)
if err != nil {
return nil, err
}
err = util.DirectWriteJson(context.Background(), input.ConfigPath, cfg)
return cfg, err
}
if isPreSharedKeyHidden(input.PreSharedKey) {
input.PreSharedKey = nil
}
// Enforce permissions on existing config files (same as UpdateOrCreateConfig)
if err := util.EnforcePermission(input.ConfigPath); err != nil {
log.Errorf("failed to enforce permission on config file: %v", err)
}
return directUpdate(input)
}
func directUpdate(input ConfigInput) (*Config, error) {
config := &Config{}
if _, err := util.ReadJson(input.ConfigPath, config); err != nil {
return nil, err
}
updated, err := config.apply(input)
if err != nil {
return nil, err
}
if updated {
if err := util.DirectWriteJson(context.Background(), input.ConfigPath, config); err != nil {
return nil, err
}
}
return config, nil
}
// ConfigToJSON serializes a Config struct to a JSON string.
// This is useful for exporting config to alternative storage mechanisms
// (e.g., UserDefaults on tvOS where file writes are blocked).
func ConfigToJSON(config *Config) (string, error) {
bs, err := json.MarshalIndent(config, "", " ")
if err != nil {
return "", err
}
return string(bs), nil
}
// ConfigFromJSON deserializes a JSON string to a Config struct.
// This is useful for restoring config from alternative storage mechanisms.
// After unmarshaling, defaults are applied to ensure the config is fully initialized.
func ConfigFromJSON(jsonStr string) (*Config, error) {
config := &Config{}
err := json.Unmarshal([]byte(jsonStr), config)
if err != nil {
return nil, err
}
// Apply defaults to ensure required fields are initialized.
// This mirrors what readConfig does after loading from file.
if _, err := config.apply(ConfigInput{}); err != nil {
return nil, fmt.Errorf("failed to apply defaults to config: %w", err)
}
return config, nil
}

View File

@@ -0,0 +1,301 @@
package profilemanager
import (
"context"
"encoding/json"
"fmt"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/util"
)
// UpdateConfig update existing configuration according to input configuration and return with the configuration
func UpdateConfig(input ConfigInput) (*Config, error) {
configExists, err := fileExists(input.ConfigPath)
if err != nil {
return nil, fmt.Errorf("failed to check if config file exists: %w", err)
}
if !configExists {
return nil, fmt.Errorf("config file %s does not exist", input.ConfigPath)
}
return update(input)
}
// UpdateOrCreateConfig reads existing config or generates a new one
func UpdateOrCreateConfig(input ConfigInput) (*Config, error) {
configExists, err := fileExists(input.ConfigPath)
if err != nil {
return nil, fmt.Errorf("failed to check if config file exists: %w", err)
}
if !configExists {
log.Infof("generating new config %s", input.ConfigPath)
cfg, err := createNewConfig(input)
if err != nil {
return nil, err
}
err = util.WriteJsonWithRestrictedPermission(context.Background(), input.ConfigPath, cfg)
return cfg, err
}
if isPreSharedKeyHidden(input.PreSharedKey) {
input.PreSharedKey = nil
}
err = util.EnforcePermission(input.ConfigPath)
if err != nil {
log.Errorf("failed to enforce permission on config dir: %v", err)
}
return update(input)
}
func update(input ConfigInput) (*Config, error) {
config := &Config{}
if _, err := util.ReadJson(input.ConfigPath, config); err != nil {
return nil, err
}
updated, err := config.apply(input)
if err != nil {
return nil, err
}
if updated {
if err := util.WriteJson(context.Background(), input.ConfigPath, config); err != nil {
return nil, err
}
}
return config, nil
}
// GetConfig read config file and return with Config and if it was created. Errors out if it does not exist
func GetConfig(configPath string) (*Config, error) {
return readConfig(configPath, false)
}
// UpdateOldManagementURL checks whether client can switch to the new Management URL with port 443 and the management domain.
// If it can switch, then it updates the config and returns a new one. Otherwise, it returns the provided config.
// The check is performed only for the NetBird's managed version.
func UpdateOldManagementURL(ctx context.Context, config *Config, configPath string) (*Config, error) {
defaultManagementURL, err := parseURL("Management URL", DefaultManagementURL)
if err != nil {
return nil, err
}
parsedOldDefaultManagementURL, err := parseURL("Management URL", oldDefaultManagementURL)
if err != nil {
return nil, err
}
if config.ManagementURL.Hostname() != defaultManagementURL.Hostname() &&
config.ManagementURL.Hostname() != parsedOldDefaultManagementURL.Hostname() {
// only do the check for the NetBird's managed version
return config, nil
}
var mgmTlsEnabled bool
if config.ManagementURL.Scheme == "https" {
mgmTlsEnabled = true
}
if !mgmTlsEnabled {
// only do the check for HTTPs scheme (the hosted version of the Management service is always HTTPs)
return config, nil
}
if config.ManagementURL.Port() != managementLegacyPortString &&
config.ManagementURL.Hostname() == defaultManagementURL.Hostname() {
return config, nil
}
newURL, err := parseURL("Management URL", fmt.Sprintf("%s://%s:%d",
config.ManagementURL.Scheme, defaultManagementURL.Hostname(), 443))
if err != nil {
return nil, err
}
// here we check whether we could switch from the legacy 33073 port to the new 443
log.Infof("attempting to switch from the legacy Management URL %s to the new one %s",
config.ManagementURL.String(), newURL.String())
key, err := wgtypes.ParseKey(config.PrivateKey)
if err != nil {
log.Infof("couldn't switch to the new Management %s", newURL.String())
return config, err
}
client, err := mgm.NewClient(ctx, newURL.Host, key, mgmTlsEnabled)
if err != nil {
log.Infof("couldn't switch to the new Management %s", newURL.String())
return config, err
}
defer func() {
err = client.Close()
if err != nil {
log.Warnf("failed to close the Management service client %v", err)
}
}()
// gRPC check
_, err = client.GetServerPublicKey()
if err != nil {
log.Infof("couldn't switch to the new Management %s", newURL.String())
return nil, err
}
// everything is alright => update the config
newConfig, err := UpdateConfig(ConfigInput{
ManagementURL: newURL.String(),
ConfigPath: configPath,
})
if err != nil {
log.Infof("couldn't switch to the new Management %s", newURL.String())
return config, fmt.Errorf("failed updating config file: %v", err)
}
log.Infof("successfully switched to the new Management URL: %s", newURL.String())
return newConfig, nil
}
// CreateInMemoryConfig generate a new config but do not write out it to the store
func CreateInMemoryConfig(input ConfigInput) (*Config, error) {
return createNewConfig(input)
}
// ReadConfig read config file and return with Config. If it is not exists create a new with default values
func ReadConfig(configPath string) (*Config, error) {
return readConfig(configPath, true)
}
// readConfig read config file and return with Config. If it is not exists create a new with default values
func readConfig(configPath string, createIfMissing bool) (*Config, error) {
configExists, err := fileExists(configPath)
if err != nil {
return nil, fmt.Errorf("failed to check if config file exists: %w", err)
}
if configExists {
err := util.EnforcePermission(configPath)
if err != nil {
log.Errorf("failed to enforce permission on config dir: %v", err)
}
config := &Config{}
if _, err := util.ReadJson(configPath, config); err != nil {
return nil, err
}
// initialize through apply() without changes
if changed, err := config.apply(ConfigInput{}); err != nil {
return nil, err
} else if changed {
if err = WriteOutConfig(configPath, config); err != nil {
return nil, err
}
}
return config, nil
} else if !createIfMissing {
return nil, fmt.Errorf("config file %s does not exist", configPath)
}
cfg, err := createNewConfig(ConfigInput{ConfigPath: configPath})
if err != nil {
return nil, err
}
err = WriteOutConfig(configPath, cfg)
return cfg, err
}
// WriteOutConfig write put the prepared config to the given path
func WriteOutConfig(path string, config *Config) error {
return util.WriteJson(context.Background(), path, config)
}
// DirectWriteOutConfig writes config directly without atomic temp file operations.
// Use this on platforms where atomic writes are blocked (e.g., tvOS sandbox).
func DirectWriteOutConfig(path string, config *Config) error {
return util.DirectWriteJson(context.Background(), path, config)
}
// DirectUpdateOrCreateConfig is like UpdateOrCreateConfig but uses direct (non-atomic) writes.
// Use this on platforms where atomic writes are blocked (e.g., tvOS sandbox).
func DirectUpdateOrCreateConfig(input ConfigInput) (*Config, error) {
configExists, err := fileExists(input.ConfigPath)
if err != nil {
return nil, fmt.Errorf("failed to check if config file exists: %w", err)
}
if !configExists {
log.Infof("generating new config %s", input.ConfigPath)
cfg, err := createNewConfig(input)
if err != nil {
return nil, err
}
err = util.DirectWriteJson(context.Background(), input.ConfigPath, cfg)
return cfg, err
}
if isPreSharedKeyHidden(input.PreSharedKey) {
input.PreSharedKey = nil
}
// Enforce permissions on existing config files (same as UpdateOrCreateConfig)
if err := util.EnforcePermission(input.ConfigPath); err != nil {
log.Errorf("failed to enforce permission on config file: %v", err)
}
return directUpdate(input)
}
func directUpdate(input ConfigInput) (*Config, error) {
config := &Config{}
if _, err := util.ReadJson(input.ConfigPath, config); err != nil {
return nil, err
}
updated, err := config.apply(input)
if err != nil {
return nil, err
}
if updated {
if err := util.DirectWriteJson(context.Background(), input.ConfigPath, config); err != nil {
return nil, err
}
}
return config, nil
}
// ConfigToJSON serializes a Config struct to a JSON string.
// This is useful for exporting config to alternative storage mechanisms
// (e.g., UserDefaults on tvOS where file writes are blocked).
func ConfigToJSON(config *Config) (string, error) {
bs, err := json.MarshalIndent(config, "", " ")
if err != nil {
return "", err
}
return string(bs), nil
}
// ConfigFromJSON deserializes a JSON string to a Config struct.
// This is useful for restoring config from alternative storage mechanisms.
// After unmarshaling, defaults are applied to ensure the config is fully initialized.
func ConfigFromJSON(jsonStr string) (*Config, error) {
config := &Config{}
err := json.Unmarshal([]byte(jsonStr), config)
if err != nil {
return nil, err
}
// Apply defaults to ensure required fields are initialized.
// This mirrors what readConfig does after loading from file.
if _, err := config.apply(ConfigInput{}); err != nil {
return nil, fmt.Errorf("failed to apply defaults to config: %w", err)
}
return config, nil
}

View File

@@ -108,6 +108,87 @@ func TestExtraIFaceBlackList(t *testing.T) {
assert.Contains(t, readConf.(*Config).IFaceBlackList, "eth1")
}
func TestIFaceBlackListMigratesNewDefaults(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.json")
// Create a config that simulates an old install with a partial IFaceBlackList
// (missing the newer CNI entries like "cilium_", "cali", etc.)
config, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: configPath,
})
require.NoError(t, err)
// Simulate an old config that predates AppliedDefaults tracking:
// it has only the original entries, no CNI prefixes, and no AppliedDefaults.
oldList := []string{iface.WgInterfaceDefault, "wt", "utun", "tun0", "zt", "ZeroTier", "wg", "ts",
"Tailscale", "tailscale", "docker", "veth", "br-", "lo"}
config.IFaceBlackList = oldList
config.IFaceBlackListAppliedDefaults = nil
err = WriteOutConfig(configPath, config)
require.NoError(t, err)
// Re-read the config — apply() should merge in missing defaults
reloaded, err := GetConfig(configPath)
require.NoError(t, err)
for _, entry := range DefaultInterfaceBlacklist {
assert.Contains(t, reloaded.IFaceBlackList, entry,
"IFaceBlackList should contain default entry %q after migration", entry)
}
// Verify no duplicates were introduced
seen := make(map[string]bool)
for _, entry := range reloaded.IFaceBlackList {
assert.False(t, seen[entry], "duplicate entry %q in IFaceBlackList", entry)
seen[entry] = true
}
// AppliedDefaults should now track all current defaults
for _, entry := range DefaultInterfaceBlacklist {
assert.Contains(t, reloaded.IFaceBlackListAppliedDefaults, entry,
"AppliedDefaults should track %q", entry)
}
// Re-read again — should not change (idempotent)
reloaded2, err := GetConfig(configPath)
require.NoError(t, err)
assert.Equal(t, reloaded.IFaceBlackList, reloaded2.IFaceBlackList,
"IFaceBlackList should be stable on subsequent reads")
}
func TestIFaceBlackListRespectsUserRemoval(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.json")
// Create a fresh config (all defaults applied)
config, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: configPath,
})
require.NoError(t, err)
require.Contains(t, config.IFaceBlackList, "cali")
// User deliberately removes "cali" from their blacklist
filtered := make([]string, 0, len(config.IFaceBlackList))
for _, entry := range config.IFaceBlackList {
if entry != "cali" {
filtered = append(filtered, entry)
}
}
config.IFaceBlackList = filtered
err = WriteOutConfig(configPath, config)
require.NoError(t, err)
// Re-read — "cali" should NOT be re-added because it's in AppliedDefaults
reloaded, err := GetConfig(configPath)
require.NoError(t, err)
assert.NotContains(t, reloaded.IFaceBlackList, "cali",
"user-removed entry should not be re-added")
// AppliedDefaults should still contain "cali" (it was offered)
assert.Contains(t, reloaded.IFaceBlackListAppliedDefaults, "cali")
}
func TestHiddenPreSharedKey(t *testing.T) {
hidden := "**********"
samplePreSharedKey := "mysecretpresharedkey"