275 lines
12 KiB
Go
275 lines
12 KiB
Go
// Command rvbox is the RVBox client daemon and Windows service executable.
|
|
package 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"
|
|
"github.com/rvbox/rvbox/internal/observability"
|
|
"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|--launcher|--signal-helper|--check-config|--install-service|--uninstall-service|--configure-service|--start-service|--stop-service|--restart-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")
|
|
launcherMode := flags.Bool("launcher", false, "run one private authenticated command launcher")
|
|
signalHelperMode := flags.Bool("signal-helper", false, "run one private authenticated signal helper")
|
|
channel := flags.String("channel", "", "private launcher channel (internal use only)")
|
|
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")
|
|
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")
|
|
start := flags.Bool("start-service", false, "start the machine-wide service")
|
|
stop := flags.Bool("stop-service", false, "stop the machine-wide service")
|
|
restart := flags.Bool("restart-service", false, "restart the machine-wide service")
|
|
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, *launcherMode, *signalHelperMode, *checkConfig, *install, *uninstall, *configure, *start, *stop, *restart} {
|
|
if value {
|
|
selected++
|
|
}
|
|
}
|
|
if selected != 1 {
|
|
return errors.New("select exactly one rvbox mode")
|
|
}
|
|
if *checkConfig {
|
|
configured, err := loadClientConfig(*configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = fmt.Fprintf(output, "valid client configuration: path=%s server_url=%s state_dir=%s daemon_cwd=%s observability_listen=%s\n", *configPath, configured.Client.ServerURL, configured.Client.StateDir, configured.Client.DaemonCWD, configured.Observability.Listen)
|
|
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 *configure {
|
|
return windowsservice.Configure(windowsservice.StartupMode(*startup))
|
|
}
|
|
if *start {
|
|
return windowsservice.Start()
|
|
}
|
|
if *stop {
|
|
return windowsservice.Stop(30)
|
|
}
|
|
if *restart {
|
|
return windowsservice.Restart(30)
|
|
}
|
|
if *trayMode {
|
|
return runTray(*configPath, diagnostics)
|
|
}
|
|
if *launcherMode {
|
|
if *channel == "" {
|
|
return errors.New("--launcher requires an internal channel")
|
|
}
|
|
return runLauncher(*channel, diagnostics)
|
|
}
|
|
if *signalHelperMode {
|
|
if *channel == "" {
|
|
return errors.New("--signal-helper requires an internal channel")
|
|
}
|
|
return runSignalHelper(*channel, 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
|
|
}
|
|
health := observability.New()
|
|
go func() {
|
|
if serveErr := health.Serve(ctx, configured.Observability.Listen, observability.Paths{Liveness: configured.Observability.LivenessPath, Readiness: configured.Observability.ReadinessPath, Metrics: configured.Observability.MetricsPath}); serveErr != nil && ctx.Err() == nil && diagnostics != nil {
|
|
_, _ = 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}})
|
|
if err != nil {
|
|
health.SetDirty(true)
|
|
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, JobProfiles: clientJobProfiles(configured.Profiles), 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 {
|
|
health.SetDirty(true)
|
|
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 {
|
|
health.SetDirty(true)
|
|
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))
|
|
}
|
|
health.SetReady(true)
|
|
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 clientJobProfiles(profiles config.Profiles) map[string]clientwindows.JobProfile {
|
|
return map[string]clientwindows.JobProfile{
|
|
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_LIGHT.String(): toJobProfile(profiles.Light),
|
|
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_CPU_MEDIUM.String(): toJobProfile(profiles.CPUMedium),
|
|
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_CPU_HEAVY.String(): toJobProfile(profiles.CPUHeavy),
|
|
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_MEM_MEDIUM.String(): toJobProfile(profiles.MemMedium),
|
|
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_MEM_HEAVY.String(): toJobProfile(profiles.MemHeavy),
|
|
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_DISK_MEDIUM.String(): toJobProfile(profiles.DiskMedium),
|
|
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_DISK_HEAVY.String(): toJobProfile(profiles.DiskHeavy),
|
|
}
|
|
}
|
|
|
|
func toJobProfile(profile config.Profile) clientwindows.JobProfile {
|
|
return clientwindows.JobProfile{RequiredControls: append([]string(nil), profile.RequiredControls...), CPUPercent: profile.CPUPercent, MemoryMaxBytes: profile.MemoryMaxBytes, PIDsMax: profile.PIDsMax, IOReadBPS: profile.WindowsIOReadBPS, IOWriteBPS: profile.WindowsIOWriteBPS}
|
|
}
|
|
|
|
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()),
|
|
}
|
|
}
|