Files
netbird/client/ui/services/connection.go
Zoltán Papp 0ce3fbf5af [client] Tolerate already-deleted peer on profile logout, clear stale email
logoutFromProfile failed hard when the management server returned NotFound
(peer already deleted from the dashboard), blocking both profile logout and
profile removal. Treat NotFound as success — the peer is already gone, so
deregistering it is already satisfied.

Also drop the user-side per-profile state file on logout. The account email is
sourced from <profile>.state.json (written by the CLI after SSO login), which
the root daemon can't reach, so logout left a stale email showing in the UI.
Connection.Logout now removes it from the UI process after a successful logout;
the next SSO login recreates it.
2026-06-05 12:05:41 +02:00

358 lines
11 KiB
Go

//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"os/user"
"runtime"
"strings"
log "github.com/sirupsen/logrus"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/i18n"
"github.com/netbirdio/netbird/client/ui/preferences"
)
// ErrorTranslator is the subset of i18n.Bundle Connection needs to localise
// daemon errors. Defined as an interface so tests can stub it; the runtime
// implementation is *i18n.Bundle.
type ErrorTranslator interface {
Translate(lang i18n.LanguageCode, key string, args ...string) string
}
// LanguagePreference is the subset of preferences.Store Connection needs
// to discover the current UI language at error-classification time. The
// runtime implementation is *preferences.Store.
type LanguagePreference interface {
Get() preferences.UIPreferences
}
// ClientError is a structured error returned to the frontend.
//
// The daemon hands us gRPC errors whose Message is a stack of wrapped strings
// from the management server and the underlying JWT library, for example:
//
// "invalid jwt token, err: token could not be parsed: token has invalid
// claims: token used before issued"
//
// Showing that raw message in a native dialog is unreadable, so we map the
// substrings we recognise to a {code, short, long} triple. The frontend
// translates Code through i18n (preferred); Short is an English fallback so
// the dialog still reads cleanly if a code is missing from the locale; Long
// always carries the unwrapped daemon message for the operator.
type ClientError struct {
Code string `json:"code"`
Short string `json:"short"`
Long string `json:"long"`
}
// Error returns the user-facing short message so plain Go callers and the
// Wails default error path still get a readable string.
func (e *ClientError) Error() string {
if e == nil {
return ""
}
return e.Short
}
// MarshalJSON encodes the full {code, short, long} triple so the Wails
// binding emits a structured object instead of the default "error: ..."
// string. The TS layer accesses these fields via try/catch.
func (e *ClientError) MarshalJSON() ([]byte, error) {
if e == nil {
return []byte("null"), nil
}
type alias ClientError
return json.Marshal((*alias)(e))
}
// classifyDaemonError turns a raw gRPC error from the daemon into a
// ClientError with a stable code and a short localised summary. The Long
// field always carries the unwrapped daemon message so the operator can
// inspect the root cause when the short text is too generic. Short is
// looked up via i18n under "error.<code>": i18n.Bundle.Translate already
// handles current-language → English → key passthrough, so any missing
// locale entry surfaces as a visible "error.<code>" string in the dialog —
// a deliberate fail-loud signal that the bundle needs updating.
func (s *Connection) classifyDaemonError(err error) *ClientError {
if err == nil {
return nil
}
msg := err.Error()
if st, ok := gstatus.FromError(err); ok {
msg = st.Message()
}
lower := strings.ToLower(msg)
code := "unknown"
switch {
case strings.Contains(lower, "token used before issued"),
strings.Contains(lower, "token is not valid yet"):
code = "jwt_clock_skew"
case strings.Contains(lower, "token is expired"),
strings.Contains(lower, "token has expired"):
code = "jwt_expired"
case strings.Contains(lower, "token signature is invalid"):
code = "jwt_signature_invalid"
case strings.Contains(lower, "peer login has expired"):
code = "session_expired"
case strings.Contains(lower, "invalid setup-key"),
strings.Contains(lower, "invalid setup key"):
code = "invalid_setup_key"
case strings.Contains(lower, "permission denied"):
code = "permission_denied"
case strings.Contains(lower, "no connection could be made"),
strings.Contains(lower, "connection refused"),
strings.Contains(lower, "context deadline exceeded"):
code = "daemon_unreachable"
}
return &ClientError{
Code: code,
Short: s.translateShort(code),
Long: msg,
}
}
// translateShort resolves the localised short message for code. The i18n
// Bundle's own Translate already falls back current-language → English →
// key passthrough, so callers either see the localised string or the bare
// "error.<code>" key (which makes the missing translation obvious). If
// the translator is nil — e.g. a Connection constructed in a unit test —
// we return the key for the same reason.
func (s *Connection) translateShort(code string) string {
key := "error." + code
if s.translator == nil {
return key
}
lang := i18n.DefaultLanguage
if s.prefs != nil {
if pref := s.prefs.Get().Language; pref != "" {
lang = pref
}
}
return s.translator.Translate(lang, key)
}
// LoginParams carries the fields the UI sets when starting a login.
type LoginParams struct {
ProfileName string `json:"profileName"`
Username string `json:"username"`
ManagementURL string `json:"managementUrl"`
SetupKey string `json:"setupKey"`
PreSharedKey string `json:"preSharedKey"`
Hostname string `json:"hostname"`
Hint string `json:"hint"`
}
// LoginResult is the daemon's reply to a Login call.
type LoginResult struct {
NeedsSSOLogin bool `json:"needsSsoLogin"`
UserCode string `json:"userCode"`
VerificationURI string `json:"verificationUri"`
VerificationURIComplete string `json:"verificationUriComplete"`
}
// WaitSSOParams carries the fields the UI passes to WaitSSOLogin.
type WaitSSOParams struct {
UserCode string `json:"userCode"`
Hostname string `json:"hostname"`
}
// UpParams selects the profile the daemon should bring up.
type UpParams struct {
ProfileName string `json:"profileName"`
Username string `json:"username"`
}
// LogoutParams selects the profile the daemon should log out.
type LogoutParams struct {
ProfileName string `json:"profileName"`
Username string `json:"username"`
}
// Connection groups the daemon RPCs that drive login / connect / disconnect.
type Connection struct {
conn DaemonConn
translator ErrorTranslator
prefs LanguagePreference
}
// NewConnection wires Connection with its translation dependencies. Either
// translator or prefs may be nil; in that case classifyDaemonError falls
// back to the English Short text baked into the error map. main.go always
// supplies both at startup.
func NewConnection(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Connection {
return &Connection{conn: conn, translator: translator, prefs: prefs}
}
func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, error) {
cli, err := s.conn.Client()
if err != nil {
return LoginResult{}, err
}
// No pre-Login Down: the daemon's Login dislodges a pending WaitSSOLogin
// itself (server.go cancels the in-flight wait via actCancel), and an
// abandoned browser leg is torn down by startLogin cancelling the
// WaitSSOLogin RPC, which the daemon reacts to by clearing the stale
// OAuth flow. A defensive Down here would only add a visible Idle blink
// to the tray during the SSO handoff (Connect/profile-switch →
// NeedsLogin → auto-login) for no gain.
// Mirror the Fyne client's defaulting: when the frontend doesn't supply
// profile / username, fall back to the daemon's active profile and the
// current OS user. The flag matches the Fyne ui's IsUnixDesktopClient
// condition so the daemon knows we can render an SSO browser flow.
profileName := p.ProfileName
username := p.Username
if profileName == "" {
if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil {
profileName = active.GetProfileName()
if username == "" {
username = active.GetUsername()
}
}
}
if username == "" {
if u, uerr := user.Current(); uerr == nil {
username = u.Username
}
}
req := &proto.LoginRequest{
ManagementUrl: p.ManagementURL,
SetupKey: p.SetupKey,
Hostname: p.Hostname,
IsUnixDesktopClient: runtime.GOOS == "linux",
}
if profileName != "" {
req.ProfileName = ptrStr(profileName)
}
if username != "" {
req.Username = ptrStr(username)
}
if p.PreSharedKey != "" {
req.OptionalPreSharedKey = ptrStr(p.PreSharedKey)
}
if p.Hint != "" {
req.Hint = ptrStr(p.Hint)
}
resp, err := cli.Login(ctx, req)
if err != nil {
return LoginResult{}, s.classifyDaemonError(err)
}
return LoginResult{
NeedsSSOLogin: resp.GetNeedsSSOLogin(),
UserCode: resp.GetUserCode(),
VerificationURI: resp.GetVerificationURI(),
VerificationURIComplete: resp.GetVerificationURIComplete(),
}, nil
}
func (s *Connection) WaitSSOLogin(ctx context.Context, p WaitSSOParams) (string, error) {
cli, err := s.conn.Client()
if err != nil {
return "", err
}
resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{
UserCode: p.UserCode,
Hostname: p.Hostname,
})
if err != nil {
return "", s.classifyDaemonError(err)
}
return resp.GetEmail(), nil
}
func (s *Connection) Up(ctx context.Context, p UpParams) error {
cli, err := s.conn.Client()
if err != nil {
return err
}
// The UI always uses async mode: status updates flow via SubscribeStatus.
req := &proto.UpRequest{Async: true}
if p.ProfileName != "" {
req.ProfileName = ptrStr(p.ProfileName)
}
if p.Username != "" {
req.Username = ptrStr(p.Username)
}
if _, err = cli.Up(ctx, req); err != nil {
return s.classifyDaemonError(err)
}
return nil
}
func (s *Connection) Down(ctx context.Context) error {
cli, err := s.conn.Client()
if err != nil {
return err
}
if _, err = cli.Down(ctx, &proto.DownRequest{}); err != nil {
return s.classifyDaemonError(err)
}
return nil
}
// OpenURL launches the user's preferred browser to display url. Mirrors the
// Fyne client's openURL helper so the SSO flow can pop the verification page
// the same way as the legacy UI — WebKitGTK's window.open is blocked by the
// embedded webview, and asking the user to copy/paste defeats the point of
// SSO. Honors $BROWSER first, then falls back to the platform default.
func (s *Connection) OpenURL(url string) error {
if browser := os.Getenv("BROWSER"); browser != "" {
return exec.Command(browser, url).Start()
}
switch runtime.GOOS {
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
return exec.Command("open", url).Start()
case "linux":
return exec.Command("xdg-open", url).Start()
default:
return fmt.Errorf("unsupported platform")
}
}
func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
cli, err := s.conn.Client()
if err != nil {
return err
}
req := &proto.LogoutRequest{}
if p.ProfileName != "" {
req.ProfileName = ptrStr(p.ProfileName)
}
if p.Username != "" {
req.Username = ptrStr(p.Username)
}
if _, err = cli.Logout(ctx, req); err != nil {
return s.classifyDaemonError(err)
}
// The daemon runs as root and can't reach the user-owned per-profile state
// file that holds the account email (see Profiles.List). Drop it here from
// the UI process so a logged-out profile no longer shows a stale email; the
// next SSO login recreates it.
if p.ProfileName != "" {
if err := profilemanager.NewProfileManager().RemoveProfileState(p.ProfileName); err != nil {
// Non-fatal: the logout itself succeeded.
log.Warnf("failed to remove profile state for %s: %v", p.ProfileName, err)
}
}
return nil
}