Compare commits
24
Commits
d753e8b698
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
620f882fe2 | ||
|
|
9a2b0fd3c1 | ||
|
|
e3aadd8dd6 | ||
|
|
a68729def0 | ||
|
|
3fdd0e2774 | ||
|
|
cbd3f40523 | ||
|
|
102a5486f4 | ||
|
|
d01bb5a9a7 | ||
|
|
38e6ca5eed | ||
|
|
6882a6808a | ||
|
|
9e181b81dc | ||
|
|
c5e9bc8b35 | ||
|
|
0e18af02bb | ||
|
|
ea8a4d8846 | ||
|
|
6c35c1a1d3 | ||
|
|
15b2c4f9d3 | ||
|
|
cc31f6cb67 | ||
|
|
2cf563d88e | ||
|
|
46185f1f6c | ||
|
|
684981c235 | ||
|
|
f6f900e597 | ||
|
|
418681d38e | ||
|
|
f86abcecb5 | ||
|
|
e79f882993 |
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -25,12 +26,22 @@ import (
|
|||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// buildVersion is set by scripts/release for published artifacts. Development
|
||||||
|
// and test builds intentionally retain the explicit non-release value.
|
||||||
|
var buildVersion = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var configPath string
|
var configPath string
|
||||||
var checkConfig bool
|
var checkConfig bool
|
||||||
|
var version bool
|
||||||
flag.StringVar(&configPath, "config", "", "absolute server TOML configuration path")
|
flag.StringVar(&configPath, "config", "", "absolute server TOML configuration path")
|
||||||
flag.BoolVar(&checkConfig, "check-config", false, "validate server configuration and exit")
|
flag.BoolVar(&checkConfig, "check-config", false, "validate server configuration and exit")
|
||||||
|
flag.BoolVar(&version, "version", false, "print build version and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
if version {
|
||||||
|
fmt.Fprintln(os.Stdout, buildVersion)
|
||||||
|
return
|
||||||
|
}
|
||||||
if configPath == "" {
|
if configPath == "" {
|
||||||
log.Print("rvbox-server: --config is required")
|
log.Print("rvbox-server: --config is required")
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
@@ -58,6 +69,12 @@ func run(configPath string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
runtimeLog, err := observability.OpenRotatingFile(configured.Observability.LogFile, configured.Observability.LogMaxBytes, configured.Observability.LogMaxFiles)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open server rotating log: %w", err)
|
||||||
|
}
|
||||||
|
defer runtimeLog.Close()
|
||||||
|
logger := log.New(io.MultiWriter(os.Stderr, observability.FormatLog(runtimeLog, configured.Observability.LogFormat)), "rvbox-server: ", log.LstdFlags|log.LUTC)
|
||||||
persistence, err := store.Open(context.Background(), store.Options{
|
persistence, err := store.Open(context.Background(), store.Options{
|
||||||
DataDir: configured.Server.DataDir, BusyTimeout: configured.Storage.SQLiteBusyTimeout,
|
DataDir: configured.Server.DataDir, BusyTimeout: configured.Storage.SQLiteBusyTimeout,
|
||||||
SegmentTargetSize: configured.Storage.SegmentTargetBytes,
|
SegmentTargetSize: configured.Storage.SegmentTargetBytes,
|
||||||
@@ -84,7 +101,7 @@ func run(configPath string) error {
|
|||||||
if configured.Observability.Listen != "" {
|
if configured.Observability.Listen != "" {
|
||||||
healthListener, err = net.Listen("tcp", configured.Observability.Listen)
|
healthListener, err = net.Listen("tcp", configured.Observability.Listen)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("rvbox-server: observability endpoint unavailable (continuing without it): %v", err)
|
logger.Printf("observability endpoint unavailable (continuing without it): %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if healthListener != nil {
|
if healthListener != nil {
|
||||||
@@ -92,6 +109,7 @@ func run(configPath string) error {
|
|||||||
}
|
}
|
||||||
recoveryContext, cancelRecovery := context.WithCancel(context.Background())
|
recoveryContext, cancelRecovery := context.WithCancel(context.Background())
|
||||||
defer cancelRecovery()
|
defer cancelRecovery()
|
||||||
|
go publishServerTelemetry(recoveryContext, persistence, health)
|
||||||
// Recovery is deliberately asynchronous: liveness and incident inspection
|
// Recovery is deliberately asynchronous: liveness and incident inspection
|
||||||
// remain available while committed-range checks run. Readiness becomes
|
// remain available while committed-range checks run. Readiness becomes
|
||||||
// true only after the real SQLite/segment recovery completes.
|
// true only after the real SQLite/segment recovery completes.
|
||||||
@@ -104,10 +122,10 @@ func run(configPath string) error {
|
|||||||
Scope: store.IncidentScopeGlobal, ScopeKey: "server-startup-recovery", Summary: "startup storage recovery failed",
|
Scope: store.IncidentScopeGlobal, ScopeKey: "server-startup-recovery", Summary: "startup storage recovery failed",
|
||||||
Evidence: []byte(recoverErr.Error()), AutomaticallyRepairable: false,
|
Evidence: []byte(recoverErr.Error()), AutomaticallyRepairable: false,
|
||||||
}); incidentErr != nil {
|
}); incidentErr != nil {
|
||||||
log.Printf("rvbox-server: could not persist recovery incident: %v", incidentErr)
|
logger.Printf("could not persist recovery incident: %v", incidentErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("rvbox-server: storage recovery left readiness disabled: %v", recoverErr)
|
logger.Printf("storage recovery left readiness disabled: %v", recoverErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
health.SetReady(true)
|
health.SetReady(true)
|
||||||
@@ -127,6 +145,7 @@ func run(configPath string) error {
|
|||||||
controlService, err := control.NewService(control.Options{
|
controlService, err := control.NewService(control.Options{
|
||||||
Store: persistence, DefaultQueueTTL: configured.Queue.DefaultTTL, DefaultQueueTTLSet: true, TakeoverTTL: configured.Protocol.TakeoverTTL,
|
Store: persistence, DefaultQueueTTL: configured.Queue.DefaultTTL, DefaultQueueTTLSet: true, TakeoverTTL: configured.Protocol.TakeoverTTL,
|
||||||
WakeClient: registry.Wake,
|
WakeClient: registry.Wake,
|
||||||
|
Metrics: health,
|
||||||
Limits: agentproto.Limits{MaxEnvelopeBytes: configured.Protocol.MaxAgentEnvelopeBytes, MaxExecutionSpecBytes: configured.Protocol.MaxExecutionSpecBytes, MaxRawChunkBytes: configured.Protocol.MaxRawChunkBytes, MaxScriptBytes: configured.Protocol.MaxScriptBytes, MaxDetailBytes: configured.Storage.ProtocolDetailMaxBytes},
|
Limits: agentproto.Limits{MaxEnvelopeBytes: configured.Protocol.MaxAgentEnvelopeBytes, MaxExecutionSpecBytes: configured.Protocol.MaxExecutionSpecBytes, MaxRawChunkBytes: configured.Protocol.MaxRawChunkBytes, MaxScriptBytes: configured.Protocol.MaxScriptBytes, MaxDetailBytes: configured.Storage.ProtocolDetailMaxBytes},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -143,12 +162,13 @@ func run(configPath string) error {
|
|||||||
}
|
}
|
||||||
defer rpcListener.Close()
|
defer rpcListener.Close()
|
||||||
if configured.JSONRPC.NonLoopbackBind {
|
if configured.JSONRPC.NonLoopbackBind {
|
||||||
log.Printf("rvbox-server: WARNING JSON-RPC is unauthenticated and bound to non-loopback address %s", configured.JSONRPC.Listen)
|
logger.Printf("WARNING JSON-RPC is unauthenticated and bound to non-loopback address %s", configured.JSONRPC.Listen)
|
||||||
}
|
}
|
||||||
rpcServer = &http.Server{Handler: control.NewJSONRPCHandler(controlService, int64(configured.Protocol.MaxJSONRPCBodyBytes)), ReadHeaderTimeout: configured.Flow.WriteDeadline}
|
rpcServer = &http.Server{Handler: control.NewJSONRPCHandler(controlService, int64(configured.Protocol.MaxJSONRPCBodyBytes)), ReadHeaderTimeout: configured.Flow.WriteDeadline}
|
||||||
}
|
}
|
||||||
agent := &session.AgentServer{
|
agent := &session.AgentServer{
|
||||||
Store: persistence, Registry: registry, Path: configured.Server.AgentPath,
|
Store: persistence, Registry: registry, Path: configured.Server.AgentPath,
|
||||||
|
Metrics: health,
|
||||||
Limits: agentproto.Limits{
|
Limits: agentproto.Limits{
|
||||||
MaxEnvelopeBytes: configured.Protocol.MaxAgentEnvelopeBytes,
|
MaxEnvelopeBytes: configured.Protocol.MaxAgentEnvelopeBytes,
|
||||||
MaxExecutionSpecBytes: configured.Protocol.MaxExecutionSpecBytes,
|
MaxExecutionSpecBytes: configured.Protocol.MaxExecutionSpecBytes,
|
||||||
@@ -220,3 +240,31 @@ func run(configPath string) error {
|
|||||||
return httpErr
|
return httpErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func publishServerTelemetry(ctx context.Context, persistence *store.Store, health *observability.Health) {
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
publishServerTelemetrySample(ctx, persistence.Telemetry, health)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishServerTelemetrySample performs one intentionally bounded, best-effort
|
||||||
|
// telemetry read. A failed metrics read must never affect server readiness or
|
||||||
|
// durable command processing.
|
||||||
|
func publishServerTelemetrySample(ctx context.Context, read func(context.Context) (store.Telemetry, error), health *observability.Health) {
|
||||||
|
sampleContext, cancel := context.WithTimeout(ctx, time.Second)
|
||||||
|
telemetry, err := read(sampleContext)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
health.Inc("telemetry_read_failure")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
health.SetGauge("queue_depth", float64(telemetry.QueuedCommands))
|
||||||
|
health.SetGauge("server_charged_bytes", float64(telemetry.ChargedBytes))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rvbox/rvbox/internal/observability"
|
||||||
|
"github.com/rvbox/rvbox/internal/server/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublishServerTelemetrySample_HP_OPS_07(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
health := observability.New()
|
||||||
|
publishServerTelemetrySample(context.Background(), func(context.Context) (store.Telemetry, error) {
|
||||||
|
return store.Telemetry{QueuedCommands: 3, ChargedBytes: 4096}, nil
|
||||||
|
}, health)
|
||||||
|
snapshot := health.MetricsSnapshot()
|
||||||
|
if snapshot.Gauges["queue_depth"] != 3 || snapshot.Gauges["server_charged_bytes"] != 4096 {
|
||||||
|
t.Fatalf("server telemetry gauges = %#v", snapshot.Gauges)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishServerTelemetrySampleFailure_BH_OPS_04(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
health := observability.New()
|
||||||
|
publishServerTelemetrySample(context.Background(), func(context.Context) (store.Telemetry, error) {
|
||||||
|
return store.Telemetry{}, errors.New("store unavailable")
|
||||||
|
}, health)
|
||||||
|
_, _, counters := health.Snapshot()
|
||||||
|
if counters["telemetry_read_failure"] != 1 {
|
||||||
|
t.Fatalf("telemetry failure counters = %#v", counters)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rvbox/rvbox/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultInstallerStateDir = `C:\ProgramData\RVBox\data`
|
||||||
|
// Public is a pre-existing directory usable by the active user, LocalSystem,
|
||||||
|
// and LocalService fallback. Durable RVBox state stays separately protected.
|
||||||
|
defaultInstallerWorkDir = `C:\Users\Public`
|
||||||
|
)
|
||||||
|
|
||||||
|
// packagedClientConfig creates the deliberately small first-install TOML. The
|
||||||
|
// strict production decoder validates it before an elevated installer writes
|
||||||
|
// anything, so the SCM service never starts from a hand-built invalid file.
|
||||||
|
func packagedClientConfig(serverURL, clientID string) ([]byte, error) {
|
||||||
|
serverURL = strings.TrimSpace(serverURL)
|
||||||
|
clientID = installerClientID(clientID)
|
||||||
|
if serverURL == "" {
|
||||||
|
return nil, fmt.Errorf("this RVBox package has no bootstrap server URL")
|
||||||
|
}
|
||||||
|
content := fmt.Sprintf("# Generated by the RVBox installer. Edit only through a deliberate reconfiguration.\n[client]\nserver_url = %s\nstate_dir = %s\nclient_id = %s\ndaemon_cwd = %s\n\n[observability]\n# Disabled by default; enable a loopback listener only when diagnostics are needed.\nlisten = \"\"\nlog_file = \"\"\n", strconv.Quote(serverURL), strconv.Quote(defaultInstallerStateDir), strconv.Quote(clientID), strconv.Quote(defaultInstallerWorkDir))
|
||||||
|
if _, err := config.DecodeClient([]byte(content), config.ClientOptions{Platform: config.PlatformWindows}); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate packaged client configuration: %w", err)
|
||||||
|
}
|
||||||
|
return []byte(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func installerClientID(hostname string) string {
|
||||||
|
hostname = strings.TrimSpace(hostname)
|
||||||
|
var result strings.Builder
|
||||||
|
for _, value := range []byte(hostname) {
|
||||||
|
if value >= 0x21 && value <= 0x7e {
|
||||||
|
result.WriteByte(value)
|
||||||
|
} else {
|
||||||
|
result.WriteByte('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value := strings.Trim(result.String(), "-")
|
||||||
|
if value == "" {
|
||||||
|
value = "rvbox-client"
|
||||||
|
}
|
||||||
|
if len(value) > 128 {
|
||||||
|
value = value[:128]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runInteractiveInstaller(io.Writer, io.Writer) error {
|
||||||
|
return errors.New("the v1 interactive installer is implemented for Windows only")
|
||||||
|
}
|
||||||
|
|
||||||
|
func installPackagedDefault(io.Writer) error {
|
||||||
|
return errors.New("the v1 packaged installer is implemented for Windows only")
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/rvbox/rvbox/internal/client/windowsservice"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
installerInfoIcon = 0x00000040
|
||||||
|
installerErrorIcon = 0x00000010
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
installerUser32 = syscall.NewLazyDLL("user32.dll")
|
||||||
|
installerMessageBox = installerUser32.NewProc("MessageBoxW")
|
||||||
|
)
|
||||||
|
|
||||||
|
// runInteractiveInstaller is the double-click entry point. It does no
|
||||||
|
// machine-wide mutation itself: ShellExecute's runas verb causes Windows to
|
||||||
|
// show the standard UAC consent prompt before the elevated child installs.
|
||||||
|
func runInteractiveInstaller(_ io.Writer, _ io.Writer) error {
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
showInstallerMessage("RVBox setup could not locate its executable.", installerErrorIcon)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
file, err := windows.UTF16PtrFromString(executable)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
arguments, err := windows.UTF16PtrFromString("--install-default")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := windows.ShellExecute(0, installerUTF16("runas"), file, arguments, nil, windows.SW_SHOWNORMAL); err != nil {
|
||||||
|
showInstallerMessage("RVBox setup was cancelled or could not obtain administrator approval.\n\nNo changes were made.", installerErrorIcon)
|
||||||
|
return fmt.Errorf("request installer elevation: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// installPackagedDefault executes only in the UAC-elevated child. It preserves
|
||||||
|
// a pre-existing operator configuration, atomically updates the executable,
|
||||||
|
// installs/updates the SCM service, and starts it.
|
||||||
|
func installPackagedDefault(_ io.Writer) error {
|
||||||
|
err := installPackagedDefaultFiles()
|
||||||
|
if err != nil {
|
||||||
|
showInstallerMessage("RVBox could not be installed.\n\n"+err.Error()+"\n\nNo existing configuration was replaced.", installerErrorIcon)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
showInstallerMessage("RVBox is installed and its Windows service is starting.\n\nThe notification-area icon starts automatically at the next sign-in.", installerInfoIcon)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func installPackagedDefaultFiles() error {
|
||||||
|
if strings.TrimSpace(bootstrapServerURL) == "" {
|
||||||
|
return errors.New("this RVBox package was built without a bootstrap server URL")
|
||||||
|
}
|
||||||
|
source, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("locate installer executable: %w", err)
|
||||||
|
}
|
||||||
|
programFiles := os.Getenv("ProgramFiles")
|
||||||
|
if programFiles == "" {
|
||||||
|
programFiles = `C:\Program Files`
|
||||||
|
}
|
||||||
|
programData := os.Getenv("ProgramData")
|
||||||
|
if programData == "" {
|
||||||
|
programData = `C:\ProgramData`
|
||||||
|
}
|
||||||
|
targetDirectory := filepath.Join(programFiles, "RVBox")
|
||||||
|
targetExecutable := filepath.Join(targetDirectory, "rvbox.exe")
|
||||||
|
configDirectory := filepath.Join(programData, "RVBox")
|
||||||
|
configPath := filepath.Join(configDirectory, "client.toml")
|
||||||
|
|
||||||
|
// A running service can keep the prior executable open. Stop it before the
|
||||||
|
// atomic replacement; a missing service is already a successful first run.
|
||||||
|
if err := windowsservice.Stop(30); err != nil {
|
||||||
|
return fmt.Errorf("stop existing RVBox service: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(targetDirectory, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create installation directory: %w", err)
|
||||||
|
}
|
||||||
|
if !sameWindowsPath(source, targetExecutable) {
|
||||||
|
if err := copyInstallerExecutable(source, targetExecutable); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(configDirectory, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("create configuration directory: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
|
||||||
|
hostname, hostnameErr := os.Hostname()
|
||||||
|
if hostnameErr != nil {
|
||||||
|
hostname = "rvbox-client"
|
||||||
|
}
|
||||||
|
content, configErr := packagedClientConfig(bootstrapServerURL, hostname)
|
||||||
|
if configErr != nil {
|
||||||
|
return configErr
|
||||||
|
}
|
||||||
|
if err := writeMachineConfig(configPath, content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("inspect existing configuration: %w", err)
|
||||||
|
}
|
||||||
|
if err := windowsservice.Install(windowsservice.InstallSpec{ExecutablePath: targetExecutable, ConfigPath: configPath, Startup: windowsservice.StartupAutomatic}); err != nil {
|
||||||
|
return fmt.Errorf("install and start RVBox service: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyInstallerExecutable(source, target string) error {
|
||||||
|
input, err := os.Open(source)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open installer executable: %w", err)
|
||||||
|
}
|
||||||
|
defer input.Close()
|
||||||
|
temporary := target + ".new"
|
||||||
|
_ = os.Remove(temporary)
|
||||||
|
output, err := os.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o755)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create replacement executable: %w", err)
|
||||||
|
}
|
||||||
|
_, copyErr := io.Copy(output, input)
|
||||||
|
if syncErr := output.Sync(); copyErr == nil {
|
||||||
|
copyErr = syncErr
|
||||||
|
}
|
||||||
|
if closeErr := output.Close(); copyErr == nil {
|
||||||
|
copyErr = closeErr
|
||||||
|
}
|
||||||
|
if copyErr != nil {
|
||||||
|
_ = os.Remove(temporary)
|
||||||
|
return fmt.Errorf("copy installer executable: %w", copyErr)
|
||||||
|
}
|
||||||
|
from, fromErr := windows.UTF16PtrFromString(temporary)
|
||||||
|
to, toErr := windows.UTF16PtrFromString(target)
|
||||||
|
if fromErr != nil || toErr != nil {
|
||||||
|
_ = os.Remove(temporary)
|
||||||
|
return errors.New("encode replacement executable path")
|
||||||
|
}
|
||||||
|
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH); err != nil {
|
||||||
|
_ = os.Remove(temporary)
|
||||||
|
return fmt.Errorf("activate replacement executable: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMachineConfig(path string, content []byte) error {
|
||||||
|
temporary := path + ".new"
|
||||||
|
_ = os.Remove(temporary)
|
||||||
|
if err := os.WriteFile(temporary, content, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write default configuration: %w", err)
|
||||||
|
}
|
||||||
|
from, fromErr := windows.UTF16PtrFromString(temporary)
|
||||||
|
to, toErr := windows.UTF16PtrFromString(path)
|
||||||
|
if fromErr != nil || toErr != nil {
|
||||||
|
_ = os.Remove(temporary)
|
||||||
|
return errors.New("encode configuration path")
|
||||||
|
}
|
||||||
|
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH); err != nil {
|
||||||
|
_ = os.Remove(temporary)
|
||||||
|
return fmt.Errorf("activate default configuration: %w", err)
|
||||||
|
}
|
||||||
|
return protectMachineConfig(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func protectMachineConfig(path string) error {
|
||||||
|
descriptor, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;SY)(A;;FA;;;BA)")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dacl, _, err := descriptor.DACL()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil {
|
||||||
|
return fmt.Errorf("protect generated configuration: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameWindowsPath(first, second string) bool {
|
||||||
|
return strings.EqualFold(filepath.Clean(first), filepath.Clean(second))
|
||||||
|
}
|
||||||
|
|
||||||
|
func installerUTF16(value string) *uint16 {
|
||||||
|
encoded, _ := windows.UTF16PtrFromString(value)
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func showInstallerMessage(message string, icon uintptr) {
|
||||||
|
caption := installerUTF16("RVBox Setup")
|
||||||
|
text := installerUTF16(message)
|
||||||
|
_, _, _ = installerMessageBox.Call(0, uintptr(unsafe.Pointer(text)), uintptr(unsafe.Pointer(caption)), icon)
|
||||||
|
}
|
||||||
+69
-5
@@ -26,6 +26,9 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// buildVersion is set by scripts/release for published artifacts.
|
||||||
|
var buildVersion = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "rvbox:", err)
|
fmt.Fprintln(os.Stderr, "rvbox:", err)
|
||||||
@@ -35,15 +38,24 @@ func main() {
|
|||||||
|
|
||||||
var nativeTestContextFailures map[clientwindows.ExecutionContext]bool
|
var nativeTestContextFailures map[clientwindows.ExecutionContext]bool
|
||||||
|
|
||||||
|
// bootstrapServerURL is set only for a packaged Windows installer. A fresh
|
||||||
|
// machine has no service connection from which it could discover this value,
|
||||||
|
// so release packaging must deliberately supply the intended WSS endpoint.
|
||||||
|
var bootstrapServerURL string
|
||||||
|
|
||||||
// run is deliberately a small mode dispatcher. The SCM service invokes only
|
// run is deliberately a small mode dispatcher. The SCM service invokes only
|
||||||
// --service with an explicit config path; tray/helper modes cannot silently
|
// --service with an explicit config path; tray/helper modes cannot silently
|
||||||
// turn an ordinary process invocation into a privileged service.
|
// turn an ordinary process invocation into a privileged service.
|
||||||
func run(args []string, output, diagnostics io.Writer) error {
|
func run(args []string, output, diagnostics io.Writer) error {
|
||||||
|
if len(args) == 1 && args[0] == "--version" {
|
||||||
|
_, err := fmt.Fprintln(output, buildVersion)
|
||||||
|
return err
|
||||||
|
}
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("an internal mode is required (use --help)")
|
return runInteractiveInstaller(output, diagnostics)
|
||||||
}
|
}
|
||||||
if args[0] == "--help" || args[0] == "-h" {
|
if args[0] == "--help" || args[0] == "-h" {
|
||||||
_, err := io.WriteString(output, "usage: rvbox --service|--tray|--launcher|--signal-helper|--check-config|--install-service|--uninstall-service|--configure-service|--start-service|--stop-service|--restart-service --config PATH\n")
|
_, err := io.WriteString(output, "usage: rvbox [--install-default]|--service|--tray|--launcher|--signal-helper|--check-config|--install-service|--uninstall-service|--configure-service|--start-service|--stop-service|--restart-service --config PATH\n")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
flags := flag.NewFlagSet("rvbox", flag.ContinueOnError)
|
flags := flag.NewFlagSet("rvbox", flag.ContinueOnError)
|
||||||
@@ -56,6 +68,7 @@ func run(args []string, output, diagnostics io.Writer) error {
|
|||||||
channel := flags.String("channel", "", "private launcher channel (internal use only)")
|
channel := flags.String("channel", "", "private launcher channel (internal use only)")
|
||||||
checkConfig := flags.Bool("check-config", false, "validate client configuration and exit")
|
checkConfig := flags.Bool("check-config", false, "validate client configuration and exit")
|
||||||
install := flags.Bool("install-service", false, "install or update the machine-wide service")
|
install := flags.Bool("install-service", false, "install or update the machine-wide service")
|
||||||
|
installDefault := flags.Bool("install-default", false, "install the packaged default configuration (internal installer use)")
|
||||||
uninstall := flags.Bool("uninstall-service", false, "remove the machine-wide service")
|
uninstall := flags.Bool("uninstall-service", false, "remove the machine-wide service")
|
||||||
configure := flags.Bool("configure-service", false, "configure machine-wide service startup mode")
|
configure := flags.Bool("configure-service", false, "configure machine-wide service startup mode")
|
||||||
startup := flags.String("startup", string(windowsservice.StartupAutomatic), "service startup mode: automatic or manual")
|
startup := flags.String("startup", string(windowsservice.StartupAutomatic), "service startup mode: automatic or manual")
|
||||||
@@ -70,7 +83,7 @@ func run(args []string, output, diagnostics io.Writer) error {
|
|||||||
return fmt.Errorf("unexpected argument %q", flags.Arg(0))
|
return fmt.Errorf("unexpected argument %q", flags.Arg(0))
|
||||||
}
|
}
|
||||||
selected := 0
|
selected := 0
|
||||||
for _, value := range []bool{*serviceMode, *trayMode, *launcherMode, *signalHelperMode, *checkConfig, *install, *uninstall, *configure, *start, *stop, *restart} {
|
for _, value := range []bool{*serviceMode, *trayMode, *launcherMode, *signalHelperMode, *checkConfig, *install, *installDefault, *uninstall, *configure, *start, *stop, *restart} {
|
||||||
if value {
|
if value {
|
||||||
selected++
|
selected++
|
||||||
}
|
}
|
||||||
@@ -93,6 +106,9 @@ func run(args []string, output, diagnostics io.Writer) error {
|
|||||||
}
|
}
|
||||||
return windowsservice.Install(windowsservice.InstallSpec{ExecutablePath: executable, ConfigPath: *configPath, Startup: windowsservice.StartupAutomatic})
|
return windowsservice.Install(windowsservice.InstallSpec{ExecutablePath: executable, ConfigPath: *configPath, Startup: windowsservice.StartupAutomatic})
|
||||||
}
|
}
|
||||||
|
if *installDefault {
|
||||||
|
return installPackagedDefault(diagnostics)
|
||||||
|
}
|
||||||
if *uninstall {
|
if *uninstall {
|
||||||
return windowsservice.Uninstall()
|
return windowsservice.Uninstall()
|
||||||
}
|
}
|
||||||
@@ -150,18 +166,32 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
runtimeLog, err := observability.OpenRotatingFile(configured.Observability.LogFile, configured.Observability.LogMaxBytes, configured.Observability.LogMaxFiles)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open client rotating log: %w", err)
|
||||||
|
}
|
||||||
|
defer runtimeLog.Close()
|
||||||
|
formattedRuntimeLog := observability.FormatLog(runtimeLog, configured.Observability.LogFormat)
|
||||||
|
if diagnostics == nil {
|
||||||
|
diagnostics = formattedRuntimeLog
|
||||||
|
} else {
|
||||||
|
diagnostics = io.MultiWriter(formattedRuntimeLog, diagnostics)
|
||||||
|
}
|
||||||
health := observability.New()
|
health := observability.New()
|
||||||
|
if configured.Observability.Listen != "" {
|
||||||
go func() {
|
go func() {
|
||||||
if serveErr := health.Serve(ctx, configured.Observability.Listen, observability.Paths{Liveness: configured.Observability.LivenessPath, Readiness: configured.Observability.ReadinessPath, Metrics: configured.Observability.MetricsPath}); serveErr != nil && ctx.Err() == nil && diagnostics != nil {
|
if serveErr := health.Serve(ctx, configured.Observability.Listen, observability.Paths{Liveness: configured.Observability.LivenessPath, Readiness: configured.Observability.ReadinessPath, Metrics: configured.Observability.MetricsPath}); serveErr != nil && ctx.Err() == nil && diagnostics != nil {
|
||||||
_, _ = fmt.Fprintf(diagnostics, "rvbox client observability endpoint stopped: %v\n", serveErr)
|
_, _ = fmt.Fprintf(diagnostics, "rvbox client observability endpoint stopped: %v\n", serveErr)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
}
|
||||||
state, err := spool.Open(ctx, spool.Options{DataDir: configured.Client.StateDir, BusyTimeout: 5 * time.Second, TombstoneLimit: configured.Storage.TombstoneMaxEntries, MaxScriptBytes: configured.Execution.MaxScriptBytes, MaxExecutionSpecBytes: configured.Execution.MaxExecutionSpecBytes, QuotaLimits: spool.QuotaLimits{HardAllocationBytes: 1 << 20, CommandOutputBytes: configured.Storage.CommandOutputLimitBytes, CommandTotalBytes: configured.Storage.CommandTotalLimitBytes, ClientTotalBytes: configured.Storage.ClientTotalLimitBytes, CloseoutReserveBytes: configured.Storage.CommandCloseoutReserveBytes}})
|
state, err := spool.Open(ctx, spool.Options{DataDir: configured.Client.StateDir, BusyTimeout: 5 * time.Second, TombstoneLimit: configured.Storage.TombstoneMaxEntries, MaxScriptBytes: configured.Execution.MaxScriptBytes, MaxExecutionSpecBytes: configured.Execution.MaxExecutionSpecBytes, QuotaLimits: spool.QuotaLimits{HardAllocationBytes: 1 << 20, CommandOutputBytes: configured.Storage.CommandOutputLimitBytes, CommandTotalBytes: configured.Storage.CommandTotalLimitBytes, ClientTotalBytes: configured.Storage.ClientTotalLimitBytes, CloseoutReserveBytes: configured.Storage.CommandCloseoutReserveBytes}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
health.SetDirty(true)
|
health.SetDirty(true)
|
||||||
return fmt.Errorf("open client durable state: %w", err)
|
return fmt.Errorf("open client durable state: %w", err)
|
||||||
}
|
}
|
||||||
defer state.Close()
|
defer state.Close()
|
||||||
|
go publishClientTelemetry(ctx, state, health)
|
||||||
if diagnostics != nil {
|
if diagnostics != nil {
|
||||||
_, _ = fmt.Fprintf(diagnostics, "rvbox client service initialized for %s\n", configured.Client.ServerURL)
|
_, _ = fmt.Fprintf(diagnostics, "rvbox client service initialized for %s\n", configured.Client.ServerURL)
|
||||||
}
|
}
|
||||||
@@ -179,7 +209,7 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("configure command supervisor: %w", err)
|
return fmt.Errorf("configure command supervisor: %w", err)
|
||||||
}
|
}
|
||||||
executor, err := agent.NewExecutor(agent.ExecutorOptions{Store: state, Supervisor: supervised, WorkDir: configured.Client.DaemonCWD, Notify: func(issue domain.UUID) {
|
executor, err := agent.NewExecutor(agent.ExecutorOptions{Store: state, Supervisor: supervised, WorkDir: configured.Client.DaemonCWD, Metrics: health, Notify: func(issue domain.UUID) {
|
||||||
select {
|
select {
|
||||||
case eventReady <- issue:
|
case eventReady <- issue:
|
||||||
default:
|
default:
|
||||||
@@ -205,7 +235,6 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
|
|||||||
} else if len(recovered) > 0 && diagnostics != nil {
|
} else if len(recovered) > 0 && diagnostics != nil {
|
||||||
_, _ = fmt.Fprintf(diagnostics, "rvbox recovered %d uncertain launch(es)\n", len(recovered))
|
_, _ = fmt.Fprintf(diagnostics, "rvbox recovered %d uncertain launch(es)\n", len(recovered))
|
||||||
}
|
}
|
||||||
health.SetReady(true)
|
|
||||||
if runErr := agent.Run(ctx, agent.RunnerOptions{
|
if runErr := agent.Run(ctx, agent.RunnerOptions{
|
||||||
Store: state,
|
Store: state,
|
||||||
Dial: func(dialContext context.Context) (agent.Transport, error) {
|
Dial: func(dialContext context.Context) (agent.Transport, error) {
|
||||||
@@ -216,6 +245,13 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
|
|||||||
Jitter: agent.CryptoJitter, Now: func() time.Time { return time.Now().UTC() },
|
Jitter: agent.CryptoJitter, Now: func() time.Time { return time.Now().UTC() },
|
||||||
OnDispatch: executor.Dispatch, OnScriptReady: executor.ScriptReady, OnStdin: executor.Stdin,
|
OnDispatch: executor.Dispatch, OnScriptReady: executor.ScriptReady, OnStdin: executor.Stdin,
|
||||||
OnCloseStdin: executor.CloseStdin, OnSignal: executor.Signal, OnTerminate: executor.Terminate, EventReady: eventReady,
|
OnCloseStdin: executor.CloseStdin, OnSignal: executor.Signal, OnTerminate: executor.Terminate, EventReady: eventReady,
|
||||||
|
Metrics: health,
|
||||||
|
OnSessionActive: health.SetReady,
|
||||||
|
OnSessionError: func(sessionErr error) {
|
||||||
|
if diagnostics != nil {
|
||||||
|
_, _ = fmt.Fprintf(diagnostics, "rvbox client session retry: %v\n", sessionErr)
|
||||||
|
}
|
||||||
|
},
|
||||||
}); runErr != nil && ctx.Err() == nil && diagnostics != nil {
|
}); runErr != nil && ctx.Err() == nil && diagnostics != nil {
|
||||||
_, _ = fmt.Fprintf(diagnostics, "rvbox client session stopped: %v\n", runErr)
|
_, _ = fmt.Fprintf(diagnostics, "rvbox client session stopped: %v\n", runErr)
|
||||||
}
|
}
|
||||||
@@ -226,6 +262,34 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func publishClientTelemetry(ctx context.Context, state *spool.Store, health *observability.Health) {
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
publishClientTelemetrySample(ctx, state.Telemetry, health)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishClientTelemetrySample performs one intentionally bounded, best-effort
|
||||||
|
// telemetry read. An unavailable spool metric must not interrupt reconnect or
|
||||||
|
// command supervision.
|
||||||
|
func publishClientTelemetrySample(ctx context.Context, read func(context.Context) (spool.Telemetry, error), health *observability.Health) {
|
||||||
|
sampleContext, cancel := context.WithTimeout(ctx, time.Second)
|
||||||
|
telemetry, err := read(sampleContext)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
health.Inc("telemetry_read_failure")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
health.SetGauge("queue_depth", float64(telemetry.QueuedCommands))
|
||||||
|
health.SetGauge("client_spool_bytes", float64(telemetry.ChargedBytes))
|
||||||
|
}
|
||||||
|
|
||||||
func clientJobProfiles(profiles config.Profiles) map[string]clientwindows.JobProfile {
|
func clientJobProfiles(profiles config.Profiles) map[string]clientwindows.JobProfile {
|
||||||
return map[string]clientwindows.JobProfile{
|
return map[string]clientwindows.JobProfile{
|
||||||
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_LIGHT.String(): toJobProfile(profiles.Light),
|
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_LIGHT.String(): toJobProfile(profiles.Light),
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rvbox/rvbox/internal/client/spool"
|
||||||
"github.com/rvbox/rvbox/internal/config"
|
"github.com/rvbox/rvbox/internal/config"
|
||||||
|
"github.com/rvbox/rvbox/internal/observability"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestClientModeSelectionRequiresExactlyOneMode_HP_WINCLI_01(t *testing.T) {
|
func TestClientModeSelectionRequiresExactlyOneMode_HP_WINCLI_01(t *testing.T) {
|
||||||
@@ -23,6 +28,14 @@ func TestClientModeSelectionRequiresExactlyOneMode_HP_WINCLI_01(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientVersionDoesNotRequireConfiguration_HP_WINCLI_03(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var output, diagnostics bytes.Buffer
|
||||||
|
if err := run([]string{"--version"}, &output, &diagnostics); err != nil || output.String() != "dev\n" {
|
||||||
|
t.Fatalf("version = %q, %v", output.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestClientHTTPClientAcceptsMatchingSelfSignedLeaf(t *testing.T) {
|
func TestClientHTTPClientAcceptsMatchingSelfSignedLeaf(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
@@ -69,3 +82,60 @@ func TestNonWindowsServiceModesRemainExplicitlyUnsupported_BH_WINCLI_01(t *testi
|
|||||||
t.Fatal("non-Windows tray mode unexpectedly available")
|
t.Fatal("non-Windows tray mode unexpectedly available")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPackagedClientConfigUsesMinimalSafeDefaults_HP_WINCLI_04(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
content, err := packagedClientConfig("wss://controller.example.test/v1/agent", "desktop-01")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
text := string(content)
|
||||||
|
for _, want := range []string{
|
||||||
|
`server_url = "wss://controller.example.test/v1/agent"`,
|
||||||
|
`client_id = "desktop-01"`,
|
||||||
|
`state_dir = "C:\\ProgramData\\RVBox\\data"`,
|
||||||
|
`daemon_cwd = "C:\\Users\\Public"`,
|
||||||
|
`listen = ""`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("generated configuration missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := packagedClientConfig("", "desktop-01"); err == nil {
|
||||||
|
t.Fatal("missing bootstrap URL was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerClientIDNormalizesHostnames_HP_WINCLI_05(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := installerClientID(" desktop host\n"); got != "desktop-host" {
|
||||||
|
t.Fatalf("normalized hostname = %q", got)
|
||||||
|
}
|
||||||
|
if got := installerClientID("\x00"); got != "rvbox-client" {
|
||||||
|
t.Fatalf("fallback hostname = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishClientTelemetrySample_HP_OPS_07(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
health := observability.New()
|
||||||
|
publishClientTelemetrySample(context.Background(), func(context.Context) (spool.Telemetry, error) {
|
||||||
|
return spool.Telemetry{QueuedCommands: 2, ChargedBytes: 2048}, nil
|
||||||
|
}, health)
|
||||||
|
snapshot := health.MetricsSnapshot()
|
||||||
|
if snapshot.Gauges["queue_depth"] != 2 || snapshot.Gauges["client_spool_bytes"] != 2048 {
|
||||||
|
t.Fatalf("client telemetry gauges = %#v", snapshot.Gauges)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishClientTelemetrySampleFailure_BH_OPS_04(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
health := observability.New()
|
||||||
|
publishClientTelemetrySample(context.Background(), func(context.Context) (spool.Telemetry, error) {
|
||||||
|
return spool.Telemetry{}, errors.New("spool unavailable")
|
||||||
|
}, health)
|
||||||
|
_, _, counters := health.Snapshot()
|
||||||
|
if counters["telemetry_read_failure"] != 1 {
|
||||||
|
t.Fatalf("telemetry failure counters = %#v", counters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
clientwindows "github.com/rvbox/rvbox/internal/client/supervisor/windows"
|
clientwindows "github.com/rvbox/rvbox/internal/client/supervisor/windows"
|
||||||
"github.com/rvbox/rvbox/internal/client/windowsservice"
|
"github.com/rvbox/rvbox/internal/client/windowsservice"
|
||||||
"github.com/rvbox/rvbox/internal/client/windowstray"
|
"github.com/rvbox/rvbox/internal/client/windowstray"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
"golang.org/x/sys/windows/svc"
|
"golang.org/x/sys/windows/svc"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,6 +91,16 @@ func openServiceDiagnostics(configPath string, diagnostics io.Writer) (io.Writer
|
|||||||
}
|
}
|
||||||
return diagnostics, func() {}
|
return diagnostics, func() {}
|
||||||
}
|
}
|
||||||
|
// Service diagnostics are intentionally not private spool data. Preserve a
|
||||||
|
// protected SYSTEM/Administrators DACL so an operator can diagnose an SCM
|
||||||
|
// startup or reconnect failure without weakening access to command state.
|
||||||
|
if err := applyServiceDiagnosticsACL(path); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
if diagnostics == nil {
|
||||||
|
return io.Discard, func() {}
|
||||||
|
}
|
||||||
|
return diagnostics, func() {}
|
||||||
|
}
|
||||||
if diagnostics == nil {
|
if diagnostics == nil {
|
||||||
return file, func() { _ = file.Close() }
|
return file, func() { _ = file.Close() }
|
||||||
}
|
}
|
||||||
@@ -99,6 +110,18 @@ func openServiceDiagnostics(configPath string, diagnostics io.Writer) (io.Writer
|
|||||||
return io.MultiWriter(file, diagnostics), func() { _ = file.Close() }
|
return io.MultiWriter(file, diagnostics), func() { _ = file.Close() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyServiceDiagnosticsACL(path string) error {
|
||||||
|
descriptor, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;SY)(A;;FA;;;BA)")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dacl, _, err := descriptor.DACL()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, dacl, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func runTray(configPath string, diagnostics io.Writer) error {
|
func runTray(configPath string, diagnostics io.Writer) error {
|
||||||
_ = configPath // the tray obtains the canonical paths from the service.
|
_ = configPath // the tray obtains the canonical paths from the service.
|
||||||
return windowstray.Run(context.Background(), diagnostics)
|
return windowstray.Run(context.Background(), diagnostics)
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import (
|
|||||||
|
|
||||||
const defaultControlSocket = "/run/rvbox/server.sock"
|
const defaultControlSocket = "/run/rvbox/server.sock"
|
||||||
|
|
||||||
|
// buildVersion is set by scripts/release for published artifacts.
|
||||||
|
var buildVersion = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "rvc:", err)
|
fmt.Fprintln(os.Stderr, "rvc:", err)
|
||||||
@@ -33,6 +36,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run(args []string, output, diagnostics io.Writer) error {
|
func run(args []string, output, diagnostics io.Writer) error {
|
||||||
|
if len(args) == 1 && args[0] == "--version" {
|
||||||
|
_, err := fmt.Fprintln(output, buildVersion)
|
||||||
|
return err
|
||||||
|
}
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("a command is required (stat or run)")
|
return errors.New("a command is required (stat or run)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ func TestGlobalRequestIDIsInjectedOnlyForMutations_HP_CTL_12(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVersionDoesNotRequireControlSocket_HP_CTL_16(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var output, diagnostics bytes.Buffer
|
||||||
|
if err := run([]string{"--version"}, &output, &diagnostics); err != nil || output.String() != "dev\n" {
|
||||||
|
t.Fatalf("version = %q, %v", output.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenderCommandStatShowsExpiryRetentionAndWindowsIdentity_HP_CTL_13(t *testing.T) {
|
func TestRenderCommandStatShowsExpiryRetentionAndWindowsIdentity_HP_CTL_13(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
expiry := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
|
expiry := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ ARG PROTOC_AMD64_SHA256=a45cda0989c17dd950db55f6fbe1e5814c50fda08e87aa422980ac1f
|
|||||||
ARG PROTOC_ARM64_SHA256=36b518ac14d90351cc6598228ed2bbe5afe4e357b1af470b07e0ec1609875de2
|
ARG PROTOC_ARM64_SHA256=36b518ac14d90351cc6598228ed2bbe5afe4e357b1af470b07e0ec1609875de2
|
||||||
ARG PROTOC_GEN_GO_VERSION=1.36.12
|
ARG PROTOC_GEN_GO_VERSION=1.36.12
|
||||||
ARG PROTOC_GEN_GO_GRPC_VERSION=1.6.2
|
ARG PROTOC_GEN_GO_GRPC_VERSION=1.6.2
|
||||||
|
ARG GO_WINRES_VERSION=0.3.3
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
ENV GOCACHE=/cache/go-build \
|
ENV GOCACHE=/cache/go-build \
|
||||||
@@ -34,6 +35,11 @@ RUN apt-get update \
|
|||||||
RUN GOBIN=/usr/local/bin go install "google.golang.org/protobuf/cmd/protoc-gen-go@v${PROTOC_GEN_GO_VERSION}" \
|
RUN GOBIN=/usr/local/bin go install "google.golang.org/protobuf/cmd/protoc-gen-go@v${PROTOC_GEN_GO_VERSION}" \
|
||||||
&& GOBIN=/usr/local/bin go install "google.golang.org/grpc/cmd/protoc-gen-go-grpc@v${PROTOC_GEN_GO_GRPC_VERSION}"
|
&& GOBIN=/usr/local/bin go install "google.golang.org/grpc/cmd/protoc-gen-go-grpc@v${PROTOC_GEN_GO_GRPC_VERSION}"
|
||||||
|
|
||||||
|
# Release packaging generates a temporary, architecture-suffixed Windows
|
||||||
|
# resource object with this pinned pure-Go tool. It is intentionally part of
|
||||||
|
# the toolchain image rather than a host prerequisite.
|
||||||
|
RUN GOBIN=/usr/local/bin go install "github.com/tc-hib/go-winres@v${GO_WINRES_VERSION}"
|
||||||
|
|
||||||
RUN mkdir -p /cache/go-build /cache/go/pkg/mod /cache/xdg /workspace \
|
RUN mkdir -p /cache/go-build /cache/go/pkg/mod /cache/xdg /workspace \
|
||||||
&& chown -R 1001:1001 /cache /workspace
|
&& chown -R 1001:1001 /cache /workspace
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ services:
|
|||||||
|
|
||||||
server:
|
server:
|
||||||
image: "${RVBOX_SERVER_IMAGE:?set RVBOX_SERVER_IMAGE to a pinned rvbox-server image}"
|
image: "${RVBOX_SERVER_IMAGE:?set RVBOX_SERVER_IMAGE to a pinned rvbox-server image}"
|
||||||
|
# The init container is the only root process. Its exact 65532 ownership
|
||||||
|
# setup lets the long-lived server run without host/container root power.
|
||||||
|
user: "65532:65532"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["--config", "/etc/rvbox/server.toml"]
|
command: ["--config", "/etc/rvbox/server.toml"]
|
||||||
read_only: true
|
read_only: true
|
||||||
|
|||||||
@@ -19,3 +19,9 @@ enabled = false
|
|||||||
[observability]
|
[observability]
|
||||||
# nginx exposes only /livez and /readyz, not the metrics listener itself.
|
# nginx exposes only /livez and /readyz, not the metrics listener itself.
|
||||||
listen = "0.0.0.0:6901"
|
listen = "0.0.0.0:6901"
|
||||||
|
# The server retains its own bounded diagnostics beside durable state.
|
||||||
|
log_file = "/var/lib/rvbox-server/logs/rvbox-server.log"
|
||||||
|
# Rotate the current log after this many bytes (10 MiB).
|
||||||
|
log_max_bytes = 10485760
|
||||||
|
# Number of sealed rotated log files to retain.
|
||||||
|
log_max_files = 5
|
||||||
|
|||||||
@@ -97,8 +97,11 @@ Scheduler is not used.
|
|||||||
4. The server queues work while a client is offline and dispatches it only when
|
4. The server queues work while a client is offline and dispatches it only when
|
||||||
the active session advertises capacity. A replacement session immediately
|
the active session advertises capacity. A replacement session immediately
|
||||||
performs bidirectional reconciliation between the server's non-terminal set
|
performs bidirectional reconciliation between the server's non-terminal set
|
||||||
and the client's complete retained-command set. The server returns explicit
|
and the client's complete retained-command set. That comparison uses one
|
||||||
local terminate/discard decisions before new dispatch begins.
|
server receipt-time cutoff captured before session registration, so commands
|
||||||
|
admitted during the handshake remain fresh queued work rather than false
|
||||||
|
missing-client contradictions. The server returns explicit local
|
||||||
|
terminate/discard decisions before new dispatch begins.
|
||||||
|
|
||||||
## Command model
|
## Command model
|
||||||
|
|
||||||
|
|||||||
@@ -888,10 +888,28 @@ invisible Guest Control session must never answer it.
|
|||||||
When that bounded manual step is necessary, use the tracked Docker-only
|
When that bounded manual step is necessary, use the tracked Docker-only
|
||||||
[`test/rdp-access`](../test/rdp-access/README.md) helper. It starts a
|
[`test/rdp-access`](../test/rdp-access/README.md) helper. It starts a
|
||||||
self-signed HTTPS Guacamole gateway only after `test-host prepare` holds the
|
self-signed HTTPS Guacamole gateway only after `test-host prepare` holds the
|
||||||
fixture lease; VRDE remains loopback-only on Helium and its SSH tunnel is bound
|
fixture lease; VRDE remains loopback-only on Helium and its reconnecting SSH
|
||||||
|
watchdog/tunnel is bound
|
||||||
only to the helper's private Docker gateway. Stop the helper before the normal
|
only to the helper's private Docker gateway. Stop the helper before the normal
|
||||||
`test-host reset`. It is a recovery interface, not a product component or a
|
`test-host reset`. It is a recovery interface, not a product component or a
|
||||||
replacement for Guest Control/native test automation.
|
replacement for Guest Control/native test automation.
|
||||||
|
The helper's `repair` action may restart only guacd when a dropped browser
|
||||||
|
leaves a stale worker holding the fixture's single VRDE slot; it must not
|
||||||
|
restart or mutate the Windows VM automatically.
|
||||||
|
Because Windows can power off the virtual monitor while the VM remains
|
||||||
|
running, `test-host prepare` must also reapply the disposable display/sleep
|
||||||
|
keepalive and record `prepare-display-keepalive`. A zero-bpp VRDE framebuffer
|
||||||
|
otherwise leaves an otherwise authenticated Guacamole browser at “Waiting for
|
||||||
|
response”. If the public browser hostname has an AAAA record, `rdp-access up
|
||||||
|
--bind 0.0.0.0` must own a tracked IPv6-to-IPv4 forward and expose its
|
||||||
|
`ipv6_forward` status; this prevents a browser selecting IPv6 for the WebSocket
|
||||||
|
from silently taking a different, refused path.
|
||||||
|
The helper preserves the single-fixture `default` profile for compatibility;
|
||||||
|
simultaneous already-running VM gateways use a unique `RDP_ACCESS_PROFILE` (or
|
||||||
|
`--profile`) with separate runtime/project state and HTTP/tunnel ports, while
|
||||||
|
`RVBOX_TEST_VBOX_HOST`, the documented VM/snapshot identity variables, and
|
||||||
|
`RDP_ACCESS_VRDE_PORT` select the actual target. Profile isolation does not
|
||||||
|
override the native controller's exclusive lease for prepare/reset operations.
|
||||||
|
|
||||||
For this provisioned lane, the approved host-only credential-file location is
|
For this provisioned lane, the approved host-only credential-file location is
|
||||||
`/home/cabbage/.local/share/rvbox-secrets/rvbox-win10-test.password`. It must
|
`/home/cabbage/.local/share/rvbox-secrets/rvbox-win10-test.password`. It must
|
||||||
@@ -1157,8 +1175,10 @@ Use one declared test matrix and the same harness entry points everywhere:
|
|||||||
native Windows integration suites, plus `smoke`, `idempotency`, `reconnect`,
|
native Windows integration suites, plus `smoke`, `idempotency`, `reconnect`,
|
||||||
and `retention` E2E scenarios;
|
and `retention` E2E scenarios;
|
||||||
- release candidates run every E2E scenario, including the resettable
|
- release candidates run every E2E scenario, including the resettable
|
||||||
interactive Windows VM matrix, Server Core smoke, destructive fault cases,
|
interactive Windows 10 VM matrix, destructive fault cases, upgrade/recovery,
|
||||||
upgrade/recovery, and soak.
|
and soak. Server Core, older-build, and ambiguous-multi-session smoke lanes
|
||||||
|
run when their dedicated fixtures are provisioned; they are explicitly
|
||||||
|
deferred compatibility work and do not block the Windows 10 v1 baseline.
|
||||||
|
|
||||||
CI allocates a run ID per job, always invokes `collect` after failure, and invokes
|
CI allocates a run ID per job, always invokes `collect` after failure, and invokes
|
||||||
`reset` in an unconditional finalizer. Upload only the bounded redacted report,
|
`reset` in an unconditional finalizer. Upload only the bounded redacted report,
|
||||||
@@ -1751,6 +1771,12 @@ a lost final `EventAck` followed by server retention. Write this idempotent
|
|||||||
result before dispatch. A client with unresolved essential-store corruption
|
result before dispatch. A client with unresolved essential-store corruption
|
||||||
cannot complete reconciliation or accept work.
|
cannot complete reconciliation or accept work.
|
||||||
|
|
||||||
|
Capture one server receipt-time cutoff immediately before registering the live
|
||||||
|
session, and use that identical cutoff when building `ReconcileRequest` and
|
||||||
|
applying its `ReconcileSnapshot`. A command admitted after that cutoff is new
|
||||||
|
work: it must remain queued for post-reconciliation dispatch, never be treated
|
||||||
|
as absent client evidence merely because its admission raced the handshake.
|
||||||
|
|
||||||
Implement reconciliation as this explicit matrix:
|
Implement reconciliation as this explicit matrix:
|
||||||
|
|
||||||
| Server state | Client evidence | Durable result |
|
| Server state | Client evidence | Durable result |
|
||||||
|
|||||||
@@ -25,6 +25,24 @@ the process is up; `/readyz` becomes successful only after durable recovery.
|
|||||||
The public endpoint accepts only `wss://HOST/v1/agent`. Do not publish port
|
The public endpoint accepts only `wss://HOST/v1/agent`. Do not publish port
|
||||||
6900 or add a proxy route for JSON-RPC.
|
6900 or add a proxy route for JSON-RPC.
|
||||||
|
|
||||||
|
## Health, metrics, and logs
|
||||||
|
|
||||||
|
Keep the configured observability listener private to the host or monitoring
|
||||||
|
network. `/metrics` exposes only bounded, aggregate Prometheus samples: health
|
||||||
|
state; registration/takeover and protocol failures; session/reconnect and
|
||||||
|
heartbeat timing; dispatch/event/transition counts and latency; and client
|
||||||
|
output accounting. It never includes a command body, stdin, output, client ID,
|
||||||
|
or UUID label. Duration histograms use fixed buckets, so monitoring traffic
|
||||||
|
cannot create unbounded series.
|
||||||
|
|
||||||
|
For a Windows client, readiness is false while its durable spool is recovering
|
||||||
|
or it has no reconciled server session; it becomes true only while the active
|
||||||
|
WSS session can exchange command data. Server liveness starts before
|
||||||
|
asynchronous recovery, while server readiness remains false until that recovery
|
||||||
|
completes. The configured rotating JSON/text service logs are diagnostics, not
|
||||||
|
a command-output store; use `rvc` history and the audited storage for command
|
||||||
|
evidence.
|
||||||
|
|
||||||
## Backup and restore
|
## Backup and restore
|
||||||
|
|
||||||
Stop dispatch before copying data: stop the server gracefully, confirm it is
|
Stop dispatch before copying data: stop the server gracefully, confirm it is
|
||||||
@@ -51,6 +69,37 @@ false and records an incident; do not delete segments to force readiness.
|
|||||||
and full data directory, then start the known-good version. Preserve logs
|
and full data directory, then start the known-good version. Preserve logs
|
||||||
and the failed copy for diagnosis.
|
and the failed copy for diagnosis.
|
||||||
|
|
||||||
|
## Release bundle
|
||||||
|
|
||||||
|
Build a release candidate only from a clean, committed worktree. The
|
||||||
|
containerized release wrapper embeds the supplied version in all three binaries,
|
||||||
|
creates an immutable `dist/rvbox-VERSION` directory, and writes `SHA256SUMS`
|
||||||
|
plus `manifest.json` only after the Windows executable has optionally been
|
||||||
|
signed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/release build \
|
||||||
|
--version 1.0.0-rc.1 \
|
||||||
|
--bootstrap-server-url wss://rvbox.example.test/v1/agent
|
||||||
|
(cd dist/rvbox-1.0.0-rc.1 && sha256sum -c SHA256SUMS)
|
||||||
|
dist/rvbox-1.0.0-rc.1/rvbox-server-linux-ARCH --version
|
||||||
|
dist/rvbox-1.0.0-rc.1/rvc-linux-ARCH --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `ARCH` with `amd64` or `arm64` for the Linux host. Both variants are
|
||||||
|
created from the same pinned toolchain and have the same embedded version.
|
||||||
|
|
||||||
|
For a Windows-signed release, provide an executable host-side signing hook via
|
||||||
|
`--sign-windows-hook /absolute/path/to/hook`. The wrapper invokes it with the
|
||||||
|
Windows executable path and version, then records `windows_signed: true` in the
|
||||||
|
manifest. Without that hook the manifest deliberately declares the artifact
|
||||||
|
unsigned; this is suitable for CI/test evidence but not a signed public
|
||||||
|
release. `--output` is intentionally constrained beneath this repository's
|
||||||
|
ignored `dist/` tree so the pinned build container always sees the exact output
|
||||||
|
mount. The wrapper never overwrites a final bundle, so correcting a failed
|
||||||
|
candidate requires choosing a new version/output or deliberately removing that
|
||||||
|
exact ignored `dist/` directory after preserving any evidence.
|
||||||
|
|
||||||
## Common incidents
|
## Common incidents
|
||||||
|
|
||||||
- **No client / stale session:** verify nginx has WebSocket `101` entries for
|
- **No client / stale session:** verify nginx has WebSocket `101` entries for
|
||||||
|
|||||||
+16
-4
@@ -88,8 +88,16 @@ and collection. Do not expose the VM's RDP endpoints beyond the test LAN.
|
|||||||
For the rare interactive UAC/manual-recovery step, use the Docker-only helper
|
For the rare interactive UAC/manual-recovery step, use the Docker-only helper
|
||||||
in [`test/rdp-access`](../test/rdp-access/README.md). It creates a temporary
|
in [`test/rdp-access`](../test/rdp-access/README.md). It creates a temporary
|
||||||
self-signed HTTPS Guacamole gateway while retaining VRDE on Helium loopback and
|
self-signed HTTPS Guacamole gateway while retaining VRDE on Helium loopback and
|
||||||
the SSH tunnel on a private Docker gateway. Follow its full lease/prepare/up/
|
the reconnecting SSH watchdog/tunnel on a private Docker gateway. Follow its
|
||||||
|
full lease/prepare/up/
|
||||||
down/reset lifecycle; it is not an alternative to the native test controller.
|
down/reset lifecycle; it is not an alternative to the native test controller.
|
||||||
|
In public-bind mode the helper also owns a tracked IPv6-to-IPv4 `socat` forward
|
||||||
|
when the browser hostname has an AAAA record; check `ipv6_forward=active` in
|
||||||
|
`rdp-access status` before diagnosing a browser-side “Waiting for response”.
|
||||||
|
When more than one already-running VM needs browser access, set matching
|
||||||
|
`RVBOX_TEST_VBOX_*`/`RDP_ACCESS_VRDE_PORT` values and a unique
|
||||||
|
`RDP_ACCESS_PROFILE` plus HTTP/tunnel ports; the helper README has the
|
||||||
|
copy/paste example and explains the native-host lease boundary.
|
||||||
|
|
||||||
## Snapshots and reset contract
|
## Snapshots and reset contract
|
||||||
|
|
||||||
@@ -111,10 +119,14 @@ stateful run must:
|
|||||||
1. Acquire the run lease and verify the VM name, UUID, and snapshot UUID.
|
1. Acquire the run lease and verify the VM name, UUID, and snapshot UUID.
|
||||||
2. Restore `baseline-clean-administrator` if the current state is not the baseline.
|
2. Restore `baseline-clean-administrator` if the current state is not the baseline.
|
||||||
3. Start headless and wait for `VMState=running` plus Guest Additions readiness.
|
3. Start headless and wait for `VMState=running` plus Guest Additions readiness.
|
||||||
4. Run the bounded test, collect redacted artifacts, and close every Guest
|
4. Apply the disposable display/sleep keepalive (`monitor-timeout`, standby,
|
||||||
|
and hibernate timers set to zero) so VirtualBox VRDE cannot expose a
|
||||||
|
zero-bpp framebuffer after Windows idle timeout. This is recorded as
|
||||||
|
`prepare-display-keepalive` and is reapplied after every snapshot restore.
|
||||||
|
5. Run the bounded test, collect redacted artifacts, and close every Guest
|
||||||
Control process that was opened by the run.
|
Control process that was opened by the run.
|
||||||
5. Request a graceful guest shutdown and wait for `VMState=poweroff`.
|
6. Request a graceful guest shutdown and wait for `VMState=poweroff`.
|
||||||
6. Restore `baseline-clean-administrator` again and leave the VM powered off.
|
7. Restore `baseline-clean-administrator` again and leave the VM powered off.
|
||||||
|
|
||||||
Use `controlvm ... poweroff` only for a hung, disposable test; it can lose
|
Use `controlvm ... poweroff` only for a hung, disposable test; it can lose
|
||||||
guest state. Never delete any clean snapshot, unregister the VM, or alter
|
guest state. Never delete any clean snapshot, unregister the VM, or alter
|
||||||
|
|||||||
+37
-8
@@ -15,6 +15,18 @@ Run focused unit tests with:
|
|||||||
scripts/test-unit --package ./internal/domain --run UUIDv7 --race
|
scripts/test-unit --package ./internal/domain --run UUIDv7 --race
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run one bounded hostile-input fuzz target in the same pinned container with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/test-fuzz --package ./internal/agentproto --name FuzzDecodeEnvelopeBounded_SEC_PROTO_01 --time 30s
|
||||||
|
scripts/test-fuzz --package ./internal/server/control --name FuzzDecodeJSONRPCRequestBounded_SEC_CTL_01 --time 30s
|
||||||
|
```
|
||||||
|
|
||||||
|
The fuzz command disables ordinary tests for that invocation and runs exactly
|
||||||
|
one target. A crash stores Go's minimized reproducer in the affected package's
|
||||||
|
fuzz corpus, so the normal test gate exercises it as a deterministic seed
|
||||||
|
after it is reviewed and committed.
|
||||||
|
|
||||||
Build all supported binaries without installing `make` or Go on the host:
|
Build all supported binaries without installing `make` or Go on the host:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -70,8 +82,12 @@ default). Helium hosts the VM only; it does not host any RVBox server
|
|||||||
containers. The self-signed server certificate is intentionally accepted by
|
containers. The self-signed server certificate is intentionally accepted by
|
||||||
the v1 client without a test CA. It then drives the installed SCM service
|
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
|
through the server's real Unix control socket and verifies every Windows
|
||||||
execution context. The tagged binary's controlled pre-launch failures are
|
execution context, ordered stdin close, TERM delivery, and a server-process
|
||||||
limited to the test fixture; a release binary rejects that switch.
|
restart while a command is running. The restart check preserves the server
|
||||||
|
state volume, waits for a new reconciled WSS session, then proves that the same
|
||||||
|
command can receive its terminal signal; it covers reconnect without treating
|
||||||
|
the old session as valid. 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
|
Successful runs collect bounded artifacts, remove only their labeled Compose
|
||||||
project, and restore the exact clean snapshot. A failed or --keep run stays
|
project, and restore the exact clean snapshot. A failed or --keep run stays
|
||||||
@@ -192,6 +208,11 @@ Interactive browser access is a deliberately temporary recovery path only. See
|
|||||||
[`test/rdp-access`](../test/rdp-access/README.md) for the Docker-only,
|
[`test/rdp-access`](../test/rdp-access/README.md) for the Docker-only,
|
||||||
self-signed HTTPS Guacamole lifecycle; it must be started only after the native
|
self-signed HTTPS Guacamole lifecycle; it must be started only after the native
|
||||||
fixture controller has prepared and leased the VM, and stopped before reset.
|
fixture controller has prepared and leased the VM, and stopped before reset.
|
||||||
|
For a public hostname with an AAAA record, its `up --bind 0.0.0.0` mode also
|
||||||
|
tracks an IPv6-to-IPv4 forward; verify `ipv6_forward=active` before debugging
|
||||||
|
a browser stuck at “Waiting for response”. If several already-running VMs need
|
||||||
|
browser access at once, use the documented `RDP_ACCESS_PROFILE` and matching
|
||||||
|
host/VRDE/HTTP/tunnel-port overrides in the helper README.
|
||||||
|
|
||||||
The Linux production Compose asset has a separate, loopback-only smoke lane. It
|
The Linux production Compose asset has a separate, loopback-only smoke lane. It
|
||||||
uses a disposable self-signed key only under the ignored `.test-runs` tree,
|
uses a disposable self-signed key only under the ignored `.test-runs` tree,
|
||||||
@@ -222,7 +243,7 @@ that mode-600 file; the controller never puts it on a command line, manifest,
|
|||||||
log, or artifact.
|
log, or artifact.
|
||||||
|
|
||||||
The native lifecycle is `status`, `prepare`, `stage`, `install`, `run`,
|
The native lifecycle is `status`, `prepare`, `stage`, `install`, `run`,
|
||||||
`collect`, `stop`, and `reset`. `prepare` verifies the VM and snapshot UUIDs,
|
`collect`, `logs`, `stop`, and `reset`. `prepare` verifies the VM and snapshot UUIDs,
|
||||||
restores the clean baseline, starts headless, waits for Guest Additions, and
|
restores the clean baseline, starts headless, waits for Guest Additions, and
|
||||||
proves that `RVBoxClient` is absent. `stage` copies a versioned non-secret test
|
proves that `RVBoxClient` is absent. `stage` copies a versioned non-secret test
|
||||||
bundle through a run-specific host directory to a run-specific guest directory.
|
bundle through a run-specific host directory to a run-specific guest directory.
|
||||||
@@ -230,8 +251,14 @@ bundle through a run-specific host directory to a run-specific guest directory.
|
|||||||
the real `rvbox.exe --install-service` path and proves completion through SCM.
|
the real `rvbox.exe --install-service` path and proves completion through SCM.
|
||||||
`run` is for reconfiguration/restart scenarios after that first installation.
|
`run` is for reconfiguration/restart scenarios after that first installation.
|
||||||
Neither action invokes the GUI-subsystem executable directly with the normal
|
Neither action invokes the GUI-subsystem executable directly with the normal
|
||||||
Guest Control account. `collect` obtains only bounded/redacted artifacts, and
|
Guest Control account. `collect` obtains only bounded/redacted artifacts.
|
||||||
`reset` restores the exact clean baseline and leaves the VM powered off.
|
`logs` is the narrow read-only service-startup/client-diagnostics action for a
|
||||||
|
retained prepared run. The service diagnostic file grants access to SYSTEM and
|
||||||
|
local Administrators only; it contains no command spool data. `reset` restores
|
||||||
|
the exact clean baseline and leaves the VM powered off. It
|
||||||
|
first permits a bounded ACPI shutdown; if that hangs, it force-powers off only
|
||||||
|
the exact leased disposable VM before snapshot restoration. That intentional
|
||||||
|
state loss is confined to the test isolation boundary.
|
||||||
|
|
||||||
This service-driven protocol is required because VirtualBox Guest Control
|
This service-driven protocol is required because VirtualBox Guest Control
|
||||||
7.2.16 does not reliably complete a direct GUI-subsystem `rvbox.exe` run;
|
7.2.16 does not reliably complete a direct GUI-subsystem `rvbox.exe` run;
|
||||||
@@ -264,6 +291,8 @@ execution context. The normal `rvboxtest` console session remains the subject
|
|||||||
of active-user and elevation tests. See [testing-vm.md](testing-vm.md) for the
|
of active-user and elevation tests. See [testing-vm.md](testing-vm.md) for the
|
||||||
exact fixture contract.
|
exact fixture contract.
|
||||||
|
|
||||||
The VM is the minimum smoke lane, so native multi-session/ambiguous-session,
|
The VM is the current Windows 10 v1 smoke lane. Native
|
||||||
Server Core, and older-build entries remain explicitly blocked until their own
|
multi-session/ambiguous-session, Server Core, and older-build entries are
|
||||||
fixtures exist. Their pure selector tests remain mandatory.
|
explicitly deferred compatibility work until their own fixtures exist; they do
|
||||||
|
not block the Windows 10 v1 baseline. Their pure selector tests remain
|
||||||
|
mandatory.
|
||||||
|
|||||||
@@ -42,6 +42,35 @@ func TestDecodeEnvelopeBoundaries_HP_PROTO_01(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FuzzDecodeEnvelopeBounded_SEC_PROTO_01(f *testing.F) {
|
||||||
|
valid, err := proto.Marshal(&rvboxv1.AgentEnvelope{Payload: &rvboxv1.AgentEnvelope_ClientHello{ClientHello: validHello()}})
|
||||||
|
if err != nil {
|
||||||
|
f.Fatal(err)
|
||||||
|
}
|
||||||
|
f.Add(valid)
|
||||||
|
f.Add([]byte{0xff})
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
limits := DefaultLimits()
|
||||||
|
if len(data) > int(limits.MaxEnvelopeBytes)+1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = DecodeEnvelope(data, limits, rvboxv1.Platform_PLATFORM_WINDOWS)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzDecodeOutputChunkBounded_SEC_PROTO_02(f *testing.F) {
|
||||||
|
f.Add([]byte("plain"), uint8(rvboxv1.Compression_COMPRESSION_NONE), uint64(5))
|
||||||
|
f.Add([]byte{0x28, 0xb5, 0x2f, 0xfd}, uint8(rvboxv1.Compression_COMPRESSION_ZSTD), uint64(1))
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte, compression uint8, rawBytes uint64) {
|
||||||
|
const limit = uint64(64 << 10)
|
||||||
|
if len(data) > int(limit) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunk := &rvboxv1.OutputChunk{Stream: rvboxv1.StreamKind_STREAM_STDOUT, Compression: rvboxv1.Compression(compression % 3), CompressedSize: uint64(len(data)), UncompressedSize: rawBytes % (limit + 2), Data: data}
|
||||||
|
_, _ = DecodeOutputChunk(chunk, limit)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnvelopeSessionFencingShape_BH_SES_01(t *testing.T) {
|
func TestEnvelopeSessionFencingShape_BH_SES_01(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/rvbox/rvbox/internal/client/spool"
|
"github.com/rvbox/rvbox/internal/client/spool"
|
||||||
"github.com/rvbox/rvbox/internal/client/supervisor"
|
"github.com/rvbox/rvbox/internal/client/supervisor"
|
||||||
"github.com/rvbox/rvbox/internal/domain"
|
"github.com/rvbox/rvbox/internal/domain"
|
||||||
|
"github.com/rvbox/rvbox/internal/observability"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
@@ -25,6 +26,7 @@ type Executor struct {
|
|||||||
WorkDir string
|
WorkDir string
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
Notify func(domain.UUID)
|
Notify func(domain.UUID)
|
||||||
|
Metrics *observability.Health
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
active map[domain.UUID]supervisor.Process
|
active map[domain.UUID]supervisor.Process
|
||||||
@@ -38,6 +40,7 @@ type ExecutorOptions struct {
|
|||||||
WorkDir string
|
WorkDir string
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
Notify func(domain.UUID)
|
Notify func(domain.UUID)
|
||||||
|
Metrics *observability.Health
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExecutor(options ExecutorOptions) (*Executor, error) {
|
func NewExecutor(options ExecutorOptions) (*Executor, error) {
|
||||||
@@ -47,7 +50,7 @@ func NewExecutor(options ExecutorOptions) (*Executor, error) {
|
|||||||
if options.Now == nil {
|
if options.Now == nil {
|
||||||
options.Now = func() time.Time { return time.Now().UTC() }
|
options.Now = func() time.Time { return time.Now().UTC() }
|
||||||
}
|
}
|
||||||
return &Executor{Store: options.Store, Supervisor: options.Supervisor, WorkDir: options.WorkDir, Now: options.Now, Notify: options.Notify, active: make(map[domain.UUID]supervisor.Process), cancel: make(map[domain.UUID]context.CancelFunc), forced: make(map[domain.UUID]bool)}, nil
|
return &Executor{Store: options.Store, Supervisor: options.Supervisor, WorkDir: options.WorkDir, Now: options.Now, Notify: options.Notify, Metrics: options.Metrics, active: make(map[domain.UUID]supervisor.Process), cancel: make(map[domain.UUID]context.CancelFunc), forced: make(map[domain.UUID]bool)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch is safe to invoke after CommandAccepted has been sent. Script
|
// Dispatch is safe to invoke after CommandAccepted has been sent. Script
|
||||||
@@ -158,6 +161,7 @@ func (executor *Executor) launch(ctx context.Context, issue domain.UUID, revisio
|
|||||||
executor.remove(issue)
|
executor.remove(issue)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
executor.incMetric("command_running")
|
||||||
executor.notify(issue)
|
executor.notify(issue)
|
||||||
go executor.watch(runContext, issue, revision, process, cancel)
|
go executor.watch(runContext, issue, revision, process, cancel)
|
||||||
return nil
|
return nil
|
||||||
@@ -171,6 +175,7 @@ func (executor *Executor) watch(ctx context.Context, issue domain.UUID, revision
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
executor.incMetric("output_loss")
|
||||||
_ = executor.appendIncomplete(context.Background(), issue, revision, err.Error())
|
_ = executor.appendIncomplete(context.Background(), issue, revision, err.Error())
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -178,10 +183,13 @@ func (executor *Executor) watch(ctx context.Context, issue domain.UUID, revision
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := executor.Store.AppendOutput(context.Background(), issue, spool.OutputInput{Stream: chunk.Stream, Raw: chunk.Data, ObservedAt: executor.Now()}); err != nil {
|
if _, err := executor.Store.AppendOutput(context.Background(), issue, spool.OutputInput{Stream: chunk.Stream, Raw: chunk.Data, ObservedAt: executor.Now()}); err != nil {
|
||||||
|
executor.incMetric("output_loss")
|
||||||
_ = executor.appendIncomplete(context.Background(), issue, revision, err.Error())
|
_ = executor.appendIncomplete(context.Background(), issue, revision, err.Error())
|
||||||
_, _ = executor.Supervisor.Signal(context.Background(), process, supervisor.SignalKill)
|
_, _ = executor.Supervisor.Signal(context.Background(), process, supervisor.SignalKill)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
executor.incMetric("output_chunk")
|
||||||
|
executor.incMetricBy("output_bytes", uint64(len(chunk.Data)))
|
||||||
executor.notify(issue)
|
executor.notify(issue)
|
||||||
}
|
}
|
||||||
status, waitErr := process.Wait(context.Background())
|
status, waitErr := process.Wait(context.Background())
|
||||||
@@ -210,9 +218,24 @@ func (executor *Executor) appendLifecycle(ctx context.Context, issue domain.UUID
|
|||||||
|
|
||||||
func (executor *Executor) appendLifecycleWithIdentity(ctx context.Context, issue domain.UUID, revision uint64, phase rvboxv1.CommandLifecycle, detail string, identity *rvboxv1.WindowsExecutionIdentity) error {
|
func (executor *Executor) appendLifecycleWithIdentity(ctx context.Context, issue domain.UUID, revision uint64, phase rvboxv1.CommandLifecycle, detail string, identity *rvboxv1.WindowsExecutionIdentity) error {
|
||||||
_, err := executor.Store.AppendLifecycleWithIdentity(ctx, issue, uint32(phase), revision, detail, executor.Now(), identity)
|
_, err := executor.Store.AppendLifecycleWithIdentity(ctx, issue, uint32(phase), revision, detail, executor.Now(), identity)
|
||||||
|
if err == nil {
|
||||||
|
executor.incMetric("command_transition")
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (executor *Executor) incMetric(name string) {
|
||||||
|
if executor != nil && executor.Metrics != nil {
|
||||||
|
executor.Metrics.Inc(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (executor *Executor) incMetricBy(name string, delta uint64) {
|
||||||
|
if executor != nil && executor.Metrics != nil {
|
||||||
|
executor.Metrics.IncBy(name, delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (executor *Executor) reject(ctx context.Context, issue domain.UUID, revision uint64, cause error) error {
|
func (executor *Executor) reject(ctx context.Context, issue domain.UUID, revision uint64, cause error) error {
|
||||||
return executor.rejectWithIdentity(ctx, issue, revision, cause, nil)
|
return executor.rejectWithIdentity(ctx, issue, revision, cause, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/rvbox/rvbox/internal/agentproto"
|
"github.com/rvbox/rvbox/internal/agentproto"
|
||||||
"github.com/rvbox/rvbox/internal/client/spool"
|
"github.com/rvbox/rvbox/internal/client/spool"
|
||||||
"github.com/rvbox/rvbox/internal/domain"
|
"github.com/rvbox/rvbox/internal/domain"
|
||||||
|
"github.com/rvbox/rvbox/internal/observability"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
@@ -35,6 +36,16 @@ type RunnerOptions struct {
|
|||||||
OnSignal func(context.Context, Session, *rvboxv1.SignalCommand) error
|
OnSignal func(context.Context, Session, *rvboxv1.SignalCommand) error
|
||||||
OnScriptReady func(context.Context, Session, domain.UUID) error
|
OnScriptReady func(context.Context, Session, domain.UUID) error
|
||||||
OnTerminate func(context.Context, domain.UUID) error
|
OnTerminate func(context.Context, domain.UUID) error
|
||||||
|
// OnSessionError observes one failed dial, handshake, protocol, or active
|
||||||
|
// transport session before normal reconnect backoff. It must not block; the
|
||||||
|
// durable spool and retry policy remain owned by Run.
|
||||||
|
OnSessionError func(error)
|
||||||
|
// OnSessionActive reports whether a reconciled transport is currently able
|
||||||
|
// to exchange command data. It lets daemon readiness represent connection
|
||||||
|
// state without making reconnect behavior depend on the observer.
|
||||||
|
OnSessionActive func(bool)
|
||||||
|
// Metrics is optional. It only receives static, bounded metric names.
|
||||||
|
Metrics *observability.Health
|
||||||
// EventReady wakes the active session after a supervisor worker appends a
|
// EventReady wakes the active session after a supervisor worker appends a
|
||||||
// durable event. The network loop remains the sole writer; a reconnect can
|
// durable event. The network loop remains the sole writer; a reconnect can
|
||||||
// safely ignore a stale notification because replay reads the spool again.
|
// safely ignore a stale notification because replay reads the spool again.
|
||||||
@@ -94,11 +105,21 @@ func Run(ctx context.Context, options RunnerOptions) error {
|
|||||||
if err := waitUntil(ctx, delay); err != nil {
|
if err := waitUntil(ctx, delay); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if failures > 0 {
|
||||||
|
options.incMetric("reconnect_attempt")
|
||||||
|
}
|
||||||
|
options.incMetric("connection_attempt")
|
||||||
sessionStarted := options.Now()
|
sessionStarted := options.Now()
|
||||||
_ = runOnce(ctx, options)
|
sessionErr := runOnce(ctx, options)
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if sessionErr != nil && options.OnSessionError != nil {
|
||||||
|
options.OnSessionError(sessionErr)
|
||||||
|
}
|
||||||
|
if sessionErr != nil {
|
||||||
|
options.incMetric("connection_failure")
|
||||||
|
}
|
||||||
if options.Now().Sub(sessionStarted) >= options.Backoff.StableReset {
|
if options.Now().Sub(sessionStarted) >= options.Backoff.StableReset {
|
||||||
failures = 0
|
failures = 0
|
||||||
}
|
}
|
||||||
@@ -152,7 +173,7 @@ func RunOnce(ctx context.Context, options RunnerOptions) error {
|
|||||||
func runOnce(ctx context.Context, options RunnerOptions) (resultErr error) {
|
func runOnce(ctx context.Context, options RunnerOptions) (resultErr error) {
|
||||||
transport, err := options.Dial(ctx)
|
transport, err := options.Dial(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("dial agent server: %w", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if closeErr := transport.Close(); resultErr == nil && closeErr != nil {
|
if closeErr := transport.Close(); resultErr == nil && closeErr != nil {
|
||||||
@@ -166,7 +187,8 @@ func runOnce(ctx context.Context, options RunnerOptions) (resultErr error) {
|
|||||||
}
|
}
|
||||||
session, err := Handshake(ctx, transport, options.Hello, limits)
|
session, err := Handshake(ctx, transport, options.Hello, limits)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
options.incMetric("protocol_error")
|
||||||
|
return fmt.Errorf("agent handshake: %w", err)
|
||||||
}
|
}
|
||||||
snapshot, err := options.Store.ReconcileSnapshot(ctx)
|
snapshot, err := options.Store.ReconcileSnapshot(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -174,7 +196,7 @@ func runOnce(ctx context.Context, options RunnerOptions) (resultErr error) {
|
|||||||
}
|
}
|
||||||
result, err := Reconcile(ctx, transport, session, snapshot, limits)
|
result, err := Reconcile(ctx, transport, session, snapshot, limits)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("exchange reconciliation: %w", err)
|
||||||
}
|
}
|
||||||
terminated, err := ApplyReconcileResult(ctx, options.Store, result, options.Now())
|
terminated, err := ApplyReconcileResult(ctx, options.Store, result, options.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -183,17 +205,28 @@ func runOnce(ctx context.Context, options RunnerOptions) (resultErr error) {
|
|||||||
for _, issue := range terminated {
|
for _, issue := range terminated {
|
||||||
if options.OnTerminate != nil {
|
if options.OnTerminate != nil {
|
||||||
if err := options.OnTerminate(ctx, issue); err != nil {
|
if err := options.OnTerminate(ctx, issue); err != nil {
|
||||||
return err
|
return fmt.Errorf("terminate reconciled command %s: %w", issue, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sentEvents := make(map[domain.UUID]uint64)
|
sentEvents := make(map[domain.UUID]uint64)
|
||||||
if err := replayEvents(ctx, transport, options.Store, session, snapshot, limits, sentEvents); err != nil {
|
if err := replayEvents(ctx, transport, options.Store, session, snapshot, limits, sentEvents); err != nil {
|
||||||
return err
|
return fmt.Errorf("replay client events: %w", err)
|
||||||
}
|
}
|
||||||
if err := sendCapacity(ctx, transport, session, options.Hello, snapshot, limits); err != nil {
|
if err := sendCapacity(ctx, transport, session, options.Hello, snapshot, limits); err != nil {
|
||||||
return err
|
return fmt.Errorf("advertise client capacity: %w", err)
|
||||||
}
|
}
|
||||||
|
activeStarted := options.Now()
|
||||||
|
options.incMetric("session_established")
|
||||||
|
if options.OnSessionActive != nil {
|
||||||
|
options.OnSessionActive(true)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if options.OnSessionActive != nil {
|
||||||
|
options.OnSessionActive(false)
|
||||||
|
}
|
||||||
|
options.observeMetric("session_duration", options.Now().Sub(activeStarted))
|
||||||
|
}()
|
||||||
return serveActive(ctx, transport, options, session, sentEvents)
|
return serveActive(ctx, transport, options, session, sentEvents)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,46 +356,48 @@ func serveActive(ctx context.Context, transport Transport, options RunnerOptions
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil
|
return nil
|
||||||
case err := <-readErrors:
|
case err := <-readErrors:
|
||||||
return err
|
return fmt.Errorf("read active agent frame: %w", err)
|
||||||
case issue := <-options.EventReady:
|
case issue := <-options.EventReady:
|
||||||
if issue == (domain.UUID{}) {
|
if issue == (domain.UUID{}) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := flushEvents(ctx, transport, options.Store, session, issue, limits, sent); err != nil {
|
if err := flushEvents(ctx, transport, options.Store, session, issue, limits, sent); err != nil {
|
||||||
return err
|
return fmt.Errorf("flush ready command %s events: %w", issue, err)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
case encoded = <-frames:
|
case encoded = <-frames:
|
||||||
}
|
}
|
||||||
envelope, err := agentproto.DecodeEnvelope(encoded, limits, rvboxv1.Platform_PLATFORM_WINDOWS)
|
envelope, err := agentproto.DecodeEnvelope(encoded, limits, rvboxv1.Platform_PLATFORM_WINDOWS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
options.incMetric("protocol_error")
|
||||||
|
return fmt.Errorf("decode active agent frame: %w", err)
|
||||||
}
|
}
|
||||||
if envelope.GetSessionId() != session.ID || envelope.GetSessionGeneration() != session.Generation {
|
if envelope.GetSessionId() != session.ID || envelope.GetSessionGeneration() != session.Generation {
|
||||||
|
options.incMetric("stale_message")
|
||||||
return ErrProtocolHandshake
|
return ErrProtocolHandshake
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case envelope.GetCommandDispatch() != nil:
|
case envelope.GetCommandDispatch() != nil:
|
||||||
if err := handleDispatch(ctx, transport, options, session, envelope.GetCommandDispatch(), limits); err != nil {
|
if err := handleDispatch(ctx, transport, options, session, envelope.GetCommandDispatch(), limits); err != nil {
|
||||||
return err
|
return fmt.Errorf("handle command dispatch %s: %w", envelope.GetCommandDispatch().GetIssueUuid(), err)
|
||||||
}
|
}
|
||||||
issue, parseErr := domain.ParseUUIDv7(envelope.GetCommandDispatch().GetIssueUuid())
|
issue, parseErr := domain.ParseUUIDv7(envelope.GetCommandDispatch().GetIssueUuid())
|
||||||
if parseErr == nil {
|
if parseErr == nil {
|
||||||
if err := flushEvents(ctx, transport, options.Store, session, issue, limits, sent); err != nil {
|
if err := flushEvents(ctx, transport, options.Store, session, issue, limits, sent); err != nil {
|
||||||
return err
|
return fmt.Errorf("flush dispatched command %s events: %w", issue, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case envelope.GetEventAck() != nil:
|
case envelope.GetEventAck() != nil:
|
||||||
if err := ApplyEventAck(ctx, options.Store, envelope.GetEventAck()); err != nil {
|
if err := ApplyEventAck(ctx, options.Store, envelope.GetEventAck()); err != nil {
|
||||||
return err
|
return fmt.Errorf("apply event acknowledgement for %s: %w", envelope.GetEventAck().GetIssueUuid(), err)
|
||||||
}
|
}
|
||||||
case envelope.GetScriptChunk() != nil:
|
case envelope.GetScriptChunk() != nil:
|
||||||
if err := handleScriptChunk(ctx, transport, options.Store, session, envelope.GetScriptChunk(), limits, options.Now, sent); err != nil {
|
if err := handleScriptChunk(ctx, transport, options.Store, session, envelope.GetScriptChunk(), limits, options.Now, sent); err != nil {
|
||||||
return err
|
return fmt.Errorf("apply script chunk for %s: %w", envelope.GetScriptChunk().GetIssueUuid(), err)
|
||||||
}
|
}
|
||||||
case envelope.GetScriptCommit() != nil:
|
case envelope.GetScriptCommit() != nil:
|
||||||
if err := handleScriptCommit(ctx, transport, options.Store, session, envelope.GetScriptCommit(), limits, options.Now, sent); err != nil {
|
if err := handleScriptCommit(ctx, transport, options.Store, session, envelope.GetScriptCommit(), limits, options.Now, sent); err != nil {
|
||||||
return err
|
return fmt.Errorf("commit script for %s: %w", envelope.GetScriptCommit().GetIssueUuid(), err)
|
||||||
}
|
}
|
||||||
if options.OnScriptReady != nil {
|
if options.OnScriptReady != nil {
|
||||||
issue, err := domain.ParseUUIDv7(envelope.GetScriptCommit().GetIssueUuid())
|
issue, err := domain.ParseUUIDv7(envelope.GetScriptCommit().GetIssueUuid())
|
||||||
@@ -370,40 +405,40 @@ func serveActive(ctx context.Context, transport Transport, options RunnerOptions
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := options.OnScriptReady(ctx, session, issue); err != nil {
|
if err := options.OnScriptReady(ctx, session, issue); err != nil {
|
||||||
return err
|
return fmt.Errorf("start committed script %s: %w", issue, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case envelope.GetStdinWrite() != nil:
|
case envelope.GetStdinWrite() != nil:
|
||||||
if options.OnStdin != nil {
|
if options.OnStdin != nil {
|
||||||
if err := options.OnStdin(ctx, session, envelope.GetStdinWrite()); err != nil {
|
if err := options.OnStdin(ctx, session, envelope.GetStdinWrite()); err != nil {
|
||||||
return err
|
return fmt.Errorf("append stdin for %s: %w", envelope.GetStdinWrite().GetIssueUuid(), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if issue, parseErr := domain.ParseUUIDv7(envelope.GetStdinWrite().GetIssueUuid()); parseErr == nil {
|
if issue, parseErr := domain.ParseUUIDv7(envelope.GetStdinWrite().GetIssueUuid()); parseErr == nil {
|
||||||
if err := flushEvents(ctx, transport, options.Store, session, issue, limits, sent); err != nil {
|
if err := flushEvents(ctx, transport, options.Store, session, issue, limits, sent); err != nil {
|
||||||
return err
|
return fmt.Errorf("flush stdin command %s events: %w", issue, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case envelope.GetCloseStdin() != nil:
|
case envelope.GetCloseStdin() != nil:
|
||||||
if options.OnCloseStdin != nil {
|
if options.OnCloseStdin != nil {
|
||||||
if err := options.OnCloseStdin(ctx, session, envelope.GetCloseStdin()); err != nil {
|
if err := options.OnCloseStdin(ctx, session, envelope.GetCloseStdin()); err != nil {
|
||||||
return err
|
return fmt.Errorf("close stdin for %s: %w", envelope.GetCloseStdin().GetIssueUuid(), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if issue, parseErr := domain.ParseUUIDv7(envelope.GetCloseStdin().GetIssueUuid()); parseErr == nil {
|
if issue, parseErr := domain.ParseUUIDv7(envelope.GetCloseStdin().GetIssueUuid()); parseErr == nil {
|
||||||
if err := flushEvents(ctx, transport, options.Store, session, issue, limits, sent); err != nil {
|
if err := flushEvents(ctx, transport, options.Store, session, issue, limits, sent); err != nil {
|
||||||
return err
|
return fmt.Errorf("flush closed-stdin command %s events: %w", issue, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case envelope.GetSignalCommand() != nil:
|
case envelope.GetSignalCommand() != nil:
|
||||||
if options.OnSignal != nil {
|
if options.OnSignal != nil {
|
||||||
if err := options.OnSignal(ctx, session, envelope.GetSignalCommand()); err != nil {
|
if err := options.OnSignal(ctx, session, envelope.GetSignalCommand()); err != nil {
|
||||||
return err
|
return fmt.Errorf("signal command %s: %w", envelope.GetSignalCommand().GetIssueUuid(), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if issue, parseErr := domain.ParseUUIDv7(envelope.GetSignalCommand().GetIssueUuid()); parseErr == nil {
|
if issue, parseErr := domain.ParseUUIDv7(envelope.GetSignalCommand().GetIssueUuid()); parseErr == nil {
|
||||||
if err := flushEvents(ctx, transport, options.Store, session, issue, limits, sent); err != nil {
|
if err := flushEvents(ctx, transport, options.Store, session, issue, limits, sent); err != nil {
|
||||||
return err
|
return fmt.Errorf("flush signalled command %s events: %w", issue, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case envelope.GetError() != nil:
|
case envelope.GetError() != nil:
|
||||||
@@ -445,6 +480,11 @@ func flushEvents(ctx context.Context, transport Transport, store *spool.Store, s
|
|||||||
func handleDispatch(ctx context.Context, transport Transport, options RunnerOptions, session Session, dispatch *rvboxv1.CommandDispatch, limits agentproto.Limits) error {
|
func handleDispatch(ctx context.Context, transport Transport, options RunnerOptions, session Session, dispatch *rvboxv1.CommandDispatch, limits agentproto.Limits) error {
|
||||||
acceptance, err := PersistDispatch(ctx, options.Store, session, dispatch, options.Now(), limits)
|
acceptance, err := PersistDispatch(ctx, options.Store, session, dispatch, options.Now(), limits)
|
||||||
accepted := err == nil
|
accepted := err == nil
|
||||||
|
if accepted {
|
||||||
|
options.incMetric("command_dispatch_accepted")
|
||||||
|
} else {
|
||||||
|
options.incMetric("command_dispatch_rejected")
|
||||||
|
}
|
||||||
ack := &rvboxv1.CommandAccepted{IssueUuid: dispatch.GetIssueUuid(), CommandRevision: dispatch.GetCommandRevision(), Accepted: accepted}
|
ack := &rvboxv1.CommandAccepted{IssueUuid: dispatch.GetIssueUuid(), CommandRevision: dispatch.GetCommandRevision(), Accepted: accepted}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ack.Rejection = rejectionForError(err, dispatch.GetIssueUuid())
|
ack.Rejection = rejectionForError(err, dispatch.GetIssueUuid())
|
||||||
@@ -467,6 +507,18 @@ func handleDispatch(ctx context.Context, transport Transport, options RunnerOpti
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (options RunnerOptions) incMetric(name string) {
|
||||||
|
if options.Metrics != nil {
|
||||||
|
options.Metrics.Inc(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (options RunnerOptions) observeMetric(name string, duration time.Duration) {
|
||||||
|
if options.Metrics != nil {
|
||||||
|
options.Metrics.ObserveDuration(name, duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func rejectionForError(err error, issue string) *rvboxv1.ControlError {
|
func rejectionForError(err error, issue string) *rvboxv1.ControlError {
|
||||||
code := rvboxv1.ControlError_TRANSIENT
|
code := rvboxv1.ControlError_TRANSIENT
|
||||||
if errors.Is(err, spool.ErrCommandConflict) || errors.Is(err, spool.ErrAlreadyExecuted) {
|
if errors.Is(err, spool.ErrCommandConflict) || errors.Is(err, spool.ErrAlreadyExecuted) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/rvbox/rvbox/internal/agentproto"
|
"github.com/rvbox/rvbox/internal/agentproto"
|
||||||
"github.com/rvbox/rvbox/internal/client/spool"
|
"github.com/rvbox/rvbox/internal/client/spool"
|
||||||
"github.com/rvbox/rvbox/internal/domain"
|
"github.com/rvbox/rvbox/internal/domain"
|
||||||
|
"github.com/rvbox/rvbox/internal/observability"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
@@ -46,7 +47,9 @@ func TestRunOnceReconcilesReplaysAndAdvertisesCapacity_HP_RUNTIME_01(t *testing.
|
|||||||
reconcileResult, _ := proto.Marshal(&rvboxv1.AgentEnvelope{SessionId: "session", SessionGeneration: 1, Payload: &rvboxv1.AgentEnvelope_ReconcileResult{ReconcileResult: &rvboxv1.ReconcileResult{}}})
|
reconcileResult, _ := proto.Marshal(&rvboxv1.AgentEnvelope{SessionId: "session", SessionGeneration: 1, Payload: &rvboxv1.AgentEnvelope_ReconcileResult{ReconcileResult: &rvboxv1.ReconcileResult{}}})
|
||||||
transport := &runnerTransport{reads: [][]byte{welcome, reconcileRequest, reconcileResult}, terminal: errors.New("transport closed")}
|
transport := &runnerTransport{reads: [][]byte{welcome, reconcileRequest, reconcileResult}, terminal: errors.New("transport closed")}
|
||||||
hello := &rvboxv1.ClientHello{ClientId: "runner-client", SupportedProtocol: &rvboxv1.ProtocolRange{Major: 1, MinMinor: 0, MaxMinor: 0}, DaemonVersion: "test", Platform: rvboxv1.Platform_PLATFORM_WINDOWS, Architecture: "amd64", DaemonCwd: `C:\`, SupportedShells: []rvboxv1.ShellType{rvboxv1.ShellType_SHELL_POWERSHELL}, ClientInstanceId: store.ClientInstanceID().String(), MaxRunningCommands: 1, MaxQueuedCommands: 1, SentAt: timestamppb.Now()}
|
hello := &rvboxv1.ClientHello{ClientId: "runner-client", SupportedProtocol: &rvboxv1.ProtocolRange{Major: 1, MinMinor: 0, MaxMinor: 0}, DaemonVersion: "test", Platform: rvboxv1.Platform_PLATFORM_WINDOWS, Architecture: "amd64", DaemonCwd: `C:\`, SupportedShells: []rvboxv1.ShellType{rvboxv1.ShellType_SHELL_POWERSHELL}, ClientInstanceId: store.ClientInstanceID().String(), MaxRunningCommands: 1, MaxQueuedCommands: 1, SentAt: timestamppb.Now()}
|
||||||
err = RunOnce(ctx, RunnerOptions{Store: store, Dial: func(context.Context) (Transport, error) { return transport, nil }, Hello: hello, Limits: agentproto.DefaultLimits(), Backoff: BackoffOptions{Initial: time.Millisecond, Maximum: time.Millisecond, StableReset: time.Second}, Jitter: func(value time.Duration) time.Duration { return 0 }, Now: func() time.Time { return time.Now().UTC() }})
|
metrics := observability.New()
|
||||||
|
var active []bool
|
||||||
|
err = RunOnce(ctx, RunnerOptions{Store: store, Dial: func(context.Context) (Transport, error) { return transport, nil }, Hello: hello, Limits: agentproto.DefaultLimits(), Backoff: BackoffOptions{Initial: time.Millisecond, Maximum: time.Millisecond, StableReset: time.Second}, Jitter: func(value time.Duration) time.Duration { return 0 }, Now: func() time.Time { return time.Now().UTC() }, Metrics: metrics, OnSessionActive: func(value bool) { active = append(active, value) }})
|
||||||
if !errors.Is(err, transport.terminal) {
|
if !errors.Is(err, transport.terminal) {
|
||||||
t.Fatalf("RunOnce error = %v, want transport close", err)
|
t.Fatalf("RunOnce error = %v, want transport close", err)
|
||||||
}
|
}
|
||||||
@@ -64,6 +67,13 @@ func TestRunOnceReconcilesReplaysAndAdvertisesCapacity_HP_RUNTIME_01(t *testing.
|
|||||||
if err != nil || eventEnvelope.GetCommandEvent() == nil || eventEnvelope.GetCommandEvent().GetEventSeq() != 1 || eventEnvelope.GetCommandEvent().GetIssueUuid() != issue.String() {
|
if err != nil || eventEnvelope.GetCommandEvent() == nil || eventEnvelope.GetCommandEvent().GetEventSeq() != 1 || eventEnvelope.GetCommandEvent().GetIssueUuid() != issue.String() {
|
||||||
t.Fatalf("replayed event = %#v, %v", eventEnvelope, err)
|
t.Fatalf("replayed event = %#v, %v", eventEnvelope, err)
|
||||||
}
|
}
|
||||||
|
_, _, counters := metrics.Snapshot()
|
||||||
|
if counters["session_established"] != 1 {
|
||||||
|
t.Fatalf("session metrics = %#v", counters)
|
||||||
|
}
|
||||||
|
if len(active) != 2 || !active[0] || active[1] {
|
||||||
|
t.Fatalf("active transitions = %#v", active)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunnerOptionsRejectMissingJitter_HP_RUNTIME_02(t *testing.T) {
|
func TestRunnerOptionsRejectMissingJitter_HP_RUNTIME_02(t *testing.T) {
|
||||||
@@ -72,6 +82,36 @@ func TestRunnerOptionsRejectMissingJitter_HP_RUNTIME_02(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunReportsRetryableSessionError_HP_RUNTIME_04(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
store, err := spool.Open(ctx, spool.Options{DataDir: filepath.Join(t.TempDir(), "spool"), BusyTimeout: time.Second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = store.Close() })
|
||||||
|
want := errors.New("dial refused")
|
||||||
|
reported := make(chan error, 1)
|
||||||
|
hello := &rvboxv1.ClientHello{ClientId: "runner-client", SupportedProtocol: &rvboxv1.ProtocolRange{Major: 1, MinMinor: 0, MaxMinor: 0}, DaemonVersion: "test", Platform: rvboxv1.Platform_PLATFORM_WINDOWS, Architecture: "amd64", DaemonCwd: `C:\\`, SupportedShells: []rvboxv1.ShellType{rvboxv1.ShellType_SHELL_POWERSHELL}, ClientInstanceId: store.ClientInstanceID().String(), MaxRunningCommands: 1, MaxQueuedCommands: 1, SentAt: timestamppb.Now()}
|
||||||
|
err = Run(ctx, RunnerOptions{
|
||||||
|
Store: store, Dial: func(context.Context) (Transport, error) { return nil, want }, Hello: hello,
|
||||||
|
Limits: agentproto.DefaultLimits(), Backoff: BackoffOptions{Initial: time.Millisecond, Maximum: time.Millisecond, StableReset: time.Second},
|
||||||
|
Jitter: func(time.Duration) time.Duration { return 0 }, Now: func() time.Time { return time.Now().UTC() },
|
||||||
|
OnSessionError: func(got error) { reported <- got; cancel() },
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run error = %v, want graceful cancellation", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-reported:
|
||||||
|
if !errors.Is(got, want) {
|
||||||
|
t.Fatalf("reported error = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("retryable session error was not reported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFlushEventsAssignsAndSendsOnlyUnacknowledgedRows_HP_RUNTIME_03(t *testing.T) {
|
func TestFlushEventsAssignsAndSendsOnlyUnacknowledgedRows_HP_RUNTIME_03(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
store, err := spool.Open(ctx, spool.Options{DataDir: filepath.Join(t.TempDir(), "spool"), BusyTimeout: time.Second})
|
store, err := spool.Open(ctx, spool.Options{DataDir: filepath.Join(t.TempDir(), "spool"), BusyTimeout: time.Second})
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package spool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Telemetry is the bounded durable-spool state exposed as client gauges. It
|
||||||
|
// contains no command body, output, UUID, or client identity.
|
||||||
|
type Telemetry struct {
|
||||||
|
QueuedCommands uint64
|
||||||
|
ChargedBytes uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) Telemetry(ctx context.Context) (Telemetry, error) {
|
||||||
|
if store == nil {
|
||||||
|
return Telemetry{}, errors.New("client spool is unavailable")
|
||||||
|
}
|
||||||
|
store.mu.Lock()
|
||||||
|
defer store.mu.Unlock()
|
||||||
|
if store.db == nil {
|
||||||
|
return Telemetry{}, errors.New("client spool is closed")
|
||||||
|
}
|
||||||
|
var result Telemetry
|
||||||
|
if err := store.db.QueryRowContext(ctx, `SELECT count(*) FROM commands WHERE terminal = 0 AND phase IN (?, ?)`, uint32(rvboxv1.CommandLifecycle_COMMAND_QUEUED), uint32(rvboxv1.CommandLifecycle_COMMAND_DISPATCHED)).Scan(&result.QueuedCommands); err != nil {
|
||||||
|
return Telemetry{}, err
|
||||||
|
}
|
||||||
|
if err := store.db.QueryRowContext(ctx, `SELECT client_total_charged_bytes FROM spool_counters WHERE singleton = 1`).Scan(&result.ChargedBytes); err != nil {
|
||||||
|
return Telemetry{}, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package spool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTelemetryReportsQueuedDurableSpool_HP_OPS_07(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
store := openTestStore(t, ctx, filepath.Join(t.TempDir(), "spool"), DefaultTombstoneLimit)
|
||||||
|
issue := testUUID(t, "019c46f1-1d02-7000-8000-0000000000d2")
|
||||||
|
if _, err := store.AcceptCommand(ctx, testCommand(issue, []byte("telemetry")), time.Now().UTC()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
telemetry, err := store.Telemetry(ctx)
|
||||||
|
if err != nil || telemetry.QueuedCommands != 1 || telemetry.ChargedBytes == 0 {
|
||||||
|
t.Fatalf("telemetry = %#v, %v", telemetry, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,20 @@ func TestClientDefaultsDecodeForWindowsWithoutNativeFilesystem(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientMayDisableObservabilityListener_HP_CFG_10(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
client, err := DecodeClient([]byte("[client]\nstate_dir = \"C:\\\\ProgramData\\\\RVBox\\\\state\"\ndaemon_cwd = \"C:\\\\Users\\\\Public\"\n\n[observability]\nlisten = \"\"\nlog_file = \"\"\n"), ClientOptions{Platform: PlatformWindows})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeClient disabled observability: %v", err)
|
||||||
|
}
|
||||||
|
if client.Observability.Listen != "" || client.Observability.LogFile != "" {
|
||||||
|
t.Fatalf("disabled observability = %+v", client.Observability)
|
||||||
|
}
|
||||||
|
if _, err := DecodeServer([]byte("[observability]\nlisten = \"\"\n")); err == nil {
|
||||||
|
t.Fatal("server accepted a disabled observability listener")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStrictTOMLRejections_HP_CFG_01(t *testing.T) {
|
func TestStrictTOMLRejections_HP_CFG_01(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func defaultServerFile() serverFile {
|
|||||||
},
|
},
|
||||||
Observability: observabilityFile{
|
Observability: observabilityFile{
|
||||||
Listen: "127.0.0.1:6901", LivenessPath: "/livez", ReadinessPath: "/readyz",
|
Listen: "127.0.0.1:6901", LivenessPath: "/livez", ReadinessPath: "/readyz",
|
||||||
MetricsPath: "/metrics", LogLevel: "info", LogFormat: "json",
|
MetricsPath: "/metrics", LogLevel: "info", LogFormat: "json", LogMaxBytes: 10 << 20, LogMaxFiles: 5,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func TestEffectiveConfigurationGolden_HP_CFG_09(t *testing.T) {
|
|||||||
t.Fatal("effective server configuration is nondeterministic")
|
t.Fatal("effective server configuration is nondeterministic")
|
||||||
}
|
}
|
||||||
digest := sha256.Sum256(first)
|
digest := sha256.Sum256(first)
|
||||||
const goldenSHA256 = "55c5fec2c10bc27372353a60f317c5a2959f8087685cf7c11202bb12dc59aa28"
|
const goldenSHA256 = "65c938efd935ce0c0d2354cdb62c38a2dc177755520d1ae1e5951902a70dd323"
|
||||||
if got := hex.EncodeToString(digest[:]); got != goldenSHA256 {
|
if got := hex.EncodeToString(digest[:]); got != goldenSHA256 {
|
||||||
t.Fatalf("effective server configuration golden changed: got %s", got)
|
t.Fatalf("effective server configuration golden changed: got %s", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func validateServer(raw serverFile) (*Server, error) {
|
|||||||
if err := protocolLimits(raw.Protocol.MaxAgentEnvelopeBytes, raw.Protocol.MaxExecutionSpecBytes, raw.Protocol.MaxRawChunkBytes, raw.Protocol.MaxScriptBytes, raw.Protocol.MaxControlRequestBytes, raw.Protocol.MaxJSONRPCBodyBytes); err != nil {
|
if err := protocolLimits(raw.Protocol.MaxAgentEnvelopeBytes, raw.Protocol.MaxExecutionSpecBytes, raw.Protocol.MaxRawChunkBytes, raw.Protocol.MaxScriptBytes, raw.Protocol.MaxControlRequestBytes, raw.Protocol.MaxJSONRPCBodyBytes); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
observability, err := validateObservability(raw.Observability, PlatformUnix)
|
observability, err := validateObservability(raw.Observability, PlatformUnix, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -220,7 +220,7 @@ func validateClient(raw clientFile, options ClientOptions) (*Client, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
observability, err := validateObservability(raw.Observability, options.Platform)
|
observability, err := validateObservability(raw.Observability, options.Platform, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -439,10 +439,15 @@ func validateDeviceRate(key string, value uint64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateObservability(raw observabilityFile, platform Platform) (Observability, error) {
|
func validateObservability(raw observabilityFile, platform Platform, allowDisabled bool) (Observability, error) {
|
||||||
|
if raw.Listen == "" && !allowDisabled {
|
||||||
|
return Observability{}, fmt.Errorf("observability.listen must be a host:port listener")
|
||||||
|
}
|
||||||
|
if raw.Listen != "" {
|
||||||
if err := validateListener("observability.listen", raw.Listen); err != nil {
|
if err := validateListener("observability.listen", raw.Listen); err != nil {
|
||||||
return Observability{}, err
|
return Observability{}, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for name, value := range map[string]string{"liveness_path": raw.LivenessPath, "readiness_path": raw.ReadinessPath, "metrics_path": raw.MetricsPath} {
|
for name, value := range map[string]string{"liveness_path": raw.LivenessPath, "readiness_path": raw.ReadinessPath, "metrics_path": raw.MetricsPath} {
|
||||||
if err := validateHTTPPath("observability."+name, value); err != nil {
|
if err := validateHTTPPath("observability."+name, value); err != nil {
|
||||||
return Observability{}, err
|
return Observability{}, err
|
||||||
|
|||||||
@@ -72,3 +72,24 @@ func TestCursorInputBounds_BH_CTL_01(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FuzzDecodeCursorBounded_SEC_CTL_02 exercises the token boundary shared by
|
||||||
|
// every cursor-resumable control read. Decode must reject malformed or forged
|
||||||
|
// input without panicking or allocating past its documented token ceiling.
|
||||||
|
func FuzzDecodeCursorBounded_SEC_CTL_02(f *testing.F) {
|
||||||
|
codec, err := NewCursorCodec(bytes.Repeat([]byte{0x42}, 32))
|
||||||
|
if err != nil {
|
||||||
|
f.Fatal(err)
|
||||||
|
}
|
||||||
|
filters := HashCursorFilters([]byte("client=host-1\x00streams=stdout"))
|
||||||
|
valid, err := codec.Encode(Cursor{Kind: CursorKindOutput, FilterHash: filters, Position: []byte("event/offset"), SnapshotBoundary: []byte("upper-event")})
|
||||||
|
if err != nil {
|
||||||
|
f.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, seed := range []string{"", "***", valid, strings.Repeat("a", 4096)} {
|
||||||
|
f.Add(seed)
|
||||||
|
}
|
||||||
|
f.Fuzz(func(t *testing.T, token string) {
|
||||||
|
_, _ = codec.Decode(token, CursorKindOutput, filters)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package observability
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -27,9 +28,28 @@ type Health struct {
|
|||||||
dirty atomic.Bool
|
dirty atomic.Bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
count map[string]uint64
|
count map[string]uint64
|
||||||
|
gauge map[string]float64
|
||||||
|
hist map[string]*durationHistogram
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Health { return &Health{count: make(map[string]uint64)} }
|
type durationHistogram struct {
|
||||||
|
count uint64
|
||||||
|
sum float64
|
||||||
|
buckets [len(durationBuckets)]uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fixed buckets are intentionally shared by every duration metric. This
|
||||||
|
// keeps the Prometheus surface bounded and makes comparable operational
|
||||||
|
// latencies available without allowing callers to create arbitrary labels.
|
||||||
|
var durationBuckets = [...]float64{0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1, 5, 15, 60}
|
||||||
|
|
||||||
|
func New() *Health {
|
||||||
|
return &Health{
|
||||||
|
count: make(map[string]uint64),
|
||||||
|
gauge: make(map[string]float64),
|
||||||
|
hist: make(map[string]*durationHistogram),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (health *Health) SetReady(value bool) {
|
func (health *Health) SetReady(value bool) {
|
||||||
if health != nil {
|
if health != nil {
|
||||||
@@ -44,11 +64,52 @@ func (health *Health) SetDirty(value bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (health *Health) Inc(name string) {
|
func (health *Health) Inc(name string) {
|
||||||
if health == nil || !validMetricName(name) {
|
health.IncBy(name, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncBy records a non-negative integral counter delta. Metric names are
|
||||||
|
// deliberately restricted to a small identifier grammar; callers cannot turn
|
||||||
|
// a client ID, request UUID, or other untrusted data into a metric name.
|
||||||
|
func (health *Health) IncBy(name string, delta uint64) {
|
||||||
|
if health == nil || delta == 0 || !validMetricName(name) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
health.mu.Lock()
|
health.mu.Lock()
|
||||||
health.count[name]++
|
health.count[name] += delta
|
||||||
|
health.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGauge records a bounded-cardinality instantaneous value. NaN and
|
||||||
|
// infinities are discarded because they are not safe Prometheus samples.
|
||||||
|
func (health *Health) SetGauge(name string, value float64) {
|
||||||
|
if health == nil || !validMetricName(name) || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
health.mu.Lock()
|
||||||
|
health.gauge[name] = value
|
||||||
|
health.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObserveDuration records a duration in a fixed-bucket histogram. Negative
|
||||||
|
// durations are invalid (normally a caller clock error) and are ignored.
|
||||||
|
func (health *Health) ObserveDuration(name string, duration time.Duration) {
|
||||||
|
if health == nil || !validMetricName(name) || duration < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seconds := duration.Seconds()
|
||||||
|
health.mu.Lock()
|
||||||
|
histogram := health.hist[name]
|
||||||
|
if histogram == nil {
|
||||||
|
histogram = &durationHistogram{}
|
||||||
|
health.hist[name] = histogram
|
||||||
|
}
|
||||||
|
histogram.count++
|
||||||
|
histogram.sum += seconds
|
||||||
|
for index, upperBound := range durationBuckets {
|
||||||
|
if seconds <= upperBound {
|
||||||
|
histogram.buckets[index]++
|
||||||
|
}
|
||||||
|
}
|
||||||
health.mu.Unlock()
|
health.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +126,46 @@ func (health *Health) Snapshot() (ready, dirty bool, counters map[string]uint64)
|
|||||||
return health.ready.Load(), health.dirty.Load(), copyCounters
|
return health.ready.Load(), health.dirty.Load(), copyCounters
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MetricsSnapshot struct {
|
||||||
|
Ready bool
|
||||||
|
Dirty bool
|
||||||
|
Counters map[string]uint64
|
||||||
|
Gauges map[string]float64
|
||||||
|
Histograms map[string]DurationHistogramSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
type DurationHistogramSnapshot struct {
|
||||||
|
Count uint64
|
||||||
|
Sum float64
|
||||||
|
Buckets [len(durationBuckets)]uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetricsSnapshot returns a copy suitable for rendering or testing. It never
|
||||||
|
// includes application identifiers or payloads.
|
||||||
|
func (health *Health) MetricsSnapshot() MetricsSnapshot {
|
||||||
|
if health == nil {
|
||||||
|
return MetricsSnapshot{Dirty: true}
|
||||||
|
}
|
||||||
|
health.mu.Lock()
|
||||||
|
defer health.mu.Unlock()
|
||||||
|
result := MetricsSnapshot{
|
||||||
|
Ready: health.ready.Load(), Dirty: health.dirty.Load(),
|
||||||
|
Counters: make(map[string]uint64, len(health.count)),
|
||||||
|
Gauges: make(map[string]float64, len(health.gauge)),
|
||||||
|
Histograms: make(map[string]DurationHistogramSnapshot, len(health.hist)),
|
||||||
|
}
|
||||||
|
for name, value := range health.count {
|
||||||
|
result.Counters[name] = value
|
||||||
|
}
|
||||||
|
for name, value := range health.gauge {
|
||||||
|
result.Gauges[name] = value
|
||||||
|
}
|
||||||
|
for name, value := range health.hist {
|
||||||
|
result.Histograms[name] = DurationHistogramSnapshot{Count: value.count, Sum: value.sum, Buckets: value.buckets}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (health *Health) Handler(paths Paths) http.Handler {
|
func (health *Health) Handler(paths Paths) http.Handler {
|
||||||
if paths.Liveness == "" {
|
if paths.Liveness == "" {
|
||||||
paths.Liveness = "/livez"
|
paths.Liveness = "/livez"
|
||||||
@@ -92,17 +193,38 @@ func (health *Health) Handler(paths Paths) http.Handler {
|
|||||||
_, _ = fmt.Fprintf(response, "ready=%s dirty=%s\n", strconv.FormatBool(ready), strconv.FormatBool(dirty))
|
_, _ = fmt.Fprintf(response, "ready=%s dirty=%s\n", strconv.FormatBool(ready), strconv.FormatBool(dirty))
|
||||||
})
|
})
|
||||||
mux.HandleFunc(paths.Metrics, func(response http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc(paths.Metrics, func(response http.ResponseWriter, _ *http.Request) {
|
||||||
ready, dirty, counters := health.Snapshot()
|
snapshot := health.MetricsSnapshot()
|
||||||
response.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
response.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||||
response.WriteHeader(http.StatusOK)
|
response.WriteHeader(http.StatusOK)
|
||||||
_, _ = fmt.Fprintf(response, "rvbox_health_ready %d\nrvbox_health_dirty %d\n", boolMetric(ready), boolMetric(dirty))
|
_, _ = fmt.Fprintf(response, "rvbox_health_ready %d\nrvbox_health_dirty %d\n", boolMetric(snapshot.Ready), boolMetric(snapshot.Dirty))
|
||||||
keys := make([]string, 0, len(counters))
|
keys := make([]string, 0, len(snapshot.Counters))
|
||||||
for key := range counters {
|
for key := range snapshot.Counters {
|
||||||
keys = append(keys, key)
|
keys = append(keys, key)
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
_, _ = fmt.Fprintf(response, "rvbox_%s_total %d\n", key, counters[key])
|
_, _ = fmt.Fprintf(response, "rvbox_%s_total %d\n", key, snapshot.Counters[key])
|
||||||
|
}
|
||||||
|
keys = keys[:0]
|
||||||
|
for key := range snapshot.Gauges {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
_, _ = fmt.Fprintf(response, "rvbox_%s %g\n", key, snapshot.Gauges[key])
|
||||||
|
}
|
||||||
|
keys = keys[:0]
|
||||||
|
for key := range snapshot.Histograms {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
histogram := snapshot.Histograms[key]
|
||||||
|
for index, upperBound := range durationBuckets {
|
||||||
|
_, _ = fmt.Fprintf(response, "rvbox_%s_seconds_bucket{le=\"%g\"} %d\n", key, upperBound, histogram.Buckets[index])
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(response, "rvbox_%s_seconds_bucket{le=\"+Inf\"} %d\n", key, histogram.Count)
|
||||||
|
_, _ = fmt.Fprintf(response, "rvbox_%s_seconds_sum %g\nrvbox_%s_seconds_count %d\n", key, histogram.Sum, key, histogram.Count)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return mux
|
return mux
|
||||||
@@ -142,7 +264,10 @@ func validMetricName(name string) bool {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, character := range name {
|
for index, character := range name {
|
||||||
|
if index == 0 && !(character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if !(character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9') {
|
if !(character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthHandlerReadinessAndMetrics_HP_OPS_01(t *testing.T) {
|
func TestHealthHandlerReadinessAndMetrics_HP_OPS_01(t *testing.T) {
|
||||||
@@ -35,8 +36,36 @@ func TestHealthMetricNamesAreBounded_BH_OPS_01(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
health := New()
|
health := New()
|
||||||
health.Inc("bad name")
|
health.Inc("bad name")
|
||||||
|
health.Inc("1bad_name")
|
||||||
_, _, counters := health.Snapshot()
|
_, _, counters := health.Snapshot()
|
||||||
if len(counters) != 0 {
|
if len(counters) != 0 {
|
||||||
t.Fatalf("invalid metric name recorded: %#v", counters)
|
t.Fatalf("invalid metric name recorded: %#v", counters)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHealthRendersBoundedGaugesAndDurationHistograms_HP_OPS_02(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
health := New()
|
||||||
|
health.SetGauge("client_spool_bytes", 4096)
|
||||||
|
health.SetGauge("bad gauge", 1)
|
||||||
|
health.ObserveDuration("dispatch_latency", 12*time.Millisecond)
|
||||||
|
health.ObserveDuration("bad latency", time.Second)
|
||||||
|
|
||||||
|
metrics := httptest.NewRecorder()
|
||||||
|
health.Handler(Paths{}).ServeHTTP(metrics, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||||
|
body := metrics.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"rvbox_client_spool_bytes 4096",
|
||||||
|
"rvbox_dispatch_latency_seconds_bucket{le=\"0.05\"} 1",
|
||||||
|
"rvbox_dispatch_latency_seconds_bucket{le=\"+Inf\"} 1",
|
||||||
|
"rvbox_dispatch_latency_seconds_sum 0.012",
|
||||||
|
"rvbox_dispatch_latency_seconds_count 1",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("metrics missing %q:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "bad gauge") || strings.Contains(body, "bad latency") {
|
||||||
|
t.Fatalf("unbounded metric name rendered: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RotatingFile is an append-only daemon log with bounded numbered archives.
|
||||||
|
// It is deliberately a single-process writer: deployment supplies one daemon
|
||||||
|
// process per configured path, and a second writer must use a distinct file.
|
||||||
|
// Archive .1 is newest; .maxFiles is oldest.
|
||||||
|
type RotatingFile struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
path string
|
||||||
|
maxBytes uint64
|
||||||
|
maxFiles uint32
|
||||||
|
file *os.File
|
||||||
|
size uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenRotatingFile opens path for append, creating its parent directories with
|
||||||
|
// conservative permissions. A blank path disables file logging and returns a
|
||||||
|
// no-op closer. Both rotation controls must be zero (unbounded) or positive.
|
||||||
|
func OpenRotatingFile(path string, maxBytes uint64, maxFiles uint32) (io.WriteCloser, error) {
|
||||||
|
if path == "" {
|
||||||
|
return nopWriteCloser{Writer: io.Discard}, nil
|
||||||
|
}
|
||||||
|
if (maxBytes == 0) != (maxFiles == 0) {
|
||||||
|
return nil, errors.New("log rotation byte/file limits must both be zero or positive")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("create log directory: %w", err)
|
||||||
|
}
|
||||||
|
result := &RotatingFile{path: path, maxBytes: maxBytes, maxFiles: maxFiles}
|
||||||
|
if err := result.open(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (file *RotatingFile) open() error {
|
||||||
|
opened, err := os.OpenFile(file.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open log file: %w", err)
|
||||||
|
}
|
||||||
|
info, err := opened.Stat()
|
||||||
|
if err != nil {
|
||||||
|
_ = opened.Close()
|
||||||
|
return fmt.Errorf("stat log file: %w", err)
|
||||||
|
}
|
||||||
|
file.file = opened
|
||||||
|
file.size = uint64(info.Size())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (file *RotatingFile) Write(data []byte) (int, error) {
|
||||||
|
file.mu.Lock()
|
||||||
|
defer file.mu.Unlock()
|
||||||
|
if file.file == nil {
|
||||||
|
return 0, os.ErrClosed
|
||||||
|
}
|
||||||
|
if file.maxBytes != 0 && file.size != 0 && (file.size >= file.maxBytes || uint64(len(data)) > file.maxBytes-file.size) {
|
||||||
|
if err := file.rotate(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
written, err := file.file.Write(data)
|
||||||
|
file.size += uint64(written)
|
||||||
|
return written, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (file *RotatingFile) Close() error {
|
||||||
|
file.mu.Lock()
|
||||||
|
defer file.mu.Unlock()
|
||||||
|
if file.file == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := file.file.Close()
|
||||||
|
file.file = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (file *RotatingFile) rotate() error {
|
||||||
|
if err := file.file.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close log before rotation: %w", err)
|
||||||
|
}
|
||||||
|
file.file = nil
|
||||||
|
oldest := file.archivePath(file.maxFiles)
|
||||||
|
if err := os.Remove(oldest); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("remove oldest log archive: %w", err)
|
||||||
|
}
|
||||||
|
for index := file.maxFiles; index > 1; index-- {
|
||||||
|
from := file.archivePath(index - 1)
|
||||||
|
to := file.archivePath(index)
|
||||||
|
if err := os.Rename(from, to); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("rotate log archive: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Rename(file.path, file.archivePath(1)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("seal current log: %w", err)
|
||||||
|
}
|
||||||
|
return file.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (file *RotatingFile) archivePath(index uint32) string {
|
||||||
|
return file.path + "." + strconv.FormatUint(uint64(index), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
type nopWriteCloser struct{ io.Writer }
|
||||||
|
|
||||||
|
func (nopWriteCloser) Close() error { return nil }
|
||||||
|
|
||||||
|
// FormatLog makes file records either newline-delimited JSON or plain text.
|
||||||
|
// It is intentionally applied only to daemon diagnostics, never command
|
||||||
|
// output, stdin, environment values, or other payload-bearing data.
|
||||||
|
func FormatLog(destination io.Writer, format string) io.Writer {
|
||||||
|
if destination == nil || format != "json" {
|
||||||
|
return destination
|
||||||
|
}
|
||||||
|
return &jsonLogWriter{destination: destination}
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonLogWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
destination io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (writer *jsonLogWriter) Write(data []byte) (int, error) {
|
||||||
|
writer.mu.Lock()
|
||||||
|
defer writer.mu.Unlock()
|
||||||
|
for _, line := range bytes.Split(data, []byte{'\n'}) {
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
record, err := json.Marshal(struct {
|
||||||
|
Time string `json:"time"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}{Time: time.Now().UTC().Format(time.RFC3339Nano), Level: "info", Message: string(line)})
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("encode structured log: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := writer.destination.Write(append(record, '\n')); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(data), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRotatingFileBoundsArchives_HP_OPS_02(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
path := filepath.Join(t.TempDir(), "nested", "rvbox.log")
|
||||||
|
writer, err := OpenRotatingFile(path, 5, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := writer.Write([]byte("first")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := writer.Write([]byte("two")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := writer.Write([]byte("three")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := writer.Write([]byte("four")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
current, err := os.ReadFile(path)
|
||||||
|
if err != nil || string(current) != "four" {
|
||||||
|
t.Fatalf("current = %q, %v", current, err)
|
||||||
|
}
|
||||||
|
newest, err := os.ReadFile(path + ".1")
|
||||||
|
if err != nil || string(newest) != "three" {
|
||||||
|
t.Fatalf("newest archive = %q, %v", newest, err)
|
||||||
|
}
|
||||||
|
oldest, err := os.ReadFile(path + ".2")
|
||||||
|
if err != nil || string(oldest) != "two" {
|
||||||
|
t.Fatalf("oldest archive = %q, %v", oldest, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path + ".3"); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("unexpected third archive: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRotatingFileRejectsPartialRotationConfig_BH_OPS_03(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, limits := range [][2]uint64{{1, 0}, {0, 1}} {
|
||||||
|
_, err := OpenRotatingFile(filepath.Join(t.TempDir(), "rvbox.log"), limits[0], uint32(limits[1]))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "both be zero") {
|
||||||
|
t.Fatalf("limits %v error = %v", limits, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatLogWritesStructuredBoundedRecords_HP_OPS_04(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var structured bytes.Buffer
|
||||||
|
if _, err := FormatLog(&structured, "json").Write([]byte("connected\nrecovered\n")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lines := bytes.Split(bytes.TrimSpace(structured.Bytes()), []byte{'\n'})
|
||||||
|
if len(lines) != 2 {
|
||||||
|
t.Fatalf("structured records = %q", structured.String())
|
||||||
|
}
|
||||||
|
for index, want := range []string{"connected", "recovered"} {
|
||||||
|
var record struct {
|
||||||
|
Time string `json:"time"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(lines[index], &record); err != nil || record.Time == "" || record.Level != "info" || record.Message != want {
|
||||||
|
t.Fatalf("record %d = %+v, %v", index, record, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var plain bytes.Buffer
|
||||||
|
if _, err := FormatLog(&plain, "text").Write([]byte("plain\n")); err != nil || plain.String() != "plain\n" {
|
||||||
|
t.Fatalf("text log = %q, %v", plain.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,20 +55,9 @@ func (handler *JSONRPCHandler) ServeHTTP(response http.ResponseWriter, request *
|
|||||||
handler.writeRPCError(response, nil, jsonRPCParseError, "could not read request", nil)
|
handler.writeRPCError(response, nil, jsonRPCParseError, "could not read request", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if int64(len(body)) > handler.MaxBody {
|
envelope, errorCode, errorMessage := decodeJSONRPCRequest(body, handler.MaxBody)
|
||||||
handler.writeRPCError(response, nil, jsonRPCInvalid, "request body exceeds limit", nil)
|
if errorCode != 0 {
|
||||||
return
|
handler.writeRPCError(response, nil, errorCode, errorMessage, nil)
|
||||||
}
|
|
||||||
var envelope jsonRPCRequest
|
|
||||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
|
||||||
decoder.DisallowUnknownFields()
|
|
||||||
if err := decoder.Decode(&envelope); err != nil {
|
|
||||||
handler.writeRPCError(response, nil, jsonRPCParseError, "invalid JSON", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var trailing any
|
|
||||||
if err := decoder.Decode(&trailing); err != io.EOF || envelope.JSONRPC != jsonRPCVersion || envelope.Method == "" || len(envelope.ID) == 0 || bytes.Equal(bytes.TrimSpace(envelope.ID), []byte("null")) {
|
|
||||||
handler.writeRPCError(response, nil, jsonRPCInvalid, "invalid JSON-RPC request", nil)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, callErr := handler.call(request.Context(), envelope.Method, envelope.Params)
|
result, callErr := handler.call(request.Context(), envelope.Method, envelope.Params)
|
||||||
@@ -87,6 +76,29 @@ type jsonRPCRequest struct {
|
|||||||
Params json.RawMessage `json:"params"`
|
Params json.RawMessage `json:"params"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decodeJSONRPCRequest keeps the hostile JSON boundary independently bounded
|
||||||
|
// and fuzzable. The returned code/message are the externally stable JSON-RPC
|
||||||
|
// parse or invalid-request result; callers must not inspect partial fields.
|
||||||
|
func decodeJSONRPCRequest(body []byte, maxBody int64) (jsonRPCRequest, int, string) {
|
||||||
|
if maxBody <= 0 {
|
||||||
|
maxBody = defaultJSONRPCBody
|
||||||
|
}
|
||||||
|
if int64(len(body)) > maxBody {
|
||||||
|
return jsonRPCRequest{}, jsonRPCInvalid, "request body exceeds limit"
|
||||||
|
}
|
||||||
|
var envelope jsonRPCRequest
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&envelope); err != nil {
|
||||||
|
return jsonRPCRequest{}, jsonRPCParseError, "invalid JSON"
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF || envelope.JSONRPC != jsonRPCVersion || envelope.Method == "" || len(envelope.ID) == 0 || bytes.Equal(bytes.TrimSpace(envelope.ID), []byte("null")) {
|
||||||
|
return jsonRPCRequest{}, jsonRPCInvalid, "invalid JSON-RPC request"
|
||||||
|
}
|
||||||
|
return envelope, 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
type jsonRPCResponse struct {
|
type jsonRPCResponse struct {
|
||||||
JSONRPC string `json:"jsonrpc"`
|
JSONRPC string `json:"jsonrpc"`
|
||||||
ID json.RawMessage `json:"id"`
|
ID json.RawMessage `json:"id"`
|
||||||
|
|||||||
@@ -95,3 +95,18 @@ func TestJSONRPCRejectsOversizeAndMalformedRequests_BH_CTL_16(t *testing.T) {
|
|||||||
t.Fatalf("malformed response = %s", malformedBody)
|
t.Fatalf("malformed response = %s", malformedBody)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FuzzDecodeJSONRPCRequestBounded_SEC_CTL_01(f *testing.F) {
|
||||||
|
f.Add([]byte(`{"jsonrpc":"2.0","id":1,"method":"listClients","params":{}}`))
|
||||||
|
f.Add([]byte(`{"jsonrpc":"2.0","id":null,"method":"listClients"}`))
|
||||||
|
f.Add([]byte(`{"jsonrpc":"2.0","id":1,"method":"listClients"}{}`))
|
||||||
|
f.Fuzz(func(t *testing.T, body []byte) {
|
||||||
|
// Keep fuzzing at the same independently enforced boundary as the
|
||||||
|
// production handler rather than allowing a corpus entry to allocate
|
||||||
|
// unbounded JSON decoder state.
|
||||||
|
if len(body) > 64<<10 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _, _ = decodeJSONRPCRequest(body, 64<<10)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||||
"github.com/rvbox/rvbox/internal/agentproto"
|
"github.com/rvbox/rvbox/internal/agentproto"
|
||||||
"github.com/rvbox/rvbox/internal/domain"
|
"github.com/rvbox/rvbox/internal/domain"
|
||||||
|
"github.com/rvbox/rvbox/internal/observability"
|
||||||
"github.com/rvbox/rvbox/internal/server/store"
|
"github.com/rvbox/rvbox/internal/server/store"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
@@ -48,6 +49,8 @@ type Options struct {
|
|||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
CursorKey []byte
|
CursorKey []byte
|
||||||
WakeClient func(string) bool
|
WakeClient func(string) bool
|
||||||
|
// Metrics is optional and receives only static metric names.
|
||||||
|
Metrics *observability.Health
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -59,6 +62,7 @@ type Service struct {
|
|||||||
now func() time.Time
|
now func() time.Time
|
||||||
cursors *domain.CursorCodec
|
cursors *domain.CursorCodec
|
||||||
wakeClient func(string) bool
|
wakeClient func(string) bool
|
||||||
|
metrics *observability.Health
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(options Options) (*Service, error) {
|
func NewService(options Options) (*Service, error) {
|
||||||
@@ -94,7 +98,7 @@ func NewService(options Options) (*Service, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Service{store: options.Store, defaultQueueTTL: options.DefaultQueueTTL, takeoverTTL: options.TakeoverTTL, limits: options.Limits, now: options.Now, cursors: codec, wakeClient: options.WakeClient}, nil
|
return &Service{store: options.Store, defaultQueueTTL: options.DefaultQueueTTL, takeoverTTL: options.TakeoverTTL, limits: options.Limits, now: options.Now, cursors: codec, wakeClient: options.WakeClient, metrics: options.Metrics}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) ListClients(ctx context.Context, request *rvboxv1.ListClientsRequest) (*rvboxv1.ListClientsResponse, error) {
|
func (service *Service) ListClients(ctx context.Context, request *rvboxv1.ListClientsRequest) (*rvboxv1.ListClientsResponse, error) {
|
||||||
@@ -252,6 +256,7 @@ func (service *Service) RunCommand(ctx context.Context, request *rvboxv1.RunComm
|
|||||||
descriptor := spec.GetScript()
|
descriptor := spec.GetScript()
|
||||||
digest := sha256.Sum256(request.GetScriptContent())
|
digest := sha256.Sum256(request.GetScriptContent())
|
||||||
if descriptor == nil || uint64(len(request.GetScriptContent())) != descriptor.GetSizeBytes() || !bytes.Equal(digest[:], descriptor.GetSha256()) {
|
if descriptor == nil || uint64(len(request.GetScriptContent())) != descriptor.GetSizeBytes() || !bytes.Equal(digest[:], descriptor.GetSha256()) {
|
||||||
|
service.incMetric("script_verification_failure")
|
||||||
return nil, controlError(codes.InvalidArgument, rvboxv1.ControlError_INVALID_ARGUMENT, "script_content does not match the script descriptor")
|
return nil, controlError(codes.InvalidArgument, rvboxv1.ControlError_INVALID_ARGUMENT, "script_content does not match the script descriptor")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -676,11 +681,19 @@ func (service *Service) AuthorizeClientTakeover(ctx context.Context, request *rv
|
|||||||
expires := service.now().UTC().Add(service.takeoverTTL)
|
expires := service.now().UTC().Add(service.takeoverTTL)
|
||||||
value, _, err := service.store.AuthorizeClientTakeover(ctx, store.TakeoverAuthorization{ClientID: request.GetClientId(), ClientInstanceID: [16]byte(instance), RequestID: [16]byte(requestID), ExpiresAt: expires})
|
value, _, err := service.store.AuthorizeClientTakeover(ctx, store.TakeoverAuthorization{ClientID: request.GetClientId(), ClientInstanceID: [16]byte(instance), RequestID: [16]byte(requestID), ExpiresAt: expires})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
service.incMetric("client_takeover_rejected")
|
||||||
return nil, mapStoreError(err)
|
return nil, mapStoreError(err)
|
||||||
}
|
}
|
||||||
|
service.incMetric("client_takeover_authorized")
|
||||||
return &rvboxv1.AuthorizeClientTakeoverResponse{ExpiresAt: timestamppb.New(value)}, nil
|
return &rvboxv1.AuthorizeClientTakeoverResponse{ExpiresAt: timestamppb.New(value)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *Service) incMetric(name string) {
|
||||||
|
if service != nil && service.metrics != nil {
|
||||||
|
service.metrics.Inc(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func incidentRecord(view store.IncidentView) *rvboxv1.StorageIncident {
|
func incidentRecord(view store.IncidentView) *rvboxv1.StorageIncident {
|
||||||
result := &rvboxv1.StorageIncident{IncidentId: domain.UUID(view.IncidentUUID).String(), DetectedAt: timestamppb.New(view.DetectedAt), State: rvboxv1.StorageIncidentState(view.State), Scope: string(view.Scope), ClientId: view.ClientID, Summary: view.Summary, DataLoss: view.DataLoss, AutomaticallyRepairable: view.AutomaticallyRepairable}
|
result := &rvboxv1.StorageIncident{IncidentId: domain.UUID(view.IncidentUUID).String(), DetectedAt: timestamppb.New(view.DetectedAt), State: rvboxv1.StorageIncidentState(view.State), Scope: string(view.Scope), ClientId: view.ClientID, Summary: view.Summary, DataLoss: view.DataLoss, AutomaticallyRepairable: view.AutomaticallyRepairable}
|
||||||
if view.ResolvedAt != nil {
|
if view.ResolvedAt != nil {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||||
"github.com/rvbox/rvbox/internal/agentproto"
|
"github.com/rvbox/rvbox/internal/agentproto"
|
||||||
"github.com/rvbox/rvbox/internal/domain"
|
"github.com/rvbox/rvbox/internal/domain"
|
||||||
|
"github.com/rvbox/rvbox/internal/observability"
|
||||||
"github.com/rvbox/rvbox/internal/server/store"
|
"github.com/rvbox/rvbox/internal/server/store"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
@@ -70,6 +71,8 @@ func TestControlListAndGetViews_HP_CONTROL_01(t *testing.T) {
|
|||||||
func TestRunCommandRequestIDIdempotencyAndValidation_BH_CONTROL_02(t *testing.T) {
|
func TestRunCommandRequestIDIdempotencyAndValidation_BH_CONTROL_02(t *testing.T) {
|
||||||
service, persistence := newTestService(t)
|
service, persistence := newTestService(t)
|
||||||
defer persistence.Close()
|
defer persistence.Close()
|
||||||
|
metrics := observability.New()
|
||||||
|
service.metrics = metrics
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
registerControlClient(t, persistence, "win-a", rvboxv1.Platform_PLATFORM_WINDOWS, rvboxv1.ShellType_SHELL_POWERSHELL, 3)
|
registerControlClient(t, persistence, "win-a", rvboxv1.Platform_PLATFORM_WINDOWS, rvboxv1.ShellType_SHELL_POWERSHELL, 3)
|
||||||
issue := fixedIssue(0xa2)
|
issue := fixedIssue(0xa2)
|
||||||
@@ -102,6 +105,10 @@ func TestRunCommandRequestIDIdempotencyAndValidation_BH_CONTROL_02(t *testing.T)
|
|||||||
if _, err := service.RunCommand(ctx, badScript); status.Code(err) != codes.InvalidArgument {
|
if _, err := service.RunCommand(ctx, badScript); status.Code(err) != codes.InvalidArgument {
|
||||||
t.Fatalf("mismatched script code = %v", status.Code(err))
|
t.Fatalf("mismatched script code = %v", status.Code(err))
|
||||||
}
|
}
|
||||||
|
_, _, counters := metrics.Snapshot()
|
||||||
|
if counters["script_verification_failure"] != 1 {
|
||||||
|
t.Fatalf("script verification metrics = %#v", counters)
|
||||||
|
}
|
||||||
badTTL := proto.Clone(request).(*rvboxv1.RunCommandRequest)
|
badTTL := proto.Clone(request).(*rvboxv1.RunCommandRequest)
|
||||||
badTTL.RequestId = fixedIssue(0xa4).String()
|
badTTL.RequestId = fixedIssue(0xa4).String()
|
||||||
badTTL.QueueTtl = durationpb.New(-time.Second)
|
badTTL.QueueTtl = durationpb.New(-time.Second)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||||
"github.com/rvbox/rvbox/internal/agentproto"
|
"github.com/rvbox/rvbox/internal/agentproto"
|
||||||
"github.com/rvbox/rvbox/internal/domain"
|
"github.com/rvbox/rvbox/internal/domain"
|
||||||
|
"github.com/rvbox/rvbox/internal/observability"
|
||||||
"github.com/rvbox/rvbox/internal/server/store"
|
"github.com/rvbox/rvbox/internal/server/store"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
@@ -45,6 +46,9 @@ type AgentServer struct {
|
|||||||
HeartbeatIdle time.Duration
|
HeartbeatIdle time.Duration
|
||||||
LivenessTimeout time.Duration
|
LivenessTimeout time.Duration
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
|
// Metrics is optional so transport tests and embeddings do not need an
|
||||||
|
// observability endpoint. Every emitted name is a static bounded value.
|
||||||
|
Metrics *observability.Health
|
||||||
}
|
}
|
||||||
|
|
||||||
func (server *AgentServer) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
func (server *AgentServer) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
||||||
@@ -53,6 +57,7 @@ func (server *AgentServer) ServeHTTP(response http.ResponseWriter, request *http
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if request.Header.Get("Origin") != "" {
|
if request.Header.Get("Origin") != "" {
|
||||||
|
server.incMetric("protocol_error")
|
||||||
http.Error(response, ErrUnexpectedOrigin.Error(), http.StatusForbidden)
|
http.Error(response, ErrUnexpectedOrigin.Error(), http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -83,6 +88,7 @@ func (server *AgentServer) ServeHTTP(response http.ResponseWriter, request *http
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
server.incMetric("agent_connection")
|
||||||
defer connection.CloseNow()
|
defer connection.CloseNow()
|
||||||
server.serveConnection(request.Context(), connection, heartbeat, started)
|
server.serveConnection(request.Context(), connection, heartbeat, started)
|
||||||
}
|
}
|
||||||
@@ -98,17 +104,24 @@ func (server *AgentServer) serveConnection(parent context.Context, connection *w
|
|||||||
}
|
}
|
||||||
helloEnvelope, err := agentproto.DecodeEnvelope(payload, server.limits(), rvboxv1.Platform_PLATFORM_UNSPECIFIED)
|
helloEnvelope, err := agentproto.DecodeEnvelope(payload, server.limits(), rvboxv1.Platform_PLATFORM_UNSPECIFIED)
|
||||||
if err != nil || helloEnvelope.GetClientHello() == nil {
|
if err != nil || helloEnvelope.GetClientHello() == nil {
|
||||||
|
server.incMetric("protocol_error")
|
||||||
server.close(connection, websocket.StatusPolicyViolation, "invalid ClientHello")
|
server.close(connection, websocket.StatusPolicyViolation, "invalid ClientHello")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hello := helloEnvelope.GetClientHello()
|
hello := helloEnvelope.GetClientHello()
|
||||||
|
// This cutoff precedes registration, which is when control callers can
|
||||||
|
// first observe the live client. Reconciliation must compare only the
|
||||||
|
// state included in its request; later control commands are fresh work.
|
||||||
|
reconcileBoundary := server.now()
|
||||||
instanceID, err := domain.ParseUUIDv7(hello.GetClientInstanceId())
|
instanceID, err := domain.ParseUUIDv7(hello.GetClientInstanceId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
server.incMetric("protocol_error")
|
||||||
server.close(connection, websocket.StatusPolicyViolation, "invalid client instance ID")
|
server.close(connection, websocket.StatusPolicyViolation, "invalid client instance ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
selected, err := domain.SelectProtocol(server.protocolRange(), hello.GetSupportedProtocol())
|
selected, err := domain.SelectProtocol(server.protocolRange(), hello.GetSupportedProtocol())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
server.incMetric("protocol_error")
|
||||||
server.close(connection, websocket.StatusPolicyViolation, "unsupported protocol")
|
server.close(connection, websocket.StatusPolicyViolation, "unsupported protocol")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -122,15 +135,22 @@ func (server *AgentServer) serveConnection(parent context.Context, connection *w
|
|||||||
server.close(connection, websocket.StatusInternalError, "could not persist client capabilities")
|
server.close(connection, websocket.StatusInternalError, "could not persist client capabilities")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
registrationStarted := time.Now()
|
||||||
registration, err := server.Store.RegisterClientSession(parent, store.ClientRegistration{
|
registration, err := server.Store.RegisterClientSession(parent, store.ClientRegistration{
|
||||||
ClientID: hello.GetClientId(), Platform: uint32(hello.GetPlatform()), Architecture: hello.GetArchitecture(),
|
ClientID: hello.GetClientId(), Platform: uint32(hello.GetPlatform()), Architecture: hello.GetArchitecture(),
|
||||||
DaemonVersion: hello.GetDaemonVersion(), DaemonCWD: hello.GetDaemonCwd(), SupportedShells: shells,
|
DaemonVersion: hello.GetDaemonVersion(), DaemonCWD: hello.GetDaemonCwd(), SupportedShells: shells,
|
||||||
ClientInstanceID: [16]byte(instanceID), SessionID: sessionID, ConnectedAt: server.now(),
|
ClientInstanceID: [16]byte(instanceID), SessionID: sessionID, ConnectedAt: server.now(),
|
||||||
})
|
})
|
||||||
|
server.observeMetric("sqlite_write_latency", time.Since(registrationStarted))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
server.incMetric("client_registration_failure")
|
||||||
|
if errors.Is(err, store.ErrTakeoverRequired) {
|
||||||
|
server.incMetric("client_takeover_required")
|
||||||
|
}
|
||||||
server.close(connection, websocket.StatusPolicyViolation, sessionCloseReason(err))
|
server.close(connection, websocket.StatusPolicyViolation, sessionCloseReason(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
server.incMetric("client_registration")
|
||||||
registry := server.Registry
|
registry := server.Registry
|
||||||
handle, err := registry.Install(hello.GetClientId(), sessionID, registration.Generation)
|
handle, err := registry.Install(hello.GetClientId(), sessionID, registration.Generation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -176,7 +196,7 @@ func (server *AgentServer) serveConnection(parent context.Context, connection *w
|
|||||||
server.close(connection, websocket.StatusPolicyViolation, "invalid initial client capacity")
|
server.close(connection, websocket.StatusPolicyViolation, "invalid initial client capacity")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go server.dispatchLoop(sessionContext, queue, handle.DispatchWake(), reconciled, &capacityMu, capacity, reservations, stdinSent, signalSent, scriptTransfers, hello.GetClientId(), hello.GetPlatform(), encodeSessionID(sessionID), registration.Generation, cancel)
|
go server.dispatchLoop(sessionContext, queue, handle.DispatchWake(), handle.SignalDispatch, reconciled, &capacityMu, capacity, reservations, stdinSent, signalSent, scriptTransfers, hello.GetClientId(), hello.GetPlatform(), encodeSessionID(sessionID), registration.Generation, cancel)
|
||||||
encodedSessionID := encodeSessionID(sessionID)
|
encodedSessionID := encodeSessionID(sessionID)
|
||||||
welcome, err := proto.Marshal(&rvboxv1.AgentEnvelope{
|
welcome, err := proto.Marshal(&rvboxv1.AgentEnvelope{
|
||||||
SessionId: encodedSessionID, SessionGeneration: registration.Generation,
|
SessionId: encodedSessionID, SessionGeneration: registration.Generation,
|
||||||
@@ -192,7 +212,7 @@ func (server *AgentServer) serveConnection(parent context.Context, connection *w
|
|||||||
server.close(connection, websocket.StatusInternalError, "could not queue welcome")
|
server.close(connection, websocket.StatusInternalError, "could not queue welcome")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
targets, err := server.Store.ReconcileTargets(parent, hello.GetClientId())
|
targets, err := server.Store.ReconcileTargetsAt(parent, hello.GetClientId(), reconcileBoundary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
server.close(connection, websocket.StatusInternalError, "could not build reconciliation request")
|
server.close(connection, websocket.StatusInternalError, "could not build reconciliation request")
|
||||||
return
|
return
|
||||||
@@ -223,21 +243,24 @@ func (server *AgentServer) serveConnection(parent context.Context, connection *w
|
|||||||
// to determine liveness.
|
// to determine liveness.
|
||||||
heartbeat.Observe(time.Since(started))
|
heartbeat.Observe(time.Since(started))
|
||||||
if messageType != websocket.MessageBinary {
|
if messageType != websocket.MessageBinary {
|
||||||
|
server.incMetric("protocol_error")
|
||||||
server.close(connection, websocket.StatusUnsupportedData, ErrUnexpectedMessage.Error())
|
server.close(connection, websocket.StatusUnsupportedData, ErrUnexpectedMessage.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
envelope, err := agentproto.DecodeEnvelope(payload, server.limits(), hello.GetPlatform())
|
envelope, err := agentproto.DecodeEnvelope(payload, server.limits(), hello.GetPlatform())
|
||||||
if err != nil || envelope.GetClientHello() != nil || envelope.GetSessionId() != encodedSessionID || envelope.GetSessionGeneration() != registration.Generation {
|
if err != nil || envelope.GetClientHello() != nil || envelope.GetSessionId() != encodedSessionID || envelope.GetSessionGeneration() != registration.Generation {
|
||||||
|
server.incMetric("stale_message")
|
||||||
server.close(connection, websocket.StatusPolicyViolation, ErrStaleSession.Error())
|
server.close(connection, websocket.StatusPolicyViolation, ErrStaleSession.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
clientID, err := server.Store.ValidateLiveSession(sessionContext, sessionID, registration.Generation)
|
clientID, err := server.Store.ValidateLiveSession(sessionContext, sessionID, registration.Generation)
|
||||||
if err != nil || clientID != hello.GetClientId() {
|
if err != nil || clientID != hello.GetClientId() {
|
||||||
|
server.incMetric("stale_message")
|
||||||
server.close(connection, websocket.StatusPolicyViolation, ErrStaleSession.Error())
|
server.close(connection, websocket.StatusPolicyViolation, ErrStaleSession.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if snapshot := envelope.GetReconcileSnapshot(); snapshot != nil {
|
if snapshot := envelope.GetReconcileSnapshot(); snapshot != nil {
|
||||||
result, reconcileErr := server.Store.ReconcileClientSnapshotForSession(sessionContext, hello.GetClientId(), registration.Generation, snapshot)
|
result, reconcileErr := server.Store.ReconcileClientSnapshotForSessionAt(sessionContext, hello.GetClientId(), registration.Generation, snapshot, reconcileBoundary)
|
||||||
if reconcileErr != nil {
|
if reconcileErr != nil {
|
||||||
server.close(connection, websocket.StatusPolicyViolation, "reconciliation failed")
|
server.close(connection, websocket.StatusPolicyViolation, "reconciliation failed")
|
||||||
return
|
return
|
||||||
@@ -313,14 +336,20 @@ func (server *AgentServer) serveConnection(parent context.Context, connection *w
|
|||||||
}
|
}
|
||||||
appendEvent, eventErr := eventAppendFromWire(event, hello.GetClientId(), registration.Generation, server.now())
|
appendEvent, eventErr := eventAppendFromWire(event, hello.GetClientId(), registration.Generation, server.now())
|
||||||
if eventErr != nil {
|
if eventErr != nil {
|
||||||
|
server.incMetric("protocol_error")
|
||||||
server.close(connection, websocket.StatusPolicyViolation, "invalid command event")
|
server.close(connection, websocket.StatusPolicyViolation, "invalid command event")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
appended, eventErr := server.Store.AppendCommandEvent(sessionContext, appendEvent)
|
appended, eventErr := server.Store.AppendCommandEvent(sessionContext, appendEvent)
|
||||||
if eventErr != nil {
|
if eventErr != nil {
|
||||||
|
server.incMetric("command_event_rejected")
|
||||||
server.close(connection, websocket.StatusPolicyViolation, "command event was not accepted")
|
server.close(connection, websocket.StatusPolicyViolation, "command event was not accepted")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
server.incMetric("command_event")
|
||||||
|
if event.GetLifecycle() != nil {
|
||||||
|
server.incMetric("command_transition")
|
||||||
|
}
|
||||||
ack, eventErr := proto.Marshal(&rvboxv1.AgentEnvelope{SessionId: encodedSessionID, SessionGeneration: registration.Generation, Payload: &rvboxv1.AgentEnvelope_EventAck{EventAck: &rvboxv1.EventAck{IssueUuid: event.GetIssueUuid(), ThroughEventSeq: appended.ThroughEventSeq}}})
|
ack, eventErr := proto.Marshal(&rvboxv1.AgentEnvelope{SessionId: encodedSessionID, SessionGeneration: registration.Generation, Payload: &rvboxv1.AgentEnvelope_EventAck{EventAck: &rvboxv1.EventAck{IssueUuid: event.GetIssueUuid(), ThroughEventSeq: appended.ThroughEventSeq}}})
|
||||||
if eventErr != nil || queue.EnqueueControl(Frame{Kind: FrameControl, Payload: ack}) != nil {
|
if eventErr != nil || queue.EnqueueControl(Frame{Kind: FrameControl, Payload: ack}) != nil {
|
||||||
server.close(connection, websocket.StatusInternalError, "could not acknowledge command event")
|
server.close(connection, websocket.StatusInternalError, "could not acknowledge command event")
|
||||||
@@ -409,7 +438,7 @@ func eventType(event *rvboxv1.CommandEvent) uint16 {
|
|||||||
// enqueueNextDispatch records the queued-to-dispatched transition before
|
// enqueueNextDispatch records the queued-to-dispatched transition before
|
||||||
// exposing work to the network. A full data lane is a pre-write failure, so
|
// exposing work to the network. A full data lane is a pre-write failure, so
|
||||||
// only the owning generation can put the command back into the queue.
|
// only the owning generation can put the command back into the queue.
|
||||||
func (server *AgentServer) enqueueNextDispatch(ctx context.Context, queue *WriterQueue, clientID string, platform rvboxv1.Platform, sessionID string, generation uint64, beforeEnqueue func(*store.DispatchCandidate)) (domain.UUID, bool, error) {
|
func (server *AgentServer) enqueueNextDispatch(ctx context.Context, queue *WriterQueue, clientID string, platform rvboxv1.Platform, sessionID string, generation uint64, beforeEnqueue func(*store.DispatchCandidate), onWritten func(domain.UUID)) (domain.UUID, bool, error) {
|
||||||
candidate, err := server.Store.ClaimNextDispatch(ctx, clientID, generation, server.now())
|
candidate, err := server.Store.ClaimNextDispatch(ctx, clientID, generation, server.now())
|
||||||
if err != nil || candidate == nil {
|
if err != nil || candidate == nil {
|
||||||
return domain.UUID{}, false, err
|
return domain.UUID{}, false, err
|
||||||
@@ -447,7 +476,13 @@ func (server *AgentServer) enqueueNextDispatch(ctx context.Context, queue *Write
|
|||||||
if beforeEnqueue != nil {
|
if beforeEnqueue != nil {
|
||||||
beforeEnqueue(candidate)
|
beforeEnqueue(candidate)
|
||||||
}
|
}
|
||||||
if !queue.EnqueueData(Frame{Kind: FrameData, Payload: encoded}) {
|
if !queue.EnqueueData(Frame{Kind: FrameData, Payload: encoded, OnWritten: func() {
|
||||||
|
server.incMetric("command_dispatch")
|
||||||
|
server.observeMetric("dispatch_latency", server.now().Sub(candidate.IssueTime))
|
||||||
|
if onWritten != nil {
|
||||||
|
onWritten(candidate.IssueUUID)
|
||||||
|
}
|
||||||
|
}}) {
|
||||||
sent, requeueErr := requeue(ErrDispatchDataFull)
|
sent, requeueErr := requeue(ErrDispatchDataFull)
|
||||||
return candidate.IssueUUID, sent, requeueErr
|
return candidate.IssueUUID, sent, requeueErr
|
||||||
}
|
}
|
||||||
@@ -527,12 +562,18 @@ func (server *AgentServer) enqueueNextScript(queue *WriterQueue, sessionID strin
|
|||||||
// lane. Session-local sent tracking suppresses duplicate frames while a live
|
// lane. Session-local sent tracking suppresses duplicate frames while a live
|
||||||
// connection remains usable; reconnecting naturally replays unacknowledged
|
// connection remains usable; reconnecting naturally replays unacknowledged
|
||||||
// writes from storage.
|
// writes from storage.
|
||||||
func (server *AgentServer) enqueueNextStdin(ctx context.Context, queue *WriterQueue, clientID, sessionID string, generation uint64, sent map[string]struct{}, sentMu *sync.Mutex) (bool, error) {
|
func (server *AgentServer) enqueueNextStdin(ctx context.Context, queue *WriterQueue, clientID, sessionID string, generation uint64, sent map[string]struct{}, dispatchWritten map[string]bool, sentMu *sync.Mutex) (bool, error) {
|
||||||
intents, err := server.Store.PendingStdin(ctx, clientID)
|
intents, err := server.Store.PendingStdin(ctx, clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
for _, intent := range intents {
|
for _, intent := range intents {
|
||||||
|
sentMu.Lock()
|
||||||
|
written, waitingForDispatch := dispatchWritten[intent.IssueUUID.String()]
|
||||||
|
sentMu.Unlock()
|
||||||
|
if waitingForDispatch && !written {
|
||||||
|
continue
|
||||||
|
}
|
||||||
key := stdinIntentKey(intent.IssueUUID, intent.WriteSeq)
|
key := stdinIntentKey(intent.IssueUUID, intent.WriteSeq)
|
||||||
sentMu.Lock()
|
sentMu.Lock()
|
||||||
_, alreadySent := sent[key]
|
_, alreadySent := sent[key]
|
||||||
@@ -575,12 +616,18 @@ func signalIntentKey(issue domain.UUID, revision uint64, signal rvboxv1.SignalKi
|
|||||||
return issue.String() + ":" + fmt.Sprint(revision) + ":" + fmt.Sprint(signal)
|
return issue.String() + ":" + fmt.Sprint(revision) + ":" + fmt.Sprint(signal)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (server *AgentServer) enqueueNextSignal(ctx context.Context, queue *WriterQueue, clientID, sessionID string, generation uint64, sent map[string]struct{}, sentMu *sync.Mutex) (bool, error) {
|
func (server *AgentServer) enqueueNextSignal(ctx context.Context, queue *WriterQueue, clientID, sessionID string, generation uint64, sent map[string]struct{}, dispatchWritten map[string]bool, sentMu *sync.Mutex) (bool, error) {
|
||||||
intents, err := server.Store.PendingSignals(ctx, clientID, generation)
|
intents, err := server.Store.PendingSignals(ctx, clientID, generation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
for _, intent := range intents {
|
for _, intent := range intents {
|
||||||
|
sentMu.Lock()
|
||||||
|
written, waitingForDispatch := dispatchWritten[intent.IssueUUID.String()]
|
||||||
|
sentMu.Unlock()
|
||||||
|
if waitingForDispatch && !written {
|
||||||
|
continue
|
||||||
|
}
|
||||||
key := signalIntentKey(intent.IssueUUID, intent.CommandRevision, intent.Signal)
|
key := signalIntentKey(intent.IssueUUID, intent.CommandRevision, intent.Signal)
|
||||||
sentMu.Lock()
|
sentMu.Lock()
|
||||||
_, alreadySent := sent[key]
|
_, alreadySent := sent[key]
|
||||||
@@ -613,7 +660,11 @@ func (server *AgentServer) enqueueNextSignal(ctx context.Context, queue *WriterQ
|
|||||||
// dispatchLoop is the per-session serialized dispatcher. It waits for a
|
// dispatchLoop is the per-session serialized dispatcher. It waits for a
|
||||||
// complete reconciliation result before consuming queued work, then coalesces
|
// complete reconciliation result before consuming queued work, then coalesces
|
||||||
// wakeups from local control RPCs, capacity advertisements, and acceptances.
|
// wakeups from local control RPCs, capacity advertisements, and acceptances.
|
||||||
func (server *AgentServer) dispatchLoop(ctx context.Context, queue *WriterQueue, wake <-chan struct{}, reconciled <-chan struct{}, capacityMu *sync.Mutex, capacity *CapacityShadow, reservations map[string]DispatchLane, stdinSent, signalSent map[string]struct{}, scriptTransfers map[string]*scriptTransfer, clientID string, platform rvboxv1.Platform, sessionID string, generation uint64, cancel context.CancelFunc) {
|
func (server *AgentServer) dispatchLoop(ctx context.Context, queue *WriterQueue, wake <-chan struct{}, signalWake func(), reconciled <-chan struct{}, capacityMu *sync.Mutex, capacity *CapacityShadow, reservations map[string]DispatchLane, stdinSent, signalSent map[string]struct{}, scriptTransfers map[string]*scriptTransfer, clientID string, platform rvboxv1.Platform, sessionID string, generation uint64, cancel context.CancelFunc) {
|
||||||
|
// A control frame may use the essential queue and therefore overtake data.
|
||||||
|
// Keep its issue blocked only until the dispatch frame has actually crossed
|
||||||
|
// the socket writer; after that, WebSocket ordering preserves the dependency.
|
||||||
|
dispatchWritten := make(map[string]bool)
|
||||||
select {
|
select {
|
||||||
case <-reconciled:
|
case <-reconciled:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -621,7 +672,7 @@ func (server *AgentServer) dispatchLoop(ctx context.Context, queue *WriterQueue,
|
|||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
for {
|
for {
|
||||||
stdinQueued, stdinErr := server.enqueueNextStdin(ctx, queue, clientID, sessionID, generation, stdinSent, capacityMu)
|
stdinQueued, stdinErr := server.enqueueNextStdin(ctx, queue, clientID, sessionID, generation, stdinSent, dispatchWritten, capacityMu)
|
||||||
if stdinErr != nil {
|
if stdinErr != nil {
|
||||||
server.closeForDispatchFailure(cancel)
|
server.closeForDispatchFailure(cancel)
|
||||||
return
|
return
|
||||||
@@ -629,7 +680,7 @@ func (server *AgentServer) dispatchLoop(ctx context.Context, queue *WriterQueue,
|
|||||||
if stdinQueued {
|
if stdinQueued {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
signalQueued, signalErr := server.enqueueNextSignal(ctx, queue, clientID, sessionID, generation, signalSent, capacityMu)
|
signalQueued, signalErr := server.enqueueNextSignal(ctx, queue, clientID, sessionID, generation, signalSent, dispatchWritten, capacityMu)
|
||||||
if signalErr != nil {
|
if signalErr != nil {
|
||||||
server.closeForDispatchFailure(cancel)
|
server.closeForDispatchFailure(cancel)
|
||||||
return
|
return
|
||||||
@@ -655,16 +706,25 @@ func (server *AgentServer) dispatchLoop(ctx context.Context, queue *WriterQueue,
|
|||||||
issue, sent, err := server.enqueueNextDispatch(ctx, queue, clientID, platform, sessionID, generation, func(candidate *store.DispatchCandidate) {
|
issue, sent, err := server.enqueueNextDispatch(ctx, queue, clientID, platform, sessionID, generation, func(candidate *store.DispatchCandidate) {
|
||||||
capacityMu.Lock()
|
capacityMu.Lock()
|
||||||
reservations[candidate.IssueUUID.String()] = lane
|
reservations[candidate.IssueUUID.String()] = lane
|
||||||
|
dispatchWritten[candidate.IssueUUID.String()] = false
|
||||||
if candidate.ScriptPresent {
|
if candidate.ScriptPresent {
|
||||||
scriptTransfers[candidate.IssueUUID.String()] = &scriptTransfer{Body: append([]byte(nil), candidate.ScriptContent...), Digest: sha256.Sum256(candidate.ScriptContent)}
|
scriptTransfers[candidate.IssueUUID.String()] = &scriptTransfer{Body: append([]byte(nil), candidate.ScriptContent...), Digest: sha256.Sum256(candidate.ScriptContent)}
|
||||||
}
|
}
|
||||||
inserted = true
|
inserted = true
|
||||||
capacityMu.Unlock()
|
capacityMu.Unlock()
|
||||||
|
}, func(issue domain.UUID) {
|
||||||
|
capacityMu.Lock()
|
||||||
|
dispatchWritten[issue.String()] = true
|
||||||
|
capacityMu.Unlock()
|
||||||
|
if signalWake != nil {
|
||||||
|
signalWake()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if !sent {
|
if !sent {
|
||||||
capacityMu.Lock()
|
capacityMu.Lock()
|
||||||
if inserted {
|
if inserted {
|
||||||
delete(reservations, issue.String())
|
delete(reservations, issue.String())
|
||||||
|
delete(dispatchWritten, issue.String())
|
||||||
delete(scriptTransfers, issue.String())
|
delete(scriptTransfers, issue.String())
|
||||||
}
|
}
|
||||||
capacity.Release(lane)
|
capacity.Release(lane)
|
||||||
@@ -706,6 +766,9 @@ func (server *AgentServer) writeLoop(ctx context.Context, connection *websocket.
|
|||||||
if frame.Written != nil {
|
if frame.Written != nil {
|
||||||
close(frame.Written)
|
close(frame.Written)
|
||||||
}
|
}
|
||||||
|
if frame.OnWritten != nil {
|
||||||
|
frame.OnWritten()
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !errors.Is(err, context.DeadlineExceeded) {
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
@@ -720,11 +783,24 @@ func (server *AgentServer) writeLoop(ctx context.Context, connection *websocket.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case HeartbeatClose:
|
case HeartbeatClose:
|
||||||
|
server.incMetric("heartbeat_timeout")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (server *AgentServer) incMetric(name string) {
|
||||||
|
if server != nil && server.Metrics != nil {
|
||||||
|
server.Metrics.Inc(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *AgentServer) observeMetric(name string, duration time.Duration) {
|
||||||
|
if server != nil && server.Metrics != nil {
|
||||||
|
server.Metrics.ObserveDuration(name, duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (server *AgentServer) close(connection *websocket.Conn, status websocket.StatusCode, reason string) {
|
func (server *AgentServer) close(connection *websocket.Conn, status websocket.StatusCode, reason string) {
|
||||||
_ = connection.Close(status, reason)
|
_ = connection.Close(status, reason)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||||
"github.com/rvbox/rvbox/internal/agentproto"
|
"github.com/rvbox/rvbox/internal/agentproto"
|
||||||
"github.com/rvbox/rvbox/internal/domain"
|
"github.com/rvbox/rvbox/internal/domain"
|
||||||
|
"github.com/rvbox/rvbox/internal/observability"
|
||||||
"github.com/rvbox/rvbox/internal/server/store"
|
"github.com/rvbox/rvbox/internal/server/store"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
@@ -98,6 +99,70 @@ func TestAgentServerRegistrationAndReplacement_HP_SES_05(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAgentServerEmitsBoundedRegistrationMetrics_HP_OPS_05(t *testing.T) {
|
||||||
|
server, agent, cleanup := newTestAgentServerWithStore(t)
|
||||||
|
defer cleanup()
|
||||||
|
metrics := observability.New()
|
||||||
|
agent.Metrics = metrics
|
||||||
|
connection, _ := dialAndHello(t, server, "metric-client", "019c46f1-1d02-7000-8000-000000000073")
|
||||||
|
defer connection.CloseNow()
|
||||||
|
_, _, counters := metrics.Snapshot()
|
||||||
|
if counters["agent_connection"] != 1 || counters["client_registration"] != 1 {
|
||||||
|
t.Fatalf("registration metrics = %#v", counters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentServerDispatchesCommandAdmittedDuringReconcile_HP_SES_11(t *testing.T) {
|
||||||
|
server, agent, cleanup := newTestAgentServerWithStore(t)
|
||||||
|
defer cleanup()
|
||||||
|
instance, err := domain.NewUUIDv7()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
connection, welcome := dialAndHello(t, server, "client-race", instance.String())
|
||||||
|
defer connection.CloseNow()
|
||||||
|
issue, err := domain.NewUUIDv7()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
spec, err := proto.Marshal(&rvboxv1.ExecutionSpec{ShellType: rvboxv1.ShellType_SHELL_CMD, Source: &rvboxv1.ExecutionSpec_CommandText{CommandText: "echo dispatch"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if _, err := agent.Store.QueueCommand(context.Background(), store.QueueCommandInput{IssueUUID: issue, ClientID: "client-race", IssueTime: now, ReceiptTime: now, ImmutableSHA256: sha256.Sum256([]byte("during-reconcile")), ExecutionSpec: spec}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshot, err := proto.Marshal(&rvboxv1.AgentEnvelope{
|
||||||
|
SessionId: welcome.GetSessionId(), SessionGeneration: welcome.GetSessionGeneration(),
|
||||||
|
Payload: &rvboxv1.AgentEnvelope_ReconcileSnapshot{ReconcileSnapshot: &rvboxv1.ReconcileSnapshot{}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := connection.Write(context.Background(), websocket.MessageBinary, snapshot); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
readContext, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
for index := 0; index < 2; index++ {
|
||||||
|
_, payload, err := connection.Read(readContext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var envelope rvboxv1.AgentEnvelope
|
||||||
|
if err := proto.Unmarshal(payload, &envelope); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if index == 0 && envelope.GetReconcileResult() == nil {
|
||||||
|
t.Fatalf("first post-snapshot envelope = %T, want reconcile result", envelope.Payload)
|
||||||
|
}
|
||||||
|
if index == 1 && (envelope.GetCommandDispatch() == nil || envelope.GetCommandDispatch().GetIssueUuid() != issue.String()) {
|
||||||
|
t.Fatalf("second post-snapshot envelope = %+v, want dispatch %s", envelope.Payload, issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAgentServerRejectsHostileWireInputs_BH_SES_04(t *testing.T) {
|
func TestAgentServerRejectsHostileWireInputs_BH_SES_04(t *testing.T) {
|
||||||
server, cleanup := newTestAgentServer(t)
|
server, cleanup := newTestAgentServer(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -174,6 +239,11 @@ func TestWireEventAppendCarriesClientBinding_HP_EVENT_01(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newTestAgentServer(t *testing.T) (*httptest.Server, func()) {
|
func newTestAgentServer(t *testing.T) (*httptest.Server, func()) {
|
||||||
|
server, _, cleanup := newTestAgentServerWithStore(t)
|
||||||
|
return server, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestAgentServerWithStore(t *testing.T) (*httptest.Server, *AgentServer, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dataDirectory := filepath.Join(t.TempDir(), "store")
|
dataDirectory := filepath.Join(t.TempDir(), "store")
|
||||||
if err := os.Mkdir(dataDirectory, 0o700); err != nil {
|
if err := os.Mkdir(dataDirectory, 0o700); err != nil {
|
||||||
@@ -190,7 +260,7 @@ func newTestAgentServer(t *testing.T) (*httptest.Server, func()) {
|
|||||||
HeartbeatIdle: time.Second, LivenessTimeout: 2 * time.Second,
|
HeartbeatIdle: time.Second, LivenessTimeout: 2 * time.Second,
|
||||||
}
|
}
|
||||||
httpServer := httptest.NewServer(agent)
|
httpServer := httptest.NewServer(agent)
|
||||||
return httpServer, func() {
|
return httpServer, agent, func() {
|
||||||
httpServer.Close()
|
httpServer.Close()
|
||||||
if err := persistence.Close(); err != nil {
|
if err := persistence.Close(); err != nil {
|
||||||
t.Errorf("close persistence: %v", err)
|
t.Errorf("close persistence: %v", err)
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ type Frame struct {
|
|||||||
// Written is closed by the sole socket writer after a successful write.
|
// Written is closed by the sole socket writer after a successful write.
|
||||||
// It is used only for protocol barriers such as reconciliation-before-work.
|
// It is used only for protocol barriers such as reconciliation-before-work.
|
||||||
Written chan<- struct{}
|
Written chan<- struct{}
|
||||||
|
// OnWritten is a non-blocking session-local scheduler notification. It is
|
||||||
|
// invoked only after the sole socket writer has completed the frame, so a
|
||||||
|
// dependent control frame cannot overtake its command dispatch.
|
||||||
|
OnWritten func()
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriterQueue is owned by one socket writer. Data saturation leaves the work
|
// WriterQueue is owned by one socket writer. Data saturation leaves the work
|
||||||
|
|||||||
@@ -126,6 +126,37 @@ func TestClaimDispatchExpiresAndFencesRequeue_HP_DISPATCH_02(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPendingStdinWaitsForDurableDispatch_HP_DISPATCH_06(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
opened, err := Open(ctx, Options{DataDir: filepath.Join(t.TempDir(), "state"), BusyTimeout: time.Second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = opened.Close() })
|
||||||
|
now := time.Date(2026, time.September, 11, 8, 50, 0, 0, time.UTC)
|
||||||
|
if _, err := opened.RegisterClientSession(ctx, ClientRegistration{ClientID: "stdin-client", Platform: 2, Architecture: "amd64", DaemonVersion: "test", DaemonCWD: `C:\\`, SupportedShells: []byte{1}, ClientInstanceID: [16]byte{41}, SessionID: [16]byte{42}, ConnectedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
issue := fixedStoreIssue(0x97)
|
||||||
|
if _, err := opened.QueueCommand(ctx, QueueCommandInput{IssueUUID: issue, ClientID: "stdin-client", IssueTime: now, ReceiptTime: now, ImmutableSHA256: sha256.Sum256([]byte("stdin ordering")), ExecutionSpec: []byte("spec")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := opened.AppendStdin(ctx, StdinWriteInput{IssueUUID: issue, ClientID: "stdin-client", RequestUUID: fixedStoreIssue(0x98), Data: []byte("input"), ImmutableHash: sha256.Sum256([]byte("stdin append")), OccurredAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if pending, err := opened.PendingStdin(ctx, "stdin-client"); err != nil || len(pending) != 0 {
|
||||||
|
t.Fatalf("queued command exposed stdin = %#v, %v", pending, err)
|
||||||
|
}
|
||||||
|
if candidate, err := opened.ClaimNextDispatch(ctx, "stdin-client", 7, now); err != nil || candidate == nil || candidate.IssueUUID != issue {
|
||||||
|
t.Fatalf("dispatch claim = %#v, %v", candidate, err)
|
||||||
|
}
|
||||||
|
pending, err := opened.PendingStdin(ctx, "stdin-client")
|
||||||
|
if err != nil || len(pending) != 1 || pending[0].IssueUUID != issue || pending[0].WriteSeq != 1 || string(pending[0].Data) != "input" {
|
||||||
|
t.Fatalf("dispatched command stdin = %#v, %v", pending, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLateDispatchAcceptanceAndEventRetainExpiryContradiction_BH_DISPATCH_09(t *testing.T) {
|
func TestLateDispatchAcceptanceAndEventRetainExpiryContradiction_BH_DISPATCH_09(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -16,11 +16,26 @@ import (
|
|||||||
// client. It is a read-only snapshot used to tell a reconnecting agent which
|
// client. It is a read-only snapshot used to tell a reconnecting agent which
|
||||||
// UUIDs and durable cursors must be compared before fresh dispatch is enabled.
|
// UUIDs and durable cursors must be compared before fresh dispatch is enabled.
|
||||||
func (store *Store) ReconcileTargets(ctx context.Context, clientID string) ([]*rvboxv1.ReconcileTarget, error) {
|
func (store *Store) ReconcileTargets(ctx context.Context, clientID string) ([]*rvboxv1.ReconcileTarget, error) {
|
||||||
|
return store.ReconcileTargetsAt(ctx, clientID, time.Time{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReconcileTargetsAt returns the durable non-terminal view at the supplied
|
||||||
|
// receipt-time boundary. A connecting session uses one boundary for both the
|
||||||
|
// request and its reply: commands admitted after it are fresh work, not absent
|
||||||
|
// client evidence to be reconciled.
|
||||||
|
func (store *Store) ReconcileTargetsAt(ctx context.Context, clientID string, receiptBoundary time.Time) ([]*rvboxv1.ReconcileTarget, error) {
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
return nil, errors.New("client ID is required")
|
return nil, errors.New("client ID is required")
|
||||||
}
|
}
|
||||||
rows, err := store.db.QueryContext(ctx, `SELECT issue_uuid, last_event_seq, revision, immutable_request_sha256
|
query := `SELECT issue_uuid, last_event_seq, revision, immutable_request_sha256
|
||||||
FROM commands WHERE client_id = ? AND lifecycle BETWEEN 1 AND 4 ORDER BY issue_time, issue_uuid`, clientID)
|
FROM commands WHERE client_id = ? AND lifecycle BETWEEN 1 AND 4`
|
||||||
|
args := []any{clientID}
|
||||||
|
if !receiptBoundary.IsZero() {
|
||||||
|
query += ` AND server_receipt_time <= ?`
|
||||||
|
args = append(args, receiptBoundary.UTC().UnixNano())
|
||||||
|
}
|
||||||
|
query += ` ORDER BY issue_time, issue_uuid`
|
||||||
|
rows, err := store.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -62,10 +77,19 @@ func (store *Store) ReconcileClientSnapshot(ctx context.Context, clientID string
|
|||||||
// incidented. Retained non-terminal rows are retargeted to this generation so
|
// incidented. Retained non-terminal rows are retargeted to this generation so
|
||||||
// late events from the previous connection cannot advance the command.
|
// late events from the previous connection cannot advance the command.
|
||||||
func (store *Store) ReconcileClientSnapshotForSession(ctx context.Context, clientID string, generation uint64, snapshot *rvboxv1.ReconcileSnapshot) (*rvboxv1.ReconcileResult, error) {
|
func (store *Store) ReconcileClientSnapshotForSession(ctx context.Context, clientID string, generation uint64, snapshot *rvboxv1.ReconcileSnapshot) (*rvboxv1.ReconcileResult, error) {
|
||||||
|
return store.ReconcileClientSnapshotForSessionAt(ctx, clientID, generation, snapshot, time.Time{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReconcileClientSnapshotForSessionAt reconciles exactly the server state that
|
||||||
|
// was included in the matching ReconcileRequest. Work admitted after the
|
||||||
|
// boundary is intentionally left for the dispatch loop once reconciliation
|
||||||
|
// completes; treating it as absent client evidence would lose a valid command
|
||||||
|
// during the Hello/reconcile race.
|
||||||
|
func (store *Store) ReconcileClientSnapshotForSessionAt(ctx context.Context, clientID string, generation uint64, snapshot *rvboxv1.ReconcileSnapshot, receiptBoundary time.Time) (*rvboxv1.ReconcileResult, error) {
|
||||||
if generation == 0 {
|
if generation == 0 {
|
||||||
return nil, errors.New("session generation is required")
|
return nil, errors.New("session generation is required")
|
||||||
}
|
}
|
||||||
return store.reconcileClientSnapshot(ctx, clientID, generation, snapshot)
|
return store.reconcileClientSnapshotAt(ctx, clientID, generation, snapshot, receiptBoundary)
|
||||||
}
|
}
|
||||||
|
|
||||||
type reconcileServerRow struct {
|
type reconcileServerRow struct {
|
||||||
@@ -78,6 +102,10 @@ type reconcileServerRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) reconcileClientSnapshot(ctx context.Context, clientID string, generation uint64, snapshot *rvboxv1.ReconcileSnapshot) (*rvboxv1.ReconcileResult, error) {
|
func (store *Store) reconcileClientSnapshot(ctx context.Context, clientID string, generation uint64, snapshot *rvboxv1.ReconcileSnapshot) (*rvboxv1.ReconcileResult, error) {
|
||||||
|
return store.reconcileClientSnapshotAt(ctx, clientID, generation, snapshot, time.Time{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) reconcileClientSnapshotAt(ctx context.Context, clientID string, generation uint64, snapshot *rvboxv1.ReconcileSnapshot, receiptBoundary time.Time) (*rvboxv1.ReconcileResult, error) {
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
return nil, errors.New("client ID is required")
|
return nil, errors.New("client ID is required")
|
||||||
}
|
}
|
||||||
@@ -112,7 +140,7 @@ func (store *Store) reconcileClientSnapshot(ctx context.Context, clientID string
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
serverRows, tombstones, err := loadReconcileRows(ctx, tx, clientID)
|
serverRows, tombstones, err := loadReconcileRows(ctx, tx, clientID, receiptBoundary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
store.writeMu.Unlock()
|
store.writeMu.Unlock()
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -215,8 +243,14 @@ type reconcileIncident struct {
|
|||||||
dataLoss bool
|
dataLoss bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadReconcileRows(ctx context.Context, tx *sql.Tx, clientID string) (map[string]reconcileServerRow, map[string][]byte, error) {
|
func loadReconcileRows(ctx context.Context, tx *sql.Tx, clientID string, receiptBoundary time.Time) (map[string]reconcileServerRow, map[string][]byte, error) {
|
||||||
rows, err := tx.QueryContext(ctx, `SELECT issue_uuid, lifecycle, revision, last_event_seq, immutable_request_sha256, target_session_generation FROM commands WHERE client_id = ?`, clientID)
|
query := `SELECT issue_uuid, lifecycle, revision, last_event_seq, immutable_request_sha256, target_session_generation FROM commands WHERE client_id = ?`
|
||||||
|
args := []any{clientID}
|
||||||
|
if !receiptBoundary.IsZero() {
|
||||||
|
query += ` AND server_receipt_time <= ?`
|
||||||
|
args = append(args, receiptBoundary.UTC().UnixNano())
|
||||||
|
}
|
||||||
|
rows, err := tx.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,31 @@ func TestReconcileSnapshotMutatesMissingAndRetargets_HP_SES_13(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReconcileSessionBoundaryLeavesNewlyQueuedCommandForDispatch_HP_RECONCILE_08(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
opened, err := Open(ctx, Options{DataDir: filepath.Join(t.TempDir(), "state"), BusyTimeout: time.Second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = opened.Close() })
|
||||||
|
boundary := time.Date(2026, time.September, 11, 12, 0, 0, 0, time.UTC)
|
||||||
|
if _, err := opened.RegisterClientSession(ctx, ClientRegistration{ClientID: "boundary-client", Platform: 3, Architecture: "amd64", DaemonVersion: "test", DaemonCWD: `C:\\`, SupportedShells: []byte{1}, ClientInstanceID: [16]byte{31}, SessionID: [16]byte{32}, ConnectedAt: boundary}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
issue := mustReconcileIssue(t, "019c46f1-1d02-7000-8000-0000000000d1")
|
||||||
|
if _, err := opened.QueueCommand(ctx, QueueCommandInput{IssueUUID: issue, ClientID: "boundary-client", IssueTime: boundary.Add(time.Nanosecond), ReceiptTime: boundary.Add(time.Nanosecond), ImmutableSHA256: sha256.Sum256([]byte("new-after-reconcile-boundary")), ExecutionSpec: []byte("echo queued")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := opened.ReconcileClientSnapshotForSessionAt(ctx, "boundary-client", 1, &rvboxv1.ReconcileSnapshot{}, boundary); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
view, err := opened.GetCommandView(ctx, "boundary-client", issue)
|
||||||
|
if err != nil || view.Lifecycle != uint32(rvboxv1.CommandLifecycle_COMMAND_QUEUED) {
|
||||||
|
t.Fatalf("post-boundary command = %#v, %v", view, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustReconcileIssue(t *testing.T, value string) domain.UUID {
|
func mustReconcileIssue(t *testing.T, value string) domain.UUID {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
issue, err := domain.ParseUUIDv7(value)
|
issue, err := domain.ParseUUIDv7(value)
|
||||||
|
|||||||
@@ -46,10 +46,12 @@ type StdinIntent struct {
|
|||||||
Close bool
|
Close bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// PendingStdin returns unacknowledged input intents for one client. Delivery is
|
// PendingStdin returns unacknowledged input intents only after the command has
|
||||||
// intentionally tracked by the session dispatcher, not by this durable query:
|
// entered DISPATCHED state. This durable gate prevents append/close controls
|
||||||
// a lost connection simply causes the next session to replay the same write
|
// from overtaking their initial CommandDispatch on a fresh session. Delivery is
|
||||||
// sequence, which the client acknowledges idempotently.
|
// otherwise tracked by the session dispatcher: a lost connection simply causes
|
||||||
|
// the next session to replay the same write sequence, which the client
|
||||||
|
// acknowledges idempotently.
|
||||||
func (store *Store) PendingStdin(ctx context.Context, clientID string) ([]StdinIntent, error) {
|
func (store *Store) PendingStdin(ctx context.Context, clientID string) ([]StdinIntent, error) {
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
return nil, errors.New("client ID is required")
|
return nil, errors.New("client ID is required")
|
||||||
@@ -60,7 +62,7 @@ func (store *Store) PendingStdin(ctx context.Context, clientID string) ([]StdinI
|
|||||||
}
|
}
|
||||||
rows, err := database.QueryContext(ctx, `SELECT w.issue_uuid, w.write_seq, w.payload, w.raw_bytes, w.stored_bytes, w.compression, w.sha256, w.append_newline, w.close_intent
|
rows, err := database.QueryContext(ctx, `SELECT w.issue_uuid, w.write_seq, w.payload, w.raw_bytes, w.stored_bytes, w.compression, w.sha256, w.append_newline, w.close_intent
|
||||||
FROM stdin_writes w JOIN commands c ON c.issue_uuid = w.issue_uuid
|
FROM stdin_writes w JOIN commands c ON c.issue_uuid = w.issue_uuid
|
||||||
WHERE c.client_id = ? AND c.lifecycle BETWEEN 1 AND 4 AND w.acknowledged = 0
|
WHERE c.client_id = ? AND c.lifecycle BETWEEN 2 AND 4 AND w.acknowledged = 0
|
||||||
ORDER BY c.issue_time, c.issue_uuid, w.write_seq`, clientID)
|
ORDER BY c.issue_time, c.issue_uuid, w.write_seq`, clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Telemetry is the bounded operational state that the server publishes as
|
||||||
|
// gauges. It deliberately contains no client or command identifier.
|
||||||
|
type Telemetry struct {
|
||||||
|
QueuedCommands uint64
|
||||||
|
ChargedBytes uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) Telemetry(ctx context.Context) (Telemetry, error) {
|
||||||
|
if store == nil {
|
||||||
|
return Telemetry{}, errors.New("server store is unavailable")
|
||||||
|
}
|
||||||
|
store.mu.Lock()
|
||||||
|
defer store.mu.Unlock()
|
||||||
|
if store.db == nil {
|
||||||
|
return Telemetry{}, ErrStoreClosed
|
||||||
|
}
|
||||||
|
var result Telemetry
|
||||||
|
if err := store.db.QueryRowContext(ctx, `SELECT count(*) FROM commands WHERE lifecycle = ?`, uint32(rvboxv1.CommandLifecycle_COMMAND_QUEUED)).Scan(&result.QueuedCommands); err != nil {
|
||||||
|
return Telemetry{}, err
|
||||||
|
}
|
||||||
|
if err := store.db.QueryRowContext(ctx, `SELECT command_charged_bytes FROM storage_counters WHERE singleton = 1`).Scan(&result.ChargedBytes); err != nil {
|
||||||
|
return Telemetry{}, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rvbox/rvbox/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTelemetryReportsQueuedDurableState_HP_OPS_06(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
persistence, err := Open(ctx, Options{DataDir: filepath.Join(t.TempDir(), "state"), BusyTimeout: time.Second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = persistence.Close() })
|
||||||
|
if _, err := persistence.RegisterClientSession(ctx, ClientRegistration{ClientID: "telemetry-client", Platform: 2, Architecture: "amd64", DaemonVersion: "test", DaemonCWD: `C:\`, SupportedShells: []byte{1}, ClientInstanceID: [16]byte{1}, SessionID: [16]byte{2}, ConnectedAt: time.Now().UTC()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
issue, err := domain.ParseUUIDv7("019c46f1-1d02-7000-8000-0000000000d1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := persistence.QueueCommand(ctx, QueueCommandInput{IssueUUID: issue, ClientID: "telemetry-client", IssueTime: time.Now().UTC(), ReceiptTime: time.Now().UTC(), ImmutableSHA256: sha256.Sum256([]byte("telemetry")), ExecutionSpec: []byte("spec")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
telemetry, err := persistence.Telemetry(ctx)
|
||||||
|
if err != nil || telemetry.QueuedCommands != 1 || telemetry.ChargedBytes == 0 {
|
||||||
|
t.Fatalf("telemetry = %#v, %v", telemetry, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+143
@@ -0,0 +1,143 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Build an inspectable, reproducible RVBox Linux-server/Windows-client bundle.
|
||||||
|
# Publishing and certificate custody remain outside this repository; an optional
|
||||||
|
# local signing hook can sign the Windows executable before checksums are made.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
compose_file=$repo_root/deploy/compose.yaml
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
usage: scripts/release build --version VERSION --bootstrap-server-url WSS_URL [--output DIR] [--sign-windows-hook FILE]
|
||||||
|
|
||||||
|
Builds a fresh immutable bundle containing:
|
||||||
|
rvbox-server-linux-amd64
|
||||||
|
rvbox-server-linux-arm64
|
||||||
|
rvc-linux-amd64
|
||||||
|
rvc-linux-arm64
|
||||||
|
rvbox-windows-amd64.exe
|
||||||
|
SHA256SUMS
|
||||||
|
manifest.json
|
||||||
|
|
||||||
|
The default output is dist/rvbox-VERSION. --output must remain below this
|
||||||
|
repository's ignored dist/ tree. VERSION must be a safe release label
|
||||||
|
([0-9A-Za-z][0-9A-Za-z._+-]{0,63}); an existing final output is never replaced.
|
||||||
|
WSS_URL is the deliberate first-install endpoint embedded only in the Windows
|
||||||
|
installer; it must be an absolute `wss://` URL and is never inferred from a
|
||||||
|
test fixture. Artifacts are built inside the pinned Docker toolchain with that
|
||||||
|
exact version embedded in `--version`. The optional signing hook is a regular executable run
|
||||||
|
on the host as: HOOK WINDOWS_EXE VERSION. It receives RVBOX_ARTIFACT and
|
||||||
|
RVBOX_VERSION as environment variables and must sign the supplied executable
|
||||||
|
in place. SHA256SUMS and manifest.json are generated only after it succeeds.
|
||||||
|
|
||||||
|
No signing hook means the manifest explicitly marks the Windows artifact as
|
||||||
|
unsigned. This is deliberate for CI/test builds; do not publish it as signed.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() { printf '%s\n' "release: $*" >&2; exit 2; }
|
||||||
|
|
||||||
|
command=${1:-}
|
||||||
|
[ "$#" -gt 0 ] && shift
|
||||||
|
[ "$command" = build ] || { usage >&2; fail "expected build"; }
|
||||||
|
|
||||||
|
version=
|
||||||
|
output=
|
||||||
|
sign_hook=
|
||||||
|
bootstrap_server_url=
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case $1 in
|
||||||
|
--version) [ "$#" -ge 2 ] || fail "--version needs a value"; version=$2; shift 2 ;;
|
||||||
|
--bootstrap-server-url) [ "$#" -ge 2 ] || fail "--bootstrap-server-url needs a value"; bootstrap_server_url=$2; shift 2 ;;
|
||||||
|
--output) [ "$#" -ge 2 ] || fail "--output needs a directory"; output=$2; shift 2 ;;
|
||||||
|
--sign-windows-hook) [ "$#" -ge 2 ] || fail "--sign-windows-hook needs an executable file"; sign_hook=$2; shift 2 ;;
|
||||||
|
--help|-h) usage; exit 0 ;;
|
||||||
|
*) fail "unknown argument $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case $version in
|
||||||
|
''|[!0-9A-Za-z]*|*[!0-9A-Za-z._+-]*|?????????????????????????????????????????????????????????????????*)
|
||||||
|
fail "--version must match [0-9A-Za-z][0-9A-Za-z._+-]{0,63}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
case $bootstrap_server_url in
|
||||||
|
wss://*) ;;
|
||||||
|
*) fail "--bootstrap-server-url must be an absolute wss:// URL" ;;
|
||||||
|
esac
|
||||||
|
case $bootstrap_server_url in
|
||||||
|
*[!A-Za-z0-9:/._-]*) fail "--bootstrap-server-url contains unsupported characters" ;;
|
||||||
|
esac
|
||||||
|
if [ -z "$output" ]; then output=$repo_root/dist/rvbox-$version; fi
|
||||||
|
case $output in
|
||||||
|
/*) ;;
|
||||||
|
*) output=$repo_root/$output ;;
|
||||||
|
esac
|
||||||
|
case $output in
|
||||||
|
"$repo_root"/dist/*) ;;
|
||||||
|
*) fail "--output must be below $repo_root/dist" ;;
|
||||||
|
esac
|
||||||
|
parent=$(dirname -- "$output")
|
||||||
|
[ ! -e "$output" ] || fail "refusing to replace existing output $output"
|
||||||
|
[ -d "$parent" ] || mkdir -p "$parent"
|
||||||
|
[ ! -L "$parent" ] || fail "refusing symlink output parent $parent"
|
||||||
|
if [ -n "$sign_hook" ]; then
|
||||||
|
[ -f "$sign_hook" ] && [ ! -L "$sign_hook" ] && [ -x "$sign_hook" ] || fail "signing hook must be an executable regular file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker version >/dev/null 2>&1 || fail "Docker is unavailable"
|
||||||
|
docker compose -f "$compose_file" version >/dev/null 2>&1 || fail "Docker Compose is unavailable"
|
||||||
|
|
||||||
|
partial=$parent/.rvbox-$version.partial-$$
|
||||||
|
mkdir "$partial" || fail "could not create private release staging directory"
|
||||||
|
cleanup() { rm -rf -- "$partial"; }
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
container_partial=/workspace/${partial#"$repo_root"/}
|
||||||
|
|
||||||
|
commit=$(git -C "$repo_root" rev-parse HEAD)
|
||||||
|
dirty=$(git -C "$repo_root" status --porcelain)
|
||||||
|
[ -z "$dirty" ] || fail "refusing release build from a dirty worktree"
|
||||||
|
|
||||||
|
docker compose -f "$compose_file" run --rm toolchain sh -ec '
|
||||||
|
set -eu
|
||||||
|
version=$1
|
||||||
|
out=$2
|
||||||
|
flags="-s -w -X main.buildVersion=$version"
|
||||||
|
windows_flags="$flags -X main.bootstrapServerURL=$3"
|
||||||
|
resource=/workspace/cmd/rvbox/rvbox-release_windows_amd64.syso
|
||||||
|
icon="$out/.rvbox-release-icon.png"
|
||||||
|
trap "rm -f -- \"$resource\" \"$icon\"" EXIT HUP INT TERM
|
||||||
|
go run ./tools/releaseicon --out "$icon"
|
||||||
|
go-winres simply --arch amd64 --out /workspace/cmd/rvbox/rvbox-release --icon "$icon" --manifest gui --file-version "$version" --product-version "$version" --file-description "RVBox Windows client" --product-name "RVBox" --original-filename "rvbox.exe"
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$flags" -o "$out/rvbox-server-linux-amd64" ./cmd/rvbox-server
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$flags" -o "$out/rvbox-server-linux-arm64" ./cmd/rvbox-server
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$flags" -o "$out/rvc-linux-amd64" ./cmd/rvc
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$flags" -o "$out/rvc-linux-arm64" ./cmd/rvc
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-H=windowsgui $windows_flags" -o "$out/rvbox-windows-amd64.exe" ./cmd/rvbox
|
||||||
|
' sh "$version" "$container_partial" "$bootstrap_server_url"
|
||||||
|
|
||||||
|
signed=false
|
||||||
|
if [ -n "$sign_hook" ]; then
|
||||||
|
RVBOX_ARTIFACT=$partial/rvbox-windows-amd64.exe RVBOX_VERSION=$version "$sign_hook" "$partial/rvbox-windows-amd64.exe" "$version"
|
||||||
|
signed=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
for artifact in rvbox-server-linux-amd64 rvbox-server-linux-arm64 rvc-linux-amd64 rvc-linux-arm64 rvbox-windows-amd64.exe; do
|
||||||
|
[ -f "$partial/$artifact" ] && [ ! -L "$partial/$artifact" ] || fail "builder did not create regular $artifact"
|
||||||
|
done
|
||||||
|
(cd "$partial" && sha256sum rvbox-server-linux-amd64 rvbox-server-linux-arm64 rvc-linux-amd64 rvc-linux-arm64 rvbox-windows-amd64.exe) >"$partial/SHA256SUMS"
|
||||||
|
{
|
||||||
|
printf '{\n'
|
||||||
|
printf ' "schema": 1,\n'
|
||||||
|
printf ' "version": "%s",\n' "$version"
|
||||||
|
printf ' "git_commit": "%s",\n' "$commit"
|
||||||
|
printf ' "windows_signed": %s,\n' "$signed"
|
||||||
|
printf ' "artifacts": "SHA256SUMS"\n'
|
||||||
|
printf '}\n'
|
||||||
|
} >"$partial/manifest.json"
|
||||||
|
chmod 0755 "$partial/rvbox-server-linux-amd64" "$partial/rvbox-server-linux-arm64" "$partial/rvc-linux-amd64" "$partial/rvc-linux-arm64" "$partial/rvbox-windows-amd64.exe"
|
||||||
|
chmod 0644 "$partial/SHA256SUMS" "$partial/manifest.json"
|
||||||
|
mv -- "$partial" "$output"
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
printf 'release_bundle=%s\n' "$output"
|
||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Run one bounded Go fuzz target in the pinned RVBox toolchain container.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
usage: scripts/test-fuzz --package ./PACKAGE --name FUZZ_TEST [--time DURATION]
|
||||||
|
|
||||||
|
Runs exactly one Fuzz* target with ordinary unit tests disabled. The default
|
||||||
|
duration is 30s. A crash leaves Go's minimal reproducer in the package fuzz
|
||||||
|
corpus, where it becomes a normal deterministic seed on the next test run.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() { printf '%s\n' "test-fuzz: $*" >&2; exit 2; }
|
||||||
|
|
||||||
|
package=
|
||||||
|
name=
|
||||||
|
duration=30s
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case $1 in
|
||||||
|
--package) [ "$#" -ge 2 ] || fail "--package needs a value"; package=$2; shift 2 ;;
|
||||||
|
--name) [ "$#" -ge 2 ] || fail "--name needs a value"; name=$2; shift 2 ;;
|
||||||
|
--time) [ "$#" -ge 2 ] || fail "--time needs a value"; duration=$2; shift 2 ;;
|
||||||
|
--help|-h) usage; exit 0 ;;
|
||||||
|
*) fail "unknown argument $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case $package in ./*) ;; *) fail "--package must be a repository-relative Go package" ;; esac
|
||||||
|
case $name in Fuzz*) ;; *) fail "--name must be a Fuzz* test function" ;; esac
|
||||||
|
case $name in *[!A-Za-z0-9_]* ) fail "--name contains unsupported characters" ;; esac
|
||||||
|
case $duration in *[!0-9a-zA-Z.]*) fail "--time contains unsupported characters" ;; esac
|
||||||
|
|
||||||
|
cd "$repo_root"
|
||||||
|
exec docker compose -f deploy/compose.yaml run --rm toolchain \
|
||||||
|
go test "$package" -run '^$' -fuzz "$name" -fuzztime "$duration"
|
||||||
@@ -78,6 +78,15 @@ compose() {
|
|||||||
docker compose -p "$project" -f "$repo_root/test/linux-server/compose.yaml" "$@"
|
docker compose -p "$project" -f "$repo_root/test/linux-server/compose.yaml" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remove_project_networks() {
|
||||||
|
# The project label is Docker Compose's exact resource-ownership key. This
|
||||||
|
# also reclaims legacy certgen default networks left by fixtures created
|
||||||
|
# before certgen joined the labeled native network.
|
||||||
|
for network_id in $(docker network ls --filter "label=com.docker.compose.project=$project" --quiet); do
|
||||||
|
docker network rm "$network_id" >/dev/null || fail "could not remove owned Docker network $network_id"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
rvc() {
|
rvc() {
|
||||||
compose exec -T server /opt/rvbox/rvc --socket /run/rvbox/server.sock "$@"
|
compose exec -T server /opt/rvbox/rvc --socket /run/rvbox/server.sock "$@"
|
||||||
}
|
}
|
||||||
@@ -179,6 +188,69 @@ assert_stdin_close() {
|
|||||||
fail "stdin command did not reach terminal success"
|
fail "stdin command did not reach terminal success"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert_signal_term() {
|
||||||
|
issued=$(rvc run --background --shell cmd "$client_id" 'ping -t 127.0.0.1 >NUL' 2>&1) || fail "signal command admission failed: $issued"
|
||||||
|
issue=$(printf '%s\n' "$issued" | awk 'NR == 1 { print $1 }')
|
||||||
|
case $issue in ????????-????-7???-????-????????????) ;; *) fail "signal command returned invalid issue UUID: $issued" ;; esac
|
||||||
|
attempt=0
|
||||||
|
while [ "$attempt" -lt 30 ]; do
|
||||||
|
result=$(rvc stat "$client_id" "$issue" 2>/dev/null || true)
|
||||||
|
if printf '%s\n' "$result" | grep -q 'lifecycle=COMMAND_RUNNING'; then break; fi
|
||||||
|
attempt=$((attempt + 1)); sleep 1
|
||||||
|
done
|
||||||
|
[ "$attempt" -lt 30 ] || fail "signal command did not reach running state"
|
||||||
|
rvc kill TERM "$client_id" "$issue" >/dev/null || fail "TERM request failed"
|
||||||
|
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_TERMINATED'; then
|
||||||
|
printf '%s\n' "$result" >"$fixture_dir/signal-term.stat"
|
||||||
|
printf 'passed signal-term issue=%s\n' "$issue"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case $result in *'lifecycle=COMMAND_FAILED'*|*'lifecycle=COMMAND_REJECTED'*|*'lifecycle=COMMAND_SUCCEEDED'*) fail "TERM command reached wrong terminal state: $result" ;; esac
|
||||||
|
attempt=$((attempt + 1)); sleep 1
|
||||||
|
done
|
||||||
|
fail "TERM command did not reach terminal state"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_server_restart_reconnect() {
|
||||||
|
issued=$(rvc run --background --shell cmd "$client_id" 'ping -t 127.0.0.1 >NUL' 2>&1) || fail "restart command admission failed: $issued"
|
||||||
|
issue=$(printf '%s\n' "$issued" | awk 'NR == 1 { print $1 }')
|
||||||
|
case $issue in ????????-????-7???-????-????????????) ;; *) fail "restart command returned invalid issue UUID: $issued" ;; esac
|
||||||
|
attempt=0
|
||||||
|
while [ "$attempt" -lt 30 ]; do
|
||||||
|
result=$(rvc stat "$client_id" "$issue" 2>/dev/null || true)
|
||||||
|
if printf '%s\n' "$result" | grep -q 'lifecycle=COMMAND_RUNNING'; then break; fi
|
||||||
|
attempt=$((attempt + 1)); sleep 1
|
||||||
|
done
|
||||||
|
[ "$attempt" -lt 30 ] || fail "restart command did not reach running state"
|
||||||
|
# This preserves the server state volume while replacing the actual server
|
||||||
|
# process behind nginx. A later TERM terminal result can only arrive after
|
||||||
|
# the Windows daemon has established a fresh reconciled WSS session.
|
||||||
|
compose restart server
|
||||||
|
attempt=0
|
||||||
|
while [ "$attempt" -lt 45 ]; do
|
||||||
|
if rvc stat "$client_id" >/dev/null 2>&1; then break; fi
|
||||||
|
attempt=$((attempt + 1)); sleep 1
|
||||||
|
done
|
||||||
|
[ "$attempt" -lt 45 ] || fail "server control socket did not recover after restart"
|
||||||
|
wait_client
|
||||||
|
rvc kill TERM "$client_id" "$issue" >/dev/null || fail "TERM after server restart failed"
|
||||||
|
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_TERMINATED'; then
|
||||||
|
printf '%s\n' "$result" >"$fixture_dir/server-restart-reconnect.stat"
|
||||||
|
printf 'passed server-restart-reconnect issue=%s\n' "$issue"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case $result in *'lifecycle=COMMAND_FAILED'*|*'lifecycle=COMMAND_REJECTED'*|*'lifecycle=COMMAND_SUCCEEDED'*) fail "restart TERM command reached wrong terminal state: $result" ;; esac
|
||||||
|
attempt=$((attempt + 1)); sleep 1
|
||||||
|
done
|
||||||
|
fail "restart TERM command did not reach terminal state"
|
||||||
|
}
|
||||||
|
|
||||||
collect() {
|
collect() {
|
||||||
if [ "$vm_prepared" = yes ]; then
|
if [ "$vm_prepared" = yes ]; then
|
||||||
"$repo_root/scripts/windows/test-host" collect --run-id "$run_id" || true
|
"$repo_root/scripts/windows/test-host" collect --run-id "$run_id" || true
|
||||||
@@ -190,6 +262,7 @@ collect() {
|
|||||||
|
|
||||||
clean() {
|
clean() {
|
||||||
compose down --volumes --remove-orphans || true
|
compose down --volumes --remove-orphans || true
|
||||||
|
remove_project_networks
|
||||||
# A reset is the isolation boundary for the next run. Do not conceal a
|
# A reset is the isolation boundary for the next run. Do not conceal a
|
||||||
# failed shutdown/snapshot restore behind a successful-looking `clean`:
|
# failed shutdown/snapshot restore behind a successful-looking `clean`:
|
||||||
# callers must repair or explicitly inspect the retained VM lease first.
|
# callers must repair or explicitly inspect the retained VM lease first.
|
||||||
@@ -224,6 +297,8 @@ case $action in
|
|||||||
"$repo_root/scripts/windows/test-host" run --run-id "$run_id" --endpoint "$endpoint_host:$port"
|
"$repo_root/scripts/windows/test-host" run --run-id "$run_id" --endpoint "$endpoint_host:$port"
|
||||||
wait_client
|
wait_client
|
||||||
assert_stdin_close
|
assert_stdin_close
|
||||||
|
assert_signal_term
|
||||||
|
assert_server_restart_reconnect
|
||||||
assert_context active-user no active-user
|
assert_context active-user no active-user
|
||||||
assert_context active-user-elevated yes active-user-elevated
|
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
|
"$repo_root/scripts/windows/test-host" run --run-id "$run_id" --fail-contexts ACTIVE_USER_ELEVATED
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Actions:
|
|||||||
logoff log off the sole active fixture user; use only after service installation
|
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
|
inspect read-only RVBox SCM state and bounded client log from a prepared run
|
||||||
|
logs read-only service-startup and client logs 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
|
||||||
@@ -87,7 +88,7 @@ while [ "$#" -gt 0 ]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
case $action in status|prepare|stage|install|run|logoff|collect|inspect|stop|reset|recover) ;; *) usage >&2; fail "unknown action $action" ;; esac
|
case $action in status|prepare|stage|install|run|logoff|collect|inspect|logs|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"
|
||||||
@@ -169,7 +170,7 @@ remote() {
|
|||||||
# retry. Lifecycle transitions remain single-attempt: their caller must
|
# retry. Lifecycle transitions remain single-attempt: their caller must
|
||||||
# inspect/recover rather than risk a duplicate reset, shutdown, or logoff.
|
# inspect/recover rather than risk a duplicate reset, shutdown, or logoff.
|
||||||
case $remote_action in
|
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)
|
status|recover|prepare-stage|stage|stage-create-root|stage-copy-exe|stage-copy-config|stage-copy-ca|collect|inspect|logs|install|run)
|
||||||
retry_limit=4
|
retry_limit=4
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -315,6 +316,18 @@ wait_guest_additions() {
|
|||||||
fail "Guest Additions did not publish version and Windows OS-release properties"
|
fail "Guest Additions did not publish version and Windows OS-release properties"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configure_display_keepalive() {
|
||||||
|
# VirtualBox VRDE reports a zero-bpp framebuffer when Windows powers off
|
||||||
|
# the virtual monitor. That leaves an authenticated Guacamole tunnel in
|
||||||
|
# its "Waiting for response" state even though RDP negotiation succeeded.
|
||||||
|
# Keep the disposable diagnostic fixture awake; this is deliberately
|
||||||
|
# applied after every snapshot restore because power-plan state belongs to
|
||||||
|
# the guest snapshot, not to the controller.
|
||||||
|
provisioner_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
|
||||||
|
/d /s /c '(powercfg /change monitor-timeout-ac 0 && powercfg /change monitor-timeout-dc 0 && powercfg /change standby-timeout-ac 0 && powercfg /change standby-timeout-dc 0 && powercfg /change hibernate-timeout-ac 0 && powercfg /change hibernate-timeout-dc 0 && echo RVBOX_GUEST_OK)' >/dev/null || \
|
||||||
|
fail "fixture provisioner could not disable disposable display/sleep timers"
|
||||||
|
}
|
||||||
|
|
||||||
wait_service() {
|
wait_service() {
|
||||||
wanted=$1
|
wanted=$1
|
||||||
attempt=0
|
attempt=0
|
||||||
@@ -365,6 +378,8 @@ case "$action" in
|
|||||||
step prepare-vm-started
|
step prepare-vm-started
|
||||||
wait_guest_additions
|
wait_guest_additions
|
||||||
step prepare-guest-additions-ready
|
step prepare-guest-additions-ready
|
||||||
|
configure_display_keepalive
|
||||||
|
step prepare-display-keepalive
|
||||||
assert_clean_guest
|
assert_clean_guest
|
||||||
step prepare-clean-baseline-verified
|
step prepare-clean-baseline-verified
|
||||||
;;
|
;;
|
||||||
@@ -488,9 +503,9 @@ case "$action" in
|
|||||||
require_lease
|
require_lease
|
||||||
install -d -m 700 "$host_stage/artifacts"
|
install -d -m 700 "$host_stage/artifacts"
|
||||||
if [ "$(state)" = running ]; then
|
if [ "$(state)" = running ]; then
|
||||||
guest_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 queryex RVBoxClient > \"$guest_root\\service-status.txt\" 2>&1 & echo RVBOX_GUEST_OK" >/dev/null || true
|
/d /s /c "sc.exe queryex RVBoxClient > \"$guest_root\\service-status.txt\" 2>&1 & if exist \"C:/ProgramData/RVBox/service-startup.log\" copy /y \"C:/ProgramData/RVBox/service-startup.log\" \"$guest_root\\service-startup.log\" >NUL & if exist \"C:/ProgramData/RVBox/test-logs/rvbox.log\" copy /y \"C:/ProgramData/RVBox/test-logs/rvbox.log\" \"$guest_root\\rvbox.log\" >NUL & echo RVBOX_GUEST_OK" >/dev/null || true
|
||||||
VBoxManage guestcontrol "$vm" --username "$guest_user" --passwordfile "$password_file" \
|
VBoxManage guestcontrol "$vm" --username "$provisioner_user" --passwordfile "$provisioner_password_file" \
|
||||||
copyfrom "$guest_root" "$host_stage/artifacts" --recursive </dev/null >/dev/null 2>&1 || true
|
copyfrom "$guest_root" "$host_stage/artifacts" --recursive </dev/null >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
printf 'collected host_stage=%s/artifacts\n' "$host_stage"
|
printf 'collected host_stage=%s/artifacts\n' "$host_stage"
|
||||||
@@ -500,7 +515,14 @@ case "$action" in
|
|||||||
require_lease
|
require_lease
|
||||||
[ "$(state)" = running ] || fail "inspect requires a running prepared VM"
|
[ "$(state)" = running ] || fail "inspect 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\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"
|
/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\" & 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"
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
assert_identity
|
||||||
|
require_lease
|
||||||
|
[ "$(state)" = running ] || fail "logs requires a running prepared VM"
|
||||||
|
provisioner_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
|
||||||
|
/d /s /c "dir \"C:\\ProgramData\\RVBox\" & dir \"C:\\ProgramData\\RVBox\\test-logs\" & type \"C:\\ProgramData\\RVBox\\service-startup.log\" & type \"C:\\ProgramData\\RVBox\\test-logs\\rvbox.log\" & echo RVBOX_GUEST_OK"
|
||||||
;;
|
;;
|
||||||
stop)
|
stop)
|
||||||
assert_identity
|
assert_identity
|
||||||
@@ -540,7 +562,22 @@ case "$action" in
|
|||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
[ "$(state)" = poweroff ] || fail "guest did not power off before reset"
|
# This is the exact named disposable fixture, with the matching
|
||||||
|
# run lease. Reset is its isolation boundary, not a graceful-stop
|
||||||
|
# diagnostic command: after a bounded ACPI attempt, discard only
|
||||||
|
# this guest's state so snapshot restore cannot strand the lane.
|
||||||
|
if [ "$(state)" != poweroff ]; then
|
||||||
|
printf 'reset: ACPI shutdown timed out; forcing disposable VM poweroff\n' >&2
|
||||||
|
VBoxManage controlvm "$vm" poweroff >/dev/null
|
||||||
|
attempt=0
|
||||||
|
while [ "$attempt" -lt 30 ]; do
|
||||||
|
[ "$(state)" = poweroff ] && break
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[ "$(state)" = poweroff ] || fail "guest did not power off after forced reset"
|
||||||
|
printf 'reset-force-poweroff vm=%s\n' "$vm"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
VBoxManage snapshot "$vm" restore "$snapshot" >/dev/null
|
VBoxManage snapshot "$vm" restore "$snapshot" >/dev/null
|
||||||
assert_identity
|
assert_identity
|
||||||
@@ -580,7 +617,9 @@ accelerated_stage() {
|
|||||||
printf 'stage: accelerated publish directory unavailable\n' >&2
|
printf 'stage: accelerated publish directory unavailable\n' >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
stage_http_dir=$(mktemp -d "${TMPDIR:-/tmp}/rvbox-http-stage.XXXXXX")
|
# Keep all disposable test bytes inside the owning run directory; never
|
||||||
|
# consume global /tmp, which may belong to a different test or user.
|
||||||
|
stage_http_dir=$(mktemp -d "$run_root/.accelerated-stage.XXXXXX")
|
||||||
stage_http_xz=$stage_http_dir/rvbox.exe.xz
|
stage_http_xz=$stage_http_dir/rvbox.exe.xz
|
||||||
xz -T0 -3 -c "$bundle/rvbox.exe" >"$stage_http_xz"
|
xz -T0 -3 -c "$bundle/rvbox.exe" >"$stage_http_xz"
|
||||||
stage_http_hash=$(sha256sum "$stage_http_xz" | awk '{print $1}')
|
stage_http_hash=$(sha256sum "$stage_http_xz" | awk '{print $1}')
|
||||||
@@ -615,7 +654,7 @@ copy_stage_file() {
|
|||||||
target_name=$2
|
target_name=$2
|
||||||
copy_attempt=1
|
copy_attempt=1
|
||||||
while [ "$copy_attempt" -le 4 ]; do
|
while [ "$copy_attempt" -le 4 ]; do
|
||||||
if scp -q "$source_file" "$RVBOX_TEST_VBOX_HOST:$host_stage/$target_name"; then
|
if scp -q -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=10 -o ServerAliveCountMax=2 "$source_file" "$RVBOX_TEST_VBOX_HOST:$host_stage/$target_name"; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
copy_attempt=$((copy_attempt + 1))
|
copy_attempt=$((copy_attempt + 1))
|
||||||
|
|||||||
@@ -17,6 +17,21 @@ layer = "unit"
|
|||||||
status = "implemented"
|
status = "implemented"
|
||||||
tests = ["internal/config/effective_test.go:TestEffectiveConfigurationGolden_HP_CFG_09"]
|
tests = ["internal/config/effective_test.go:TestEffectiveConfigurationGolden_HP_CFG_09"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "HP-CFG-10"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = ["internal/config/config_test.go:TestClientMayDisableObservabilityListener_HP_CFG_10"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "HP-WINCLI-04"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = [
|
||||||
|
"cmd/rvbox/main_test.go:TestPackagedClientConfigUsesMinimalSafeDefaults_HP_WINCLI_04",
|
||||||
|
"cmd/rvbox/main_test.go:TestInstallerClientIDNormalizesHostnames_HP_WINCLI_05",
|
||||||
|
]
|
||||||
|
|
||||||
[[requirements]]
|
[[requirements]]
|
||||||
id = "HP-CTL-06"
|
id = "HP-CTL-06"
|
||||||
layer = "unit"
|
layer = "unit"
|
||||||
@@ -173,6 +188,18 @@ layer = "unit"
|
|||||||
status = "implemented"
|
status = "implemented"
|
||||||
tests = ["internal/agentproto/validate_test.go:TestOutputChunkBoundedDecompression_HP_PROTO_05"]
|
tests = ["internal/agentproto/validate_test.go:TestOutputChunkBoundedDecompression_HP_PROTO_05"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "SEC-PROTO-01"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = [
|
||||||
|
"internal/agentproto/validate_test.go:FuzzDecodeEnvelopeBounded_SEC_PROTO_01",
|
||||||
|
"internal/agentproto/validate_test.go:FuzzDecodeOutputChunkBounded_SEC_PROTO_02",
|
||||||
|
"internal/server/control/jsonrpc_test.go:FuzzDecodeJSONRPCRequestBounded_SEC_CTL_01",
|
||||||
|
"internal/server/store/segment_test.go:FuzzDecodeSegmentRecordDoesNotEscapeBounds",
|
||||||
|
"internal/domain/cursor_test.go:FuzzDecodeCursorBounded_SEC_CTL_02",
|
||||||
|
]
|
||||||
|
|
||||||
[[requirements]]
|
[[requirements]]
|
||||||
id = "HP-PROTO-09"
|
id = "HP-PROTO-09"
|
||||||
layer = "unit"
|
layer = "unit"
|
||||||
@@ -608,6 +635,66 @@ layer = "unit"
|
|||||||
status = "implemented"
|
status = "implemented"
|
||||||
tests = ["test/windowsnative/native_fixture_test.go:TestProductionComposeAssets_HP_OPS_01"]
|
tests = ["test/windowsnative/native_fixture_test.go:TestProductionComposeAssets_HP_OPS_01"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "HP-OPS-02"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = ["internal/observability/rotate_test.go:TestRotatingFileBoundsArchives_HP_OPS_02"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "BH-OPS-03"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = ["internal/observability/rotate_test.go:TestRotatingFileRejectsPartialRotationConfig_BH_OPS_03"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "HP-OPS-04"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = ["internal/observability/rotate_test.go:TestFormatLogWritesStructuredBoundedRecords_HP_OPS_04"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "HP-OPS-05"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = [
|
||||||
|
"internal/observability/health_test.go:TestHealthRendersBoundedGaugesAndDurationHistograms_HP_OPS_02",
|
||||||
|
"internal/server/session/agent_server_test.go:TestAgentServerEmitsBoundedRegistrationMetrics_HP_OPS_05",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "HP-OPS-06"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = [
|
||||||
|
"internal/server/store/telemetry_test.go:TestTelemetryReportsQueuedDurableState_HP_OPS_06",
|
||||||
|
"internal/client/spool/telemetry_test.go:TestTelemetryReportsQueuedDurableSpool_HP_OPS_07",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "HP-OPS-07"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = [
|
||||||
|
"cmd/rvbox-server/main_test.go:TestPublishServerTelemetrySample_HP_OPS_07",
|
||||||
|
"cmd/rvbox/main_test.go:TestPublishClientTelemetrySample_HP_OPS_07",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "BH-OPS-04"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = [
|
||||||
|
"cmd/rvbox-server/main_test.go:TestPublishServerTelemetrySampleFailure_BH_OPS_04",
|
||||||
|
"cmd/rvbox/main_test.go:TestPublishClientTelemetrySampleFailure_BH_OPS_04",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "HP-DISPATCH-10"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = ["internal/server/store/command_test.go:TestPendingStdinWaitsForDurableDispatch_HP_DISPATCH_06"]
|
||||||
|
|
||||||
[[requirements]]
|
[[requirements]]
|
||||||
id = "HP-STORE-01"
|
id = "HP-STORE-01"
|
||||||
layer = "integration"
|
layer = "integration"
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ services:
|
|||||||
- "${RVBOX_NATIVE_RUNTIME_DIR}/pki:/pki"
|
- "${RVBOX_NATIVE_RUNTIME_DIR}/pki:/pki"
|
||||||
- ./certgen.sh:/fixture/certgen.sh:ro
|
- ./certgen.sh:/fixture/certgen.sh:ro
|
||||||
entrypoint: ["/bin/sh", "/fixture/certgen.sh"]
|
entrypoint: ["/bin/sh", "/fixture/certgen.sh"]
|
||||||
|
networks: [native]
|
||||||
networks:
|
networks:
|
||||||
native:
|
native:
|
||||||
labels: { rvbox.native.run_id: "${RVBOX_NATIVE_RUN_ID}" }
|
labels: { rvbox.native.run_id: "${RVBOX_NATIVE_RUN_ID}" }
|
||||||
|
|||||||
+200
-19
@@ -9,10 +9,11 @@ The helper builds this private path:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
browser -- HTTPS/self-signed --> nginx + Guacamole containers
|
browser -- HTTPS/self-signed --> nginx + Guacamole containers
|
||||||
|
(IPv4 and, in public mode, a tracked IPv6 forward)
|
||||||
|
|
|
|
||||||
private Docker gateway
|
private Docker gateway
|
||||||
|
|
|
|
||||||
controller SSH tunnel --> Helium 127.0.0.1:3389 --> VirtualBox VRDE --> VM console
|
controller SSH watchdog/tunnel --> Helium 127.0.0.1:3389 --> VirtualBox VRDE --> VM console
|
||||||
```
|
```
|
||||||
|
|
||||||
Only the HTTPS listener can be made public, and that requires an explicit
|
Only the HTTPS listener can be made public, and that requires an explicit
|
||||||
@@ -23,6 +24,63 @@ password to the VM. Clipboard, drives, printing, audio, microphone input, GFX,
|
|||||||
and display-resize extensions are disabled because the fixture's VirtualBox
|
and display-resize extensions are disabled because the fixture's VirtualBox
|
||||||
RDP4 server does not handle them reliably.
|
RDP4 server does not handle them reliably.
|
||||||
|
|
||||||
|
## Current fixture quick start
|
||||||
|
|
||||||
|
The following is the copy/paste path for the documented `rvbox-win10-test`
|
||||||
|
fixture. Run it from the repository root. The first command acquires the
|
||||||
|
exclusive VM lease, restores the clean administrator-enabled snapshot, starts
|
||||||
|
the VM, and applies the VRDE display keepalive. The second command starts the
|
||||||
|
Docker-only gateway at the currently published endpoint:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/windows/test-host prepare --run-id interactive-rdp
|
||||||
|
test/rdp-access/rdp-access up \
|
||||||
|
--bind 0.0.0.0 \
|
||||||
|
--public-host x1.xcel.me
|
||||||
|
```
|
||||||
|
|
||||||
|
`up` prompts for the fixture password. If the password is not already in the
|
||||||
|
operator's secure notes, the test-only value is available only in the Helium
|
||||||
|
host's mode-600 file documented in [`docs/testing-vm.md`](../../docs/testing-vm.md).
|
||||||
|
For an unattended first start, pass that file through the existing SSH alias
|
||||||
|
without putting the value in an argument, environment variable, or log:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh helium-remote 'cat /home/cabbage/.local/share/rvbox-secrets/rvbox-win10-test.password' |
|
||||||
|
test/rdp-access/rdp-access up \
|
||||||
|
--bind 0.0.0.0 \
|
||||||
|
--public-host x1.xcel.me \
|
||||||
|
--web-password-stdin
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser URL is `https://x1.xcel.me:5002/guacamole/`; accept the temporary
|
||||||
|
self-signed certificate warning and sign in as `rvboxtest`. The same command
|
||||||
|
is safe to rerun after a tunnel interruption. If the stack is already running,
|
||||||
|
`up` reuses it and repairs only missing forwarding; it does not consume stdin.
|
||||||
|
Use `--reset-auth` only after `down` when intentionally replacing the saved
|
||||||
|
verifier, for example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
test/rdp-access/rdp-access down
|
||||||
|
ssh helium-remote 'cat /home/cabbage/.local/share/rvbox-secrets/rvbox-win10-test.password' |
|
||||||
|
test/rdp-access/rdp-access up \
|
||||||
|
--bind 0.0.0.0 \
|
||||||
|
--public-host x1.xcel.me \
|
||||||
|
--reset-auth \
|
||||||
|
--web-password-stdin
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not run `clean` or `test-host reset` while another agent owns the fixture
|
||||||
|
lease or has a live browser session. The generated verifier, certificate,
|
||||||
|
tunnel state, and logs are disposable and live only under the ignored
|
||||||
|
`test/rdp-access/.runtime/` directory.
|
||||||
|
|
||||||
|
Before a first `prepare`, run `scripts/windows/test-host status`. If it reports
|
||||||
|
`state=running`, another run may already own the fixture lease; do not restore
|
||||||
|
or reset it. Reuse that run's access path or coordinate with its owner. If it
|
||||||
|
reports `state=poweroff`, the quick-start sequence above is safe. The helper
|
||||||
|
does not guess at ownership or silently take over a running VM.
|
||||||
|
|
||||||
## Lifecycle
|
## Lifecycle
|
||||||
|
|
||||||
Run commands from the repository root. First prepare the disposable VM using a
|
Run commands from the repository root. First prepare the disposable VM using a
|
||||||
@@ -53,18 +111,88 @@ For localhost-only use, omit `--bind` and `--public-host`. The default listener
|
|||||||
is `127.0.0.1:5002`; use a local SSH forward or a browser on the controller.
|
is `127.0.0.1:5002`; use a local SSH forward or a browser on the controller.
|
||||||
Choose alternate ports with `--http-port` and `--tunnel-port` if either is in
|
Choose alternate ports with `--http-port` and `--tunnel-port` if either is in
|
||||||
use. The VM must already be running. `up` checks the documented VM/snapshot
|
use. The VM must already be running. `up` checks the documented VM/snapshot
|
||||||
identity but intentionally does not restore, start, stop, or reset the VM.
|
identity but intentionally does not restore, start, stop, or reset the VM. It
|
||||||
|
starts a small host-side watchdog for the SSH master. Before printing the URL,
|
||||||
|
`up` waits until the HTTPS gateway can serve `/guacamole/`; this prevents a
|
||||||
|
browser login from racing Tomcat's Guacamole WAR deployment. The watchdog
|
||||||
|
reconnects after a transient Helium/SSH failure while preserving the same
|
||||||
|
Docker-gateway listener, so an already-open Guacamole session can recover
|
||||||
|
without restarting the containers. Its PID, stop marker, and diagnostic log
|
||||||
|
are kept under the ignored `.runtime/` directory.
|
||||||
|
|
||||||
All fixture-specific values have embedded, working defaults: the
|
All fixture-specific values have embedded, working defaults: the
|
||||||
`helium-remote` SSH alias, Helium's loopback VRDE endpoint (`127.0.0.1:3389`),
|
`helium-remote` SSH alias, Helium's loopback VRDE endpoint (`127.0.0.1:3389`),
|
||||||
`rvboxtest`, the browser listener (`127.0.0.1:5002`), and the private tunnel
|
`rvboxtest`, the browser listener (`127.0.0.1:5002`), and the private tunnel
|
||||||
port (`54001`). They can be overridden without editing tracked files through
|
port (`54001`). They can be overridden without editing tracked files through
|
||||||
`RVBOX_TEST_VBOX_HOST`, `RDP_ACCESS_VRDE_HOST`, `RDP_ACCESS_VRDE_PORT`,
|
`RVBOX_TEST_VBOX_HOST`, `RDP_ACCESS_VRDE_HOST`, `RDP_ACCESS_VRDE_PORT`,
|
||||||
|
`RDP_ACCESS_PROFILE`,
|
||||||
`RDP_ACCESS_WEB_USER`, `RDP_ACCESS_RDP_USER`, `RDP_ACCESS_BIND`,
|
`RDP_ACCESS_WEB_USER`, `RDP_ACCESS_RDP_USER`, `RDP_ACCESS_BIND`,
|
||||||
`RDP_ACCESS_HTTP_PORT`, `RDP_ACCESS_TUNNEL_PORT`, and
|
`RDP_ACCESS_HTTP_PORT`, `RDP_ACCESS_TUNNEL_PORT`, and
|
||||||
`RDP_ACCESS_PUBLIC_HOST`. The helper deliberately limits the VRDE host to
|
`RDP_ACCESS_PUBLIC_HOST`, `RDP_ACCESS_IPV6_BIND`, and
|
||||||
|
`RDP_ACCESS_IPV6_FORWARD`. The helper deliberately limits the VRDE host to
|
||||||
Helium loopback (`127.0.0.1` or `localhost`) so an override cannot accidentally
|
Helium loopback (`127.0.0.1` or `localhost`) so an override cannot accidentally
|
||||||
turn the diagnostic server into a remote target.
|
turn the diagnostic server into a remote target.
|
||||||
|
Set `RDP_ACCESS_GUACD_LOG_LEVEL=debug` temporarily when collecting detailed
|
||||||
|
guacd/RDP negotiation diagnostics; the default is `info`.
|
||||||
|
|
||||||
|
The VM selector and the gateway profile are separate. Set the matching
|
||||||
|
`RVBOX_TEST_VBOX_*` identity variables (and `RDP_ACCESS_VRDE_PORT`) for the
|
||||||
|
VM you want; the complete identity set is listed in
|
||||||
|
[`docs/testing-vm.md`](../../docs/testing-vm.md). For two already-running VMs,
|
||||||
|
give the second gateway a distinct profile and listener ports, for example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
RDP_ACCESS_PROFILE=vm-b \
|
||||||
|
RVBOX_TEST_VBOX_HOST=helium-remote-b \
|
||||||
|
RDP_ACCESS_VRDE_PORT=3391 \
|
||||||
|
RDP_ACCESS_HTTP_PORT=5003 \
|
||||||
|
RDP_ACCESS_TUNNEL_PORT=54002 \
|
||||||
|
test/rdp-access/rdp-access up --bind 0.0.0.0 --public-host vm-b.example.net
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `helium-remote-b`, `3391`, and the public hostname with the second
|
||||||
|
fixture's recorded values, and export its matching VM/snapshot UUID variables
|
||||||
|
before running `up` or `status`. A non-`default` profile stores its verifier,
|
||||||
|
certificate, tunnel state, and logs under `.runtime/<profile>/` and uses the
|
||||||
|
Compose project `rvbox-rdp-access-<profile>`. Keep the HTTP port, public
|
||||||
|
hostname, and profile unique so IPv4/IPv6 listeners and browser sessions do
|
||||||
|
not collide. Inspect, repair, stop, or clean that instance by repeating the
|
||||||
|
same `RDP_ACCESS_PROFILE` and endpoint variables on `status`, `repair`,
|
||||||
|
`down`, or `clean`.
|
||||||
|
|
||||||
|
Profile isolation does not bypass the native controller's exclusive lease:
|
||||||
|
`test-host prepare`/`reset` must still be coordinated when VMs share one
|
||||||
|
fixture host and staging root. For VMs that are already running, the profile
|
||||||
|
and matching identity/VRDE variables are sufficient to select the endpoint.
|
||||||
|
|
||||||
|
When `--bind 0.0.0.0` is used, `up` also starts a tracked `socat` listener on
|
||||||
|
`[::]:$RDP_ACCESS_HTTP_PORT` and forwards it to the IPv4 gateway listener. This
|
||||||
|
matters when the public hostname has an AAAA record: some browsers choose IPv6
|
||||||
|
for the WebSocket even if the initial page used IPv4. The listener is recorded
|
||||||
|
under `.runtime/ipv6-forward.pid` and is removed by `down` and `clean`. Its
|
||||||
|
default bind is `::` (`RDP_ACCESS_IPV6_BIND`); set
|
||||||
|
`RDP_ACCESS_IPV6_FORWARD=never` to deliberately use IPv4 only, `always` to make
|
||||||
|
IPv6 startup a hard requirement, or leave the default `auto` for a warning and
|
||||||
|
an IPv4-only fallback when `socat` is unavailable. `status` reports
|
||||||
|
`ipv6_forward=active`, `active_external`, `inactive`, or `disabled`.
|
||||||
|
|
||||||
|
If `up` is run again while the Compose services are still running, it is
|
||||||
|
idempotent: an existing healthy tunnel is reused, and a missing tunnel is
|
||||||
|
recreated in place. `status` reports a helper-owned tunnel as
|
||||||
|
`private_tunnel=active` and a listener supplied by an external/interactive
|
||||||
|
SSH supervisor as `private_tunnel=active_external`. A missing listener is
|
||||||
|
reported as `private_tunnel=inactive`; inspect `.runtime/tunnel.log` and run
|
||||||
|
`up` again to trigger a bounded reconnect attempt.
|
||||||
|
|
||||||
|
If `up` reports that an existing gateway has a different configuration, or a
|
||||||
|
previous start left only part of the Compose stack, run `down` once and then
|
||||||
|
repeat `up`. Changing the bind address, browser host, or either port likewise
|
||||||
|
requires `down` first; refusing to mutate a live stack prevents an old browser
|
||||||
|
session from silently reaching a different endpoint.
|
||||||
|
|
||||||
|
The XML mapping intentionally uses Guacamole's `${GUAC_PASSWORD}` connection
|
||||||
|
parameter token. Guacamole resolves this to the password entered at web login;
|
||||||
|
it is not a host environment variable and must remain in the template.
|
||||||
|
|
||||||
After the interactive action, close the browser connection and remove the
|
After the interactive action, close the browser connection and remove the
|
||||||
temporary access path before releasing the fixture lease:
|
temporary access path before releasing the fixture lease:
|
||||||
@@ -74,9 +202,10 @@ test/rdp-access/rdp-access down
|
|||||||
scripts/windows/test-host reset --run-id interactive-rdp
|
scripts/windows/test-host reset --run-id interactive-rdp
|
||||||
```
|
```
|
||||||
|
|
||||||
`down` stops containers and the SSH master/tunnel but retains the one-day
|
`down` stops containers and the SSH watchdog/master/tunnel but retains the
|
||||||
certificate and password verifier for a quick restart. To remove all generated
|
one-day certificate and password verifier for a quick restart. To remove all
|
||||||
state, including the certificate and verifier:
|
generated state, including the certificate, verifier, watchdog PID, and tunnel
|
||||||
|
log:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
test/rdp-access/rdp-access clean
|
test/rdp-access/rdp-access clean
|
||||||
@@ -101,18 +230,70 @@ test/rdp-access/rdp-access logs --tail=100
|
|||||||
test/rdp-access/rdp-access url
|
test/rdp-access/rdp-access url
|
||||||
```
|
```
|
||||||
|
|
||||||
If the browser reaches Guacamole but stays on “Waiting for response”, verify
|
If the browser reaches Guacamole but stays on “Waiting for response”, run
|
||||||
that the VM is running and the private tunnel is active with `status`. This
|
`status` first. Confirm `private_tunnel=active` (or
|
||||||
helper already uses `security=rdp` and disables Guacamole's GFX extension,
|
`active_external`) and, for a public dual-stack hostname,
|
||||||
which are required by the fixture's legacy VRDE server. Do not switch the
|
`ipv6_forward=active` (or a known-good external forward). Then check the VM
|
||||||
helper to native Windows RDP: `TermService` is intentionally disabled in the
|
state and the last lines of
|
||||||
baseline. If VRDE remains unusable, stop this helper and use Guest Control for
|
`.runtime/tunnel.log`. A tunnel can be recreated without losing the Compose
|
||||||
the deterministic portion of the work; record the blocked interactive step in
|
stack by running `up` again. This helper already uses `security=rdp` and
|
||||||
the native test report.
|
disables Guacamole's GFX extension, which are required by the fixture's legacy
|
||||||
|
VRDE server. Do not switch the helper to native Windows RDP:
|
||||||
|
`TermService` is intentionally disabled in the baseline. If VRDE remains
|
||||||
|
unusable, stop this helper and use Guest Control for the deterministic portion
|
||||||
|
of the work; record the blocked interactive step in the native test report.
|
||||||
|
|
||||||
The helper requires Docker/Docker Compose, SSH access through the existing
|
If guacd has a stale worker and the VM reports that multiple connections are
|
||||||
`helium-remote` alias, and the fixture password file documented in
|
disabled, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
test/rdp-access/rdp-access repair
|
||||||
|
```
|
||||||
|
|
||||||
|
`repair` restarts only guacd, retains the VM, SSH tunnel, gateway, and IPv6
|
||||||
|
forward, and does not alter the Windows guest. Close old browser tabs and sign
|
||||||
|
in again after it completes.
|
||||||
|
|
||||||
|
For quick recovery, use this decision table:
|
||||||
|
|
||||||
|
| Need | Command | Effect |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Inspect everything | `test/rdp-access/rdp-access status` | Read-only VM, Compose, tunnel, and IPv6-forward state |
|
||||||
|
| Start or reconnect access | `test/rdp-access/rdp-access up --bind 0.0.0.0 --public-host x1.xcel.me` | Reuses healthy services; recreates only missing tunnel/forward |
|
||||||
|
| Browser says “Waiting for response” | `test/rdp-access/rdp-access status`; `test/rdp-access/rdp-access logs --tail=100`; if guacd is stale, `test/rdp-access/rdp-access repair` | Diagnoses tunnel/dual-stack issues; restarts only guacd |
|
||||||
|
| Partial stack or changed endpoint | `test/rdp-access/rdp-access down`, then `test/rdp-access/rdp-access up --bind 0.0.0.0 --public-host x1.xcel.me` | Recreates the exact project with the new settings |
|
||||||
|
| Print the current URL | `test/rdp-access/rdp-access url` | Read-only URL from the saved session |
|
||||||
|
| Stop temporary access | `test/rdp-access/rdp-access down` | Stops gateway, IPv6 forward, SSH watchdog/tunnel; retains verifier/cert |
|
||||||
|
| Reclaim generated state | `test/rdp-access/rdp-access clean` | Stops access and removes only `.runtime/` |
|
||||||
|
| Reclaim exact unused images too | `test/rdp-access/rdp-access clean --images` | Also removes owned Guacamole/nginx images; leaves shared Alpine intact |
|
||||||
|
| Return VM to clean baseline | `scripts/windows/test-host reset --run-id interactive-rdp` | Stops/ restores the exact leased VM snapshot and leaves it powered off |
|
||||||
|
|
||||||
|
The two scripts have a deliberate ownership boundary. `rdp-access` manages only
|
||||||
|
the temporary browser gateway, Guacamole/guacd containers, IPv6 forward, and
|
||||||
|
Helium SSH tunnel. `scripts/windows/test-host` manages the leased VM and its
|
||||||
|
service lifecycle. For a prepared run, the remaining VM-side management actions
|
||||||
|
are:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/windows/test-host inspect --run-id interactive-rdp # SCM/read-only client state
|
||||||
|
scripts/windows/test-host logs --run-id interactive-rdp # bounded startup/client logs
|
||||||
|
scripts/windows/test-host collect --run-id interactive-rdp # bounded artifacts
|
||||||
|
scripts/windows/test-host stop --run-id interactive-rdp # stop service and request guest shutdown
|
||||||
|
scripts/windows/test-host recover --run-id interactive-rdp # inspect an interrupted stopped run
|
||||||
|
```
|
||||||
|
|
||||||
|
`stage`, `install`, `run`, and `logoff` are also available in the
|
||||||
|
`test-host --help` command reference for a complete native-service run; they
|
||||||
|
are not required merely to use the RDP gateway. `native-test recover` and
|
||||||
|
`native-test clean` are the corresponding whole-lane recovery/cleanup actions
|
||||||
|
when the Linux server stack is part of the run.
|
||||||
|
|
||||||
|
The helper requires Docker/Docker Compose with Docker socket access for the
|
||||||
|
invoking account (`docker version` must succeed), `socat` for the optional
|
||||||
|
public dual-stack forward, SSH access through the existing `helium-remote`
|
||||||
|
alias, and the fixture password file documented in
|
||||||
[`docs/testing-vm.md`](../../docs/testing-vm.md). It does not install host
|
[`docs/testing-vm.md`](../../docs/testing-vm.md). It does not install host
|
||||||
packages, write credentials into Git, or alter VM settings. The tracked files
|
packages, write credentials into Git, or alter VM identity/settings. The
|
||||||
are Docker-only configuration and the controller script; all generated content
|
authoritative `test-host prepare` step applies only the disposable
|
||||||
is ignored beneath `.runtime/`.
|
display/sleep keepalive needed by VirtualBox VRDE; all generated content is
|
||||||
|
ignored beneath `.runtime/`.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
guacd:
|
guacd:
|
||||||
image: guacamole/guacd:1.6.0
|
image: guacamole/guacd:1.6.0
|
||||||
|
environment:
|
||||||
|
LOG_LEVEL: ${RDP_ACCESS_GUACD_LOG_LEVEL:-info}
|
||||||
|
|
||||||
guacamole:
|
guacamole:
|
||||||
image: guacamole/guacamole:1.6.0
|
image: guacamole/guacamole:1.6.0
|
||||||
|
|||||||
@@ -15,5 +15,8 @@ server {
|
|||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
proxy_send_timeout 1h;
|
||||||
|
proxy_set_header X-Accel-Buffering no;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+343
-17
@@ -5,17 +5,20 @@ set -eu
|
|||||||
|
|
||||||
helper_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
helper_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
repo_root=$(CDPATH= cd -- "$helper_dir/../.." && pwd)
|
repo_root=$(CDPATH= cd -- "$helper_dir/../.." && pwd)
|
||||||
runtime_dir=$helper_dir/.runtime
|
runtime_root=$helper_dir/.runtime
|
||||||
compose_file=$helper_dir/compose.yaml
|
compose_file=$helper_dir/compose.yaml
|
||||||
mapping_template=$helper_dir/user-mapping.xml.in
|
mapping_template=$helper_dir/user-mapping.xml.in
|
||||||
project=rvbox-rdp-access
|
|
||||||
|
|
||||||
|
: "${RDP_ACCESS_PROFILE:=default}"
|
||||||
: "${RDP_ACCESS_BIND:=127.0.0.1}"
|
: "${RDP_ACCESS_BIND:=127.0.0.1}"
|
||||||
: "${RDP_ACCESS_HTTP_PORT:=5002}"
|
: "${RDP_ACCESS_HTTP_PORT:=5002}"
|
||||||
: "${RDP_ACCESS_TUNNEL_PORT:=54001}"
|
: "${RDP_ACCESS_TUNNEL_PORT:=54001}"
|
||||||
: "${RDP_ACCESS_PUBLIC_HOST:=localhost}"
|
: "${RDP_ACCESS_PUBLIC_HOST:=localhost}"
|
||||||
: "${RDP_ACCESS_WEB_USER:=rvboxtest}"
|
: "${RDP_ACCESS_WEB_USER:=rvboxtest}"
|
||||||
: "${RDP_ACCESS_RDP_USER:=rvboxtest}"
|
: "${RDP_ACCESS_RDP_USER:=rvboxtest}"
|
||||||
|
: "${RDP_ACCESS_GUACD_LOG_LEVEL:=info}"
|
||||||
|
: "${RDP_ACCESS_IPV6_BIND:=::}"
|
||||||
|
: "${RDP_ACCESS_IPV6_FORWARD:=auto}"
|
||||||
: "${RVBOX_TEST_VBOX_HOST:=helium-remote}"
|
: "${RVBOX_TEST_VBOX_HOST:=helium-remote}"
|
||||||
: "${RDP_ACCESS_VRDE_HOST:=127.0.0.1}"
|
: "${RDP_ACCESS_VRDE_HOST:=127.0.0.1}"
|
||||||
: "${RDP_ACCESS_VRDE_PORT:=3389}"
|
: "${RDP_ACCESS_VRDE_PORT:=3389}"
|
||||||
@@ -27,13 +30,16 @@ usage: test/rdp-access/rdp-access ACTION [OPTIONS]
|
|||||||
Actions:
|
Actions:
|
||||||
up start a private VRDE SSH tunnel and self-signed HTTPS Guacamole
|
up start a private VRDE SSH tunnel and self-signed HTTPS Guacamole
|
||||||
status show gateway, tunnel, and fixture status without changing anything
|
status show gateway, tunnel, and fixture status without changing anything
|
||||||
|
repair restart only guacd to release a stale single-VRDE-slot worker
|
||||||
url print the current browser URL
|
url print the current browser URL
|
||||||
logs follow or print Compose logs (pass Docker Compose log options)
|
logs follow or print Compose logs (pass Docker Compose log options)
|
||||||
down stop containers and the private SSH tunnel; retain generated state
|
down stop containers and the private SSH watchdog/tunnel; retain generated state
|
||||||
clean run down and delete generated state; pass --images to also remove
|
clean run down and delete generated state; pass --images to also remove
|
||||||
the exact unused Guacamole/nginx images
|
the exact unused Guacamole/nginx images
|
||||||
|
|
||||||
up options:
|
up options:
|
||||||
|
--profile NAME isolated VM/gateway profile (default default; use one
|
||||||
|
distinct profile per simultaneous VM gateway)
|
||||||
--bind ADDRESS listener address (default 127.0.0.1; use 0.0.0.0 only
|
--bind ADDRESS listener address (default 127.0.0.1; use 0.0.0.0 only
|
||||||
for a temporary, deliberately public endpoint)
|
for a temporary, deliberately public endpoint)
|
||||||
--http-port PORT HTTPS listener port (default 5002)
|
--http-port PORT HTTPS listener port (default 5002)
|
||||||
@@ -43,10 +49,13 @@ up options:
|
|||||||
--reset-auth discard the saved password hash and prompt again
|
--reset-auth discard the saved password hash and prompt again
|
||||||
--web-password-stdin read the password once from stdin instead of prompting
|
--web-password-stdin read the password once from stdin instead of prompting
|
||||||
|
|
||||||
Environment equivalents: RDP_ACCESS_BIND, RDP_ACCESS_HTTP_PORT,
|
Environment equivalents: RDP_ACCESS_PROFILE, RDP_ACCESS_BIND, RDP_ACCESS_HTTP_PORT,
|
||||||
RDP_ACCESS_TUNNEL_PORT, RDP_ACCESS_PUBLIC_HOST, RDP_ACCESS_WEB_USER,
|
RDP_ACCESS_TUNNEL_PORT, RDP_ACCESS_PUBLIC_HOST, RDP_ACCESS_WEB_USER,
|
||||||
RDP_ACCESS_RDP_USER, RVBOX_TEST_VBOX_HOST, RDP_ACCESS_VRDE_HOST, and
|
RDP_ACCESS_RDP_USER, RVBOX_TEST_VBOX_HOST, RDP_ACCESS_VRDE_HOST, and
|
||||||
RDP_ACCESS_VRDE_PORT.
|
RDP_ACCESS_VRDE_PORT. In public mode, RDP_ACCESS_IPV6_BIND and
|
||||||
|
RDP_ACCESS_IPV6_FORWARD (auto, always, or never) control the reversible
|
||||||
|
IPv6-to-IPv4 listener for dual-stack hostnames. Set
|
||||||
|
RDP_ACCESS_GUACD_LOG_LEVEL=debug for temporary protocol diagnostics.
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +65,16 @@ safe_name() {
|
|||||||
case $2 in ''|*[!A-Za-z0-9.-]*) fail "$1 contains unsupported characters" ;; esac
|
case $2 in ''|*[!A-Za-z0-9.-]*) fail "$1 contains unsupported characters" ;; esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
safe_profile() {
|
||||||
|
case $2 in
|
||||||
|
[a-z0-9]* ) ;;
|
||||||
|
* ) fail "$1 must start with a lowercase alphanumeric" ;;
|
||||||
|
esac
|
||||||
|
case $2 in
|
||||||
|
*[!a-z0-9-]*) fail "$1 must contain only lowercase letters, digits, and hyphens" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
safe_port() {
|
safe_port() {
|
||||||
case $2 in ''|*[!0-9]*) fail "$1 must be a port number" ;; esac
|
case $2 in ''|*[!0-9]*) fail "$1 must be a port number" ;; esac
|
||||||
[ "$2" -ge 1024 ] && [ "$2" -le 65535 ] || fail "$1 must be between 1024 and 65535"
|
[ "$2" -ge 1024 ] && [ "$2" -le 65535 ] || fail "$1 must be between 1024 and 65535"
|
||||||
@@ -69,6 +88,7 @@ compose() {
|
|||||||
RDP_ACCESS_RUNTIME_DIR=$runtime_dir \
|
RDP_ACCESS_RUNTIME_DIR=$runtime_dir \
|
||||||
RDP_ACCESS_BIND=$RDP_ACCESS_BIND \
|
RDP_ACCESS_BIND=$RDP_ACCESS_BIND \
|
||||||
RDP_ACCESS_HTTP_PORT=$RDP_ACCESS_HTTP_PORT \
|
RDP_ACCESS_HTTP_PORT=$RDP_ACCESS_HTTP_PORT \
|
||||||
|
RDP_ACCESS_GUACD_LOG_LEVEL=$RDP_ACCESS_GUACD_LOG_LEVEL \
|
||||||
RDP_ACCESS_CERT_NAME=$RDP_ACCESS_PUBLIC_HOST \
|
RDP_ACCESS_CERT_NAME=$RDP_ACCESS_PUBLIC_HOST \
|
||||||
RDP_ACCESS_CERT_SAN=$cert_san \
|
RDP_ACCESS_CERT_SAN=$cert_san \
|
||||||
RDP_ACCESS_HOST_UID=$(id -u) \
|
RDP_ACCESS_HOST_UID=$(id -u) \
|
||||||
@@ -76,15 +96,158 @@ compose() {
|
|||||||
docker compose --project-name "$project" -f "$compose_file" "$@"
|
docker compose --project-name "$project" -f "$compose_file" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
socket_path=$runtime_dir/ssh-control.socket
|
wait_for_gateway() {
|
||||||
session_file=$runtime_dir/session.env
|
command -v curl >/dev/null 2>&1 || fail "curl is required for the Guacamole readiness check"
|
||||||
cert_name_file=$runtime_dir/cert-name
|
attempts=0
|
||||||
|
while [ "$attempts" -lt 60 ]; do
|
||||||
|
# The listener is bound on the controller, even when the public
|
||||||
|
# address is 0.0.0.0. Ignore the self-signed certificate here: this
|
||||||
|
# check is only proving that nginx can reach the Guacamole servlet.
|
||||||
|
if curl -kfsS --connect-timeout 1 --max-time 3 \
|
||||||
|
"https://127.0.0.1:$RDP_ACCESS_HTTP_PORT/guacamole/" \
|
||||||
|
>/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
attempts=$((attempts + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
stop_tunnel() {
|
stop_tunnel() {
|
||||||
|
mkdir -p "$runtime_dir"
|
||||||
|
: >"$tunnel_stop_file"
|
||||||
if [ -S "$socket_path" ]; then
|
if [ -S "$socket_path" ]; then
|
||||||
ssh -S "$socket_path" -O exit "$RVBOX_TEST_VBOX_HOST" >/dev/null 2>&1 || true
|
ssh -S "$socket_path" -O exit -o ConnectTimeout=5 "$RVBOX_TEST_VBOX_HOST" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
if [ -f "$tunnel_pid_file" ]; then
|
||||||
|
tunnel_pid=$(cat "$tunnel_pid_file" 2>/dev/null || true)
|
||||||
|
case $tunnel_pid in
|
||||||
|
''|*[!0-9]*) ;;
|
||||||
|
*)
|
||||||
|
# Never trust a stale numeric PID file. Confirm that it is
|
||||||
|
# still this helper's watchdog before signalling it.
|
||||||
|
tunnel_cmd=$(ps -p "$tunnel_pid" -o args= 2>/dev/null || true)
|
||||||
|
case $tunnel_cmd in
|
||||||
|
*rdp-tunnel-watchdog*"$tunnel_stop_file"*)
|
||||||
|
kill "$tunnel_pid" >/dev/null 2>&1 || true
|
||||||
|
# The watchdog normally exits within its two-second
|
||||||
|
# retry interval. Do not leave a stale reconnect loop
|
||||||
|
# behind, but only force the verified process.
|
||||||
|
sleep 1
|
||||||
|
tunnel_cmd=$(ps -p "$tunnel_pid" -o args= 2>/dev/null || true)
|
||||||
|
case $tunnel_cmd in
|
||||||
|
*rdp-tunnel-watchdog*"$tunnel_stop_file"*) kill -KILL "$tunnel_pid" >/dev/null 2>&1 || true ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
rm -f "$socket_path"
|
rm -f "$socket_path"
|
||||||
|
rm -f "$tunnel_pid_file" "$tunnel_stop_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
ipv6_process_matches() {
|
||||||
|
ipv6_pid=$1
|
||||||
|
ipv6_cmd=$(ps -p "$ipv6_pid" -o args= 2>/dev/null || true)
|
||||||
|
case $ipv6_cmd in
|
||||||
|
*"TCP6-LISTEN:$RDP_ACCESS_HTTP_PORT"*"TCP4:127.0.0.1:$RDP_ACCESS_HTTP_PORT"*) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
ipv6_listener_active() {
|
||||||
|
command -v ss >/dev/null 2>&1 || return 1
|
||||||
|
ss -H -ltn6 2>/dev/null | awk -v port=":$RDP_ACCESS_HTTP_PORT" \
|
||||||
|
'{ if ($4 ~ (port "$")) found=1 } END { exit !found }'
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_ipv6_forward() {
|
||||||
|
[ -f "$ipv6_pid_file" ] || return 0
|
||||||
|
ipv6_pid=$(cat "$ipv6_pid_file" 2>/dev/null || true)
|
||||||
|
case $ipv6_pid in
|
||||||
|
''|*[!0-9]*) ;;
|
||||||
|
*)
|
||||||
|
if ipv6_process_matches "$ipv6_pid"; then
|
||||||
|
kill "$ipv6_pid" >/dev/null 2>&1 || true
|
||||||
|
sleep 1
|
||||||
|
if ipv6_process_matches "$ipv6_pid"; then
|
||||||
|
kill -KILL "$ipv6_pid" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
rm -f "$ipv6_pid_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_ipv6_forward() {
|
||||||
|
[ "$RDP_ACCESS_BIND" = 0.0.0.0 ] || return 0
|
||||||
|
case $RDP_ACCESS_IPV6_FORWARD in
|
||||||
|
never) stop_ipv6_forward; return 0 ;;
|
||||||
|
auto|always) ;;
|
||||||
|
*) fail "RDP_ACCESS_IPV6_FORWARD must be auto, always, or never" ;;
|
||||||
|
esac
|
||||||
|
if ! command -v socat >/dev/null 2>&1; then
|
||||||
|
if [ "$RDP_ACCESS_IPV6_FORWARD" = always ]; then
|
||||||
|
fail "socat is required for RDP_ACCESS_IPV6_FORWARD=always"
|
||||||
|
fi
|
||||||
|
printf 'rdp-access: warning: socat unavailable; IPv6 forwarding disabled (IPv4 remains active)\n' >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ -f "$ipv6_pid_file" ] && ipv6_process_matches "$(cat "$ipv6_pid_file" 2>/dev/null || true)" && ipv6_listener_active; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if ipv6_listener_active; then
|
||||||
|
# Do not kill an unrelated listener. The operator can inspect it via
|
||||||
|
# status; down only stops a PID that this helper started and verified.
|
||||||
|
if [ "$RDP_ACCESS_IPV6_FORWARD" = always ]; then
|
||||||
|
fail "IPv6 port $RDP_ACCESS_HTTP_PORT is already occupied by an unowned listener"
|
||||||
|
fi
|
||||||
|
printf 'rdp-access: IPv6 port %s is already occupied; retaining the existing listener\n' \
|
||||||
|
"$RDP_ACCESS_HTTP_PORT" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
stop_ipv6_forward
|
||||||
|
umask 077
|
||||||
|
mkdir -p "$runtime_dir"
|
||||||
|
: >"$ipv6_log_file"
|
||||||
|
chmod 600 "$ipv6_log_file"
|
||||||
|
setsid nohup socat \
|
||||||
|
"TCP6-LISTEN:$RDP_ACCESS_HTTP_PORT,ipv6only=1,reuseaddr,fork,bind=$RDP_ACCESS_IPV6_BIND" \
|
||||||
|
"TCP4:127.0.0.1:$RDP_ACCESS_HTTP_PORT" \
|
||||||
|
</dev/null >>"$ipv6_log_file" 2>&1 &
|
||||||
|
ipv6_pid=$!
|
||||||
|
printf '%s\n' "$ipv6_pid" >"$ipv6_pid_file"
|
||||||
|
chmod 600 "$ipv6_pid_file"
|
||||||
|
attempts=0
|
||||||
|
while [ "$attempts" -lt 10 ]; do
|
||||||
|
if ipv6_process_matches "$ipv6_pid" && ipv6_listener_active; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
attempts=$((attempts + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
stop_ipv6_forward
|
||||||
|
if [ "$RDP_ACCESS_IPV6_FORWARD" = always ]; then
|
||||||
|
fail "could not start IPv6 forwarding listener; inspect $ipv6_log_file"
|
||||||
|
fi
|
||||||
|
printf 'rdp-access: warning: IPv6 forwarding could not start (IPv4 remains active); inspect %s\n' \
|
||||||
|
"$ipv6_log_file" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
ipv6_forward_status() {
|
||||||
|
if [ -f "$ipv6_pid_file" ] && ipv6_process_matches "$(cat "$ipv6_pid_file" 2>/dev/null || true)" && ipv6_listener_active; then
|
||||||
|
printf 'ipv6_forward=active\n'
|
||||||
|
printf 'ipv6_forward_supervisor=helper\n'
|
||||||
|
elif ipv6_listener_active; then
|
||||||
|
printf 'ipv6_forward=active_external\n'
|
||||||
|
printf 'ipv6_forward_supervisor=external\n'
|
||||||
|
elif [ "$RDP_ACCESS_BIND" != 0.0.0.0 ] || [ "$RDP_ACCESS_IPV6_FORWARD" = never ]; then
|
||||||
|
printf 'ipv6_forward=disabled\n'
|
||||||
|
else
|
||||||
|
printf 'ipv6_forward=inactive\n'
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
gateway_for_network() {
|
gateway_for_network() {
|
||||||
@@ -101,7 +264,9 @@ password_hash_from_terminal() {
|
|||||||
password=
|
password=
|
||||||
restore_tty=false
|
restore_tty=false
|
||||||
if [ "$password_stdin" = true ]; then
|
if [ "$password_stdin" = true ]; then
|
||||||
IFS= read -r password || fail "could not read password from stdin"
|
# Password files commonly omit a final newline. POSIX read returns
|
||||||
|
# non-zero at that EOF even after assigning the final nonempty line.
|
||||||
|
IFS= read -r password || [ -n "$password" ] || fail "could not read password from stdin"
|
||||||
else
|
else
|
||||||
[ -t 0 ] || fail "stdin is not a terminal; use --web-password-stdin"
|
[ -t 0 ] || fail "stdin is not a terminal; use --web-password-stdin"
|
||||||
printf 'Fixture password for %s: ' "$RDP_ACCESS_WEB_USER" >&2
|
printf 'Fixture password for %s: ' "$RDP_ACCESS_WEB_USER" >&2
|
||||||
@@ -150,19 +315,111 @@ render_mapping() {
|
|||||||
printf 'url=https://%s:%s/guacamole/\n' "$RDP_ACCESS_PUBLIC_HOST" "$RDP_ACCESS_HTTP_PORT" >"$session_file"
|
printf 'url=https://%s:%s/guacamole/\n' "$RDP_ACCESS_PUBLIC_HOST" "$RDP_ACCESS_HTTP_PORT" >"$session_file"
|
||||||
printf 'docker_gateway=%s\n' "$docker_gateway" >>"$session_file"
|
printf 'docker_gateway=%s\n' "$docker_gateway" >>"$session_file"
|
||||||
printf 'tunnel_port=%s\n' "$RDP_ACCESS_TUNNEL_PORT" >>"$session_file"
|
printf 'tunnel_port=%s\n' "$RDP_ACCESS_TUNNEL_PORT" >>"$session_file"
|
||||||
|
printf 'bind=%s\n' "$RDP_ACCESS_BIND" >>"$session_file"
|
||||||
|
printf 'http_port=%s\n' "$RDP_ACCESS_HTTP_PORT" >>"$session_file"
|
||||||
|
printf 'public_host=%s\n' "$RDP_ACCESS_PUBLIC_HOST" >>"$session_file"
|
||||||
|
printf 'web_user=%s\n' "$RDP_ACCESS_WEB_USER" >>"$session_file"
|
||||||
|
printf 'rdp_user=%s\n' "$RDP_ACCESS_RDP_USER" >>"$session_file"
|
||||||
chmod 600 "$session_file"
|
chmod 600 "$session_file"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session_has() {
|
||||||
|
grep -Fqx "$1=$2" "$session_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
session_matches_current() {
|
||||||
|
[ -f "$session_file" ] || return 1
|
||||||
|
session_has url "https://$RDP_ACCESS_PUBLIC_HOST:$RDP_ACCESS_HTTP_PORT/guacamole/" || return 1
|
||||||
|
session_has docker_gateway "$docker_gateway" || return 1
|
||||||
|
session_has tunnel_port "$RDP_ACCESS_TUNNEL_PORT" || return 1
|
||||||
|
session_has bind "$RDP_ACCESS_BIND" || return 1
|
||||||
|
session_has http_port "$RDP_ACCESS_HTTP_PORT" || return 1
|
||||||
|
session_has public_host "$RDP_ACCESS_PUBLIC_HOST" || return 1
|
||||||
|
session_has web_user "$RDP_ACCESS_WEB_USER" || return 1
|
||||||
|
session_has rdp_user "$RDP_ACCESS_RDP_USER"
|
||||||
|
}
|
||||||
|
|
||||||
start_tunnel() {
|
start_tunnel() {
|
||||||
stop_tunnel
|
stop_tunnel
|
||||||
ssh -M -S "$socket_path" -fN \
|
umask 077
|
||||||
|
: >"$tunnel_log_file"
|
||||||
|
chmod 600 "$tunnel_log_file"
|
||||||
|
|
||||||
|
# Keep the SSH master outside the short-lived controller process. Helium
|
||||||
|
# may drop an idle or metered route; the watchdog reconnects it and keeps
|
||||||
|
# the same local listener for Guacamole. The stop marker is deliberately
|
||||||
|
# inside .runtime so down/clean can terminate the loop deterministically.
|
||||||
|
watchdog_script='
|
||||||
|
set -eu
|
||||||
|
stop_file=$1
|
||||||
|
socket=$2
|
||||||
|
host=$3
|
||||||
|
bind=$4
|
||||||
|
tunnel_port=$5
|
||||||
|
vrde_host=$6
|
||||||
|
vrde_port=$7
|
||||||
|
while [ ! -e "$stop_file" ]; do
|
||||||
|
if [ -S "$socket" ] && ssh -S "$socket" -O check "$host" >/dev/null 2>&1; then
|
||||||
|
sleep 2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
rm -f "$socket"
|
||||||
|
ssh -M -S "$socket" -fN \
|
||||||
-o BatchMode=yes \
|
-o BatchMode=yes \
|
||||||
-o ExitOnForwardFailure=yes \
|
-o ExitOnForwardFailure=yes \
|
||||||
-o ServerAliveInterval=30 \
|
-o ServerAliveInterval=30 \
|
||||||
-o ServerAliveCountMax=3 \
|
-o ServerAliveCountMax=3 \
|
||||||
-L "$docker_gateway:$RDP_ACCESS_TUNNEL_PORT:$RDP_ACCESS_VRDE_HOST:$RDP_ACCESS_VRDE_PORT" \
|
-L "$bind:$tunnel_port:$vrde_host:$vrde_port" \
|
||||||
"$RVBOX_TEST_VBOX_HOST"
|
"$host" || true
|
||||||
ssh -S "$socket_path" -O check "$RVBOX_TEST_VBOX_HOST" >/dev/null 2>&1 || fail "private VRDE tunnel did not start"
|
sleep 2
|
||||||
|
done
|
||||||
|
'
|
||||||
|
if command -v setsid >/dev/null 2>&1; then
|
||||||
|
setsid nohup sh -c "$watchdog_script" rdp-tunnel-watchdog \
|
||||||
|
"$tunnel_stop_file" "$socket_path" "$RVBOX_TEST_VBOX_HOST" \
|
||||||
|
"$docker_gateway" "$RDP_ACCESS_TUNNEL_PORT" "$RDP_ACCESS_VRDE_HOST" \
|
||||||
|
"$RDP_ACCESS_VRDE_PORT" </dev/null >>"$tunnel_log_file" 2>&1 &
|
||||||
|
else
|
||||||
|
nohup sh -c "$watchdog_script" rdp-tunnel-watchdog \
|
||||||
|
"$tunnel_stop_file" "$socket_path" "$RVBOX_TEST_VBOX_HOST" \
|
||||||
|
"$docker_gateway" "$RDP_ACCESS_TUNNEL_PORT" "$RDP_ACCESS_VRDE_HOST" \
|
||||||
|
"$RDP_ACCESS_VRDE_PORT" </dev/null >>"$tunnel_log_file" 2>&1 &
|
||||||
|
fi
|
||||||
|
tunnel_watchdog_pid=$!
|
||||||
|
printf '%s\n' "$tunnel_watchdog_pid" >"$tunnel_pid_file"
|
||||||
|
chmod 600 "$tunnel_pid_file"
|
||||||
|
|
||||||
|
# A listener is useful only after the SSH master has authenticated and
|
||||||
|
# bound the Docker-network gateway. Give the reconnect loop a bounded
|
||||||
|
# window, then fail with a clear recovery path.
|
||||||
|
attempts=0
|
||||||
|
while [ "$attempts" -lt 20 ]; do
|
||||||
|
if [ -S "$socket_path" ] && ssh -S "$socket_path" -O check "$RVBOX_TEST_VBOX_HOST" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if command -v nc >/dev/null 2>&1 && nc -z -w 2 "$docker_gateway" "$RDP_ACCESS_TUNNEL_PORT" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
attempts=$((attempts + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
stop_tunnel
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnel_listener_active() {
|
||||||
|
[ -n "${docker_gateway-}" ] || return 1
|
||||||
|
command -v nc >/dev/null 2>&1 || return 1
|
||||||
|
nc -z -w 2 "$docker_gateway" "$RDP_ACCESS_TUNNEL_PORT" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnel_control_active() {
|
||||||
|
[ -S "$socket_path" ] || return 1
|
||||||
|
ssh -S "$socket_path" -O check "$RVBOX_TEST_VBOX_HOST" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnel_active() {
|
||||||
|
tunnel_control_active || tunnel_listener_active
|
||||||
}
|
}
|
||||||
|
|
||||||
action=${1-}
|
action=${1-}
|
||||||
@@ -175,6 +432,7 @@ password_stdin=false
|
|||||||
remove_images=false
|
remove_images=false
|
||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
|
--profile) [ "$#" -ge 2 ] || fail "--profile needs a value"; RDP_ACCESS_PROFILE=$2; shift 2 ;;
|
||||||
--bind) [ "$#" -ge 2 ] || fail "--bind needs a value"; RDP_ACCESS_BIND=$2; shift 2 ;;
|
--bind) [ "$#" -ge 2 ] || fail "--bind needs a value"; RDP_ACCESS_BIND=$2; shift 2 ;;
|
||||||
--http-port) [ "$#" -ge 2 ] || fail "--http-port needs a value"; RDP_ACCESS_HTTP_PORT=$2; shift 2 ;;
|
--http-port) [ "$#" -ge 2 ] || fail "--http-port needs a value"; RDP_ACCESS_HTTP_PORT=$2; shift 2 ;;
|
||||||
--tunnel-port) [ "$#" -ge 2 ] || fail "--tunnel-port needs a value"; RDP_ACCESS_TUNNEL_PORT=$2; shift 2 ;;
|
--tunnel-port) [ "$#" -ge 2 ] || fail "--tunnel-port needs a value"; RDP_ACCESS_TUNNEL_PORT=$2; shift 2 ;;
|
||||||
@@ -188,6 +446,26 @@ while [ "$#" -gt 0 ]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
safe_profile RDP_ACCESS_PROFILE "$RDP_ACCESS_PROFILE"
|
||||||
|
profile=$RDP_ACCESS_PROFILE
|
||||||
|
if [ "$profile" = default ]; then
|
||||||
|
runtime_dir=$runtime_root
|
||||||
|
project=rvbox-rdp-access
|
||||||
|
else
|
||||||
|
runtime_dir=$runtime_root/$profile
|
||||||
|
project=rvbox-rdp-access-$profile
|
||||||
|
fi
|
||||||
|
[ ! -L "$runtime_root" ] || fail "runtime root must not be a symlink"
|
||||||
|
[ ! -L "$runtime_dir" ] || fail "runtime directory must not be a symlink"
|
||||||
|
socket_path=$runtime_dir/ssh-control.socket
|
||||||
|
session_file=$runtime_dir/session.env
|
||||||
|
cert_name_file=$runtime_dir/cert-name
|
||||||
|
tunnel_stop_file=$runtime_dir/tunnel.stop
|
||||||
|
tunnel_pid_file=$runtime_dir/tunnel-watchdog.pid
|
||||||
|
tunnel_log_file=$runtime_dir/tunnel.log
|
||||||
|
ipv6_pid_file=$runtime_dir/ipv6-forward.pid
|
||||||
|
ipv6_log_file=$runtime_dir/ipv6-forward.log
|
||||||
|
|
||||||
safe_bind "$RDP_ACCESS_BIND"
|
safe_bind "$RDP_ACCESS_BIND"
|
||||||
safe_port RDP_ACCESS_HTTP_PORT "$RDP_ACCESS_HTTP_PORT"
|
safe_port RDP_ACCESS_HTTP_PORT "$RDP_ACCESS_HTTP_PORT"
|
||||||
safe_port RDP_ACCESS_TUNNEL_PORT "$RDP_ACCESS_TUNNEL_PORT"
|
safe_port RDP_ACCESS_TUNNEL_PORT "$RDP_ACCESS_TUNNEL_PORT"
|
||||||
@@ -198,6 +476,8 @@ safe_name RDP_ACCESS_RDP_USER "$RDP_ACCESS_RDP_USER"
|
|||||||
safe_name RVBOX_TEST_VBOX_HOST "$RVBOX_TEST_VBOX_HOST"
|
safe_name RVBOX_TEST_VBOX_HOST "$RVBOX_TEST_VBOX_HOST"
|
||||||
case $RDP_ACCESS_VRDE_HOST in 127.0.0.1|localhost) ;; *) fail "RDP_ACCESS_VRDE_HOST must be 127.0.0.1 or localhost" ;; esac
|
case $RDP_ACCESS_VRDE_HOST in 127.0.0.1|localhost) ;; *) fail "RDP_ACCESS_VRDE_HOST must be 127.0.0.1 or localhost" ;; esac
|
||||||
safe_port RDP_ACCESS_VRDE_PORT "$RDP_ACCESS_VRDE_PORT"
|
safe_port RDP_ACCESS_VRDE_PORT "$RDP_ACCESS_VRDE_PORT"
|
||||||
|
case $RDP_ACCESS_IPV6_BIND in ::|::1) ;; *) fail "RDP_ACCESS_IPV6_BIND must be :: or ::1" ;; esac
|
||||||
|
case $RDP_ACCESS_IPV6_FORWARD in auto|always|never) ;; *) fail "RDP_ACCESS_IPV6_FORWARD must be auto, always, or never" ;; esac
|
||||||
|
|
||||||
case $RDP_ACCESS_PUBLIC_HOST in
|
case $RDP_ACCESS_PUBLIC_HOST in
|
||||||
*[!0-9.]* ) cert_san="DNS:$RDP_ACCESS_PUBLIC_HOST" ;;
|
*[!0-9.]* ) cert_san="DNS:$RDP_ACCESS_PUBLIC_HOST" ;;
|
||||||
@@ -218,7 +498,24 @@ case $action in
|
|||||||
docker compose version >/dev/null
|
docker compose version >/dev/null
|
||||||
assert_fixture_running
|
assert_fixture_running
|
||||||
if compose ps -q | grep -q .; then
|
if compose ps -q | grep -q .; then
|
||||||
fail "gateway already exists; use status or down first"
|
# A short-lived controller (or a dropped SSH route) can leave the
|
||||||
|
# Compose stack running after its tunnel has disappeared. Reuse
|
||||||
|
# that stack and repair only the private forwarding path instead
|
||||||
|
# of forcing the operator to tear down a usable Guacamole session.
|
||||||
|
mkdir -p "$runtime_dir"
|
||||||
|
docker_gateway=$(gateway_for_network) || fail "could not determine private Docker gateway"
|
||||||
|
session_matches_current || fail "gateway already exists with a different configuration; run down first, then run up with the desired options"
|
||||||
|
if tunnel_active; then
|
||||||
|
printf 'Guacamole stack and private VRDE tunnel are already active.\n'
|
||||||
|
elif start_tunnel; then
|
||||||
|
printf 'Existing Guacamole stack reused; private VRDE tunnel restored.\n'
|
||||||
|
else
|
||||||
|
fail "existing Guacamole stack was found but the private VRDE tunnel could not be restored; inspect $tunnel_log_file"
|
||||||
|
fi
|
||||||
|
wait_for_gateway || fail "Guacamole stack is running but its HTTPS endpoint did not become ready; inspect Compose logs"
|
||||||
|
start_ipv6_forward
|
||||||
|
if [ -f "$session_file" ]; then sed -n '1p' "$session_file"; fi
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
mkdir -p "$runtime_dir"
|
mkdir -p "$runtime_dir"
|
||||||
compose up -d guacd
|
compose up -d guacd
|
||||||
@@ -238,19 +535,43 @@ case $action in
|
|||||||
compose down --remove-orphans
|
compose down --remove-orphans
|
||||||
fail "could not start Guacamole gateway"
|
fail "could not start Guacamole gateway"
|
||||||
fi
|
fi
|
||||||
|
if ! wait_for_gateway; then
|
||||||
|
fail "Guacamole gateway started but its HTTPS endpoint did not become ready; inspect Compose logs"
|
||||||
|
fi
|
||||||
|
start_ipv6_forward
|
||||||
printf 'Guacamole is ready at https://%s:%s/guacamole/\n' "$RDP_ACCESS_PUBLIC_HOST" "$RDP_ACCESS_HTTP_PORT"
|
printf 'Guacamole is ready at https://%s:%s/guacamole/\n' "$RDP_ACCESS_PUBLIC_HOST" "$RDP_ACCESS_HTTP_PORT"
|
||||||
printf 'Accept the self-signed certificate warning, then sign in as %s with the fixture password.\n' "$RDP_ACCESS_WEB_USER"
|
printf 'Accept the self-signed certificate warning, then sign in as %s with the fixture password.\n' "$RDP_ACCESS_WEB_USER"
|
||||||
;;
|
;;
|
||||||
status)
|
status)
|
||||||
[ "$#" -eq 0 ] || { usage >&2; fail "status accepts no options"; }
|
[ "$#" -eq 0 ] || { usage >&2; fail "status accepts no options"; }
|
||||||
|
printf 'profile=%s project=%s runtime=%s\n' "$profile" "$project" "$runtime_dir"
|
||||||
"$repo_root/scripts/windows/test-host" status || true
|
"$repo_root/scripts/windows/test-host" status || true
|
||||||
if [ -f "$session_file" ]; then sed -n '1p' "$session_file"; fi
|
if [ -f "$session_file" ]; then sed -n '1p' "$session_file"; fi
|
||||||
compose ps
|
compose ps
|
||||||
if [ -S "$socket_path" ] && ssh -S "$socket_path" -O check "$RVBOX_TEST_VBOX_HOST" >/dev/null 2>&1; then
|
docker_gateway=$(gateway_for_network 2>/dev/null || true)
|
||||||
|
if tunnel_control_active; then
|
||||||
printf 'private_tunnel=active\n'
|
printf 'private_tunnel=active\n'
|
||||||
|
printf 'private_tunnel_supervisor=helper\n'
|
||||||
|
elif tunnel_listener_active; then
|
||||||
|
# A tunnel started by an interactive shell or another supervisor
|
||||||
|
# has no control socket owned by this helper, but it is still a
|
||||||
|
# valid path when the Docker listener is reachable.
|
||||||
|
printf 'private_tunnel=active_external\n'
|
||||||
|
printf 'private_tunnel_supervisor=external\n'
|
||||||
else
|
else
|
||||||
printf 'private_tunnel=inactive\n'
|
printf 'private_tunnel=inactive\n'
|
||||||
fi
|
fi
|
||||||
|
ipv6_forward_status
|
||||||
|
;;
|
||||||
|
repair)
|
||||||
|
[ "$#" -eq 0 ] || { usage >&2; fail "repair accepts no options"; }
|
||||||
|
docker version >/dev/null
|
||||||
|
docker compose version >/dev/null
|
||||||
|
assert_fixture_running
|
||||||
|
[ -n "$(compose ps -q guacd 2>/dev/null || true)" ] || fail "Guacamole stack is not running; run up first"
|
||||||
|
compose restart guacd >/dev/null
|
||||||
|
wait_for_gateway || fail "Guacamole gateway did not become ready after guacd repair"
|
||||||
|
printf 'guacd restarted; stale VRDE workers released. Close any old browser tab and sign in again.\n'
|
||||||
;;
|
;;
|
||||||
url)
|
url)
|
||||||
[ "$#" -eq 0 ] || { usage >&2; fail "url accepts no options"; }
|
[ "$#" -eq 0 ] || { usage >&2; fail "url accepts no options"; }
|
||||||
@@ -262,15 +583,20 @@ case $action in
|
|||||||
;;
|
;;
|
||||||
down)
|
down)
|
||||||
[ "$#" -eq 0 ] || { usage >&2; fail "down accepts no options"; }
|
[ "$#" -eq 0 ] || { usage >&2; fail "down accepts no options"; }
|
||||||
|
stop_ipv6_forward
|
||||||
stop_tunnel
|
stop_tunnel
|
||||||
compose down --remove-orphans || true
|
compose down --remove-orphans || true
|
||||||
printf 'Temporary gateway and private tunnel stopped; generated certificate and password hash retained in %s.\n' "$runtime_dir"
|
printf 'Temporary gateway and private tunnel stopped; generated certificate and password hash retained in %s.\n' "$runtime_dir"
|
||||||
;;
|
;;
|
||||||
clean)
|
clean)
|
||||||
[ "$#" -eq 0 ] || { usage >&2; fail "clean accepts only --images"; }
|
[ "$#" -eq 0 ] || { usage >&2; fail "clean accepts only --images"; }
|
||||||
|
stop_ipv6_forward
|
||||||
stop_tunnel
|
stop_tunnel
|
||||||
compose down --remove-orphans || true
|
compose down --remove-orphans || true
|
||||||
case $runtime_dir in "$helper_dir"/.runtime) rm -rf "$runtime_dir" ;; *) fail "unsafe runtime path" ;; esac
|
case "$runtime_dir" in
|
||||||
|
"$runtime_root"|"$runtime_root/$profile") rm -rf "$runtime_dir" ;;
|
||||||
|
*) fail "unsafe runtime path" ;;
|
||||||
|
esac
|
||||||
if [ "$remove_images" = true ]; then
|
if [ "$remove_images" = true ]; then
|
||||||
docker image rm guacamole/guacamole:1.6.0 guacamole/guacd:1.6.0 nginx:1.27-alpine >/dev/null 2>&1 || true
|
docker image rm guacamole/guacamole:1.6.0 guacamole/guacd:1.6.0 nginx:1.27-alpine >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<param name="enable-wallpaper">false</param>
|
<param name="enable-wallpaper">false</param>
|
||||||
<param name="enable-theming">false</param>
|
<param name="enable-theming">false</param>
|
||||||
<param name="enable-font-smoothing">false</param>
|
<param name="enable-font-smoothing">false</param>
|
||||||
|
<param name="color-depth">24</param>
|
||||||
<param name="disable-gfx">true</param>
|
<param name="disable-gfx">true</param>
|
||||||
</connection>
|
</connection>
|
||||||
</authorize>
|
</authorize>
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ func TestNativeFixtureAssets_HP_HARNESS_20(t *testing.T) {
|
|||||||
"ACTIVE_USER_ELEVATED,ACTIVE_SYSTEM",
|
"ACTIVE_USER_ELEVATED,ACTIVE_SYSTEM",
|
||||||
"assert_context local-service no local-service",
|
"assert_context local-service no local-service",
|
||||||
"assert_stdin_close",
|
"assert_stdin_close",
|
||||||
|
"assert_signal_term",
|
||||||
|
"rvc kill TERM \"$client_id\" \"$issue\"",
|
||||||
|
"assert_server_restart_reconnect",
|
||||||
|
"compose restart server",
|
||||||
"rvc append \"$client_id\" \"$issue\" RVBOX_NATIVE_INPUT",
|
"rvc append \"$client_id\" \"$issue\" RVBOX_NATIVE_INPUT",
|
||||||
"rvc close-stdin \"$client_id\" \"$issue\"",
|
"rvc close-stdin \"$client_id\" \"$issue\"",
|
||||||
"clean --purge --yes",
|
"clean --purge --yes",
|
||||||
@@ -40,13 +44,25 @@ func TestNativeFixtureAssets_HP_HARNESS_20(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
testHost := read("scripts/windows/test-host")
|
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"} {
|
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", "reset-force-poweroff", "$run_root/.accelerated-stage.XXXXXX", "service-startup.log", "test-logs/rvbox.log"} {
|
||||||
if !strings.Contains(testHost, required) {
|
if !strings.Contains(testHost, required) {
|
||||||
t.Fatalf("native test-host is missing compressed transfer contract %q", required)
|
t.Fatalf("native test-host is missing compressed transfer contract %q", required)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
rdpAccess := read("test/rdp-access/rdp-access")
|
||||||
|
for _, required := range []string{"RDP_ACCESS_PROFILE", "--profile", "rvbox-rdp-access-$profile", "runtime_root", "ipv6_forward_status", "compose restart guacd", "compose down --remove-orphans"} {
|
||||||
|
if !strings.Contains(rdpAccess, required) {
|
||||||
|
t.Fatalf("RDP helper is missing isolated lifecycle contract %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rdpReadme := read("test/rdp-access/README.md")
|
||||||
|
for _, required := range []string{"Current fixture quick start", "RDP_ACCESS_PROFILE=vm-b", "Partial stack or changed endpoint", "Inspect, repair, stop, or clean that instance"} {
|
||||||
|
if !strings.Contains(rdpReadme, required) {
|
||||||
|
t.Fatalf("RDP helper documentation is missing %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
compose := read("test/linux-server/compose.yaml")
|
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"} {
|
for _, required := range []string{"../../bin/rvbox-server", "nginx:1.27-alpine", "rvbox.native.run_id", "RVBOX_NATIVE_RUNTIME_DIR", "certgen", "networks: [native]"} {
|
||||||
if !strings.Contains(compose, required) {
|
if !strings.Contains(compose, required) {
|
||||||
t.Fatalf("native Compose fixture is missing %q", required)
|
t.Fatalf("native Compose fixture is missing %q", required)
|
||||||
}
|
}
|
||||||
@@ -56,6 +72,16 @@ func TestNativeFixtureAssets_HP_HARNESS_20(t *testing.T) {
|
|||||||
if !strings.Contains(defaults, "//go:build !rvbox_native_test") || !strings.Contains(fixture, "//go:build rvbox_native_test") {
|
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")
|
t.Fatal("fixture-only context faults are not separated from release builds")
|
||||||
}
|
}
|
||||||
|
service := read("cmd/rvbox/service_windows.go")
|
||||||
|
if !strings.Contains(service, "applyServiceDiagnosticsACL") || !strings.Contains(service, "D:P(A;;FA;;;SY)(A;;FA;;;BA)") {
|
||||||
|
t.Fatal("Windows service diagnostics are not readable by administrators")
|
||||||
|
}
|
||||||
|
testing := read("docs/testing.md")
|
||||||
|
for _, required := range []string{"server-process\nrestart", "new reconciled WSS session", "`logs` is the narrow read-only"} {
|
||||||
|
if !strings.Contains(testing, required) {
|
||||||
|
t.Fatalf("native workflow documentation is missing %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestProductionComposeAssets_HP_OPS_01 guards the deployment properties that
|
// TestProductionComposeAssets_HP_OPS_01 guards the deployment properties that
|
||||||
@@ -77,6 +103,7 @@ func TestProductionComposeAssets_HP_OPS_01(t *testing.T) {
|
|||||||
"RVBOX_SERVER_CONFIG:-./server.toml",
|
"RVBOX_SERVER_CONFIG:-./server.toml",
|
||||||
"RVBOX_HTTPS_BIND:-0.0.0.0",
|
"RVBOX_HTTPS_BIND:-0.0.0.0",
|
||||||
"nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10",
|
"nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10",
|
||||||
|
"user: \"65532:65532\"",
|
||||||
"nofile:", "soft: 65536", "hard: 65536",
|
"nofile:", "soft: 65536", "hard: 65536",
|
||||||
"condition: service_completed_successfully",
|
"condition: service_completed_successfully",
|
||||||
"condition: service_healthy",
|
"condition: service_healthy",
|
||||||
@@ -87,7 +114,7 @@ func TestProductionComposeAssets_HP_OPS_01(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
config := read("deploy/production/server.toml.example")
|
config := read("deploy/production/server.toml.example")
|
||||||
for _, required := range []string{"agent_listen = \"0.0.0.0:6899\"", "listen = \"0.0.0.0:6901\"", "enabled = false"} {
|
for _, required := range []string{"agent_listen = \"0.0.0.0:6899\"", "listen = \"0.0.0.0:6901\"", "enabled = false", "log_file = \"/var/lib/rvbox-server/logs/rvbox-server.log\"", "log_max_bytes = 10485760", "log_max_files = 5"} {
|
||||||
if !strings.Contains(config, required) {
|
if !strings.Contains(config, required) {
|
||||||
t.Fatalf("production server example is missing %q", required)
|
t.Fatalf("production server example is missing %q", required)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Command releaseicon creates the small deterministic PNG used only by the
|
||||||
|
// Windows release resource generator. Keeping the source rather than a binary
|
||||||
|
// asset makes the icon reproducible in the pinned toolchain.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
const canvasSize = 256
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
output := flag.String("out", "", "absolute PNG output path")
|
||||||
|
flag.Parse()
|
||||||
|
if *output == "" || !filepath.IsAbs(*output) {
|
||||||
|
panic("--out must be an absolute path")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(*output), 0o700); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
file, err := os.OpenFile(*output, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
image := image.NewNRGBA(image.Rect(0, 0, canvasSize, canvasSize))
|
||||||
|
fill(image, color.NRGBA{R: 15, G: 28, B: 48, A: 255})
|
||||||
|
fillRoundedRect(image, 18, 18, 238, 238, 42, color.NRGBA{R: 28, G: 56, B: 92, A: 255})
|
||||||
|
// A compact outlined box and terminal prompt remain recognizable at the
|
||||||
|
// 16-pixel size Windows Explorer generates from this source image.
|
||||||
|
strokeRect(image, 57, 62, 199, 194, 12, color.NRGBA{R: 54, G: 211, B: 189, A: 255})
|
||||||
|
fillRect(image, 79, 105, 96, 121, color.NRGBA{R: 240, G: 249, B: 255, A: 255})
|
||||||
|
fillRect(image, 95, 121, 112, 137, color.NRGBA{R: 240, G: 249, B: 255, A: 255})
|
||||||
|
fillRect(image, 79, 137, 96, 153, color.NRGBA{R: 240, G: 249, B: 255, A: 255})
|
||||||
|
fillRect(image, 126, 150, 176, 166, color.NRGBA{R: 240, G: 249, B: 255, A: 255})
|
||||||
|
if err := png.Encode(file, image); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fill(image *image.NRGBA, value color.NRGBA) {
|
||||||
|
for y := 0; y < canvasSize; y++ {
|
||||||
|
for x := 0; x < canvasSize; x++ {
|
||||||
|
image.SetNRGBA(x, y, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillRoundedRect(image *image.NRGBA, left, top, right, bottom, radius int, value color.NRGBA) {
|
||||||
|
for y := top; y < bottom; y++ {
|
||||||
|
for x := left; x < right; x++ {
|
||||||
|
dx, dy := 0, 0
|
||||||
|
if x < left+radius {
|
||||||
|
dx = left + radius - x
|
||||||
|
} else if x >= right-radius {
|
||||||
|
dx = x - (right - radius - 1)
|
||||||
|
}
|
||||||
|
if y < top+radius {
|
||||||
|
dy = top + radius - y
|
||||||
|
} else if y >= bottom-radius {
|
||||||
|
dy = y - (bottom - radius - 1)
|
||||||
|
}
|
||||||
|
if dx*dx+dy*dy <= radius*radius {
|
||||||
|
image.SetNRGBA(x, y, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func strokeRect(image *image.NRGBA, left, top, right, bottom, width int, value color.NRGBA) {
|
||||||
|
fillRect(image, left, top, right, top+width, value)
|
||||||
|
fillRect(image, left, bottom-width, right, bottom, value)
|
||||||
|
fillRect(image, left, top, left+width, bottom, value)
|
||||||
|
fillRect(image, right-width, top, right, bottom, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillRect(image *image.NRGBA, left, top, right, bottom int, value color.NRGBA) {
|
||||||
|
for y := top; y < bottom; y++ {
|
||||||
|
for x := left; x < right; x++ {
|
||||||
|
image.SetNRGBA(x, y, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user