feat: authenticate Windows command launcher and signal helper
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
package windows
|
||||
|
||||
// The launcher wire format is deliberately private to one service/launcher
|
||||
// pair. It is not a second public protocol: the channel name is random, the
|
||||
// generation is bound into every frame, and every frame is length- and
|
||||
// checksum-validated before its payload is decoded.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
const (
|
||||
launcherMagic uint32 = 0x52564c31 // RVL1
|
||||
launcherVersion uint16 = 1
|
||||
launcherHeaderBytes = 4 + 2 + 2 + 16 + 4 + sha256.Size
|
||||
launcherMaxFrameBytes = 4 << 20
|
||||
)
|
||||
|
||||
type launcherFrameKind uint16
|
||||
|
||||
const (
|
||||
launcherFrameHello launcherFrameKind = iota + 1
|
||||
launcherFrameLaunch
|
||||
launcherFrameShellPrepared
|
||||
launcherFrameRelease
|
||||
launcherFrameReleased
|
||||
launcherFrameAbort
|
||||
launcherFrameSignalRequest
|
||||
launcherFrameSignalResult
|
||||
)
|
||||
|
||||
type launcherFrame struct {
|
||||
Kind launcherFrameKind
|
||||
Generation [16]byte
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
func marshalLauncherFrame(frame launcherFrame) ([]byte, error) {
|
||||
if frame.Kind == 0 || frame.Kind > launcherFrameSignalResult {
|
||||
return nil, errors.New("launcher frame kind is invalid")
|
||||
}
|
||||
if uint64(len(frame.Payload)) > launcherMaxFrameBytes {
|
||||
return nil, fmt.Errorf("launcher frame payload exceeds %d bytes", launcherMaxFrameBytes)
|
||||
}
|
||||
encoded := make([]byte, launcherHeaderBytes+len(frame.Payload))
|
||||
binary.LittleEndian.PutUint32(encoded[0:4], launcherMagic)
|
||||
binary.LittleEndian.PutUint16(encoded[4:6], launcherVersion)
|
||||
binary.LittleEndian.PutUint16(encoded[6:8], uint16(frame.Kind))
|
||||
copy(encoded[8:24], frame.Generation[:])
|
||||
binary.LittleEndian.PutUint32(encoded[24:28], uint32(len(frame.Payload)))
|
||||
copy(encoded[launcherHeaderBytes:], frame.Payload)
|
||||
check := make([]byte, 28+len(frame.Payload))
|
||||
copy(check, encoded[:28])
|
||||
copy(check[28:], frame.Payload)
|
||||
digest := sha256.Sum256(check)
|
||||
copy(encoded[28:launcherHeaderBytes], digest[:])
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func readLauncherFrame(reader io.Reader, expected [16]byte) (launcherFrame, error) {
|
||||
header := make([]byte, launcherHeaderBytes)
|
||||
if _, err := io.ReadFull(reader, header); err != nil {
|
||||
return launcherFrame{}, err
|
||||
}
|
||||
if binary.LittleEndian.Uint32(header[0:4]) != launcherMagic {
|
||||
return launcherFrame{}, errors.New("launcher frame magic mismatch")
|
||||
}
|
||||
if binary.LittleEndian.Uint16(header[4:6]) != launcherVersion {
|
||||
return launcherFrame{}, errors.New("launcher frame version mismatch")
|
||||
}
|
||||
kind := launcherFrameKind(binary.LittleEndian.Uint16(header[6:8]))
|
||||
if kind == 0 || kind > launcherFrameSignalResult {
|
||||
return launcherFrame{}, errors.New("launcher frame kind is invalid")
|
||||
}
|
||||
var generation [16]byte
|
||||
copy(generation[:], header[8:24])
|
||||
if generation != expected {
|
||||
return launcherFrame{}, errors.New("launcher frame generation mismatch")
|
||||
}
|
||||
length := binary.LittleEndian.Uint32(header[24:28])
|
||||
if length > launcherMaxFrameBytes {
|
||||
return launcherFrame{}, fmt.Errorf("launcher frame payload exceeds %d bytes", launcherMaxFrameBytes)
|
||||
}
|
||||
payload := make([]byte, int(length))
|
||||
if _, err := io.ReadFull(reader, payload); err != nil {
|
||||
return launcherFrame{}, err
|
||||
}
|
||||
check := make([]byte, 28+len(payload))
|
||||
copy(check, header[:28])
|
||||
copy(check[28:], payload)
|
||||
digest := sha256.Sum256(check)
|
||||
if string(digest[:]) != string(header[28:launcherHeaderBytes]) {
|
||||
return launcherFrame{}, errors.New("launcher frame checksum mismatch")
|
||||
}
|
||||
return launcherFrame{Kind: kind, Generation: generation, Payload: payload}, nil
|
||||
}
|
||||
|
||||
func readLauncherFrameContext(ctx context.Context, reader io.Reader, expected [16]byte) (launcherFrame, error) {
|
||||
result := make(chan struct {
|
||||
frame launcherFrame
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
frame, err := readLauncherFrame(reader, expected)
|
||||
result <- struct {
|
||||
frame launcherFrame
|
||||
err error
|
||||
}{frame: frame, err: err}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return launcherFrame{}, ctx.Err()
|
||||
case value := <-result:
|
||||
return value.frame, value.err
|
||||
}
|
||||
}
|
||||
|
||||
func writeLauncherFrame(writer io.Writer, frame launcherFrame) error {
|
||||
encoded, err := marshalLauncherFrame(frame)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for len(encoded) > 0 {
|
||||
written, err := writer.Write(encoded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written <= 0 || written > len(encoded) {
|
||||
return errors.New("launcher frame writer made no progress")
|
||||
}
|
||||
encoded = encoded[written:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLauncherFrameRoundTripAndGenerationFence_HP_LAUNCH_06(t *testing.T) {
|
||||
var generation [16]byte
|
||||
for index := range generation {
|
||||
generation[index] = byte(index + 1)
|
||||
}
|
||||
original := launcherFrame{Kind: launcherFrameLaunch, Generation: generation, Payload: []byte("bounded launch request")}
|
||||
encoded, err := marshalLauncherFrame(original)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := readLauncherFrame(bytes.NewReader(encoded), generation)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Kind != original.Kind || !bytes.Equal(decoded.Payload, original.Payload) {
|
||||
t.Fatalf("decoded frame = %#v, want %#v", decoded, original)
|
||||
}
|
||||
wrong := generation
|
||||
wrong[0]++
|
||||
if _, err := readLauncherFrame(bytes.NewReader(encoded), wrong); err == nil {
|
||||
t.Fatal("frame with a different generation was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherFrameRejectsChecksumLengthAndKind_BH_LAUNCH_06(t *testing.T) {
|
||||
var generation [16]byte
|
||||
encoded, err := marshalLauncherFrame(launcherFrame{Kind: launcherFrameHello, Generation: generation, Payload: []byte("hello")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded[len(encoded)-1] ^= 1
|
||||
if _, err := readLauncherFrame(bytes.NewReader(encoded), generation); err == nil {
|
||||
t.Fatal("checksum-corrupted frame was accepted")
|
||||
}
|
||||
encoded, err = marshalLauncherFrame(launcherFrame{Kind: launcherFrameHello, Generation: generation, Payload: nil})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded[6] = 0xff
|
||||
encoded[7] = 0xff
|
||||
if _, err := readLauncherFrame(bytes.NewReader(encoded), generation); err == nil {
|
||||
t.Fatal("invalid frame kind was accepted")
|
||||
}
|
||||
if _, err := marshalLauncherFrame(launcherFrame{Kind: launcherFrameAbort, Generation: generation, Payload: make([]byte, launcherMaxFrameBytes+1)}); err == nil {
|
||||
t.Fatal("oversized launcher payload was accepted")
|
||||
}
|
||||
if _, err := readLauncherFrame(bytes.NewReader(encoded[:len(encoded)-1]), generation); err == nil {
|
||||
t.Fatal("truncated launcher frame was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
//go:build windows
|
||||
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
cryptorand "crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/rvbox/rvbox/internal/client/supervisor"
|
||||
winapi "golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
launcherHandshakeTimeout = 30 * time.Second
|
||||
launcherPipeBuffer = 64 << 10
|
||||
launcherPipePrefix = `\\.\pipe\rvbox-launch-`
|
||||
)
|
||||
|
||||
var (
|
||||
procGetNamedPipeClientProcessID = winapi.NewLazySystemDLL("kernel32.dll").NewProc("GetNamedPipeClientProcessId")
|
||||
procGetNamedPipeClientSessionID = winapi.NewLazySystemDLL("kernel32.dll").NewProc("GetNamedPipeClientSessionId")
|
||||
procIsProcessInJob = winapi.NewLazySystemDLL("kernel32.dll").NewProc("IsProcessInJob")
|
||||
)
|
||||
|
||||
type launcherPipe struct {
|
||||
name string
|
||||
file *os.File
|
||||
}
|
||||
|
||||
type launcherPipeSet struct {
|
||||
channel string
|
||||
generation [16]byte
|
||||
control launcherPipe
|
||||
stdin launcherPipe
|
||||
stdout launcherPipe
|
||||
stderr launcherPipe
|
||||
}
|
||||
|
||||
func (pipes *launcherPipeSet) closeAll() {
|
||||
for _, pipe := range []*launcherPipe{&pipes.control, &pipes.stdin, &pipes.stdout, &pipes.stderr} {
|
||||
if pipe.file != nil {
|
||||
_ = pipe.file.Close()
|
||||
pipe.file = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newLauncherPipes(effectiveSID string) (*launcherPipeSet, error) {
|
||||
if effectiveSID == "" {
|
||||
return nil, errors.New("launcher pipe ACL requires an effective SID")
|
||||
}
|
||||
if _, err := winapi.StringToSid(effectiveSID); err != nil {
|
||||
return nil, fmt.Errorf("invalid effective SID for launcher pipe ACL: %w", err)
|
||||
}
|
||||
var generation [16]byte
|
||||
if _, err := io.ReadFull(cryptorand.Reader, generation[:]); err != nil {
|
||||
return nil, fmt.Errorf("generate launcher channel: %w", err)
|
||||
}
|
||||
channel := hex.EncodeToString(generation[:])
|
||||
securityDescriptor, err := winapi.SecurityDescriptorFromString(fmt.Sprintf("O:SYD:(A;;GA;;;SY)(A;;GA;;;%s)", effectiveSID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build launcher pipe ACL: %w", err)
|
||||
}
|
||||
security := &winapi.SecurityAttributes{Length: uint32(unsafe.Sizeof(winapi.SecurityAttributes{})), SecurityDescriptor: securityDescriptor}
|
||||
newPipe := func(suffix string, access uint32) (launcherPipe, error) {
|
||||
name := launcherPipePrefix + channel + "-" + suffix
|
||||
namePtr, err := winapi.UTF16PtrFromString(name)
|
||||
if err != nil {
|
||||
return launcherPipe{}, err
|
||||
}
|
||||
mode := uint32(winapi.PIPE_TYPE_BYTE | winapi.PIPE_READMODE_BYTE | winapi.PIPE_WAIT | winapi.PIPE_REJECT_REMOTE_CLIENTS)
|
||||
handle, err := winapi.CreateNamedPipe(namePtr, access, mode, 1, launcherPipeBuffer, launcherPipeBuffer, 0, security)
|
||||
if err != nil {
|
||||
return launcherPipe{}, fmt.Errorf("create launcher pipe %s: %w", suffix, err)
|
||||
}
|
||||
return launcherPipe{name: name, file: os.NewFile(uintptr(handle), "rvbox-launch-"+suffix)}, nil
|
||||
}
|
||||
pipes := &launcherPipeSet{channel: channel, generation: generation}
|
||||
if pipes.control, err = newPipe("control", winapi.PIPE_ACCESS_DUPLEX); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pipes.stdin, err = newPipe("stdin", winapi.PIPE_ACCESS_OUTBOUND); err != nil {
|
||||
pipes.closeAll()
|
||||
return nil, err
|
||||
}
|
||||
if pipes.stdout, err = newPipe("stdout", winapi.PIPE_ACCESS_INBOUND); err != nil {
|
||||
pipes.closeAll()
|
||||
return nil, err
|
||||
}
|
||||
if pipes.stderr, err = newPipe("stderr", winapi.PIPE_ACCESS_INBOUND); err != nil {
|
||||
pipes.closeAll()
|
||||
return nil, err
|
||||
}
|
||||
return pipes, nil
|
||||
}
|
||||
|
||||
type launcherPeer struct {
|
||||
PID uint32
|
||||
Creation uint64
|
||||
SessionID uint32
|
||||
UserSID string
|
||||
}
|
||||
|
||||
func processCreation(handle winapi.Handle) (uint64, error) {
|
||||
var creation, exit, kernel, user winapi.Filetime
|
||||
if err := winapi.GetProcessTimes(handle, &creation, &exit, &kernel, &user); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint64(creation.HighDateTime)<<32 | uint64(creation.LowDateTime), nil
|
||||
}
|
||||
|
||||
func verifyLauncherPipePeer(handle winapi.Handle, expected launcherPeer) error {
|
||||
var pid uint32
|
||||
if result, _, callErr := procGetNamedPipeClientProcessID.Call(uintptr(handle), uintptr(unsafe.Pointer(&pid))); result == 0 {
|
||||
if callErr == syscall.Errno(0) {
|
||||
callErr = syscall.GetLastError()
|
||||
}
|
||||
return fmt.Errorf("query launcher pipe client PID: %w", callErr)
|
||||
}
|
||||
if pid != expected.PID {
|
||||
return fmt.Errorf("launcher pipe client PID %d does not match %d", pid, expected.PID)
|
||||
}
|
||||
var sessionID uint32
|
||||
if result, _, callErr := procGetNamedPipeClientSessionID.Call(uintptr(handle), uintptr(unsafe.Pointer(&sessionID))); result == 0 {
|
||||
if callErr == syscall.Errno(0) {
|
||||
callErr = syscall.GetLastError()
|
||||
}
|
||||
return fmt.Errorf("query launcher pipe client session: %w", callErr)
|
||||
}
|
||||
if sessionID != expected.SessionID {
|
||||
return fmt.Errorf("launcher pipe client session %d does not match %d", sessionID, expected.SessionID)
|
||||
}
|
||||
process, err := winapi.OpenProcess(winapi.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open launcher pipe client process: %w", err)
|
||||
}
|
||||
defer winapi.CloseHandle(process)
|
||||
creation, err := processCreation(process)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query launcher process creation time: %w", err)
|
||||
}
|
||||
if creation != expected.Creation {
|
||||
return errors.New("launcher pipe client creation time does not match")
|
||||
}
|
||||
var token winapi.Token
|
||||
if err := winapi.OpenProcessToken(process, winapi.TOKEN_QUERY, &token); err != nil {
|
||||
return fmt.Errorf("open launcher client token: %w", err)
|
||||
}
|
||||
defer token.Close()
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil || user.User.Sid == nil {
|
||||
if err != nil {
|
||||
return fmt.Errorf("query launcher client SID: %w", err)
|
||||
}
|
||||
return errors.New("launcher client token has no SID")
|
||||
}
|
||||
if user.User.Sid.String() != expected.UserSID {
|
||||
return fmt.Errorf("launcher client SID %q does not match %q", user.User.Sid.String(), expected.UserSID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func connectLauncherPipe(ctx context.Context, pipe launcherPipe, expected launcherPeer) error {
|
||||
handle := winapi.Handle(pipe.file.Fd())
|
||||
connected := make(chan error, 1)
|
||||
go func() {
|
||||
err := winapi.ConnectNamedPipe(handle, nil)
|
||||
if err != nil && !errors.Is(err, winapi.ERROR_PIPE_CONNECTED) {
|
||||
connected <- err
|
||||
return
|
||||
}
|
||||
connected <- verifyLauncherPipePeer(handle, expected)
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-connected:
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect launcher pipe %s: %w", pipe.name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func acceptLauncherPipes(ctx context.Context, pipes *launcherPipeSet, expected launcherPeer) error {
|
||||
results := make(chan error, 4)
|
||||
for _, pipe := range []launcherPipe{pipes.control, pipes.stdin, pipes.stdout, pipes.stderr} {
|
||||
go func(pipe launcherPipe) { results <- connectLauncherPipe(ctx, pipe, expected) }(pipe)
|
||||
}
|
||||
for range 4 {
|
||||
if err := <-results; err != nil {
|
||||
pipes.closeAll()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func launcherGeneration(channel string) ([16]byte, error) {
|
||||
var generation [16]byte
|
||||
if len(channel) != hex.EncodedLen(len(generation)) {
|
||||
return generation, errors.New("invalid launcher channel length")
|
||||
}
|
||||
if _, err := hex.Decode(generation[:], []byte(channel)); err != nil {
|
||||
return generation, errors.New("invalid launcher channel encoding")
|
||||
}
|
||||
return generation, nil
|
||||
}
|
||||
|
||||
func openLauncherPipe(channel, suffix string, access uint32) (*os.File, error) {
|
||||
generation, err := launcherGeneration(channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = generation
|
||||
name, err := winapi.UTF16PtrFromString(launcherPipePrefix + strings.ToLower(channel) + "-" + suffix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deadline := time.Now().Add(launcherHandshakeTimeout)
|
||||
for {
|
||||
handle, openErr := winapi.CreateFile(name, access, 0, nil, winapi.OPEN_EXISTING, 0, 0)
|
||||
if openErr == nil {
|
||||
return os.NewFile(uintptr(handle), "rvbox-launch-"+suffix), nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("open launcher pipe %s: %w", suffix, openErr)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *execSupervisor) startViaLauncher(ctx context.Context, spec supervisor.StartSpec, token winapi.Token, identity supervisor.EffectiveIdentity, launch LaunchPlan, cleanup func()) (supervisor.Process, error) {
|
||||
pipes, err := newLauncherPipes(identity.UserSID)
|
||||
if err != nil {
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
job, err := createKillOnCloseJob()
|
||||
if err != nil {
|
||||
pipes.closeAll()
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
return nil, fmt.Errorf("create command Job: %w", err)
|
||||
}
|
||||
if err := applyJobProfiles(job, manager.options.JobProfiles, spec.ExecutionProfiles); err != nil {
|
||||
_ = winapi.CloseHandle(job)
|
||||
pipes.closeAll()
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
closeOnFailure := true
|
||||
defer func() {
|
||||
if closeOnFailure {
|
||||
_ = winapi.TerminateJobObject(job, 1)
|
||||
_ = winapi.CloseHandle(job)
|
||||
pipes.closeAll()
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}()
|
||||
executable := manager.options.ExecutablePath
|
||||
if executable == "" {
|
||||
executable, err = os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve launcher executable: %w", err)
|
||||
}
|
||||
}
|
||||
if err := verifyExecutable(executable); err != nil {
|
||||
return nil, fmt.Errorf("verify launcher executable: %w", err)
|
||||
}
|
||||
launcherApplication, err := winapi.UTF16PtrFromString(executable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
launcherCommand, err := BuildCommandLine([]string{executable, "--launcher", "--channel", pipes.channel})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
launcherCommandUTF16, err := winapi.UTF16FromString(launcherCommand)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startup := winapi.StartupInfoEx{StartupInfo: winapi.StartupInfo{Cb: uint32(unsafe.Sizeof(winapi.StartupInfoEx{}))}}
|
||||
var launcherInfo winapi.ProcessInformation
|
||||
flags := uint32(winapi.CREATE_SUSPENDED | winapi.CREATE_UNICODE_ENVIRONMENT | winapi.EXTENDED_STARTUPINFO_PRESENT | winapi.CREATE_NO_WINDOW)
|
||||
if err := winapi.CreateProcessAsUser(token, launcherApplication, &launcherCommandUTF16[0], nil, nil, false, flags, nil, nil, &startup.StartupInfo, &launcherInfo); err != nil {
|
||||
return nil, fmt.Errorf("create suspended launcher: %w", err)
|
||||
}
|
||||
launcherCreated := true
|
||||
defer func() {
|
||||
if launcherCreated {
|
||||
_ = winapi.TerminateProcess(launcherInfo.Process, 1)
|
||||
_ = winapi.CloseHandle(launcherInfo.Process)
|
||||
_ = winapi.CloseHandle(launcherInfo.Thread)
|
||||
}
|
||||
}()
|
||||
if err := winapi.AssignProcessToJobObject(job, launcherInfo.Process); err != nil {
|
||||
return nil, fmt.Errorf("assign launcher to Job: %w", err)
|
||||
}
|
||||
launcherBirth, err := processCreation(launcherInfo.Process)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query launcher creation time: %w", err)
|
||||
}
|
||||
if _, err := winapi.ResumeThread(launcherInfo.Thread); err != nil {
|
||||
return nil, fmt.Errorf("resume launcher: %w", err)
|
||||
}
|
||||
_ = winapi.CloseHandle(launcherInfo.Thread)
|
||||
expected := launcherPeer{PID: launcherInfo.ProcessId, Creation: launcherBirth, SessionID: identity.SessionID, UserSID: identity.UserSID}
|
||||
handshakeCtx, cancel := context.WithTimeout(ctx, launcherHandshakeTimeout)
|
||||
defer cancel()
|
||||
if err := acceptLauncherPipes(handshakeCtx, pipes, expected); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
frame, err := readLauncherFrameContext(handshakeCtx, pipes.control.file, pipes.generation)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read launcher hello: %w", err)
|
||||
}
|
||||
if frame.Kind != launcherFrameHello {
|
||||
return nil, errors.New("launcher did not send hello first")
|
||||
}
|
||||
var hello launcherHello
|
||||
if err := unmarshalLauncherPayload(frame.Payload, &hello); err != nil {
|
||||
return nil, fmt.Errorf("decode launcher hello: %w", err)
|
||||
}
|
||||
if hello.PID != expected.PID || hello.Creation != expected.Creation || hello.SessionID != expected.SessionID || hello.Effective != expected.UserSID {
|
||||
return nil, errors.New("launcher hello identity mismatch")
|
||||
}
|
||||
requestPayload, err := marshalLauncherPayload(launcherRequest{ApplicationName: launch.ApplicationName, CommandLine: launch.CommandLine, WorkingDirectory: launch.WorkingDirectory, Environment: launch.Environment})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeLauncherFrame(pipes.control.file, launcherFrame{Kind: launcherFrameLaunch, Generation: pipes.generation, Payload: requestPayload}); err != nil {
|
||||
return nil, fmt.Errorf("send launcher request: %w", err)
|
||||
}
|
||||
frame, err = readLauncherFrameContext(handshakeCtx, pipes.control.file, pipes.generation)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read shell preparation: %w", err)
|
||||
}
|
||||
if frame.Kind != launcherFrameShellPrepared {
|
||||
return nil, errors.New("launcher did not prepare a shell")
|
||||
}
|
||||
var prepared launcherShellPrepared
|
||||
if err := unmarshalLauncherPayload(frame.Payload, &prepared); err != nil {
|
||||
return nil, fmt.Errorf("decode shell preparation: %w", err)
|
||||
}
|
||||
if prepared.PID == 0 || prepared.Creation == 0 {
|
||||
return nil, errors.New("launcher returned incomplete shell identity")
|
||||
}
|
||||
shellHandle, err := winapi.OpenProcess(winapi.PROCESS_QUERY_LIMITED_INFORMATION, false, prepared.PID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open prepared shell: %w", err)
|
||||
}
|
||||
actualShellBirth, birthErr := processCreation(shellHandle)
|
||||
if birthErr != nil || actualShellBirth != prepared.Creation {
|
||||
_ = winapi.CloseHandle(shellHandle)
|
||||
if birthErr != nil {
|
||||
return nil, fmt.Errorf("verify prepared shell creation time: %w", birthErr)
|
||||
}
|
||||
return nil, errors.New("prepared shell creation time changed")
|
||||
}
|
||||
var inJob bool
|
||||
if result, _, callErr := procIsProcessInJob.Call(uintptr(shellHandle), uintptr(job), uintptr(unsafe.Pointer(&inJob))); result == 0 {
|
||||
_ = winapi.CloseHandle(shellHandle)
|
||||
if callErr == syscall.Errno(0) {
|
||||
callErr = syscall.GetLastError()
|
||||
}
|
||||
return nil, fmt.Errorf("verify prepared shell Job membership: %w", callErr)
|
||||
}
|
||||
_ = winapi.CloseHandle(shellHandle)
|
||||
if !inJob {
|
||||
return nil, errors.New("prepared shell is not in the command Job")
|
||||
}
|
||||
started := manager.options.Now()
|
||||
var resourceClose sync.Once
|
||||
closeResources := func() {
|
||||
resourceClose.Do(func() {
|
||||
_ = pipes.control.file.Close()
|
||||
_ = pipes.stdin.file.Close()
|
||||
_ = winapi.CloseHandle(launcherInfo.Process)
|
||||
_ = winapi.CloseHandle(job)
|
||||
})
|
||||
}
|
||||
var signalToken winapi.Token
|
||||
if err := winapi.DuplicateTokenEx(token, winapi.TOKEN_ALL_ACCESS, nil, winapi.SecurityImpersonation, winapi.TokenPrimary, &signalToken); err != nil {
|
||||
// A signal helper is an optional control path. The command itself is
|
||||
// already fully prepared; Signal falls back to the verified direct
|
||||
// console attach path if Windows refuses this duplicate.
|
||||
signalToken = 0
|
||||
}
|
||||
var signalTokenClose sync.Once
|
||||
releaseFn := func() error {
|
||||
if err := writeLauncherFrame(pipes.control.file, launcherFrame{Kind: launcherFrameRelease, Generation: pipes.generation}); err != nil {
|
||||
return fmt.Errorf("send launcher release: %w", err)
|
||||
}
|
||||
ack, err := readLauncherFrame(pipes.control.file, pipes.generation)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read launcher release acknowledgement: %w", err)
|
||||
}
|
||||
if ack.Kind != launcherFrameReleased {
|
||||
return errors.New("launcher did not acknowledge release")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
waitFn := func() (int32, bool, error) {
|
||||
_, waitErr := winapi.WaitForSingleObject(launcherInfo.Process, winapi.INFINITE)
|
||||
var code uint32
|
||||
if err := winapi.GetExitCodeProcess(launcherInfo.Process, &code); err != nil && waitErr == nil {
|
||||
waitErr = err
|
||||
}
|
||||
signalTokenClose.Do(func() {
|
||||
if signalToken != 0 {
|
||||
_ = signalToken.Close()
|
||||
}
|
||||
})
|
||||
closeResources()
|
||||
return int32(code), false, waitErr
|
||||
}
|
||||
killFn := func(code uint32) error {
|
||||
err := winapi.TerminateJobObject(job, code)
|
||||
return err
|
||||
}
|
||||
command := &exec.Cmd{Process: osProcess(prepared.PID)}
|
||||
process := manager.registerProcess(spec.IssueUUID, identity, command, pipes.stdin.file, pipes.stdout.file, pipes.stderr.file, started, waitFn, killFn, releaseFn, cleanup)
|
||||
process.creation = prepared.Creation
|
||||
if signalToken != 0 {
|
||||
process.signalFn = func(signalContext context.Context) (bool, error) {
|
||||
return runSignalHelper(signalContext, manager.options.ExecutablePath, signalToken, identity, prepared.PID, prepared.Creation)
|
||||
}
|
||||
}
|
||||
process.snapshotFn = func() (supervisor.ResourceSnapshot, error) {
|
||||
return queryJobSnapshot(job, manager.options.Now())
|
||||
}
|
||||
closeOnFailure = false
|
||||
launcherCreated = false
|
||||
return process, nil
|
||||
}
|
||||
|
||||
// RunLauncher is the only entry point for --launcher. It opens the four
|
||||
// private pipes, reports its immutable identity, creates the requested shell
|
||||
// suspended, and waits for the service's durable release frame before running
|
||||
// any command code.
|
||||
func RunLauncher(_ context.Context, channel string) error {
|
||||
generation, err := launcherGeneration(channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
control, err := openLauncherPipe(channel, "control", winapi.GENERIC_READ|winapi.GENERIC_WRITE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer control.Close()
|
||||
stdin, err := openLauncherPipe(channel, "stdin", winapi.GENERIC_READ)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stdin.Close()
|
||||
stdout, err := openLauncherPipe(channel, "stdout", winapi.GENERIC_WRITE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stdout.Close()
|
||||
stderr, err := openLauncherPipe(channel, "stderr", winapi.GENERIC_WRITE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stderr.Close()
|
||||
var current winapi.Token
|
||||
err = winapi.OpenProcessToken(winapi.CurrentProcess(), winapi.TOKEN_QUERY, ¤t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer current.Close()
|
||||
user, err := current.GetTokenUser()
|
||||
if err != nil || user.User.Sid == nil {
|
||||
return errors.New("launcher token has no user SID")
|
||||
}
|
||||
sessionID, err := tokenInformationUint32(current, winapi.TokenSessionId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
creation, err := processCreation(winapi.CurrentProcess())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
helloPayload, err := marshalLauncherPayload(launcherHello{PID: winapi.GetCurrentProcessId(), Creation: creation, SessionID: sessionID, Effective: user.User.Sid.String()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeLauncherFrame(control, launcherFrame{Kind: launcherFrameHello, Generation: generation, Payload: helloPayload}); err != nil {
|
||||
return err
|
||||
}
|
||||
frame, err := readLauncherFrame(control, generation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if frame.Kind != launcherFrameLaunch {
|
||||
return errors.New("launcher received an unexpected first command")
|
||||
}
|
||||
var request launcherRequest
|
||||
if err := unmarshalLauncherPayload(frame.Payload, &request); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateWindowsExecutablePath(request.ApplicationName); err != nil || request.CommandLine == "" || !ValidAbsoluteWindowsPath(request.WorkingDirectory) || len(request.Environment) < 2 || request.Environment[len(request.Environment)-1] != 0 || request.Environment[len(request.Environment)-2] != 0 {
|
||||
return errors.New("launcher request failed validation")
|
||||
}
|
||||
if err := verifyExecutable(request.ApplicationName); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, handle := range []winapi.Handle{winapi.Handle(stdin.Fd()), winapi.Handle(stdout.Fd()), winapi.Handle(stderr.Fd())} {
|
||||
if err := winapi.SetHandleInformation(handle, winapi.HANDLE_FLAG_INHERIT, winapi.HANDLE_FLAG_INHERIT); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
application, err := winapi.UTF16PtrFromString(request.ApplicationName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commandLine, err := winapi.UTF16FromString(request.CommandLine)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workingDirectory, err := winapi.UTF16PtrFromString(request.WorkingDirectory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attributes, err := winapi.NewProcThreadAttributeList(1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer attributes.Delete()
|
||||
childHandles := []winapi.Handle{winapi.Handle(stdin.Fd()), winapi.Handle(stdout.Fd()), winapi.Handle(stderr.Fd())}
|
||||
if err := attributes.Update(winapi.PROC_THREAD_ATTRIBUTE_HANDLE_LIST, unsafe.Pointer(&childHandles[0]), uintptr(len(childHandles))*unsafe.Sizeof(childHandles[0])); err != nil {
|
||||
return err
|
||||
}
|
||||
startup := winapi.StartupInfoEx{StartupInfo: winapi.StartupInfo{Cb: uint32(unsafe.Sizeof(winapi.StartupInfoEx{})), Flags: winapi.STARTF_USESTDHANDLES | winapi.STARTF_USESHOWWINDOW, ShowWindow: winapi.SW_HIDE, StdInput: childHandles[0], StdOutput: childHandles[1], StdErr: childHandles[2]}}
|
||||
startup.ProcThreadAttributeList = attributes.List()
|
||||
var shellInfo winapi.ProcessInformation
|
||||
var environment *uint16
|
||||
if len(request.Environment) > 0 {
|
||||
environment = &request.Environment[0]
|
||||
}
|
||||
flags := uint32(winapi.CREATE_NEW_CONSOLE | winapi.CREATE_SUSPENDED | winapi.CREATE_UNICODE_ENVIRONMENT | winapi.EXTENDED_STARTUPINFO_PRESENT)
|
||||
if err := winapi.CreateProcess(application, &commandLine[0], nil, nil, true, flags, environment, workingDirectory, &startup.StartupInfo, &shellInfo); err != nil {
|
||||
return fmt.Errorf("create suspended shell: %w", err)
|
||||
}
|
||||
threadClosed := false
|
||||
closeThread := func() {
|
||||
if !threadClosed {
|
||||
threadClosed = true
|
||||
_ = winapi.CloseHandle(shellInfo.Thread)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
closeThread()
|
||||
_ = winapi.CloseHandle(shellInfo.Process)
|
||||
}()
|
||||
shellCreation, err := processCreation(shellInfo.Process)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preparedPayload, err := marshalLauncherPayload(launcherShellPrepared{PID: shellInfo.ProcessId, Creation: shellCreation})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeLauncherFrame(control, launcherFrame{Kind: launcherFrameShellPrepared, Generation: generation, Payload: preparedPayload}); err != nil {
|
||||
return err
|
||||
}
|
||||
frame, err = readLauncherFrame(control, generation)
|
||||
if err != nil {
|
||||
_ = winapi.TerminateProcess(shellInfo.Process, 1)
|
||||
return err
|
||||
}
|
||||
if frame.Kind != launcherFrameRelease {
|
||||
_ = winapi.TerminateProcess(shellInfo.Process, 1)
|
||||
return errors.New("launcher release was not authorized")
|
||||
}
|
||||
if _, err := winapi.ResumeThread(shellInfo.Thread); err != nil {
|
||||
_ = winapi.TerminateProcess(shellInfo.Process, 1)
|
||||
return err
|
||||
}
|
||||
closeThread()
|
||||
if err := writeLauncherFrame(control, launcherFrame{Kind: launcherFrameReleased, Generation: generation}); err != nil {
|
||||
_ = winapi.TerminateProcess(shellInfo.Process, 1)
|
||||
return err
|
||||
}
|
||||
_, _ = winapi.WaitForSingleObject(shellInfo.Process, winapi.INFINITE)
|
||||
var exitCode uint32
|
||||
if err := winapi.GetExitCodeProcess(shellInfo.Process, &exitCode); err != nil {
|
||||
return err
|
||||
}
|
||||
winapi.ExitProcess(exitCode)
|
||||
return nil
|
||||
}
|
||||
|
||||
const signalPipePrefix = `\\.\pipe\rvbox-signal-`
|
||||
|
||||
func newSignalPipe(effectiveSID string) (string, [16]byte, *os.File, error) {
|
||||
var generation [16]byte
|
||||
if effectiveSID == "" {
|
||||
return "", generation, nil, errors.New("signal pipe ACL requires an effective SID")
|
||||
}
|
||||
if _, err := winapi.StringToSid(effectiveSID); err != nil {
|
||||
return "", generation, nil, err
|
||||
}
|
||||
if _, err := io.ReadFull(cryptorand.Reader, generation[:]); err != nil {
|
||||
return "", generation, nil, err
|
||||
}
|
||||
channel := hex.EncodeToString(generation[:])
|
||||
sd, err := winapi.SecurityDescriptorFromString(fmt.Sprintf("O:SYD:(A;;GA;;;SY)(A;;GA;;;%s)", effectiveSID))
|
||||
if err != nil {
|
||||
return "", generation, nil, err
|
||||
}
|
||||
security := &winapi.SecurityAttributes{Length: uint32(unsafe.Sizeof(winapi.SecurityAttributes{})), SecurityDescriptor: sd}
|
||||
name := signalPipePrefix + channel
|
||||
namePtr, err := winapi.UTF16PtrFromString(name)
|
||||
if err != nil {
|
||||
return "", generation, nil, err
|
||||
}
|
||||
mode := uint32(winapi.PIPE_TYPE_BYTE | winapi.PIPE_READMODE_BYTE | winapi.PIPE_WAIT | winapi.PIPE_REJECT_REMOTE_CLIENTS)
|
||||
handle, err := winapi.CreateNamedPipe(namePtr, winapi.PIPE_ACCESS_DUPLEX, mode, 1, launcherPipeBuffer, launcherPipeBuffer, 0, security)
|
||||
if err != nil {
|
||||
return "", generation, nil, err
|
||||
}
|
||||
return channel, generation, os.NewFile(uintptr(handle), "rvbox-signal"), nil
|
||||
}
|
||||
|
||||
func openSignalPipe(channel string) (*os.File, error) {
|
||||
if _, err := launcherGeneration(channel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := winapi.UTF16PtrFromString(signalPipePrefix + strings.ToLower(channel))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deadline := time.Now().Add(launcherHandshakeTimeout)
|
||||
for {
|
||||
handle, openErr := winapi.CreateFile(name, winapi.GENERIC_READ|winapi.GENERIC_WRITE, 0, nil, winapi.OPEN_EXISTING, 0, 0)
|
||||
if openErr == nil {
|
||||
return os.NewFile(uintptr(handle), "rvbox-signal"), nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, openErr
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func runSignalHelper(ctx context.Context, executable string, token winapi.Token, identity supervisor.EffectiveIdentity, targetPID uint32, targetCreation uint64) (bool, error) {
|
||||
if executable == "" {
|
||||
var err error
|
||||
executable, err = os.Executable()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if err := verifyExecutable(executable); err != nil {
|
||||
return false, err
|
||||
}
|
||||
serverChannel, serverGeneration, server, err := newSignalPipe(identity.UserSID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer server.Close()
|
||||
// Use the generated helper channel, not the command launcher channel. The
|
||||
// target identity is carried only after this helper's peer is verified.
|
||||
channel := serverChannel
|
||||
application, err := winapi.UTF16PtrFromString(executable)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
command, err := BuildCommandLine([]string{executable, "--signal-helper", "--channel", channel})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
commandUTF16, err := winapi.UTF16FromString(command)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
startup := winapi.StartupInfo{Cb: uint32(unsafe.Sizeof(winapi.StartupInfo{}))}
|
||||
var info winapi.ProcessInformation
|
||||
flags := uint32(winapi.CREATE_NO_WINDOW | winapi.CREATE_UNICODE_ENVIRONMENT)
|
||||
if err := winapi.CreateProcessAsUser(token, application, &commandUTF16[0], nil, nil, false, flags, nil, nil, &startup, &info); err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() {
|
||||
_ = winapi.CloseHandle(info.Process)
|
||||
_ = winapi.CloseHandle(info.Thread)
|
||||
}()
|
||||
creation, err := processCreation(info.Process)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := winapi.ResumeThread(info.Thread); err != nil {
|
||||
return false, err
|
||||
}
|
||||
pipe := launcherPipe{name: signalPipePrefix + channel, file: server}
|
||||
peerCtx, cancel := context.WithTimeout(ctx, launcherHandshakeTimeout)
|
||||
defer cancel()
|
||||
if err := connectLauncherPipe(peerCtx, pipe, launcherPeer{PID: info.ProcessId, Creation: creation, SessionID: identity.SessionID, UserSID: identity.UserSID}); err != nil {
|
||||
_ = winapi.TerminateProcess(info.Process, 1)
|
||||
return false, err
|
||||
}
|
||||
frame, err := readLauncherFrameContext(peerCtx, server, serverGeneration)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if frame.Kind != launcherFrameHello {
|
||||
return false, errors.New("signal helper did not send hello")
|
||||
}
|
||||
var hello launcherHello
|
||||
if err := unmarshalLauncherPayload(frame.Payload, &hello); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if hello.PID != info.ProcessId || hello.Creation != creation || hello.SessionID != identity.SessionID || hello.Effective != identity.UserSID {
|
||||
return false, errors.New("signal helper identity mismatch")
|
||||
}
|
||||
payload, err := marshalLauncherPayload(signalHelperRequest{PID: targetPID, Creation: targetCreation, SessionID: identity.SessionID})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := writeLauncherFrame(server, launcherFrame{Kind: launcherFrameSignalRequest, Generation: serverGeneration, Payload: payload}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
frame, err = readLauncherFrameContext(peerCtx, server, serverGeneration)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if frame.Kind != launcherFrameSignalResult {
|
||||
return false, errors.New("signal helper returned an unexpected frame")
|
||||
}
|
||||
var result signalHelperResult
|
||||
if err := unmarshalLauncherPayload(frame.Payload, &result); err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, _ = winapi.WaitForSingleObject(info.Process, uint32(launcherHandshakeTimeout/time.Millisecond))
|
||||
return result.Delivered, nil
|
||||
}
|
||||
|
||||
// RunSignalHelper verifies the target process identity supplied over its
|
||||
// authenticated pipe before attaching to its private console. It never takes
|
||||
// a PID from the command line and never receives a Job or stdio handle.
|
||||
func RunSignalHelper(_ context.Context, channel string) error {
|
||||
generation, err := launcherGeneration(channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pipe, err := openSignalPipe(channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pipe.Close()
|
||||
var current winapi.Token
|
||||
if err := winapi.OpenProcessToken(winapi.CurrentProcess(), winapi.TOKEN_QUERY, ¤t); err != nil {
|
||||
return err
|
||||
}
|
||||
defer current.Close()
|
||||
user, err := current.GetTokenUser()
|
||||
if err != nil || user.User.Sid == nil {
|
||||
return errors.New("signal helper token has no user SID")
|
||||
}
|
||||
sessionID, err := tokenInformationUint32(current, winapi.TokenSessionId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
creation, err := processCreation(winapi.CurrentProcess())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hello, err := marshalLauncherPayload(launcherHello{PID: winapi.GetCurrentProcessId(), Creation: creation, SessionID: sessionID, Effective: user.User.Sid.String()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeLauncherFrame(pipe, launcherFrame{Kind: launcherFrameHello, Generation: generation, Payload: hello}); err != nil {
|
||||
return err
|
||||
}
|
||||
frame, err := readLauncherFrame(pipe, generation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if frame.Kind != launcherFrameSignalRequest {
|
||||
return errors.New("signal helper received an unexpected frame")
|
||||
}
|
||||
var request signalHelperRequest
|
||||
if err := unmarshalLauncherPayload(frame.Payload, &request); err != nil {
|
||||
return err
|
||||
}
|
||||
if request.PID == 0 || request.Creation == 0 || request.SessionID != sessionID {
|
||||
return errors.New("signal helper target identity is invalid")
|
||||
}
|
||||
target, err := winapi.OpenProcess(winapi.PROCESS_QUERY_LIMITED_INFORMATION, false, request.PID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actualCreation, err := processCreation(target)
|
||||
if err != nil {
|
||||
_ = winapi.CloseHandle(target)
|
||||
return err
|
||||
}
|
||||
if actualCreation != request.Creation {
|
||||
_ = winapi.CloseHandle(target)
|
||||
return errors.New("signal helper target creation time mismatch")
|
||||
}
|
||||
var targetToken winapi.Token
|
||||
if err := winapi.OpenProcessToken(target, winapi.TOKEN_QUERY, &targetToken); err != nil {
|
||||
_ = winapi.CloseHandle(target)
|
||||
return err
|
||||
}
|
||||
defer targetToken.Close()
|
||||
targetUser, err := targetToken.GetTokenUser()
|
||||
if err != nil || targetUser.User.Sid == nil || targetUser.User.Sid.String() != user.User.Sid.String() {
|
||||
_ = winapi.CloseHandle(target)
|
||||
return errors.New("signal helper target token SID mismatch")
|
||||
}
|
||||
targetSession, err := tokenInformationUint32(targetToken, winapi.TokenSessionId)
|
||||
_ = winapi.CloseHandle(target)
|
||||
if err != nil || targetSession != sessionID {
|
||||
return errors.New("signal helper target session mismatch")
|
||||
}
|
||||
delivered, deliveryErr := sendControlBreak(request.PID)
|
||||
detail := "CTRL_BREAK delivered"
|
||||
if deliveryErr != nil {
|
||||
detail = deliveryErr.Error()
|
||||
}
|
||||
result, err := marshalLauncherPayload(signalHelperResult{Delivered: delivered, Detail: detail})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeLauncherFrame(pipe, launcherFrame{Kind: launcherFrameSignalResult, Generation: generation, Payload: result}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Wire payloads are JSON only inside the authenticated private pipe. The
|
||||
// outer frame still supplies a bounded binary length, generation and digest;
|
||||
// JSON keeps the launcher mode stateless and makes malformed-field rejection
|
||||
// explicit rather than relying on Go's gob type registry.
|
||||
type launcherHello struct {
|
||||
PID uint32 `json:"pid"`
|
||||
Creation uint64 `json:"creation"`
|
||||
SessionID uint32 `json:"session_id"`
|
||||
Effective string `json:"effective_sid"`
|
||||
}
|
||||
|
||||
type launcherRequest struct {
|
||||
ApplicationName string `json:"application_name"`
|
||||
CommandLine string `json:"command_line"`
|
||||
WorkingDirectory string `json:"working_directory"`
|
||||
Environment []uint16 `json:"environment"`
|
||||
}
|
||||
|
||||
type launcherShellPrepared struct {
|
||||
PID uint32 `json:"pid"`
|
||||
Creation uint64 `json:"creation"`
|
||||
}
|
||||
|
||||
type signalHelperRequest struct {
|
||||
PID uint32 `json:"pid"`
|
||||
Creation uint64 `json:"creation"`
|
||||
SessionID uint32 `json:"session_id"`
|
||||
}
|
||||
|
||||
type signalHelperResult struct {
|
||||
Delivered bool `json:"delivered"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
func marshalLauncherPayload(value any) ([]byte, error) {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(payload) > launcherMaxFrameBytes {
|
||||
return nil, fmt.Errorf("launcher payload exceeds %d bytes", launcherMaxFrameBytes)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func unmarshalLauncherPayload(payload []byte, target any) error {
|
||||
if len(payload) == 0 || len(payload) > launcherMaxFrameBytes {
|
||||
return fmt.Errorf("launcher payload length %d is invalid", len(payload))
|
||||
}
|
||||
return json.Unmarshal(payload, target)
|
||||
}
|
||||
@@ -33,7 +33,11 @@ var (
|
||||
// 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
|
||||
Shells ShellPaths
|
||||
// ExecutablePath is the canonical rvbox.exe path used for private
|
||||
// per-command launcher mode. An empty value is resolved from the running
|
||||
// service executable on Windows.
|
||||
ExecutablePath string
|
||||
WorkRoot string
|
||||
JobProfiles map[string]JobProfile
|
||||
MaxWrapperBytes uint64
|
||||
@@ -87,6 +91,7 @@ type execProcess struct {
|
||||
identity supervisor.EffectiveIdentity
|
||||
cmd *exec.Cmd
|
||||
pid uint32
|
||||
creation uint64
|
||||
stdin io.WriteCloser
|
||||
stdout io.ReadCloser
|
||||
stderr io.ReadCloser
|
||||
@@ -96,6 +101,7 @@ type execProcess struct {
|
||||
waitFn func() (int32, bool, error)
|
||||
killFn func(uint32) error
|
||||
releaseFn func() error
|
||||
signalFn func(context.Context) (bool, error)
|
||||
snapshotFn func() (supervisor.ResourceSnapshot, error)
|
||||
|
||||
mu sync.Mutex
|
||||
|
||||
@@ -12,9 +12,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
@@ -47,13 +45,6 @@ var (
|
||||
|
||||
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.
|
||||
@@ -94,12 +85,14 @@ func (manager *execSupervisor) Start(ctx context.Context, spec supervisor.StartS
|
||||
}
|
||||
return nil, cause
|
||||
}
|
||||
|
||||
token, identity, err := manager.selectToken(spec.Execution.GetElevated())
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
defer token.Close()
|
||||
if err := verifyWorkingDirectory(spec.WorkingDirectory); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
wrapperPath, cleanup, err := materializeWrapper(spec.WorkingDirectory, wrapper, manager.options.Now(), func(path string) error {
|
||||
return secureWrapperFile(path, identity.UserSID)
|
||||
})
|
||||
@@ -118,133 +111,11 @@ func (manager *execSupervisor) Start(ctx context.Context, spec supervisor.StartS
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
|
||||
stdinRead, stdinWrite, stdoutRead, stdoutWrite, stderrRead, stderrWrite, err := createStandardPipes()
|
||||
process, err := manager.startViaLauncher(ctx, spec, token, identity, launch, cleanup)
|
||||
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))
|
||||
}
|
||||
if err := applyJobProfiles(job, manager.options.JobProfiles, spec.ExecutionProfiles); err != nil {
|
||||
_ = winapi.CloseHandle(job)
|
||||
return fail(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. The
|
||||
// primary thread remains suspended until the executor has durably recorded
|
||||
// launch authorization and calls Process.Release.
|
||||
_ = 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))
|
||||
}
|
||||
pipesTransferred = true
|
||||
var threadClosed sync.Once
|
||||
closeThread := func() {
|
||||
threadClosed.Do(func() { _ = winapi.CloseHandle(processInfo.Thread) })
|
||||
}
|
||||
releaseFn := func() error {
|
||||
if _, err := winapi.ResumeThread(processInfo.Thread); err != nil {
|
||||
closeThread()
|
||||
return fmt.Errorf("release suspended command: %w", err)
|
||||
}
|
||||
closeThread()
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
closeThread()
|
||||
handles.close.Do(func() {
|
||||
_ = winapi.CloseHandle(processInfo.Process)
|
||||
_ = winapi.CloseHandle(job)
|
||||
})
|
||||
return int32(code), false, waitErr
|
||||
}
|
||||
killFn := func(code uint32) error {
|
||||
err := winapi.TerminateJobObject(job, code)
|
||||
closeThread()
|
||||
return err
|
||||
}
|
||||
process := manager.registerProcess(spec.IssueUUID, identity, command, stdinWrite, stdoutRead, stderrRead, started, waitFn, killFn, releaseFn, cleanup)
|
||||
process.snapshotFn = func() (supervisor.ResourceSnapshot, error) {
|
||||
return queryJobSnapshot(job, manager.options.Now())
|
||||
return nil, err
|
||||
}
|
||||
cleanup = nil
|
||||
return process, nil
|
||||
}
|
||||
|
||||
@@ -267,6 +138,31 @@ func verifyExecutable(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyWorkingDirectory(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat working directory %q: %w", path, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("working directory %q is not a directory", path)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("working directory %q is a symlink", path)
|
||||
}
|
||||
name, err := winapi.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attributes, err := winapi.GetFileAttributes(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query working directory attributes %q: %w", path, err)
|
||||
}
|
||||
if attributes&winapi.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return fmt.Errorf("working directory %q is a reparse point", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// secureWrapperFile replaces the inherited directory ACL with a protected
|
||||
// DACL. The service writes the wrapper before this call; afterward only
|
||||
// LocalSystem and the selected effective token SID can read it. This is done
|
||||
@@ -311,36 +207,6 @@ func secureWrapperFile(path, effectiveSID string) error {
|
||||
return winapi.SetNamedSecurityInfo(path, winapi.SE_FILE_OBJECT, winapi.OWNER_SECURITY_INFORMATION|winapi.DACL_SECURITY_INFORMATION|winapi.PROTECTED_DACL_SECURITY_INFORMATION, systemSID, nil, acl, 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 {
|
||||
@@ -900,18 +766,32 @@ func (manager *execSupervisor) Signal(ctx context.Context, process supervisor.Pr
|
||||
return supervisor.SignalOutcome{}, errors.New("unsupported signal")
|
||||
}
|
||||
if signal == supervisor.SignalTerm {
|
||||
// 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)
|
||||
// Each command has its own hidden console. The short-lived canonical
|
||||
// helper is preferred; the direct attach path remains a last-resort
|
||||
// diagnostic fallback when helper creation is unavailable.
|
||||
breakDelivered, breakErr := false, error(nil)
|
||||
helperUsed := native.signalFn != nil
|
||||
if native.signalFn != nil {
|
||||
breakDelivered, breakErr = native.signalFn(ctx)
|
||||
if breakErr != nil && ctx.Err() == nil {
|
||||
// Keep TERM best-effort if the helper itself cannot be started;
|
||||
// the fallback still checks the immutable PID/creation evidence.
|
||||
breakDelivered, breakErr = sendControlBreakVerified(native.pid, native.creation)
|
||||
}
|
||||
} else {
|
||||
breakDelivered, breakErr = sendControlBreakVerified(native.pid, native.creation)
|
||||
}
|
||||
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
|
||||
detail := "CTRL_BREAK delivered"
|
||||
if helperUsed {
|
||||
detail = "CTRL_BREAK delivered by authenticated signal helper"
|
||||
}
|
||||
return supervisor.SignalOutcome{Delivered: true, Detail: detail, ObservedAt: manager.options.Now()}, nil
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -933,11 +813,9 @@ func (manager *execSupervisor) Signal(ctx context.Context, process supervisor.Pr
|
||||
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.
|
||||
// sendControlBreak is the last-resort local fallback for the authenticated
|
||||
// signal-helper path. It never addresses a caller-supplied PID: the PID comes
|
||||
// only from immutable execProcess metadata captured during preparation.
|
||||
func sendControlBreak(pid uint32) (bool, error) {
|
||||
if pid == 0 {
|
||||
return false, errors.New("command has no verified console PID")
|
||||
@@ -962,6 +840,25 @@ func sendControlBreak(pid uint32) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func sendControlBreakVerified(pid uint32, creation uint64) (bool, error) {
|
||||
if creation == 0 {
|
||||
return false, errors.New("command has no verified process creation time")
|
||||
}
|
||||
process, err := winapi.OpenProcess(winapi.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
actual, err := processCreation(process)
|
||||
_ = winapi.CloseHandle(process)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if actual != creation {
|
||||
return false, errors.New("command PID was reused")
|
||||
}
|
||||
return sendControlBreak(pid)
|
||||
}
|
||||
|
||||
func (manager *execSupervisor) Snapshot(ctx context.Context, process supervisor.Process) (supervisor.ResourceSnapshot, error) {
|
||||
if process == nil {
|
||||
return supervisor.ResourceSnapshot{}, ErrProcessNotFound
|
||||
|
||||
Reference in New Issue
Block a user