mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-23 22:09:09 +02:00
[client] Improve error surfacing for restricted daemon sockets (#7704)
* Add probe for daemon restriction and improve error surfacing
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 サービスを使用する権限がありません。アクセスについては管理者にお問い合わせください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 на этом устройстве. Обратитесь к администратору за доступом."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 на цьому пристрої. Зверніться до адміністратора щодо доступу."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 服务。请联系管理员获取访问权限。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user