mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-03 20:19:07 +02:00
Add daemon socket owner enforcement via SO_PEERCRED
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
package owner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ConfigAdapter is a thread-safe OwnerConfig that delegates to a lazily-set backend.
|
||||
// This allows the interceptor to be created before the daemon server (and its config)
|
||||
// is initialized, which is necessary because gRPC interceptors are set at server creation time.
|
||||
type ConfigAdapter struct {
|
||||
mu sync.RWMutex
|
||||
backend OwnerConfig
|
||||
}
|
||||
|
||||
// SetBackend sets the actual config implementation. Must be called before any RPCs are served.
|
||||
func (a *ConfigAdapter) SetBackend(backend OwnerConfig) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.backend = backend
|
||||
}
|
||||
|
||||
// GetOwnerUIDs delegates to the backend.
|
||||
func (a *ConfigAdapter) GetOwnerUIDs() []UID {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
if a.backend == nil {
|
||||
// No backend yet, return empty (root-only).
|
||||
return []UID{}
|
||||
}
|
||||
|
||||
return a.backend.GetOwnerUIDs()
|
||||
}
|
||||
|
||||
// AddOwnerUID delegates to the backend.
|
||||
func (a *ConfigAdapter) AddOwnerUID(uid UID) error {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
if a.backend == nil {
|
||||
return fmt.Errorf("owner config backend not initialized")
|
||||
}
|
||||
|
||||
return a.backend.AddOwnerUID(uid)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Package consoleuser provides the OS-level "active console user" UID lookup
|
||||
// used to gate ownership TOFU. The active console user is the local user
|
||||
// physically at the machine (or in the foreground GUI session): the user that
|
||||
// can legitimately claim the daemon as theirs on first run.
|
||||
package consoleuser
|
||||
|
||||
// ActiveUID returns the UID of the currently active console / GUI session
|
||||
// user, and true if such a user exists. Returns 0, false on platforms without
|
||||
// a console concept (ios, android), on headless servers with no active
|
||||
// session, or on lookup failure.
|
||||
//
|
||||
// Implementations must fail closed: any error or ambiguity returns (0, false)
|
||||
// so that the caller treats the result as "no console user" rather than
|
||||
// granting access to an unverified UID.
|
||||
func ActiveUID() (uint32, bool) {
|
||||
return activeUID()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package consoleuser
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
)
|
||||
|
||||
// activeUID returns the UID of the user currently logged into the macOS GUI
|
||||
// console session. Uses SCDynamicStoreCopyConsoleUser from the
|
||||
// SystemConfiguration framework via purego (no cgo).
|
||||
func activeUID() (uint32, bool) {
|
||||
sc, err := purego.Dlopen(
|
||||
"/System/Library/Frameworks/SystemConfiguration.framework/SystemConfiguration",
|
||||
purego.RTLD_NOW|purego.RTLD_GLOBAL,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
cf, err := purego.Dlopen(
|
||||
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation",
|
||||
purego.RTLD_NOW|purego.RTLD_GLOBAL,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// CFStringRef SCDynamicStoreCopyConsoleUser(SCDynamicStoreRef store,
|
||||
// uid_t *uid, gid_t *gid);
|
||||
//
|
||||
// We pass nil for the store (NULL is accepted; the framework creates a
|
||||
// transient one), discard the returned CFStringRef username (we only
|
||||
// need the UID), and read uid via the out-pointer.
|
||||
var copyConsoleUser func(store uintptr, uidPtr, gidPtr unsafe.Pointer) uintptr
|
||||
purego.RegisterLibFunc(©ConsoleUser, sc, "SCDynamicStoreCopyConsoleUser")
|
||||
|
||||
var cfRelease func(uintptr)
|
||||
purego.RegisterLibFunc(&cfRelease, cf, "CFRelease")
|
||||
|
||||
var uid uint32
|
||||
var gid uint32
|
||||
|
||||
cfStr := copyConsoleUser(0, unsafe.Pointer(&uid), unsafe.Pointer(&gid))
|
||||
if cfStr == 0 {
|
||||
return 0, false
|
||||
}
|
||||
cfRelease(cfStr)
|
||||
|
||||
// loginwindow / no GUI session reports uid 0. We don't want the
|
||||
// console-user path to grant anything to root (root is already always
|
||||
// allowed by the interceptor), so treat uid 0 as "no console user".
|
||||
if uid == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return uid, true
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package consoleuser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// activeUID returns the UID of the user currently logged into the FreeBSD
|
||||
// console. FreeBSD's vt(4) chowns the active virtual terminal device to the
|
||||
// logged-in user, so a non-root owner of any /dev/ttyvN reliably identifies
|
||||
// the console user.
|
||||
//
|
||||
// We scan /dev/ttyv0../dev/ttyv9 and return the first non-root owner. Network
|
||||
// ptys (pts) are intentionally not considered: SSH'd users are not "at the
|
||||
// console" and must not TOFU-claim ownership.
|
||||
func activeUID() (uint32, bool) {
|
||||
for i := 0; i < 10; i++ {
|
||||
path := fmt.Sprintf("/dev/ttyv%d", i)
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
st, ok := fi.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if st.Uid == 0 {
|
||||
continue
|
||||
}
|
||||
return st.Uid, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package consoleuser
|
||||
|
||||
import (
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
loginDest = "org.freedesktop.login1"
|
||||
loginPath = dbus.ObjectPath("/org/freedesktop/login1")
|
||||
loginInterface = "org.freedesktop.login1.Manager"
|
||||
listSessions = loginInterface + ".ListSessions"
|
||||
|
||||
sessionInterface = "org.freedesktop.login1.Session"
|
||||
sessionActive = sessionInterface + ".Active"
|
||||
sessionClass = sessionInterface + ".Class"
|
||||
)
|
||||
|
||||
// activeUID queries systemd-logind for the active local user session and
|
||||
// returns that user's UID. Falls back to (0, false) on any error or when no
|
||||
// active user session exists (headless box, no GUI, no login at the console).
|
||||
func activeUID() (uint32, bool) {
|
||||
conn, err := dbus.SystemBus()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
mgr := conn.Object(loginDest, loginPath)
|
||||
|
||||
// ListSessions returns []struct{ID string; UID uint32; User string;
|
||||
// Seat string; Path dbus.ObjectPath}.
|
||||
var sessions []struct {
|
||||
ID string
|
||||
UID uint32
|
||||
User string
|
||||
Seat string
|
||||
Path dbus.ObjectPath
|
||||
}
|
||||
if err := mgr.Call(listSessions, 0).Store(&sessions); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
for _, s := range sessions {
|
||||
obj := conn.Object(loginDest, s.Path)
|
||||
|
||||
active, err := obj.GetProperty(sessionActive)
|
||||
if err != nil || active.Value() != true {
|
||||
continue
|
||||
}
|
||||
|
||||
class, err := obj.GetProperty(sessionClass)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Only "user" sessions count; "greeter" / "lock-screen" / etc. are
|
||||
// not someone we should grant ownership to.
|
||||
if classStr, ok := class.Value().(string); !ok || classStr != "user" {
|
||||
continue
|
||||
}
|
||||
|
||||
return s.UID, true
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !linux && !darwin && !freebsd && !windows
|
||||
|
||||
package consoleuser
|
||||
|
||||
// activeUID has no meaning on platforms without a console-user concept
|
||||
// (ios, android). Returns no-user so TOFU never fires.
|
||||
func activeUID() (uint32, bool) {
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package consoleuser
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// activeUID returns a synthetic UID (the user SID's RID) for the currently
|
||||
// active Windows console session. The owner package treats UIDs as opaque
|
||||
// uint32 identifiers; on Windows we use the user account RID, which is stable
|
||||
// per-account on a given machine.
|
||||
//
|
||||
// Returns (0, false) when there is no active console session, the session has
|
||||
// no logged-in user, or any lookup fails.
|
||||
func activeUID() (uint32, bool) {
|
||||
sessionID := windows.WTSGetActiveConsoleSessionId()
|
||||
if sessionID == 0xFFFFFFFF {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var token windows.Token
|
||||
if err := windows.WTSQueryUserToken(sessionID, &token); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
defer token.Close()
|
||||
|
||||
user, err := tokenUserSID(token)
|
||||
if err != nil || user == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
subCount := user.SubAuthorityCount()
|
||||
if subCount == 0 {
|
||||
return 0, false
|
||||
}
|
||||
rid := user.SubAuthority(uint32(subCount) - 1)
|
||||
if rid == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return rid, true
|
||||
}
|
||||
|
||||
// tokenUserSID returns the user SID associated with the given access token.
|
||||
func tokenUserSID(token windows.Token) (*windows.SID, error) {
|
||||
var size uint32
|
||||
err := windows.GetTokenInformation(token, windows.TokenUser, nil, 0, &size)
|
||||
if err != windows.ERROR_INSUFFICIENT_BUFFER {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
buf := make([]byte, size)
|
||||
if err := windows.GetTokenInformation(token, windows.TokenUser, &buf[0], size, &size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tu := (*windows.Tokenuser)(unsafe.Pointer(&buf[0]))
|
||||
return tu.User.Sid, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package owner
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
// UnixAuthInfo implements credentials.AuthInfo carrying the peer's UID from SO_PEERCRED.
|
||||
type UnixAuthInfo struct {
|
||||
credentials.CommonAuthInfo
|
||||
UID UID
|
||||
GID uint32
|
||||
PID int32
|
||||
}
|
||||
|
||||
// AuthType returns the authentication type.
|
||||
func (u UnixAuthInfo) AuthType() string {
|
||||
return "unix_peercred"
|
||||
}
|
||||
|
||||
// UIDFromContext extracts the caller's UID from the gRPC peer context.
|
||||
// Returns uid and true if Unix credentials were available, 0 and false otherwise.
|
||||
func UIDFromContext(ctx context.Context) (UID, bool) {
|
||||
p, ok := peer.FromContext(ctx)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
info, ok := p.AuthInfo.(UnixAuthInfo)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return info.UID, true
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package owner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// EnvOwnerUID is the environment variable that seeds the owner UID list for new config files.
|
||||
// MDM deployments can set this (e.g. via --service-env NB_OWNER_UID=1000) so the first
|
||||
// config created by the daemon pre-populates the owner without requiring "netbird up --owner".
|
||||
// Multiple UIDs can be comma-separated: NB_OWNER_UID=1000,1001
|
||||
const EnvOwnerUID = "NB_OWNER_UID"
|
||||
|
||||
// OwnerUIDsFromEnv parses NB_OWNER_UID into a UID slice.
|
||||
// Returns nil if the variable is unset, allowing the caller to distinguish
|
||||
// "not configured" from "explicitly empty".
|
||||
func OwnerUIDsFromEnv() []UID {
|
||||
val := os.Getenv(EnvOwnerUID)
|
||||
if val == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(val, ",")
|
||||
uids := make([]UID, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
uid, err := strconv.ParseUint(p, 10, 32)
|
||||
if err != nil {
|
||||
log.Warnf("ignoring invalid UID %q in %s: %v", p, EnvOwnerUID, err)
|
||||
continue
|
||||
}
|
||||
uids = append(uids, UID(uid))
|
||||
}
|
||||
|
||||
if len(uids) == 0 {
|
||||
log.Warnf("%s set but contains no valid UIDs, defaulting to root-only", EnvOwnerUID)
|
||||
return []UID{}
|
||||
}
|
||||
|
||||
log.Infof("seeding owner UIDs from %s: %v", EnvOwnerUID, uids)
|
||||
return uids
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package owner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOwnerUIDsFromEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envValue string
|
||||
unset bool
|
||||
want []UID
|
||||
}{
|
||||
{
|
||||
name: "unset returns nil",
|
||||
unset: true,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "empty string returns nil",
|
||||
envValue: "",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single UID",
|
||||
envValue: "1000",
|
||||
want: []UID{1000},
|
||||
},
|
||||
{
|
||||
name: "multiple UIDs",
|
||||
envValue: "1000,1001,1002",
|
||||
want: []UID{1000, 1001, 1002},
|
||||
},
|
||||
{
|
||||
name: "spaces around UIDs",
|
||||
envValue: " 1000 , 1001 ",
|
||||
want: []UID{1000, 1001},
|
||||
},
|
||||
{
|
||||
name: "invalid UID skipped",
|
||||
envValue: "1000,notanumber,1001",
|
||||
want: []UID{1000, 1001},
|
||||
},
|
||||
{
|
||||
name: "all invalid returns empty slice",
|
||||
envValue: "abc,def",
|
||||
want: []UID{},
|
||||
},
|
||||
{
|
||||
name: "trailing comma",
|
||||
envValue: "1000,",
|
||||
want: []UID{1000},
|
||||
},
|
||||
{
|
||||
name: "zero UID is valid",
|
||||
envValue: "0",
|
||||
want: []UID{0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(EnvOwnerUID, tt.envValue)
|
||||
if tt.unset {
|
||||
os.Unsetenv(EnvOwnerUID)
|
||||
}
|
||||
|
||||
got := OwnerUIDsFromEnv()
|
||||
|
||||
if tt.want == nil {
|
||||
require.Nil(t, got)
|
||||
} else {
|
||||
assert.Equal(t, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package owner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/owner/consoleuser"
|
||||
)
|
||||
|
||||
const servicePath = "/daemon.DaemonService/"
|
||||
|
||||
// profileBypassMethods skip the active-profile owner check. They either
|
||||
// operate on a specific target profile (and the handler enforces target-profile
|
||||
// owner-or-root itself) or are per-user listings/creations that don't affect
|
||||
// the active session and shouldn't require active-profile ownership. Peer
|
||||
// credentials are still required.
|
||||
var profileBypassMethods = map[string]bool{
|
||||
servicePath + "AddProfile": true,
|
||||
servicePath + "ListProfiles": true,
|
||||
servicePath + "RemoveProfile": true,
|
||||
servicePath + "SwitchProfile": true,
|
||||
}
|
||||
|
||||
// Error messages returned to denied callers. They are multi-line so the
|
||||
// suggested commands sit on their own line for easy triple-click copy-paste.
|
||||
const (
|
||||
errNoPeerCreds = "peer credentials unavailable; rerun via the netbird CLI"
|
||||
|
||||
errNoOwnerConfigured = `no daemon owner is configured and no console-session user matches your UID.
|
||||
Run as root for one-off use:
|
||||
sudo netbird ...
|
||||
Or call from the active console session: the first call from the user logged in
|
||||
at the GUI/console claims ownership automatically.`
|
||||
|
||||
errOwnerRequired = `this operation requires root or the daemon owner (uid %d is not an owner).
|
||||
Run as root for one-off use:
|
||||
sudo netbird ...
|
||||
Or ask an existing owner (or root) to add you:
|
||||
sudo netbird owner add %[1]d`
|
||||
)
|
||||
|
||||
// consoleUIDLookup is the function used to look up the active console UID.
|
||||
// Overridable in tests; defaults to the platform implementation.
|
||||
var consoleUIDLookup = consoleuser.ActiveUID
|
||||
|
||||
// OwnerConfig provides access to the current owner UIDs setting.
|
||||
// The interceptor reads and writes through this interface so it can
|
||||
// work with the profile manager's config without a direct dependency.
|
||||
type OwnerConfig interface {
|
||||
// GetOwnerUIDs returns the current owner UIDs.
|
||||
// nil means legacy/migration TOFU (field absent from existing config).
|
||||
// empty means fresh install (root-only with console-user TOFU exception).
|
||||
// populated means those UIDs plus root may control the daemon.
|
||||
GetOwnerUIDs() []UID
|
||||
|
||||
// AddOwnerUID adds the given UID to the owner list and persists it.
|
||||
AddOwnerUID(uid UID) error
|
||||
}
|
||||
|
||||
// Interceptor enforces owner restrictions on the daemon gRPC socket.
|
||||
type Interceptor struct {
|
||||
config OwnerConfig
|
||||
// mu serializes the read-then-write of OwnerUIDs during TOFU/claim flows
|
||||
// so two concurrent first-callers can't both end up persisted as owners.
|
||||
// Holds across the OwnerConfig.AddOwnerUID call; safe because no callback
|
||||
// path takes this mutex.
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewInterceptor creates an owner interceptor backed by the given config.
|
||||
func NewInterceptor(config OwnerConfig) *Interceptor {
|
||||
return &Interceptor{config: config}
|
||||
}
|
||||
|
||||
// UnaryInterceptor returns a gRPC unary server interceptor that enforces owner policy.
|
||||
func (i *Interceptor) UnaryInterceptor() 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)
|
||||
}
|
||||
}
|
||||
|
||||
// StreamInterceptor returns a gRPC stream server interceptor that enforces owner policy.
|
||||
func (i *Interceptor) StreamInterceptor() 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)
|
||||
}
|
||||
}
|
||||
|
||||
// authorize checks whether the caller is allowed to call the given method.
|
||||
// Every RPC is gated; root is always allowed. Non-root callers are accepted
|
||||
// when they are existing owners, when the config is in legacy TOFU state
|
||||
// (claim on first call, preserves pre-enforcement behavior), or when the
|
||||
// config is in fresh-install state and they match the active console user.
|
||||
func (i *Interceptor) authorize(ctx context.Context, fullMethod string) error {
|
||||
uid, ok := UIDFromContext(ctx)
|
||||
if !ok {
|
||||
return status.Error(codes.PermissionDenied, errNoPeerCreds)
|
||||
}
|
||||
|
||||
if uid == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Profile-management RPCs do their own per-target authorization in the
|
||||
// handler. The interceptor only confirms peer credentials are present.
|
||||
if profileBypassMethods[fullMethod] {
|
||||
return nil
|
||||
}
|
||||
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
|
||||
ownerUIDs := i.config.GetOwnerUIDs()
|
||||
|
||||
switch {
|
||||
case ownerUIDs == nil:
|
||||
// Legacy / migration TOFU: existing pre-enforcement config has no
|
||||
// owners field. Any non-root local caller claims on first call so
|
||||
// upgrades don't break.
|
||||
return i.claim(uid, "migration TOFU")
|
||||
|
||||
case len(ownerUIDs) == 0:
|
||||
// Fresh-install root-only mode with a console-user exception so the
|
||||
// GUI/CLI just works for the user physically at the machine. SSH'd
|
||||
// or otherwise non-console callers are denied.
|
||||
consoleUID, ok := consoleUIDLookup()
|
||||
if ok && uint32(uid) == consoleUID {
|
||||
return i.claim(uid, "console-user TOFU")
|
||||
}
|
||||
return status.Error(codes.PermissionDenied, errNoOwnerConfigured)
|
||||
|
||||
case slices.Contains(ownerUIDs, uid):
|
||||
return nil
|
||||
|
||||
default:
|
||||
return status.Errorf(codes.PermissionDenied, errOwnerRequired, uid)
|
||||
}
|
||||
}
|
||||
|
||||
// claim adds uid to the owner list and persists it. The caller must hold i.mu.
|
||||
func (i *Interceptor) claim(uid UID, reason string) error {
|
||||
log.Infof("%s: claiming owner for UID %d", reason, uid)
|
||||
if err := i.config.AddOwnerUID(uid); err != nil {
|
||||
log.Errorf("persist owner UID: %v", err)
|
||||
return status.Error(codes.Internal, "persist owner UID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package owner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type mockOwnerConfig struct {
|
||||
uids []UID
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockOwnerConfig) GetOwnerUIDs() []UID {
|
||||
return m.uids
|
||||
}
|
||||
|
||||
func (m *mockOwnerConfig) AddOwnerUID(uid UID) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.uids = append(m.uids, uid)
|
||||
return nil
|
||||
}
|
||||
|
||||
func peerContext(uid UID) context.Context {
|
||||
return peer.NewContext(context.Background(), &peer.Peer{
|
||||
Addr: &net.UnixAddr{Name: "/tmp/test.sock", Net: "unix"},
|
||||
AuthInfo: UnixAuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
UID: uid,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func noPeerContext() context.Context {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
// withConsoleUID overrides the platform console-user lookup for a single test.
|
||||
func withConsoleUID(t *testing.T, uid uint32, ok bool) {
|
||||
t.Helper()
|
||||
prev := consoleUIDLookup
|
||||
consoleUIDLookup = func() (uint32, bool) { return uid, ok }
|
||||
t.Cleanup(func() { consoleUIDLookup = prev })
|
||||
}
|
||||
|
||||
func TestInterceptor_RootAlwaysAllowed(t *testing.T) {
|
||||
cfg := &mockOwnerConfig{uids: []UID{1000}}
|
||||
interceptor := NewInterceptor(cfg)
|
||||
|
||||
for _, method := range []string{
|
||||
"/daemon.DaemonService/Up",
|
||||
"/daemon.DaemonService/Status",
|
||||
"/daemon.DaemonService/Down",
|
||||
} {
|
||||
err := interceptor.authorize(peerContext(0), method)
|
||||
assert.NoError(t, err, "root should always be allowed for %s", method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_NoPeerCreds_AlwaysDenies(t *testing.T) {
|
||||
cfg := &mockOwnerConfig{uids: []UID{1000}}
|
||||
interceptor := NewInterceptor(cfg)
|
||||
|
||||
for _, method := range []string{
|
||||
"/daemon.DaemonService/Status",
|
||||
"/daemon.DaemonService/Up",
|
||||
"/daemon.DaemonService/SomeNewMethod",
|
||||
} {
|
||||
err := interceptor.authorize(noPeerContext(), method)
|
||||
require.Error(t, err, "method %s should be denied without peer creds", method)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterceptor_LegacyMigration covers the nil-OwnerUIDs branch:
|
||||
// pre-enforcement configs upgraded to this version. Any non-root local caller
|
||||
// can claim on first call.
|
||||
func TestInterceptor_LegacyMigration_AnyCallerClaims(t *testing.T) {
|
||||
withConsoleUID(t, 0, false) // no console; should not matter for nil
|
||||
cfg := &mockOwnerConfig{uids: nil}
|
||||
interceptor := NewInterceptor(cfg)
|
||||
|
||||
// First call from any UID claims regardless of method.
|
||||
err := interceptor.authorize(peerContext(1000), "/daemon.DaemonService/Status")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []UID{1000}, cfg.uids)
|
||||
|
||||
// After claim, a different UID is denied.
|
||||
err = interceptor.authorize(peerContext(2000), "/daemon.DaemonService/Status")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
}
|
||||
|
||||
// TestInterceptor_FreshInstall covers the empty-OwnerUIDs branch: console-user
|
||||
// can claim, others denied.
|
||||
func TestInterceptor_FreshInstall_ConsoleUserClaims(t *testing.T) {
|
||||
withConsoleUID(t, 1000, true)
|
||||
cfg := &mockOwnerConfig{uids: []UID{}}
|
||||
interceptor := NewInterceptor(cfg)
|
||||
|
||||
err := interceptor.authorize(peerContext(1000), "/daemon.DaemonService/Status")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []UID{1000}, cfg.uids)
|
||||
}
|
||||
|
||||
func TestInterceptor_FreshInstall_NonConsoleDenied(t *testing.T) {
|
||||
withConsoleUID(t, 1000, true)
|
||||
cfg := &mockOwnerConfig{uids: []UID{}}
|
||||
interceptor := NewInterceptor(cfg)
|
||||
|
||||
err := interceptor.authorize(peerContext(2000), "/daemon.DaemonService/Up")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
assert.Empty(t, cfg.uids, "non-console caller must not claim")
|
||||
}
|
||||
|
||||
func TestInterceptor_FreshInstall_NoConsole_Denied(t *testing.T) {
|
||||
withConsoleUID(t, 0, false)
|
||||
cfg := &mockOwnerConfig{uids: []UID{}}
|
||||
interceptor := NewInterceptor(cfg)
|
||||
|
||||
err := interceptor.authorize(peerContext(1000), "/daemon.DaemonService/Up")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
}
|
||||
|
||||
func TestInterceptor_OwnerUID_AllowsOwner(t *testing.T) {
|
||||
cfg := &mockOwnerConfig{uids: []UID{1000}}
|
||||
interceptor := NewInterceptor(cfg)
|
||||
|
||||
err := interceptor.authorize(peerContext(1000), "/daemon.DaemonService/Down")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestInterceptor_OwnerUID_DeniesOther(t *testing.T) {
|
||||
withConsoleUID(t, 9999, true) // console-user TOFU should not apply once owners exist
|
||||
cfg := &mockOwnerConfig{uids: []UID{1000}}
|
||||
interceptor := NewInterceptor(cfg)
|
||||
|
||||
err := interceptor.authorize(peerContext(2000), "/daemon.DaemonService/Down")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
}
|
||||
|
||||
func TestInterceptor_MultipleOwners(t *testing.T) {
|
||||
cfg := &mockOwnerConfig{uids: []UID{1000, 2000}}
|
||||
interceptor := NewInterceptor(cfg)
|
||||
|
||||
err := interceptor.authorize(peerContext(1000), "/daemon.DaemonService/Down")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = interceptor.authorize(peerContext(2000), "/daemon.DaemonService/Up")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = interceptor.authorize(peerContext(3000), "/daemon.DaemonService/Down")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
}
|
||||
|
||||
// TestInterceptor_UnknownMethodRequiresOwner pins the safe-by-default invariant:
|
||||
// any future RPC still goes through owner enforcement.
|
||||
func TestInterceptor_UnknownMethodRequiresOwner(t *testing.T) {
|
||||
cfg := &mockOwnerConfig{uids: []UID{1000}}
|
||||
interceptor := NewInterceptor(cfg)
|
||||
|
||||
err := interceptor.authorize(peerContext(2000), "/daemon.DaemonService/SomeFutureMethod")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
|
||||
err = interceptor.authorize(peerContext(1000), "/daemon.DaemonService/SomeFutureMethod")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestInterceptor_ErrorMessageActionable(t *testing.T) {
|
||||
withConsoleUID(t, 9999, true)
|
||||
cfg := &mockOwnerConfig{uids: []UID{1000}}
|
||||
interceptor := NewInterceptor(cfg)
|
||||
|
||||
err := interceptor.authorize(peerContext(2000), "/daemon.DaemonService/Down")
|
||||
require.Error(t, err)
|
||||
msg := status.Convert(err).Message()
|
||||
assert.Contains(t, msg, "sudo netbird")
|
||||
assert.Contains(t, msg, "owner add")
|
||||
}
|
||||
|
||||
func TestInterceptor_UnaryIntegration(t *testing.T) {
|
||||
cfg := &mockOwnerConfig{uids: []UID{1000}}
|
||||
interceptor := NewInterceptor(cfg)
|
||||
|
||||
unary := interceptor.UnaryInterceptor()
|
||||
|
||||
resp, err := unary(peerContext(1000), nil, &grpc.UnaryServerInfo{FullMethod: "/daemon.DaemonService/Down"}, func(ctx context.Context, req any) (any, error) {
|
||||
return "ok", nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", resp)
|
||||
|
||||
_, err = unary(peerContext(2000), nil, &grpc.UnaryServerInfo{FullMethod: "/daemon.DaemonService/Down"}, func(ctx context.Context, req any) (any, error) {
|
||||
t.Fatal("handler should not be called")
|
||||
return nil, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
}
|
||||
|
||||
func TestInterceptor_StreamIntegration(t *testing.T) {
|
||||
cfg := &mockOwnerConfig{uids: []UID{1000}}
|
||||
interceptor := NewInterceptor(cfg)
|
||||
|
||||
stream := interceptor.StreamInterceptor()
|
||||
|
||||
called := false
|
||||
err := stream(nil, &mockServerStream{ctx: peerContext(1000)},
|
||||
&grpc.StreamServerInfo{FullMethod: "/daemon.DaemonService/SubscribeEvents"},
|
||||
func(srv any, stream grpc.ServerStream) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
|
||||
err = stream(nil, &mockServerStream{ctx: peerContext(2000)},
|
||||
&grpc.StreamServerInfo{FullMethod: "/daemon.DaemonService/SubscribeEvents"},
|
||||
func(srv any, stream grpc.ServerStream) error {
|
||||
t.Fatal("handler should not be called")
|
||||
return nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
}
|
||||
|
||||
type mockServerStream struct {
|
||||
grpc.ServerStream
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (m *mockServerStream) Context() context.Context { return m.ctx }
|
||||
|
||||
// TestInterceptor_ProfileBypass pins that profile-management methods reach
|
||||
// the handler regardless of active-profile ownership; the handler enforces
|
||||
// per-target-profile auth itself.
|
||||
func TestInterceptor_ProfileBypass(t *testing.T) {
|
||||
cfg := &mockOwnerConfig{uids: []UID{1000}}
|
||||
interceptor := NewInterceptor(cfg)
|
||||
|
||||
// Caller UID 2000 is not an owner of the active profile but must be
|
||||
// allowed through for these methods.
|
||||
for _, method := range []string{
|
||||
"/daemon.DaemonService/AddProfile",
|
||||
"/daemon.DaemonService/ListProfiles",
|
||||
"/daemon.DaemonService/RemoveProfile",
|
||||
"/daemon.DaemonService/SwitchProfile",
|
||||
} {
|
||||
err := interceptor.authorize(peerContext(2000), method)
|
||||
assert.NoError(t, err, "profile method %s should bypass active-owner check", method)
|
||||
}
|
||||
|
||||
// Without peer creds, even bypass methods are denied.
|
||||
for _, method := range []string{
|
||||
"/daemon.DaemonService/AddProfile",
|
||||
"/daemon.DaemonService/SwitchProfile",
|
||||
} {
|
||||
err := interceptor.authorize(noPeerContext(), method)
|
||||
require.Error(t, err, "bypass method %s still requires peer creds", method)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build darwin || freebsd
|
||||
|
||||
package owner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// NewUnixTransportCredentials returns gRPC TransportCredentials that extract
|
||||
// peer UID from Unix socket connections via LOCAL_PEERCRED (Xucred).
|
||||
func NewUnixTransportCredentials() credentials.TransportCredentials {
|
||||
return &unixCreds{}
|
||||
}
|
||||
|
||||
type unixCreds struct{}
|
||||
|
||||
func (c *unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return conn, UnixAuthInfo{}, nil
|
||||
}
|
||||
|
||||
// ServerHandshake extracts peer credentials from the Unix connection using LOCAL_PEERCRED.
|
||||
// Returns an error if credentials cannot be extracted (fail-closed).
|
||||
func (c *unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
uc, ok := conn.(*net.UnixConn)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("expected *net.UnixConn, got %T", conn)
|
||||
}
|
||||
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("get raw conn for peer credentials: %w", err)
|
||||
}
|
||||
|
||||
var xucred *unix.Xucred
|
||||
var credErr error
|
||||
if err := raw.Control(func(fd uintptr) {
|
||||
xucred, credErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED)
|
||||
}); err != nil {
|
||||
return nil, nil, fmt.Errorf("control raw conn for peer credentials: %w", err)
|
||||
}
|
||||
if credErr != nil {
|
||||
return nil, nil, fmt.Errorf("get peer credentials: %w", credErr)
|
||||
}
|
||||
|
||||
return conn, UnixAuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
UID: UID(xucred.Uid),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *unixCreds) Info() credentials.ProtocolInfo {
|
||||
return credentials.ProtocolInfo{SecurityProtocol: "unix_peercred"}
|
||||
}
|
||||
|
||||
func (c *unixCreds) Clone() credentials.TransportCredentials {
|
||||
return &unixCreds{}
|
||||
}
|
||||
|
||||
func (c *unixCreds) OverrideServerName(_ string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !linux && !darwin && !freebsd
|
||||
|
||||
package owner
|
||||
|
||||
import "google.golang.org/grpc/credentials"
|
||||
|
||||
// NewUnixTransportCredentials returns nil on platforms without Unix socket peer credentials.
|
||||
// The daemon should use insecure credentials and skip owner enforcement.
|
||||
func NewUnixTransportCredentials() credentials.TransportCredentials {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package owner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// NewUnixTransportCredentials returns gRPC TransportCredentials that extract
|
||||
// peer UID/GID/PID from Unix socket connections via SO_PEERCRED.
|
||||
func NewUnixTransportCredentials() credentials.TransportCredentials {
|
||||
return &unixCreds{}
|
||||
}
|
||||
|
||||
type unixCreds struct{}
|
||||
|
||||
func (c *unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return conn, UnixAuthInfo{}, nil
|
||||
}
|
||||
|
||||
// ServerHandshake extracts peer credentials from the Unix connection.
|
||||
// Returns an error if credentials cannot be extracted (fail-closed).
|
||||
func (c *unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
uc, ok := conn.(*net.UnixConn)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("expected *net.UnixConn, got %T", conn)
|
||||
}
|
||||
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("get raw conn for peer credentials: %w", err)
|
||||
}
|
||||
|
||||
var ucred *unix.Ucred
|
||||
var credErr error
|
||||
if err := raw.Control(func(fd uintptr) {
|
||||
ucred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
|
||||
}); err != nil {
|
||||
return nil, nil, fmt.Errorf("control raw conn for peer credentials: %w", err)
|
||||
}
|
||||
if credErr != nil {
|
||||
return nil, nil, fmt.Errorf("get peer credentials: %w", credErr)
|
||||
}
|
||||
|
||||
return conn, UnixAuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
UID: UID(ucred.Uid),
|
||||
GID: ucred.Gid,
|
||||
PID: ucred.Pid,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *unixCreds) Info() credentials.ProtocolInfo {
|
||||
return credentials.ProtocolInfo{SecurityProtocol: "unix_peercred"}
|
||||
}
|
||||
|
||||
func (c *unixCreds) Clone() credentials.TransportCredentials {
|
||||
return &unixCreds{}
|
||||
}
|
||||
|
||||
func (c *unixCreds) OverrideServerName(_ string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package owner
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
func TestUnixTransportCredentials_ServerHandshake(t *testing.T) {
|
||||
creds := NewUnixTransportCredentials()
|
||||
if creds == nil {
|
||||
t.Skip("unix transport credentials not supported on this platform")
|
||||
}
|
||||
|
||||
sockPath := filepath.Join(t.TempDir(), "test.sock")
|
||||
|
||||
ln, err := net.Listen("unix", sockPath)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
|
||||
done := make(chan struct{})
|
||||
var serverConn net.Conn
|
||||
var serverAuth credentials.AuthInfo
|
||||
var serverErr error
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
raw, err := ln.Accept()
|
||||
if err != nil {
|
||||
serverErr = err
|
||||
return
|
||||
}
|
||||
serverConn, serverAuth, serverErr = creds.ServerHandshake(raw)
|
||||
}()
|
||||
|
||||
client, err := net.Dial("unix", sockPath)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { client.Close() })
|
||||
|
||||
<-done
|
||||
require.NoError(t, serverErr)
|
||||
require.NotNil(t, serverConn)
|
||||
t.Cleanup(func() { serverConn.Close() })
|
||||
|
||||
authInfo, ok := serverAuth.(UnixAuthInfo)
|
||||
require.True(t, ok, "expected UnixAuthInfo, got %T", serverAuth)
|
||||
assert.Equal(t, UID(os.Getuid()), authInfo.UID, "UID should match current user")
|
||||
}
|
||||
|
||||
func TestUnixTransportCredentials_ServerHandshake_NonUnixConn(t *testing.T) {
|
||||
creds := NewUnixTransportCredentials()
|
||||
if creds == nil {
|
||||
t.Skip("unix transport credentials not supported on this platform")
|
||||
}
|
||||
|
||||
// Use a TCP connection, which is not *net.UnixConn.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
|
||||
done := make(chan struct{})
|
||||
var handshakeErr error
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
raw, err := ln.Accept()
|
||||
if err != nil {
|
||||
handshakeErr = err
|
||||
return
|
||||
}
|
||||
defer raw.Close()
|
||||
_, _, handshakeErr = creds.ServerHandshake(raw)
|
||||
}()
|
||||
|
||||
client, err := net.Dial("tcp", ln.Addr().String())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { client.Close() })
|
||||
|
||||
<-done
|
||||
require.Error(t, handshakeErr, "ServerHandshake must fail for non-Unix connections")
|
||||
}
|
||||
|
||||
func TestUnixTransportCredentials_Info(t *testing.T) {
|
||||
creds := NewUnixTransportCredentials()
|
||||
if creds == nil {
|
||||
t.Skip("unix transport credentials not supported on this platform")
|
||||
}
|
||||
|
||||
info := creds.Info()
|
||||
assert.Equal(t, "unix_peercred", info.SecurityProtocol)
|
||||
}
|
||||
|
||||
func TestUnixTransportCredentials_Clone(t *testing.T) {
|
||||
creds := NewUnixTransportCredentials()
|
||||
if creds == nil {
|
||||
t.Skip("unix transport credentials not supported on this platform")
|
||||
}
|
||||
|
||||
cloned := creds.Clone()
|
||||
require.NotNil(t, cloned)
|
||||
assert.Equal(t, creds.Info(), cloned.Info())
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package owner
|
||||
|
||||
// UID is a Unix user ID. Defined as a distinct type so it can't be silently
|
||||
// swapped with GID, PID, or other uint32 values at call sites.
|
||||
type UID uint32
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/internal/owner"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
|
||||
"github.com/netbirdio/netbird/client/ssh"
|
||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||
@@ -99,6 +100,10 @@ type ConfigInput struct {
|
||||
LazyConnectionEnabled *bool
|
||||
|
||||
MTU *uint16
|
||||
|
||||
// OwnerUIDs sets the UIDs of users allowed to control the daemon.
|
||||
// When non-nil, replaces the config's OwnerUIDs.
|
||||
OwnerUIDs []owner.UID
|
||||
}
|
||||
|
||||
// Config Configuration type
|
||||
@@ -174,6 +179,12 @@ type Config struct {
|
||||
LazyConnectionEnabled bool
|
||||
|
||||
MTU uint16
|
||||
|
||||
// OwnerUIDs controls who can perform privileged daemon operations via the gRPC socket.
|
||||
// nil (absent from JSON): TOFU mode, first privileged caller claims ownership (backward compat for existing installs).
|
||||
// [] (empty slice): root-only, no non-root owners until explicitly set via "netbird up --owner".
|
||||
// [uid1, uid2, ...]: these UIDs plus root can perform privileged operations.
|
||||
OwnerUIDs []owner.UID `json:"OwnerUIDs"`
|
||||
}
|
||||
|
||||
var ConfigDirOverride string
|
||||
@@ -234,10 +245,18 @@ func fileExists(path string) (bool, error) {
|
||||
|
||||
// createNewConfig creates a new config generating a new Wireguard key and saving to file
|
||||
func createNewConfig(input ConfigInput) (*Config, error) {
|
||||
// Seed owner UIDs from environment if set (for MDM deployments),
|
||||
// otherwise default to root-only (empty slice).
|
||||
ownerUIDs := owner.OwnerUIDsFromEnv()
|
||||
if ownerUIDs == nil {
|
||||
ownerUIDs = []owner.UID{}
|
||||
}
|
||||
|
||||
config := &Config{
|
||||
// defaults to false only for new (post 0.26) configurations
|
||||
ServerSSHAllowed: util.False(),
|
||||
WgPort: iface.DefaultWgPort,
|
||||
OwnerUIDs: ownerUIDs,
|
||||
}
|
||||
|
||||
if _, err := config.apply(input); err != nil {
|
||||
@@ -612,6 +631,14 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.OwnerUIDs != nil {
|
||||
if !slices.Equal(config.OwnerUIDs, input.OwnerUIDs) {
|
||||
log.Infof("updating owner UIDs to %v", input.OwnerUIDs)
|
||||
config.OwnerUIDs = input.OwnerUIDs
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/owner"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
@@ -243,7 +244,10 @@ func (s *ServiceManager) DefaultProfilePath() string {
|
||||
return DefaultConfigPath
|
||||
}
|
||||
|
||||
func (s *ServiceManager) AddProfile(profileName, username string) error {
|
||||
// AddProfile creates a new profile with the given name. inheritOwnerUIDs is
|
||||
// applied to the new profile's OwnerUIDs (pass the active profile's owners so
|
||||
// the caller stays authorized; pass nil to leave the default empty/env-seeded).
|
||||
func (s *ServiceManager) AddProfile(profileName, username string, inheritOwnerUIDs []owner.UID) error {
|
||||
configDir, err := s.getConfigDir(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get config directory: %w", err)
|
||||
@@ -264,7 +268,7 @@ func (s *ServiceManager) AddProfile(profileName, username string) error {
|
||||
return ErrProfileAlreadyExists
|
||||
}
|
||||
|
||||
cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath})
|
||||
cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath, OwnerUIDs: inheritOwnerUIDs})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create new config: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user