// 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, tokenTypeImpersonation, 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 }