mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-22 16:31:28 +02:00
Compare commits
1 Commits
fix-ebpf-p
...
daemon-ipc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f44040feb0 |
2
.github/workflows/frontend-ui.yml
vendored
2
.github/workflows/frontend-ui.yml
vendored
@@ -86,7 +86,7 @@ jobs:
|
||||
${{ runner.os }}-pnpm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate Wails bindings
|
||||
run: pnpm run bindings
|
||||
|
||||
@@ -247,9 +247,6 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
|
||||
deps.SyncResponse = resp
|
||||
|
||||
if e := cc.Engine(); e != nil {
|
||||
deps.RefreshStatus = func() {
|
||||
e.RunHealthProbes(context.Background(), true)
|
||||
}
|
||||
if cm := e.GetClientMetrics(); cm != nil {
|
||||
deps.ClientMetrics = cm
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
@@ -79,6 +80,8 @@ var (
|
||||
updateSettingsDisabled bool
|
||||
captureEnabled bool
|
||||
networksDisabled bool
|
||||
socketOwner string
|
||||
strictSocketDisabled bool
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "netbird",
|
||||
@@ -143,10 +146,12 @@ func init() {
|
||||
|
||||
defaultDaemonAddr := "unix:///var/run/netbird.sock"
|
||||
if runtime.GOOS == "windows" {
|
||||
defaultDaemonAddr = "tcp://127.0.0.1:41731"
|
||||
// Named pipe (not loopback TCP): the pipe SDDL gates who may connect and
|
||||
// the pipe client token carries the caller's SID for per-RPC authorization.
|
||||
defaultDaemonAddr = "npipe://netbird"
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]")
|
||||
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]")
|
||||
rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL))
|
||||
rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL))
|
||||
rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level")
|
||||
@@ -265,16 +270,31 @@ func FlagNameToEnvVar(cmdFlag string, prefix string) string {
|
||||
}
|
||||
|
||||
// DialClientGRPCServer returns client connection to the daemon server.
|
||||
//
|
||||
// The daemon reads the caller's kernel identity from the transport (SO_PEERCRED
|
||||
// on a Unix socket, the client token on a Windows named pipe), so the client
|
||||
// side uses insecure (plaintext) credentials — it needs no cooperation to be
|
||||
// identified. For npipe addresses we install a context dialer since gRPC's
|
||||
// resolver does not understand Windows named pipes.
|
||||
func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
return grpc.DialContext(
|
||||
ctx,
|
||||
strings.TrimPrefix(addr, "tcp://"),
|
||||
opts := []grpc.DialOption{
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithBlock(),
|
||||
)
|
||||
}
|
||||
|
||||
target := strings.TrimPrefix(addr, "tcp://")
|
||||
if strings.HasPrefix(addr, "npipe://") {
|
||||
path := pipePath(strings.TrimPrefix(addr, "npipe://"))
|
||||
opts = append(opts, grpc.WithContextDialer(func(dialCtx context.Context, _ string) (net.Conn, error) {
|
||||
return dialNamedPipe(dialCtx, path)
|
||||
}))
|
||||
target = "passthrough:///netbird-daemon-pipe"
|
||||
}
|
||||
|
||||
return grpc.DialContext(ctx, target, opts...)
|
||||
}
|
||||
|
||||
// WithBackOff execute function in backoff cycle.
|
||||
|
||||
@@ -56,6 +56,9 @@ func init() {
|
||||
serviceCmd.PersistentFlags().BoolVar(&enableJSONSocket, "enable-json-socket", false, "Enables the HTTP/JSON API socket served by grpc-gateway. To persist, use: netbird service install --enable-json-socket")
|
||||
serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp]://[path|host:port]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket")
|
||||
|
||||
serviceCmd.PersistentFlags().StringVar(&socketOwner, "socket-owner", "", "user to own the daemon control socket; restricts it to that user plus the netbird group (0660). If unset, the first client to connect claims ownership (trust-on-first-use). Persisted via: netbird service install --socket-owner")
|
||||
serviceCmd.PersistentFlags().BoolVar(&strictSocketDisabled, "disable-strict-socket", false, "leave the daemon control socket world-writable (0666) instead of restricting it (root-only, discouraged). Persisted via: netbird service install --disable-strict-socket")
|
||||
|
||||
rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name")
|
||||
serviceEnvDesc := `Sets extra environment variables for the service. ` +
|
||||
`You can specify a comma-separated list of KEY=VALUE pairs. ` +
|
||||
|
||||
@@ -5,6 +5,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
@@ -13,12 +14,31 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// daemonServerOptions returns the gRPC server options that install peer-identity
|
||||
// transport credentials on the daemon control channel. Identity extraction is
|
||||
// only possible over a Unix socket (SO_PEERCRED) or Windows named pipe (client
|
||||
// token); over TCP, or on platforms without a peer-credential primitive, the
|
||||
// daemon runs without per-caller authorization and logs a warning.
|
||||
func daemonServerOptions(network string) []grpc.ServerOption {
|
||||
creds := ipcauth.NewTransportCredentials()
|
||||
if creds == nil {
|
||||
log.Warnf("daemon control channel has no peer-identity primitive on %s; per-caller authorization is disabled", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
if network == "tcp" {
|
||||
log.Warnf("daemon is listening on TCP (%s); peer identity cannot be authenticated over TCP, per-caller authorization is disabled", daemonAddr)
|
||||
return nil
|
||||
}
|
||||
return []grpc.ServerOption{grpc.Creds(creds)}
|
||||
}
|
||||
|
||||
func validateJSONSocketFlags() error {
|
||||
if serviceCmd.PersistentFlags().Changed("json-socket") && !enableJSONSocket {
|
||||
return fmt.Errorf("--json-socket requires --enable-json-socket to configure the daemon JSON gateway")
|
||||
@@ -37,8 +57,13 @@ func (p *program) Start(svc service.Service) error {
|
||||
// Collect static system and platform information
|
||||
system.UpdateStaticInfoAsync()
|
||||
|
||||
// in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
|
||||
p.serv = grpc.NewServer()
|
||||
network, _, err := parseListenAddress(daemonAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse daemon address: %w", err)
|
||||
}
|
||||
|
||||
// in any case, even if configuration does not exist we run daemon to serve the CLI gRPC API.
|
||||
p.serv = grpc.NewServer(daemonServerOptions(network)...)
|
||||
|
||||
daemonListener, err := listenOnAddress(daemonAddr)
|
||||
if err != nil {
|
||||
@@ -62,7 +87,8 @@ func (p *program) Start(svc service.Service) error {
|
||||
defer jsonListener.Close()
|
||||
}
|
||||
|
||||
if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
|
||||
serveListener, err := secureDaemonListener(daemonListener)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -84,6 +110,7 @@ func (p *program) Start(svc service.Service) error {
|
||||
p.serverInstanceMu.Unlock()
|
||||
|
||||
if jsonListener != nil {
|
||||
log.Warnf("JSON gateway (--enable-json-socket) re-dials the daemon locally as the daemon's own identity and BYPASSES per-caller authorization; restrict access to %s separately", jsonSocket)
|
||||
if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
|
||||
log.Fatalf("failed to start daemon JSON server: %v", err)
|
||||
}
|
||||
@@ -92,7 +119,7 @@ func (p *program) Start(svc service.Service) error {
|
||||
}
|
||||
|
||||
log.Printf("started daemon server: %v", daemonListener.address)
|
||||
if err := p.serv.Serve(daemonListener.Listener); err != nil {
|
||||
if err := p.serv.Serve(serveListener); err != nil {
|
||||
log.Errorf("failed to serve daemon requests: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -71,6 +71,14 @@ func buildServiceArguments() []string {
|
||||
args = append(args, "--enable-json-socket", "--json-socket", jsonSocket)
|
||||
}
|
||||
|
||||
if socketOwner != "" {
|
||||
args = append(args, "--socket-owner", socketOwner)
|
||||
}
|
||||
|
||||
if strictSocketDisabled {
|
||||
args = append(args, "--disable-strict-socket")
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ type serviceParams struct {
|
||||
EnableCapture bool `json:"enable_capture,omitempty"`
|
||||
DisableNetworks bool `json:"disable_networks,omitempty"`
|
||||
EnableJSONSocket bool `json:"enable_json_socket,omitempty"`
|
||||
SocketOwner string `json:"socket_owner,omitempty"`
|
||||
DisableStrictSocket bool `json:"disable_strict_socket,omitempty"`
|
||||
ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"`
|
||||
}
|
||||
|
||||
@@ -86,6 +88,8 @@ func currentServiceParams() *serviceParams {
|
||||
EnableCapture: captureEnabled,
|
||||
DisableNetworks: networksDisabled,
|
||||
EnableJSONSocket: enableJSONSocket,
|
||||
SocketOwner: socketOwner,
|
||||
DisableStrictSocket: strictSocketDisabled,
|
||||
}
|
||||
|
||||
if len(serviceEnvVars) > 0 {
|
||||
@@ -165,6 +169,14 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
||||
networksDisabled = params.DisableNetworks
|
||||
}
|
||||
|
||||
if !serviceCmd.PersistentFlags().Changed("socket-owner") {
|
||||
socketOwner = params.SocketOwner
|
||||
}
|
||||
|
||||
if !serviceCmd.PersistentFlags().Changed("disable-strict-socket") {
|
||||
strictSocketDisabled = params.DisableStrictSocket
|
||||
}
|
||||
|
||||
applyServiceEnvParams(cmd, params)
|
||||
}
|
||||
|
||||
|
||||
20
client/cmd/service_pipe_other.go
Normal file
20
client/cmd/service_pipe_other.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build !windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// listenNamedPipe is unsupported off Windows; named pipes are a Windows-only transport.
|
||||
func listenNamedPipe(string) (net.Listener, error) {
|
||||
return nil, fmt.Errorf("named pipe daemon socket is only supported on Windows, not %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
// dialNamedPipe is unsupported off Windows.
|
||||
func dialNamedPipe(context.Context, string) (net.Conn, error) {
|
||||
return nil, fmt.Errorf("named pipe daemon socket is only supported on Windows, not %s", runtime.GOOS)
|
||||
}
|
||||
32
client/cmd/service_pipe_windows.go
Normal file
32
client/cmd/service_pipe_windows.go
Normal file
@@ -0,0 +1,32 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// listenNamedPipe creates the daemon control named pipe with a tight SDDL
|
||||
// (SYSTEM + Administrators + interactive users). ListenPipe fails if the pipe
|
||||
// already exists (first-instance semantics), which prevents a squatting process
|
||||
// from pre-creating it — we surface that error loudly rather than falling back.
|
||||
func listenNamedPipe(path string) (net.Listener, error) {
|
||||
return winio.ListenPipe(path, &winio.PipeConfig{
|
||||
SecurityDescriptor: ipcauth.DefaultPipeSDDL(),
|
||||
})
|
||||
}
|
||||
|
||||
// dialNamedPipe connects to the daemon control named pipe.
|
||||
func dialNamedPipe(ctx context.Context, path string) (net.Conn, error) {
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
timeout := time.Until(deadline)
|
||||
return winio.DialPipe(path, &timeout)
|
||||
}
|
||||
return winio.DialPipeContext(ctx, path)
|
||||
}
|
||||
@@ -26,6 +26,15 @@ func listenOnAddress(addr string) (*socketListener, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if network == "npipe" {
|
||||
path := pipePath(address)
|
||||
listener, err := listenNamedPipe(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &socketListener{Listener: listener, network: network, address: path}, nil
|
||||
}
|
||||
|
||||
if network == "unix" {
|
||||
removeStaleUnixSocket(address)
|
||||
}
|
||||
@@ -45,13 +54,23 @@ func parseListenAddress(addr string) (string, string, error) {
|
||||
}
|
||||
|
||||
switch network {
|
||||
case "unix", "tcp":
|
||||
case "unix", "tcp", "npipe":
|
||||
return network, address, nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("unsupported daemon address protocol: %v", network)
|
||||
}
|
||||
}
|
||||
|
||||
// pipePath maps a daemon-addr npipe name (e.g. "netbird" from "npipe://netbird")
|
||||
// to a Windows named-pipe path (\\.\pipe\netbird). A caller may also pass a full
|
||||
// \\.\pipe\ path, which is returned unchanged.
|
||||
func pipePath(name string) string {
|
||||
if strings.HasPrefix(name, `\\`) {
|
||||
return name
|
||||
}
|
||||
return `\\.\pipe\` + name
|
||||
}
|
||||
|
||||
func removeStaleUnixSocket(path string) {
|
||||
stat, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
|
||||
11
client/cmd/service_socket_perms_other.go
Normal file
11
client/cmd/service_socket_perms_other.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
import "net"
|
||||
|
||||
// secureDaemonListener is a no-op on Windows: the named-pipe SDDL gates who may
|
||||
// connect (Layer 1), and the pipe client token supplies per-RPC identity.
|
||||
func secureDaemonListener(l *socketListener) (net.Listener, error) {
|
||||
return l.Listener, nil
|
||||
}
|
||||
225
client/cmd/service_socket_perms_unix.go
Normal file
225
client/cmd/service_socket_perms_unix.go
Normal file
@@ -0,0 +1,225 @@
|
||||
//go:build !windows && !ios && !android
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
// secureDaemonListener applies the Layer-1 access control to the daemon control
|
||||
// socket and returns the listener to serve on. For a Unix socket this restricts
|
||||
// the socket to an owner (plus the netbird group); for anything else it is a
|
||||
// no-op (TCP is legacy/unauthenticated; named pipes are gated by their SDDL).
|
||||
func secureDaemonListener(l *socketListener) (net.Listener, error) {
|
||||
if l.network != "unix" {
|
||||
return l.Listener, nil
|
||||
}
|
||||
|
||||
owner := effectiveSocketOwner()
|
||||
switch {
|
||||
case strictSocketDisabled:
|
||||
// Root-only opt-out (via service.json): leave it world-writable.
|
||||
if err := os.Chmod(l.address, 0666); err != nil {
|
||||
return nil, fmt.Errorf("set daemon socket permissions: %w", err)
|
||||
}
|
||||
log.Warnf("daemon control socket left world-writable (0666) by --disable-strict-socket")
|
||||
return l.Listener, nil
|
||||
|
||||
case owner != "":
|
||||
// Seeded owner (flag, MDM, or persisted TOFU result): restrict before
|
||||
// serving so there is no open window.
|
||||
uid, err := lookupUser(owner)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup socket owner %q: %w", owner, err)
|
||||
}
|
||||
if err := restrictSocket(l.address, uid); err != nil {
|
||||
return nil, fmt.Errorf("restrict socket to %q: %w", owner, err)
|
||||
}
|
||||
return l.Listener, nil
|
||||
|
||||
default:
|
||||
// Trust-on-first-use: open the socket now; tofuListener locks it to the
|
||||
// first caller's uid on the first connection.
|
||||
if err := os.Chmod(l.address, 0666); err != nil {
|
||||
return nil, fmt.Errorf("set daemon socket permissions: %w", err)
|
||||
}
|
||||
return &tofuListener{Listener: l.Listener, path: l.address, owner: -1}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func lookupUser(username string) (int, error) {
|
||||
u, err := shell.LookupWithGetent(username)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("lookup user %s: %w", username, err)
|
||||
}
|
||||
uid, err := strconv.Atoi(u.Uid)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("parse uid %s: %w", u.Uid, err)
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
|
||||
// addGroup creates a system group if it doesn't already exist and returns the gid.
|
||||
// Must run as root.
|
||||
func addGroup(name string) (int, error) {
|
||||
group, err := shell.LookupGroupWithGetent(name)
|
||||
if err == nil {
|
||||
gid, err := strconv.ParseInt(group.Gid, 10, 64)
|
||||
return int(gid), err
|
||||
}
|
||||
|
||||
groupadd, err := exec.LookPath("groupadd")
|
||||
if err != nil {
|
||||
// Fallback for Alpine/BusyBox systems.
|
||||
if groupadd, err = exec.LookPath("addgroup"); err != nil {
|
||||
return -1, errors.New("neither groupadd nor addgroup found")
|
||||
}
|
||||
}
|
||||
|
||||
// Use --system for a service/daemon group (no login, low GID).
|
||||
out, err := exec.Command(groupadd, "--system", name).CombinedOutput()
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("create group %q: %w: %s", name, err, out)
|
||||
}
|
||||
if group, err := shell.LookupGroupWithGetent(name); err == nil {
|
||||
gid, err := strconv.ParseInt(group.Gid, 10, 64)
|
||||
return int(gid), err
|
||||
}
|
||||
return -1, fmt.Errorf("lookup group %q: %w", name, err)
|
||||
}
|
||||
|
||||
// restrictSocket locks the unix socket down to the owner uid plus the netbird
|
||||
// group (0660). If the group cannot be created or applied, it fails closed to
|
||||
// owner-only 0600 — it never leaves the socket world-writable.
|
||||
func restrictSocket(path string, uid int) error {
|
||||
gid, err := addGroup("netbird")
|
||||
if err != nil {
|
||||
log.Errorf("create netbird group, failing closed to owner-only 0600: %v", err)
|
||||
return chownChmod(path, uid, -1, 0600)
|
||||
}
|
||||
if err := chownChmod(path, uid, gid, 0660); err != nil {
|
||||
log.Errorf("apply netbird group to socket, failing closed to owner-only 0600: %v", err)
|
||||
return chownChmod(path, uid, -1, 0600)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// chownChmod sets ownership and mode on the socket. A gid of -1 leaves the
|
||||
// group unchanged.
|
||||
func chownChmod(path string, uid, gid int, mode os.FileMode) error {
|
||||
if err := os.Chown(path, uid, gid); err != nil {
|
||||
return fmt.Errorf("chown socket %s: %w", path, err)
|
||||
}
|
||||
if err := os.Chmod(path, mode); err != nil {
|
||||
return fmt.Errorf("chmod socket %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tofuListener implements trust-on-first-use for the daemon control socket.
|
||||
// The socket starts world-writable; the first caller's uid (read via SO_PEERCRED)
|
||||
// becomes the owner. On that first connection the socket is restricted and the
|
||||
// owner persisted so the open window never reopens on later starts. Connections
|
||||
// that raced in during the open window and are neither the owner nor root are
|
||||
// dropped. Changing the socket mode does not disturb the already-open
|
||||
// connection, so the first caller's request is served normally.
|
||||
type tofuListener struct {
|
||||
net.Listener
|
||||
path string
|
||||
mu sync.Mutex
|
||||
owner int // -1 until claimed
|
||||
}
|
||||
|
||||
func (l *tofuListener) Accept() (net.Conn, error) {
|
||||
for {
|
||||
c, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, err := ipcauth.PeerIdentity(c)
|
||||
if err != nil {
|
||||
log.Errorf("read peer credentials, dropping connection: %v", err)
|
||||
_ = c.Close()
|
||||
continue
|
||||
}
|
||||
uid := int(id.UID)
|
||||
|
||||
l.mu.Lock()
|
||||
if l.owner == -1 {
|
||||
if err := restrictSocket(l.path, uid); err != nil {
|
||||
l.mu.Unlock()
|
||||
_ = c.Close()
|
||||
// Refuse to serve on a socket we could not lock down.
|
||||
return nil, fmt.Errorf("restrict socket on first connection: %w", err)
|
||||
}
|
||||
l.owner = uid
|
||||
persistSocketOwner(uid)
|
||||
log.Infof("control socket restricted to first caller (uid %d)", uid)
|
||||
l.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
owner := l.owner
|
||||
l.mu.Unlock()
|
||||
|
||||
// New connects are already gated by the 0660 perms set above; this only
|
||||
// drops anything that slipped in during the brief open window.
|
||||
if uid != owner && uid != 0 {
|
||||
log.Warnf("dropping non-owner connection (uid %d) during socket bootstrap", uid)
|
||||
_ = c.Close()
|
||||
continue
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveSocketOwner returns the configured socket owner: the --socket-owner
|
||||
// flag when set, otherwise the owner persisted by a previous TOFU migration.
|
||||
func effectiveSocketOwner() string {
|
||||
if socketOwner != "" {
|
||||
return socketOwner
|
||||
}
|
||||
params, err := loadServiceParams()
|
||||
if err != nil {
|
||||
log.Errorf("load service params for socket owner: %v", err)
|
||||
return ""
|
||||
}
|
||||
if params != nil {
|
||||
return params.SocketOwner
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// persistSocketOwner records the TOFU-selected owner (by username) so the next
|
||||
// daemon start restricts the socket immediately, with no open window.
|
||||
func persistSocketOwner(uid int) {
|
||||
u, err := user.LookupId(strconv.Itoa(uid))
|
||||
if err != nil {
|
||||
log.Errorf("resolve uid %d to username for persistence: %v", uid, err)
|
||||
return
|
||||
}
|
||||
params, err := loadServiceParams()
|
||||
if err != nil {
|
||||
log.Errorf("load service params to persist socket owner: %v", err)
|
||||
return
|
||||
}
|
||||
if params == nil {
|
||||
params = currentServiceParams()
|
||||
}
|
||||
params.SocketOwner = u.Username
|
||||
if err := saveServiceParams(params); err != nil {
|
||||
log.Errorf("persist socket owner: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
maxPastHorizon = 30 * 24 * time.Hour
|
||||
// Skew tolerates a small clock difference between the management
|
||||
// server and this peer before treating a deadline as "in the past".
|
||||
// Slightly above typical NTP drift; tight enough that the UI doesn't
|
||||
// paint a stale expiry as if it were valid.
|
||||
Skew = 30 * time.Second
|
||||
|
||||
// maxDeadlineHorizon caps how far in the future an accepted deadline
|
||||
// can sit. A timestamp beyond this is almost certainly a protocol
|
||||
@@ -53,7 +57,7 @@ var (
|
||||
ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future")
|
||||
|
||||
// ErrDeadlineInPast is returned by Update when the supplied deadline
|
||||
// is more than maxPastHorizon in the past.
|
||||
// is more than Skew in the past.
|
||||
ErrDeadlineInPast = errors.New("session deadline in the past")
|
||||
)
|
||||
|
||||
@@ -62,14 +66,15 @@ var (
|
||||
// for deadline change/clear, PublishEvent for the two warnings); tests pass
|
||||
// a fake recorder so the same surface is observable without an engine.
|
||||
//
|
||||
// While the watcher runs, it owns the deadline propagated to the recorder:
|
||||
// every set, clear and sanity-check rejection routes the value through
|
||||
// SetSessionExpiresAt, so the SubscribeStatus snapshot the UI reads can
|
||||
// never drift from the watcher's timer state. (SetSessionExpiresAt fans
|
||||
// out its own state-change notification, so no separate notify is needed.)
|
||||
// The recorder is server-scoped and outlives this engine-scoped watcher;
|
||||
// Close deliberately leaves the recorder value in place so transient engine
|
||||
// restarts don't blank it — the client run loop clears it on real teardown.
|
||||
// The watcher is the single owner of the deadline propagated to the
|
||||
// recorder: every set, clear, sanity-check rejection and Close routes the
|
||||
// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI
|
||||
// reads can never drift from the watcher's timer state. (SetSessionExpiresAt
|
||||
// fans out its own state-change notification, so no separate notify is
|
||||
// needed.) The recorder is server-scoped and outlives this engine-scoped
|
||||
// watcher — without the Close-time clear a teardown (Down, or the Down+Up of
|
||||
// a profile switch) would leave the next session showing the previous one's
|
||||
// stale "expires in" value.
|
||||
//
|
||||
// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher
|
||||
// composes the metadata internally so the wire format (MetaSession*) is
|
||||
@@ -130,13 +135,10 @@ func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
|
||||
// was disabled).
|
||||
//
|
||||
// Same-value updates are no-ops. A different non-zero value cancels any
|
||||
// pending timer, resets the "already fired" guards, and — when the
|
||||
// deadline lies in the future — arms fresh warning timers. A deadline
|
||||
// already in the past (within maxPastHorizon) is recorded as-is with no
|
||||
// timers: the session has expired and consumers render it that way.
|
||||
// pending timer, resets the "already fired" guard, and arms a new one.
|
||||
//
|
||||
// Returns one of the sentinel Err* values when the deadline fails the
|
||||
// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon).
|
||||
// sanity checks (pre-epoch, far future, or in the past beyond Skew).
|
||||
// In every error case the watcher first clears its state so it stays
|
||||
// consistent with what the caller will push into its other sinks (e.g.
|
||||
// applySessionDeadline forces a zero deadline into the status recorder
|
||||
@@ -161,7 +163,7 @@ func (w *Watcher) Update(deadline time.Time) error {
|
||||
case deadline.After(now.Add(maxDeadlineHorizon)):
|
||||
w.clearLocked()
|
||||
return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline)
|
||||
case deadline.Before(now.Add(-maxPastHorizon)):
|
||||
case deadline.Before(now.Add(-Skew)):
|
||||
w.clearLocked()
|
||||
return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now)
|
||||
}
|
||||
@@ -181,9 +183,7 @@ func (w *Watcher) Update(deadline time.Time) error {
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
|
||||
if deadline.After(now) {
|
||||
w.armTimerLocked(deadline)
|
||||
}
|
||||
w.armTimerLocked(deadline)
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder != nil {
|
||||
@@ -227,25 +227,30 @@ func (w *Watcher) Dismiss() {
|
||||
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// Close stops any pending timer. Update calls after Close are ignored.
|
||||
// The recorder keeps its deadline: the watcher is engine-scoped and closes
|
||||
// on every engine restart (network change, sleep/wake, stream errors)
|
||||
// while the SSO deadline stays valid across those, so clearing here would
|
||||
// blank the UI's "expires in" row on every transient reconnect. The
|
||||
// client run loop clears the server-scoped recorder when it exits for
|
||||
// real (Down, profile switch, permanent login failure).
|
||||
// Close stops any pending timer and drops the deadline on the status
|
||||
// recorder. Update calls after Close are ignored. Clearing the recorder
|
||||
// here is what keeps a teardown (Down, or the Down+Up of a profile switch)
|
||||
// from leaving the next session showing this one's stale "expires in"
|
||||
// value — the recorder is server-scoped and outlives this engine-scoped
|
||||
// watcher, so nothing else drops the anchor on teardown.
|
||||
func (w *Watcher) Close() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.closed = true
|
||||
w.stopTimerLocked()
|
||||
hadDeadline := !w.current.IsZero()
|
||||
w.current = time.Time{}
|
||||
w.firedAt = time.Time{}
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder != nil && hadDeadline {
|
||||
recorder.SetSessionExpiresAt(time.Time{})
|
||||
}
|
||||
}
|
||||
|
||||
// clearLocked drops the tracked deadline and notifies the recorder so
|
||||
|
||||
@@ -224,13 +224,11 @@ func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
|
||||
|
||||
func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
lead := 150 * time.Millisecond
|
||||
lead := 30 * time.Millisecond
|
||||
w := newWatcher(lead, r)
|
||||
defer w.Close()
|
||||
|
||||
// Warning fires ~20ms in; the deadline itself stays 150ms away so the
|
||||
// replacement below lands well before it.
|
||||
first := time.Now().Add(170 * time.Millisecond)
|
||||
first := time.Now().Add(50 * time.Millisecond)
|
||||
_ = w.Update(first)
|
||||
|
||||
// Wait for stateChange + warning of the first cycle.
|
||||
@@ -308,29 +306,7 @@ func TestUpdateRejectsTooFarFuture(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRecentPastRecordedAsExpired(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(-1 * time.Hour)
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("recent-past Update should succeed, got %v", err)
|
||||
}
|
||||
if !w.Deadline().Equal(d) {
|
||||
t.Fatalf("expected deadline to be recorded, got %v want %v", w.Deadline(), d)
|
||||
}
|
||||
if got := r.deadline(); !got.Equal(d) {
|
||||
t.Fatalf("recorder deadline = %v, want %v", got, d)
|
||||
}
|
||||
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
|
||||
t.Fatalf("no warning events may fire for an already-past deadline, got %+v", r.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAncientPastRejected(t *testing.T) {
|
||||
func TestUpdateInPastClearsDeadline(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
@@ -342,12 +318,12 @@ func TestUpdateAncientPastRejected(t *testing.T) {
|
||||
// Drain the stateChange from the seed.
|
||||
waitForEvents(t, r, 1)
|
||||
|
||||
err := w.Update(time.Now().Add(-31 * 24 * time.Hour))
|
||||
err := w.Update(time.Now().Add(-1 * time.Hour))
|
||||
if !errors.Is(err, ErrDeadlineInPast) {
|
||||
t.Fatalf("want ErrDeadlineInPast, got %v", err)
|
||||
}
|
||||
if !w.Deadline().IsZero() {
|
||||
t.Fatalf("rejected ancient-past update must clear the deadline, got %v", w.Deadline())
|
||||
t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline())
|
||||
}
|
||||
events := waitForEvents(t, r, 2)
|
||||
if events[1].kind != stateChange {
|
||||
@@ -355,25 +331,39 @@ func TestUpdateAncientPastRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWithinSkewAccepted(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
// 5 seconds in the past is within the 30s Skew tolerance — accept it.
|
||||
d := time.Now().Add(-5 * time.Second)
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("within-skew Update should succeed, got %v", err)
|
||||
}
|
||||
if !w.Deadline().Equal(d) {
|
||||
t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseSilencesUpdates(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
w.Close()
|
||||
|
||||
if err := w.Update(time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("Update after Close: want nil, got %v", err)
|
||||
}
|
||||
_ = w.Update(time.Now().Add(time.Hour))
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if got := r.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("expected no events after Close, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseKeepsRecorderDeadline pins the reconnect-flap fix: the watcher
|
||||
// closes on every engine restart (network change, sleep/wake) while the
|
||||
// SSO deadline stays valid across those, so Close must leave the
|
||||
// server-scoped recorder's value in place. The client run loop clears the
|
||||
// recorder when it exits for real.
|
||||
func TestCloseKeepsRecorderDeadline(t *testing.T) {
|
||||
// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher
|
||||
// holding a live deadline must zero the recorder on Close so the next
|
||||
// engine's watcher (and the UI reading the shared server-scoped recorder)
|
||||
// doesn't start out showing the previous session's stale "expires in".
|
||||
func TestCloseClearsRecorderDeadline(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(time.Hour, r)
|
||||
|
||||
@@ -387,8 +377,8 @@ func TestCloseKeepsRecorderDeadline(t *testing.T) {
|
||||
|
||||
w.Close()
|
||||
|
||||
if got := r.deadline(); !got.Equal(d) {
|
||||
t.Fatalf("recorder deadline after Close = %v, want %v", got, d)
|
||||
if got := r.deadline(); !got.IsZero() {
|
||||
t.Fatalf("recorder deadline after Close = %v, want zero", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -257,10 +257,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
log.Errorf("failed to clean up temporary installer file: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
c.statusRecorder.SetSessionExpiresAt(time.Time{})
|
||||
c.statusRecorder.ClientStop()
|
||||
}()
|
||||
defer c.statusRecorder.ClientStop()
|
||||
operation := func() error {
|
||||
// if context cancelled we not start new backoff cycle
|
||||
if c.ctx.Err() != nil {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -52,14 +52,11 @@ int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
|
||||
|
||||
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
|
||||
udp->dest = dns_port;
|
||||
// Clear the now-stale checksum; zero means "not computed" for IPv4.
|
||||
udp->check = 0;
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
if (udp->source == dns_port && ip->saddr == dns_ip) {
|
||||
udp->source = GENERAL_DNS_PORT;
|
||||
udp->check = 0;
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,11 +50,5 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) {
|
||||
__be16 new_dst_port = htons(proxy_port);
|
||||
udp->dest = new_dst_port;
|
||||
udp->source = new_src_port;
|
||||
|
||||
// The ports are covered by the UDP checksum. This is an IPv4 loopback hop
|
||||
// and the payload is already integrity-protected, so clear the checksum (a
|
||||
// zero UDP checksum means "not computed" for IPv4) rather than leave a
|
||||
// stale value the kernel would drop as UDP_CSUM.
|
||||
udp->check = 0;
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
@@ -75,14 +75,4 @@ func TestApplySessionDeadline_ThreeState(t *testing.T) {
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
||||
"invalid timestamp must clear the deadline")
|
||||
})
|
||||
|
||||
t.Run("recently expired timestamp stays visible as expired", func(t *testing.T) {
|
||||
e := newEngine()
|
||||
expired := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second)
|
||||
|
||||
e.ApplySessionDeadline(timestamppb.New(expired))
|
||||
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(expired),
|
||||
"recently-expired deadline must stay on the recorder so consumers render it as expired")
|
||||
})
|
||||
}
|
||||
|
||||
13
client/internal/ipcauth/creds_stub.go
Normal file
13
client/internal/ipcauth/creds_stub.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !linux && !darwin && !freebsd && !windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import "google.golang.org/grpc/credentials"
|
||||
|
||||
// NewTransportCredentials returns nil on platforms without a peer-identity
|
||||
// primitive. The daemon falls back to insecure credentials and skips per-RPC
|
||||
// authorization (logging a warning), preserving pre-hardening behavior until
|
||||
// the transport gains an identity primitive.
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return nil
|
||||
}
|
||||
48
client/internal/ipcauth/creds_unix.go
Normal file
48
client/internal/ipcauth/creds_unix.go
Normal file
@@ -0,0 +1,48 @@
|
||||
//go:build linux || darwin || freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// NewTransportCredentials returns gRPC transport credentials that extract the
|
||||
// caller's kernel-authenticated identity from a Unix-socket connection and
|
||||
// expose it via IdentityFromContext. It is non-nil on platforms with a
|
||||
// peer-credential primitive.
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return unixCreds{}
|
||||
}
|
||||
|
||||
// unixCreds implements credentials.TransportCredentials over a Unix socket.
|
||||
// The server side reads SO_PEERCRED/LOCAL_PEERCRED during the handshake; the
|
||||
// client side is a no-op (the kernel supplies the peer identity to the server
|
||||
// without any client cooperation).
|
||||
type unixCreds struct{}
|
||||
|
||||
func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return conn, AuthInfo{}, nil
|
||||
}
|
||||
|
||||
// ServerHandshake extracts the peer identity and fails closed if it cannot be read.
|
||||
func (unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
id, err := PeerIdentity(conn)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return conn, AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (unixCreds) Info() credentials.ProtocolInfo {
|
||||
return credentials.ProtocolInfo{SecurityProtocol: "netbird-ipc-peercred"}
|
||||
}
|
||||
|
||||
func (unixCreds) Clone() credentials.TransportCredentials { return unixCreds{} }
|
||||
|
||||
func (unixCreds) OverrideServerName(string) error { return nil }
|
||||
142
client/internal/ipcauth/creds_windows.go
Normal file
142
client/internal/ipcauth/creds_windows.go
Normal file
@@ -0,0 +1,142 @@
|
||||
//go:build windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
var (
|
||||
modadvapi32 = windows.NewLazySystemDLL("advapi32.dll")
|
||||
procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient")
|
||||
procRevertToSelf = modadvapi32.NewProc("RevertToSelf")
|
||||
)
|
||||
|
||||
// Windows group-SID attribute flags (winnt.h): a group only counts toward
|
||||
// membership when it is enabled and not marked use-for-deny-only.
|
||||
const (
|
||||
seGroupEnabled = 0x00000004
|
||||
seGroupUseForDenyOnly = 0x00000010
|
||||
)
|
||||
|
||||
// DefaultPipeSDDL restricts the daemon control pipe to LocalSystem (SY), the
|
||||
// Administrators group (BA), and interactive logon users (IU). It deliberately
|
||||
// excludes Authenticated Users / Everyone so remote or arbitrary service
|
||||
// principals cannot connect. This is the Layer-1 channel gate; the interceptor
|
||||
// (Layer 2) further restricts by per-profile ownership.
|
||||
func DefaultPipeSDDL() string {
|
||||
return "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;IU)"
|
||||
}
|
||||
|
||||
// NewTransportCredentials returns gRPC transport credentials that derive the
|
||||
// caller's identity from the named-pipe client token, following Microsoft's
|
||||
// "Verifying Client Access with ACLs" pattern: ImpersonateNamedPipeClient ->
|
||||
// OpenThreadToken -> RevertToSelf. Per threat-model M-NOIMP, impersonation is
|
||||
// used only to read the client token for identity, never to perform privileged work.
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return winpipeCreds{}
|
||||
}
|
||||
|
||||
type winpipeCreds struct{}
|
||||
|
||||
func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return conn, AuthInfo{}, nil
|
||||
}
|
||||
|
||||
// ServerHandshake extracts the connecting client's identity from the pipe token.
|
||||
// Fails closed if the handle or token cannot be read.
|
||||
func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
// go-winio's pipe connection embeds *win32File, which exposes Fd().
|
||||
fdConn, ok := conn.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("connection %T does not expose a pipe handle", conn)
|
||||
}
|
||||
handle := windows.Handle(fdConn.Fd())
|
||||
|
||||
id, err := pipeClientIdentity(handle)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return conn, AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (winpipeCreds) Info() credentials.ProtocolInfo {
|
||||
return credentials.ProtocolInfo{SecurityProtocol: "netbird-ipc-peercred"}
|
||||
}
|
||||
|
||||
func (winpipeCreds) Clone() credentials.TransportCredentials { return winpipeCreds{} }
|
||||
|
||||
func (winpipeCreds) OverrideServerName(string) error { return nil }
|
||||
|
||||
// pipeClientIdentity reads the connecting client's user SID and enabled group
|
||||
// SIDs from the named-pipe handle. The impersonation window is kept as small as
|
||||
// possible and pinned to the OS thread (impersonation is thread-local).
|
||||
func pipeClientIdentity(handle windows.Handle) (Identity, error) {
|
||||
var pid uint32
|
||||
hasPID := windows.GetNamedPipeClientProcessId(handle, &pid) == nil
|
||||
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
if err := impersonateNamedPipeClient(handle); err != nil {
|
||||
return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err)
|
||||
}
|
||||
defer func() { _ = revertToSelf() }()
|
||||
|
||||
// openAsSelf=true: the token is opened using the daemon's process context
|
||||
// (LocalSystem), not the impersonated client's, so the open always succeeds.
|
||||
var token windows.Token
|
||||
if err := windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_QUERY, true, &token); err != nil {
|
||||
return Identity{}, fmt.Errorf("open thread token: %w", err)
|
||||
}
|
||||
defer token.Close()
|
||||
|
||||
tu, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("get token user: %w", err)
|
||||
}
|
||||
|
||||
tg, err := token.GetTokenGroups()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("get token groups: %w", err)
|
||||
}
|
||||
var groups []string
|
||||
for _, g := range tg.AllGroups() {
|
||||
if g.Attributes&seGroupEnabled == 0 || g.Attributes&seGroupUseForDenyOnly != 0 {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, g.Sid.String())
|
||||
}
|
||||
|
||||
return Identity{
|
||||
SID: tu.User.Sid.String(),
|
||||
Groups: groups,
|
||||
PID: int32(pid),
|
||||
HasPID: hasPID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func impersonateNamedPipeClient(h windows.Handle) error {
|
||||
r, _, e := procImpersonateNamedPipeClient.Call(uintptr(h))
|
||||
if r == 0 {
|
||||
return e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func revertToSelf() error {
|
||||
r, _, e := procRevertToSelf.Call()
|
||||
if r == 0 {
|
||||
return e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
93
client/internal/ipcauth/identity.go
Normal file
93
client/internal/ipcauth/identity.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Package ipcauth provides kernel-authenticated caller identity for the daemon's
|
||||
// local IPC (gRPC) channel and the transport credentials that populate it.
|
||||
//
|
||||
// It is the identity foundation shared by two layers of the local-IPC hardening:
|
||||
// - the socket-permission layer (Layer 1, client/cmd), which reads the peer
|
||||
// identity to gate who may connect and to run trust-on-first-use; and
|
||||
// - the per-RPC authorization interceptor (Layer 2), which reads the same
|
||||
// identity from the gRPC context to enforce per-profile ownership.
|
||||
//
|
||||
// On Unix the identity is read from the kernel via SO_PEERCRED (Linux) or
|
||||
// LOCAL_PEERCRED (Darwin/FreeBSD). On Windows it is derived from the named-pipe
|
||||
// client token. Platforms without a peer-identity primitive get no credentials
|
||||
// and therefore no enforcement (the daemon logs a warning and stays open,
|
||||
// preserving today's behavior until the transport is hardened).
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
// Identity is the kernel-authenticated identity of a local IPC caller.
|
||||
//
|
||||
// The zero value is not a valid identity; callers obtain one via
|
||||
// IdentityFromContext (which reports presence) or PeerIdentity.
|
||||
type Identity struct {
|
||||
// UID and GID are the caller's Unix user ID and primary group ID.
|
||||
// Zero on Windows, where SID is authoritative instead.
|
||||
UID uint32
|
||||
GID uint32
|
||||
|
||||
// PID is the caller's process ID, for audit only. HasPID is false when the
|
||||
// platform cannot supply it (e.g. Darwin/FreeBSD xucred carries no PID).
|
||||
PID int32
|
||||
HasPID bool
|
||||
|
||||
// SID is the caller's Windows security identifier (empty on Unix).
|
||||
SID string
|
||||
|
||||
// Groups holds the caller's Windows group SIDs, captured from the client
|
||||
// token at handshake time (empty on Unix, where supplementary group
|
||||
// membership is resolved on demand via NSS/getent by the authorizer).
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// IsWindows reports whether this identity is a Windows principal (SID-based)
|
||||
// rather than a Unix uid/gid principal.
|
||||
func (i Identity) IsWindows() bool {
|
||||
return i.SID != ""
|
||||
}
|
||||
|
||||
// String renders the identity for audit logs.
|
||||
func (i Identity) String() string {
|
||||
if i.IsWindows() {
|
||||
if i.HasPID {
|
||||
return fmt.Sprintf("sid=%s pid=%d", i.SID, i.PID)
|
||||
}
|
||||
return fmt.Sprintf("sid=%s", i.SID)
|
||||
}
|
||||
if i.HasPID {
|
||||
return fmt.Sprintf("uid=%d gid=%d pid=%d", i.UID, i.GID, i.PID)
|
||||
}
|
||||
return fmt.Sprintf("uid=%d gid=%d", i.UID, i.GID)
|
||||
}
|
||||
|
||||
// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so the
|
||||
// interceptor can retrieve it from the request context via IdentityFromContext.
|
||||
type AuthInfo struct {
|
||||
credentials.CommonAuthInfo
|
||||
Identity Identity
|
||||
}
|
||||
|
||||
// AuthType identifies the authentication scheme.
|
||||
func (AuthInfo) AuthType() string { return "netbird-ipc-peercred" }
|
||||
|
||||
// IdentityFromContext extracts the caller's kernel-authenticated identity from
|
||||
// the gRPC peer context. The second return value is false when no IPC transport
|
||||
// credentials were negotiated (e.g. an unsupported platform, or a caller that
|
||||
// did not come through the daemon socket) — callers MUST fail closed in that case.
|
||||
func IdentityFromContext(ctx context.Context) (Identity, bool) {
|
||||
p, ok := peer.FromContext(ctx)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
info, ok := p.AuthInfo.(AuthInfo)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
return info.Identity, true
|
||||
}
|
||||
47
client/internal/ipcauth/identity_test.go
Normal file
47
client/internal/ipcauth/identity_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
func TestIdentityFromContext_NoPeer(t *testing.T) {
|
||||
_, ok := IdentityFromContext(context.Background())
|
||||
assert.False(t, ok, "bare context must report no identity (fail closed)")
|
||||
}
|
||||
|
||||
func TestIdentityFromContext_WrongAuthInfo(t *testing.T) {
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{})
|
||||
_, ok := IdentityFromContext(ctx)
|
||||
assert.False(t, ok, "peer without our AuthInfo must report no identity")
|
||||
}
|
||||
|
||||
func TestIdentityFromContext_Present(t *testing.T) {
|
||||
want := Identity{UID: 1000, GID: 1000, PID: 4242, HasPID: true}
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||
AuthInfo: AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: want,
|
||||
},
|
||||
})
|
||||
|
||||
got, ok := IdentityFromContext(ctx)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestIdentity_String(t *testing.T) {
|
||||
assert.Equal(t, "uid=1000 gid=1000 pid=42", Identity{UID: 1000, GID: 1000, PID: 42, HasPID: true}.String())
|
||||
assert.Equal(t, "uid=1000 gid=1000", Identity{UID: 1000, GID: 1000}.String())
|
||||
assert.Equal(t, "sid=S-1-5-21-1 pid=42", Identity{SID: "S-1-5-21-1", PID: 42, HasPID: true}.String())
|
||||
assert.Equal(t, "sid=S-1-5-21-1", Identity{SID: "S-1-5-21-1"}.String())
|
||||
}
|
||||
|
||||
func TestIdentity_IsWindows(t *testing.T) {
|
||||
assert.True(t, Identity{SID: "S-1-5-18"}.IsWindows())
|
||||
assert.False(t, Identity{UID: 0}.IsWindows())
|
||||
}
|
||||
43
client/internal/ipcauth/peercred_bsd.go
Normal file
43
client/internal/ipcauth/peercred_bsd.go
Normal file
@@ -0,0 +1,43 @@
|
||||
//go:build darwin || freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// PeerIdentity reads the kernel-authenticated identity of the process on the
|
||||
// other end of a Unix socket connection via LOCAL_PEERCRED (xucred). xucred
|
||||
// carries the uid and group list but no pid, so audit on these platforms is
|
||||
// uid/gid-based (HasPID is false); PID via LOCAL_PEERPID is a possible follow-up.
|
||||
func PeerIdentity(c net.Conn) (Identity, error) {
|
||||
uc, ok := c.(*net.UnixConn)
|
||||
if !ok {
|
||||
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", c)
|
||||
}
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("raw conn: %w", err)
|
||||
}
|
||||
|
||||
var cred *unix.Xucred
|
||||
var credErr error
|
||||
if err := raw.Control(func(fd uintptr) {
|
||||
cred, credErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED)
|
||||
}); err != nil {
|
||||
return Identity{}, fmt.Errorf("getsockopt control: %w", err)
|
||||
}
|
||||
if credErr != nil {
|
||||
return Identity{}, fmt.Errorf("LOCAL_PEERCRED: %w", credErr)
|
||||
}
|
||||
|
||||
id := Identity{UID: cred.Uid}
|
||||
// Groups[0] is the effective (primary) GID; guard against an empty list.
|
||||
if cred.Ngroups > 0 {
|
||||
id.GID = cred.Groups[0]
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
43
client/internal/ipcauth/peercred_linux.go
Normal file
43
client/internal/ipcauth/peercred_linux.go
Normal file
@@ -0,0 +1,43 @@
|
||||
//go:build linux
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// PeerIdentity reads the kernel-authenticated identity of the process on the
|
||||
// other end of a Unix socket connection via SO_PEERCRED. The credentials are
|
||||
// captured by the kernel at connect() time and cannot be spoofed or changed for
|
||||
// the life of the connection.
|
||||
func PeerIdentity(c net.Conn) (Identity, error) {
|
||||
uc, ok := c.(*net.UnixConn)
|
||||
if !ok {
|
||||
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", c)
|
||||
}
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("raw conn: %w", err)
|
||||
}
|
||||
|
||||
var cred *unix.Ucred
|
||||
var credErr error
|
||||
if err := raw.Control(func(fd uintptr) {
|
||||
cred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
|
||||
}); err != nil {
|
||||
return Identity{}, fmt.Errorf("getsockopt control: %w", err)
|
||||
}
|
||||
if credErr != nil {
|
||||
return Identity{}, fmt.Errorf("SO_PEERCRED: %w", credErr)
|
||||
}
|
||||
|
||||
return Identity{
|
||||
UID: cred.Uid,
|
||||
GID: cred.Gid,
|
||||
PID: cred.Pid,
|
||||
HasPID: true,
|
||||
}, nil
|
||||
}
|
||||
16
client/internal/ipcauth/peercred_stub.go
Normal file
16
client/internal/ipcauth/peercred_stub.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build !linux && !darwin && !freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// PeerIdentity is unimplemented on platforms without a Unix-socket peer-credential
|
||||
// primitive. Windows derives identity from the named-pipe client token instead
|
||||
// (see the Windows transport credentials), so it never calls this.
|
||||
func PeerIdentity(net.Conn) (Identity, error) {
|
||||
return Identity{}, fmt.Errorf("peer credential check not supported on %s", runtime.GOOS)
|
||||
}
|
||||
113
client/internal/ipcauth/peercred_unix_test.go
Normal file
113
client/internal/ipcauth/peercred_unix_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
//go:build linux || darwin || freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPeerIdentity_MatchesCurrentProcess connects to a real Unix socket and
|
||||
// verifies the extracted UID/GID match the running process (both ends are us).
|
||||
func TestPeerIdentity_MatchesCurrentProcess(t *testing.T) {
|
||||
sock := filepath.Join(t.TempDir(), "peer.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
type result struct {
|
||||
id Identity
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
c, aerr := ln.Accept()
|
||||
if aerr != nil {
|
||||
done <- result{err: aerr}
|
||||
return
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
id, ierr := PeerIdentity(c)
|
||||
done <- result{id: id, err: ierr}
|
||||
}()
|
||||
|
||||
client, err := net.Dial("unix", sock)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
res := <-done
|
||||
require.NoError(t, res.err)
|
||||
assert.Equal(t, uint32(os.Getuid()), res.id.UID, "UID should match current process")
|
||||
assert.Equal(t, uint32(os.Getgid()), res.id.GID, "primary GID should match current process")
|
||||
}
|
||||
|
||||
// TestPeerIdentity_NonUnixConn rejects non-Unix connections (fail closed).
|
||||
func TestPeerIdentity_NonUnixConn(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
c, aerr := ln.Accept()
|
||||
if aerr != nil {
|
||||
done <- aerr
|
||||
return
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
_, ierr := PeerIdentity(c)
|
||||
done <- ierr
|
||||
}()
|
||||
|
||||
client, err := net.Dial("tcp", ln.Addr().String())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
assert.Error(t, <-done, "PeerIdentity must reject a non-Unix connection")
|
||||
}
|
||||
|
||||
// TestUnixCreds_ServerHandshake exercises the transport-credentials path end to end.
|
||||
func TestUnixCreds_ServerHandshake(t *testing.T) {
|
||||
creds := NewTransportCredentials()
|
||||
require.NotNil(t, creds)
|
||||
|
||||
sock := filepath.Join(t.TempDir(), "hs.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
type result struct {
|
||||
info interface{ AuthType() string }
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
c, aerr := ln.Accept()
|
||||
if aerr != nil {
|
||||
done <- result{err: aerr}
|
||||
return
|
||||
}
|
||||
_, ai, herr := creds.ServerHandshake(c)
|
||||
if herr != nil {
|
||||
done <- result{err: herr}
|
||||
return
|
||||
}
|
||||
done <- result{info: ai}
|
||||
}()
|
||||
|
||||
client, err := net.Dial("unix", sock)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
res := <-done
|
||||
require.NoError(t, res.err)
|
||||
ai, ok := res.info.(AuthInfo)
|
||||
require.True(t, ok, "expected ipcauth.AuthInfo, got %T", res.info)
|
||||
assert.Equal(t, uint32(os.Getuid()), ai.Identity.UID)
|
||||
assert.Equal(t, "netbird-ipc-peercred", ai.AuthType())
|
||||
}
|
||||
@@ -813,14 +813,19 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) {
|
||||
}
|
||||
|
||||
// GetSessionExpiresAt returns the most recently recorded SSO session deadline,
|
||||
// or the zero value when no deadline is tracked. A deadline in the past is
|
||||
// returned as-is: it means the session has expired, and consumers (tray row,
|
||||
// CLI status) render it as "expired" rather than hiding it — masking it as
|
||||
// "none" would blank the UI at the exact moment it should say the session
|
||||
// ended.
|
||||
// or the zero value when no deadline is tracked. A deadline that has already
|
||||
// slipped into the past reports as "none": once the session has expired it is
|
||||
// no longer a meaningful countdown, and the sessionwatch.Watcher does not
|
||||
// arm a timer at the deadline itself to clear it (only the two pre-expiry
|
||||
// warnings). Without this guard the UI would keep painting a stale
|
||||
// "expires in …" against a moment that has passed until the next login,
|
||||
// extend, or teardown rewrote the value.
|
||||
func (d *Status) GetSessionExpiresAt() time.Time {
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
if !d.sessionExpiresAt.IsZero() && d.sessionExpiresAt.Before(time.Now()) {
|
||||
return time.Time{}
|
||||
}
|
||||
return d.sessionExpiresAt
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (r *Route) update(ctx context.Context) error {
|
||||
resolved, err := r.resolveDomains(ctx)
|
||||
resolved, err := r.resolveDomains()
|
||||
if err != nil {
|
||||
if len(resolved) == 0 {
|
||||
return fmt.Errorf("resolve domains: %w", err)
|
||||
@@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) {
|
||||
func (r *Route) resolveDomains() (domainMap, error) {
|
||||
results := make(chan resolveResult)
|
||||
go r.resolve(ctx, results)
|
||||
go r.resolve(results)
|
||||
|
||||
resolved := domainMap{}
|
||||
var merr *multierror.Error
|
||||
@@ -217,7 +217,7 @@ func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) {
|
||||
return resolved, nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
func (r *Route) resolve(ctx context.Context, results chan resolveResult) {
|
||||
func (r *Route) resolve(results chan resolveResult) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, d := range r.route.Domains {
|
||||
@@ -225,10 +225,10 @@ func (r *Route) resolve(ctx context.Context, results chan resolveResult) {
|
||||
go func(domain domain.Domain) {
|
||||
defer wg.Done()
|
||||
|
||||
ips, err := r.getIPsFromResolver(ctx, domain)
|
||||
ips, err := r.getIPsFromResolver(domain)
|
||||
if err != nil {
|
||||
log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err)
|
||||
ips, err = lookupHostIPs(ctx, domain)
|
||||
ips, err = net.LookupIP(domain.PunycodeString())
|
||||
if err != nil {
|
||||
results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)}
|
||||
return
|
||||
@@ -364,20 +364,6 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR
|
||||
return
|
||||
}
|
||||
|
||||
// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation.
|
||||
func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) {
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ips := make([]net.IP, 0, len(addrs))
|
||||
for _, addr := range addrs {
|
||||
ips = append(ips, addr.IP)
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix {
|
||||
prefixSet := make(map[netip.Prefix]struct{})
|
||||
for _, prefix := range oldPrefixes {
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
package dynamic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
|
||||
return lookupHostIPs(ctx, domain)
|
||||
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
||||
return net.LookupIP(domain.PunycodeString())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
package dynamic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
@@ -17,7 +16,7 @@ import (
|
||||
|
||||
const dialTimeout = 10 * time.Second
|
||||
|
||||
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
|
||||
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
||||
privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error while creating private client: %s", err)
|
||||
@@ -33,7 +32,7 @@ func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(fqdn, qtype)
|
||||
|
||||
response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String())
|
||||
response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String())
|
||||
if err != nil {
|
||||
if queryErr == nil {
|
||||
queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err)
|
||||
|
||||
29
client/internal/shell/getent_cgo_unix.go
Normal file
29
client/internal/shell/getent_cgo_unix.go
Normal file
@@ -0,0 +1,29 @@
|
||||
//go:build cgo && !osusergo && !windows
|
||||
|
||||
package shell
|
||||
|
||||
import "os/user"
|
||||
|
||||
// LookupWithGetent with CGO delegates directly to os/user.Lookup.
|
||||
// When CGO is enabled, os/user uses libc (getpwnam_r) which goes through
|
||||
// the NSS stack natively. If it fails, the user truly doesn't exist and
|
||||
// getent would also fail.
|
||||
func LookupWithGetent(username string) (*user.User, error) {
|
||||
return user.Lookup(username)
|
||||
}
|
||||
|
||||
// CurrentUserWithGetent with CGO delegates directly to os/user.Current.
|
||||
func CurrentUserWithGetent() (*user.User, error) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
// LookupGroupWithGetent returns the resolved group from either a gid or groupname.
|
||||
func LookupGroupWithGetent(name string) (*user.Group, error) {
|
||||
return user.LookupGroup(name)
|
||||
}
|
||||
|
||||
// GroupIdsWithFallback with CGO delegates directly to user.GroupIds.
|
||||
// libc's getgrouplist handles NSS groups natively.
|
||||
func GroupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
return u.GroupIds()
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build (!cgo || osusergo) && !windows
|
||||
|
||||
package server
|
||||
package shell
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// lookupWithGetent looks up a user by name, falling back to getent if os/user fails.
|
||||
// LookupWithGetent looks up a user by name, falling back to getent if os/user fails.
|
||||
// Without CGO, os/user only reads /etc/passwd and misses NSS-provided users.
|
||||
// getent goes through the host's NSS stack.
|
||||
func lookupWithGetent(username string) (*user.User, error) {
|
||||
func LookupWithGetent(username string) (*user.User, error) {
|
||||
u, err := user.Lookup(username)
|
||||
if err == nil {
|
||||
return u, nil
|
||||
@@ -22,7 +22,7 @@ func lookupWithGetent(username string) (*user.User, error) {
|
||||
stdErr := err
|
||||
log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err)
|
||||
|
||||
u, _, getentErr := runGetent(username)
|
||||
u, _, getentErr := runGetentPasswd(username)
|
||||
if getentErr != nil {
|
||||
log.Debugf("getent fallback for %q also failed: %v", username, getentErr)
|
||||
return nil, stdErr
|
||||
@@ -31,8 +31,26 @@ func lookupWithGetent(username string) (*user.User, error) {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// currentUserWithGetent gets the current user, falling back to getent if os/user fails.
|
||||
func currentUserWithGetent() (*user.User, error) {
|
||||
// LookupGroupWithGetent returns the resolved group from either a gid or groupname,
|
||||
// falling back to getent if os/user fails (NSS groups under nocgo).
|
||||
func LookupGroupWithGetent(name string) (*user.Group, error) {
|
||||
g, err := user.LookupGroup(name)
|
||||
if err == nil {
|
||||
return g, nil
|
||||
}
|
||||
|
||||
stdErr := err
|
||||
log.Debugf("os/user.LookupGroup(%q) failed, trying getent: %v", name, err)
|
||||
g, getentErr := runGetentGroup(name)
|
||||
if getentErr != nil {
|
||||
log.Debugf("getent fallback for %q also failed: %v", name, getentErr)
|
||||
return nil, stdErr
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// CurrentUserWithGetent gets the current user, falling back to getent if os/user fails.
|
||||
func CurrentUserWithGetent() (*user.User, error) {
|
||||
u, err := user.Current()
|
||||
if err == nil {
|
||||
return u, nil
|
||||
@@ -42,7 +60,7 @@ func currentUserWithGetent() (*user.User, error) {
|
||||
uid := strconv.Itoa(os.Getuid())
|
||||
log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err)
|
||||
|
||||
u, _, getentErr := runGetent(uid)
|
||||
u, _, getentErr := runGetentPasswd(uid)
|
||||
if getentErr != nil {
|
||||
return nil, stdErr
|
||||
}
|
||||
@@ -50,14 +68,14 @@ func currentUserWithGetent() (*user.User, error) {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// groupIdsWithFallback gets group IDs for a user via the id command first,
|
||||
// GroupIdsWithFallback gets group IDs for a user via the id command first,
|
||||
// falling back to user.GroupIds().
|
||||
// NOTE: unlike lookupWithGetent/currentUserWithGetent which try stdlib first,
|
||||
// NOTE: unlike LookupWithGetent/CurrentUserWithGetent which try stdlib first,
|
||||
// this intentionally tries `id -G` first because without CGO, user.GroupIds()
|
||||
// only reads /etc/group and silently returns incomplete results for NSS users
|
||||
// (no error, just missing groups). The id command goes through NSS and returns
|
||||
// the full set.
|
||||
func groupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
func GroupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
ids, err := runIdGroups(u.Username)
|
||||
if err == nil {
|
||||
return ids, nil
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package shell
|
||||
|
||||
import (
|
||||
"os/user"
|
||||
@@ -15,7 +15,7 @@ func TestLookupWithGetent_CurrentUser(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := lookupWithGetent(current.Username)
|
||||
u, err := LookupWithGetent(current.Username)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, current.Username, u.Username)
|
||||
assert.Equal(t, current.Uid, u.Uid)
|
||||
@@ -23,7 +23,7 @@ func TestLookupWithGetent_CurrentUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLookupWithGetent_NonexistentUser(t *testing.T) {
|
||||
_, err := lookupWithGetent("nonexistent_user_xyzzy_12345")
|
||||
_, err := LookupWithGetent("nonexistent_user_xyzzy_12345")
|
||||
require.Error(t, err, "should fail for nonexistent user")
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestCurrentUserWithGetent(t *testing.T) {
|
||||
stdUser, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := currentUserWithGetent()
|
||||
u, err := CurrentUserWithGetent()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, stdUser.Uid, u.Uid)
|
||||
assert.Equal(t, stdUser.Username, u.Username)
|
||||
@@ -41,7 +41,7 @@ func TestGroupIdsWithFallback_CurrentUser(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
groups, err := groupIdsWithFallback(current)
|
||||
groups, err := GroupIdsWithFallback(current)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "current user should have at least one group")
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestGroupIdsWithFallback_CurrentUser(t *testing.T) {
|
||||
func TestGetShellFromGetent_CurrentUser(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows stub always returns empty, which is correct
|
||||
shell := getShellFromGetent("1000")
|
||||
shell := GetShellFromGetent("1000")
|
||||
assert.Empty(t, shell, "Windows stub should return empty")
|
||||
return
|
||||
}
|
||||
@@ -65,9 +65,9 @@ func TestGetShellFromGetent_CurrentUser(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// getent may not be available on all systems (e.g., macOS without Homebrew getent)
|
||||
shell := getShellFromGetent(current.Uid)
|
||||
shell := GetShellFromGetent(current.Uid)
|
||||
if shell == "" {
|
||||
t.Log("getShellFromGetent returned empty, getent may not be available")
|
||||
t.Log("GetShellFromGetent returned empty, getent may not be available")
|
||||
return
|
||||
}
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
@@ -78,7 +78,7 @@ func TestLookupWithGetent_RootUser(t *testing.T) {
|
||||
t.Skip("no root user on Windows")
|
||||
}
|
||||
|
||||
u, err := lookupWithGetent("root")
|
||||
u, err := LookupWithGetent("root")
|
||||
if err != nil {
|
||||
t.Skip("root user not available on this system")
|
||||
}
|
||||
@@ -86,25 +86,25 @@ func TestLookupWithGetent_RootUser(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestIntegration_FullLookupChain exercises the complete user lookup chain
|
||||
// against the real system, testing that all wrappers (lookupWithGetent,
|
||||
// currentUserWithGetent, groupIdsWithFallback, getShellFromGetent) produce
|
||||
// against the real system, testing that all wrappers (LookupWithGetent,
|
||||
// CurrentUserWithGetent, GroupIdsWithFallback, GetShellFromGetent) produce
|
||||
// consistent and correct results when composed together.
|
||||
func TestIntegration_FullLookupChain(t *testing.T) {
|
||||
// Step 1: currentUserWithGetent must resolve the running user.
|
||||
current, err := currentUserWithGetent()
|
||||
require.NoError(t, err, "currentUserWithGetent must resolve the running user")
|
||||
// Step 1: CurrentUserWithGetent must resolve the running user.
|
||||
current, err := CurrentUserWithGetent()
|
||||
require.NoError(t, err, "CurrentUserWithGetent must resolve the running user")
|
||||
require.NotEmpty(t, current.Uid)
|
||||
require.NotEmpty(t, current.Username)
|
||||
|
||||
// Step 2: lookupWithGetent by the same username must return matching identity.
|
||||
byName, err := lookupWithGetent(current.Username)
|
||||
// Step 2: LookupWithGetent by the same username must return matching identity.
|
||||
byName, err := LookupWithGetent(current.Username)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, current.Uid, byName.Uid, "lookup by name should return same UID")
|
||||
assert.Equal(t, current.Gid, byName.Gid, "lookup by name should return same GID")
|
||||
assert.Equal(t, current.HomeDir, byName.HomeDir, "lookup by name should return same home")
|
||||
|
||||
// Step 3: groupIdsWithFallback must return at least the primary GID.
|
||||
groups, err := groupIdsWithFallback(current)
|
||||
// Step 3: GroupIdsWithFallback must return at least the primary GID.
|
||||
groups, err := GroupIdsWithFallback(current)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "user must have at least one group")
|
||||
|
||||
@@ -120,10 +120,10 @@ func TestIntegration_FullLookupChain(t *testing.T) {
|
||||
}
|
||||
assert.True(t, foundPrimary, "primary GID %s should appear in supplementary groups", current.Gid)
|
||||
|
||||
// Step 4: getShellFromGetent should either return a valid shell path or empty
|
||||
// Step 4: GetShellFromGetent should either return a valid shell path or empty
|
||||
// (empty is OK when getent is not available, e.g. macOS without Homebrew getent).
|
||||
if runtime.GOOS != "windows" {
|
||||
shell := getShellFromGetent(current.Uid)
|
||||
shell := GetShellFromGetent(current.Uid)
|
||||
if shell != "" {
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
}
|
||||
@@ -131,17 +131,17 @@ func TestIntegration_FullLookupChain(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestIntegration_LookupAndGroupsConsistency verifies that a user resolved via
|
||||
// lookupWithGetent can have their groups resolved via groupIdsWithFallback,
|
||||
// LookupWithGetent can have their groups resolved via GroupIdsWithFallback,
|
||||
// testing the handoff between the two functions as used by the SSH server.
|
||||
func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate the SSH server flow: lookup user, then get their groups.
|
||||
resolved, err := lookupWithGetent(current.Username)
|
||||
resolved, err := LookupWithGetent(current.Username)
|
||||
require.NoError(t, err)
|
||||
|
||||
groups, err := groupIdsWithFallback(resolved)
|
||||
groups, err := GroupIdsWithFallback(resolved)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "resolved user must have groups")
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestIntegration_ShellLookupChain tests the full shell resolution chain
|
||||
// (getShellFromPasswd -> getShellFromGetent -> $SHELL -> default) on Unix.
|
||||
// (getShellFromPasswd -> GetShellFromGetent -> $SHELL -> default) on Unix.
|
||||
func TestIntegration_ShellLookupChain(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix shell lookup not applicable on Windows")
|
||||
@@ -165,8 +165,8 @@ func TestIntegration_ShellLookupChain(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
// getUserShell is the top-level function used by the SSH server.
|
||||
shell := getUserShell(current.Uid)
|
||||
require.NotEmpty(t, shell, "getUserShell must always return a shell")
|
||||
// GetUserShell is the top-level function used by the SSH server.
|
||||
shell := GetUserShell(current.Uid)
|
||||
require.NotEmpty(t, shell, "GetUserShell must always return a shell")
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
package shell
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -14,19 +14,26 @@ import (
|
||||
|
||||
const getentTimeout = 5 * time.Second
|
||||
|
||||
// getShellFromGetent gets a user's login shell via getent by UID.
|
||||
// GetShellFromGetent gets a user's login shell via getent by UID.
|
||||
// This is needed even with CGO because getShellFromPasswd reads /etc/passwd
|
||||
// directly and won't find NSS-provided users there.
|
||||
func getShellFromGetent(userID string) string {
|
||||
_, shell, err := runGetent(userID)
|
||||
func GetShellFromGetent(userID string) string {
|
||||
_, shell, err := runGetentPasswd(userID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return shell
|
||||
}
|
||||
|
||||
// runGetent executes `getent passwd <query>` and returns the user and login shell.
|
||||
func runGetent(query string) (*user.User, string, error) {
|
||||
// GetUserFromGetent returns the resolved user from either a uid or username,
|
||||
// going through the host's NSS stack.
|
||||
func GetUserFromGetent(query string) (*user.User, error) {
|
||||
u, _, err := runGetentPasswd(query)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// runGetentPasswd executes `getent passwd <query>` and returns the user and login shell.
|
||||
func runGetentPasswd(query string) (*user.User, string, error) {
|
||||
if !validateGetentInput(query) {
|
||||
return nil, "", fmt.Errorf("invalid getent input: %q", query)
|
||||
}
|
||||
@@ -42,7 +49,24 @@ func runGetent(query string) (*user.User, string, error) {
|
||||
return parseGetentPasswd(string(out))
|
||||
}
|
||||
|
||||
// parseGetentPasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell"
|
||||
// runGetentGroup executes `getent group <query>` and returns the group.
|
||||
func runGetentGroup(query string) (*user.Group, error) {
|
||||
if !validateGetentInput(query) {
|
||||
return nil, fmt.Errorf("invalid getent input: %q", query)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), getentTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, "getent", "group", query).Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getent group %s: %w", query, err)
|
||||
}
|
||||
|
||||
return parseGetentGroup(string(out))
|
||||
}
|
||||
|
||||
// parseGetentPasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell".
|
||||
func parseGetentPasswd(output string) (*user.User, string, error) {
|
||||
fields := strings.SplitN(strings.TrimSpace(output), ":", 8)
|
||||
if len(fields) < 6 {
|
||||
@@ -67,6 +91,20 @@ func parseGetentPasswd(output string) (*user.User, string, error) {
|
||||
}, shell, nil
|
||||
}
|
||||
|
||||
// parseGetentGroup parses getent group output: "group:x:gid:members".
|
||||
func parseGetentGroup(output string) (*user.Group, error) {
|
||||
fields := strings.SplitN(strings.TrimSpace(output), ":", 8)
|
||||
if len(fields) < 4 {
|
||||
return nil, fmt.Errorf("unexpected getent output (need 4+ fields): %q", output)
|
||||
}
|
||||
|
||||
if fields[0] == "" || fields[2] == "" {
|
||||
return nil, fmt.Errorf("missing required fields in getent output: %q", output)
|
||||
}
|
||||
|
||||
return &user.Group{Gid: fields[2], Name: fields[0]}, nil
|
||||
}
|
||||
|
||||
// validateGetentInput checks that the input is safe to pass to getent or id.
|
||||
// Allows POSIX usernames, numeric UIDs, and common NSS extensions
|
||||
// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
package shell
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
@@ -198,7 +198,7 @@ func TestRunGetent_RootUser(t *testing.T) {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
u, shell, err := runGetent("root")
|
||||
u, shell, err := runGetentPasswd("root")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "root", u.Username)
|
||||
assert.Equal(t, "0", u.Uid)
|
||||
@@ -211,7 +211,7 @@ func TestRunGetent_ByUID(t *testing.T) {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
u, _, err := runGetent("0")
|
||||
u, _, err := runGetentPasswd("0")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "root", u.Username)
|
||||
assert.Equal(t, "0", u.Uid)
|
||||
@@ -222,15 +222,15 @@ func TestRunGetent_NonexistentUser(t *testing.T) {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
_, _, err := runGetent("nonexistent_user_xyzzy_12345")
|
||||
_, _, err := runGetentPasswd("nonexistent_user_xyzzy_12345")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRunGetent_InvalidInput(t *testing.T) {
|
||||
_, _, err := runGetent("")
|
||||
_, _, err := runGetentPasswd("")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, _, err = runGetent("user\x00name")
|
||||
_, _, err = runGetentPasswd("user\x00name")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestRunGetent_NotAvailable(t *testing.T) {
|
||||
t.Skip("getent is available, can't test missing case")
|
||||
}
|
||||
|
||||
_, _, err := runGetent("root")
|
||||
_, _, err := runGetentPasswd("root")
|
||||
assert.Error(t, err, "should fail when getent is not installed")
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ func TestGetentResultsMatchStdlib(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
getentUser, _, err := runGetent(current.Username)
|
||||
getentUser, _, err := runGetentPasswd(current.Username)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, current.Username, getentUser.Username, "username should match")
|
||||
@@ -303,7 +303,7 @@ func TestGetentResultsMatchStdlib_ByUID(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
getentUser, _, err := runGetent(current.Uid)
|
||||
getentUser, _, err := runGetentPasswd(current.Uid)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID")
|
||||
@@ -359,7 +359,7 @@ func TestGetShellFromPasswd_CurrentUser(t *testing.T) {
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
|
||||
if _, err := exec.LookPath("getent"); err == nil {
|
||||
_, getentShell, getentErr := runGetent(current.Uid)
|
||||
_, getentShell, getentErr := runGetentPasswd(current.Uid)
|
||||
if getentErr == nil && getentShell != "" {
|
||||
assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent")
|
||||
}
|
||||
@@ -403,7 +403,7 @@ func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
_, getentShell, err := runGetent(uid)
|
||||
_, getentShell, err := runGetentPasswd(uid)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
31
client/internal/shell/getent_windows.go
Normal file
31
client/internal/shell/getent_windows.go
Normal file
@@ -0,0 +1,31 @@
|
||||
//go:build windows
|
||||
|
||||
package shell
|
||||
|
||||
import "os/user"
|
||||
|
||||
// LookupWithGetent on Windows just delegates to os/user.Lookup.
|
||||
// Windows does not use NSS/getent; its user lookup works without CGO.
|
||||
func LookupWithGetent(username string) (*user.User, error) {
|
||||
return user.Lookup(username)
|
||||
}
|
||||
|
||||
// CurrentUserWithGetent on Windows just delegates to os/user.Current.
|
||||
func CurrentUserWithGetent() (*user.User, error) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
// LookupGroupWithGetent on Windows just delegates to os/user.LookupGroup.
|
||||
func LookupGroupWithGetent(name string) (*user.Group, error) {
|
||||
return user.LookupGroup(name)
|
||||
}
|
||||
|
||||
// GetShellFromGetent is a no-op on Windows; shell resolution uses PowerShell detection.
|
||||
func GetShellFromGetent(_ string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GroupIdsWithFallback on Windows just delegates to u.GroupIds().
|
||||
func GroupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
return u.GroupIds()
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
package server
|
||||
package shell
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -22,9 +19,9 @@ const (
|
||||
powershellExe = "powershell.exe"
|
||||
)
|
||||
|
||||
// getUserShell returns the appropriate shell for the given user ID
|
||||
// Handles all platform-specific logic and fallbacks consistently
|
||||
func getUserShell(userID string) string {
|
||||
// GetUserShell returns the appropriate shell for the given user ID.
|
||||
// Handles all platform-specific logic and fallbacks consistently.
|
||||
func GetUserShell(userID string) string {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return getWindowsUserShell()
|
||||
@@ -56,7 +53,7 @@ func getUnixUserShell(userID string) string {
|
||||
return shell
|
||||
}
|
||||
|
||||
if shell := getShellFromGetent(userID); shell != "" {
|
||||
if shell := GetShellFromGetent(userID); shell != "" {
|
||||
return shell
|
||||
}
|
||||
|
||||
@@ -67,7 +64,7 @@ func getUnixUserShell(userID string) string {
|
||||
return defaultUnixShell
|
||||
}
|
||||
|
||||
// getShellFromPasswd reads the shell from /etc/passwd for the given user ID
|
||||
// getShellFromPasswd reads the shell from /etc/passwd for the given user ID.
|
||||
func getShellFromPasswd(userID string) string {
|
||||
file, err := os.Open("/etc/passwd")
|
||||
if err != nil {
|
||||
@@ -101,8 +98,8 @@ func getShellFromPasswd(userID string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// prepareUserEnv prepares environment variables for user execution
|
||||
func prepareUserEnv(user *user.User, shell string) []string {
|
||||
// PrepareUserEnv prepares environment variables for user execution.
|
||||
func PrepareUserEnv(user *user.User, shell string) []string {
|
||||
pathValue := "/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games"
|
||||
if runtime.GOOS == "windows" {
|
||||
pathValue = `C:\Windows\System32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0`
|
||||
@@ -117,9 +114,9 @@ func prepareUserEnv(user *user.User, shell string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// acceptEnv checks if environment variable from SSH client should be accepted
|
||||
// This is a whitelist of variables that SSH clients can send to the server
|
||||
func acceptEnv(envVar string) bool {
|
||||
// AcceptEnv checks if an environment variable from an SSH client should be accepted.
|
||||
// This is a whitelist of variables that SSH clients can send to the server.
|
||||
func AcceptEnv(envVar string) bool {
|
||||
varName := envVar
|
||||
if idx := strings.Index(envVar, "="); idx != -1 {
|
||||
varName = envVar[:idx]
|
||||
@@ -156,29 +153,3 @@ func acceptEnv(envVar string) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// prepareSSHEnv prepares SSH protocol-specific environment variables
|
||||
// These variables provide information about the SSH connection itself
|
||||
func prepareSSHEnv(session ssh.Session) []string {
|
||||
remoteAddr := session.RemoteAddr()
|
||||
localAddr := session.LocalAddr()
|
||||
|
||||
remoteHost, remotePort, err := net.SplitHostPort(remoteAddr.String())
|
||||
if err != nil {
|
||||
remoteHost = remoteAddr.String()
|
||||
remotePort = "0"
|
||||
}
|
||||
|
||||
localHost, localPort, err := net.SplitHostPort(localAddr.String())
|
||||
if err != nil {
|
||||
localHost = localAddr.String()
|
||||
localPort = strconv.Itoa(InternalSSHPort)
|
||||
}
|
||||
|
||||
return []string{
|
||||
// SSH_CLIENT format: "client_ip client_port server_port"
|
||||
fmt.Sprintf("SSH_CLIENT=%s %s %s", remoteHost, remotePort, localPort),
|
||||
// SSH_CONNECTION format: "client_ip client_port server_ip server_port"
|
||||
fmt.Sprintf("SSH_CONNECTION=%s %s %s %s", remoteHost, remotePort, localHost, localPort),
|
||||
}
|
||||
}
|
||||
@@ -233,9 +233,6 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
|
||||
deps.SyncResponse = resp
|
||||
|
||||
if e := cc.Engine(); e != nil {
|
||||
deps.RefreshStatus = func() {
|
||||
e.RunHealthProbes(context.Background(), true)
|
||||
}
|
||||
if cm := e.GetClientMetrics(); cm != nil {
|
||||
deps.ClientMetrics = cm
|
||||
}
|
||||
|
||||
@@ -1081,10 +1081,7 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
|
||||
|
||||
if err := s.cleanupConnection(); err != nil {
|
||||
s.mutex.Unlock()
|
||||
if errors.Is(err, ErrServiceNotUp) {
|
||||
log.Debugf("Down called while service not up: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
// todo review to update the status in case any type of error
|
||||
log.Errorf("failed to shut down properly: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
@@ -1157,7 +1154,7 @@ func (s *Server) cleanupConnection() error {
|
||||
// making the run loop the sole owner of engine shutdown.
|
||||
if engine != nil {
|
||||
if err := engine.Stop(); err != nil {
|
||||
log.Errorf("failed to stop engine during cleanup: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"github.com/creack/pty"
|
||||
"github.com/gliderlabs/ssh"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
shellutil "github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
// ptyManager manages Pty file operations with thread safety
|
||||
@@ -146,10 +148,10 @@ func (s *Server) createShellCommand(ctx context.Context, shell string, args []st
|
||||
|
||||
// prepareCommandEnv prepares environment variables for command execution on Unix
|
||||
func (s *Server) prepareCommandEnv(_ *log.Entry, localUser *user.User, session ssh.Session) []string {
|
||||
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
|
||||
env := shellutil.PrepareUserEnv(localUser, shellutil.GetUserShell(localUser.Uid))
|
||||
env = append(env, prepareSSHEnv(session)...)
|
||||
for _, v := range session.Environ() {
|
||||
if acceptEnv(v) {
|
||||
if shellutil.AcceptEnv(v) {
|
||||
env = append(env, v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
shellutil "github.com/netbirdio/netbird/client/internal/shell"
|
||||
"github.com/netbirdio/netbird/client/ssh/server/winpty"
|
||||
)
|
||||
|
||||
@@ -247,10 +248,10 @@ func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, sess
|
||||
userEnv, err := s.getUserEnvironment(logger, username, domain)
|
||||
if err != nil {
|
||||
log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err)
|
||||
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
|
||||
env := shellutil.PrepareUserEnv(localUser, shellutil.GetUserShell(localUser.Uid))
|
||||
env = append(env, prepareSSHEnv(session)...)
|
||||
for _, v := range session.Environ() {
|
||||
if acceptEnv(v) {
|
||||
if shellutil.AcceptEnv(v) {
|
||||
env = append(env, v)
|
||||
}
|
||||
}
|
||||
@@ -260,7 +261,7 @@ func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, sess
|
||||
env := userEnv
|
||||
env = append(env, prepareSSHEnv(session)...)
|
||||
for _, v := range session.Environ() {
|
||||
if acceptEnv(v) {
|
||||
if shellutil.AcceptEnv(v) {
|
||||
env = append(env, v)
|
||||
}
|
||||
}
|
||||
@@ -273,7 +274,7 @@ func (s *Server) handlePtyLogin(logger *log.Entry, session ssh.Session, privileg
|
||||
return false
|
||||
}
|
||||
|
||||
shell := getUserShell(privilegeResult.User.Uid)
|
||||
shell := shellutil.GetUserShell(privilegeResult.User.Uid)
|
||||
logger.Infof("starting interactive shell: %s", shell)
|
||||
|
||||
s.executeCommandWithPty(logger, session, nil, privilegeResult, ptyReq, nil)
|
||||
@@ -384,7 +385,7 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _
|
||||
}
|
||||
|
||||
username, domain := s.parseUsername(localUser.Username)
|
||||
shell := getUserShell(localUser.Uid)
|
||||
shell := shellutil.GetUserShell(localUser.Uid)
|
||||
|
||||
req := PtyExecutionRequest{
|
||||
Shell: shell,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
//go:build cgo && !osusergo && !windows
|
||||
|
||||
package server
|
||||
|
||||
import "os/user"
|
||||
|
||||
// lookupWithGetent with CGO delegates directly to os/user.Lookup.
|
||||
// When CGO is enabled, os/user uses libc (getpwnam_r) which goes through
|
||||
// the NSS stack natively. If it fails, the user truly doesn't exist and
|
||||
// getent would also fail.
|
||||
func lookupWithGetent(username string) (*user.User, error) {
|
||||
return user.Lookup(username)
|
||||
}
|
||||
|
||||
// currentUserWithGetent with CGO delegates directly to os/user.Current.
|
||||
func currentUserWithGetent() (*user.User, error) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
// groupIdsWithFallback with CGO delegates directly to user.GroupIds.
|
||||
// libc's getgrouplist handles NSS groups natively.
|
||||
func groupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
return u.GroupIds()
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import "os/user"
|
||||
|
||||
// lookupWithGetent on Windows just delegates to os/user.Lookup.
|
||||
// Windows does not use NSS/getent; its user lookup works without CGO.
|
||||
func lookupWithGetent(username string) (*user.User, error) {
|
||||
return user.Lookup(username)
|
||||
}
|
||||
|
||||
// currentUserWithGetent on Windows just delegates to os/user.Current.
|
||||
func currentUserWithGetent() (*user.User, error) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
// getShellFromGetent is a no-op on Windows; shell resolution uses PowerShell detection.
|
||||
func getShellFromGetent(_ string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// groupIdsWithFallback on Windows just delegates to u.GroupIds().
|
||||
func groupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
return u.GroupIds()
|
||||
}
|
||||
35
client/ssh/server/ssh_env.go
Normal file
35
client/ssh/server/ssh_env.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
)
|
||||
|
||||
// prepareSSHEnv prepares SSH protocol-specific environment variables
|
||||
// These variables provide information about the SSH connection itself
|
||||
func prepareSSHEnv(session ssh.Session) []string {
|
||||
remoteAddr := session.RemoteAddr()
|
||||
localAddr := session.LocalAddr()
|
||||
|
||||
remoteHost, remotePort, err := net.SplitHostPort(remoteAddr.String())
|
||||
if err != nil {
|
||||
remoteHost = remoteAddr.String()
|
||||
remotePort = "0"
|
||||
}
|
||||
|
||||
localHost, localPort, err := net.SplitHostPort(localAddr.String())
|
||||
if err != nil {
|
||||
localHost = localAddr.String()
|
||||
localPort = strconv.Itoa(InternalSSHPort)
|
||||
}
|
||||
|
||||
return []string{
|
||||
// SSH_CLIENT format: "client_ip client_port server_port"
|
||||
fmt.Sprintf("SSH_CLIENT=%s %s %s", remoteHost, remotePort, localPort),
|
||||
// SSH_CONNECTION format: "client_ip client_port server_ip server_port"
|
||||
fmt.Sprintf("SSH_CONNECTION=%s %s %s %s", remoteHost, remotePort, localHost, localPort),
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
shellutil "github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -23,8 +25,8 @@ func isPlatformUnix() bool {
|
||||
|
||||
// Dependency injection variables for testing - allows mocking dynamic runtime checks
|
||||
var (
|
||||
getCurrentUser = currentUserWithGetent
|
||||
lookupUser = lookupWithGetent
|
||||
getCurrentUser = shellutil.CurrentUserWithGetent
|
||||
lookupUser = shellutil.LookupWithGetent
|
||||
getCurrentOS = func() string { return runtime.GOOS }
|
||||
getIsProcessPrivileged = isCurrentProcessPrivileged
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
shellutil "github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
// POSIX portable filename character set regex: [a-zA-Z0-9._-]
|
||||
@@ -160,7 +162,7 @@ func (s *Server) parseUserCredentials(localUser *user.User) (uint32, uint32, []u
|
||||
// getSupplementaryGroups retrieves supplementary group IDs for a user.
|
||||
// Uses id/getent fallback for NSS users in CGO_ENABLED=0 builds.
|
||||
func (s *Server) getSupplementaryGroups(u *user.User) ([]uint32, error) {
|
||||
groupIDStrings, err := groupIdsWithFallback(u)
|
||||
groupIDStrings, err := shellutil.GroupIdsWithFallback(u)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err)
|
||||
}
|
||||
@@ -196,7 +198,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l
|
||||
GID: gid,
|
||||
Groups: groups,
|
||||
WorkingDir: localUser.HomeDir,
|
||||
Shell: getUserShell(localUser.Uid),
|
||||
Shell: shellutil.GetUserShell(localUser.Uid),
|
||||
Command: session.RawCommand(),
|
||||
PTY: hasPty,
|
||||
}
|
||||
@@ -228,7 +230,7 @@ func (s *Server) createPtyCommand(privilegeResult PrivilegeCheckResult, ptyReq s
|
||||
func (s *Server) createDirectPtyCommand(session ssh.Session, localUser *user.User, ptyReq ssh.Pty) *exec.Cmd {
|
||||
log.Debugf("creating direct Pty command for user %s (no user switching needed)", localUser.Username)
|
||||
|
||||
shell := getUserShell(localUser.Uid)
|
||||
shell := shellutil.GetUserShell(localUser.Uid)
|
||||
args := s.getShellCommandArgs(shell, session.RawCommand())
|
||||
|
||||
cmd := s.createShellCommand(session.Context(), shell, args)
|
||||
@@ -245,12 +247,12 @@ func (s *Server) preparePtyEnv(localUser *user.User, ptyReq ssh.Pty, session ssh
|
||||
termType = "xterm-256color"
|
||||
}
|
||||
|
||||
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
|
||||
env := shellutil.PrepareUserEnv(localUser, shellutil.GetUserShell(localUser.Uid))
|
||||
env = append(env, prepareSSHEnv(session)...)
|
||||
env = append(env, fmt.Sprintf("TERM=%s", termType))
|
||||
|
||||
for _, v := range session.Environ() {
|
||||
if acceptEnv(v) {
|
||||
if shellutil.AcceptEnv(v) {
|
||||
env = append(env, v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"github.com/gliderlabs/ssh"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
shellutil "github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
// validateUsername validates Windows usernames according to SAM Account Name rules
|
||||
@@ -104,7 +106,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l
|
||||
func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, localUser *user.User) (*exec.Cmd, func(), error) {
|
||||
username, domain := s.parseUsername(localUser.Username)
|
||||
|
||||
shell := getUserShell(localUser.Uid)
|
||||
shell := shellutil.GetUserShell(localUser.Uid)
|
||||
|
||||
rawCmd := session.RawCommand()
|
||||
var command string
|
||||
|
||||
@@ -51,7 +51,7 @@ func autostartDisabledByMDM(policy *mdm.Policy) bool {
|
||||
// netbirdFootprintExists reports whether the machine already carries NetBird
|
||||
// daemon config or state, meaning this is not a genuinely fresh install. It is
|
||||
// the update-safety gate for the autostart default: upgrading users always
|
||||
// have a footprint, so an update can never trigger a autostart entry write.
|
||||
// have a footprint, so an update can never trigger a login-item write.
|
||||
func netbirdFootprintExists() bool {
|
||||
candidates := []string{
|
||||
profilemanager.DefaultConfigPath,
|
||||
@@ -69,23 +69,9 @@ func netbirdFootprintExists() bool {
|
||||
// applyAutostartDefault runs the one-time launch-on-login default for genuinely
|
||||
// fresh installs. The autostartInitialized marker is persisted before any
|
||||
// enable attempt so a crash mid-flow degrades to "never enabled" instead of
|
||||
// retrying autostart entry writes on every launch. A user's later disable in
|
||||
// retrying login-item writes on every launch. A user's later disable in
|
||||
// Settings is never overridden: the marker guarantees at-most-once, ever.
|
||||
func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) {
|
||||
mdmDisabled := autostartDisabledByMDM(mdm.LoadPolicy())
|
||||
|
||||
if mdmDisabled {
|
||||
if enabled, err := autostart.IsEnabled(ctx); err != nil {
|
||||
log.Warnf("MDM disableAutostart: read autostart state: %v", err)
|
||||
} else if enabled {
|
||||
if err := autostart.SetEnabled(ctx, false); err != nil {
|
||||
log.Warnf("MDM disableAutostart: force off failed: %v", err)
|
||||
} else {
|
||||
log.Info("MDM disableAutostart enforced: autostart turned off")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
priorFootprint := netbirdFootprintExists() || prefsFileExisted
|
||||
|
||||
if prefs.Get().AutostartInitialized {
|
||||
@@ -98,7 +84,7 @@ func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, p
|
||||
|
||||
state := autostartDefaultState{
|
||||
supported: autostart.Supported(ctx),
|
||||
mdmDisabled: mdmDisabled,
|
||||
mdmDisabled: autostartDisabledByMDM(mdm.LoadPolicy()),
|
||||
priorInstall: priorFootprint,
|
||||
}
|
||||
enable, reason := shouldEnableAutostartDefault(state)
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangleIcon, DownloadIcon } from "lucide-react";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { Version } from "@bindings/services";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
|
||||
const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest";
|
||||
const RC_RELEASES_URL = "https://pkgs.netbird.io/releases/rc";
|
||||
|
||||
function openUrl(url: string) {
|
||||
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
|
||||
@@ -15,26 +12,7 @@ function openUrl(url: string) {
|
||||
|
||||
export const DaemonOutdatedOverlay = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status, isDaemonOutdated } = useStatus();
|
||||
|
||||
const [guiVersion, setGuiVersion] = useState<string>("-");
|
||||
const clientVersion = status?.daemonVersion ?? "—";
|
||||
|
||||
const isRc = /-rc/i.test(guiVersion) || /-rc/i.test(clientVersion);
|
||||
const downloadUrl = isRc ? RC_RELEASES_URL : RELEASES_URL;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDaemonOutdated) return;
|
||||
let cancelled = false;
|
||||
Version.GUI()
|
||||
.then((v) => {
|
||||
if (!cancelled) setGuiVersion(v);
|
||||
})
|
||||
.catch((err) => console.error("[DaemonOutdatedOverlay] GUI version error", err));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isDaemonOutdated]);
|
||||
const { isDaemonOutdated } = useStatus();
|
||||
|
||||
if (!isDaemonOutdated) return null;
|
||||
|
||||
@@ -60,37 +38,10 @@ export const DaemonOutdatedOverlay = () => {
|
||||
<p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col items-center gap-0.5 text-center"}>
|
||||
<p className={"text-sm font-semibold text-nb-gray-100"}>
|
||||
{clientVersion === "development" ? (
|
||||
<span>
|
||||
{t("settings.about.clientName")}{" "}
|
||||
<span className={"font-mono text-yellow-400"}>
|
||||
{t("settings.about.development")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
t("settings.about.client", { version: clientVersion })
|
||||
)}
|
||||
</p>
|
||||
<p className={"text-sm font-medium text-nb-gray-250"}>
|
||||
{guiVersion === "development" ? (
|
||||
<span>
|
||||
{t("settings.about.guiName")}{" "}
|
||||
<span className={"font-mono text-yellow-400"}>
|
||||
{t("settings.about.development")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
t("settings.about.gui", { version: guiVersion })
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={"wails-no-draggable"}>
|
||||
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(downloadUrl)}>
|
||||
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(RELEASES_URL)}>
|
||||
<DownloadIcon size={14} />
|
||||
{t("daemon.outdated.download")}
|
||||
{t("update.card.getInstaller")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,6 @@ type ProfileContextValue = {
|
||||
loaded: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
switchProfile: (id: string) => Promise<void>;
|
||||
switchProfileNoConnect: (id: string) => Promise<void>;
|
||||
addProfile: (name: string) => Promise<string>;
|
||||
removeProfile: (id: string) => Promise<void>;
|
||||
renameProfile: (id: string, newName: string) => Promise<void>;
|
||||
@@ -113,16 +112,6 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
// Manage-profiles variant: switches without connecting, so the user can
|
||||
// still adjust the management URL before bringing the connection up.
|
||||
const switchProfileNoConnect = useCallback(
|
||||
async (id: string) => {
|
||||
await ProfileSwitcher.SwitchActiveNoConnect({ profileName: id, username });
|
||||
await refresh();
|
||||
},
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
// addProfile creates a profile by display name and returns the
|
||||
// daemon-generated ID, so the caller can immediately address it by ID.
|
||||
const addProfile = useCallback(
|
||||
@@ -169,7 +158,6 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
loaded,
|
||||
refresh,
|
||||
switchProfile,
|
||||
switchProfileNoConnect,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
@@ -183,7 +171,6 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
loaded,
|
||||
refresh,
|
||||
switchProfile,
|
||||
switchProfileNoConnect,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
|
||||
@@ -45,7 +45,7 @@ export function ProfilesTab() {
|
||||
activeProfileId,
|
||||
loaded,
|
||||
username,
|
||||
switchProfileNoConnect,
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
@@ -100,7 +100,7 @@ export function ProfilesTab() {
|
||||
confirmLabel: t("profile.switch.confirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfileNoConnect(id));
|
||||
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id));
|
||||
};
|
||||
|
||||
const handleDeregister = async (id: string, name: string) => {
|
||||
@@ -129,13 +129,14 @@ export function ProfilesTab() {
|
||||
await guarded(i18next.t("profile.error.createTitle"), async () => {
|
||||
const id = await addProfile(name);
|
||||
// SetConfig is keyed by the new profile's ID, so it writes the
|
||||
// not-yet-active profile before the switch makes it current.
|
||||
// not-yet-active profile. Write before switching so any reconnect
|
||||
// targets the right deployment.
|
||||
if (!isNetbirdCloud(managementUrl)) {
|
||||
await SettingsSvc.SetConfig(
|
||||
new SetConfigParams({ profileName: id, username, managementUrl }),
|
||||
);
|
||||
}
|
||||
await switchProfileNoConnect(id);
|
||||
await switchProfile(id);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -73,13 +73,6 @@ export default function SessionExpirationDialog() {
|
||||
|
||||
let offCancel: (() => void) | undefined;
|
||||
|
||||
// Return the dialog to its interactive state and dismiss the browser popup
|
||||
const resetDialog = () => {
|
||||
offCancel?.();
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
try {
|
||||
const start = await Session.RequestExtend({ hint: "" });
|
||||
const uri = start.verificationUriComplete || start.verificationUri;
|
||||
@@ -112,22 +105,25 @@ export default function SessionExpirationDialog() {
|
||||
if (outcome.kind === "cancel") {
|
||||
waitPromise.cancel?.();
|
||||
waitPromise.catch(() => {});
|
||||
resetDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
// Another surface owns this flow; keep the dialog open to retry.
|
||||
if (outcome.result.preempted) {
|
||||
resetDialog();
|
||||
return;
|
||||
}
|
||||
WindowManager.CloseRenewFlow().catch(console.error);
|
||||
|
||||
// Close before the popup so the restore can't flash this window back.
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
} catch (e) {
|
||||
resetDialog();
|
||||
await errorDialog({
|
||||
Title: t("sessionExpiration.extendFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
offCancel?.();
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
@@ -143,11 +139,12 @@ export default function SessionExpirationDialog() {
|
||||
});
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
} catch (e) {
|
||||
setBusy(false);
|
||||
await errorDialog({
|
||||
Title: t("sessionExpiration.logoutFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
|
||||
@@ -22,9 +22,6 @@ type WelcomeStepTrayProps = {
|
||||
export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) {
|
||||
const { t } = useTranslation();
|
||||
const trayScreenshot = trayScreenshotForOS();
|
||||
// macOS has no tray — the icon sits in the menu bar, so the copy says so.
|
||||
const titleKey = isMacOS() ? "welcome.titleMac" : "welcome.title";
|
||||
const descriptionKey = isMacOS() ? "welcome.descriptionMac" : "welcome.description";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -39,9 +36,9 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
|
||||
|
||||
<div className={"flex w-full flex-col gap-1"}>
|
||||
<DialogHeading id={"nb-welcome-title"} align={"left"}>
|
||||
{t(titleKey)}
|
||||
{t("welcome.title")}
|
||||
</DialogHeading>
|
||||
<DialogDescription align={"left"}>{t(descriptionKey)}</DialogDescription>
|
||||
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "Suchen Sie NetBird in der Taskleiste"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Suchen Sie NetBird in der Menüleiste"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird läuft in Ihrer Menüleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Weiter"
|
||||
},
|
||||
@@ -1299,13 +1293,10 @@
|
||||
"message": "Dokumentation"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "NetBird Client ist veraltet"
|
||||
"message": "NetBird-Dienst ist veraltet"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Die neue GUI ist nicht mit Ihrem älteren Client kompatibel. Aktualisieren Sie Ihren Client, um die neue Anwendung zu verwenden."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Neueste Version herunterladen"
|
||||
"message": "Aktualisieren Sie den NetBird-Dienst, um diese App zu verwenden."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut."
|
||||
|
||||
@@ -1377,19 +1377,11 @@
|
||||
},
|
||||
"welcome.title": {
|
||||
"message": "Look for NetBird in your tray",
|
||||
"description": "Heading on the first onboarding step, pointing the user to the tray icon. Shown on Windows and Linux; macOS uses welcome.titleMac."
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Look for NetBird in your menu bar",
|
||||
"description": "Heading on the first onboarding step on macOS, pointing the user to the menu bar icon. Use your language's Apple term for the macOS menu bar."
|
||||
"description": "Heading on the first onboarding step, pointing the user to the tray icon. 'tray' = system tray / menu bar."
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.",
|
||||
"description": "Body of the first onboarding step explaining the tray icon. Shown on Windows and Linux; macOS uses welcome.descriptionMac."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird lives in your menu bar. Click the icon to connect, switch profiles, or open settings.",
|
||||
"description": "Body of the first onboarding step on macOS explaining the menu bar icon. Use your language's Apple term for the macOS menu bar."
|
||||
"description": "Body of the first onboarding step explaining the tray icon."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continue",
|
||||
@@ -1732,16 +1724,12 @@
|
||||
"description": "Documentation link on the daemon-unavailable overlay."
|
||||
},
|
||||
"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."
|
||||
"message": "NetBird Service Is Outdated",
|
||||
"description": "Title of the overlay shown when the NetBird background service is too old to drive this UI."
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "The new GUI isn't compatible with the older NetBird client. Update your client to use the new application.",
|
||||
"description": "Body of the daemon-outdated overlay explaining that the GUI is newer than the client and the client must be updated."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Download Latest",
|
||||
"description": "Button on the daemon-outdated overlay that opens the download page for the latest release."
|
||||
"message": "Update the NetBird service to use this app.",
|
||||
"description": "Body of the daemon-outdated overlay telling the user to upgrade the service."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.",
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "Busque NetBird en su bandeja del sistema"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Busque NetBird en su barra de menús"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird reside en su bandeja del sistema. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird reside en su barra de menús. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continuar"
|
||||
},
|
||||
@@ -1299,13 +1293,10 @@
|
||||
"message": "Documentación"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "NetBird Client está desactualizado"
|
||||
"message": "El servicio de NetBird está desactualizado"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "La nueva GUI no es compatible con su cliente anterior. Actualice su cliente para usar la nueva aplicación."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Descargar la última versión"
|
||||
"message": "Actualice el servicio de NetBird para usar esta aplicación."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Error al iniciar sesión: el reloj de este dispositivo no está sincronizado con el servidor. Sincronice el reloj del sistema e inténtelo de nuevo."
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "Cherchez NetBird dans votre barre d’état système"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Cherchez NetBird dans votre barre des menus"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird se trouve dans votre barre d’état système. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird se trouve dans votre barre des menus. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continuer"
|
||||
},
|
||||
@@ -1299,13 +1293,10 @@
|
||||
"message": "Documentation"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "Le Client NetBird est obsolète"
|
||||
"message": "Le service NetBird est obsolète"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "La nouvelle GUI n'est pas compatible avec votre ancien client. Mettez à jour votre client pour utiliser la nouvelle application."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Télécharger la dernière version"
|
||||
"message": "Mettez à jour le service NetBird pour utiliser cette application."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Échec de la connexion : l’horloge de cet appareil n’est pas synchronisée avec le serveur. Veuillez synchroniser l’horloge de votre système et réessayer."
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "Keresse a NetBirdöt a tálcán"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Keresse a NetBirdöt a menüsorban"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "A NetBird a menüsorban fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Folytatás"
|
||||
},
|
||||
@@ -1299,13 +1293,10 @@
|
||||
"message": "Dokumentáció"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "A NetBird Kliens elavult"
|
||||
"message": "A NetBird szolgáltatás elavult"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Az új GUI nem kompatibilis a régebbi klienseddel. Frissítsd a klienst az új alkalmazás használatához."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Legújabb letöltése"
|
||||
"message": "Frissítsd a NetBird szolgáltatást az alkalmazás használatához."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra."
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "Cerchi NetBird nella tray"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Cerchi NetBird nella barra dei menu"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird risiede nella tray. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird risiede nella barra dei menu. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continua"
|
||||
},
|
||||
@@ -1299,13 +1293,10 @@
|
||||
"message": "Documentazione"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "NetBird Client è obsoleto"
|
||||
"message": "Il servizio NetBird è obsoleto"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "La nuova GUI non è compatibile con il tuo client precedente. Aggiorna il client per usare la nuova applicazione."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Scarica l'ultima versione"
|
||||
"message": "Aggiorna il servizio NetBird per usare questa app."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Accesso non riuscito: l'orologio di questo dispositivo non è sincronizzato con il server. Sincronizzi l'orologio di sistema e riprovi."
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "トレイの NetBird を確認してください"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "メニューバーの NetBird を確認してください"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird はトレイに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。"
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird はメニューバーに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。"
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "続ける"
|
||||
},
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "Procure o NetBird na sua bandeja"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Procure o NetBird na sua barra de menus"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "O NetBird fica na sua bandeja. Clique no ícone para conectar, alternar perfis ou abrir as configurações."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "O NetBird fica na sua barra de menus. Clique no ícone para conectar, alternar perfis ou abrir as configurações."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continuar"
|
||||
},
|
||||
@@ -1299,13 +1293,10 @@
|
||||
"message": "Documentação"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "O NetBird Client está desatualizado"
|
||||
"message": "O serviço NetBird está desatualizado"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "A nova GUI não é compatível com o seu cliente mais antigo. Atualize o seu cliente para usar o novo aplicativo."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Baixar a versão mais recente"
|
||||
"message": "Atualize o serviço NetBird para usar este aplicativo."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Falha no login: o relógio deste dispositivo está fora de sincronia com o servidor. Sincronize o relógio do sistema e tente novamente."
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "Найдите NetBird в системном трее"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Найдите NetBird в строке меню"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird находится в системном трее. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird находится в строке меню. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Продолжить"
|
||||
},
|
||||
@@ -1299,13 +1293,10 @@
|
||||
"message": "Документация"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "Клиент NetBird устарел"
|
||||
"message": "Служба NetBird устарела"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Новый GUI несовместим с вашим более старым клиентом. Обновите клиент, чтобы использовать новое приложение."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Скачать последнюю версию"
|
||||
"message": "Обновите службу NetBird, чтобы использовать это приложение."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Не удалось войти: часы этого устройства рассинхронизированы с сервером. Синхронизируйте системные часы и повторите попытку."
|
||||
|
||||
@@ -1034,15 +1034,9 @@
|
||||
"welcome.title": {
|
||||
"message": "在托盘中查找 NetBird"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "在菜单栏中查找 NetBird"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird 驻留在您的托盘中。点击图标即可连接、切换配置文件或打开设置。"
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird 驻留在您的菜单栏中。点击图标即可连接、切换配置文件或打开设置。"
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "继续"
|
||||
},
|
||||
@@ -1299,13 +1293,10 @@
|
||||
"message": "文档"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "NetBird 客户端版本过旧"
|
||||
"message": "NetBird 服务版本过旧"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "新版 GUI 与您较旧的客户端不兼容。请更新客户端以使用新应用。"
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "下载最新版本"
|
||||
"message": "请更新 NetBird 服务以使用此应用。"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "登录失败:此设备的时钟与服务器不同步。请同步您的系统时钟后重试。"
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// Autostart facade over Wails' AutostartManager. The OS autostart entry registration
|
||||
// Autostart facade over Wails' AutostartManager. The OS login-item registration
|
||||
// is the single source of truth; nothing is mirrored to preferences.
|
||||
type Autostart struct {
|
||||
mgr *application.AutostartManager
|
||||
|
||||
@@ -12,15 +12,13 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
// ProfileSwitcher holds the switch policy shared by the tray and React
|
||||
// frontend so both flip profiles identically. SwitchActive (plain selection:
|
||||
// header dropdown, tray submenu) always connects after the switch;
|
||||
// SwitchActiveNoConnect (manage-profiles screen) never does, so the user can
|
||||
// still adjust the management URL before connecting. prevStatus from
|
||||
// DaemonFeed.Get at entry only decides the teardown:
|
||||
// ProfileSwitcher holds the reconnect policy shared by the tray and React
|
||||
// frontend so both flip profiles identically. The policy keys off prevStatus
|
||||
// from DaemonFeed.Get at SwitchActive entry:
|
||||
//
|
||||
// Connected/Connecting/NeedsLogin/LoginFailed/SessionExpired → Down first.
|
||||
// Idle → no Down.
|
||||
// Connected/Connecting → Switch + Down + Up; optimistic Connecting paint.
|
||||
// NeedsLogin/LoginFailed/SessionExpired → Switch + Down; clear stale error for re-login.
|
||||
// Idle → Switch only.
|
||||
type ProfileSwitcher struct {
|
||||
profiles *Profiles
|
||||
connection *Connection
|
||||
@@ -31,40 +29,29 @@ func NewProfileSwitcher(profiles *Profiles, connection *Connection, feed *Daemon
|
||||
return &ProfileSwitcher{profiles: profiles, connection: connection, feed: feed}
|
||||
}
|
||||
|
||||
// SwitchActive switches to the named profile and always connects afterwards.
|
||||
// SwitchActive switches to the named profile applying the reconnect policy.
|
||||
func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error {
|
||||
return s.switchActive(ctx, p, true)
|
||||
}
|
||||
|
||||
// SwitchActiveNoConnect switches to the named profile without connecting,
|
||||
// tearing down any existing connection first.
|
||||
func (s *ProfileSwitcher) SwitchActiveNoConnect(ctx context.Context, p ProfileRef) error {
|
||||
return s.switchActive(ctx, p, false)
|
||||
}
|
||||
|
||||
func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connect bool) error {
|
||||
prevStatus := ""
|
||||
if s.feed != nil {
|
||||
if st, err := s.feed.Get(ctx); err == nil {
|
||||
prevStatus = st.Status
|
||||
} else {
|
||||
log.Warnf("profileswitcher: get status: %v", err)
|
||||
}
|
||||
if st, err := s.feed.Get(ctx); err == nil {
|
||||
prevStatus = st.Status
|
||||
} else {
|
||||
log.Warnf("profileswitcher: get status: %v", err)
|
||||
}
|
||||
|
||||
needsDown := strings.EqualFold(prevStatus, StatusConnected) ||
|
||||
strings.EqualFold(prevStatus, StatusConnecting) ||
|
||||
wasActive := strings.EqualFold(prevStatus, StatusConnected) ||
|
||||
strings.EqualFold(prevStatus, StatusConnecting)
|
||||
needsDown := wasActive ||
|
||||
strings.EqualFold(prevStatus, StatusNeedsLogin) ||
|
||||
strings.EqualFold(prevStatus, StatusLoginFailed) ||
|
||||
strings.EqualFold(prevStatus, StatusSessionExpired)
|
||||
|
||||
log.Infof("profileswitcher: switch profile=%q prevStatus=%q connect=%v needsDown=%v",
|
||||
p.ProfileName, prevStatus, connect, needsDown)
|
||||
log.Infof("profileswitcher: switch profile=%q prevStatus=%q wasActive=%v needsDown=%v",
|
||||
p.ProfileName, prevStatus, wasActive, needsDown)
|
||||
|
||||
// Optimistic Connecting paint plus stale-push suppression during Down (see
|
||||
// DaemonFeed suppression table); also arms the login-watch that pops
|
||||
// browser-login when the new profile turns out to need SSO.
|
||||
if connect && s.feed != nil {
|
||||
// Optimistic Connecting paint only when wasActive: those prevStatuses emit
|
||||
// stale Connected + transient Idle pushes during Down that must be
|
||||
// suppressed until Up resumes the stream (see DaemonFeed suppression table).
|
||||
if wasActive {
|
||||
s.feed.BeginProfileSwitch()
|
||||
}
|
||||
|
||||
@@ -89,9 +76,9 @@ func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connec
|
||||
}
|
||||
}
|
||||
|
||||
if connect {
|
||||
if wasActive {
|
||||
if err := s.connection.Up(ctx, UpParams(p)); err != nil {
|
||||
return fmt.Errorf("connect %q: %w", p.ProfileName, err)
|
||||
return fmt.Errorf("reconnect %q: %w", p.ProfileName, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,38 +185,37 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
|
||||
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
|
||||
}
|
||||
s.hideOtherWindowsLocked("browser-login")
|
||||
// Prefer the main window's screen (multi-monitor); falls back to OS-default centering.
|
||||
var screen *application.Screen
|
||||
if s.mainWindow != nil {
|
||||
if sc, err := s.mainWindow.GetScreen(); err == nil {
|
||||
screen = sc
|
||||
}
|
||||
}
|
||||
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon)
|
||||
// Not always-on-top: it would obscure the browser tab the user logs in through.
|
||||
opts.AlwaysOnTop = false
|
||||
opts.InitialPosition = application.WindowCentered
|
||||
// Open on the active (where users cursor is) display, like the session-expiration dialog.
|
||||
opts.Screen = s.getScreenBasedOnCursorPosition()
|
||||
opts.Screen = screen
|
||||
s.browserLogin = s.app.Window.NewWithOptions(opts)
|
||||
bl := s.browserLogin
|
||||
// Red-X close means cancel: emit the event so startLogin() tears down the SSO wait.
|
||||
bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.app.Event.Emit(EventBrowserLoginCancel)
|
||||
s.mu.Lock()
|
||||
// Only a live user red-X still has this registered; programmatic closers
|
||||
// nil s.browserLogin first and clean up themselves. Guarding here stops a
|
||||
// stale close event from wiping a replacement popup's state.
|
||||
userClosed := s.browserLogin == bl
|
||||
if userClosed {
|
||||
s.browserLogin = nil
|
||||
s.restoreHiddenWindowsLocked()
|
||||
}
|
||||
s.browserLogin = nil
|
||||
s.restoreHiddenWindowsLocked()
|
||||
s.mu.Unlock()
|
||||
if userClosed {
|
||||
s.app.Event.Emit(EventBrowserLoginCancel)
|
||||
}
|
||||
})
|
||||
s.centerOnCursorScreen(s.browserLogin)
|
||||
s.centerWhenReady(s.browserLogin)
|
||||
return
|
||||
}
|
||||
if uri != "" {
|
||||
s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri))
|
||||
}
|
||||
s.centerOnCursorScreen(s.browserLogin)
|
||||
s.browserLogin.Show()
|
||||
s.browserLogin.Focus()
|
||||
s.centerWhenReady(s.browserLogin)
|
||||
}
|
||||
|
||||
// BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the
|
||||
@@ -239,15 +238,6 @@ func (s *WindowManager) CloseBrowserLogin() {
|
||||
s.mu.Lock()
|
||||
w := s.browserLogin
|
||||
s.browserLogin = nil
|
||||
// The WindowClosing hook no-ops on a programmatic close, so restore here —
|
||||
// but only if a popup was actually open. The frontend calls this even when no
|
||||
// popup was ever shown (e.g. resetDialog() after an early RequestExtend failure,
|
||||
// or connection.ts's catch path), and hiddenForLogin is shared with
|
||||
// OpenInstallProgress, so an unconditional restore could re-show windows a
|
||||
// still-running install-progress is hiding.
|
||||
if w != nil {
|
||||
s.restoreHiddenWindowsLocked()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if w != nil {
|
||||
w.Close()
|
||||
@@ -289,35 +279,6 @@ func (s *WindowManager) CloseSessionExpiration() {
|
||||
}
|
||||
}
|
||||
|
||||
// CloseRenewFlow tears down the SSO session-renewal UI in a single call: it
|
||||
// closes the browser-login popup and the session-expiration window together.
|
||||
func (s *WindowManager) CloseRenewFlow() {
|
||||
s.mu.Lock()
|
||||
bl := s.browserLogin
|
||||
se := s.sessionExpiration
|
||||
s.browserLogin = nil
|
||||
s.sessionExpiration = nil
|
||||
if se != nil {
|
||||
kept := s.hiddenForLogin[:0]
|
||||
for _, w := range s.hiddenForLogin {
|
||||
if w != se {
|
||||
kept = append(kept, w)
|
||||
}
|
||||
}
|
||||
s.hiddenForLogin = kept
|
||||
}
|
||||
s.restoreHiddenWindowsLocked()
|
||||
s.mu.Unlock()
|
||||
|
||||
// Close after unlock so the re-entrant handlers can take s.mu.
|
||||
if bl != nil {
|
||||
bl.Close()
|
||||
}
|
||||
if se != nil {
|
||||
se.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// OpenInstallProgress shows the install-progress window and hides the rest for the duration
|
||||
// (restored on close). It owns its own result polling since the daemon restarts mid-install.
|
||||
func (s *WindowManager) OpenInstallProgress(version string) {
|
||||
|
||||
@@ -30,8 +30,6 @@ const (
|
||||
|
||||
statusError = "Error"
|
||||
|
||||
quitDownTimeout = 5 * time.Second
|
||||
|
||||
urlGitHubRepo = "https://github.com/netbirdio/netbird"
|
||||
urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest"
|
||||
urlDocs = "https://docs.netbird.io"
|
||||
@@ -317,7 +315,8 @@ func (t *Tray) relayoutMenu() {
|
||||
if sessionDeadline.IsZero() {
|
||||
t.sessionExpiresItem.SetHidden(true)
|
||||
} else {
|
||||
t.sessionExpiresItem.SetLabel(t.sessionRowLabel(sessionDeadline))
|
||||
remaining := t.formatSessionRemaining(time.Until(sessionDeadline))
|
||||
t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
|
||||
t.sessionExpiresItem.SetHidden(false)
|
||||
}
|
||||
}
|
||||
@@ -447,28 +446,11 @@ func (t *Tray) buildMenu() *application.Menu {
|
||||
menu.AddSeparator()
|
||||
menu.Add(t.loc.T("tray.menu.quit")).
|
||||
SetAccelerator("CmdOrCtrl+Q").
|
||||
OnClick(func(*application.Context) { t.handleQuit() })
|
||||
OnClick(func(*application.Context) { t.app.Quit() })
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
func (t *Tray) handleQuit() {
|
||||
t.profileMu.Lock()
|
||||
if t.switchCancel != nil {
|
||||
t.switchCancel()
|
||||
t.switchCancel = nil
|
||||
}
|
||||
t.profileMu.Unlock()
|
||||
t.svc.DaemonFeed.CancelProfileSwitch()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
|
||||
defer cancel()
|
||||
if err := t.svc.Connection.Down(ctx); err != nil {
|
||||
log.Errorf("disconnect on quit: %v", err)
|
||||
}
|
||||
t.app.Quit()
|
||||
}
|
||||
|
||||
// handleConnect receives the clicked item from the buildMenu closure —
|
||||
// t.upItem is menuMu-guarded and must not be read here.
|
||||
func (t *Tray) handleConnect(upItem *application.MenuItem) {
|
||||
|
||||
@@ -64,42 +64,11 @@ func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) bool {
|
||||
return changed
|
||||
}
|
||||
|
||||
// runSessionExpiryTicker recomputes the "Expires in …" row label until process exit.
|
||||
// The interval scales with the remaining time: coarse when the deadline is far off,
|
||||
// down to 10s in the final two minutes so the label doesn't lag the ceiling-rounded
|
||||
// countdown near expiry. The cached deadline is re-read every iteration, so an extend
|
||||
// or reconnect that moves it is picked up on the next tick.
|
||||
// runSessionExpiryTicker recomputes the "Expires in …" row label every 30s. Runs until process exit.
|
||||
func (t *Tray) runSessionExpiryTicker() {
|
||||
tm := time.NewTimer(sessionRefreshInterval(t.sessionRemaining()))
|
||||
defer tm.Stop()
|
||||
for range tm.C {
|
||||
tk := time.NewTicker(30 * time.Second)
|
||||
for range tk.C {
|
||||
t.refreshSessionExpiresLabel()
|
||||
tm.Reset(sessionRefreshInterval(t.sessionRemaining()))
|
||||
}
|
||||
}
|
||||
|
||||
// sessionRemaining returns the time left on the cached SSO deadline, or 0 when unknown.
|
||||
func (t *Tray) sessionRemaining() time.Duration {
|
||||
t.sessionMu.Lock()
|
||||
deadline := t.sessionExpiresAt
|
||||
t.sessionMu.Unlock()
|
||||
if deadline.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return time.Until(deadline)
|
||||
}
|
||||
|
||||
// sessionRefreshInterval picks how long to wait before the next label recompute.
|
||||
func sessionRefreshInterval(remaining time.Duration) time.Duration {
|
||||
switch {
|
||||
case remaining <= 0:
|
||||
return 30 * time.Second
|
||||
case remaining <= 2*time.Minute:
|
||||
return 10 * time.Second
|
||||
case remaining <= time.Hour:
|
||||
return 30 * time.Second
|
||||
default:
|
||||
return time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,39 +87,30 @@ func (t *Tray) refreshSessionExpiresLabel() {
|
||||
if deadline.IsZero() {
|
||||
return
|
||||
}
|
||||
item.SetLabel(t.sessionRowLabel(deadline))
|
||||
}
|
||||
|
||||
func (t *Tray) sessionRowLabel(deadline time.Time) string {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return t.loc.T("tray.status.sessionExpired")
|
||||
}
|
||||
return t.loc.T("tray.session.expiresIn", "remaining", t.formatSessionRemaining(remaining))
|
||||
remaining := t.formatSessionRemaining(time.Until(deadline))
|
||||
item.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
|
||||
}
|
||||
|
||||
// formatSessionRemaining renders d as a localised long-form string picking the largest non-zero unit.
|
||||
// Each unit is rounded up so the label never claims less time than actually remains, matching the
|
||||
// upper-bound sense of the sub-minute "less than a minute" fragment.
|
||||
// Singular/plural keys are split per language for proper translation.
|
||||
func (t *Tray) formatSessionRemaining(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return t.loc.T("tray.session.unit.lessThanMinute")
|
||||
case d <= 59*time.Minute:
|
||||
m := ceilDiv(d, time.Minute)
|
||||
case d < time.Hour:
|
||||
m := int(d / time.Minute)
|
||||
if m == 1 {
|
||||
return t.loc.T("tray.session.unit.minute")
|
||||
}
|
||||
return t.loc.T("tray.session.unit.minutes", "count", strconv.Itoa(m))
|
||||
case d <= 23*time.Hour:
|
||||
h := ceilDiv(d, time.Hour)
|
||||
case d < 24*time.Hour:
|
||||
h := int((d + 30*time.Minute) / time.Hour)
|
||||
if h == 1 {
|
||||
return t.loc.T("tray.session.unit.hour")
|
||||
}
|
||||
return t.loc.T("tray.session.unit.hours", "count", strconv.Itoa(h))
|
||||
default:
|
||||
days := ceilDiv(d, 24*time.Hour)
|
||||
days := int((d + 12*time.Hour) / (24 * time.Hour))
|
||||
if days == 1 {
|
||||
return t.loc.T("tray.session.unit.day")
|
||||
}
|
||||
@@ -158,11 +118,6 @@ func (t *Tray) formatSessionRemaining(d time.Duration) string {
|
||||
}
|
||||
}
|
||||
|
||||
// ceilDiv divides d by unit rounding up, assuming d > 0.
|
||||
func ceilDiv(d, unit time.Duration) int {
|
||||
return int((d + unit - time.Nanosecond) / unit)
|
||||
}
|
||||
|
||||
// registerSessionWarningCategory wires the OS notification category and response handler for the expiry warning.
|
||||
// Errors are swallowed since the worst case is a plain notification without buttons.
|
||||
func (t *Tray) registerSessionWarningCategory() {
|
||||
@@ -297,9 +252,11 @@ func (t *Tray) openSessionExpiration() {
|
||||
}
|
||||
|
||||
// openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time,
|
||||
// for the "Expires in …" tray row. Once the deadline has elapsed the row reads "Session expired" and the
|
||||
// click routes to the login flow instead. No-op when the deadline is unknown.
|
||||
// for the "Expires in …" tray row. No-ops when the deadline is unknown or elapsed.
|
||||
func (t *Tray) openSessionExtendFlow() {
|
||||
if t.svc.WindowManager == nil {
|
||||
return
|
||||
}
|
||||
t.sessionMu.Lock()
|
||||
deadline := t.sessionExpiresAt
|
||||
t.sessionMu.Unlock()
|
||||
@@ -308,14 +265,6 @@ func (t *Tray) openSessionExtendFlow() {
|
||||
}
|
||||
seconds := int(time.Until(deadline).Seconds())
|
||||
if seconds <= 0 {
|
||||
if t.window != nil {
|
||||
t.window.SetURL("/#/login")
|
||||
t.window.Show()
|
||||
t.window.Focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
if t.svc.WindowManager == nil {
|
||||
return
|
||||
}
|
||||
t.svc.WindowManager.OpenSessionExpiration(seconds)
|
||||
|
||||
@@ -66,9 +66,6 @@
|
||||
<key>disableAutoConnect</key>
|
||||
<false/>
|
||||
|
||||
<key>disableAutostart</key>
|
||||
<false/>
|
||||
|
||||
<key>disableClientRoutes</key>
|
||||
<false/>
|
||||
|
||||
|
||||
@@ -103,8 +103,6 @@
|
||||
<!--
|
||||
<key>disableAutoConnect</key>
|
||||
<false/>
|
||||
<key>disableAutostart</key>
|
||||
<false/>
|
||||
<key>disableClientRoutes</key>
|
||||
<false/>
|
||||
<key>disableServerRoutes</key>
|
||||
|
||||
@@ -58,7 +58,6 @@ preSharedKey="$NULL" # secret; redacted in log
|
||||
allowServerSSH='true'
|
||||
blockInbound="$NULL"
|
||||
disableAutoConnect="$NULL"
|
||||
disableAutostart="$NULL"
|
||||
disableClientRoutes="$NULL"
|
||||
disableServerRoutes="$NULL"
|
||||
disableMetricsCollection="$NULL"
|
||||
@@ -156,7 +155,6 @@ main() {
|
||||
is_set "$allowServerSSH" && emit_bool allowServerSSH "$allowServerSSH"
|
||||
is_set "$blockInbound" && emit_bool blockInbound "$blockInbound"
|
||||
is_set "$disableAutoConnect" && emit_bool disableAutoConnect "$disableAutoConnect"
|
||||
is_set "$disableAutostart" && emit_bool disableAutostart "$disableAutostart"
|
||||
is_set "$disableClientRoutes" && emit_bool disableClientRoutes "$disableClientRoutes"
|
||||
is_set "$disableServerRoutes" && emit_bool disableServerRoutes "$disableServerRoutes"
|
||||
is_set "$disableMetricsCollection" && emit_bool disableMetricsCollection "$disableMetricsCollection"
|
||||
|
||||
Binary file not shown.
@@ -24,9 +24,6 @@
|
||||
<string id="DisableAutoConnect_Name">Disable auto-connect</string>
|
||||
<string id="DisableAutoConnect_Help">When enabled, the NetBird tunnel does not auto-connect at daemon startup. Equivalent to --disable-auto-connect.</string>
|
||||
|
||||
<string id="DisableAutostart_Name">Disable autostart</string>
|
||||
<string id="DisableAutostart_Help">When enabled, the NetBird GUI is prevented from registering itself as an OS autostart entry on fresh installs, and any existing OS autostart entry registration is removed on the next GUI launch (Windows Registry Run key, macOS Login Item, Linux .desktop). Once the admin lifts the policy, the setting stays off until the user re-enables it in Settings.</string>
|
||||
|
||||
<string id="DisableClientRoutes_Name">Disable client routes</string>
|
||||
<string id="DisableClientRoutes_Help">When enabled, this client will not consume routes advertised by routing peers. Equivalent to --disable-client-routes.</string>
|
||||
|
||||
|
||||
@@ -64,18 +64,6 @@
|
||||
<disabledValue><decimal value="0" /></disabledValue>
|
||||
</policy>
|
||||
|
||||
<policy name="DisableAutostart"
|
||||
class="Machine"
|
||||
displayName="$(string.DisableAutostart_Name)"
|
||||
explainText="$(string.DisableAutostart_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
valueName="DisableAutostart">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<enabledValue><decimal value="1" /></enabledValue>
|
||||
<disabledValue><decimal value="0" /></disabledValue>
|
||||
</policy>
|
||||
|
||||
<policy name="DisableClientRoutes"
|
||||
class="Machine"
|
||||
displayName="$(string.DisableClientRoutes_Name)"
|
||||
|
||||
5
go.mod
5
go.mod
@@ -30,6 +30,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/DeRuina/timberjack v1.4.2
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/awnumar/memguard v0.23.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.38.3
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1
|
||||
@@ -113,7 +114,7 @@ require (
|
||||
github.com/ti-mo/conntrack v0.5.1
|
||||
github.com/ti-mo/netfilter v0.5.2
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.111
|
||||
github.com/yusufpapurcu/wmi v1.2.4
|
||||
github.com/zcalusic/sysinfo v1.1.3
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
||||
@@ -156,7 +157,6 @@ require (
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/adrg/xdg v0.5.3 // indirect
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
@@ -303,6 +303,7 @@ require (
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/wailsapp/wails/webview2 v1.0.27 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/zeebo/blake3 v0.2.3 // indirect
|
||||
|
||||
6
go.sum
6
go.sum
@@ -660,8 +660,10 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.111 h1:MKx1nOnhnDuEGrRBmtxLOJq1NERwailu2cI4BvzWhi4=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.111/go.mod h1:wrdvmyeCsB/K3YqJDoH8E3MwcN8NXAMnEFaDTW46w60=
|
||||
github.com/wailsapp/wails/webview2 v1.0.27 h1:wjgAi/I8BBZ7kUGU8um3XF3ILEfzr96Q2Q1G4GPjMns=
|
||||
github.com/wailsapp/wails/webview2 v1.0.27/go.mod h1:zdM4jcO1IaC61RiJL5F1BzgoqBHFIdacz8gPr5exr0o=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
|
||||
@@ -14,7 +14,6 @@ COPY proxy ./proxy
|
||||
COPY route ./route
|
||||
COPY shared ./shared
|
||||
COPY sharedsock ./sharedsock
|
||||
COPY trustedproxy ./trustedproxy
|
||||
COPY upload-server ./upload-server
|
||||
COPY util ./util
|
||||
COPY version ./version
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// bedrockRegionPrefixes are the cross-region inference-profile prefixes that
|
||||
// front a Bedrock model id (e.g. "eu.anthropic.claude-...").
|
||||
var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."}
|
||||
|
||||
// bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]"
|
||||
// version/throughput suffix of a Bedrock model id.
|
||||
var bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`)
|
||||
|
||||
// NormalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile
|
||||
// prefix, and the version/throughput suffix from a Bedrock model id so it
|
||||
// matches the catalog/pricing key, e.g.
|
||||
// "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" -> "anthropic.claude-sonnet-4-5"
|
||||
// and the inference-profile ARN's last segment likewise. It is the single
|
||||
// source of truth shared by the request parser (which normalizes the request
|
||||
// model from the URL path) and the router (which normalizes the operator's
|
||||
// registered Bedrock model ids so both sides compare equal).
|
||||
func NormalizeBedrockModel(modelID string) string {
|
||||
m := modelID
|
||||
if strings.HasPrefix(m, "arn:") {
|
||||
if i := strings.LastIndex(m, "/"); i >= 0 {
|
||||
m = m[i+1:]
|
||||
}
|
||||
}
|
||||
for _, p := range bedrockRegionPrefixes {
|
||||
if strings.HasPrefix(m, p) {
|
||||
m = m[len(p):]
|
||||
break
|
||||
}
|
||||
}
|
||||
return bedrockVersionSuffix.ReplaceAllString(m, "")
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeBedrockModel(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5",
|
||||
"us.anthropic.claude-haiku-4-5": "anthropic.claude-haiku-4-5",
|
||||
"us.anthropic.claude-opus-4-8-20250101-v1:0": "anthropic.claude-opus-4-8",
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5",
|
||||
"meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct",
|
||||
"amazon.nova-pro-v1:0": "amazon.nova-pro",
|
||||
// Inference-profile ARN — model id lives in the last path segment.
|
||||
"arn:aws:bedrock:eu-central-1:123456789012:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5",
|
||||
}
|
||||
for in, want := range cases {
|
||||
require.Equal(t, want, NormalizeBedrockModel(in), "normalize %q", in)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package llm_router
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestRouteClaimsModel_BedrockNormalizesCandidate guards the fix for the native
|
||||
// Bedrock routing gap: the request model reaches the router already normalized
|
||||
// (the parser strips the region/inference-profile prefix and version suffix),
|
||||
// so a provider registered with the raw inference-profile id must still match.
|
||||
func TestRouteClaimsModel_BedrockNormalizesCandidate(t *testing.T) {
|
||||
route := ProviderRoute{Bedrock: true, Models: []string{"us.anthropic.claude-haiku-4-5"}}
|
||||
assert.True(t, routeClaimsModel(route, "anthropic.claude-haiku-4-5"),
|
||||
"raw region-prefixed Bedrock model must match the normalized request model")
|
||||
assert.False(t, routeClaimsModel(route, "anthropic.claude-opus-4-8"),
|
||||
"a model outside the provider's list must not match")
|
||||
|
||||
// A provider registered with the already-normalized id also matches.
|
||||
normalized := ProviderRoute{Bedrock: true, Models: []string{"anthropic.claude-haiku-4-5"}}
|
||||
assert.True(t, routeClaimsModel(normalized, "anthropic.claude-haiku-4-5"),
|
||||
"normalized Bedrock model must match")
|
||||
|
||||
// Non-Bedrock routes keep exact matching (no prefix stripping).
|
||||
openai := ProviderRoute{Models: []string{"gpt-4o"}}
|
||||
assert.True(t, routeClaimsModel(openai, "gpt-4o"), "exact model must match")
|
||||
assert.False(t, routeClaimsModel(openai, "us.gpt-4o"),
|
||||
"non-Bedrock routes must not strip a us. prefix")
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/llm"
|
||||
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
||||
)
|
||||
|
||||
@@ -556,14 +555,6 @@ func routeClaimsModel(route ProviderRoute, model string) bool {
|
||||
if candidate == model {
|
||||
return true
|
||||
}
|
||||
// Bedrock request models reach the router already normalized (the parser
|
||||
// strips the region / inference-profile prefix and version suffix), but
|
||||
// the operator may register the raw inference-profile id (e.g.
|
||||
// "us.anthropic.claude-haiku-4-5"). Normalize the candidate so both sides
|
||||
// compare equal; otherwise a native Bedrock request denies as not-routable.
|
||||
if route.Bedrock && llm.NormalizeBedrockModel(candidate) == model {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user