mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-09 14:16:14 +02:00
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>
948 lines
26 KiB
Go
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
|
|
}
|