mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-24 06:19:08 +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"
|
"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.
|
// daemonCallError adds the context a failed daemon call happened in.
|
||||||
func daemonCallError(context string, err error) error {
|
func daemonCallError(context string, err error) error {
|
||||||
return fmt.Errorf("%s: %w", context, err)
|
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 {
|
func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey string, activeProf *profilemanager.Profile, username string, pm *profilemanager.ProfileManager) error {
|
||||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
//nolint
|
return daemonConnectError(err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
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 {
|
func doExtendSession(ctx context.Context, cmd *cobra.Command, activeProf *profilemanager.Profile) error {
|
||||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
//nolint
|
return daemonConnectError(err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
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) {
|
func switchProfile(ctx context.Context, handle string, username string) (profilemanager.ID, error) {
|
||||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
//nolint
|
return "", daemonConnectError(err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
|
|||||||
+31
-5
@@ -12,6 +12,7 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -299,7 +300,35 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e
|
|||||||
grpc.WithChainStreamInterceptor(denialStreamInterceptor),
|
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
|
// 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)
|
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
//nolint
|
return nil, daemonConnectError(err)
|
||||||
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 conn, nil
|
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) {
|
func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) (*proto.StatusResponse, error) {
|
||||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
//nolint
|
return nil, daemonConnectError(err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
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) {
|
func createProfile(ctx context.Context, profileName, username string) (profilemanager.ID, error) {
|
||||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
//nolint
|
return "", daemonConnectError(err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
@@ -285,10 +282,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
|
|||||||
|
|
||||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
//nolint
|
return daemonConnectError(err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
err := conn.Close()
|
err := conn.Close()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
"github.com/Microsoft/go-winio"
|
"github.com/Microsoft/go-winio"
|
||||||
@@ -16,13 +17,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// dialPipePaths connects to the first path that answers with a pipe server this
|
// 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) {
|
func dialPipePaths(ctx context.Context, paths []string) (net.Conn, error) {
|
||||||
var lastErr error
|
var lastErr, deniedErr error
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
conn, err := dialPipe(ctx, path)
|
conn, err := dialPipe(ctx, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("dial daemon pipe %s: %v", path, err)
|
log.Debugf("dial daemon pipe %s: %v", path, err)
|
||||||
|
if deniedErr == nil && errors.Is(err, fs.ErrPermission) {
|
||||||
|
deniedErr = err
|
||||||
|
}
|
||||||
lastErr = err
|
lastErr = err
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -43,6 +48,9 @@ func dialPipePaths(ctx context.Context, paths []string) (net.Conn, error) {
|
|||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if deniedErr != nil {
|
||||||
|
return nil, deniedErr
|
||||||
|
}
|
||||||
if lastErr == nil {
|
if lastErr == nil {
|
||||||
lastErr = errors.New("no daemon pipe to connect to")
|
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 = () => {
|
export const DaemonUnavailableOverlay = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isDaemonUnavailable } = useStatus();
|
const { isDaemonUnavailable, isDaemonAccessDenied } = useStatus();
|
||||||
|
|
||||||
if (!isDaemonUnavailable) return null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
@@ -32,20 +39,18 @@ export const DaemonUnavailableOverlay = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={"flex flex-col items-center gap-1"}>
|
<div className={"flex flex-col items-center gap-1"}>
|
||||||
<p className={"text-base font-medium text-nb-gray-50"}>
|
<p className={"text-base font-medium text-nb-gray-50"}>{title}</p>
|
||||||
{t("daemon.unavailable.title")}
|
<p className={"text-sm text-nb-gray-300"}>{description}</p>
|
||||||
</p>
|
|
||||||
<p className={"text-sm text-nb-gray-300"}>
|
|
||||||
{t("daemon.unavailable.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={"wails-no-draggable"}>
|
{!isDaemonAccessDenied && (
|
||||||
<Button variant={"secondary"} size={"xs"} onClick={() => openUrl(DOCS_URL)}>
|
<div className={"wails-no-draggable"}>
|
||||||
<BookText size={14} />
|
<Button variant={"secondary"} size={"xs"} onClick={() => openUrl(DOCS_URL)}>
|
||||||
{t("daemon.unavailable.docsLink")}
|
<BookText size={14} />
|
||||||
</Button>
|
{t("daemon.unavailable.docsLink")}
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type StatusContextValue = {
|
|||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
isReady: boolean;
|
isReady: boolean;
|
||||||
isDaemonUnavailable: boolean;
|
isDaemonUnavailable: boolean;
|
||||||
|
isDaemonAccessDenied: boolean;
|
||||||
isDaemonAvailable: boolean;
|
isDaemonAvailable: boolean;
|
||||||
isDaemonOutdated: boolean;
|
isDaemonOutdated: boolean;
|
||||||
};
|
};
|
||||||
@@ -65,7 +66,9 @@ export const StatusProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const isReady = status !== null;
|
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;
|
const isDaemonAvailable = isReady && !isDaemonUnavailable;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,10 +93,20 @@ export const StatusProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
refresh,
|
refresh,
|
||||||
isReady,
|
isReady,
|
||||||
isDaemonUnavailable,
|
isDaemonUnavailable,
|
||||||
|
isDaemonAccessDenied,
|
||||||
isDaemonAvailable,
|
isDaemonAvailable,
|
||||||
isDaemonOutdated,
|
isDaemonOutdated,
|
||||||
}),
|
}),
|
||||||
[status, error, refresh, isReady, isDaemonUnavailable, isDaemonAvailable, isDaemonOutdated],
|
[
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
isReady,
|
||||||
|
isDaemonUnavailable,
|
||||||
|
isDaemonAccessDenied,
|
||||||
|
isDaemonAvailable,
|
||||||
|
isDaemonOutdated,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -62,11 +62,11 @@ export const formatErrorMessage = (e: unknown): string => {
|
|||||||
export const errorCommand = (e: unknown): string => classify(e)?.command ?? "";
|
export const errorCommand = (e: unknown): string => classify(e)?.command ?? "";
|
||||||
|
|
||||||
// isDaemonUnavailable reports whether an error means the daemon could not be
|
// 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
|
// reached or refuses this user. Matches the classified code first and the raw
|
||||||
// the service is still starting. Matches the classified code first and the raw
|
|
||||||
// gRPC status text second, since not every service classifies its errors.
|
// gRPC status text second, since not every service classifies its errors.
|
||||||
export const isDaemonUnavailable = (e: unknown): boolean => {
|
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);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
return msg.includes("code = Unavailable");
|
return msg.includes("code = Unavailable");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const MainConnectionStatusSwitch = () => {
|
|||||||
|
|
||||||
const daemonState = status?.status ?? "Idle";
|
const daemonState = status?.status ?? "Idle";
|
||||||
const needsLogin = NEEDS_LOGIN_STATES.has(daemonState);
|
const needsLogin = NEEDS_LOGIN_STATES.has(daemonState);
|
||||||
const unreachable = daemonState === "DaemonUnavailable";
|
const unreachable = daemonState === "DaemonUnavailable" || daemonState === "DaemonAccessDenied";
|
||||||
|
|
||||||
type Action = "connect" | "logging-in" | "disconnect" | null;
|
type Action = "connect" | "logging-in" | "disconnect" | null;
|
||||||
const [action, setAction] = useState<Action>(null);
|
const [action, setAction] = useState<Action>(null);
|
||||||
@@ -81,6 +81,7 @@ export const MainConnectionStatusSwitch = () => {
|
|||||||
case "LoginFailed":
|
case "LoginFailed":
|
||||||
case "SessionExpired":
|
case "SessionExpired":
|
||||||
case "DaemonUnavailable":
|
case "DaemonUnavailable":
|
||||||
|
case "DaemonAccessDenied":
|
||||||
return ConnectionState.Disconnected;
|
return ConnectionState.Disconnected;
|
||||||
default:
|
default:
|
||||||
return ConnectionState.Disconnected;
|
return ConnectionState.Disconnected;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -60,6 +61,11 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
|
|||||||
return c.client, nil
|
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
|
// 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,
|
// 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.
|
// which loopback TCP does not, so the daemon can tell who is calling.
|
||||||
|
|||||||
@@ -1411,5 +1411,17 @@
|
|||||||
},
|
},
|
||||||
"error.profile_unowned": {
|
"error.profile_unowned": {
|
||||||
"message": "Dieses Profil hat noch keinen Besitzer."
|
"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",
|
"message": "Not running",
|
||||||
"description": "Tray status: the background NetBird service (daemon) is 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": {
|
"tray.status.error": {
|
||||||
"message": "Error",
|
"message": "Error",
|
||||||
"description": "Tray status: the client is in an error state."
|
"description": "Tray status: the client is in an error state."
|
||||||
@@ -1775,6 +1779,14 @@
|
|||||||
"message": "Documentation",
|
"message": "Documentation",
|
||||||
"description": "Documentation link on the daemon-unavailable overlay."
|
"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": {
|
"daemon.outdated.title": {
|
||||||
"message": "NetBird Client Is Outdated",
|
"message": "NetBird Client Is Outdated",
|
||||||
"description": "Title of the overlay shown when the NetBird client (daemon) is too old to drive this UI."
|
"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.",
|
"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."
|
"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": {
|
"error.settings_locked": {
|
||||||
"message": "Settings cannot be changed on this device: an administrator has locked them.",
|
"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."
|
"description": "Error: the local daemon was started with update-settings disabled, so it refuses configuration changes."
|
||||||
|
|||||||
@@ -1411,5 +1411,17 @@
|
|||||||
},
|
},
|
||||||
"error.profile_unowned": {
|
"error.profile_unowned": {
|
||||||
"message": "Este perfil aún no tiene propietario."
|
"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": {
|
"error.profile_unowned": {
|
||||||
"message": "Ce profil n'a pas encore de propriétaire."
|
"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": {
|
"error.profile_unowned": {
|
||||||
"message": "Ennek a profilnak még nincs tulajdonosa."
|
"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": {
|
"error.profile_unowned": {
|
||||||
"message": "Questo profilo non ha ancora un proprietario."
|
"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": {
|
"error.profile_unowned": {
|
||||||
"message": "このプロファイルにはまだ所有者がいません。"
|
"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": {
|
"error.profile_unowned": {
|
||||||
"message": "Este perfil ainda não tem proprietário."
|
"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": {
|
"error.profile_unowned": {
|
||||||
"message": "У этого профиля пока нет владельца."
|
"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": {
|
"error.profile_unowned": {
|
||||||
"message": "Цей профіль ще не має власника."
|
"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": {
|
"error.profile_unowned": {
|
||||||
"message": "此配置文件尚未设置所有者。"
|
"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")
|
return l.T("tray.status.disconnected")
|
||||||
case strings.EqualFold(status, services.StatusDaemonUnavailable):
|
case strings.EqualFold(status, services.StatusDaemonUnavailable):
|
||||||
return l.T("tray.status.daemonUnavailable")
|
return l.T("tray.status.daemonUnavailable")
|
||||||
|
case strings.EqualFold(status, services.StatusDaemonAccessDenied):
|
||||||
|
return l.T("tray.status.daemonAccessDenied")
|
||||||
case strings.EqualFold(status, services.StatusConnected):
|
case strings.EqualFold(status, services.StatusConnected):
|
||||||
return l.T("tray.status.connected")
|
return l.T("tray.status.connected")
|
||||||
case strings.EqualFold(status, services.StatusConnecting):
|
case strings.EqualFold(status, services.StatusConnecting):
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
package services
|
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.
|
// DaemonConn returns a lazy gRPC client to the NetBird daemon.
|
||||||
// All services receive a DaemonConn so they share a single connection.
|
// All services receive a DaemonConn so they share a single connection.
|
||||||
@@ -10,4 +14,10 @@ type DaemonConn interface {
|
|||||||
Client() (proto.DaemonServiceClient, error)
|
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 }
|
func ptrStr(s string) *string { return &s }
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ const (
|
|||||||
// StatusDaemonUnavailable is the synthetic Status emitted when the daemon's
|
// StatusDaemonUnavailable is the synthetic Status emitted when the daemon's
|
||||||
// gRPC socket is unreachable. No internal.Status* collides with this label.
|
// gRPC socket is unreachable. No internal.Status* collides with this label.
|
||||||
StatusDaemonUnavailable = "DaemonUnavailable"
|
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
|
// Daemon connection status strings — mirror internal.Status* in
|
||||||
// client/internal/state.go.
|
// client/internal/state.go.
|
||||||
@@ -245,20 +248,21 @@ func (s *DaemonFeed) ServiceShutdown() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get returns the current daemon status snapshot. An unreachable daemon socket
|
// Get returns the current daemon status snapshot. An unreachable daemon socket
|
||||||
// yields Status{Status: StatusDaemonUnavailable} rather than an error, so the
|
// yields a synthetic StatusDaemonUnavailable or StatusDaemonAccessDenied rather
|
||||||
// frontend keys off a single status enum without a parallel "error" path.
|
// 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) {
|
func (s *DaemonFeed) Get(ctx context.Context) (Status, error) {
|
||||||
cli, err := s.conn.Client()
|
cli, err := s.conn.Client()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isDaemonUnreachable(err) {
|
if isDaemonUnreachable(err) {
|
||||||
return Status{Status: StatusDaemonUnavailable}, nil
|
return Status{Status: s.unreachableStatus(ctx)}, nil
|
||||||
}
|
}
|
||||||
return Status{}, err
|
return Status{}, err
|
||||||
}
|
}
|
||||||
resp, err := cli.Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true})
|
resp, err := cli.Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isDaemonUnreachable(err) {
|
if isDaemonUnreachable(err) {
|
||||||
return Status{Status: StatusDaemonUnavailable}, nil
|
return Status{Status: s.unreachableStatus(ctx)}, nil
|
||||||
}
|
}
|
||||||
return Status{}, err
|
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, StatusNeedsLogin),
|
||||||
strings.EqualFold(st.Status, StatusLoginFailed),
|
strings.EqualFold(st.Status, StatusLoginFailed),
|
||||||
strings.EqualFold(st.Status, StatusSessionExpired),
|
strings.EqualFold(st.Status, StatusSessionExpired),
|
||||||
strings.EqualFold(st.Status, StatusDaemonUnavailable):
|
IsDaemonOutage(st.Status):
|
||||||
// New flow has begun (Up started, or daemon refused it).
|
// New flow has begun (Up started, or daemon refused it).
|
||||||
s.switchInProgress = false
|
s.switchInProgress = false
|
||||||
default:
|
default:
|
||||||
@@ -311,7 +315,7 @@ func (s *DaemonFeed) consumeForSwitch(st Status) (suppress, triggerLogin bool) {
|
|||||||
return false, true
|
return false, true
|
||||||
case strings.EqualFold(st.Status, StatusConnected),
|
case strings.EqualFold(st.Status, StatusConnected),
|
||||||
strings.EqualFold(st.Status, StatusIdle),
|
strings.EqualFold(st.Status, StatusIdle),
|
||||||
strings.EqualFold(st.Status, StatusDaemonUnavailable):
|
IsDaemonOutage(st.Status):
|
||||||
// Terminal but not SSO — disarm without triggering.
|
// Terminal but not SSO — disarm without triggering.
|
||||||
s.switchLoginWatch = false
|
s.switchLoginWatch = false
|
||||||
}
|
}
|
||||||
@@ -336,18 +340,19 @@ func (s *DaemonFeed) statusStreamLoop(ctx context.Context) {
|
|||||||
Clock: backoff.SystemClock,
|
Clock: backoff.SystemClock,
|
||||||
}, ctx)
|
}, ctx)
|
||||||
|
|
||||||
// unavailable fires the synthetic event once per outage, not on every retry.
|
// outage is the synthetic status last emitted, reset once the stream recovers.
|
||||||
unavailable := false
|
outage := ""
|
||||||
emitUnavailable := func() {
|
emitUnavailable := func() {
|
||||||
if unavailable {
|
st := s.unreachableStatus(ctx)
|
||||||
|
if st == outage {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
unavailable = true
|
outage = st
|
||||||
s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusDaemonUnavailable})
|
s.emitter.Emit(EventStatusSnapshot, Status{Status: st})
|
||||||
}
|
}
|
||||||
|
|
||||||
op := func() error {
|
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 {
|
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
|
// subscribeAndStreamStatus is one attempt of the status backoff loop: open
|
||||||
// SubscribeStatus and re-emit every snapshot until it errors. A daemon-
|
// SubscribeStatus and re-emit every snapshot until it errors. A daemon-
|
||||||
// unreachable failure also flips the synthetic-unavailable signal.
|
// 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()
|
cli, err := s.conn.Client()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
emitUnavailable()
|
emitUnavailable()
|
||||||
@@ -376,7 +381,7 @@ func (s *DaemonFeed) subscribeAndStreamStatus(ctx context.Context, unavailable *
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return s.handleStatusRecvErr(ctx, err, emitUnavailable)
|
return s.handleStatusRecvErr(ctx, err, emitUnavailable)
|
||||||
}
|
}
|
||||||
*unavailable = false
|
*outage = ""
|
||||||
s.emitStatus(statusFromProto(resp))
|
s.emitStatus(statusFromProto(resp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -574,6 +579,21 @@ func systemEventFromProto(e *proto.SystemEvent) SystemEvent {
|
|||||||
return out
|
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
|
// isDaemonUnreachable reports whether a gRPC error means the daemon socket isn't
|
||||||
// answering, versus the daemon responding with an application-level code. Only
|
// 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.
|
// 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"
|
code := "unknown"
|
||||||
switch {
|
switch {
|
||||||
|
case grpcCode == gcodes.Unavailable && isSocketDenial(lower):
|
||||||
|
code = "daemon_access_denied"
|
||||||
case strings.Contains(lower, "token used before issued"),
|
case strings.Contains(lower, "token used before issued"),
|
||||||
strings.Contains(lower, "token is not valid yet"):
|
strings.Contains(lower, "token is not valid yet"):
|
||||||
code = "jwt_clock_skew"
|
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
|
// classifyDenial presents a refusal the daemon explained: a localised headline
|
||||||
// for the reasons this build knows, with the daemon's own sentence as the
|
// 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
|
// 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)
|
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) {
|
t.Run("unmatched stays unknown", func(t *testing.T) {
|
||||||
ce := c.classify(errors.New("something odd"))
|
ce := c.classify(errors.New("something odd"))
|
||||||
require.Equal(t, "unknown", ce.Code)
|
require.Equal(t, "unknown", ce.Code)
|
||||||
|
|||||||
@@ -911,7 +911,7 @@ func (s *WindowManager) watchTriggerLogin() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch st.Status {
|
switch st.Status {
|
||||||
case StatusConnected, StatusLoginFailed, StatusDaemonUnavailable:
|
case StatusConnected, StatusLoginFailed, StatusDaemonUnavailable, StatusDaemonAccessDenied:
|
||||||
s.scheduleHeadlessTeardown()
|
s.scheduleHeadlessTeardown()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-1
@@ -348,7 +348,7 @@ func (t *Tray) relayoutMenu() {
|
|||||||
|
|
||||||
disableProfiles, disableNetworks := t.featuresDisabled()
|
disableProfiles, disableNetworks := t.featuresDisabled()
|
||||||
|
|
||||||
daemonUnavailable := strings.EqualFold(lastStatus, services.StatusDaemonUnavailable)
|
daemonUnavailable := services.IsDaemonOutage(lastStatus)
|
||||||
connecting := strings.EqualFold(lastStatus, services.StatusConnecting)
|
connecting := strings.EqualFold(lastStatus, services.StatusConnecting)
|
||||||
|
|
||||||
if t.statusItem != nil && lastStatus != "" {
|
if t.statusItem != nil && lastStatus != "" {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func (t *Tray) iconForState() (icon, dark []byte) {
|
|||||||
|
|
||||||
connecting := strings.EqualFold(statusLabel, services.StatusConnecting)
|
connecting := strings.EqualFold(statusLabel, services.StatusConnecting)
|
||||||
errored := strings.EqualFold(statusLabel, statusError) ||
|
errored := strings.EqualFold(statusLabel, statusError) ||
|
||||||
strings.EqualFold(statusLabel, services.StatusDaemonUnavailable)
|
services.IsDaemonOutage(statusLabel)
|
||||||
needsLogin := strings.EqualFold(statusLabel, services.StatusNeedsLogin) ||
|
needsLogin := strings.EqualFold(statusLabel, services.StatusNeedsLogin) ||
|
||||||
strings.EqualFold(statusLabel, services.StatusSessionExpired) ||
|
strings.EqualFold(statusLabel, services.StatusSessionExpired) ||
|
||||||
strings.EqualFold(statusLabel, services.StatusLoginFailed)
|
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.StatusIdle),
|
||||||
strings.EqualFold(status, services.StatusLoginFailed),
|
strings.EqualFold(status, services.StatusLoginFailed),
|
||||||
strings.EqualFold(status, services.StatusSessionExpired),
|
strings.EqualFold(status, services.StatusSessionExpired),
|
||||||
strings.EqualFold(status, services.StatusDaemonUnavailable):
|
services.IsDaemonOutage(status):
|
||||||
t.pendingConnectLogin = false
|
t.pendingConnectLogin = false
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -118,7 +118,7 @@ func statusIndicatorBitmap(status string) []byte {
|
|||||||
case strings.EqualFold(status, services.StatusLoginFailed),
|
case strings.EqualFold(status, services.StatusLoginFailed),
|
||||||
strings.EqualFold(status, statusError):
|
strings.EqualFold(status, statusError):
|
||||||
return iconMenuDotError
|
return iconMenuDotError
|
||||||
case strings.EqualFold(status, services.StatusDaemonUnavailable):
|
case services.IsDaemonOutage(status):
|
||||||
return iconMenuDotOffline
|
return iconMenuDotOffline
|
||||||
default:
|
default:
|
||||||
return iconMenuDotIdle
|
return iconMenuDotIdle
|
||||||
|
|||||||
Reference in New Issue
Block a user