tailscale/util/winutil/s4u/s4u_windows.go
Aaron Klotz da078b4c09 util/winutil: add package for logging into Windows via Service-for-User (S4U)
This PR ties together pseudoconsoles, user profiles, s4u logons, and
process creation into what is (hopefully) a simple API for various
Tailscale services to obtain Windows access tokens without requiring
knowledge of any Windows passwords. It works both for domain-joined
machines (Kerberos) and non-domain-joined machines. The former case
is fairly straightforward as it is fully documented. OTOH, the latter
case is not documented, though it is fully defined in the C headers in
the Windows SDK. The documentation blanks were filled in by reading
the source code of Microsoft's Win32 port of OpenSSH.

We need to do a bit of acrobatics to make conpty work correctly while
creating a child process with an s4u token; see the doc comments above
startProcessInternal for details.

Updates #12383

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2024-06-25 22:05:52 -06:00

942 lines
25 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package s4u is an API for accessing Service-For-User (S4U) functionality on Windows.
package s4u
import (
"encoding/binary"
"errors"
"flag"
"fmt"
"io"
"math"
"os"
"os/user"
"runtime"
"slices"
"strconv"
"strings"
"sync/atomic"
"unsafe"
"golang.org/x/sys/windows"
"tailscale.com/cmd/tailscaled/childproc"
"tailscale.com/types/logger"
"tailscale.com/util/winutil"
"tailscale.com/util/winutil/conpty"
)
func init() {
childproc.Add("s4u", beRelay)
}
var errInsufficientCapabilityLevel = errors.New("insufficient capability level")
// ListGroupIDsForSSHPreAuthOnly returns user u's group memberships as a slice
// containing group SIDs. srcName must contain the name of the service that is
// retrieving this information. srcName must be non-empty, ASCII-only, and no
// longer than 8 characters.
//
// NOTE: This should only be used by Tailscale SSH! It is not a generic
// mechanism for access checks!
func ListGroupIDsForSSHPreAuthOnly(srcName string, u *user.User) ([]string, error) {
tok, err := createToken(srcName, u, tokenTypeIdentification, CapImpersonateOnly)
if err != nil {
return nil, err
}
defer tok.Close()
tokenGroups, err := tok.GetTokenGroups()
if err != nil {
return nil, err
}
result := make([]string, 0, tokenGroups.GroupCount)
for _, group := range tokenGroups.AllGroups() {
if group.Attributes&windows.SE_GROUP_ENABLED != 0 {
result = append(result, group.Sid.String())
}
}
return result, nil
}
type tokenType uint
const (
tokenTypeIdentification tokenType = iota
tokenTypeImpersonation
)
// createToken creates a new S4U access token for user u for the purposes
// specified by s4uType, with capability capLevel. srcName must contain the name
// of the service that is intended to use the token. srcName must be non-empty,
// ASCII-only, and no longer than 8 characters.
//
// When s4uType is tokenTypeImpersonation, the current OS thread's access token must have SeTcbPrivilege.
func createToken(srcName string, u *user.User, s4uType tokenType, capLevel CapabilityLevel) (tok windows.Token, err error) {
if u == nil {
return 0, os.ErrInvalid
}
var lsa *lsaSession
switch s4uType {
case tokenTypeIdentification:
lsa, err = newLSASessionForQuery()
case tokenTypeImpersonation:
lsa, err = newLSASessionForLogon("")
default:
return 0, os.ErrInvalid
}
if err != nil {
return 0, err
}
defer lsa.Close()
return lsa.logonAs(srcName, u, capLevel)
}
// Session encapsulates an S4U login session.
type Session struct {
refCnt atomic.Int32
logf logger.Logf
token windows.Token
userProfile *winutil.UserProfile
capLevel CapabilityLevel
}
// CapabilityLevel specifies the desired capabilities that will be supported by a Session.
type CapabilityLevel uint
const (
// The Session supports Do but none of the StartProcess* methods.
CapImpersonateOnly CapabilityLevel = iota
// The Session supports both Do and the StartProcess* methods.
CapCreateProcess
)
// Login logs user u into Windows on behalf of service srcName, loads the user's
// profile, and returns a Session that may be used for impersonating that user,
// or optionally creating processes as that user. Logs will be written to logf,
// if provided. srcName must be non-empty, ASCII-only, and no longer than 8
// characters.
//
// The current OS thread's access token must have SeTcbPrivilege.
func Login(logf logger.Logf, srcName string, u *user.User, capLevel CapabilityLevel) (sess *Session, err error) {
token, err := createToken(srcName, u, tokenTypeIdentification, capLevel)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
token.Close()
}
}()
sessToken := token
if capLevel == CapCreateProcess {
// Obtain token's security descriptor so that it may be applied to
// a primary token.
sd, err := windows.GetSecurityInfo(windows.Handle(token),
windows.SE_KERNEL_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
return nil, err
}
sa := windows.SecurityAttributes{
Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})),
SecurityDescriptor: sd,
}
// token is an impersonation token. Upgrade us to a primary token so that
// our StartProcess* methods will work correctly.
var dupToken windows.Token
if err := windows.DuplicateTokenEx(token, 0, &sa, windows.SecurityImpersonation,
windows.TokenPrimary, &dupToken); err != nil {
return nil, err
}
sessToken = dupToken
defer func() {
if err != nil {
sessToken.Close()
}
}()
}
userProfile, err := winutil.LoadUserProfile(sessToken, u)
if err != nil {
return nil, err
}
if logf == nil {
logf = logger.Discard
} else {
logf = logger.WithPrefix(logf, "(s4u) ")
}
return &Session{logf: logf, token: sessToken, userProfile: userProfile, capLevel: capLevel}, nil
}
// Close unloads the user profile and S4U access token associated with the
// session. The close operation is not guaranteed to have finished when Close
// returns; it may remain alive until all processes created by ss have
// themselves been closed, and no more Do requests are pending.
func (ss *Session) Close() error {
refs := ss.refCnt.Load()
if (refs & 1) != 0 {
// Close already called
return nil
}
// Set the low bit to indicate that a close operation has been requested.
// We don't have atomic OR so we need to use CAS. Sigh.
for !ss.refCnt.CompareAndSwap(refs, refs|1) {
refs = ss.refCnt.Load()
}
if refs > 1 {
// Still active processes, just return.
return nil
}
return ss.closeInternal()
}
func (ss *Session) closeInternal() error {
if ss.userProfile != nil {
if err := ss.userProfile.Close(); err != nil {
return err
}
ss.userProfile = nil
}
if ss.token != 0 {
if err := ss.token.Close(); err != nil {
return err
}
ss.token = 0
}
return nil
}
// CapabilityLevel returns the CapabilityLevel that was specified when the
// session was created.
func (ss *Session) CapabilityLevel() CapabilityLevel {
return ss.capLevel
}
// Do executes fn while impersonating ss's user. Impersonation only affects
// the current goroutine; any new goroutines spawned by fn will not be
// impersonated. Do may be called concurrently by multiple goroutines.
//
// Do returns an error if impersonation did not succeed and fn could not be run.
// If called after ss has already been closed, it will panic.
func (ss *Session) Do(fn func()) error {
if fn == nil {
return os.ErrInvalid
}
ss.addRef()
defer ss.release()
// Impersonation touches thread-local state.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := impersonateLoggedOnUser(ss.token); err != nil {
return err
}
defer func() {
if err := windows.RevertToSelf(); err != nil {
// This is not recoverable in any way, shape, or form!
panic(fmt.Sprintf("RevertToSelf failed: %v", err))
}
}()
fn()
return nil
}
func (ss *Session) addRef() {
if (ss.refCnt.Add(2) & 1) != 0 {
panic("addRef after Close")
}
}
func (ss *Session) release() {
rc := ss.refCnt.Add(-2)
if rc < 0 {
panic("negative refcount")
}
if rc == 1 {
ss.closeInternal()
}
}
type startProcessOpts struct {
token windows.Token
extraEnv map[string]string
ptySize windows.Coord
pipes bool
}
// StartProcess creates a new process running under ss via cmdLineInfo.
// The process will be started with its working directory set to the S4U user's
// profile directory and its environment set to the S4U user's environment.
// extraEnv, when specified, contains any additional environment variables to
// be added to the process's environment.
//
// If called after ss has already been closed, StartProcess will panic.
func (ss *Session) StartProcess(cmdLineInfo winutil.CommandLineInfo, extraEnv map[string]string) (psp *Process, err error) {
if ss.capLevel != CapCreateProcess {
return nil, errInsufficientCapabilityLevel
}
opts := startProcessOpts{
token: ss.token,
extraEnv: extraEnv,
}
return startProcessInternal(ss, ss.logf, cmdLineInfo, opts)
}
// StartProcessWithPTY creates a new process running under ss via cmdLineInfo
// with a pseudoconsole initialized to initialPtySize. The resulting Process
// will return non-nil values from Stdin and Stdout, but Stderr will return nil.
// The process will be started with its working directory set to the S4U user's
// profile directory and its environment set to the S4U user's environment.
// extraEnv, when specified, contains any additional environment variables to
// be added to the process's environment.
//
// If called after ss has already been closed, StartProcessWithPTY will panic.
func (ss *Session) StartProcessWithPTY(cmdLineInfo winutil.CommandLineInfo, extraEnv map[string]string, initialPtySize windows.Coord) (psp *Process, err error) {
if ss.capLevel != CapCreateProcess {
return nil, errInsufficientCapabilityLevel
}
opts := startProcessOpts{
token: ss.token,
extraEnv: extraEnv,
ptySize: initialPtySize,
}
return startProcessInternal(ss, ss.logf, cmdLineInfo, opts)
}
// StartProcessWithPipes creates a new process running under ss via cmdLineInfo
// with all standard handles set to pipes. The resulting Process will return
// non-nil values from Stdin, Stdout, and Stderr.
// The process will be started with its working directory set to the S4U user's
// profile directory and its environment set to the S4U user's environment.
// extraEnv, when specified, contains any additional environment variables to
// be added to the process's environment.
//
// If called after ss has already been closed, StartProcessWithPipes will panic.
func (ss *Session) StartProcessWithPipes(cmdLineInfo winutil.CommandLineInfo, extraEnv map[string]string) (psp *Process, err error) {
if ss.capLevel != CapCreateProcess {
return nil, errInsufficientCapabilityLevel
}
opts := startProcessOpts{
token: ss.token,
extraEnv: extraEnv,
pipes: true,
}
return startProcessInternal(ss, ss.logf, cmdLineInfo, opts)
}
// startProcessInternal is the common implementation behind Session's exported
// StartProcess* methods. It uses opts to distinguish between the various
// requested modes of operation.
//
// A note on pseudoconsoles:
// The conpty API currently does not provide a way to create a pseudoconsole for
// a different user than the current process. The way we deal with this is
// to first create a "relay" process running with the desired user token,
// and then create the actual requested process as a child of the relay,
// at which time we create the pseudoconsole. The relay simply copies the
// PTY's I/O into/out of its own stdin and stdout, which are piped to the
// parent still running as LocalSystem. We also relay pseudoconsole resize requests.
func startProcessInternal(ss *Session, logf logger.Logf, cmdLineInfo winutil.CommandLineInfo, opts startProcessOpts) (psp *Process, err error) {
var sib winutil.StartupInfoBuilder
defer sib.Close()
var sp Process
defer func() {
if err != nil {
sp.Close()
}
}()
var zeroCoord windows.Coord
ptySizeValid := opts.ptySize != zeroCoord
useToken := opts.token != 0
usePty := ptySizeValid && !useToken
useRelay := ptySizeValid && useToken
useSystem32WD := useToken && opts.token.IsElevated()
if usePty {
sp.pty, err = conpty.NewPseudoConsole(opts.ptySize)
if err != nil {
return nil, err
}
if err := sp.pty.ConfigureStartupInfo(&sib); err != nil {
return nil, err
}
sp.wStdin = sp.pty.InputPipe()
sp.rStdout = sp.pty.OutputPipe()
} else if useRelay || opts.pipes {
if sp.wStdin, sp.rStdout, sp.rStderr, err = createStdPipes(&sib); err != nil {
return nil, err
}
}
var relayStderr io.ReadCloser
if useRelay {
// Later on we're going to use stderr for logging instead of providing it to the caller.
relayStderr = sp.rStderr
sp.rStderr = nil
defer func() {
if err != nil {
relayStderr.Close()
}
}()
// Set up a pipe to send PTY resize requests.
var resizeRead, resizeWrite windows.Handle
if err := windows.CreatePipe(&resizeRead, &resizeWrite, nil, 0); err != nil {
return nil, err
}
sp.wResize = os.NewFile(uintptr(resizeWrite), "wPTYResizePipe")
defer windows.CloseHandle(resizeRead)
if err := sib.InheritHandles(resizeRead); err != nil {
return nil, err
}
// Revise the command line. First, get the existing one.
_, _, strCmdLine, err := cmdLineInfo.Resolve()
if err != nil {
return nil, err
}
// Now rebuild it, passing the strCmdLine as the --cmd argument...
newArgs := []string{
"be-child", "s4u",
"--resize", fmt.Sprintf("0x%x", uintptr(resizeRead)),
"--x", strconv.Itoa(int(opts.ptySize.X)),
"--y", strconv.Itoa(int(opts.ptySize.Y)),
"--cmd", strCmdLine,
}
// ...to be passed in as arguments to our own executable.
cmdLineInfo.ExePath, err = os.Executable()
if err != nil {
return nil, err
}
cmdLineInfo.SetArgs(newArgs)
}
exePath, cmdLine, cmdLineStr, err := cmdLineInfo.Resolve()
if err != nil {
return nil, err
}
logf("starting %s", cmdLineStr)
var env []string
var wd16 *uint16
if useToken {
env, err = opts.token.Environ(false)
if err != nil {
return nil, err
}
folderID := windows.FOLDERID_Profile
if useSystem32WD {
folderID = windows.FOLDERID_System
}
wd, err := opts.token.KnownFolderPath(folderID, windows.KF_FLAG_DEFAULT)
if err != nil {
return nil, err
}
wd16, err = windows.UTF16PtrFromString(wd)
if err != nil {
return nil, err
}
} else {
env = os.Environ()
}
env = mergeEnv(env, opts.extraEnv)
var env16 *uint16
if useToken || len(opts.extraEnv) > 0 {
env16 = winutil.NewEnvBlock(env)
}
if useToken {
// We want the child process to be assigned to job such that when it exits,
// its descendents within the job will be terminated as well.
job, err := createJob()
if err != nil {
return nil, err
}
// We don't need to hang onto job beyond this func...
defer job.Close()
if err := sib.AssignToJob(job.Handle()); err != nil {
return nil, err
}
// ...because we're now gonna make a read-only copy...
qjob, err := job.QueryOnlyClone()
if err != nil {
return nil, err
}
defer qjob.Close()
// ...which will be inherited by the child process.
// When the child process terminates, the job will too.
if err := sib.InheritHandles(qjob.Handle()); err != nil {
return nil, err
}
}
si, inheritHandles, creationFlags, err := sib.Resolve()
if err != nil {
return nil, err
}
var pi windows.ProcessInformation
if useToken {
// DETACHED_PROCESS so that the child does not receive a console.
// CREATE_NEW_PROCESS_GROUP so that the child's console group is isolated from ours.
creationFlags |= windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP
doCreate := func() {
err = windows.CreateProcessAsUser(opts.token, exePath, cmdLine, nil, nil, inheritHandles, creationFlags, env16, wd16, si, &pi)
}
switch {
case useRelay:
doCreate()
case ss != nil:
// We want to ensure that the executable is accessible via the token's
// security context, not ours.
if err := ss.Do(doCreate); err != nil {
return nil, err
}
default:
panic("should not have reached here")
}
} else {
err = windows.CreateProcess(exePath, cmdLine, nil, nil, inheritHandles, creationFlags, env16, wd16, si, &pi)
}
if err != nil {
return nil, err
}
windows.CloseHandle(pi.Thread)
if relayStderr != nil {
logw := logger.FuncWriter(logger.WithPrefix(logf, fmt.Sprintf("(s4u relay process %d [0x%x]) ", pi.ProcessId, pi.ProcessId)))
go func() {
defer relayStderr.Close()
io.Copy(logw, relayStderr)
}()
}
sp.hproc = pi.Process
sp.pid = pi.ProcessId
if ss != nil {
ss.addRef()
sp.sess = ss
}
return &sp, nil
}
type jobObject windows.Handle
func createJob() (job *jobObject, err error) {
hjob, err := windows.CreateJobObject(nil, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
windows.CloseHandle(hjob)
}
}()
limitInfo := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
// We want every process within the job to terminate when the job is closed.
// We also want to allow processes within the job to create child processes
// that are outside the job (otherwise you couldn't leave background
// processes running after exiting a session, for example).
// These flags also match those used by the Win32 port of OpenSSH.
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | windows.JOB_OBJECT_LIMIT_BREAKAWAY_OK,
},
}
_, err = windows.SetInformationJobObject(hjob,
windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limitInfo)),
uint32(unsafe.Sizeof(limitInfo)))
if err != nil {
return nil, err
}
jo := jobObject(hjob)
return &jo, nil
}
func (job *jobObject) Close() error {
if hjob := job.Handle(); hjob != 0 {
windows.CloseHandle(hjob)
*job = 0
}
return nil
}
func (job *jobObject) Handle() windows.Handle {
if job == nil {
return 0
}
return windows.Handle(*job)
}
const _JOB_OBJECT_QUERY = 0x0004
func (job *jobObject) QueryOnlyClone() (*jobObject, error) {
hjob := job.Handle()
cp := windows.CurrentProcess()
var dupe windows.Handle
err := windows.DuplicateHandle(cp, hjob, cp, &dupe, _JOB_OBJECT_QUERY, true, 0)
if err != nil {
return nil, err
}
result := jobObject(dupe)
return &result, nil
}
func createStdPipes(sib *winutil.StartupInfoBuilder) (stdin io.WriteCloser, stdout, stderr io.ReadCloser, err error) {
var rStdin, wStdin windows.Handle
if err := windows.CreatePipe(&rStdin, &wStdin, nil, 0); err != nil {
return nil, nil, nil, err
}
defer func() {
if err != nil {
windows.CloseHandle(rStdin)
windows.CloseHandle(wStdin)
}
}()
var rStdout, wStdout windows.Handle
if err := windows.CreatePipe(&rStdout, &wStdout, nil, 0); err != nil {
return nil, nil, nil, err
}
defer func() {
if err != nil {
windows.CloseHandle(rStdout)
windows.CloseHandle(wStdout)
}
}()
var rStderr, wStderr windows.Handle
if err := windows.CreatePipe(&rStderr, &wStderr, nil, 0); err != nil {
return nil, nil, nil, err
}
defer func() {
if err != nil {
windows.CloseHandle(rStderr)
windows.CloseHandle(wStderr)
}
}()
if err := sib.SetStdHandles(rStdin, wStdout, wStderr); err != nil {
return nil, nil, nil, err
}
stdin = os.NewFile(uintptr(wStdin), "wStdin")
stdout = os.NewFile(uintptr(rStdout), "rStdout")
stderr = os.NewFile(uintptr(rStderr), "rStderr")
return stdin, stdout, stderr, nil
}
// Process encapsulates a child process started with a Session.
type Process struct {
sess *Session
wStdin io.WriteCloser
rStdout io.ReadCloser
rStderr io.ReadCloser
wResize io.WriteCloser
pty *conpty.PseudoConsole
hproc windows.Handle
pid uint32
}
// Stdin returns the write side of a pipe connected to the child process's
// stdin, or nil if no I/O was requested.
func (sp *Process) Stdin() io.WriteCloser {
return sp.wStdin
}
// Stdout returns the read side of a pipe connected to the child process's
// stdout, or nil if no I/O was requested.
func (sp *Process) Stdout() io.ReadCloser {
return sp.rStdout
}
// Stderr returns the read side of a pipe connected to the child process's
// stderr, or nil if no I/O was requested.
func (sp *Process) Stderr() io.ReadCloser {
return sp.rStderr
}
// Terminate kills the process.
func (sp *Process) Terminate() {
if sp.hproc != 0 {
windows.TerminateProcess(sp.hproc, 255)
}
}
// Close waits for sp to complete and then cleans up any resources owned by it.
// Close must wait because the Session associated with sp should not be destroyed
// until all its processes have terminated. If necessary, call Terminate to
// forcibly end the process.
//
// If the process was created with a pseudoconsole then the caller must continue
// concurrently draining sp's stdout until either Close finishes executing, or EOF.
func (sp *Process) Close() error {
for _, pc := range []*io.WriteCloser{&sp.wStdin, &sp.wResize} {
if *pc == nil {
continue
}
(*pc).Close()
(*pc) = nil
}
if sp.pty != nil {
if err := sp.pty.Close(); err != nil {
return err
}
sp.pty = nil
}
if sp.hproc != 0 {
if _, err := sp.Wait(); err != nil {
return err
}
windows.CloseHandle(sp.hproc)
sp.hproc = 0
sp.pid = 0
if sp.sess != nil {
sp.sess.release()
sp.sess = nil
}
}
// Order is important here. Do not close sp.rStdout until _after_
// ss.pty (when present) has been closed! We're going to do one better by
// doing this after the process is done.
for _, pc := range []*io.ReadCloser{&sp.rStdout, &sp.rStderr} {
if *pc == nil {
continue
}
(*pc).Close()
(*pc) = nil
}
return nil
}
// Wait blocks the caller until sp terminates. It returns the process exit code.
// exitCode will be set to 254 if the process terminated but the exit code could
// not be retrieved.
func (sp *Process) Wait() (exitCode uint32, err error) {
_, err = windows.WaitForSingleObject(sp.hproc, windows.INFINITE)
if err == nil {
if err := windows.GetExitCodeProcess(sp.hproc, &exitCode); err != nil {
exitCode = 254
}
}
return exitCode, err
}
// OSProcess returns an *os.Process associated with sp. This is useful for
// integration with external code that expects an os.Process.
func (sp *Process) OSProcess() (*os.Process, error) {
if sp.hproc == 0 {
return nil, winutil.ErrDefunctProcess
}
return os.FindProcess(int(sp.pid))
}
// PTYResizer returns a function to be called to resize the pseudoconsole.
// It returns nil if no pseudoconsole was requested when creating sp.
func (sp *Process) PTYResizer() func(windows.Coord) error {
if sp.wResize != nil {
wResize := sp.wResize
return func(c windows.Coord) error {
return binary.Write(wResize, binary.LittleEndian, c)
}
}
if sp.pty != nil {
pty := sp.pty
return func(c windows.Coord) error {
return pty.Resize(c)
}
}
return nil
}
type relayArgs struct {
command string
resize string
ptyX int
ptyY int
}
func parseRelayArgs(args []string) (a relayArgs) {
flags := flag.NewFlagSet("", flag.ExitOnError)
flags.StringVar(&a.command, "cmd", "", "the command to run")
flags.StringVar(&a.resize, "resize", "", "handle to resize pipe")
flags.IntVar(&a.ptyX, "x", 80, "initial width of pty")
flags.IntVar(&a.ptyY, "y", 25, "initial height of pty")
flags.Parse(args)
return a
}
func flagSizeErr(flagName byte) error {
return fmt.Errorf("--%c must be greater than zero and less than %d", flagName, math.MaxInt16)
}
const debugRelay = false
func beRelay(args []string) error {
ra := parseRelayArgs(args)
if ra.command == "" {
return fmt.Errorf("--cmd must be specified")
}
bitSize := int(unsafe.Sizeof(windows.Handle(0)) * 8)
resize64, err := strconv.ParseUint(ra.resize, 0, bitSize)
if err != nil {
return err
}
hResize := windows.Handle(resize64)
if ft, _ := windows.GetFileType(hResize); ft != windows.FILE_TYPE_PIPE {
return fmt.Errorf("--resize is an invalid handle type")
}
resize := os.NewFile(uintptr(hResize), "rPTYResizePipe")
defer resize.Close()
switch {
case ra.ptyX <= 0 || ra.ptyX > math.MaxInt16:
return flagSizeErr('x')
case ra.ptyY <= 0 || ra.ptyY > math.MaxInt16:
return flagSizeErr('y')
default:
}
logf := logger.Discard
if debugRelay {
// Our parent process will write our stderr to its log.
logf = func(format string, args ...any) {
fmt.Fprintf(os.Stderr, format, args...)
}
}
logf("starting")
argv, err := windows.DecomposeCommandLine(ra.command)
if err != nil {
logf("DecomposeCommandLine failed: %v", err)
return err
}
cli := winutil.CommandLineInfo{
ExePath: argv[0],
}
cli.SetArgs(argv[1:])
opts := startProcessOpts{
ptySize: windows.Coord{X: int16(ra.ptyX), Y: int16(ra.ptyY)},
}
psp, err := startProcessInternal(nil, logf, cli, opts)
if err != nil {
logf("startProcessInternal failed: %v", err)
return err
}
defer psp.Close()
go resizeLoop(logf, resize, psp.PTYResizer())
if debugRelay {
go debugLogPTYInput(logf, psp.wStdin, os.Stdin)
go debugLogPTYOutput(logf, os.Stdout, psp.rStdout)
} else {
go io.Copy(psp.wStdin, os.Stdin)
go io.Copy(os.Stdout, psp.rStdout)
}
exitCode, err := psp.Wait()
if err != nil {
logf("waiting on relayed process: %v", err)
return err
}
if exitCode > 0 {
logf("relayed process returned %v", exitCode)
}
if err := psp.Close(); err != nil {
logf("s4u.Process.Close error: %v", err)
return err
}
return nil
}
func resizeLoop(logf logger.Logf, resizePipe io.Reader, resizeFn func(windows.Coord) error) {
var coord windows.Coord
for binary.Read(resizePipe, binary.LittleEndian, &coord) == nil {
logf("resizing pty window to %#v", coord)
resizeFn(coord)
}
}
func debugLogPTYInput(logf logger.Logf, w io.Writer, r io.Reader) {
logw := logger.FuncWriter(logger.WithPrefix(logf, "(pty input) "))
io.Copy(io.MultiWriter(w, logw), r)
}
func debugLogPTYOutput(logf logger.Logf, w io.Writer, r io.Reader) {
logw := logger.FuncWriter(logger.WithPrefix(logf, "(pty output) "))
io.Copy(w, io.TeeReader(r, logw))
}
// mergeEnv returns the union of existingEnv and extraEnv, deduplicated and
// sorted.
func mergeEnv(existingEnv []string, extraEnv map[string]string) []string {
if len(extraEnv) == 0 {
return existingEnv
}
mergedMap := make(map[string]string, len(existingEnv)+len(extraEnv))
for _, line := range existingEnv {
k, v, _ := strings.Cut(line, "=")
mergedMap[strings.ToUpper(k)] = v
}
for k, v := range extraEnv {
mergedMap[strings.ToUpper(k)] = v
}
result := make([]string, 0, len(mergedMap))
for k, v := range mergedMap {
result = append(result, strings.Join([]string{k, v}, "="))
}
slices.SortFunc(result, func(l, r string) int {
kl, _, _ := strings.Cut(l, "=")
kr, _, _ := strings.Cut(r, "=")
return strings.Compare(kl, kr)
})
return result
}