xetup/internal/prereboot/prereboot_windows.go
X9 Dev 5e01826a49
All checks were successful
release / build-and-release (push) Successful in 24s
feat: state machine for reboot-resume across Windows Update cycles
xetup.exe now acts as an orchestrator across system reboots:
- PS scripts exit 9 to signal "reboot needed, re-run me" (WU) or
  "done but reboot needed to finalize" (Dell BIOS)
- On exit 9: xetup saves state.json, ensures adminx9 account,
  copies itself to stable path, enables autologon, registers
  X9-Resume scheduled task (AtLogOn adminx9, RunLevel Highest)
- On resume: loads pending steps from state, continues seamlessly
  with "Pokracuji po restartu..." label in the run window
- On completion: disables autologon, removes X9-Resume task,
  deletes state file, shows summary with accumulated results
  across all reboot rounds

New packages: internal/state, internal/prereboot
Script 12: simplified to exit 0 (done) or exit 9 (reboot needed)
Script 11: exit 9 when DCU exit code 1 (BIOS staged, reboot needed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:09:53 +02:00

190 lines
5.7 KiB
Go

//go:build windows
// Package prereboot ensures the reboot-resume infrastructure is in place
// before xetup triggers a system restart mid-deployment.
package prereboot
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"golang.org/x/sys/windows/registry"
)
// StablePath is where xetup.exe is copied so it survives across reboots.
const StablePath = `C:\Windows\Setup\Scripts\xetup.exe`
// TaskName is the scheduled task that re-launches xetup after each reboot.
const TaskName = "X9-Resume"
const adminUser = "adminx9"
const winlogonKey = `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon`
// IsAdminx9 reports whether the current process runs as the adminx9 account.
func IsAdminx9() bool {
return strings.EqualFold(os.Getenv("USERNAME"), adminUser)
}
// Prepare ensures the reboot-resume infrastructure is in place.
// When not running as adminx9 (first reboot trigger):
// - creates the adminx9 local account if it does not exist
// - copies the running binary to StablePath
// - enables autologon for adminx9 (AutoLogonCount=20 safety cap)
// - registers the X9-Resume scheduled task (AtLogOn adminx9, RunLevel Highest)
//
// When already running as adminx9 (subsequent rounds) all of the above is
// already in place; the function returns immediately without changes.
// Safe to call repeatedly (idempotent).
func Prepare() error {
if IsAdminx9() {
return nil
}
if err := ensureAdminx9User(); err != nil {
return fmt.Errorf("adminx9 account: %w", err)
}
if err := copySelfTo(StablePath); err != nil {
return fmt.Errorf("copy binary: %w", err)
}
if err := setAutologon(); err != nil {
return fmt.Errorf("autologon: %w", err)
}
if err := registerResumeTask(); err != nil {
return fmt.Errorf("resume task: %w", err)
}
return nil
}
// Cleanup disables autologon and removes the X9-Resume scheduled task.
// Called when all deployment steps have completed successfully.
func Cleanup() {
_ = disableAutologon()
_ = unregisterResumeTask()
}
// ensureAdminx9User creates the adminx9 local account if absent.
func ensureAdminx9User() error {
// "net user adminx9" exits 0 when user exists
if err := newHiddenCmd("net", "user", adminUser).Run(); err == nil {
return nil // already exists
}
// Create with empty password
if b, err := newHiddenCmd("net", "user", adminUser, "", "/add").CombinedOutput(); err != nil {
return fmt.Errorf("net user /add: %s: %w", strings.TrimSpace(string(b)), err)
}
// Add to local Administrators group
if b, err := newHiddenCmd("net", "localgroup", "Administrators", adminUser, "/add").CombinedOutput(); err != nil {
return fmt.Errorf("net localgroup: %s: %w", strings.TrimSpace(string(b)), err)
}
// Hide from the Windows login screen
k, _, err := registry.CreateKey(registry.LOCAL_MACHINE,
`SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList`,
registry.SET_VALUE)
if err != nil {
return fmt.Errorf("registry hide: %w", err)
}
defer k.Close()
return k.SetDWordValue(adminUser, 0)
}
// copySelfTo copies the running executable to dst.
// Skips the copy when src and dst resolve to the same path.
func copySelfTo(dst string) error {
src, err := os.Executable()
if err != nil {
return err
}
if resolved, err := filepath.EvalSymlinks(src); err == nil {
src = resolved
}
if strings.EqualFold(src, dst) {
return nil
}
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
return copyFile(src, dst)
}
func copyFile(src, dst string) error {
r, err := os.Open(src)
if err != nil {
return err
}
defer r.Close()
w, err := os.Create(dst)
if err != nil {
return err
}
defer w.Close()
_, err = io.Copy(w, r)
return err
}
func setAutologon() error {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, winlogonKey, registry.SET_VALUE)
if err != nil {
return err
}
defer k.Close()
for _, kv := range []struct{ name, val string }{
{"AutoAdminLogon", "1"},
{"DefaultUserName", adminUser},
{"DefaultPassword", ""},
{"DefaultDomainName", "."},
} {
if err := k.SetStringValue(kv.name, kv.val); err != nil {
return fmt.Errorf("set %s: %w", kv.name, err)
}
}
// Safety cap: self-limits even if cleanup task fails to run
return k.SetDWordValue("AutoLogonCount", 20)
}
func disableAutologon() error {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, winlogonKey, registry.SET_VALUE)
if err != nil {
return err
}
defer k.Close()
_ = k.SetStringValue("AutoAdminLogon", "0")
_ = k.DeleteValue("DefaultPassword")
_ = k.DeleteValue("AutoLogonCount")
return nil
}
func registerResumeTask() error {
ps := fmt.Sprintf(`
$action = New-ScheduledTaskAction -Execute '%s' -Argument '--resume'
$trigger = New-ScheduledTaskTrigger -AtLogOn -User '%s'
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 4) -MultipleInstances IgnoreNew
$principal = New-ScheduledTaskPrincipal -UserId '%s' -LogonType Interactive -RunLevel Highest
Unregister-ScheduledTask -TaskName 'X9-Resume' -Confirm:$false -ErrorAction SilentlyContinue
Register-ScheduledTask -TaskName 'X9-Resume' -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null
`, StablePath, adminUser, adminUser)
return runPS(ps)
}
func unregisterResumeTask() error {
return runPS(`Unregister-ScheduledTask -TaskName 'X9-Resume' -Confirm:$false -ErrorAction SilentlyContinue`)
}
func runPS(script string) error {
cmd := newHiddenCmd("powershell.exe",
"-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
b, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s: %w", strings.TrimSpace(string(b)), err)
}
return nil
}
func newHiddenCmd(name string, args ...string) *exec.Cmd {
cmd := exec.Command(name, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd
}