Compare commits

..

2 Commits

Author SHA1 Message Date
Theodor S. Midtlien
5d9ef0123f Add impersonateNamedPipeClient 2026-07-23 16:05:27 +02:00
Theodor S. Midtlien
27afbd7952 WIP: acl interceptor and named pipe ui 2026-07-23 14:14:42 +02:00
177 changed files with 5546 additions and 13123 deletions

View File

@@ -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

View File

@@ -45,7 +45,7 @@ jobs:
display_name: Linux
name: ${{ matrix.display_name }}
runs-on: ${{ matrix.os }}
timeout-minutes: 25
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -79,4 +79,4 @@ jobs:
skip-cache: true
skip-save-cache: true
cache-invalidation-interval: 0
args: --timeout=20m
args: --timeout=12m

View File

@@ -145,7 +145,7 @@ func (pm *ProfileManager) SwitchProfile(id string) error {
// AddProfile creates a new profile
func (pm *ProfileManager) AddProfile(profileName string) error {
// Use ServiceManager (creates profile in profiles/ directory)
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername)
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername, nil)
if err != nil {
return fmt.Errorf("failed to add profile: %w", err)
}

115
client/cmd/owner.go Normal file
View File

@@ -0,0 +1,115 @@
package cmd
import (
"context"
"time"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util"
)
var ownerCmd = &cobra.Command{
Use: "owner",
Short: "Manage who may control the active NetBird profile",
Long: `Manage the owners of the active profile's daemon control channel.
Ownership is enforced per profile: an isolated profile can only be controlled by
its owner principals (plus root/administrator). A new profile is automatically
owned by its creator; an unowned profile is claimed by the first caller.`,
}
var ownerAddCmd = &cobra.Command{
Use: "add <principal>",
Short: "Add an owner principal to the active profile",
Long: `Add an owner principal to the active profile. Principals are typed:
uid:1000 a Unix user ID
gid:1000 a Unix group ID
group:netbird-admins a Unix group name (resolved via NSS/getent)
sid:S-1-5-21-... a Windows user or group SID
Requires root/administrator or an existing owner.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
if _, err := c.AddOwner(ctx, &proto.AddOwnerRequest{Principal: args[0]}); err != nil {
return err
}
cmd.Printf("Added owner %q to the active profile\n", args[0])
return nil
})
},
}
var ownerResetCmd = &cobra.Command{
Use: "reset",
Short: "Clear the active profile's owner list (root/administrator only)",
Long: `Clear the active profile's owner list, returning it to the unowned
state. The next caller then claims ownership (trust-on-first-use). Requires
root/administrator.`,
RunE: func(cmd *cobra.Command, args []string) error {
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
if _, err := c.ResetOwner(ctx, &proto.ResetOwnerRequest{}); err != nil {
return err
}
cmd.Println("Owner list cleared; the next caller will claim ownership")
return nil
})
},
}
var ownerShareCmd = &cobra.Command{
Use: "share",
Short: "Mark the active profile shared (any local user may control it)",
RunE: func(cmd *cobra.Command, args []string) error {
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
if _, err := c.ShareProfile(ctx, &proto.ShareProfileRequest{Shared: true}); err != nil {
return err
}
cmd.Println("Active profile is now shared with all local users")
return nil
})
},
}
var ownerUnshareCmd = &cobra.Command{
Use: "unshare",
Short: "Stop sharing the active profile (restrict to its owners)",
RunE: func(cmd *cobra.Command, args []string) error {
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
if _, err := c.ShareProfile(ctx, &proto.ShareProfileRequest{Shared: false}); err != nil {
return err
}
cmd.Println("Active profile is no longer shared")
return nil
})
},
}
// withDaemon runs fn with a connected daemon client, handling setup and teardown.
func withDaemon(cmd *cobra.Command, fn func(context.Context, proto.DaemonServiceClient) error) error {
SetFlagsFromEnvVars(rootCmd)
cmd.SetOut(cmd.OutOrStdout())
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
log.Errorf("failed initializing log %v", err)
return err
}
ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
defer cancel()
conn, err := DialClientGRPCServer(ctx, daemonAddr)
if err != nil {
log.Errorf("failed to connect to service CLI interface %v", err)
return err
}
defer func() {
if cerr := conn.Close(); cerr != nil {
log.Debugf("close daemon connection: %v", cerr)
}
}()
return fn(ctx, proto.NewDaemonServiceClient(conn))
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"io/fs"
"net"
"os"
"os/signal"
"path"
@@ -143,10 +144,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 client token carries the
// caller's SID so the daemon can authorize each RPC by caller identity.
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")
@@ -172,6 +175,9 @@ func init() {
rootCmd.AddCommand(profileCmd)
rootCmd.AddCommand(exposeCmd)
rootCmd.AddCommand(ownerCmd)
ownerCmd.AddCommand(ownerAddCmd, ownerResetCmd, ownerShareCmd, ownerUnshareCmd)
networksCMD.AddCommand(routesListCmd)
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
@@ -265,16 +271,28 @@ func FlagNameToEnvVar(cmdFlag string, prefix string) string {
}
// DialClientGRPCServer returns client connection to the daemon server.
func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, error) {
func DialClientGRPCServer(ctx context.Context, addr string, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
defer cancel()
return grpc.DialContext(
ctx,
strings.TrimPrefix(addr, "tcp://"),
opts = append([]grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
grpc.WithBlock()}, opts...)
// 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 stays insecure. For npipe we install a context dialer since
// gRPC's resolver does not understand Windows named pipes.
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.

View File

@@ -5,6 +5,7 @@ package cmd
import (
"context"
"fmt"
"runtime"
"time"
"github.com/kardianos/service"
@@ -13,12 +14,36 @@ 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 installs peer-identity transport credentials and the
// authorization interceptor on the daemon control channel. Identity is only
// available over a Unix socket (SO_PEERCRED) or a Windows named pipe (client
// token); over TCP, or on platforms without a peer-credential primitive, the
// daemon runs without per-caller authorization and warns (no interceptor, so it
// does not deny everyone).
func daemonServerOptions(network string, interceptor *ipcauth.Interceptor) []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),
grpc.ChainUnaryInterceptor(interceptor.UnaryServerInterceptor()),
grpc.ChainStreamInterceptor(interceptor.StreamServerInterceptor()),
}
}
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 +62,19 @@ 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)
}
// Owner-authorization interceptor. The ConfigAdapter is a lazy bridge: the
// gRPC server is built before the daemon server instance exists, so we set
// the real policy backend below once serverInstance is created.
ownerAdapter := &ipcauth.ConfigAdapter{}
authInterceptor := ipcauth.NewInterceptor(ownerAdapter, ipcauth.NewDefaultGroupResolver())
// in any case, even if configuration does not exist we run daemon to serve the CLI gRPC API.
p.serv = grpc.NewServer(daemonServerOptions(network, authInterceptor)...)
daemonListener, err := listenOnAddress(daemonAddr)
if err != nil {
@@ -77,6 +113,7 @@ func (p *program) Start(svc service.Service) error {
if err := serverInstance.Start(); err != nil {
log.Fatalf("failed to start daemon: %v", err)
}
ownerAdapter.SetBackend(serverInstance)
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
p.serverInstanceMu.Lock()
@@ -84,6 +121,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; the HTTP client's identity is forwarded so per-caller authorization still applies, but restrict access to %s appropriately", jsonSocket)
if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
log.Fatalf("failed to start daemon JSON server: %v", err)
}

View File

@@ -14,16 +14,45 @@ import (
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
)
// jsonPeerCtxKey keys the HTTP client's kernel identity in the request context.
type jsonPeerCtxKey struct{}
// jsonConnContext reads the connecting HTTP client's identity from the JSON
// socket (peercred) and stashes it so it can be forwarded to the daemon. The
// gateway re-dials the daemon as the daemon's own identity, so without this the
// daemon would see every JSON request as privileged.
func jsonConnContext(ctx context.Context, c net.Conn) context.Context {
id, err := ipcauth.PeerIdentity(c)
if err != nil {
log.Debugf("json gateway: cannot read HTTP client identity, requests won't carry it: %v", err)
return ctx
}
return context.WithValue(ctx, jsonPeerCtxKey{}, id)
}
// jsonForwardIdentity injects the stashed HTTP client identity as gRPC metadata
// on the gateway's re-dial to the daemon. The daemon trusts it only because the
// dial arrives as the daemon's own (self/privileged) identity.
func jsonForwardIdentity(ctx context.Context, _ *http.Request) metadata.MD {
id, ok := ctx.Value(jsonPeerCtxKey{}).(ipcauth.Identity)
if !ok {
return nil
}
return ipcauth.ForwardIdentityMetadata(id)
}
func grpcGatewayEndpoint(addr string) string {
return strings.TrimPrefix(addr, "tcp://")
}
func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error {
mux := runtime.NewServeMux()
mux := runtime.NewServeMux(runtime.WithMetadata(jsonForwardIdentity))
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
if err := proto.RegisterDaemonServiceHandlerFromEndpoint(p.ctx, mux, grpcGatewayEndpoint(daemonEndpoint), opts); err != nil {
return err
@@ -35,6 +64,7 @@ func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint
BaseContext: func(net.Listener) context.Context {
return p.ctx
},
ConnContext: jsonConnContext,
}
p.jsonServMu.Lock()

View 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)
}

View File

@@ -0,0 +1,35 @@
//go:build windows
package cmd
import (
"context"
"net"
"github.com/Microsoft/go-winio"
"golang.org/x/sys/windows"
"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 at SECURITY_IDENTIFICATION.
//
// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the daemon
// cannot read the caller's token (ImpersonateNamedPipeClient fails / yields an
// anonymous token and the handshake is dropped). Identification lets the daemon
// *identify* the caller (read its SID/groups) without granting it the ability to
// act as the caller — the least privilege the daemon needs for authorization.
func dialNamedPipe(ctx context.Context, path string) (net.Conn, error) {
access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE)
return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification)
}

View File

@@ -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 full \\.\pipe\ path 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 {

View File

@@ -56,6 +56,7 @@ var (
showQR bool
profileName string
configPath string
claimOwner bool
upCmd = &cobra.Command{
Use: "up",
@@ -67,6 +68,7 @@ var (
func init() {
upCmd.PersistentFlags().BoolVarP(&foregroundMode, "foreground-mode", "F", false, "start service in foreground")
upCmd.PersistentFlags().BoolVar(&claimOwner, "owner", false, "claim ownership of this profile for the current user, restricting daemon control of it to you and root/administrator")
upCmd.PersistentFlags().StringVar(&interfaceName, interfaceNameFlag, iface.WgInterfaceDefault, "WireGuard interface name")
upCmd.PersistentFlags().Uint16Var(&wireguardPort, wireguardPortFlag, iface.DefaultWgPort, "WireGuard interface listening port")
upCmd.PersistentFlags().Uint16Var(&mtu, mtuFlag, iface.DefaultMTU, "Set MTU (Maximum Transmission Unit) for the WireGuard interface")
@@ -391,6 +393,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
if _, err := client.Up(ctx, &proto.UpRequest{
ProfileName: &profileID,
Username: &username,
ClaimOwner: claimOwner,
}); err != nil {
return fmt.Errorf("call service up method: %v", err)
}

View File

@@ -29,7 +29,7 @@ func TestUpDaemon(t *testing.T) {
}
sm := profilemanager.ServiceManager{}
created, err := sm.AddProfile("test1", currUser.Username)
created, err := sm.AddProfile("test1", currUser.Username, nil)
if err != nil {
t.Fatalf("failed to add profile: %v", err)
return

View File

@@ -351,7 +351,6 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
a.config.BlockLANAccess,
a.config.BlockInbound,
a.config.DisableIPv6,
a.config.SyncMessageVersion,
a.config.EnableSSHRoot,
a.config.EnableSSHSFTP,
a.config.EnableSSHLocalPortForwarding,

View File

@@ -34,8 +34,6 @@ const (
// - Handling connection establishment based on peer signaling
//
// The implementation is not thread-safe; it is protected by engine.syncMsgMux.
// The only exception is ActivatePeer, which is safe for concurrent use so the
// DNS warm-up path can call it without contending on the engine mutex.
type ConnMgr struct {
peerStore *peerstore.Store
statusRecorder *peer.Status
@@ -44,10 +42,6 @@ type ConnMgr struct {
rosenpassEnabled bool
lazyConnMgr *manager.Manager
// lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the
// engine loop (ActivatePeer). Writers hold it in addition to
// engine.syncMsgMux; all other reads stay under engine.syncMsgMux only.
lazyConnMgrMu sync.RWMutex
wg sync.WaitGroup
lazyCtx context.Context
@@ -244,20 +238,12 @@ func (e *ConnMgr) RemovePeerConn(peerKey string) {
conn.Log.Infof("removed peer from lazy conn manager")
}
// ActivatePeer wakes an idle lazy connection. Unlike the rest of ConnMgr it is
// safe for concurrent use: the lazy manager pointer is read under lazyConnMgrMu
// and the manager itself is internally synchronized, so callers outside the
// engine loop (DNS warm-up) do not need engine.syncMsgMux.
func (e *ConnMgr) ActivatePeer(ctx context.Context, conn *peer.Conn) {
e.lazyConnMgrMu.RLock()
lazyConnMgr := e.lazyConnMgr
started := lazyConnMgr != nil && e.lazyCtxCancel != nil
e.lazyConnMgrMu.RUnlock()
if !started {
if !e.isStartedWithLazyMgr() {
return
}
if found := lazyConnMgr.ActivatePeer(conn.GetKey()); found {
if found := e.lazyConnMgr.ActivatePeer(conn.GetKey()); found {
if err := conn.Open(ctx); err != nil {
conn.Log.Errorf("failed to open connection: %v", err)
}
@@ -282,21 +268,16 @@ func (e *ConnMgr) Close() {
e.lazyCtxCancel()
e.wg.Wait()
e.lazyConnMgrMu.Lock()
e.lazyConnMgr = nil
e.lazyConnMgrMu.Unlock()
}
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
cfg := manager.Config{
InactivityThreshold: inactivityThresholdEnv(),
}
e.lazyConnMgrMu.Lock()
e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface)
e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx)
e.lazyConnMgrMu.Unlock()
e.wg.Add(1)
go func() {
@@ -335,10 +316,7 @@ func (e *ConnMgr) closeManager(ctx context.Context) {
e.lazyCtxCancel()
e.wg.Wait()
e.lazyConnMgrMu.Lock()
e.lazyConnMgr = nil
e.lazyConnMgrMu.Unlock()
for _, peerID := range e.peerStore.PeersPubKey() {
e.peerStore.PeerConnOpen(ctx, peerID)

View File

@@ -1,21 +1,10 @@
package internal
import (
"context"
"net"
"net/netip"
"os"
"sync"
"testing"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/lazyconn"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/peerstore"
"github.com/netbirdio/netbird/monotime"
)
func TestResolveLazyForce(t *testing.T) {
@@ -49,58 +38,3 @@ func TestResolveLazyForce(t *testing.T) {
})
}
}
type mockLazyWGIface struct{}
func (mockLazyWGIface) RemovePeer(string) error { return nil }
func (mockLazyWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
return nil
}
func (mockLazyWGIface) IsUserspaceBind() bool { return false }
func (mockLazyWGIface) Address() wgaddr.Address { return wgaddr.Address{} }
func (mockLazyWGIface) LastActivities() map[string]monotime.Time { return nil }
func (mockLazyWGIface) MTU() uint16 { return 1280 }
// TestConnMgr_ActivatePeerConcurrentWithLifecycle exercises ActivatePeer from
// non-engine goroutines (the DNS warm-up path) racing the manager lifecycle,
// which stays on the engine loop. Run with -race: it fails if ActivatePeer
// still requires engine.syncMsgMux for safety.
func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) {
t.Setenv(lazyconn.EnvLazyConn, "on")
status := peer.NewRecorder("https://mgm")
store := peerstore.NewConnStore()
connMgr := NewConnMgr(&EngineConfig{}, status, store, mockLazyWGIface{})
conn := newTestPeerConn(t, "peerA")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
connMgr.Start(ctx)
done := make(chan struct{})
var wg sync.WaitGroup
for range 4 {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-done:
return
default:
connMgr.ActivatePeer(ctx, conn)
}
}
}()
}
// Let the activators spin against the started manager, then tear it down
// underneath them and let them spin against the stopped manager.
time.Sleep(100 * time.Millisecond)
connMgr.Close()
time.Sleep(50 * time.Millisecond)
close(done)
wg.Wait()
}

View File

@@ -621,7 +621,6 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
BlockLANAccess: config.BlockLANAccess,
BlockInbound: config.BlockInbound,
DisableIPv6: config.DisableIPv6,
SyncMessageVersion: config.SyncMessageVersion,
LazyConnection: lazyconn.ParseState(config.LazyConnection),
@@ -697,7 +696,6 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
config.BlockLANAccess,
config.BlockInbound,
config.DisableIPv6,
config.SyncMessageVersion,
config.EnableSSHRoot,
config.EnableSSHSFTP,
config.EnableSSHLocalPortForwarding,

View File

@@ -676,7 +676,6 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion))
if g.internalConfig.DisableNotifications != nil {
configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications))

View File

@@ -887,8 +887,6 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
ClientCertKeyPath: "/tmp/key",
LazyConnection: "on",
MTU: 1280,
DisableIPv6: true,
SyncMessageVersion: func(v int) *int { return &v }(1),
}
for _, anonymize := range []bool{false, true} {

View File

@@ -6,7 +6,6 @@ import (
"fmt"
"net"
"net/netip"
"os"
"slices"
"strings"
"sync"
@@ -37,43 +36,7 @@ type resolver interface {
// record is left alone (it points at something outside our mesh, e.g.
// a non-peer upstream).
type PeerConnectivity interface {
IsConnectedByIP(ip netip.Addr) (known, connected bool)
}
// PeerActivator wakes lazy-connection peers on demand. The local resolver calls
// it with the tunnel IPs an answer points at, so a peer that is idle (lazily
// disconnected) starts connecting at DNS-resolution time rather than racing the
// client's first request packet. nil disables warm-up.
type PeerActivator interface {
// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and blocks
// until one is connected or ctx (a short per-query budget) expires. It is a
// fast no-op for unknown or already-connected addresses.
ActivatePeersByIP(ctx context.Context, addrs []netip.Addr)
}
const (
defaultLazyWarmupTimeout = 2 * time.Second
envLazyWarmupTimeout = "NB_DNS_LAZY_WARMUP_TIMEOUT"
)
// lazyWarmupTimeoutFromEnv returns the per-query budget for waking a
// lazy-connection peer a DNS answer points at. Tunable via
// NB_DNS_LAZY_WARMUP_TIMEOUT (a Go duration). Parsed once at construction time.
func lazyWarmupTimeoutFromEnv() time.Duration {
v := os.Getenv(envLazyWarmupTimeout)
if v == "" {
return defaultLazyWarmupTimeout
}
d, err := time.ParseDuration(v)
if err != nil {
log.Warnf("invalid %s value %q, using default %s: %v", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout, err)
return defaultLazyWarmupTimeout
}
if d <= 0 {
log.Warnf("non-positive %s value %q, using default %s", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout)
return defaultLazyWarmupTimeout
}
return d
IsConnectedByIP(ip string) (known, connected bool)
}
type Resolver struct {
@@ -88,12 +51,6 @@ type Resolver struct {
// filter and preserves the legacy "return whatever is registered"
// behaviour for callers that never wire a status source.
peerConn PeerConnectivity
// peerActivator, when non-nil, is called at resolution time to warm the
// lazy connection to the peer(s) an answer points at. nil disables warm-up.
peerActivator PeerActivator
// warmupTimeout is the per-query budget for the lazy-connection warm-up
// wait, resolved from the environment once at construction time.
warmupTimeout time.Duration
ctx context.Context
cancel context.CancelFunc
@@ -102,12 +59,11 @@ type Resolver struct {
func NewResolver() *Resolver {
ctx, cancel := context.WithCancel(context.Background())
return &Resolver{
records: make(map[dns.Question][]dns.RR),
domains: make(map[domain.Domain]struct{}),
zones: make(map[domain.Domain]bool),
warmupTimeout: lazyWarmupTimeoutFromEnv(),
ctx: ctx,
cancel: cancel,
records: make(map[dns.Question][]dns.RR),
domains: make(map[domain.Domain]struct{}),
zones: make(map[domain.Domain]bool),
ctx: ctx,
cancel: cancel,
}
}
@@ -120,14 +76,6 @@ func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) {
d.peerConn = p
}
// SetPeerActivator wires the DNS-time lazy-connection warm-up. Pass nil to
// disable. Safe to call multiple times; the latest value wins.
func (d *Resolver) SetPeerActivator(a PeerActivator) {
d.mu.Lock()
defer d.mu.Unlock()
d.peerActivator = a
}
func (d *Resolver) MatchSubdomains() bool {
return true
}
@@ -174,9 +122,6 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
replyMessage.RecursionAvailable = true
result := d.lookupRecords(logger, question)
// Warm before filtering: activation flips a lazily-idle target to connected,
// which then lets it survive the disconnected-peer filter below.
d.warmLazyPeers(question, result.records)
result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records)
replyMessage.Authoritative = !result.hasExternalData
replyMessage.Answer = result.records
@@ -550,8 +495,8 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
kept := make([]dns.RR, 0, len(records))
var dropped int
for _, rr := range records {
ip, ok := extractRecordAddr(rr)
if !ok {
ip := extractRecordIP(rr)
if ip == "" {
kept = append(kept, rr)
continue
}
@@ -573,57 +518,22 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
return kept
}
// warmLazyPeers triggers lazy-connection wake-up for the peers a resolved
// answer points at and waits briefly for one to connect, so the caller's first
// request doesn't race the connection establishment. Warm-up is scoped to
// match-only (non-authoritative) zones — the synthesized private-service zones
// and user-created zones whose records point at specific peers. The account's
// peer zone is authoritative, so plain peer-name lookups never trigger warm-up;
// otherwise resolving any peer's name would wake its idle connection, defeating
// laziness mesh-wide. No-op when no activator is wired (lazy connections
// disabled) or the answer carries no peer IPs.
func (d *Resolver) warmLazyPeers(question dns.Question, records []dns.RR) {
if len(records) < 2 {
return
}
d.mu.RLock()
activator := d.peerActivator
var nonAuth, found bool
if activator != nil {
nonAuth, found = d.findZone(question.Name)
}
d.mu.RUnlock()
if activator == nil || !found || !nonAuth {
return
}
var addrs []netip.Addr
for _, rr := range records {
if addr, ok := extractRecordAddr(rr); ok {
addrs = append(addrs, addr)
}
}
if len(addrs) == 0 {
return
}
ctx, cancel := context.WithTimeout(d.ctx, d.warmupTimeout)
defer cancel()
activator.ActivatePeersByIP(ctx, addrs)
}
// extractRecordAddr returns the IP address carried by an A or AAAA record.
// ok is false for any other record type or a record with no address.
func extractRecordAddr(rr dns.RR) (netip.Addr, bool) {
// extractRecordIP returns the dotted-decimal / colon-hex IP carried by
// an A or AAAA record, or "" for any other record type.
func extractRecordIP(rr dns.RR) string {
switch r := rr.(type) {
case *dns.A:
addr, ok := netip.AddrFromSlice(r.A)
return addr.Unmap(), ok
if r.A == nil {
return ""
}
return r.A.String()
case *dns.AAAA:
addr, ok := netip.AddrFromSlice(r.AAAA)
return addr.Unmap(), ok
if r.AAAA == nil {
return ""
}
return r.AAAA.String()
}
return netip.Addr{}, false
return ""
}
// Update replaces all zones and their records

View File

@@ -37,8 +37,8 @@ type mockPeerConnectivity struct {
byIP map[string]struct{ known, connected bool }
}
func (m mockPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
v, ok := m.byIP[ip.String()]
func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
v, ok := m.byIP[ip]
if !ok {
return false, false
}

View File

@@ -1,204 +0,0 @@
package local
import (
"context"
"net"
"net/netip"
"sync"
"testing"
"time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/dns/test"
nbdns "github.com/netbirdio/netbird/dns"
)
// recordingActivator records the addresses it was asked to warm and returns
// immediately, so ServeDNS is not blocked by the test.
type recordingActivator struct {
mu sync.Mutex
called bool
addrs []netip.Addr
}
func (r *recordingActivator) ActivatePeersByIP(_ context.Context, addrs []netip.Addr) {
r.mu.Lock()
defer r.mu.Unlock()
r.called = true
r.addrs = append(r.addrs, addrs...)
}
func serveA(t *testing.T, resolver *Resolver, name string) *dns.Msg {
t.Helper()
var resp *dns.Msg
w := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}
resolver.ServeDNS(w, new(dns.Msg).SetQuestion(name, dns.TypeA))
return resp
}
// serviceZone registers rec in a match-only (non-authoritative) zone, the shape
// the synthesized private-service zones arrive in.
func serviceZone(t *testing.T, resolver *Resolver, zone string, records ...nbdns.SimpleRecord) {
t.Helper()
resolver.Update([]nbdns.CustomZone{{
Domain: zone,
Records: records,
NonAuthoritative: true,
}})
}
func TestLocalResolver_WarmsLazyPeerOnResolve(t *testing.T) {
// Warm-up fires only for multi-record answers (the HA / round-robin shape of
// the synthesized private-service zones), so register two peer targets.
const name = "svc.proxy.netbird.cloud."
recs := []nbdns.SimpleRecord{
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"},
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.8"},
}
resolver := NewResolver()
serviceZone(t, resolver, "proxy.netbird.cloud", recs...)
act := &recordingActivator{}
resolver.SetPeerActivator(act)
resp := serveA(t, resolver, name)
require.NotNil(t, resp, "resolver must answer")
require.NotEmpty(t, resp.Answer, "answer must carry the A records")
act.mu.Lock()
defer act.mu.Unlock()
assert.True(t, act.called, "activator must be invoked for a multi-record service-zone answer")
assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.7"), "activator must receive the first peer IP")
assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.8"), "activator must receive the second peer IP")
}
func TestLocalResolver_NoWarmupForSingleRecord(t *testing.T) {
// A single-record answer does not trigger warm-up; the resolver only warms
// multi-record answers.
rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
resolver := NewResolver()
serviceZone(t, resolver, "proxy.netbird.cloud", rec)
act := &recordingActivator{}
resolver.SetPeerActivator(act)
resp := serveA(t, resolver, rec.Name)
require.NotNil(t, resp, "resolver must answer")
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
act.mu.Lock()
defer act.mu.Unlock()
assert.False(t, act.called, "activator must not be invoked for a single-record answer")
}
func TestLocalResolver_NoActivatorNoWarmup(t *testing.T) {
// With no activator wired the resolver behaves exactly as before.
rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
resolver := NewResolver()
serviceZone(t, resolver, "proxy.netbird.cloud", rec)
resp := serveA(t, resolver, rec.Name)
require.NotNil(t, resp, "resolver must still answer without an activator")
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
}
func TestLocalResolver_NoWarmupForMissingRecord(t *testing.T) {
// A query that resolves to nothing must not invoke the activator (no IPs).
resolver := NewResolver()
serviceZone(t, resolver, "proxy.netbird.cloud",
nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"})
act := &recordingActivator{}
resolver.SetPeerActivator(act)
serveA(t, resolver, "absent.proxy.netbird.cloud.")
act.mu.Lock()
defer act.mu.Unlock()
assert.False(t, act.called, "activator must not be invoked when there is no answer")
}
func TestLocalResolver_NoWarmupInAuthoritativeZone(t *testing.T) {
// The account's peer zone is authoritative; resolving a peer's name there
// must not wake its lazy connection — warm-up is scoped to match-only
// (non-authoritative) zones such as the synthesized private-service zones.
// Use a multi-record answer so the authoritative-zone scoping is the only
// reason warm-up is skipped, not the single-record guard.
const name = "peer.netbird.cloud."
recs := []nbdns.SimpleRecord{
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.9"},
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.10"},
}
resolver := NewResolver()
resolver.Update([]nbdns.CustomZone{{
Domain: "netbird.cloud",
Records: recs,
}})
act := &recordingActivator{}
resolver.SetPeerActivator(act)
resp := serveA(t, resolver, name)
require.NotNil(t, resp, "resolver must answer")
require.NotEmpty(t, resp.Answer, "answer must carry the A records")
act.mu.Lock()
defer act.mu.Unlock()
assert.False(t, act.called, "activator must not be invoked for authoritative-zone answers")
}
func TestLazyWarmupTimeoutFromEnv(t *testing.T) {
tests := []struct {
name string
value string
envSet bool
want time.Duration
}{
{name: "unset uses default", want: defaultLazyWarmupTimeout},
{name: "valid overrides", value: "5s", envSet: true, want: 5 * time.Second},
{name: "invalid falls back", value: "not-a-duration", envSet: true, want: defaultLazyWarmupTimeout},
{name: "negative falls back", value: "-1s", envSet: true, want: defaultLazyWarmupTimeout},
{name: "zero falls back", value: "0s", envSet: true, want: defaultLazyWarmupTimeout},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envSet {
t.Setenv(envLazyWarmupTimeout, tt.value)
}
assert.Equal(t, tt.want, lazyWarmupTimeoutFromEnv())
assert.Equal(t, tt.want, NewResolver().warmupTimeout, "constructor must resolve the timeout once")
})
}
}
func TestExtractRecordAddr(t *testing.T) {
t.Run("A record yields unmapped v4", func(t *testing.T) {
// net.ParseIP returns the 16-byte v4-in-v6 form, the same shape
// miekg/dns stores after parsing an A record; the extracted address
// must compare equal to a plain v4 netip.Addr.
addr, ok := extractRecordAddr(&dns.A{A: net.ParseIP("100.64.0.7")})
require.True(t, ok)
assert.True(t, addr.Is4())
assert.Equal(t, netip.MustParseAddr("100.64.0.7"), addr)
})
t.Run("AAAA record yields v6", func(t *testing.T) {
addr, ok := extractRecordAddr(&dns.AAAA{AAAA: net.ParseIP("fd00::1")})
require.True(t, ok)
assert.Equal(t, netip.MustParseAddr("fd00::1"), addr)
})
t.Run("A record without address", func(t *testing.T) {
_, ok := extractRecordAddr(&dns.A{})
assert.False(t, ok)
})
t.Run("non-address record", func(t *testing.T) {
_, ok := extractRecordAddr(&dns.CNAME{Target: "target.netbird.cloud."})
assert.False(t, ok)
})
}

View File

@@ -8,7 +8,6 @@ import (
"github.com/miekg/dns"
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
"github.com/netbirdio/netbird/client/internal/dns/local"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -93,11 +92,6 @@ func (m *MockServer) SetFirewall(Firewall) {
// Mock implementation - no-op
}
// SetPeerActivator mock implementation of SetPeerActivator from Server interface
func (m *MockServer) SetPeerActivator(local.PeerActivator) {
// Mock implementation - no-op
}
// BeginBatch mock implementation of BeginBatch from Server interface
func (m *MockServer) BeginBatch() {
// Mock implementation - no-op

View File

@@ -82,7 +82,6 @@ type Server interface {
PopulateManagementDomain(mgmtURL *url.URL) error
SetRouteSources(selected, active func() route.HAMap)
SetFirewall(Firewall)
SetPeerActivator(local.PeerActivator)
}
type nsGroupsByDomain struct {
@@ -492,13 +491,6 @@ func (s *DefaultServer) SetFirewall(fw Firewall) {
}
}
// SetPeerActivator wires the DNS-time lazy-connection warm-up on the local
// resolver. Injected after the connection manager exists (it does not at
// DNS-server construction time). Pass nil to disable.
func (s *DefaultServer) SetPeerActivator(a local.PeerActivator) {
s.localResolver.SetPeerActivator(a)
}
// Stop stops the server
func (s *DefaultServer) Stop() {
s.ctxCancel()
@@ -1443,11 +1435,11 @@ type localPeerConnectivity struct {
// IsConnectedByIP looks the IP up in the peerstore and surfaces both
// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
func (l localPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
if l.status == nil {
return false, false
}
state, ok := l.status.PeerStateByIP(ip.String())
state, ok := l.status.PeerStateByIP(ip)
if !ok {
return false, false
}

View File

@@ -1,76 +0,0 @@
package internal
import (
"context"
"net/netip"
"time"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/peerstore"
)
const dnsActivationPollInterval = 50 * time.Millisecond
// dnsPeerActivator wakes lazy-connection peers from the DNS resolution path. It
// implements dns/local.PeerActivator. DNS queries run on their own goroutines,
// so it only touches state that is safe for concurrent use — ConnMgr.ActivatePeer,
// peerstore.Store and peer.Status — and never takes the engine's syncMsgMux,
// keeping DNS resolution from contending with network-map processing.
type dnsPeerActivator struct {
connMgr *ConnMgr
peerStore *peerstore.Store
status *peer.Status
// ctx is the engine's long-lived context. The connection dial is tied to it
// (not the per-query DNS wait budget) so a handshake that outlasts the wait
// still completes in the background rather than being cancelled at the deadline.
ctx context.Context
}
// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and waits
// until one is connected or ctx (the per-query DNS wait budget) expires.
// Activation itself is tied to the engine's long-lived context so the dial
// survives a wait that times out. Unknown or already-connected addresses are
// skipped, so the steady-state (warm) path adds no latency.
func (a *dnsPeerActivator) ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) {
if a == nil || a.connMgr == nil {
return
}
var pending []string
for _, addr := range addrs {
ip := addr.String()
st, ok := a.status.PeerStateByIP(ip)
if !ok || st.ConnStatus == peer.StatusConnected {
continue
}
conn, ok := a.peerStore.PeerConn(st.PubKey)
if !ok {
continue
}
a.connMgr.ActivatePeer(a.ctx, conn)
pending = append(pending, ip)
}
if len(pending) == 0 {
return
}
a.waitConnected(ctx, pending)
}
// waitConnected blocks until any of ips reports a connected peer or ctx expires.
func (a *dnsPeerActivator) waitConnected(ctx context.Context, ips []string) {
ticker := time.NewTicker(dnsActivationPollInterval)
defer ticker.Stop()
for {
for _, ip := range ips {
if st, ok := a.status.PeerStateByIP(ip); ok && st.ConnStatus == peer.StatusConnected {
return
}
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}

View File

@@ -1,129 +0,0 @@
package internal
import (
"context"
"net/netip"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/peerstore"
)
func newTestPeerConn(t *testing.T, key string) *peer.Conn {
t.Helper()
conn, err := peer.NewConn(peer.ConnConfig{
Key: key,
LocalKey: "local",
WgConfig: peer.WgConfig{
AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
}, peer.ServiceDependencies{})
require.NoError(t, err)
return conn
}
func newTestDNSPeerActivator(t *testing.T) (*dnsPeerActivator, *peer.Status, *peerstore.Store) {
t.Helper()
status := peer.NewRecorder("https://mgm")
store := peerstore.NewConnStore()
// ConnMgr without Start: the lazy manager is nil, so ActivatePeer is a
// no-op — these tests exercise the activator's skip/wait logic.
connMgr := NewConnMgr(&EngineConfig{}, status, store, nil)
return &dnsPeerActivator{
connMgr: connMgr,
peerStore: store,
status: status,
ctx: context.Background(),
}, status, store
}
func TestDNSPeerActivator_NilSafe(t *testing.T) {
var a *dnsPeerActivator
a.ActivatePeersByIP(context.Background(), []netip.Addr{netip.MustParseAddr("100.64.0.1")})
}
// TestDNSPeerActivator_SkipsUnknownAndConnectedPeers verifies the steady-state
// (warm) path adds no latency: already-connected and unknown addresses never
// enter the wait loop.
func TestDNSPeerActivator_SkipsUnknownAndConnectedPeers(t *testing.T) {
a, status, store := newTestDNSPeerActivator(t)
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "fd00::1"))
require.NoError(t, status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected}))
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
start := time.Now()
a.ActivatePeersByIP(ctx, []netip.Addr{
netip.MustParseAddr("100.64.0.1"), // known, connected -> skipped
netip.MustParseAddr("fd00::1"), // known via IPv6, connected -> skipped
netip.MustParseAddr("100.64.0.99"), // unknown -> skipped
})
require.Less(t, time.Since(start), time.Second, "no pending peer must mean no wait")
}
// TestDNSPeerActivator_WaitsForPendingPeerToConnect verifies the wait loop
// returns as soon as a pending peer reports connected, well before the
// per-query budget expires.
func TestDNSPeerActivator_WaitsForPendingPeerToConnect(t *testing.T) {
a, status, store := newTestDNSPeerActivator(t)
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
go func() {
time.Sleep(150 * time.Millisecond)
_ = status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected})
}()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
start := time.Now()
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
elapsed := time.Since(start)
require.GreaterOrEqual(t, elapsed, 100*time.Millisecond, "must wait for the pending peer")
require.Less(t, elapsed, 5*time.Second, "must return on connect, not at the deadline")
}
// TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle verifies a peer that
// never connects releases the DNS response at the per-query budget instead of
// blocking it indefinitely.
func TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle(t *testing.T) {
a, status, store := newTestDNSPeerActivator(t)
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
start := time.Now()
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
elapsed := time.Since(start)
require.GreaterOrEqual(t, elapsed, 250*time.Millisecond, "must wait out the budget for a pending peer")
require.Less(t, elapsed, 5*time.Second, "must not block past the budget")
}
// TestDNSPeerActivator_NoWaitWithoutPeerConn verifies a known-but-idle peer
// with no connection object in the store is not waited on: there is nothing to
// activate, so waiting could only ever time out.
func TestDNSPeerActivator_NoWaitWithoutPeerConn(t *testing.T) {
a, status, _ := newTestDNSPeerActivator(t)
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
start := time.Now()
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
require.Less(t, time.Since(start), time.Second, "peer without a conn must not be waited on")
}

View File

@@ -64,10 +64,7 @@ import (
"github.com/netbirdio/netbird/route"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
types "github.com/netbirdio/netbird/shared/management/types"
"github.com/netbirdio/netbird/shared/netiputil"
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
@@ -150,7 +147,6 @@ type EngineConfig struct {
BlockLANAccess bool
BlockInbound bool
DisableIPv6 bool
SyncMessageVersion *int
// LazyConnection is the MDM-sourced lazy-connection override; StateUnset defers to
// the env var and management feature flag.
@@ -224,13 +220,6 @@ type Engine struct {
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
networkSerial uint64
// latestComponents is the most-recent NetworkMapComponents decoded from
// a NetworkMapEnvelope (capability=3 peers only). Held alongside the
// NetworkMap that Calculate() produced from it so future incremental
// updates have a base to apply changes against. nil for legacy-format
// peers. Guarded by syncMsgMux.
latestComponents *types.NetworkMapComponents
networkMonitor *networkmonitor.NetworkMonitor
sshServer sshServer
@@ -665,16 +654,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface)
e.connMgr.Start(e.ctx)
// Wire DNS-time lazy-connection warm-up now that the connection manager
// exists (it does not at DNS-server construction time). A DNS answer that
// points at an idle peer then wakes it before the client's first request.
e.dnsServer.SetPeerActivator(&dnsPeerActivator{
connMgr: e.connMgr,
peerStore: e.peerStore,
status: e.statusRecorder,
ctx: e.ctx,
})
e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg)
e.srWatcher.Start(peer.IsForceRelayed())
@@ -984,12 +963,8 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
e.ApplySessionDeadline(update.GetSessionExpiresAt())
// Envelope sync responses carry PeerConfig at the top level; legacy
// NetworkMap syncs carry it under NetworkMap.PeerConfig.
if pc := update.GetPeerConfig(); pc != nil {
e.handleAutoUpdateVersion(pc.GetAutoUpdate())
} else if nm := update.GetNetworkMap(); nm != nil && nm.GetPeerConfig() != nil {
e.handleAutoUpdateVersion(nm.GetPeerConfig().GetAutoUpdate())
if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
}
done := e.phase("netbird_config")
@@ -999,47 +974,12 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return err
}
// Decode the network map from either the components envelope or the
// legacy proto.NetworkMap before the posture-check gating below, so the
// "is there a network map" decision covers both wire shapes.
var (
nm *mgmProto.NetworkMap
components *types.NetworkMapComponents
)
if version := update.GetVersion(); version == int32(sharedgrpc.ComponentNetworkMap) {
// Components-format peer: decode the envelope back to typed
// components, run Calculate() locally, and convert to the wire
// NetworkMap shape the rest of the engine consumes. Components are
// retained so future incremental updates can apply deltas instead
// of doing a full reconstruction.
envelope := update.GetNetworkMapEnvelope()
if envelope == nil {
return fmt.Errorf("received a SyncReponse indicating use of components network map, but components are missing")
}
localKey := e.config.WgPrivateKey.PublicKey().String()
dnsName := ""
if pc := update.GetPeerConfig(); pc != nil {
// PeerConfig.Fqdn = "<dns_label>.<dns_domain>" — extract the
// shared domain by stripping the peer's own label prefix. Falls
// back to empty if the FQDN doesn't have the expected shape.
dnsName = extractDNSDomainFromFQDN(pc.GetFqdn())
}
result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName)
if err != nil {
return fmt.Errorf("decode network map envelope: %w", err)
}
nm = result.NetworkMap
components = result.Components
} else {
nm = update.GetNetworkMap()
}
// Posture checks are bound to the network map presence:
// NetworkMap != nil, checks present -> apply the received checks
// NetworkMap != nil, checks nil -> posture checks were removed, clear them
// NetworkMap == nil -> config-only update (e.g. relay token rotation),
// leave the previously applied checks untouched
nm := update.GetNetworkMap()
if nm == nil {
return nil
}
@@ -1052,14 +992,6 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
}
done = e.phase("persist")
// Only retain the components view when the server sent the envelope
// path. A legacy proto.NetworkMap means components == nil; writing it
// here would clobber a previously-cached snapshot, breaking the
// incremental-delta base on a future envelope sync.
if components != nil {
e.latestComponents = components
}
e.persistSyncResponse(update)
done()
@@ -1073,19 +1005,6 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return nil
}
// extractDNSDomainFromFQDN returns the trailing dotted domain part of the
// receiving peer's FQDN — the same value the management server fills as
// dnsName when it builds the legacy NetworkMap. "peer42.netbird.cloud" →
// "netbird.cloud". An empty string is returned for unrecognized formats.
func extractDNSDomainFromFQDN(fqdn string) string {
for i := 0; i < len(fqdn); i++ {
if fqdn[i] == '.' && i+1 < len(fqdn) {
return fqdn[i+1:]
}
}
return ""
}
// updateNetbirdConfig applies the management-provided NetBird configuration:
// STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op,
// which is the case for sync updates carrying only a network map.
@@ -1245,7 +1164,6 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
e.config.BlockLANAccess,
e.config.BlockInbound,
e.config.DisableIPv6,
e.config.SyncMessageVersion,
e.config.EnableSSHRoot,
e.config.EnableSSHSFTP,
e.config.EnableSSHLocalPortForwarding,
@@ -2114,7 +2032,6 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
e.config.BlockLANAccess,
e.config.BlockInbound,
e.config.DisableIPv6,
e.config.SyncMessageVersion,
e.config.EnableSSHRoot,
e.config.EnableSSHSFTP,
e.config.EnableSSHLocalPortForwarding,

View File

@@ -0,0 +1,90 @@
package ipcauth
import (
"slices"
"strconv"
)
// Ownership is a profile's access policy: the typed owner principals plus the
// opt-in shared flag.
type Ownership struct {
Owners []string
Shared bool
}
// GroupResolver resolves a Unix caller's effective group IDs (primary +
// supplementary, NSS-aware) and owner group names to GIDs. It is only consulted
// for Unix `gid:`/`group:` owners; Windows uses the SIDs carried in the Identity.
// A nil resolver disables group matching.
type GroupResolver interface {
// CallerGIDs returns the set of group IDs the caller belongs to.
CallerGIDs(id Identity) map[uint32]struct{}
// GroupNameGID resolves a group name to its GID.
GroupNameGID(name string) (uint32, bool)
}
// Authorize reports whether the identity may control a profile with the given
// ownership. Privileged callers (root / elevated-admin / LocalSystem) and shared
// profiles are always allowed; otherwise the identity must match one of the
// owner principals.
func Authorize(o Ownership, id Identity, r GroupResolver) bool {
if id.IsPrivileged() {
return true
}
if o.Shared {
return true
}
for _, raw := range o.Owners {
p, ok := ParsePrincipal(raw)
if !ok {
continue
}
if principalMatches(p, id, r) {
return true
}
}
return false
}
func principalMatches(p Principal, id Identity, r GroupResolver) bool {
switch p.Kind {
case KindUID:
if id.IsWindows() {
return false
}
uid, err := strconv.ParseUint(p.Value, 10, 32)
return err == nil && uint32(uid) == id.UID
case KindGID:
if id.IsWindows() {
return false
}
gid, err := strconv.ParseUint(p.Value, 10, 32)
return err == nil && callerHasGID(uint32(gid), id, r)
case KindGroup:
if id.IsWindows() || r == nil {
return false
}
gid, ok := r.GroupNameGID(p.Value)
return ok && callerHasGID(gid, id, r)
case KindSID:
if !id.IsWindows() {
return false
}
return id.SID == p.Value || slices.Contains(id.Groups, p.Value)
default:
return false
}
}
// callerHasGID reports whether gid is the caller's primary GID (from peercred,
// no lookup) or one of their supplementary groups (NSS-resolved via r).
func callerHasGID(gid uint32, id Identity, r GroupResolver) bool {
if id.GID == gid {
return true
}
if r == nil {
return false
}
_, ok := r.CallerGIDs(id)[gid]
return ok
}

View File

@@ -0,0 +1,12 @@
//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.
func NewTransportCredentials() credentials.TransportCredentials {
return nil
}

View 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. 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), so an ordinary insecure client still works.
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 }

View File

@@ -0,0 +1,144 @@
//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 connection gate; the interceptor still
// does per-RPC authorization by caller identity.
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.
//
// It uses ImpersonateNamedPipeClient, which binds the client's security context
// to the *connection* in the kernel — there is no PID lookup, so it is immune to
// the PID-reuse race that OpenProcess-by-PID would have. Per threat-model
// M-NOIMP, impersonation is used only to read the client token, then reverted
// immediately; the daemon never performs privileged work while impersonating.
// This requires the client to dial at SECURITY_IDENTIFICATION (see dialNamedPipe).
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. 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, enabled group SIDs,
// and elevation by impersonating the pipe client on this thread and reading the
// impersonation token. Impersonation is connection-bound (no PID race) and the
// window is kept minimal and pinned to the OS thread (impersonation is thread-local).
func pipeClientIdentity(handle windows.Handle) (Identity, error) {
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,
Elevated: token.IsElevated(),
HasPID: false,
}, 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
}

View File

@@ -0,0 +1,72 @@
package ipcauth
import (
"context"
"strconv"
"google.golang.org/grpc/metadata"
)
// Metadata keys used by the local JSON gateway to forward the HTTP client's
// identity to the daemon. Trusted by the interceptor ONLY when the gRPC peer is
// itself the daemon (self/privileged) — i.e. the loopback gateway — so a direct
// gRPC caller cannot forge them.
const (
mdFwdUID = "x-netbird-fwd-uid"
mdFwdGID = "x-netbird-fwd-gid"
mdFwdPID = "x-netbird-fwd-pid"
)
// ForwardIdentityMetadata encodes a Unix identity for the gateway to forward to
// the daemon. Windows identities are not forwarded (the gateway cannot read a
// pipe token for an HTTP client); nil is returned in that case.
func ForwardIdentityMetadata(id Identity) metadata.MD {
if id.IsWindows() {
return nil
}
md := metadata.Pairs(
mdFwdUID, strconv.FormatUint(uint64(id.UID), 10),
mdFwdGID, strconv.FormatUint(uint64(id.GID), 10),
)
if id.HasPID {
md.Set(mdFwdPID, strconv.FormatInt(int64(id.PID), 10))
}
return md
}
// forwardedIdentity extracts a forwarded Unix identity from incoming gRPC
// metadata, if present and well-formed.
func forwardedIdentity(ctx context.Context) (Identity, bool) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return Identity{}, false
}
uidStr := mdFirst(md, mdFwdUID)
if uidStr == "" {
return Identity{}, false
}
uid, err := strconv.ParseUint(uidStr, 10, 32)
if err != nil {
return Identity{}, false
}
id := Identity{UID: uint32(uid)}
if g := mdFirst(md, mdFwdGID); g != "" {
if v, err := strconv.ParseUint(g, 10, 32); err == nil {
id.GID = uint32(v)
}
}
if p := mdFirst(md, mdFwdPID); p != "" {
if v, err := strconv.ParseInt(p, 10, 32); err == nil {
id.PID = int32(v)
id.HasPID = true
}
}
return id, true
}
func mdFirst(md metadata.MD, key string) string {
if v := md.Get(key); len(v) > 0 {
return v[0]
}
return ""
}

View File

@@ -0,0 +1,106 @@
// Package ipcauth provides the kernel-authenticated identity of a local IPC
// (gRPC) caller and the transport credentials that surface it into the gRPC
// context, so the daemon can authorize each RPC by caller identity.
//
// 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 gains an identity primitive).
package ipcauth
import (
"context"
"fmt"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/peer"
)
// sidLocalSystem is the well-known Windows SID for the LocalSystem account.
const sidLocalSystem = "S-1-5-18"
// Identity is the kernel-authenticated identity of a local IPC caller. The zero
// value is not a valid identity; 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 (empty on Unix, where supplementary group membership is
// resolved on demand via NSS/getent by the authorizer).
Groups []string
// Elevated reports whether the Windows client token is elevated (run as
// administrator). Always false on Unix, where privilege is uid==0.
Elevated bool
}
// 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 != ""
}
// IsPrivileged reports whether the caller is the platform's administrative
// principal — Unix root (uid 0), or on Windows an elevated token or LocalSystem.
// It deliberately requires actual elevation on Windows (a non-elevated member of
// Administrators has a filtered token and is NOT privileged), mirroring "must
// really be root" on Unix.
func (i Identity) IsPrivileged() bool {
if i.IsWindows() {
return i.Elevated || i.SID == sidLocalSystem
}
return i.UID == 0
}
// String renders the identity for audit logs.
func (i Identity) String() string {
if i.IsWindows() {
if i.HasPID {
return fmt.Sprintf("sid=%s elevated=%t pid=%d", i.SID, i.Elevated, i.PID)
}
return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated)
}
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
}

View File

@@ -0,0 +1,50 @@
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_IsPrivileged(t *testing.T) {
// Unix
assert.True(t, Identity{UID: 0}.IsPrivileged(), "root is privileged")
assert.False(t, Identity{UID: 1000}.IsPrivileged(), "non-root is not privileged")
// Windows
assert.True(t, Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}.IsPrivileged(), "elevated admin is privileged")
assert.True(t, Identity{SID: "S-1-5-18"}.IsPrivileged(), "LocalSystem is privileged")
assert.False(t, Identity{SID: "S-1-5-21-1-2-3-1001"}.IsPrivileged(), "non-elevated admin is NOT privileged")
}
func TestIdentity_IsWindows(t *testing.T) {
assert.True(t, Identity{SID: "S-1-5-18"}.IsWindows())
assert.False(t, Identity{UID: 0}.IsWindows())
}

View File

@@ -0,0 +1,124 @@
package ipcauth
import (
"context"
"os"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Interceptor enforces per-RPC authorization on the daemon control channel,
// keyed to the caller's kernel-authenticated identity. It is safe-by-default:
// any RPC without a matching bypass is gated by the active profile's ownership,
// and a caller without a readable identity is denied.
type Interceptor struct {
policy ProfilePolicy
resolver GroupResolver
// selfUID is the daemon's own effective UID. A caller whose UID matches it
// (rootless container / foreground daemon running as the invoking user) is
// allowed: it already has full control of the daemon process. -1 on Windows.
selfUID int
}
// NewInterceptor builds an interceptor over the given policy and group resolver.
func NewInterceptor(policy ProfilePolicy, resolver GroupResolver) *Interceptor {
return &Interceptor{policy: policy, resolver: resolver, selfUID: os.Geteuid()}
}
// UnaryServerInterceptor authorizes each unary RPC before the handler runs.
func (i *Interceptor) UnaryServerInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
if err := i.authorize(ctx, info.FullMethod); err != nil {
return nil, err
}
return handler(ctx, req)
}
}
// StreamServerInterceptor authorizes each streaming RPC before the handler runs.
func (i *Interceptor) StreamServerInterceptor() grpc.StreamServerInterceptor {
return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
if err := i.authorize(ss.Context(), info.FullMethod); err != nil {
return err
}
return handler(srv, ss)
}
}
func (i *Interceptor) authorize(ctx context.Context, fullMethod string) error {
id, ok := IdentityFromContext(ctx)
if !ok {
log.Warnf("ipc authz: DENY %s — caller identity unavailable", fullMethod)
return status.Error(codes.PermissionDenied, "caller identity could not be verified on the daemon control channel")
}
if i.isSelfOrPrivileged(id) {
// The local JSON gateway connects as the daemon itself (self/privileged)
// and forwards the real HTTP client's identity. Trust it here — and only
// here, where the transport peer is already the daemon — then authorize
// as the forwarded client. A direct non-privileged caller never reaches
// this branch, so it cannot forge the forwarding metadata.
fwd, hasFwd := forwardedIdentity(ctx)
if !hasFwd {
i.auditAllow(id, fullMethod)
return nil
}
log.Infof("ipc authz: honoring gateway-forwarded identity %s", fwd)
id = fwd
if i.isSelfOrPrivileged(id) {
i.auditAllow(id, fullMethod)
return nil
}
}
// Per-user / per-target-profile RPCs authorize themselves in the handler.
if handlerAuthorizedMethods[fullMethod] {
return nil
}
o := i.policy.ActiveProfileOwnership()
// Trust-on-first-use: an unowned, non-shared profile is claimed by the first
// caller. The claim is atomic; if we lose the race we re-read and authorize.
if len(o.Owners) == 0 && !o.Shared {
claimed, err := i.policy.ClaimActiveProfileOwnerIfUnowned(id)
if err != nil {
log.Errorf("ipc authz: claim active profile for %s: %v", id, err)
return status.Error(codes.Internal, "failed to claim profile ownership")
}
if claimed {
log.Infof("ipc authz: %s claimed ownership of the active profile (trust-on-first-use)", id)
i.auditAllow(id, fullMethod)
return nil
}
o = i.policy.ActiveProfileOwnership()
}
if Authorize(o, id, i.resolver) {
i.auditAllow(id, fullMethod)
return nil
}
log.Warnf("ipc authz: DENY %s for %s — active profile owned by another principal", fullMethod, id)
return status.Errorf(codes.PermissionDenied,
"not authorized to control the active profile (caller %s); ask an owner or run as root/administrator", id)
}
// isSelfOrPrivileged reports whether the caller is the platform administrator
// (root / elevated-admin / LocalSystem) or the daemon's own user.
func (i *Interceptor) isSelfOrPrivileged(id Identity) bool {
if id.IsPrivileged() {
return true
}
// Daemon-self: only meaningful on Unix (Windows privilege is covered above).
return !id.IsWindows() && i.selfUID >= 0 && int(id.UID) == i.selfUID
}
func (i *Interceptor) auditAllow(id Identity, fullMethod string) {
if auditMethods[fullMethod] {
log.Infof("ipc authz: allow %s for %s", fullMethod, id)
}
}

View File

@@ -0,0 +1,139 @@
package ipcauth
import (
"context"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
)
type mockPolicy struct {
o Ownership
claimed bool
}
func (m *mockPolicy) ActiveProfileOwnership() Ownership { return m.o }
// ClaimActiveProfileOwnerIfUnowned records a claim and marks the profile owned.
func (m *mockPolicy) ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error) {
if len(m.o.Owners) == 0 && !m.o.Shared {
m.o.Owners = []string{OwnerPrincipalForIdentity(id)}
m.claimed = true
return true, nil
}
return false, nil
}
type mockResolver struct {
gids map[uint32]struct{}
names map[string]uint32
}
func (m mockResolver) CallerGIDs(Identity) map[uint32]struct{} { return m.gids }
func (m mockResolver) GroupNameGID(n string) (uint32, bool) { g, ok := m.names[n]; return g, ok }
func ctxWith(id Identity) context.Context {
return peer.NewContext(context.Background(), &peer.Peer{AuthInfo: AuthInfo{Identity: id}})
}
const (
up = servicePath + "Up"
list = servicePath + "ListProfiles"
unkwn = servicePath + "SomeFutureMethod"
)
func TestInterceptorAuthorize(t *testing.T) {
const selfUID = 4000
tests := []struct {
name string
own Ownership
resolver GroupResolver
ctx context.Context
method string
wantErr bool
}{
{"no identity denies", Ownership{}, nil, context.Background(), up, true},
{"root allowed", Ownership{}, nil, ctxWith(Identity{UID: 0}), up, false},
{"daemon-self allowed", Ownership{}, nil, ctxWith(Identity{UID: selfUID}), up, false},
{"shared allows any", Ownership{Shared: true}, nil, ctxWith(Identity{UID: 1234}), up, false},
{"uid owner allowed", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), up, false},
{"non-owner denied", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), up, true},
{"handler-authorized bypass", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), list, false},
{"unknown method gated", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), unkwn, true},
{"primary gid owner", Ownership{Owners: []string{"gid:5000"}}, nil, ctxWith(Identity{UID: 2000, GID: 5000}), up, false},
{"group-name owner via resolver", Ownership{Owners: []string{"group:admins"}},
mockResolver{names: map[string]uint32{"admins": 5000}, gids: map[uint32]struct{}{5000: {}}},
ctxWith(Identity{UID: 2000, GID: 42}), up, false},
{"windows sid owner", Ownership{Owners: []string{"sid:S-1-5-21-9"}}, nil,
ctxWith(Identity{SID: "S-1-5-21-9"}), up, false},
{"windows group-sid owner", Ownership{Owners: []string{"sid:S-1-5-32-544"}}, nil,
ctxWith(Identity{SID: "S-1-5-21-1", Groups: []string{"S-1-5-32-544"}}), up, false},
{"windows elevated privileged", Ownership{}, nil,
ctxWith(Identity{SID: "S-1-5-21-1", Elevated: true}), up, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := &Interceptor{policy: &mockPolicy{o: tt.own}, resolver: tt.resolver, selfUID: selfUID}
err := i.authorize(tt.ctx, tt.method)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, codes.PermissionDenied, status.Code(err))
} else {
assert.NoError(t, err)
}
})
}
}
// TestInterceptorForwardedIdentity verifies the JSON-gateway trust model: a
// self/privileged transport peer (the loopback gateway) may forward a real
// client identity, but a non-privileged caller cannot forge it.
func TestInterceptorForwardedIdentity(t *testing.T) {
const selfUID = 4000
owners := Ownership{Owners: []string{"uid:1000"}}
withFwd := func(peerUID, fwdUID uint32) context.Context {
ctx := ctxWith(Identity{UID: peerUID})
return metadata.NewIncomingContext(ctx, metadata.Pairs(mdFwdUID, itoa(fwdUID)))
}
// Gateway (peer == daemon-self) forwards a non-owner client → denied as that client.
i := &Interceptor{policy: &mockPolicy{o: owners}, selfUID: selfUID}
assert.Error(t, i.authorize(withFwd(selfUID, 2000), up))
// Gateway forwards the owner → allowed.
assert.NoError(t, i.authorize(withFwd(selfUID, 1000), up))
// A non-privileged direct caller's forwarded metadata is IGNORED (can't forge):
// caller uid 2000 forwarding uid:1000 is still treated as 2000 → denied.
assert.Error(t, i.authorize(withFwd(2000, 1000), up))
}
func itoa(u uint32) string {
return strconv.FormatUint(uint64(u), 10)
}
// TestInterceptorTOFU verifies an unowned, non-shared profile is claimed by the
// first non-privileged caller, and a different caller is then denied.
func TestInterceptorTOFU(t *testing.T) {
policy := &mockPolicy{o: Ownership{}} // unowned
i := &Interceptor{policy: policy, resolver: nil, selfUID: 4000}
// First caller (uid 1000) claims via TOFU.
err := i.authorize(ctxWith(Identity{UID: 1000}), up)
assert.NoError(t, err)
assert.True(t, policy.claimed, "first caller should claim ownership")
assert.Equal(t, []string{"uid:1000"}, policy.o.Owners)
// A different caller is now denied (profile owned by uid 1000).
err = i.authorize(ctxWith(Identity{UID: 2000}), up)
assert.Error(t, err)
assert.Equal(t, codes.PermissionDenied, status.Code(err))
}

View 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
}

View 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
}

View 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)
}

View File

@@ -0,0 +1,114 @@
//go:build linux || darwin || freebsd
package ipcauth
import (
"context"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
)
// 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")
}
// TestGRPCRoundTrip_ServerCredsClientInsecure proves the transport contract end
// to end: a gRPC server using the peercred transport credentials still serves a
// plain insecure client (the CLI never changed), and the caller's kernel
// identity reaches the handler via IdentityFromContext.
func TestGRPCRoundTrip_ServerCredsClientInsecure(t *testing.T) {
sock := filepath.Join(t.TempDir(), "rt.sock")
ln, err := net.Listen("unix", sock)
require.NoError(t, err)
var gotUID uint32
var gotOK bool
srv := grpc.NewServer(
grpc.Creds(NewTransportCredentials()),
grpc.UnaryInterceptor(func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, h grpc.UnaryHandler) (any, error) {
id, ok := IdentityFromContext(ctx)
gotUID, gotOK = id.UID, ok
return h(ctx, req)
}),
)
healthpb.RegisterHealthServer(srv, health.NewServer())
go func() { _ = srv.Serve(ln) }()
t.Cleanup(srv.Stop)
conn, err := grpc.NewClient("unix://"+sock, grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = healthpb.NewHealthClient(conn).Check(ctx, &healthpb.HealthCheckRequest{})
require.NoError(t, err, "insecure client must reach the peercred server")
assert.True(t, gotOK, "handler must see a peer identity")
assert.Equal(t, uint32(os.Getuid()), gotUID, "handler must see the caller's UID")
}

View File

@@ -0,0 +1,92 @@
package ipcauth
import "sync"
const servicePath = "/daemon.DaemonService/"
// ProfilePolicy exposes the active profile's ownership to the interceptor. The
// daemon server implements it; ConfigAdapter bridges the gap because the gRPC
// server (and its interceptor) is constructed before the server instance exists.
type ProfilePolicy interface {
// ActiveProfileOwnership returns the active profile's ownership policy.
ActiveProfileOwnership() Ownership
// ClaimActiveProfileOwnerIfUnowned atomically claims the active profile for
// id when it has no owners and is not shared (trust-on-first-use), and
// reports whether id is now an owner. A false return means the profile was
// already owned/shared or another caller won the claim — the caller must
// re-read ownership and authorize normally.
ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error)
}
// handlerAuthorizedMethods bypass the active-profile gate: they are per-user or
// per-target-profile operations whose handler does its own authorization (bound
// to the caller identity). Peer identity is still required to reach them.
var handlerAuthorizedMethods = map[string]bool{
servicePath + "AddProfile": true,
servicePath + "ListProfiles": true,
servicePath + "GetActiveProfile": true,
servicePath + "RemoveProfile": true,
servicePath + "RenameProfile": true,
}
// auditMethods are the Tier-C/H RPCs (threat model §3) whose successful
// authorization is worth an audit log line. Denials are always logged.
var auditMethods = map[string]bool{
servicePath + "GetConfig": true,
servicePath + "SetConfig": true,
servicePath + "Login": true,
servicePath + "WaitSSOLogin": true,
servicePath + "RequestJWTAuth": true,
servicePath + "WaitJWTToken": true,
servicePath + "StartCapture": true,
servicePath + "StartBundleCapture": true,
servicePath + "DebugBundle": true,
servicePath + "ExposeService": true,
servicePath + "Up": true,
servicePath + "Down": true,
servicePath + "SelectNetworks": true,
servicePath + "DeselectNetworks": true,
servicePath + "SwitchProfile": true,
servicePath + "TriggerUpdate": true,
servicePath + "Logout": true,
servicePath + "CleanState": true,
servicePath + "DeleteState": true,
}
// ConfigAdapter is a ProfilePolicy whose backend is set lazily, once the daemon
// server instance is created. Until then it reports an unowned profile
// (Ownership zero value), so non-privileged callers are denied — fail closed.
type ConfigAdapter struct {
mu sync.RWMutex
backend ProfilePolicy
}
// SetBackend installs the real policy. Must be called before serving RPCs.
func (a *ConfigAdapter) SetBackend(backend ProfilePolicy) {
a.mu.Lock()
defer a.mu.Unlock()
a.backend = backend
}
// ActiveProfileOwnership delegates to the backend, or reports an unowned profile
// when no backend is set yet.
func (a *ConfigAdapter) ActiveProfileOwnership() Ownership {
a.mu.RLock()
defer a.mu.RUnlock()
if a.backend == nil {
return Ownership{}
}
return a.backend.ActiveProfileOwnership()
}
// ClaimActiveProfileOwnerIfUnowned delegates to the backend. Before the backend
// is set it cannot claim, so it reports not-owned (fail closed).
func (a *ConfigAdapter) ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.backend == nil {
return false, nil
}
return a.backend.ClaimActiveProfileOwnerIfUnowned(id)
}

View File

@@ -0,0 +1,55 @@
package ipcauth
import (
"strconv"
"strings"
)
// PrincipalKind is the type of an owner principal.
type PrincipalKind string
const (
KindUID PrincipalKind = "uid" // Unix user ID
KindGID PrincipalKind = "gid" // Unix group ID
KindGroup PrincipalKind = "group" // Unix group name (NSS-resolved)
KindSID PrincipalKind = "sid" // Windows user or group SID
)
// Principal is a parsed owner entry from a profile's Owners list.
type Principal struct {
Kind PrincipalKind
Value string
}
// ParsePrincipal parses a "kind:value" owner string. Returns false for empty
// values or unknown kinds so malformed entries are ignored rather than trusted.
func ParsePrincipal(s string) (Principal, bool) {
kind, value, ok := strings.Cut(s, ":")
if !ok || value == "" {
return Principal{}, false
}
switch PrincipalKind(kind) {
case KindUID, KindGID, KindGroup, KindSID:
return Principal{Kind: PrincipalKind(kind), Value: value}, true
default:
return Principal{}, false
}
}
// UIDPrincipal builds the owner string for a Unix user ID.
func UIDPrincipal(uid uint32) string {
return string(KindUID) + ":" + strconv.FormatUint(uint64(uid), 10)
}
// SIDPrincipal builds the owner string for a Windows SID.
func SIDPrincipal(sid string) string { return string(KindSID) + ":" + sid }
// OwnerPrincipalForIdentity returns the self-ownership principal for an identity:
// the user's UID on Unix, or the user's SID on Windows. Used to auto-isolate a
// new profile to its creator.
func OwnerPrincipalForIdentity(id Identity) string {
if id.IsWindows() {
return SIDPrincipal(id.SID)
}
return UIDPrincipal(id.UID)
}

View File

@@ -0,0 +1,72 @@
//go:build !windows
package ipcauth
import (
"strconv"
"sync"
"time"
"github.com/netbirdio/netbird/client/internal/shell"
)
const groupCacheTTL = 30 * time.Second
// NewDefaultGroupResolver returns an NSS-aware group resolver backed by
// getent/`id -G` (via client/internal/shell), so `gid:`/`group:` owners resolve
// correctly for LDAP/AD users under CGO_ENABLED=0. Results are cached briefly.
func NewDefaultGroupResolver() GroupResolver {
return &nssResolver{byUID: make(map[uint32]gidCacheEntry)}
}
type gidCacheEntry struct {
gids map[uint32]struct{}
at time.Time
}
type nssResolver struct {
mu sync.Mutex
byUID map[uint32]gidCacheEntry
}
func (r *nssResolver) CallerGIDs(id Identity) map[uint32]struct{} {
r.mu.Lock()
defer r.mu.Unlock()
if e, ok := r.byUID[id.UID]; ok && time.Since(e.at) < groupCacheTTL {
return e.gids
}
gids := resolveGIDs(id.UID)
r.byUID[id.UID] = gidCacheEntry{gids: gids, at: time.Now()}
return gids
}
func resolveGIDs(uid uint32) map[uint32]struct{} {
out := make(map[uint32]struct{})
u, err := shell.GetUserFromGetent(strconv.FormatUint(uint64(uid), 10))
if err != nil {
return out
}
ids, err := shell.GroupIdsWithFallback(u)
if err != nil {
return out
}
for _, s := range ids {
if g, err := strconv.ParseUint(s, 10, 32); err == nil {
out[uint32(g)] = struct{}{}
}
}
return out
}
func (r *nssResolver) GroupNameGID(name string) (uint32, bool) {
g, err := shell.LookupGroupWithGetent(name)
if err != nil {
return 0, false
}
gid, err := strconv.ParseUint(g.Gid, 10, 32)
if err != nil {
return 0, false
}
return uint32(gid), true
}

View File

@@ -0,0 +1,10 @@
//go:build windows
package ipcauth
// NewDefaultGroupResolver returns nil on Windows: group authorization uses the
// group SIDs carried in the client token (see the Windows transport
// credentials), not NSS/getent.
func NewDefaultGroupResolver() GroupResolver {
return nil
}

View File

@@ -96,13 +96,17 @@ type ConfigInput struct {
BlockLANAccess *bool
BlockInbound *bool
DisableIPv6 *bool
SyncMessageVersion *int
DisableNotifications *bool
DNSLabels domain.List
MTU *uint16
// Owners replaces the profile's owner principal list when non-nil.
// Shared replaces the profile's shared flag when non-nil.
Owners []string
Shared *bool
}
// Config Configuration type
@@ -138,7 +142,6 @@ type Config struct {
BlockLANAccess bool
BlockInbound bool
DisableIPv6 bool
SyncMessageVersion *int
DisableNotifications *bool
@@ -186,6 +189,17 @@ type Config struct {
MTU uint16
// Owners lists the principals allowed to control this profile over the local
// IPC, as typed strings: "uid:1000", "gid:1000", "group:netbird-admins"
// (Unix, NSS-resolved) or "sid:S-1-5-..." (Windows user or group SID). Empty
// with Shared=false means the profile is owned by nobody yet (privileged +
// daemon-self only, until claimed). Interpreted by client/internal/ipcauth.
Owners []string `json:"Owners,omitempty"`
// Shared, when true, lets any authenticated local caller control this profile
// (opt-in). Takes precedence over Owners.
Shared bool `json:"Shared,omitempty"`
// policy is the MDM policy that produced the currently-set values for
// any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
// and reset on every apply() invocation. Never persisted to disk.
@@ -589,12 +603,6 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
if input.SyncMessageVersion != nil && *input.SyncMessageVersion != *config.SyncMessageVersion {
log.Infof("setting SyncMessageVersion to %v", *input.SyncMessageVersion)
*config.SyncMessageVersion = *input.SyncMessageVersion
updated = true
}
if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) {
if *input.DisableNotifications {
log.Infof("disabling notifications")
@@ -650,6 +658,18 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
if input.Owners != nil && !slices.Equal(config.Owners, input.Owners) {
log.Infof("updating profile owners to %v", input.Owners)
config.Owners = input.Owners
updated = true
}
if input.Shared != nil && *input.Shared != config.Shared {
log.Infof("updating profile shared flag to %t", *input.Shared)
config.Shared = *input.Shared
updated = true
}
// MDM is the last override layer: any key present in the policy
// supersedes defaults, on-disk config, env vars and CLI input.
config.applyMDMPolicy(loadMDMPolicy())

View File

@@ -296,7 +296,7 @@ func (s *ServiceManager) DefaultProfilePath() string {
// The returned Profile carries the freshly-generated ID so callers can
// show it to the user (and so the gRPC AddProfileResponse can include
// it).
func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, error) {
func (s *ServiceManager) AddProfile(displayName, username string, owners []string) (*Profile, error) {
configDir, err := s.getConfigDir(username)
if err != nil {
return nil, fmt.Errorf("failed to get config directory: %w", err)
@@ -318,6 +318,9 @@ func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, err
return nil, fmt.Errorf("failed to create new config: %w", err)
}
cfg.Name = displayName
// owners auto-isolates the new profile to its creator (nil = unowned, so the
// eventual first caller claims it via trust-on-first-use).
cfg.Owners = owners
if err := util.WriteJson(context.Background(), profPath, cfg); err != nil {
return nil, fmt.Errorf("failed to write profile config: %w", err)

View File

@@ -32,7 +32,7 @@ func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) {
func TestServiceProfile_ExactID(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
created, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
got, err := sm.ResolveProfile(created.ID.String(), username)
@@ -44,7 +44,7 @@ func TestServiceProfile_ExactID(t *testing.T) {
func TestServiceProfile_IDPrefix(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
created, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
prefix := created.ID[:4]
@@ -75,7 +75,7 @@ func TestServiceProfile_AmbiguousPrefix(t *testing.T) {
func TestServiceProfile_ExactNameUnique(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
_, err := sm.AddProfile("work", username)
_, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
got, err := sm.ResolveProfile("work", username)
@@ -86,9 +86,9 @@ func TestServiceProfile_ExactNameUnique(t *testing.T) {
func TestServiceProfile_AmbiguousName(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
_, err := sm.AddProfile("work", username)
_, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
_, err = sm.AddProfile("work", username)
_, err = sm.AddProfile("work", username, nil)
require.NoError(t, err)
_, err = sm.ResolveProfile("work", username)
@@ -133,10 +133,10 @@ func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) {
func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
first, err := sm.AddProfile("work", username)
first, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
second, err := sm.AddProfile("work", username)
second, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
assert.NotEqual(t, first.ID, second.ID)
assert.Equal(t, "work", second.Name)
@@ -151,7 +151,7 @@ func TestAddProfile_RejectsInvalidNames(t *testing.T) {
strings.Repeat("a", maxProfileNameLen+1), // too long
}
for _, name := range cases {
_, err := sm.AddProfile(name, username)
_, err := sm.AddProfile(name, username, nil)
assert.Error(t, err, "expected error for %q", name)
}
})
@@ -215,7 +215,7 @@ func TestIsValidProfileFilenameStem(t *testing.T) {
func TestRemoveProfile_DeletesStateFile(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
created, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
configDir, err := sm.getConfigDir(username)

View 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()
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
}

View 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()
}

View File

@@ -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),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -791,6 +791,78 @@ func local_request_DaemonService_GetActiveProfile_0(ctx context.Context, marshal
return msg, metadata, err
}
func request_DaemonService_AddOwner_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq AddOwnerRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.AddOwner(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_DaemonService_AddOwner_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq AddOwnerRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.AddOwner(ctx, &protoReq)
return msg, metadata, err
}
func request_DaemonService_ResetOwner_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ResetOwnerRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.ResetOwner(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_DaemonService_ResetOwner_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ResetOwnerRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.ResetOwner(ctx, &protoReq)
return msg, metadata, err
}
func request_DaemonService_ShareProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ShareProfileRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.ShareProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_DaemonService_ShareProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ShareProfileRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.ShareProfile(ctx, &protoReq)
return msg, metadata, err
}
func request_DaemonService_Logout_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq LogoutRequest
@@ -1730,6 +1802,66 @@ func RegisterDaemonServiceHandlerServer(ctx context.Context, mux *runtime.ServeM
}
forward_DaemonService_GetActiveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_AddOwner_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/AddOwner", runtime.WithHTTPPathPattern("/daemon.DaemonService/AddOwner"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_DaemonService_AddOwner_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_DaemonService_AddOwner_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_ResetOwner_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ResetOwner", runtime.WithHTTPPathPattern("/daemon.DaemonService/ResetOwner"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_DaemonService_ResetOwner_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_DaemonService_ResetOwner_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_ShareProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ShareProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/ShareProfile"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_DaemonService_ShareProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_DaemonService_ShareProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_Logout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@@ -2581,6 +2713,57 @@ func RegisterDaemonServiceHandlerClient(ctx context.Context, mux *runtime.ServeM
}
forward_DaemonService_GetActiveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_AddOwner_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/AddOwner", runtime.WithHTTPPathPattern("/daemon.DaemonService/AddOwner"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_DaemonService_AddOwner_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_DaemonService_AddOwner_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_ResetOwner_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ResetOwner", runtime.WithHTTPPathPattern("/daemon.DaemonService/ResetOwner"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_DaemonService_ResetOwner_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_DaemonService_ResetOwner_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_ShareProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ShareProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/ShareProfile"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_DaemonService_ShareProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_DaemonService_ShareProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_Logout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@@ -2855,6 +3038,9 @@ var (
pattern_DaemonService_RemoveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RemoveProfile"}, ""))
pattern_DaemonService_ListProfiles_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListProfiles"}, ""))
pattern_DaemonService_GetActiveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetActiveProfile"}, ""))
pattern_DaemonService_AddOwner_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "AddOwner"}, ""))
pattern_DaemonService_ResetOwner_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ResetOwner"}, ""))
pattern_DaemonService_ShareProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ShareProfile"}, ""))
pattern_DaemonService_Logout_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Logout"}, ""))
pattern_DaemonService_GetFeatures_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetFeatures"}, ""))
pattern_DaemonService_TriggerUpdate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "TriggerUpdate"}, ""))
@@ -2904,6 +3090,9 @@ var (
forward_DaemonService_RemoveProfile_0 = runtime.ForwardResponseMessage
forward_DaemonService_ListProfiles_0 = runtime.ForwardResponseMessage
forward_DaemonService_GetActiveProfile_0 = runtime.ForwardResponseMessage
forward_DaemonService_AddOwner_0 = runtime.ForwardResponseMessage
forward_DaemonService_ResetOwner_0 = runtime.ForwardResponseMessage
forward_DaemonService_ShareProfile_0 = runtime.ForwardResponseMessage
forward_DaemonService_Logout_0 = runtime.ForwardResponseMessage
forward_DaemonService_GetFeatures_0 = runtime.ForwardResponseMessage
forward_DaemonService_TriggerUpdate_0 = runtime.ForwardResponseMessage

View File

@@ -104,6 +104,18 @@ service DaemonService {
rpc GetActiveProfile(GetActiveProfileRequest) returns (GetActiveProfileResponse) {}
// AddOwner adds a principal to the active profile's owner list. Requires the
// caller to be privileged (root/administrator) or an existing owner.
rpc AddOwner(AddOwnerRequest) returns (AddOwnerResponse) {}
// ResetOwner clears the active profile's owner list, returning it to the
// unowned state (next caller re-claims via trust-on-first-use). Privileged only.
rpc ResetOwner(ResetOwnerRequest) returns (ResetOwnerResponse) {}
// ShareProfile marks the active profile shared (any local caller) or unshared.
// Requires the caller to be an owner or privileged.
rpc ShareProfile(ShareProfileRequest) returns (ShareProfileResponse) {}
// Logout disconnects from the network and deletes the peer from the management server
rpc Logout(LogoutRequest) returns (LogoutResponse) {}
@@ -270,6 +282,10 @@ message UpRequest {
// RPC blocks until the engine is running or gives up, which is the behaviour
// needed by the CLI.
bool async = 4;
// claimOwner, when true, claims ownership of the active profile for the
// calling user (adds their kernel principal to the profile's owner list).
bool claimOwner = 5;
}
message UpResponse {}
@@ -774,6 +790,24 @@ message AddProfileResponse {
string id = 1;
}
message AddOwnerRequest {
// principal is a typed owner string: "uid:1000", "gid:1000",
// "group:netbird-admins" (Unix, NSS-resolved), or "sid:S-1-5-..." (Windows).
string principal = 1;
}
message AddOwnerResponse {}
message ResetOwnerRequest {}
message ResetOwnerResponse {}
message ShareProfileRequest {
bool shared = 1;
}
message ShareProfileResponse {}
message RenameProfileRequest {
string username = 1;
// handle: an exact ID, a unique ID prefix, or a unique display name.

View File

@@ -51,6 +51,9 @@ const (
DaemonService_RemoveProfile_FullMethodName = "/daemon.DaemonService/RemoveProfile"
DaemonService_ListProfiles_FullMethodName = "/daemon.DaemonService/ListProfiles"
DaemonService_GetActiveProfile_FullMethodName = "/daemon.DaemonService/GetActiveProfile"
DaemonService_AddOwner_FullMethodName = "/daemon.DaemonService/AddOwner"
DaemonService_ResetOwner_FullMethodName = "/daemon.DaemonService/ResetOwner"
DaemonService_ShareProfile_FullMethodName = "/daemon.DaemonService/ShareProfile"
DaemonService_Logout_FullMethodName = "/daemon.DaemonService/Logout"
DaemonService_GetFeatures_FullMethodName = "/daemon.DaemonService/GetFeatures"
DaemonService_TriggerUpdate_FullMethodName = "/daemon.DaemonService/TriggerUpdate"
@@ -132,6 +135,15 @@ type DaemonServiceClient interface {
RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error)
ListProfiles(ctx context.Context, in *ListProfilesRequest, opts ...grpc.CallOption) (*ListProfilesResponse, error)
GetActiveProfile(ctx context.Context, in *GetActiveProfileRequest, opts ...grpc.CallOption) (*GetActiveProfileResponse, error)
// AddOwner adds a principal to the active profile's owner list. Requires the
// caller to be privileged (root/administrator) or an existing owner.
AddOwner(ctx context.Context, in *AddOwnerRequest, opts ...grpc.CallOption) (*AddOwnerResponse, error)
// ResetOwner clears the active profile's owner list, returning it to the
// unowned state (next caller re-claims via trust-on-first-use). Privileged only.
ResetOwner(ctx context.Context, in *ResetOwnerRequest, opts ...grpc.CallOption) (*ResetOwnerResponse, error)
// ShareProfile marks the active profile shared (any local caller) or unshared.
// Requires the caller to be an owner or privileged.
ShareProfile(ctx context.Context, in *ShareProfileRequest, opts ...grpc.CallOption) (*ShareProfileResponse, error)
// Logout disconnects from the network and deletes the peer from the management server
Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error)
GetFeatures(ctx context.Context, in *GetFeaturesRequest, opts ...grpc.CallOption) (*GetFeaturesResponse, error)
@@ -528,6 +540,36 @@ func (c *daemonServiceClient) GetActiveProfile(ctx context.Context, in *GetActiv
return out, nil
}
func (c *daemonServiceClient) AddOwner(ctx context.Context, in *AddOwnerRequest, opts ...grpc.CallOption) (*AddOwnerResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AddOwnerResponse)
err := c.cc.Invoke(ctx, DaemonService_AddOwner_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *daemonServiceClient) ResetOwner(ctx context.Context, in *ResetOwnerRequest, opts ...grpc.CallOption) (*ResetOwnerResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ResetOwnerResponse)
err := c.cc.Invoke(ctx, DaemonService_ResetOwner_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *daemonServiceClient) ShareProfile(ctx context.Context, in *ShareProfileRequest, opts ...grpc.CallOption) (*ShareProfileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ShareProfileResponse)
err := c.cc.Invoke(ctx, DaemonService_ShareProfile_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *daemonServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LogoutResponse)
@@ -742,6 +784,15 @@ type DaemonServiceServer interface {
RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error)
ListProfiles(context.Context, *ListProfilesRequest) (*ListProfilesResponse, error)
GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error)
// AddOwner adds a principal to the active profile's owner list. Requires the
// caller to be privileged (root/administrator) or an existing owner.
AddOwner(context.Context, *AddOwnerRequest) (*AddOwnerResponse, error)
// ResetOwner clears the active profile's owner list, returning it to the
// unowned state (next caller re-claims via trust-on-first-use). Privileged only.
ResetOwner(context.Context, *ResetOwnerRequest) (*ResetOwnerResponse, error)
// ShareProfile marks the active profile shared (any local caller) or unshared.
// Requires the caller to be an owner or privileged.
ShareProfile(context.Context, *ShareProfileRequest) (*ShareProfileResponse, error)
// Logout disconnects from the network and deletes the peer from the management server
Logout(context.Context, *LogoutRequest) (*LogoutResponse, error)
GetFeatures(context.Context, *GetFeaturesRequest) (*GetFeaturesResponse, error)
@@ -887,6 +938,15 @@ func (UnimplementedDaemonServiceServer) ListProfiles(context.Context, *ListProfi
func (UnimplementedDaemonServiceServer) GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetActiveProfile not implemented")
}
func (UnimplementedDaemonServiceServer) AddOwner(context.Context, *AddOwnerRequest) (*AddOwnerResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AddOwner not implemented")
}
func (UnimplementedDaemonServiceServer) ResetOwner(context.Context, *ResetOwnerRequest) (*ResetOwnerResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ResetOwner not implemented")
}
func (UnimplementedDaemonServiceServer) ShareProfile(context.Context, *ShareProfileRequest) (*ShareProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ShareProfile not implemented")
}
func (UnimplementedDaemonServiceServer) Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Logout not implemented")
}
@@ -1505,6 +1565,60 @@ func _DaemonService_GetActiveProfile_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _DaemonService_AddOwner_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AddOwnerRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DaemonServiceServer).AddOwner(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_AddOwner_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).AddOwner(ctx, req.(*AddOwnerRequest))
}
return interceptor(ctx, in, info, handler)
}
func _DaemonService_ResetOwner_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ResetOwnerRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DaemonServiceServer).ResetOwner(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_ResetOwner_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).ResetOwner(ctx, req.(*ResetOwnerRequest))
}
return interceptor(ctx, in, info, handler)
}
func _DaemonService_ShareProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ShareProfileRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DaemonServiceServer).ShareProfile(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_ShareProfile_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).ShareProfile(ctx, req.(*ShareProfileRequest))
}
return interceptor(ctx, in, info, handler)
}
func _DaemonService_Logout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LogoutRequest)
if err := dec(in); err != nil {
@@ -1873,6 +1987,18 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetActiveProfile",
Handler: _DaemonService_GetActiveProfile_Handler,
},
{
MethodName: "AddOwner",
Handler: _DaemonService_AddOwner_Handler,
},
{
MethodName: "ResetOwner",
Handler: _DaemonService_ResetOwner_Handler,
},
{
MethodName: "ShareProfile",
Handler: _DaemonService_ShareProfile_Handler,
},
{
MethodName: "Logout",
Handler: _DaemonService_Logout_Handler,

204
client/server/ownership.go Normal file
View File

@@ -0,0 +1,204 @@
package server
import (
"context"
"fmt"
"slices"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util"
)
// The daemon Server implements ipcauth.ProfilePolicy so the gRPC interceptor can
// authorize each RPC against the active profile's ownership.
var _ ipcauth.ProfilePolicy = (*Server)(nil)
// ActiveProfileOwnership returns the active profile's ownership policy. Reads
// the in-memory active config (kept current by the handlers), falling back to
// the on-disk active profile when the daemon hasn't loaded one yet.
func (s *Server) ActiveProfileOwnership() ipcauth.Ownership {
s.mutex.Lock()
defer s.mutex.Unlock()
cfg := s.config
if cfg == nil {
loaded, err := s.loadActiveProfileConfigLocked()
if err != nil {
log.Warnf("ownership: cannot load active profile config, treating as unowned: %v", err)
return ipcauth.Ownership{}
}
cfg = loaded
}
return ipcauth.Ownership{Owners: cfg.Owners, Shared: cfg.Shared}
}
// ClaimActiveProfileOwnerIfUnowned atomically claims the active profile for id
// when it has no owners and is not shared (trust-on-first-use). Returns whether
// id is now an owner. Concurrent first-callers are serialized by s.mutex, so
// exactly one wins the claim; the others get false and are authorized normally.
func (s *Server) ClaimActiveProfileOwnerIfUnowned(id ipcauth.Identity) (bool, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
cfg := s.config
if cfg == nil {
loaded, err := s.loadActiveProfileConfigLocked()
if err != nil {
return false, fmt.Errorf("load active profile config: %w", err)
}
cfg = loaded
}
if len(cfg.Owners) > 0 || cfg.Shared {
return false, nil // already owned or shared — someone won the race
}
cfg.Owners = []string{ipcauth.OwnerPrincipalForIdentity(id)}
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
cfg.Owners = nil // revert in-memory on persistence failure
return false, fmt.Errorf("persist claimed ownership: %w", err)
}
s.config = cfg
log.Infof("profile ownership claimed by %s (trust-on-first-use)", id)
return true, nil
}
// activeProfileConfigPathLocked resolves the active profile's config file path.
func (s *Server) activeProfileConfigPathLocked() (string, error) {
activeProf, err := s.profileManager.GetActiveProfileState()
if err != nil {
return "", fmt.Errorf("get active profile: %w", err)
}
path, err := activeProf.FilePath()
if err != nil {
return "", fmt.Errorf("resolve active profile path: %w", err)
}
return path, nil
}
// loadActiveProfileConfigLocked reads the active profile's config from disk.
func (s *Server) loadActiveProfileConfigLocked() (*profilemanager.Config, error) {
path, err := s.activeProfileConfigPathLocked()
if err != nil {
return nil, err
}
return profilemanager.GetConfig(path)
}
// persistActiveProfileConfigLocked writes cfg to the active profile's config file.
func (s *Server) persistActiveProfileConfigLocked(cfg *profilemanager.Config) error {
path, err := s.activeProfileConfigPathLocked()
if err != nil {
return err
}
return util.WriteJson(context.Background(), path, cfg)
}
// activeConfigLocked returns the in-memory active config, loading it from disk
// if the daemon hasn't cached one. Caller must hold s.mutex.
func (s *Server) activeConfigLocked() (*profilemanager.Config, error) {
if s.config != nil {
return s.config, nil
}
return s.loadActiveProfileConfigLocked()
}
// claimForCallerLocked adds the caller's principal to cfg (if absent) and
// persists. No-op for privileged callers (they need no ownership entry). Caller
// must hold s.mutex.
func (s *Server) claimForCallerLocked(id ipcauth.Identity, cfg *profilemanager.Config) error {
if id.IsPrivileged() {
return nil
}
principal := ipcauth.OwnerPrincipalForIdentity(id)
if slices.Contains(cfg.Owners, principal) {
return nil
}
cfg.Owners = append(cfg.Owners, principal)
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
cfg.Owners = cfg.Owners[:len(cfg.Owners)-1] // revert on failure
return err
}
s.config = cfg
return nil
}
// AddOwner adds a principal to the active profile's owner list. The interceptor
// has already confirmed the caller is an owner or privileged; the handler just
// validates and persists.
func (s *Server) AddOwner(_ context.Context, msg *proto.AddOwnerRequest) (*proto.AddOwnerResponse, error) {
principal := msg.GetPrincipal()
if _, ok := ipcauth.ParsePrincipal(principal); !ok {
return nil, gstatus.Errorf(codes.InvalidArgument, "invalid owner principal %q (expected uid:/gid:/group:/sid:)", principal)
}
s.mutex.Lock()
defer s.mutex.Unlock()
cfg, err := s.activeConfigLocked()
if err != nil {
return nil, fmt.Errorf("load active profile config: %w", err)
}
if slices.Contains(cfg.Owners, principal) {
return &proto.AddOwnerResponse{}, nil
}
cfg.Owners = append(cfg.Owners, principal)
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
cfg.Owners = cfg.Owners[:len(cfg.Owners)-1]
return nil, fmt.Errorf("persist owner: %w", err)
}
s.config = cfg
log.Infof("added owner %q to the active profile", principal)
return &proto.AddOwnerResponse{}, nil
}
// ResetOwner clears the active profile's owner list (and shared flag), returning
// it to the unowned state so the next caller re-claims via trust-on-first-use.
// Privileged-only, so co-owners cannot evict each other.
func (s *Server) ResetOwner(ctx context.Context, _ *proto.ResetOwnerRequest) (*proto.ResetOwnerResponse, error) {
id, ok := ipcauth.IdentityFromContext(ctx)
if !ok || !id.IsPrivileged() {
return nil, gstatus.Error(codes.PermissionDenied, "reset-owner requires root/administrator")
}
s.mutex.Lock()
defer s.mutex.Unlock()
cfg, err := s.activeConfigLocked()
if err != nil {
return nil, fmt.Errorf("load active profile config: %w", err)
}
cfg.Owners = nil
cfg.Shared = false
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
return nil, fmt.Errorf("persist owner reset: %w", err)
}
s.config = cfg
log.Infof("active profile owner list reset; next caller will re-claim (trust-on-first-use)")
return &proto.ResetOwnerResponse{}, nil
}
// ShareProfile marks the active profile shared or unshared. The interceptor has
// already confirmed the caller is an owner or privileged.
func (s *Server) ShareProfile(_ context.Context, msg *proto.ShareProfileRequest) (*proto.ShareProfileResponse, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
cfg, err := s.activeConfigLocked()
if err != nil {
return nil, fmt.Errorf("load active profile config: %w", err)
}
cfg.Shared = msg.GetShared()
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
return nil, fmt.Errorf("persist shared flag: %w", err)
}
s.config = cfg
log.Infof("active profile shared flag set to %t", msg.GetShared())
return &proto.ShareProfileResponse{}, nil
}

View File

@@ -0,0 +1,53 @@
package server
import (
"context"
"runtime"
"strings"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// bindCallerUsername enforces that a non-privileged caller may only operate on
// its OWN user's profiles. Profiles live in per-username directories, but the
// username is a client-supplied gRPC field the server historically never
// checked; binding it to the caller's kernel identity closes the "pass another
// user's username" hole. Privileged callers (root / elevated-admin) may manage
// any user's profiles.
func (s *Server) bindCallerUsername(ctx context.Context, requested string) error {
if requested == "" {
return nil // handlers validate emptiness themselves; nothing to bind
}
id, ok := ipcauth.IdentityFromContext(ctx)
if !ok {
return gstatus.Error(codes.PermissionDenied, "caller identity could not be verified")
}
if id.IsPrivileged() {
return nil
}
caller, err := usernameForIdentity(id)
if err != nil {
log.Warnf("profile authz: resolve caller username for %s: %v", id, err)
return gstatus.Error(codes.PermissionDenied, "could not resolve caller identity to a username")
}
if !usernamesEqual(requested, caller) {
return gstatus.Errorf(codes.PermissionDenied,
"not authorized to operate on another user's profiles (caller %q requested %q)", caller, requested)
}
return nil
}
// usernamesEqual compares usernames case-insensitively on Windows (domain
// accounts) and exactly on Unix.
func usernamesEqual(a, b string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(a, b)
}
return a == b
}

View File

@@ -0,0 +1,20 @@
//go:build !windows
package server
import (
"strconv"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/internal/shell"
)
// usernameForIdentity resolves a Unix caller's UID to its username via NSS
// (getent), so LDAP/AD users resolve correctly under CGO_ENABLED=0.
func usernameForIdentity(id ipcauth.Identity) (string, error) {
u, err := shell.GetUserFromGetent(strconv.FormatUint(uint64(id.UID), 10))
if err != nil {
return "", err
}
return u.Username, nil
}

View File

@@ -0,0 +1,18 @@
//go:build windows
package server
import (
"os/user"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// usernameForIdentity resolves a Windows caller's SID to its account name.
func usernameForIdentity(id ipcauth.Identity) (string, error) {
u, err := user.LookupId(id.SID)
if err != nil {
return "", err
}
return u.Username, nil
}

View File

@@ -23,6 +23,7 @@ import (
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/expose"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
"github.com/netbirdio/netbird/client/mdm"
@@ -402,6 +403,12 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
}
}
// M-SSHGATE: turning on SSH root login / disabling SSH auth requires a
// privileged caller (root or elevated admin).
if err := requirePrivilegedForDangerousSSH(callerCtx, msg.EnableSSHRoot, msg.DisableSSHAuth); err != nil {
return nil, err
}
// MDM gate: refuse the whole request if any of its fields is enforced
// by the active MDM policy. The error carries an MDMManagedFields-
// Violation detail listing the offending key names. Non-conflicting
@@ -537,6 +544,12 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
}
}
// M-SSHGATE: turning on SSH root login / disabling SSH auth requires a
// privileged caller (root or elevated admin).
if err := requirePrivilegedForDangerousSSH(callerCtx, msg.EnableSSHRoot, msg.DisableSSHAuth); err != nil {
return nil, err
}
s.mutex.Lock()
if s.actCancel != nil {
s.actCancel()
@@ -943,6 +956,17 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
}
s.config = config
// --owner: explicitly claim ownership of the active profile for the caller.
if msg != nil && msg.GetClaimOwner() {
if id, ok := ipcauth.IdentityFromContext(callerCtx); ok {
if err := s.claimForCallerLocked(id, s.config); err != nil {
s.mutex.Unlock()
log.Errorf("failed to claim profile ownership: %v", err)
return nil, fmt.Errorf("claim owner: %w", err)
}
}
}
s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String())
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
@@ -1048,6 +1072,16 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
}
if msg != nil && msg.ProfileName != nil {
targetUsername := ""
if msg.Username != nil {
targetUsername = *msg.Username
}
// The interceptor already gated this against the CURRENT active profile;
// also bind the TARGET profile's username so a caller can't switch into
// another user's profile.
if err := s.bindCallerUsername(callerCtx, targetUsername); err != nil {
return nil, err
}
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
log.Errorf("failed to switch profile: %v", err)
return nil, err
@@ -2005,7 +2039,18 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) (
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name and username must be provided")
}
created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username)
if err := s.bindCallerUsername(ctx, msg.Username); err != nil {
return nil, err
}
// Auto-isolate the new profile to its creator. When root/admin creates it we
// leave it unowned so the intended user claims it via trust-on-first-use.
var initialOwners []string
if id, ok := ipcauth.IdentityFromContext(ctx); ok && !id.IsPrivileged() {
initialOwners = []string{ipcauth.OwnerPrincipalForIdentity(id)}
}
created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username, initialOwners)
if err != nil {
log.Errorf("failed to create profile: %v", err)
return nil, fmt.Errorf("failed to create profile: %w", err)
@@ -2028,6 +2073,10 @@ func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequ
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name, username and new profile name must be provided")
}
if err := s.bindCallerUsername(ctx, msg.Username); err != nil {
return nil, err
}
resolved, err := s.resolveProfileHandle(msg.Handle, msg.Username)
if err != nil {
return nil, err
@@ -2057,6 +2106,10 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
}
if err := s.bindCallerUsername(ctx, msg.Username); err != nil {
return nil, err
}
resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username)
if err != nil {
return nil, err
@@ -2125,6 +2178,10 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques
return nil, gstatus.Errorf(codes.InvalidArgument, "username must be provided")
}
if err := s.bindCallerUsername(ctx, msg.Username); err != nil {
return nil, err
}
profiles, err := s.profileManager.ListProfiles(msg.Username)
if err != nil {
log.Errorf("failed to list profiles: %v", err)

44
client/server/ssh_gate.go Normal file
View File

@@ -0,0 +1,44 @@
package server
import (
"context"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// requirePrivilegedForDangerousSSH enforces M-SSHGATE: enabling SSH root login
// or disabling SSH authentication turns the root/LocalSystem daemon's SSH server
// into an unauthenticated root shell (local-to-remote-root escalation), so only
// a privileged caller (Unix root, or Windows elevated-admin/LocalSystem) may set
// these flags to true over the local IPC.
//
// It gates the request fields, not the resulting config: a value already
// persisted (e.g. set previously by root, or by MDM/managed config) is untouched;
// only a new attempt to turn them on via SetConfig/Login is checked.
//
// When the caller identity cannot be verified (e.g. the daemon is on a TCP
// control channel with no peer credentials) it fails closed — refusing rather
// than letting an unauthenticated local caller flip these flags.
func requirePrivilegedForDangerousSSH(ctx context.Context, enableSSHRoot, disableSSHAuth *bool) error {
dangerous := (enableSSHRoot != nil && *enableSSHRoot) || (disableSSHAuth != nil && *disableSSHAuth)
if !dangerous {
return nil
}
id, ok := ipcauth.IdentityFromContext(ctx)
if !ok {
log.Warnf("denying SSH root/no-auth config change: caller identity unavailable on this control channel")
return gstatus.Error(codes.PermissionDenied,
"enabling SSH root login or disabling SSH authentication requires root/administrator, but the caller identity could not be verified on this daemon control channel")
}
if !id.IsPrivileged() {
log.Warnf("denying SSH root/no-auth config change from non-privileged caller %s", id)
return gstatus.Errorf(codes.PermissionDenied,
"enabling SSH root login or disabling SSH authentication requires root/administrator (caller %s is not privileged); rerun as root/administrator", id)
}
return nil
}

View File

@@ -0,0 +1,52 @@
package server
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/peer"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
func ctxWithIdentity(id ipcauth.Identity) context.Context {
return peer.NewContext(context.Background(), &peer.Peer{AuthInfo: ipcauth.AuthInfo{Identity: id}})
}
func boolPtr(b bool) *bool { return &b }
func TestRequirePrivilegedForDangerousSSH(t *testing.T) {
root := ipcauth.Identity{UID: 0}
user := ipcauth.Identity{UID: 1000}
tests := []struct {
name string
ctx context.Context
enableSSHRoot *bool
disableSSHAuth *bool
wantDenied bool
}{
{"no flags, no identity", context.Background(), nil, nil, false},
{"flags false, non-priv", ctxWithIdentity(user), boolPtr(false), boolPtr(false), false},
{"enableSSHRoot by root", ctxWithIdentity(root), boolPtr(true), nil, false},
{"enableSSHRoot by non-priv", ctxWithIdentity(user), boolPtr(true), nil, true},
{"disableSSHAuth by non-priv", ctxWithIdentity(user), nil, boolPtr(true), true},
{"enableSSHRoot no identity (fail closed)", context.Background(), boolPtr(true), nil, true},
{"both by root", ctxWithIdentity(root), boolPtr(true), boolPtr(true), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := requirePrivilegedForDangerousSSH(tt.ctx, tt.enableSSHRoot, tt.disableSSHAuth)
if tt.wantDenied {
assert.Error(t, err)
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
} else {
assert.NoError(t, err)
}
})
}
}

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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()
}

View File

@@ -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()
}

View 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),
}
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -79,15 +79,13 @@ type Info struct {
EnableSSHLocalPortForwarding bool
EnableSSHRemotePortForwarding bool
DisableSSHAuth bool
SyncMessageVersion *int
}
func (i *Info) SetFlags(
rosenpassEnabled, rosenpassPermissive bool,
serverSSHAllowed *bool,
disableClientRoutes, disableServerRoutes,
disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, syncMessageVersion *int,
disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool,
enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool,
disableSSHAuth *bool,
) {
@@ -105,8 +103,6 @@ func (i *Info) SetFlags(
i.BlockInbound = blockInbound
i.DisableIPv6 = disableIPv6
i.SyncMessageVersion = syncMessageVersion
if enableSSHRoot != nil {
i.EnableSSHRoot = *enableSSHRoot
}

View File

@@ -6,3 +6,4 @@ frontend/bindings
frontend/.vite
build/linux/appimage/build
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
build/windows/frontend

View File

@@ -3,16 +3,16 @@
package main
import (
"context"
"fmt"
"runtime"
"strings"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/backoff"
"google.golang.org/grpc/credentials/insecure"
"github.com/netbirdio/netbird/client/cmd"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/desktop"
)
@@ -36,9 +36,7 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
return c.client, nil
}
cc, err := grpc.NewClient(
strings.TrimPrefix(c.addr, "tcp://"),
grpc.WithTransportCredentials(insecure.NewCredentials()),
opts := []grpc.DialOption{
grpc.WithUserAgent(desktop.GetUIUserAgent()),
// Cap reconnect backoff at 5s; gRPC's default 120s MaxDelay would
// leave the UI waiting 30-60s to notice a freshly-started daemon.
@@ -50,6 +48,12 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
MaxDelay: 5 * time.Second,
},
}),
}
cc, err := cmd.DialClientGRPCServer(
context.Background(),
c.addr,
opts...,
)
if err != nil {
return nil, fmt.Errorf("dial daemon: %w", err)
@@ -61,7 +65,7 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
// DaemonAddr returns the default daemon gRPC address: a Unix socket on Linux/macOS, TCP loopback on Windows.
func DaemonAddr() string {
if runtime.GOOS == "windows" {
return "tcp://127.0.0.1:41731"
return "npipe://netbird"
}
return "unix:///var/run/netbird.sock"
}

View File

@@ -1,151 +0,0 @@
package cmd
import (
"context"
"fmt"
"github.com/dexidp/dex/storage"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/formatter/hook"
admincmd "github.com/netbirdio/netbird/management/cmd/admin"
tokencmd "github.com/netbirdio/netbird/management/cmd/token"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/activity"
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/util"
)
// newAdminCommands creates the admin command tree with combined-specific resource openers.
func newAdminCommands() *cobra.Command {
return admincmd.NewCommands(admincmd.Openers{
Resources: withAdminResources,
Store: withAdminStoreOnly,
IDP: withAdminIDPOnly,
})
}
func newLegacyTokenCommand() *cobra.Command {
cmd := tokencmd.NewCommands(tokencmd.StoreOpener(withAdminStoreOnly))
cmd.Deprecated = "use 'admin token' instead"
return cmd
}
// withAdminResources loads the combined YAML config, initializes stores, and calls fn.
func withAdminResources(cmd *cobra.Command, fn func(ctx context.Context, resources admincmd.Resources) error) error {
return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error {
mgmtConfig, err := adminManagementConfig(cfg)
if err != nil {
return err
}
managementStore, err := openAdminStore(ctx, cfg)
if err != nil {
return err
}
defer admincmd.CloseStore(ctx, managementStore)
idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(mgmtConfig)
if err != nil {
return err
}
defer admincmd.CloseIDPStorage(idpStorage)
eventStore, esErr := openAdminEventStore(ctx, cfg, mgmtConfig)
if esErr != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: audit events will not be recorded: %v\n", esErr)
}
if eventStore != nil {
defer func() {
if err := eventStore.Close(ctx); err != nil {
log.Debugf("close activity event store: %v", err)
}
}()
}
return fn(ctx, admincmd.Resources{Store: managementStore, IDPStorage: idpStorage, IDPStorageFile: idpStorageFile, EventStore: eventStore})
})
}
// withAdminStoreOnly opens only the management store for admin subcommands that do not
// need embedded IdP storage.
func withAdminStoreOnly(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error {
return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error {
managementStore, err := openAdminStore(ctx, cfg)
if err != nil {
return err
}
defer admincmd.CloseStore(ctx, managementStore)
return fn(ctx, managementStore)
})
}
func withAdminIDPOnly(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error {
return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error {
mgmtConfig, err := adminManagementConfig(cfg)
if err != nil {
return err
}
idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(mgmtConfig)
if err != nil {
return err
}
defer admincmd.CloseIDPStorage(idpStorage)
return fn(ctx, idpStorage, idpStorageFile)
})
}
func withAdminConfig(cmd *cobra.Command, fn func(ctx context.Context, cfg *CombinedConfig) error) error {
if err := util.InitLog("error", "console"); err != nil {
return fmt.Errorf("init log: %w", err)
}
ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck
cfg, err := LoadConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
cfg.ApplyAdminDefaults()
applyServerStoreEnv(cfg.Server.Store)
return fn(ctx, cfg)
}
func adminManagementConfig(cfg *CombinedConfig) (*nbconfig.Config, error) {
mgmtConfig, err := cfg.ToManagementConfig()
if err != nil {
return nil, fmt.Errorf("create management config: %w", err)
}
return mgmtConfig, nil
}
func openAdminStore(ctx context.Context, cfg *CombinedConfig) (store.Store, error) {
managementStore, err := store.NewStore(ctx, types.Engine(cfg.Management.Store.Engine), cfg.Management.DataDir, nil, true)
if err != nil {
return nil, fmt.Errorf("create store: %w", err)
}
return managementStore, nil
}
func openAdminEventStore(ctx context.Context, cfg *CombinedConfig, config *nbconfig.Config) (activity.Store, error) {
if config.DataStoreEncryptionKey == "" {
return nil, fmt.Errorf("data store encryption key is not configured")
}
if err := applyActivityStoreEnv(cfg.Server.ActivityStore); err != nil {
return nil, fmt.Errorf("configure activity event store: %w", err)
}
eventStore, err := activitystore.NewSqlStore(ctx, config.Datadir, config.DataStoreEncryptionKey)
if err != nil {
return nil, fmt.Errorf("open activity event store: %w", err)
}
if eventStore == nil {
return nil, fmt.Errorf("open activity event store: returned nil store")
}
return eventStore, nil
}

View File

@@ -1,47 +0,0 @@
package cmd
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/require"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
)
func TestApplyAdminDefaultsCopiesServerStoreWithoutExposedAddress(t *testing.T) {
cfg := DefaultConfig()
cfg.Server.ExposedAddress = ""
cfg.Server.DataDir = "/srv/netbird"
cfg.Server.Store = StoreConfig{
Engine: "postgres",
DSN: "postgres://user:pass@example.com/netbird",
}
cfg.ApplyAdminDefaults()
require.Equal(t, "/srv/netbird", cfg.Management.DataDir)
require.Equal(t, "postgres", cfg.Management.Store.Engine)
require.Equal(t, cfg.Server.Store.DSN, cfg.Management.Store.DSN)
}
func TestOpenAdminEventStoreMissingEncryptionKeyReturnsNilInterface(t *testing.T) {
eventStore, err := openAdminEventStore(context.Background(), &CombinedConfig{}, &nbconfig.Config{})
require.Error(t, err)
require.Contains(t, err.Error(), "encryption key")
require.Nil(t, eventStore)
}
func TestApplyServerStoreEnv(t *testing.T) {
t.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", "")
t.Setenv("NB_STORE_ENGINE_MYSQL_DSN", "")
t.Setenv("NB_STORE_ENGINE_SQLITE_FILE", "")
applyServerStoreEnv(StoreConfig{Engine: "postgres", DSN: "postgres-dsn", File: "store.db"})
require.Equal(t, "postgres-dsn", os.Getenv("NB_STORE_ENGINE_POSTGRES_DSN"))
require.Equal(t, "store.db", os.Getenv("NB_STORE_ENGINE_SQLITE_FILE"))
applyServerStoreEnv(StoreConfig{Engine: "mysql", DSN: "mysql-dsn"})
require.Equal(t, "mysql-dsn", os.Getenv("NB_STORE_ENGINE_MYSQL_DSN"))
}

View File

@@ -6,7 +6,8 @@ import (
"net"
"net/netip"
"os"
filePath "path/filepath"
"path"
"path/filepath"
"strings"
"time"
@@ -73,9 +74,6 @@ type ServerConfig struct {
ActivityStore StoreConfig `yaml:"activityStore"`
AuthStore StoreConfig `yaml:"authStore"`
ReverseProxy ReverseProxyConfig `yaml:"reverseProxy"`
SupportedSyncMessageVersions *int `yaml:"supportedSyncMessageVersions,omitempty"`
PerAccountSupportedSyncMessageVersions map[string]int `yaml:"perAccountSupportedSyncMessageVersions,omitempty"`
}
// TLSConfig contains TLS/HTTPS settings
@@ -302,19 +300,6 @@ func (c *CombinedConfig) ApplySimplifiedDefaults() {
c.autoConfigureClientSettings(exposedProto, exposedHost, exposedHostPort, hasExternalStuns, hasExternalRelay, hasExternalSignal)
}
// ApplyAdminDefaults applies the management settings needed by admin commands even
// when the full server config is invalid and ApplySimplifiedDefaults cannot run.
func (c *CombinedConfig) ApplyAdminDefaults() {
if c.Management.DataDir == "" || c.Management.DataDir == "/var/lib/netbird/" {
c.Management.DataDir = c.Server.DataDir
}
if c.Management.Store.Engine == "" || c.Management.Store.Engine == "sqlite" {
if c.Server.Store.Engine != "" || c.Server.Store.File != "" || c.Server.Store.DSN != "" {
c.Management.Store = c.Server.Store
}
}
}
// applyRelayDefaults configures the relay service if no external relay is configured.
func (c *CombinedConfig) applyRelayDefaults(exposedProto, exposedHostPort string, hasExternalRelay, hasExternalStuns bool) {
if hasExternalRelay {
@@ -592,11 +577,11 @@ func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.Emb
return nil, fmt.Errorf("authStore.dsn is required when authStore.engine is postgres")
}
} else {
authStorageFile = filePath.Join(mgmt.DataDir, "idp.db")
authStorageFile = path.Join(mgmt.DataDir, "idp.db")
if c.Server.AuthStore.File != "" {
authStorageFile = c.Server.AuthStore.File
if !filePath.IsAbs(authStorageFile) {
authStorageFile = filePath.Join(mgmt.DataDir, authStorageFile)
if !filepath.IsAbs(authStorageFile) {
authStorageFile = filepath.Join(mgmt.DataDir, authStorageFile)
}
}
}
@@ -711,18 +696,16 @@ func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) {
httpConfig.AuthCallbackURL = callbackURL + types.ProxyCallbackEndpointFull
return &nbconfig.Config{
Stuns: stuns,
Relay: relayConfig,
Signal: signalConfig,
Datadir: mgmt.DataDir,
DataStoreEncryptionKey: mgmt.Store.EncryptionKey,
HttpConfig: httpConfig,
StoreConfig: storeConfig,
ReverseProxy: reverseProxy,
DisableDefaultPolicy: mgmt.DisableDefaultPolicy,
EmbeddedIdP: embeddedIdP,
HighestSupportedSyncMessageVersion: c.Server.SupportedSyncMessageVersions,
PerAccountHighestSupportedSyncMessageVersion: c.Server.PerAccountSupportedSyncMessageVersions,
Stuns: stuns,
Relay: relayConfig,
Signal: signalConfig,
Datadir: mgmt.DataDir,
DataStoreEncryptionKey: mgmt.Store.EncryptionKey,
HttpConfig: httpConfig,
StoreConfig: storeConfig,
ReverseProxy: reverseProxy,
DisableDefaultPolicy: mgmt.DisableDefaultPolicy,
EmbeddedIdP: embeddedIdP,
}, nil
}
@@ -746,7 +729,7 @@ func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config, mgmtPort
cfg.EmbeddedIdP.Storage.Type = "sqlite3"
}
if cfg.EmbeddedIdP.Storage.Config.File == "" && cfg.Datadir != "" {
cfg.EmbeddedIdP.Storage.Config.File = filePath.Join(cfg.Datadir, "idp.db")
cfg.EmbeddedIdP.Storage.Config.File = path.Join(cfg.Datadir, "idp.db")
}
issuer := cfg.EmbeddedIdP.Issuer

View File

@@ -31,7 +31,6 @@ import (
relayServer "github.com/netbirdio/netbird/relay/server"
"github.com/netbirdio/netbird/relay/server/listener"
"github.com/netbirdio/netbird/relay/server/listener/ws"
syncgrpc "github.com/netbirdio/netbird/shared/management/grpc"
sharedMetrics "github.com/netbirdio/netbird/shared/metrics"
"github.com/netbirdio/netbird/shared/relay/auth"
"github.com/netbirdio/netbird/shared/signal/proto"
@@ -65,8 +64,7 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to YAML configuration file (required)")
_ = rootCmd.MarkPersistentFlagRequired("config")
rootCmd.AddCommand(newAdminCommands())
rootCmd.AddCommand(newLegacyTokenCommand())
rootCmd.AddCommand(newTokenCommands())
}
func RootCmd() *cobra.Command {
@@ -124,37 +122,6 @@ func execute(cmd *cobra.Command, _ []string) error {
}
// initializeConfig loads and validates the configuration, then initializes logging.
func applyServerStoreEnv(storeConfig StoreConfig) {
if dsn := storeConfig.DSN; dsn != "" {
switch strings.ToLower(storeConfig.Engine) {
case "postgres":
os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn)
case "mysql":
os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn)
}
}
if file := storeConfig.File; file != "" {
os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file)
}
}
func applyActivityStoreEnv(storeConfig StoreConfig) error {
if engine := storeConfig.Engine; engine != "" {
engineLower := strings.ToLower(engine)
if engineLower == "postgres" && storeConfig.DSN == "" {
return fmt.Errorf("activityStore.dsn is required when activityStore.engine is postgres")
}
os.Setenv("NB_ACTIVITY_EVENT_STORE_ENGINE", engineLower)
if dsn := storeConfig.DSN; dsn != "" {
os.Setenv("NB_ACTIVITY_EVENT_POSTGRES_DSN", dsn)
}
}
if file := storeConfig.File; file != "" {
os.Setenv("NB_ACTIVITY_EVENT_SQLITE_FILE", file)
}
return nil
}
func initializeConfig() error {
var err error
config, err = LoadConfig(configPath)
@@ -170,10 +137,30 @@ func initializeConfig() error {
return fmt.Errorf("failed to initialize log: %w", err)
}
applyServerStoreEnv(config.Server.Store)
if dsn := config.Server.Store.DSN; dsn != "" {
switch strings.ToLower(config.Server.Store.Engine) {
case "postgres":
os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn)
case "mysql":
os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn)
}
}
if file := config.Server.Store.File; file != "" {
os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file)
}
if err := applyActivityStoreEnv(config.Server.ActivityStore); err != nil {
return err
if engine := config.Server.ActivityStore.Engine; engine != "" {
engineLower := strings.ToLower(engine)
if engineLower == "postgres" && config.Server.ActivityStore.DSN == "" {
return fmt.Errorf("activityStore.dsn is required when activityStore.engine is postgres")
}
os.Setenv("NB_ACTIVITY_EVENT_STORE_ENGINE", engineLower)
if dsn := config.Server.ActivityStore.DSN; dsn != "" {
os.Setenv("NB_ACTIVITY_EVENT_POSTGRES_DSN", dsn)
}
}
if file := config.Server.ActivityStore.File; file != "" {
os.Setenv("NB_ACTIVITY_EVENT_SQLITE_FILE", file)
}
log.Infof("Starting combined NetBird server")
@@ -518,16 +505,6 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (m
}
mgmtPort, _ := strconv.Atoi(portStr)
if err := syncgrpc.ValidateSyncMessageVersion(mgmtConfig.HighestSupportedSyncMessageVersion); err != nil {
return nil, err
}
for accountId, version := range mgmtConfig.PerAccountHighestSupportedSyncMessageVersion {
if err := syncgrpc.ValidateSyncMessageVersion(&version); err != nil {
return nil, fmt.Errorf("unrecognized sync message version in perAccountSupportedSyncMessageVersions for account %s %w", accountId, err)
}
}
mgmtSrv := newServer(
&mgmtServer.Config{
NbConfig: mgmtConfig,

63
combined/cmd/token.go Normal file
View File

@@ -0,0 +1,63 @@
package cmd
import (
"context"
"fmt"
"os"
"strings"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/formatter/hook"
tokencmd "github.com/netbirdio/netbird/management/cmd/token"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/util"
)
// newTokenCommands creates the token command tree with combined-specific store opener.
func newTokenCommands() *cobra.Command {
return tokencmd.NewCommands(withTokenStore)
}
// withTokenStore loads the combined YAML config, initializes the store, and calls fn.
func withTokenStore(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error {
if err := util.InitLog("error", "console"); err != nil {
return fmt.Errorf("init log: %w", err)
}
ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck
cfg, err := LoadConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if dsn := cfg.Server.Store.DSN; dsn != "" {
switch strings.ToLower(cfg.Server.Store.Engine) {
case "postgres":
os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn)
case "mysql":
os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn)
}
}
if file := cfg.Server.Store.File; file != "" {
os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file)
}
datadir := cfg.Management.DataDir
engine := types.Engine(cfg.Management.Store.Engine)
s, err := store.NewStore(ctx, engine, datadir, nil, true)
if err != nil {
return fmt.Errorf("create store: %w", err)
}
defer func() {
if err := s.Close(ctx); err != nil {
log.Debugf("close store: %v", err)
}
}()
return fn(ctx, s)
}

View File

@@ -53,7 +53,6 @@ type NameServerGroup struct {
ID string `gorm:"primaryKey"`
// AccountID is a reference to Account that this object belongs
AccountID string `gorm:"index"`
PublicID string `json:"-"`
// Name group name
Name string
// Description group description

View File

@@ -91,14 +91,7 @@ func availableProviders() []providerCase {
if region == "" {
region = "us-east-1"
}
// A valid Bedrock inference-profile id (region prefix + date + version),
// overridable per account. `global.` profiles can be invoked from any
// region; set AWS_BEDROCK_MODEL to match the enabled profile for the token.
model := os.Getenv("AWS_BEDROCK_MODEL")
if model == "" {
model = "global.anthropic.claude-haiku-4-5-20251001-v1:0"
}
ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: model, kind: harness.WireBedrock})
ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: "us.anthropic.claude-haiku-4-5", kind: harness.WireBedrock})
}
return ps
}
@@ -115,16 +108,8 @@ func providerRequest(pc providerCase) api.AgentNetworkProviderRequest {
Enabled: ptr(true),
}
if pc.kind != harness.WireVertex {
// The router matches the normalized catalog id. Bedrock's request model
// travels as a region-prefixed inference-profile id in the URL path
// (us.anthropic...), which the router strips before matching, so register
// the normalized form here or routing fails as model_not_routable.
modelID := pc.model
if pc.kind == harness.WireBedrock {
modelID = catalogModel(pc)
}
req.Models = &[]api.AgentNetworkProviderModel{
{Id: modelID, InputPer1k: 0.001, OutputPer1k: 0.002},
{Id: pc.model, InputPer1k: 0.001, OutputPer1k: 0.002},
}
}
return req
@@ -216,13 +201,11 @@ func TestProvidersMatrix(t *testing.T) {
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
// Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking
// the proxy peer so WaitProxyPeer then observes it connected.
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
}
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
for _, pc := range matrix {
pc := pc

View File

@@ -4,7 +4,6 @@ package agentnetwork
import (
"context"
"regexp"
"strings"
"testing"
"time"
@@ -16,29 +15,13 @@ import (
"github.com/netbirdio/netbird/shared/management/http/api"
)
// bedrockRegionPrefixes and bedrockVersionSuffix mirror the proxy's Bedrock
// model normalization (region/inference-profile prefix + version suffix) so the
// provider is registered under the same catalog key the router matches against.
var (
bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."}
bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`)
)
// catalogModel returns the normalized catalog id the proxy stamps for a
// path-routed provider's configured model — the form the router and guardrail
// allowlist compare against (Bedrock region prefix + version stripped, Vertex
// @version stripped).
// path-routed provider's configured model — the form the guardrail allowlist is
// compared against (region prefix / @version stripped).
func catalogModel(pc providerCase) string {
switch pc.kind {
case harness.WireBedrock:
m := pc.model
for _, p := range bedrockRegionPrefixes {
if strings.HasPrefix(m, p) {
m = m[len(p):]
break
}
}
return bedrockVersionSuffix.ReplaceAllString(m, "")
return strings.TrimPrefix(pc.model, "us.")
case harness.WireVertex:
return strings.SplitN(pc.model, "@", 2)[0]
default:
@@ -164,13 +147,11 @@ func TestModelAllowlistEnforced(t *testing.T) {
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
// Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking
// the proxy peer so WaitProxyPeer then observes it connected.
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
}
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
for _, pc := range providers {
pc := pc

View File

@@ -104,13 +104,11 @@ func TestProviderSkipTLSVerification(t *testing.T) {
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
// Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking
// the proxy peer so WaitProxyPeer then observes it connected.
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve endpoint to proxy IP")
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
}
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve endpoint to proxy IP")
// Positive: skip=true reaches the self-signed upstream. Retry to absorb
// tunnel/DNS jitter on the first call; success also proves the path works.

View File

@@ -106,13 +106,11 @@ func TestVLLMProvider(t *testing.T) {
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
// Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking
// the proxy peer so WaitProxyPeer then observes it connected.
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve endpoint to proxy IP")
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
}
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve endpoint to proxy IP")
before, _ := srv.ListAccessLogs(ctx)
sessionID := "e2e-session-vllm"

16
go.mod
View File

@@ -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
@@ -70,7 +71,7 @@ require (
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2
github.com/hashicorp/go-version v1.7.0
github.com/jackc/pgx/v5 v5.6.0
github.com/jackc/pgx/v5 v5.5.5
github.com/libdns/route53 v1.5.0
github.com/libp2p/go-nat v0.2.0
github.com/libp2p/go-netroute v0.4.0
@@ -134,10 +135,10 @@ require (
golang.org/x/time v0.15.0
google.golang.org/api v0.276.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.30.0
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.7
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89
howett.net/plist v1.0.2-0.20250314012144-ee69052608d9
)
@@ -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
@@ -235,8 +235,8 @@ require (
github.com/huin/goupnp v1.2.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jackpal/go-nat-pmp v1.0.2 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect

30
go.sum
View File

@@ -217,6 +217,7 @@ github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3yg
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
@@ -340,12 +341,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
@@ -919,14 +920,15 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 h1:mGJaeA61P8dEHTqdvAgc70ZIV3QoUoJcXCRyyjO26OA=

View File

@@ -308,7 +308,7 @@ func (s *Storage) OpenStorage(logger *slog.Logger) (storage.Storage, error) {
if file == "" {
return nil, fmt.Errorf("sqlite3 storage requires 'file' config")
}
return newSQLite3(file).Open(logger)
return (&sql.SQLite3{File: file}).Open(logger)
case "postgres":
dsn, _ := s.Config["dsn"].(string)
if dsn == "" {

View File

@@ -20,6 +20,7 @@ import (
"github.com/dexidp/dex/server"
"github.com/dexidp/dex/server/signer"
"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/sql"
"github.com/go-jose/go-jose/v4"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
@@ -40,7 +41,7 @@ type Config struct {
GRPCAddr string
}
const LocalConnectorID = "local"
const localConnectorID = "local"
// Provider wraps a Dex server
type Provider struct {
@@ -78,7 +79,7 @@ func NewProvider(ctx context.Context, config *Config) (*Provider, error) {
// Initialize SQLite storage
dbPath := filepath.Join(config.DataDir, "oidc.db")
sqliteConfig := newSQLite3(dbPath)
sqliteConfig := &sql.SQLite3{File: dbPath}
stor, err := sqliteConfig.Open(logger)
if err != nil {
return nil, fmt.Errorf("failed to open storage: %w", err)
@@ -494,60 +495,18 @@ func (p *Provider) Storage() storage.Storage {
return p.storage
}
// SetClientsMFAChain updates the MFAChain field on OAuth2 clients in Dex storage.
// Pass a non-empty slice (e.g. []string{"default-totp"}) to enable MFA, or nil to disable it.
func SetClientsMFAChain(ctx context.Context, st storage.Storage, clientIDs []string, mfaChain []string) error {
previousChains := make(map[string][]string, len(clientIDs))
for _, clientID := range clientIDs {
client, err := st.GetClient(ctx, clientID)
if err != nil {
return fmt.Errorf("failed to get client %s before MFA chain update: %w", clientID, err)
}
previousChains[clientID] = cloneMFAChain(client.MFAChain)
}
updatedClientIDs := make([]string, 0, len(clientIDs))
for _, clientID := range clientIDs {
if err := st.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) {
old.MFAChain = cloneMFAChain(mfaChain)
return old, nil
}); err != nil {
if rollbackErr := rollbackClientsMFAChain(ctx, st, updatedClientIDs, previousChains); rollbackErr != nil {
return fmt.Errorf("failed to update MFA chain on client %s: %w (also failed to roll back previous MFA chains: %v)", clientID, err, rollbackErr)
}
return fmt.Errorf("failed to update MFA chain on client %s: %w", clientID, err)
}
updatedClientIDs = append(updatedClientIDs, clientID)
}
return nil
}
func rollbackClientsMFAChain(ctx context.Context, st storage.Storage, clientIDs []string, previousChains map[string][]string) error {
var rollbackErrs []error
for i := len(clientIDs) - 1; i >= 0; i-- {
clientID := clientIDs[i]
previousChain := cloneMFAChain(previousChains[clientID])
if err := st.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) {
old.MFAChain = previousChain
return old, nil
}); err != nil {
rollbackErrs = append(rollbackErrs, fmt.Errorf("client %s: %w", clientID, err))
}
}
return errors.Join(rollbackErrs...)
}
func cloneMFAChain(chain []string) []string {
if chain == nil {
return nil
}
return append([]string(nil), chain...)
}
// SetClientsMFAChain updates the MFAChain field on the dashboard and CLI OAuth2 clients.
// Pass a non-empty slice (e.g. []string{"default-totp"}) to enable MFA, or nil to disable it.
func (p *Provider) SetClientsMFAChain(ctx context.Context, clientIDs []string, mfaChain []string) error {
return SetClientsMFAChain(ctx, p.storage, clientIDs, mfaChain)
for _, clientID := range clientIDs {
if err := p.storage.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) {
old.MFAChain = mfaChain
return old, nil
}); err != nil {
return fmt.Errorf("failed to update MFA chain on client %s: %w", clientID, err)
}
}
return nil
}
// Handler returns the Dex server as an http.Handler for embedding in another server.
@@ -587,7 +546,7 @@ func (p *Provider) CreateUser(ctx context.Context, email, username, password str
// Encode the user ID in Dex's format: base64(protobuf{user_id, connector_id})
// This matches the format Dex uses in JWT tokens
encodedID := EncodeDexUserID(userID, LocalConnectorID)
encodedID := EncodeDexUserID(userID, localConnectorID)
return encodedID, nil
}
@@ -666,7 +625,7 @@ func DecodeDexUserID(encodedID string) (userID, connectorID string, err error) {
// local password connector.
func IsLocalUserID(encodedID string) bool {
_, connectorID, err := DecodeDexUserID(encodedID)
return err == nil && connectorID == LocalConnectorID
return err == nil && connectorID == localConnectorID
}
// GetUser returns a user by email

View File

@@ -3,8 +3,6 @@ package dex
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
@@ -13,44 +11,11 @@ import (
"testing"
"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/memory"
sqllib "github.com/dexidp/dex/storage/sql"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type updateFailingStorage struct {
storage.Storage
failClientID string
}
func (s *updateFailingStorage) UpdateClient(ctx context.Context, id string, updater func(storage.Client) (storage.Client, error)) error {
if id == s.failClientID {
return errors.New("forced update failure")
}
return s.Storage.UpdateClient(ctx, id, updater)
}
func TestSetClientsMFAChainRollsBackUpdatedClients(t *testing.T) {
ctx := context.Background()
st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil)))
require.NoError(t, st.CreateClient(ctx, storage.Client{ID: "client-1", MFAChain: []string{"old-1"}}))
require.NoError(t, st.CreateClient(ctx, storage.Client{ID: "client-2", MFAChain: []string{"old-2"}}))
err := SetClientsMFAChain(ctx, &updateFailingStorage{Storage: st, failClientID: "client-2"}, []string{"client-1", "client-2"}, []string{"new"})
require.Error(t, err)
require.Contains(t, err.Error(), "failed to update MFA chain on client client-2")
client1, err := st.GetClient(ctx, "client-1")
require.NoError(t, err)
require.Equal(t, []string{"old-1"}, client1.MFAChain)
client2, err := st.GetClient(ctx, "client-2")
require.NoError(t, err)
require.Equal(t, []string{"old-2"}, client2.MFAChain)
}
func TestUserCreationFlow(t *testing.T) {
ctx := context.Background()

View File

@@ -1,15 +0,0 @@
//go:build cgo
package dex
import (
sql "github.com/dexidp/dex/storage/sql"
)
// newSQLite3 builds the dex SQLite3 config. CGO builds use the upstream
// struct that takes a File path. Non-CGO builds get an empty stub whose
// Open() returns the dex "SQLite not available" error — correct behaviour
// for binaries that can't link sqlite3 (e.g. cross-compiled ARM targets).
func newSQLite3(file string) *sql.SQLite3 {
return &sql.SQLite3{File: file}
}

View File

@@ -1,15 +0,0 @@
//go:build !cgo
package dex
import (
sql "github.com/dexidp/dex/storage/sql"
)
// newSQLite3 for non-CGO builds. The dex SQLite3 stub has no fields and its
// Open() returns an error documenting the missing CGO support — correct
// behaviour for cross-compiled artefacts that never actually run the
// embedded IdP. The `file` argument is ignored.
func newSQLite3(_ string) *sql.SQLite3 {
return &sql.SQLite3{}
}

View File

@@ -556,7 +556,7 @@ start_services_and_show_instructions() {
echo "Creating proxy access token..."
# Use docker exec with bash to run the token command directly
PROXY_TOKEN=$($DOCKER_COMPOSE_COMMAND exec -T netbird-server \
/go/bin/netbird-server admin token create --name "default-proxy" --config /etc/netbird/config.yaml 2>/dev/null | grep "^Token:" | awk '{print $2}')
/go/bin/netbird-server token create --name "default-proxy" --config /etc/netbird/config.yaml 2>/dev/null | grep "^Token:" | awk '{print $2}')
if [[ -z "$PROXY_TOKEN" ]]; then
echo "ERROR: Failed to create proxy token. Check netbird-server logs." > /dev/stderr

View File

@@ -1,177 +0,0 @@
package cmd
import (
"context"
"fmt"
"path"
"path/filepath"
"github.com/dexidp/dex/storage"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/formatter/hook"
admincmd "github.com/netbirdio/netbird/management/cmd/admin"
tokencmd "github.com/netbirdio/netbird/management/cmd/token"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/activity"
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/util"
)
var adminDatadir string
// newAdminCommands creates the admin command tree with management-specific resource openers.
func newAdminCommands() *cobra.Command {
cmd := admincmd.NewCommands(admincmd.Openers{
Resources: withAdminResources,
Store: withAdminStoreOnly,
IDP: withAdminIDPOnly,
})
cmd.PersistentFlags().StringVar(&adminDatadir, "datadir", "", "Override the data directory from config (used for store.db and the default idp.db)")
return cmd
}
func newLegacyTokenCommand() *cobra.Command {
cmd := tokencmd.NewCommands(tokencmd.StoreOpener(withAdminStoreOnly))
cmd.Deprecated = "use 'admin token' instead"
cmd.PersistentFlags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location")
return cmd
}
// withAdminResources initializes logging, loads config, opens the management store
// and embedded IdP storage, and calls fn.
func withAdminResources(cmd *cobra.Command, fn func(ctx context.Context, resources admincmd.Resources) error) error {
return withAdminConfig(cmd, true, func(ctx context.Context, config *nbconfig.Config, datadir string) error {
managementStore, err := openAdminStore(ctx, config, datadir)
if err != nil {
return err
}
defer admincmd.CloseStore(ctx, managementStore)
idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(config)
if err != nil {
return err
}
defer admincmd.CloseIDPStorage(idpStorage)
eventStore, esErr := openAdminEventStore(ctx, config, datadir)
if esErr != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: audit events will not be recorded: %v\n", esErr)
}
if eventStore != nil {
defer func() {
if err := eventStore.Close(ctx); err != nil {
log.Debugf("close activity event store: %v", err)
}
}()
}
return fn(ctx, admincmd.Resources{Store: managementStore, IDPStorage: idpStorage, IDPStorageFile: idpStorageFile, EventStore: eventStore})
})
}
// withAdminStoreOnly opens only the management store for admin subcommands that do not
// need embedded IdP storage.
func withAdminStoreOnly(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error {
return withAdminConfig(cmd, false, func(ctx context.Context, config *nbconfig.Config, datadir string) error {
managementStore, err := openAdminStore(ctx, config, datadir)
if err != nil {
return err
}
defer admincmd.CloseStore(ctx, managementStore)
return fn(ctx, managementStore)
})
}
func withAdminIDPOnly(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error {
return withAdminConfig(cmd, true, func(ctx context.Context, config *nbconfig.Config, _ string) error {
idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(config)
if err != nil {
return err
}
defer admincmd.CloseIDPStorage(idpStorage)
return fn(ctx, idpStorage, idpStorageFile)
})
}
func withAdminConfig(cmd *cobra.Command, applyIDPDefaults bool, fn func(ctx context.Context, config *nbconfig.Config, datadir string) error) error {
if err := util.InitLog("error", "console"); err != nil {
return fmt.Errorf("init log: %w", err)
}
ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck
config, datadir, err := loadAdminMgmtConfig(ctx, applyIDPDefaults)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
return fn(ctx, config, datadir)
}
func loadAdminMgmtConfig(ctx context.Context, applyIDPDefaults bool) (*nbconfig.Config, string, error) {
config := &nbconfig.Config{}
if _, err := util.ReadJsonWithEnvSub(nbconfig.MgmtConfigPath, config); err != nil {
return nil, "", err
}
if applyIDPDefaults {
if err := ApplyEmbeddedIdPConfig(ctx, config); err != nil {
return nil, "", err
}
}
datadir := config.Datadir
applyAdminDatadirOverride(config, &datadir)
return config, datadir, nil
}
func applyAdminDatadirOverride(config *nbconfig.Config, datadir *string) {
if adminDatadir == "" {
return
}
oldDatadir := *datadir
*datadir = adminDatadir
if config.EmbeddedIdP != nil && config.EmbeddedIdP.Storage.Type == "sqlite3" && isDefaultIDPStorageFile(config.EmbeddedIdP.Storage.Config.File, oldDatadir) {
config.EmbeddedIdP.Storage.Config.File = filepath.Join(*datadir, "idp.db")
}
}
func isDefaultIDPStorageFile(file, datadir string) bool {
if file == "" {
return true
}
defaultFile := filepath.Join(datadir, "idp.db")
legacyDefaultFile := path.Join(datadir, "idp.db")
legacySlashDefaultFile := path.Join(filepath.ToSlash(datadir), "idp.db")
return filepath.Clean(file) == filepath.Clean(defaultFile) ||
file == legacyDefaultFile ||
filepath.ToSlash(file) == legacySlashDefaultFile
}
func openAdminStore(ctx context.Context, config *nbconfig.Config, datadir string) (store.Store, error) {
managementStore, err := store.NewStore(ctx, config.StoreConfig.Engine, datadir, nil, true)
if err != nil {
return nil, fmt.Errorf("create store: %w", err)
}
return managementStore, nil
}
func openAdminEventStore(ctx context.Context, config *nbconfig.Config, datadir string) (activity.Store, error) {
if config.DataStoreEncryptionKey == "" {
return nil, fmt.Errorf("data store encryption key is not configured")
}
eventStore, err := activitystore.NewSqlStore(ctx, datadir, config.DataStoreEncryptionKey)
if err != nil {
return nil, fmt.Errorf("open activity event store: %w", err)
}
if eventStore == nil {
return nil, fmt.Errorf("open activity event store: returned nil store")
}
return eventStore, nil
}

View File

@@ -1,577 +0,0 @@
// Package admincmd provides reusable cobra commands for self-hosted administrator helpers.
// Both the management and combined binaries use these commands, each providing
// their own opener to handle config loading and storage initialization.
package admincmd
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"strings"
"time"
"github.com/dexidp/dex/storage"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"golang.org/x/crypto/bcrypt"
"github.com/netbirdio/netbird/formatter/hook"
nbdex "github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/cmd/proxy"
"github.com/netbirdio/netbird/management/cmd/token"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
)
// Resources contains the storages required by the admin commands.
type Resources struct {
Store store.Store
IDPStorage storage.Storage
IDPStorageFile string
EventStore activity.Store
}
// Opener initializes command resources from the command context and calls fn.
type Opener func(cmd *cobra.Command, fn func(ctx context.Context, resources Resources) error) error
// StoreOpener initializes only the management store from the command context and calls fn.
type StoreOpener func(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error
// IDPOpener initializes only the embedded IdP storage from the command context and calls fn.
type IDPOpener func(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error
// Openers contains the resource openers needed by the admin command tree.
type Openers struct {
Resources Opener
Store StoreOpener
IDP IDPOpener
}
type userSelector struct {
email string
userID string
}
func (s userSelector) normalized() userSelector {
return userSelector{
email: strings.TrimSpace(s.email),
userID: strings.TrimSpace(s.userID),
}
}
func (s userSelector) validate() error {
s = s.normalized()
if (s.email == "") == (s.userID == "") {
return fmt.Errorf("provide exactly one of --email or --user-id")
}
return nil
}
// NewCommands creates the admin command tree with the given resource openers.
func NewCommands(openers Openers) *cobra.Command {
adminCmd := &cobra.Command{
Use: "admin",
Short: "Self-hosted administrator helpers",
Long: "Administrative helpers for self-hosted deployments using the embedded identity provider.",
}
userCmd := &cobra.Command{
Use: "user",
Short: "Manage local embedded IdP users",
}
var passwordSelector userSelector
var password string
var passwordFile string
passwordCmd := &cobra.Command{
Use: "change-password (--email email | --user-id id) (--password password | --password-file path)",
Aliases: []string{"set-password"},
Short: "Change a local user's password",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := passwordSelector.validate(); err != nil {
return err
}
newPassword, err := resolvePasswordInput(cmd, password, passwordFile)
if err != nil {
return err
}
return openers.IDP(cmd, func(ctx context.Context, idpStorage storage.Storage, storageFile string) error {
return runChangePassword(ctx, idpStorage, cmd.OutOrStdout(), passwordSelector, newPassword, storageFile)
})
},
}
addUserSelectorFlags(passwordCmd, &passwordSelector)
passwordCmd.Flags().StringVar(&password, "password", "", "New password for the user")
passwordCmd.Flags().StringVar(&passwordFile, "password-file", "", "Read new password from file ('-' for stdin)")
var resetSelector userSelector
resetMFACmd := &cobra.Command{
Use: "reset-mfa (--email email | --user-id id)",
Short: "Reset a local user's MFA enrollment",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := resetSelector.validate(); err != nil {
return err
}
return openers.IDP(cmd, func(ctx context.Context, idpStorage storage.Storage, storageFile string) error {
return runResetMFA(ctx, idpStorage, cmd.OutOrStdout(), resetSelector, storageFile)
})
},
}
addUserSelectorFlags(resetMFACmd, &resetSelector)
userCmd.AddCommand(passwordCmd, resetMFACmd)
mfaCmd := &cobra.Command{
Use: "mfa",
Short: "Manage local MFA for embedded IdP users",
}
enableCmd := &cobra.Command{
Use: "enable",
Short: "Enable MFA for local embedded IdP users",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return openers.Resources(cmd, func(ctx context.Context, resources Resources) error {
return runSetMFAEnabled(ctx, resources, cmd.OutOrStdout(), true)
})
},
}
disableCmd := &cobra.Command{
Use: "disable",
Short: "Disable MFA for local embedded IdP users",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return openers.Resources(cmd, func(ctx context.Context, resources Resources) error {
return runSetMFAEnabled(ctx, resources, cmd.OutOrStdout(), false)
})
},
}
statusCmd := &cobra.Command{
Use: "status",
Short: "Show local MFA status",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return openers.Resources(cmd, func(ctx context.Context, resources Resources) error {
return runMFAStatus(ctx, resources, cmd.OutOrStdout())
})
},
}
mfaCmd.AddCommand(enableCmd, disableCmd, statusCmd)
adminCmd.AddCommand(userCmd, mfaCmd)
if openers.Store != nil {
adminCmd.AddCommand(tokencmd.NewCommands(tokencmd.StoreOpener(openers.Store)))
adminCmd.AddCommand(proxycmd.NewCommands(proxycmd.StoreOpener(openers.Store)))
}
return adminCmd
}
// OpenEmbeddedIDPStorage opens the Dex storage configured for the embedded IdP.
func OpenEmbeddedIDPStorage(cfg *idp.EmbeddedIdPConfig) (storage.Storage, error) {
if cfg == nil || !cfg.Enabled {
return nil, fmt.Errorf("admin commands require the embedded IdP to be enabled")
}
yamlConfig, err := cfg.ToYAMLConfig()
if err != nil {
return nil, fmt.Errorf("build embedded IdP config: %w", err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
st, err := yamlConfig.Storage.OpenStorage(logger)
if err != nil {
return nil, fmt.Errorf("open embedded IdP storage: %w", err)
}
return st, nil
}
// CloseStore closes the management store and logs cleanup errors at debug level.
func CloseStore(ctx context.Context, s store.Store) {
if s == nil {
return
}
if err := s.Close(ctx); err != nil {
log.Debugf("close store: %v", err)
}
}
// OpenIDPStorage opens embedded IdP storage and returns its sqlite file path when applicable.
func OpenIDPStorage(config *nbconfig.Config) (storage.Storage, string, error) {
if config == nil {
return nil, "", fmt.Errorf("management config is required")
}
idpStorage, err := OpenEmbeddedIDPStorage(config.EmbeddedIdP)
if err != nil {
return nil, "", err
}
return idpStorage, embeddedIDPStorageFile(config), nil
}
func embeddedIDPStorageFile(config *nbconfig.Config) string {
if config.EmbeddedIdP == nil || config.EmbeddedIdP.Storage.Type != "sqlite3" {
return ""
}
return config.EmbeddedIdP.Storage.Config.File
}
// CloseIDPStorage closes embedded IdP storage and logs cleanup errors at debug level.
func CloseIDPStorage(s storage.Storage) {
if s == nil {
return
}
if err := s.Close(); err != nil {
log.Debugf("close embedded IdP storage: %v", err)
}
}
func addUserSelectorFlags(cmd *cobra.Command, selector *userSelector) {
cmd.Flags().StringVar(&selector.email, "email", "", "User email")
cmd.Flags().StringVar(&selector.userID, "user-id", "", "User ID")
}
func resolvePasswordInput(cmd *cobra.Command, password, passwordFile string) (string, error) {
if password != "" && passwordFile != "" {
return "", fmt.Errorf("provide only one of --password or --password-file")
}
if passwordFile == "" {
return password, nil
}
var data []byte
var err error
if passwordFile == "-" {
data, err = io.ReadAll(cmd.InOrStdin())
} else {
data, err = os.ReadFile(passwordFile)
}
if err != nil {
return "", fmt.Errorf("read password: %w", err)
}
return strings.TrimRight(string(data), "\r\n"), nil
}
func runChangePassword(ctx context.Context, idpStorage storage.Storage, w io.Writer, selector userSelector, password string, idpStorageFile string) error {
if idpStorage == nil {
return fmt.Errorf("embedded IdP storage is required")
}
selector = selector.normalized()
if err := selector.validate(); err != nil {
return err
}
if password == "" {
return fmt.Errorf("password is required")
}
if err := server.ValidatePassword(password); err != nil {
return fmt.Errorf("invalid password: %w", err)
}
user, err := findLocalUser(ctx, idpStorage, selector, idpStorageFile)
if err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
if err := idpStorage.UpdatePassword(ctx, user.Email, func(old storage.Password) (storage.Password, error) {
old.Hash = hash
return old, nil
}); err != nil {
return fmt.Errorf("update password for %s: %w", user.Email, err)
}
if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil {
return err
}
_, _ = fmt.Fprintf(w, "Password updated for %s.\n", user.Email)
return nil
}
func runResetMFA(ctx context.Context, idpStorage storage.Storage, w io.Writer, selector userSelector, idpStorageFile string) error {
if idpStorage == nil {
return fmt.Errorf("embedded IdP storage is required")
}
selector = selector.normalized()
if err := selector.validate(); err != nil {
return err
}
user, err := findLocalUser(ctx, idpStorage, selector, idpStorageFile)
if err != nil {
return err
}
reset := false
err = idpStorage.UpdateUserIdentity(ctx, user.UserID, idp.LocalConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) {
reset = reset || len(old.MFASecrets) > 0 || len(old.WebAuthnCredentials) > 0
old.MFASecrets = map[string]*storage.MFASecret{}
old.WebAuthnCredentials = map[string][]storage.WebAuthnCredential{}
return old, nil
})
if errors.Is(err, storage.ErrNotFound) {
if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil {
return err
}
_, _ = fmt.Fprintf(w, "No MFA enrollment found for %s.\n", user.Email)
return nil
}
if err != nil {
return fmt.Errorf("reset MFA for %s: %w", user.Email, err)
}
if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil {
return err
}
if reset {
_, _ = fmt.Fprintf(w, "MFA reset for %s. The user will re-enroll at next login.\n", user.Email)
} else {
_, _ = fmt.Fprintf(w, "No MFA enrollment found for %s.\n", user.Email)
}
return nil
}
func runSetMFAEnabled(ctx context.Context, resources Resources, w io.Writer, enabled bool) error {
if resources.Store == nil {
return fmt.Errorf("management store is required")
}
if resources.IDPStorage == nil {
return fmt.Errorf("embedded IdP storage is required")
}
accountID, settings, err := getSingleAccountSettings(ctx, resources.Store)
if err != nil {
return err
}
oldEnabled := settings.LocalMfaEnabled
newSettings := settings.Copy()
newSettings.LocalMfaEnabled = enabled
if err := setIDPClientsMFA(ctx, resources.IDPStorage, enabled); err != nil {
return err
}
if err := resources.Store.SaveAccountSettings(ctx, accountID, newSettings); err != nil {
if rollbackErr := setIDPClientsMFA(ctx, resources.IDPStorage, oldEnabled); rollbackErr != nil {
return fmt.Errorf("save local MFA account setting: %w (also failed to roll back embedded IdP MFA state: %v)", err, rollbackErr)
}
return fmt.Errorf("save local MFA account setting: %w", err)
}
if err := storeMFAActivity(ctx, resources.EventStore, accountID, enabled); err != nil {
_, _ = fmt.Fprintf(w, "Warning: failed to record audit event: %v\n", err)
}
state := "disabled"
if enabled {
state = "enabled"
}
_, _ = fmt.Fprintf(w, "Local MFA %s.\n", state)
return nil
}
func runMFAStatus(ctx context.Context, resources Resources, w io.Writer) error {
if resources.Store == nil {
return fmt.Errorf("management store is required")
}
if resources.IDPStorage == nil {
return fmt.Errorf("embedded IdP storage is required")
}
_, settings, err := getSingleAccountSettings(ctx, resources.Store)
if err != nil {
return err
}
accountStatus := "disabled"
if settings.LocalMfaEnabled {
accountStatus = "enabled"
}
clientStatus, err := idpClientsMFAStatus(ctx, resources.IDPStorage)
if err != nil {
return err
}
_, _ = fmt.Fprintf(w, "Account setting: %s\n", accountStatus)
_, _ = fmt.Fprintf(w, "Embedded IdP clients: %s\n", clientStatus)
return nil
}
func getSingleAccountSettings(ctx context.Context, s store.Store) (string, *types.Settings, error) {
count, err := s.GetAccountsCounter(ctx)
if err != nil {
return "", nil, fmt.Errorf("count accounts: %w", err)
}
if count != 1 {
return "", nil, fmt.Errorf("expected exactly one account, got %d; local MFA is supported only in single-account embedded IdP deployments", count)
}
accountID, err := s.GetAnyAccountID(ctx)
if err != nil {
return "", nil, fmt.Errorf("get account ID: %w", err)
}
settings, err := s.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return "", nil, fmt.Errorf("get account settings: %w", err)
}
if settings == nil {
settings = &types.Settings{}
}
return accountID, settings, nil
}
func storeMFAActivity(ctx context.Context, eventStore activity.Store, accountID string, enabled bool) error {
if eventStore == nil {
return nil
}
event := activity.AccountLocalMfaDisabled
if enabled {
event = activity.AccountLocalMfaEnabled
}
_, err := eventStore.Save(ctx, &activity.Event{
Timestamp: time.Now().UTC(),
Activity: event,
InitiatorID: string(hook.SystemSource),
TargetID: accountID,
AccountID: accountID,
})
if err != nil {
return fmt.Errorf("save local MFA audit event: %w", err)
}
return nil
}
func findLocalUser(ctx context.Context, idpStorage storage.Storage, selector userSelector, idpStorageFile string) (storage.Password, error) {
selector = selector.normalized()
if err := selector.validate(); err != nil {
return storage.Password{}, err
}
if selector.email != "" {
user, err := idpStorage.GetPassword(ctx, selector.email)
if errors.Is(err, storage.ErrNotFound) {
if empty, listErr := localUsersEmpty(ctx, idpStorage); listErr != nil {
return storage.Password{}, listErr
} else if empty {
return storage.Password{}, noLocalUsersError(idpStorageFile)
}
return storage.Password{}, fmt.Errorf("local user with email %q not found", selector.email)
}
if err != nil {
return storage.Password{}, fmt.Errorf("get local user by email %q: %w", selector.email, err)
}
return user, nil
}
rawUserID := selector.userID
if decodedUserID, _, err := nbdex.DecodeDexUserID(selector.userID); err == nil && decodedUserID != "" {
rawUserID = decodedUserID
}
users, err := idpStorage.ListPasswords(ctx)
if err != nil {
return storage.Password{}, fmt.Errorf("list local users: %w", err)
}
for _, user := range users {
if user.UserID == rawUserID || user.UserID == selector.userID {
return user, nil
}
}
if len(users) == 0 {
return storage.Password{}, noLocalUsersError(idpStorageFile)
}
return storage.Password{}, fmt.Errorf("local user with ID %q not found", selector.userID)
}
func localUsersEmpty(ctx context.Context, idpStorage storage.Storage) (bool, error) {
users, err := idpStorage.ListPasswords(ctx)
if err != nil {
return false, fmt.Errorf("list local users: %w", err)
}
return len(users) == 0, nil
}
func noLocalUsersError(idpStorageFile string) error {
location := ""
if idpStorageFile != "" {
location = fmt.Sprintf(" (%s)", idpStorageFile)
}
return fmt.Errorf("no local users exist in the embedded IdP storage%s; the management server may never have started with this config, or --datadir points at the wrong location", location)
}
func deleteLocalAuthSession(ctx context.Context, idpStorage storage.Storage, userID string) error {
err := idpStorage.DeleteAuthSession(ctx, userID, idp.LocalConnectorID)
if err == nil || errors.Is(err, storage.ErrNotFound) {
return nil
}
return fmt.Errorf("delete local auth session for user %s: %w", userID, err)
}
func setIDPClientsMFA(ctx context.Context, idpStorage storage.Storage, enabled bool) error {
var mfaChain []string
if enabled {
mfaChain = []string{idp.DefaultTOTPAuthenticatorID}
}
clientIDs := []string{idp.StaticClientCLI, idp.StaticClientDashboard}
if err := nbdex.SetClientsMFAChain(ctx, idpStorage, clientIDs, mfaChain); err != nil {
if errors.Is(err, storage.ErrNotFound) {
return fmt.Errorf("embedded IdP client not found; start the management server once before toggling MFA: %w", err)
}
return fmt.Errorf("update MFA chain on embedded IdP clients: %w", err)
}
return nil
}
func idpClientsMFAStatus(ctx context.Context, idpStorage storage.Storage) (string, error) {
clientIDs := []string{idp.StaticClientCLI, idp.StaticClientDashboard}
enabledCount := 0
for _, clientID := range clientIDs {
client, err := idpStorage.GetClient(ctx, clientID)
if errors.Is(err, storage.ErrNotFound) {
return "unknown", fmt.Errorf("embedded IdP client %q not found", clientID)
}
if err != nil {
return "unknown", fmt.Errorf("get embedded IdP client %q: %w", clientID, err)
}
if hasAuthenticator(client.MFAChain, idp.DefaultTOTPAuthenticatorID) {
enabledCount++
}
}
switch enabledCount {
case 0:
return "disabled", nil
case len(clientIDs):
return "enabled", nil
default:
return "partially enabled", nil
}
}
func hasAuthenticator(chain []string, authenticatorID string) bool {
for _, id := range chain {
if id == authenticatorID {
return true
}
}
return false
}

View File

@@ -1,250 +0,0 @@
package admincmd
import (
"bytes"
"context"
"io"
"log/slog"
"strings"
"testing"
"time"
"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/memory"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
nbdex "github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/server/idp"
mgmtstore "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
)
func newTestIDPStorage(t *testing.T) storage.Storage {
t.Helper()
st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil)))
hash, err := bcrypt.GenerateFromPassword([]byte("OldPass1!"), bcrypt.DefaultCost)
require.NoError(t, err)
require.NoError(t, st.CreatePassword(context.Background(), storage.Password{
Email: "user@example.com",
Username: "User",
UserID: "user-1",
Hash: hash,
}))
require.NoError(t, st.CreateUserIdentity(context.Background(), storage.UserIdentity{
UserID: "user-1",
ConnectorID: idp.LocalConnectorID,
MFASecrets: map[string]*storage.MFASecret{
idp.DefaultTOTPAuthenticatorID: {
AuthenticatorID: idp.DefaultTOTPAuthenticatorID,
Type: "TOTP",
Secret: "otpauth://totp/NetBird:user@example.com?secret=ABC",
Confirmed: true,
CreatedAt: time.Now(),
},
},
WebAuthnCredentials: map[string][]storage.WebAuthnCredential{
"webauthn": {{CredentialID: []byte("credential")}},
},
}))
require.NoError(t, st.CreateAuthSession(context.Background(), storage.AuthSession{
UserID: "user-1",
ConnectorID: idp.LocalConnectorID,
Nonce: "nonce",
}))
require.NoError(t, st.CreateClient(context.Background(), storage.Client{ID: idp.StaticClientCLI, Name: "CLI"}))
require.NoError(t, st.CreateClient(context.Background(), storage.Client{ID: idp.StaticClientDashboard, Name: "Dashboard"}))
return st
}
func TestRunChangePassword(t *testing.T) {
ctx := context.Background()
st := newTestIDPStorage(t)
var out bytes.Buffer
err := runChangePassword(ctx, st, &out, userSelector{email: "user@example.com"}, "NewPass1!", "")
require.NoError(t, err)
require.Contains(t, out.String(), "Password updated")
user, err := st.GetPassword(ctx, "user@example.com")
require.NoError(t, err)
require.NoError(t, bcrypt.CompareHashAndPassword(user.Hash, []byte("NewPass1!")))
_, err = st.GetAuthSession(ctx, "user-1", idp.LocalConnectorID)
require.ErrorIs(t, err, storage.ErrNotFound)
}
func TestRunChangePasswordValidatesPassword(t *testing.T) {
st := newTestIDPStorage(t)
err := runChangePassword(context.Background(), st, io.Discard, userSelector{email: "user@example.com"}, "short", "")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid password")
}
func TestRunResetMFA(t *testing.T) {
ctx := context.Background()
st := newTestIDPStorage(t)
var out bytes.Buffer
encodedUserID := nbdex.EncodeDexUserID("user-1", idp.LocalConnectorID)
err := runResetMFA(ctx, st, &out, userSelector{userID: encodedUserID}, "")
require.NoError(t, err)
require.Contains(t, out.String(), "MFA reset")
identity, err := st.GetUserIdentity(ctx, "user-1", idp.LocalConnectorID)
require.NoError(t, err)
require.Empty(t, identity.MFASecrets)
require.Empty(t, identity.WebAuthnCredentials)
_, err = st.GetAuthSession(ctx, "user-1", idp.LocalConnectorID)
require.ErrorIs(t, err, storage.ErrNotFound)
}
func TestRunResetMFAWithoutEnrollment(t *testing.T) {
ctx := context.Background()
st := newTestIDPStorage(t)
require.NoError(t, st.UpdateUserIdentity(ctx, "user-1", idp.LocalConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) {
old.MFASecrets = nil
old.WebAuthnCredentials = nil
return old, nil
}))
var out bytes.Buffer
err := runResetMFA(ctx, st, &out, userSelector{email: "user@example.com"}, "")
require.NoError(t, err)
require.Contains(t, out.String(), "No MFA enrollment found")
}
func TestSetIDPClientsMFA(t *testing.T) {
ctx := context.Background()
st := newTestIDPStorage(t)
require.NoError(t, setIDPClientsMFA(ctx, st, true))
status, err := idpClientsMFAStatus(ctx, st)
require.NoError(t, err)
require.Equal(t, "enabled", status)
require.NoError(t, setIDPClientsMFA(ctx, st, false))
status, err = idpClientsMFAStatus(ctx, st)
require.NoError(t, err)
require.Equal(t, "disabled", status)
}
func newTestManagementStore(t *testing.T, localMFAEnabled bool) mgmtstore.Store {
t.Helper()
ctx := context.Background()
st, err := mgmtstore.NewStore(ctx, types.SqliteStoreEngine, t.TempDir(), nil, false)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, st.Close(ctx)) })
require.NoError(t, st.SaveAccount(ctx, &types.Account{
Id: "account-1",
Settings: &types.Settings{LocalMfaEnabled: localMFAEnabled},
}))
return st
}
func TestRunSetMFAEnabledDoesNotSaveWhenIDPUpdateFails(t *testing.T) {
ctx := context.Background()
managementStore := newTestManagementStore(t, false)
idpStorage := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil)))
err := runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage}, io.Discard, true)
require.Error(t, err)
require.Contains(t, err.Error(), "embedded IdP client")
settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1")
require.NoError(t, err)
require.False(t, settings.LocalMfaEnabled)
}
func TestRunSetMFAEnabledUpdatesSettingsAfterIDP(t *testing.T) {
ctx := context.Background()
managementStore := newTestManagementStore(t, false)
idpStorage := newTestIDPStorage(t)
err := runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage}, io.Discard, true)
require.NoError(t, err)
settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1")
require.NoError(t, err)
require.True(t, settings.LocalMfaEnabled)
clientStatus, err := idpClientsMFAStatus(ctx, idpStorage)
require.NoError(t, err)
require.Equal(t, "enabled", clientStatus)
}
func TestRunSetMFAEnabledSucceedsWithNilEventStore(t *testing.T) {
ctx := context.Background()
managementStore := newTestManagementStore(t, false)
idpStorage := newTestIDPStorage(t)
var out bytes.Buffer
var err error
require.NotPanics(t, func() {
err = runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage, EventStore: nil}, &out, true)
})
require.NoError(t, err)
require.Contains(t, out.String(), "Local MFA enabled")
settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1")
require.NoError(t, err)
require.True(t, settings.LocalMfaEnabled)
}
func TestUserSelectorValidate(t *testing.T) {
require.NoError(t, userSelector{email: " user@example.com "}.validate())
require.NoError(t, userSelector{userID: "user-1"}.validate())
require.Error(t, userSelector{}.validate())
require.Error(t, userSelector{email: "user@example.com", userID: "user-1"}.validate())
}
func TestFindLocalUserNotFound(t *testing.T) {
st := newTestIDPStorage(t)
_, err := findLocalUser(context.Background(), st, userSelector{email: "missing@example.com"}, "")
require.Error(t, err)
require.True(t, strings.Contains(err.Error(), "not found"))
}
func TestFindLocalUserZeroUsersIncludesStoragePath(t *testing.T) {
st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil)))
_, err := findLocalUser(context.Background(), st, userSelector{email: "missing@example.com"}, "/var/lib/netbird/idp.db")
require.Error(t, err)
require.Contains(t, err.Error(), "no local users exist")
require.Contains(t, err.Error(), "/var/lib/netbird/idp.db")
}
func TestUserCommandValidatesSelectorBeforeOpeningStorage(t *testing.T) {
opened := false
cmd := NewCommands(Openers{
IDP: func(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error {
opened = true
return nil
},
})
cmd.SetArgs([]string{"user", "change-password", "--password", "NewPass1!"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
require.Error(t, err)
require.Contains(t, err.Error(), "provide exactly one")
require.False(t, opened)
}
func TestResolvePasswordInputFromStdin(t *testing.T) {
cmd := &cobra.Command{}
cmd.SetIn(strings.NewReader("NewPass1!\n"))
password, err := resolvePasswordInput(cmd, "", "-")
require.NoError(t, err)
require.Equal(t, "NewPass1!", password)
}
func TestResolvePasswordInputRejectsMultipleSources(t *testing.T) {
_, err := resolvePasswordInput(&cobra.Command{}, "NewPass1!", "-")
require.Error(t, err)
}

View File

@@ -1,80 +0,0 @@
package cmd
import (
"context"
"path"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/idp"
)
func TestApplyAdminDatadirOverrideRelocatesDefaultIDPStorage(t *testing.T) {
oldDatadir := filepath.Join(t.TempDir(), "old")
newDatadir := filepath.Join(t.TempDir(), "new")
for _, defaultFile := range []string{
"",
filepath.Join(oldDatadir, "idp.db"),
path.Join(oldDatadir, "idp.db"),
} {
t.Run(defaultFile, func(t *testing.T) {
cfg := &nbconfig.Config{
EmbeddedIdP: &idp.EmbeddedIdPConfig{
Enabled: true,
Storage: idp.EmbeddedStorageConfig{
Type: "sqlite3",
Config: idp.EmbeddedStorageTypeConfig{
File: defaultFile,
},
},
},
}
datadir := oldDatadir
oldAdminDatadir := adminDatadir
adminDatadir = newDatadir
t.Cleanup(func() { adminDatadir = oldAdminDatadir })
applyAdminDatadirOverride(cfg, &datadir)
require.Equal(t, newDatadir, datadir)
require.Equal(t, filepath.Join(newDatadir, "idp.db"), cfg.EmbeddedIdP.Storage.Config.File)
})
}
}
func TestOpenAdminEventStoreMissingEncryptionKeyReturnsNilInterface(t *testing.T) {
eventStore, err := openAdminEventStore(context.Background(), &nbconfig.Config{}, t.TempDir())
require.Error(t, err)
require.Contains(t, err.Error(), "encryption key")
require.Nil(t, eventStore)
}
func TestApplyAdminDatadirOverrideKeepsExplicitIDPStorage(t *testing.T) {
oldDatadir := filepath.Join(t.TempDir(), "old")
newDatadir := filepath.Join(t.TempDir(), "new")
explicitFile := filepath.Join(t.TempDir(), "custom-idp.db")
cfg := &nbconfig.Config{
EmbeddedIdP: &idp.EmbeddedIdPConfig{
Enabled: true,
Storage: idp.EmbeddedStorageConfig{
Type: "sqlite3",
Config: idp.EmbeddedStorageTypeConfig{
File: explicitFile,
},
},
},
}
datadir := oldDatadir
oldAdminDatadir := adminDatadir
adminDatadir = newDatadir
t.Cleanup(func() { adminDatadir = oldAdminDatadir })
applyAdminDatadirOverride(cfg, &datadir)
require.Equal(t, newDatadir, datadir)
require.Equal(t, explicitFile, cfg.EmbeddedIdP.Storage.Config.File)
}

View File

@@ -13,7 +13,6 @@ import (
"os"
"os/signal"
"path"
"path/filepath"
"strings"
"syscall"
@@ -26,7 +25,6 @@ import (
"github.com/netbirdio/netbird/management/internals/server"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
nbdomain "github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/shared/management/grpc"
"github.com/netbirdio/netbird/util"
"github.com/netbirdio/netbird/util/crypt"
)
@@ -155,20 +153,8 @@ func LoadMgmtConfig(ctx context.Context, mgmtConfigPath string) (*nbconfig.Confi
ApplyCommandLineOverrides(loadedConfig)
err := grpc.ValidateSyncMessageVersion(loadedConfig.HighestSupportedSyncMessageVersion)
if err != nil {
return nil, err
}
for account, version := range loadedConfig.PerAccountHighestSupportedSyncMessageVersion {
err := grpc.ValidateSyncMessageVersion(&version)
if err != nil {
return nil, fmt.Errorf("unrecognized sync message version for account %s, %w", account, err)
}
}
// Apply EmbeddedIdP config to HttpConfig if embedded IdP is enabled
err = ApplyEmbeddedIdPConfig(ctx, loadedConfig)
err := ApplyEmbeddedIdPConfig(ctx, loadedConfig)
if err != nil {
return nil, err
}
@@ -223,7 +209,7 @@ func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error {
cfg.EmbeddedIdP.Storage.Type = "sqlite3"
}
if cfg.EmbeddedIdP.Storage.Config.File == "" && cfg.Datadir != "" {
cfg.EmbeddedIdP.Storage.Config.File = filepath.Join(cfg.Datadir, "idp.db")
cfg.EmbeddedIdP.Storage.Config.File = path.Join(cfg.Datadir, "idp.db")
}
issuer := cfg.EmbeddedIdP.Issuer

View File

@@ -4,9 +4,6 @@ import (
"context"
"os"
"testing"
"github.com/netbirdio/netbird/shared/management/grpc"
"github.com/stretchr/testify/assert"
)
const (
@@ -23,49 +20,34 @@ const (
"AuthAudience": "https://stageapp/",
"AuthIssuer": "https://something.eu.auth0.com/",
"OIDCConfigEndpoint": "https://something.eu.auth0.com/.well-known/openid-configuration"
},
"HighestSupportedSyncMessageVersion": 1,
"PerAccountHighestSupportedSyncMessageVersion": {
"1": 0,
"2": 1
}
}`
)
func Test_LoadMgmtConfig(t *testing.T) {
tmpFile, err := createConfig(exampleConfig)
assert.NoError(t, err)
func Test_loadMgmtConfig(t *testing.T) {
tmpFile, err := createConfig()
if err != nil {
t.Fatalf("failed to create config: %s", err)
}
cfg, err := LoadMgmtConfig(context.Background(), tmpFile)
assert.NoError(t, err)
assert.NotEmpty(t, cfg.Relay)
assert.NotEmpty(t, cfg.Relay.Addresses)
assert.Equal(t, int(grpc.ComponentNetworkMap), *cfg.HighestSupportedSyncMessageVersion)
assert.Equal(t, map[string]int{"1": int(grpc.Base), "2": int(grpc.ComponentNetworkMap)}, cfg.PerAccountHighestSupportedSyncMessageVersion)
if err != nil {
t.Fatalf("failed to load management config: %s", err)
}
if cfg.Relay == nil {
t.Fatalf("config is nil")
}
if len(cfg.Relay.Addresses) == 0 {
t.Fatalf("relay address is empty")
}
}
func Test_LoadMgmtConfig_Empty(t *testing.T) {
tmpFile, err := createConfig(`{
"HttpConfig": {
"AuthAudience": "https://stageapp/",
"AuthIssuer": "https://something.eu.auth0.com/",
"OIDCConfigEndpoint": "https://something.eu.auth0.com/.well-known/openid-configuration"
}
}`)
assert.NoError(t, err)
cfg, err := LoadMgmtConfig(context.Background(), tmpFile)
assert.NoError(t, err)
assert.Nil(t, cfg.HighestSupportedSyncMessageVersion)
assert.Nil(t, cfg.PerAccountHighestSupportedSyncMessageVersion)
}
func createConfig(config string) (string, error) {
func createConfig() (string, error) {
tmpfile, err := os.CreateTemp("", "config.json")
if err != nil {
return "", err
}
_, err = tmpfile.Write([]byte(config))
_, err = tmpfile.Write([]byte(exampleConfig))
if err != nil {
return "", err
}

View File

@@ -1,141 +0,0 @@
// Package proxycmd provides reusable cobra commands for managing reverse proxy instances.
// Both the management and combined binaries use these commands, each providing
// their own StoreOpener to handle config loading and store initialization.
package proxycmd
import (
"bufio"
"context"
"fmt"
"io"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
"github.com/netbirdio/netbird/management/server/store"
)
// StoreOpener initializes a store from the command context and calls fn.
type StoreOpener func(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error
const disconnectAllConfirmation = "disconnect all proxies"
// NewCommands creates the proxy command tree with the given store opener.
// Returns the parent "proxy" command with the disconnect-all subcommand.
func NewCommands(opener StoreOpener) *cobra.Command {
var dryRun bool
var force bool
proxyCmd := &cobra.Command{
Use: "proxy",
Short: "Manage reverse proxy instances",
Long: "Commands for inspecting and repairing the reverse proxy instances registered with the management server.",
}
disconnectAllCmd := &cobra.Command{
Use: "disconnect-all",
Short: "Force-mark all reverse proxy instances as disconnected",
Long: "Lists all reverse proxy instances and force-marks them as disconnected, regardless of their session state. " +
"Use this to repair stale connection state, e.g. after an unclean management server shutdown. " +
"By default, it asks for manual confirmation before changing state. Use --dry-run to preview without changing state, or --force to skip confirmation. " +
"Run during a maintenance window; affected live proxies may stay hidden until their next heartbeat or reconnect/re-register.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return opener(cmd, func(ctx context.Context, s store.Store) error {
return runDisconnectAll(ctx, s, cmd.OutOrStdout(), cmd.InOrStdin(), dryRun, force)
})
},
}
disconnectAllCmd.Flags().BoolVar(&dryRun, "dry-run", false, "List reverse proxy instances that would be disconnected without changing state")
disconnectAllCmd.Flags().BoolVar(&force, "force", false, "Skip the confirmation prompt and apply the repair")
proxyCmd.AddCommand(disconnectAllCmd)
return proxyCmd
}
func runDisconnectAll(ctx context.Context, s store.Store, out io.Writer, in io.Reader, dryRun, force bool) error {
proxies, err := s.GetAllProxies(ctx)
if err != nil {
return fmt.Errorf("list proxies: %w", err)
}
if len(proxies) == 0 {
_, _ = fmt.Fprintln(out, "No reverse proxy instances found.")
return nil
}
toDisconnect := 0
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(w, "ID\tCLUSTER\tIP\tACCOUNT\tSTATUS\tLAST SEEN")
_, _ = fmt.Fprintln(w, "--\t-------\t--\t-------\t------\t---------")
for _, p := range proxies {
if p.Status != rpproxy.StatusDisconnected {
toDisconnect++
}
account := "-"
if p.AccountID != nil {
account = *p.AccountID
}
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
p.ID,
p.ClusterAddress,
p.IPAddress,
account,
p.Status,
p.LastSeen.Format("2006-01-02 15:04:05"),
)
}
if err := w.Flush(); err != nil {
return fmt.Errorf("write proxy list: %w", err)
}
if dryRun {
_, _ = fmt.Fprintf(out, "\nDry run: would force-mark %d of %d reverse proxy instance(s) as disconnected.\n", toDisconnect, len(proxies))
return nil
}
if !force {
confirmed, err := confirmDisconnectAll(out, in)
if err != nil {
return err
}
if !confirmed {
_, _ = fmt.Fprintln(out, "Aborted. No reverse proxy instances were changed.")
return nil
}
}
disconnected, err := s.DisconnectAllProxies(ctx)
if err != nil {
return fmt.Errorf("disconnect proxies: %w", err)
}
_, _ = fmt.Fprintf(out, "\nForce-marked %d of %d reverse proxy instance(s) as disconnected.\n", disconnected, len(proxies))
return nil
}
func confirmDisconnectAll(out io.Writer, in io.Reader) (bool, error) {
if in == nil {
in = strings.NewReader("")
}
_, _ = fmt.Fprintln(out, "\nWARNING: This command changes stored reverse proxy state for every non-disconnected instance.")
_, _ = fmt.Fprintln(out, "Run it during a maintenance window; affected live proxies may stay hidden until "+
"their next heartbeat or reconnect/re-register.")
_, _ = fmt.Fprintf(out, "Type %q to continue: ", disconnectAllConfirmation)
scanner := bufio.NewScanner(in)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return false, fmt.Errorf("read confirmation: %w", err)
}
return false, nil
}
return strings.EqualFold(strings.TrimSpace(scanner.Text()), disconnectAllConfirmation), nil
}

Some files were not shown because too many files have changed in this diff Show More