[client] Feat: Support Multiple Profiles (#3980)

[client] Feat: Support Multiple Profiles (#3980)
This commit is contained in:
hakansa
2025-07-25 16:54:46 +03:00
committed by GitHub
parent e0d9306b05
commit cb8b6ca59b
53 changed files with 4651 additions and 768 deletions

View File

@@ -0,0 +1,733 @@
package profilemanager
import (
"context"
"crypto/tls"
"fmt"
"net/url"
"os"
"path/filepath"
"reflect"
"runtime"
"slices"
"strings"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
log "github.com/sirupsen/logrus"
"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/management/client"
"github.com/netbirdio/netbird/management/domain"
"github.com/netbirdio/netbird/util"
)
const (
// managementLegacyPortString is the port that was used before by the Management gRPC server.
// It is used for backward compatibility now.
// NB: hardcoded from github.com/netbirdio/netbird/management/cmd to avoid import
managementLegacyPortString = "33073"
// DefaultManagementURL points to the NetBird's cloud management endpoint
DefaultManagementURL = "https://api.netbird.io:443"
// oldDefaultManagementURL points to the NetBird's old cloud management endpoint
oldDefaultManagementURL = "https://api.wiretrustee.com:443"
// DefaultAdminURL points to NetBird's cloud management console
DefaultAdminURL = "https://app.netbird.io:443"
)
var DefaultInterfaceBlacklist = []string{
iface.WgInterfaceDefault, "wt", "utun", "tun0", "zt", "ZeroTier", "wg", "ts",
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
}
// ConfigInput carries configuration changes to the client
type ConfigInput struct {
ManagementURL string
AdminURL string
ConfigPath string
StateFilePath string
PreSharedKey *string
ServerSSHAllowed *bool
NATExternalIPs []string
CustomDNSAddress []byte
RosenpassEnabled *bool
RosenpassPermissive *bool
InterfaceName *string
WireguardPort *int
NetworkMonitor *bool
DisableAutoConnect *bool
ExtraIFaceBlackList []string
DNSRouteInterval *time.Duration
ClientCertPath string
ClientCertKeyPath string
DisableClientRoutes *bool
DisableServerRoutes *bool
DisableDNS *bool
DisableFirewall *bool
BlockLANAccess *bool
BlockInbound *bool
DisableNotifications *bool
DNSLabels domain.List
LazyConnectionEnabled *bool
}
// Config Configuration type
type Config struct {
// Wireguard private key of local peer
PrivateKey string
PreSharedKey string
ManagementURL *url.URL
AdminURL *url.URL
WgIface string
WgPort int
NetworkMonitor *bool
IFaceBlackList []string
DisableIPv6Discovery bool
RosenpassEnabled bool
RosenpassPermissive bool
ServerSSHAllowed *bool
DisableClientRoutes bool
DisableServerRoutes bool
DisableDNS bool
DisableFirewall bool
BlockLANAccess bool
BlockInbound bool
DisableNotifications *bool
DNSLabels domain.List
// SSHKey is a private SSH key in a PEM format
SSHKey string
// ExternalIP mappings, if different from the host interface IP
//
// External IP must not be behind a CGNAT and port-forwarding for incoming UDP packets from WgPort on ExternalIP
// to WgPort on host interface IP must be present. This can take form of single port-forwarding rule, 1:1 DNAT
// mapping ExternalIP to host interface IP, or a NAT DMZ to host interface IP.
//
// A single mapping will take the form of: external[/internal]
// external (required): either the external IP address or "stun" to use STUN to determine the external IP address
// internal (optional): either the internal/interface IP address or an interface name
//
// examples:
// "12.34.56.78" => all interfaces IPs will be mapped to external IP of 12.34.56.78
// "12.34.56.78/eth0" => IPv4 assigned to interface eth0 will be mapped to external IP of 12.34.56.78
// "12.34.56.78/10.1.2.3" => interface IP 10.1.2.3 will be mapped to external IP of 12.34.56.78
NATExternalIPs []string
// CustomDNSAddress sets the DNS resolver listening address in format ip:port
CustomDNSAddress string
// DisableAutoConnect determines whether the client should not start with the service
// it's set to false by default due to backwards compatibility
DisableAutoConnect bool
// DNSRouteInterval is the interval in which the DNS routes are updated
DNSRouteInterval time.Duration
// Path to a certificate used for mTLS authentication
ClientCertPath string
// Path to corresponding private key of ClientCertPath
ClientCertKeyPath string
ClientCertKeyPair *tls.Certificate `json:"-"`
LazyConnectionEnabled bool
}
var ConfigDirOverride string
func getConfigDir() (string, error) {
if ConfigDirOverride != "" {
return ConfigDirOverride, nil
}
configDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
configDir = filepath.Join(configDir, "netbird")
if _, err := os.Stat(configDir); os.IsNotExist(err) {
if err := os.MkdirAll(configDir, 0755); err != nil {
return "", err
}
}
return configDir, nil
}
func getConfigDirForUser(username string) (string, error) {
if ConfigDirOverride != "" {
return ConfigDirOverride, nil
}
username = sanitizeProfileName(username)
configDir := filepath.Join(DefaultConfigPathDir, username)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
if err := os.MkdirAll(configDir, 0600); err != nil {
return "", err
}
}
return configDir, nil
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return !os.IsNotExist(err)
}
// createNewConfig creates a new config generating a new Wireguard key and saving to file
func createNewConfig(input ConfigInput) (*Config, error) {
config := &Config{
// defaults to false only for new (post 0.26) configurations
ServerSSHAllowed: util.False(),
}
if _, err := config.apply(input); err != nil {
return nil, err
}
return config, nil
}
func (config *Config) apply(input ConfigInput) (updated bool, err error) {
if config.ManagementURL == nil {
log.Infof("using default Management URL %s", DefaultManagementURL)
config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL)
if err != nil {
return false, err
}
}
if input.ManagementURL != "" && input.ManagementURL != config.ManagementURL.String() {
log.Infof("new Management URL provided, updated to %#v (old value %#v)",
input.ManagementURL, config.ManagementURL.String())
URL, err := parseURL("Management URL", input.ManagementURL)
if err != nil {
return false, err
}
config.ManagementURL = URL
updated = true
} else if config.ManagementURL == nil {
log.Infof("using default Management URL %s", DefaultManagementURL)
config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL)
if err != nil {
return false, err
}
}
if config.AdminURL == nil {
log.Infof("using default Admin URL %s", DefaultManagementURL)
config.AdminURL, err = parseURL("Admin URL", DefaultAdminURL)
if err != nil {
return false, err
}
}
if input.AdminURL != "" && input.AdminURL != config.AdminURL.String() {
log.Infof("new Admin Panel URL provided, updated to %#v (old value %#v)",
input.AdminURL, config.AdminURL.String())
newURL, err := parseURL("Admin Panel URL", input.AdminURL)
if err != nil {
return updated, err
}
config.AdminURL = newURL
updated = true
}
if config.PrivateKey == "" {
log.Infof("generated new Wireguard key")
config.PrivateKey = generateKey()
updated = true
}
if config.SSHKey == "" {
log.Infof("generated new SSH key")
pem, err := ssh.GeneratePrivateKey(ssh.ED25519)
if err != nil {
return false, err
}
config.SSHKey = string(pem)
updated = true
}
if input.WireguardPort != nil && *input.WireguardPort != config.WgPort {
log.Infof("updating Wireguard port %d (old value %d)",
*input.WireguardPort, config.WgPort)
config.WgPort = *input.WireguardPort
updated = true
}
if input.InterfaceName != nil && *input.InterfaceName != config.WgIface {
log.Infof("updating Wireguard interface %#v (old value %#v)",
*input.InterfaceName, config.WgIface)
config.WgIface = *input.InterfaceName
updated = true
} else if config.WgIface == "" {
config.WgIface = iface.WgInterfaceDefault
log.Infof("using default Wireguard interface %s", config.WgIface)
updated = true
}
if input.NATExternalIPs != nil && !reflect.DeepEqual(config.NATExternalIPs, input.NATExternalIPs) {
log.Infof("updating NAT External IP [ %s ] (old value: [ %s ])",
strings.Join(input.NATExternalIPs, " "),
strings.Join(config.NATExternalIPs, " "))
config.NATExternalIPs = input.NATExternalIPs
updated = true
}
if input.PreSharedKey != nil && *input.PreSharedKey != config.PreSharedKey {
log.Infof("new pre-shared key provided, replacing old key")
config.PreSharedKey = *input.PreSharedKey
updated = true
}
if input.RosenpassEnabled != nil && *input.RosenpassEnabled != config.RosenpassEnabled {
log.Infof("switching Rosenpass to %t", *input.RosenpassEnabled)
config.RosenpassEnabled = *input.RosenpassEnabled
updated = true
}
if input.RosenpassPermissive != nil && *input.RosenpassPermissive != config.RosenpassPermissive {
log.Infof("switching Rosenpass permissive to %t", *input.RosenpassPermissive)
config.RosenpassPermissive = *input.RosenpassPermissive
updated = true
}
if input.NetworkMonitor != nil && input.NetworkMonitor != config.NetworkMonitor {
log.Infof("switching Network Monitor to %t", *input.NetworkMonitor)
config.NetworkMonitor = input.NetworkMonitor
updated = true
}
if config.NetworkMonitor == nil {
// enable network monitoring by default on windows and darwin clients
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
enabled := true
config.NetworkMonitor = &enabled
updated = true
}
}
if input.CustomDNSAddress != nil && string(input.CustomDNSAddress) != config.CustomDNSAddress {
log.Infof("updating custom DNS address %#v (old value %#v)",
string(input.CustomDNSAddress), config.CustomDNSAddress)
config.CustomDNSAddress = string(input.CustomDNSAddress)
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...)
updated = true
}
if len(input.ExtraIFaceBlackList) > 0 {
for _, iFace := range util.SliceDiff(input.ExtraIFaceBlackList, config.IFaceBlackList) {
log.Infof("adding new entry to interface blacklist: %s", iFace)
config.IFaceBlackList = append(config.IFaceBlackList, iFace)
updated = true
}
}
if input.DisableAutoConnect != nil && *input.DisableAutoConnect != config.DisableAutoConnect {
if *input.DisableAutoConnect {
log.Infof("turning off automatic connection on startup")
} else {
log.Infof("enabling automatic connection on startup")
}
config.DisableAutoConnect = *input.DisableAutoConnect
updated = true
}
if input.ServerSSHAllowed != nil && *input.ServerSSHAllowed != *config.ServerSSHAllowed {
if *input.ServerSSHAllowed {
log.Infof("enabling SSH server")
} else {
log.Infof("disabling SSH server")
}
config.ServerSSHAllowed = input.ServerSSHAllowed
updated = true
} else if config.ServerSSHAllowed == nil {
if runtime.GOOS == "android" {
// default to disabled SSH on Android for security
log.Infof("setting SSH server to false by default on Android")
config.ServerSSHAllowed = util.False()
} else {
// enables SSH for configs from old versions to preserve backwards compatibility
log.Infof("falling back to enabled SSH server for pre-existing configuration")
config.ServerSSHAllowed = util.True()
}
updated = true
}
if input.DNSRouteInterval != nil && *input.DNSRouteInterval != config.DNSRouteInterval {
log.Infof("updating DNS route interval to %s (old value %s)",
input.DNSRouteInterval.String(), config.DNSRouteInterval.String())
config.DNSRouteInterval = *input.DNSRouteInterval
updated = true
} else if config.DNSRouteInterval == 0 {
config.DNSRouteInterval = dynamic.DefaultInterval
log.Infof("using default DNS route interval %s", config.DNSRouteInterval)
updated = true
}
if input.DisableClientRoutes != nil && *input.DisableClientRoutes != config.DisableClientRoutes {
if *input.DisableClientRoutes {
log.Infof("disabling client routes")
} else {
log.Infof("enabling client routes")
}
config.DisableClientRoutes = *input.DisableClientRoutes
updated = true
}
if input.DisableServerRoutes != nil && *input.DisableServerRoutes != config.DisableServerRoutes {
if *input.DisableServerRoutes {
log.Infof("disabling server routes")
} else {
log.Infof("enabling server routes")
}
config.DisableServerRoutes = *input.DisableServerRoutes
updated = true
}
if input.DisableDNS != nil && *input.DisableDNS != config.DisableDNS {
if *input.DisableDNS {
log.Infof("disabling DNS configuration")
} else {
log.Infof("enabling DNS configuration")
}
config.DisableDNS = *input.DisableDNS
updated = true
}
if input.DisableFirewall != nil && *input.DisableFirewall != config.DisableFirewall {
if *input.DisableFirewall {
log.Infof("disabling firewall configuration")
} else {
log.Infof("enabling firewall configuration")
}
config.DisableFirewall = *input.DisableFirewall
updated = true
}
if input.BlockLANAccess != nil && *input.BlockLANAccess != config.BlockLANAccess {
if *input.BlockLANAccess {
log.Infof("blocking LAN access")
} else {
log.Infof("allowing LAN access")
}
config.BlockLANAccess = *input.BlockLANAccess
updated = true
}
if input.BlockInbound != nil && *input.BlockInbound != config.BlockInbound {
if *input.BlockInbound {
log.Infof("blocking inbound connections")
} else {
log.Infof("allowing inbound connections")
}
config.BlockInbound = *input.BlockInbound
updated = true
}
if input.DisableNotifications != nil && input.DisableNotifications != config.DisableNotifications {
if *input.DisableNotifications {
log.Infof("disabling notifications")
} else {
log.Infof("enabling notifications")
}
config.DisableNotifications = input.DisableNotifications
updated = true
}
if config.DisableNotifications == nil {
disabled := true
config.DisableNotifications = &disabled
log.Infof("setting notifications to disabled by default")
updated = true
}
if input.ClientCertKeyPath != "" {
config.ClientCertKeyPath = input.ClientCertKeyPath
updated = true
}
if input.ClientCertPath != "" {
config.ClientCertPath = input.ClientCertPath
updated = true
}
if config.ClientCertPath != "" && config.ClientCertKeyPath != "" {
cert, err := tls.LoadX509KeyPair(config.ClientCertPath, config.ClientCertKeyPath)
if err != nil {
log.Error("Failed to load mTLS cert/key pair: ", err)
} else {
config.ClientCertKeyPair = &cert
log.Info("Loaded client mTLS cert/key pair")
}
}
if input.DNSLabels != nil && !slices.Equal(config.DNSLabels, input.DNSLabels) {
log.Infof("updating DNS labels [ %s ] (old value: [ %s ])",
input.DNSLabels.SafeString(),
config.DNSLabels.SafeString())
config.DNSLabels = input.DNSLabels
updated = true
}
if input.LazyConnectionEnabled != nil && *input.LazyConnectionEnabled != config.LazyConnectionEnabled {
log.Infof("switching lazy connection to %t", *input.LazyConnectionEnabled)
config.LazyConnectionEnabled = *input.LazyConnectionEnabled
updated = true
}
return updated, nil
}
// parseURL parses and validates a service URL
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
if err != nil {
log.Errorf("failed parsing %s URL %s: [%s]", serviceName, serviceURL, err.Error())
return nil, err
}
if parsedMgmtURL.Scheme != "https" && parsedMgmtURL.Scheme != "http" {
return nil, fmt.Errorf(
"invalid %s URL provided %s. Supported format [http|https]://[host]:[port]",
serviceName, serviceURL)
}
if parsedMgmtURL.Port() == "" {
switch parsedMgmtURL.Scheme {
case "https":
parsedMgmtURL.Host += ":443"
case "http":
parsedMgmtURL.Host += ":80"
default:
log.Infof("unable to determine a default port for schema %s in URL %s", parsedMgmtURL.Scheme, serviceURL)
}
}
return parsedMgmtURL, err
}
// generateKey generates a new Wireguard private key
func generateKey() string {
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
panic(err)
}
return key.String()
}
// don't overwrite pre-shared key if we receive asterisks from UI
func isPreSharedKeyHidden(preSharedKey *string) bool {
if preSharedKey != nil && *preSharedKey == "**********" {
return true
}
return false
}
// UpdateConfig update existing configuration according to input configuration and return with the configuration
func UpdateConfig(input ConfigInput) (*Config, error) {
if !fileExists(input.ConfigPath) {
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) {
if !fileExists(input.ConfigPath) {
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
}
func GetConfig(configPath string) (*Config, error) {
if !fileExists(configPath) {
return nil, fmt.Errorf("config file %s does not exist", configPath)
}
config := &Config{}
if _, err := util.ReadJson(configPath, config); err != nil {
return nil, fmt.Errorf("failed to read config file %s: %w", configPath, err)
}
return config, nil
}
// 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) {
if fileExists(configPath) {
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
}
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)
}

