From 0383155b86527f0fac7115a01c6fc3e7cbaa3008 Mon Sep 17 00:00:00 2001 From: cabbage Date: Sun, 6 Sep 2026 14:38:16 +0000 Subject: [PATCH] feat: authenticate Windows command launcher and signal helper --- Makefile | 2 +- cmd/rvbox/main.go | 19 +- cmd/rvbox/service_other.go | 8 + cmd/rvbox/service_windows.go | 9 + docs/implementation-plan.v1.md | 35 +- docs/testing.md | 13 +- .../supervisor/windows/launcher_protocol.go | 139 +++ .../windows/launcher_protocol_test.go | 57 ++ .../supervisor/windows/launcher_windows.go | 850 ++++++++++++++++++ .../supervisor/windows/launcher_wire.go | 58 ++ .../client/supervisor/windows/native_exec.go | 8 +- .../supervisor/windows/native_windows.go | 249 ++--- test/coverage.toml | 12 + 13 files changed, 1270 insertions(+), 189 deletions(-) create mode 100644 internal/client/supervisor/windows/launcher_protocol.go create mode 100644 internal/client/supervisor/windows/launcher_protocol_test.go create mode 100644 internal/client/supervisor/windows/launcher_windows.go create mode 100644 internal/client/supervisor/windows/launcher_wire.go diff --git a/Makefile b/Makefile index 0202239..26980a0 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/cmd/rvbox/main.go b/cmd/rvbox/main.go index ac2c7f7..d59c653 100644 --- a/cmd/rvbox/main.go +++ b/cmd/rvbox/main.go @@ -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) } diff --git a/cmd/rvbox/service_other.go b/cmd/rvbox/service_other.go index e7bf0e9..00f935c 100644 --- a/cmd/rvbox/service_other.go +++ b/cmd/rvbox/service_other.go @@ -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") +} diff --git a/cmd/rvbox/service_windows.go b/cmd/rvbox/service_windows.go index 8200237..229da22 100644 --- a/cmd/rvbox/service_windows.go +++ b/cmd/rvbox/service_windows.go @@ -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 { diff --git a/docs/implementation-plan.v1.md b/docs/implementation-plan.v1.md index b9f6665..33c4512 100644 --- a/docs/implementation-plan.v1.md +++ b/docs/implementation-plan.v1.md @@ -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 diff --git a/docs/testing.md b/docs/testing.md index dd159ad..672475b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 diff --git a/internal/client/supervisor/windows/launcher_protocol.go b/internal/client/supervisor/windows/launcher_protocol.go new file mode 100644 index 0000000..08c0190 --- /dev/null +++ b/internal/client/supervisor/windows/launcher_protocol.go @@ -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 +} diff --git a/internal/client/supervisor/windows/launcher_protocol_test.go b/internal/client/supervisor/windows/launcher_protocol_test.go new file mode 100644 index 0000000..487cd9d --- /dev/null +++ b/internal/client/supervisor/windows/launcher_protocol_test.go @@ -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") + } +} diff --git a/internal/client/supervisor/windows/launcher_windows.go b/internal/client/supervisor/windows/launcher_windows.go new file mode 100644 index 0000000..1c9a647 --- /dev/null +++ b/internal/client/supervisor/windows/launcher_windows.go @@ -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 +} diff --git a/internal/client/supervisor/windows/launcher_wire.go b/internal/client/supervisor/windows/launcher_wire.go new file mode 100644 index 0000000..edf1b0c --- /dev/null +++ b/internal/client/supervisor/windows/launcher_wire.go @@ -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) +} diff --git a/internal/client/supervisor/windows/native_exec.go b/internal/client/supervisor/windows/native_exec.go index bb3542b..3186fd6 100644 --- a/internal/client/supervisor/windows/native_exec.go +++ b/internal/client/supervisor/windows/native_exec.go @@ -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 diff --git a/internal/client/supervisor/windows/native_windows.go b/internal/client/supervisor/windows/native_windows.go index bc15d9e..de2be34 100644 --- a/internal/client/supervisor/windows/native_windows.go +++ b/internal/client/supervisor/windows/native_windows.go @@ -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 diff --git a/test/coverage.toml b/test/coverage.toml index 8eb1a12..1c5fcb6 100644 --- a/test/coverage.toml +++ b/test/coverage.toml @@ -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"