Merge remote-tracking branch 'origin/main' into fix_debug_upload_url_from_mgmt

# Conflicts:
#	client/proto/daemon.pb.go
#	management/internals/controllers/network_map/controller/controller.go
#	management/internals/controllers/network_map/nmaptest/runner.go
#	management/internals/shared/grpc/server.go
#	management/server/account.go
#	shared/management/proto/management.pb.go
#	upload-server/server/server.go
This commit is contained in:
riccardom
2026-10-09 11:13:57 +02:00
518 changed files with 30626 additions and 12765 deletions
+31 -11
View File
@@ -103,8 +103,7 @@ type Client struct {
stateChangeMu sync.Mutex
stateChangeSubID string
eventSub *peer.EventSubscription
// Closed to stop the watch goroutines from delivering buffered items to a
// Closed to stop the watch goroutine from delivering buffered ticks to a
// listener that has been removed or replaced. See stopStateChangeWatchLocked.
stateChangeDone chan struct{}
@@ -212,6 +211,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
internal.WithNetEvents(c.netMgr))
c.setState(cfg, cacheDir, cfgFile, connectClient)
connectClient.SetSyncResponsePersistence(true)
// 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()
@@ -255,6 +255,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
internal.WithNetEvents(c.netMgr))
c.setState(cfg, cacheDir, cfgFile, connectClient)
connectClient.SetSyncResponsePersistence(true)
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
}
@@ -326,6 +327,19 @@ func (c *Client) NotifyNetworkChange() {
// or "strict"; strict also anonymizes internal IP ranges, peer names, and
// WireGuard public keys, and implies anonymize.
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) {
return c.debugBundle(platformFiles, anonymize, anonymizeLevel, true)
}
// DebugBundleFile generates a debug bundle and returns the path of the zip in
// the cache directory instead of uploading it, so the app can hand the file to
// the user for inspection. The caller owns the file and removes it once done;
// the stale-bundle cleanup of later runs removes it only after a day.
// anonymize and anonymizeLevel behave as in DebugBundle.
func (c *Client) DebugBundleFile(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) {
return c.debugBundle(platformFiles, anonymize, anonymizeLevel, false)
}
func (c *Client) debugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string, upload bool) (string, error) {
cfg, cacheDir, cc := c.stateSnapshot()
// If the engine hasn't been started, load config from disk
@@ -341,6 +355,11 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym
cacheDir = platformFiles.CacheDir()
}
// Clear what an interrupted earlier run may have left in the cache before
// adding to it. Remote debug jobs write to the same directory, so anything
// younger than an hour is treated as possibly still in use.
debug.RemoveStaleBundles(cacheDir, time.Hour)
deps := debug.GeneratorDependencies{
InternalConfig: cfg,
StatusRecorder: c.recorder,
@@ -388,6 +407,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym
if err != nil {
return "", fmt.Errorf("generate debug bundle: %w", err)
}
if !upload {
return debug.ExportBundle(path)
}
defer func() {
if err := os.Remove(path); err != nil {
log.Errorf("failed to remove debug bundle file: %v", err)
@@ -484,6 +506,7 @@ func (c *Client) Networks() *NetworkArray {
routesMap := routeManager.GetClientRoutesWithNetID()
v6Merged := route.V6ExitMergeSet(routesMap)
resolvedDomains := c.recorder.GetResolvedDomainsStates()
activeRoutePeers := c.recorder.GetActiveRoutePeers()
networkArray := &NetworkArray{
items: make([]Network, 0),
@@ -497,7 +520,7 @@ func (c *Client) Networks() *NetworkArray {
continue
}
network := c.buildNetwork(id, routes, routeSelector.IsSelected(id), resolvedDomains, v6Merged)
network := c.buildNetwork(id, routes, routeSelector.IsSelected(id), resolvedDomains, v6Merged, activeRoutePeers)
if network == nil {
continue
}
@@ -506,14 +529,14 @@ func (c *Client) Networks() *NetworkArray {
return networkArray
}
func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bool, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo, v6Merged map[route.NetID]struct{}) *Network {
func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bool, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo, v6Merged map[route.NetID]struct{}, activeRoutePeers map[route.HAUniqueID]string) *Network {
r := routes[0]
netStr := r.Network.String()
if r.IsDynamic() {
netStr = r.Domains.SafeString()
}
routePeer, err := c.findBestRoutePeer(routes)
routePeer, err := c.findBestRoutePeer(routes, activeRoutePeers)
if err != nil {
log.Errorf("could not get peer info for route %s: %v", id, err)
return nil
@@ -537,12 +560,9 @@ func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bo
// findBestRoutePeer returns the peer actively routing traffic for the given
// HA route group. Falls back to the first connected peer, then the first peer.
func (c *Client) findBestRoutePeer(routes []*route.Route) (peer.State, error) {
netStr := routes[0].Network.String()
fullStatus := c.recorder.GetFullStatus()
for _, p := range fullStatus.Peers {
if _, ok := p.GetRoutes()[netStr]; ok {
func (c *Client) findBestRoutePeer(routes []*route.Route, activeRoutePeers map[route.HAUniqueID]string) (peer.State, error) {
if peerKey, ok := activeRoutePeers[routes[0].GetHAUniqueID()]; ok {
if p, err := c.recorder.GetPeer(peerKey); err == nil {
return p, nil
}
}
+39 -2
View File
@@ -194,12 +194,49 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
}
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV, profileLoginHint(a.cfgPath))
return a.foregroundGetTokenInfoFlow(authClient, urlOpener, isAndroidTV, false)
}
// foregroundGetTokenInfoFlow runs the interactive flow. sessionExtend tells the
// server the token will renew this peer's session rather than log a peer in, so
// it can rule out a silent authorization the IdP could answer from an unrelated
// account. See PKCEAuthorizationFlowRequest.
func (a *Auth) foregroundGetTokenInfoFlow(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool, sessionExtend bool) (*auth.TokenInfo, error) {
hint := profileLoginHint(a.cfgPath)
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV, sessionExtend, hint)
if err != nil {
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
}
return runOAuthFlow(a.ctx, oAuthFlow, urlOpener, nil)
tokenInfo, err := runOAuthFlow(a.ctx, oAuthFlow, urlOpener, nil)
if err != nil {
return nil, err
}
if tokenInfo.MatchesAccount(hint) {
return tokenInfo, nil
}
// The IdP answered from a session belonging to another account. Retrying is
// what makes this recoverable: on a peer already registered the server would
// reject the token, and on a fresh one it would silently register the peer
// under the wrong account and bind the profile to it.
log.Infof("login returned an account other than the one this profile is bound to, retrying with an account prompt")
retryFlow := auth.RetryFlowForAccount(oAuthFlow)
if retryFlow == nil {
return tokenInfo, nil
}
retryToken, err := runOAuthFlow(a.ctx, retryFlow, urlOpener, nil)
if err != nil {
return nil, err
}
if !retryToken.MatchesAccount(hint) {
log.Warnf("login still returned a different account after the prompt, continuing with it")
}
return retryToken, nil
}
// profileLoginHint returns the stored account email for the profile at cfgPath.
+17 -17
View File
@@ -46,7 +46,7 @@ func (p *Preferences) GetManagementURL() (string, error) {
return p.configInput.ManagementURL, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return "", err
}
@@ -64,7 +64,7 @@ func (p *Preferences) GetAdminURL() (string, error) {
return p.configInput.AdminURL, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return "", err
}
@@ -86,7 +86,7 @@ func (p *Preferences) HasPreSharedKey() (bool, error) {
return *p.configInput.PreSharedKey != "", nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return false, err
}
@@ -112,7 +112,7 @@ func (p *Preferences) GetRosenpassEnabled() (bool, error) {
return *p.configInput.RosenpassEnabled, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return false, err
}
@@ -133,7 +133,7 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) {
return *p.configInput.RosenpassPermissive, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return false, err
}
@@ -149,7 +149,7 @@ func (p *Preferences) GetDisableClientRoutes() (bool, error) {
return *p.configInput.DisableClientRoutes, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return false, err
}
@@ -170,7 +170,7 @@ func (p *Preferences) GetDisableServerRoutes() (bool, error) {
return *p.configInput.DisableServerRoutes, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return false, err
}
@@ -188,7 +188,7 @@ func (p *Preferences) GetDisableDNS() (bool, error) {
return *p.configInput.DisableDNS, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return false, err
}
@@ -206,7 +206,7 @@ func (p *Preferences) GetDisableFirewall() (bool, error) {
return *p.configInput.DisableFirewall, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return false, err
}
@@ -227,7 +227,7 @@ func (p *Preferences) GetServerSSHAllowed() (bool, error) {
return *p.configInput.ServerSSHAllowed, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return false, err
}
@@ -249,7 +249,7 @@ func (p *Preferences) GetEnableSSHRoot() (bool, error) {
return *p.configInput.EnableSSHRoot, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return false, err
}
@@ -271,7 +271,7 @@ func (p *Preferences) GetEnableSSHSFTP() (bool, error) {
return *p.configInput.EnableSSHSFTP, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return false, err
}
@@ -293,7 +293,7 @@ func (p *Preferences) GetEnableSSHLocalPortForwarding() (bool, error) {
return *p.configInput.EnableSSHLocalPortForwarding, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return false, err
}
@@ -315,7 +315,7 @@ func (p *Preferences) GetEnableSSHRemotePortForwarding() (bool, error) {
return *p.configInput.EnableSSHRemotePortForwarding, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return false, err
}
@@ -340,7 +340,7 @@ func (p *Preferences) GetBlockInbound() (bool, error) {
return *p.configInput.BlockInbound, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return false, err
}
@@ -358,7 +358,7 @@ func (p *Preferences) GetDisableIPv6() (bool, error) {
return *p.configInput.DisableIPv6, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return false, err
}
@@ -377,7 +377,7 @@ func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
return *p.configInput.RemoteJobsAllowed, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath)
if err != nil {
return false, err
}
+16 -85
View File
@@ -6,13 +6,8 @@ import (
"context"
"fmt"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
"github.com/netbirdio/netbird/client/internal/peer"
cProto "github.com/netbirdio/netbird/client/proto"
)
// StateChangeListener receives client state notifications.
@@ -21,16 +16,11 @@ import (
// changed: connection state, the run-loop status label (e.g. NeedsLogin) or
// the session deadline. It mirrors the daemon's SubscribeStatus stream
// trigger — on each signal the consumer pulls the fresh values via
// Status() / SessionExpiresAtUnix().
//
// OnSessionExpiring forwards the engine's session-expiry warnings, fired at
// sessionwatch.WarningLead before the deadline and again at FinalWarningLead
// (finalWarning true). The second one is suppressed when the user dismissed
// the first via DismissSessionWarning. The daemon turns the same events into
// its tray notification.
// Status() / SessionExpiresAtUnix(). The engine arms no expiry-warning
// timers on Android; the app schedules the warnings from the deadline it
// reads here.
type StateChangeListener interface {
OnStateChanged()
OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool)
}
// Status returns the connect run-loop's status label — the same value the
@@ -110,11 +100,11 @@ func (c *Client) SetStateChangeListener(listener StateChangeListener) {
return
}
// Both subscriptions are buffered (one pending tick, ten pending events),
// so unsubscribing is not enough to stop callbacks: the loops would drain
// what is already queued and deliver it to a listener the caller has
// already removed or replaced. Gate every callback on this registration's
// own signal, which is closed before unsubscribing.
// The subscription is buffered (one pending tick), so unsubscribing is
// not enough to stop callbacks: the loop would drain what is already
// queued and deliver it to a listener the caller has already removed or
// replaced. Gate every callback on this registration's own signal, which
// is closed before unsubscribing.
done := make(chan struct{})
c.stateChangeDone = done
@@ -133,9 +123,6 @@ func (c *Client) SetStateChangeListener(listener StateChangeListener) {
listener.OnStateChanged()
}
}()
c.eventSub = c.recorder.SubscribeToEvents()
go watchSessionWarnings(c.eventSub, listener, done)
}
// RemoveStateChangeListener unregisters the state notification listener.
@@ -145,21 +132,6 @@ func (c *Client) RemoveStateChangeListener() {
c.stopStateChangeWatchLocked()
}
// DismissSessionWarning records the user's "Dismiss" on the first expiry
// warning and suppresses the final one for the current deadline. A refreshed
// deadline re-arms both. No-op while the engine is not running.
func (c *Client) DismissSessionWarning() {
cc := c.getConnectClient()
if cc == nil {
return
}
engine := cc.Engine()
if engine == nil {
return
}
engine.DismissSessionWarning()
}
// ExtendAuthSession runs the interactive SSO flow to obtain a fresh JWT and
// asks the management server to extend the session deadline. The tunnel is
// untouched: no resync, no reconnect. Async; the result arrives on the
@@ -201,8 +173,8 @@ func (c *Client) CancelExtendAuthSession() {
}
func (c *Client) stopStateChangeWatchLocked() {
// Signal first, unsubscribe second: closing the channels only stops new
// items, and the loops would still hand whatever is buffered to a listener
// Signal first, unsubscribe second: closing the channel only stops new
// items, and the loop would still hand whatever is buffered to a listener
// that is no longer registered.
if c.stateChangeDone != nil {
close(c.stateChangeDone)
@@ -212,49 +184,6 @@ func (c *Client) stopStateChangeWatchLocked() {
c.recorder.UnsubscribeFromStateChanges(c.stateChangeSubID)
c.stateChangeSubID = ""
}
if c.eventSub != nil {
// Closes the channel, which ends watchSessionWarnings.
c.recorder.UnsubscribeFromEvents(c.eventSub)
c.eventSub = nil
}
}
// watchSessionWarnings forwards the engine's session-expiry warnings to the
// listener. The event stream also carries unrelated traffic — network-map
// updates on every sync, DNS and route errors — so everything but an
// AUTHENTICATION event carrying the session-warning marker is dropped. Exits
// when the subscription is closed by UnsubscribeFromEvents, or earlier when
// done is closed — the stream buffers up to ten events, and a deregistered
// listener must not receive the ones already queued.
func watchSessionWarnings(sub *peer.EventSubscription, listener StateChangeListener, done <-chan struct{}) {
for ev := range sub.Events() {
select {
case <-done:
return
default:
}
if ev.GetCategory() != cProto.SystemEvent_AUTHENTICATION {
continue
}
meta := ev.GetMetadata()
if meta[sessionwatch.MetaSessionWarning] != "true" {
// Other AUTHENTICATION events exist (e.g. a deadline rejected as
// out of range); they carry no warning marker.
continue
}
deadline, err := sessionwatch.ParseExpiresAt(meta[sessionwatch.MetaSessionExpiresAt])
if err != nil {
log.Warnf("session warning event with unparsable deadline: %v", err)
continue
}
lead, err := sessionwatch.ParseLeadMinutes(meta[sessionwatch.MetaSessionLeadMinutes])
if err != nil {
// Informational only — the deadline above is what drives the UI.
lead = 0
}
listener.OnSessionExpiring(deadline.Unix(), int64(lead),
meta[sessionwatch.MetaSessionFinal] == "true")
}
}
func (c *Client) beginExtend() (context.Context, error) {
@@ -293,11 +222,13 @@ func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isA
}
defer authClient.Close()
// 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.
// Passing the config path makes the flow pick up the login_hint. That alone
// cannot keep the IdP on this profile's account though — a hint is only a
// suggestion, and a silent authorization is answered from whatever session the
// IdP already has, which need not be this peer's when several accounts are
// signed in. Marking the flow as an extend lets the server rule that out.
a := NewAuthWithConfig(ctx, cfg, cfgPath)
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
tokenInfo, err := a.foregroundGetTokenInfoFlow(authClient, urlOpener, isAndroidTV, true)
if err != nil {
return fmt.Errorf("interactive sso login failed: %v", err)
}
+1 -1
View File
@@ -469,7 +469,7 @@ func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config, cfgPath string)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profileLoginHint(cfgPath))
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profileLoginHint(cfgPath), false)
if err != nil {
return "", fmt.Errorf("create oauth flow: %w", err)
}