feat: authenticate Windows command launcher and signal helper

This commit is contained in:
2026-09-06 14:38:16 +00:00
parent 158fd8f3aa
commit 0383155b86
13 changed files with 1270 additions and 189 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ _toolchain-build:
mkdir -p bin
CGO_ENABLED=0 GOOS=linux go build -trimpath -o bin/rvbox-server ./cmd/rvbox-server
CGO_ENABLED=0 GOOS=linux go build -trimpath -o bin/rvc ./cmd/rvc
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -o bin/rvbox.exe ./cmd/rvbox
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags '-H=windowsgui' -o bin/rvbox.exe ./cmd/rvbox
_toolchain-verify: _toolchain-fmt _toolchain-lint _toolchain-test _toolchain-build
git diff --exit-code -- gen/go
+17 -2
View File
@@ -41,7 +41,7 @@ func run(args []string, output, diagnostics io.Writer) error {
return errors.New("an internal mode is required (use --help)")
}
if args[0] == "--help" || args[0] == "-h" {
_, err := io.WriteString(output, "usage: rvbox --service|--tray|--check-config|--install-service|--uninstall-service|--configure-service|--start-service|--stop-service|--restart-service --config PATH\n")
_, err := io.WriteString(output, "usage: rvbox --service|--tray|--launcher|--signal-helper|--check-config|--install-service|--uninstall-service|--configure-service|--start-service|--stop-service|--restart-service --config PATH\n")
return err
}
flags := flag.NewFlagSet("rvbox", flag.ContinueOnError)
@@ -49,6 +49,9 @@ func run(args []string, output, diagnostics io.Writer) error {
configPath := flags.String("config", defaultClientConfigPath(), "absolute client TOML configuration path")
serviceMode := flags.Bool("service", false, "run under the Windows Service Control Manager")
trayMode := flags.Bool("tray", false, "run the current user's notification-area frontend")
launcherMode := flags.Bool("launcher", false, "run one private authenticated command launcher")
signalHelperMode := flags.Bool("signal-helper", false, "run one private authenticated signal helper")
channel := flags.String("channel", "", "private launcher channel (internal use only)")
checkConfig := flags.Bool("check-config", false, "validate client configuration and exit")
install := flags.Bool("install-service", false, "install or update the machine-wide service")
uninstall := flags.Bool("uninstall-service", false, "remove the machine-wide service")
@@ -64,7 +67,7 @@ func run(args []string, output, diagnostics io.Writer) error {
return fmt.Errorf("unexpected argument %q", flags.Arg(0))
}
selected := 0
for _, value := range []bool{*serviceMode, *trayMode, *checkConfig, *install, *uninstall, *configure, *start, *stop, *restart} {
for _, value := range []bool{*serviceMode, *trayMode, *launcherMode, *signalHelperMode, *checkConfig, *install, *uninstall, *configure, *start, *stop, *restart} {
if value {
selected++
}
@@ -105,6 +108,18 @@ func run(args []string, output, diagnostics io.Writer) error {
if *trayMode {
return runTray(*configPath, diagnostics)
}
if *launcherMode {
if *channel == "" {
return errors.New("--launcher requires an internal channel")
}
return runLauncher(*channel, diagnostics)
}
if *signalHelperMode {
if *channel == "" {
return errors.New("--signal-helper requires an internal channel")
}
return runSignalHelper(*channel, diagnostics)
}
return runService(*configPath, diagnostics)
}
+8
View File
@@ -16,3 +16,11 @@ func runService(string, io.Writer) error {
func runTray(string, io.Writer) error {
return errors.New("the v1 tray frontend is implemented for Windows only")
}
func runLauncher(string, io.Writer) error {
return errors.New("the v1 command launcher is implemented for Windows only")
}
func runSignalHelper(string, io.Writer) error {
return errors.New("the v1 signal helper is implemented for Windows only")
}
+9
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"strings"
clientwindows "github.com/rvbox/rvbox/internal/client/supervisor/windows"
"github.com/rvbox/rvbox/internal/client/windowsservice"
"github.com/rvbox/rvbox/internal/client/windowstray"
"golang.org/x/sys/windows/svc"
@@ -56,6 +57,14 @@ func runTray(configPath string, diagnostics io.Writer) error {
return windowstray.Run(context.Background(), diagnostics)
}
func runLauncher(channel string, _ io.Writer) error {
return clientwindows.RunLauncher(context.Background(), channel)
}
func runSignalHelper(channel string, _ io.Writer) error {
return clientwindows.RunSignalHelper(context.Background(), channel)
}
func handleTrayRequest(ctx context.Context, configPath string, request windowstray.Frame) (windowstray.Frame, error) {
response := windowstray.Frame{Action: windowstray.ActionStatus}
switch request.Action {
+29 -6
View File
@@ -1937,12 +1937,14 @@ import Windows APIs. Keep launch phases identical across platforms:
The first native adapter is now required to expose this contract through
`internal/client/supervisor.Supervisor`: the non-Windows adapter is test-only,
while the Windows implementation must perform token selection and Job setup
inside the same `Start` call. It may return only after the child has been
assigned to its kill-on-close Job and released; all token/session attempts must
be represented in the returned immutable identity. A failed start clears the
pre-launch barrier and produces one rejected lifecycle event; an uncertain
authorized row is never retried as a fresh process.
while the Windows implementation must perform token selection, launcher
authentication, and Job setup inside the same `Start` call. It may return only
after the launcher and shell are prepared, suspended, and assigned to the
kill-on-close Job; `Process.Release` crosses the later durable authorization
barrier. All token/session attempts must be represented in the returned
immutable identity. A failed start clears the pre-launch barrier and produces
one rejected lifecycle event; an uncertain authorized row is never retried as a
fresh process.
Implement one exhaustive token selector; do not scatter token fallback across
launch code:
@@ -2045,6 +2047,27 @@ effective token SID the required access.
Service exit therefore kills launcher, shell, and descendants in every crash
window. Recovery never signals or kills by PID alone.
The Windows implementation uses the same signed `rvbox.exe` for the private
`--launcher` and `--signal-helper` modes. The service creates one random
per-command generation and four byte-mode named pipes (`control`, `stdin`,
`stdout`, `stderr`) with a protected DACL containing only SYSTEM and the
effective token SID plus `PIPE_REJECT_REMOTE_CLIENTS`. The launcher receives
only the opaque channel name on its command line. Every control frame has a
fixed magic/version/type/generation/length/checksum header and a bounded JSON
payload; the service rejects a peer before reading launch material unless the
pipe client PID, process-creation `FILETIME`, token SID, session ID, and frame
generation all match the suspended process it created. The shell inherits only
the three explicitly listed stdio pipe handles. `Process.Release` writes the
authorized frame and waits for the launcher's release acknowledgement.
TERM starts a separate helper with a fresh one-pipe generation under the same
verified effective token/session. The helper authenticates identically, then
receives the target PID and creation time over the pipe, reopens the target,
checks creation time/SID/session, attaches to its private console, and emits a
bounded result. It receives no Job or stdio handle. A helper creation or attach
failure is recorded and may use the verified direct attach fallback before the
configured grace-period Job escalation.
Do not combine `CREATE_NEW_PROCESS_GROUP` with `CREATE_NEW_CONSOLE`: Windows
ignores the former, and it is unnecessary because every command owns a distinct
hidden console. For TERM, start a short-lived private signal-helper mode of the
+10 -3
View File
@@ -61,9 +61,16 @@ that command text is accepted once, output is journaled, lifecycle/terminal
events are durable, and a script cannot launch before its contiguous upload is
committed. The Windows build uses the same executor contract with the
platform-native adapter: a verified token is selected, the child is created
suspended, assigned to a kill-on-close Job, and only then released. The
durable `launch_phase` barrier is recovered as `interrupted` after a daemon
restart, so an uncertain release is never redispatched.
suspended, assigned to a kill-on-close Job, and held behind an authenticated
per-command launcher pipe. The daemon records `launch_prepared`, then the
durable `launch_authorized` transition sends the launcher's release frame; the
launcher resumes the shell only after that acknowledgement. The durable
`launch_phase` barrier is recovered as `interrupted` after a daemon restart, so
an uncertain release is never redispatched.
The Windows artifact is linked with the GUI subsystem (`-H=windowsgui`) so
service, launcher, and tray startup do not flash a console. Human-facing modes
still attach to a parent console explicitly when one exists.
Suite output is capped at 1 MiB and stored as `artifacts/suite.log`. A failed
run remains inspectable and can be moved back to `ready` with `recover`, then
@@ -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, &current)
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, &current); 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
+12
View File
@@ -506,6 +506,18 @@ layer = "unit"
status = "implemented"
tests = ["internal/client/supervisor/windows/native_other_test.go:TestExecProcessReleaseIsIdempotent_BH_LAUNCH_05"]
[[requirements]]
id = "HP-LAUNCH-06"
layer = "unit"
status = "implemented"
tests = ["internal/client/supervisor/windows/launcher_protocol_test.go:TestLauncherFrameRoundTripAndGenerationFence_HP_LAUNCH_06"]
[[requirements]]
id = "BH-LAUNCH-06"
layer = "unit"
status = "implemented"
tests = ["internal/client/supervisor/windows/launcher_protocol_test.go:TestLauncherFrameRejectsChecksumLengthAndKind_BH_LAUNCH_06"]
[[requirements]]
id = "BH-OUTFLOW-01"
layer = "unit"