feat: execute durable client commands through supervisor

This commit is contained in:
2026-09-06 12:18:15 +00:00
parent 3f84d3b2f1
commit 56b15c7f4f
41 changed files with 4272 additions and 66 deletions
+219 -1
View File
@@ -1,4 +1,222 @@
// Command rvbox is the RVBox client daemon and Windows service executable.
package main
func main() {}
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"runtime"
"time"
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
"github.com/rvbox/rvbox/internal/agentproto"
"github.com/rvbox/rvbox/internal/client/agent"
"github.com/rvbox/rvbox/internal/client/spool"
clientwindows "github.com/rvbox/rvbox/internal/client/supervisor/windows"
"github.com/rvbox/rvbox/internal/client/windowsservice"
"github.com/rvbox/rvbox/internal/config"
"github.com/rvbox/rvbox/internal/domain"
"google.golang.org/protobuf/types/known/timestamppb"
)
func main() {
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "rvbox:", err)
os.Exit(1)
}
}
// run is deliberately a small mode dispatcher. The SCM service invokes only
// --service with an explicit config path; tray/helper modes cannot silently
// turn an ordinary process invocation into a privileged service.
func run(args []string, output, diagnostics io.Writer) error {
if len(args) == 0 {
return errors.New("an internal mode is required (use --help)")
}
if args[0] == "--help" || args[0] == "-h" {
_, err := io.WriteString(output, "usage: rvbox --service|--tray|--check-config|--install-service|--uninstall-service|--start-service|--stop-service --config PATH\n")
return err
}
flags := flag.NewFlagSet("rvbox", flag.ContinueOnError)
flags.SetOutput(io.Discard)
configPath := flags.String("config", defaultClientConfigPath(), "absolute client TOML configuration path")
serviceMode := flags.Bool("service", false, "run under the Windows Service Control Manager")
trayMode := flags.Bool("tray", false, "run the current user's notification-area frontend")
checkConfig := flags.Bool("check-config", false, "validate client configuration and exit")
install := flags.Bool("install-service", false, "install or update the machine-wide service")
uninstall := flags.Bool("uninstall-service", false, "remove the machine-wide service")
start := flags.Bool("start-service", false, "start the machine-wide service")
stop := flags.Bool("stop-service", false, "stop the machine-wide service")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return fmt.Errorf("unexpected argument %q", flags.Arg(0))
}
selected := 0
for _, value := range []bool{*serviceMode, *trayMode, *checkConfig, *install, *uninstall, *start, *stop} {
if value {
selected++
}
}
if selected != 1 {
return errors.New("select exactly one rvbox mode")
}
if *checkConfig {
if _, err := loadClientConfig(*configPath); err != nil {
return err
}
_, err := fmt.Fprintf(output, "valid client configuration: %s\n", *configPath)
return err
}
if *install {
executable, err := os.Executable()
if err != nil {
return err
}
return windowsservice.Install(windowsservice.InstallSpec{ExecutablePath: executable, ConfigPath: *configPath, Startup: windowsservice.StartupAutomatic})
}
if *uninstall {
return windowsservice.Uninstall()
}
if *start {
return windowsservice.Start()
}
if *stop {
return windowsservice.Stop(30)
}
if *trayMode {
return runTray(*configPath, diagnostics)
}
return runService(*configPath, diagnostics)
}
func loadClientConfig(path string) (*config.Client, error) {
platform := config.PlatformUnix
checkFilesystem := false
if runtime.GOOS == "windows" {
platform = config.PlatformWindows
checkFilesystem = true
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read client config: %w", err)
}
return config.DecodeClient(data, config.ClientOptions{Platform: platform, CheckFilesystem: checkFilesystem})
}
func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writer) error {
configured, err := loadClientConfig(configPath)
if err != nil {
return err
}
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 {
return fmt.Errorf("open client durable state: %w", err)
}
defer state.Close()
if diagnostics != nil {
_, _ = fmt.Fprintf(diagnostics, "rvbox client service initialized for %s\n", configured.Client.ServerURL)
}
hello := clientHello(configured, state.ClientInstanceID())
httpClient, err := clientHTTPClient(configured.TLS)
if err != nil {
return fmt.Errorf("configure client TLS: %w", err)
}
limits := agentproto.Limits{MaxEnvelopeBytes: configured.Execution.MaxAgentEnvelopeBytes, MaxExecutionSpecBytes: configured.Execution.MaxExecutionSpecBytes, MaxRawChunkBytes: configured.Execution.MaxRawChunkBytes, MaxScriptBytes: configured.Execution.MaxScriptBytes, MaxDetailBytes: configured.Execution.ProtocolDetailMaxBytes}
if limits.MaxEnvelopeBytes == 0 {
limits = agentproto.DefaultLimits()
}
eventReady := make(chan domain.UUID, 256)
supervised, err := clientwindows.NewSupervisor(clientwindows.NativeOptions{Shells: clientwindows.ShellPaths{CMD: configured.Shells.CMD, PowerShell: configured.Shells.PowerShell}, WorkRoot: configured.Client.DaemonCWD, MaxWrapperBytes: configured.Execution.MaxScriptBytes, MaxOutputChunk: configured.Execution.MaxRawChunkBytes, WindowsTermGrace: configured.Execution.WindowsTermGrace})
if err != nil {
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) {
select {
case eventReady <- issue:
default:
}
}})
if err != nil {
return fmt.Errorf("configure command executor: %w", err)
}
runner := func() {
if _, checkErr := state.Check(ctx); checkErr != nil {
if diagnostics != nil {
_, _ = fmt.Fprintf(diagnostics, "rvbox client spool is dirty: %v\n", checkErr)
}
return
}
if recovered, recoverErr := state.RecoverLaunchUncertainty(ctx, time.Now().UTC()); recoverErr != nil {
if diagnostics != nil {
_, _ = fmt.Fprintf(diagnostics, "rvbox launch recovery failed: %v\n", recoverErr)
}
return
} else if len(recovered) > 0 && diagnostics != nil {
_, _ = fmt.Fprintf(diagnostics, "rvbox recovered %d uncertain launch(es)\n", len(recovered))
}
if runErr := agent.Run(ctx, agent.RunnerOptions{
Store: state,
Dial: func(dialContext context.Context) (agent.Transport, error) {
return agent.DialWebSocket(dialContext, configured.Client.ServerURL, httpClient)
},
Hello: hello, Limits: limits,
Backoff: agent.BackoffOptions{Initial: configured.Network.ReconnectInitial, Maximum: configured.Network.ReconnectMax, StableReset: configured.Network.StableSessionReset},
Jitter: agent.CryptoJitter, Now: func() time.Time { return time.Now().UTC() },
OnDispatch: executor.Dispatch, OnScriptReady: executor.ScriptReady, OnStdin: executor.Stdin,
OnCloseStdin: executor.CloseStdin, OnSignal: executor.Signal, OnTerminate: executor.Terminate, EventReady: eventReady,
}); runErr != nil && ctx.Err() == nil && diagnostics != nil {
_, _ = fmt.Fprintf(diagnostics, "rvbox client session stopped: %v\n", runErr)
}
}
go runner()
<-ctx.Done()
_ = executor.StopAll(context.Background())
return nil
}
func clientHTTPClient(settings config.TLS) (*http.Client, error) {
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: settings.ServerName} // #nosec G402 -- TLS 1.2 is the v1 floor.
if settings.CAFile != "" {
pem, err := os.ReadFile(settings.CAFile)
if err != nil {
return nil, err
}
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(pem) {
return nil, errors.New("TLS CA file contains no certificates")
}
tlsConfig.RootCAs = pool
}
return &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}}, nil
}
func clientHello(configured *config.Client, instance domain.UUID) *rvboxv1.ClientHello {
platform := rvboxv1.Platform_PLATFORM_LINUX
shells := []rvboxv1.ShellType{rvboxv1.ShellType_SHELL_CMD, rvboxv1.ShellType_SHELL_POWERSHELL}
if runtime.GOOS == "windows" {
platform = rvboxv1.Platform_PLATFORM_WINDOWS
} else {
shells = []rvboxv1.ShellType{rvboxv1.ShellType_SHELL_SH, rvboxv1.ShellType_SHELL_BASH}
}
clientID := configured.Client.ClientID
if clientID == "" {
clientID, _ = os.Hostname()
}
return &rvboxv1.ClientHello{
ClientId: clientID, SupportedProtocol: &rvboxv1.ProtocolRange{Major: 1, MinMinor: 0, MaxMinor: 0},
DaemonVersion: "v1", Platform: platform, Architecture: runtime.GOARCH, DaemonCwd: configured.Client.DaemonCWD,
SupportedShells: shells, ClientInstanceId: instance.String(), MaxRunningCommands: configured.Client.MaxRunningCommands,
MaxQueuedCommands: configured.Client.MaxQueuedCommands, SentAt: timestamppb.New(time.Now().UTC()),
}
}