feat: execute durable client commands through supervisor
This commit is contained in:
@@ -144,6 +144,8 @@ func BuildWrapper(spec *rvboxv1.ExecutionSpec, scriptBody []byte, maxBytes uint6
|
||||
|
||||
func shellTemplate(shellType rvboxv1.ShellType) (ShellPlan, error) {
|
||||
switch shellType {
|
||||
case rvboxv1.ShellType_SHELL_SH, rvboxv1.ShellType_SHELL_BASH:
|
||||
return ShellPlan{Type: shellType, WrapperExtension: ".sh", Encoding: WrapperEncodingUTF8}, nil
|
||||
case rvboxv1.ShellType_SHELL_CMD:
|
||||
return ShellPlan{Type: shellType, Arguments: []string{"/D", "/S", "/C"}, WrapperExtension: ".cmd", Encoding: WrapperEncodingUTF8}, nil
|
||||
case rvboxv1.ShellType_SHELL_POWERSHELL:
|
||||
@@ -192,6 +194,13 @@ func ValidateWindowsExecutablePath(path string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// ValidAbsoluteWindowsPath reports whether value passes the lexical absolute
|
||||
// path checks. It does not touch the filesystem; native callers must still
|
||||
// re-stat the object and verify its ACL immediately before use.
|
||||
func ValidAbsoluteWindowsPath(value string) bool {
|
||||
return validAbsoluteWindowsPath(value)
|
||||
}
|
||||
|
||||
func validAbsoluteWindowsPath(value string) bool {
|
||||
if value == "" || strings.IndexByte(value, 0) >= 0 || !utf8.ValidString(value) || strings.ContainsAny(value, "\r\n\t") {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
package windows
|
||||
|
||||
// This file contains the platform-neutral process bookkeeping shared by the
|
||||
// native Windows adapter and the deterministic non-Windows test adapter. The
|
||||
// Windows build supplies the token/Job-backed start and signal operations in
|
||||
// native_windows.go; keeping output and stdin semantics here prevents those
|
||||
// paths from drifting apart.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||
"github.com/rvbox/rvbox/internal/client/supervisor"
|
||||
"github.com/rvbox/rvbox/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProcessAlreadyRunning = errors.New("a process for this command is already running")
|
||||
ErrProcessNotFound = errors.New("supervised process was not found")
|
||||
ErrProcessNotReady = errors.New("supervised process is not ready")
|
||||
)
|
||||
|
||||
// NativeOptions is the common policy input. Windows callers additionally get
|
||||
// token/session selection and Job Object containment in the native build; the
|
||||
// test adapter uses the same shell and output limits without OS handles.
|
||||
type NativeOptions struct {
|
||||
Shells ShellPaths
|
||||
WorkRoot string
|
||||
MaxWrapperBytes uint64
|
||||
MaxOutputChunk uint64
|
||||
WindowsTermGrace time.Duration
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (options NativeOptions) withDefaults() NativeOptions {
|
||||
if options.MaxWrapperBytes == 0 {
|
||||
options.MaxWrapperBytes = 10 << 20
|
||||
}
|
||||
if options.MaxOutputChunk == 0 || options.MaxOutputChunk > 64<<10 {
|
||||
options.MaxOutputChunk = 64 << 10
|
||||
}
|
||||
if options.WindowsTermGrace <= 0 {
|
||||
options.WindowsTermGrace = 10 * time.Second
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = func() time.Time { return time.Now().UTC() }
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
type execSupervisor struct {
|
||||
options NativeOptions
|
||||
mu sync.Mutex
|
||||
active map[domain.UUID]*execProcess
|
||||
}
|
||||
|
||||
func newExecSupervisor(options NativeOptions) *execSupervisor {
|
||||
options = options.withDefaults()
|
||||
return &execSupervisor{options: options, active: make(map[domain.UUID]*execProcess)}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
mu sync.Mutex
|
||||
finished bool
|
||||
status supervisor.ExitStatus
|
||||
waitErr error
|
||||
closeIn sync.Once
|
||||
}
|
||||
|
||||
type outputResult struct {
|
||||
chunk supervisor.OutputChunk
|
||||
err error
|
||||
}
|
||||
|
||||
func (process *execProcess) IssueUUID() domain.UUID { return process.issue }
|
||||
|
||||
func (process *execProcess) Identity() supervisor.EffectiveIdentity { return process.identity }
|
||||
|
||||
func (process *execProcess) ReadOutput(ctx context.Context) (supervisor.OutputChunk, error) {
|
||||
if process == nil || process.outputs == nil {
|
||||
return supervisor.OutputChunk{}, ErrProcessNotReady
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return supervisor.OutputChunk{}, ctx.Err()
|
||||
case result, ok := <-process.outputs:
|
||||
if !ok {
|
||||
return supervisor.OutputChunk{}, io.EOF
|
||||
}
|
||||
return result.chunk, result.err
|
||||
}
|
||||
}
|
||||
|
||||
func (process *execProcess) Wait(ctx context.Context) (supervisor.ExitStatus, error) {
|
||||
if process == nil || process.done == nil {
|
||||
return supervisor.ExitStatus{}, ErrProcessNotReady
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return supervisor.ExitStatus{}, ctx.Err()
|
||||
case <-process.done:
|
||||
process.mu.Lock()
|
||||
defer process.mu.Unlock()
|
||||
return process.status, process.waitErr
|
||||
}
|
||||
}
|
||||
|
||||
func (process *execProcess) WriteStdin(ctx context.Context, data []byte, appendNewline bool) error {
|
||||
if process == nil || process.stdin == nil {
|
||||
return ErrProcessNotReady
|
||||
}
|
||||
if appendNewline {
|
||||
data = append(append([]byte(nil), data...), stdinLineEnding()...)
|
||||
}
|
||||
return writeWithContext(ctx, process.stdin, data)
|
||||
}
|
||||
|
||||
func (process *execProcess) CloseStdin(_ context.Context) error {
|
||||
if process == nil || process.stdin == nil {
|
||||
return ErrProcessNotReady
|
||||
}
|
||||
var err error
|
||||
process.closeIn.Do(func() { err = process.stdin.Close() })
|
||||
return err
|
||||
}
|
||||
|
||||
func writeWithContext(ctx context.Context, writer io.Writer, data []byte) error {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := writer.Write(data)
|
||||
result <- err
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-result:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (process *execProcess) setResult(status supervisor.ExitStatus, err error) {
|
||||
process.mu.Lock()
|
||||
process.status, process.waitErr, process.finished = status, err, true
|
||||
process.mu.Unlock()
|
||||
close(process.done)
|
||||
}
|
||||
|
||||
func (process *execProcess) startReaders(maxChunk uint64, remove func()) {
|
||||
var readers sync.WaitGroup
|
||||
read := func(stream rvboxv1.StreamKind, source io.ReadCloser) {
|
||||
defer readers.Done()
|
||||
defer source.Close()
|
||||
limit := int(maxChunk)
|
||||
if limit <= 0 {
|
||||
limit = 64 << 10
|
||||
}
|
||||
buffer := make([]byte, limit)
|
||||
for {
|
||||
count, err := source.Read(buffer)
|
||||
if count > 0 {
|
||||
data := append([]byte(nil), buffer[:count]...)
|
||||
select {
|
||||
case process.outputs <- outputResult{chunk: supervisor.OutputChunk{Stream: stream, Data: data}}:
|
||||
default:
|
||||
// The output channel is bounded. A reader must never hold a
|
||||
// child pipe open while waiting for network I/O; dropping here
|
||||
// is surfaced as an explicit read error to the caller.
|
||||
select {
|
||||
case process.outputs <- outputResult{err: fmt.Errorf("%w: output channel full", supervisor.ErrUnsupported)}:
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
select {
|
||||
case process.outputs <- outputResult{err: err}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
readers.Add(2)
|
||||
go read(rvboxv1.StreamKind_STREAM_STDOUT, process.stdout)
|
||||
go read(rvboxv1.StreamKind_STREAM_STDERR, process.stderr)
|
||||
go func() {
|
||||
readers.Wait()
|
||||
close(process.outputs)
|
||||
if remove != nil {
|
||||
remove()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func materializeWrapper(directory string, wrapper Wrapper, now time.Time) (string, func(), error) {
|
||||
if directory == "" || !now.IsZero() && now.Location() == nil {
|
||||
return "", nil, ErrInvalidWorkingDirectory
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
temporary, err := os.CreateTemp(directory, ".rvbox-wrapper-*")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
temporaryName := temporary.Name()
|
||||
cleanup := func() {
|
||||
_ = temporary.Close()
|
||||
_ = os.Remove(temporaryName)
|
||||
}
|
||||
if err := temporary.Chmod(0o600); err != nil {
|
||||
cleanup()
|
||||
return "", nil, err
|
||||
}
|
||||
if _, err := temporary.Write(wrapper.Bytes); err != nil {
|
||||
cleanup()
|
||||
return "", nil, err
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
cleanup()
|
||||
return "", nil, err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
_ = os.Remove(temporaryName)
|
||||
return "", nil, err
|
||||
}
|
||||
finalName := temporaryName + wrapper.Extension
|
||||
if err := os.Rename(temporaryName, finalName); err != nil {
|
||||
_ = os.Remove(temporaryName)
|
||||
return "", nil, err
|
||||
}
|
||||
return finalName, func() { _ = os.Remove(finalName) }, nil
|
||||
}
|
||||
|
||||
func parseEnvironment(values []string) map[string]string {
|
||||
result := make(map[string]string, len(values))
|
||||
for _, value := range values {
|
||||
index := strings.IndexByte(value, '=')
|
||||
if index <= 0 {
|
||||
continue
|
||||
}
|
||||
result[value[:index]] = value[index+1:]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (process *execProcess) terminate(code uint32) error {
|
||||
if process == nil {
|
||||
return ErrProcessNotReady
|
||||
}
|
||||
if process.killFn != nil {
|
||||
return process.killFn(code)
|
||||
}
|
||||
if process.cmd == nil || process.cmd.Process == nil {
|
||||
return ErrProcessNotReady
|
||||
}
|
||||
return process.cmd.Process.Kill()
|
||||
}
|
||||
|
||||
func (manager *execSupervisor) remove(issue domain.UUID, process *execProcess) {
|
||||
manager.mu.Lock()
|
||||
if manager.active[issue] == process {
|
||||
delete(manager.active, issue)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
|
||||
func validateExecutionSource(spec *supervisor.StartSpec) error {
|
||||
if err := spec.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.Execution.GetScript() != nil && len(spec.ScriptBody) == 0 && spec.Execution.GetScript().GetSizeBytes() != 0 {
|
||||
return ErrProcessNotReady
|
||||
}
|
||||
if len(spec.ExecutionProfiles) != 0 {
|
||||
return fmt.Errorf("%w: execution profiles are not supported by this adapter yet", supervisor.ErrUnsupported)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sourceCommand(spec supervisor.StartSpec, wrapperPath string) (string, []string, error) {
|
||||
source := spec.Execution.GetCommandText()
|
||||
if wrapperPath != "" {
|
||||
source = wrapperPath
|
||||
}
|
||||
switch spec.Execution.GetShellType() {
|
||||
case rvboxv1.ShellType_SHELL_SH:
|
||||
if wrapperPath != "" {
|
||||
return "/bin/sh", []string{wrapperPath}, nil
|
||||
}
|
||||
return "/bin/sh", []string{"-c", source}, nil
|
||||
case rvboxv1.ShellType_SHELL_BASH:
|
||||
if wrapperPath != "" {
|
||||
return "/bin/bash", []string{wrapperPath}, nil
|
||||
}
|
||||
return "/bin/bash", []string{"-c", source}, nil
|
||||
default:
|
||||
return "", nil, ErrUnsupportedShell
|
||||
}
|
||||
}
|
||||
|
||||
// startPortable launches the same bounded pipe contract on Unix. It is kept
|
||||
// private because v1 does not advertise a Unix client; tests use it to prove
|
||||
// runner/supervisor ordering without a Windows host.
|
||||
func (manager *execSupervisor) startPortable(ctx context.Context, spec supervisor.StartSpec) (supervisor.Process, error) {
|
||||
if err := validateExecutionSource(&spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manager.mu.Lock()
|
||||
if _, exists := manager.active[spec.IssueUUID]; exists {
|
||||
manager.mu.Unlock()
|
||||
return nil, ErrProcessAlreadyRunning
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
var wrapperPath string
|
||||
var cleanup func()
|
||||
if spec.Execution.GetScript() != nil {
|
||||
wrapper, err := BuildWrapper(spec.Execution, spec.ScriptBody, manager.options.MaxWrapperBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wrapperPath, cleanup, err = materializeWrapper(spec.WorkingDirectory, wrapper, manager.options.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
program, arguments, err := sourceCommand(spec, wrapperPath)
|
||||
if err != nil {
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return manager.startCommand(ctx, spec, program, arguments, supervisor.EffectiveIdentity{Context: "CURRENT_PROCESS", Elevated: false}, cleanup, nil)
|
||||
}
|
||||
|
||||
func (manager *execSupervisor) startCommand(ctx context.Context, spec supervisor.StartSpec, program string, arguments []string, identity supervisor.EffectiveIdentity, cleanup func(), configure func(*exec.Cmd) error) (supervisor.Process, error) {
|
||||
command := exec.CommandContext(ctx, program, arguments...)
|
||||
if configure != nil {
|
||||
if err := configure(command); err != nil {
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
command.Dir = filepath.Clean(spec.WorkingDirectory)
|
||||
base := parseEnvironment(os.Environ())
|
||||
entries, err := MergeEnvironment(base, spec.Environment)
|
||||
if err != nil {
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
command.Env = make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
command.Env = append(command.Env, entry.Key+"="+entry.Value)
|
||||
}
|
||||
stdinRead, stdin, err := os.Pipe()
|
||||
if err != nil {
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
stdout, stdoutWrite, err := os.Pipe()
|
||||
if err != nil {
|
||||
_ = stdinRead.Close()
|
||||
_ = stdin.Close()
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
stderr, stderrWrite, err := os.Pipe()
|
||||
if err != nil {
|
||||
_ = stdinRead.Close()
|
||||
_ = stdin.Close()
|
||||
_ = stdout.Close()
|
||||
_ = stdoutWrite.Close()
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
command.Stdin = stdinRead
|
||||
command.Stdout = stdoutWrite
|
||||
command.Stderr = stderrWrite
|
||||
if err := command.Start(); err != nil {
|
||||
_ = stdinRead.Close()
|
||||
_ = stdin.Close()
|
||||
_ = stdout.Close()
|
||||
_ = stdoutWrite.Close()
|
||||
_ = stderr.Close()
|
||||
_ = stderrWrite.Close()
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
_ = stdinRead.Close()
|
||||
_ = stdoutWrite.Close()
|
||||
_ = stderrWrite.Close()
|
||||
started := manager.options.Now()
|
||||
waitFn := func() (int32, bool, error) {
|
||||
err := command.Wait()
|
||||
var code int32
|
||||
if command.ProcessState != nil {
|
||||
code = int32(command.ProcessState.ExitCode())
|
||||
}
|
||||
return code, false, err
|
||||
}
|
||||
return manager.registerProcess(spec.IssueUUID, identity, command, stdin, stdout, stderr, started, waitFn, func(uint32) error { return command.Process.Kill() }, cleanup), nil
|
||||
}
|
||||
|
||||
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}
|
||||
manager.mu.Lock()
|
||||
manager.active[issue] = process
|
||||
manager.mu.Unlock()
|
||||
process.startReaders(manager.options.MaxOutputChunk, cleanup)
|
||||
go func() {
|
||||
code, signaled, err := process.waitFn()
|
||||
finished := manager.options.Now()
|
||||
status := supervisor.ExitStatus{StartedAt: started, FinishedAt: finished, OutputDrained: false, Code: code, Signaled: signaled}
|
||||
process.setResult(status, err)
|
||||
manager.remove(issue, process)
|
||||
}()
|
||||
return process
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build !windows
|
||||
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/rvbox/rvbox/internal/client/supervisor"
|
||||
)
|
||||
|
||||
func stdinLineEnding() []byte { return []byte{'\n'} }
|
||||
|
||||
// NewSupervisor returns the deterministic local adapter used by tests and by
|
||||
// non-Windows development builds. The v1 client does not advertise this
|
||||
// adapter as a supported Unix client; it exists so protocol/runtime tests can
|
||||
// exercise real child processes without a Windows host.
|
||||
func NewSupervisor(options NativeOptions) (supervisor.Supervisor, error) {
|
||||
return newExecSupervisor(options), nil
|
||||
}
|
||||
|
||||
func (manager *execSupervisor) Start(ctx context.Context, spec supervisor.StartSpec) (supervisor.Process, error) {
|
||||
return manager.startPortable(ctx, spec)
|
||||
}
|
||||
|
||||
func (manager *execSupervisor) Signal(ctx context.Context, process supervisor.Process, signal supervisor.SignalKind) (supervisor.SignalOutcome, error) {
|
||||
if process == nil {
|
||||
return supervisor.SignalOutcome{}, ErrProcessNotFound
|
||||
}
|
||||
executable, ok := process.(*execProcess)
|
||||
if !ok {
|
||||
return supervisor.SignalOutcome{}, ErrProcessNotFound
|
||||
}
|
||||
if signal != supervisor.SignalTerm && signal != supervisor.SignalKill {
|
||||
return supervisor.SignalOutcome{}, errors.New("unsupported signal")
|
||||
}
|
||||
if signal == supervisor.SignalTerm {
|
||||
if executable.cmd.Process != nil {
|
||||
_ = executable.cmd.Process.Signal(os.Interrupt)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return supervisor.SignalOutcome{}, ctx.Err()
|
||||
case <-time.After(manager.options.WindowsTermGrace):
|
||||
}
|
||||
}
|
||||
if err := executable.terminate(1); err != nil {
|
||||
return supervisor.SignalOutcome{}, err
|
||||
}
|
||||
return supervisor.SignalOutcome{Delivered: true, Escalated: signal == supervisor.SignalTerm, Detail: "process terminated", ObservedAt: manager.options.Now()}, nil
|
||||
}
|
||||
|
||||
func (manager *execSupervisor) Snapshot(ctx context.Context, process supervisor.Process) (supervisor.ResourceSnapshot, error) {
|
||||
if process == nil {
|
||||
return supervisor.ResourceSnapshot{}, ErrProcessNotFound
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return supervisor.ResourceSnapshot{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
return supervisor.ResourceSnapshot{ProcessCount: 1, ObservedAt: manager.options.Now(), Complete: false, Detail: "portable adapter does not expose aggregate process accounting"}, nil
|
||||
}
|
||||
|
||||
func (manager *execSupervisor) StopAll(_ context.Context) error {
|
||||
manager.mu.Lock()
|
||||
processes := make([]*execProcess, 0, len(manager.active))
|
||||
for _, process := range manager.active {
|
||||
processes = append(processes, process)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
for _, process := range processes {
|
||||
if err := process.terminate(1); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//go:build !windows
|
||||
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||
"github.com/rvbox/rvbox/internal/client/supervisor"
|
||||
"github.com/rvbox/rvbox/internal/domain"
|
||||
)
|
||||
|
||||
func TestPortableSupervisorCapturesOutputAndSupportsStdin_HP_SUPERVISOR_03(t *testing.T) {
|
||||
t.Parallel()
|
||||
manager, err := NewSupervisor(NativeOptions{MaxOutputChunk: 8, WindowsTermGrace: 10 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issue, err := domain.ParseUUIDv7("019c46f1-1d02-7000-8000-0000000000c1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process, err := manager.Start(context.Background(), supervisor.StartSpec{IssueUUID: issue, CommandRevision: 1, WorkingDirectory: t.TempDir(), Execution: &rvboxv1.ExecutionSpec{ShellType: rvboxv1.ShellType_SHELL_SH, Source: &rvboxv1.ExecutionSpec_CommandText{CommandText: "read line; printf 'out:%s\\n' \"$line\"; printf 'err\\n' >&2"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := process.WriteStdin(context.Background(), []byte("hello"), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := process.CloseStdin(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err := process.Wait(context.Background())
|
||||
if err != nil || status.Code != 0 {
|
||||
t.Fatalf("wait = %+v, %v", status, err)
|
||||
}
|
||||
var output strings.Builder
|
||||
streams := map[rvboxv1.StreamKind]bool{}
|
||||
for {
|
||||
chunk, err := process.ReadOutput(context.Background())
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output.Write(chunk.Data)
|
||||
streams[chunk.Stream] = true
|
||||
}
|
||||
if !strings.Contains(output.String(), "out:hello\n") || !strings.Contains(output.String(), "err\n") || !streams[rvboxv1.StreamKind_STREAM_STDOUT] || !streams[rvboxv1.StreamKind_STREAM_STDERR] {
|
||||
t.Fatalf("captured output = %q streams=%v", output.String(), streams)
|
||||
}
|
||||
second, err := manager.Start(context.Background(), supervisor.StartSpec{IssueUUID: issue, CommandRevision: 1, WorkingDirectory: t.TempDir(), Execution: &rvboxv1.ExecutionSpec{ShellType: rvboxv1.ShellType_SHELL_SH, Source: &rvboxv1.ExecutionSpec_CommandText{CommandText: "true"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("restart after terminal = %v", err)
|
||||
}
|
||||
_, _ = second.Wait(context.Background())
|
||||
}
|
||||
|
||||
func TestPortableSupervisorScriptMaterializationAndCleanup_HP_SUPERVISOR_04(t *testing.T) {
|
||||
t.Parallel()
|
||||
manager, err := NewSupervisor(NativeOptions{MaxWrapperBytes: 1024})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issue, _ := domain.ParseUUIDv7("019c46f1-1d02-7000-8000-0000000000c2")
|
||||
body := []byte("printf script-ok")
|
||||
process, err := manager.Start(context.Background(), supervisor.StartSpec{IssueUUID: issue, CommandRevision: 1, WorkingDirectory: t.TempDir(), ScriptBody: body, Execution: &rvboxv1.ExecutionSpec{ShellType: rvboxv1.ShellType_SHELL_SH, Source: &rvboxv1.ExecutionSpec_Script{Script: &rvboxv1.ScriptDescriptor{SizeBytes: uint64(len(body)), Sha256: digest(body)}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := process.Wait(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chunk, err := process.ReadOutput(context.Background())
|
||||
if err != nil || string(chunk.Data) != "script-ok" {
|
||||
t.Fatalf("script output = %+v, %v", chunk, err)
|
||||
}
|
||||
if _, err := process.ReadOutput(context.Background()); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("script output close = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func digest(value []byte) []byte {
|
||||
result := sha256.Sum256(value)
|
||||
return result[:]
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//go:build windows
|
||||
|
||||
package windows
|
||||
|
||||
// The Windows adapter deliberately keeps all Win32 handles in this file. The
|
||||
// selector in selection.go is pure policy; this layer obtains one verified
|
||||
// primary token, creates a suspended child with an explicit handle list, and
|
||||
// puts it in a kill-on-close Job before releasing it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||
"github.com/rvbox/rvbox/internal/client/supervisor"
|
||||
winapi "golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
logon32LogonService = 5
|
||||
logon32ProviderDefault = 0
|
||||
securitySystemRID = "S-1-5-18"
|
||||
)
|
||||
|
||||
var (
|
||||
advapi32 = syscall.NewLazyDLL("advapi32.dll")
|
||||
procLogonUserW = advapi32.NewProc("LogonUserW")
|
||||
)
|
||||
|
||||
func stdinLineEnding() []byte { return []byte{'\r', '\n'} }
|
||||
|
||||
type nativeHandles struct {
|
||||
process winapi.Handle
|
||||
job winapi.Handle
|
||||
pid uint32
|
||||
close sync.Once
|
||||
}
|
||||
|
||||
// NewSupervisor constructs the machine-wide Windows implementation. The
|
||||
// service process is expected to run as LocalSystem; token selection verifies
|
||||
// that assumption when a command is started and records the selected context.
|
||||
func NewSupervisor(options NativeOptions) (supervisor.Supervisor, error) {
|
||||
options = options.withDefaults()
|
||||
return newExecSupervisor(options), nil
|
||||
}
|
||||
|
||||
func (manager *execSupervisor) Start(ctx context.Context, spec supervisor.StartSpec) (supervisor.Process, error) {
|
||||
if err := validateExecutionSource(&spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec.Execution.GetShellType() != rvboxv1.ShellType_SHELL_CMD && spec.Execution.GetShellType() != rvboxv1.ShellType_SHELL_POWERSHELL {
|
||||
return nil, ErrUnsupportedShell
|
||||
}
|
||||
manager.mu.Lock()
|
||||
if _, exists := manager.active[spec.IssueUUID]; exists {
|
||||
manager.mu.Unlock()
|
||||
return nil, ErrProcessAlreadyRunning
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
|
||||
plan, err := ResolveShell(spec.Execution.GetShellType(), manager.options.Shells)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := verifyExecutable(plan.ApplicationName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wrapper, err := BuildWrapper(spec.Execution, spec.ScriptBody, manager.options.MaxWrapperBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wrapperPath, cleanup, err := materializeWrapper(spec.WorkingDirectory, wrapper, manager.options.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fail := func(cause error) (supervisor.Process, error) {
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
return nil, cause
|
||||
}
|
||||
|
||||
token, identity, err := manager.selectToken(spec.Execution.GetElevated())
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
defer token.Close()
|
||||
baseEnvironment, err := token.Environ(false)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("build token environment: %w", err))
|
||||
}
|
||||
environment, err := BuildEnvironmentBlock(parseEnvironment(baseEnvironment), spec.Environment)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
launch, err := plan.BuildLaunchPlan(wrapperPath, spec.WorkingDirectory, environment)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
|
||||
stdinRead, stdinWrite, stdoutRead, stdoutWrite, stderrRead, stderrWrite, err := createStandardPipes()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
closeFiles := func() {
|
||||
for _, file := range []*os.File{stdinRead, stdinWrite, stdoutRead, stdoutWrite, stderrRead, stderrWrite} {
|
||||
if file != nil {
|
||||
_ = file.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
pipesTransferred := false
|
||||
defer func() {
|
||||
// Parent-side handles are retained only after successful process
|
||||
// creation. Any error path closes both ends here.
|
||||
if !pipesTransferred {
|
||||
closeFiles()
|
||||
}
|
||||
}()
|
||||
|
||||
job, err := createKillOnCloseJob()
|
||||
if err != nil {
|
||||
closeFiles()
|
||||
return fail(fmt.Errorf("create command Job: %w", err))
|
||||
}
|
||||
cleanupJob := true
|
||||
defer func() {
|
||||
if cleanupJob {
|
||||
_ = winapi.CloseHandle(job)
|
||||
}
|
||||
}()
|
||||
|
||||
application, err := winapi.UTF16PtrFromString(launch.ApplicationName)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
commandLine, err := winapi.UTF16FromString(launch.CommandLine)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
workingDirectory, err := winapi.UTF16PtrFromString(launch.WorkingDirectory)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
attributeList, err := winapi.NewProcThreadAttributeList(1)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
defer attributeList.Delete()
|
||||
childHandles := []winapi.Handle{winapi.Handle(stdinRead.Fd()), winapi.Handle(stdoutWrite.Fd()), winapi.Handle(stderrWrite.Fd())}
|
||||
if err := attributeList.Update(winapi.PROC_THREAD_ATTRIBUTE_HANDLE_LIST, unsafe.Pointer(&childHandles[0]), uintptr(len(childHandles))*unsafe.Sizeof(childHandles[0])); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
startup := winapi.StartupInfoEx{}
|
||||
startup.Cb = uint32(unsafe.Sizeof(startup))
|
||||
startup.Flags = winapi.STARTF_USESTDHANDLES | winapi.STARTF_USESHOWWINDOW
|
||||
startup.ShowWindow = winapi.SW_HIDE
|
||||
startup.StdInput = childHandles[0]
|
||||
startup.StdOutput = childHandles[1]
|
||||
startup.StdErr = childHandles[2]
|
||||
startup.ProcThreadAttributeList = attributeList.List()
|
||||
var processInfo winapi.ProcessInformation
|
||||
flags := uint32(winapi.CREATE_NEW_CONSOLE | winapi.CREATE_SUSPENDED | winapi.CREATE_UNICODE_ENVIRONMENT | winapi.EXTENDED_STARTUPINFO_PRESENT)
|
||||
var environmentPointer *uint16
|
||||
if len(environment) > 0 {
|
||||
environmentPointer = &environment[0]
|
||||
}
|
||||
if err := winapi.CreateProcessAsUser(token, application, &commandLine[0], nil, nil, true, flags, environmentPointer, workingDirectory, &startup.StartupInfo, &processInfo); err != nil {
|
||||
return fail(fmt.Errorf("create suspended command process: %w", err))
|
||||
}
|
||||
// The child owns these handles after CreateProcessAsUser returns. Keep only
|
||||
// the three parent ends and the process/job handles in the daemon.
|
||||
_ = stdinRead.Close()
|
||||
_ = stdoutWrite.Close()
|
||||
_ = stderrWrite.Close()
|
||||
if err := winapi.AssignProcessToJobObject(job, processInfo.Process); err != nil {
|
||||
_ = winapi.TerminateProcess(processInfo.Process, 1)
|
||||
_ = winapi.CloseHandle(processInfo.Process)
|
||||
_ = winapi.CloseHandle(processInfo.Thread)
|
||||
return fail(fmt.Errorf("assign command to Job: %w", err))
|
||||
}
|
||||
if _, err := winapi.ResumeThread(processInfo.Thread); err != nil {
|
||||
_ = winapi.TerminateJobObject(job, 1)
|
||||
_ = winapi.CloseHandle(processInfo.Process)
|
||||
_ = winapi.CloseHandle(processInfo.Thread)
|
||||
return fail(fmt.Errorf("release suspended command: %w", err))
|
||||
}
|
||||
pipesTransferred = true
|
||||
_ = winapi.CloseHandle(processInfo.Thread)
|
||||
started := manager.options.Now()
|
||||
handles := &nativeHandles{process: processInfo.Process, job: job, pid: processInfo.ProcessId}
|
||||
cleanupJob = false
|
||||
command := &exec.Cmd{Process: osProcess(processInfo.ProcessId)}
|
||||
waitFn := func() (int32, bool, error) {
|
||||
_, waitErr := winapi.WaitForSingleObject(processInfo.Process, winapi.INFINITE)
|
||||
var code uint32
|
||||
if err := winapi.GetExitCodeProcess(processInfo.Process, &code); err != nil && waitErr == nil {
|
||||
waitErr = err
|
||||
}
|
||||
handles.close.Do(func() {
|
||||
_ = winapi.CloseHandle(processInfo.Process)
|
||||
_ = winapi.CloseHandle(job)
|
||||
})
|
||||
return int32(code), false, waitErr
|
||||
}
|
||||
killFn := func(code uint32) error {
|
||||
return winapi.TerminateJobObject(job, code)
|
||||
}
|
||||
process := manager.registerProcess(spec.IssueUUID, identity, command, stdinWrite, stdoutRead, stderrRead, started, waitFn, killFn, cleanup)
|
||||
return process, nil
|
||||
}
|
||||
|
||||
func osProcess(pid uint32) *os.Process {
|
||||
process, err := os.FindProcess(int(pid))
|
||||
if err != nil {
|
||||
return &os.Process{}
|
||||
}
|
||||
return process
|
||||
}
|
||||
|
||||
func verifyExecutable(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat configured shell %q: %w", path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("configured shell %q is not a regular file", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createStandardPipes() (*os.File, *os.File, *os.File, *os.File, *os.File, *os.File, error) {
|
||||
security := &winapi.SecurityAttributes{Length: uint32(unsafe.Sizeof(winapi.SecurityAttributes{})), InheritHandle: 1}
|
||||
var stdinReadHandle, stdinWriteHandle winapi.Handle
|
||||
var stdoutReadHandle, stdoutWriteHandle winapi.Handle
|
||||
var stderrReadHandle, stderrWriteHandle winapi.Handle
|
||||
if err := winapi.CreatePipe(&stdinReadHandle, &stdinWriteHandle, security, 0); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
if err := winapi.CreatePipe(&stdoutReadHandle, &stdoutWriteHandle, security, 0); err != nil {
|
||||
_ = winapi.CloseHandle(stdinReadHandle)
|
||||
_ = winapi.CloseHandle(stdinWriteHandle)
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
if err := winapi.CreatePipe(&stderrReadHandle, &stderrWriteHandle, security, 0); err != nil {
|
||||
for _, handle := range []winapi.Handle{stdinReadHandle, stdinWriteHandle, stdoutReadHandle, stdoutWriteHandle} {
|
||||
_ = winapi.CloseHandle(handle)
|
||||
}
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
for _, handle := range []winapi.Handle{stdinWriteHandle, stdoutReadHandle, stderrReadHandle} {
|
||||
if err := winapi.SetHandleInformation(handle, winapi.HANDLE_FLAG_INHERIT, 0); err != nil {
|
||||
for _, closeHandle := range []winapi.Handle{stdinReadHandle, stdinWriteHandle, stdoutReadHandle, stdoutWriteHandle, stderrReadHandle, stderrWriteHandle} {
|
||||
_ = winapi.CloseHandle(closeHandle)
|
||||
}
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
}
|
||||
return os.NewFile(uintptr(stdinReadHandle), "rvbox-stdin-read"), os.NewFile(uintptr(stdinWriteHandle), "rvbox-stdin-write"), os.NewFile(uintptr(stdoutReadHandle), "rvbox-stdout-read"), os.NewFile(uintptr(stdoutWriteHandle), "rvbox-stdout-write"), os.NewFile(uintptr(stderrReadHandle), "rvbox-stderr-read"), os.NewFile(uintptr(stderrWriteHandle), "rvbox-stderr-write"), nil
|
||||
}
|
||||
|
||||
func createKillOnCloseJob() (winapi.Handle, error) {
|
||||
job, err := winapi.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
info := winapi.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
|
||||
info.BasicLimitInformation.LimitFlags = winapi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
if _, err := winapi.SetInformationJobObject(job, winapi.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
|
||||
_ = winapi.CloseHandle(job)
|
||||
return 0, err
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (manager *execSupervisor) selectToken(elevated bool) (winapi.Token, supervisor.EffectiveIdentity, error) {
|
||||
candidates, err := DiscoverActiveSessions()
|
||||
if err != nil {
|
||||
// A failed WTS enumeration is treated as no usable interactive
|
||||
// session; LocalSystem still provides a deterministic service path.
|
||||
candidates = nil
|
||||
}
|
||||
selection := Select(SelectionInput{Elevated: elevated, ActiveSessions: candidates, ActiveSystemAvailable: true, LocalServiceAvailable: true, LocalSystemAvailable: true})
|
||||
if selection.Effective == nil {
|
||||
if selection.Error != nil {
|
||||
return 0, supervisor.EffectiveIdentity{}, selection.Error
|
||||
}
|
||||
return 0, supervisor.EffectiveIdentity{}, errors.New("Windows execution context selection failed")
|
||||
}
|
||||
var selected *SessionCandidate
|
||||
if selection.Effective.SessionID != nil {
|
||||
for index := range candidates {
|
||||
if candidates[index].SessionID == *selection.Effective.SessionID {
|
||||
selected = &candidates[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, attempt := range selection.Attempts {
|
||||
token, identity, err := openTokenForAttempt(attempt.Context, selected)
|
||||
if err == nil {
|
||||
return token, identity, nil
|
||||
}
|
||||
if !elevated {
|
||||
return 0, supervisor.EffectiveIdentity{}, err
|
||||
}
|
||||
}
|
||||
// The pure selector stops as soon as ACTIVE_SYSTEM is available. A native
|
||||
// privilege/session operation can still fail (for example, SeTcb was
|
||||
// removed), so the final LOCAL_SYSTEM fallback is attempted here before
|
||||
// launch preparation, never by retrying a created process.
|
||||
if elevated {
|
||||
if token, identity, err := openTokenForAttempt(ContextLocalSystem, nil); err == nil {
|
||||
return token, identity, nil
|
||||
}
|
||||
}
|
||||
return 0, supervisor.EffectiveIdentity{}, errors.New("all Windows execution contexts failed before launch preparation")
|
||||
}
|
||||
|
||||
func openTokenForAttempt(contextName ExecutionContext, candidate *SessionCandidate) (winapi.Token, supervisor.EffectiveIdentity, error) {
|
||||
switch contextName {
|
||||
case ContextActiveUser, ContextActiveUserElevated, ContextActiveSystem:
|
||||
if candidate == nil {
|
||||
return 0, supervisor.EffectiveIdentity{}, errors.New("active execution context has no selected session")
|
||||
}
|
||||
var token winapi.Token
|
||||
if err := winapi.WTSQueryUserToken(candidate.SessionID, &token); err != nil {
|
||||
return 0, supervisor.EffectiveIdentity{}, err
|
||||
}
|
||||
if contextName == ContextActiveUserElevated {
|
||||
if !token.IsElevated() {
|
||||
linked, err := token.GetLinkedToken()
|
||||
_ = token.Close()
|
||||
if err != nil {
|
||||
return 0, supervisor.EffectiveIdentity{}, err
|
||||
}
|
||||
token = linked
|
||||
}
|
||||
} else if contextName == ContextActiveSystem {
|
||||
_ = token.Close()
|
||||
serviceToken, identity, err := duplicateServiceTokenForSession(candidate.SessionID)
|
||||
if err != nil {
|
||||
return 0, supervisor.EffectiveIdentity{}, err
|
||||
}
|
||||
identity.Context = string(ContextActiveSystem)
|
||||
return serviceToken, identity, nil
|
||||
} else if token.IsElevated() {
|
||||
_ = token.Close()
|
||||
return 0, supervisor.EffectiveIdentity{}, errors.New("active-user token is elevated and no restricted medium token was available")
|
||||
}
|
||||
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]}
|
||||
return token, identity, nil
|
||||
case ContextLocalService:
|
||||
token, err := logonLocalService()
|
||||
if err != nil {
|
||||
return 0, supervisor.EffectiveIdentity{}, err
|
||||
}
|
||||
return token, supervisor.EffectiveIdentity{Context: string(ContextLocalService), Elevated: false, Integrity: "medium"}, nil
|
||||
case ContextLocalSystem:
|
||||
return duplicateServiceToken()
|
||||
default:
|
||||
return 0, supervisor.EffectiveIdentity{}, errors.New("unknown Windows execution context")
|
||||
}
|
||||
}
|
||||
|
||||
func duplicateServiceToken() (winapi.Token, supervisor.EffectiveIdentity, error) {
|
||||
return duplicateServiceTokenForSession(0)
|
||||
}
|
||||
|
||||
func duplicateServiceTokenForSession(sessionID uint32) (winapi.Token, supervisor.EffectiveIdentity, error) {
|
||||
var source winapi.Token
|
||||
if err := winapi.OpenProcessToken(winapi.CurrentProcess(), winapi.TOKEN_ALL_ACCESS, &source); err != nil {
|
||||
return 0, supervisor.EffectiveIdentity{}, err
|
||||
}
|
||||
defer source.Close()
|
||||
var target winapi.Token
|
||||
if err := winapi.DuplicateTokenEx(source, winapi.TOKEN_ALL_ACCESS, nil, winapi.SecurityImpersonation, winapi.TokenPrimary, &target); err != nil {
|
||||
return 0, supervisor.EffectiveIdentity{}, err
|
||||
}
|
||||
if sessionID != 0 {
|
||||
if err := winapi.SetTokenInformation(target, winapi.TokenSessionId, (*byte)(unsafe.Pointer(&sessionID)), uint32(unsafe.Sizeof(sessionID))); err != nil {
|
||||
_ = target.Close()
|
||||
return 0, supervisor.EffectiveIdentity{}, err
|
||||
}
|
||||
}
|
||||
user, err := target.GetTokenUser()
|
||||
if err != nil || user.User.Sid == nil || user.User.Sid.String() != securitySystemRID {
|
||||
_ = target.Close()
|
||||
if err != nil {
|
||||
return 0, supervisor.EffectiveIdentity{}, err
|
||||
}
|
||||
return 0, supervisor.EffectiveIdentity{}, errors.New("duplicated service token is not LocalSystem")
|
||||
}
|
||||
return target, supervisor.EffectiveIdentity{Context: string(ContextLocalSystem), SessionID: sessionID, UserSID: user.User.Sid.String(), Elevated: true, Integrity: "system"}, nil
|
||||
}
|
||||
|
||||
func logonLocalService() (winapi.Token, error) {
|
||||
account, _ := syscall.UTF16PtrFromString("LocalService")
|
||||
domainName, _ := syscall.UTF16PtrFromString("NT AUTHORITY")
|
||||
var token winapi.Token
|
||||
r, _, callErr := procLogonUserW.Call(uintptr(unsafe.Pointer(account)), uintptr(unsafe.Pointer(domainName)), 0, logon32LogonService, logon32ProviderDefault, uintptr(unsafe.Pointer(&token)))
|
||||
if r == 0 {
|
||||
if callErr != syscall.Errno(0) {
|
||||
return 0, callErr
|
||||
}
|
||||
return 0, syscall.GetLastError()
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (manager *execSupervisor) Signal(ctx context.Context, process supervisor.Process, signal supervisor.SignalKind) (supervisor.SignalOutcome, error) {
|
||||
if process == nil {
|
||||
return supervisor.SignalOutcome{}, ErrProcessNotFound
|
||||
}
|
||||
native, ok := process.(*execProcess)
|
||||
if !ok || native.killFn == nil {
|
||||
return supervisor.SignalOutcome{}, ErrProcessNotFound
|
||||
}
|
||||
if signal != supervisor.SignalTerm && signal != supervisor.SignalKill {
|
||||
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.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return supervisor.SignalOutcome{}, ctx.Err()
|
||||
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
|
||||
}
|
||||
|
||||
func (manager *execSupervisor) Snapshot(ctx context.Context, process supervisor.Process) (supervisor.ResourceSnapshot, error) {
|
||||
if process == nil {
|
||||
return supervisor.ResourceSnapshot{}, ErrProcessNotFound
|
||||
}
|
||||
native, ok := process.(*execProcess)
|
||||
if !ok || native.cmd == nil || native.cmd.Process == nil {
|
||||
return supervisor.ResourceSnapshot{}, ErrProcessNotFound
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return supervisor.ResourceSnapshot{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
return supervisor.ResourceSnapshot{ProcessCount: 1, ObservedAt: manager.options.Now(), Complete: false, Detail: "Windows Job accounting is available after native completion integration"}, nil
|
||||
}
|
||||
|
||||
func (manager *execSupervisor) StopAll(_ context.Context) error {
|
||||
manager.mu.Lock()
|
||||
processes := make([]*execProcess, 0, len(manager.active))
|
||||
for _, process := range manager.active {
|
||||
processes = append(processes, process)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
for _, process := range processes {
|
||||
if process.killFn != nil {
|
||||
if err := process.killFn(1); err != nil && !errors.Is(err, winapi.ERROR_INVALID_HANDLE) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user