feat: complete Windows client control and recovery paths
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user