diff --git a/tools/homarr-dev/README.md b/tools/homarr-dev/README.md index 793a34b9b..b9e74a2d3 100644 --- a/tools/homarr-dev/README.md +++ b/tools/homarr-dev/README.md @@ -37,6 +37,7 @@ homarr run --env FOO=bar --env FEATURE=true dev ``` PR launches always pull the latest GHCR tag before starting. Local tags remain local. +The first launch generates a per-user encryption key in the OS config directory and reuses it for later launches. Set `HOMARR_DEV_SECRET_ENCRYPTION_KEY` to a 64-character hexadecimal key to override it. ## Commands diff --git a/tools/homarr-dev/internal/docker/docker.go b/tools/homarr-dev/internal/docker/docker.go index 0070bd7cb..8fcf1a9e9 100644 --- a/tools/homarr-dev/internal/docker/docker.go +++ b/tools/homarr-dev/internal/docker/docker.go @@ -2,17 +2,20 @@ package docker import ( "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "io" "net" "os" "os/exec" + "path/filepath" "strconv" "strings" ) -const defaultDevelopmentEncryptionKey = "cdca265c289df60f6605a2fc9c43cfa8eb6efdc98b203cad2c3f2970aca09fbe" +const developmentEncryptionKeyEnv = "HOMARR_DEV_SECRET_ENCRYPTION_KEY" type Container struct { ID string @@ -163,6 +166,11 @@ type StartOptions struct { } func Start(opts StartOptions) error { + encryptionKey, err := developmentEncryptionKey() + if err != nil { + return err + } + if opts.PullAlways { cmd := PullCommand(opts.Image, opts.Platform) cmd.Stdin = os.Stdin @@ -175,10 +183,6 @@ func Start(opts StartOptions) error { _ = exec.Command("docker", "rm", "-f", opts.Name).Run() - encryptionKey := os.Getenv("HOMARR_DEV_SECRET_ENCRYPTION_KEY") - if encryptionKey == "" { - encryptionKey = defaultDevelopmentEncryptionKey - } args := []string{"run", "--rm", "--name", opts.Name, "-e", "SECRET_ENCRYPTION_KEY=" + encryptionKey, } @@ -218,6 +222,65 @@ func Start(opts StartOptions) error { return nil } +func developmentEncryptionKey() (string, error) { + if key := os.Getenv(developmentEncryptionKeyEnv); key != "" { + return validateDevelopmentEncryptionKey(key) + } + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("locate user config directory: %w", err) + } + return persistedDevelopmentEncryptionKey(configDir) +} + +func persistedDevelopmentEncryptionKey(configDir string) (string, error) { + dir := filepath.Join(configDir, "homarr-dev") + path := filepath.Join(dir, "secret-encryption-key") + if data, err := os.ReadFile(path); err == nil { + return validateDevelopmentEncryptionKey(strings.TrimSpace(string(data))) + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("read development encryption key: %w", err) + } + + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("create development config directory: %w", err) + } + keyBytes := make([]byte, 32) + if _, err := rand.Read(keyBytes); err != nil { + return "", fmt.Errorf("generate development encryption key: %w", err) + } + key := hex.EncodeToString(keyBytes) + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if os.IsExist(err) { + data, readErr := os.ReadFile(path) + if readErr != nil { + return "", fmt.Errorf("read development encryption key: %w", readErr) + } + return validateDevelopmentEncryptionKey(strings.TrimSpace(string(data))) + } + if err != nil { + return "", fmt.Errorf("create development encryption key: %w", err) + } + if _, err := file.WriteString(key + "\n"); err != nil { + _ = file.Close() + _ = os.Remove(path) + return "", fmt.Errorf("write development encryption key: %w", err) + } + if err := file.Close(); err != nil { + _ = os.Remove(path) + return "", fmt.Errorf("close development encryption key: %w", err) + } + return key, nil +} + +func validateDevelopmentEncryptionKey(key string) (string, error) { + decoded, err := hex.DecodeString(key) + if err != nil || len(decoded) != 32 { + return "", fmt.Errorf("development encryption key must contain exactly 64 hexadecimal characters") + } + return key, nil +} + type tailBuffer struct { data []byte } diff --git a/tools/homarr-dev/internal/docker/docker_test.go b/tools/homarr-dev/internal/docker/docker_test.go index de9cb41d1..6d0ca844c 100644 --- a/tools/homarr-dev/internal/docker/docker_test.go +++ b/tools/homarr-dev/internal/docker/docker_test.go @@ -1,7 +1,11 @@ package docker import ( + "encoding/hex" "errors" + "os" + "path/filepath" + "strings" "testing" ) @@ -40,3 +44,55 @@ func TestFindFreePortRejectsOutOfRangeStart(t *testing.T) { t.Fatalf("port = %d, want 0", port) } } + +func TestDevelopmentEncryptionKeyUsesEnvironmentOverride(t *testing.T) { + want := strings.Repeat("a", 64) + t.Setenv(developmentEncryptionKeyEnv, want) + got, err := developmentEncryptionKey() + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("key = %q, want environment value", got) + } +} + +func TestPersistedDevelopmentEncryptionKeyIsReused(t *testing.T) { + configDir := t.TempDir() + first, err := persistedDevelopmentEncryptionKey(configDir) + if err != nil { + t.Fatal(err) + } + second, err := persistedDevelopmentEncryptionKey(configDir) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Fatal("persisted key changed between reads") + } + decoded, err := hex.DecodeString(first) + if err != nil || len(decoded) != 32 { + t.Fatalf("generated key is not 32 bytes: %q", first) + } + info, err := os.Stat(filepath.Join(configDir, "homarr-dev", "secret-encryption-key")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("key permissions = %o, want 600", info.Mode().Perm()) + } +} + +func TestPersistedDevelopmentEncryptionKeyRejectsInvalidValue(t *testing.T) { + configDir := t.TempDir() + dir := filepath.Join(configDir, "homarr-dev") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "secret-encryption-key"), []byte("invalid\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := persistedDevelopmentEncryptionKey(configDir); err == nil { + t.Fatal("expected invalid persisted key to fail") + } +}