diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e4e290d..0fdf6f0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -152,8 +152,8 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: { go-version-file: helper/go.mod, cache-dependency-path: helper/go.sum } - - name: Unit tests for Windows-only code (DPAPI sealing, NanoKVM client) - run: go test -count=1 ./internal/secret/ ./internal/nanokvm/... + - name: Unit tests for Windows-only code (DPAPI sealing, NanoKVM client, window access, PowerShell) + run: go test -count=1 -v -run . ./internal/secret/ ./internal/nanokvm/... ./internal/winsvc/ ./internal/setup/ - name: Build PowerDeck.exe run: | go install github.com/tc-hib/go-winres@v0.3.3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 99b40ca..bc08a96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2.2.1 + +- **PC window over Remote Desktop.** An administrator of the PC can now open the PowerDeck window over Remote Desktop without running it as administrator. Standard accounts in another session are still refused, and the message now says what to do. +- **Wake-on-LAN on Windows.** The check could report Wake-on-LAN as off while it was on, and **Fix** failed with a block of XML ("the driver reports magic-packet wake as &1 | Out-Null +# Best effort, and Windows PowerShell turns a native program's stderr into an error under 'Stop'. +try { powercfg.exe /deviceenablewake "$($nic.InterfaceDescription)" 2>&1 | Out-Null } catch {} $pm = Get-NetAdapterPowerManagement -Name $nic.Name Write-Output ("{0}|{1}" -f $pm.WakeOnMagicPacket, $nic.InterfaceDescription) ` // powershell runs a script block with single-quoted arguments via -EncodedCommand, which sidesteps -// Windows command-line quoting entirely. +// Windows command-line quoting entirely. It returns only what the script writes as output: started +// this way, PowerShell writes its other streams (progress such as "Preparing modules for first use", +// warnings, errors) to stderr as CLIXML, which mustn't end up in a check's reading or an error message. +// So progress is off, any error stops the script, and its message comes back as the error. func powershell(script string, args ...string) (string, error) { - cmd := "& {" + script + "}" + cmd := "$global:ProgressPreference = 'SilentlyContinue'; $ErrorActionPreference = 'Stop'\ntry { & {" + script + "}" for _, a := range args { cmd += " '" + strings.ReplaceAll(a, "'", "''") + "'" } + cmd += " } catch { Write-Output $_.Exception.Message; exit 1 }" + var stdout bytes.Buffer + c := hidden(powershellExe(), "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encodeCommand(cmd)) + c.Stdout = &stdout + err := c.Run() + out := strings.TrimSpace(stdout.String()) + if err != nil { + if msg := lastLine(out); msg != "" { + return "", errors.New(msg) + } + return "", err + } + return out, nil +} + +func powershellExe() string { + return filepath.Join(os.Getenv("SystemRoot"), `System32\WindowsPowerShell\v1.0\powershell.exe`) +} + +// encodeCommand: -EncodedCommand takes base64 of the UTF-16LE script. +func encodeCommand(cmd string) string { u := windows.StringToUTF16(cmd) b := make([]byte, 0, len(u)*2) for _, c := range u[:len(u)-1] { // drop the trailing NUL b = append(b, byte(c), byte(c>>8)) } - enc := base64.StdEncoding.EncodeToString(b) - out, err := hidden(filepath.Join(os.Getenv("SystemRoot"), `System32\WindowsPowerShell\v1.0\powershell.exe`), - "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", enc).CombinedOutput() - return strings.TrimSpace(string(out)), err + return base64.StdEncoding.EncodeToString(b) } func enableWake(mac string) (string, error) { dashed := strings.ToUpper(strings.ReplaceAll(mac, ":", "-")) out, err := powershell(wakeScript, dashed) if err != nil { - return "", fmt.Errorf("%v: %s", err, lastLine(out)) + return "", err } state, desc, _ := strings.Cut(lastLine(out), "|") if !strings.EqualFold(state, "Enabled") { @@ -466,7 +488,10 @@ func Checks(e Env) []check.Check { return check.Result{Level: check.Info, Title: "Wake-on-LAN", Detail: "Needs a wired network card."} } out, err := powershell(`(Get-NetAdapterPowerManagement -Name $args[0]).WakeOnMagicPacket`, n.Interface) - if err == nil && strings.EqualFold(lastLine(out), "Enabled") { + if err != nil { + return check.Result{Level: check.Warn, Title: "Couldn't read Wake-on-LAN on " + n.Interface, Detail: err.Error()} + } + if strings.EqualFold(lastLine(out), "Enabled") { return check.Result{Level: check.OK, Title: "Wake-on-LAN on " + n.Interface, Detail: n.MAC} } return check.Result{Level: check.Warn, Title: "Wake-on-LAN is off on " + n.Interface} diff --git a/helper/internal/setup/setup_windows_test.go b/helper/internal/setup/setup_windows_test.go new file mode 100644 index 0000000..53d26dc --- /dev/null +++ b/helper/internal/setup/setup_windows_test.go @@ -0,0 +1,39 @@ +//go:build windows + +package setup + +import ( + "strings" + "testing" +) + +// Started with -EncodedCommand, PowerShell writes progress ("Preparing modules for first use"), +// warnings and errors to stderr as CLIXML. A check read that as the adapter's Wake-on-LAN state. +func TestPowerShellReturnsOnlyOutput(t *testing.T) { + // Show that this PowerShell really does write CLIXML here, so the test below means something. + raw, _ := hidden(powershellExe(), "-NoProfile", "-NonInteractive", "-EncodedCommand", + encodeCommand(`Write-Progress -Activity 'Preparing modules for first use.'; Write-Warning 'careful'; 'Enabled'`)).CombinedOutput() + t.Logf("PowerShell's own combined output: %q", raw) + + out, err := powershell(`Write-Progress -Activity 'Preparing modules for first use.'; Write-Warning 'careful'; Write-Output ('Enabled|' + $args[0])`, "Owner's NIC") + if err != nil || out != "Enabled|Owner's NIC" { + t.Fatalf("got %q, %v; want only the output", out, err) + } +} + +func TestPowerShellErrorsAreReadable(t *testing.T) { + for _, script := range []string{ + `throw 'No adapter with MAC 02-00-5E-00-00-01'`, + `Write-Error 'No adapter with MAC 02-00-5E-00-00-01'`, // not terminating unless stopped + `Write-Progress -Activity 'Preparing modules for first use.'; Get-NetAdapterPowerManagement -Name 'No adapter with MAC 02-00-5E-00-00-01'`, + } { + out, err := powershell(script) + if err == nil { + t.Errorf("%s: no error (output %q)", script, out) + continue + } + if msg := err.Error(); strings.Contains(msg, "CLIXML") || strings.Contains(msg, "