208 lines
6.9 KiB
Go
208 lines
6.9 KiB
Go
//go:build windows
|
|
|
|
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"unsafe"
|
|
|
|
"github.com/rvbox/rvbox/internal/client/windowsservice"
|
|
"golang.org/x/sys/windows"
|
|
)
|
|
|
|
const (
|
|
installerInfoIcon = 0x00000040
|
|
installerErrorIcon = 0x00000010
|
|
)
|
|
|
|
var (
|
|
installerUser32 = syscall.NewLazyDLL("user32.dll")
|
|
installerMessageBox = installerUser32.NewProc("MessageBoxW")
|
|
)
|
|
|
|
// runInteractiveInstaller is the double-click entry point. It does no
|
|
// machine-wide mutation itself: ShellExecute's runas verb causes Windows to
|
|
// show the standard UAC consent prompt before the elevated child installs.
|
|
func runInteractiveInstaller(_ io.Writer, _ io.Writer) error {
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
showInstallerMessage("RVBox setup could not locate its executable.", installerErrorIcon)
|
|
return err
|
|
}
|
|
file, err := windows.UTF16PtrFromString(executable)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
arguments, err := windows.UTF16PtrFromString("--install-default")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := windows.ShellExecute(0, installerUTF16("runas"), file, arguments, nil, windows.SW_SHOWNORMAL); err != nil {
|
|
showInstallerMessage("RVBox setup was cancelled or could not obtain administrator approval.\n\nNo changes were made.", installerErrorIcon)
|
|
return fmt.Errorf("request installer elevation: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// installPackagedDefault executes only in the UAC-elevated child. It preserves
|
|
// a pre-existing operator configuration, atomically updates the executable,
|
|
// installs/updates the SCM service, and starts it.
|
|
func installPackagedDefault(_ io.Writer) error {
|
|
err := installPackagedDefaultFiles()
|
|
if err != nil {
|
|
showInstallerMessage("RVBox could not be installed.\n\n"+err.Error()+"\n\nNo existing configuration was replaced.", installerErrorIcon)
|
|
return err
|
|
}
|
|
showInstallerMessage("RVBox is installed and its Windows service is starting.\n\nThe notification-area icon starts automatically at the next sign-in.", installerInfoIcon)
|
|
return nil
|
|
}
|
|
|
|
func installPackagedDefaultFiles() error {
|
|
if strings.TrimSpace(bootstrapServerURL) == "" {
|
|
return errors.New("this RVBox package was built without a bootstrap server URL")
|
|
}
|
|
source, err := os.Executable()
|
|
if err != nil {
|
|
return fmt.Errorf("locate installer executable: %w", err)
|
|
}
|
|
programFiles := os.Getenv("ProgramFiles")
|
|
if programFiles == "" {
|
|
programFiles = `C:\Program Files`
|
|
}
|
|
programData := os.Getenv("ProgramData")
|
|
if programData == "" {
|
|
programData = `C:\ProgramData`
|
|
}
|
|
targetDirectory := filepath.Join(programFiles, "RVBox")
|
|
targetExecutable := filepath.Join(targetDirectory, "rvbox.exe")
|
|
configDirectory := filepath.Join(programData, "RVBox")
|
|
configPath := filepath.Join(configDirectory, "client.toml")
|
|
|
|
// A running service can keep the prior executable open. Stop it before the
|
|
// atomic replacement; a missing service is already a successful first run.
|
|
if err := windowsservice.Stop(30); err != nil {
|
|
return fmt.Errorf("stop existing RVBox service: %w", err)
|
|
}
|
|
if err := os.MkdirAll(targetDirectory, 0o755); err != nil {
|
|
return fmt.Errorf("create installation directory: %w", err)
|
|
}
|
|
if !sameWindowsPath(source, targetExecutable) {
|
|
if err := copyInstallerExecutable(source, targetExecutable); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := os.MkdirAll(configDirectory, 0o700); err != nil {
|
|
return fmt.Errorf("create configuration directory: %w", err)
|
|
}
|
|
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
|
|
hostname, hostnameErr := os.Hostname()
|
|
if hostnameErr != nil {
|
|
hostname = "rvbox-client"
|
|
}
|
|
content, configErr := packagedClientConfig(bootstrapServerURL, hostname)
|
|
if configErr != nil {
|
|
return configErr
|
|
}
|
|
if err := writeMachineConfig(configPath, content); err != nil {
|
|
return err
|
|
}
|
|
} else if err != nil {
|
|
return fmt.Errorf("inspect existing configuration: %w", err)
|
|
}
|
|
if err := windowsservice.Install(windowsservice.InstallSpec{ExecutablePath: targetExecutable, ConfigPath: configPath, Startup: windowsservice.StartupAutomatic}); err != nil {
|
|
return fmt.Errorf("install and start RVBox service: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyInstallerExecutable(source, target string) error {
|
|
input, err := os.Open(source)
|
|
if err != nil {
|
|
return fmt.Errorf("open installer executable: %w", err)
|
|
}
|
|
defer input.Close()
|
|
temporary := target + ".new"
|
|
_ = os.Remove(temporary)
|
|
output, err := os.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o755)
|
|
if err != nil {
|
|
return fmt.Errorf("create replacement executable: %w", err)
|
|
}
|
|
_, copyErr := io.Copy(output, input)
|
|
if syncErr := output.Sync(); copyErr == nil {
|
|
copyErr = syncErr
|
|
}
|
|
if closeErr := output.Close(); copyErr == nil {
|
|
copyErr = closeErr
|
|
}
|
|
if copyErr != nil {
|
|
_ = os.Remove(temporary)
|
|
return fmt.Errorf("copy installer executable: %w", copyErr)
|
|
}
|
|
from, fromErr := windows.UTF16PtrFromString(temporary)
|
|
to, toErr := windows.UTF16PtrFromString(target)
|
|
if fromErr != nil || toErr != nil {
|
|
_ = os.Remove(temporary)
|
|
return errors.New("encode replacement executable path")
|
|
}
|
|
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH); err != nil {
|
|
_ = os.Remove(temporary)
|
|
return fmt.Errorf("activate replacement executable: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeMachineConfig(path string, content []byte) error {
|
|
temporary := path + ".new"
|
|
_ = os.Remove(temporary)
|
|
if err := os.WriteFile(temporary, content, 0o600); err != nil {
|
|
return fmt.Errorf("write default configuration: %w", err)
|
|
}
|
|
from, fromErr := windows.UTF16PtrFromString(temporary)
|
|
to, toErr := windows.UTF16PtrFromString(path)
|
|
if fromErr != nil || toErr != nil {
|
|
_ = os.Remove(temporary)
|
|
return errors.New("encode configuration path")
|
|
}
|
|
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH); err != nil {
|
|
_ = os.Remove(temporary)
|
|
return fmt.Errorf("activate default configuration: %w", err)
|
|
}
|
|
return protectMachineConfig(path)
|
|
}
|
|
|
|
func protectMachineConfig(path string) error {
|
|
descriptor, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;SY)(A;;FA;;;BA)")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dacl, _, err := descriptor.DACL()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil {
|
|
return fmt.Errorf("protect generated configuration: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sameWindowsPath(first, second string) bool {
|
|
return strings.EqualFold(filepath.Clean(first), filepath.Clean(second))
|
|
}
|
|
|
|
func installerUTF16(value string) *uint16 {
|
|
encoded, _ := windows.UTF16PtrFromString(value)
|
|
return encoded
|
|
}
|
|
|
|
func showInstallerMessage(message string, icon uintptr) {
|
|
caption := installerUTF16("RVBox Setup")
|
|
text := installerUTF16(message)
|
|
_, _, _ = installerMessageBox.Call(0, uintptr(unsafe.Pointer(text)), uintptr(unsafe.Pointer(caption)), icon)
|
|
}
|