feat: complete Windows client control and recovery paths
This commit is contained in:
@@ -12,10 +12,13 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||
"github.com/rvbox/rvbox/internal/agentproto"
|
||||
"github.com/rvbox/rvbox/internal/config"
|
||||
"github.com/rvbox/rvbox/internal/domain"
|
||||
"github.com/rvbox/rvbox/internal/observability"
|
||||
"github.com/rvbox/rvbox/internal/server/control"
|
||||
"github.com/rvbox/rvbox/internal/server/session"
|
||||
"github.com/rvbox/rvbox/internal/server/store"
|
||||
@@ -24,12 +27,26 @@ import (
|
||||
|
||||
func main() {
|
||||
var configPath string
|
||||
var checkConfig bool
|
||||
flag.StringVar(&configPath, "config", "", "absolute server TOML configuration path")
|
||||
flag.BoolVar(&checkConfig, "check-config", false, "validate server configuration and exit")
|
||||
flag.Parse()
|
||||
if configPath == "" {
|
||||
log.Print("rvbox-server: --config is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
if checkConfig {
|
||||
configured, err := config.LoadServer(configPath)
|
||||
if err != nil {
|
||||
log.Printf("rvbox-server: invalid configuration: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if _, err := fmt.Fprintf(os.Stdout, "valid server configuration: data_dir=%s agent_listen=%s control_socket=%s observability_listen=%s json_rpc_enabled=%t\n", configured.Server.DataDir, configured.Server.AgentListen, configured.Server.ControlSocket, configured.Observability.Listen, configured.JSONRPC.Enabled); err != nil {
|
||||
log.Printf("rvbox-server: write check result: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := run(configPath); err != nil {
|
||||
log.Printf("rvbox-server: %v", err)
|
||||
os.Exit(1)
|
||||
@@ -58,6 +75,43 @@ func run(configPath string) error {
|
||||
return err
|
||||
}
|
||||
defer persistence.Close()
|
||||
health := observability.New()
|
||||
healthServer := &http.Server{Handler: health.Handler(observability.Paths{Liveness: configured.Observability.LivenessPath, Readiness: configured.Observability.ReadinessPath, Metrics: configured.Observability.MetricsPath}), ReadHeaderTimeout: configured.Flow.WriteDeadline}
|
||||
// Health is deliberately best effort. A port collision or a temporary
|
||||
// listener failure must not prevent the control/agent service from starting;
|
||||
// operators still get the failure in the daemon log.
|
||||
var healthListener net.Listener
|
||||
if configured.Observability.Listen != "" {
|
||||
healthListener, err = net.Listen("tcp", configured.Observability.Listen)
|
||||
if err != nil {
|
||||
log.Printf("rvbox-server: observability endpoint unavailable (continuing without it): %v", err)
|
||||
}
|
||||
}
|
||||
if healthListener != nil {
|
||||
defer healthListener.Close()
|
||||
}
|
||||
recoveryContext, cancelRecovery := context.WithCancel(context.Background())
|
||||
defer cancelRecovery()
|
||||
// Recovery is deliberately asynchronous: liveness and incident inspection
|
||||
// remain available while committed-range checks run. Readiness becomes
|
||||
// true only after the real SQLite/segment recovery completes.
|
||||
go func() {
|
||||
if _, recoverErr := persistence.RecoverCommandSegments(recoveryContext); recoverErr != nil {
|
||||
health.SetDirty(true)
|
||||
if incidentID, idErr := domain.NewUUIDv7(); idErr == nil {
|
||||
if _, incidentErr := persistence.RecordIncident(context.Background(), store.IncidentInput{
|
||||
IncidentUUID: incidentID, DetectedAt: time.Now().UTC(), Kind: store.IncidentSQLiteIntegrity,
|
||||
Scope: store.IncidentScopeGlobal, ScopeKey: "server-startup-recovery", Summary: "startup storage recovery failed",
|
||||
Evidence: []byte(recoverErr.Error()), AutomaticallyRepairable: false,
|
||||
}); incidentErr != nil {
|
||||
log.Printf("rvbox-server: could not persist recovery incident: %v", incidentErr)
|
||||
}
|
||||
}
|
||||
log.Printf("rvbox-server: storage recovery left readiness disabled: %v", recoverErr)
|
||||
return
|
||||
}
|
||||
health.SetReady(true)
|
||||
}()
|
||||
|
||||
listener, err := net.Listen("tcp", configured.Server.AgentListen)
|
||||
if err != nil {
|
||||
@@ -106,9 +160,12 @@ func run(configPath string) error {
|
||||
HeartbeatIdle: configured.Protocol.HeartbeatIdle, LivenessTimeout: configured.Protocol.LivenessTimeout,
|
||||
}
|
||||
httpServer := &http.Server{Handler: agent, ReadHeaderTimeout: configured.Flow.WriteDeadline}
|
||||
serveError := make(chan error, 3)
|
||||
serveError := make(chan error, 4)
|
||||
go func() { serveError <- httpServer.Serve(listener) }()
|
||||
go func() { serveError <- grpcServer.Serve(controlListener) }()
|
||||
if healthListener != nil {
|
||||
go func() { serveError <- healthServer.Serve(healthListener) }()
|
||||
}
|
||||
if rpcServer != nil {
|
||||
go func() { serveError <- rpcServer.Serve(rpcListener) }()
|
||||
}
|
||||
@@ -119,6 +176,9 @@ func run(configPath string) error {
|
||||
select {
|
||||
case err := <-serveError:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
if healthListener != nil {
|
||||
_ = healthServer.Close()
|
||||
}
|
||||
if rpcServer != nil {
|
||||
_ = rpcServer.Close()
|
||||
}
|
||||
@@ -126,6 +186,9 @@ func run(configPath string) error {
|
||||
return nil
|
||||
}
|
||||
_ = httpServer.Close()
|
||||
if healthListener != nil {
|
||||
_ = healthServer.Close()
|
||||
}
|
||||
if rpcServer != nil {
|
||||
_ = rpcServer.Close()
|
||||
}
|
||||
@@ -135,6 +198,13 @@ func run(configPath string) error {
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), configured.Server.ShutdownGrace)
|
||||
defer cancel()
|
||||
httpErr := httpServer.Shutdown(shutdownContext)
|
||||
var healthErr error
|
||||
if healthListener != nil {
|
||||
healthErr = healthServer.Shutdown(shutdownContext)
|
||||
}
|
||||
if httpErr == nil {
|
||||
httpErr = healthErr
|
||||
}
|
||||
if rpcServer != nil {
|
||||
if err := rpcServer.Shutdown(shutdownContext); httpErr == nil {
|
||||
httpErr = err
|
||||
|
||||
+42
-5
@@ -22,6 +22,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -40,7 +41,7 @@ func run(args []string, output, diagnostics io.Writer) error {
|
||||
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")
|
||||
_, err := io.WriteString(output, "usage: rvbox --service|--tray|--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)
|
||||
@@ -51,8 +52,11 @@ func run(args []string, output, diagnostics io.Writer) error {
|
||||
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
|
||||
}
|
||||
@@ -60,7 +64,7 @@ func run(args []string, output, diagnostics io.Writer) error {
|
||||
return fmt.Errorf("unexpected argument %q", flags.Arg(0))
|
||||
}
|
||||
selected := 0
|
||||
for _, value := range []bool{*serviceMode, *trayMode, *checkConfig, *install, *uninstall, *start, *stop} {
|
||||
for _, value := range []bool{*serviceMode, *trayMode, *checkConfig, *install, *uninstall, *configure, *start, *stop, *restart} {
|
||||
if value {
|
||||
selected++
|
||||
}
|
||||
@@ -69,10 +73,11 @@ func run(args []string, output, diagnostics io.Writer) error {
|
||||
return errors.New("select exactly one rvbox mode")
|
||||
}
|
||||
if *checkConfig {
|
||||
if _, err := loadClientConfig(*configPath); err != nil {
|
||||
configured, err := loadClientConfig(*configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintf(output, "valid client configuration: %s\n", *configPath)
|
||||
_, 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 {
|
||||
@@ -85,12 +90,18 @@ func run(args []string, output, diagnostics io.Writer) error {
|
||||
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)
|
||||
}
|
||||
@@ -116,8 +127,15 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
|
||||
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()
|
||||
@@ -134,7 +152,7 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
|
||||
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})
|
||||
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)
|
||||
}
|
||||
@@ -149,12 +167,14 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -162,6 +182,7 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
|
||||
} 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) {
|
||||
@@ -182,6 +203,22 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
|
||||
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 != "" {
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/rvbox/rvbox/internal/client/windowsservice"
|
||||
"github.com/rvbox/rvbox/internal/client/windowstray"
|
||||
"golang.org/x/sys/windows/svc"
|
||||
)
|
||||
|
||||
@@ -34,9 +36,80 @@ func runService(configPath string, diagnostics io.Writer) error {
|
||||
}
|
||||
|
||||
func runWindowsService(configPath string, diagnostics io.Writer) error {
|
||||
return windowsservice.Run(func(ctx context.Context) error { return runClientDaemon(ctx, configPath, diagnostics) })
|
||||
return windowsservice.Run(func(ctx context.Context) error {
|
||||
// The tray endpoint lives in the same LocalSystem service process. It
|
||||
// has no store access; the handler below returns only bounded status/path
|
||||
// data and rechecks SCM authorization in the native pipe adapter.
|
||||
go func() {
|
||||
if err := windowstray.Serve(ctx, func(requestContext context.Context, _ windowstray.Peer, request windowstray.Frame) (windowstray.Frame, error) {
|
||||
return handleTrayRequest(requestContext, configPath, request)
|
||||
}); err != nil && ctx.Err() == nil && diagnostics != nil {
|
||||
_, _ = fmt.Fprintf(diagnostics, "rvbox tray endpoint stopped: %v\n", err)
|
||||
}
|
||||
}()
|
||||
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")
|
||||
_ = configPath // the tray obtains the canonical paths from the service.
|
||||
return windowstray.Run(context.Background(), diagnostics)
|
||||
}
|
||||
|
||||
func handleTrayRequest(ctx context.Context, configPath string, request windowstray.Frame) (windowstray.Frame, error) {
|
||||
response := windowstray.Frame{Action: windowstray.ActionStatus}
|
||||
switch request.Action {
|
||||
case windowstray.ActionStatus:
|
||||
response.Payload = []byte("RVBox service=running")
|
||||
case windowstray.ActionOpenConfig:
|
||||
response.Payload = []byte(configPath)
|
||||
case windowstray.ActionOpenLog:
|
||||
configured, err := loadClientConfig(configPath)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
logPath := configured.Observability.LogFile
|
||||
if logPath == "" {
|
||||
logPath = filepath.Join(filepath.Dir(configPath), "logs", "rvbox.log")
|
||||
}
|
||||
response.Payload = []byte(logPath)
|
||||
case windowstray.ActionStartService:
|
||||
if err := windowsservice.Start(); err != nil {
|
||||
return response, err
|
||||
}
|
||||
response.Payload = []byte("RVBox service start requested")
|
||||
case windowstray.ActionStopService:
|
||||
// A service cannot synchronously wait for its own stop request from the
|
||||
// pipe handler: the SCM callback must return so the process can unwind.
|
||||
go func() { _ = windowsservice.Stop(30) }()
|
||||
response.Payload = []byte("RVBox service stop requested")
|
||||
case windowstray.ActionRestartService:
|
||||
// Restart must be performed by the external UAC helper. If the tray is
|
||||
// already elevated, its native fallback still launches the canonical
|
||||
// --restart-service mode outside this service process.
|
||||
return response, errors.New("restart requires the external service helper")
|
||||
case windowstray.ActionSetAutomatic:
|
||||
if err := windowsservice.Configure(windowsservice.StartupAutomatic); err != nil {
|
||||
return response, err
|
||||
}
|
||||
response.Payload = []byte("RVBox service startup set to automatic")
|
||||
case windowstray.ActionSetManual:
|
||||
if err := windowsservice.Configure(windowsservice.StartupManual); err != nil {
|
||||
return response, err
|
||||
}
|
||||
response.Payload = []byte("RVBox service startup set to manual")
|
||||
case windowstray.ActionExitTray:
|
||||
response.Payload = []byte("tray exit acknowledged")
|
||||
default:
|
||||
return response, fmt.Errorf("unknown tray action %d", request.Action)
|
||||
}
|
||||
if len(response.Payload) > 4<<10 {
|
||||
response.Payload = []byte(strings.TrimSpace(string(response.Payload[:4<<10])))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return response, ctx.Err()
|
||||
default:
|
||||
return response, nil
|
||||
}
|
||||
}
|
||||
|
||||
+87
-2
@@ -193,14 +193,99 @@ func stat(ctx context.Context, client rvboxv1.ControlClient, args []string, outp
|
||||
return err
|
||||
}
|
||||
item := response.GetCommand()
|
||||
renderCommandStat(output, item, time.Now().UTC())
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderCommandStat keeps the human CLI useful when a caller does not have a
|
||||
// protobuf-aware inspection tool. Durable lifecycle, expiry, retention, and
|
||||
// Windows identity are rendered independently: a late terminal result must
|
||||
// not erase the fact that the queue deadline was crossed.
|
||||
func renderCommandStat(output io.Writer, item *rvboxv1.CommandRecord, now time.Time) {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(output, "command %s client=%s lifecycle=%s revision=%d events=%d\n", item.GetIssueUuid(), item.GetTargetClientId(), item.GetLifecycle(), item.GetCommandRevision(), item.GetLastEventSeq())
|
||||
if item.GetQueueExpiryTime() != nil {
|
||||
fmt.Fprintf(output, "queue_expiry=%s\n", item.GetQueueExpiryTime().AsTime().UTC().Format(time.RFC3339Nano))
|
||||
expiry := item.GetQueueExpiryTime().AsTime().UTC()
|
||||
fmt.Fprintf(output, "queue_expiry=%s\n", expiry.Format(time.RFC3339Nano))
|
||||
if !now.IsZero() && !now.Before(expiry) {
|
||||
fmt.Fprintln(output, "queue_expired=true")
|
||||
}
|
||||
}
|
||||
if item.GetLateAfterExpiry() {
|
||||
fmt.Fprintln(output, "late_after_expiry=true (terminal result arrived after queue expiry)")
|
||||
}
|
||||
if item.GetTerminalTime() != nil {
|
||||
fmt.Fprintf(output, "terminal_time=%s\n", item.GetTerminalTime().AsTime().UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
return nil
|
||||
if item.GetExitCode() != 0 || item.ExitCode != nil {
|
||||
fmt.Fprintf(output, "exit_code=%d\n", item.GetExitCode())
|
||||
}
|
||||
if item.GetOutputTruncated() {
|
||||
fmt.Fprintln(output, "output_truncated=true")
|
||||
}
|
||||
if item.GetOutputIncomplete() {
|
||||
fmt.Fprintln(output, "output_incomplete=true")
|
||||
}
|
||||
if item.GetRetainedCompressedBytes() > 0 {
|
||||
fmt.Fprintf(output, "retained_compressed_bytes=%d\n", item.GetRetainedCompressedBytes())
|
||||
}
|
||||
if rejection := item.GetRejection(); rejection != nil {
|
||||
fmt.Fprintf(output, "rejection_code=%s rejection=%s\n", rejection.GetCode(), singleLine(rejection.GetMessage()))
|
||||
}
|
||||
if identity := item.GetWindowsExecutionIdentity(); identity != nil {
|
||||
renderWindowsIdentity(output, item.GetSpec().GetElevated(), identity)
|
||||
}
|
||||
}
|
||||
|
||||
func renderWindowsIdentity(output io.Writer, requestedElevated bool, identity *rvboxv1.WindowsExecutionIdentity) {
|
||||
fmt.Fprintf(output, "windows_requested_elevated=%t\n", requestedElevated)
|
||||
if contexts := identity.GetAttemptedContexts(); len(contexts) > 0 {
|
||||
names := make([]string, 0, len(contexts))
|
||||
for _, context := range contexts {
|
||||
names = append(names, windowsContextName(context))
|
||||
}
|
||||
fmt.Fprintf(output, "windows_attempted_contexts=%s\n", strings.Join(names, ","))
|
||||
}
|
||||
if identity.EffectiveContext != nil {
|
||||
fmt.Fprintf(output, "windows_effective_context=%s\n", windowsContextName(identity.GetEffectiveContext()))
|
||||
} else {
|
||||
fmt.Fprintln(output, "windows_effective_context=none")
|
||||
}
|
||||
if identity.GetEffectiveUserSid() != "" {
|
||||
fmt.Fprintf(output, "windows_effective_user_sid=%s\n", singleLine(identity.GetEffectiveUserSid()))
|
||||
}
|
||||
if identity.SessionId != nil {
|
||||
fmt.Fprintf(output, "windows_session_id=%d\n", identity.GetSessionId())
|
||||
}
|
||||
if identity.GetSessionUserSid() != "" {
|
||||
fmt.Fprintf(output, "windows_session_user_sid=%s\n", singleLine(identity.GetSessionUserSid()))
|
||||
}
|
||||
if identity.GetSelectionDetail() != "" {
|
||||
fmt.Fprintf(output, "windows_selection_detail=%s\n", singleLine(identity.GetSelectionDetail()))
|
||||
}
|
||||
}
|
||||
|
||||
func windowsContextName(value rvboxv1.WindowsExecutionContext) string {
|
||||
switch value {
|
||||
case rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_LOCAL_SERVICE:
|
||||
return "local-service"
|
||||
case rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_LOCAL_SYSTEM:
|
||||
return "local-system"
|
||||
case rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER:
|
||||
return "active-user"
|
||||
case rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER_ELEVATED:
|
||||
return "active-user-elevated"
|
||||
case rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_SYSTEM:
|
||||
return "active-system"
|
||||
default:
|
||||
return "unspecified"
|
||||
}
|
||||
}
|
||||
|
||||
func singleLine(value string) string {
|
||||
return strings.NewReplacer("\r", "\\r", "\n", "\\n", "\t", "\\t").Replace(value)
|
||||
}
|
||||
|
||||
func runCommand(ctx context.Context, client rvboxv1.ControlClient, args []string, output, diagnostics io.Writer) error {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func TestGlobalSocketAndCLIValueParsing_HP_CTL_11(t *testing.T) {
|
||||
@@ -47,3 +49,32 @@ func TestGlobalRequestIDIsInjectedOnlyForMutations_HP_CTL_12(t *testing.T) {
|
||||
t.Fatal("duplicate global request IDs accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCommandStatShowsExpiryRetentionAndWindowsIdentity_HP_CTL_13(t *testing.T) {
|
||||
t.Parallel()
|
||||
expiry := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
|
||||
sessionID := uint32(7)
|
||||
effective := rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_SYSTEM
|
||||
var output bytes.Buffer
|
||||
renderCommandStat(&output, &rvboxv1.CommandRecord{
|
||||
IssueUuid: "019c46f1-1d02-7000-8000-0000000000f2", TargetClientId: "win-a",
|
||||
Lifecycle: rvboxv1.CommandLifecycle_COMMAND_SUCCEEDED, CommandRevision: 3,
|
||||
LastEventSeq: 9, QueueExpiryTime: timestamppb.New(expiry), OutputIncomplete: true,
|
||||
OutputTruncated: true, RetainedCompressedBytes: 1234, LateAfterExpiry: true,
|
||||
WindowsExecutionIdentity: &rvboxv1.WindowsExecutionIdentity{
|
||||
EffectiveContext: &effective, SessionId: &sessionID, EffectiveUserSid: "S-1-5-18",
|
||||
SessionUserSid: "S-1-5-21-user", AttemptedContexts: []rvboxv1.WindowsExecutionContext{
|
||||
rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER_ELEVATED, effective,
|
||||
}, SelectionDetail: "fallback\nused",
|
||||
},
|
||||
}, expiry.Add(time.Minute))
|
||||
for _, want := range []string{
|
||||
"queue_expired=true", "late_after_expiry=true", "output_truncated=true", "output_incomplete=true",
|
||||
"retained_compressed_bytes=1234", "windows_attempted_contexts=active-user-elevated,active-system",
|
||||
"windows_effective_context=active-system", "windows_session_id=7", "windows_selection_detail=fallback\\nused",
|
||||
} {
|
||||
if !bytes.Contains(output.Bytes(), []byte(want)) {
|
||||
t.Errorf("stat output missing %q:\n%s", want, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user