View File

@@ -0,0 +1,199 @@
package profilemanager
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/util"
)
func TestGetConfig(t *testing.T) {
// case 1: new default config has to be generated
config, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
})
if err != nil {
return
}
assert.Equal(t, config.ManagementURL.String(), DefaultManagementURL)
assert.Equal(t, config.AdminURL.String(), DefaultAdminURL)
managementURL := "https://test.management.url:33071"
adminURL := "https://app.admin.url:443"
path := filepath.Join(t.TempDir(), "config.json")
preSharedKey := "preSharedKey"
// case 2: new config has to be generated
config, err = UpdateOrCreateConfig(ConfigInput{
ManagementURL: managementURL,
AdminURL: adminURL,
ConfigPath: path,
PreSharedKey: &preSharedKey,
})
if err != nil {
return
}
assert.Equal(t, config.ManagementURL.String(), managementURL)
assert.Equal(t, config.PreSharedKey, preSharedKey)
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
t.Errorf("config file was expected to be created under path %s", path)
}
// case 3: existing config -> fetch it
config, err = UpdateOrCreateConfig(ConfigInput{
ManagementURL: managementURL,
AdminURL: adminURL,
ConfigPath: path,
PreSharedKey: &preSharedKey,
})
if err != nil {
return
}
assert.Equal(t, config.ManagementURL.String(), managementURL)
assert.Equal(t, config.PreSharedKey, preSharedKey)
// case 4: existing config, but new managementURL has been provided -> update config
newManagementURL := "https://test.newManagement.url:33071"
config, err = UpdateOrCreateConfig(ConfigInput{
ManagementURL: newManagementURL,
AdminURL: adminURL,
ConfigPath: path,
PreSharedKey: &preSharedKey,
})
if err != nil {
return
}
assert.Equal(t, config.ManagementURL.String(), newManagementURL)
assert.Equal(t, config.PreSharedKey, preSharedKey)
// read once more to make sure that config file has been updated with the new management URL
readConf, err := util.ReadJson(path, config)
if err != nil {
return
}
assert.Equal(t, readConf.(*Config).ManagementURL.String(), newManagementURL)
}
func TestExtraIFaceBlackList(t *testing.T) {
extraIFaceBlackList := []string{"eth1"}
path := filepath.Join(t.TempDir(), "config.json")
config, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: path,
ExtraIFaceBlackList: extraIFaceBlackList,
})
if err != nil {
return
}
assert.Contains(t, config.IFaceBlackList, "eth1")
readConf, err := util.ReadJson(path, config)
if err != nil {
return
}
assert.Contains(t, readConf.(*Config).IFaceBlackList, "eth1")
}
func TestHiddenPreSharedKey(t *testing.T) {
hidden := "**********"
samplePreSharedKey := "mysecretpresharedkey"
tests := []struct {
name string
preSharedKey *string
want string
}{
{"nil", nil, ""},
{"hidden", &hidden, ""},
{"filled", &samplePreSharedKey, samplePreSharedKey},
}
// generate default cfg
cfgFile := filepath.Join(t.TempDir(), "config.json")
_, _ = UpdateOrCreateConfig(ConfigInput{
ConfigPath: cfgFile,
})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: cfgFile,
PreSharedKey: tt.preSharedKey,
})
if err != nil {
t.Fatalf("failed to get cfg: %s", err)
}
if cfg.PreSharedKey != tt.want {
t.Fatalf("invalid preshared key: '%s', expected: '%s' ", cfg.PreSharedKey, tt.want)
}
})
}
}
func TestUpdateOldManagementURL(t *testing.T) {
tests := []struct {
name string
previousManagementURL string
expectedManagementURL string
fileShouldNotChange bool
}{
{
name: "Update old management URL with legacy port",
previousManagementURL: "https://api.wiretrustee.com:33073",
expectedManagementURL: DefaultManagementURL,
},
{
name: "Update old management URL",
previousManagementURL: oldDefaultManagementURL,
expectedManagementURL: DefaultManagementURL,
},
{
name: "No update needed when management URL is up to date",
previousManagementURL: DefaultManagementURL,
expectedManagementURL: DefaultManagementURL,
fileShouldNotChange: true,
},
{
name: "No update needed when not using cloud management",
previousManagementURL: "https://netbird.example.com:33073",
expectedManagementURL: "https://netbird.example.com:33073",
fileShouldNotChange: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.json")
config, err := UpdateOrCreateConfig(ConfigInput{
ManagementURL: tt.previousManagementURL,
ConfigPath: configPath,
})
require.NoError(t, err, "failed to create testing config")
previousStats, err := os.Stat(configPath)
require.NoError(t, err, "failed to create testing config stats")
resultConfig, err := UpdateOldManagementURL(context.TODO(), config, configPath)
require.NoError(t, err, "got error when updating old management url")
require.Equal(t, tt.expectedManagementURL, resultConfig.ManagementURL.String())
newStats, err := os.Stat(configPath)
require.NoError(t, err, "failed to create testing config stats")
switch tt.fileShouldNotChange {
case true:
require.Equal(t, previousStats.ModTime(), newStats.ModTime(), "file should not change")
case false:
require.NotEqual(t, previousStats.ModTime(), newStats.ModTime(), "file should have changed")
}
})
}
}

