feat: add interactive Windows installer

This commit is contained in:
2026-09-11 10:10:48 +00:00
parent 9e181b81dc
commit 6882a6808a
11 changed files with 390 additions and 20 deletions
+52
View File
@@ -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
}
+16
View File
@@ -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")
}
+207
View File
@@ -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
View File
@@ -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)
+34
View File
@@ -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()
+3 -1
View File
@@ -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
+14
View File
@@ -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()
+8 -3
View File
@@ -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
View File
@@ -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
+15
View File
@@ -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"
+3 -1
View File
@@ -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