Compare commits

..

8 Commits

Author SHA1 Message Date
Theodor S. Midtlien
5e07a0c27b Add migration for legacy profile ownership 2026-07-24 15:31:57 +02:00
Theodor S. Midtlien
0137876618 Clean up comments 2026-07-24 14:57:21 +02:00
Theodor S. Midtlien
57f9cbe5ff Add migration from legacy tcp to named pipes 2026-07-24 10:58:29 +02:00
Theodor S. Midtlien
f60ac9e746 Wire up json socket as a named pipe with metdata exchange to daemon 2026-07-23 20:27:06 +02:00
Theodor S. Midtlien
2f84aa3d20 Remove PID from Identity 2026-07-23 19:43:58 +02:00
Theodor S. Midtlien
4de39a80f8 Clean up windows impersonation/SDDL and some comments 2026-07-23 19:35:22 +02:00
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
216 changed files with 6123 additions and 14046 deletions

View File

@@ -51,9 +51,6 @@ jobs:
# token (and URL, for gateways) is unset, so partial coverage is fine.
OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }}
ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }}
# Moonshot AI platform key (platform.kimi.ai); drives both Kimi wire
# shapes (OpenAI /v1 and Anthropic /anthropic) through kimi_api.
KIMI_TOKEN: ${{ secrets.E2E_KIMI_TOKEN }}
VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }}
VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }}
OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }}

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

@@ -237,7 +237,7 @@ task dev
Pass daemon flags after `--`:
```
task dev -- --daemon-addr=tcp://127.0.0.1:41731
task dev -- --daemon-addr=npipe://netbird
```
Production build (frontend assets embedded into the binary, output in `client/ui/bin/`):

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,10 @@ func init() {
defaultDaemonAddr := "unix:///var/run/netbird.sock"
if runtime.GOOS == "windows" {
defaultDaemonAddr = "tcp://127.0.0.1:41731"
defaultDaemonAddr = windowsPipeDaemonAddr
}
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 +173,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)
@@ -264,17 +268,32 @@ func FlagNameToEnvVar(cmdFlag string, prefix string) string {
return prefix + upper
}
// daemonDialTarget returns the gRPC dial target and base options for the daemon
// address, handling the npipe scheme (Windows named pipe, via a context dialer)
// and unix/tcp.
func daemonDialTarget(addr string) (string, []grpc.DialOption) {
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
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 target, opts
}
// 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://"),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
target, dialOpts := daemonDialTarget(addr)
dialOpts = append(dialOpts, grpc.WithBlock())
dialOpts = append(dialOpts, opts...)
return grpc.DialContext(ctx, target, dialOpts...)
}
// WithBackOff execute function in backoff cycle.

View File

@@ -54,7 +54,7 @@ func init() {
serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture")
serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks")
serviceCmd.PersistentFlags().BoolVar(&enableJSONSocket, "enable-json-socket", false, "Enables the HTTP/JSON API socket served by grpc-gateway. To persist, use: netbird service install --enable-json-socket")
serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp]://[path|host:port]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket")
serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp|npipe]://[path|host:port|name]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket")
rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name")
serviceEnvDesc := `Sets extra environment variables for the service. ` +

View File

