diff --git a/client/cmd/daemon_access_unix_test.go b/client/cmd/daemon_access_unix_test.go new file mode 100644 index 000000000..a4cbfa1e3 --- /dev/null +++ b/client/cmd/daemon_access_unix_test.go @@ -0,0 +1,46 @@ +//go:build !windows + +package cmd + +import ( + "context" + "errors" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A socket the caller may not write must fail the dial with the denial, well +// before the dial timeout. +func TestDialClientGRPCServer_SocketRefusesCaller(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses socket file permissions") + } + dir, err := os.MkdirTemp("", "nbdial") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + path := filepath.Join(dir, "d.sock") + ln, err := net.Listen("unix", path) + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + require.NoError(t, os.Chmod(path, 0o000)) + + start := time.Now() + _, err = DialClientGRPCServer(context.Background(), "unix://"+path) + elapsed := time.Since(start) + + require.Error(t, err) + assert.ErrorIs(t, err, errDaemonAccessDenied) + assert.Less(t, elapsed, 5*time.Second, "a refused socket must not wait out the dial timeout") + assert.Equal(t, errDaemonAccessDenied, daemonConnectError(err), "the denial is reported without the start-the-service hint") +} + +func TestDaemonConnectError_KeepsServiceHintOtherwise(t *testing.T) { + err := daemonConnectError(errors.New("context deadline exceeded")) + assert.Contains(t, err.Error(), "netbird service start", "a daemon that did not answer keeps the hint to start it") +} diff --git a/client/cmd/daemon_error.go b/client/cmd/daemon_error.go index 5c256774f..749d48bac 100644 --- a/client/cmd/daemon_error.go +++ b/client/cmd/daemon_error.go @@ -14,6 +14,21 @@ import ( "github.com/netbirdio/netbird/client/internal/ipcauth" ) +// errDaemonAccessDenied reports that the daemon socket refuses this user. +var errDaemonAccessDenied = errors.New("this account is not allowed to use the NetBird daemon on this device: " + + "an administrator restricted it to other users or groups") + +// daemonConnectError explains a failed dial. +func daemonConnectError(err error) error { + if errors.Is(err, errDaemonAccessDenied) { + return err + } + // nolint + return fmt.Errorf("failed to connect to daemon error: %w\n"+ + "If the daemon is not running please run: "+ + "\nnetbird service install \nnetbird service start\n", err) +} + // daemonCallError adds the context a failed daemon call happened in. func daemonCallError(context string, err error) error { return fmt.Errorf("%s: %w", context, err) diff --git a/client/cmd/login.go b/client/cmd/login.go index c715d4f1d..ade7f95e4 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -99,10 +99,7 @@ var loginCmd = &cobra.Command{ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey string, activeProf *profilemanager.Profile, username string, pm *profilemanager.ProfileManager) error { conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { - //nolint - return fmt.Errorf("failed to connect to daemon error: %v\n"+ - "If the daemon is not running please run: "+ - "\nnetbird service install \nnetbird service start\n", err) + return daemonConnectError(err) } defer conn.Close() @@ -174,10 +171,7 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str func doExtendSession(ctx context.Context, cmd *cobra.Command, activeProf *profilemanager.Profile) error { conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { - //nolint - return fmt.Errorf("failed to connect to daemon error: %v\n"+ - "If the daemon is not running please run: "+ - "\nnetbird service install \nnetbird service start\n", err) + return daemonConnectError(err) } defer conn.Close() @@ -286,10 +280,7 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage func switchProfile(ctx context.Context, handle string, username string) (profilemanager.ID, error) { conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { - //nolint - return "", fmt.Errorf("failed to connect to daemon error: %v\n"+ - "If the daemon is not running please run: "+ - "\nnetbird service install \nnetbird service start\n", err) + return "", daemonConnectError(err) } defer conn.Close() diff --git a/client/cmd/root.go b/client/cmd/root.go index 29eae00e6..b4400e7b5 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -12,6 +12,7 @@ import ( "runtime" "slices" "strings" + "sync/atomic" "syscall" "time" @@ -299,7 +300,35 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e grpc.WithChainStreamInterceptor(denialStreamInterceptor), ) - return grpc.DialContext(ctx, target, opts...) + denied := watchAccessDenied(ctx, cancel, addr) + conn, err := grpc.DialContext(ctx, target, opts...) + if err != nil && denied.Load() { + return nil, errDaemonAccessDenied + } + return conn, err +} + +// accessProbeDelay is how long a dial may block before the socket is probed. +const accessProbeDelay = 500 * time.Millisecond + +// watchAccessDenied probes addr after accessProbeDelay and cancels the dial if +// the socket refuses this user. +func watchAccessDenied(ctx context.Context, cancel context.CancelFunc, addr string) *atomic.Bool { + denied := &atomic.Bool{} + go func() { + timer := time.NewTimer(accessProbeDelay) + defer timer.Stop() + select { + case <-ctx.Done(): + return + case <-timer.C: + } + if daddr.DeniesCaller(ctx, addr) { + denied.Store(true) + cancel() + } + }() + return denied } // terminalLoginError reports whether a Login failure is final, so the backoff @@ -499,10 +528,7 @@ func getClient(cmd *cobra.Command) (*grpc.ClientConn, error) { conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr) if err != nil { - //nolint - return nil, fmt.Errorf("failed to connect to daemon error: %v\n"+ - "If the daemon is not running please run: "+ - "\nnetbird service install \nnetbird service start\n", err) + return nil, daemonConnectError(err) } return conn, nil diff --git a/client/cmd/status.go b/client/cmd/status.go index fb4f2bf5d..7abf189d0 100644 --- a/client/cmd/status.go +++ b/client/cmd/status.go @@ -192,10 +192,7 @@ func statusFunc(cmd *cobra.Command, args []string) error { func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) (*proto.StatusResponse, error) { conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { - //nolint - return nil, fmt.Errorf("failed to connect to daemon error: %v\n"+ - "If the daemon is not running please run: "+ - "\nnetbird service install \nnetbird service start\n", err) + return nil, daemonConnectError(err) } defer conn.Close() diff --git a/client/cmd/up.go b/client/cmd/up.go index 65225b0e3..617ad34db 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -184,10 +184,7 @@ func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManage func createProfile(ctx context.Context, profileName, username string) (profilemanager.ID, error) { conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { - //nolint - return "", fmt.Errorf("failed to connect to daemon error: %v\n"+ - "If the daemon is not running please run: "+ - "\nnetbird service install \nnetbird service start\n", err) + return "", daemonConnectError(err) } defer conn.Close() @@ -285,10 +282,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { - //nolint - return fmt.Errorf("failed to connect to daemon error: %v\n"+ - "If the daemon is not running please run: "+ - "\nnetbird service install \nnetbird service start\n", err) + return daemonConnectError(err) } defer func() { err := conn.Close() diff --git a/client/internal/daemonaddr/pipe_windows.go b/client/internal/daemonaddr/pipe_windows.go index 3cd10a6c3..cbb0f80a4 100644 --- a/client/internal/daemonaddr/pipe_windows.go +++ b/client/internal/daemonaddr/pipe_windows.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "io/fs" "net" "github.com/Microsoft/go-winio" @@ -16,13 +17,17 @@ import ( ) // dialPipePaths connects to the first path that answers with a pipe server this -// client may trust, and returns the last error when none does. +// client may trust. When none does, it returns an access-denied error if any +// path gave one, and the last error otherwise. func dialPipePaths(ctx context.Context, paths []string) (net.Conn, error) { - var lastErr error + var lastErr, deniedErr error for _, path := range paths { conn, err := dialPipe(ctx, path) if err != nil { log.Debugf("dial daemon pipe %s: %v", path, err) + if deniedErr == nil && errors.Is(err, fs.ErrPermission) { + deniedErr = err + } lastErr = err continue } @@ -43,6 +48,9 @@ func dialPipePaths(ctx context.Context, paths []string) (net.Conn, error) { return conn, nil } + if deniedErr != nil { + return nil, deniedErr + } if lastErr == nil { lastErr = errors.New("no daemon pipe to connect to") } diff --git a/client/internal/daemonaddr/pipe_windows_test.go b/client/internal/daemonaddr/pipe_windows_test.go new file mode 100644 index 000000000..3beeb48cd --- /dev/null +++ b/client/internal/daemonaddr/pipe_windows_test.go @@ -0,0 +1,48 @@ +//go:build windows + +package daemonaddr + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "testing" + "time" + + "github.com/Microsoft/go-winio" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A denied first path and a missing second path must return the denial. +func TestDialPipePaths_PrefersAccessDenied(t *testing.T) { + base := fmt.Sprintf(`\\.\pipe\netbird-test-%d-%d`, os.Getpid(), time.Now().UnixNano()) + denied := base + "-denied" + + // An empty DACL denies everyone. + ln, err := winio.ListenPipe(denied, &winio.PipeConfig{SecurityDescriptor: "D:P"}) + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _, err = dialPipePaths(ctx, []string{denied, base + "-missing"}) + require.Error(t, err) + assert.True(t, errors.Is(err, fs.ErrPermission), "want the access-denied error, got %v", err) + assert.True(t, DeniesCaller(context.Background(), "npipe://"+denied), "a pipe whose DACL refuses the caller must report a denial") +} + +func TestDialPipePaths_MissingIsNotDenied(t *testing.T) { + missing := fmt.Sprintf(`\\.\pipe\netbird-test-%d-%d-missing`, os.Getpid(), time.Now().UnixNano()) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _, err := dialPipePaths(ctx, []string{missing}) + require.Error(t, err) + assert.False(t, errors.Is(err, fs.ErrPermission), "a missing pipe is not a denial: %v", err) + assert.False(t, DeniesCaller(context.Background(), "npipe://"+missing), "a stopped daemon is not a denial") +} diff --git a/client/internal/daemonaddr/probe.go b/client/internal/daemonaddr/probe.go new file mode 100644 index 000000000..eb330bb88 --- /dev/null +++ b/client/internal/daemonaddr/probe.go @@ -0,0 +1,52 @@ +package daemonaddr + +import ( + "context" + "errors" + "io/fs" + "net" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +// accessProbeTimeout bounds the probe. +const accessProbeTimeout = time.Second + +// DeniesCaller reports whether the OS refuses this process access to the daemon +// socket or pipe at addr. It dials without gRPC, trying up to two candidate +// pipe paths on Windows; loopback TCP is never probed. +func DeniesCaller(ctx context.Context, addr string) bool { + ctx, cancel := context.WithTimeout(ctx, accessProbeTimeout) + defer cancel() + + conn, err := dialRaw(ctx, addr) + if err != nil { + return errors.Is(err, fs.ErrPermission) + } + if err := conn.Close(); err != nil { + log.Debugf("close daemon access probe: %v", err) + } + return false +} + +func dialRaw(ctx context.Context, addr string) (net.Conn, error) { + if name, ok := strings.CutPrefix(addr, pipeScheme); ok { + return dialPipePaths(ctx, PipePaths(name)) + } + if path, ok := unixSocketPath(addr); ok { + var d net.Dialer + return d.DialContext(ctx, "unix", path) + } + // Loopback TCP is never group-restricted. + return nil, errors.New("no socket access to probe for " + addr) +} + +// unixSocketPath accepts unix:///abs and unix:path. +func unixSocketPath(addr string) (string, bool) { + if path, ok := strings.CutPrefix(addr, "unix://"); ok { + return path, true + } + return strings.CutPrefix(addr, "unix:") +} diff --git a/client/internal/daemonaddr/probe_unix_test.go b/client/internal/daemonaddr/probe_unix_test.go new file mode 100644 index 000000000..a51ddbadc --- /dev/null +++ b/client/internal/daemonaddr/probe_unix_test.go @@ -0,0 +1,66 @@ +//go:build !windows + +package daemonaddr + +import ( + "context" + "net" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func listenTestSocket(t *testing.T) string { + t.Helper() + // Keeps the path under the macOS sun_path limit. + dir, err := os.MkdirTemp("", "nbprobe") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + + path := filepath.Join(dir, "d.sock") + ln, err := net.Listen("unix", path) + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + return path +} + +// The kernel refuses connect to a socket the caller may not write. +func TestDeniesCaller_SocketWithoutPermission(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses socket file permissions") + } + path := listenTestSocket(t) + require.NoError(t, os.Chmod(path, 0o000)) + + assert.True(t, DeniesCaller(context.Background(), "unix://"+path), "a socket the caller may not write must report a denial") +} + +func TestDeniesCaller_ReachableDaemon(t *testing.T) { + path := listenTestSocket(t) + + assert.False(t, DeniesCaller(context.Background(), "unix://"+path), "a socket that accepts the caller is not a denial") + assert.False(t, DeniesCaller(context.Background(), "unix:"+path), "the unix:path spelling must probe the same socket") +} + +// A missing or stale socket is not a denial. +func TestDeniesCaller_DaemonNotRunning(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing.sock") + assert.False(t, DeniesCaller(context.Background(), "unix://"+missing), "a missing socket is not a denial") + + dir, err := os.MkdirTemp("", "nbprobe") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + stale := filepath.Join(dir, "s.sock") + ln, err := net.Listen("unix", stale) + require.NoError(t, err) + ln.(*net.UnixListener).SetUnlinkOnClose(false) + require.NoError(t, ln.Close()) + assert.False(t, DeniesCaller(context.Background(), "unix://"+stale), "a stale socket is not a denial") +} + +func TestDeniesCaller_TCPIsNeverDenied(t *testing.T) { + assert.False(t, DeniesCaller(context.Background(), "127.0.0.1:1"), "loopback TCP cannot be group-restricted") +} diff --git a/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx index 89c121e21..a40959a23 100644 --- a/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx +++ b/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx @@ -12,10 +12,17 @@ function openUrl(url: string) { export const DaemonUnavailableOverlay = () => { const { t } = useTranslation(); - const { isDaemonUnavailable } = useStatus(); + const { isDaemonUnavailable, isDaemonAccessDenied } = useStatus(); if (!isDaemonUnavailable) return null; + const title = isDaemonAccessDenied + ? t("daemon.accessDenied.title") + : t("daemon.unavailable.title"); + const description = isDaemonAccessDenied + ? t("daemon.accessDenied.description") + : t("daemon.unavailable.description"); + return (
{
-

- {t("daemon.unavailable.title")} -

-

- {t("daemon.unavailable.description")} -

+

{title}

+

{description}

-
- -
+ {!isDaemonAccessDenied && ( +
+ +
+ )} ); diff --git a/client/ui/frontend/src/contexts/StatusContext.tsx b/client/ui/frontend/src/contexts/StatusContext.tsx index 0ad9c7875..9ac14b135 100644 --- a/client/ui/frontend/src/contexts/StatusContext.tsx +++ b/client/ui/frontend/src/contexts/StatusContext.tsx @@ -22,6 +22,7 @@ type StatusContextValue = { refresh: () => Promise; isReady: boolean; isDaemonUnavailable: boolean; + isDaemonAccessDenied: boolean; isDaemonAvailable: boolean; isDaemonOutdated: boolean; }; @@ -65,7 +66,9 @@ export const StatusProvider = ({ children }: { children: ReactNode }) => { }, [refresh]); const isReady = status !== null; - const isDaemonUnavailable = isReady && status.status === "DaemonUnavailable"; + const isDaemonAccessDenied = isReady && status.status === "DaemonAccessDenied"; + const isDaemonUnavailable = + isReady && (status.status === "DaemonUnavailable" || isDaemonAccessDenied); const isDaemonAvailable = isReady && !isDaemonUnavailable; useEffect(() => { @@ -90,10 +93,20 @@ export const StatusProvider = ({ children }: { children: ReactNode }) => { refresh, isReady, isDaemonUnavailable, + isDaemonAccessDenied, isDaemonAvailable, isDaemonOutdated, }), - [status, error, refresh, isReady, isDaemonUnavailable, isDaemonAvailable, isDaemonOutdated], + [ + status, + error, + refresh, + isReady, + isDaemonUnavailable, + isDaemonAccessDenied, + isDaemonAvailable, + isDaemonOutdated, + ], ); return ( diff --git a/client/ui/frontend/src/lib/errors.ts b/client/ui/frontend/src/lib/errors.ts index 5c9c80a91..9da197074 100644 --- a/client/ui/frontend/src/lib/errors.ts +++ b/client/ui/frontend/src/lib/errors.ts @@ -62,11 +62,11 @@ export const formatErrorMessage = (e: unknown): string => { export const errorCommand = (e: unknown): string => classify(e)?.command ?? ""; // isDaemonUnavailable reports whether an error means the daemon could not be -// reached, so a caller can retry quietly instead of putting a dialog up while -// the service is still starting. Matches the classified code first and the raw +// reached or refuses this user. Matches the classified code first and the raw // gRPC status text second, since not every service classifies its errors. export const isDaemonUnavailable = (e: unknown): boolean => { - if (classify(e)?.code === "daemon_unreachable") return true; + const code = classify(e)?.code; + if (code === "daemon_unreachable" || code === "daemon_access_denied") return true; const msg = e instanceof Error ? e.message : String(e); return msg.includes("code = Unavailable"); }; diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx index 71f63df14..845252dcf 100644 --- a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -47,7 +47,7 @@ export const MainConnectionStatusSwitch = () => { const daemonState = status?.status ?? "Idle"; const needsLogin = NEEDS_LOGIN_STATES.has(daemonState); - const unreachable = daemonState === "DaemonUnavailable"; + const unreachable = daemonState === "DaemonUnavailable" || daemonState === "DaemonAccessDenied"; type Action = "connect" | "logging-in" | "disconnect" | null; const [action, setAction] = useState(null); @@ -81,6 +81,7 @@ export const MainConnectionStatusSwitch = () => { case "LoginFailed": case "SessionExpired": case "DaemonUnavailable": + case "DaemonAccessDenied": return ConnectionState.Disconnected; default: return ConnectionState.Disconnected; diff --git a/client/ui/grpc.go b/client/ui/grpc.go index 5450e136d..5a11b5f40 100644 --- a/client/ui/grpc.go +++ b/client/ui/grpc.go @@ -3,6 +3,7 @@ package main import ( + "context" "fmt" "runtime" "sync" @@ -60,6 +61,11 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) { return c.client, nil } +// DeniesCaller reports whether the daemon socket refuses this user. +func (c *Conn) DeniesCaller(ctx context.Context) bool { + return daemonaddr.DeniesCaller(ctx, daemonaddr.ResolveDaemonAddr(c.addr)) +} + // DaemonAddr returns the default daemon gRPC address: a Unix socket on // Linux/macOS, a named pipe on Windows. The pipe carries the caller's token, // which loopback TCP does not, so the daemon can tell who is calling. diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 39461a29d..53ac22911 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -1411,5 +1411,17 @@ }, "error.profile_unowned": { "message": "Dieses Profil hat noch keinen Besitzer." + }, + "tray.status.daemonAccessDenied": { + "message": "Kein Zugriff" + }, + "daemon.accessDenied.title": { + "message": "Kein Zugriff auf den NetBird-Dienst" + }, + "daemon.accessDenied.description": { + "message": "Ihr Administrator hat den NetBird-Dienst auf diesem Gerät auf ausgewählte Benutzer oder Gruppen beschränkt, und Ihr Konto gehört nicht dazu. Wenden Sie sich für den Zugriff an Ihren Administrator." + }, + "error.daemon_access_denied": { + "message": "Ihr Konto darf den NetBird-Dienst auf diesem Gerät nicht verwenden. Wenden Sie sich für den Zugriff an Ihren Administrator." } } diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 5b5166a1d..c30832037 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -11,6 +11,10 @@ "message": "Not running", "description": "Tray status: the background NetBird service (daemon) is not running." }, + "tray.status.daemonAccessDenied": { + "message": "No access", + "description": "Tray status: the NetBird background service is running, but this user account is not allowed to use it. Keep short." + }, "tray.status.error": { "message": "Error", "description": "Tray status: the client is in an error state." @@ -1775,6 +1779,14 @@ "message": "Documentation", "description": "Documentation link on the daemon-unavailable overlay." }, + "daemon.accessDenied.title": { + "message": "No Access to the NetBird Service", + "description": "Title of the overlay shown when the NetBird background service is running but refuses this user account." + }, + "daemon.accessDenied.description": { + "message": "Your administrator has restricted the NetBird service on this device to selected users or groups, and your account is not one of them. Contact your administrator for access.", + "description": "Body of the access-denied overlay: the service is limited to certain accounts by an administrator and this one is not allowed." + }, "daemon.outdated.title": { "message": "NetBird Client Is Outdated", "description": "Title of the overlay shown when the NetBird client (daemon) is too old to drive this UI." @@ -1815,6 +1827,10 @@ "message": "The NetBird daemon is not responding. Please check that the service is running.", "description": "Error: the NetBird background service isn't responding. 'daemon' = the background service." }, + "error.daemon_access_denied": { + "message": "Your account is not allowed to use the NetBird service on this device. Contact your administrator for access.", + "description": "Error: the NetBird background service refused this user account because an administrator restricted it to other users or groups." + }, "error.settings_locked": { "message": "Settings cannot be changed on this device: an administrator has locked them.", "description": "Error: the local daemon was started with update-settings disabled, so it refuses configuration changes." diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 416ea937d..d29be68a1 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -1411,5 +1411,17 @@ }, "error.profile_unowned": { "message": "Este perfil aún no tiene propietario." + }, + "tray.status.daemonAccessDenied": { + "message": "Sin acceso" + }, + "daemon.accessDenied.title": { + "message": "Sin acceso al servicio NetBird" + }, + "daemon.accessDenied.description": { + "message": "Su administrador ha restringido el servicio NetBird en este dispositivo a determinados usuarios o grupos, y su cuenta no es uno de ellos. Póngase en contacto con su administrador para obtener acceso." + }, + "error.daemon_access_denied": { + "message": "Su cuenta no tiene permiso para usar el servicio NetBird en este dispositivo. Póngase en contacto con su administrador para obtener acceso." } } diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index 3e8bf0a1d..3dd08feb2 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -1411,5 +1411,17 @@ }, "error.profile_unowned": { "message": "Ce profil n'a pas encore de propriétaire." + }, + "tray.status.daemonAccessDenied": { + "message": "Aucun accès" + }, + "daemon.accessDenied.title": { + "message": "Aucun accès au service NetBird" + }, + "daemon.accessDenied.description": { + "message": "Votre administrateur a limité le service NetBird sur cet appareil à certains utilisateurs ou groupes, et votre compte n’en fait pas partie. Contactez votre administrateur pour obtenir l’accès." + }, + "error.daemon_access_denied": { + "message": "Votre compte n’est pas autorisé à utiliser le service NetBird sur cet appareil. Contactez votre administrateur pour obtenir l’accès." } } diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index a4d612056..e3dc5a6ec 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -1411,5 +1411,17 @@ }, "error.profile_unowned": { "message": "Ennek a profilnak még nincs tulajdonosa." + }, + "tray.status.daemonAccessDenied": { + "message": "Nincs hozzáférés" + }, + "daemon.accessDenied.title": { + "message": "Nincs hozzáférés a NetBird szolgáltatáshoz" + }, + "daemon.accessDenied.description": { + "message": "A rendszergazda ezen az eszközön kiválasztott felhasználókra vagy csoportokra korlátozta a NetBird szolgáltatást, és az Ön fiókja nincs köztük. A hozzáférésért forduljon a rendszergazdához." + }, + "error.daemon_access_denied": { + "message": "Az Ön fiókja nem használhatja a NetBird szolgáltatást ezen az eszközön. A hozzáférésért forduljon a rendszergazdához." } } diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 9cb601657..520b01dc1 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -1411,5 +1411,17 @@ }, "error.profile_unowned": { "message": "Questo profilo non ha ancora un proprietario." + }, + "tray.status.daemonAccessDenied": { + "message": "Nessun accesso" + }, + "daemon.accessDenied.title": { + "message": "Nessun accesso al servizio NetBird" + }, + "daemon.accessDenied.description": { + "message": "L’amministratore ha limitato il servizio NetBird su questo dispositivo ad alcuni utenti o gruppi, e il tuo account non è tra questi. Contatta l’amministratore per ottenere l’accesso." + }, + "error.daemon_access_denied": { + "message": "Il Suo account non è autorizzato a usare il servizio NetBird su questo dispositivo. Contatti l'amministratore per ottenere l'accesso." } } diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index 486ae6663..7278d46e9 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -1411,5 +1411,17 @@ }, "error.profile_unowned": { "message": "このプロファイルにはまだ所有者がいません。" + }, + "tray.status.daemonAccessDenied": { + "message": "アクセス権なし" + }, + "daemon.accessDenied.title": { + "message": "NetBird サービスへのアクセス権がありません" + }, + "daemon.accessDenied.description": { + "message": "管理者がこのデバイスの NetBird サービスを特定のユーザーまたはグループに制限しており、お使いのアカウントは含まれていません。アクセスについては管理者にお問い合わせください。" + }, + "error.daemon_access_denied": { + "message": "お使いのアカウントには、このデバイスで NetBird サービスを使用する権限がありません。アクセスについては管理者にお問い合わせください。" } } diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index 2301d79a0..a78b8b96a 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -1411,5 +1411,17 @@ }, "error.profile_unowned": { "message": "Este perfil ainda não tem proprietário." + }, + "tray.status.daemonAccessDenied": { + "message": "Sem acesso" + }, + "daemon.accessDenied.title": { + "message": "Sem acesso ao serviço NetBird" + }, + "daemon.accessDenied.description": { + "message": "O seu administrador restringiu o serviço NetBird neste dispositivo a determinados usuários ou grupos, e a sua conta não é um deles. Contate o seu administrador para obter acesso." + }, + "error.daemon_access_denied": { + "message": "A sua conta não tem permissão para usar o serviço NetBird neste dispositivo. Contacte o seu administrador para obter acesso." } } diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index 5805b9a27..36491500a 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -1411,5 +1411,17 @@ }, "error.profile_unowned": { "message": "У этого профиля пока нет владельца." + }, + "tray.status.daemonAccessDenied": { + "message": "Нет доступа" + }, + "daemon.accessDenied.title": { + "message": "Нет доступа к службе NetBird" + }, + "daemon.accessDenied.description": { + "message": "Администратор ограничил доступ к службе NetBird на этом устройстве выбранными пользователями или группами, и ваша учётная запись в их число не входит. Обратитесь к администратору за доступом." + }, + "error.daemon_access_denied": { + "message": "Вашей учётной записи не разрешено использовать службу NetBird на этом устройстве. Обратитесь к администратору за доступом." } } diff --git a/client/ui/i18n/locales/uk/common.json b/client/ui/i18n/locales/uk/common.json index a5b2dac43..5cfd82e67 100644 --- a/client/ui/i18n/locales/uk/common.json +++ b/client/ui/i18n/locales/uk/common.json @@ -1411,5 +1411,17 @@ }, "error.profile_unowned": { "message": "Цей профіль ще не має власника." + }, + "tray.status.daemonAccessDenied": { + "message": "Немає доступу" + }, + "daemon.accessDenied.title": { + "message": "Немає доступу до служби NetBird" + }, + "daemon.accessDenied.description": { + "message": "Адміністратор обмежив доступ до служби NetBird на цьому пристрої вибраними користувачами або групами, і ваш обліковий запис до них не входить. Зверніться до адміністратора щодо доступу." + }, + "error.daemon_access_denied": { + "message": "Вашому обліковому запису не дозволено використовувати службу NetBird на цьому пристрої. Зверніться до адміністратора щодо доступу." } } diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 8f34216b0..140a4564f 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -1411,5 +1411,17 @@ }, "error.profile_unowned": { "message": "此配置文件尚未设置所有者。" + }, + "tray.status.daemonAccessDenied": { + "message": "无访问权限" + }, + "daemon.accessDenied.title": { + "message": "无权访问 NetBird 服务" + }, + "daemon.accessDenied.description": { + "message": "管理员已将此设备上的 NetBird 服务限制为指定的用户或组,而您的帐户不在其中。请联系管理员获取访问权限。" + }, + "error.daemon_access_denied": { + "message": "您的帐户无权在此设备上使用 NetBird 服务。请联系管理员获取访问权限。" } } diff --git a/client/ui/localizer.go b/client/ui/localizer.go index 33c1cf205..25b42da50 100644 --- a/client/ui/localizer.go +++ b/client/ui/localizer.go @@ -115,6 +115,8 @@ func (l *Localizer) StatusLabel(status string) string { return l.T("tray.status.disconnected") case strings.EqualFold(status, services.StatusDaemonUnavailable): return l.T("tray.status.daemonUnavailable") + case strings.EqualFold(status, services.StatusDaemonAccessDenied): + return l.T("tray.status.daemonAccessDenied") case strings.EqualFold(status, services.StatusConnected): return l.T("tray.status.connected") case strings.EqualFold(status, services.StatusConnecting): diff --git a/client/ui/services/conn.go b/client/ui/services/conn.go index 531abe7d9..52d933206 100644 --- a/client/ui/services/conn.go +++ b/client/ui/services/conn.go @@ -2,7 +2,11 @@ package services -import "github.com/netbirdio/netbird/client/proto" +import ( + "context" + + "github.com/netbirdio/netbird/client/proto" +) // DaemonConn returns a lazy gRPC client to the NetBird daemon. // All services receive a DaemonConn so they share a single connection. @@ -10,4 +14,10 @@ type DaemonConn interface { Client() (proto.DaemonServiceClient, error) } +// AccessProber is implemented by a DaemonConn that can probe whether the daemon +// socket refuses this user. +type AccessProber interface { + DeniesCaller(ctx context.Context) bool +} + func ptrStr(s string) *string { return &s } diff --git a/client/ui/services/daemon_feed.go b/client/ui/services/daemon_feed.go index 632581fe9..3e9bb36e9 100644 --- a/client/ui/services/daemon_feed.go +++ b/client/ui/services/daemon_feed.go @@ -37,6 +37,9 @@ const ( // StatusDaemonUnavailable is the synthetic Status emitted when the daemon's // gRPC socket is unreachable. No internal.Status* collides with this label. StatusDaemonUnavailable = "DaemonUnavailable" + // StatusDaemonAccessDenied is the synthetic Status emitted when the daemon + // socket refuses this user. + StatusDaemonAccessDenied = "DaemonAccessDenied" // Daemon connection status strings — mirror internal.Status* in // client/internal/state.go. @@ -245,20 +248,21 @@ func (s *DaemonFeed) ServiceShutdown() error { } // Get returns the current daemon status snapshot. An unreachable daemon socket -// yields Status{Status: StatusDaemonUnavailable} rather than an error, so the -// frontend keys off a single status enum without a parallel "error" path. +// yields a synthetic StatusDaemonUnavailable or StatusDaemonAccessDenied rather +// than an error, so the frontend keys off a single status enum without a +// parallel "error" path. func (s *DaemonFeed) Get(ctx context.Context) (Status, error) { cli, err := s.conn.Client() if err != nil { if isDaemonUnreachable(err) { - return Status{Status: StatusDaemonUnavailable}, nil + return Status{Status: s.unreachableStatus(ctx)}, nil } return Status{}, err } resp, err := cli.Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true}) if err != nil { if isDaemonUnreachable(err) { - return Status{Status: StatusDaemonUnavailable}, nil + return Status{Status: s.unreachableStatus(ctx)}, nil } return Status{}, err } @@ -291,7 +295,7 @@ func (s *DaemonFeed) consumeForSwitch(st Status) (suppress, triggerLogin bool) { strings.EqualFold(st.Status, StatusNeedsLogin), strings.EqualFold(st.Status, StatusLoginFailed), strings.EqualFold(st.Status, StatusSessionExpired), - strings.EqualFold(st.Status, StatusDaemonUnavailable): + IsDaemonOutage(st.Status): // New flow has begun (Up started, or daemon refused it). s.switchInProgress = false default: @@ -311,7 +315,7 @@ func (s *DaemonFeed) consumeForSwitch(st Status) (suppress, triggerLogin bool) { return false, true case strings.EqualFold(st.Status, StatusConnected), strings.EqualFold(st.Status, StatusIdle), - strings.EqualFold(st.Status, StatusDaemonUnavailable): + IsDaemonOutage(st.Status): // Terminal but not SSO — disarm without triggering. s.switchLoginWatch = false } @@ -336,18 +340,19 @@ func (s *DaemonFeed) statusStreamLoop(ctx context.Context) { Clock: backoff.SystemClock, }, ctx) - // unavailable fires the synthetic event once per outage, not on every retry. - unavailable := false + // outage is the synthetic status last emitted, reset once the stream recovers. + outage := "" emitUnavailable := func() { - if unavailable { + st := s.unreachableStatus(ctx) + if st == outage { return } - unavailable = true - s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusDaemonUnavailable}) + outage = st + s.emitter.Emit(EventStatusSnapshot, Status{Status: st}) } op := func() error { - return s.subscribeAndStreamStatus(ctx, &unavailable, emitUnavailable) + return s.subscribeAndStreamStatus(ctx, &outage, emitUnavailable) } if err := backoff.Retry(op, bo); err != nil && ctx.Err() == nil { @@ -358,7 +363,7 @@ func (s *DaemonFeed) statusStreamLoop(ctx context.Context) { // subscribeAndStreamStatus is one attempt of the status backoff loop: open // SubscribeStatus and re-emit every snapshot until it errors. A daemon- // unreachable failure also flips the synthetic-unavailable signal. -func (s *DaemonFeed) subscribeAndStreamStatus(ctx context.Context, unavailable *bool, emitUnavailable func()) error { +func (s *DaemonFeed) subscribeAndStreamStatus(ctx context.Context, outage *string, emitUnavailable func()) error { cli, err := s.conn.Client() if err != nil { emitUnavailable() @@ -376,7 +381,7 @@ func (s *DaemonFeed) subscribeAndStreamStatus(ctx context.Context, unavailable * if err != nil { return s.handleStatusRecvErr(ctx, err, emitUnavailable) } - *unavailable = false + *outage = "" s.emitStatus(statusFromProto(resp)) } } @@ -574,6 +579,21 @@ func systemEventFromProto(e *proto.SystemEvent) SystemEvent { return out } +// IsDaemonOutage reports whether status is a synthetic unreachable status. +func IsDaemonOutage(status string) bool { + return strings.EqualFold(status, StatusDaemonUnavailable) || + strings.EqualFold(status, StatusDaemonAccessDenied) +} + +// unreachableStatus returns StatusDaemonAccessDenied when the daemon socket +// refuses this user, and StatusDaemonUnavailable otherwise. +func (s *DaemonFeed) unreachableStatus(ctx context.Context) string { + if p, ok := s.conn.(AccessProber); ok && p.DeniesCaller(ctx) { + return StatusDaemonAccessDenied + } + return StatusDaemonUnavailable +} + // isDaemonUnreachable reports whether a gRPC error means the daemon socket isn't // answering, versus the daemon responding with an application-level code. Only // the former should flip the tray to "Not running" — a daemon returning e.g. diff --git a/client/ui/services/daemon_feed_test.go b/client/ui/services/daemon_feed_test.go new file mode 100644 index 000000000..904122d01 --- /dev/null +++ b/client/ui/services/daemon_feed_test.go @@ -0,0 +1,114 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/proto" +) + +// unreachableDaemon fails every call with Unavailable. +type unreachableDaemon struct { + proto.DaemonServiceClient +} + +func (unreachableDaemon) Status(context.Context, *proto.StatusRequest, ...grpc.CallOption) (*proto.StatusResponse, error) { + return nil, gstatus.Error(codes.Unavailable, "connection error") +} + +func (unreachableDaemon) SubscribeStatus(context.Context, *proto.StatusRequest, ...grpc.CallOption) (grpc.ServerStreamingClient[proto.StatusResponse], error) { + return nil, gstatus.Error(codes.Unavailable, "connection error") +} + +// probingConn is a DaemonConn whose socket probe reports denied. +type probingConn struct { + stubConn + denied bool +} + +func (c probingConn) DeniesCaller(context.Context) bool { return c.denied } + +type recordingEmitter struct { + mu sync.Mutex + events []Status + got chan struct{} +} + +func (e *recordingEmitter) Emit(name string, data ...any) bool { + if name != EventStatusSnapshot { + return true + } + e.mu.Lock() + e.events = append(e.events, data[0].(Status)) + e.mu.Unlock() + select { + case e.got <- struct{}{}: + default: + } + return true +} + +func TestDaemonFeedGet_TellsDenialFromNotRunning(t *testing.T) { + tests := []struct { + name string + conn DaemonConn + want string + }{ + {"socket refuses the user", probingConn{stubConn: stubConn{client: unreachableDaemon{}}, denied: true}, StatusDaemonAccessDenied}, + {"daemon not running", probingConn{stubConn: stubConn{client: unreachableDaemon{}}}, StatusDaemonUnavailable}, + {"conn cannot probe", stubConn{client: unreachableDaemon{}}, StatusDaemonUnavailable}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + feed := &DaemonFeed{conn: tt.conn} + st, err := feed.Get(context.Background()) + require.NoError(t, err) + assert.Equal(t, tt.want, st.Status, "synthetic status for an unreachable daemon") + }) + } +} + +// The denial is emitted once across stream retries. +func TestDaemonFeedStream_EmitsDenialOnce(t *testing.T) { + emitter := &recordingEmitter{got: make(chan struct{}, 1)} + feed := &DaemonFeed{ + conn: probingConn{stubConn: stubConn{client: unreachableDaemon{}}, denied: true}, + emitter: emitter, + } + + ctx, cancel := context.WithCancel(context.Background()) + feed.streamWg.Add(1) + go feed.statusStreamLoop(ctx) + + select { + case <-emitter.got: + case <-time.After(5 * time.Second): + t.Fatal("no status emitted") + } + // Let at least one retry run past the first attempt. + time.Sleep(1500 * time.Millisecond) + cancel() + feed.streamWg.Wait() + + emitter.mu.Lock() + defer emitter.mu.Unlock() + require.NotEmpty(t, emitter.events) + assert.Len(t, emitter.events, 1, "the outage is reported once across retries") + assert.Equal(t, StatusDaemonAccessDenied, emitter.events[0].Status, "the denial, not not-running") +} + +func TestIsDaemonOutage(t *testing.T) { + assert.True(t, IsDaemonOutage(StatusDaemonUnavailable), "not running is an outage") + assert.True(t, IsDaemonOutage(StatusDaemonAccessDenied), "a refused user is an outage") + assert.False(t, IsDaemonOutage(StatusConnected), "a live daemon is not") +} diff --git a/client/ui/services/errors.go b/client/ui/services/errors.go index 27abb6141..cb807b559 100644 --- a/client/ui/services/errors.go +++ b/client/ui/services/errors.go @@ -112,6 +112,8 @@ func (c errorClassifier) classify(err error) *ClientError { code := "unknown" switch { + case grpcCode == gcodes.Unavailable && isSocketDenial(lower): + code = "daemon_access_denied" case strings.Contains(lower, "token used before issued"), strings.Contains(lower, "token is not valid yet"): code = "jwt_clock_skew" @@ -163,6 +165,15 @@ func (c errorClassifier) classify(err error) *ClientError { } } +// isSocketDenial reports whether a lowercased Unavailable message carries an OS +// access denial on the daemon socket or pipe. "connect: permission denied" is +// the Unix socket EACCES text and "access is denied" the Windows +// ERROR_ACCESS_DENIED text; both matched on English OS messages. +func isSocketDenial(lower string) bool { + return strings.Contains(lower, "connect: permission denied") || + strings.Contains(lower, "access is denied") +} + // classifyDenial presents a refusal the daemon explained: a localised headline // for the reasons this build knows, with the daemon's own sentence as the // detail the frontend shows under it. An unrecognised reason keeps that sentence diff --git a/client/ui/services/errors_test.go b/client/ui/services/errors_test.go index 65bb25d32..32c732e9b 100644 --- a/client/ui/services/errors_test.go +++ b/client/ui/services/errors_test.go @@ -68,6 +68,21 @@ func TestErrorClassifier_Classify(t *testing.T) { require.Equal(t, "daemon_unreachable", ce.Code) }) + // The OS denial on the socket must not be read as a management sign-in + // rejection. + t.Run("socket denial maps to daemon_access_denied", func(t *testing.T) { + unix := gstatus.Error(gcodes.Unavailable, `connection error: desc = "transport: Error while dialing: dial unix /var/run/netbird.sock: connect: permission denied"`) + assert.Equal(t, "daemon_access_denied", c.classify(unix).Code, "unix EACCES") + + pipe := gstatus.Error(gcodes.Unavailable, `connection error: desc = "transport: Error while dialing: open \\.\pipe\ProtectedPrefix\Administrators\netbird: Access is denied."`) + assert.Equal(t, "daemon_access_denied", c.classify(pipe).Code, "windows ERROR_ACCESS_DENIED") + }) + + t.Run("missing socket stays daemon_unreachable", func(t *testing.T) { + err := gstatus.Error(gcodes.Unavailable, `connection error: desc = "transport: Error while dialing: dial unix /var/run/netbird.sock: connect: no such file or directory"`) + assert.Equal(t, "daemon_unreachable", c.classify(err).Code, "a stopped daemon is not a denial") + }) + t.Run("unmatched stays unknown", func(t *testing.T) { ce := c.classify(errors.New("something odd")) require.Equal(t, "unknown", ce.Code) diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index fc26f67e7..9466d503d 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -911,7 +911,7 @@ func (s *WindowManager) watchTriggerLogin() { return } switch st.Status { - case StatusConnected, StatusLoginFailed, StatusDaemonUnavailable: + case StatusConnected, StatusLoginFailed, StatusDaemonUnavailable, StatusDaemonAccessDenied: s.scheduleHeadlessTeardown() } }) diff --git a/client/ui/tray.go b/client/ui/tray.go index 07d49003a..82233a66c 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -348,7 +348,7 @@ func (t *Tray) relayoutMenu() { disableProfiles, disableNetworks := t.featuresDisabled() - daemonUnavailable := strings.EqualFold(lastStatus, services.StatusDaemonUnavailable) + daemonUnavailable := services.IsDaemonOutage(lastStatus) connecting := strings.EqualFold(lastStatus, services.StatusConnecting) if t.statusItem != nil && lastStatus != "" { diff --git a/client/ui/tray_icon.go b/client/ui/tray_icon.go index 6c7b85d79..c59d5fd72 100644 --- a/client/ui/tray_icon.go +++ b/client/ui/tray_icon.go @@ -63,7 +63,7 @@ func (t *Tray) iconForState() (icon, dark []byte) { connecting := strings.EqualFold(statusLabel, services.StatusConnecting) errored := strings.EqualFold(statusLabel, statusError) || - strings.EqualFold(statusLabel, services.StatusDaemonUnavailable) + services.IsDaemonOutage(statusLabel) needsLogin := strings.EqualFold(statusLabel, services.StatusNeedsLogin) || strings.EqualFold(statusLabel, services.StatusSessionExpired) || strings.EqualFold(statusLabel, services.StatusLoginFailed) diff --git a/client/ui/tray_status.go b/client/ui/tray_status.go index 793f1785a..c79869055 100644 --- a/client/ui/tray_status.go +++ b/client/ui/tray_status.go @@ -90,7 +90,7 @@ func (t *Tray) consumePendingConnectLogin(status string) bool { strings.EqualFold(status, services.StatusIdle), strings.EqualFold(status, services.StatusLoginFailed), strings.EqualFold(status, services.StatusSessionExpired), - strings.EqualFold(status, services.StatusDaemonUnavailable): + services.IsDaemonOutage(status): t.pendingConnectLogin = false } return false @@ -118,7 +118,7 @@ func statusIndicatorBitmap(status string) []byte { case strings.EqualFold(status, services.StatusLoginFailed), strings.EqualFold(status, statusError): return iconMenuDotError - case strings.EqualFold(status, services.StatusDaemonUnavailable): + case services.IsDaemonOutage(status): return iconMenuDotOffline default: return iconMenuDotIdle