View File

@@ -0,0 +1,9 @@
package profilemanager
import "errors"
var (
ErrProfileNotFound = errors.New("profile not found")
ErrProfileAlreadyExists = errors.New("profile already exists")
ErrNoActiveProfile = errors.New("no active profile set")
)

View File

@@ -0,0 +1,133 @@
package profilemanager
import (
"fmt"
"os"
"os/user"
"path/filepath"
"strings"
"sync"
"unicode"
log "github.com/sirupsen/logrus"
)
const (
defaultProfileName = "default"
activeProfileStateFilename = "active_profile.txt"
)
type Profile struct {
Name string
IsActive bool
}
func (p *Profile) FilePath() (string, error) {
if p.Name == "" {
return "", fmt.Errorf("active profile name is empty")
}
if p.Name == defaultProfileName {
return DefaultConfigPath, nil
}
username, err := user.Current()
if err != nil {
return "", fmt.Errorf("failed to get current user: %w", err)
}
configDir, err := getConfigDirForUser(username.Username)
if err != nil {
return "", fmt.Errorf("failed to get config directory for user %s: %w", username.Username, err)
}
return filepath.Join(configDir, p.Name+".json"), nil
}
func (p *Profile) IsDefault() bool {
return p.Name == defaultProfileName
}
type ProfileManager struct {
mu sync.Mutex
}
func NewProfileManager() *ProfileManager {
return &ProfileManager{}
}
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
pm.mu.Lock()
defer pm.mu.Unlock()
prof := pm.getActiveProfileState()
return &Profile{Name: prof}, nil
}
func (pm *ProfileManager) SwitchProfile(profileName string) error {
profileName = sanitizeProfileName(profileName)
if err := pm.setActiveProfileState(profileName); err != nil {
return fmt.Errorf("failed to switch profile: %w", err)
}
return nil
}
// sanitizeProfileName sanitizes the username by removing any invalid characters and spaces.
func sanitizeProfileName(name string) string {
return strings.Map(func(r rune) rune {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' {
return r
}
// drop everything else
return -1
}, name)
}
func (pm *ProfileManager) getActiveProfileState() string {
configDir, err := getConfigDir()
if err != nil {
log.Warnf("failed to get config directory: %v", err)
return defaultProfileName
}
statePath := filepath.Join(configDir, activeProfileStateFilename)
prof, err := os.ReadFile(statePath)
if err != nil {
if !os.IsNotExist(err) {
log.Warnf("failed to read active profile state: %v", err)
} else {
if err := pm.setActiveProfileState(defaultProfileName); err != nil {
log.Warnf("failed to set default profile state: %v", err)
}
}
return defaultProfileName
}
profileName := strings.TrimSpace(string(prof))
if profileName == "" {
log.Warnf("active profile state is empty, using default profile: %s", defaultProfileName)
return defaultProfileName
}
return profileName
}
func (pm *ProfileManager) setActiveProfileState(profileName string) error {
configDir, err := getConfigDir()
if err != nil {
return fmt.Errorf("failed to get config directory: %w", err)
}
statePath := filepath.Join(configDir, activeProfileStateFilename)
err = os.WriteFile(statePath, []byte(profileName), 0600)
if err != nil {
return fmt.Errorf("failed to write active profile state: %w", err)
}
return nil
}

