ui: elevate Windows debug-bundle reveal to bypass SystemTemp ACL

The daemon runs as SYSTEM and writes the bundle into C:\Windows\SystemTemp,
whose ACL denies the logged-in user, so a plain 'explorer /select' could not
open it. The Windows reveal now elevates via ShellExecuteW with the runas verb,
falling back to an unelevated reveal of the parent dir on decline.
This commit is contained in:
Zoltán Papp
2026-06-16 15:34:09 +02:00
parent 44709983f7
commit 1f69cd189e
4 changed files with 76 additions and 14 deletions

View File

@@ -37,7 +37,7 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr
| `Peers` | `peers.go` | Daemon status snapshot + two long-running streams (`SubscribeStatus``EventStatus`, `SubscribeEvents``EventSystem`). Emits synthetic `StatusDaemonUnavailable` when the socket is unreachable. Owns the profile-switch suppression filter (`BeginProfileSwitch` / `CancelProfileSwitch` / `shouldSuppress`). Fan-outs update metadata into dedicated `EventUpdateAvailable` / `EventUpdateProgress` events. |
| `Networks` | `network.go` | `List` / `Select` / `Deselect` of routed networks. |
| `Forwarding` | `forwarding.go` | `List` exposed/forwarded services from the daemon's reverse-proxy table. |
| `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RegisterUILog` (report the GUI log path to the daemon for bundle collection) / `RevealFile` (cross-platform "show in file manager"). |
| `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RegisterUILog` (report the GUI log path to the daemon for bundle collection) / `RevealFile` (cross-platform "show in file manager"; the platform body lives in `debug_reveal_{windows,other}.go`). **Windows:** the bundle is written by the daemon (SYSTEM) into `C:\Windows\SystemTemp`, whose ACL denies the logged-in user — a plain `explorer /select` can't traverse it. So the Windows reveal elevates via `shell32!ShellExecuteW` with the `runas` verb (UAC prompt); on decline/failure (`HINSTANCE <= 32`) it falls back to an unelevated `explorer <parentDir>`. |
| `Update` | `update.go` | `GetState` / `Trigger` (enforced installer) / `GetInstallerResult` / `Quit`. The install-progress UI lives in its own auxiliary window (`/#/dialog/install-progress`), opened by `WindowManager.OpenInstallProgress` — the daemon goes unreachable mid-install so it can't be inside the main window. |
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpiration(seconds)` / `CloseSessionExpiration` / `OpenInstallProgress(version)` / `CloseInstallProgress` / `OpenWelcome` / `CloseWelcome` / `OpenError(title, message)` / `CloseError` / `OpenMain`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). `OpenMain` is the handoff path from the welcome window to the main UI (avoids depending on the tray). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
| `I18n` | `i18n.go` | Thin facade over `i18n.Bundle`. `Languages()` returns the shipped locales (`_index.json`); `Bundle(code)` returns the full key→text map for one language so the React layer can drive its own translation library. |

View File

@@ -5,9 +5,6 @@ package services
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
@@ -85,16 +82,7 @@ func (s *Debug) RevealFile(_ context.Context, path string) error {
if path == "" {
return fmt.Errorf("empty path")
}
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", "-R", path)
case "windows":
cmd = exec.Command("explorer", "/select,"+path)
default:
cmd = exec.Command("xdg-open", filepath.Dir(path))
}
return cmd.Start()
return revealFile(path)
}
// RegisterUILog reports the GUI log path to the daemon for bundle collection;

View File

@@ -0,0 +1,20 @@
//go:build !android && !ios && !freebsd && !js && !windows
package services
import (
"os/exec"
"path/filepath"
"runtime"
)
// revealFile opens the OS file manager focused on path.
func revealFile(path string) error {
var cmd *exec.Cmd
if runtime.GOOS == "darwin" {
cmd = exec.Command("open", "-R", path)
} else {
cmd = exec.Command("xdg-open", filepath.Dir(path))
}
return cmd.Start()
}

View File

@@ -0,0 +1,54 @@
package services
import (
"fmt"
"os/exec"
"path/filepath"
"unsafe"
"golang.org/x/sys/windows"
)
// SW_SHOWNORMAL for ShellExecuteW's nShowCmd.
const swShowNormal = 1
var (
shell32 = windows.NewLazySystemDLL("shell32.dll")
procShellExecute = shell32.NewProc("ShellExecuteW")
)
// revealFile opens Explorer focused on path. The debug bundle is written by the
// daemon (running as SYSTEM) into C:\Windows\SystemTemp, whose ACL denies the
// logged-in user. A plain "explorer /select" can't traverse it, so we elevate
// via the ShellExecuteW "runas" verb (UAC prompt) — the elevated Explorer can
// read the folder and highlight the file.
func revealFile(path string) error {
verb, err := windows.UTF16PtrFromString("runas")
if err != nil {
return fmt.Errorf("encode verb: %w", err)
}
file, err := windows.UTF16PtrFromString("explorer.exe")
if err != nil {
return fmt.Errorf("encode file: %w", err)
}
params, err := windows.UTF16PtrFromString("/select," + path)
if err != nil {
return fmt.Errorf("encode params: %w", err)
}
// ShellExecuteW returns an HINSTANCE; a value <=32 is an error code.
ret, _, _ := procShellExecute.Call(
0,
uintptr(unsafe.Pointer(verb)),
uintptr(unsafe.Pointer(file)),
uintptr(unsafe.Pointer(params)),
0,
swShowNormal,
)
if ret <= 32 {
// Elevation declined or failed: fall back to an unelevated reveal of the
// parent directory so the user at least lands near the bundle.
return exec.Command("explorer", filepath.Dir(path)).Start() //nolint:gosec
}
return nil
}