mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-17 03:59:56 +00:00
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.
358 lines
11 KiB
Go
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
|
|
}
|