feat: execute durable client commands through supervisor
This commit is contained in:
+219
-1
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientModeSelectionRequiresExactlyOneMode_HP_WINCLI_01(t *testing.T) {
|
||||
t.Parallel()
|
||||
var output, diagnostics bytes.Buffer
|
||||
if err := run([]string{"--help"}, &output, &diagnostics); err != nil || !bytes.Contains(output.Bytes(), []byte("--service")) {
|
||||
t.Fatalf("help = %q, %v", output.String(), err)
|
||||
}
|
||||
if err := run(nil, &output, &diagnostics); err == nil {
|
||||
t.Fatal("empty client invocation accepted")
|
||||
}
|
||||
if err := run([]string{"--service", "--check-config"}, &output, &diagnostics); err == nil {
|
||||
t.Fatal("multiple client modes accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonWindowsServiceModesRemainExplicitlyUnsupported_BH_WINCLI_01(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := runService("", nil); err == nil {
|
||||
t.Fatal("non-Windows service mode unexpectedly available")
|
||||
}
|
||||
if err := runTray("", nil); err == nil {
|
||||
t.Fatal("non-Windows tray mode unexpectedly available")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
func defaultClientConfigPath() string { return "/etc/rvbox/client.toml" }
|
||||
|
||||
func runService(string, io.Writer) error {
|
||||
return errors.New("the v1 client service is implemented for Windows only")
|
||||
}
|
||||
|
||||
func runTray(string, io.Writer) error {
|
||||
return errors.New("the v1 tray frontend is implemented for Windows only")
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/rvbox/rvbox/internal/client/windowsservice"
|
||||
"golang.org/x/sys/windows/svc"
|
||||
)
|
||||
|
||||
func defaultClientConfigPath() string {
|
||||
root := os.Getenv("ProgramData")
|
||||
if root == "" {
|
||||
root = `C:\ProgramData`
|
||||
}
|
||||
return filepath.Join(root, "RVBox", "client.toml")
|
||||
}
|
||||
|
||||
func runService(configPath string, diagnostics io.Writer) error {
|
||||
inService, err := svc.IsWindowsService()
|
||||
if err != nil {
|
||||
return fmt.Errorf("detect service control manager context: %w", err)
|
||||
}
|
||||
if !inService {
|
||||
return errors.New("--service is reserved for the installed Windows service")
|
||||
}
|
||||
return runWindowsService(configPath, diagnostics)
|
||||
}
|
||||
|
||||
func runWindowsService(configPath string, diagnostics io.Writer) error {
|
||||
return windowsservice.Run(func(ctx context.Context) error { return runClientDaemon(ctx, configPath, diagnostics) })
|
||||
}
|
||||
|
||||
func runTray(configPath string, diagnostics io.Writer) error {
|
||||
return errors.New("Windows tray frontend is not available in this build")
|
||||
}
|
||||
Reference in New Issue
Block a user