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:
Zoltán Papp
2026-08-27 11:13:19 +02:00
344 changed files with 19335 additions and 4922 deletions
+294
View File
@@ -0,0 +1,294 @@
// Package mobile holds the profile manager implementation shared by the
// Android and iOS gomobile bindings. The platform packages (client/android,
// client/ios/NetBirdSDK) only adapt this API to gomobile-friendly types.
package mobile
import (
"fmt"
"os"
"path/filepath"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
const (
// Config filename of the default profile, stored at the configDir root.
// Both platforms use netbird.cfg (matching the desktop netbird.cfg rather
// than default.json); the app-side path constants must match.
defaultConfigFilename = "netbird.cfg"
// Subdirectory of configDir holding non-default profiles.
profilesSubdir = "profiles"
)
/*
<configDir>/ ← app-writable config root
├── netbird.cfg ← Default profile config
├── netbird.account.json ← Default profile account email (see profile_state.go)
├── state.json ← Default profile state
├── active_profile.json ← Active profile tracker (JSON with ID + Username)
└── profiles/ ← Subdirectory for non-default profiles
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← Profile config (filename = ID)
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← Profile state
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.account.json ← Profile account email
└── 4c5f5c8198c3989cffb5b5394f5a7ae0.prefs.json ← Profile preferences
*/
// Profile is the platform-independent profile view handed to the bindings.
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.
Email string
IsActive bool
}
// ProfileManager manages profiles for the mobile platforms. It wraps the
// internal profilemanager.ServiceManager with mobile-specific path handling.
// All profile identity is ID-based; the human-readable name lives inside the
// profile config's Name field.
type ProfileManager struct {
configDir string
username string
serviceMgr *profilemanager.ServiceManager
}
// NewProfileManager creates a profile manager rooted at configDir, the
// app-writable directory that every process of the app can reach. username is
// the platform's fixed single-user context (a non-empty username is required
// by ServiceManager for non-default profiles).
func NewProfileManager(configDir, username string) *ProfileManager {
// The default profile is stored in the root configDir, not under profiles/.
defaultConfigPath := filepath.Join(configDir, defaultConfigFilename)
// Point the package globals at the app-provided directory, overriding the
// desktop defaults set in profilemanager's init().
profilemanager.DefaultConfigPathDir = configDir
profilemanager.DefaultConfigPath = defaultConfigPath
profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json")
// Non-default profiles live in the profiles/ subdirectory. Passing it
// explicitly avoids touching the global config-dir override.
profilesDir := filepath.Join(configDir, profilesSubdir)
serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir)
return &ProfileManager{
configDir: configDir,
username: username,
serviceMgr: serviceMgr,
}
}
// ListProfiles returns all available profiles, including the default profile,
// with their active status set.
func (pm *ProfileManager) ListProfiles() ([]Profile, error) {
internalProfiles, err := pm.serviceMgr.ListProfiles(pm.username)
if err != nil {
return nil, fmt.Errorf("list profiles: %w", err)
}
profiles := make([]Profile, 0, len(internalProfiles))
for _, p := range internalProfiles {
profiles = append(profiles, Profile{
ID: p.ID.String(),
Name: p.Name,
Email: pm.profileEmail(p.ID.String()),
IsActive: p.IsActive,
})
}
return profiles, nil
}
// GetActiveProfile returns the currently active profile, resolving its ID to
// the full profile so callers get the real display name.
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
activeState, err := pm.serviceMgr.GetActiveProfileState()
if err != nil {
return nil, fmt.Errorf("get active profile: %w", err)
}
prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), pm.username)
if err != nil {
return nil, fmt.Errorf("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
}
// 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 {
if err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
ID: profilemanager.ID(id),
Username: pm.username,
}); err != nil {
return fmt.Errorf("switch profile: %w", err)
}
log.Infof("switched to profile: %s", id)
return nil
}
// AddProfile creates a new profile with the given display name and a
// generated ID. It returns the created profile so the caller learns the ID.
func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) {
profile, err := pm.serviceMgr.AddProfile(displayName, pm.username)
if err != nil {
return nil, fmt.Errorf("add profile: %w", err)
}
log.Infof("created new profile: %s", profile.ID)
return &Profile{ID: profile.ID.String(), Name: profile.Name, IsActive: false}, nil
}
// 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), pm.username, newName); err != nil {
return fmt.Errorf("rename profile: %w", err)
}
log.Infof("renamed profile %s to %q", id, newName)
return nil
}
// LogoutProfile clears authentication data for a profile by removing its
// private key and SSH key from the config, forcing a re-login. The management
// URL and other settings are preserved.
func (pm *ProfileManager) LogoutProfile(id string) error {
configPath, err := pm.getProfileConfigPath(id)
if err != nil {
return err
}
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return fmt.Errorf("profile %q does not exist", id)
}
config, err := profilemanager.ReadConfig(configPath)
if err != nil {
return fmt.Errorf("read profile config: %w", err)
}
config.PrivateKey = ""
config.SSHKey = ""
if err := profilemanager.WriteOutConfig(configPath, config); err != nil {
return fmt.Errorf("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
}
// 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
}
if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), pm.username); err != nil {
return fmt.Errorf("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
}
// ProfilePrefs returns the namespaced per-profile preference store of the
// profile identified by id.
func (pm *ProfileManager) ProfilePrefs(id string) (*profilemanager.Prefs, error) {
prefs, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(id), pm.username)
if err != nil {
return nil, fmt.Errorf("resolve profile prefs: %w", err)
}
return prefs, nil
}
// GetConfigPath returns the config file path for the given profile ID. The
// platform code should call this instead of constructing paths itself.
func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
return pm.getProfileConfigPath(id)
}
// GetStateFilePath returns the state file path for the given profile ID.
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
}
// 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("get active profile: %w", err)
}
return pm.GetConfigPath(activeProfile.ID)
}
// 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("get active profile: %w", err)
}
return pm.GetStateFilePath(activeProfile.ID)
}
// 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)
}
// getProfileConfigPath returns the config file path for a profile ID. The
// default profile uses netbird.cfg in the root configDir; other profiles use
// <id>.json in the profiles/ subdirectory.
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 {
return filepath.Join(pm.configDir, defaultConfigFilename), nil
}
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
return filepath.Join(profilesDir, id+".json"), nil
}
+131
View File
@@ -0,0 +1,131 @@
package mobile
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 (
// 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 mobile both resolve under configDir, 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: login flows run 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
}
+190
View File
@@ -0,0 +1,190 @@
package mobile
import (
"os"
"path/filepath"
"testing"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
func TestProfileAccountPathFor(t *testing.T) {
tests := []struct {
name string
configPath string
want string
wantErr bool
}{
{
name: "default profile",
configPath: "/data/netbird/files/netbird.cfg",
want: filepath.FromSlash("/data/netbird/files/netbird.account.json"),
},
{
name: "id profile",
configPath: "/data/netbird/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json",
want: filepath.FromSlash("/data/netbird/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"),
},
{
name: "legacy name-keyed profile is handled the same way",
configPath: "/data/netbird/files/profiles/work.json",
want: filepath.FromSlash("/data/netbird/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/netbird/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 mobile both
// resolve under configDir, 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/netbird/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)
}
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)
}
}
}