462 lines
14 KiB
Go
462 lines
14 KiB
Go
package profile
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"golang.org/x/sys/unix"
|
|
|
|
"proxyctl/internal/securefs"
|
|
)
|
|
|
|
type FileStore struct {
|
|
path string
|
|
owner *UserIdentity
|
|
trustedRoot string
|
|
operations fileOperations
|
|
}
|
|
|
|
type fileOperations interface {
|
|
SetOwner(*os.File, int, int) error
|
|
SyncDirectory(*os.File) error
|
|
}
|
|
|
|
type systemFileOperations struct{}
|
|
|
|
func (systemFileOperations) SetOwner(file *os.File, uid, gid int) error {
|
|
return file.Chown(uid, gid)
|
|
}
|
|
|
|
func (systemFileOperations) SyncDirectory(directory *os.File) error {
|
|
return directory.Sync()
|
|
}
|
|
|
|
func NewFileStore(path string) *FileStore {
|
|
return &FileStore{path: path, operations: systemFileOperations{}}
|
|
}
|
|
|
|
func NewOwnedFileStore(path string, owner UserIdentity) *FileStore {
|
|
return NewOwnedFileStoreAt(path, owner, owner.HomeDir)
|
|
}
|
|
|
|
func NewOwnedFileStoreAt(path string, owner UserIdentity, trustedRoot string) *FileStore {
|
|
return &FileStore{path: path, owner: &owner, trustedRoot: trustedRoot, operations: systemFileOperations{}}
|
|
}
|
|
|
|
func newFileStore(path string, owner *UserIdentity, operations fileOperations) *FileStore {
|
|
trustedRoot := ""
|
|
if owner != nil {
|
|
trustedRoot = owner.HomeDir
|
|
}
|
|
return &FileStore{path: path, owner: owner, trustedRoot: trustedRoot, operations: operations}
|
|
}
|
|
|
|
type UserIdentity struct {
|
|
Username string
|
|
HomeDir string
|
|
UID int
|
|
GID int
|
|
}
|
|
|
|
type Configuration struct {
|
|
Path string
|
|
Owner UserIdentity
|
|
TrustedRoot string
|
|
}
|
|
|
|
type IdentityEnvironment interface {
|
|
CurrentUser() (UserIdentity, error)
|
|
LookupUser(string) (UserIdentity, error)
|
|
LookupEnv(string) (string, bool)
|
|
}
|
|
|
|
type systemIdentityEnvironment struct{}
|
|
|
|
func identityFromUser(value *user.User) (UserIdentity, error) {
|
|
uid, err := strconv.Atoi(value.Uid)
|
|
if err != nil {
|
|
return UserIdentity{}, fmt.Errorf("parse uid %q: %w", value.Uid, err)
|
|
}
|
|
gid, err := strconv.Atoi(value.Gid)
|
|
if err != nil {
|
|
return UserIdentity{}, fmt.Errorf("parse gid %q: %w", value.Gid, err)
|
|
}
|
|
return UserIdentity{Username: value.Username, HomeDir: value.HomeDir, UID: uid, GID: gid}, nil
|
|
}
|
|
|
|
func (systemIdentityEnvironment) CurrentUser() (UserIdentity, error) {
|
|
value, err := user.Current()
|
|
if err != nil {
|
|
return UserIdentity{}, err
|
|
}
|
|
return identityFromUser(value)
|
|
}
|
|
|
|
func (systemIdentityEnvironment) LookupUser(name string) (UserIdentity, error) {
|
|
value, err := user.Lookup(name)
|
|
if err != nil {
|
|
return UserIdentity{}, err
|
|
}
|
|
return identityFromUser(value)
|
|
}
|
|
|
|
func (systemIdentityEnvironment) LookupEnv(name string) (string, bool) {
|
|
return os.LookupEnv(name)
|
|
}
|
|
|
|
// SystemCurrentUser and SystemLookupUser expose the same injectable identity model
|
|
// to user-scoped Target configuration without duplicating OS account parsing.
|
|
func SystemCurrentUser() (UserIdentity, error) {
|
|
return systemIdentityEnvironment{}.CurrentUser()
|
|
}
|
|
|
|
func SystemLookupUser(name string) (UserIdentity, error) {
|
|
return systemIdentityEnvironment{}.LookupUser(name)
|
|
}
|
|
|
|
func ConfigPath() (string, error) {
|
|
configuration, err := ResolveConfigurationForCurrentProcess()
|
|
return configuration.Path, err
|
|
}
|
|
|
|
func ResolveConfigurationForCurrentProcess() (Configuration, error) {
|
|
return ResolveConfiguration(systemIdentityEnvironment{})
|
|
}
|
|
|
|
func ResolveConfiguration(environment IdentityEnvironment) (Configuration, error) {
|
|
owner, err := environment.CurrentUser()
|
|
if err != nil {
|
|
return Configuration{}, fmt.Errorf("identify configuration owner: %w", err)
|
|
}
|
|
invokingUser := false
|
|
if name, ok := environment.LookupEnv("SUDO_USER"); ok && name != "" && name != owner.Username {
|
|
owner, err = environment.LookupUser(name)
|
|
if err != nil {
|
|
return Configuration{}, fmt.Errorf("identify invoking user %q: %w", name, err)
|
|
}
|
|
invokingUser = true
|
|
}
|
|
if owner.HomeDir == "" {
|
|
return Configuration{}, errors.New("configuration owner has no home directory")
|
|
}
|
|
if owner.UID < 0 || owner.GID < 0 {
|
|
return Configuration{}, errors.New("configuration owner has an invalid uid or gid")
|
|
}
|
|
|
|
configDir := ""
|
|
trustedRoot := owner.HomeDir
|
|
if value, ok := environment.LookupEnv("XDG_CONFIG_HOME"); ok && filepath.IsAbs(value) {
|
|
if !invokingUser || pathWithin(value, owner.HomeDir) {
|
|
configDir = value
|
|
if !pathWithin(value, owner.HomeDir) {
|
|
trustedRoot = value
|
|
}
|
|
}
|
|
}
|
|
if configDir == "" {
|
|
configDir = filepath.Join(owner.HomeDir, ".config")
|
|
}
|
|
return Configuration{
|
|
Path: filepath.Join(configDir, "proxyctl", "profiles.json"),
|
|
Owner: owner,
|
|
TrustedRoot: trustedRoot,
|
|
}, nil
|
|
}
|
|
|
|
func ConfigPathWith(environment IdentityEnvironment) (string, error) {
|
|
configuration, err := ResolveConfiguration(environment)
|
|
return configuration.Path, err
|
|
}
|
|
|
|
func pathWithin(path, parent string) bool {
|
|
relative, err := filepath.Rel(parent, path)
|
|
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
|
|
}
|
|
|
|
type CommitError struct {
|
|
Err error
|
|
}
|
|
|
|
func (e *CommitError) Error() string {
|
|
return fmt.Sprintf("Profile configuration was committed but durability could not be confirmed: %v", e.Err)
|
|
}
|
|
|
|
func (e *CommitError) Unwrap() error { return e.Err }
|
|
|
|
func (s *FileStore) Load() (State, error) {
|
|
return s.load()
|
|
}
|
|
|
|
func (s *FileStore) Mutate(change func(State) (State, error)) (State, error) {
|
|
directory, err := s.openConfigurationDirectory(true)
|
|
if err != nil {
|
|
return State{}, err
|
|
}
|
|
defer directory.Close()
|
|
lock, err := s.openLockAt(directory)
|
|
if err != nil {
|
|
return State{}, err
|
|
}
|
|
defer lock.Close()
|
|
if err := unix.Flock(int(lock.Fd()), unix.LOCK_EX); err != nil {
|
|
return State{}, fmt.Errorf("lock Profile configuration: %w", err)
|
|
}
|
|
defer func() { _ = unix.Flock(int(lock.Fd()), unix.LOCK_UN) }()
|
|
|
|
current, err := s.loadFrom(directory)
|
|
if err != nil {
|
|
return State{}, err
|
|
}
|
|
next, err := change(current)
|
|
if err != nil {
|
|
return current, err
|
|
}
|
|
committed, err := s.save(next, directory)
|
|
if committed {
|
|
return next, err
|
|
}
|
|
if err != nil {
|
|
return current, err
|
|
}
|
|
return next, nil
|
|
}
|
|
|
|
func openNoFollow(path string, flags int, mode uint32) (*os.File, error) {
|
|
return securefs.OpenNoFollow(path, flags, mode)
|
|
}
|
|
|
|
func openAt(directory *os.File, name string, flags int, mode uint32) (*os.File, error) {
|
|
return securefs.OpenAt(directory, name, flags, mode)
|
|
}
|
|
|
|
func (s *FileStore) load() (State, error) {
|
|
directory, err := s.openConfigurationDirectory(false)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return State{}, nil
|
|
}
|
|
if err != nil {
|
|
return State{}, err
|
|
}
|
|
defer directory.Close()
|
|
if err := s.secureExistingLock(directory); err != nil {
|
|
return State{}, err
|
|
}
|
|
return s.loadFrom(directory)
|
|
}
|
|
|
|
func (s *FileStore) loadFrom(directory *os.File) (State, error) {
|
|
file, err := openAt(directory, filepath.Base(s.path), unix.O_RDONLY|unix.O_NONBLOCK, 0)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return State{}, nil
|
|
}
|
|
if err != nil {
|
|
return State{}, fmt.Errorf("open Profile configuration without following links: %w", err)
|
|
}
|
|
defer file.Close()
|
|
if err := s.secureRegularFile(file, "Profile configuration"); err != nil {
|
|
return State{}, err
|
|
}
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
return State{}, fmt.Errorf("read Profile configuration: %w", err)
|
|
}
|
|
var state State
|
|
if err := json.Unmarshal(data, &state); err != nil {
|
|
return State{}, fmt.Errorf("parse Profile configuration: %w", err)
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
func (s *FileStore) secureRegularFile(file *os.File, description string) error {
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return fmt.Errorf("inspect %s: %w", description, err)
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return fmt.Errorf("%s is not a regular file", description)
|
|
}
|
|
if s.owner != nil {
|
|
if err := s.operations.SetOwner(file, s.owner.UID, s.owner.GID); err != nil {
|
|
return fmt.Errorf("set %s owner: %w", description, err)
|
|
}
|
|
}
|
|
if info.Mode().Perm() != 0o600 {
|
|
if err := file.Chmod(0o600); err != nil {
|
|
return fmt.Errorf("secure %s: %w", description, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *FileStore) openConfigurationDirectory(create bool) (*os.File, error) {
|
|
directoryPath := filepath.Clean(filepath.Dir(s.path))
|
|
if !filepath.IsAbs(directoryPath) {
|
|
return nil, errors.New("Profile configuration path must be absolute")
|
|
}
|
|
if directoryPath == string(filepath.Separator) {
|
|
return nil, errors.New("Profile configuration directory cannot be the filesystem root")
|
|
}
|
|
creationRoot := directoryPath
|
|
if s.owner != nil {
|
|
creationRoot = filepath.Clean(s.trustedRoot)
|
|
if !filepath.IsAbs(creationRoot) || !pathWithin(directoryPath, creationRoot) || directoryPath == creationRoot {
|
|
return nil, errors.New("Profile configuration directory escapes its trusted root")
|
|
}
|
|
}
|
|
|
|
current, err := openNoFollow(string(filepath.Separator), unix.O_RDONLY|unix.O_DIRECTORY, 0)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open filesystem root: %w", err)
|
|
}
|
|
currentPath := string(filepath.Separator)
|
|
for _, component := range strings.Split(strings.TrimPrefix(directoryPath, string(filepath.Separator)), string(filepath.Separator)) {
|
|
if component == "" {
|
|
continue
|
|
}
|
|
nextPath := filepath.Join(currentPath, component)
|
|
next, openErr := openAt(current, component, unix.O_RDONLY|unix.O_DIRECTORY, 0)
|
|
if errors.Is(openErr, fs.ErrNotExist) && create && s.mayCreateDirectory(nextPath, directoryPath, creationRoot) {
|
|
created := false
|
|
if mkdirErr := unix.Mkdirat(int(current.Fd()), component, 0o700); mkdirErr != nil {
|
|
if !errors.Is(mkdirErr, fs.ErrExist) {
|
|
current.Close()
|
|
return nil, fmt.Errorf("create Profile configuration directory component %q: %w", nextPath, mkdirErr)
|
|
}
|
|
} else {
|
|
created = true
|
|
}
|
|
next, openErr = openAt(current, component, unix.O_RDONLY|unix.O_DIRECTORY, 0)
|
|
if openErr == nil && created {
|
|
if err := s.secureCreatedDirectory(next, nextPath); err != nil {
|
|
next.Close()
|
|
_ = unix.Unlinkat(int(current.Fd()), component, unix.AT_REMOVEDIR)
|
|
current.Close()
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
current.Close()
|
|
if openErr != nil {
|
|
if errors.Is(openErr, fs.ErrNotExist) {
|
|
return nil, openErr
|
|
}
|
|
return nil, fmt.Errorf("open Profile configuration ancestor %q without following links: %w", nextPath, openErr)
|
|
}
|
|
current = next
|
|
currentPath = nextPath
|
|
}
|
|
if err := s.secureConfigurationDirectory(current); err != nil {
|
|
current.Close()
|
|
return nil, err
|
|
}
|
|
return current, nil
|
|
}
|
|
|
|
func (s *FileStore) mayCreateDirectory(path, directoryPath, creationRoot string) bool {
|
|
if s.owner == nil {
|
|
return path == directoryPath
|
|
}
|
|
return path != creationRoot && pathWithin(path, creationRoot)
|
|
}
|
|
|
|
func (s *FileStore) secureCreatedDirectory(directory *os.File, path string) error {
|
|
if s.owner != nil {
|
|
if err := s.operations.SetOwner(directory, s.owner.UID, s.owner.GID); err != nil {
|
|
return fmt.Errorf("set created Profile configuration directory %q owner: %w", path, err)
|
|
}
|
|
}
|
|
if err := directory.Chmod(0o700); err != nil {
|
|
return fmt.Errorf("secure created Profile configuration directory %q: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *FileStore) secureConfigurationDirectory(directory *os.File) error {
|
|
if s.owner != nil {
|
|
if err := s.operations.SetOwner(directory, s.owner.UID, s.owner.GID); err != nil {
|
|
return fmt.Errorf("set Profile configuration directory owner: %w", err)
|
|
}
|
|
}
|
|
if err := directory.Chmod(0o700); err != nil {
|
|
return fmt.Errorf("secure Profile configuration directory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *FileStore) secureExistingLock(directory *os.File) error {
|
|
lock, err := openAt(directory, filepath.Base(s.path)+".lock", unix.O_RDWR, 0)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("open existing Profile configuration lock without following links: %w", err)
|
|
}
|
|
defer lock.Close()
|
|
return s.secureRegularFile(lock, "Profile configuration lock")
|
|
}
|
|
|
|
func (s *FileStore) openLockAt(directory *os.File) (*os.File, error) {
|
|
lock, err := openAt(directory, filepath.Base(s.path)+".lock", unix.O_CREAT|unix.O_RDWR, 0o600)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open Profile configuration lock without following links: %w", err)
|
|
}
|
|
if err := s.secureRegularFile(lock, "Profile configuration lock"); err != nil {
|
|
lock.Close()
|
|
return nil, err
|
|
}
|
|
return lock, nil
|
|
}
|
|
|
|
func (s *FileStore) save(state State, directory *os.File) (bool, error) {
|
|
data, err := json.MarshalIndent(state, "", " ")
|
|
if err != nil {
|
|
return false, fmt.Errorf("encode Profile configuration: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
temp, tempBase, err := createTemporaryAt(directory)
|
|
if err != nil {
|
|
return false, fmt.Errorf("create temporary Profile configuration: %w", err)
|
|
}
|
|
defer unix.Unlinkat(int(directory.Fd()), tempBase, 0) //nolint:errcheck -- rename normally removes the temporary name
|
|
if s.owner != nil {
|
|
if err := s.operations.SetOwner(temp, s.owner.UID, s.owner.GID); err != nil {
|
|
temp.Close()
|
|
return false, fmt.Errorf("set temporary Profile configuration owner: %w", err)
|
|
}
|
|
}
|
|
if err := temp.Chmod(0o600); err != nil {
|
|
temp.Close()
|
|
return false, fmt.Errorf("secure temporary Profile configuration: %w", err)
|
|
}
|
|
if _, err := temp.Write(data); err != nil {
|
|
temp.Close()
|
|
return false, fmt.Errorf("write temporary Profile configuration: %w", err)
|
|
}
|
|
if err := temp.Sync(); err != nil {
|
|
temp.Close()
|
|
return false, fmt.Errorf("sync temporary Profile configuration: %w", err)
|
|
}
|
|
if err := temp.Close(); err != nil {
|
|
return false, fmt.Errorf("close temporary Profile configuration: %w", err)
|
|
}
|
|
if err := unix.Renameat(int(directory.Fd()), tempBase, int(directory.Fd()), filepath.Base(s.path)); err != nil {
|
|
return false, fmt.Errorf("replace Profile configuration: %w", err)
|
|
}
|
|
if err := s.operations.SyncDirectory(directory); err != nil {
|
|
return true, &CommitError{Err: fmt.Errorf("sync Profile configuration directory: %w", err)}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func createTemporaryAt(directory *os.File) (*os.File, string, error) {
|
|
return securefs.CreateTemporaryAt(directory, ".profiles-")
|
|
}
|