210 lines
5.7 KiB
Go
210 lines
5.7 KiB
Go
package profile
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
)
|
|
|
|
type FileStore struct {
|
|
path string
|
|
}
|
|
|
|
func NewFileStore(path string) *FileStore {
|
|
return &FileStore{path: path}
|
|
}
|
|
|
|
type UserIdentity struct {
|
|
Username string
|
|
HomeDir string
|
|
}
|
|
|
|
type IdentityEnvironment interface {
|
|
CurrentUser() (UserIdentity, error)
|
|
LookupUser(string) (UserIdentity, error)
|
|
LookupEnv(string) (string, bool)
|
|
}
|
|
|
|
type systemIdentityEnvironment struct{}
|
|
|
|
func (systemIdentityEnvironment) CurrentUser() (UserIdentity, error) {
|
|
value, err := user.Current()
|
|
if err != nil {
|
|
return UserIdentity{}, err
|
|
}
|
|
return UserIdentity{Username: value.Username, HomeDir: value.HomeDir}, nil
|
|
}
|
|
|
|
func (systemIdentityEnvironment) LookupUser(name string) (UserIdentity, error) {
|
|
value, err := user.Lookup(name)
|
|
if err != nil {
|
|
return UserIdentity{}, err
|
|
}
|
|
return UserIdentity{Username: value.Username, HomeDir: value.HomeDir}, nil
|
|
}
|
|
|
|
func (systemIdentityEnvironment) LookupEnv(name string) (string, bool) {
|
|
return os.LookupEnv(name)
|
|
}
|
|
|
|
func ConfigPath() (string, error) {
|
|
return ConfigPathWith(systemIdentityEnvironment{})
|
|
}
|
|
|
|
func ConfigPathWith(environment IdentityEnvironment) (string, error) {
|
|
owner, err := environment.CurrentUser()
|
|
if err != nil {
|
|
return "", 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 "", fmt.Errorf("identify invoking user %q: %w", name, err)
|
|
}
|
|
invokingUser = true
|
|
}
|
|
if owner.HomeDir == "" {
|
|
return "", errors.New("configuration owner has no home directory")
|
|
}
|
|
|
|
configDir := ""
|
|
if value, ok := environment.LookupEnv("XDG_CONFIG_HOME"); ok && filepath.IsAbs(value) {
|
|
if !invokingUser || pathWithin(value, owner.HomeDir) {
|
|
configDir = value
|
|
}
|
|
}
|
|
if configDir == "" {
|
|
configDir = filepath.Join(owner.HomeDir, ".config")
|
|
}
|
|
return filepath.Join(configDir, "proxyctl", "profiles.json"), nil
|
|
}
|
|
|
|
func pathWithin(path, parent string) bool {
|
|
relative, err := filepath.Rel(parent, path)
|
|
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
|
|
}
|
|
|
|
func (s *FileStore) Load() (State, error) {
|
|
return s.load()
|
|
}
|
|
|
|
func (s *FileStore) Mutate(change func(State) (State, error)) (State, error) {
|
|
if err := s.secureDirectory(); err != nil {
|
|
return State{}, err
|
|
}
|
|
lock, err := os.OpenFile(s.path+".lock", os.O_CREATE|os.O_RDWR, 0o600)
|
|
if err != nil {
|
|
return State{}, fmt.Errorf("open Profile configuration lock: %w", err)
|
|
}
|
|
defer lock.Close()
|
|
if err := lock.Chmod(0o600); err != nil {
|
|
return State{}, fmt.Errorf("secure Profile configuration lock: %w", err)
|
|
}
|
|
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
|
|
return State{}, fmt.Errorf("lock Profile configuration: %w", err)
|
|
}
|
|
defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) //nolint:errcheck -- closing also releases the lock
|
|
|
|
current, err := s.load()
|
|
if err != nil {
|
|
return State{}, err
|
|
}
|
|
next, err := change(current)
|
|
if err != nil {
|
|
return current, err
|
|
}
|
|
if err := s.save(next); err != nil {
|
|
return current, err
|
|
}
|
|
return next, nil
|
|
}
|
|
|
|
func (s *FileStore) load() (State, error) {
|
|
file, err := os.Open(s.path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return State{}, nil
|
|
}
|
|
if err != nil {
|
|
return State{}, fmt.Errorf("read Profile configuration: %w", err)
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return State{}, fmt.Errorf("inspect Profile configuration: %w", err)
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return State{}, errors.New("Profile configuration is not a regular file")
|
|
}
|
|
if info.Mode().Perm() != 0o600 {
|
|
if err := file.Chmod(0o600); err != nil {
|
|
return State{}, fmt.Errorf("secure Profile configuration: %w", 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) secureDirectory() error {
|
|
directory := filepath.Dir(s.path)
|
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
|
return fmt.Errorf("create Profile configuration directory: %w", err)
|
|
}
|
|
if err := os.Chmod(directory, 0o700); err != nil {
|
|
return fmt.Errorf("secure Profile configuration directory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *FileStore) save(state State) error {
|
|
if err := s.secureDirectory(); err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(state, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode Profile configuration: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
temp, err := os.CreateTemp(filepath.Dir(s.path), ".profiles-*")
|
|
if err != nil {
|
|
return fmt.Errorf("create temporary Profile configuration: %w", err)
|
|
}
|
|
tempName := temp.Name()
|
|
defer os.Remove(tempName)
|
|
if err := temp.Chmod(0o600); err != nil {
|
|
temp.Close()
|
|
return fmt.Errorf("secure temporary Profile configuration: %w", err)
|
|
}
|
|
if _, err := temp.Write(data); err != nil {
|
|
temp.Close()
|
|
return fmt.Errorf("write temporary Profile configuration: %w", err)
|
|
}
|
|
if err := temp.Sync(); err != nil {
|
|
temp.Close()
|
|
return fmt.Errorf("sync temporary Profile configuration: %w", err)
|
|
}
|
|
if err := temp.Close(); err != nil {
|
|
return fmt.Errorf("close temporary Profile configuration: %w", err)
|
|
}
|
|
// Rename is the commit point. There are deliberately no fallible operations
|
|
// afterwards, so callers never retain stale memory after a visible commit.
|
|
if err := os.Rename(tempName, s.path); err != nil {
|
|
return fmt.Errorf("replace Profile configuration: %w", err)
|
|
}
|
|
return nil
|
|
}
|