feat: add native Windows hierarchy test harness
This commit is contained in:
+45
-1
@@ -33,6 +33,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var nativeTestContextFailures map[clientwindows.ExecutionContext]bool
|
||||||
|
|
||||||
// run is deliberately a small mode dispatcher. The SCM service invokes only
|
// run is deliberately a small mode dispatcher. The SCM service invokes only
|
||||||
// --service with an explicit config path; tray/helper modes cannot silently
|
// --service with an explicit config path; tray/helper modes cannot silently
|
||||||
// turn an ordinary process invocation into a privileged service.
|
// turn an ordinary process invocation into a privileged service.
|
||||||
@@ -60,6 +62,7 @@ func run(args []string, output, diagnostics io.Writer) error {
|
|||||||
start := flags.Bool("start-service", false, "start the machine-wide service")
|
start := flags.Bool("start-service", false, "start the machine-wide service")
|
||||||
stop := flags.Bool("stop-service", false, "stop 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")
|
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 {
|
if err := flags.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -120,6 +123,11 @@ func run(args []string, output, diagnostics io.Writer) error {
|
|||||||
}
|
}
|
||||||
return runSignalHelper(*channel, diagnostics)
|
return runSignalHelper(*channel, diagnostics)
|
||||||
}
|
}
|
||||||
|
var testFaultErr error
|
||||||
|
nativeTestContextFailures, testFaultErr = clientwindows.NativeTestContextFailures(*testFailContexts)
|
||||||
|
if testFaultErr != nil {
|
||||||
|
return testFaultErr
|
||||||
|
}
|
||||||
return runService(*configPath, diagnostics)
|
return runService(*configPath, diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +175,7 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
|
|||||||
limits = agentproto.DefaultLimits()
|
limits = agentproto.DefaultLimits()
|
||||||
}
|
}
|
||||||
eventReady := make(chan domain.UUID, 256)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("configure command supervisor: %w", err)
|
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")
|
return nil, errors.New("TLS CA file contains no certificates")
|
||||||
}
|
}
|
||||||
tlsConfig.RootCAs = pool
|
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
|
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 {
|
func clientHello(configured *config.Client, instance domain.UUID) *rvboxv1.ClientHello {
|
||||||
platform := rvboxv1.Platform_PLATFORM_LINUX
|
platform := rvboxv1.Platform_PLATFORM_LINUX
|
||||||
shells := []rvboxv1.ShellType{rvboxv1.ShellType_SHELL_CMD, rvboxv1.ShellType_SHELL_POWERSHELL}
|
shells := []rvboxv1.ShellType{rvboxv1.ShellType_SHELL_CMD, rvboxv1.ShellType_SHELL_POWERSHELL}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rvbox/rvbox/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestClientModeSelectionRequiresExactlyOneMode_HP_WINCLI_01(t *testing.T) {
|
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) {
|
func TestNonWindowsServiceModesRemainExplicitlyUnsupported_BH_WINCLI_01(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
if err := runService("", nil); err == nil {
|
if err := runService("", nil); err == nil {
|
||||||
|
|||||||
@@ -26,30 +26,77 @@ func defaultClientConfigPath() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runService(configPath string, diagnostics io.Writer) error {
|
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()
|
inService, err := svc.IsWindowsService()
|
||||||
if err != nil {
|
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 {
|
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)
|
return runWindowsService(configPath, diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runWindowsService(configPath string, diagnostics io.Writer) error {
|
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
|
// The tray endpoint lives in the same LocalSystem service process. It
|
||||||
// has no store access; the handler below returns only bounded status/path
|
// has no store access; the handler below returns only bounded status/path
|
||||||
// data and rechecks SCM authorization in the native pipe adapter.
|
// data and rechecks SCM authorization in the native pipe adapter.
|
||||||
go func() {
|
go func() {
|
||||||
if err := windowstray.Serve(ctx, func(requestContext context.Context, _ windowstray.Peer, request windowstray.Frame) (windowstray.Frame, error) {
|
if err := windowstray.Serve(ctx, func(requestContext context.Context, _ windowstray.Peer, request windowstray.Frame) (windowstray.Frame, error) {
|
||||||
return handleTrayRequest(requestContext, configPath, request)
|
return handleTrayRequest(requestContext, configPath, request)
|
||||||
}); err != nil && ctx.Err() == nil && diagnostics != nil {
|
}); err != nil && ctx.Err() == nil {
|
||||||
_, _ = fmt.Fprintf(diagnostics, "rvbox tray endpoint stopped: %v\n", err)
|
_, _ = 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 {
|
func runTray(configPath string, diagnostics io.Writer) error {
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ use the defaults documented by the all-knob client reference.
|
|||||||
- The daemon prints its effective configuration after validation, with no
|
- The daemon prints its effective configuration after validation, with no
|
||||||
command data or TLS material. Since v1 stores command/environment payloads in
|
command data or TLS material. Since v1 stores command/environment payloads in
|
||||||
plaintext, configuration output is hygiene rather than a secrecy guarantee.
|
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
|
## Limits and cross-field validation
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ max_queued_commands = 100
|
|||||||
shutdown_grace = "30s"
|
shutdown_grace = "30s"
|
||||||
|
|
||||||
[tls]
|
[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 = ""
|
ca_file = ""
|
||||||
# Optional certificate name override; empty derives it from server_url.
|
# Optional certificate name override; empty derives it from server_url.
|
||||||
server_name = ""
|
server_name = ""
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ max_queued_commands = 100
|
|||||||
shutdown_grace = "30s"
|
shutdown_grace = "30s"
|
||||||
|
|
||||||
[tls]
|
[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 = ""
|
ca_file = ""
|
||||||
# Optional certificate-name override; empty derives it from server_url.
|
# Optional certificate-name override; empty derives it from server_url.
|
||||||
server_name = ""
|
server_name = ""
|
||||||
|
|||||||
@@ -740,6 +740,45 @@ side effect must add a hook and coverage row before merging.
|
|||||||
|
|
||||||
### 2.6 Native Windows test-host requirements
|
### 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
|
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
|
unit tests and cross-compilation before a host is connected, but the Windows
|
||||||
supervisor/service/tray implementation cannot be called complete without it.
|
supervisor/service/tray implementation cannot be called complete without it.
|
||||||
|
|||||||
@@ -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
|
recorded in the run report and reclaimed by the snapshot reset rather than broad
|
||||||
guest deletion.
|
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
|
### Clean baseline and non-interactive installation
|
||||||
|
|
||||||
`rvboxtest` deliberately remains a split-token administrator. Guest Control
|
`rvboxtest` deliberately remains a split-token administrator. Guest Control
|
||||||
|
|||||||
@@ -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,
|
file. It is a test artifact, not a signed release package; release signing,
|
||||||
version resources, and publication are Phase 8 gates.
|
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
|
The integration harness provides the Phase 0 `sample` suite, the incremental
|
||||||
Phase 2 `store` suite, and the incremental Phase 3 `server-session` suite.
|
Phase 2 `store` suite, and the incremental Phase 3 `server-session` suite.
|
||||||
The resumable E2E harness adds `smoke`, `script`, `recovery`, and `all`
|
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
|
starts the service. Wine and protocol stubs are not equivalent Windows
|
||||||
coverage.
|
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,
|
The clean baseline intentionally contains no RVBox service, tray registration,
|
||||||
or RVBox state. Guest Control supplies `rvboxtest` with a filtered medium UAC
|
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
|
token, so it cannot safely perform the first machine-wide install. The fixture
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ func syncDirectory(path string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,9 @@ const privateStateSDDL = "D:P(A;;FA;;;SY)(A;;FA;;;BA)"
|
|||||||
func ensurePrivateFile(path string) error {
|
func ensurePrivateFile(path string) error {
|
||||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = file.Close()
|
return file.Close()
|
||||||
} else if !errors.Is(err, os.ErrExist) {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if !errors.Is(err, os.ErrExist) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
info, err := os.Lstat(path)
|
info, err := os.Lstat(path)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -116,8 +117,7 @@ func Open(ctx context.Context, options Options) (*Store, error) {
|
|||||||
query.Add("_pragma", "foreign_keys(ON)")
|
query.Add("_pragma", "foreign_keys(ON)")
|
||||||
query.Add("_pragma", "synchronous(FULL)")
|
query.Add("_pragma", "synchronous(FULL)")
|
||||||
query.Add("_pragma", "busy_timeout("+strconv.FormatInt(options.BusyTimeout.Milliseconds(), 10)+")")
|
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", sqliteFileURI(databasePath, query))
|
||||||
db, err := sql.Open("sqlite", databaseURL.String())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = unlock()
|
_ = unlock()
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -136,6 +136,17 @@ func Open(ctx context.Context, options Options) (*Store, error) {
|
|||||||
return store, nil
|
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) ClientInstanceID() domain.UUID { return store.identity }
|
||||||
|
|
||||||
func (store *Store) Close() error {
|
func (store *Store) Close() error {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -16,6 +17,13 @@ import (
|
|||||||
"google.golang.org/protobuf/proto"
|
"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) {
|
func TestExecutionSpecIsDurableAndQuotaCounted_HP_DISPATCH_07(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ type NativeOptions struct {
|
|||||||
MaxWrapperBytes uint64
|
MaxWrapperBytes uint64
|
||||||
MaxOutputChunk uint64
|
MaxOutputChunk uint64
|
||||||
WindowsTermGrace time.Duration
|
WindowsTermGrace time.Duration
|
||||||
|
// 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
|
Now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -388,6 +388,10 @@ func (manager *execSupervisor) selectToken(elevated bool) (winapi.Token, supervi
|
|||||||
}
|
}
|
||||||
for _, attempt := range selection.Attempts {
|
for _, attempt := range selection.Attempts {
|
||||||
token, identity, err := openTokenForAttempt(attempt.Context, selected)
|
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 {
|
if err == nil {
|
||||||
return token, withEvidence(identity), 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)
|
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
|
// The pure selector stops as soon as ACTIVE_SYSTEM is available. A native
|
||||||
// privilege/session operation can still fail (for example, SeTcb was
|
// privilege/session operation can still fail (for example, SeTcb was
|
||||||
// removed), so the final LOCAL_SYSTEM fallback is attempted here before
|
// removed), so the final LOCAL_SYSTEM fallback is attempted here before
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -74,8 +74,13 @@ func newTrayPipe() (*os.File, error) {
|
|||||||
Length: uint32(unsafe.Sizeof(winapi.SecurityAttributes{})),
|
Length: uint32(unsafe.Sizeof(winapi.SecurityAttributes{})),
|
||||||
SecurityDescriptor: descriptor,
|
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)
|
// CreateNamedPipe has separate open-mode and pipe-mode arguments. Remote
|
||||||
handle, err := winapi.CreateNamedPipe(name, mode, pipeInstances, pipeBufferBytes, pipeBufferBytes, 0, 0, attributes)
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestStrictTOMLRejections_HP_CFG_01(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -21,10 +22,13 @@ func validateAbsolutePath(name, value string, platform Platform, allowEmpty bool
|
|||||||
}
|
}
|
||||||
return cleanWindowsPath(value), nil
|
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 "", 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 {
|
func isWindowsAbsolute(value string) bool {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
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:
|
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}
|
.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 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
|
the target guest paths and the declared test nginx endpoint. Existing bundles
|
||||||
are refused rather than overwritten.
|
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
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,12 +28,14 @@ run_id=
|
|||||||
config=
|
config=
|
||||||
ca=
|
ca=
|
||||||
build=yes
|
build=yes
|
||||||
|
native_fixture=no
|
||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
--run-id) [ "$#" -ge 2 ] || fail "--run-id needs a value"; run_id=$2; shift 2 ;;
|
--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 ;;
|
--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 ;;
|
--ca) [ "$#" -ge 2 ] || fail "--ca needs a PEM file"; ca=$2; shift 2 ;;
|
||||||
--no-build) build=no; shift ;;
|
--no-build) build=no; shift ;;
|
||||||
|
--native-fixture) native_fixture=yes; shift ;;
|
||||||
--help|-h) usage; exit 0 ;;
|
--help|-h) usage; exit 0 ;;
|
||||||
*) fail "unknown argument $1" ;;
|
*) fail "unknown argument $1" ;;
|
||||||
esac
|
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 [ -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 [ "$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
|
binary=$repo_root/bin/rvbox.exe
|
||||||
[ -f "$binary" ] && [ ! -L "$binary" ] || fail "expected regular Windows binary at 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 'run_id=%s\n' "$run_id"
|
||||||
printf 'git_commit=%s\n' "$commit"
|
printf 'git_commit=%s\n' "$commit"
|
||||||
|
printf 'native_fixture=%s\n' "$native_fixture"
|
||||||
(cd "$bundle" && sha256sum rvbox.exe client.toml ${ca:+ca.pem})
|
(cd "$bundle" && sha256sum rvbox.exe client.toml ${ca:+ca.pem})
|
||||||
} >"$bundle/manifest.sha256"
|
} >"$bundle/manifest.sha256"
|
||||||
chmod 600 "$bundle/manifest.sha256"
|
chmod 600 "$bundle/manifest.sha256"
|
||||||
|
|||||||
Executable
+221
@@ -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
|
||||||
+212
-18
@@ -12,7 +12,7 @@ repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
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:
|
Actions:
|
||||||
status read-only VM/snapshot identity and state check
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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)
|
(default Administrator and the documented fixture password file)
|
||||||
RVBOX_TEST_HOST_STAGE_ROOT (default /home/cabbage/.local/state/rvbox-test-runs)
|
RVBOX_TEST_HOST_STAGE_ROOT (default /home/cabbage/.local/state/rvbox-test-runs)
|
||||||
RVBOX_TEST_RUN_ROOT (default .test-runs/windows-vm)
|
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
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,17 +75,19 @@ shift
|
|||||||
run_id=
|
run_id=
|
||||||
bundle=
|
bundle=
|
||||||
endpoint=
|
endpoint=
|
||||||
|
fail_contexts=
|
||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
--run-id) [ "$#" -ge 2 ] || fail "--run-id needs a value"; run_id=$2; shift 2 ;;
|
--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 ;;
|
--bundle) [ "$#" -ge 2 ] || fail "--bundle needs a value"; bundle=$2; shift 2 ;;
|
||||||
--endpoint) [ "$#" -ge 2 ] || fail "--endpoint needs a value"; endpoint=$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 ;;
|
--help|-h) usage; exit 0 ;;
|
||||||
*) fail "unknown argument $1" ;;
|
*) fail "unknown argument $1" ;;
|
||||||
esac
|
esac
|
||||||
done
|
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
|
if [ "$action" != status ]; then
|
||||||
[ -n "$run_id" ] || fail "$action requires --run-id"
|
[ -n "$run_id" ] || fail "$action requires --run-id"
|
||||||
safe_id "$run_id"
|
safe_id "$run_id"
|
||||||
@@ -91,6 +98,7 @@ if [ "$action" = stage ]; then
|
|||||||
[ -f "$bundle/client.toml" ] || fail "bundle must contain client.toml"
|
[ -f "$bundle/client.toml" ] || fail "bundle must contain client.toml"
|
||||||
fi
|
fi
|
||||||
if [ -n "$endpoint" ]; then safe_word endpoint "$endpoint"; 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
|
# 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
|
# 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_GUEST_PASSWORD_FILE:=/home/cabbage/.local/share/rvbox-secrets/rvbox-win10-test.password}"
|
||||||
: "${RVBOX_TEST_PROVISIONER_USER:=Administrator}"
|
: "${RVBOX_TEST_PROVISIONER_USER:=Administrator}"
|
||||||
: "${RVBOX_TEST_PROVISIONER_PASSWORD_FILE:=$RVBOX_TEST_GUEST_PASSWORD_FILE}"
|
: "${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_user=$RVBOX_TEST_PROVISIONER_USER
|
||||||
provisioner_password_file=$RVBOX_TEST_PROVISIONER_PASSWORD_FILE
|
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 \
|
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 \
|
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"
|
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_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 "$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}
|
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}
|
run_root=${RVBOX_TEST_RUN_ROOT:-$repo_root/.test-runs/windows-vm}
|
||||||
safe_word RVBOX_TEST_HOST_STAGE_ROOT "$host_stage_root"
|
safe_word RVBOX_TEST_HOST_STAGE_ROOT "$host_stage_root"
|
||||||
remote_run_id=${run_id:-fixture-status}
|
remote_run_id=${run_id:-fixture-status}
|
||||||
host_stage=$host_stage_root/$remote_run_id
|
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() {
|
remote() {
|
||||||
# All values below are constrained words before becoming remote shell
|
# All values below are constrained words before becoming remote shell
|
||||||
# arguments. Password contents are never transmitted or printed; only the
|
# arguments. Password contents are never transmitted or printed; only the
|
||||||
# approved host-local password-file path is passed to VBoxManage.
|
# 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_endpoint=${endpoint:--}
|
||||||
remote_script=/home/cabbage/.local/state/rvbox-test-controller/$remote_run_id.sh
|
remote_fail_contexts=${fail_contexts:--}
|
||||||
ssh -o BatchMode=yes "$RVBOX_TEST_VBOX_HOST" \
|
retry_attempt=1
|
||||||
"install -d -m 700 /home/cabbage/.local/state/rvbox-test-controller && cat > '$remote_script' && chmod 700 '$remote_script'" <<'REMOTE'
|
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
|
set -eu
|
||||||
trap 'rm -f "$0"' EXIT
|
|
||||||
|
|
||||||
action=$1
|
action=$1
|
||||||
vm=$2
|
vm=$2
|
||||||
@@ -156,7 +194,9 @@ shift 9
|
|||||||
endpoint=$1
|
endpoint=$1
|
||||||
provisioner_user=$2
|
provisioner_user=$2
|
||||||
provisioner_password_file=$3
|
provisioner_password_file=$3
|
||||||
|
fail_contexts=$4
|
||||||
[ "$endpoint" = - ] && endpoint=
|
[ "$endpoint" = - ] && endpoint=
|
||||||
|
[ "$fail_contexts" = - ] && fail_contexts=
|
||||||
|
|
||||||
fail() { printf '%s\n' "remote test-host: $*" >&2; exit 2; }
|
fail() { printf '%s\n' "remote test-host: $*" >&2; exit 2; }
|
||||||
|
|
||||||
@@ -289,6 +329,19 @@ wait_service() {
|
|||||||
fail "RVBoxClient did not reach $wanted"
|
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
|
case "$action" in
|
||||||
prepare-stage)
|
prepare-stage)
|
||||||
assert_identity
|
assert_identity
|
||||||
@@ -332,8 +385,8 @@ case "$action" in
|
|||||||
assert_identity
|
assert_identity
|
||||||
require_lease
|
require_lease
|
||||||
[ "$(state)" = running ] || fail "stage requires a running prepared VM"
|
[ "$(state)" = running ] || fail "stage requires a running prepared VM"
|
||||||
guest_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
|
guest_run --exe 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' --wait-stdout --wait-stderr --unquoted-args -- \
|
||||||
/d /s /c "if not exist \"$guest_root\" mkdir \"$guest_root\" & echo RVBOX_GUEST_OK" >/dev/null
|
-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)
|
stage-copy-exe)
|
||||||
assert_identity
|
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 -- \
|
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
|
-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
|
fi
|
||||||
|
# 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\\\""
|
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 -- \
|
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 || \
|
/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"
|
fail "RVBoxClient is not installed; run install from the clean baseline first"
|
||||||
@@ -397,6 +457,32 @@ case "$action" in
|
|||||||
wait_service RUNNING
|
wait_service RUNNING
|
||||||
printf 'service=RVBoxClient state=RUNNING\n'
|
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)
|
collect)
|
||||||
assert_identity
|
assert_identity
|
||||||
require_lease
|
require_lease
|
||||||
@@ -409,6 +495,13 @@ case "$action" in
|
|||||||
fi
|
fi
|
||||||
printf 'collected host_stage=%s/artifacts\n' "$host_stage"
|
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)
|
stop)
|
||||||
assert_identity
|
assert_identity
|
||||||
require_lease
|
require_lease
|
||||||
@@ -428,6 +521,16 @@ case "$action" in
|
|||||||
;;
|
;;
|
||||||
reset)
|
reset)
|
||||||
assert_identity
|
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
|
require_lease
|
||||||
if [ "$(state)" = running ]; then
|
if [ "$(state)" = running ]; then
|
||||||
VBoxManage controlvm "$vm" acpipowerbutton >/dev/null
|
VBoxManage controlvm "$vm" acpipowerbutton >/dev/null
|
||||||
@@ -454,12 +557,74 @@ case "$action" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
REMOTE
|
REMOTE
|
||||||
ssh -o BatchMode=yes "$RVBOX_TEST_VBOX_HOST" sh "$remote_script" \
|
then
|
||||||
"$1" "$RVBOX_TEST_VBOX_VM" "$RVBOX_TEST_VBOX_VM_UUID" \
|
return 0
|
||||||
"$RVBOX_TEST_VBOX_SNAPSHOT" "$RVBOX_TEST_VBOX_SNAPSHOT_UUID" \
|
fi
|
||||||
"$RVBOX_TEST_GUEST_USER" "$RVBOX_TEST_GUEST_PASSWORD_FILE" \
|
retry_attempt=$((retry_attempt + 1))
|
||||||
"$host_stage" "$guest_root" "$remote_endpoint" \
|
if [ "$retry_attempt" -le "$retry_limit" ]; then
|
||||||
"$provisioner_user" "$provisioner_password_file" </dev/null
|
printf 'remote action=%s interrupted; retry %s/%s\n' "$remote_action" "$retry_attempt" "$retry_limit" >&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
|
case $action in
|
||||||
@@ -468,14 +633,43 @@ case $action in
|
|||||||
printf 'prepared vm=%s stage=%s\n' "$RVBOX_TEST_VBOX_VM" "$host_stage"
|
printf 'prepared vm=%s stage=%s\n' "$RVBOX_TEST_VBOX_VM" "$host_stage"
|
||||||
;;
|
;;
|
||||||
stage)
|
stage)
|
||||||
|
printf 'stage: verify prepared VM and lease\n'
|
||||||
remote prepare-stage
|
remote prepare-stage
|
||||||
scp -q "$bundle/rvbox.exe" "$bundle/client.toml" "$RVBOX_TEST_VBOX_HOST:$host_stage/"
|
[ -f "$bundle/rvbox.exe" ] && [ ! -L "$bundle/rvbox.exe" ] || fail "rvbox.exe must be a regular non-symlink file"
|
||||||
if [ -f "$bundle/ca.pem" ]; then scp -q "$bundle/ca.pem" "$RVBOX_TEST_VBOX_HOST:$host_stage/"; fi
|
[ -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
|
||||||
remote stage-create-root
|
remote stage-create-root
|
||||||
remote stage-copy-exe
|
remote stage-copy-exe
|
||||||
remote stage-copy-config
|
remote stage-copy-config
|
||||||
if [ -f "$bundle/ca.pem" ]; then remote stage-copy-ca; fi
|
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"
|
printf 'staged guest_root=%s\n' "$guest_root"
|
||||||
;;
|
;;
|
||||||
collect)
|
collect)
|
||||||
|
|||||||
+8
-2
@@ -590,8 +590,14 @@ tests = ["internal/client/spool/spool_test.go:TestSendWindowPinsUnacknowledgedBy
|
|||||||
[[requirements]]
|
[[requirements]]
|
||||||
id = "HP-WINCTX-02"
|
id = "HP-WINCTX-02"
|
||||||
layer = "integration"
|
layer = "integration"
|
||||||
status = "blocked_native_windows"
|
status = "planned"
|
||||||
tests = []
|
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]]
|
[[requirements]]
|
||||||
id = "HP-STORE-01"
|
id = "HP-STORE-01"
|
||||||
|
|||||||
@@ -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.
|
||||||
Executable
+13
@@ -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
|
||||||
@@ -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}" }
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user