From 409b64a2fb9d51869eacfaa706250973e9a68301 Mon Sep 17 00:00:00 2001 From: cabbage Date: Wed, 9 Sep 2026 16:48:54 +0000 Subject: [PATCH] feat: add native Windows hierarchy test harness --- cmd/rvbox/main.go | 46 +++- cmd/rvbox/main_test.go | 41 +++ cmd/rvbox/service_windows.go | 59 ++++- docs/configuration.md | 5 + docs/examples/client.toml | 3 +- docs/examples/client.windows.toml | 3 +- docs/implementation-plan.v1.md | 39 +++ docs/testing-vm.md | 59 +++++ docs/testing.md | 39 +++ internal/client/spool/lock_windows.go | 5 +- internal/client/spool/private_windows.go | 6 +- internal/client/spool/private_windows_test.go | 18 ++ internal/client/spool/spool.go | 15 +- internal/client/spool/spool_test.go | 8 + .../client/supervisor/windows/native_exec.go | 8 +- .../supervisor/windows/native_windows.go | 31 +++ .../supervisor/windows/testfaults_default.go | 15 ++ .../supervisor/windows/testfaults_fixture.go | 28 +++ .../client/windowstray/service_windows.go | 9 +- internal/config/config_test.go | 11 + internal/config/path.go | 8 +- scripts/windows/build-test-bundle | 18 +- scripts/windows/native-test | 221 +++++++++++++++++ scripts/windows/test-host | 234 ++++++++++++++++-- test/coverage.toml | 10 +- test/linux-server/README.md | 11 + test/linux-server/certgen.sh | 13 + test/linux-server/compose.yaml | 37 +++ test/linux-server/nginx.conf | 21 ++ test/windowsnative/native_fixture_test.go | 56 +++++ 30 files changed, 1033 insertions(+), 44 deletions(-) create mode 100644 internal/client/spool/private_windows_test.go create mode 100644 internal/client/supervisor/windows/testfaults_default.go create mode 100644 internal/client/supervisor/windows/testfaults_fixture.go create mode 100755 scripts/windows/native-test create mode 100644 test/linux-server/README.md create mode 100755 test/linux-server/certgen.sh create mode 100644 test/linux-server/compose.yaml create mode 100644 test/linux-server/nginx.conf create mode 100644 test/windowsnative/native_fixture_test.go diff --git a/cmd/rvbox/main.go b/cmd/rvbox/main.go index d59c653..9c87f74 100644 --- a/cmd/rvbox/main.go +++ b/cmd/rvbox/main.go @@ -33,6 +33,8 @@ func main() { } } +var nativeTestContextFailures map[clientwindows.ExecutionContext]bool + // 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. @@ -60,6 +62,7 @@ func run(args []string, output, diagnostics io.Writer) error { start := flags.Bool("start-service", false, "start the machine-wide service") stop := flags.Bool("stop-service", false, "stop the machine-wide service") restart := flags.Bool("restart-service", false, "restart the machine-wide service") + testFailContexts := flags.String("test-fail-contexts", "", "fixture-only pre-launch Windows context failures") if err := flags.Parse(args); err != nil { return err } @@ -120,6 +123,11 @@ func run(args []string, output, diagnostics io.Writer) error { } return runSignalHelper(*channel, diagnostics) } + var testFaultErr error + nativeTestContextFailures, testFaultErr = clientwindows.NativeTestContextFailures(*testFailContexts) + if testFaultErr != nil { + return testFaultErr + } return runService(*configPath, diagnostics) } @@ -167,7 +175,7 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ limits = agentproto.DefaultLimits() } eventReady := make(chan domain.UUID, 256) - supervised, err := clientwindows.NewSupervisor(clientwindows.NativeOptions{Shells: clientwindows.ShellPaths{CMD: configured.Shells.CMD, PowerShell: configured.Shells.PowerShell}, WorkRoot: configured.Client.DaemonCWD, JobProfiles: clientJobProfiles(configured.Profiles), MaxWrapperBytes: configured.Execution.MaxScriptBytes, MaxOutputChunk: configured.Execution.MaxRawChunkBytes, WindowsTermGrace: configured.Execution.WindowsTermGrace}) + supervised, err := clientwindows.NewSupervisor(clientwindows.NativeOptions{Shells: clientwindows.ShellPaths{CMD: configured.Shells.CMD, PowerShell: configured.Shells.PowerShell}, WorkRoot: configured.Client.DaemonCWD, JobProfiles: clientJobProfiles(configured.Profiles), MaxWrapperBytes: configured.Execution.MaxScriptBytes, MaxOutputChunk: configured.Execution.MaxRawChunkBytes, WindowsTermGrace: configured.Execution.WindowsTermGrace, TestContextFailures: nativeTestContextFailures}) if err != nil { return fmt.Errorf("configure command supervisor: %w", err) } @@ -249,10 +257,46 @@ func clientHTTPClient(settings config.TLS) (*http.Client, error) { return nil, errors.New("TLS CA file contains no certificates") } tlsConfig.RootCAs = pool + } else { + // v1 deliberately permits a self-signed endpoint certificate without a + // separately distributed CA. Keep hostname checking: this retains the + // useful routing guard while making no claim that the peer is trusted or + // authenticated. A configured CA file opts back into normal PKI-only + // verification above. + tlsConfig.InsecureSkipVerify = true // #nosec G402 -- verified below to admit matching self-signed leaves for v1. + tlsConfig.VerifyConnection = verifySystemOrSelfSignedServer } return &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}}, nil } +func verifySystemOrSelfSignedServer(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return errors.New("TLS peer sent no certificates") + } + leaf := state.PeerCertificates[0] + roots, err := x509.SystemCertPool() + if err != nil || roots == nil { + roots = x509.NewCertPool() + } + intermediates := x509.NewCertPool() + for _, certificate := range state.PeerCertificates[1:] { + intermediates.AddCert(certificate) + } + _, verifyErr := leaf.Verify(x509.VerifyOptions{ + DNSName: state.ServerName, + Roots: roots, + Intermediates: intermediates, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }) + if verifyErr == nil { + return nil + } + if err := leaf.CheckSignatureFrom(leaf); err != nil { + return verifyErr + } + return leaf.VerifyHostname(state.ServerName) +} + func clientHello(configured *config.Client, instance domain.UUID) *rvboxv1.ClientHello { platform := rvboxv1.Platform_PLATFORM_LINUX shells := []rvboxv1.ShellType{rvboxv1.ShellType_SHELL_CMD, rvboxv1.ShellType_SHELL_POWERSHELL} diff --git a/cmd/rvbox/main_test.go b/cmd/rvbox/main_test.go index bb2244a..1aa6b24 100644 --- a/cmd/rvbox/main_test.go +++ b/cmd/rvbox/main_test.go @@ -2,7 +2,11 @@ package main import ( "bytes" + "net/http" + "net/http/httptest" "testing" + + "github.com/rvbox/rvbox/internal/config" ) func TestClientModeSelectionRequiresExactlyOneMode_HP_WINCLI_01(t *testing.T) { @@ -19,6 +23,43 @@ func TestClientModeSelectionRequiresExactlyOneMode_HP_WINCLI_01(t *testing.T) { } } +func TestClientHTTPClientAcceptsMatchingSelfSignedLeaf(t *testing.T) { + t.Parallel() + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client, err := clientHTTPClient(config.TLS{ServerName: "example.com"}) + if err != nil { + t.Fatalf("clientHTTPClient: %v", err) + } + response, err := client.Get(server.URL) + if err != nil { + t.Fatalf("self-signed request: %v", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusNoContent { + t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusNoContent) + } +} + +func TestClientHTTPClientRejectsWrongNameSelfSignedLeaf(t *testing.T) { + t.Parallel() + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client, err := clientHTTPClient(config.TLS{ServerName: "wrong-name.invalid"}) + if err != nil { + t.Fatalf("clientHTTPClient: %v", err) + } + if _, err := client.Get(server.URL); err == nil { + t.Fatal("self-signed request with wrong name unexpectedly succeeded") + } +} + func TestNonWindowsServiceModesRemainExplicitlyUnsupported_BH_WINCLI_01(t *testing.T) { t.Parallel() if err := runService("", nil); err == nil { diff --git a/cmd/rvbox/service_windows.go b/cmd/rvbox/service_windows.go index 229da22..90c364a 100644 --- a/cmd/rvbox/service_windows.go +++ b/cmd/rvbox/service_windows.go @@ -26,30 +26,77 @@ func defaultClientConfigPath() string { } func runService(configPath string, diagnostics io.Writer) error { + earlyDiagnostics, closeDiagnostics := openServiceDiagnostics(configPath, diagnostics) + defer closeDiagnostics() + _, _ = fmt.Fprintf(earlyDiagnostics, "rvbox service preflight config=%s\n", configPath) inService, err := svc.IsWindowsService() if err != nil { - return fmt.Errorf("detect service control manager context: %w", err) + err = fmt.Errorf("detect service control manager context: %w", err) + _, _ = fmt.Fprintf(earlyDiagnostics, "rvbox service preflight failed: %v\n", err) + return err } if !inService { - return errors.New("--service is reserved for the installed Windows service") + err = errors.New("--service is reserved for the installed Windows service") + _, _ = fmt.Fprintf(earlyDiagnostics, "rvbox service preflight failed: %v\n", err) + return err } + _, _ = fmt.Fprintln(earlyDiagnostics, "rvbox service preflight confirmed SCM context") return runWindowsService(configPath, diagnostics) } func runWindowsService(configPath string, diagnostics io.Writer) error { - return windowsservice.Run(func(ctx context.Context) error { + serviceDiagnostics, closeDiagnostics := openServiceDiagnostics(configPath, diagnostics) + defer closeDiagnostics() + _, _ = fmt.Fprintf(serviceDiagnostics, "rvbox Windows service starting config=%s\n", configPath) + err := windowsservice.Run(func(ctx context.Context) error { // The tray endpoint lives in the same LocalSystem service process. It // has no store access; the handler below returns only bounded status/path // data and rechecks SCM authorization in the native pipe adapter. go func() { if err := windowstray.Serve(ctx, func(requestContext context.Context, _ windowstray.Peer, request windowstray.Frame) (windowstray.Frame, error) { return handleTrayRequest(requestContext, configPath, request) - }); err != nil && ctx.Err() == nil && diagnostics != nil { - _, _ = fmt.Fprintf(diagnostics, "rvbox tray endpoint stopped: %v\n", err) + }); err != nil && ctx.Err() == nil { + _, _ = fmt.Fprintf(serviceDiagnostics, "rvbox tray endpoint stopped: %v\n", err) } }() - return runClientDaemon(ctx, configPath, diagnostics) + err := runClientDaemon(ctx, configPath, serviceDiagnostics) + if err != nil { + _, _ = fmt.Fprintf(serviceDiagnostics, "rvbox Windows service startup failed: %v\n", err) + } + return err }) + if err != nil { + _, _ = fmt.Fprintf(serviceDiagnostics, "rvbox Windows service stopped with error: %v\n", err) + } + return err +} + +// openServiceDiagnostics preserves the reason for an early service failure: +// a GUI-subsystem executable has no reliable inherited stderr under SCM. The +// file is deliberately machine-wide instead of beside the selected config. +// LocalSystem can therefore report an access failure to a per-run bundle, +// config, or state directory before the normal client logger exists. +func openServiceDiagnostics(configPath string, diagnostics io.Writer) (io.Writer, func()) { + _ = configPath // Kept in the signature so callers document the selected config. + root := os.Getenv("ProgramData") + if root == "" { + root = `C:\ProgramData` + } + path := filepath.Join(root, "RVBox", "service-startup.log") + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + if diagnostics == nil { + return io.Discard, func() {} + } + return diagnostics, func() {} + } + if diagnostics == nil { + return file, func() { _ = file.Close() } + } + // A GUI-subsystem service can inherit an invalid stderr handle from SCM. + // MultiWriter stops on its first failed destination, so keep the durable + // machine log first and make the inherited diagnostic stream best effort. + return io.MultiWriter(file, diagnostics), func() { _ = file.Close() } } func runTray(configPath string, diagnostics io.Writer) error { diff --git a/docs/configuration.md b/docs/configuration.md index faf749e..b121af1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,6 +39,11 @@ use the defaults documented by the all-knob client reference. - The daemon prints its effective configuration after validation, with no command data or TLS material. Since v1 stores command/environment payloads in plaintext, configuration output is hygiene rather than a secrecy guarantee. +- With an empty `tls.ca_file`, the client accepts a matching-host self-signed + server leaf. This provides TLS encryption and hostname routing only; v1 makes + no server-authentication or endpoint-ownership guarantee. Supplying a PEM CA + bundle restores normal CA-chain verification and is the preferred future + deployment model. ## Limits and cross-field validation diff --git a/docs/examples/client.toml b/docs/examples/client.toml index 69585e2..5ab0366 100644 --- a/docs/examples/client.toml +++ b/docs/examples/client.toml @@ -17,7 +17,8 @@ max_queued_commands = 100 shutdown_grace = "30s" [tls] -# Optional PEM CA bundle; empty uses the operating-system trust store. +# Optional PEM CA bundle. Empty permits a matching-host self-signed leaf in v1; +# this encrypts transport but does not authenticate server ownership. ca_file = "" # Optional certificate name override; empty derives it from server_url. server_name = "" diff --git a/docs/examples/client.windows.toml b/docs/examples/client.windows.toml index dc7e3a0..cd037cc 100644 --- a/docs/examples/client.windows.toml +++ b/docs/examples/client.windows.toml @@ -17,7 +17,8 @@ max_queued_commands = 100 shutdown_grace = "30s" [tls] -# Optional PEM CA bundle; empty uses the Windows trust store. +# Optional PEM CA bundle. Empty permits a matching-host self-signed leaf in v1; +# this encrypts transport but does not authenticate server ownership. ca_file = "" # Optional certificate-name override; empty derives it from server_url. server_name = "" diff --git a/docs/implementation-plan.v1.md b/docs/implementation-plan.v1.md index f4b7b47..a5af7f5 100644 --- a/docs/implementation-plan.v1.md +++ b/docs/implementation-plan.v1.md @@ -740,6 +740,45 @@ side effect must add a hook and coverage row before merging. ### 2.6 Native Windows test-host requirements +#### 2.6.0 Implemented current-controller native hierarchy lane + +Implement scripts/windows/native-test as the first-class production-shaped +native gate. One run must create an immutable run directory, compile a +separately tagged fixture-only rvbox.exe in the pinned Docker toolchain, and +run the Linux server and nginx fixture from test/linux-server in a labeled +Docker Compose project on the current controller. Helium hosts only the +Windows VM and the VirtualBox Guest Control bridge: no RVBox server, proxy, +or Compose resource may be staged or run there. The local stack must create a +two-day matching-host self-signed leaf for the explicit current-controller +endpoint (x1.xcel.me by default, overridable by +RVBOX_NATIVE_ENDPOINT_HOST). It binds the configured test port on the current +controller and copies only rvbox.exe and client configuration to the Windows +guest test root. + +The Windows service must connect through that WSS endpoint; controller-side +success is not enough. With no tls.ca_file configured, the v1 client must +accept the matching-host self-signed leaf while retaining hostname validation; +that is encrypted routing only and makes no claim that the endpoint is +authenticated. Wait for the client to become connected through the local +server's actual Unix rvc socket, issue real CMD commands, wait for durable +terminal success, and retain the resulting command status records. Verify, +in order: normal ACTIVE_USER; elevated ACTIVE_USER_ELEVATED; forced +ACTIVE_USER_ELEVATED preparation failure falling back to ACTIVE_SYSTEM; forced +ACTIVE_USER_ELEVATED plus ACTIVE_SYSTEM failure falling back to LOCAL_SYSTEM; +then log off the sole fixture console session and verify normal LOCAL_SERVICE +and elevated LOCAL_SYSTEM. The forced faults must be available only in the +separately tagged fixture binary, only before child-process creation, and only +for these two active elevated contexts. They are not a protocol field, TOML +knob, release-build behavior, or product broker. + +On ordinary success collect bounded guest and local server/proxy artifacts, +remove only that labeled Compose project and its volumes, and restore the exact +snapshot. On failure retain the exact local stack and VM lease for recover; +clean must reset the fixture and retain artifacts, while an explicit purge with +a confirmation removes only the matching local run root. Add static +tests for the runner's file layout and tagged-build boundary, and promote the +native coverage row only after this lane passes on the documented VM. + Native Windows is mandatory for the Phase 4/5 gates. Development can begin with unit tests and cross-compilation before a host is connected, but the Windows supervisor/service/tray implementation cannot be called complete without it. diff --git a/docs/testing-vm.md b/docs/testing-vm.md index 813cbcc..e08cb26 100644 --- a/docs/testing-vm.md +++ b/docs/testing-vm.md @@ -174,6 +174,65 @@ connectivity and CA-trust probe. The resulting service and artifact paths are recorded in the run report and reclaimed by the snapshot reset rather than broad guest deletion. +The first-class end-to-end controller is scripts/windows/native-test run +--run-id ID. It runs the per-run Linux server and nginx stack in Docker Compose +under test/linux-server on the current controller. Helium hosts only the +Windows VM and VirtualBox Guest Control bridge. The client connects to the +explicit current-controller WSS endpoint (x1.xcel.me by default), never the +DHCP guest address, and intentionally accepts that endpoint's matching-host +self-signed certificate in v1. The controller drives requests with the local +stack's rvc, collects bounded artifacts, and deletes just its labeled Compose +project during cleanup. + +The test bundle never sends a Windows executable over the controller-to-Helium +SSH hop. The tagged fixture build strips symbol and DWARF tables; `test-host +stage` creates a temporary `xz -3` payload, publishes it to the authenticated +controller HTTP endpoint, and directs Helium to download it through its SOCKS5 +acceleration proxy. Helium resumes the HTTP payload, verifies its compressed +SHA-256, atomically decompresses it into the exact host stage, then validates +the ordinary uncompressed bundle manifest before Guest Control copies files +into Windows. The baseline Helium host must provide `curl` and `xz`. Small +non-secret `client.toml` and optional CA files still use bounded-retry SSH +copies; executable staging fails closed if the accelerated route is unavailable. +Each successful stage prints `verified transfer_sha256` and the verified +manifest. No manual remote checksum check is needed for a normal or resumed +test run. The controller uses bounded SSH retries (four attempts, short +backoff) for idempotent inspection, staging, installation, and service-run +operations. Reset, stop, and logoff intentionally remain single-attempt; +after an interrupted lifecycle transition, use `recover` and decide whether to +resume or reset instead of replaying an ambiguous action. + +For this controller/Helium fixture, the required executable route publishes +only the stripped, compressed test executable under the current controller's +Downloads HTTP endpoint +(`http://x1.xcel.me:9124`, authentication `x1:x1`) and asks Helium to fetch it +through its metered `socks5h://127.0.0.1:1085` proxy. The endpoint currently +advertises Basic authentication, so the harness deliberately uses curl's +`--anyauth` negotiation rather than assuming Digest. It never publishes +`client.toml`, CA material, passwords, or other configuration. Helium resumes +the HTTP download, verifies the compressed SHA-256, atomically decompresses it +into its stage, and the ordinary manifest check still runs before Guest +Control copies files into Windows. The uniquely named published artifact is +removed after successful guest staging (and on a subsequently failed stage). +`RVBOX_TEST_ACCEL_HTTP_URL`, `RVBOX_TEST_ACCEL_HTTP_AUTH`, +`RVBOX_TEST_ACCEL_HTTP_PUBLISH_DIR`, and `RVBOX_TEST_ACCEL_SOCKS5` override the +documented fixture defaults. + +Early Windows-service failures are appended to +`C:\ProgramData\RVBox\service-startup.log`, before client config, durable +state, or normal observability logging begins. This is intentionally outside a +per-run bundle directory so the LocalSystem service can report an ACL/path +failure affecting that directory. + +To prove the complete elevated fallback chain in one single-user fixture, the +controller builds a separately tagged disposable rvbox.exe. Its only extra +behavior is the internal --test-fail-contexts switch, which can force +ACTIVE_USER_ELEVATED and then ACTIVE_SYSTEM token preparation to fail before +launch. Release binaries reject the switch. The normal active-user, +active-user-elevated, active-system, local-system, local-service, and +logged-out local-system rows are therefore observed through the real SCM +service without adding a product broker or protocol field. + ### Clean baseline and non-interactive installation `rvboxtest` deliberately remains a split-token administrator. Guest Control diff --git a/docs/testing.md b/docs/testing.md index ce742ff..028b774 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -56,6 +56,37 @@ The bundle manifest records the source commit and SHA-256 of every bundled file. It is a test artifact, not a signed release package; release signing, version resources, and publication are Phase 8 gates. +The preferred complete native lane is now one command: + +~~~sh +scripts/windows/native-test run --run-id windows-hierarchy +~~~ + +It builds a disposable fixture-tagged Windows binary in the pinned toolchain, +starts the real Linux server and nginx TLS proxy in Docker Compose under +test/linux-server on the current controller, and connects the Helium-hosted +NAT guest to the current controller's explicit endpoint (x1.xcel.me by +default). Helium hosts the VM only; it does not host any RVBox server +containers. The self-signed server certificate is intentionally accepted by +the v1 client without a test CA. It then drives the installed SCM service +through the server's real Unix control socket and verifies every Windows +execution context. The tagged binary's controlled pre-launch failures are +limited to the test fixture; a release binary rejects that switch. + +Successful runs collect bounded artifacts, remove only their labeled Compose +project, and restore the exact clean snapshot. A failed or --keep run stays +recoverable: + +~~~sh +scripts/windows/native-test recover --run-id windows-hierarchy +scripts/windows/native-test clean --run-id windows-hierarchy +scripts/windows/native-test clean --run-id windows-hierarchy --purge --yes +~~~ + +clean retains local artifacts by default. The explicit purge form removes only +the exact local run root after the local stack is down and the VM snapshot has +been restored. + The integration harness provides the Phase 0 `sample` suite, the incremental Phase 2 `store` suite, and the incremental Phase 3 `server-session` suite. The resumable E2E harness adds `smoke`, `script`, `recovery`, and `all` @@ -180,6 +211,14 @@ also performs a bounded guest-to-nginx HTTPS/TCP readiness probe before it starts the service. Wine and protocol stubs are not equivalent Windows coverage. +For the hierarchy fixture only, run --fail-contexts +ACTIVE_USER_ELEVATED[,ACTIVE_SYSTEM] restarts the separately tagged test +service and forces the named token preparation step to fail before process +creation. This proves the real daemon's fallback order without changing the +wire protocol, normal client TOML, or release binary. The logoff action ends +the sole active fixture session so the LocalService and no-user LocalSystem +rows can be tested with the same running service. + The clean baseline intentionally contains no RVBox service, tray registration, or RVBox state. Guest Control supplies `rvboxtest` with a filtered medium UAC token, so it cannot safely perform the first machine-wide install. The fixture diff --git a/internal/client/spool/lock_windows.go b/internal/client/spool/lock_windows.go index d1015cd..d42a8f8 100644 --- a/internal/client/spool/lock_windows.go +++ b/internal/client/spool/lock_windows.go @@ -37,7 +37,10 @@ func syncDirectory(path string) error { if err != nil { return err } - handle, err := windows.CreateFile(value, windows.GENERIC_READ, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS, 0) + // FlushFileBuffers requires GENERIC_WRITE even when the handle refers to a + // directory. Opening it read-only succeeds, then deterministically fails + // the durability barrier with ERROR_ACCESS_DENIED on Windows. + handle, err := windows.CreateFile(value, windows.GENERIC_READ|windows.GENERIC_WRITE, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS, 0) if err != nil { return err } diff --git a/internal/client/spool/private_windows.go b/internal/client/spool/private_windows.go index cb65927..78b4537 100644 --- a/internal/client/spool/private_windows.go +++ b/internal/client/spool/private_windows.go @@ -18,11 +18,9 @@ const privateStateSDDL = "D:P(A;;FA;;;SY)(A;;FA;;;BA)" func ensurePrivateFile(path string) error { file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) if err == nil { - err = file.Close() - } else if !errors.Is(err, os.ErrExist) { - return err + return file.Close() } - if err != nil { + if !errors.Is(err, os.ErrExist) { return err } info, err := os.Lstat(path) diff --git a/internal/client/spool/private_windows_test.go b/internal/client/spool/private_windows_test.go new file mode 100644 index 0000000..dbaff8b --- /dev/null +++ b/internal/client/spool/private_windows_test.go @@ -0,0 +1,18 @@ +//go:build windows + +package spool + +import ( + "path/filepath" + "testing" +) + +func TestEnsurePrivateFileAcceptsExistingPrivateFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "spool.lock") + if err := ensurePrivateFile(path); err != nil { + t.Fatalf("create private file: %v", err) + } + if err := ensurePrivateFile(path); err != nil { + t.Fatalf("recheck existing private file: %v", err) + } +} diff --git a/internal/client/spool/spool.go b/internal/client/spool/spool.go index 31ee03a..b72a37b 100644 --- a/internal/client/spool/spool.go +++ b/internal/client/spool/spool.go @@ -11,6 +11,7 @@ import ( "net/url" "path/filepath" "strconv" + "strings" "sync" "time" @@ -116,8 +117,7 @@ func Open(ctx context.Context, options Options) (*Store, error) { query.Add("_pragma", "foreign_keys(ON)") query.Add("_pragma", "synchronous(FULL)") query.Add("_pragma", "busy_timeout("+strconv.FormatInt(options.BusyTimeout.Milliseconds(), 10)+")") - databaseURL := &url.URL{Scheme: "file", Path: filepath.ToSlash(databasePath), RawQuery: query.Encode()} - db, err := sql.Open("sqlite", databaseURL.String()) + db, err := sql.Open("sqlite", sqliteFileURI(databasePath, query)) if err != nil { _ = unlock() return nil, err @@ -136,6 +136,17 @@ func Open(ctx context.Context, options Options) (*Store, error) { return store, nil } +// sqliteFileURI creates a file: URI with no authority. SQLite requires a +// Windows drive path to be /C:/... in the URI path; without the leading slash +// net/url renders C: as an authority (file://C:/...), which SQLite rejects. +func sqliteFileURI(path string, query url.Values) string { + path = strings.ReplaceAll(path, `\`, "/") + if len(path) >= 2 && path[1] == ':' { + path = "/" + path + } + return (&url.URL{Scheme: "file", Path: path, RawQuery: query.Encode()}).String() +} + func (store *Store) ClientInstanceID() domain.UUID { return store.identity } func (store *Store) Close() error { diff --git a/internal/client/spool/spool_test.go b/internal/client/spool/spool_test.go index 616dade..c267930 100644 --- a/internal/client/spool/spool_test.go +++ b/internal/client/spool/spool_test.go @@ -5,6 +5,7 @@ import ( "context" "crypto/sha256" "errors" + "net/url" "os" "path/filepath" "testing" @@ -16,6 +17,13 @@ import ( "google.golang.org/protobuf/proto" ) +func TestSQLiteFileURIWindowsDrivePath(t *testing.T) { + query := url.Values{"_pragma": {"journal_mode(WAL)"}} + if got, want := sqliteFileURI(`C:\ProgramData\RVBox\test-state\spool.db`, query), "file:///C:/ProgramData/RVBox/test-state/spool.db?_pragma=journal_mode%28WAL%29"; got != want { + t.Fatalf("sqliteFileURI() = %q, want %q", got, want) + } +} + func TestExecutionSpecIsDurableAndQuotaCounted_HP_DISPATCH_07(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/internal/client/supervisor/windows/native_exec.go b/internal/client/supervisor/windows/native_exec.go index 3186fd6..cc4d5a8 100644 --- a/internal/client/supervisor/windows/native_exec.go +++ b/internal/client/supervisor/windows/native_exec.go @@ -43,7 +43,13 @@ type NativeOptions struct { MaxWrapperBytes uint64 MaxOutputChunk uint64 WindowsTermGrace time.Duration - Now func() time.Time + // TestContextFailures is populated only by the separately tagged native + // fixture binary. It is deliberately not protocol or TOML policy: it lets + // the fixture fail token preparation before launch so the real daemon can + // prove its fallback order without creating a second broker or weakening a + // release binary. + TestContextFailures map[ExecutionContext]bool + Now func() time.Time } // JobProfile is the validated administrator policy for one protocol profile. diff --git a/internal/client/supervisor/windows/native_windows.go b/internal/client/supervisor/windows/native_windows.go index de2be34..fca7b77 100644 --- a/internal/client/supervisor/windows/native_windows.go +++ b/internal/client/supervisor/windows/native_windows.go @@ -388,6 +388,10 @@ func (manager *execSupervisor) selectToken(elevated bool) (winapi.Token, supervi } for _, attempt := range selection.Attempts { token, identity, err := openTokenForAttempt(attempt.Context, selected) + if err == nil && manager.options.TestContextFailures[attempt.Context] { + _ = token.Close() + err = errors.New("native test fixture forced pre-launch context failure") + } if err == nil { return token, withEvidence(identity), nil } @@ -396,6 +400,33 @@ func (manager *execSupervisor) selectToken(elevated bool) (winapi.Token, supervi return 0, supervisor.EffectiveIdentity{}, rejection(err) } } + // Select stops at the first policy-available elevated context. Native + // preparation can still fail after that point (a linked token can vanish or + // SeTcbPrivilege can be unavailable), so preserve the documented order by + // trying ACTIVE_SYSTEM before the final LOCAL_SYSTEM fallback. This is a + // preparation fallback only: no child has been created yet. + if elevated && selected != nil { + seenActiveSystem := false + for _, contextName := range attempted { + if contextName == string(ContextActiveSystem) { + seenActiveSystem = true + break + } + } + if !seenActiveSystem { + addAttempt(ContextActiveSystem, "fallback after active-user-elevated preparation failure") + if token, identity, err := openTokenForAttempt(ContextActiveSystem, selected); err == nil { + if manager.options.TestContextFailures[ContextActiveSystem] { + _ = token.Close() + addAttempt(ContextActiveSystem, "native test fixture forced pre-launch context failure") + } else { + return token, withEvidence(identity), nil + } + } else { + addAttempt(ContextActiveSystem, "native preparation failed: "+err.Error()) + } + } + } // The pure selector stops as soon as ACTIVE_SYSTEM is available. A native // privilege/session operation can still fail (for example, SeTcb was // removed), so the final LOCAL_SYSTEM fallback is attempted here before diff --git a/internal/client/supervisor/windows/testfaults_default.go b/internal/client/supervisor/windows/testfaults_default.go new file mode 100644 index 0000000..545265b --- /dev/null +++ b/internal/client/supervisor/windows/testfaults_default.go @@ -0,0 +1,15 @@ +//go:build !rvbox_native_test + +package windows + +import "fmt" + +// NativeTestContextFailures is intentionally unavailable in shipped binaries. +// The native fixture compiles a separately tagged test executable when it +// needs to prove a pre-launch fallback row. +func NativeTestContextFailures(raw string) (map[ExecutionContext]bool, error) { + if raw != "" { + return nil, fmt.Errorf("--test-fail-contexts requires the rvbox_native_test fixture build") + } + return nil, nil +} diff --git a/internal/client/supervisor/windows/testfaults_fixture.go b/internal/client/supervisor/windows/testfaults_fixture.go new file mode 100644 index 0000000..bde8ab3 --- /dev/null +++ b/internal/client/supervisor/windows/testfaults_fixture.go @@ -0,0 +1,28 @@ +//go:build rvbox_native_test + +package windows + +import ( + "fmt" + "strings" +) + +// NativeTestContextFailures accepts only the two active elevated preparation +// stages. It is compiled into the disposable native-fixture binary, never a +// release binary, and is applied before a process is launched. +func NativeTestContextFailures(raw string) (map[ExecutionContext]bool, error) { + if raw == "" { + return nil, nil + } + result := make(map[ExecutionContext]bool) + for _, item := range strings.Split(raw, ",") { + contextName := ExecutionContext(strings.TrimSpace(item)) + switch contextName { + case ContextActiveUserElevated, ContextActiveSystem: + result[contextName] = true + default: + return nil, fmt.Errorf("unsupported native test context %q", item) + } + } + return result, nil +} diff --git a/internal/client/windowstray/service_windows.go b/internal/client/windowstray/service_windows.go index 41c0ec5..1e5f2a8 100644 --- a/internal/client/windowstray/service_windows.go +++ b/internal/client/windowstray/service_windows.go @@ -74,8 +74,13 @@ func newTrayPipe() (*os.File, error) { Length: uint32(unsafe.Sizeof(winapi.SecurityAttributes{})), SecurityDescriptor: descriptor, } - mode := uint32(winapi.PIPE_ACCESS_DUPLEX | winapi.PIPE_TYPE_MESSAGE | winapi.PIPE_READMODE_MESSAGE | winapi.PIPE_WAIT | winapi.PIPE_REJECT_REMOTE_CLIENTS | winapi.SECURITY_IDENTIFICATION) - handle, err := winapi.CreateNamedPipe(name, mode, pipeInstances, pipeBufferBytes, pipeBufferBytes, 0, 0, attributes) + // CreateNamedPipe has separate open-mode and pipe-mode arguments. Remote + // client rejection belongs to the latter; placing it in open mode produces + // ERROR_INVALID_PARAMETER on Windows. We inspect the client token directly + // and never impersonate it, so no SQOS/impersonation flag is needed here. + openMode := uint32(winapi.PIPE_ACCESS_DUPLEX) + pipeMode := uint32(winapi.PIPE_TYPE_MESSAGE | winapi.PIPE_READMODE_MESSAGE | winapi.PIPE_WAIT | winapi.PIPE_REJECT_REMOTE_CLIENTS) + handle, err := winapi.CreateNamedPipe(name, openMode, pipeMode, pipeInstances, pipeBufferBytes, pipeBufferBytes, 0, attributes) if err != nil { return nil, err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c9f5c73..1311017 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -49,6 +49,17 @@ func TestAnnotatedExamples_HP_CFG_01(t *testing.T) { } } +func TestClientDefaultsDecodeForWindowsWithoutNativeFilesystem(t *testing.T) { + t.Parallel() + client, err := DecodeClient([]byte("[client]\nstate_dir = \"C:\\\\ProgramData\\\\RVBox\\\\state\"\ndaemon_cwd = \"C:\\\\ProgramData\\\\RVBox\\\\work\"\n"), ClientOptions{Platform: PlatformWindows, CheckFilesystem: false}) + if err != nil { + t.Fatalf("DecodeClient Windows defaults: %v", err) + } + if client.Shells.SH != "/bin/sh" || client.Shells.CMD == "" { + t.Fatalf("Windows defaults have unexpected shell paths: %+v", client.Shells) + } +} + func TestStrictTOMLRejections_HP_CFG_01(t *testing.T) { t.Parallel() diff --git a/internal/config/path.go b/internal/config/path.go index 93c1373..12c4f2a 100644 --- a/internal/config/path.go +++ b/internal/config/path.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "path" "path/filepath" "runtime" "strings" @@ -21,10 +22,13 @@ func validateAbsolutePath(name, value string, platform Platform, allowEmpty bool } return cleanWindowsPath(value), nil } - if !filepath.IsAbs(value) { + // Config parsing can validate an inactive Unix shell path while running on + // Windows. filepath follows the host OS, whereas the config field's stated + // platform is Unix, so use slash-path semantics here. + if !path.IsAbs(value) { return "", fmt.Errorf("%s must be an absolute Unix path", name) } - return filepath.Clean(value), nil + return path.Clean(value), nil } func isWindowsAbsolute(value string) bool { diff --git a/scripts/windows/build-test-bundle b/scripts/windows/build-test-bundle index aae84c7..f0d1a2b 100755 --- a/scripts/windows/build-test-bundle +++ b/scripts/windows/build-test-bundle @@ -7,7 +7,7 @@ repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) usage() { cat <<'EOF' -usage: scripts/windows/build-test-bundle --run-id ID --config FILE [--ca FILE] [--no-build] +usage: scripts/windows/build-test-bundle --run-id ID --config FILE [--ca FILE] [--no-build] [--native-fixture] Builds bin/rvbox.exe in the pinned Docker toolchain, then creates exactly: .test-runs/ID/windows-bundle/{rvbox.exe,client.toml[,ca.pem],manifest.sha256} @@ -15,6 +15,10 @@ Builds bin/rvbox.exe in the pinned Docker toolchain, then creates exactly: The bundle is for the resettable native-test fixture only. The config must use the target guest paths and the declared test nginx endpoint. Existing bundles are refused rather than overwritten. + +--native-fixture compiles the Windows executable with the rvbox_native_test +tag. That non-release binary alone accepts --test-fail-contexts, used only to +prove pre-launch elevation fallback order in the disposable VM. EOF } @@ -24,12 +28,14 @@ run_id= config= ca= build=yes +native_fixture=no while [ "$#" -gt 0 ]; do case $1 in --run-id) [ "$#" -ge 2 ] || fail "--run-id needs a value"; run_id=$2; shift 2 ;; --config) [ "$#" -ge 2 ] || fail "--config needs a file"; config=$2; shift 2 ;; --ca) [ "$#" -ge 2 ] || fail "--ca needs a PEM file"; ca=$2; shift 2 ;; --no-build) build=no; shift ;; + --native-fixture) native_fixture=yes; shift ;; --help|-h) usage; exit 0 ;; *) fail "unknown argument $1" ;; esac @@ -48,6 +54,15 @@ esac if [ -n "$ca" ]; then [ -f "$ca" ] && [ ! -L "$ca" ] || fail "--ca must be a regular non-symlink file"; fi if [ "$build" = yes ]; then "$repo_root/scripts/build" build; fi +if [ "$native_fixture" = yes ]; then + # Keep the server/rvc artifacts produced by scripts/build, but replace only + # the disposable test client with its explicitly tagged fixture build. + # No host Go toolchain is used. Strip symbol/DWARF tables because this + # disposable binary is transferred to the remote VM fixture; this does not + # change executable behavior or the tagged test boundary. + docker compose -f "$repo_root/deploy/compose.yaml" run --rm toolchain \ + env GOOS=windows GOARCH=amd64 go build -trimpath -tags rvbox_native_test -ldflags '-H=windowsgui -s -w' -o bin/rvbox.exe ./cmd/rvbox +fi binary=$repo_root/bin/rvbox.exe [ -f "$binary" ] && [ ! -L "$binary" ] || fail "expected regular Windows binary at bin/rvbox.exe" @@ -66,6 +81,7 @@ commit=$(git -C "$repo_root" rev-parse HEAD) { printf 'run_id=%s\n' "$run_id" printf 'git_commit=%s\n' "$commit" + printf 'native_fixture=%s\n' "$native_fixture" (cd "$bundle" && sha256sum rvbox.exe client.toml ${ca:+ca.pem}) } >"$bundle/manifest.sha256" chmod 600 "$bundle/manifest.sha256" diff --git a/scripts/windows/native-test b/scripts/windows/native-test new file mode 100755 index 0000000..8fadf1a --- /dev/null +++ b/scripts/windows/native-test @@ -0,0 +1,221 @@ +#!/bin/sh +# Production-shaped Linux server/nginx -> native Windows client test lane. +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) + +usage() { + cat <<'EOF' +usage: scripts/windows/native-test run|recover|clean --run-id ID [options] + +run options: + --port PORT Current-controller TLS port (default RVBOX_NATIVE_PORT or 16899) + --keep retain the stack/VM lease for inspection + +recover reports the exact VM lease and Compose resources. +clean stops only the matching stack and resets the matching VM run. + --purge --yes also delete only the matching local and Helium run files + +run uses a separately tagged disposable fixture binary to prove every Windows +execution context through SCM, nginx WSS, the Linux server, and rvc. Release +binaries reject the fixture-only pre-launch failure switch. +EOF +} + +fail() { printf '%s\n' "native-test: $*" >&2; exit 2; } + +safe_id() { + case $1 in [a-z0-9]* ) ;; *) fail "run ID must start with lowercase alphanumeric" ;; esac + case $1 in ''|*[!a-z0-9-]*|????????????????????????????????????????????????????????????????*) fail "run ID must match [a-z0-9][a-z0-9-]{0,63}" ;; esac +} + +action=${1-} +[ -n "$action" ] || { usage >&2; exit 2; } +shift +case $action in run|recover|clean|--help|-h) ;; *) usage >&2; fail "unknown action $action" ;; esac +[ "$action" != --help ] && [ "$action" != -h ] || { usage; exit 0; } + +run_id= +port=${RVBOX_NATIVE_PORT:-16899} +keep=no +purge=no +yes=no +while [ "$#" -gt 0 ]; do + case $1 in + --run-id) [ "$#" -ge 2 ] || fail "--run-id needs a value"; run_id=$2; shift 2 ;; + --port) [ "$#" -ge 2 ] || fail "--port needs a value"; port=$2; shift 2 ;; + --keep) keep=yes; shift ;; + --purge) purge=yes; shift ;; + --yes) yes=yes; shift ;; + --help|-h) usage; exit 0 ;; + *) fail "unknown argument $1" ;; + esac +done +[ -n "$run_id" ] || fail "$action requires --run-id" +safe_id "$run_id" +case $port in *[!0-9]*|'') fail "--port must be an integer" ;; esac +[ "$port" -ge 1024 ] && [ "$port" -le 65535 ] || fail "--port must be 1024..65535" +[ "$purge" = no ] || [ "$action" = clean ] || fail "--purge is only valid with clean" +[ "$yes" = no ] || [ "$action" = clean ] || fail "--yes is only valid with clean" +[ "$purge" = no ] || [ "$yes" = yes ] || fail "--purge requires --yes" + +: "${RVBOX_TEST_VBOX_HOST:=helium-remote}" +case $RVBOX_TEST_VBOX_HOST in ''|*[!A-Za-z0-9._:@-]*) fail "RVBOX_TEST_VBOX_HOST contains unsupported characters" ;; esac +endpoint_host=${RVBOX_NATIVE_ENDPOINT_HOST:-x1.xcel.me} +case $endpoint_host in ''|*[!A-Za-z0-9.-]*) fail "RVBOX_NATIVE_ENDPOINT_HOST contains unsupported characters" ;; esac +project=rvbox-native-$run_id +run_root=$repo_root/.test-runs/$run_id +fixture_dir=$run_root/native-windows +client_id=native-$run_id +client_config=$fixture_dir/client.toml +server_config=$fixture_dir/server.toml +vm_prepared=no + +compose() { + RVBOX_NATIVE_RUN_ID="$run_id" RVBOX_NATIVE_PORT="$port" \ + RVBOX_NATIVE_BIND=0.0.0.0 RVBOX_NATIVE_UID="$(id -u)" RVBOX_NATIVE_GID="$(id -g)" \ + RVBOX_NATIVE_RUNTIME_DIR="$fixture_dir" RVBOX_NATIVE_ENDPOINT_HOST="$endpoint_host" \ + docker compose -p "$project" -f "$repo_root/test/linux-server/compose.yaml" "$@" +} + +rvc() { + compose exec -T server /opt/rvbox/rvc --socket /run/rvbox/server.sock "$@" +} + +prepare_files() { + umask 077 + mkdir -p "$fixture_dir" + [ ! -e "$server_config" ] || fail "refusing to overwrite existing $server_config" + [ ! -e "$client_config" ] || fail "refusing to overwrite existing $client_config" + printf '%s\n' '[server]' 'data_dir = "/state/data"' 'agent_listen = "0.0.0.0:6899"' 'control_socket = "/run/rvbox/server.sock"' '' '[observability]' 'listen = "0.0.0.0:6901"' >"$server_config" + printf '%s\n' \ + '[client]' \ + "server_url = \"wss://$endpoint_host:$port/v1/agent\"" \ + 'state_dir = "C:\\ProgramData\\RVBox\\test-state"' \ + "client_id = \"$client_id\"" \ + 'daemon_cwd = "C:\\ProgramData\\RVBox\\test-work"' \ + '' '[tls]' \ + '# Empty intentionally exercises v1 matching-host self-signed TLS.' \ + 'ca_file = ""' \ + "server_name = \"$endpoint_host\"" \ + '' '[observability]' \ + 'listen = "127.0.0.1:6902"' \ + 'log_file = "C:\\ProgramData\\RVBox\\test-logs\\rvbox.log"' >"$client_config" + chmod 600 "$server_config" "$client_config" +} + +stage_stack() { + # scripts/build intentionally execs its Docker command. Keep that process + # replacement inside a subshell so this lifecycle controller continues. + ("$repo_root/scripts/build" build) + install -d -m 700 "$fixture_dir/pki" "$fixture_dir/state/data" "$fixture_dir/state/control" + compose --profile tools run --rm certgen + compose up -d server nginx + attempt=0 + while [ "$attempt" -lt 30 ]; do + if rvc stat >/dev/null 2>&1; then return 0; fi + attempt=$((attempt + 1)); sleep 1 + done + compose logs --tail=200 + fail "server control socket did not become ready" +} + +wait_client() { + attempt=0 + while [ "$attempt" -lt 45 ]; do + state=$(rvc stat "$client_id" 2>/dev/null || true) + if printf '%s\n' "$state" | grep -q "client $client_id connected=true"; then return 0; fi + attempt=$((attempt + 1)); sleep 1 + done + compose logs --tail=200 + fail "native Windows client did not connect through nginx WSS" +} + +assert_context() { + label=$1 + elevated=$2 + want=$3 + if [ "$elevated" = yes ]; then + issued=$(rvc run --background --shell cmd --elevated "$client_id" "echo RVBOX_NATIVE_$label" 2>&1) || fail "$label admission failed: $issued" + else + issued=$(rvc run --background --shell cmd "$client_id" "echo RVBOX_NATIVE_$label" 2>&1) || fail "$label admission failed: $issued" + fi + issue=$(printf '%s\n' "$issued" | awk 'NR == 1 { print $1 }') + case $issue in ????????-????-7???-????-????????????) ;; *) fail "$label returned invalid issue UUID: $issued" ;; esac + attempt=0 + while [ "$attempt" -lt 45 ]; do + result=$(rvc stat "$client_id" "$issue" 2>/dev/null || true) + if printf '%s\n' "$result" | grep -q 'lifecycle=COMMAND_SUCCEEDED'; then + printf '%s\n' "$result" | grep -q "windows_effective_context=$want" || fail "$label effective context mismatch: $result" + printf '%s\n' "$result" >"$fixture_dir/$label.stat" + printf 'passed %s issue=%s context=%s\n' "$label" "$issue" "$want" + return 0 + fi + case $result in *'lifecycle=COMMAND_FAILED'*|*'lifecycle=COMMAND_REJECTED'*|*'lifecycle=COMMAND_TERMINATED'*) fail "$label did not succeed: $result" ;; esac + attempt=$((attempt + 1)); sleep 1 + done + fail "$label did not reach terminal success" +} + +collect() { + if [ "$vm_prepared" = yes ]; then + "$repo_root/scripts/windows/test-host" collect --run-id "$run_id" || true + fi + if [ -d "$fixture_dir" ]; then + compose logs --no-color --tail=500 >"$fixture_dir/server-proxy.log" 2>&1 || true + fi +} + +clean() { + compose down --volumes --remove-orphans || true + # A reset is the isolation boundary for the next run. Do not conceal a + # failed shutdown/snapshot restore behind a successful-looking `clean`: + # callers must repair or explicitly inspect the retained VM lease first. + "$repo_root/scripts/windows/test-host" reset --run-id "$run_id" + if [ "$purge" = yes ]; then + [ -L "$run_root" ] && fail "refusing symlink run root $run_root" + rm -rf "$run_root" + fi +} + +case $action in + recover) + "$repo_root/scripts/windows/test-host" recover --run-id "$run_id" + compose ps + printf 'run_root=%s\n' "$run_root" + ;; + clean) + collect + clean + printf 'cleaned run_id=%s\n' "$run_id" + ;; + run) + trap 'status=$?; if [ "$status" -ne 0 ]; then collect; fi' EXIT + [ ! -e "$run_root" ] || fail "refusing to reuse existing run root $run_root; inspect with recover or remove with clean --purge --yes" + prepare_files + stage_stack + "$repo_root/scripts/windows/build-test-bundle" --native-fixture --run-id "$run_id" --config "$client_config" + "$repo_root/scripts/windows/test-host" prepare --run-id "$run_id" + vm_prepared=yes + "$repo_root/scripts/windows/test-host" stage --run-id "$run_id" --bundle "$run_root/windows-bundle" + "$repo_root/scripts/windows/test-host" install --run-id "$run_id" + "$repo_root/scripts/windows/test-host" run --run-id "$run_id" --endpoint "$endpoint_host:$port" + wait_client + assert_context active-user no active-user + assert_context active-user-elevated yes active-user-elevated + "$repo_root/scripts/windows/test-host" run --run-id "$run_id" --fail-contexts ACTIVE_USER_ELEVATED + wait_client + assert_context active-system yes active-system + "$repo_root/scripts/windows/test-host" run --run-id "$run_id" --fail-contexts ACTIVE_USER_ELEVATED,ACTIVE_SYSTEM + wait_client + assert_context local-system-active-fallback yes local-system + "$repo_root/scripts/windows/test-host" run --run-id "$run_id" + wait_client + "$repo_root/scripts/windows/test-host" logoff --run-id "$run_id" + assert_context local-service no local-service + assert_context local-system-no-user yes local-system + collect + if [ "$keep" = no ]; then clean; fi + printf 'native Windows hierarchy run passed: %s\n' "$run_id" + ;; +esac diff --git a/scripts/windows/test-host b/scripts/windows/test-host index d11edd7..517bf78 100755 --- a/scripts/windows/test-host +++ b/scripts/windows/test-host @@ -12,7 +12,7 @@ repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) usage() { cat <<'EOF' -usage: scripts/windows/test-host ACTION [--run-id ID] [--bundle DIRECTORY] [--endpoint HOST:PORT] +usage: scripts/windows/test-host ACTION [--run-id ID] [--bundle DIRECTORY] [--endpoint HOST:PORT] [--fail-contexts LIST] Actions: status read-only VM/snapshot identity and state check @@ -20,7 +20,9 @@ Actions: stage copy a bundle containing rvbox.exe and client.toml into the guest test root install install and start RVBox from the staged bundle through a fixture-only full-admin principal run start the already-installed RVBox SCM service from the staged bundle + logoff log off the sole active fixture user; use only after service installation collect copy bounded guest artifacts to the local test-run directory + inspect read-only RVBox SCM state and bounded client log from a prepared run stop stop RVBox through SCM and request a graceful guest shutdown reset stop the guest if necessary, restore the declared baseline, and leave it off recover read-only fixture/run-state check for a stopped-resumable run @@ -34,6 +36,9 @@ Optional environment: (default Administrator and the documented fixture password file) RVBOX_TEST_HOST_STAGE_ROOT (default /home/cabbage/.local/state/rvbox-test-runs) RVBOX_TEST_RUN_ROOT (default .test-runs/windows-vm) + RVBOX_TEST_ACCEL_HTTP_URL, RVBOX_TEST_ACCEL_HTTP_AUTH, + RVBOX_TEST_ACCEL_HTTP_PUBLISH_DIR, RVBOX_TEST_ACCEL_SOCKS5 + (documented accelerated HTTP stage route; empty URL disables it) EOF } @@ -70,17 +75,19 @@ shift run_id= bundle= endpoint= +fail_contexts= while [ "$#" -gt 0 ]; do case $1 in --run-id) [ "$#" -ge 2 ] || fail "--run-id needs a value"; run_id=$2; shift 2 ;; --bundle) [ "$#" -ge 2 ] || fail "--bundle needs a value"; bundle=$2; shift 2 ;; --endpoint) [ "$#" -ge 2 ] || fail "--endpoint needs a value"; endpoint=$2; shift 2 ;; + --fail-contexts) [ "$#" -ge 2 ] || fail "--fail-contexts needs a value"; fail_contexts=$2; shift 2 ;; --help|-h) usage; exit 0 ;; *) fail "unknown argument $1" ;; esac done -case $action in status|prepare|stage|install|run|collect|stop|reset|recover) ;; *) usage >&2; fail "unknown action $action" ;; esac +case $action in status|prepare|stage|install|run|logoff|collect|inspect|stop|reset|recover) ;; *) usage >&2; fail "unknown action $action" ;; esac if [ "$action" != status ]; then [ -n "$run_id" ] || fail "$action requires --run-id" safe_id "$run_id" @@ -91,6 +98,7 @@ if [ "$action" = stage ]; then [ -f "$bundle/client.toml" ] || fail "bundle must contain client.toml" fi if [ -n "$endpoint" ]; then safe_word endpoint "$endpoint"; fi +if [ -n "$fail_contexts" ]; then safe_word fail_contexts "$fail_contexts"; fi # The Helium smoke fixture is the only supported native lane today. Keep its # non-secret identity and host-local password-file *path* here so a developer @@ -106,8 +114,13 @@ if [ -n "$endpoint" ]; then safe_word endpoint "$endpoint"; fi : "${RVBOX_TEST_GUEST_PASSWORD_FILE:=/home/cabbage/.local/share/rvbox-secrets/rvbox-win10-test.password}" : "${RVBOX_TEST_PROVISIONER_USER:=Administrator}" : "${RVBOX_TEST_PROVISIONER_PASSWORD_FILE:=$RVBOX_TEST_GUEST_PASSWORD_FILE}" +: "${RVBOX_TEST_ACCEL_HTTP_URL:=http://x1.xcel.me:9124}" +: "${RVBOX_TEST_ACCEL_HTTP_AUTH:=x1:x1}" +: "${RVBOX_TEST_ACCEL_HTTP_PUBLISH_DIR:=/home/ubuntu/Downloads}" +: "${RVBOX_TEST_ACCEL_SOCKS5:=socks5h://127.0.0.1:1085}" provisioner_user=$RVBOX_TEST_PROVISIONER_USER provisioner_password_file=$RVBOX_TEST_PROVISIONER_PASSWORD_FILE +accelerated_artifact= for name in RVBOX_TEST_VBOX_HOST RVBOX_TEST_VBOX_VM RVBOX_TEST_VBOX_VM_UUID \ RVBOX_TEST_VBOX_SNAPSHOT RVBOX_TEST_VBOX_SNAPSHOT_UUID \ @@ -124,24 +137,49 @@ safe_word RVBOX_TEST_GUEST_USER "$RVBOX_TEST_GUEST_USER" safe_word RVBOX_TEST_GUEST_PASSWORD_FILE "$RVBOX_TEST_GUEST_PASSWORD_FILE" if [ -n "$provisioner_user" ]; then safe_word RVBOX_TEST_PROVISIONER_USER "$provisioner_user"; fi if [ -n "$provisioner_password_file" ]; then safe_word RVBOX_TEST_PROVISIONER_PASSWORD_FILE "$provisioner_password_file"; fi +if [ -n "$RVBOX_TEST_ACCEL_HTTP_URL" ]; then + safe_word RVBOX_TEST_ACCEL_HTTP_URL "$RVBOX_TEST_ACCEL_HTTP_URL" + safe_word RVBOX_TEST_ACCEL_HTTP_AUTH "$RVBOX_TEST_ACCEL_HTTP_AUTH" + safe_word RVBOX_TEST_ACCEL_HTTP_PUBLISH_DIR "$RVBOX_TEST_ACCEL_HTTP_PUBLISH_DIR" + safe_word RVBOX_TEST_ACCEL_SOCKS5 "$RVBOX_TEST_ACCEL_SOCKS5" + case $RVBOX_TEST_ACCEL_HTTP_URL in http://*|https://*) ;; *) fail "RVBOX_TEST_ACCEL_HTTP_URL must use http(s)" ;; esac + case $RVBOX_TEST_ACCEL_SOCKS5 in socks5://*|socks5h://*) ;; *) fail "RVBOX_TEST_ACCEL_SOCKS5 must use socks5" ;; esac +fi host_stage_root=${RVBOX_TEST_HOST_STAGE_ROOT:-/home/cabbage/.local/state/rvbox-test-runs} run_root=${RVBOX_TEST_RUN_ROOT:-$repo_root/.test-runs/windows-vm} safe_word RVBOX_TEST_HOST_STAGE_ROOT "$host_stage_root" remote_run_id=${run_id:-fixture-status} host_stage=$host_stage_root/$remote_run_id -guest_root="C:\\ProgramData\\RVBox\\test-runs\\$remote_run_id" +# This value crosses a remote POSIX shell before Guest Control. Windows accepts +# forward slashes, which avoids backslash loss while SSH reconstructs argv. +guest_root="C:/ProgramData/RVBox/test-runs/$remote_run_id" remote() { # All values below are constrained words before becoming remote shell - # arguments. Password contents are never transmitted or printed; only the - # approved host-local password-file path is passed to VBoxManage. + # arguments. Password contents are never transmitted or printed; only the + # approved host-local password-file path is passed to VBoxManage. Execute + # the helper through this one SSH connection: the former upload-then-run + # scheme could race with a stale controller that removed the shared helper + # filename between those two connections. + remote_action=$1 + retry_limit=1 + # These operations are either read-only or converge on the same staged + # artifact/service configuration. A lost SSH response is therefore safe to + # retry. Lifecycle transitions remain single-attempt: their caller must + # inspect/recover rather than risk a duplicate reset, shutdown, or logoff. + case $remote_action in + status|recover|prepare-stage|stage|stage-create-root|stage-copy-exe|stage-copy-config|stage-copy-ca|collect|inspect|install|run) + retry_limit=4 + ;; + esac remote_endpoint=${endpoint:--} - remote_script=/home/cabbage/.local/state/rvbox-test-controller/$remote_run_id.sh - ssh -o BatchMode=yes "$RVBOX_TEST_VBOX_HOST" \ - "install -d -m 700 /home/cabbage/.local/state/rvbox-test-controller && cat > '$remote_script' && chmod 700 '$remote_script'" <<'REMOTE' + remote_fail_contexts=${fail_contexts:--} + retry_attempt=1 + while [ "$retry_attempt" -le "$retry_limit" ]; do + if ssh -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=10 -o ServerAliveCountMax=2 "$RVBOX_TEST_VBOX_HOST" \ + "sh -s -- '$1' '$RVBOX_TEST_VBOX_VM' '$RVBOX_TEST_VBOX_VM_UUID' '$RVBOX_TEST_VBOX_SNAPSHOT' '$RVBOX_TEST_VBOX_SNAPSHOT_UUID' '$RVBOX_TEST_GUEST_USER' '$RVBOX_TEST_GUEST_PASSWORD_FILE' '$host_stage' '$guest_root' '$remote_endpoint' '$provisioner_user' '$provisioner_password_file' '$remote_fail_contexts'" <<'REMOTE' set -eu -trap 'rm -f "$0"' EXIT action=$1 vm=$2 @@ -156,7 +194,9 @@ shift 9 endpoint=$1 provisioner_user=$2 provisioner_password_file=$3 +fail_contexts=$4 [ "$endpoint" = - ] && endpoint= +[ "$fail_contexts" = - ] && fail_contexts= fail() { printf '%s\n' "remote test-host: $*" >&2; exit 2; } @@ -289,6 +329,19 @@ wait_service() { fail "RVBoxClient did not reach $wanted" } +wait_service_stopped() { + attempt=0 + while [ "$attempt" -lt 30 ]; do + if guest_run --exe 'C:\\Windows\\System32\\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \ + /d /s /c 'sc.exe query RVBoxClient | findstr /c:"STOPPED" >NUL && echo RVBOX_GUEST_OK' >/dev/null 2>&1; then + return 0 + fi + attempt=$((attempt + 1)) + sleep 1 + done + fail "RVBoxClient did not reach STOPPED" +} + case "$action" in prepare-stage) assert_identity @@ -332,8 +385,8 @@ case "$action" in assert_identity require_lease [ "$(state)" = running ] || fail "stage requires a running prepared VM" - guest_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \ - /d /s /c "if not exist \"$guest_root\" mkdir \"$guest_root\" & echo RVBOX_GUEST_OK" >/dev/null + guest_run --exe 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' --wait-stdout --wait-stderr --unquoted-args -- \ + -NoProfile -NonInteractive -Command "New-Item -ItemType Directory -Force -Path '$guest_root','C:/ProgramData/RVBox/test-work','C:/ProgramData/RVBox/test-logs' | Out-Null; Write-Output RVBOX_GUEST_OK" >/dev/null ;; stage-copy-exe) assert_identity @@ -384,7 +437,14 @@ case "$action" in guest_run --exe 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' --wait-stdout --wait-stderr --unquoted-args -- \ -NoProfile -NonInteractive -Command "if (-not (Test-NetConnection -ComputerName '$endpoint_host' -Port $endpoint_port -InformationLevel Quiet)) { exit 1 }; Write-Output RVBOX_GUEST_OK" >/dev/null fi - image="\\\"$guest_root\\rvbox.exe\\\" --service --config \\\"$guest_root\\client.toml\\\"" + # Reconfigure from a stopped service so a controlled fixture fault cannot + # leak across hierarchy rows. The release build rejects this argument; + # only the separately tagged disposable test binary accepts it. + provisioner_run --exe 'C:\\Windows\\System32\\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \ + /d /s /c '(sc.exe stop RVBoxClient >NUL 2>&1 || sc.exe query RVBoxClient | findstr /c:"STOPPED" >NUL) && echo RVBOX_GUEST_OK' >/dev/null || true + wait_service_stopped + image="\\\"$guest_root\\rvbox.exe\\\" --service --config \\\"$guest_root\\client.toml\\\"" + if [ -n "$fail_contexts" ]; then image="$image --test-fail-contexts $fail_contexts"; fi provisioner_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \ /d /s /c "sc.exe query RVBoxClient >NUL 2>&1 && echo RVBOX_GUEST_OK" >/dev/null || \ fail "RVBoxClient is not installed; run install from the clean baseline first" @@ -397,6 +457,32 @@ case "$action" in wait_service RUNNING printf 'service=RVBoxClient state=RUNNING\n' ;; + logoff) + assert_identity + require_lease + [ "$(state)" = running ] || fail "logoff requires a running prepared VM" + # The clean fixture has exactly one active console account. Logging it off + # leaves the LocalSystem service alive and makes the no-user hierarchy + # rows observable without introducing a second session candidate. Do not + # run `logoff` through that account's Guest Control channel: Windows ends + # the channel before VBoxManage can return its completion sentinel. The + # fixture-only full-token provisioner is non-interactive (and separately + # asserted absent from WTS candidates), so it can prove the transition. + provisioner_run --exe 'C:\\Windows\\System32\\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \ + /d /s /c "logoff 1 & echo RVBOX_GUEST_OK" >/dev/null + attempt=0 + while [ "$attempt" -lt 30 ]; do + if provisioner_run --exe 'C:\\Windows\\System32\\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \ + /d /s /c "query user | findstr /i /c:\"$guest_user\" >NUL & if errorlevel 1 echo RVBOX_GUEST_OK" >/dev/null 2>&1; then + step logoff-no-active-user + printf 'session=none\n' + break + fi + attempt=$((attempt + 1)) + sleep 1 + done + [ "$attempt" -lt 30 ] || fail "fixture user did not log off" + ;; collect) assert_identity require_lease @@ -409,6 +495,13 @@ case "$action" in fi printf 'collected host_stage=%s/artifacts\n' "$host_stage" ;; + inspect) + assert_identity + require_lease + [ "$(state)" = running ] || fail "inspect requires a running prepared VM" + guest_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \ + /d /s /c "sc.exe queryex RVBoxClient & sc.exe qc RVBoxClient & reg.exe query \"HKLM\\SYSTEM\\CurrentControlSet\\Services\\RVBoxClient\" /v ImagePath & reg.exe query \"HKLM\\SYSTEM\\CurrentControlSet\\Services\\RVBoxClient\" /v ObjectName & dir \"C:/ProgramData/RVBox\" & icacls \"C:/ProgramData/RVBox\" & certutil -hashfile \"$guest_root\\rvbox.exe\" SHA256 & \"$guest_root\\rvbox.exe\" --check-config --config \"$guest_root\\client.toml\" > \"$guest_root\\check-config.txt\" 2>&1 & type \"$guest_root\\check-config.txt\" & \"$guest_root\\rvbox.exe\" --service --config \"$guest_root\\client.toml\" > \"$guest_root\\direct-service-probe.txt\" 2>&1 & type \"$guest_root\\direct-service-probe.txt\" & if exist \"C:/ProgramData/RVBox/service-startup.log\" type \"C:/ProgramData/RVBox/service-startup.log\" & wevtutil qe System /q:\"*[System[(EventID=7000 or EventID=7009 or EventID=7031 or EventID=7034)]]\" /c:3 /rd:true /f:text & if exist \"C:/ProgramData/RVBox/test-logs/rvbox.log\" type \"C:/ProgramData/RVBox/test-logs/rvbox.log\" & echo RVBOX_GUEST_OK" + ;; stop) assert_identity require_lease @@ -428,6 +521,16 @@ case "$action" in ;; reset) assert_identity + # A controller may be interrupted after it has brought down its + # Compose stack but before it removes local run files. When the VM is + # already powered off at the declared baseline and no lease exists, + # reset is therefore a safe no-op. It lets `native-test clean` repair + # that abandoned local run without pretending it owns a live VM. + if [ ! -f "$lease_owner" ]; then + [ "$(state)" = poweroff ] || fail "reset requires the run lease while the VM is not powered off" + printf 'reset vm=%s snapshot=%s already-clean\n' "$vm" "$snapshot" + exit 0 + fi require_lease if [ "$(state)" = running ]; then VBoxManage controlvm "$vm" acpipowerbutton >/dev/null @@ -454,12 +557,74 @@ case "$action" in ;; esac REMOTE - ssh -o BatchMode=yes "$RVBOX_TEST_VBOX_HOST" sh "$remote_script" \ - "$1" "$RVBOX_TEST_VBOX_VM" "$RVBOX_TEST_VBOX_VM_UUID" \ - "$RVBOX_TEST_VBOX_SNAPSHOT" "$RVBOX_TEST_VBOX_SNAPSHOT_UUID" \ - "$RVBOX_TEST_GUEST_USER" "$RVBOX_TEST_GUEST_PASSWORD_FILE" \ - "$host_stage" "$guest_root" "$remote_endpoint" \ - "$provisioner_user" "$provisioner_password_file" &2 + sleep 2 + fi + done + fail "remote action $remote_action exhausted $retry_limit SSH attempts" +} + +# accelerated_stage uses the documented controller HTTP endpoint only for a +# compressed disposable test executable. The endpoint remains authenticated; +# the artifact name contains the run ID and payload digest and is deleted after +# successful guest staging. A failed HTTP attempt leaves no host executable +# change and fails the stage; the executable is never sent over the fragile +# SSH route. +accelerated_stage() { + [ -d "$RVBOX_TEST_ACCEL_HTTP_PUBLISH_DIR" ] || { + printf 'stage: accelerated publish directory unavailable\n' >&2 + return 1 + } + stage_http_dir=$(mktemp -d "${TMPDIR:-/tmp}/rvbox-http-stage.XXXXXX") + stage_http_xz=$stage_http_dir/rvbox.exe.xz + xz -T0 -3 -c "$bundle/rvbox.exe" >"$stage_http_xz" + stage_http_hash=$(sha256sum "$stage_http_xz" | awk '{print $1}') + stage_http_name="rvbox-$run_id-$stage_http_hash.xz" + stage_http_published=$RVBOX_TEST_ACCEL_HTTP_PUBLISH_DIR/$stage_http_name + if [ -e "$stage_http_published" ]; then + [ ! -L "$stage_http_published" ] || fail "refusing symlink accelerated artifact $stage_http_published" + existing_hash=$(sha256sum "$stage_http_published" | awk '{print $1}') + [ "$existing_hash" = "$stage_http_hash" ] || fail "accelerated artifact name collision: $stage_http_published" + else + # The payload contains no configuration or credential. It is readable + # only to the authenticated HTTP service so nginx can serve it. + install -m 644 "$stage_http_xz" "$stage_http_published" + fi + rm -rf "$stage_http_dir" + stage_http_url=$RVBOX_TEST_ACCEL_HTTP_URL/$stage_http_name + printf 'stage: accelerated HTTP transfer\n' + if ! ssh -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=10 -o ServerAliveCountMax=2 "$RVBOX_TEST_VBOX_HOST" \ + "set -eu; stage='$host_stage'; target=\$stage/rvbox.exe.xz.http; curl --proxy '$RVBOX_TEST_ACCEL_SOCKS5' --anyauth -u '$RVBOX_TEST_ACCEL_HTTP_AUTH' --continue-at - --retry 4 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 --fail --silent --show-error --output \$target '$stage_http_url'; expected='$stage_http_hash'; actual=\$(sha256sum \$target | awk '{print \$1}'); test \"\$actual\" = \"\$expected\"; xz -dc \$target >\$stage/rvbox.exe.new; chmod 700 \$stage/rvbox.exe.new; mv \$stage/rvbox.exe.new \$stage/rvbox.exe; rm -f \$target"; then + printf 'stage: accelerated HTTP transfer failed\n' >&2 + rm -f "$stage_http_published" + return 1 + fi + accelerated_artifact=$stage_http_published + return 0 +} + +# Only small non-secret configuration files use the SSH control route. The +# executable itself always uses accelerated_stage above. +copy_stage_file() { + source_file=$1 + target_name=$2 + copy_attempt=1 + while [ "$copy_attempt" -le 4 ]; do + if scp -q "$source_file" "$RVBOX_TEST_VBOX_HOST:$host_stage/$target_name"; then + return 0 + fi + copy_attempt=$((copy_attempt + 1)) + if [ "$copy_attempt" -le 4 ]; then + printf 'stage: small-file SSH copy retry %s/4 (%s)\n' "$copy_attempt" "$target_name" >&2 + sleep 2 + fi + done + fail "could not copy staged $target_name after 4 SSH attempts" } case $action in @@ -468,14 +633,43 @@ case $action in printf 'prepared vm=%s stage=%s\n' "$RVBOX_TEST_VBOX_VM" "$host_stage" ;; stage) + printf 'stage: verify prepared VM and lease\n' remote prepare-stage - scp -q "$bundle/rvbox.exe" "$bundle/client.toml" "$RVBOX_TEST_VBOX_HOST:$host_stage/" - if [ -f "$bundle/ca.pem" ]; then scp -q "$bundle/ca.pem" "$RVBOX_TEST_VBOX_HOST:$host_stage/"; fi + [ -f "$bundle/rvbox.exe" ] && [ ! -L "$bundle/rvbox.exe" ] || fail "rvbox.exe must be a regular non-symlink file" + [ -f "$bundle/client.toml" ] && [ ! -L "$bundle/client.toml" ] || fail "client.toml must be a regular non-symlink file" + if [ -f "$bundle/ca.pem" ]; then + [ ! -L "$bundle/ca.pem" ] || fail "ca.pem must not be a symlink" + local_hashes=$(cd "$bundle" && sha256sum rvbox.exe client.toml ca.pem) + else + local_hashes=$(cd "$bundle" && sha256sum rvbox.exe client.toml) + fi + copy_stage_file "$bundle/client.toml" client.toml + if [ -f "$bundle/ca.pem" ]; then copy_stage_file "$bundle/ca.pem" ca.pem; fi + local_exe_hash=$(sha256sum "$bundle/rvbox.exe" | awk '{print $1}') + remote_exe_hash=$(ssh -o BatchMode=yes "$RVBOX_TEST_VBOX_HOST" "sha256sum '$host_stage/rvbox.exe' 2>/dev/null | awk '{print \$1}'" 2>/dev/null || true) + if [ "$local_exe_hash" = "$remote_exe_hash" ]; then + printf 'stage: matching accelerated executable already present\n' + else + accelerated_stage || fail "accelerated executable transfer failed" + trap 'if [ -n "$accelerated_artifact" ]; then rm -f "$accelerated_artifact"; fi' EXIT HUP INT TERM + fi + if [ -f "$bundle/ca.pem" ]; then + remote_hashes=$(ssh -o BatchMode=yes "$RVBOX_TEST_VBOX_HOST" "cd '$host_stage' && sha256sum rvbox.exe client.toml ca.pem" 2>/dev/null || true) + else + remote_hashes=$(ssh -o BatchMode=yes "$RVBOX_TEST_VBOX_HOST" "cd '$host_stage' && sha256sum rvbox.exe client.toml" 2>/dev/null || true) + fi + [ "$local_hashes" = "$remote_hashes" ] || fail "bundle transfer did not reach the expected SHA-256 manifest" + printf 'verified transfer_sha256 run_id=%s\n%s\n' "$run_id" "$local_hashes" remote stage remote stage-create-root remote stage-copy-exe remote stage-copy-config if [ -f "$bundle/ca.pem" ]; then remote stage-copy-ca; fi + if [ -n "$accelerated_artifact" ]; then + rm -f "$accelerated_artifact" + accelerated_artifact= + trap - EXIT HUP INT TERM + fi printf 'staged guest_root=%s\n' "$guest_root" ;; collect) diff --git a/test/coverage.toml b/test/coverage.toml index 1c5fcb6..8861ceb 100644 --- a/test/coverage.toml +++ b/test/coverage.toml @@ -590,8 +590,14 @@ tests = ["internal/client/spool/spool_test.go:TestSendWindowPinsUnacknowledgedBy [[requirements]] id = "HP-WINCTX-02" layer = "integration" -status = "blocked_native_windows" -tests = [] +status = "planned" +tests = ["test/windowsnative/native_fixture_test.go:TestNativeFixtureAssets_HP_HARNESS_20"] + +[[requirements]] +id = "HP-HARNESS-20" +layer = "unit" +status = "implemented" +tests = ["test/windowsnative/native_fixture_test.go:TestNativeFixtureAssets_HP_HARNESS_20"] [[requirements]] id = "HP-STORE-01" diff --git a/test/linux-server/README.md b/test/linux-server/README.md new file mode 100644 index 0000000..9ca2d39 --- /dev/null +++ b/test/linux-server/README.md @@ -0,0 +1,11 @@ +# Linux server native-test stack + +This directory owns the Docker Compose stack for the Windows native E2E lane. +It runs on the current Linux controller and owns the Linux RVBox server, nginx +TLS proxy with a per-run matching-host self-signed leaf, SQLite/segment state, +control socket, and logs. The v1 client deliberately accepts this self-signed +leaf when no CA file is configured; it is encrypted transport, not server +authentication. + +The Helium VM never hosts this stack. It receives only rvbox.exe and client +TOML through the Windows fixture controller. diff --git a/test/linux-server/certgen.sh b/test/linux-server/certgen.sh new file mode 100755 index 0000000..86b06d7 --- /dev/null +++ b/test/linux-server/certgen.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +test -f /pki/server.pem && test -f /pki/server-key.pem && exit 0 +apk add --no-cache openssl +umask 077 +openssl genrsa -out /pki/server-key.pem 2048 +openssl req -x509 -new -key /pki/server-key.pem -sha256 -days 2 \ + -subj "/CN=${RVBOX_NATIVE_ENDPOINT_HOST:?}" \ + -addext "subjectAltName=DNS:${RVBOX_NATIVE_ENDPOINT_HOST}" \ + -out /pki/server.pem +chmod 600 /pki/*key.pem +chmod 644 /pki/server.pem diff --git a/test/linux-server/compose.yaml b/test/linux-server/compose.yaml new file mode 100644 index 0000000..0ede422 --- /dev/null +++ b/test/linux-server/compose.yaml @@ -0,0 +1,37 @@ +# Per-run production-shaped Linux server/proxy fixture. It runs on the current +# controller; the Windows VM receives only its client bundle. +services: + server: + image: alpine:3.22 + user: "${RVBOX_NATIVE_UID:-65532}:${RVBOX_NATIVE_GID:-65532}" + command: ["/opt/rvbox/rvbox-server", "--config", "/etc/rvbox/server.toml"] + volumes: + - ../../bin/rvbox-server:/opt/rvbox/rvbox-server:ro + - ../../bin/rvc:/opt/rvbox/rvc:ro + - "${RVBOX_NATIVE_RUNTIME_DIR}/server.toml:/etc/rvbox/server.toml:ro" + - "${RVBOX_NATIVE_RUNTIME_DIR}/state:/state" + - "${RVBOX_NATIVE_RUNTIME_DIR}/state/control:/run/rvbox" + networks: [native] + labels: { rvbox.native.run_id: "${RVBOX_NATIVE_RUN_ID}" } + nginx: + image: nginx:1.27-alpine + depends_on: [server] + ports: [ "${RVBOX_NATIVE_BIND:-0.0.0.0}:${RVBOX_NATIVE_PORT}:443" ] + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - "${RVBOX_NATIVE_RUNTIME_DIR}/pki/server.pem:/etc/nginx/tls/server.pem:ro" + - "${RVBOX_NATIVE_RUNTIME_DIR}/pki/server-key.pem:/etc/nginx/tls/server-key.pem:ro" + networks: [native] + labels: { rvbox.native.run_id: "${RVBOX_NATIVE_RUN_ID}" } + certgen: + image: alpine:3.22 + profiles: [tools] + environment: + RVBOX_NATIVE_ENDPOINT_HOST: "${RVBOX_NATIVE_ENDPOINT_HOST}" + volumes: + - "${RVBOX_NATIVE_RUNTIME_DIR}/pki:/pki" + - ./certgen.sh:/fixture/certgen.sh:ro + entrypoint: ["/bin/sh", "/fixture/certgen.sh"] +networks: + native: + labels: { rvbox.native.run_id: "${RVBOX_NATIVE_RUN_ID}" } diff --git a/test/linux-server/nginx.conf b/test/linux-server/nginx.conf new file mode 100644 index 0000000..61d9a77 --- /dev/null +++ b/test/linux-server/nginx.conf @@ -0,0 +1,21 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} +server { + listen 443 ssl; + server_name _; + ssl_certificate /etc/nginx/tls/server.pem; + ssl_certificate_key /etc/nginx/tls/server-key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + location = /v1/agent { + proxy_pass http://server:6899; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_read_timeout 75s; + proxy_send_timeout 15s; + } + location / { return 404; } +} diff --git a/test/windowsnative/native_fixture_test.go b/test/windowsnative/native_fixture_test.go new file mode 100644 index 0000000..b8b0c9f --- /dev/null +++ b/test/windowsnative/native_fixture_test.go @@ -0,0 +1,56 @@ +package windowsnative + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestNativeFixtureAssets_HP_HARNESS_20 prevents the host-side controller from +// drifting back to a PowerShell-only or protocol-stub lane. It intentionally +// checks only static contracts; the real hierarchy proof remains the documented +// native VM run. +func TestNativeFixtureAssets_HP_HARNESS_20(t *testing.T) { + t.Parallel() + root := filepath.Clean(filepath.Join("..", "..")) + read := func(relative string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, relative)) + if err != nil { + t.Fatalf("read %s: %v", relative, err) + } + return string(data) + } + runner := read("scripts/windows/native-test") + for _, required := range []string{ + "scripts/windows/build-test-bundle\" --native-fixture", + "scripts/windows/test-host\" prepare", + "ACTIVE_USER_ELEVATED,ACTIVE_SYSTEM", + "assert_context local-service no local-service", + "clean --purge --yes", + "RVBOX_NATIVE_ENDPOINT_HOST", + "test/linux-server/compose.yaml", + } { + if !strings.Contains(runner, required) { + t.Fatalf("native runner is missing %q", required) + } + } + testHost := read("scripts/windows/test-host") + for _, required := range []string{"xz -T0 -3", "accelerated_stage", "copy_stage_file", "--proxy", "--anyauth", "--continue-at", "rvbox.exe.xz", "retry_limit=4", "ConnectTimeout=10", "bundle transfer did not reach the expected SHA-256 manifest", "verified transfer_sha256"} { + if !strings.Contains(testHost, required) { + t.Fatalf("native test-host is missing compressed transfer contract %q", required) + } + } + compose := read("test/linux-server/compose.yaml") + for _, required := range []string{"../../bin/rvbox-server", "nginx:1.27-alpine", "rvbox.native.run_id", "RVBOX_NATIVE_RUNTIME_DIR"} { + if !strings.Contains(compose, required) { + t.Fatalf("native Compose fixture is missing %q", required) + } + } + defaults := read("internal/client/supervisor/windows/testfaults_default.go") + fixture := read("internal/client/supervisor/windows/testfaults_fixture.go") + if !strings.Contains(defaults, "//go:build !rvbox_native_test") || !strings.Contains(fixture, "//go:build rvbox_native_test") { + t.Fatal("fixture-only context faults are not separated from release builds") + } +}