mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-31 11:38:42 -04:00
## Describe your changes Adds the session surface the Android client was missing: read the status label and session deadline, receive the engine's expiry warnings, extend the session via SSO without dropping the tunnel (cancellable), and dismiss a warning. Status() latches NeedsLogin so an engine restart doesn't erase it. ## Issue ticket number and link ## Stack <!-- branch-stack --> ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ <!-- codesmith:footer --> --- <a href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6945"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img alt="View with [code]smith" src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a> <a href="https://backend.blacksmith.sh/track/enable-autofix?expires=1787842170&installation_model_id=427504&pr_number=6945&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6945&signature=c3279ac9ce68e376c23320aa7c2a317ce92377595c433e72925bc0264ef372a5"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img alt="Autofix with [code]smith" src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a> <sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you need. Autofix is disabled.</sup> <!-- codesmith:autofix:disabled --> <!-- /codesmith:footer --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Android session status and session expiration details. * Added listener support for wake-state changes and session-expiry warnings. * Added interactive authentication session extension with async login-success handling, error reporting, warning dismissal, and cancellation of an in-progress extension. * **Bug Fixes** * Improved “login required” state handling after successful sign-in to prevent stale login-needed prompts. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
310 lines
10 KiB
Go
310 lines
10 KiB
Go
//go:build android
|
|
|
|
package android
|
|
|
|
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.
|
|
//
|
|
// OnStateChanged is a payload-free wake-up whenever the state snapshot
|
|
// 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.
|
|
type StateChangeListener interface {
|
|
OnStateChanged()
|
|
OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool)
|
|
}
|
|
|
|
// Status returns the connect run-loop's status label — the same value the
|
|
// desktop daemon serves in StatusResponse.Status. "NeedsLogin" means the
|
|
// management server rejected the peer and an interactive login is required.
|
|
//
|
|
// The label is latched: the run loop keeps its status in a per-run context
|
|
// state, which a restart replaces with a fresh Idle one, so an engine restart
|
|
// (network change, always-on) would otherwise erase the fact that the peer
|
|
// still needs to log in. Only a successful interactive login or extend clears
|
|
// it — see clearLoginRequired.
|
|
func (c *Client) Status() string {
|
|
latched, generation := c.loginRequiredState()
|
|
if latched {
|
|
return string(internal.StatusNeedsLogin)
|
|
}
|
|
cc := c.getConnectClient()
|
|
if cc == nil {
|
|
return string(internal.StatusIdle)
|
|
}
|
|
status := cc.Status()
|
|
if status == internal.StatusNeedsLogin {
|
|
c.latchLoginRequired(generation)
|
|
}
|
|
return string(status)
|
|
}
|
|
|
|
func (c *Client) loginRequiredState() (bool, uint64) {
|
|
c.loginRequiredMu.Lock()
|
|
defer c.loginRequiredMu.Unlock()
|
|
return c.loginRequired, c.loginCleared
|
|
}
|
|
|
|
// latchLoginRequired records a NeedsLogin observation, unless a clear landed
|
|
// while the caller was reading the run loop's status: cc.Status() is read
|
|
// outside the lock, so a login or extend completing in that window would
|
|
// otherwise be undone by this stale observation, stranding the UI on
|
|
// "login required" over a healthy session.
|
|
func (c *Client) latchLoginRequired(observedGeneration uint64) {
|
|
c.loginRequiredMu.Lock()
|
|
defer c.loginRequiredMu.Unlock()
|
|
if c.loginCleared != observedGeneration {
|
|
return
|
|
}
|
|
c.loginRequired = true
|
|
}
|
|
|
|
// clearLoginRequired releases the latch after a successful interactive login
|
|
// or session extend, and invalidates any observation already in flight.
|
|
func (c *Client) clearLoginRequired() {
|
|
c.loginRequiredMu.Lock()
|
|
defer c.loginRequiredMu.Unlock()
|
|
c.loginRequired = false
|
|
c.loginCleared++
|
|
}
|
|
|
|
// SessionExpiresAtUnix returns the SSO session deadline as unix seconds, or 0
|
|
// when no deadline is known (not SSO-registered, expiry disabled, or the
|
|
// engine has not received one yet). A past value means the session expired.
|
|
// Mirror of StatusResponse.sessionExpiresAt on the desktop daemon.
|
|
func (c *Client) SessionExpiresAtUnix() int64 {
|
|
deadline := c.recorder.GetSessionExpiresAt()
|
|
if deadline.IsZero() {
|
|
return 0
|
|
}
|
|
return deadline.Unix()
|
|
}
|
|
|
|
// SetStateChangeListener registers the state notification listener.
|
|
// Replaces any previously registered listener; remove it with
|
|
// RemoveStateChangeListener.
|
|
func (c *Client) SetStateChangeListener(listener StateChangeListener) {
|
|
c.stateChangeMu.Lock()
|
|
defer c.stateChangeMu.Unlock()
|
|
c.stopStateChangeWatchLocked()
|
|
if listener == nil {
|
|
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.
|
|
done := make(chan struct{})
|
|
c.stateChangeDone = done
|
|
|
|
id, ch := c.recorder.SubscribeToStateChanges()
|
|
c.stateChangeSubID = id
|
|
// The channel is closed by UnsubscribeFromStateChanges, which ends the
|
|
// goroutine. Ticks are coalesced (buffer of one), so a burst of changes
|
|
// wakes the listener once.
|
|
go func() {
|
|
for range ch {
|
|
select {
|
|
case <-done:
|
|
return
|
|
default:
|
|
}
|
|
listener.OnStateChanged()
|
|
}
|
|
}()
|
|
|
|
c.eventSub = c.recorder.SubscribeToEvents()
|
|
go watchSessionWarnings(c.eventSub, listener, done)
|
|
}
|
|
|
|
// RemoveStateChangeListener unregisters the state notification listener.
|
|
func (c *Client) RemoveStateChangeListener() {
|
|
c.stateChangeMu.Lock()
|
|
defer c.stateChangeMu.Unlock()
|
|
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
|
|
// listener. Mirror of the daemon's RequestExtendAuthSession /
|
|
// WaitExtendAuthSession RPC pair, with URLOpener playing the "UI opens the
|
|
// browser" role.
|
|
//
|
|
// Only one flow may be in flight: the PKCE step binds a fixed loopback port,
|
|
// so a second concurrent flow would fail on that bind. Call
|
|
// CancelExtendAuthSession when the user abandons the browser.
|
|
func (c *Client) ExtendAuthSession(urlOpener URLOpener, isAndroidTV bool, resultListener ErrListener) {
|
|
ctx, err := c.beginExtend()
|
|
if err != nil {
|
|
resultListener.OnError(err)
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
defer c.endExtend()
|
|
if err := c.extendAuthSession(ctx, urlOpener, isAndroidTV); err != nil {
|
|
resultListener.OnError(err)
|
|
return
|
|
}
|
|
resultListener.OnSuccess()
|
|
}()
|
|
}
|
|
|
|
// CancelExtendAuthSession aborts an in-flight ExtendAuthSession. The tunnel is
|
|
// left alone — unlike the login flow, which cancels the whole client context
|
|
// by stopping the engine. Without this the abandoned PKCE wait keeps its
|
|
// loopback port for the full flow timeout and blocks every later attempt.
|
|
// No-op when no flow is running.
|
|
func (c *Client) CancelExtendAuthSession() {
|
|
c.extendMu.Lock()
|
|
defer c.extendMu.Unlock()
|
|
if c.extendCancel != nil {
|
|
c.extendCancel()
|
|
}
|
|
}
|
|
|
|
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
|
|
// that is no longer registered.
|
|
if c.stateChangeDone != nil {
|
|
close(c.stateChangeDone)
|
|
c.stateChangeDone = nil
|
|
}
|
|
if c.stateChangeSubID != "" {
|
|
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) {
|
|
c.extendMu.Lock()
|
|
defer c.extendMu.Unlock()
|
|
if c.extendCancel != nil {
|
|
return nil, fmt.Errorf("session extend already in progress")
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
c.extendCancel = cancel
|
|
return ctx, nil
|
|
}
|
|
|
|
func (c *Client) endExtend() {
|
|
c.extendMu.Lock()
|
|
defer c.extendMu.Unlock()
|
|
if c.extendCancel != nil {
|
|
c.extendCancel()
|
|
c.extendCancel = nil
|
|
}
|
|
}
|
|
|
|
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
|
|
cfg, _, cc := c.stateSnapshot()
|
|
if cfg == nil || cc == nil {
|
|
return fmt.Errorf("engine is not running")
|
|
}
|
|
engine := cc.Engine()
|
|
if engine == nil {
|
|
return fmt.Errorf("engine is not initialized")
|
|
}
|
|
|
|
authClient, err := auth.NewAuth(ctx, cfg.PrivateKey, cfg.ManagementURL, cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create auth client: %v", err)
|
|
}
|
|
defer authClient.Close()
|
|
|
|
a := &Auth{ctx: ctx, config: cfg}
|
|
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
|
if err != nil {
|
|
return fmt.Errorf("interactive sso login failed: %v", err)
|
|
}
|
|
|
|
if _, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse()); err != nil {
|
|
return err
|
|
}
|
|
c.clearLoginRequired()
|
|
|
|
go urlOpener.OnLoginSuccess()
|
|
return nil
|
|
}
|