Compare commits

...
24 Commits
Author SHA1 Message Date
cabbage 620f882fe2 feat: isolate simultaneous RDP fixture profiles 2026-09-14 07:16:39 +00:00
cabbage 9a2b0fd3c1 docs: clarify RDP recovery and prerequisites 2026-09-14 07:08:41 +00:00
cabbage e3aadd8dd6 docs: make RDP fixture management self-service 2026-09-14 07:07:23 +00:00
cabbage a68729def0 fix: fail closed on unowned IPv6 listener 2026-09-14 06:53:49 +00:00
cabbage 3fdd0e2774 feat: add Guacamole stale-session repair action 2026-09-14 06:50:32 +00:00
cabbage cbd3f40523 fix: recognize owned Guacamole IPv6 forward 2026-09-14 06:46:21 +00:00
cabbage 102a5486f4 fix: make Guacamole fixture dual-stack and keep VM display awake 2026-09-14 06:31:07 +00:00
cabbage d01bb5a9a7 test: wait for Guacamole readiness 2026-09-14 04:34:18 +00:00
cabbage 38e6ca5eed fix: keep Guacamole VRDE tunnel alive 2026-09-14 04:02:32 +00:00
cabbage 6882a6808a feat: add interactive Windows installer 2026-09-11 10:10:48 +00:00
cabbage 9e181b81dc test: cover daemon telemetry publication 2026-09-11 09:43:24 +00:00
cabbage c5e9bc8b35 test: cover cursor decoding and script integrity metrics 2026-09-11 09:39:33 +00:00
cabbage 0e18af02bb feat: publish durable queue and spool gauges 2026-09-11 09:28:54 +00:00
cabbage ea8a4d8846 build: embed Windows release resources 2026-09-11 09:24:32 +00:00
cabbage 6c35c1a1d3 build: publish linux arm64 release artifacts 2026-09-11 09:18:29 +00:00
cabbage 15b2c4f9d3 fix: map release staging into toolchain workspace 2026-09-11 09:16:58 +00:00
cabbage cc31f6cb67 build: add reproducible release bundles 2026-09-11 09:13:36 +00:00
cabbage 2cf563d88e feat: add bounded operational metrics 2026-09-11 09:09:52 +00:00
cabbage 46185f1f6c test: cover stdin dispatch ordering 2026-09-11 08:58:12 +00:00
cabbage 684981c235 fix: order command controls after dispatch 2026-09-11 08:56:00 +00:00
cabbage f6f900e597 test: fuzz bounded protocol parsers 2026-09-11 08:21:24 +00:00
cabbage 418681d38e fix: run production server without root 2026-09-11 08:13:30 +00:00
cabbage f86abcecb5 test: prove native reconnect recovery 2026-09-11 08:08:32 +00:00
cabbage e79f882993 fix: fence reconciliation admission race 2026-09-11 07:36:11 +00:00
59 changed files with 2832 additions and 162 deletions
+52 -4
View File
@@ -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))
}
+34
View File
@@ -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)
}
}
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"fmt"
"strconv"
"strings"
"github.com/rvbox/rvbox/internal/config"
)
const (
defaultInstallerStateDir = `C:\ProgramData\RVBox\data`
// Public is a pre-existing directory usable by the active user, LocalSystem,
// and LocalService fallback. Durable RVBox state stays separately protected.
defaultInstallerWorkDir = `C:\Users\Public`
)
// packagedClientConfig creates the deliberately small first-install TOML. The
// strict production decoder validates it before an elevated installer writes
// anything, so the SCM service never starts from a hand-built invalid file.
func packagedClientConfig(serverURL, clientID string) ([]byte, error) {
serverURL = strings.TrimSpace(serverURL)
clientID = installerClientID(clientID)
if serverURL == "" {
return nil, fmt.Errorf("this RVBox package has no bootstrap server URL")
}
content := fmt.Sprintf("# Generated by the RVBox installer. Edit only through a deliberate reconfiguration.\n[client]\nserver_url = %s\nstate_dir = %s\nclient_id = %s\ndaemon_cwd = %s\n\n[observability]\n# Disabled by default; enable a loopback listener only when diagnostics are needed.\nlisten = \"\"\nlog_file = \"\"\n", strconv.Quote(serverURL), strconv.Quote(defaultInstallerStateDir), strconv.Quote(clientID), strconv.Quote(defaultInstallerWorkDir))
if _, err := config.DecodeClient([]byte(content), config.ClientOptions{Platform: config.PlatformWindows}); err != nil {
return nil, fmt.Errorf("validate packaged client configuration: %w", err)
}
return []byte(content), nil
}
func installerClientID(hostname string) string {
hostname = strings.TrimSpace(hostname)
var result strings.Builder
for _, value := range []byte(hostname) {
if value >= 0x21 && value <= 0x7e {
result.WriteByte(value)
} else {
result.WriteByte('-')
}
}
value := strings.Trim(result.String(), "-")
if value == "" {
value = "rvbox-client"
}
if len(value) > 128 {
value = value[:128]
}
return value
}
+16
View File
@@ -0,0 +1,16 @@
//go:build !windows
package main
import (
"errors"
"io"
)
func runInteractiveInstaller(io.Writer, io.Writer) error {
return errors.New("the v1 interactive installer is implemented for Windows only")
}
func installPackagedDefault(io.Writer) error {
return errors.New("the v1 packaged installer is implemented for Windows only")
}
+207
View File
@@ -0,0 +1,207 @@
//go:build windows
package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"unsafe"
"github.com/rvbox/rvbox/internal/client/windowsservice"
"golang.org/x/sys/windows"
)
const (
installerInfoIcon = 0x00000040
installerErrorIcon = 0x00000010
)
var (
installerUser32 = syscall.NewLazyDLL("user32.dll")
installerMessageBox = installerUser32.NewProc("MessageBoxW")
)
// runInteractiveInstaller is the double-click entry point. It does no
// machine-wide mutation itself: ShellExecute's runas verb causes Windows to
// show the standard UAC consent prompt before the elevated child installs.
func runInteractiveInstaller(_ io.Writer, _ io.Writer) error {
executable, err := os.Executable()
if err != nil {
showInstallerMessage("RVBox setup could not locate its executable.", installerErrorIcon)
return err
}
file, err := windows.UTF16PtrFromString(executable)
if err != nil {
return err
}
arguments, err := windows.UTF16PtrFromString("--install-default")
if err != nil {
return err
}
if err := windows.ShellExecute(0, installerUTF16("runas"), file, arguments, nil, windows.SW_SHOWNORMAL); err != nil {
showInstallerMessage("RVBox setup was cancelled or could not obtain administrator approval.\n\nNo changes were made.", installerErrorIcon)
return fmt.Errorf("request installer elevation: %w", err)
}
return nil
}
// installPackagedDefault executes only in the UAC-elevated child. It preserves
// a pre-existing operator configuration, atomically updates the executable,
// installs/updates the SCM service, and starts it.
func installPackagedDefault(_ io.Writer) error {
err := installPackagedDefaultFiles()
if err != nil {
showInstallerMessage("RVBox could not be installed.\n\n"+err.Error()+"\n\nNo existing configuration was replaced.", installerErrorIcon)
return err
}
showInstallerMessage("RVBox is installed and its Windows service is starting.\n\nThe notification-area icon starts automatically at the next sign-in.", installerInfoIcon)
return nil
}
func installPackagedDefaultFiles() error {
if strings.TrimSpace(bootstrapServerURL) == "" {
return errors.New("this RVBox package was built without a bootstrap server URL")
}
source, err := os.Executable()
if err != nil {
return fmt.Errorf("locate installer executable: %w", err)
}
programFiles := os.Getenv("ProgramFiles")
if programFiles == "" {
programFiles = `C:\Program Files`
}
programData := os.Getenv("ProgramData")
if programData == "" {
programData = `C:\ProgramData`
}
targetDirectory := filepath.Join(programFiles, "RVBox")
targetExecutable := filepath.Join(targetDirectory, "rvbox.exe")
configDirectory := filepath.Join(programData, "RVBox")
configPath := filepath.Join(configDirectory, "client.toml")
// A running service can keep the prior executable open. Stop it before the
// atomic replacement; a missing service is already a successful first run.
if err := windowsservice.Stop(30); err != nil {
return fmt.Errorf("stop existing RVBox service: %w", err)
}
if err := os.MkdirAll(targetDirectory, 0o755); err != nil {
return fmt.Errorf("create installation directory: %w", err)
}
if !sameWindowsPath(source, targetExecutable) {
if err := copyInstallerExecutable(source, targetExecutable); err != nil {
return err
}
}
if err := os.MkdirAll(configDirectory, 0o700); err != nil {
return fmt.Errorf("create configuration directory: %w", err)
}
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
hostname, hostnameErr := os.Hostname()
if hostnameErr != nil {
hostname = "rvbox-client"
}
content, configErr := packagedClientConfig(bootstrapServerURL, hostname)
if configErr != nil {
return configErr
}
if err := writeMachineConfig(configPath, content); err != nil {
return err
}
} else if err != nil {
return fmt.Errorf("inspect existing configuration: %w", err)
}
if err := windowsservice.Install(windowsservice.InstallSpec{ExecutablePath: targetExecutable, ConfigPath: configPath, Startup: windowsservice.StartupAutomatic}); err != nil {
return fmt.Errorf("install and start RVBox service: %w", err)
}
return nil
}
func copyInstallerExecutable(source, target string) error {
input, err := os.Open(source)
if err != nil {
return fmt.Errorf("open installer executable: %w", err)
}
defer input.Close()
temporary := target + ".new"
_ = os.Remove(temporary)
output, err := os.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o755)
if err != nil {
return fmt.Errorf("create replacement executable: %w", err)
}
_, copyErr := io.Copy(output, input)
if syncErr := output.Sync(); copyErr == nil {
copyErr = syncErr
}
if closeErr := output.Close(); copyErr == nil {
copyErr = closeErr
}
if copyErr != nil {
_ = os.Remove(temporary)
return fmt.Errorf("copy installer executable: %w", copyErr)
}
from, fromErr := windows.UTF16PtrFromString(temporary)
to, toErr := windows.UTF16PtrFromString(target)
if fromErr != nil || toErr != nil {
_ = os.Remove(temporary)
return errors.New("encode replacement executable path")
}
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH); err != nil {
_ = os.Remove(temporary)
return fmt.Errorf("activate replacement executable: %w", err)
}
return nil
}
func writeMachineConfig(path string, content []byte) error {
temporary := path + ".new"
_ = os.Remove(temporary)
if err := os.WriteFile(temporary, content, 0o600); err != nil {
return fmt.Errorf("write default configuration: %w", err)
}
from, fromErr := windows.UTF16PtrFromString(temporary)
to, toErr := windows.UTF16PtrFromString(path)
if fromErr != nil || toErr != nil {
_ = os.Remove(temporary)
return errors.New("encode configuration path")
}
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH); err != nil {
_ = os.Remove(temporary)
return fmt.Errorf("activate default configuration: %w", err)
}
return protectMachineConfig(path)
}
func protectMachineConfig(path string) error {
descriptor, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;SY)(A;;FA;;;BA)")
if err != nil {
return err
}
dacl, _, err := descriptor.DACL()
if err != nil {
return err
}
if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil {
return fmt.Errorf("protect generated configuration: %w", err)
}
return nil
}
func sameWindowsPath(first, second string) bool {
return strings.EqualFold(filepath.Clean(first), filepath.Clean(second))
}
func installerUTF16(value string) *uint16 {
encoded, _ := windows.UTF16PtrFromString(value)
return encoded
}
func showInstallerMessage(message string, icon uintptr) {
caption := installerUTF16("RVBox Setup")
text := installerUTF16(message)
_, _, _ = installerMessageBox.Call(0, uintptr(unsafe.Pointer(text)), uintptr(unsafe.Pointer(caption)), icon)
}
+74 -10
View File
@@ -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()
go func() { if configured.Observability.Listen != "" {
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 { go func() {
_, _ = fmt.Fprintf(diagnostics, "rvbox client observability endpoint stopped: %v\n", serveErr) if serveErr := health.Serve(ctx, configured.Observability.Listen, observability.Paths{Liveness: configured.Observability.LivenessPath, Readiness: configured.Observability.ReadinessPath, Metrics: configured.Observability.MetricsPath}); serveErr != nil && ctx.Err() == nil && diagnostics != nil {
} _, _ = fmt.Fprintf(diagnostics, "rvbox client observability endpoint stopped: %v\n", serveErr)
}() }
}()
}
state, err := spool.Open(ctx, spool.Options{DataDir: configured.Client.StateDir, BusyTimeout: 5 * time.Second, TombstoneLimit: configured.Storage.TombstoneMaxEntries, MaxScriptBytes: configured.Execution.MaxScriptBytes, MaxExecutionSpecBytes: configured.Execution.MaxExecutionSpecBytes, QuotaLimits: spool.QuotaLimits{HardAllocationBytes: 1 << 20, CommandOutputBytes: configured.Storage.CommandOutputLimitBytes, CommandTotalBytes: configured.Storage.CommandTotalLimitBytes, ClientTotalBytes: configured.Storage.ClientTotalLimitBytes, CloseoutReserveBytes: configured.Storage.CommandCloseoutReserveBytes}}) 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),
+70
View File
@@ -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)
}
}
+23
View File
@@ -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)
+7
View File
@@ -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)")
} }
+8
View File
@@ -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)
+6
View File
@@ -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
+3
View File
@@ -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
+6
View File
@@ -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
+5 -2
View File
@@ -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
+29 -3
View File
@@ -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 |
+49
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+29
View File
@@ -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()
+24 -1
View File
@@ -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)
} }
+74 -22
View File
@@ -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) {
+41 -1
View File
@@ -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})
+34
View File
@@ -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
}
+22
View File
@@ -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)
}
}
+14
View File
@@ -60,6 +60,20 @@ func TestClientDefaultsDecodeForWindowsWithoutNativeFilesystem(t *testing.T) {
} }
} }
func TestClientMayDisableObservabilityListener_HP_CFG_10(t *testing.T) {
t.Parallel()
client, err := DecodeClient([]byte("[client]\nstate_dir = \"C:\\\\ProgramData\\\\RVBox\\\\state\"\ndaemon_cwd = \"C:\\\\Users\\\\Public\"\n\n[observability]\nlisten = \"\"\nlog_file = \"\"\n"), ClientOptions{Platform: PlatformWindows})
if err != nil {
t.Fatalf("DecodeClient disabled observability: %v", err)
}
if client.Observability.Listen != "" || client.Observability.LogFile != "" {
t.Fatalf("disabled observability = %+v", client.Observability)
}
if _, err := DecodeServer([]byte("[observability]\nlisten = \"\"\n")); err == nil {
t.Fatal("server accepted a disabled observability listener")
}
}
func TestStrictTOMLRejections_HP_CFG_01(t *testing.T) { func TestStrictTOMLRejections_HP_CFG_01(t *testing.T) {
t.Parallel() t.Parallel()
+1 -1
View File
@@ -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,
}, },
} }
} }
+1 -1
View File
@@ -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)
} }
+10 -5
View File
@@ -119,7 +119,7 @@ func validateServer(raw serverFile) (*Server, error) {
if err := protocolLimits(raw.Protocol.MaxAgentEnvelopeBytes, raw.Protocol.MaxExecutionSpecBytes, raw.Protocol.MaxRawChunkBytes, raw.Protocol.MaxScriptBytes, raw.Protocol.MaxControlRequestBytes, raw.Protocol.MaxJSONRPCBodyBytes); err != nil { if err := protocolLimits(raw.Protocol.MaxAgentEnvelopeBytes, raw.Protocol.MaxExecutionSpecBytes, raw.Protocol.MaxRawChunkBytes, raw.Protocol.MaxScriptBytes, raw.Protocol.MaxControlRequestBytes, raw.Protocol.MaxJSONRPCBodyBytes); err != nil {
return nil, err return nil, err
} }
observability, err := validateObservability(raw.Observability, PlatformUnix) observability, err := validateObservability(raw.Observability, PlatformUnix, false)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -220,7 +220,7 @@ func validateClient(raw clientFile, options ClientOptions) (*Client, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
observability, err := validateObservability(raw.Observability, options.Platform) observability, err := validateObservability(raw.Observability, options.Platform, true)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -439,9 +439,14 @@ 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 err := validateListener("observability.listen", raw.Listen); err != nil { if raw.Listen == "" && !allowDisabled {
return Observability{}, err return Observability{}, fmt.Errorf("observability.listen must be a host:port listener")
}
if raw.Listen != "" {
if err := validateListener("observability.listen", raw.Listen); err != nil {
return Observability{}, err
}
} }
for name, value := range map[string]string{"liveness_path": raw.LivenessPath, "readiness_path": raw.ReadinessPath, "metrics_path": raw.MetricsPath} { 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 {
+21
View File
@@ -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)
})
}
+134 -9
View File
@@ -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
} }
+29
View File
@@ -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)
}
}
+156
View File
@@ -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
}
+86
View File
@@ -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)
}
}
+26 -14
View File
@@ -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"`
+15
View File
@@ -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)
})
}
+14 -1
View File
@@ -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 {
+7
View File
@@ -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)
+86 -10
View File
@@ -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)
} }
+71 -1
View File
@@ -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)
+4
View File
@@ -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
+31
View File
@@ -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()
+40 -6
View File
@@ -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
} }
+25
View File
@@ -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)
+7 -5
View File
@@ -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
+34
View File
@@ -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
}
+35
View File
@@ -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)
}
}
+143
View File
@@ -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"
+39
View File
@@ -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"
+75
View File
@@ -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
+50 -11
View File
@@ -20,9 +20,10 @@ Actions:
stage copy a bundle containing rvbox.exe and client.toml into the guest test root stage copy a bundle containing rvbox.exe and client.toml into the guest test root
install install and start RVBox from the staged bundle through a fixture-only full-admin principal install install and start RVBox from the staged bundle through a fixture-only full-admin principal
run start the already-installed RVBox SCM service from the staged bundle run start the already-installed RVBox SCM service from the staged bundle
logoff log off the sole active fixture user; use only after service installation 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
@@ -81,13 +82,13 @@ while [ "$#" -gt 0 ]; do
--run-id) [ "$#" -ge 2 ] || fail "--run-id needs a value"; run_id=$2; shift 2 ;; --run-id) [ "$#" -ge 2 ] || fail "--run-id needs a value"; run_id=$2; shift 2 ;;
--bundle) [ "$#" -ge 2 ] || fail "--bundle needs a value"; bundle=$2; shift 2 ;; --bundle) [ "$#" -ge 2 ] || fail "--bundle needs a value"; bundle=$2; shift 2 ;;
--endpoint) [ "$#" -ge 2 ] || fail "--endpoint needs a value"; endpoint=$2; shift 2 ;; --endpoint) [ "$#" -ge 2 ] || fail "--endpoint needs a value"; endpoint=$2; shift 2 ;;
--fail-contexts) [ "$#" -ge 2 ] || fail "--fail-contexts needs a value"; fail_contexts=$2; shift 2 ;; --fail-contexts) [ "$#" -ge 2 ] || fail "--fail-contexts needs a value"; fail_contexts=$2; shift 2 ;;
--help|-h) usage; exit 0 ;; --help|-h) usage; exit 0 ;;
*) fail "unknown argument $1" ;; *) fail "unknown argument $1" ;;
esac esac
done done
case $action in status|prepare|stage|install|run|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))
+87
View File
@@ -17,6 +17,21 @@ layer = "unit"
status = "implemented" status = "implemented"
tests = ["internal/config/effective_test.go:TestEffectiveConfigurationGolden_HP_CFG_09"] tests = ["internal/config/effective_test.go:TestEffectiveConfigurationGolden_HP_CFG_09"]
[[requirements]]
id = "HP-CFG-10"
layer = "unit"
status = "implemented"
tests = ["internal/config/config_test.go:TestClientMayDisableObservabilityListener_HP_CFG_10"]
[[requirements]]
id = "HP-WINCLI-04"
layer = "unit"
status = "implemented"
tests = [
"cmd/rvbox/main_test.go:TestPackagedClientConfigUsesMinimalSafeDefaults_HP_WINCLI_04",
"cmd/rvbox/main_test.go:TestInstallerClientIDNormalizesHostnames_HP_WINCLI_05",
]
[[requirements]] [[requirements]]
id = "HP-CTL-06" id = "HP-CTL-06"
layer = "unit" layer = "unit"
@@ -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"
+1
View File
@@ -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
View File
@@ -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/`.
+2
View File
@@ -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
+3
View File
@@ -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;
} }
} }
+347 -21
View File
@@ -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
-o BatchMode=yes \ : >"$tunnel_log_file"
-o ExitOnForwardFailure=yes \ chmod 600 "$tunnel_log_file"
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \ # Keep the SSH master outside the short-lived controller process. Helium
-L "$docker_gateway:$RDP_ACCESS_TUNNEL_PORT:$RDP_ACCESS_VRDE_HOST:$RDP_ACCESS_VRDE_PORT" \ # may drop an idle or metered route; the watchdog reconnects it and keeps
"$RVBOX_TEST_VBOX_HOST" # the same local listener for Guacamole. The stop marker is deliberately
ssh -S "$socket_path" -O check "$RVBOX_TEST_VBOX_HOST" >/dev/null 2>&1 || fail "private VRDE tunnel did not start" # 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 ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-L "$bind:$tunnel_port:$vrde_host:$vrde_port" \
"$host" || true
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
+1
View File
@@ -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>
+30 -3
View File
@@ -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)
} }
+89
View File
@@ -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)
}
}
}