mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-24 15:49:06 +02:00
[client] Feat: Support Multiple Profiles (#3980)
[client] Feat: Support Multiple Profiles (#3980)
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
// OAuthFlow represents an interface for authorization using different OAuth 2.0 flows
|
||||
@@ -48,6 +49,7 @@ type TokenInfo struct {
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
UseIDToken bool `json:"-"`
|
||||
Email string `json:"-"`
|
||||
}
|
||||
|
||||
// GetTokenToUse returns either the access or id token based on UseIDToken field
|
||||
@@ -64,7 +66,7 @@ func (t TokenInfo) GetTokenToUse() string {
|
||||
// and if that also fails, the authentication process is deemed unsuccessful
|
||||
//
|
||||
// On Linux distros without desktop environment support, it only tries to initialize the Device Code Flow
|
||||
func NewOAuthFlow(ctx context.Context, config *internal.Config, isUnixDesktopClient bool) (OAuthFlow, error) {
|
||||
func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesktopClient bool) (OAuthFlow, error) {
|
||||
if (runtime.GOOS == "linux" || runtime.GOOS == "freebsd") && !isUnixDesktopClient {
|
||||
return authenticateWithDeviceCodeFlow(ctx, config)
|
||||
}
|
||||
@@ -80,7 +82,7 @@ func NewOAuthFlow(ctx context.Context, config *internal.Config, isUnixDesktopCli
|
||||
}
|
||||
|
||||
// authenticateWithPKCEFlow initializes the Proof Key for Code Exchange flow auth flow
|
||||
func authenticateWithPKCEFlow(ctx context.Context, config *internal.Config) (OAuthFlow, error) {
|
||||
func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config) (OAuthFlow, error) {
|
||||
pkceFlowInfo, err := internal.GetPKCEAuthorizationFlowInfo(ctx, config.PrivateKey, config.ManagementURL, config.ClientCertKeyPair)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err)
|
||||
@@ -89,7 +91,7 @@ func authenticateWithPKCEFlow(ctx context.Context, config *internal.Config) (OAu
|
||||
}
|
||||
|
||||
// authenticateWithDeviceCodeFlow initializes the Device Code auth Flow
|
||||
func authenticateWithDeviceCodeFlow(ctx context.Context, config *internal.Config) (OAuthFlow, error) {
|
||||
func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager.Config) (OAuthFlow, error) {
|
||||
deviceFlowInfo, err := internal.GetDeviceAuthorizationFlowInfo(ctx, config.PrivateKey, config.ManagementURL)
|
||||
if err != nil {
|
||||
switch s, ok := gstatus.FromError(err); {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
@@ -230,9 +231,46 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
|
||||
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
|
||||
}
|
||||
|
||||
email, err := parseEmailFromIDToken(tokenInfo.IDToken)
|
||||
if err != nil {
|
||||
log.Warnf("failed to parse email from ID token: %v", err)
|
||||
} else {
|
||||
tokenInfo.Email = email
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
func parseEmailFromIDToken(token string) (string, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("invalid token format")
|
||||
}
|
||||
|
||||
data, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode payload: %w", err)
|
||||
}
|
||||
var claims map[string]interface{}
|
||||
if err := json.Unmarshal(data, &claims); err != nil {
|
||||
return "", fmt.Errorf("json unmarshal error: %w", err)
|
||||
}
|
||||
|
||||
var email string
|
||||
if emailValue, ok := claims["email"].(string); ok {
|
||||
email = emailValue
|
||||
} else {
|
||||
val, ok := claims["name"].(string)
|
||||
if ok {
|
||||
email = val
|
||||
} else {
|
||||
return "", fmt.Errorf("email or name field not found in token payload")
|
||||
}
|
||||
}
|
||||
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func createCodeChallenge(codeVerifier string) string {
|
||||
sha2 := sha256.Sum256([]byte(codeVerifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sha2[:])
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/dns"
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ssh"
|
||||
@@ -37,7 +38,7 @@ import (
|
||||
|
||||
type ConnectClient struct {
|
||||
ctx context.Context
|
||||
config *Config
|
||||
config *profilemanager.Config
|
||||
statusRecorder *peer.Status
|
||||
engine *Engine
|
||||
engineMutex sync.Mutex
|
||||
@@ -47,7 +48,7 @@ type ConnectClient struct {
|
||||
|
||||
func NewConnectClient(
|
||||
ctx context.Context,
|
||||
config *Config,
|
||||
config *profilemanager.Config,
|
||||
statusRecorder *peer.Status,
|
||||
|
||||
) *ConnectClient {
|
||||
@@ -413,7 +414,7 @@ func (c *ConnectClient) SetNetworkMapPersistence(enabled bool) {
|
||||
}
|
||||
|
||||
// createEngineConfig converts configuration received from Management Service to EngineConfig
|
||||
func createEngineConfig(key wgtypes.Key, config *Config, peerConfig *mgmProto.PeerConfig) (*EngineConfig, error) {
|
||||
func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConfig *mgmProto.PeerConfig) (*EngineConfig, error) {
|
||||
nm := false
|
||||
if config.NetworkMonitor != nil {
|
||||
nm = *config.NetworkMonitor
|
||||
@@ -483,7 +484,7 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP
|
||||
}
|
||||
|
||||
// loginToManagement creates Management ServiceDependencies client, establishes a connection, logs-in and gets a global Netbird config (signal, turn, stun hosts, etc)
|
||||
func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte, config *Config) (*mgmProto.LoginResponse, error) {
|
||||
func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte, config *profilemanager.Config) (*mgmProto.LoginResponse, error) {
|
||||
|
||||
serverPublicKey, err := client.GetServerPublicKey()
|
||||
if err != nil {
|
||||
|
||||
@@ -25,9 +25,8 @@ import (
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
mgmProto "github.com/netbirdio/netbird/management/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
@@ -199,7 +198,8 @@ const (
|
||||
type BundleGenerator struct {
|
||||
anonymizer *anonymize.Anonymizer
|
||||
|
||||
internalConfig *internal.Config
|
||||
// deps
|
||||
internalConfig *profilemanager.Config
|
||||
statusRecorder *peer.Status
|
||||
networkMap *mgmProto.NetworkMap
|
||||
logFile string
|
||||
@@ -220,7 +220,7 @@ type BundleConfig struct {
|
||||
}
|
||||
|
||||
type GeneratorDependencies struct {
|
||||
InternalConfig *internal.Config
|
||||
InternalConfig *profilemanager.Config
|
||||
StatusRecorder *peer.Status
|
||||
NetworkMap *mgmProto.NetworkMap
|
||||
LogFile string
|
||||
@@ -558,7 +558,8 @@ func (g *BundleGenerator) addNetworkMap() error {
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addStateFile() error {
|
||||
path := statemanager.GetDefaultStatePath()
|
||||
sm := profilemanager.ServiceManager{}
|
||||
path := sm.GetStatePath()
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -596,7 +597,8 @@ func (g *BundleGenerator) addStateFile() error {
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addCorruptedStateFiles() error {
|
||||
pattern := statemanager.GetDefaultStatePath()
|
||||
sm := profilemanager.ServiceManager{}
|
||||
pattern := sm.GetStatePath()
|
||||
if pattern == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"slices"
|
||||
@@ -41,6 +42,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer/guard"
|
||||
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/relay"
|
||||
"github.com/netbirdio/netbird/client/internal/rosenpass"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||
@@ -236,7 +238,9 @@ func NewEngine(
|
||||
connSemaphore: semaphoregroup.NewSemaphoreGroup(connInitLimit),
|
||||
}
|
||||
|
||||
path := statemanager.GetDefaultStatePath()
|
||||
sm := profilemanager.ServiceManager{}
|
||||
|
||||
path := sm.GetStatePath()
|
||||
if runtime.GOOS == "ios" {
|
||||
if !fileExists(mobileDep.StateFilePath) {
|
||||
err := createFile(mobileDep.StateFilePath)
|
||||
@@ -2062,3 +2066,16 @@ func compareNetIPLists(list1 []netip.Prefix, list2 []string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func createFile(path string) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/guard"
|
||||
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||
"github.com/netbirdio/netbird/client/ssh"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
@@ -1149,25 +1150,25 @@ func Test_ParseNATExternalIPMappings(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "Parse Valid List Should Be OK",
|
||||
inputBlacklistInterface: defaultInterfaceBlacklist,
|
||||
inputBlacklistInterface: profilemanager.DefaultInterfaceBlacklist,
|
||||
inputMapList: []string{"1.1.1.1", "8.8.8.8/" + testingInterface},
|
||||
expectedOutput: []string{"1.1.1.1", "8.8.8.8/" + testingIP},
|
||||
},
|
||||
{
|
||||
name: "Only Interface name Should Return Nil",
|
||||
inputBlacklistInterface: defaultInterfaceBlacklist,
|
||||
inputBlacklistInterface: profilemanager.DefaultInterfaceBlacklist,
|
||||
inputMapList: []string{testingInterface},
|
||||
expectedOutput: nil,
|
||||
},
|
||||
{
|
||||
name: "Invalid IP Return Nil",
|
||||
inputBlacklistInterface: defaultInterfaceBlacklist,
|
||||
inputBlacklistInterface: profilemanager.DefaultInterfaceBlacklist,
|
||||
inputMapList: []string{"1.1.1.1000"},
|
||||
expectedOutput: nil,
|
||||
},
|
||||
{
|
||||
name: "Invalid Mapping Element Should return Nil",
|
||||
inputBlacklistInterface: defaultInterfaceBlacklist,
|
||||
inputBlacklistInterface: profilemanager.DefaultInterfaceBlacklist,
|
||||
inputMapList: []string{"1.1.1.1/10.10.10.1/eth0"},
|
||||
expectedOutput: nil,
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/ssh"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
mgm "github.com/netbirdio/netbird/management/client"
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// IsLoginRequired check that the server is support SSO or not
|
||||
func IsLoginRequired(ctx context.Context, config *Config) (bool, error) {
|
||||
func IsLoginRequired(ctx context.Context, config *profilemanager.Config) (bool, error) {
|
||||
mgmURL := config.ManagementURL
|
||||
mgmClient, err := getMgmClient(ctx, config.PrivateKey, mgmURL)
|
||||
if err != nil {
|
||||
@@ -47,7 +48,7 @@ func IsLoginRequired(ctx context.Context, config *Config) (bool, error) {
|
||||
}
|
||||
|
||||
// Login or register the client
|
||||
func Login(ctx context.Context, config *Config, setupKey string, jwtToken string) error {
|
||||
func Login(ctx context.Context, config *profilemanager.Config, setupKey string, jwtToken string) error {
|
||||
mgmClient, err := getMgmClient(ctx, config.PrivateKey, config.ManagementURL)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -100,7 +101,7 @@ func getMgmClient(ctx context.Context, privateKey string, mgmURL *url.URL) (*mgm
|
||||
return mgmClient, err
|
||||
}
|
||||
|
||||
func doMgmLogin(ctx context.Context, mgmClient *mgm.GrpcClient, pubSSHKey []byte, config *Config) (*wgtypes.Key, error) {
|
||||
func doMgmLogin(ctx context.Context, mgmClient *mgm.GrpcClient, pubSSHKey []byte, config *profilemanager.Config) (*wgtypes.Key, error) {
|
||||
serverKey, err := mgmClient.GetServerPublicKey()
|
||||
if err != nil {
|
||||
log.Errorf("failed while getting Management Service public key: %v", err)
|
||||
@@ -126,7 +127,7 @@ func doMgmLogin(ctx context.Context, mgmClient *mgm.GrpcClient, pubSSHKey []byte
|
||||
|
||||
// registerPeer checks whether setupKey was provided via cmd line and if not then it prompts user to enter a key.
|
||||
// Otherwise tries to register with the provided setupKey via command line.
|
||||
func registerPeer(ctx context.Context, serverPublicKey wgtypes.Key, client *mgm.GrpcClient, setupKey string, jwtToken string, pubSSHKey []byte, config *Config) (*mgmProto.LoginResponse, error) {
|
||||
func registerPeer(ctx context.Context, serverPublicKey wgtypes.Key, client *mgm.GrpcClient, setupKey string, jwtToken string, pubSSHKey []byte, config *profilemanager.Config) (*mgmProto.LoginResponse, error) {
|
||||
validSetupKey, err := uuid.Parse(setupKey)
|
||||
if err != nil && jwtToken == "" {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid setup-key or no sso information provided, err: %v", err)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package internal
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -6,16 +6,16 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
|
||||
@@ -38,7 +38,7 @@ const (
|
||||
DefaultAdminURL = "https://app.netbird.io:443"
|
||||
)
|
||||
|
||||
var defaultInterfaceBlacklist = []string{
|
||||
var DefaultInterfaceBlacklist = []string{
|
||||
iface.WgInterfaceDefault, "wt", "utun", "tun0", "zt", "ZeroTier", "wg", "ts",
|
||||
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
|
||||
}
|
||||
@@ -144,78 +144,47 @@ type Config struct {
|
||||
LazyConnectionEnabled bool
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
var ConfigDirOverride string
|
||||
|
||||
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
|
||||
func getConfigDir() (string, error) {
|
||||
if ConfigDirOverride != "" {
|
||||
return ConfigDirOverride, nil
|
||||
}
|
||||
|
||||
cfg, err := createNewConfig(ConfigInput{ConfigPath: configPath})
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = WriteOutConfig(configPath, cfg)
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
// 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, status.Errorf(codes.NotFound, "config file doesn't exist")
|
||||
}
|
||||
|
||||
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
|
||||
configDir = filepath.Join(configDir, "netbird")
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return "", 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)
|
||||
return configDir, nil
|
||||
}
|
||||
|
||||
// CreateInMemoryConfig generate a new config but do not write out it to the store
|
||||
func CreateInMemoryConfig(input ConfigInput) (*Config, error) {
|
||||
return createNewConfig(input)
|
||||
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
|
||||
}
|
||||
|
||||
// WriteOutConfig write put the prepared config to the given path
|
||||
func WriteOutConfig(path string, config *Config) error {
|
||||
return util.WriteJson(context.Background(), path, config)
|
||||
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
|
||||
@@ -223,8 +192,6 @@ func createNewConfig(input ConfigInput) (*Config, error) {
|
||||
config := &Config{
|
||||
// defaults to false only for new (post 0.26) configurations
|
||||
ServerSSHAllowed: util.False(),
|
||||
// default to disabling server routes on Android for security
|
||||
DisableServerRoutes: runtime.GOOS == "android",
|
||||
}
|
||||
|
||||
if _, err := config.apply(input); err != nil {
|
||||
@@ -234,27 +201,6 @@ func createNewConfig(input ConfigInput) (*Config, error) {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
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 (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
if config.ManagementURL == nil {
|
||||
log.Infof("using default Management URL %s", DefaultManagementURL)
|
||||
@@ -382,8 +328,8 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
|
||||
if len(config.IFaceBlackList) == 0 {
|
||||
log.Infof("filling in interface blacklist with defaults: [ %s ]",
|
||||
strings.Join(defaultInterfaceBlacklist, " "))
|
||||
config.IFaceBlackList = append(config.IFaceBlackList, defaultInterfaceBlacklist...)
|
||||
strings.Join(DefaultInterfaceBlacklist, " "))
|
||||
config.IFaceBlackList = append(config.IFaceBlackList, DefaultInterfaceBlacklist...)
|
||||
updated = true
|
||||
}
|
||||
|
||||
@@ -596,17 +542,69 @@ func isPreSharedKeyHidden(preSharedKey *string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return !os.IsNotExist(err)
|
||||
// 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)
|
||||
}
|
||||
|
||||
func createFile(path string) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
// 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
|
||||
}
|
||||
return file.Close()
|
||||
|
||||
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.
|
||||
@@ -690,3 +688,46 @@ func UpdateOldManagementURL(ctx context.Context, config *Config, configPath stri
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package internal
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package statemanager
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/configs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// GetDefaultStatePath returns the path to the state file based on the operating system
|
||||
// It returns an empty string if the path cannot be determined.
|
||||
func GetDefaultStatePath() string {
|
||||
if path := os.Getenv("NB_DNS_STATE_FILE"); path != "" {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(configs.StateDir, "state.json")
|
||||
}
|
||||
Reference in New Issue
Block a user