Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 41 additions & 9 deletions cmd/devproxy/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package cmd

import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"path/filepath"
"syscall"

Expand All @@ -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)
})
}

Expand All @@ -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 {
Expand All @@ -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
}

// =========================================================================
Expand Down
115 changes: 115 additions & 0 deletions cmd/devproxy/cmd/run_test.go
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 9 additions & 4 deletions cmd/devproxy/cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
Expand Down
14 changes: 9 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
}

Expand Down
Loading