Install opens up COMFY_DIR for the sandboxed service: ACL grant on Windows, BindPaths on Linux

This commit is contained in:
mram
2026-09-21 21:30:35 +02:00
parent be0bb36317
commit 9589e58ce6
6 changed files with 81 additions and 25 deletions
+9
View File
@@ -134,6 +134,15 @@ answers on the port, gpu-turnstile just uses it instead of spawning
instance already holds the port when you open the desktop app, the instance already holds the port when you open the desktop app, the
desktop's server is the one that fails to bind. desktop's server is the one that fails to bind.
One catch when gpu-turnstile runs as a service: the sandboxed service
account may not enter your user profile, so a ComfyUI install under
`C:\Users\...` (or `/home/...`) fails with "Access is denied".
`--install-service` fixes that automatically — it grants
`NT SERVICE\gpu-turnstile` recursive access to `COMFY_DIR` on Windows and
adds a `BindPaths=` to the systemd unit on Linux. Re-run it after changing
`COMFY_DIR`; or grant by hand from an admin shell:
`icacls "<COMFY_DIR>" /grant "NT SERVICE\gpu-turnstile:(OI)(CI)M" /T`.
## Game detection ## Game detection
Want to game on the same GPU without Ollama/ComfyUI squatting on the VRAM? Want to game on the same GPU without Ollama/ComfyUI squatting on the VRAM?
+8
View File
@@ -159,6 +159,14 @@ files are flagged in the startup log.
- Its stdout/stderr is forwarded to the log at INFO. The health check - Its stdout/stderr is forwarded to the log at INFO. The health check
skips the intentionally-stopped/starting states; a failed probe while skips the intentionally-stopped/starting states; a failed probe while
the process is alive and was previously ready is logged as DOWN. the process is alive and was previously ready is logged as DOWN.
- **Permissions**: the service account is sandboxed (Windows virtual
account, systemd `DynamicUser`), so a ComfyUI install inside a user
profile is off-limits by default. `--install-service` opens it up —
a recursive ACL grant for `NT SERVICE\gpu-turnstile` on Windows, a
`BindPaths=` in the unit on Linux — reading `COMFY_DIR` from the config
it installs. Re-run `--install-service` after changing `COMFY_DIR`, or
grant by hand (admin shell):
`icacls "<COMFY_DIR>" /grant "NT SERVICE\gpu-turnstile:(OI)(CI)M" /T`.
## Game detection (foreign GPU holders) ## Game detection (foreign GPU holders)
+16
View File
@@ -45,3 +45,19 @@ func writeEnvFile(path, content string) error {
} }
return nil return nil
} }
// configuredValue reads one key from the env file the service will load, so
// the installers can adapt the sandbox to it (ACL grants, unit directives).
// "" when unset or unreadable.
func configuredValue(configPath, key string) string {
f, err := os.Open(configPath)
if err != nil {
return ""
}
defer f.Close()
values, err := config.ParseEnvFile(f)
if err != nil {
return ""
}
return values[key]
}
+14 -6
View File
@@ -62,8 +62,14 @@ func Run(run func(ctx context.Context) error) error {
// filesystem is read-only except StateDirectory (the install dir, so // filesystem is read-only except StateDirectory (the install dir, so
// self-updates can rewrite the binary), and the usual no-privilege-escalation // self-updates can rewrite the binary), and the usual no-privilege-escalation
// directives apply. The proxy needs nothing but outbound TCP/UDP and the // directives apply. The proxy needs nothing but outbound TCP/UDP and the
// notify socket, so it loses nothing. // notify socket, so it loses nothing. A managed ComfyUI (comfyDir) gets a
func renderUnit(exePath, configPath string) string { // BindPaths hole through ProtectHome/ProtectSystem: it reads its venv and
// writes output/temp/user data under COMFY_DIR.
func renderUnit(exePath, configPath, comfyDir string) string {
bind := ""
if comfyDir != "" {
bind = "BindPaths=" + comfyDir + "\n"
}
return fmt.Sprintf(`[Unit] return fmt.Sprintf(`[Unit]
Description=gpu-turnstile GPU arbitration proxy for Ollama and ComfyUI Description=gpu-turnstile GPU arbitration proxy for Ollama and ComfyUI
After=network-online.target After=network-online.target
@@ -78,7 +84,7 @@ RestartSec=5s
DynamicUser=yes DynamicUser=yes
StateDirectory=%s StateDirectory=%s
ProtectSystem=strict %sProtectSystem=strict
ProtectHome=yes ProtectHome=yes
PrivateTmp=yes PrivateTmp=yes
NoNewPrivileges=yes NoNewPrivileges=yes
@@ -100,7 +106,7 @@ SystemCallErrorNumber=EPERM
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
`, exePath, configPath, Name) `, exePath, configPath, Name, bind)
} }
// copyFile copies src to dst, creating dst with the given mode. // copyFile copies src to dst, creating dst with the given mode.
@@ -125,7 +131,9 @@ func copyFile(src, dst string, mode os.FileMode) error {
// sure /etc/gpu-turnstile.env exists (copied from the given config file if // sure /etc/gpu-turnstile.env exists (copied from the given config file if
// provided), writes the hardened unit, then enables and starts it. With // provided), writes the hardened unit, then enables and starts it. With
// copyBin=false the current executable location and config path are // copyBin=false the current executable location and config path are
// registered as-is instead. Needs root. // registered as-is instead. When the config sets COMFY_DIR, the unit gets a
// BindPaths= for it so the sandboxed service can reach the managed ComfyUI
// even under /home. Needs root.
// //
// Re-running install converges an existing unit instead of failing: it is // Re-running install converges an existing unit instead of failing: it is
// stopped first if active, the installed binary is replaced only when the // stopped first if active, the installed binary is replaced only when the
@@ -186,7 +194,7 @@ func Install(configPath string, copyBin bool, version string) error {
cfg = abs cfg = abs
} }
} }
rendered := renderUnit(exe, cfg) rendered := renderUnit(exe, cfg, configuredValue(cfg, "COMFY_DIR"))
if old, _ := os.ReadFile(unitPath); string(old) != rendered { if old, _ := os.ReadFile(unitPath); string(old) != rendered {
if err := os.WriteFile(unitPath, []byte(rendered), 0o644); err != nil { if err := os.WriteFile(unitPath, []byte(rendered), 0o644); err != nil {
return fmt.Errorf("write %s (run as root): %w", unitPath, err) return fmt.Errorf("write %s (run as root): %w", unitPath, err)
+9 -1
View File
@@ -8,7 +8,7 @@ import (
) )
func TestRenderUnit(t *testing.T) { func TestRenderUnit(t *testing.T) {
unit := renderUnit("/var/lib/gpu-turnstile/gpu-turnstile", "/etc/gpu-turnstile.env") unit := renderUnit("/var/lib/gpu-turnstile/gpu-turnstile", "/etc/gpu-turnstile.env", "")
for _, want := range []string{ for _, want := range []string{
"Type=notify", "Type=notify",
"WatchdogSec=30s", "WatchdogSec=30s",
@@ -26,4 +26,12 @@ func TestRenderUnit(t *testing.T) {
t.Fatalf("unit missing %q:\n%s", want, unit) t.Fatalf("unit missing %q:\n%s", want, unit)
} }
} }
if strings.Contains(unit, "BindPaths") {
t.Fatalf("unit without COMFY_DIR must not bind anything:\n%s", unit)
}
unit = renderUnit("/var/lib/gpu-turnstile/gpu-turnstile", "/etc/gpu-turnstile.env", "/home/gpu/ComfyUI")
if !strings.Contains(unit, "BindPaths=/home/gpu/ComfyUI\n") {
t.Fatalf("unit with COMFY_DIR must bind it:\n%s", unit)
}
} }
+25 -18
View File
@@ -24,8 +24,6 @@ import (
"golang.org/x/sys/windows" "golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc" "golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr" "golang.org/x/sys/windows/svc/mgr"
"gpu-turnstile/internal/config"
) )
// Name is the Windows service name. // Name is the Windows service name.
@@ -106,10 +104,12 @@ func (h *handler) Execute(_ []string, requests <-chan svc.ChangeRequest, status
// the service after 5s on failure — this is also what brings up a staged // the service after 5s on failure — this is also what brings up a staged
// update after the updater exits with a non-zero code. After registering, // update after the updater exits with a non-zero code. After registering,
// the virtual account is granted modify access to the install and data // the virtual account is granted modify access to the install and data
// directories (self-updates rewrite the exe), and read access to the // directories (self-updates rewrite the exe), read access to the
// config file if it lives elsewhere. The grants must come after // config file if it lives elsewhere, and — when the config sets COMFY_DIR —
// CreateService: the virtual account's SID only exists once the service is // recursive modify access to the managed ComfyUI's install tree, which may
// registered. // live inside a user profile the account otherwise cannot enter. The grants
// must come after CreateService: the virtual account's SID only exists once
// the service is registered.
// //
// Re-running install on an existing service converges instead of failing: // Re-running install on an existing service converges instead of failing:
// the service is stopped first if running (so the binary can be replaced), // the service is stopped first if running (so the binary can be replaced),
@@ -354,7 +354,7 @@ func grantAll(exe, configPath string) error {
return err return err
} }
} }
if logFile := configuredLogFile(configPath); logFile != "" { if logFile := configuredValue(configPath, "LOG_FILE"); logFile != "" {
dir := filepath.Dir(logFile) dir := filepath.Dir(logFile)
if err := os.MkdirAll(dir, 0o755); err == nil { if err := os.MkdirAll(dir, 0o755); err == nil {
if err := grantAccess(dir, "(OI)(CI)(M)"); err != nil { if err := grantAccess(dir, "(OI)(CI)(M)"); err != nil {
@@ -362,6 +362,17 @@ func grantAll(exe, configPath string) error {
} }
} }
} }
// A managed ComfyUI whose install lives somewhere the virtual account
// may not go (a user profile) needs an explicit grant — recursively,
// since ComfyUI also writes output/temp/user data next to its code. A
// missing directory is skipped: the startup warning covers it.
if comfyDir := configuredValue(configPath, "COMFY_DIR"); comfyDir != "" {
if _, err := os.Stat(comfyDir); err == nil {
if err := grantAccessTree(comfyDir, "(OI)(CI)(M)"); err != nil {
return err
}
}
}
return nil return nil
} }
@@ -473,19 +484,15 @@ func grantAccess(path, perms string) error {
return nil return nil
} }
// configuredLogFile reads LOG_FILE from the config file so the installer // grantAccessTree is grantAccess with /T: the ACE is applied to the
// can pre-create and ACL the log directory. "" when unset or unreadable. // existing tree, not just inherited by children created later. Needed when
func configuredLogFile(configPath string) string { // the tree already exists, e.g. a ComfyUI install in a user profile.
f, err := os.Open(configPath) func grantAccessTree(path, perms string) error {
out, err := exec.Command("icacls", path, "/grant", virtualAccount+":"+perms, "/T").CombinedOutput()
if err != nil { if err != nil {
return "" return fmt.Errorf("grant %s access to %s: %w (%s)", virtualAccount, path, err, strings.TrimSpace(string(out)))
} }
defer f.Close() return nil
values, err := config.ParseEnvFile(f)
if err != nil {
return ""
}
return values["LOG_FILE"]
} }
// RestartIfRunning restarts the service when it is installed and running // RestartIfRunning restarts the service when it is installed and running