@@ -5,6 +5,7 @@ package cmd
import (
"context"
"fmt"
"runtime"
"time"
"github.com/kardianos/service"
@@ -13,12 +14,32 @@ 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 ipc if supported.
func daemonServerOptions(network string, interceptor *ipcauth.Interceptor) []grpc.ServerOption {
creds := ipcauth.NewTransportCredentials()
if creds == nil {
log.Warnf("daemon ipc 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 +58,28 @@ 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()
// A daemon installed before named-pipe support uses the old loopback-TCP
// address as the daemon address. We migrate to a named pipe so an
// upgraded daemon enforces per-caller authorization instead of silently
// running on identity-less TCP.
if migrated, ok := migrateLegacyDaemonAddr(daemonAddr); ok {
log.Infof("legacy daemon address %q predates named-pipe support. listening on %q so per-caller authorization is enforced", daemonAddr, migrated)
daemonAddr = migrated
}
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 +118,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 +126,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

@@ -5,27 +5,58 @@ package cmd
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
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"
)
func grpcGatewayEndpoint(addr string) string {
return strings.TrimPrefix(addr, "tcp://")
// 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 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.ConnIdentity(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 (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error {
mux := runtime.NewServeMux()
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
if err := proto.RegisterDaemonServiceHandlerFromEndpoint(p.ctx, mux, grpcGatewayEndpoint(daemonEndpoint), opts); err != nil {
mux := runtime.NewServeMux(runtime.WithMetadata(jsonForwardIdentity))
// Lazy client to the daemon, npipe-aware (grpc.NewClient does not connect
// until the first request, so this does not block startup before Serve).
target, opts := daemonDialTarget(daemonEndpoint)
conn, err := grpc.NewClient(target, opts...)
if err != nil {
return fmt.Errorf("create daemon client for JSON gateway: %w", err)
}
if err := proto.RegisterDaemonServiceHandler(p.ctx, mux, conn); err != nil {
return err
}
@@ -35,6 +66,7 @@ func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint
BaseContext: func(net.Listener) context.Context {
return p.ctx
},
ConnContext: jsonConnContext,
}
p.jsonServMu.Lock()

View File

@@ -125,6 +125,10 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
if !rootCmd.PersistentFlags().Changed("daemon-addr") && params.DaemonAddr != "" {
daemonAddr = params.DaemonAddr
if migrated, ok := migrateLegacyDaemonAddr(daemonAddr); ok {
cmd.Printf("Migrating saved daemon address %q to %q so per-caller authorization can be enforced\n", daemonAddr, migrated)
daemonAddr = migrated
}
}
if !serviceCmd.PersistentFlags().Changed("json-socket") && params.JSONSocket != "" {

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,30 @@
//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 permissive,
// local-only SDDL. Any local caller may connect, like Unix socket with 0666.
func listenNamedPipe(path string) (net.Listener, error) {
return winio.ListenPipe(path, &winio.PipeConfig{
SecurityDescriptor: ipcauth.DefaultPipeSDDL(),
})
}
// dialNamedPipe connects to the daemon ipc named pipe at SECURITY_IDENTIFICATION.
func dialNamedPipe(ctx context.Context, path string) (net.Conn, error) {
access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE)
// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the
// daemon cannot read the caller's token. Identification lets the daemon
// read its SID/groups without granting it the ability to act as the caller.
return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification)
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"net"
"os"
"runtime"
"strings"
"syscall"
"time"
@@ -14,6 +15,30 @@ import (
log "github.com/sirupsen/logrus"
)
const (
windowsPipeDaemonAddr = "npipe://netbird"
// legacyWindowsDaemonAddr is the loopback-TCP address the Windows daemon used
// before named-pipe support.
legacyWindowsDaemonAddr = "tcp://127.0.0.1:41731"
)
// migrateLegacyDaemonAddr upgrades the pre-named-pipe Windows daemon address to
// the pipe. Existing installs persist daemon addr, so on upgrade the daemon
// would otherwise keep listening on TCP and silently run without IPC
// authorization. Only the exact legacy default is rewritten, while a
// deliberately-chosen custom TCP address is left alone.
func migrateLegacyDaemonAddr(addr string) (string, bool) {
return migrateLegacyDaemonAddrForOS(runtime.GOOS, addr)
}
func migrateLegacyDaemonAddrForOS(goos, addr string) (string, bool) {
if goos == "windows" && addr == legacyWindowsDaemonAddr {
return windowsPipeDaemonAddr, true
}
return addr, false
}
type socketListener struct {
net.Listener
network string
@@ -26,6 +51,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)
}
@@ -41,17 +75,26 @@ func listenOnAddress(addr string) (*socketListener, error) {
func parseListenAddress(addr string) (string, string, error) {
network, address, ok := strings.Cut(addr, "://")
if !ok || network == "" || address == "" {
return "", "", fmt.Errorf("address must be in [unix|tcp]://[path|host:port] format: %q", addr)
return "", "", fmt.Errorf("address must be in [unix|tcp|npipe]://[path|host:port|name] format: %q", addr)
}
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 ("npipe://netbird") to a Windows
// named-pipe path (\\.\pipe\netbird).
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

@@ -0,0 +1,63 @@
//go:build !ios && !android
package cmd
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMigrateLegacyDaemonAddrForOS(t *testing.T) {
cases := []struct {
name string
goos string
addr string
want string
migrate bool
}{
{
name: "windows legacy tcp migrates to pipe",
goos: "windows",
addr: legacyWindowsDaemonAddr,
want: windowsPipeDaemonAddr,
migrate: true,
},
{
name: "windows pipe already migrated stays",
goos: "windows",
addr: windowsPipeDaemonAddr,
want: windowsPipeDaemonAddr,
migrate: false,
},
{
name: "windows custom tcp left alone",
goos: "windows",
addr: "tcp://127.0.0.1:9999",
want: "tcp://127.0.0.1:9999",
migrate: false,
},
{
name: "linux legacy-looking tcp not migrated",
goos: "linux",
addr: legacyWindowsDaemonAddr,
want: legacyWindowsDaemonAddr,
migrate: false,
},
{
name: "linux unix socket untouched",
goos: "linux",
addr: "unix:///var/run/netbird.sock",
want: "unix:///var/run/netbird.sock",
migrate: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := migrateLegacyDaemonAddrForOS(tc.goos, tc.addr)
assert.Equal(t, tc.want, got)
assert.Equal(t, tc.migrate, ok)
})
}
}

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

@@ -569,17 +569,17 @@ func Test_ConnectPeers(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// The peers use userspace WireGuard (stdnet transport). A tight busy-loop
// here starves the wireguard-go goroutines that process the handshake, so
// poll on a ticker instead and yield the CPU between checks. WireGuard also
// only retries a lost handshake initiation every REKEY_TIMEOUT (5s), which
// is why the overall wait can occasionally stretch to tens of seconds.
// todo: investigate why in some tests execution we need 30s
timeout := 30 * time.Second
timeoutChannel := time.After(timeout)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-timeoutChannel:
t.Fatalf("waiting for peer handshake timeout after %s", timeout.String())
default:
}
peer, gpErr := getPeer(peer1ifaceName, peer2Key.PublicKey().String())
if gpErr != nil {
t.Fatal(gpErr)
@@ -588,12 +588,6 @@ func Test_ConnectPeers(t *testing.T) {
t.Log("peers successfully handshake")
break
}
select {
case <-timeoutChannel:
t.Fatalf("waiting for peer handshake timeout after %s", timeout.String())
case <-ticker.C:
}
}
}

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,26 +42,12 @@ 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
// reconcileRoutedIPs re-applies a peer's routed allowed IPs after its lazy wake endpoint is
// (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile.
reconcileRoutedIPs func(peerKey string) error
wg sync.WaitGroup
lazyCtx context.Context
lazyCtxCancel context.CancelFunc
}
// SetRoutedIPsReconciler injects the callback used to re-apply a peer's routed allowed IPs when
// its lazy wake endpoint is (re)armed. Must be called before the lazy manager starts.
func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) {
e.reconcileRoutedIPs = fn
}
func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr {
e := &ConnMgr{
peerStore: peerStore,
@@ -254,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)
}
@@ -292,22 +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(),
ReconcileAllowedIPs: e.reconcileRoutedIPs,
}
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() {
@@ -346,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

@@ -52,14 +52,11 @@ int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
udp->dest = dns_port;
// Clear the now-stale checksum; zero means "not computed" for IPv4.
udp->check = 0;
return XDP_PASS;
}
if (udp->source == dns_port && ip->saddr == dns_ip) {
udp->source = GENERAL_DNS_PORT;
udp->check = 0;
return XDP_PASS;
}

