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) // DaemonOwnerStore persists the daemon-wide owner set. The cmd layer implements // it over service.json. The interface lives here to avoid an import cycle. A nil // store means the daemon is unowned, so non-privileged callers are denied on the // default profile. type DaemonOwnerStore interface { // Load returns the persisted daemon owner principals and shared flag. Load() (owners []string, shared bool, err error) // Save persists the daemon owner principals and shared flag. Save(owners []string, shared bool) error } // SetDaemonOwnerStore installs the owner persistence backend and loads the // current owner set into memory. Called once by cmd before serving RPCs. func (s *Server) SetDaemonOwnerStore(store DaemonOwnerStore) { s.mutex.Lock() defer s.mutex.Unlock() s.daemonOwnerStore = store if store == nil { return } owners, shared, err := store.Load() if err != nil { log.Warnf("ownership: cannot load daemon owners, treating as unowned: %v", err) return } s.owners = ipcauth.Ownership{Owners: owners, Shared: shared} log.Infof("daemon owners loaded: %d principal(s), shared=%t", len(owners), shared) } // activeIsDefaultLocked reports whether the active profile is the shared default. // The default is owned daemon-wide, every other profile by its own per-profile // owner. Caller must hold s.mutex. func (s *Server) activeIsDefaultLocked() (bool, error) { active, err := s.profileManager.GetActiveProfileState() if err != nil { return false, fmt.Errorf("get active profile: %w", err) } return active.ID == profilemanager.DefaultProfileName, nil } // ActiveProfileOwnership returns the ownership the interceptor gates the active // profile against. The default profile uses the daemon-wide owner set, every // other profile uses its own collision-free owner (isolated per user). func (s *Server) ActiveProfileOwnership() ipcauth.Ownership { s.mutex.Lock() defer s.mutex.Unlock() isDefault, err := s.activeIsDefaultLocked() if err != nil { log.Warnf("ownership: cannot determine active profile, treating as unowned: %v", err) return ipcauth.Ownership{} } if isDefault { return s.owners } 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). The default // profile claims daemon-wide ownership via the owner store, every other profile // claims its own per-profile owner. 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() isDefault, err := s.activeIsDefaultLocked() if err != nil { return false, fmt.Errorf("determine active profile: %w", err) } if isDefault { return s.claimDaemonOwnerLocked(id) } 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 } // DaemonOwnership returns the daemon-wide owner set. It governs the owner-tier // RPCs (Add, Down, Status) and the default profile. func (s *Server) DaemonOwnership() ipcauth.Ownership { s.mutex.Lock() defer s.mutex.Unlock() return s.owners } // ClaimDaemonOwnerIfUnowned claims daemon-wide ownership for id when the daemon // is unowned and unshared (trust-on-first-use). func (s *Server) ClaimDaemonOwnerIfUnowned(id ipcauth.Identity) (bool, error) { s.mutex.Lock() defer s.mutex.Unlock() return s.claimDaemonOwnerLocked(id) } // claimDaemonOwnerLocked claims daemon-wide ownership for id when the daemon is // unowned and unshared, persisting via the owner store. Caller must hold s.mutex. func (s *Server) claimDaemonOwnerLocked(id ipcauth.Identity) (bool, error) { if s.daemonOwnerStore == nil { return false, nil // no store, cannot claim, fail closed } if len(s.owners.Owners) > 0 || s.owners.Shared { return false, nil // already owned or shared } principal := ipcauth.OwnerPrincipalForIdentity(id) if err := s.daemonOwnerStore.Save([]string{principal}, false); err != nil { return false, fmt.Errorf("persist daemon owner claim: %w", err) } s.owners = ipcauth.Ownership{Owners: []string{principal}} log.Infof("daemon ownership claimed by %s (trust-on-first-use)", id) return true, nil } // addDaemonOwnerLocked adds id's principal to the daemon owner set and persists. // Idempotent, and a no-op for privileged callers. Caller must hold s.mutex. func (s *Server) addDaemonOwnerLocked(id ipcauth.Identity) error { if id.IsPrivileged() { return nil } if s.daemonOwnerStore == nil { return fmt.Errorf("daemon owner store unavailable") } principal := ipcauth.OwnerPrincipalForIdentity(id) if slices.Contains(s.owners.Owners, principal) { return nil } next := append(slices.Clone(s.owners.Owners), principal) if err := s.daemonOwnerStore.Save(next, s.owners.Shared); err != nil { return err } s.owners.Owners = next return 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) } // claimForCallerLocked adds the caller's principal to the active profile's owner // set (if absent) and persists. No-op for privileged callers. For the default // profile it adds to the daemon-wide owners, for any other profile it adds to // that profile's per-profile owners. Caller must hold s.mutex. func (s *Server) claimForCallerLocked(id ipcauth.Identity, cfg *profilemanager.Config) error { if id.IsPrivileged() { return nil } isDefault, err := s.activeIsDefaultLocked() if err != nil { return err } if isDefault { return s.addDaemonOwnerLocked(id) } 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 } // The default profile is governed by the daemon-wide owners (all owners may // use it), not a per-profile owner. Authorize against s.owners and never stamp. if target.ID == profilemanager.DefaultProfileName { if ipcauth.Authorize(s.owners, id, s.groupResolver) { return nil } return gstatus.Errorf(codes.PermissionDenied, "not authorized to use the default profile (caller %s is not a daemon owner)", id) } 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 } // requireDaemonOwnerLocked fails closed unless the caller is a daemon owner or // privileged. Defense in depth for the owner-set mutations, which the interceptor // owner tier already gates. Caller must hold s.mutex. func (s *Server) requireDaemonOwnerLocked(ctx context.Context) error { id, ok := ipcauth.IdentityFromContext(ctx) if !ok { return gstatus.Error(codes.PermissionDenied, "caller identity could not be verified") } if id.IsPrivileged() || ipcauth.Authorize(s.owners, id, s.groupResolver) { return nil } return gstatus.Error(codes.PermissionDenied, "not authorized: managing daemon owners requires a daemon owner or root/administrator") } // AddOwner adds a principal to the daemon-wide owner set. The owner tier gates // this, the handler re-checks (defense in depth), validates, and persists. func (s *Server) AddOwner(ctx 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() if err := s.requireDaemonOwnerLocked(ctx); err != nil { return nil, err } if s.daemonOwnerStore == nil { return nil, gstatus.Error(codes.Unavailable, "daemon owner store unavailable") } if slices.Contains(s.owners.Owners, principal) { return &proto.AddOwnerResponse{}, nil } next := append(slices.Clone(s.owners.Owners), principal) if err := s.daemonOwnerStore.Save(next, s.owners.Shared); err != nil { return nil, fmt.Errorf("persist owner: %w", err) } s.owners.Owners = next log.Infof("added daemon owner %q", principal) return &proto.AddOwnerResponse{}, nil } // ResetOwner clears the daemon-wide owner set (and shared flag), returning the // daemon 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() if s.daemonOwnerStore == nil { return nil, gstatus.Error(codes.Unavailable, "daemon owner store unavailable") } if err := s.daemonOwnerStore.Save(nil, false); err != nil { return nil, fmt.Errorf("persist owner reset: %w", err) } s.owners = ipcauth.Ownership{} log.Infof("daemon owner list reset, next caller will re-claim (trust-on-first-use)") return &proto.ResetOwnerResponse{}, nil } // ShareProfile marks the daemon shared or unshared. When shared, any authenticated // local caller may control the daemon and its default profile. The owner tier // gates this, the handler re-checks (defense in depth). func (s *Server) ShareProfile(ctx context.Context, msg *proto.ShareProfileRequest) (*proto.ShareProfileResponse, error) { s.mutex.Lock() defer s.mutex.Unlock() if err := s.requireDaemonOwnerLocked(ctx); err != nil { return nil, err } if s.daemonOwnerStore == nil { return nil, gstatus.Error(codes.Unavailable, "daemon owner store unavailable") } if err := s.daemonOwnerStore.Save(s.owners.Owners, msg.GetShared()); err != nil { return nil, fmt.Errorf("persist shared flag: %w", err) } s.owners.Shared = msg.GetShared() log.Infof("daemon shared flag set to %t", msg.GetShared()) return &proto.ShareProfileResponse{}, nil }