diff --git a/cmd/rvbox/installer_config.go b/cmd/rvbox/installer_config.go new file mode 100644 index 0000000..9803e15 --- /dev/null +++ b/cmd/rvbox/installer_config.go @@ -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 +} diff --git a/cmd/rvbox/installer_other.go b/cmd/rvbox/installer_other.go new file mode 100644 index 0000000..a45e144 --- /dev/null +++ b/cmd/rvbox/installer_other.go @@ -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") +} diff --git a/cmd/rvbox/installer_windows.go b/cmd/rvbox/installer_windows.go new file mode 100644 index 0000000..9d359b2 --- /dev/null +++ b/cmd/rvbox/installer_windows.go @@ -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) +} diff --git a/cmd/rvbox/main.go b/cmd/rvbox/main.go index 1e33953..114b0ec 100644 --- a/cmd/rvbox/main.go +++ b/cmd/rvbox/main.go @@ -38,6 +38,11 @@ func main() { 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 // --service with an explicit config path; tray/helper modes cannot silently // turn an ordinary process invocation into a privileged service. @@ -47,10 +52,10 @@ func run(args []string, output, diagnostics io.Writer) error { return err } 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" { - _, 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 } 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)") checkConfig := flags.Bool("check-config", false, "validate client configuration and exit") 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") 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") @@ -77,7 +83,7 @@ func run(args []string, output, diagnostics io.Writer) error { return fmt.Errorf("unexpected argument %q", flags.Arg(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 { 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}) } + if *installDefault { + return installPackagedDefault(diagnostics) + } if *uninstall { return windowsservice.Uninstall() } @@ -169,11 +178,13 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ diagnostics = io.MultiWriter(formattedRuntimeLog, diagnostics) } health := observability.New() - 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 { - _, _ = fmt.Fprintf(diagnostics, "rvbox client observability endpoint stopped: %v\n", serveErr) - } - }() + if configured.Observability.Listen != "" { + 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 { + _, _ = 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}}) if err != nil { health.SetDirty(true) diff --git a/cmd/rvbox/main_test.go b/cmd/rvbox/main_test.go index 352239d..8e7d752 100644 --- a/cmd/rvbox/main_test.go +++ b/cmd/rvbox/main_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "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) { t.Parallel() health := observability.New() diff --git a/docs/operations-runbook.md b/docs/operations-runbook.md index 64ec847..d0a4ce0 100644 --- a/docs/operations-runbook.md +++ b/docs/operations-runbook.md @@ -78,7 +78,9 @@ plus `manifest.json` only after the Windows executable has optionally been signed: ```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) dist/rvbox-1.0.0-rc.1/rvbox-server-linux-ARCH --version dist/rvbox-1.0.0-rc.1/rvc-linux-ARCH --version diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1311017..9b2f893 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) { t.Parallel() diff --git a/internal/config/validate.go b/internal/config/validate.go index 1cbbc59..52911ee 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -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 { return nil, err } - observability, err := validateObservability(raw.Observability, PlatformUnix) + observability, err := validateObservability(raw.Observability, PlatformUnix, false) if err != nil { return nil, err } @@ -220,7 +220,7 @@ func validateClient(raw clientFile, options ClientOptions) (*Client, error) { if err != nil { return nil, err } - observability, err := validateObservability(raw.Observability, options.Platform) + observability, err := validateObservability(raw.Observability, options.Platform, true) if err != nil { return nil, err } @@ -439,9 +439,14 @@ func validateDeviceRate(key string, value uint64) error { return nil } -func validateObservability(raw observabilityFile, platform Platform) (Observability, error) { - if err := validateListener("observability.listen", raw.Listen); err != nil { - return Observability{}, err +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 { + return Observability{}, err + } } 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 { diff --git a/scripts/release b/scripts/release index 8722fd2..35accdd 100755 --- a/scripts/release +++ b/scripts/release @@ -9,7 +9,7 @@ compose_file=$repo_root/deploy/compose.yaml usage() { 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: 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 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. -Artifacts are built inside the pinned Docker toolchain with that exact version -embedded in `--version`. The optional signing hook is a regular executable run +WSS_URL is the deliberate first-install endpoint embedded only in the Windows +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 RVBOX_VERSION as environment variables and must sign the supplied executable in place. SHA256SUMS and manifest.json are generated only after it succeeds. @@ -43,9 +45,11 @@ command=${1:-} version= output= sign_hook= +bootstrap_server_url= while [ "$#" -gt 0 ]; do case $1 in --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 ;; --sign-windows-hook) [ "$#" -ge 2 ] || fail "--sign-windows-hook needs an executable file"; sign_hook=$2; shift 2 ;; --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}" ;; 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 case $output in /*) ;; @@ -93,6 +104,7 @@ docker compose -f "$compose_file" run --rm toolchain sh -ec ' version=$1 out=$2 flags="-s -w -X main.buildVersion=$version" + windows_flags="$flags -X main.bootstrapServerURL=$3" resource=/workspace/cmd/rvbox/rvbox-release_windows_amd64.syso icon="$out/.rvbox-release-icon.png" 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=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=windows GOARCH=amd64 go build -trimpath -ldflags "-H=windowsgui $flags" -o "$out/rvbox-windows-amd64.exe" ./cmd/rvbox -' sh "$version" "$container_partial" + 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" "$bootstrap_server_url" signed=false if [ -n "$sign_hook" ]; then diff --git a/test/coverage.toml b/test/coverage.toml index 4ac9504..c2616d6 100644 --- a/test/coverage.toml +++ b/test/coverage.toml @@ -17,6 +17,21 @@ layer = "unit" status = "implemented" 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]] id = "HP-CTL-06" layer = "unit" diff --git a/test/rdp-access/rdp-access b/test/rdp-access/rdp-access index f4f409c..ac801af 100755 --- a/test/rdp-access/rdp-access +++ b/test/rdp-access/rdp-access @@ -101,7 +101,9 @@ password_hash_from_terminal() { password= restore_tty=false 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 [ -t 0 ] || fail "stdin is not a terminal; use --web-password-stdin" printf 'Fixture password for %s: ' "$RDP_ACCESS_WEB_USER" >&2