[client] Improve error surfacing for restricted daemon sockets (#7704)

* Add probe for daemon restriction and improve error surfacing
This commit is contained in:
Theodor Midtlien
2026-10-23 11:38:26 +02:00
committed by GitHub
parent c75f272252
commit e13dbe8cb4
36 changed files with 646 additions and 70 deletions
+46
View File
@@ -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")
}
+15
View File
@@ -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)
+3 -12
View File
@@ -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()
+31 -5
View File
@@ -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
+1 -4
View File
@@ -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()
+2 -8
View File
@@ -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()
+10 -2
View File
@@ -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")
}
@@ -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")
}
+52
View File
@@ -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:")
}
@@ -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")
}
@@ -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 (
<div
className={
@@ -32,20 +39,18 @@ export const DaemonUnavailableOverlay = () => {
</div>
<div className={"flex flex-col items-center gap-1"}>
<p className={"text-base font-medium text-nb-gray-50"}>
{t("daemon.unavailable.title")}
</p>
<p className={"text-sm text-nb-gray-300"}>
{t("daemon.unavailable.description")}
</p>
<p className={"text-base font-medium text-nb-gray-50"}>{title}</p>
<p className={"text-sm text-nb-gray-300"}>{description}</p>
</div>
<div className={"wails-no-draggable"}>
<Button variant={"secondary"} size={"xs"} onClick={() => openUrl(DOCS_URL)}>
<BookText size={14} />
{t("daemon.unavailable.docsLink")}
</Button>
</div>
{!isDaemonAccessDenied && (
<div className={"wails-no-draggable"}>
<Button variant={"secondary"} size={"xs"} onClick={() => openUrl(DOCS_URL)}>
<BookText size={14} />
{t("daemon.unavailable.docsLink")}
</Button>
</div>
)}
</div>
</div>
);
@@ -22,6 +22,7 @@ type StatusContextValue = {
refresh: () => Promise<void>;
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 (
+3 -3
View File
@@ -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");
};
@@ -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<Action>(null);
@@ -81,6 +81,7 @@ export const MainConnectionStatusSwitch = () => {
case "LoginFailed":
case "SessionExpired":
case "DaemonUnavailable":
case "DaemonAccessDenied":
return ConnectionState.Disconnected;
default:
return ConnectionState.Disconnected;
+6
View File
@@ -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.
+12
View File
@@ -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."
}
}
+16
View File
@@ -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."
+12
View File
@@ -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."
}
}
+12
View File
@@ -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."
}
}
+12
View File
@@ -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."
}
}
+12
View File
@@ -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."
}
}
+12
View File
@@ -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 サービスを使用する権限がありません。アクセスについては管理者にお問い合わせください。"
}
}
+12
View File
@@ -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."
}
}
+12
View File
@@ -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 на этом устройстве. Обратитесь к администратору за доступом."
}
}
+12
View File
@@ -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 на цьому пристрої. Зверніться до адміністратора щодо доступу."
}
}
+12
View File
@@ -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 服务。请联系管理员获取访问权限。"
}
}
+2
View File
@@ -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):
+11 -1
View File
@@ -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 }
+34 -14
View File
@@ -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.
+114
View File
@@ -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")
}
+11
View File
@@ -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
+15
View File
@@ -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)
+1 -1
View File
@@ -911,7 +911,7 @@ func (s *WindowManager) watchTriggerLogin() {
return
}
switch st.Status {
case StatusConnected, StatusLoginFailed, StatusDaemonUnavailable:
case StatusConnected, StatusLoginFailed, StatusDaemonUnavailable, StatusDaemonAccessDenied:
s.scheduleHeadlessTeardown()
}
})
+1 -1
View File
@@ -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 != "" {
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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