diff --git a/config/config.go b/config/config.go index 74f238dfd..fa207a1dc 100644 --- a/config/config.go +++ b/config/config.go @@ -6,6 +6,7 @@ import ( "net" "os" "path/filepath" + "runtime" "time" "github.com/ilyakaznacheev/cleanenv" @@ -19,6 +20,18 @@ var TrayMode bool const defaultHost = "localhost" +const ( + // configFilePerm restricts the config file to its owner — it can hold + // secrets (auth.adminPassword, auth.jwtKey), so it must not be world-readable. + configFilePerm = 0o600 + // configDirPerm restricts the config directory to its owner, matching the + // confidentiality of the file it contains. + configDirPerm = 0o700 + // goosWindows is runtime.GOOS's value on Windows, named to avoid repeating + // the bare string literal across path resolution and tests. + goosWindows = "windows" +) + type ( // Config -. Config struct { @@ -215,6 +228,47 @@ func resolveConfigPath(configPathFlag string) (string, error) { return configPathFlag, nil } + // The tray runs unelevated as the logged-in user, and its config holds + // credentials (auth.adminPassword, auth.jwtKey). Keep it in the per-user + // config dir — the same base the SQLite DB uses — instead of world-readable + // beside the system-wide binary (Program Files / /usr/local / /opt). + if TrayMode { + if perUser, err := perUserConfigPath(); err == nil { + return perUser, nil + } + // Fall through to the headless resolution below if the per-user dir is unavailable. + } + + // Headless uses the installer-provisioned machine-wide config when one is + // present. Without an installer (a plain `go run`, a dev build, or the test + // binary) that path is an unwritable system dir (/etc, /Library, ProgramData), + // so fall back to the writable beside-binary path — keeping zero-infra startup. + machine, err := machineConfigPath() + if err != nil { + return "", err + } + + if _, statErr := os.Stat(machine); statErr == nil { + return machine, nil + } + + return besideBinaryConfigPath() +} + +// perUserConfigPath returns /device-management-toolkit/config/config.yml. +func perUserConfigPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + + return filepath.Join(dir, "device-management-toolkit", "config", "config.yml"), nil +} + +// besideBinaryConfigPath returns the config path next to the executable. It is +// the writable fallback for headless runs with no installer-provisioned machine +// config (dev builds, `go run`, tests) and for an unrecognized GOOS. +func besideBinaryConfigPath() (string, error) { ex, err := os.Executable() if err != nil { return "", err @@ -227,9 +281,57 @@ func resolveConfigPath(configPathFlag string) (string, error) { ex = resolved } - exPath := filepath.Dir(ex) + return filepath.Join(filepath.Dir(ex), "config", "config.yml"), nil +} - return filepath.Join(exPath, "config", "config.yml"), nil +// machineConfigPath returns the machine-wide, installer-provisioned config path, +// kept out of the program directory and readable by the unelevated user: +// - Windows: %ProgramData%\device-management-toolkit\config.yml +// - macOS: /Library/Application Support/device-management-toolkit/config.yml +// - Linux: /etc/dmt-console/config/config.yml +// +// Anything else falls back to the beside-binary path. +func machineConfigPath() (string, error) { + switch runtime.GOOS { + case goosWindows: + if dir := os.Getenv("ProgramData"); dir != "" { + return filepath.Join(dir, "device-management-toolkit", "config.yml"), nil + } + case "darwin": + return "/Library/Application Support/device-management-toolkit/config.yml", nil + case "linux": + return "/etc/dmt-console/config/config.yml", nil + } + + return besideBinaryConfigPath() +} + +// seedConfig copies an installer-provisioned config from src to dst when dst does +// not yet exist, carrying the installer's credentials (admin password, jwtKey) into +// the per-user location instead of generating a fresh config. A missing src or an +// already-present dst are both no-ops. On a shared machine each OS user seeds from +// the same src, inheriting one credential set until rotated — intentional. +func seedConfig(src, dst string) error { + _, err := os.Stat(dst) + if err == nil { + return nil // dst already present — never overwrite an existing per-user config + } + + if !os.IsNotExist(err) { + return err // unexpected stat error (permissions/IO) — surface it rather than silently re-seeding + } + + data, err := os.ReadFile(src) + if err != nil { + return nil //nolint:nilerr // no installer config to migrate (e.g. dev run); init proceeds normally + } + + if mkErr := os.MkdirAll(filepath.Dir(dst), configDirPerm); mkErr != nil { + return mkErr + } + + // #nosec G703 -- dst is derived from os.UserConfigDir()/os.Executable() (see resolveConfigPath), not external input. + return os.WriteFile(dst, data, configFilePerm) } // readOrInitConfig attempts to read the config file; if it doesn't exist, writes the provided cfg to disk. @@ -248,18 +350,28 @@ func readOrInitConfig(configPath string, cfg *Config) error { } // writeConfig serializes cfg to configPath, creating the parent directory if needed. +// The file and directory are owner-only (configFilePerm/configDirPerm): a generated +// config carries secrets (a freshly generated jwtKey/adminPassword) just like a seeded +// one, so the freshly-generated path must not write them world-readable. func writeConfig(configPath string, cfg *Config) error { configDir := filepath.Dir(configPath) - if mkErr := os.MkdirAll(configDir, os.ModePerm); mkErr != nil { + if mkErr := os.MkdirAll(configDir, configDirPerm); mkErr != nil { return mkErr } - file, err := os.Create(configPath) + file, err := os.OpenFile(configPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, configFilePerm) if err != nil { return err } defer file.Close() + // O_CREATE only applies configFilePerm when the file is newly created; an + // existing config left world-readable by an older build keeps its old mode. + // Chmod explicitly so upgrades are tightened to owner-only too. + if err := file.Chmod(configFilePerm); err != nil { + return err + } + encoder := yaml.NewEncoder(file) defer encoder.Close() @@ -321,6 +433,16 @@ func NewConfig() (*Config, error) { return nil, err } + // First tray launch: seed the per-user config from the machine-wide installer + // config so its credentials carry over instead of being regenerated. + if TrayMode && configPathFlag == "" { + if src, srcErr := machineConfigPath(); srcErr == nil { + if seedErr := seedConfig(src, configPath); seedErr != nil { + return nil, seedErr + } + } + } + if err := readOrInitConfig(configPath, ConsoleConfig); err != nil { return nil, err } diff --git a/config/config_test.go b/config/config_test.go index 24e4360e7..d2e4984ef 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -2,6 +2,8 @@ package config import ( "os" + "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -20,11 +22,15 @@ func TestNewConfig_Defaults(t *testing.T) { //nolint:paralleltest // cannot have cfg, err := NewConfig() - cfg.EncryptionKey = "test" // Added to pass the test - assert.NoError(t, err) assert.NotNil(t, cfg) + if cfg == nil { + t.Fatal("NewConfig returned nil config") + } + + cfg.EncryptionKey = "test" // Added to pass the test + // Verify default values assert.Equal(t, "console", cfg.Name) assert.Equal(t, "device-management-toolkit/console", cfg.Repo) @@ -66,6 +72,239 @@ func TestNewConfig_EnvVars(t *testing.T) { //nolint:paralleltest // cannot have assert.Equal(t, false, cfg.TLS.Enabled) } +func TestResolveConfigPath_FlagWins(t *testing.T) { //nolint:paralleltest // mutates package-global TrayMode + orig := TrayMode + TrayMode = true + + defer func() { TrayMode = orig }() + + got, err := resolveConfigPath("/custom/path.yml") + assert.NoError(t, err) + assert.Equal(t, "/custom/path.yml", got) +} + +func TestResolveConfigPath_TrayUsesPerUserDir(t *testing.T) { //nolint:paralleltest // mutates package-global TrayMode + orig := TrayMode + TrayMode = true + + defer func() { TrayMode = orig }() + + got, err := resolveConfigPath("") + assert.NoError(t, err) + + base, err := os.UserConfigDir() + assert.NoError(t, err) + assert.Equal(t, filepath.Join(base, "device-management-toolkit", "config", "config.yml"), got) +} + +func TestResolveConfigPath_NonTrayPrefersMachineThenBesideBinary(t *testing.T) { //nolint:paralleltest // mutates package-global TrayMode + orig := TrayMode + TrayMode = false + + defer func() { TrayMode = orig }() + + got, err := resolveConfigPath("") + assert.NoError(t, err) + + // With an installer-provisioned machine config present, that path wins; + // without one (the usual dev/test case) it falls back to beside-binary. + machine, err := machineConfigPath() + assert.NoError(t, err) + + if _, statErr := os.Stat(machine); statErr == nil { + assert.Equal(t, machine, got) + } else { + want, beErr := besideBinaryConfigPath() + assert.NoError(t, beErr) + assert.Equal(t, want, got) + } +} + +func TestMachineConfigPath_PerGOOS(t *testing.T) { //nolint:paralleltest // reads ProgramData env on Windows + got, err := machineConfigPath() + assert.NoError(t, err) + + switch runtime.GOOS { + case goosWindows: + if pd := os.Getenv("ProgramData"); pd != "" { + assert.Equal(t, filepath.Join(pd, "device-management-toolkit", "config.yml"), got) + } + case "darwin": + assert.Equal(t, "/Library/Application Support/device-management-toolkit/config.yml", got) + case "linux": + assert.Equal(t, "/etc/dmt-console/config/config.yml", got) + default: + want, beErr := besideBinaryConfigPath() + assert.NoError(t, beErr) + assert.Equal(t, want, got) + } +} + +func TestMachineConfigPath_WindowsProgramDataUnsetFallsBack(t *testing.T) { //nolint:paralleltest // mutates ProgramData env + if runtime.GOOS != goosWindows { + t.Skip("ProgramData fallback only applies on Windows") + } + + if orig, had := os.LookupEnv("ProgramData"); had { + os.Unsetenv("ProgramData") + + defer os.Setenv("ProgramData", orig) + } + + got, err := machineConfigPath() + assert.NoError(t, err) + + want, err := besideBinaryConfigPath() + assert.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestResolveConfigPath_TrayFallsBackToHeadlessResolution(t *testing.T) { //nolint:paralleltest // mutates TrayMode and user-config env + orig := TrayMode + TrayMode = true + + defer func() { TrayMode = orig }() + + // Force os.UserConfigDir to fail so perUserConfigPath errors and we fall through. + saved := map[string]string{} + + for _, key := range []string{"XDG_CONFIG_HOME", "HOME", "AppData"} { + if v, ok := os.LookupEnv(key); ok { + saved[key] = v + + os.Unsetenv(key) + } + } + + defer func() { + for key, v := range saved { + os.Setenv(key, v) + } + }() + + if _, err := os.UserConfigDir(); err == nil { + t.Skip("os.UserConfigDir still resolves; cannot exercise the fallback on this platform") + } + + got, err := resolveConfigPath("") + assert.NoError(t, err) + + // Fallthrough lands in the headless resolution: machine config when present, + // otherwise the writable beside-binary path. + machine, err := machineConfigPath() + assert.NoError(t, err) + + if _, statErr := os.Stat(machine); statErr == nil { + assert.Equal(t, machine, got) + } else { + want, beErr := besideBinaryConfigPath() + assert.NoError(t, beErr) + assert.Equal(t, want, got) + } +} + +func TestSeedConfig_StatErrorIsSurfaced(t *testing.T) { + t.Parallel() + + if runtime.GOOS == goosWindows { + t.Skip("stat error semantics for a non-directory parent differ on Windows") + } + + dir := t.TempDir() + + // A regular file as dst's parent makes os.Stat(dst) fail with ENOTDIR — not IsNotExist. + notDir := filepath.Join(dir, "file") + assert.NoError(t, os.WriteFile(notDir, []byte("x"), 0o600)) + dst := filepath.Join(notDir, "config.yml") + + src := filepath.Join(dir, "src.yml") + assert.NoError(t, os.WriteFile(src, []byte("app:\n name: seeded\n"), 0o600)) + + err := seedConfig(src, dst) + assert.Error(t, err) + assert.False(t, os.IsNotExist(err)) +} + +func TestSeedConfig(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + src := filepath.Join(dir, "install", "config.yml") + dst := filepath.Join(dir, "user", "config.yml") + + assert.NoError(t, os.MkdirAll(filepath.Dir(src), 0o755)) + assert.NoError(t, os.WriteFile(src, []byte("app:\n name: seeded\n"), 0o600)) + + // Copies installer config when the per-user file is missing. + assert.NoError(t, seedConfig(src, dst)) + data, err := os.ReadFile(dst) + assert.NoError(t, err) + assert.Contains(t, string(data), "seeded") + + // The seeded file holds credentials — it must be owner-only, not world-readable. + if runtime.GOOS != goosWindows { + info, statErr := os.Stat(dst) + assert.NoError(t, statErr) + assert.Equal(t, os.FileMode(configFilePerm), info.Mode().Perm()) + } + + // Does not overwrite an existing per-user file. + assert.NoError(t, os.WriteFile(dst, []byte("app:\n name: existing\n"), 0o600)) + assert.NoError(t, seedConfig(src, dst)) + data, err = os.ReadFile(dst) + assert.NoError(t, err) + assert.Contains(t, string(data), "existing") + + // Missing src is a no-op, not an error. + assert.NoError(t, seedConfig(filepath.Join(dir, "nope.yml"), filepath.Join(dir, "out.yml"))) + _, statErr := os.Stat(filepath.Join(dir, "out.yml")) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestWriteConfig_OwnerOnlyPermissions(t *testing.T) { + t.Parallel() + + if runtime.GOOS == goosWindows { + t.Skip("POSIX permission bits are not meaningful on Windows") + } + + dir := t.TempDir() + configPath := filepath.Join(dir, "device-management-toolkit", "config.yml") + + // A freshly generated config carries secrets (generated jwtKey/adminPassword), + // so the generate path must write owner-only just like the seed path. + assert.NoError(t, writeConfig(configPath, defaultConfig())) + + fileInfo, err := os.Stat(configPath) + assert.NoError(t, err) + assert.Equal(t, os.FileMode(configFilePerm), fileInfo.Mode().Perm()) + + dirInfo, err := os.Stat(filepath.Dir(configPath)) + assert.NoError(t, err) + assert.Equal(t, os.FileMode(configDirPerm), dirInfo.Mode().Perm()) +} + +func TestWriteConfig_TightensExistingLooseFile(t *testing.T) { + t.Parallel() + + if runtime.GOOS == goosWindows { + t.Skip("POSIX permission bits are not meaningful on Windows") + } + + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yml") + + // Simulate a config left world-readable by an older build. O_CREATE alone + // would not retighten it, so writeConfig must chmod the existing file. + assert.NoError(t, os.WriteFile(configPath, []byte("app:\n name: old\n"), 0o644)) + + assert.NoError(t, writeConfig(configPath, defaultConfig())) + + fileInfo, err := os.Stat(configPath) + assert.NoError(t, err) + assert.Equal(t, os.FileMode(configFilePerm), fileInfo.Mode().Perm()) +} + func TestNewConfig_FileAndEnvVars(t *testing.T) { //nolint:paralleltest // cannot have simultaneous tests modifying environment variables clearEnv() // Clear environment variables before setting new ones