View File

@@ -50,11 +50,5 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) {
__be16 new_dst_port = htons(proxy_port);
udp->dest = new_dst_port;
udp->source = new_src_port;
// The ports are covered by the UDP checksum. This is an IPv4 loopback hop
// and the payload is already integrity-protected, so clear the checksum (a
// zero UDP checksum means "not computed" for IPv4) rather than leave a
// stale value the kernel would drop as UDP_CSUM.
udp->check = 0;
return XDP_PASS;
}

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
@@ -663,24 +652,8 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
iceCfg := e.createICEConfig()
e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface)
e.connMgr.SetRoutedIPsReconciler(func(peerKey string) error {
if e.routeManager == nil {
return nil
}
return e.routeManager.ReconcilePeerAllowedIPs(peerKey)
})
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())
@@ -990,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")
@@ -1005,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
}
@@ -1058,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()
@@ -1079,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.
@@ -1251,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,
@@ -2120,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,84 @@
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 and owner group
// names to GIDs. 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 and shared profiles are always allowed.
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
}
}
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,22 @@
//go:build !linux && !darwin && !freebsd && !windows
package ipcauth
import (
"fmt"
"net"
"runtime"
"google.golang.org/grpc/credentials"
)
// NewTransportCredentials returns nil on platforms without a peer-identity
// primitive.
func NewTransportCredentials() credentials.TransportCredentials {
return nil
}
// ConnIdentity is unsupported on platforms without a peer-identity primitive.
func ConnIdentity(net.Conn) (Identity, error) {
return Identity{}, fmt.Errorf("peer identity not supported on %s", runtime.GOOS)
}

View File

@@ -0,0 +1,51 @@
//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{}
}
type unixCreds struct{}
func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
return conn, AuthInfo{}, nil
}
// ConnIdentity extracts the caller's identity from an accepted local IPC
// connection. On Unix it reads peer credentials from the socket. It is shared
// by the gRPC transport credentials and the JSON gateway (which forwards it).
func ConnIdentity(conn net.Conn) (Identity, error) {
return PeerIdentity(conn)
}
// 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 := ConnIdentity(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: AuthInfo{}.AuthType()}
}
func (unixCreds) Clone() credentials.TransportCredentials { return unixCreds{} }
func (unixCreds) OverrideServerName(string) error { return nil }

View File

