feat: add interactive Windows installer
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rvbox/rvbox/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultInstallerStateDir = `C:\ProgramData\RVBox\data`
|
||||||
|
// Public is a pre-existing directory usable by the active user, LocalSystem,
|
||||||
|
// and LocalService fallback. Durable RVBox state stays separately protected.
|
||||||
|
defaultInstallerWorkDir = `C:\Users\Public`
|
||||||
|
)
|
||||||
|
|
||||||
|
// packagedClientConfig creates the deliberately small first-install TOML. The
|
||||||
|
// strict production decoder validates it before an elevated installer writes
|
||||||
|
// anything, so the SCM service never starts from a hand-built invalid file.
|
||||||
|
func packagedClientConfig(serverURL, clientID string) ([]byte, error) {
|
||||||
|
serverURL = strings.TrimSpace(serverURL)
|
||||||
|
clientID = installerClientID(clientID)
|
||||||
|
if serverURL == "" {
|
||||||
|
return nil, fmt.Errorf("this RVBox package has no bootstrap server URL")
|
||||||
|
}
|
||||||
|
content := fmt.Sprintf("# Generated by the RVBox installer. Edit only through a deliberate reconfiguration.\n[client]\nserver_url = %s\nstate_dir = %s\nclient_id = %s\ndaemon_cwd = %s\n\n[observability]\n# Disabled by default; enable a loopback listener only when diagnostics are needed.\nlisten = \"\"\nlog_file = \"\"\n", strconv.Quote(serverURL), strconv.Quote(defaultInstallerStateDir), strconv.Quote(clientID), strconv.Quote(defaultInstallerWorkDir))
|
||||||
|
if _, err := config.DecodeClient([]byte(content), config.ClientOptions{Platform: config.PlatformWindows}); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate packaged client configuration: %w", err)
|
||||||
|
}
|
||||||
|
return []byte(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func installerClientID(hostname string) string {
|
||||||
|
hostname = strings.TrimSpace(hostname)
|
||||||
|
var result strings.Builder
|
||||||
|
for _, value := range []byte(hostname) {
|
||||||
|
if value >= 0x21 && value <= 0x7e {
|
||||||
|
result.WriteByte(value)
|
||||||
|
} else {
|
||||||
|
result.WriteByte('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value := strings.Trim(result.String(), "-")
|
||||||
|
if value == "" {
|
||||||
|
value = "rvbox-client"
|
||||||
|
}
|
||||||
|
if len(value) > 128 {
|
||||||
|
value = value[:128]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runInteractiveInstaller(io.Writer, io.Writer) error {
|
||||||
|
return errors.New("the v1 interactive installer is implemented for Windows only")
|
||||||
|
}
|
||||||
|
|
||||||
|
func installPackagedDefault(io.Writer) error {
|
||||||
|
return errors.New("the v1 packaged installer is implemented for Windows only")
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
//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)
|
||||||
|
}
|
||||||
+14
-3
@@ -38,6 +38,11 @@ func main() {
|
|||||||
|
|
||||||
var nativeTestContextFailures map[clientwindows.ExecutionContext]bool
|
var nativeTestContextFailures map[clientwindows.ExecutionContext]bool
|
||||||
|
|
||||||
|
// bootstrapServerURL is set only for a packaged Windows installer. A fresh
|
||||||
|
// machine has no service connection from which it could discover this value,
|
||||||
|
// so release packaging must deliberately supply the intended WSS endpoint.
|
||||||
|
var bootstrapServerURL string
|
||||||
|
|
||||||
// run is deliberately a small mode dispatcher. The SCM service invokes only
|
// run is deliberately a small mode dispatcher. The SCM service invokes only
|
||||||
// --service with an explicit config path; tray/helper modes cannot silently
|
// --service with an explicit config path; tray/helper modes cannot silently
|
||||||
// turn an ordinary process invocation into a privileged service.
|
// turn an ordinary process invocation into a privileged service.
|
||||||
@@ -47,10 +52,10 @@ func run(args []string, output, diagnostics io.Writer) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("an internal mode is required (use --help)")
|
return runInteractiveInstaller(output, diagnostics)
|
||||||
}
|
}
|
||||||
if args[0] == "--help" || args[0] == "-h" {
|
if args[0] == "--help" || args[0] == "-h" {
|
||||||
_, err := io.WriteString(output, "usage: rvbox --service|--tray|--launcher|--signal-helper|--check-config|--install-service|--uninstall-service|--configure-service|--start-service|--stop-service|--restart-service --config PATH\n")
|
_, err := io.WriteString(output, "usage: rvbox [--install-default]|--service|--tray|--launcher|--signal-helper|--check-config|--install-service|--uninstall-service|--configure-service|--start-service|--stop-service|--restart-service --config PATH\n")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
flags := flag.NewFlagSet("rvbox", flag.ContinueOnError)
|
flags := flag.NewFlagSet("rvbox", flag.ContinueOnError)
|
||||||
@@ -63,6 +68,7 @@ func run(args []string, output, diagnostics io.Writer) error {
|
|||||||
channel := flags.String("channel", "", "private launcher channel (internal use only)")
|
channel := flags.String("channel", "", "private launcher channel (internal use only)")
|
||||||
checkConfig := flags.Bool("check-config", false, "validate client configuration and exit")
|
checkConfig := flags.Bool("check-config", false, "validate client configuration and exit")
|
||||||
install := flags.Bool("install-service", false, "install or update the machine-wide service")
|
install := flags.Bool("install-service", false, "install or update the machine-wide service")
|
||||||
|
installDefault := flags.Bool("install-default", false, "install the packaged default configuration (internal installer use)")
|
||||||
uninstall := flags.Bool("uninstall-service", false, "remove the machine-wide service")
|
uninstall := flags.Bool("uninstall-service", false, "remove the machine-wide service")
|
||||||
configure := flags.Bool("configure-service", false, "configure machine-wide service startup mode")
|
configure := flags.Bool("configure-service", false, "configure machine-wide service startup mode")
|
||||||
startup := flags.String("startup", string(windowsservice.StartupAutomatic), "service startup mode: automatic or manual")
|
startup := flags.String("startup", string(windowsservice.StartupAutomatic), "service startup mode: automatic or manual")
|
||||||
@@ -77,7 +83,7 @@ func run(args []string, output, diagnostics io.Writer) error {
|
|||||||
return fmt.Errorf("unexpected argument %q", flags.Arg(0))
|
return fmt.Errorf("unexpected argument %q", flags.Arg(0))
|
||||||
}
|
}
|
||||||
selected := 0
|
selected := 0
|
||||||
for _, value := range []bool{*serviceMode, *trayMode, *launcherMode, *signalHelperMode, *checkConfig, *install, *uninstall, *configure, *start, *stop, *restart} {
|
for _, value := range []bool{*serviceMode, *trayMode, *launcherMode, *signalHelperMode, *checkConfig, *install, *installDefault, *uninstall, *configure, *start, *stop, *restart} {
|
||||||
if value {
|
if value {
|
||||||
selected++
|
selected++
|
||||||
}
|
}
|
||||||
@@ -100,6 +106,9 @@ func run(args []string, output, diagnostics io.Writer) error {
|
|||||||
}
|
}
|
||||||
return windowsservice.Install(windowsservice.InstallSpec{ExecutablePath: executable, ConfigPath: *configPath, Startup: windowsservice.StartupAutomatic})
|
return windowsservice.Install(windowsservice.InstallSpec{ExecutablePath: executable, ConfigPath: *configPath, Startup: windowsservice.StartupAutomatic})
|
||||||
}
|
}
|
||||||
|
if *installDefault {
|
||||||
|
return installPackagedDefault(diagnostics)
|
||||||
|
}
|
||||||
if *uninstall {
|
if *uninstall {
|
||||||
return windowsservice.Uninstall()
|
return windowsservice.Uninstall()
|
||||||
}
|
}
|
||||||
@@ -169,11 +178,13 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
|
|||||||
diagnostics = io.MultiWriter(formattedRuntimeLog, diagnostics)
|
diagnostics = io.MultiWriter(formattedRuntimeLog, diagnostics)
|
||||||
}
|
}
|
||||||
health := observability.New()
|
health := observability.New()
|
||||||
|
if configured.Observability.Listen != "" {
|
||||||
go func() {
|
go func() {
|
||||||
if serveErr := health.Serve(ctx, configured.Observability.Listen, observability.Paths{Liveness: configured.Observability.LivenessPath, Readiness: configured.Observability.ReadinessPath, Metrics: configured.Observability.MetricsPath}); serveErr != nil && ctx.Err() == nil && diagnostics != nil {
|
if serveErr := health.Serve(ctx, configured.Observability.Listen, observability.Paths{Liveness: configured.Observability.LivenessPath, Readiness: configured.Observability.ReadinessPath, Metrics: configured.Observability.MetricsPath}); serveErr != nil && ctx.Err() == nil && diagnostics != nil {
|
||||||
_, _ = fmt.Fprintf(diagnostics, "rvbox client observability endpoint stopped: %v\n", serveErr)
|
_, _ = fmt.Fprintf(diagnostics, "rvbox client observability endpoint stopped: %v\n", serveErr)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
}
|
||||||
state, err := spool.Open(ctx, spool.Options{DataDir: configured.Client.StateDir, BusyTimeout: 5 * time.Second, TombstoneLimit: configured.Storage.TombstoneMaxEntries, MaxScriptBytes: configured.Execution.MaxScriptBytes, MaxExecutionSpecBytes: configured.Execution.MaxExecutionSpecBytes, QuotaLimits: spool.QuotaLimits{HardAllocationBytes: 1 << 20, CommandOutputBytes: configured.Storage.CommandOutputLimitBytes, CommandTotalBytes: configured.Storage.CommandTotalLimitBytes, ClientTotalBytes: configured.Storage.ClientTotalLimitBytes, CloseoutReserveBytes: configured.Storage.CommandCloseoutReserveBytes}})
|
state, err := spool.Open(ctx, spool.Options{DataDir: configured.Client.StateDir, BusyTimeout: 5 * time.Second, TombstoneLimit: configured.Storage.TombstoneMaxEntries, MaxScriptBytes: configured.Execution.MaxScriptBytes, MaxExecutionSpecBytes: configured.Execution.MaxExecutionSpecBytes, QuotaLimits: spool.QuotaLimits{HardAllocationBytes: 1 << 20, CommandOutputBytes: configured.Storage.CommandOutputLimitBytes, CommandTotalBytes: configured.Storage.CommandTotalLimitBytes, ClientTotalBytes: configured.Storage.ClientTotalLimitBytes, CloseoutReserveBytes: configured.Storage.CommandCloseoutReserveBytes}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
health.SetDirty(true)
|
health.SetDirty(true)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/rvbox/rvbox/internal/client/spool"
|
"github.com/rvbox/rvbox/internal/client/spool"
|
||||||
@@ -82,6 +83,39 @@ func TestNonWindowsServiceModesRemainExplicitlyUnsupported_BH_WINCLI_01(t *testi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPackagedClientConfigUsesMinimalSafeDefaults_HP_WINCLI_04(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
content, err := packagedClientConfig("wss://controller.example.test/v1/agent", "desktop-01")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
text := string(content)
|
||||||
|
for _, want := range []string{
|
||||||
|
`server_url = "wss://controller.example.test/v1/agent"`,
|
||||||
|
`client_id = "desktop-01"`,
|
||||||
|
`state_dir = "C:\\ProgramData\\RVBox\\data"`,
|
||||||
|
`daemon_cwd = "C:\\Users\\Public"`,
|
||||||
|
`listen = ""`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("generated configuration missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := packagedClientConfig("", "desktop-01"); err == nil {
|
||||||
|
t.Fatal("missing bootstrap URL was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerClientIDNormalizesHostnames_HP_WINCLI_05(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := installerClientID(" desktop host\n"); got != "desktop-host" {
|
||||||
|
t.Fatalf("normalized hostname = %q", got)
|
||||||
|
}
|
||||||
|
if got := installerClientID("\x00"); got != "rvbox-client" {
|
||||||
|
t.Fatalf("fallback hostname = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublishClientTelemetrySample_HP_OPS_07(t *testing.T) {
|
func TestPublishClientTelemetrySample_HP_OPS_07(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
health := observability.New()
|
health := observability.New()
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ plus `manifest.json` only after the Windows executable has optionally been
|
|||||||
signed:
|
signed:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/release build --version 1.0.0-rc.1
|
scripts/release build \
|
||||||
|
--version 1.0.0-rc.1 \
|
||||||
|
--bootstrap-server-url wss://rvbox.example.test/v1/agent
|
||||||
(cd dist/rvbox-1.0.0-rc.1 && sha256sum -c SHA256SUMS)
|
(cd dist/rvbox-1.0.0-rc.1 && sha256sum -c SHA256SUMS)
|
||||||
dist/rvbox-1.0.0-rc.1/rvbox-server-linux-ARCH --version
|
dist/rvbox-1.0.0-rc.1/rvbox-server-linux-ARCH --version
|
||||||
dist/rvbox-1.0.0-rc.1/rvc-linux-ARCH --version
|
dist/rvbox-1.0.0-rc.1/rvc-linux-ARCH --version
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ func TestClientDefaultsDecodeForWindowsWithoutNativeFilesystem(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientMayDisableObservabilityListener_HP_CFG_10(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
client, err := DecodeClient([]byte("[client]\nstate_dir = \"C:\\\\ProgramData\\\\RVBox\\\\state\"\ndaemon_cwd = \"C:\\\\Users\\\\Public\"\n\n[observability]\nlisten = \"\"\nlog_file = \"\"\n"), ClientOptions{Platform: PlatformWindows})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeClient disabled observability: %v", err)
|
||||||
|
}
|
||||||
|
if client.Observability.Listen != "" || client.Observability.LogFile != "" {
|
||||||
|
t.Fatalf("disabled observability = %+v", client.Observability)
|
||||||
|
}
|
||||||
|
if _, err := DecodeServer([]byte("[observability]\nlisten = \"\"\n")); err == nil {
|
||||||
|
t.Fatal("server accepted a disabled observability listener")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStrictTOMLRejections_HP_CFG_01(t *testing.T) {
|
func TestStrictTOMLRejections_HP_CFG_01(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func validateServer(raw serverFile) (*Server, error) {
|
|||||||
if err := protocolLimits(raw.Protocol.MaxAgentEnvelopeBytes, raw.Protocol.MaxExecutionSpecBytes, raw.Protocol.MaxRawChunkBytes, raw.Protocol.MaxScriptBytes, raw.Protocol.MaxControlRequestBytes, raw.Protocol.MaxJSONRPCBodyBytes); err != nil {
|
if err := protocolLimits(raw.Protocol.MaxAgentEnvelopeBytes, raw.Protocol.MaxExecutionSpecBytes, raw.Protocol.MaxRawChunkBytes, raw.Protocol.MaxScriptBytes, raw.Protocol.MaxControlRequestBytes, raw.Protocol.MaxJSONRPCBodyBytes); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
observability, err := validateObservability(raw.Observability, PlatformUnix)
|
observability, err := validateObservability(raw.Observability, PlatformUnix, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -220,7 +220,7 @@ func validateClient(raw clientFile, options ClientOptions) (*Client, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
observability, err := validateObservability(raw.Observability, options.Platform)
|
observability, err := validateObservability(raw.Observability, options.Platform, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -439,10 +439,15 @@ func validateDeviceRate(key string, value uint64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateObservability(raw observabilityFile, platform Platform) (Observability, error) {
|
func validateObservability(raw observabilityFile, platform Platform, allowDisabled bool) (Observability, error) {
|
||||||
|
if raw.Listen == "" && !allowDisabled {
|
||||||
|
return Observability{}, fmt.Errorf("observability.listen must be a host:port listener")
|
||||||
|
}
|
||||||
|
if raw.Listen != "" {
|
||||||
if err := validateListener("observability.listen", raw.Listen); err != nil {
|
if err := validateListener("observability.listen", raw.Listen); err != nil {
|
||||||
return Observability{}, err
|
return Observability{}, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for name, value := range map[string]string{"liveness_path": raw.LivenessPath, "readiness_path": raw.ReadinessPath, "metrics_path": raw.MetricsPath} {
|
for name, value := range map[string]string{"liveness_path": raw.LivenessPath, "readiness_path": raw.ReadinessPath, "metrics_path": raw.MetricsPath} {
|
||||||
if err := validateHTTPPath("observability."+name, value); err != nil {
|
if err := validateHTTPPath("observability."+name, value); err != nil {
|
||||||
return Observability{}, err
|
return Observability{}, err
|
||||||
|
|||||||
+17
-5
@@ -9,7 +9,7 @@ compose_file=$repo_root/deploy/compose.yaml
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
usage: scripts/release build --version VERSION [--output DIR] [--sign-windows-hook FILE]
|
usage: scripts/release build --version VERSION --bootstrap-server-url WSS_URL [--output DIR] [--sign-windows-hook FILE]
|
||||||
|
|
||||||
Builds a fresh immutable bundle containing:
|
Builds a fresh immutable bundle containing:
|
||||||
rvbox-server-linux-amd64
|
rvbox-server-linux-amd64
|
||||||
@@ -23,8 +23,10 @@ Builds a fresh immutable bundle containing:
|
|||||||
The default output is dist/rvbox-VERSION. --output must remain below this
|
The default output is dist/rvbox-VERSION. --output must remain below this
|
||||||
repository's ignored dist/ tree. VERSION must be a safe release label
|
repository's ignored dist/ tree. VERSION must be a safe release label
|
||||||
([0-9A-Za-z][0-9A-Za-z._+-]{0,63}); an existing final output is never replaced.
|
([0-9A-Za-z][0-9A-Za-z._+-]{0,63}); an existing final output is never replaced.
|
||||||
Artifacts are built inside the pinned Docker toolchain with that exact version
|
WSS_URL is the deliberate first-install endpoint embedded only in the Windows
|
||||||
embedded in `--version`. The optional signing hook is a regular executable run
|
installer; it must be an absolute `wss://` URL and is never inferred from a
|
||||||
|
test fixture. Artifacts are built inside the pinned Docker toolchain with that
|
||||||
|
exact version embedded in `--version`. The optional signing hook is a regular executable run
|
||||||
on the host as: HOOK WINDOWS_EXE VERSION. It receives RVBOX_ARTIFACT and
|
on the host as: HOOK WINDOWS_EXE VERSION. It receives RVBOX_ARTIFACT and
|
||||||
RVBOX_VERSION as environment variables and must sign the supplied executable
|
RVBOX_VERSION as environment variables and must sign the supplied executable
|
||||||
in place. SHA256SUMS and manifest.json are generated only after it succeeds.
|
in place. SHA256SUMS and manifest.json are generated only after it succeeds.
|
||||||
@@ -43,9 +45,11 @@ command=${1:-}
|
|||||||
version=
|
version=
|
||||||
output=
|
output=
|
||||||
sign_hook=
|
sign_hook=
|
||||||
|
bootstrap_server_url=
|
||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
--version) [ "$#" -ge 2 ] || fail "--version needs a value"; version=$2; shift 2 ;;
|
--version) [ "$#" -ge 2 ] || fail "--version needs a value"; version=$2; shift 2 ;;
|
||||||
|
--bootstrap-server-url) [ "$#" -ge 2 ] || fail "--bootstrap-server-url needs a value"; bootstrap_server_url=$2; shift 2 ;;
|
||||||
--output) [ "$#" -ge 2 ] || fail "--output needs a directory"; output=$2; shift 2 ;;
|
--output) [ "$#" -ge 2 ] || fail "--output needs a directory"; output=$2; shift 2 ;;
|
||||||
--sign-windows-hook) [ "$#" -ge 2 ] || fail "--sign-windows-hook needs an executable file"; sign_hook=$2; shift 2 ;;
|
--sign-windows-hook) [ "$#" -ge 2 ] || fail "--sign-windows-hook needs an executable file"; sign_hook=$2; shift 2 ;;
|
||||||
--help|-h) usage; exit 0 ;;
|
--help|-h) usage; exit 0 ;;
|
||||||
@@ -58,6 +62,13 @@ case $version in
|
|||||||
fail "--version must match [0-9A-Za-z][0-9A-Za-z._+-]{0,63}"
|
fail "--version must match [0-9A-Za-z][0-9A-Za-z._+-]{0,63}"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
case $bootstrap_server_url in
|
||||||
|
wss://*) ;;
|
||||||
|
*) fail "--bootstrap-server-url must be an absolute wss:// URL" ;;
|
||||||
|
esac
|
||||||
|
case $bootstrap_server_url in
|
||||||
|
*[!A-Za-z0-9:/._-]*) fail "--bootstrap-server-url contains unsupported characters" ;;
|
||||||
|
esac
|
||||||
if [ -z "$output" ]; then output=$repo_root/dist/rvbox-$version; fi
|
if [ -z "$output" ]; then output=$repo_root/dist/rvbox-$version; fi
|
||||||
case $output in
|
case $output in
|
||||||
/*) ;;
|
/*) ;;
|
||||||
@@ -93,6 +104,7 @@ docker compose -f "$compose_file" run --rm toolchain sh -ec '
|
|||||||
version=$1
|
version=$1
|
||||||
out=$2
|
out=$2
|
||||||
flags="-s -w -X main.buildVersion=$version"
|
flags="-s -w -X main.buildVersion=$version"
|
||||||
|
windows_flags="$flags -X main.bootstrapServerURL=$3"
|
||||||
resource=/workspace/cmd/rvbox/rvbox-release_windows_amd64.syso
|
resource=/workspace/cmd/rvbox/rvbox-release_windows_amd64.syso
|
||||||
icon="$out/.rvbox-release-icon.png"
|
icon="$out/.rvbox-release-icon.png"
|
||||||
trap "rm -f -- \"$resource\" \"$icon\"" EXIT HUP INT TERM
|
trap "rm -f -- \"$resource\" \"$icon\"" EXIT HUP INT TERM
|
||||||
@@ -102,8 +114,8 @@ docker compose -f "$compose_file" run --rm toolchain sh -ec '
|
|||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$flags" -o "$out/rvbox-server-linux-arm64" ./cmd/rvbox-server
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$flags" -o "$out/rvbox-server-linux-arm64" ./cmd/rvbox-server
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$flags" -o "$out/rvc-linux-amd64" ./cmd/rvc
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$flags" -o "$out/rvc-linux-amd64" ./cmd/rvc
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$flags" -o "$out/rvc-linux-arm64" ./cmd/rvc
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$flags" -o "$out/rvc-linux-arm64" ./cmd/rvc
|
||||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-H=windowsgui $flags" -o "$out/rvbox-windows-amd64.exe" ./cmd/rvbox
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-H=windowsgui $windows_flags" -o "$out/rvbox-windows-amd64.exe" ./cmd/rvbox
|
||||||
' sh "$version" "$container_partial"
|
' sh "$version" "$container_partial" "$bootstrap_server_url"
|
||||||
|
|
||||||
signed=false
|
signed=false
|
||||||
if [ -n "$sign_hook" ]; then
|
if [ -n "$sign_hook" ]; then
|
||||||
|
|||||||
@@ -17,6 +17,21 @@ layer = "unit"
|
|||||||
status = "implemented"
|
status = "implemented"
|
||||||
tests = ["internal/config/effective_test.go:TestEffectiveConfigurationGolden_HP_CFG_09"]
|
tests = ["internal/config/effective_test.go:TestEffectiveConfigurationGolden_HP_CFG_09"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "HP-CFG-10"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = ["internal/config/config_test.go:TestClientMayDisableObservabilityListener_HP_CFG_10"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "HP-WINCLI-04"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = [
|
||||||
|
"cmd/rvbox/main_test.go:TestPackagedClientConfigUsesMinimalSafeDefaults_HP_WINCLI_04",
|
||||||
|
"cmd/rvbox/main_test.go:TestInstallerClientIDNormalizesHostnames_HP_WINCLI_05",
|
||||||
|
]
|
||||||
|
|
||||||
[[requirements]]
|
[[requirements]]
|
||||||
id = "HP-CTL-06"
|
id = "HP-CTL-06"
|
||||||
layer = "unit"
|
layer = "unit"
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ password_hash_from_terminal() {
|
|||||||
password=
|
password=
|
||||||
restore_tty=false
|
restore_tty=false
|
||||||
if [ "$password_stdin" = true ]; then
|
if [ "$password_stdin" = true ]; then
|
||||||
IFS= read -r password || fail "could not read password from stdin"
|
# Password files commonly omit a final newline. POSIX read returns
|
||||||
|
# non-zero at that EOF even after assigning the final nonempty line.
|
||||||
|
IFS= read -r password || [ -n "$password" ] || fail "could not read password from stdin"
|
||||||
else
|
else
|
||||||
[ -t 0 ] || fail "stdin is not a terminal; use --web-password-stdin"
|
[ -t 0 ] || fail "stdin is not a terminal; use --web-password-stdin"
|
||||||
printf 'Fixture password for %s: ' "$RDP_ACCESS_WEB_USER" >&2
|
printf 'Fixture password for %s: ' "$RDP_ACCESS_WEB_USER" >&2
|
||||||
|
|||||||
Reference in New Issue
Block a user