View File

@@ -0,0 +1,151 @@
package profilemanager
import (
"os"
"os/user"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func withTempConfigDir(t *testing.T, testFunc func(configDir string)) {
t.Helper()
tempDir := t.TempDir()
t.Setenv("NETBIRD_CONFIG_DIR", tempDir)
defer os.Unsetenv("NETBIRD_CONFIG_DIR")
testFunc(tempDir)
}
func withPatchedGlobals(t *testing.T, configDir string, testFunc func()) {
origDefaultConfigPathDir := DefaultConfigPathDir
origDefaultConfigPath := DefaultConfigPath
origActiveProfileStatePath := ActiveProfileStatePath
origOldDefaultConfigPath := oldDefaultConfigPath
origConfigDirOverride := ConfigDirOverride
DefaultConfigPathDir = configDir
DefaultConfigPath = filepath.Join(configDir, "default.json")
ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json")
oldDefaultConfigPath = filepath.Join(configDir, "old_config.json")
ConfigDirOverride = configDir
// Clean up any files in the config dir to ensure isolation
os.RemoveAll(configDir)
os.MkdirAll(configDir, 0755) //nolint: errcheck
defer func() {
DefaultConfigPathDir = origDefaultConfigPathDir
DefaultConfigPath = origDefaultConfigPath
ActiveProfileStatePath = origActiveProfileStatePath
oldDefaultConfigPath = origOldDefaultConfigPath
ConfigDirOverride = origConfigDirOverride
}()
testFunc()
}
func TestServiceManager_CreateAndGetDefaultProfile(t *testing.T) {
withTempConfigDir(t, func(configDir string) {
withPatchedGlobals(t, configDir, func() {
sm := &ServiceManager{}
err := sm.CreateDefaultProfile()
assert.NoError(t, err)
state, err := sm.GetActiveProfileState()
assert.NoError(t, err)
assert.Equal(t, state.Name, defaultProfileName) // No active profile state yet
err = sm.SetActiveProfileStateToDefault()
assert.NoError(t, err)
active, err := sm.GetActiveProfileState()
assert.NoError(t, err)
assert.Equal(t, "default", active.Name)
})
})
}
func TestServiceManager_CopyDefaultProfileIfNotExists(t *testing.T) {
withTempConfigDir(t, func(configDir string) {
withPatchedGlobals(t, configDir, func() {
sm := &ServiceManager{}
// Case: old default config does not exist
ok, err := sm.CopyDefaultProfileIfNotExists()
assert.False(t, ok)
assert.ErrorIs(t, err, ErrorOldDefaultConfigNotFound)
// Case: old default config exists, should be moved
f, err := os.Create(oldDefaultConfigPath)
assert.NoError(t, err)
f.Close()
ok, err = sm.CopyDefaultProfileIfNotExists()
assert.True(t, ok)
assert.NoError(t, err)
_, err = os.Stat(DefaultConfigPath)
assert.NoError(t, err)
})
})
}
func TestServiceManager_SetActiveProfileState(t *testing.T) {
withTempConfigDir(t, func(configDir string) {
withPatchedGlobals(t, configDir, func() {
currUser, err := user.Current()
assert.NoError(t, err)
sm := &ServiceManager{}
state := &ActiveProfileState{Name: "foo", Username: currUser.Username}
err = sm.SetActiveProfileState(state)
assert.NoError(t, err)
// Should error on nil or incomplete state
err = sm.SetActiveProfileState(nil)
assert.Error(t, err)
err = sm.SetActiveProfileState(&ActiveProfileState{Name: "", Username: ""})
assert.Error(t, err)
})
})
}
func TestServiceManager_DefaultProfilePath(t *testing.T) {
withTempConfigDir(t, func(configDir string) {
withPatchedGlobals(t, configDir, func() {
sm := &ServiceManager{}
assert.Equal(t, DefaultConfigPath, sm.DefaultProfilePath())
})
})
}
func TestSanitizeProfileName(t *testing.T) {
tests := []struct {
in, want string
}{
// unchanged
{"Alice", "Alice"},
{"bob123", "bob123"},
{"under_score", "under_score"},
{"dash-name", "dash-name"},
// spaces and forbidden chars removed
{"Alice Smith", "AliceSmith"},
{"bad/char\\name", "badcharname"},
{"colon:name*?", "colonname"},
{"quotes\"<>|", "quotes"},
// mixed
{"User_123-Test!@#", "User_123-Test"},
// empty and all-bad
{"", ""},
{"!@#$%^&*()", ""},
// unicode letters and digits
{"ÜserÇ", "ÜserÇ"},
{"漢字テスト123", "漢字テスト123"},
}
for _, tc := range tests {
got := sanitizeProfileName(tc.in)
if got != tc.want {
t.Errorf("sanitizeProfileName(%q) = %q; want %q", tc.in, got, tc.want)
}
}
}

View File

@@ -0,0 +1,359 @@
package profilemanager
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/util"
)
var (
oldDefaultConfigPathDir = ""
oldDefaultConfigPath = ""
DefaultConfigPathDir = ""
DefaultConfigPath = ""
ActiveProfileStatePath = ""
)
var (
ErrorOldDefaultConfigNotFound = errors.New("old default config not found")
)
func init() {
DefaultConfigPathDir = "/var/lib/netbird/"
oldDefaultConfigPathDir = "/etc/netbird/"
switch runtime.GOOS {
case "windows":
oldDefaultConfigPathDir = filepath.Join(os.Getenv("PROGRAMDATA"), "Netbird")
DefaultConfigPathDir = oldDefaultConfigPathDir
case "freebsd":
oldDefaultConfigPathDir = "/var/db/netbird/"
DefaultConfigPathDir = oldDefaultConfigPathDir
}
oldDefaultConfigPath = filepath.Join(oldDefaultConfigPathDir, "config.json")
DefaultConfigPath = filepath.Join(DefaultConfigPathDir, "default.json")
ActiveProfileStatePath = filepath.Join(DefaultConfigPathDir, "active_profile.json")
}
type ActiveProfileState struct {
Name string `json:"name"`
Username string `json:"username"`
}
func (a *ActiveProfileState) FilePath() (string, error) {
if a.Name == "" {
return "", fmt.Errorf("active profile name is empty")
}
if a.Name == defaultProfileName {
return DefaultConfigPath, nil
}
configDir, err := getConfigDirForUser(a.Username)
if err != nil {
return "", fmt.Errorf("failed to get config directory for user %s: %w", a.Username, err)
}
return filepath.Join(configDir, a.Name+".json"), nil
}
type ServiceManager struct{}
func (s *ServiceManager) CopyDefaultProfileIfNotExists() (bool, error) {
if err := os.MkdirAll(DefaultConfigPathDir, 0600); err != nil {
return false, fmt.Errorf("failed to create default config path directory: %w", err)
}
// check if default profile exists
if _, err := os.Stat(DefaultConfigPath); !os.IsNotExist(err) {
// default profile already exists
log.Debugf("default profile already exists at %s, skipping copy", DefaultConfigPath)
return false, nil
}
// check old default profile
if _, err := os.Stat(oldDefaultConfigPath); os.IsNotExist(err) {
// old default profile does not exist, nothing to copy
return false, ErrorOldDefaultConfigNotFound
}
// copy old default profile to new location
if err := copyFile(oldDefaultConfigPath, DefaultConfigPath, 0600); err != nil {
return false, fmt.Errorf("copy default profile from %s to %s: %w", oldDefaultConfigPath, DefaultConfigPath, err)
}
// set permissions for the new default profile
if err := os.Chmod(DefaultConfigPath, 0600); err != nil {
log.Warnf("failed to set permissions for default profile: %v", err)
}
if err := s.SetActiveProfileState(&ActiveProfileState{
Name: "default",
Username: "",
}); err != nil {
log.Errorf("failed to set active profile state: %v", err)
return false, fmt.Errorf("failed to set active profile state: %w", err)
}
return true, nil
}
// copyFile copies the contents of src to dst and sets dst's file mode to perm.
func copyFile(src, dst string, perm os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open source file %s: %w", src, err)
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
if err != nil {
return fmt.Errorf("open target file %s: %w", dst, err)
}
defer func() {
if cerr := out.Close(); cerr != nil && err == nil {
err = cerr
}
}()
if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("copy data to %s: %w", dst, err)
}
return nil
}
func (s *ServiceManager) CreateDefaultProfile() error {
_, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: DefaultConfigPath,
})
if err != nil {
return fmt.Errorf("failed to create default profile: %w", err)
}
log.Infof("default profile created at %s", DefaultConfigPath)
return nil
}
func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) {
if err := s.setDefaultActiveState(); err != nil {
return nil, fmt.Errorf("failed to set default active profile state: %w", err)
}
var activeProfile ActiveProfileState
if _, err := util.ReadJson(ActiveProfileStatePath, &activeProfile); err != nil {
if errors.Is(err, os.ErrNotExist) {
if err := s.SetActiveProfileStateToDefault(); err != nil {
return nil, fmt.Errorf("failed to set active profile to default: %w", err)
}
return &ActiveProfileState{
Name: "default",
Username: "",
}, nil
} else {
return nil, fmt.Errorf("failed to read active profile state: %w", err)
}
}
if activeProfile.Name == "" {
if err := s.SetActiveProfileStateToDefault(); err != nil {
return nil, fmt.Errorf("failed to set active profile to default: %w", err)
}
return &ActiveProfileState{
Name: "default",
Username: "",
}, nil
}
return &activeProfile, nil
}
func (s *ServiceManager) setDefaultActiveState() error {
_, err := os.Stat(ActiveProfileStatePath)
if err != nil {
if os.IsNotExist(err) {
if err := s.SetActiveProfileStateToDefault(); err != nil {
return fmt.Errorf("failed to set active profile to default: %w", err)
}
} else {
return fmt.Errorf("failed to stat active profile state path %s: %w", ActiveProfileStatePath, err)
}
}
return nil
}
func (s *ServiceManager) SetActiveProfileState(a *ActiveProfileState) error {
if a == nil || a.Name == "" {
return errors.New("invalid active profile state")
}
if a.Name != defaultProfileName && a.Username == "" {
return fmt.Errorf("username must be set for non-default profiles, got: %s", a.Name)
}
if err := util.WriteJsonWithRestrictedPermission(context.Background(), ActiveProfileStatePath, a); err != nil {
return fmt.Errorf("failed to write active profile state: %w", err)
}
log.Infof("active profile set to %s for %s", a.Name, a.Username)
return nil
}
func (s *ServiceManager) SetActiveProfileStateToDefault() error {
return s.SetActiveProfileState(&ActiveProfileState{
Name: "default",
Username: "",
})
}
func (s *ServiceManager) DefaultProfilePath() string {
return DefaultConfigPath
}
func (s *ServiceManager) AddProfile(profileName, username string) error {
configDir, err := getConfigDirForUser(username)
if err != nil {
return fmt.Errorf("failed to get config directory: %w", err)
}
profileName = sanitizeProfileName(profileName)
if profileName == defaultProfileName {
return fmt.Errorf("cannot create profile with reserved name: %s", defaultProfileName)
}
profPath := filepath.Join(configDir, profileName+".json")
if fileExists(profPath) {
return ErrProfileAlreadyExists
}
cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath})
if err != nil {
return fmt.Errorf("failed to create new config: %w", err)
}
err = util.WriteJson(context.Background(), profPath, cfg)
if err != nil {
return fmt.Errorf("failed to write profile config: %w", err)
}
return nil
}
func (s *ServiceManager) RemoveProfile(profileName, username string) error {
configDir, err := getConfigDirForUser(username)
if err != nil {
return fmt.Errorf("failed to get config directory: %w", err)
}
profileName = sanitizeProfileName(profileName)
if profileName == defaultProfileName {
return fmt.Errorf("cannot remove profile with reserved name: %s", defaultProfileName)
}
profPath := filepath.Join(configDir, profileName+".json")
if !fileExists(profPath) {
return ErrProfileNotFound
}
activeProf, err := s.GetActiveProfileState()
if err != nil && !errors.Is(err, ErrNoActiveProfile) {
return fmt.Errorf("failed to get active profile: %w", err)
}
if activeProf != nil && activeProf.Name == profileName {
return fmt.Errorf("cannot remove active profile: %s", profileName)
}
err = util.RemoveJson(profPath)
if err != nil {
return fmt.Errorf("failed to remove profile config: %w", err)
}
return nil
}
func (s *ServiceManager) ListProfiles(username string) ([]Profile, error) {
configDir, err := getConfigDirForUser(username)
if err != nil {
return nil, fmt.Errorf("failed to get config directory: %w", err)
}
files, err := util.ListFiles(configDir, "*.json")
if err != nil {
return nil, fmt.Errorf("failed to list profile files: %w", err)
}
var filtered []string
for _, file := range files {
if strings.HasSuffix(file, "state.json") {
continue // skip state files
}
filtered = append(filtered, file)
}
sort.Strings(filtered)
var activeProfName string
activeProf, err := s.GetActiveProfileState()
if err == nil {
activeProfName = activeProf.Name
}
var profiles []Profile
// add default profile always
profiles = append(profiles, Profile{Name: defaultProfileName, IsActive: activeProfName == "" || activeProfName == defaultProfileName})
for _, file := range filtered {
profileName := strings.TrimSuffix(filepath.Base(file), ".json")
var isActive bool
if activeProfName != "" && activeProfName == profileName {
isActive = true
}
profiles = append(profiles, Profile{Name: profileName, IsActive: isActive})
}
return profiles, nil
}
// GetStatePath returns the path to the state file based on the operating system
// It returns an empty string if the path cannot be determined.
func (s *ServiceManager) GetStatePath() string {
if path := os.Getenv("NB_DNS_STATE_FILE"); path != "" {
return path
}
defaultStatePath := filepath.Join(DefaultConfigPathDir, "state.json")
activeProf, err := s.GetActiveProfileState()
if err != nil {
log.Warnf("failed to get active profile state: %v", err)
return defaultStatePath
}
if activeProf.Name == defaultProfileName {
return defaultStatePath
}
configDir, err := getConfigDirForUser(activeProf.Username)
if err != nil {
log.Warnf("failed to get config directory for user %s: %v", activeProf.Username, err)
return defaultStatePath
}
return filepath.Join(configDir, activeProf.Name+".state.json")
}

