feat: complete Windows client control and recovery paths

This commit is contained in:
2026-09-06 13:37:32 +00:00
parent 56b15c7f4f
commit 486894557d
38 changed files with 2188 additions and 106 deletions
+16 -4
View File
@@ -129,6 +129,10 @@ func (executor *Executor) launch(ctx context.Context, issue domain.UUID, revisio
if err != nil {
cancel()
_ = executor.Store.SetLaunchPhase(context.Background(), issue, domain.LaunchPhaseNone, "", 0)
var startErr *supervisor.StartError
if errors.As(err, &startErr) {
return executor.rejectWithIdentity(ctx, issue, revision, err, startErr.WindowsIdentity)
}
return executor.reject(ctx, issue, revision, err)
}
identity := process.Identity()
@@ -141,7 +145,7 @@ func (executor *Executor) launch(ctx context.Context, issue domain.UUID, revisio
executor.active[issue] = process
executor.cancel[issue] = cancel
executor.mu.Unlock()
if _, err := executor.Store.AppendLifecycle(ctx, issue, uint32(rvboxv1.CommandLifecycle_COMMAND_RUNNING), revision, "process started", executor.Now()); err != nil {
if _, err := executor.Store.AppendLifecycleWithIdentity(ctx, issue, uint32(rvboxv1.CommandLifecycle_COMMAND_RUNNING), revision, "process started", executor.Now(), identity.WindowsIdentity()); err != nil {
_, _ = executor.Supervisor.Signal(context.Background(), process, supervisor.SignalKill)
cancel()
executor.remove(issue)
@@ -187,22 +191,30 @@ func (executor *Executor) watch(ctx context.Context, issue domain.UUID, revision
if waitErr != nil {
detail = boundedError(waitErr)
}
if err := executor.appendLifecycle(context.Background(), issue, revision, phase, detail); err == nil {
if err := executor.appendLifecycleWithIdentity(context.Background(), issue, revision, phase, detail, process.Identity().WindowsIdentity()); err == nil {
executor.notify(issue)
}
executor.remove(issue)
}
func (executor *Executor) appendLifecycle(ctx context.Context, issue domain.UUID, revision uint64, phase rvboxv1.CommandLifecycle, detail string) error {
_, err := executor.Store.AppendLifecycle(ctx, issue, uint32(phase), revision, detail, executor.Now())
return executor.appendLifecycleWithIdentity(ctx, issue, revision, phase, detail, nil)
}
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)
return err
}
func (executor *Executor) reject(ctx context.Context, issue domain.UUID, revision uint64, cause error) error {
return executor.rejectWithIdentity(ctx, issue, revision, cause, nil)
}
func (executor *Executor) rejectWithIdentity(ctx context.Context, issue domain.UUID, revision uint64, cause error, identity *rvboxv1.WindowsExecutionIdentity) error {
if revision == 0 {
return cause
}
if err := executor.appendLifecycle(ctx, issue, revision, rvboxv1.CommandLifecycle_COMMAND_REJECTED, boundedError(cause)); err != nil {
if err := executor.appendLifecycleWithIdentity(ctx, issue, revision, rvboxv1.CommandLifecycle_COMMAND_REJECTED, boundedError(cause), identity); err != nil {
return err
}
executor.notify(issue)
+24 -10
View File
@@ -94,12 +94,12 @@ func Run(ctx context.Context, options RunnerOptions) error {
if err := waitUntil(ctx, delay); err != nil {
return nil
}
sessionStarted := time.Now()
sessionStarted := options.Now()
_ = runOnce(ctx, options)
if ctx.Err() != nil {
return nil
}
if time.Since(sessionStarted) >= options.Backoff.StableReset {
if options.Now().Sub(sessionStarted) >= options.Backoff.StableReset {
failures = 0
}
if failures < ^uint32(0) {
@@ -357,11 +357,11 @@ func serveActive(ctx context.Context, transport Transport, options RunnerOptions
return err
}
case envelope.GetScriptChunk() != nil:
if err := handleScriptChunk(ctx, transport, options.Store, session, envelope.GetScriptChunk(), limits); err != nil {
if err := handleScriptChunk(ctx, transport, options.Store, session, envelope.GetScriptChunk(), limits, options.Now, sent); err != nil {
return err
}
case envelope.GetScriptCommit() != nil:
if err := handleScriptCommit(ctx, transport, options.Store, session, envelope.GetScriptCommit(), limits); err != nil {
if err := handleScriptCommit(ctx, transport, options.Store, session, envelope.GetScriptCommit(), limits, options.Now, sent); err != nil {
return err
}
if options.OnScriptReady != nil {
@@ -488,28 +488,38 @@ func boundedError(err error) string {
return message
}
func handleScriptChunk(ctx context.Context, transport Transport, store *spool.Store, session Session, chunk *rvboxv1.ScriptChunk, limits agentproto.Limits) error {
func handleScriptChunk(ctx context.Context, transport Transport, store *spool.Store, session Session, chunk *rvboxv1.ScriptChunk, limits agentproto.Limits, now func() time.Time, sent map[domain.UUID]uint64) error {
status, err := ApplyScriptChunk(ctx, store, session, chunk, limits)
if err != nil {
return err
}
return appendAndSendScriptStatus(ctx, transport, store, session, chunk.GetIssueUuid(), status, limits)
return appendAndSendScriptStatus(ctx, transport, store, session, chunk.GetIssueUuid(), status, limits, now, sent)
}
func handleScriptCommit(ctx context.Context, transport Transport, store *spool.Store, session Session, commit *rvboxv1.ScriptCommit, limits agentproto.Limits) error {
func handleScriptCommit(ctx context.Context, transport Transport, store *spool.Store, session Session, commit *rvboxv1.ScriptCommit, limits agentproto.Limits, now func() time.Time, sent map[domain.UUID]uint64) error {
status, err := ApplyScriptCommit(ctx, store, session, commit, limits)
if err != nil {
return err
}
return appendAndSendScriptStatus(ctx, transport, store, session, commit.GetIssueUuid(), status, limits)
return appendAndSendScriptStatus(ctx, transport, store, session, commit.GetIssueUuid(), status, limits, now, sent)
}
func appendAndSendScriptStatus(ctx context.Context, transport Transport, store *spool.Store, session Session, issueText string, status spool.ScriptStatus, limits agentproto.Limits) error {
func appendAndSendScriptStatus(ctx context.Context, transport Transport, store *spool.Store, session Session, issueText string, status spool.ScriptStatus, limits agentproto.Limits, now func() time.Time, sent map[domain.UUID]uint64) error {
issue, err := domain.ParseUUIDv7(issueText)
if err != nil {
return err
}
event := &rvboxv1.CommandEvent{IssueUuid: issueText, ObservedAt: timestamppb.New(time.Now().UTC()), Payload: &rvboxv1.CommandEvent_ScriptStatus{ScriptStatus: &rvboxv1.ScriptUploadStatus{ReceivedBytes: status.ReceivedBytes, Complete: status.Committed}}}
if sent == nil {
sent = make(map[domain.UUID]uint64)
}
if now == nil {
now = func() time.Time { return time.Now().UTC() }
}
observedAt := now()
if observedAt.IsZero() {
return errors.New("script status clock returned zero")
}
event := &rvboxv1.CommandEvent{IssueUuid: issueText, ObservedAt: timestamppb.New(observedAt), Payload: &rvboxv1.CommandEvent_ScriptStatus{ScriptStatus: &rvboxv1.ScriptUploadStatus{ReceivedBytes: status.ReceivedBytes, Complete: status.Committed}}}
payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(event)
if err != nil {
return err
@@ -522,9 +532,13 @@ func appendAndSendScriptStatus(ctx context.Context, transport Transport, store *
return err
}
for _, item := range assigned {
if item.EventSeq == 0 || item.EventSeq <= sent[issue] {
continue
}
if err := SendStoredEvent(ctx, transport, session, item, limits); err != nil {
return err
}
sent[issue] = item.EventSeq
}
return nil
}
+22 -3
View File
@@ -267,14 +267,25 @@ func (store *Store) AssignSendWindow(ctx context.Context, issueUUID domain.UUID,
return nil, err
}
defer tx.Rollback()
var next uint64
err = tx.QueryRowContext(ctx, `SELECT next_event_seq FROM commands WHERE issue_uuid = ?`, issueUUID[:]).Scan(&next)
var next, lastAck, assignedBytes uint64
err = tx.QueryRowContext(ctx, `SELECT next_event_seq, last_server_ack,
COALESCE((SELECT sum(length(payload)) FROM events
WHERE issue_uuid = commands.issue_uuid AND event_seq IS NOT NULL AND event_seq > commands.last_server_ack), 0)
FROM commands WHERE issue_uuid = ?`, issueUUID[:]).Scan(&next, &lastAck, &assignedBytes)
if err == sql.ErrNoRows {
return nil, ErrUnknownCommand
}
if err != nil {
return nil, err
}
// Assigned rows remain pinned until cumulative acknowledgement. Never
// assign another row while that durable send window is full; otherwise a
// burst of local events could grow the pinned set without bound even though
// callers pass a per-command byte limit on every invocation.
if assignedBytes >= maximumStoredBytes {
return eventsBySequence(ctx, tx, issueUUID, 0)
}
maximumStoredBytes -= assignedBytes
rows, err := tx.QueryContext(ctx, `SELECT local_ordinal, length(payload) FROM events WHERE issue_uuid = ? AND event_seq IS NULL ORDER BY local_ordinal LIMIT ?`, issueUUID[:], maximumEvents)
if err != nil {
return nil, err
@@ -475,6 +486,14 @@ func (store *Store) RecoverLaunchUncertainty(ctx context.Context, now time.Time)
// crash between a terminal marker and its public event from creating a state
// that can be replayed as a second execution.
func (store *Store) AppendLifecycle(ctx context.Context, issueUUID domain.UUID, phase uint32, revision uint64, detail string, observedAt time.Time) (Event, error) {
return store.AppendLifecycleWithIdentity(ctx, issueUUID, phase, revision, detail, observedAt, nil)
}
// AppendLifecycleWithIdentity is the lifecycle boundary used by the client
// runtime when the Windows supervisor has captured an immutable selection
// record. Keeping the legacy wrapper above preserves the small store API for
// recovery and tests that have no platform identity to attach.
func (store *Store) AppendLifecycleWithIdentity(ctx context.Context, issueUUID domain.UUID, phase uint32, revision uint64, detail string, observedAt time.Time, identity *rvboxv1.WindowsExecutionIdentity) (Event, error) {
if !validUUID(issueUUID) || phase == 0 || phase > 11 || observedAt.IsZero() || revision == 0 {
return Event{}, errors.New("invalid lifecycle event")
}
@@ -503,7 +522,7 @@ func (store *Store) AppendLifecycle(ctx context.Context, issueUUID domain.UUID,
if !domain.CanTransition(rvboxv1.CommandLifecycle(current), rvboxv1.CommandLifecycle(phase)) {
return Event{}, fmt.Errorf("invalid lifecycle transition %s -> %s", rvboxv1.CommandLifecycle(current), rvboxv1.CommandLifecycle(phase))
}
lifecycle := &rvboxv1.LifecycleChange{Lifecycle: rvboxv1.CommandLifecycle(phase), CommandRevision: revision, Detail: detail}
lifecycle := &rvboxv1.LifecycleChange{Lifecycle: rvboxv1.CommandLifecycle(phase), CommandRevision: revision, Detail: detail, WindowsExecutionIdentity: identity}
payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(&rvboxv1.CommandEvent{IssueUuid: issueUUID.String(), ObservedAt: timestamppb.New(observedAt), Payload: &rvboxv1.CommandEvent_Lifecycle{Lifecycle: lifecycle}})
if err != nil {
return Event{}, err
+42 -1
View File
@@ -13,6 +13,7 @@ import (
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
"github.com/rvbox/rvbox/internal/domain"
"google.golang.org/protobuf/proto"
)
func TestExecutionSpecIsDurableAndQuotaCounted_HP_DISPATCH_07(t *testing.T) {
@@ -188,6 +189,38 @@ func TestSpoolAcceptanceSequencingAndAck_HP_CLIENT_07(t *testing.T) {
}
}
func TestSendWindowPinsUnacknowledgedBytes_BH_CLIENT_13(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-000000000014")
command := testCommand(issue, []byte("send-window"))
now := time.Date(2026, time.September, 6, 12, 0, 0, 0, time.UTC)
if _, err := store.AcceptCommand(ctx, command, now); err != nil {
t.Fatal(err)
}
for index, payload := range [][]byte{[]byte("first"), []byte("second")} {
if _, err := store.AppendEvent(ctx, issue, EventInput{Kind: uint32(index + 1), Compression: 1, Payload: payload, CreatedAt: now.Add(time.Duration(index) * time.Second)}); err != nil {
t.Fatal(err)
}
}
assigned, err := store.AssignSendWindow(ctx, issue, 1, uint64(len("first")))
if err != nil || len(assigned) != 1 || assigned[0].EventSeq != 1 {
t.Fatalf("first bounded window = %#v, %v", assigned, err)
}
assigned, err = store.AssignSendWindow(ctx, issue, 1, uint64(len("first")))
if err != nil || len(assigned) != 1 || assigned[0].EventSeq != 1 {
t.Fatalf("full pinned window assigned more data = %#v, %v", assigned, err)
}
if err := store.Ack(ctx, issue, 1); err != nil {
t.Fatal(err)
}
assigned, err = store.AssignSendWindow(ctx, issue, 1, uint64(len("second")))
if err != nil || len(assigned) != 1 || assigned[0].EventSeq != 2 {
t.Fatalf("window after cumulative ack = %#v, %v", assigned, err)
}
}
func TestAppendLifecycleAtomicallyUpdatesPhaseAndEvent_HP_CLIENT_11(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -199,7 +232,9 @@ func TestAppendLifecycleAtomicallyUpdatesPhaseAndEvent_HP_CLIENT_11(t *testing.T
if _, err := store.AcceptCommand(ctx, command, now); err != nil {
t.Fatal(err)
}
if _, err := store.AppendLifecycle(ctx, issue, uint32(rvboxv1.CommandLifecycle_COMMAND_RUNNING), 1, "launch authorized", now); err != nil {
effective := rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER
identity := &rvboxv1.WindowsExecutionIdentity{EffectiveContext: &effective, SessionId: ptrUint32(1), SessionUserSid: "S-1-5-21-user", EffectiveUserSid: "S-1-5-21-user", AttemptedContexts: []rvboxv1.WindowsExecutionContext{effective}, SelectionDetail: "selected"}
if _, err := store.AppendLifecycleWithIdentity(ctx, issue, uint32(rvboxv1.CommandLifecycle_COMMAND_RUNNING), 1, "launch authorized", now, identity); err != nil {
t.Fatal(err)
}
if _, err := store.AppendLifecycle(ctx, issue, uint32(rvboxv1.CommandLifecycle_COMMAND_SUCCEEDED), 1, "exit 0", now.Add(time.Second)); err != nil {
@@ -223,8 +258,14 @@ func TestAppendLifecycleAtomicallyUpdatesPhaseAndEvent_HP_CLIENT_11(t *testing.T
if err != nil || len(events) != 2 {
t.Fatalf("lifecycle events = %#v, %v", events, err)
}
var decoded rvboxv1.CommandEvent
if err := proto.Unmarshal(events[0].Payload, &decoded); err != nil || decoded.GetLifecycle().GetWindowsExecutionIdentity().GetEffectiveContext() != effective {
t.Fatalf("running identity event = %v, %v", decoded.GetLifecycle(), err)
}
}
func ptrUint32(value uint32) *uint32 { return &value }
func TestTerminalCleanupTombstonesAndConflicts_BH_CLIENT_02(t *testing.T) {
t.Parallel()
ctx := context.Background()
+72 -6
View File
@@ -17,6 +17,29 @@ var (
ErrUnsupported = errors.New("supervisor operation is unsupported")
)
// StartError carries durable context-selection evidence when a command is
// rejected before a process exists. The runtime records the evidence with
// the rejection event without treating the error as a second public protocol
// type. Unwrap keeps ordinary retry/error classification working.
type StartError struct {
Cause error
WindowsIdentity *rvboxv1.WindowsExecutionIdentity
}
func (err *StartError) Error() string {
if err == nil || err.Cause == nil {
return "supervisor start failed"
}
return err.Cause.Error()
}
func (err *StartError) Unwrap() error {
if err == nil {
return nil
}
return err.Cause
}
type SignalKind uint8
const (
@@ -53,12 +76,55 @@ func (spec StartSpec) Validate() error {
// EffectiveIdentity is immutable process evidence captured before launch.
// Empty user/session fields mean a Session 0 service context.
type EffectiveIdentity struct {
Context string
SessionID uint32
UserSID string
LogonSID string
Elevated bool
Integrity string
Context string
SessionID uint32
SessionUserSID string
UserSID string
LogonSID string
Elevated bool
Integrity string
AttemptedContexts []string
SelectionDetail string
}
// WindowsIdentity converts the platform-neutral evidence to the public
// immutable status/event shape. Unknown contexts are deliberately omitted so
// the portable test supervisor never pretends to be a Windows launch.
func (identity EffectiveIdentity) WindowsIdentity() *rvboxv1.WindowsExecutionIdentity {
result := &rvboxv1.WindowsExecutionIdentity{SessionUserSid: identity.SessionUserSID, EffectiveUserSid: identity.UserSID, SelectionDetail: identity.SelectionDetail}
for _, contextName := range identity.AttemptedContexts {
if context, ok := windowsExecutionContext(contextName); ok {
result.AttemptedContexts = append(result.AttemptedContexts, context)
}
}
if context, ok := windowsExecutionContext(identity.Context); ok {
result.EffectiveContext = &context
if context == rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER || context == rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER_ELEVATED || context == rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_SYSTEM {
session := identity.SessionID
result.SessionId = &session
}
}
if result.EffectiveContext == nil && len(result.AttemptedContexts) == 0 && result.SelectionDetail == "" && result.SessionUserSid == "" && result.EffectiveUserSid == "" {
return nil
}
return result
}
func windowsExecutionContext(value string) (rvboxv1.WindowsExecutionContext, bool) {
switch value {
case "LOCAL_SERVICE":
return rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_LOCAL_SERVICE, true
case "LOCAL_SYSTEM":
return rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_LOCAL_SYSTEM, true
case "ACTIVE_USER":
return rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER, true
case "ACTIVE_USER_ELEVATED":
return rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER_ELEVATED, true
case "ACTIVE_SYSTEM":
return rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_SYSTEM, true
default:
return rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_UNSPECIFIED, false
}
}
type Process interface {
@@ -30,3 +30,25 @@ func TestStartSpecValidation_BH_SUPERVISOR_01(t *testing.T) {
t.Fatal(err)
}
}
func TestWindowsIdentityMapsSelectionEvidence_HP_SUPERVISOR_05(t *testing.T) {
t.Parallel()
identity := EffectiveIdentity{
Context: "ACTIVE_SYSTEM",
SessionID: 7,
SessionUserSID: "S-1-5-21-user",
UserSID: "S-1-5-18",
AttemptedContexts: []string{"ACTIVE_USER_ELEVATED", "ACTIVE_SYSTEM"},
SelectionDetail: "ACTIVE_USER_ELEVATED: ELEVATION_UNAVAILABLE",
}
encoded := identity.WindowsIdentity()
if encoded == nil || encoded.GetEffectiveContext() != rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_SYSTEM || encoded.GetSessionId() != 7 || encoded.GetSessionUserSid() != "S-1-5-21-user" || encoded.GetEffectiveUserSid() != "S-1-5-18" {
t.Fatalf("mapped identity = %#v", encoded)
}
if len(encoded.GetAttemptedContexts()) != 2 || encoded.GetAttemptedContexts()[0] != rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER_ELEVATED {
t.Fatalf("attempted contexts = %v", encoded.GetAttemptedContexts())
}
if got := (EffectiveIdentity{AttemptedContexts: []string{"not-a-context"}}).WindowsIdentity(); got != nil {
t.Fatalf("unknown context evidence = %#v", got)
}
}
@@ -35,12 +35,26 @@ var (
type NativeOptions struct {
Shells ShellPaths
WorkRoot string
JobProfiles map[string]JobProfile
MaxWrapperBytes uint64
MaxOutputChunk uint64
WindowsTermGrace time.Duration
Now func() time.Time
}
// JobProfile is the validated administrator policy for one protocol profile.
// The native Windows adapter applies only controls it can set and read back
// atomically on a Job Object; a requested unsupported required control rejects
// the command before any child is created.
type JobProfile struct {
RequiredControls []string
CPUPercent uint64
MemoryMaxBytes uint64
PIDsMax uint64
IOReadBPS uint64
IOWriteBPS uint64
}
func (options NativeOptions) withDefaults() NativeOptions {
if options.MaxWrapperBytes == 0 {
options.MaxWrapperBytes = 10 << 20
@@ -69,17 +83,19 @@ func newExecSupervisor(options NativeOptions) *execSupervisor {
}
type execProcess struct {
issue domain.UUID
identity supervisor.EffectiveIdentity
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
outputs chan outputResult
done chan struct{}
started time.Time
waitFn func() (int32, bool, error)
killFn func(uint32) error
issue domain.UUID
identity supervisor.EffectiveIdentity
cmd *exec.Cmd
pid uint32
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
outputs chan outputResult
done chan struct{}
started time.Time
waitFn func() (int32, bool, error)
killFn func(uint32) error
snapshotFn func() (supervisor.ResourceSnapshot, error)
mu sync.Mutex
finished bool
@@ -219,7 +235,7 @@ func (process *execProcess) startReaders(maxChunk uint64, remove func()) {
}
func materializeWrapper(directory string, wrapper Wrapper, now time.Time) (string, func(), error) {
if directory == "" || !now.IsZero() && now.Location() == nil {
if directory == "" {
return "", nil, ErrInvalidWorkingDirectory
}
if err := os.MkdirAll(directory, 0o700); err != nil {
@@ -441,7 +457,11 @@ func (manager *execSupervisor) startCommand(ctx context.Context, spec supervisor
}
func (manager *execSupervisor) registerProcess(issue domain.UUID, identity supervisor.EffectiveIdentity, command *exec.Cmd, stdin io.WriteCloser, stdout, stderr io.ReadCloser, started time.Time, waitFn func() (int32, bool, error), killFn func(uint32) error, cleanup func()) *execProcess {
process := &execProcess{issue: issue, identity: identity, cmd: command, stdin: stdin, stdout: stdout, stderr: stderr, outputs: make(chan outputResult, 32), done: make(chan struct{}), started: started, waitFn: waitFn, killFn: killFn}
var pid uint32
if command != nil && command.Process != nil && command.Process.Pid > 0 {
pid = uint32(command.Process.Pid)
}
process := &execProcess{issue: issue, identity: identity, cmd: command, pid: pid, stdin: stdin, stdout: stdout, stderr: stderr, outputs: make(chan outputResult, 32), done: make(chan struct{}), started: started, waitFn: waitFn, killFn: killFn}
manager.mu.Lock()
manager.active[issue] = process
manager.mu.Unlock()
@@ -13,6 +13,7 @@ import (
"fmt"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
@@ -27,11 +28,18 @@ const (
logon32LogonService = 5
logon32ProviderDefault = 0
securitySystemRID = "S-1-5-18"
disableMaxPrivilege = 0x1
)
var (
advapi32 = syscall.NewLazyDLL("advapi32.dll")
procLogonUserW = advapi32.NewProc("LogonUserW")
advapi32 = syscall.NewLazyDLL("advapi32.dll")
procLogonUserW = advapi32.NewProc("LogonUserW")
procCreateRestrictedToken = advapi32.NewProc("CreateRestrictedToken")
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procAttachConsole = kernel32.NewProc("AttachConsole")
procFreeConsole = kernel32.NewProc("FreeConsole")
procGenerateCtrlEvent = kernel32.NewProc("GenerateConsoleCtrlEvent")
procSetCtrlHandler = kernel32.NewProc("SetConsoleCtrlHandler")
)
func stdinLineEnding() []byte { return []byte{'\r', '\n'} }
@@ -130,6 +138,10 @@ func (manager *execSupervisor) Start(ctx context.Context, spec supervisor.StartS
closeFiles()
return fail(fmt.Errorf("create command Job: %w", err))
}
if err := applyJobProfiles(job, manager.options.JobProfiles, spec.ExecutionProfiles); err != nil {
_ = winapi.CloseHandle(job)
return fail(err)
}
cleanupJob := true
defer func() {
if cleanupJob {
@@ -214,6 +226,9 @@ func (manager *execSupervisor) Start(ctx context.Context, spec supervisor.StartS
return winapi.TerminateJobObject(job, code)
}
process := manager.registerProcess(spec.IssueUUID, identity, command, stdinWrite, stdoutRead, stderrRead, started, waitFn, killFn, cleanup)
process.snapshotFn = func() (supervisor.ResourceSnapshot, error) {
return queryJobSnapshot(job, manager.options.Now())
}
return process, nil
}
@@ -280,6 +295,120 @@ func createKillOnCloseJob() (winapi.Handle, error) {
return job, nil
}
// applyJobProfiles combines the requested dimensions and applies them before
// process creation. The profile names originate from the validated protobuf
// ExecutionSpec; unknown names and any required control without a native
// implementation are permanent pre-launch failures.
func applyJobProfiles(job winapi.Handle, configured map[string]JobProfile, requested []string) error {
if len(requested) == 0 {
return nil
}
var combined JobProfile
for _, name := range requested {
profile, ok := configured[name]
if !ok {
return fmt.Errorf("%w: execution profile %q is not configured", supervisor.ErrUnsupported, name)
}
for _, required := range profile.RequiredControls {
switch required {
case "cpu", "memory", "pids":
case "io":
return fmt.Errorf("%w: Windows Job I/O rate control is not available in this build", supervisor.ErrUnsupported)
default:
return fmt.Errorf("%w: unknown required Job control %q", supervisor.ErrUnsupported, required)
}
}
if profile.CPUPercent > combined.CPUPercent {
combined.CPUPercent = profile.CPUPercent
}
if profile.MemoryMaxBytes > 0 && (combined.MemoryMaxBytes == 0 || profile.MemoryMaxBytes < combined.MemoryMaxBytes) {
combined.MemoryMaxBytes = profile.MemoryMaxBytes
}
if profile.PIDsMax > 0 && (combined.PIDsMax == 0 || profile.PIDsMax < combined.PIDsMax) {
combined.PIDsMax = profile.PIDsMax
}
if profile.IOReadBPS > 0 || profile.IOWriteBPS > 0 {
return fmt.Errorf("%w: Windows Job I/O rate control is not available in this build", supervisor.ErrUnsupported)
}
}
limits := winapi.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
limits.BasicLimitInformation.LimitFlags = winapi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if combined.MemoryMaxBytes > 0 {
if uint64(uintptr(combined.MemoryMaxBytes)) != combined.MemoryMaxBytes {
return fmt.Errorf("%w: memory profile exceeds native pointer size", supervisor.ErrUnsupported)
}
limits.ProcessMemoryLimit = uintptr(combined.MemoryMaxBytes)
limits.BasicLimitInformation.LimitFlags |= winapi.JOB_OBJECT_LIMIT_PROCESS_MEMORY
}
if combined.PIDsMax > 0 {
if combined.PIDsMax > uint64(^uint32(0)) {
return fmt.Errorf("%w: process-count profile exceeds Windows limit", supervisor.ErrUnsupported)
}
limits.BasicLimitInformation.ActiveProcessLimit = uint32(combined.PIDsMax)
limits.BasicLimitInformation.LimitFlags |= winapi.JOB_OBJECT_LIMIT_ACTIVE_PROCESS
}
if _, err := winapi.SetInformationJobObject(job, winapi.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))); err != nil {
return fmt.Errorf("apply Windows Job limits: %w", err)
}
if combined.CPUPercent > 0 {
// The config contract expresses CPU allowance as a percentage of one
// logical CPU (for example 200 means two logical CPUs). Windows Job
// CpuRate is hundredths of a percentage of the whole machine, so scale
// by the active processor count before applying the hard cap. A profile
// larger than this host is intentionally capped at the host capacity,
// which means it imposes no additional CPU restriction but remains a
// valid, atomically verified profile.
processors := uint64(winapi.GetActiveProcessorCount(winapi.ALL_PROCESSOR_GROUPS))
if processors == 0 {
return fmt.Errorf("%w: Windows did not report an active processor count", supervisor.ErrUnsupported)
}
if combined.CPUPercent > (^uint64(0)-processors+1)/100 {
return fmt.Errorf("%w: CPU profile %d%% overflows Windows Job rate conversion", supervisor.ErrUnsupported, combined.CPUPercent)
}
cpuRate := (combined.CPUPercent*100 + processors - 1) / processors
if cpuRate > 10000 {
cpuRate = 10000
}
cpu := struct {
ControlFlags uint32
CPUrate uint32
Weight uint32
}{ControlFlags: 0x1 | 0x4 /* ENABLE | HARD_CAP */, CPUrate: uint32(cpuRate)}
if _, err := winapi.SetInformationJobObject(job, winapi.JobObjectCpuRateControlInformation, uintptr(unsafe.Pointer(&cpu)), uint32(unsafe.Sizeof(cpu))); err != nil {
return fmt.Errorf("apply Windows Job CPU limit: %w", err)
}
var cpuReadback struct {
ControlFlags uint32
CPUrate uint32
Weight uint32
}
var cpuReturned uint32
if err := winapi.QueryInformationJobObject(job, int32(winapi.JobObjectCpuRateControlInformation), uintptr(unsafe.Pointer(&cpuReadback)), uint32(unsafe.Sizeof(cpuReadback)), &cpuReturned); err != nil {
return fmt.Errorf("verify Windows Job CPU limit: %w", err)
}
if cpuReadback.CPUrate != cpu.CPUrate || cpuReadback.ControlFlags&0x5 != 0x5 {
return errors.New("Windows Job CPU limit did not read back as requested")
}
}
// Read back every requested limit before authorization. This catches
// policy restrictions and unsupported Job implementations early.
var readback winapi.JOBOBJECT_EXTENDED_LIMIT_INFORMATION
var returned uint32
if err := winapi.QueryInformationJobObject(job, int32(winapi.JobObjectExtendedLimitInformation), uintptr(unsafe.Pointer(&readback)), uint32(unsafe.Sizeof(readback)), &returned); err != nil {
return fmt.Errorf("verify Windows Job limits: %w", err)
}
if readback.BasicLimitInformation.LimitFlags&winapi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0 {
return errors.New("Windows Job lost kill-on-close protection")
}
if combined.MemoryMaxBytes > 0 && uint64(readback.ProcessMemoryLimit) != combined.MemoryMaxBytes {
return errors.New("Windows Job memory limit did not read back as requested")
}
if combined.PIDsMax > 0 && uint64(readback.BasicLimitInformation.ActiveProcessLimit) != combined.PIDsMax {
return errors.New("Windows Job process limit did not read back as requested")
}
return nil
}
func (manager *execSupervisor) selectToken(elevated bool) (winapi.Token, supervisor.EffectiveIdentity, error) {
candidates, err := DiscoverActiveSessions()
if err != nil {
@@ -288,11 +417,39 @@ func (manager *execSupervisor) selectToken(elevated bool) (winapi.Token, supervi
candidates = nil
}
selection := Select(SelectionInput{Elevated: elevated, ActiveSessions: candidates, ActiveSystemAvailable: true, LocalServiceAvailable: true, LocalSystemAvailable: true})
attempted := make([]string, 0, len(selection.Attempts)+1)
details := make([]string, 0, len(selection.Attempts)+1)
addAttempt := func(contextName ExecutionContext, detail string) {
for _, existing := range attempted {
if existing == string(contextName) {
if detail != "" {
details = append(details, string(contextName)+": "+detail)
}
return
}
}
attempted = append(attempted, string(contextName))
if detail != "" {
details = append(details, string(contextName)+": "+detail)
}
}
for _, attempt := range selection.Attempts {
addAttempt(attempt.Context, string(attempt.Reason))
}
withEvidence := func(identity supervisor.EffectiveIdentity) supervisor.EffectiveIdentity {
identity.AttemptedContexts = append([]string(nil), attempted...)
identity.SelectionDetail = boundSelectionDetail(strings.Join(details, "; "))
return identity
}
rejection := func(cause error) error {
identity := withEvidence(supervisor.EffectiveIdentity{})
return &supervisor.StartError{Cause: cause, WindowsIdentity: identity.WindowsIdentity()}
}
if selection.Effective == nil {
if selection.Error != nil {
return 0, supervisor.EffectiveIdentity{}, selection.Error
return 0, supervisor.EffectiveIdentity{}, rejection(selection.Error)
}
return 0, supervisor.EffectiveIdentity{}, errors.New("Windows execution context selection failed")
return 0, supervisor.EffectiveIdentity{}, rejection(errors.New("Windows execution context selection failed"))
}
var selected *SessionCandidate
if selection.Effective.SessionID != nil {
@@ -306,10 +463,11 @@ func (manager *execSupervisor) selectToken(elevated bool) (winapi.Token, supervi
for _, attempt := range selection.Attempts {
token, identity, err := openTokenForAttempt(attempt.Context, selected)
if err == nil {
return token, identity, nil
return token, withEvidence(identity), nil
}
addAttempt(attempt.Context, "native preparation failed: "+err.Error())
if !elevated {
return 0, supervisor.EffectiveIdentity{}, err
return 0, supervisor.EffectiveIdentity{}, rejection(err)
}
}
// The pure selector stops as soon as ACTIVE_SYSTEM is available. A native
@@ -317,11 +475,22 @@ func (manager *execSupervisor) selectToken(elevated bool) (winapi.Token, supervi
// removed), so the final LOCAL_SYSTEM fallback is attempted here before
// launch preparation, never by retrying a created process.
if elevated {
addAttempt(ContextLocalSystem, "fallback")
if token, identity, err := openTokenForAttempt(ContextLocalSystem, nil); err == nil {
return token, identity, nil
return token, withEvidence(identity), nil
} else {
addAttempt(ContextLocalSystem, "native preparation failed: "+err.Error())
return 0, supervisor.EffectiveIdentity{}, rejection(err)
}
}
return 0, supervisor.EffectiveIdentity{}, errors.New("all Windows execution contexts failed before launch preparation")
return 0, supervisor.EffectiveIdentity{}, rejection(errors.New("all Windows execution contexts failed before launch preparation"))
}
func boundSelectionDetail(detail string) string {
if len(detail) <= 4096 {
return detail
}
return detail[:4096]
}
func openTokenForAttempt(contextName ExecutionContext, candidate *SessionCandidate) (winapi.Token, supervisor.EffectiveIdentity, error) {
@@ -350,12 +519,27 @@ func openTokenForAttempt(contextName ExecutionContext, candidate *SessionCandida
return 0, supervisor.EffectiveIdentity{}, err
}
identity.Context = string(ContextActiveSystem)
identity.SessionUserSID = candidate.UserSID
identity.LogonSID = candidate.LogonSID
return serviceToken, identity, nil
} else if token.IsElevated() {
// A full administrator token can be returned when UAC is disabled or
// policy supplies an already-unfiltered token. Normal commands must
// still run as that user without administrator authority; create a
// restricted medium token instead of silently falling back to a
// service identity.
restricted, err := createRestrictedMediumToken(token)
_ = token.Close()
return 0, supervisor.EffectiveIdentity{}, errors.New("active-user token is elevated and no restricted medium token was available")
if err != nil {
return 0, supervisor.EffectiveIdentity{}, err
}
token = restricted
}
identity := supervisor.EffectiveIdentity{Context: string(contextName), SessionID: candidate.SessionID, UserSID: candidate.UserSID, Elevated: contextName != ContextActiveUser, Integrity: map[bool]string{true: "high", false: "medium"}[contextName != ContextActiveUser]}
if err := verifyUserToken(token, candidate, contextName == ContextActiveUser); err != nil {
_ = token.Close()
return 0, supervisor.EffectiveIdentity{}, err
}
identity := supervisor.EffectiveIdentity{Context: string(contextName), SessionID: candidate.SessionID, SessionUserSID: candidate.UserSID, UserSID: candidate.UserSID, LogonSID: candidate.LogonSID, Elevated: contextName != ContextActiveUser, Integrity: map[bool]string{true: "high", false: "medium"}[contextName != ContextActiveUser]}
return token, identity, nil
case ContextLocalService:
token, err := logonLocalService()
@@ -370,6 +554,81 @@ func openTokenForAttempt(contextName ExecutionContext, candidate *SessionCandida
}
}
// createRestrictedMediumToken turns a full administrator token into the
// normal active-user token required for elevated=false. It disables all
// privileges, disables the built-in Administrators SID, and verifies medium
// integrity before the token is returned to the launch path.
func createRestrictedMediumToken(source winapi.Token) (winapi.Token, error) {
adminSID, err := winapi.CreateWellKnownSid(winapi.WinBuiltinAdministratorsSid)
if err != nil {
return 0, err
}
disabled := winapi.SIDAndAttributes{Sid: adminSID}
var restricted winapi.Token
result, _, callErr := procCreateRestrictedToken.Call(
uintptr(source), disableMaxPrivilege,
1, uintptr(unsafe.Pointer(&disabled)),
0, 0,
0, 0,
uintptr(unsafe.Pointer(&restricted)),
)
if result == 0 {
if callErr != syscall.Errno(0) {
return 0, callErr
}
return 0, syscall.GetLastError()
}
if err := setMediumIntegrity(restricted); err != nil {
_ = restricted.Close()
return 0, err
}
if restricted.IsElevated() {
_ = restricted.Close()
return 0, errors.New("restricted active-user token remained elevated")
}
return restricted, nil
}
func setMediumIntegrity(token winapi.Token) error {
mediumSID, err := winapi.StringToSid("S-1-16-8192")
if err != nil {
return err
}
sidLength := winapi.GetLengthSid(mediumSID)
headerSize := uint32(unsafe.Sizeof(winapi.Tokenmandatorylabel{}))
buffer := make([]byte, headerSize+sidLength)
label := (*winapi.Tokenmandatorylabel)(unsafe.Pointer(&buffer[0]))
label.Label.Sid = (*winapi.SID)(unsafe.Pointer(&buffer[headerSize]))
label.Label.Attributes = winapi.SE_GROUP_INTEGRITY | winapi.SE_GROUP_INTEGRITY_ENABLED
copy(buffer[headerSize:], unsafe.Slice((*byte)(unsafe.Pointer(mediumSID)), sidLength))
return winapi.SetTokenInformation(token, winapi.TokenIntegrityLevel, &buffer[0], uint32(len(buffer)))
}
func verifyUserToken(token winapi.Token, candidate *SessionCandidate, normal bool) error {
user, err := token.GetTokenUser()
if err != nil || user.User.Sid == nil {
if err != nil {
return err
}
return errors.New("active token has no user SID")
}
if user.User.Sid.String() != candidate.UserSID {
return errors.New("active token user SID changed during launch selection")
}
var sessionID uint32
var returned uint32
if err := winapi.GetTokenInformation(token, winapi.TokenSessionId, (*byte)(unsafe.Pointer(&sessionID)), uint32(unsafe.Sizeof(sessionID)), &returned); err != nil {
return err
}
if returned != uint32(unsafe.Sizeof(sessionID)) || sessionID != candidate.SessionID {
return errors.New("active token session changed during launch selection")
}
if normal && token.IsElevated() {
return errors.New("normal active-user token is elevated")
}
return nil
}
func duplicateServiceToken() (winapi.Token, supervisor.EffectiveIdentity, error) {
return duplicateServiceTokenForSession(0)
}
@@ -427,19 +686,66 @@ func (manager *execSupervisor) Signal(ctx context.Context, process supervisor.Pr
return supervisor.SignalOutcome{}, errors.New("unsupported signal")
}
if signal == supervisor.SignalTerm {
// The command has its own hidden console. A full AttachConsole/control
// helper is intentionally isolated from the Job kill path; if it is not
// available, the bounded grace period ends in an explicit Job kill.
// Each command has its own hidden console. The helper path is kept in
// this short-lived call and is deliberately best-effort: a session that
// has already exited or a policy that denies AttachConsole is recorded,
// then the bounded grace period ends in an explicit Job kill.
breakDelivered, breakErr := sendControlBreak(native.pid)
if breakErr != nil && ctx.Err() != nil {
return supervisor.SignalOutcome{}, ctx.Err()
}
if breakDelivered {
select {
case <-native.done:
return supervisor.SignalOutcome{Delivered: true, Detail: "CTRL_BREAK delivered", ObservedAt: manager.options.Now()}, nil
default:
}
}
select {
case <-ctx.Done():
return supervisor.SignalOutcome{}, ctx.Err()
case <-native.done:
return supervisor.SignalOutcome{Delivered: breakDelivered, Detail: "command exited after TERM", ObservedAt: manager.options.Now()}, nil
case <-time.After(manager.options.WindowsTermGrace):
}
}
if err := native.killFn(1); err != nil {
return supervisor.SignalOutcome{}, err
}
return supervisor.SignalOutcome{Delivered: true, Escalated: signal == supervisor.SignalTerm, Detail: "Windows Job terminated", ObservedAt: manager.options.Now()}, nil
detail := "Windows Job terminated"
if signal == supervisor.SignalTerm {
detail = "CTRL_BREAK grace expired; Windows Job terminated"
}
return supervisor.SignalOutcome{Delivered: true, Escalated: signal == supervisor.SignalTerm, Detail: detail, ObservedAt: manager.options.Now()}, nil
}
// sendControlBreak is the native equivalent of the signal-helper mode. The
// production helper is normally a separate short-lived rvbox.exe invocation;
// this direct implementation keeps the same verified PID/console boundary
// for the first service build and never addresses a process by a caller-
// supplied PID. The PID comes only from execProcess metadata.
func sendControlBreak(pid uint32) (bool, error) {
if pid == 0 {
return false, errors.New("command has no verified console PID")
}
if result, _, err := procAttachConsole.Call(uintptr(pid)); result == 0 {
if err == syscall.Errno(0) {
err = syscall.GetLastError()
}
return false, err
}
defer procFreeConsole.Call()
// Prevent the service/helper itself from acting on the generated event.
procSetCtrlHandler.Call(0, 1)
defer procSetCtrlHandler.Call(0, 0)
result, _, err := procGenerateCtrlEvent.Call(1 /* CTRL_BREAK_EVENT */, 0)
if result == 0 {
if err == syscall.Errno(0) {
err = syscall.GetLastError()
}
return false, err
}
return true, nil
}
func (manager *execSupervisor) Snapshot(ctx context.Context, process supervisor.Process) (supervisor.ResourceSnapshot, error) {
@@ -450,6 +756,14 @@ func (manager *execSupervisor) Snapshot(ctx context.Context, process supervisor.
if !ok || native.cmd == nil || native.cmd.Process == nil {
return supervisor.ResourceSnapshot{}, ErrProcessNotFound
}
if native.snapshotFn != nil {
select {
case <-ctx.Done():
return supervisor.ResourceSnapshot{}, ctx.Err()
default:
}
return native.snapshotFn()
}
select {
case <-ctx.Done():
return supervisor.ResourceSnapshot{}, ctx.Err()
@@ -458,6 +772,39 @@ func (manager *execSupervisor) Snapshot(ctx context.Context, process supervisor.
return supervisor.ResourceSnapshot{ProcessCount: 1, ObservedAt: manager.options.Now(), Complete: false, Detail: "Windows Job accounting is available after native completion integration"}, nil
}
type jobBasicAndIOAccounting struct {
TotalUserTime int64
TotalKernelTime int64
ThisPeriodTotalUserTime int64
ThisPeriodTotalKernelTime int64
TotalPageFaultCount uint32
TotalProcesses uint32
ActiveProcesses uint32
TotalTerminatedProcesses uint32
IO winapi.IO_COUNTERS
}
func queryJobSnapshot(job winapi.Handle, now time.Time) (supervisor.ResourceSnapshot, error) {
if job == 0 || job == winapi.InvalidHandle {
return supervisor.ResourceSnapshot{}, ErrProcessNotFound
}
var accounting jobBasicAndIOAccounting
var returned uint32
if err := winapi.QueryInformationJobObject(job, int32(winapi.JobObjectBasicAndIoAccountingInformation), uintptr(unsafe.Pointer(&accounting)), uint32(unsafe.Sizeof(accounting)), &returned); err != nil {
return supervisor.ResourceSnapshot{}, err
}
var limits winapi.JOBOBJECT_EXTENDED_LIMIT_INFORMATION
if err := winapi.QueryInformationJobObject(job, int32(winapi.JobObjectExtendedLimitInformation), uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits)), &returned); err != nil {
return supervisor.ResourceSnapshot{}, err
}
userKernel := accounting.TotalUserTime + accounting.TotalKernelTime
var cpu time.Duration
if userKernel > 0 && userKernel <= int64(^uint64(0)>>1)/100 {
cpu = time.Duration(userKernel) * 100 * time.Nanosecond
}
return supervisor.ResourceSnapshot{CPUTime: cpu, ResidentBytes: uint64(limits.PeakJobMemoryUsed), IOReadBytes: accounting.IO.ReadTransferCount, IOWriteBytes: accounting.IO.WriteTransferCount, ProcessCount: uint64(accounting.ActiveProcesses), ObservedAt: now, Complete: accounting.ActiveProcesses == 0, Detail: "Windows Job accounting"}, nil
}
func (manager *execSupervisor) StopAll(_ context.Context) error {
manager.mu.Lock()
processes := make([]*execProcess, 0, len(manager.active))
@@ -119,6 +119,8 @@ func Transition(state State, command Command) (State, error) {
// platform implementations return ErrUnsupported on non-Windows builds.
func Install(spec InstallSpec) error { return installNative(spec) }
func Uninstall() error { return uninstallNative() }
func Configure(startup StartupMode) error { return configureNative(startup) }
func Start() error { return startNative() }
func Stop(timeoutSeconds uint32) error { return stopNative(timeoutSeconds) }
func Restart(timeoutSeconds uint32) error { return restartNative(timeoutSeconds) }
func Run(run func(context.Context) error) error { return runNative(run) }
@@ -6,6 +6,8 @@ import "context"
func installNative(InstallSpec) error { return ErrUnsupported }
func uninstallNative() error { return ErrUnsupported }
func configureNative(StartupMode) error { return ErrUnsupported }
func startNative() error { return ErrUnsupported }
func stopNative(uint32) error { return ErrUnsupported }
func restartNative(uint32) error { return ErrUnsupported }
func runNative(func(context.Context) error) error { return ErrUnsupported }
@@ -10,6 +10,7 @@ import (
"time"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
)
@@ -67,6 +68,9 @@ func installNative(spec InstallSpec) error {
if err := service.Start(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) {
return fmt.Errorf("start %s service: %w", Name, err)
}
if err := registerTray(spec); err != nil {
return fmt.Errorf("register per-user tray: %w", err)
}
return nil
}
@@ -75,6 +79,7 @@ func serviceImage(spec InstallSpec) string {
}
func uninstallNative() error {
var serviceErr error
manager, err := connect()
if err != nil {
return fmt.Errorf("connect to service control manager: %w", err)
@@ -82,17 +87,79 @@ func uninstallNative() error {
defer manager.Disconnect()
service, err := manager.OpenService(Name)
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
return nil
serviceErr = nil
} else if err != nil {
return fmt.Errorf("open %s service: %w", Name, err)
} else {
defer service.Close()
if err := stopService(service, 30*time.Second); err != nil {
return err
}
if err := service.Delete(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) {
return fmt.Errorf("delete %s service: %w", Name, err)
}
}
if err := removeTrayRegistration(); err != nil {
return fmt.Errorf("remove per-user tray: %w", err)
}
return serviceErr
}
func configureNative(startup StartupMode) error {
if startup != StartupAutomatic && startup != StartupManual {
return ErrInvalidInstallSpec
}
manager, err := connect()
if err != nil {
return fmt.Errorf("connect to service control manager: %w", err)
}
defer manager.Disconnect()
service, err := manager.OpenService(Name)
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
return fmt.Errorf("%s service is not installed", Name)
}
if err != nil {
return fmt.Errorf("open %s service: %w", Name, err)
}
defer service.Close()
if err := stopService(service, 30*time.Second); err != nil {
configuration, err := service.Config()
if err != nil {
return fmt.Errorf("query %s service configuration: %w", Name, err)
}
if startup == StartupAutomatic {
configuration.StartType = mgr.StartAutomatic
} else {
configuration.StartType = mgr.StartManual
}
if err := service.UpdateConfig(configuration); err != nil {
return fmt.Errorf("update %s startup type: %w", Name, err)
}
return nil
}
const trayRunValue = "RVBoxTray"
func registerTray(spec InstallSpec) error {
key, _, err := registry.CreateKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
if err != nil {
return err
}
if err := service.Delete(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) {
return fmt.Errorf("delete %s service: %w", Name, err)
defer key.Close()
image := syscall.EscapeArg(spec.ExecutablePath) + " --tray --config " + syscall.EscapeArg(spec.ConfigPath)
return key.SetStringValue(trayRunValue, image)
}
func removeTrayRegistration() error {
key, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
if errors.Is(err, registry.ErrNotExist) {
return nil
}
if err != nil {
return err
}
defer key.Close()
if err := key.DeleteValue(trayRunValue); err != nil && !errors.Is(err, registry.ErrNotExist) {
return err
}
return nil
}
@@ -138,6 +205,13 @@ func stopNative(timeoutSeconds uint32) error {
return stopService(service, timeout)
}
func restartNative(timeoutSeconds uint32) error {
if err := stopNative(timeoutSeconds); err != nil {
return err
}
return startNative()
}
func stopService(service *mgr.Service, timeout time.Duration) error {
status, err := service.Query()
if err != nil {
+16 -1
View File
@@ -5,12 +5,25 @@ package windowstray
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"unicode/utf8"
)
// PipeName is the machine-local service endpoint used by every tray session.
// The native implementation creates it with PIPE_REJECT_REMOTE_CLIENTS and
// an explicit SYSTEM/Administrators/interactive-user ACL. Keeping the name
// here (rather than deriving it from user input) prevents cross-session and
// path-confusion bugs.
const PipeName = `\\.\pipe\RVBoxClientTrayV1`
// Handler is invoked by the service after the native adapter has verified the
// connecting process token, SID, and session. A response is always encoded
// as ActionStatus; requests other than status deliberately carry no payload.
type Handler func(context.Context, Peer, Frame) (Frame, error)
const (
protocolVersion uint16 = 1
maxFrameBytes = 64 << 10
@@ -33,6 +46,8 @@ const (
ActionStartService
ActionStopService
ActionRestartService
ActionSetAutomatic
ActionSetManual
ActionExitTray
)
@@ -115,7 +130,7 @@ func Authorize(peer Peer, action Action) error {
switch action {
case ActionStatus, ActionOpenConfig, ActionOpenLog, ActionExitTray:
return nil
case ActionStartService, ActionStopService, ActionRestartService:
case ActionStartService, ActionStopService, ActionRestartService, ActionSetAutomatic, ActionSetManual:
if peer.Administrator || peer.System {
return nil
}
@@ -0,0 +1,20 @@
//go:build !windows
package windowstray
import (
"context"
"errors"
"io"
)
var ErrUnsupported = errors.New("Windows tray IPC is unavailable on this platform")
// Serve exists on every platform so the service wiring stays build-tag
// neutral. Unix-like client support is deferred in v1; this endpoint must
// never silently become a second client daemon.
func Serve(context.Context, Handler) error { return ErrUnsupported }
func Request(context.Context, Frame) (Frame, error) { return Frame{}, ErrUnsupported }
func Run(context.Context, io.Writer) error { return ErrUnsupported }
@@ -0,0 +1,242 @@
//go:build windows
package windowstray
// This file is the small native service endpoint. It intentionally uses one
// request per pipe connection: the tray is a presentation client, not a
// long-lived command channel, and a bounded connection makes cancellation and
// peer verification straightforward. The service never hands the tray a
// store handle or command payload.
import (
"context"
"errors"
"fmt"
"io"
"os"
"time"
"unsafe"
winapi "golang.org/x/sys/windows"
)
const (
pipeBufferBytes = 64 << 10
pipeInstances = 8
)
var (
ErrUnsupported = errors.New("Windows tray IPC is unavailable on this platform")
pipeSDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)"
)
// Serve accepts bounded tray requests until ctx is cancelled. It is safe to
// run before any interactive user logs in; in that state no client can pass
// the interactive-peer authorization check.
func Serve(ctx context.Context, handler Handler) error {
if handler == nil {
return errors.New("tray handler is required")
}
for {
pipe, err := newTrayPipe()
if err != nil {
return fmt.Errorf("create tray pipe: %w", err)
}
connected := make(chan error, 1)
go func() { connected <- winapi.ConnectNamedPipe(winapi.Handle(pipe.Fd()), nil) }()
select {
case <-ctx.Done():
_ = pipe.Close()
return nil
case err := <-connected:
if err != nil && !errors.Is(err, winapi.ERROR_PIPE_CONNECTED) {
_ = pipe.Close()
if ctx.Err() != nil {
return nil
}
continue
}
go serveTrayPipe(ctx, pipe, handler)
}
}
}
func newTrayPipe() (*os.File, error) {
name, err := winapi.UTF16PtrFromString(PipeName)
if err != nil {
return nil, err
}
descriptor, err := winapi.SecurityDescriptorFromString(pipeSDDL)
if err != nil {
return nil, err
}
attributes := &winapi.SecurityAttributes{
Length: uint32(unsafe.Sizeof(winapi.SecurityAttributes{})),
SecurityDescriptor: descriptor,
}
mode := uint32(winapi.PIPE_ACCESS_DUPLEX | winapi.PIPE_TYPE_MESSAGE | winapi.PIPE_READMODE_MESSAGE | winapi.PIPE_WAIT | winapi.PIPE_REJECT_REMOTE_CLIENTS | winapi.SECURITY_IDENTIFICATION)
handle, err := winapi.CreateNamedPipe(name, mode, pipeInstances, pipeBufferBytes, pipeBufferBytes, 0, 0, attributes)
if err != nil {
return nil, err
}
return os.NewFile(uintptr(handle), "rvbox-tray-pipe"), nil
}
func serveTrayPipe(ctx context.Context, pipe *os.File, handler Handler) {
if pipe == nil {
return
}
defer pipe.Close()
// A blocked Read must be interrupted when service shutdown cancels ctx.
readDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = pipe.Close()
case <-readDone:
}
}()
defer close(readDone)
peer, err := peerFromPipe(winapi.Handle(pipe.Fd()))
if err != nil {
writeTrayResponse(pipe, err)
return
}
request, err := readTrayFrame(pipe)
if err != nil {
writeTrayResponse(pipe, err)
return
}
if err := Authorize(peer, request.Action); err != nil {
writeTrayResponse(pipe, err)
return
}
response, err := handler(ctx, peer, request)
if err != nil {
writeTrayResponse(pipe, err)
return
}
if response.Action == 0 {
response.Action = ActionStatus
}
if response.Action != ActionStatus {
response = Frame{Action: ActionStatus}
}
writeTrayResponse(pipe, response)
}
func readTrayFrame(reader io.Reader) (Frame, error) {
buffer := make([]byte, maxFrameBytes)
count, err := reader.Read(buffer)
if err != nil {
return Frame{}, err
}
if count == len(buffer) {
return Frame{}, ErrFrameTooLarge
}
return Decode(buffer[:count])
}
func writeTrayResponse(writer *os.File, value any) {
frame := Frame{Action: ActionStatus}
switch response := value.(type) {
case Frame:
frame = response
case error:
message := response.Error()
if len(message) > maxPayloadBytes {
message = message[:maxPayloadBytes]
}
frame.Payload = []byte("error: " + message)
}
encoded, err := Encode(frame)
if err != nil {
return
}
_, _ = writer.Write(encoded)
_ = winapi.FlushFileBuffers(winapi.Handle(writer.Fd()))
}
func peerFromPipe(pipe winapi.Handle) (Peer, error) {
if pipe == 0 || pipe == winapi.InvalidHandle {
return Peer{}, ErrInvalidPeer
}
var pid uint32
if err := winapi.GetNamedPipeClientProcessId(pipe, &pid); err != nil || pid == 0 {
return Peer{}, ErrInvalidPeer
}
process, err := winapi.OpenProcess(winapi.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
return Peer{}, ErrInvalidPeer
}
defer winapi.CloseHandle(process)
var token winapi.Token
if err := winapi.OpenProcessToken(process, winapi.TOKEN_QUERY, &token); err != nil {
return Peer{}, ErrInvalidPeer
}
defer token.Close()
user, err := token.GetTokenUser()
if err != nil || user.User.Sid == nil {
return Peer{}, ErrInvalidPeer
}
var sessionID uint32
var returned uint32
if err := winapi.GetTokenInformation(token, winapi.TokenSessionId, (*byte)(unsafe.Pointer(&sessionID)), uint32(unsafe.Sizeof(sessionID)), &returned); err != nil || returned != uint32(unsafe.Sizeof(sessionID)) {
return Peer{}, ErrInvalidPeer
}
adminSID, err := winapi.CreateWellKnownSid(winapi.WinBuiltinAdministratorsSid)
if err != nil {
return Peer{}, ErrInvalidPeer
}
admin, err := token.IsMember(adminSID)
if err != nil {
return Peer{}, ErrInvalidPeer
}
sid := user.User.Sid.String()
return Peer{PID: pid, SessionID: sessionID, SID: sid, TokenVerified: true, Interactive: sessionID != 0, Administrator: admin, System: sid == "S-1-5-18"}, nil
}
// Request opens exactly one local pipe connection and exchanges one frame.
// It retries only the transient ERROR_PIPE_BUSY state and never falls back to
// an arbitrary filesystem/socket path.
func Request(ctx context.Context, request Frame) (Frame, error) {
encoded, err := Encode(request)
if err != nil {
return Frame{}, err
}
var pipe *os.File
for {
if ctx.Err() != nil {
return Frame{}, ctx.Err()
}
name, nameErr := winapi.UTF16PtrFromString(PipeName)
if nameErr != nil {
return Frame{}, nameErr
}
handle, openErr := winapi.CreateFile(name, winapi.GENERIC_READ|winapi.GENERIC_WRITE, 0, nil, winapi.OPEN_EXISTING, 0, 0)
if openErr == nil {
pipe = os.NewFile(uintptr(handle), "rvbox-tray-client")
break
}
if !errors.Is(openErr, winapi.ERROR_PIPE_BUSY) {
return Frame{}, openErr
}
timer := time.NewTimer(100 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return Frame{}, ctx.Err()
case <-timer.C:
}
}
defer pipe.Close()
state := uint32(winapi.PIPE_READMODE_MESSAGE)
_ = winapi.SetNamedPipeHandleState(winapi.Handle(pipe.Fd()), &state, nil, nil)
if _, err := pipe.Write(encoded); err != nil {
return Frame{}, err
}
if err := winapi.FlushFileBuffers(winapi.Handle(pipe.Fd())); err != nil {
return Frame{}, err
}
return readTrayFrame(pipe)
}
+419
View File
@@ -0,0 +1,419 @@
//go:build windows
package windowstray
// A deliberately small Win32 notification-area host. The tray has no
// durable state and no command execution path; it only renders a tooltip,
// sends enum actions over the authenticated service pipe, and opens paths
// returned by the service with the exact Windows Explorer executable.
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"unicode/utf8"
"unsafe"
winapi "golang.org/x/sys/windows"
)
const (
wmDestroy = 0x0002
wmClose = 0x0010
wmCommand = 0x0111
wmRButtonUp = 0x0205
wmLButtonDblClick = 0x0203
wmApp = 0x8000
trayMessage = wmApp + 1
trayIconID = 1
trayNIMAdd = 0
trayNIMModify = 1
trayNIMDelete = 2
trayNIFMessage = 0x00000001
trayNIFIcon = 0x00000002
trayNIFTip = 0x00000004
trayTPMRightBtn = 0x0002
trayMFString = 0x00000000
trayMFSeparator = 0x00000800
traySWHide = 0
trayIDIApplication = 32512
trayIDCArrow = 32512
)
var (
ErrTrayAlreadyRunning = errors.New("RVBox tray is already running in this session")
user32Tray = syscall.NewLazyDLL("user32.dll")
shell32Tray = syscall.NewLazyDLL("shell32.dll")
procRegisterClassEx = user32Tray.NewProc("RegisterClassExW")
procCreateWindowEx = user32Tray.NewProc("CreateWindowExW")
procDefWindowProc = user32Tray.NewProc("DefWindowProcW")
procGetMessage = user32Tray.NewProc("GetMessageW")
procTranslate = user32Tray.NewProc("TranslateMessage")
procDispatch = user32Tray.NewProc("DispatchMessageW")
procPostMessage = user32Tray.NewProc("PostMessageW")
procDestroyWindow = user32Tray.NewProc("DestroyWindow")
procShowWindow = user32Tray.NewProc("ShowWindow")
procPostQuitMessage = user32Tray.NewProc("PostQuitMessage")
procLoadIcon = user32Tray.NewProc("LoadIconW")
procLoadCursor = user32Tray.NewProc("LoadCursorW")
procCreatePopup = user32Tray.NewProc("CreatePopupMenu")
procAppendMenu = user32Tray.NewProc("AppendMenuW")
procTrackPopup = user32Tray.NewProc("TrackPopupMenu")
procDestroyMenu = user32Tray.NewProc("DestroyMenu")
procGetCursorPos = user32Tray.NewProc("GetCursorPos")
procSetForeground = user32Tray.NewProc("SetForegroundWindow")
procGetModuleHandle = user32Tray.NewProc("GetModuleHandleW")
procShellNotify = shell32Tray.NewProc("Shell_NotifyIconW")
trayCallback = syscall.NewCallback(trayWindowProc)
trayWindows sync.Map // hwnd -> *trayWindow
)
type trayPoint struct{ X, Y int32 }
type trayMessageRecord struct {
HWnd uintptr
Message uint32
WParam uintptr
LParam uintptr
Time uint32
Point trayPoint
}
type trayClass struct {
CbSize uint32
Style uint32
WndProc uintptr
CbClsExtra int32
CbWndExtra int32
HInstance uintptr
HIcon uintptr
HCursor uintptr
HbrBackground uintptr
MenuName *uint16
ClassName *uint16
HIconSm uintptr
}
type trayIconData struct {
CbSize uint32
HWnd uintptr
UID uint32
UFlags uint32
UCallbackMessage uint32
HIcon uintptr
Tip [128]uint16
State uint32
StateMask uint32
InfoFlags uint32
InfoTitle [64]uint16
InfoData [256]uint16
Guid [16]byte
BalloonIcon uintptr
}
type trayWindow struct {
hwnd uintptr
icon trayIconData
class *uint16
tooltip string
output io.Writer
closeOnce sync.Once
}
// Run starts one notification icon for the current logged-in session. It
// returns when the icon is closed, the service becomes unavailable, or ctx is
// cancelled; none of those outcomes affect the machine-wide service.
func Run(ctx context.Context, output io.Writer) error {
mutex, err := acquireTrayMutex()
if err != nil {
return err
}
defer winapi.CloseHandle(mutex)
response, err := Request(ctx, Frame{Action: ActionStatus})
if err != nil {
return fmt.Errorf("connect to RVBox service: %w", err)
}
tooltip := string(response.Payload)
if tooltip == "" {
tooltip = "RVBox service"
}
ready := make(chan *trayWindow, 1)
done := make(chan error, 1)
go runTrayMessageLoop(output, tooltip, ready, done)
var window *trayWindow
select {
case window = <-ready:
case err := <-done:
return err
case <-ctx.Done():
return nil
}
select {
case <-ctx.Done():
if window != nil {
_, _, _ = procPostMessage.Call(window.hwnd, wmClose, 0, 0)
}
return <-done
case err := <-done:
return err
}
}
func acquireTrayMutex() (winapi.Handle, error) {
var sessionID uint32
if err := winapi.ProcessIdToSessionId(winapi.GetCurrentProcessId(), &sessionID); err != nil {
return 0, err
}
name, err := winapi.UTF16PtrFromString(fmt.Sprintf("Local\\RVBoxTrayV1-%d", sessionID))
if err != nil {
return 0, err
}
mutex, err := winapi.CreateMutex(nil, true, name)
if errors.Is(err, winapi.ERROR_ALREADY_EXISTS) {
if mutex != 0 {
_ = winapi.CloseHandle(mutex)
}
return 0, ErrTrayAlreadyRunning
}
if err != nil {
return 0, err
}
return mutex, nil
}
func runTrayMessageLoop(output io.Writer, tooltip string, ready chan<- *trayWindow, done chan<- error) {
className, err := winapi.UTF16PtrFromString("RVBoxTrayWindowV1")
if err != nil {
done <- err
return
}
instance, _, _ := procGetModuleHandle.Call(0)
icon, _, _ := procLoadIcon.Call(0, uintptr(trayIDIApplication))
cursor, _, _ := procLoadCursor.Call(0, uintptr(trayIDCArrow))
class := trayClass{CbSize: uint32(unsafe.Sizeof(trayClass{})), WndProc: trayCallback, HInstance: instance, HIcon: icon, HCursor: cursor, ClassName: className, HIconSm: icon}
if result, _, callErr := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&class))); result == 0 && !errors.Is(callErr, winapi.ERROR_CLASS_ALREADY_EXISTS) {
done <- callErr
return
}
title, _ := winapi.UTF16PtrFromString("RVBox")
hwnd, _, callErr := procCreateWindowEx.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(title)), 0, 0, 0, 0, 0, 0, 0, instance, 0)
if hwnd == 0 {
done <- callErr
return
}
window := &trayWindow{hwnd: hwnd, class: className, tooltip: tooltip, output: output}
window.icon = trayIconData{CbSize: uint32(unsafe.Sizeof(trayIconData{})), HWnd: hwnd, UID: trayIconID, UFlags: trayNIFMessage | trayNIFIcon | trayNIFTip, UCallbackMessage: trayMessage, HIcon: icon}
copy(window.icon.Tip[:], winapi.StringToUTF16(tooltip))
trayWindows.Store(hwnd, window)
if result, _, err := procShellNotify.Call(uintptr(trayNIMAdd), uintptr(unsafe.Pointer(&window.icon))); result == 0 {
trayWindows.Delete(hwnd)
_, _, _ = procDestroyWindow.Call(hwnd)
done <- err
return
}
_, _, _ = procShowWindow.Call(hwnd, traySWHide)
ready <- window
for {
var message trayMessageRecord
result, _, getErr := procGetMessage.Call(uintptr(unsafe.Pointer(&message)), 0, 0, 0)
if int32(result) == -1 {
window.removeIcon()
done <- getErr
return
}
if result == 0 {
window.removeIcon()
done <- nil
return
}
_, _, _ = procTranslate.Call(uintptr(unsafe.Pointer(&message)))
_, _, _ = procDispatch.Call(uintptr(unsafe.Pointer(&message)))
}
}
func trayWindowProc(hwnd uintptr, message uint32, wParam, lParam uintptr) uintptr {
value, _ := trayWindows.Load(hwnd)
window, _ := value.(*trayWindow)
switch message {
case wmClose:
_, _, _ = procDestroyWindow.Call(hwnd)
return 0
case wmDestroy:
if window != nil {
window.removeIcon()
}
trayWindows.Delete(hwnd)
procPostQuitMessage.Call(0)
return 0
case wmCommand:
if window != nil {
window.action(Action(uint16(wParam)))
}
return 0
case trayMessage:
if window != nil && (uint32(lParam) == wmRButtonUp || uint32(lParam) == wmLButtonDblClick) {
window.showMenu()
}
return 0
}
result, _, _ := procDefWindowProc.Call(hwnd, uintptr(message), wParam, lParam)
return result
}
func (window *trayWindow) removeIcon() {
window.closeOnce.Do(func() {
window.icon.UFlags = 0
_, _, _ = procShellNotify.Call(uintptr(trayNIMDelete), uintptr(unsafe.Pointer(&window.icon)))
})
}
func (window *trayWindow) showMenu() {
menu, _, _ := procCreatePopup.Call()
if menu == 0 {
return
}
defer procDestroyMenu.Call(menu)
add := func(action Action, label string) {
text, _ := winapi.UTF16PtrFromString(label)
procAppendMenu.Call(menu, trayMFString, uintptr(action), uintptr(unsafe.Pointer(text)))
}
add(ActionStatus, "Status")
add(ActionOpenConfig, "Open config")
add(ActionOpenLog, "Open log")
procAppendMenu.Call(menu, trayMFSeparator, 0, 0)
add(ActionStartService, "Start service")
add(ActionStopService, "Stop service")
add(ActionRestartService, "Restart service")
add(ActionSetAutomatic, "Start automatically")
add(ActionSetManual, "Start manually")
procAppendMenu.Call(menu, trayMFSeparator, 0, 0)
add(ActionExitTray, "Exit")
var point trayPoint
procGetCursorPos.Call(uintptr(unsafe.Pointer(&point)))
procSetForeground.Call(window.hwnd)
procTrackPopup.Call(menu, trayTPMRightBtn, uintptr(point.X), uintptr(point.Y), 0, window.hwnd, 0)
}
func (window *trayWindow) action(action Action) {
if action == ActionExitTray {
_, _, _ = procDestroyWindow.Call(window.hwnd)
return
}
response, err := Request(context.Background(), Frame{Action: action})
if err != nil {
window.setTooltip("RVBox service unavailable")
return
}
if isServiceMutation(action) && strings.HasPrefix(string(response.Payload), "error:") {
if err := runElevatedServiceAction(action); err == nil {
window.setTooltip("RVBox elevation requested")
return
}
}
if action == ActionOpenConfig || action == ActionOpenLog {
if err := openExactPath(string(response.Payload)); err != nil {
window.setTooltip("RVBox open failed")
}
return
}
if len(response.Payload) > 0 {
window.setTooltip(string(response.Payload))
}
}
func isServiceMutation(action Action) bool {
return action == ActionStartService || action == ActionStopService || action == ActionRestartService || action == ActionSetAutomatic || action == ActionSetManual
}
// runElevatedServiceAction is the tray's narrow UAC fallback. The service
// remains the authorization boundary; this helper only replays the enum as a
// canonical rvbox mode under an administrator token when the tray's filtered
// token cannot mutate SCM state directly.
func runElevatedServiceAction(action Action) error {
mode := ""
switch action {
case ActionStartService:
mode = "--start-service"
case ActionStopService:
mode = "--stop-service"
case ActionRestartService:
mode = "--restart-service"
case ActionSetAutomatic:
mode = "--configure-service --startup automatic"
case ActionSetManual:
mode = "--configure-service --startup manual"
default:
return errors.New("unsupported elevated tray action")
}
executable, err := os.Executable()
if err != nil {
return err
}
configPath := ""
for index := 0; index+1 < len(os.Args); index++ {
if os.Args[index] == "--config" {
configPath = os.Args[index+1]
break
}
}
if configPath == "" {
return errors.New("tray config path is unavailable")
}
args, err := winapi.UTF16PtrFromString(mode + " --config " + syscall.EscapeArg(configPath))
if err != nil {
return err
}
file, err := winapi.UTF16PtrFromString(executable)
if err != nil {
return err
}
if err := winapi.ShellExecute(0, mustUTF16("runas"), file, args, nil, winapi.SW_SHOWNORMAL); err != nil {
return err
}
return nil
}
func mustUTF16(value string) *uint16 {
encoded, _ := winapi.UTF16PtrFromString(value)
return encoded
}
func (window *trayWindow) setTooltip(value string) {
value = strings.TrimSpace(value)
if value == "" {
value = "RVBox service"
}
window.tooltip = value
window.icon.UFlags = trayNIFTip
for index := range window.icon.Tip {
window.icon.Tip[index] = 0
}
copy(window.icon.Tip[:], winapi.StringToUTF16(value))
_, _, _ = procShellNotify.Call(uintptr(trayNIMModify), uintptr(unsafe.Pointer(&window.icon)))
}
func openExactPath(path string) error {
if path == "" || strings.TrimSpace(path) != path || !utf8.ValidString(path) {
return errors.New("invalid path returned by service")
}
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
return errors.New("service path is not a regular file")
}
windowsDir, err := winapi.GetWindowsDirectory()
if err != nil {
return err
}
explorer := filepath.Join(windowsDir, "explorer.exe")
if info, err := os.Stat(explorer); err != nil || !info.Mode().IsRegular() {
return errors.New("Windows Explorer is unavailable")
}
return exec.Command(explorer, path).Start()
}