tailscale/util/winutil/s4u/s4u_windows.go
Will Norris 3ec5be3f51 all: remove AUTHORS file and references to it
This file was never truly necessary and has never actually been used in
the history of Tailscale's open source releases.

A Brief History of AUTHORS files
---

The AUTHORS file was a pattern developed at Google, originally for
Chromium, then adopted by Go and a bunch of other projects. The problem
was that Chromium originally had a copyright line only recognizing
Google as the copyright holder. Because Google (and most open source
projects) do not require copyright assignemnt for contributions, each
contributor maintains their copyright. Some large corporate contributors
then tried to add their own name to the copyright line in the LICENSE
file or in file headers. This quickly becomes unwieldy, and puts a
tremendous burden on anyone building on top of Chromium, since the
license requires that they keep all copyright lines intact.

The compromise was to create an AUTHORS file that would list all of the
copyright holders. The LICENSE file and source file headers would then
include that list by reference, listing the copyright holder as "The
Chromium Authors".

This also become cumbersome to simply keep the file up to date with a
high rate of new contributors. Plus it's not always obvious who the
copyright holder is. Sometimes it is the individual making the
contribution, but many times it may be their employer. There is no way
for the proejct maintainer to know.

Eventually, Google changed their policy to no longer recommend trying to
keep the AUTHORS file up to date proactively, and instead to only add to
it when requested: https://opensource.google/docs/releasing/authors.
They are also clear that:

> Adding contributors to the AUTHORS file is entirely within the
> project's discretion and has no implications for copyright ownership.

It was primarily added to appease a small number of large contributors
that insisted that they be recognized as copyright holders (which was
entirely their right to do). But it's not truly necessary, and not even
the most accurate way of identifying contributors and/or copyright
holders.

In practice, we've never added anyone to our AUTHORS file. It only lists
Tailscale, so it's not really serving any purpose. It also causes
confusion because Tailscalars put the "Tailscale Inc & AUTHORS" header
in other open source repos which don't actually have an AUTHORS file, so
it's ambiguous what that means.

Instead, we just acknowledge that the contributors to Tailscale (whoever
they are) are copyright holders for their individual contributions. We
also have the benefit of using the DCO (developercertificate.org) which
provides some additional certification of their right to make the
contribution.

The source file changes were purely mechanical with:

    git ls-files | xargs sed -i -e 's/\(Tailscale Inc &\) AUTHORS/\1 contributors/g'

Updates #cleanup

Change-Id: Ia101a4a3005adb9118051b3416f5a64a4a45987d
Signed-off-by: Will Norris <will@tailscale.com>
2026-01-23 15:49:45 -08:00

948 lines
26 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// 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"
"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, tokenTypeImpersonation, capLevel)
if err != nil {
return nil, err
}
tokenCloseOnce := sync.OnceFunc(func() { token.Close() })
defer func() {
if err != nil {
tokenCloseOnce()
}
}()
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()
}
}()
tokenCloseOnce()
}
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 either be started with its working directory set to the S4U
// user's profile directory, or for Administrative users, the system32
// directory. The child process will receive the S4U user's environment.
// extraEnv, when specified, contains any additional environment
// variables to be inserted into the 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 either be started with its working directory set to the S4U
// user's profile directory, or for Administrative users, the system32
// directory. The child process will receive the S4U user's environment.
// extraEnv, when specified, contains any additional environment
// variables to be inserted into the 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 either be started with its working directory set to the S4U
// user's profile directory, or for Administrative users, the system32
// directory. The child process will receive the S4U user's environment.
// extraEnv, when specified, contains any additional environment
// variables to be inserted into the 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(a, b string) int {
ka, _, _ := strings.Cut(a, "=")
kb, _, _ := strings.Cut(b, "=")
return strings.Compare(ka, kb)
})
return result
}