diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d2d14a..f1ed87f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.2.4 2026-08-20 + +### Fixed + +- User configuration files are created with invoking user's ownership when devproxy elevates privileges. + ## 0.2.3 2026-01-30 ### Removed diff --git a/cmd/devproxy/cmd/run.go b/cmd/devproxy/cmd/run.go index 00f2db2..0775624 100644 --- a/cmd/devproxy/cmd/run.go +++ b/cmd/devproxy/cmd/run.go @@ -2,10 +2,12 @@ package cmd import ( "context" + "errors" "fmt" "log/slog" "net" "os" + "os/exec" "path/filepath" "syscall" @@ -23,16 +25,13 @@ import ( "github.com/munichmade/devproxy/internal/proxy" ) -// chownRecursive changes ownership of a directory and all its contents +// chownRecursive changes ownership of a directory and all its contents. func chownRecursive(path string, uid, gid int) error { return filepath.Walk(path, func(name string, info os.FileInfo, err error) error { if err != nil { - return nil // Skip files we can't access + return err } - if err := syscall.Chown(name, uid, gid); err != nil { - return nil // Skip files we can't chown - } - return nil + return syscall.Lchown(name, uid, gid) }) } @@ -51,8 +50,41 @@ var runCmd = &cobra.Command{ }, } +var initConfigCmd = &cobra.Command{ + Use: "init-config", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + _, err := config.Load() + return err + }, +} + func init() { - rootCmd.AddCommand(runCmd) + rootCmd.AddCommand(runCmd, initConfigCmd) +} + +func loadConfig(originalUser *privilege.Info) (*config.Config, error) { + if originalUser == nil { + return config.Load() + } + if err := chownRecursive(paths.ConfigDir(), originalUser.UID, originalUser.GID); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("failed to chown config directory: %w", err) + } + + executable, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("failed to get executable path: %w", err) + } + cmd := exec.Command(executable, "init-config") + cmd.Env = os.Environ() + cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{ + Uid: uint32(originalUser.UID), + Gid: uint32(originalUser.GID), + }} + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed to initialize user config: %w", err) + } + return config.LoadExisting() } func runDaemon() error { @@ -66,9 +98,9 @@ func runDaemon() error { } // Load config first to get port settings - cfg, err := config.Load() + cfg, err := loadConfig(originalUser) if err != nil { - cfg = config.Default() + return err } // ========================================================================= diff --git a/cmd/devproxy/cmd/run_test.go b/cmd/devproxy/cmd/run_test.go new file mode 100644 index 0000000..aa21add --- /dev/null +++ b/cmd/devproxy/cmd/run_test.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/munichmade/devproxy/internal/paths" + "github.com/munichmade/devproxy/internal/privilege" +) + +func TestMain(m *testing.M) { + if len(os.Args) == 2 && os.Args[1] == "init-config" { + rootCmd.SetArgs([]string{"init-config"}) + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) + } + os.Exit(m.Run()) +} + +func TestChownRecursive(t *testing.T) { + t.Run("returns error for missing path", func(t *testing.T) { + missingPath := filepath.Join(t.TempDir(), "missing") + + if err := chownRecursive(missingPath, os.Getuid(), os.Getgid()); err == nil { + t.Error("chownRecursive() error = nil, want error for missing path") + } + }) + + t.Run("does not follow symlinks", func(t *testing.T) { + gid := alternateGroupID(t) + dir := t.TempDir() + target := filepath.Join(dir, "target") + link := filepath.Join(dir, "link") + if err := os.WriteFile(target, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + before := fileGroupID(t, target) + if err := chownRecursive(link, os.Getuid(), gid); err != nil { + t.Fatal(err) + } + if after := fileGroupID(t, target); after != before { + t.Errorf("target group = %d, want %d", after, before) + } + }) +} + +func TestLoadConfigCreatesUserOwnedFiles(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires root") + } + originalUser, err := privilege.GetOriginalUser() + if err != nil || originalUser == nil { + t.Skip("requires SUDO_UID and SUDO_GID") + } + + configHome := t.TempDir() + if err := os.Chown(configHome, originalUser.UID, originalUser.GID); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_CONFIG_HOME", configHome) + paths.Reset() + defer paths.Reset() + + if _, err := loadConfig(originalUser); err != nil { + t.Fatal(err) + } + for _, path := range []string{paths.ConfigDir(), paths.ConfigFile()} { + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + stat := info.Sys().(*syscall.Stat_t) + if int(stat.Uid) != originalUser.UID || int(stat.Gid) != originalUser.GID { + t.Errorf("%s owner = %d:%d, want %d:%d", path, stat.Uid, stat.Gid, originalUser.UID, originalUser.GID) + } + } +} + +func alternateGroupID(t *testing.T) int { + t.Helper() + groups, err := os.Getgroups() + if err != nil { + t.Fatal(err) + } + for _, gid := range groups { + if gid != os.Getgid() { + return gid + } + } + t.Skip("no supplementary group available") + return 0 +} + +func fileGroupID(t *testing.T, path string) uint32 { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + t.Fatalf("unexpected file info type %T", info.Sys()) + } + return stat.Gid +} diff --git a/cmd/devproxy/cmd/setup.go b/cmd/devproxy/cmd/setup.go index 9b6ffbc..c039330 100644 --- a/cmd/devproxy/cmd/setup.go +++ b/cmd/devproxy/cmd/setup.go @@ -7,7 +7,6 @@ import ( "github.com/spf13/cobra" "github.com/munichmade/devproxy/internal/ca" - "github.com/munichmade/devproxy/internal/config" "github.com/munichmade/devproxy/internal/privilege" "github.com/munichmade/devproxy/internal/resolver" ) @@ -34,11 +33,17 @@ Administrator privileges are required to install the CA and configure DNS.`, fmt.Println("Setting up devproxy...") fmt.Println() + originalUser, err := privilege.GetOriginalUser() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to get original user: %v\n", err) + os.Exit(1) + } + // Load config to get port settings - cfg, err := config.Load() + cfg, err := loadConfig(originalUser) if err != nil { - // Use defaults if config doesn't exist - cfg = config.Default() + fmt.Fprintf(os.Stderr, "failed to load user config: %v\n", err) + os.Exit(1) } // Step 1: Generate CA if needed diff --git a/internal/config/config.go b/internal/config/config.go index c698b21..c6d1ded 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -89,6 +89,11 @@ func Load() (*Config, error) { return LoadFromFile(paths.ConfigFile()) } +// LoadExisting reads the configuration without creating a default file. +func LoadExisting() (*Config, error) { + return loadExisting(paths.ConfigFile()) +} + // LoadFromFile reads the configuration from the specified file path. // If the file doesn't exist, it creates a default configuration file. func LoadFromFile(path string) (*Config, error) { @@ -102,23 +107,22 @@ func LoadFromFile(path string) (*Config, error) { return cfg, nil } - // Read existing config + return loadExisting(path) +} + +func loadExisting(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) } - // Start with defaults and overlay with file values cfg := Default() if err := yaml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("failed to parse config file: %w", err) } - - // Validate the configuration if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid configuration: %w", err) } - return cfg, nil }