diff --git a/README.md b/README.md index 65222b2..39be971 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,9 @@ gpu-turnstile.exe --remove-service ``` Layout: `C:\Program Files\gpu-turnstile\` holds the exe and -`gpu-turnstile.env`, logs go to `C:\ProgramData\gpu-turnstile\` (set -`LOG_FILE` in the env file — there is no console). The service always runs +`gpu-turnstile.env`, logs go to `C:\ProgramData\gpu-turnstile\` — the +installer sets `LOG_FILE` in the env file by default (there is no console; +your own `LOG_FILE` setting is kept). The service always runs as the virtual account `NT SERVICE\gpu-turnstile` (low-privilege, per-service, no password); the installer automatically grants it write access to the install and data directories — nothing else to do. diff --git a/SPEC.md b/SPEC.md index c57f8e4..442cda2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -186,8 +186,13 @@ By default install creates the canonical layout and copies the binary into it (Windows: `%ProgramFiles%\gpu-turnstile\`, plus `%ProgramData%\gpu-turnstile\` for logs; Linux: `/var/lib/gpu-turnstile/` with the config at `/etc/gpu-turnstile.env`). An existing config in the -target location is never overwritten. `--no-copy` registers the current -executable location as-is instead. +target location is never overwritten. On Windows the install also makes +sure the env file sets `LOG_FILE` to +`%ProgramData%\gpu-turnstile\gpu-turnstile.log` (appended only when no +`LOG_FILE=` line exists) since a service has no console; on Linux logs go +to the journal via stderr, so no default is set there. `--no-copy` +registers the current executable location as-is and leaves the config +untouched. ### Windows @@ -195,8 +200,9 @@ executable location as-is instead. `%ProgramData%\gpu-turnstile\`, copies the exe and (if none exists there yet) the `gpu-turnstile.env` into the Program Files directory, and registers that copy as a Windows service; recovery actions restart it - 5 s after any failure. Logs go to the ProgramData directory via - `LOG_FILE` since there is no console. + 5 s after any failure. The install ensures the env file sets `LOG_FILE` + to `%ProgramData%\gpu-turnstile\gpu-turnstile.log` since there is no + console — an existing `LOG_FILE` setting is kept. - **Account**: the service always runs as the virtual account `NT SERVICE\gpu-turnstile` — a per-service low-privilege identity the SCM manages (no password, automatic logon-as-a-service right, no admin diff --git a/internal/service/service_windows.go b/internal/service/service_windows.go index 6da86b9..c8dea52 100644 --- a/internal/service/service_windows.go +++ b/internal/service/service_windows.go @@ -152,25 +152,33 @@ func Install(configPath string, copyBin bool) error { } } - installDir, _ := installDirs() - if copyBin && !strings.EqualFold(filepath.Dir(exe), installDir) { - if err := os.MkdirAll(installDir, 0o755); err != nil { - return fmt.Errorf("create %s: %w", installDir, err) - } - installedExe := filepath.Join(installDir, "gpu-turnstile.exe") - if same, _ := sameFileContent(exe, installedExe); !same { - if err := copyFile(exe, installedExe); err != nil { - return fmt.Errorf("copy binary to %s: %w", installedExe, err) - } - } - exe = installedExe + installDir, dataDir := installDirs() + if copyBin { targetCfg := filepath.Join(installDir, "gpu-turnstile.env") - if configPath != "" && !strings.EqualFold(configPath, targetCfg) { - if _, statErr := os.Stat(targetCfg); os.IsNotExist(statErr) { - copyFile(configPath, targetCfg) //nolint:errcheck // best effort + if !strings.EqualFold(filepath.Dir(exe), installDir) { + if err := os.MkdirAll(installDir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", installDir, err) + } + installedExe := filepath.Join(installDir, "gpu-turnstile.exe") + if same, _ := sameFileContent(exe, installedExe); !same { + if err := copyFile(exe, installedExe); err != nil { + return fmt.Errorf("copy binary to %s: %w", installedExe, err) + } + } + exe = installedExe + if configPath != "" && !strings.EqualFold(configPath, targetCfg) { + if _, statErr := os.Stat(targetCfg); os.IsNotExist(statErr) { + copyFile(configPath, targetCfg) //nolint:errcheck // best effort + } } - configPath = targetCfg } + // A service has no console: without LOG_FILE the output vanishes, so + // the installed env file defaults to logging into ProgramData. An + // existing LOG_FILE setting is left alone. + if err := ensureLogFile(targetCfg, filepath.Join(dataDir, "gpu-turnstile.log")); err != nil { + return err + } + configPath = targetCfg } binPath := fmt.Sprintf(`"%s" -config "%s"`, exe, configPath) @@ -304,6 +312,30 @@ func sameFileContent(a, b string) (bool, error) { return bytes.Equal(ba, bb), nil } +// ensureLogFile makes sure the env file at path sets LOG_FILE, creating +// the file or appending the line as needed. An existing LOG_FILE= line +// (even an empty one) is respected and left untouched. +func ensureLogFile(path, logPath string) error { + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("read %s: %w", path, err) + } + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "LOG_FILE=") { + return nil + } + } + s := string(data) + if s != "" && !strings.HasSuffix(s, "\n") { + s += "\n" + } + s += "LOG_FILE=" + logPath + "\n" + if err := os.WriteFile(path, []byte(s), 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} + // copyFile copies src to dst (0755 on the new file). func copyFile(src, dst string) error { in, err := os.Open(src)