@@ -0,0 +1,135 @@
//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")
)
// DefaultPipeSDDL keeps the daemon control pipe open to any LOCAL caller,
// like Unix socket with 0666 permissions.
//
// D:P protected DACL, no inheritance
// (D;;GA;;;NU) deny GENERIC_ALL to NETWORK (remote/SMB)
// (A;;GA;;;SY) allow GENERIC_ALL to LocalSystem (the daemon itself)
// (A;;GA;;;WD) allow GENERIC_ALL to Everyone (local, per-RPC ACL gates)
func DefaultPipeSDDL() string {
return "D:P(D;;GA;;;NU)(A;;GA;;;SY)(A;;GA;;;WD)"
}
// NewTransportCredentials returns gRPC transport credentials that derive the
// caller's identity from the named-pipe client token.
//
// 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
}
// ConnIdentity extracts the caller's identity from an accepted named-pipe
// connection by impersonating the pipe client and reading its token. It is
// shared by the gRPC transport credentials and the JSON gateway (which forwards
// it). Requires the client to have connected at SECURITY_IDENTIFICATION.
func ConnIdentity(conn net.Conn) (Identity, error) {
// go-winio's pipe connection embeds *win32File, which exposes Fd().
fdConn, ok := conn.(interface{ Fd() uintptr })
if !ok {
return Identity{}, fmt.Errorf("connection %T does not expose a pipe handle", conn)
}
return pipeClientIdentity(windows.Handle(fdConn.Fd()))
}
// 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) {
id, err := ConnIdentity(conn)
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: AuthInfo{}.AuthType()}
}
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.
func pipeClientIdentity(handle windows.Handle) (id Identity, err error) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err = impersonateNamedPipeClient(handle); err != nil {
return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err)
}
defer func() {
// Surface revert error if there are no other errors.
revErr := windows.RevertToSelf()
if err == nil {
err = revErr
}
}()
// 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&windows.SE_GROUP_ENABLED == 0 || g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 {
continue
}
groups = append(groups, g.Sid.String())
}
return Identity{
SID: tu.User.Sid.String(),
Groups: groups,
Elevated: token.IsElevated(),
}, nil
}
func impersonateNamedPipeClient(h windows.Handle) error {
r, _, e := procImpersonateNamedPipeClient.Call(uintptr(h))
if r == 0 {
return e
}
return nil
}

View File

@@ -0,0 +1,77 @@
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.
const (
mdFwdUID = "x-netbird-fwd-uid" // Unix
mdFwdGID = "x-netbird-fwd-gid" // Unix
mdFwdSID = "x-netbird-fwd-sid" // Windows user SID
mdFwdGroup = "x-netbird-fwd-group" // Windows group SID (repeated)
mdFwdElevated = "x-netbird-fwd-elevated" // Windows, "1" if elevated
)
// ForwardIdentityMetadata encodes an identity for the gateway to forward to the
// daemon.
func ForwardIdentityMetadata(id Identity) metadata.MD {
if id.IsWindows() {
md := metadata.MD{}
md.Set(mdFwdSID, id.SID)
if len(id.Groups) > 0 {
md.Set(mdFwdGroup, id.Groups...)
}
if id.Elevated {
md.Set(mdFwdElevated, "1")
}
return md
}
return metadata.Pairs(
mdFwdUID, strconv.FormatUint(uint64(id.UID), 10),
mdFwdGID, strconv.FormatUint(uint64(id.GID), 10),
)
}
// forwardedIdentity extracts a forwarded identity from incoming gRPC metadata
func forwardedIdentity(ctx context.Context) (Identity, bool) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return Identity{}, false
}
if sid := mdFirst(md, mdFwdSID); sid != "" {
return Identity{
SID: sid,
Groups: md.Get(mdFwdGroup),
Elevated: mdFirst(md, mdFwdElevated) == "1",
}, true
}
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)
}
}
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,41 @@
package ipcauth
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/metadata"
)
func TestForwardIdentityRoundTrip(t *testing.T) {
cases := []struct {
name string
id Identity
}{
{"unix uid/gid", Identity{UID: 1000, GID: 1000}},
{"windows sid+groups+elevated", Identity{
SID: "S-1-5-21-1-2-3-1001",
Groups: []string{"S-1-5-32-544", "S-1-1-0"},
Elevated: true,
}},
{"windows sid only", Identity{SID: "S-1-5-21-9"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := metadata.NewIncomingContext(context.Background(), ForwardIdentityMetadata(tc.id))
got, ok := forwardedIdentity(ctx)
assert.True(t, ok)
assert.Equal(t, tc.id, got)
})
}
}
func TestForwardedIdentity_None(t *testing.T) {
_, ok := forwardedIdentity(context.Background())
assert.False(t, ok, "no metadata, no forwarded identity")
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("other", "x"))
_, ok = forwardedIdentity(ctx)
assert.False(t, ok)
}

View File

@@ -0,0 +1,89 @@
// 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.
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.
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
// 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.
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() {
return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated)
}
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, 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}
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,122 @@
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 IPC, 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. -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, 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: 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,138 @@
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 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: 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,42 @@
//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 primary group.
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,41 @@
//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,
}, 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,90 @@
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.
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 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.
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,54 @@
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.
func OwnerPrincipalForIdentity(id Identity) string {
if id.IsWindows() {
return SIDPrincipal(id.SID)
}
return UIDPrincipal(id.UID)
}

View File

@@ -0,0 +1,71 @@
//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, 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).
func NewDefaultGroupResolver() GroupResolver {
return nil
}

View File

