// Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"). You may not // use this file except in compliance with the License. A copy of the // License is located at // // http://aws.amazon.com/apache2.0/ // // or in the "license" file accompanying this file. This file is distributed // on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, // either express or implied. See the License for the specific language governing // permissions and limitations under the License. // //go:build darwin || freebsd || linux || netbsd || openbsd // +build darwin freebsd linux netbsd openbsd // Package shell implements session shell plugin. package shell import ( "bytes" "context" "encoding/json" "errors" "fmt" "os" "os/exec" "os/user" "path/filepath" "strconv" "strings" "syscall" "time" "github.com/aws/amazon-ssm-agent/agent/appconfig" agentContracts "github.com/aws/amazon-ssm-agent/agent/contracts" "github.com/aws/amazon-ssm-agent/agent/log" mgsConfig "github.com/aws/amazon-ssm-agent/agent/session/config" mgsContracts "github.com/aws/amazon-ssm-agent/agent/session/contracts" "github.com/aws/amazon-ssm-agent/agent/session/shell/constants" "github.com/aws/amazon-ssm-agent/agent/session/shell/execcmd" "github.com/aws/amazon-ssm-agent/agent/session/utility" "github.com/creack/pty" "github.com/google/shlex" ) var ptyFile *os.File const ( termEnvVariable = "TERM=xterm-256color" langEnvVariable = "LANG=C.UTF-8" langEnvVariableKey = "LANG" startRecordSessionCmd = "script" newLineCharacter = "\n" catCmd = "cat" scriptFlag = "-c" groupsIdentifier = "groups=" ) // StartCommandExecutor starts command execution in different behaviors based on plugin type. // For Standard_Stream and InteractiveCommands plugins, StartCommandExecutor starts pty and provides handles to stdin and stdout. // For NonInteractiveCommands plugin, StartCommandExecutor defines a command executor with native os.Exec, without assigning stdin. // isSessionLogger determines whether its a customer shell or shell used for logging. func StartCommandExecutor( log log.T, shellProps mgsContracts.ShellProperties, isSessionLogger bool, config agentContracts.Configuration, plugin *ShellPlugin) (err error) { log.Info("Starting command executor") //Start the command with a pty var cmd *exec.Cmd appConfig := plugin.context.AppConfig() if strings.TrimSpace(constants.GetShellCommand(shellProps)) == "" || isSessionLogger { cmd = exec.Command("sh") } else { if appConfig.Agent.ContainerMode || appconfig.PluginNameNonInteractiveCommands == plugin.name { commands, err := shlex.Split(constants.GetShellCommand(shellProps)) if err != nil { log.Errorf("Failed to parse commands input: %s\n", err) return fmt.Errorf("Failed to parse commands input: %s\n", err) } if len(commands) > 1 { cmd = exec.Command(commands[0], commands[1:]...) } else { cmd = exec.Command(commands[0]) } } else { commandArgs := append(utility.ShellPluginCommandArgs, constants.GetShellCommand(shellProps)) cmd = exec.Command("sh", commandArgs...) } } //TERM is set as linux by pty which has an issue where vi editor screen does not get cleared. //Setting TERM as xterm-256color as used by standard terminals to fix this issue cmd.Env = append(os.Environ(), termEnvVariable) //If LANG environment variable is not set, shell defaults to POSIX which can contain 256 single-byte characters. //Setting C.UTF-8 as default LANG environment variable as Session Manager supports UTF-8 encoding only. langEnvVariableValue := os.Getenv(langEnvVariableKey) if langEnvVariableValue == "" { cmd.Env = append(cmd.Env, langEnvVariable) } var sessionUser string if !constants.GetRunAsElevated(shellProps) && !isSessionLogger && !appConfig.Agent.ContainerMode { // We get here only when its a customer shell that needs to be started in a specific user mode. u := &utility.SessionUtil{} if config.RunAsEnabled { if strings.TrimSpace(config.RunAsUser) == "" { return errors.New("please set the RunAs default user") } if os.Geteuid() != 0 { return errors.New("the agent must run as root to use RunAs") } // Check if user exists if userExists, _ := u.DoesUserExist(config.RunAsUser); !userExists { // if user does not exist, fail the session return fmt.Errorf("failed to start pty since RunAs user %s does not exist", config.RunAsUser) } sessionUser = config.RunAsUser } else { if os.Geteuid() == 0 { // Start as ssm-user // Create ssm-user before starting a session. u.CreateLocalAdminUser(log) sessionUser = appconfig.DefaultRunAsUserName } else { user, _ := user.Current() sessionUser = user.Username } } // Get the uid and gid of the runas user. uid, gid, groups, err := getUserCredentials(log, sessionUser) if err != nil { return err } if os.Geteuid() == 0 { cmd.SysProcAttr = &syscall.SysProcAttr{} cmd.SysProcAttr.Credential = &syscall.Credential{Uid: uid, Gid: gid, Groups: groups, NoSetGroups: false} } // Setting home environment variable for RunAs user runAsUserHomeEnvVariable := constants.HomeEnvVariable + sessionUser cmd.Env = append(cmd.Env, runAsUserHomeEnvVariable) } if constants.GetRunAsElevated(shellProps) { cmd.Env = append(cmd.Env, constants.RootHomeEnvVariable) } if appconfig.PluginNameNonInteractiveCommands == plugin.name { if plugin.separateOutput { //Open pipeline for reading only stdoutPipe, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("Failed to create command output pipe, error: %s\n", err) } errorPipe, err := cmd.StderrPipe() if err != nil { return fmt.Errorf("Failed to create command err pipe, error: %s\n", err) } plugin.stdin = nil plugin.stdout = nil plugin.stderrPipe = errorPipe plugin.stdoutPipe = stdoutPipe } else { outputPath := filepath.Join(config.OrchestrationDirectory, mgsConfig.ExecOutputFileName) outputWriter, err := os.OpenFile(outputPath, appconfig.FileFlagsCreateOrAppendReadWrite, appconfig.ReadWriteAccess) if err != nil { return fmt.Errorf("Failed to open file for writing command output. error: %s\n", err) } outputReader, err := os.Open(outputPath) if err != nil { return fmt.Errorf("Failed to read command output from file %s. error: %s\n", outputPath, err) } cmd.Stdout = outputWriter cmd.Stderr = outputWriter plugin.stdin = nil plugin.stdout = outputReader } } else { ptyFile, err = pty.Start(cmd) if err != nil { log.Errorf("Failed to start pty: %s\n", err) return fmt.Errorf("Failed to start pty: %s\n", err) } plugin.stdin = ptyFile plugin.stdout = ptyFile } plugin.runAsUser = sessionUser plugin.execCmd = execcmd.NewExecCmd(cmd) return nil } // stop closes pty file. func (p *ShellPlugin) stop(log log.T) (err error) { if ptyFile == nil { return nil } log.Info("Stopping pty") if err := ptyFile.Close(); err != nil { if err, ok := err.(*os.PathError); ok && err.Err != os.ErrClosed { return fmt.Errorf("unable to close ptyFile. %s", err) } } return nil } // SetSize sets size of console terminal window. func SetSize(log log.T, ws_col, ws_row uint32) (err error) { if ptyFile == nil { return nil } winSize := pty.Winsize{ Cols: uint16(ws_col), Rows: uint16(ws_row), } if err := pty.Setsize(ptyFile, &winSize); err != nil { return fmt.Errorf("set pty size failed: %s", err) } return nil } // getUserCredentials returns the uid, gid and groups associated to the runas user. func getUserCredentials(log log.T, sessionUser string) (uint32, uint32, []uint32, error) { uidCmdArgs := append(utility.ShellPluginCommandArgs, fmt.Sprintf("id -u %s", sessionUser)) cmd := exec.Command(utility.ShellPluginCommandName, uidCmdArgs...) out, err := cmd.Output() if err != nil { log.Errorf("Failed to retrieve uid for %s: %v", sessionUser, err) return 0, 0, nil, err } uid, err := strconv.Atoi(strings.TrimSpace(string(out))) if err != nil { log.Errorf("%s not found: %v", sessionUser, err) return 0, 0, nil, err } gidCmdArgs := append(utility.ShellPluginCommandArgs, fmt.Sprintf("id -g %s", sessionUser)) cmd = exec.Command(utility.ShellPluginCommandName, gidCmdArgs...) out, err = cmd.Output() if err != nil { log.Errorf("Failed to retrieve gid for %s: %v", sessionUser, err) return 0, 0, nil, err } gid, err := strconv.Atoi(strings.TrimSpace(string(out))) if err != nil { log.Errorf("%s not found: %v", sessionUser, err) return 0, 0, nil, err } // Get the list of associated groups groupNamesCmdArgs := append(utility.ShellPluginCommandArgs, fmt.Sprintf("id %s", sessionUser)) cmd = exec.Command(utility.ShellPluginCommandName, groupNamesCmdArgs...) out, err = cmd.Output() if err != nil { log.Errorf("Failed to retrieve groups for %s: %v", sessionUser, err) return 0, 0, nil, err } // Example format of output: uid=1873601143(ssm-user) gid=1873600513(domain users) groups=1873600513(domain users),1873601620(joiners),1873601125(aws delegated add workstations to domain users) // Extract groups from the output groupsIndex := strings.Index(string(out), groupsIdentifier) var groupIds []uint32 if groupsIndex > 0 { // Extract groups names and ids from the output groupNamesAndIds := strings.Split(string(out)[groupsIndex+len(groupsIdentifier):], ",") // Extract group ids from the output for _, value := range groupNamesAndIds { groupId, err := extractGroupId(value) if err != nil { log.Errorf("Failed to retrieve group id from %s: %v", value, err) return 0, 0, nil, err } groupIds = append(groupIds, uint32(groupId)) } } // Make sure they are non-zero valid positive ids if uid > 0 && gid > 0 { return uint32(uid), uint32(gid), groupIds, nil } return 0, 0, nil, errors.New("invalid uid and gid") } // extract groupId. func extractGroupId(value string) (int, error) { parenthesisIndex := strings.Index(value, "(") if parenthesisIndex <= 0 { return 0, errors.New("invalid group") } groupId, err := strconv.Atoi(strings.TrimSpace(value[:parenthesisIndex])) if err != nil { return 0, err } return groupId, nil } // runShellProfile executes the shell profile config func (p *ShellPlugin) runShellProfile(log log.T, config agentContracts.Configuration) error { if strings.TrimSpace(config.ShellProfile.Linux) == "" { return nil } if p.stdin == nil { return nil } if _, err := p.stdin.Write([]byte(config.ShellProfile.Linux + newLineCharacter)); err != nil { log.Errorf("Unable to write to stdin, err: %v.", err) return err } return nil } // generateLogData generates a log file with the executed commands. func (p *ShellPlugin) generateLogData(log log.T, config agentContracts.Configuration) error { var flagStderr bytes.Buffer loggerCmd := fmt.Sprintf("%s %s", catCmd, p.logger.ipcFilePath) // Sixty minutes is the maximum amount of time before the command is cancelled // If a command is running this long it is most likely a stuck process ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) defer cancel() cmdWithFlag := exec.CommandContext(ctx, startRecordSessionCmd, p.logger.logFilePath, scriptFlag, loggerCmd) cmdWithFlag.Stderr = &flagStderr flagErr := cmdWithFlag.Run() if flagErr != nil { log.Debugf("Failed to generate transcript with -c flag: %v: %s", flagErr, flagStderr.String()) var noFlagStderr bytes.Buffer // some versions of "script" does not take a -c flag when passing in commands. cmdWithoutFlag := exec.CommandContext(ctx, startRecordSessionCmd, p.logger.logFilePath, catCmd, p.logger.ipcFilePath) cmdWithoutFlag.Stderr = &noFlagStderr noFlagErr := cmdWithoutFlag.Run() if noFlagErr != nil { log.Debugf("Failed to generate transcript without -c flag: %v: %s", noFlagErr, noFlagStderr.String()) return fmt.Errorf("Failed to generate transcript with the following errors:\n%v: %s\n%v:%s", flagErr, flagStderr.String(), noFlagErr, noFlagStderr.String()) } } return nil } // isLogStreamingSupported checks if streaming of logs is supported since it depends on PowerShell's transcript logging func (p *ShellPlugin) isLogStreamingSupported(log log.T) (logStreamingSupported bool, err error) { if appconfig.PluginNameNonInteractiveCommands == p.name { return false, nil } return true, nil } // getStreamingFilePath returns the file path of ipcFile for streaming func (p *ShellPlugin) getStreamingFilePath(log log.T) (streamingFilePath string, err error) { return p.logger.ipcFilePath, nil } // isCleanupOfControlCharactersRequired returns true/false depending on whether log needs to be cleanup of control characters before streaming to destination func (p *ShellPlugin) isCleanupOfControlCharactersRequired() bool { // Source of logs for linux platform is directly from shell stdout which contains control characters and need cleanup return true } // checkForLoggingInterruption is used to detect if log streaming to CW has been interrupted var checkForLoggingInterruption = func(log log.T, ipcFile *os.File, plugin *ShellPlugin) { // Enable append only mode for the ipcTempFile to protect it from being modified u := &utility.SessionUtil{} ticker := time.NewTicker(time.Second) defer ticker.Stop() if err := u.SetAttr(ipcFile, utility.FS_APPEND_FL); err != nil { log.Debugf("Unable to set FS_APPEND_FL flag, %v", err) // Periodically check if ipcTempFile is missing for range ticker.C { if _, err := os.Stat(ipcFile.Name()); os.IsNotExist(err) { log.Warn("Local temp log file is missing, logging might be interrupted.") break } } } else { // Periodically check if ipcTempFile's append only attribute has been modified for range ticker.C { if attr, err := u.GetAttr(ipcFile); err != nil { log.Warnf("Unable to get attributes of local temp log file, logging might be interrupted. Err: %v", err) break } else if attr != utility.FS_APPEND_FL { log.Warn("Append only attribute of local temp log file has been modified, logging might be interrupted.") break } } } } // cleanupLogFile prepares temporary files for the cleanup func (p *ShellPlugin) cleanupLogFile(log log.T, ipcFile *os.File) { // remove file property so deletion of the file can be done successfully u := &utility.SessionUtil{} if err := u.SetAttr(ipcFile, utility.FS_RESET_FL); err != nil { log.Debugf("Unable to reset file properties, %v", err) } } // InputStreamMessageHandler passes payload byte stream to shell stdin func (p *ShellPlugin) InputStreamMessageHandler(log log.T, streamDataMessage mgsContracts.AgentMessage) error { var isPluginNonInteractive = appconfig.PluginNameNonInteractiveCommands == p.name if !isPluginNonInteractive && (p.stdin == nil || p.stdout == nil) { // This is to handle scenario when cli/console starts sending size data but pty has not been started yet // Since packets are rejected, cli/console will resend these packets until pty starts successfully in separate thread log.Tracef("Pty unavailable. Reject incoming message packet") return mgsContracts.ErrHandlerNotReady } switch mgsContracts.PayloadType(streamDataMessage.PayloadType) { case mgsContracts.Output: log.Tracef("Output message received: %d", streamDataMessage.SequenceNumber) if isPluginNonInteractive { var signal os.Signal = nil for _, message := range streamDataMessage.Payload { if sig, exists := appconfig.ByteControlSignalsLinux[message]; exists { log.Debugf("Received control signal. message: %v, signal: %v", string(message), sig) signal = sig break } } if signal != nil { defer func() { if err := p.execCmd.Wait(); err != nil { log.Errorf("Error received after processing control signal: %s", err) } }() if err := p.execCmd.Signal(signal); err != nil { log.Errorf("Sending signal %v to command process %v failed with error %v", signal, p.execCmd.Pid(), err) return err } } return nil } if _, err := p.stdin.Write(streamDataMessage.Payload); err != nil { log.Errorf("Unable to write to stdin, err: %v.", err) return err } case mgsContracts.Size: // Do not handle terminal resize for non-interactive plugin as there is no pty if isPluginNonInteractive { log.Debug("Terminal resize message is ignored in NonInteractiveCommands plugin") return nil } var size mgsContracts.SizeData if err := json.Unmarshal(streamDataMessage.Payload, &size); err != nil { log.Errorf("Invalid size message: %s", err) return err } log.Tracef("Resize data received: cols: %d, rows: %d", size.Cols, size.Rows) if err := SetSize(log, size.Cols, size.Rows); err != nil { log.Errorf("Unable to set pty size: %s", err) return err } } return nil }