mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-31 21:01:29 +02:00
Compare commits
11 Commits
dependabot
...
android/gu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4263315527 | ||
|
|
56ff5237dd | ||
|
|
3a17d0381c | ||
|
|
6155c94b05 | ||
|
|
09f7fb6510 | ||
|
|
4475819f38 | ||
|
|
c8adaa45da | ||
|
|
e970daaf5f | ||
|
|
5ae323a555 | ||
|
|
19337dc056 | ||
|
|
fd06d9a3d5 |
@@ -57,6 +57,12 @@ type DnsReadyListener interface {
|
||||
dns.ReadyListener
|
||||
}
|
||||
|
||||
// TunSettings is a snapshot of the settings the TUN device is rebuilt with
|
||||
type TunSettings struct {
|
||||
Routes string
|
||||
SearchDomains string
|
||||
}
|
||||
|
||||
func init() {
|
||||
formatter.SetLogcatFormatter(log.StandardLogger())
|
||||
}
|
||||
@@ -76,6 +82,8 @@ type Client struct {
|
||||
connectClient *internal.ConnectClient
|
||||
config *profilemanager.Config
|
||||
cacheDir string
|
||||
// Identifies the running profile for the SSO login hint; see profile_state.go.
|
||||
cfgPath string
|
||||
|
||||
stateChangeMu sync.Mutex
|
||||
stateChangeSubID string
|
||||
@@ -96,11 +104,12 @@ type Client struct {
|
||||
extendCancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
|
||||
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) {
|
||||
c.stateMu.Lock()
|
||||
defer c.stateMu.Unlock()
|
||||
c.config = cfg
|
||||
c.cacheDir = cacheDir
|
||||
c.cfgPath = cfgPath
|
||||
c.connectClient = cc
|
||||
}
|
||||
|
||||
@@ -110,6 +119,16 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, string, *internal.Conn
|
||||
return c.config, c.cacheDir, c.connectClient
|
||||
}
|
||||
|
||||
// authSnapshot returns the config together with the path it was loaded from, in
|
||||
// one lock: the path identifies the profile whose account email backs the login
|
||||
// hint, so reading it separately could pair one profile's config with another's
|
||||
// hint when a profile switch lands in between.
|
||||
func (c *Client) authSnapshot() (*profilemanager.Config, string, *internal.ConnectClient) {
|
||||
c.stateMu.RLock()
|
||||
defer c.stateMu.RUnlock()
|
||||
return c.config, c.cfgPath, c.connectClient
|
||||
}
|
||||
|
||||
func (c *Client) getConnectClient() *internal.ConnectClient {
|
||||
c.stateMu.RLock()
|
||||
defer c.stateMu.RUnlock()
|
||||
@@ -162,7 +181,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
defer c.ctxCancel()
|
||||
c.ctxCancelLock.Unlock()
|
||||
|
||||
auth := NewAuthWithConfig(ctx, cfg)
|
||||
auth := NewAuthWithConfig(ctx, cfg, cfgFile)
|
||||
err = auth.login(urlOpener, isAndroidTV)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -170,7 +189,7 @@ 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)
|
||||
c.setState(cfg, cacheDir, connectClient)
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
||||
// is authenticated again — release the latch Status() reports from. Clear
|
||||
// only once the fresh connect client is installed: until then Status()
|
||||
@@ -211,7 +230,7 @@ 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)
|
||||
c.setState(cfg, cacheDir, connectClient)
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||
}
|
||||
|
||||
@@ -240,6 +259,24 @@ func (c *Client) RenewTun(fd int) error {
|
||||
return e.RenewTun(fd)
|
||||
}
|
||||
|
||||
func (c *Client) GetTunSettings() (*TunSettings, error) {
|
||||
cc := c.getConnectClient()
|
||||
if cc == nil {
|
||||
return nil, fmt.Errorf("engine not running")
|
||||
}
|
||||
|
||||
e := cc.Engine()
|
||||
if e == nil {
|
||||
return nil, fmt.Errorf("engine not initialized")
|
||||
}
|
||||
|
||||
routes, searchDomains := e.TunSettings()
|
||||
return &TunSettings{
|
||||
Routes: strings.Join(routes, ";"),
|
||||
SearchDomains: strings.Join(searchDomains, ";"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
||||
// It works both with and without a running engine.
|
||||
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
@@ -53,11 +55,14 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewAuthWithConfig instantiate Auth based on existing config
|
||||
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
|
||||
// NewAuthWithConfig instantiate Auth based on existing config. cfgPath is the
|
||||
// file the config was loaded from; it identifies the profile whose account email
|
||||
// backs the login_hint.
|
||||
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config, cfgPath string) *Auth {
|
||||
return &Auth{
|
||||
ctx: ctx,
|
||||
config: config,
|
||||
ctx: ctx,
|
||||
config: config,
|
||||
cfgPath: cfgPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,12 +155,14 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
||||
}
|
||||
|
||||
jwtToken := ""
|
||||
email := ""
|
||||
if needsLogin {
|
||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||
}
|
||||
jwtToken = tokenInfo.GetTokenToUse()
|
||||
email = tokenInfo.Email
|
||||
}
|
||||
|
||||
err, _ = authClient.Login(a.ctx, "", jwtToken)
|
||||
@@ -163,17 +170,42 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
||||
return fmt.Errorf("login failed: %v", err)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
log.Warnf("failed to store profile account email: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
go urlOpener.OnLoginSuccess()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loginHintSetter is implemented by both concrete flows (PKCE and device code)
|
||||
// but absent from the OAuthFlow interface, hence the assertion below — the same
|
||||
// way internal/auth wires it in authenticateWithPKCEFlow.
|
||||
type loginHintSetter interface {
|
||||
SetLoginHint(hint string)
|
||||
}
|
||||
|
||||
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
|
||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
||||
}
|
||||
|
||||
// An empty hint is deliberate, not a fallback: a fresh or logged-out profile
|
||||
// leaves the choice to the IdP, which is how accounts get switched.
|
||||
if a.cfgPath != "" {
|
||||
if hint := readProfileEmail(a.cfgPath); hint != "" {
|
||||
if setter, ok := oAuthFlow.(loginHintSetter); ok {
|
||||
setter.SetLoginHint(hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
||||
|
||||
@@ -13,18 +13,17 @@ import (
|
||||
)
|
||||
|
||||
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"
|
||||
// Android uses a single user context per app (non-empty username required by ServiceManager)
|
||||
androidUsername = "android"
|
||||
)
|
||||
|
||||
// Profile represents a profile for gomobile
|
||||
type Profile struct {
|
||||
ID string
|
||||
Name string
|
||||
ID string
|
||||
Name string
|
||||
// Email is the account this profile last logged in with, "" if it never
|
||||
// completed an SSO login or was logged out. See profile_state.go.
|
||||
Email string
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
@@ -101,6 +100,7 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||
profiles = append(profiles, &Profile{
|
||||
ID: p.ID.String(),
|
||||
Name: p.Name,
|
||||
Email: pm.profileEmail(p.ID.String()),
|
||||
IsActive: p.IsActive,
|
||||
})
|
||||
}
|
||||
@@ -123,7 +123,22 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
||||
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, IsActive: true}, nil
|
||||
return &Profile{
|
||||
ID: prof.ID.String(),
|
||||
Name: prof.Name,
|
||||
Email: pm.profileEmail(prof.ID.String()),
|
||||
IsActive: true,
|
||||
}, 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
|
||||
@@ -185,6 +200,11 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
// Not fatal: a stale hint costs an account switch, not the logout itself.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
log.Warnf("failed to clear stored account email for profile %s: %v", id, err)
|
||||
}
|
||||
|
||||
log.Infof("logged out from profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
108
client/android/profile_state.go
Normal file
108
client/android/profile_state.go
Normal file
@@ -0,0 +1,108 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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 logout: while the
|
||||
// email is on disk it goes out as a login_hint, which would steer the next login
|
||||
// straight back into the account just logged out of. 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
|
||||
}
|
||||
161
client/android/profile_state_test.go
Normal file
161
client/android/profile_state_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
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: "/data/data/io.netbird.client/files/netbird.account.json",
|
||||
},
|
||||
{
|
||||
name: "id profile",
|
||||
configPath: "/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json",
|
||||
want: "/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: "/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 logout, got %q", got)
|
||||
}
|
||||
|
||||
// Logout 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)
|
||||
}
|
||||
@@ -278,7 +278,7 @@ func (c *Client) endExtend() {
|
||||
}
|
||||
|
||||
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
|
||||
cfg, _, cc := c.stateSnapshot()
|
||||
cfg, cfgPath, cc := c.authSnapshot()
|
||||
if cfg == nil || cc == nil {
|
||||
return fmt.Errorf("engine is not running")
|
||||
}
|
||||
@@ -293,7 +293,10 @@ func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isA
|
||||
}
|
||||
defer authClient.Close()
|
||||
|
||||
a := &Auth{ctx: ctx, config: cfg}
|
||||
// Passing the config path makes the flow pick up the login_hint: an extend
|
||||
// renews the session of the account already signed in, so it must not stop to
|
||||
// offer a choice.
|
||||
a := NewAuthWithConfig(ctx, cfg, cfgPath)
|
||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||
|
||||
@@ -113,11 +113,14 @@ func (c *ConnectClient) RunOnAndroid(
|
||||
stateFilePath string,
|
||||
cacheDir string,
|
||||
) error {
|
||||
notifier := tunnelnotifier.New(networkChangeListener, nil)
|
||||
defer notifier.Close()
|
||||
|
||||
// in case of non Android os these variables will be nil
|
||||
mobileDependency := MobileDependency{
|
||||
TunAdapter: tunAdapter,
|
||||
IFaceDiscover: iFaceDiscover,
|
||||
NetworkChangeListener: networkChangeListener,
|
||||
NetworkChangeListener: notifier,
|
||||
HostDNSAddresses: dnsAddresses,
|
||||
DnsReadyListener: dnsReadyListener,
|
||||
StateFilePath: stateFilePath,
|
||||
|
||||
@@ -51,7 +51,5 @@ func (n *notifier) notify() {
|
||||
return
|
||||
}
|
||||
|
||||
go func(l listener.NetworkChangeListener) {
|
||||
l.OnNetworkChanged("")
|
||||
}(n.listener)
|
||||
n.listener.OnNetworkChanged("")
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ func NewDefaultServerPermanentUpstream(
|
||||
ds.hostsDNSHolder.set(hostsDnsList)
|
||||
ds.permanent = true
|
||||
ds.currentConfig = dnsConfigToHostDNSConfig(config, ds.service.RuntimeIP(), ds.service.RuntimePort())
|
||||
ds.searchDomainNotifier = newNotifier(ds.SearchDomains())
|
||||
ds.searchDomainNotifier = newNotifier(ds.searchDomains())
|
||||
ds.searchDomainNotifier.setListener(listener)
|
||||
setServerDns(ds)
|
||||
return ds
|
||||
@@ -602,6 +602,12 @@ func (s *DefaultServer) UpdateDNSServer(serial uint64, update nbdns.Config) erro
|
||||
}
|
||||
|
||||
func (s *DefaultServer) SearchDomains() []string {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
return s.searchDomains()
|
||||
}
|
||||
|
||||
func (s *DefaultServer) searchDomains() []string {
|
||||
var searchDomains []string
|
||||
|
||||
for _, dConf := range s.currentConfig.Domains {
|
||||
@@ -686,7 +692,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
|
||||
}()
|
||||
|
||||
if s.searchDomainNotifier != nil {
|
||||
s.searchDomainNotifier.onNewSearchDomains(s.SearchDomains())
|
||||
s.searchDomainNotifier.onNewSearchDomains(s.searchDomains())
|
||||
}
|
||||
|
||||
s.updateNSGroupStates(update.NameServerGroups)
|
||||
|
||||
@@ -572,12 +572,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
}
|
||||
e.stateManager.Start()
|
||||
|
||||
initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read initial settings: %w", err)
|
||||
}
|
||||
|
||||
dnsServer, err := e.newDnsServer(dnsConfig)
|
||||
dnsServer, err := e.newDnsServer()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create dns server: %w", err)
|
||||
}
|
||||
@@ -595,10 +590,8 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
WGInterface: e.wgInterface,
|
||||
StatusRecorder: e.statusRecorder,
|
||||
RelayManager: e.relayManager,
|
||||
InitialRoutes: initialRoutes,
|
||||
StateManager: e.stateManager,
|
||||
DNSServer: dnsServer,
|
||||
DNSFeatureFlag: dnsFeatureFlag,
|
||||
PeerStore: e.peerStore,
|
||||
DisableClientRoutes: e.config.DisableClientRoutes,
|
||||
DisableServerRoutes: e.config.DisableServerRoutes,
|
||||
@@ -2102,42 +2095,6 @@ func (e *Engine) close() {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
|
||||
if runtime.GOOS != "android" {
|
||||
// nolint:nilnil
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
|
||||
info := system.GetInfo(e.ctx)
|
||||
info.SetFlags(
|
||||
e.config.RosenpassEnabled,
|
||||
e.config.RosenpassPermissive,
|
||||
&e.config.ServerSSHAllowed,
|
||||
e.config.DisableClientRoutes,
|
||||
e.config.DisableServerRoutes,
|
||||
e.config.DisableDNS,
|
||||
e.config.DisableFirewall,
|
||||
e.config.BlockLANAccess,
|
||||
e.config.BlockInbound,
|
||||
e.config.DisableIPv6,
|
||||
e.config.SyncMessageVersion,
|
||||
e.config.EnableSSHRoot,
|
||||
e.config.EnableSSHSFTP,
|
||||
e.config.EnableSSHLocalPortForwarding,
|
||||
e.config.EnableSSHRemotePortForwarding,
|
||||
e.config.DisableSSHAuth,
|
||||
)
|
||||
|
||||
netMap, err := e.mgmClient.GetNetworkMap(info)
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
routes := toRoutes(netMap.GetRoutes())
|
||||
dnsCfg := toDNSConfig(netMap.GetDNSConfig(), e.wgInterface.Address())
|
||||
dnsFeatureFlag := toDNSFeatureFlag(netMap)
|
||||
return routes, &dnsCfg, dnsFeatureFlag, nil
|
||||
}
|
||||
|
||||
func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
||||
transportNet, err := e.newStdNet()
|
||||
if err != nil {
|
||||
@@ -2172,7 +2129,7 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
||||
func (e *Engine) wgInterfaceCreate() (err error) {
|
||||
switch runtime.GOOS {
|
||||
case "android":
|
||||
err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
|
||||
err = e.wgInterface.CreateOnAndroid(e.routeManager.CurrentRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
|
||||
case "ios":
|
||||
e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String())
|
||||
if e.config.WgAddr.HasIPv6() {
|
||||
@@ -2185,7 +2142,7 @@ func (e *Engine) wgInterfaceCreate() (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
|
||||
func (e *Engine) newDnsServer() (dns.Server, error) {
|
||||
// due to tests where we are using a mocked version of the DNS server
|
||||
if e.dnsServer != nil {
|
||||
return e.dnsServer, nil
|
||||
@@ -2197,7 +2154,7 @@ func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
|
||||
e.ctx,
|
||||
e.wgInterface,
|
||||
e.mobileDep.HostDNSAddresses,
|
||||
*dnsConfig,
|
||||
nbdns.Config{},
|
||||
e.mobileDep.NetworkChangeListener,
|
||||
e.statusRecorder,
|
||||
e.config.DisableDNS,
|
||||
|
||||
20
client/internal/engine_tunsettings.go
Normal file
20
client/internal/engine_tunsettings.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package internal
|
||||
|
||||
func (e *Engine) TunSettings() ([]string, []string) {
|
||||
e.syncMsgMux.Lock()
|
||||
routeManager := e.routeManager
|
||||
dnsServer := e.dnsServer
|
||||
e.syncMsgMux.Unlock()
|
||||
|
||||
var routes []string
|
||||
if routeManager != nil {
|
||||
routes = routeManager.CurrentRouteRange()
|
||||
}
|
||||
|
||||
var searchDomains []string
|
||||
if dnsServer != nil {
|
||||
searchDomains = dnsServer.SearchDomains()
|
||||
}
|
||||
|
||||
return routes, searchDomains
|
||||
}
|
||||
@@ -45,12 +45,35 @@ func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||
// SetProfileState writes the state file of the profile identified by id. Prefer
|
||||
// it over SetActiveProfileState whenever the caller knows which profile the data
|
||||
// belongs to: an SSO login spans seconds of user interaction, and the active
|
||||
// profile can change during it, which would file the account email under
|
||||
// whichever profile happened to be active when the flow returned.
|
||||
func (pm *ProfileManager) SetProfileState(id ID, state *ProfileState) error {
|
||||
configDir, err := getConfigDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get config directory: %w", err)
|
||||
}
|
||||
|
||||
if id == "" {
|
||||
return fmt.Errorf("empty profile ID")
|
||||
}
|
||||
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
|
||||
return fmt.Errorf("invalid profile ID: %q", id)
|
||||
}
|
||||
|
||||
stateFile := filepath.Join(configDir, id.String()+".state.json")
|
||||
if err := util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state); err != nil {
|
||||
return fmt.Errorf("write profile state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetActiveProfileState writes the state file of whichever profile is active at
|
||||
// call time. Use SetProfileState when the target profile is known.
|
||||
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||
activeProf, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNoActiveProfile) {
|
||||
@@ -59,18 +82,7 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||
return fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
|
||||
id := activeProf.ID
|
||||
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
|
||||
return fmt.Errorf("invalid active profile ID: %q", id)
|
||||
}
|
||||
|
||||
stateFile := filepath.Join(configDir, id.String()+".state.json")
|
||||
err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write profile state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return pm.SetProfileState(activeProf.ID, state)
|
||||
}
|
||||
|
||||
// RemoveProfileState deletes the per-profile state file (which holds the
|
||||
|
||||
@@ -479,7 +479,7 @@ func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix, logger
|
||||
|
||||
// internalDnatFw checks if the firewall supports internal DNAT
|
||||
func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) {
|
||||
if d.firewall == nil || runtime.GOOS != "android" {
|
||||
if d.firewall == nil || d.fakeIPManager == nil || runtime.GOOS != "android" {
|
||||
return nil, false
|
||||
}
|
||||
fw, ok := d.firewall.(internalDNATer)
|
||||
|
||||
@@ -8,14 +8,13 @@ import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/exp/maps"
|
||||
@@ -62,7 +61,7 @@ type Manager interface {
|
||||
GetActiveClientRoutes() route.HAMap
|
||||
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
|
||||
SetRouteChangeListener(listener listener.NetworkChangeListener)
|
||||
InitialRouteRange() []string
|
||||
CurrentRouteRange() []string
|
||||
SetFirewall(firewall.Manager) error
|
||||
SetDNSForwarderPort(port uint16)
|
||||
ReconcilePeerAllowedIPs(peerKey string) error
|
||||
@@ -76,10 +75,8 @@ type ManagerConfig struct {
|
||||
WGInterface iface.WGIface
|
||||
StatusRecorder *peer.Status
|
||||
RelayManager *relayClient.Manager
|
||||
InitialRoutes []*route.Route
|
||||
StateManager *statemanager.Manager
|
||||
DNSServer dns.Server
|
||||
DNSFeatureFlag bool
|
||||
PeerStore *peerstore.Store
|
||||
DisableClientRoutes bool
|
||||
DisableServerRoutes bool
|
||||
@@ -149,45 +146,12 @@ func NewManager(config ManagerConfig) *DefaultManager {
|
||||
useNoop := netstack.IsEnabled() || config.DisableClientRoutes
|
||||
dm.setupRefCounters(useNoop)
|
||||
|
||||
// don't proceed with client routes if it is disabled
|
||||
if config.DisableClientRoutes {
|
||||
return dm
|
||||
}
|
||||
|
||||
if runtime.GOOS == "android" {
|
||||
dm.setupAndroidRoutes(config)
|
||||
}
|
||||
return dm
|
||||
}
|
||||
func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) {
|
||||
cr := m.initialClientRoutes(config.InitialRoutes)
|
||||
|
||||
routesForComparison := slices.Clone(cr)
|
||||
|
||||
if config.DNSFeatureFlag {
|
||||
m.fakeIPManager = fakeip.NewManager()
|
||||
|
||||
v4ID := uuid.NewString()
|
||||
fakeIPRoute := &route.Route{
|
||||
ID: route.ID(v4ID),
|
||||
Network: m.fakeIPManager.GetFakeIPBlock(),
|
||||
NetID: route.NetID(v4ID),
|
||||
Peer: m.pubKey,
|
||||
NetworkType: route.IPv4Network,
|
||||
}
|
||||
v6ID := uuid.NewString()
|
||||
fakeIPv6Route := &route.Route{
|
||||
ID: route.ID(v6ID),
|
||||
Network: m.fakeIPManager.GetFakeIPv6Block(),
|
||||
NetID: route.NetID(v6ID),
|
||||
Peer: m.pubKey,
|
||||
NetworkType: route.IPv6Network,
|
||||
}
|
||||
cr = append(cr, fakeIPRoute, fakeIPv6Route)
|
||||
m.notifier.SetFakeIPRoutes([]*route.Route{fakeIPRoute, fakeIPv6Route})
|
||||
}
|
||||
|
||||
m.notifier.SetInitialClientRoutes(cr, routesForComparison)
|
||||
func (m *DefaultManager) enableFakeIPRoutes() {
|
||||
m.fakeIPManager = fakeip.NewManager()
|
||||
m.notifier.NotifyRouteChange()
|
||||
}
|
||||
|
||||
func (m *DefaultManager) setupRefCounters(useNoop bool) {
|
||||
@@ -464,6 +428,9 @@ func (m *DefaultManager) UpdateRoutes(
|
||||
|
||||
var merr *multierror.Error
|
||||
if !m.disableClientRoutes {
|
||||
if runtime.GOOS == "android" && useNewDNSRoute && m.fakeIPManager == nil {
|
||||
m.enableFakeIPRoutes()
|
||||
}
|
||||
|
||||
// Update route selector based on management server's isSelected status
|
||||
m.updateRouteSelectorFromManagement(clientRoutes)
|
||||
@@ -500,9 +467,32 @@ func (m *DefaultManager) SetRouteChangeListener(listener listener.NetworkChangeL
|
||||
m.notifier.SetListener(listener)
|
||||
}
|
||||
|
||||
// InitialRouteRange return the list of initial routes. It used by mobile systems
|
||||
func (m *DefaultManager) InitialRouteRange() []string {
|
||||
return m.notifier.GetInitialRouteRanges()
|
||||
// CurrentRouteRange returns the current TUN route list. It is used by mobile systems
|
||||
func (m *DefaultManager) CurrentRouteRange() []string {
|
||||
m.mux.Lock()
|
||||
defer m.mux.Unlock()
|
||||
|
||||
if m.disableClientRoutes {
|
||||
return nil
|
||||
}
|
||||
|
||||
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
|
||||
var nets []string
|
||||
for _, routes := range filtered {
|
||||
for _, r := range routes {
|
||||
if r.IsDynamic() {
|
||||
continue
|
||||
}
|
||||
nets = append(nets, r.NetString())
|
||||
}
|
||||
}
|
||||
|
||||
if m.fakeIPManager != nil {
|
||||
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
|
||||
}
|
||||
|
||||
sort.Strings(nets)
|
||||
return nets
|
||||
}
|
||||
|
||||
// GetRouteSelector returns the route selector
|
||||
@@ -700,16 +690,6 @@ func (m *DefaultManager) ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]
|
||||
return newServerRoutesMap, newClientRoutesIDMap
|
||||
}
|
||||
|
||||
func (m *DefaultManager) initialClientRoutes(initialRoutes []*route.Route) []*route.Route {
|
||||
_, crMap := m.ClassifyRoutes(initialRoutes)
|
||||
rs := make([]*route.Route, 0, len(crMap))
|
||||
for _, routes := range crMap {
|
||||
rs = append(rs, routes...)
|
||||
}
|
||||
|
||||
return rs
|
||||
}
|
||||
|
||||
func isRouteSupported(route *route.Route) bool {
|
||||
if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() {
|
||||
return true
|
||||
|
||||
@@ -30,8 +30,8 @@ func (m *MockManager) Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitialRouteRange mock implementation of InitialRouteRange from Manager interface
|
||||
func (m *MockManager) InitialRouteRange() []string {
|
||||
// CurrentRouteRange mock implementation of CurrentRouteRange from Manager interface
|
||||
func (m *MockManager) CurrentRouteRange() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
@@ -14,12 +13,15 @@ import (
|
||||
)
|
||||
|
||||
type Notifier struct {
|
||||
initialRoutes []*route.Route
|
||||
currentRoutes []*route.Route
|
||||
fakeIPRoutes []*route.Route
|
||||
mu sync.Mutex
|
||||
|
||||
listener listener.NetworkChangeListener
|
||||
listenerMux sync.Mutex
|
||||
// currentRoutes is the last announced route set. It exists only to
|
||||
// suppress noise: without it every network map sync would trigger the
|
||||
// Java side, even when the routes did not change. The actual TUN route
|
||||
// state is owned by the route manager and pulled from there.
|
||||
currentRoutes []*route.Route
|
||||
|
||||
listener listener.NetworkChangeListener
|
||||
}
|
||||
|
||||
func NewNotifier() *Notifier {
|
||||
@@ -27,20 +29,15 @@ func NewNotifier() *Notifier {
|
||||
}
|
||||
|
||||
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
n.listenerMux.Lock()
|
||||
defer n.listenerMux.Unlock()
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.listener = listener
|
||||
}
|
||||
|
||||
// SetInitialClientRoutes stores the initial route sets for TUN configuration.
|
||||
func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) {
|
||||
n.initialRoutes = filterStatic(initialRoutes)
|
||||
n.currentRoutes = filterStatic(routesForComparison)
|
||||
}
|
||||
|
||||
// SetFakeIPRoutes stores the fake IP routes to be included in every TUN rebuild.
|
||||
func (n *Notifier) SetFakeIPRoutes(routes []*route.Route) {
|
||||
n.fakeIPRoutes = routes
|
||||
func (n *Notifier) NotifyRouteChange() {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.notifyLocked()
|
||||
}
|
||||
|
||||
func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
|
||||
@@ -54,46 +51,32 @@ func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
|
||||
}
|
||||
}
|
||||
|
||||
if !n.hasRouteDiff(n.currentRoutes, newRoutes) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if !hasRouteDiff(n.currentRoutes, newRoutes) {
|
||||
return
|
||||
}
|
||||
|
||||
n.currentRoutes = newRoutes
|
||||
n.notify()
|
||||
n.notifyLocked()
|
||||
}
|
||||
|
||||
func (n *Notifier) OnNewPrefixes([]netip.Prefix) {
|
||||
// Not used on Android
|
||||
}
|
||||
|
||||
func (n *Notifier) notify() {
|
||||
n.listenerMux.Lock()
|
||||
defer n.listenerMux.Unlock()
|
||||
func (n *Notifier) notifyLocked() {
|
||||
if n.listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
allRoutes := slices.Clone(n.currentRoutes)
|
||||
allRoutes = append(allRoutes, n.fakeIPRoutes...)
|
||||
|
||||
routeStrings := n.routesToStrings(allRoutes)
|
||||
sort.Strings(routeStrings)
|
||||
go func(l listener.NetworkChangeListener) {
|
||||
l.OnNetworkChanged(strings.Join(routeStrings, ","))
|
||||
}(n.listener)
|
||||
n.listener.OnNetworkChanged("")
|
||||
}
|
||||
|
||||
func filterStatic(routes []*route.Route) []*route.Route {
|
||||
out := make([]*route.Route, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
if !r.IsDynamic() {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
}
|
||||
|
||||
func (n *Notifier) routesToStrings(routes []*route.Route) []string {
|
||||
func routesToStrings(routes []*route.Route) []string {
|
||||
nets := make([]string, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
nets = append(nets, r.NetString())
|
||||
@@ -101,25 +84,10 @@ func (n *Notifier) routesToStrings(routes []*route.Route) []string {
|
||||
return nets
|
||||
}
|
||||
|
||||
func (n *Notifier) hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
||||
slices.SortFunc(a, func(x, y *route.Route) int {
|
||||
return strings.Compare(x.NetString(), y.NetString())
|
||||
})
|
||||
slices.SortFunc(b, func(x, y *route.Route) int {
|
||||
return strings.Compare(x.NetString(), y.NetString())
|
||||
})
|
||||
|
||||
return !slices.EqualFunc(a, b, func(x, y *route.Route) bool {
|
||||
return x.NetString() == y.NetString()
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
||||
initialStrings := n.routesToStrings(n.initialRoutes)
|
||||
sort.Strings(initialStrings)
|
||||
return initialStrings
|
||||
}
|
||||
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
||||
as := routesToStrings(a)
|
||||
bs := routesToStrings(b)
|
||||
sort.Strings(as)
|
||||
sort.Strings(bs)
|
||||
return !slices.Equal(as, bs)
|
||||
}
|
||||
|
||||
@@ -29,11 +29,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
n.listener = listener
|
||||
}
|
||||
|
||||
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
|
||||
// iOS doesn't care about initial routes
|
||||
}
|
||||
|
||||
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
|
||||
func (n *Notifier) NotifyRouteChange() {
|
||||
// Not used on iOS
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
|
||||
func (n *Notifier) NotifyRouteChange() {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
@@ -35,10 +31,6 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
}
|
||||
|
||||
@@ -43,7 +43,12 @@ function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise<v
|
||||
}
|
||||
|
||||
async function runSsoLogin(
|
||||
result: { verificationUri: string; verificationUriComplete: string; userCode: string },
|
||||
result: {
|
||||
verificationUri: string;
|
||||
verificationUriComplete: string;
|
||||
userCode: string;
|
||||
profileId: string;
|
||||
},
|
||||
state: SsoState,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
@@ -56,7 +61,7 @@ async function runSsoLogin(
|
||||
// suspended, so a frontend-driven Up (a promise continuation) would not
|
||||
// fire until the user woke the window (e.g. hovering the tray icon).
|
||||
const waitPromise = Connection.WaitSSOLoginAndUp(
|
||||
{ userCode: result.userCode, hostname: "" },
|
||||
{ userCode: result.userCode, hostname: "", profileId: result.profileId },
|
||||
{ profileName: "", username: "" },
|
||||
);
|
||||
|
||||
|
||||
@@ -33,12 +33,21 @@ type LoginResult struct {
|
||||
UserCode string `json:"userCode"`
|
||||
VerificationURI string `json:"verificationUri"`
|
||||
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||
// ProfileID is the ID of the profile this login ran against, or "" when the
|
||||
// caller named the profile itself and no ID was resolved. Pass it back in
|
||||
// WaitSSOParams so the account email lands on this profile even if the
|
||||
// active one changes during SSO.
|
||||
ProfileID string `json:"profileId"`
|
||||
}
|
||||
|
||||
// WaitSSOParams are the inputs to waitSSOLogin.
|
||||
type WaitSSOParams struct {
|
||||
UserCode string `json:"userCode"`
|
||||
Hostname string `json:"hostname"`
|
||||
// ProfileID is the profile the login was started for, used to file the
|
||||
// account email against it rather than against whichever profile is active
|
||||
// when the flow returns. Optional: empty falls back to the active profile.
|
||||
ProfileID string `json:"profileId"`
|
||||
}
|
||||
|
||||
// UpParams selects the profile to bring up.
|
||||
@@ -77,11 +86,16 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
// Fall back to the daemon's active profile and the current OS user.
|
||||
profileName := p.ProfileName
|
||||
username := p.Username
|
||||
// Only set when the daemon told us the ID. A caller-supplied ProfileName is
|
||||
// a handle — a display name or an ID prefix resolve too — and the state file
|
||||
// is named after the ID, so passing a handle on would name the wrong file.
|
||||
profileID := ""
|
||||
if profileName == "" {
|
||||
if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil {
|
||||
// Address the active profile by ID (the daemon resolves it as a
|
||||
// handle); names can collide, the ID cannot.
|
||||
profileName = active.GetId()
|
||||
profileID = profileName
|
||||
if username == "" {
|
||||
username = active.GetUsername()
|
||||
}
|
||||
@@ -122,6 +136,7 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
UserCode: resp.GetUserCode(),
|
||||
VerificationURI: resp.GetVerificationURI(),
|
||||
VerificationURIComplete: resp.GetVerificationURIComplete(),
|
||||
ProfileID: profileID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -242,6 +257,31 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
|
||||
return "", s.classifyDaemonError(err)
|
||||
}
|
||||
log.Infof("SSO login completed, daemon reported success")
|
||||
|
||||
// Persist the account email the same way the CLI does after its own
|
||||
// WaitSSOLogin: the daemon returns it but cannot store it, since it runs as
|
||||
// root and the per-profile state file is user-owned (see Logout below).
|
||||
// Without this the profile has no email, so Profiles.List shows no account
|
||||
// and later logins and session extends go out without a login_hint —
|
||||
// leaving the IdP to guess which account was meant.
|
||||
if email := resp.GetEmail(); email != "" {
|
||||
state := &profilemanager.ProfileState{Email: email}
|
||||
pm := profilemanager.NewProfileManager()
|
||||
|
||||
// Against the profile the login was started for: SSO spans seconds of
|
||||
// user interaction, and a profile switch in that window would otherwise
|
||||
// file the email under the wrong profile.
|
||||
if p.ProfileID != "" {
|
||||
err = pm.SetProfileState(profilemanager.ID(p.ProfileID), state)
|
||||
} else {
|
||||
err = pm.SetActiveProfileState(state)
|
||||
}
|
||||
if err != nil {
|
||||
// Non-fatal: the login itself succeeded.
|
||||
log.Warnf("failed to store account email: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return resp.GetEmail(), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"context"
|
||||
"os/user"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
@@ -151,11 +153,31 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
|
||||
resp, err := cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
})
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The daemon deletes what it owns but runs as root, so it leaves the
|
||||
// user-owned state file holding the account email behind (same split as
|
||||
// Connection.Logout). Legacy profiles are keyed by name rather than by a
|
||||
// generated ID, so a recreated profile of the same name would inherit the
|
||||
// deleted one's email and offer it as the login_hint.
|
||||
//
|
||||
// Keyed on the ID the daemon resolved, not on the request handle: that may
|
||||
// have been a display name or an ID prefix, which would name a different
|
||||
// file (or none).
|
||||
if id := resp.GetId(); id != "" {
|
||||
if err := profilemanager.NewProfileManager().RemoveProfileState(id); err != nil {
|
||||
// Non-fatal: the profile itself is gone.
|
||||
log.Warnf("failed to remove profile state for %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rename changes a profile's display name. The on-disk ID is unaffected, so
|
||||
|
||||
@@ -22,7 +22,6 @@ type Client interface {
|
||||
ExtendAuthSession(sysInfo *system.Info, jwtToken string) (*proto.ExtendAuthSessionResponse, error)
|
||||
GetDeviceAuthorizationFlow() (*proto.DeviceAuthorizationFlow, error)
|
||||
GetPKCEAuthorizationFlow() (*proto.PKCEAuthorizationFlow, error)
|
||||
GetNetworkMap(sysInfo *system.Info) (*proto.NetworkMap, error)
|
||||
GetServerURL() string
|
||||
// IsHealthy returns the current connection status without blocking.
|
||||
// Used by the engine to monitor connectivity in the background.
|
||||
|
||||
@@ -436,49 +436,6 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNetworkMap return with the network map
|
||||
func (c *GrpcClient) GetNetworkMap(sysInfo *system.Info) (*proto.NetworkMap, error) {
|
||||
serverPubKey, err := c.getServerPublicKey()
|
||||
if err != nil {
|
||||
log.Debugf("failed getting Management Service public key: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancelStream := context.WithCancel(c.ctx)
|
||||
defer cancelStream()
|
||||
stream, err := c.connectToSyncStream(ctx, *serverPubKey, sysInfo)
|
||||
if err != nil {
|
||||
log.Debugf("failed to open Management Service stream: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = stream.CloseSend()
|
||||
}()
|
||||
|
||||
update, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
log.Debugf("Management stream has been closed by server: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
log.Debugf("disconnected from Management Service sync stream: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
decryptedResp := &proto.SyncResponse{}
|
||||
err = encryption.DecryptMessage(*serverPubKey, c.key, update.Body, decryptedResp)
|
||||
if err != nil {
|
||||
log.Errorf("failed decrypting update message from Management Service: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if decryptedResp.GetNetworkMap() == nil {
|
||||
return nil, fmt.Errorf("invalid msg, required network map")
|
||||
}
|
||||
|
||||
return decryptedResp.GetNetworkMap(), nil
|
||||
}
|
||||
|
||||
func (c *GrpcClient) connectToSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info) (proto.ManagementService_SyncClient, error) {
|
||||
req := &proto.SyncRequest{Meta: infoToMetaData(sysInfo)}
|
||||
|
||||
|
||||
@@ -94,11 +94,6 @@ func (m *MockClient) HealthCheck() error {
|
||||
return m.HealthCheckFunc()
|
||||
}
|
||||
|
||||
// GetNetworkMap mock implementation of GetNetworkMap from Client interface.
|
||||
func (m *MockClient) GetNetworkMap(_ *system.Info) (*proto.NetworkMap, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetServerURL mock implementation of GetServerURL from mgm.Client interface
|
||||
func (m *MockClient) GetServerURL() string {
|
||||
if m.GetServerURLFunc == nil {
|
||||
|
||||
Reference in New Issue
Block a user