@@ -29,11 +29,6 @@ type managedPeer struct {
type Config struct {
InactivityThreshold *time.Duration
// ReconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is
// armed. The activity listener creates the wake peer with the overlay /32 only; without the
// routed prefixes WireGuard would not steer subnet-bound traffic to the wake endpoint, so an
// idle routing peer could never be woken by that traffic. Optional; nil disables the reconcile.
ReconcileAllowedIPs func(peerKey string) error
}
// Manager manages lazy connections
@@ -61,9 +56,6 @@ type Manager struct {
peerToHAGroups map[string][]route.HAUniqueID // peer ID -> HA groups they belong to
haGroupToPeers map[route.HAUniqueID][]string // HA group -> peer IDs in the group
routesMu sync.RWMutex
// reconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is armed.
reconcileAllowedIPs func(peerKey string) error
}
// NewManager creates a new lazy connection manager
@@ -81,7 +73,6 @@ func NewManager(config Config, engineCtx context.Context, peerStore *peerstore.S
activityManager: activity.NewManager(wgIface),
peerToHAGroups: make(map[string][]route.HAUniqueID),
haGroupToPeers: make(map[route.HAUniqueID][]string),
reconcileAllowedIPs: config.ReconcileAllowedIPs,
}
if wgIface.IsUserspaceBind() {
@@ -210,7 +201,7 @@ func (m *Manager) AddPeer(peerCfg lazyconn.PeerConfig) (bool, error) {
return false, nil
}
if err := m.armActivityListener(peerCfg); err != nil {
if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil {
return false, err
}
@@ -297,7 +288,7 @@ func (m *Manager) DeactivatePeer(peerID peerid.ConnID) {
m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey)
if err := m.armActivityListener(*mp.peerCfg); err != nil {
if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil {
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
return
}
@@ -474,31 +465,6 @@ func (m *Manager) close() {
}
// shouldDeferIdleForHA checks if peer should stay connected due to HA group requirements
// armRoutedAllowedIPs re-applies the peer's routed allowed IPs onto its freshly armed wake
// endpoint. The activity listener creates the wake peer with the overlay /32 only, so without
// this the routed prefixes would be missing and traffic to a routed subnet could not wake the
// idle routing peer. It is a no-op when no reconciler is configured.
// armActivityListener (re)arms the peer's wake endpoint via the activity manager and then
// re-applies its routed allowed IPs, so traffic to a routed subnet can wake an idle routing
// peer. The routed prefixes must be re-applied after the wake endpoint exists because the
// listener creates it with the overlay /32 only.
func (m *Manager) armActivityListener(peerCfg lazyconn.PeerConfig) error {
if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil {
return err
}
m.armRoutedAllowedIPs(&peerCfg)
return nil
}
func (m *Manager) armRoutedAllowedIPs(peerCfg *lazyconn.PeerConfig) {
if m.reconcileAllowedIPs == nil {
return
}
if err := m.reconcileAllowedIPs(peerCfg.PublicKey); err != nil {
peerCfg.Log.Errorf("failed to reconcile routed allowed IPs on wake endpoint: %v", err)
}
}
func (m *Manager) shouldDeferIdleForHA(inactivePeers map[string]struct{}, peerID string) bool {
m.routesMu.RLock()
defer m.routesMu.RUnlock()
@@ -611,7 +577,7 @@ func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) {
mp.peerCfg.Log.Infof("start activity monitor")
if err := m.armActivityListener(*mp.peerCfg); err != nil {
if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil {
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
continue
}

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,16 @@ 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, until claimed
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 +602,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 +657,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

@@ -61,7 +61,6 @@ type Manager interface {
InitialRouteRange() []string
SetFirewall(firewall.Manager) error
SetDNSForwarderPort(port uint16)
ReconcilePeerAllowedIPs(peerKey string) error
Stop(stateManager *statemanager.Manager)
}
@@ -233,30 +232,6 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) {
)
}
// ReconcilePeerAllowedIPs re-applies every routed allowed IP currently tracked for the peer
// onto the WireGuard device. The allowed-IP refcounter only calls its AddFunc (which pushes to
// the device) on a prefix's 0->1 transition, so a peer whose device entry was rebuilt without a
// matching refcounter change — e.g. a lazy connection cycling through idle->wake, which recreates
// the WireGuard peer with the overlay /32 only — ends up missing routed prefixes the refcounter
// still considers installed, and nothing retries. Calling this when the peer's WireGuard entry is
// (re)created restores convergence. It is add-only and idempotent: AddAllowedIP is update-only, so
// prefixes are re-added to an existing peer and an absent peer is left untouched.
func (m *DefaultManager) ReconcilePeerAllowedIPs(peerKey string) error {
if m.allowedIPsRefCounter == nil {
return nil
}
return m.allowedIPsRefCounter.ReapplyMatching(
func(out string) bool { return out == peerKey },
func(prefix netip.Prefix) error {
if err := m.wgInterface.AddAllowedIP(peerKey, prefix); err != nil {
return fmt.Errorf("add allowed IP %s for peer %s: %w", prefix, peerKey, err)
}
return nil
},
)
}
// Init sets up the routing
func (m *DefaultManager) Init() error {
m.routeSelector = m.initSelector()

View File

@@ -112,11 +112,6 @@ func (m *MockManager) SetFirewall(firewall.Manager) error {
func (m *MockManager) SetDNSForwarderPort(port uint16) {
}
// ReconcilePeerAllowedIPs mock implementation of ReconcilePeerAllowedIPs from Manager interface
func (m *MockManager) ReconcilePeerAllowedIPs(peerKey string) error {
return nil
}
// Stop mock implementation of Stop from Manager interface
func (m *MockManager) Stop(stateManager *statemanager.Manager) {
if m.StopFunc != nil {

View File

@@ -1,90 +0,0 @@
//go:build !windows
package routemanager
import (
"net"
"net/netip"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/tun/netstack"
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/routemanager/refcounter"
)
// reconcileWGMock is a minimal iface.WGIface that only records AddAllowedIP calls; every other
// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them.
type reconcileWGMock struct {
mu sync.Mutex
adds map[string][]netip.Prefix
}
func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.adds == nil {
m.adds = map[string][]netip.Prefix{}
}
m.adds[peerKey] = append(m.adds[peerKey], allowedIP)
return nil
}
func (m *reconcileWGMock) added(peerKey string) []netip.Prefix {
m.mu.Lock()
defer m.mu.Unlock()
return m.adds[peerKey]
}
func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil }
func (m *reconcileWGMock) Name() string { return "utun-test" }
func (m *reconcileWGMock) Address() wgaddr.Address { return wgaddr.Address{} }
func (m *reconcileWGMock) ToInterface() *net.Interface { return nil }
func (m *reconcileWGMock) IsUserspaceBind() bool { return false }
func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil }
func (m *reconcileWGMock) GetDevice() *device.FilteredDevice { return nil }
func (m *reconcileWGMock) GetNet() *netstack.Net { return nil }
// TestReconcilePeerAllowedIPs verifies the declarative reconcile re-applies every routed prefix
// tracked for the peer (self-heal, independent of refcount level) and stays scoped to that peer.
func TestReconcilePeerAllowedIPs(t *testing.T) {
wg := &reconcileWGMock{}
m := &DefaultManager{wgInterface: wg}
m.allowedIPsRefCounter = refcounter.New[netip.Prefix, string, string](
func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil },
func(netip.Prefix, string) error { return nil },
)
peerA1 := netip.MustParsePrefix("10.0.0.0/24")
peerA2 := netip.MustParsePrefix("10.1.0.0/24")
peerB1 := netip.MustParsePrefix("10.2.0.0/24")
for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} {
_, err := m.allowedIPsRefCounter.Increment(prefix, peer)
require.NoError(t, err)
}
// Extra reference: reconcile must still re-apply the prefix even though its refcount never
// hit 0 again (the exact case the plain incremental path skips).
_, err := m.allowedIPsRefCounter.Increment(peerA1, "peerA")
require.NoError(t, err)
require.NoError(t, m.ReconcilePeerAllowedIPs("peerA"))
assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, wg.added("peerA"),
"reconcile must re-apply all routed prefixes of the peer")
assert.Empty(t, wg.added("peerB"), "reconcile must not touch another peer's prefixes")
}
// TestReconcilePeerAllowedIPsNoCounter verifies reconcile is a safe no-op before the refcounter is
// set up.
func TestReconcilePeerAllowedIPsNoCounter(t *testing.T) {
wg := &reconcileWGMock{}
m := &DefaultManager{wgInterface: wg}
require.NoError(t, m.ReconcilePeerAllowedIPs("peerA"))
assert.Empty(t, wg.added("peerA"))
}

View File

@@ -94,26 +94,6 @@ func (rm *Counter[Key, I, O]) Get(key Key) (Ref[O], bool) {
return ref, ok
}
// ReapplyMatching calls apply for every key whose stored Out satisfies pred, holding the
// counter lock for the whole pass. Running apply under the lock keeps it atomic with respect
// to Increment/Decrement: a prefix dropped to zero is removed from the map (and had its
// RemoveFunc called) before this pass observes it, so a stale key can never be re-applied.
// pred and apply are invoked under the lock, so they must not call back into the counter.
func (rm *Counter[Key, I, O]) ReapplyMatching(pred func(out O) bool, apply func(key Key) error) error {
rm.mu.Lock()
defer rm.mu.Unlock()
var merr *multierror.Error
for key, ref := range rm.refCountMap {
if pred(ref.Out) {
if err := apply(key); err != nil {
merr = multierror.Append(merr, err)
}
}
}
return nberrors.FormatErrorOrNil(merr)
}
// Increment increments the reference count for the given key.
// If this is the first reference to the key, the AddFunc is called.
func (rm *Counter[Key, I, O]) Increment(key Key, in I) (Ref[O], error) {

View File

@@ -1,47 +0,0 @@
package refcounter
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestReapplyMatching verifies ReapplyMatching invokes apply for exactly the keys whose stored
// Out satisfies the predicate (no duplicates for multiply-referenced keys) — the primitive
// ReconcilePeerAllowedIPs relies on to re-apply a single peer's routed prefixes.
func TestReapplyMatching(t *testing.T) {
rc := New[netip.Prefix, string, string](
func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil },
func(netip.Prefix, string) error { return nil },
)
peerA1 := netip.MustParsePrefix("10.0.0.0/24")
peerA2 := netip.MustParsePrefix("10.1.0.0/24")
peerB1 := netip.MustParsePrefix("10.2.0.0/24")
for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} {
_, err := rc.Increment(prefix, peer)
require.NoError(t, err)
}
// a second reference must not make the key applied twice
_, err := rc.Increment(peerA1, "peerA")
require.NoError(t, err)
var applied []netip.Prefix
err = rc.ReapplyMatching(
func(out string) bool { return out == "peerA" },
func(key netip.Prefix) error { applied = append(applied, key); return nil },
)
require.NoError(t, err)
assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, applied)
var none []netip.Prefix
err = rc.ReapplyMatching(
func(out string) bool { return out == "missing" },
func(key netip.Prefix) error { none = append(none, key); return nil },
)
require.NoError(t, err)
assert.Empty(t, none)
}

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,30 @@
//go:build windows
package shell
import "os/user"
// LookupWithGetent on Windows just delegates to os/user.Lookup.
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.
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,

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

