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