View File

@@ -0,0 +1,57 @@
package profilemanager
import (
"context"
"errors"
"fmt"
"path/filepath"
"github.com/netbirdio/netbird/util"
)
type ProfileState struct {
Email string `json:"email"`
}
func (pm *ProfileManager) GetProfileState(profileName string) (*ProfileState, error) {
configDir, err := getConfigDir()
if err != nil {
return nil, fmt.Errorf("get config directory: %w", err)
}
stateFile := filepath.Join(configDir, profileName+".state.json")
if !fileExists(stateFile) {
return nil, errors.New("profile state file does not exist")
}
var state ProfileState
_, err = util.ReadJson(stateFile, &state)
if err != nil {
return nil, fmt.Errorf("read profile state: %w", err)
}
return &state, nil
}
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
configDir, err := getConfigDir()
if err != nil {
return fmt.Errorf("get config directory: %w", err)
}
activeProf, err := pm.GetActiveProfile()
if err != nil {
if errors.Is(err, ErrNoActiveProfile) {
return fmt.Errorf("no active profile set: %w", err)
}
return fmt.Errorf("get active profile: %w", err)
}
stateFile := filepath.Join(configDir, activeProf.Name+".state.json")
err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state)
if err != nil {
return fmt.Errorf("write profile state: %w", err)
}
return nil
}