@@ -0,0 +1,256 @@
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"
)
// Verify that the daemon Server implements ipcauth.ProfilePolicy.
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.
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
}
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
}
// authorizeTargetProfile authorizes a caller to operate on a specific target
// profile. It MUST be called after bindCallerUsername, which enforces the legacy
// per-username-directory guard. this layers the collision-free Owners field on
// top of it:
//
// - Privileged callers (root / elevated-admin) may operate on any profile.
// - If the target has Owners (or is Shared), they are authoritative. This
// disambiguates users whose sanitized usernames collide.
// - If the target is unowned (a legacy profile predating ownership), passing
// the username guard is sufficient and then the profile is claimed.
//
// Caller must hold s.mutex (it may persist an ownership claim).
func (s *Server) authorizeTargetProfile(ctx context.Context, target *profilemanager.Profile, claim bool) error {
id, ok := ipcauth.IdentityFromContext(ctx)
if !ok {
return gstatus.Error(codes.PermissionDenied, "caller identity could not be verified")
}
if id.IsPrivileged() {
return nil
}
path, err := target.FilePath()
if err != nil {
return fmt.Errorf("resolve target profile path: %w", err)
}
cfg, err := profilemanager.GetConfig(path)
if err != nil {
return fmt.Errorf("load target profile config: %w", err)
}
ownership := ipcauth.Ownership{Owners: cfg.Owners, Shared: cfg.Shared}
// Owned or shared: the Owners field is authoritative (collision-free).
if len(ownership.Owners) > 0 || ownership.Shared {
if ipcauth.Authorize(ownership, id, s.groupResolver) {
return nil
}
return gstatus.Errorf(codes.PermissionDenied,
"not authorized to operate on profile %q (owned by another principal)", target.Name)
}
// Unowned legacy profile: the username guard authorizes. Stamp the caller
// as owner so future access is collision-free.
if claim {
principal := ipcauth.OwnerPrincipalForIdentity(id)
cfg.Owners = []string{principal}
if err := util.WriteJson(context.Background(), path, cfg); err != nil {
return fmt.Errorf("persist profile ownership claim: %w", err)
}
log.Infof("profile %q (%s) claimed by %s on first access (trust-on-first-use)", target.Name, target.ID, id)
}
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,88 @@
package server
import (
"context"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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/util"
)
// writeTargetProfile writes a profile JSON with the given ownership and returns
// a Profile handle pointing at it (Path set, so FilePath() resolves directly).
func writeTargetProfile(t *testing.T, dir, id string, owners []string, shared bool) *profilemanager.Profile {
t.Helper()
path := filepath.Join(dir, id+".json")
cfg := &profilemanager.Config{Owners: owners, Shared: shared}
require.NoError(t, util.WriteJson(context.Background(), path, cfg))
return &profilemanager.Profile{ID: profilemanager.ID(id), Name: id, Path: path}
}
func readOwners(t *testing.T, path string) ([]string, bool) {
t.Helper()
cfg, err := profilemanager.GetConfig(path)
require.NoError(t, err)
return cfg.Owners, cfg.Shared
}
func TestAuthorizeTargetProfile(t *testing.T) {
s := &Server{groupResolver: ipcauth.NewDefaultGroupResolver()}
owner := ipcauth.Identity{UID: 1000}
other := ipcauth.Identity{UID: 1001}
root := ipcauth.Identity{UID: 0}
t.Run("no identity denies", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", []string{"uid:1000"}, false)
err := s.authorizeTargetProfile(context.Background(), p, true)
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
})
t.Run("privileged allowed on another's profile", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", []string{"uid:1000"}, false)
assert.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(root), p, true))
})
t.Run("owner allowed", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", []string{"uid:1000"}, false)
assert.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(owner), p, true))
})
t.Run("non-owner denied", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", []string{"uid:1000"}, false)
err := s.authorizeTargetProfile(ctxWithIdentity(other), p, true)
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
})
t.Run("shared allows any caller", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", nil, true)
assert.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(other), p, true))
})
t.Run("unowned claim stamps owner", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", nil, false)
require.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(other), p, true))
owners, shared := readOwners(t, p.Path)
assert.Equal(t, []string{"uid:1001"}, owners)
assert.False(t, shared)
// A different caller is now locked out of the claimed profile.
err := s.authorizeTargetProfile(ctxWithIdentity(owner), p, true)
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
})
t.Run("unowned without claim leaves profile unowned", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", nil, false)
require.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(other), p, false))
owners, _ := readOwners(t, p.Path)
assert.Empty(t, owners)
})
}

