mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-13 10:19:07 +02:00
Merge branch 'main' into file-share
# Conflicts: # client/android/client.go # client/android/login.go # client/android/profile_prefs.go # client/internal/connect.go # client/internal/engine.go # client/mobile/profile_state.go # client/ui/i18n/locales/de/common.json # client/ui/i18n/locales/en/common.json # client/ui/i18n/locales/es/common.json # client/ui/i18n/locales/fr/common.json # client/ui/i18n/locales/hu/common.json # client/ui/i18n/locales/it/common.json # client/ui/i18n/locales/ja/common.json # client/ui/i18n/locales/pt/common.json # client/ui/i18n/locales/ru/common.json # client/ui/i18n/locales/zh-CN/common.json # client/ui/main.go
This commit is contained in:
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/netevents"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/formatter"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -40,11 +41,6 @@ const (
|
||||
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile
|
||||
type ConnectionListener interface {
|
||||
peer.Listener
|
||||
}
|
||||
|
||||
// TunAdapter export internal TunAdapter for mobile
|
||||
type TunAdapter interface {
|
||||
device.TunAdapter
|
||||
@@ -85,6 +81,10 @@ type Client struct {
|
||||
deviceName string
|
||||
uiVersion string
|
||||
networkChangeListener listener.NetworkChangeListener
|
||||
// netMgr outlives engine restarts: it mirrors the OS connectivity, not
|
||||
// the engine lifecycle. Run and RunWithoutLogin inject its state and
|
||||
// sweeper into each new ConnectClient.
|
||||
netMgr *netevents.Manager
|
||||
|
||||
stateMu sync.RWMutex
|
||||
connectClient *internal.ConnectClient
|
||||
@@ -154,14 +154,17 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd
|
||||
execWorkaround(androidSDKVersion)
|
||||
|
||||
net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket)
|
||||
system.SetIFaceDiscover(iFaceDiscover)
|
||||
recorder := peer.NewRecorder("")
|
||||
return &Client{
|
||||
deviceName: deviceName,
|
||||
uiVersion: uiVersion,
|
||||
tunAdapter: tunAdapter,
|
||||
iFaceDiscover: iFaceDiscover,
|
||||
recorder: peer.NewRecorder(""),
|
||||
recorder: recorder,
|
||||
ctxCancelLock: &sync.Mutex{},
|
||||
networkChangeListener: networkChangeListener,
|
||||
netMgr: netevents.NewManager(recorder),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +205,9 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
}
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetEvents(c.netMgr))
|
||||
c.attachFileDrop(connectClient, cfgFile)
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
||||
@@ -244,7 +249,8 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
||||
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetEvents(c.netMgr))
|
||||
c.attachFileDrop(connectClient, cfgFile)
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||
@@ -293,6 +299,26 @@ func (c *Client) GetTunSettings() (*TunSettings, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetNetworkAvailable feeds OS-reported network availability into the client.
|
||||
// While unavailable, the internal reconnect loops suspend their attempts and
|
||||
// the connection listener reports NoNetwork instead of Connecting; when
|
||||
// availability returns, the loops resume immediately with a fresh backoff.
|
||||
// Losing the last network also sweeps the registered connections: nothing can
|
||||
// redial while offline, so the stale sockets would otherwise stay silently
|
||||
// "connected" until their own timeouts and the client would keep reporting
|
||||
// Connected with no network at all.
|
||||
func (c *Client) SetNetworkAvailable(available bool) {
|
||||
c.netMgr.SetNetworkAvailable(available)
|
||||
}
|
||||
|
||||
// NotifyNetworkChange marks the management, signal and relay connections
|
||||
// stale after the OS switched networks and schedules a sweep that cuts
|
||||
// whatever has not redialed on the new network by then. The engine and the
|
||||
// TUN device stay untouched.
|
||||
func (c *Client) NotifyNetworkChange() {
|
||||
c.netMgr.NotifyNetworkChange()
|
||||
}
|
||||
|
||||
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
||||
// It works both with and without a running engine. anonymizeLevel is "default"
|
||||
// or "strict"; strict also anonymizes internal IP ranges, peer names, and
|
||||
@@ -533,7 +559,11 @@ func (c *Client) OnUpdatedHostDNS(list *DNSList) error {
|
||||
|
||||
// SetConnectionListener set the network connection listener
|
||||
func (c *Client) SetConnectionListener(listener ConnectionListener) {
|
||||
c.recorder.SetConnectionListener(listener)
|
||||
if listener == nil {
|
||||
c.recorder.RemoveConnectionListener()
|
||||
return
|
||||
}
|
||||
c.recorder.SetConnectionListener(connectionListenerAdapter{listener})
|
||||
}
|
||||
|
||||
// RemoveConnectionListener remove connection listener
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/mobile"
|
||||
)
|
||||
|
||||
// SetFileDropSink installs the platform sink incoming payloads are staged
|
||||
@@ -72,7 +73,7 @@ func (c *Client) fileDropFor(configDir, profileID string) (*FileDrop, error) {
|
||||
// pair one profile's engine with another's transfers. A failure is not fatal:
|
||||
// the tunnel is worth more than the feature, so the engine runs on without it.
|
||||
func (c *Client) attachFileDrop(cc *internal.ConnectClient, cfgFile string) {
|
||||
configDir, profileID, err := profileLocationFor(cfgFile)
|
||||
configDir, profileID, err := mobile.ProfileLocationFor(cfgFile)
|
||||
if err != nil {
|
||||
log.Warnf("file drop is unavailable: %v", err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
// Client state values delivered via ConnectionListener.OnStateChanged,
|
||||
// re-exported as basic constants so gomobile emits them into the generated
|
||||
// Java bindings. They mirror peer.ClientState*: append-only, never reorder.
|
||||
const (
|
||||
ClientStateDisconnected = int(peer.ClientStateDisconnected)
|
||||
ClientStateConnected = int(peer.ClientStateConnected)
|
||||
ClientStateConnecting = int(peer.ClientStateConnecting)
|
||||
ClientStateDisconnecting = int(peer.ClientStateDisconnecting)
|
||||
ClientStateNoNetwork = int(peer.ClientStateNoNetwork)
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile. It mirrors
|
||||
// peer.Listener with OnStateChanged taking a plain int (one of the
|
||||
// ClientState* constants), because gomobile cannot bind named types.
|
||||
type ConnectionListener interface {
|
||||
OnStateChanged(state int)
|
||||
OnConnected()
|
||||
OnDisconnected()
|
||||
OnConnecting()
|
||||
OnDisconnecting()
|
||||
OnAddressChanged(string, string)
|
||||
OnPeersListChanged(int)
|
||||
}
|
||||
|
||||
// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to
|
||||
// peer.Listener, converting the typed state to the int the binding carries.
|
||||
type connectionListenerAdapter struct {
|
||||
ConnectionListener
|
||||
}
|
||||
|
||||
func (a connectionListenerAdapter) OnStateChanged(state peer.ClientState) {
|
||||
a.ConnectionListener.OnStateChanged(int(state))
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
type stubStream struct {
|
||||
@@ -224,38 +223,10 @@ func TestFileDropSendWithoutTunnelFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileLocationForSplitsConfigPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
dir, id, err := profileLocationFor(filepath.Join(root, defaultConfigFilename))
|
||||
if err != nil {
|
||||
t.Fatalf("default profile: %v", err)
|
||||
}
|
||||
if dir != root || id != profilemanager.DefaultProfileName {
|
||||
t.Fatalf("default profile = (%q, %q), want (%q, %q)", dir, id, root, profilemanager.DefaultProfileName)
|
||||
}
|
||||
|
||||
named := filepath.Join(root, profilesSubdir, "aaaaaaaabbbbbbbbccccccccdddddddd.json")
|
||||
dir, id, err = profileLocationFor(named)
|
||||
if err != nil {
|
||||
t.Fatalf("named profile: %v", err)
|
||||
}
|
||||
if dir != root || id != "aaaaaaaabbbbbbbbccccccccdddddddd" {
|
||||
t.Fatalf("named profile = (%q, %q), want (%q, %q)", dir, id, root, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
}
|
||||
|
||||
for _, path := range []string{"", filepath.Join(root, "stray.json"), filepath.Join(root, profilesSubdir, "not-an-id!.json")} {
|
||||
if _, _, err := profileLocationFor(path); err == nil {
|
||||
t.Fatalf("expected an error for %q", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestProfile(t *testing.T, configDir, id string) {
|
||||
t.Helper()
|
||||
|
||||
pm := NewProfileManager(configDir)
|
||||
if _, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(id), androidUsername); err != nil {
|
||||
if _, err := NewProfileManager(configDir).impl.ProfilePrefs(id); err != nil {
|
||||
t.Fatalf("resolve prefs for %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mobile"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
)
|
||||
|
||||
@@ -181,7 +182,7 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
||||
// Stored after Login, not before: a rejected token must not leave a hint
|
||||
// pointing at an account that cannot be used.
|
||||
if email != "" && a.cfgPath != "" {
|
||||
if err := writeProfileEmail(a.cfgPath, email); err != nil {
|
||||
if err := mobile.WriteProfileEmail(a.cfgPath, email); err != nil {
|
||||
log.Warnf("failed to store profile account email: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -208,7 +209,7 @@ func profileLoginHint(cfgPath string) string {
|
||||
if cfgPath == "" {
|
||||
return ""
|
||||
}
|
||||
return readProfileEmail(cfgPath)
|
||||
return mobile.ReadProfileEmail(cfgPath)
|
||||
}
|
||||
|
||||
// runOAuthFlow drives an already acquired OAuth flow to a token: requests the
|
||||
|
||||
@@ -3,42 +3,37 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mobile"
|
||||
)
|
||||
|
||||
const (
|
||||
// Android uses a single user context per app (non-empty username required by ServiceManager)
|
||||
// Android uses a single user context per app.
|
||||
androidUsername = "android"
|
||||
)
|
||||
|
||||
// Profile represents a profile for gomobile
|
||||
// Profile represents a profile for gomobile.
|
||||
type Profile struct {
|
||||
ID string
|
||||
Name string
|
||||
// Email is the account this profile last logged in with, "" if it never
|
||||
// completed an SSO login. Kept across logouts; cleared when the profile is
|
||||
// removed. See profile_state.go.
|
||||
// removed. See client/mobile/profile_state.go.
|
||||
Email string
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
// ProfileArray wraps profiles for gomobile compatibility
|
||||
// ProfileArray wraps profiles for gomobile compatibility (gomobile cannot
|
||||
// bind Go slices directly).
|
||||
type ProfileArray struct {
|
||||
items []*Profile
|
||||
}
|
||||
|
||||
// Length returns the number of profiles
|
||||
// Length returns the number of profiles.
|
||||
func (p *ProfileArray) Length() int {
|
||||
return len(p.items)
|
||||
}
|
||||
|
||||
// Get returns the profile at index i
|
||||
// Get returns the profile at index i, or nil if out of range.
|
||||
func (p *ProfileArray) Get(i int) *Profile {
|
||||
if i < 0 || i >= len(p.items) {
|
||||
return nil
|
||||
@@ -46,259 +41,98 @@ func (p *ProfileArray) Get(i int) *Profile {
|
||||
return p.items[i]
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
/data/data/io.netbird.client/files/ ← configDir parameter
|
||||
├── netbird.cfg ← Default profile config
|
||||
├── state.json ← Default profile state
|
||||
├── active_profile.json ← Active profile tracker (JSON with Name + Username)
|
||||
└── profiles/ ← Subdirectory for non-default profiles
|
||||
├── work.json ← Legacy work profile config
|
||||
├── work.state.json ← Legacy work profile state
|
||||
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config
|
||||
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state
|
||||
*/
|
||||
|
||||
// ProfileManager manages profiles for Android
|
||||
// It wraps the internal profilemanager to provide Android-specific behavior
|
||||
// ProfileManager adapts the shared mobile profile manager (client/mobile) to
|
||||
// gomobile-friendly types. See that package for the on-disk layout and
|
||||
// semantics.
|
||||
type ProfileManager struct {
|
||||
configDir string
|
||||
serviceMgr *profilemanager.ServiceManager
|
||||
impl *mobile.ProfileManager
|
||||
}
|
||||
|
||||
// NewProfileManager creates a new profile manager for Android
|
||||
// NewProfileManager creates a new profile manager for Android. configDir is
|
||||
// the app's files directory.
|
||||
func NewProfileManager(configDir string) *ProfileManager {
|
||||
// Set the default config path for Android (stored in root configDir, not profiles/)
|
||||
defaultConfigPath := filepath.Join(configDir, defaultConfigFilename)
|
||||
|
||||
// Set global paths for Android
|
||||
profilemanager.DefaultConfigPathDir = configDir
|
||||
profilemanager.DefaultConfigPath = defaultConfigPath
|
||||
profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json")
|
||||
|
||||
// Create ServiceManager with profiles/ subdirectory
|
||||
// This avoids modifying the global ConfigDirOverride for profile listing
|
||||
profilesDir := filepath.Join(configDir, profilesSubdir)
|
||||
serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir)
|
||||
|
||||
return &ProfileManager{
|
||||
configDir: configDir,
|
||||
serviceMgr: serviceMgr,
|
||||
}
|
||||
return &ProfileManager{impl: mobile.NewProfileManager(configDir, androidUsername)}
|
||||
}
|
||||
|
||||
// ListProfiles returns all available profiles
|
||||
// ListProfiles returns all available profiles, including the default profile,
|
||||
// with their active status set.
|
||||
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||
// Use ServiceManager (looks in profiles/ directory, checks active_profile.json for IsActive)
|
||||
internalProfiles, err := pm.serviceMgr.ListProfiles(androidUsername)
|
||||
profiles, err := pm.impl.ListProfiles()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list profiles: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert internal profiles to Android Profile type
|
||||
var profiles []*Profile
|
||||
for _, p := range internalProfiles {
|
||||
profiles = append(profiles, &Profile{
|
||||
ID: p.ID.String(),
|
||||
Name: p.Name,
|
||||
Email: pm.profileEmail(p.ID.String()),
|
||||
IsActive: p.IsActive,
|
||||
})
|
||||
items := make([]*Profile, 0, len(profiles))
|
||||
for i := range profiles {
|
||||
items = append(items, fromMobileProfile(&profiles[i]))
|
||||
}
|
||||
|
||||
return &ProfileArray{items: profiles}, nil
|
||||
return &ProfileArray{items: items}, nil
|
||||
}
|
||||
|
||||
// GetActiveProfile returns the currently active profile name
|
||||
// GetActiveProfile returns the currently active profile.
|
||||
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
||||
// Use ServiceManager to stay consistent with ListProfiles
|
||||
// ServiceManager uses active_profile.json
|
||||
activeState, err := pm.serviceMgr.GetActiveProfileState()
|
||||
p, err := pm.impl.GetActiveProfile()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active profile: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ActiveProfileState only stores the ID (and username), not the display
|
||||
// name. Resolve the ID to the full profile so callers get the real Name.
|
||||
prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), androidUsername)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
|
||||
}
|
||||
return &Profile{
|
||||
ID: prof.ID.String(),
|
||||
Name: prof.Name,
|
||||
Email: pm.profileEmail(prof.ID.String()),
|
||||
IsActive: true,
|
||||
}, nil
|
||||
return fromMobileProfile(p), nil
|
||||
}
|
||||
|
||||
// profileEmail returns the account email recorded for a profile. Display-only, so
|
||||
// an unresolvable path degrades to "" rather than an error.
|
||||
func (pm *ProfileManager) profileEmail(id string) string {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return readProfileEmail(configPath)
|
||||
}
|
||||
|
||||
// SwitchProfile switches to a different profile
|
||||
// SwitchProfile records the given profile ID as the active profile. The caller
|
||||
// must stop the VPN tunnel before switching.
|
||||
func (pm *ProfileManager) SwitchProfile(id string) error {
|
||||
// Use ServiceManager to stay consistent with ListProfiles
|
||||
// ServiceManager uses active_profile.json
|
||||
err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
ID: profilemanager.ID(id),
|
||||
Username: androidUsername,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to switch profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("switched to profile: %s", id)
|
||||
return nil
|
||||
return pm.impl.SwitchProfile(id)
|
||||
}
|
||||
|
||||
// AddProfile creates a new profile
|
||||
// AddProfile creates a new profile with the given display name and a
|
||||
// generated ID.
|
||||
func (pm *ProfileManager) AddProfile(profileName string) error {
|
||||
// Use ServiceManager (creates profile in profiles/ directory)
|
||||
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("created new profile: %s", profile.ID)
|
||||
return nil
|
||||
_, err := pm.impl.AddProfile(profileName)
|
||||
return err
|
||||
}
|
||||
|
||||
// LogoutProfile logs out from a profile (clears authentication)
|
||||
func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return fmt.Errorf("id '%s' is not valid", id)
|
||||
}
|
||||
|
||||
// Check if profile exists
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("profile '%s' does not exist", id)
|
||||
}
|
||||
|
||||
// Read current config using internal profilemanager
|
||||
config, err := profilemanager.ReadConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read profile config: %w", err)
|
||||
}
|
||||
|
||||
// Clear authentication by removing private key and SSH key
|
||||
config.PrivateKey = ""
|
||||
config.SSHKey = ""
|
||||
|
||||
// Save config using internal profilemanager
|
||||
if err := profilemanager.WriteOutConfig(configPath, config); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
// The stored account email is kept on purpose, matching the desktop and CLI
|
||||
// logout semantics: the next login passes it as the login_hint so the IdP
|
||||
// preselects the account. Removing the profile is what deletes it.
|
||||
log.Infof("logged out from profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameProfile changes a profile's display name. The profile ID, and therefore
|
||||
// its on-disk filename, is left untouched: only the "name" field of the config
|
||||
// is rewritten. This works for the default profile too, whose config lives in
|
||||
// netbird.cfg rather than under profiles/.
|
||||
// RenameProfile changes the display name of the profile identified by id. The
|
||||
// on-disk filename (the ID) is left unchanged.
|
||||
func (pm *ProfileManager) RenameProfile(id string, newName string) error {
|
||||
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), androidUsername, newName); err != nil {
|
||||
return fmt.Errorf("failed to rename profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("renamed profile %s to: %s", id, newName)
|
||||
return nil
|
||||
return pm.impl.RenameProfile(id, newName)
|
||||
}
|
||||
|
||||
// RemoveProfile deletes a profile
|
||||
// LogoutProfile clears authentication data for a profile, forcing a re-login.
|
||||
// The management URL and other settings are preserved.
|
||||
func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
return pm.impl.LogoutProfile(id)
|
||||
}
|
||||
|
||||
// RemoveProfile deletes a profile. The default profile and the active profile
|
||||
// cannot be removed.
|
||||
func (pm *ProfileManager) RemoveProfile(id string) error {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use ServiceManager (removes profile from profiles/ directory)
|
||||
if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil {
|
||||
return fmt.Errorf("failed to remove profile: %w", err)
|
||||
}
|
||||
|
||||
// The account file is this package's, not the ServiceManager's, so it must
|
||||
// go here. The default profile has a fixed filename, so a recreated one
|
||||
// would otherwise inherit the deleted profile's email as its login_hint.
|
||||
// Not fatal: the profile itself is gone.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
log.Warnf("failed to remove stored account email for profile %s: %v", id, err)
|
||||
}
|
||||
|
||||
log.Infof("removed profile: %s", id)
|
||||
return nil
|
||||
return pm.impl.RemoveProfile(id)
|
||||
}
|
||||
|
||||
// getProfileConfigPath returns the config file path for a profile
|
||||
// This is needed for Android-specific path handling (netbird.cfg for default profile)
|
||||
func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) {
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return "", fmt.Errorf("id %q is not valid", id)
|
||||
}
|
||||
|
||||
if id == profilemanager.DefaultProfileName {
|
||||
// Android uses netbird.cfg for default profile instead of default.json
|
||||
// Default profile is stored in root configDir, not in profiles/
|
||||
return filepath.Join(pm.configDir, defaultConfigFilename), nil
|
||||
}
|
||||
|
||||
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
||||
return filepath.Join(profilesDir, id+".json"), nil
|
||||
}
|
||||
|
||||
// GetConfigPath returns the config file path for a given profile id
|
||||
// Java should call this instead of constructing paths with Preferences.configFile()
|
||||
// GetConfigPath returns the config file path for the given profile ID. Java
|
||||
// should call this instead of constructing paths with Preferences.configFile().
|
||||
func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
|
||||
return pm.getProfileConfigPath(id)
|
||||
return pm.impl.GetConfigPath(id)
|
||||
}
|
||||
|
||||
// GetStateFilePath returns the state file path for a given profile
|
||||
// Java should call this instead of constructing paths with Preferences.stateFile()
|
||||
// GetStateFilePath returns the state file path for the given profile ID. Java
|
||||
// should call this instead of constructing paths with Preferences.stateFile().
|
||||
func (pm *ProfileManager) GetStateFilePath(id string) (string, error) {
|
||||
if id == "" || id == profilemanager.DefaultProfileName {
|
||||
return filepath.Join(pm.configDir, "state.json"), nil
|
||||
}
|
||||
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return "", fmt.Errorf("id %q is not valid", id)
|
||||
}
|
||||
|
||||
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
||||
return filepath.Join(profilesDir, id+".state.json"), nil
|
||||
return pm.impl.GetStateFilePath(id)
|
||||
}
|
||||
|
||||
// GetActiveConfigPath returns the config file path for the currently active profile
|
||||
// Java should call this instead of Preferences.getActiveProfileName() + Preferences.configFile()
|
||||
// GetActiveConfigPath returns the config file path for the currently active
|
||||
// profile.
|
||||
func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
|
||||
activeProfile, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get active profile: %w", err)
|
||||
}
|
||||
return pm.GetConfigPath(activeProfile.ID)
|
||||
return pm.impl.GetActiveConfigPath()
|
||||
}
|
||||
|
||||
// GetActiveStateFilePath returns the state file path for the currently active profile
|
||||
// Java should call this instead of Preferences.getActiveProfileName() + Preferences.stateFile()
|
||||
// GetActiveStateFilePath returns the state file path for the currently active
|
||||
// profile.
|
||||
func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
|
||||
activeProfile, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get active profile: %w", err)
|
||||
}
|
||||
return pm.GetStateFilePath(activeProfile.ID)
|
||||
return pm.impl.GetActiveStateFilePath()
|
||||
}
|
||||
|
||||
func fromMobileProfile(p *mobile.Profile) *Profile {
|
||||
return &Profile{ID: p.ID, Name: p.Name, Email: p.Email, IsActive: p.IsActive}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,9 @@ func newProfilePrefs(configDir, profileID string) (*profilePrefs, error) {
|
||||
if configDir == "" || profileID == "" {
|
||||
return nil, fmt.Errorf("profile prefs require a config dir and profile ID")
|
||||
}
|
||||
pm := NewProfileManager(configDir)
|
||||
prefs, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(profileID), androidUsername)
|
||||
prefs, err := NewProfileManager(configDir).impl.ProfilePrefs(profileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve profile prefs: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
return &profilePrefs{prefs: prefs}, nil
|
||||
}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// Android-specific config filename (different from desktop default.json)
|
||||
defaultConfigFilename = "netbird.cfg"
|
||||
// Subdirectory for non-default profiles (must match Java Preferences.java)
|
||||
profilesSubdir = "profiles"
|
||||
// profileAccountSuffix names the file holding the profile's account email.
|
||||
// Deliberately not ".state.json", which desktop uses for the same data:
|
||||
// there the email and the engine's state manager live in different
|
||||
// directories, but on Android both resolve under files/, so sharing the name
|
||||
// would have the two overwrite each other — the state manager rewrites the
|
||||
// whole file from its own keys (see statemanager.Manager.PersistState), and
|
||||
// this package's writer does the same in reverse.
|
||||
profileAccountSuffix = ".account.json"
|
||||
)
|
||||
|
||||
// profileAccountPathFor derives the account file path from a profile's config
|
||||
// path: netbird.cfg -> netbird.account.json, <id>.json -> <id>.account.json.
|
||||
//
|
||||
// Deriving from the config path rather than resolving the active profile keeps
|
||||
// the write on the profile the login actually ran for: Auth.login runs in a
|
||||
// goroutine, so the active profile can change under a flow already in flight.
|
||||
func profileAccountPathFor(configPath string) (string, error) {
|
||||
if configPath == "" {
|
||||
return "", fmt.Errorf("empty config path")
|
||||
}
|
||||
|
||||
base := filepath.Base(configPath)
|
||||
stem := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
if stem == "" || stem == "." {
|
||||
return "", fmt.Errorf("config path %q has no filename stem", configPath)
|
||||
}
|
||||
|
||||
return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil
|
||||
}
|
||||
|
||||
// profileLocationFor splits a profile's config path back into the config dir and
|
||||
// the profile ID: <dir>/netbird.cfg is the default profile, while
|
||||
// <dir>/profiles/<id>.json is a named one.
|
||||
func profileLocationFor(configPath string) (string, string, error) {
|
||||
if configPath == "" {
|
||||
return "", "", fmt.Errorf("empty config path")
|
||||
}
|
||||
|
||||
base := filepath.Base(configPath)
|
||||
dir := filepath.Dir(configPath)
|
||||
|
||||
if base == defaultConfigFilename {
|
||||
return dir, profilemanager.DefaultProfileName, nil
|
||||
}
|
||||
|
||||
if filepath.Base(dir) != profilesSubdir {
|
||||
return "", "", fmt.Errorf("config path %q is outside the profiles directory", configPath)
|
||||
}
|
||||
|
||||
id := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return "", "", fmt.Errorf("config path %q has no valid profile ID", configPath)
|
||||
}
|
||||
|
||||
return filepath.Dir(dir), id, nil
|
||||
}
|
||||
|
||||
// readProfileEmail returns the account email stored for the profile whose config
|
||||
// lives at configPath. A missing or unreadable file yields "", which leaves the
|
||||
// account choice to the IdP.
|
||||
func readProfileEmail(configPath string) string {
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
log.Debugf("no profile account path for login hint: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var state profilemanager.ProfileState
|
||||
if _, err := util.ReadJson(accountPath, &state); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
log.Debugf("failed to read profile account for login hint: %v", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return state.Email
|
||||
}
|
||||
|
||||
// writeProfileEmail records the account email for the profile whose config lives
|
||||
// at configPath, so later logins can pass it as an OIDC login_hint. An empty
|
||||
// email is ignored rather than blanking what is already stored.
|
||||
func writeProfileEmail(configPath string, email string) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve profile account path: %w", err)
|
||||
}
|
||||
|
||||
state := profilemanager.ProfileState{Email: email}
|
||||
if err := util.WriteJsonWithRestrictedPermission(context.Background(), accountPath, state); err != nil {
|
||||
return fmt.Errorf("write profile account: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeProfileEmail drops the stored account email. Called on profile removal,
|
||||
// not on logout: a logged-out profile keeps its email so the next login passes
|
||||
// it as the login_hint, matching the desktop and CLI semantics. Mirrors the
|
||||
// desktop UI's RemoveProfileState call.
|
||||
func removeProfileEmail(configPath string) error {
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve profile account path: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(accountPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove profile account: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProfileAccountPathFor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configPath string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "default profile",
|
||||
configPath: "/data/data/io.netbird.client/files/netbird.cfg",
|
||||
want: filepath.FromSlash("/data/data/io.netbird.client/files/netbird.account.json"),
|
||||
},
|
||||
{
|
||||
name: "id profile",
|
||||
configPath: "/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json",
|
||||
want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"),
|
||||
},
|
||||
{
|
||||
name: "legacy name-keyed profile is handled the same way",
|
||||
configPath: "/data/data/io.netbird.client/files/profiles/work.json",
|
||||
want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/work.account.json"),
|
||||
},
|
||||
{
|
||||
name: "empty path is rejected",
|
||||
configPath: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := profileAccountPathFor(tt.configPath)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error, got path %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) {
|
||||
root := "/data/data/io.netbird.client/files"
|
||||
|
||||
defaultAccount, err := profileAccountPathFor(filepath.Join(root, defaultConfigFilename))
|
||||
if err != nil {
|
||||
t.Fatalf("default profile: %v", err)
|
||||
}
|
||||
|
||||
idAccount, err := profileAccountPathFor(filepath.Join(root, profilesSubdir, "abc123.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("id profile: %v", err)
|
||||
}
|
||||
|
||||
if defaultAccount == idAccount {
|
||||
t.Fatalf("default and id profile share an account file: %q", defaultAccount)
|
||||
}
|
||||
}
|
||||
|
||||
// The account file must never land on the engine state file: on Android both
|
||||
// resolve under files/, and the state manager rewrites the whole file from its
|
||||
// own keys, so sharing a path would have the two overwrite each other. The
|
||||
// expected names here mirror ProfileManager.GetStateFilePath.
|
||||
func TestProfileAccountPathAvoidsEngineStateFile(t *testing.T) {
|
||||
root := "/data/data/io.netbird.client/files"
|
||||
|
||||
cases := []struct {
|
||||
configPath string
|
||||
engineState string
|
||||
}{
|
||||
{
|
||||
configPath: filepath.Join(root, defaultConfigFilename),
|
||||
engineState: filepath.Join(root, "state.json"),
|
||||
},
|
||||
{
|
||||
configPath: filepath.Join(root, profilesSubdir, "abc123.json"),
|
||||
engineState: filepath.Join(root, profilesSubdir, "abc123.state.json"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
account, err := profileAccountPathFor(c.configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.configPath, err)
|
||||
}
|
||||
if account == c.engineState {
|
||||
t.Errorf("account file collides with the engine state file: %q", account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteThenReadProfileEmail(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
|
||||
if err := ensureDirFor(t, configPath); err != nil {
|
||||
t.Fatalf("prepare dir: %v", err)
|
||||
}
|
||||
|
||||
if got := readProfileEmail(configPath); got != "" {
|
||||
t.Errorf("expected no email before a login, got %q", got)
|
||||
}
|
||||
|
||||
const email = "user@example.com"
|
||||
if err := writeProfileEmail(configPath, email); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
if got := readProfileEmail(configPath); got != email {
|
||||
t.Errorf("got %q, want %q", got, email)
|
||||
}
|
||||
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
if got := readProfileEmail(configPath); got != "" {
|
||||
t.Errorf("expected no email after removal, got %q", got)
|
||||
}
|
||||
|
||||
// Removal may run on a never-logged-in profile, so a second remove must pass.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
t.Fatalf("second remove should be a no-op: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProfileEmailIgnoresEmpty(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
|
||||
if err := ensureDirFor(t, configPath); err != nil {
|
||||
t.Fatalf("prepare dir: %v", err)
|
||||
}
|
||||
|
||||
const email = "user@example.com"
|
||||
if err := writeProfileEmail(configPath, email); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := writeProfileEmail(configPath, ""); err != nil {
|
||||
t.Fatalf("write empty: %v", err)
|
||||
}
|
||||
|
||||
if got := readProfileEmail(configPath); got != email {
|
||||
t.Errorf("empty write clobbered the stored email: got %q, want %q", got, email)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDirFor(t *testing.T, path string) error {
|
||||
t.Helper()
|
||||
return os.MkdirAll(filepath.Dir(path), 0o700)
|
||||
}
|
||||
Reference in New Issue
Block a user