View File

@@ -0,0 +1,51 @@
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. This binds the client-supplied gRPC field to the
// caller's kernel identity.
// 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
}
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"
@@ -126,6 +127,12 @@ type Server struct {
updateManager *updater.Manager
jwtCache *jwtCache
// groupResolver resolves a Unix caller's supplementary group membership
// (NSS/getent) so gid:/group: owner principals authorize correctly. Nil on
// Windows (SID group membership travels in the identity itself); ipcauth
// treats a nil resolver as "no group matching".
groupResolver ipcauth.GroupResolver
}
type oauthAuthFlow struct {
@@ -150,6 +157,7 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
jwtCache: newJWTCache(),
extendAuthSessionFlow: auth.NewPendingFlow(),
probeThrottle: newProbeThrottle(probeThreshold),
groupResolver: ipcauth.NewDefaultGroupResolver(),
}
agent := &serverAgent{s}
s.sleepHandler = sleephandler.New(agent)
@@ -402,6 +410,11 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
}
}
// SSH root login / disabling SSH auth requires a privileged caller.
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 +550,11 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
}
}
// SSH root login / disabling SSH auth requires a privileged caller.
if err := requirePrivilegedForDangerousSSH(callerCtx, msg.EnableSSHRoot, msg.DisableSSHAuth); err != nil {
return nil, err
}
s.mutex.Lock()
if s.actCancel != nil {
s.actCancel()
@@ -943,6 +961,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 +1077,29 @@ 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
}
// Authorize against the target profile's owners, claiming an unowned
// legacy target for the caller.
resolveUsername := targetUsername
if *msg.ProfileName == profilemanager.DefaultProfileName {
resolveUsername = ""
}
resolvedTarget, err := s.resolveProfileHandle(*msg.ProfileName, resolveUsername)
if err != nil {
return nil, err
}
if err := s.authorizeTargetProfile(callerCtx, resolvedTarget, true); 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 +2057,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,11 +2091,19 @@ 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
}
if err := s.authorizeTargetProfile(ctx, resolved, true); err != nil {
return nil, err
}
err = s.profileManager.RenameProfile(resolved.ID, msg.Username, msg.NewProfileName)
if err != nil {
log.Errorf("failed to rename profile: %v", err)
@@ -2057,11 +2128,20 @@ 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
}
// claim=false: don't stamp ownership on a profile we're about to delete.
if err := s.authorizeTargetProfile(ctx, resolved, false); err != nil {
return nil, err
}
if err := s.logoutFromProfile(ctx, resolved); err != nil {
log.Warnf("failed to logout from profile %s before removal: %v", resolved.ID, err)
}
@@ -2125,6 +2205,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)

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

@@ -0,0 +1,36 @@
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 admin permissions for SSH config.
// 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 may set
// these flags to true over the local IPC.
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

@@ -231,7 +231,7 @@ func requestNotificationAuthorization(notifier *notifications.NotificationServic
// "no flag" and an explicit "--log-file console" stay distinguishable; empty
// falls back to console for InitLog.
func parseFlagsAndInitLog() (string, bool) {
daemonAddr := flag.String("daemon-addr", DaemonAddr(), "Daemon gRPC address: unix:///path or tcp://host:port")
daemonAddr := flag.String("daemon-addr", DaemonAddr(), "Daemon gRPC address: unix:///path, npipe://name, tcp://host:port")
logFiles := &stringList{}
flag.Var(logFiles, "log-file", "Log destination. Repeat to log to multiple targets at once, e.g. `--log-file console --log-file Y:/netbird-ui.log`. Each value is one of: console, syslog, or a file path. File destinations are rotated by lumberjack (same as the daemon). Defaults to console. Passing any value disables the daemon-debug-driven gui-client.log.")
logLevel := flag.String("log-level", "info", "Log level: trace|debug|info|warn|error.")

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

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