package emulator import ( "context" "fmt" "os" "strings" "time" "github.com/c-bata/go-prompt" "github.com/google/shlex" "edge-infra.dev/pkg/lib/fog" "edge-infra.dev/pkg/sds/emergencyaccess/msgdata" "edge-infra.dev/pkg/sds/lib/colors" ) // This file contains all the private fields and methods for the session prompt. // the session prompt is responsible for: // - handling and passing commands during a connected session // - pausing display of data on the emulator when sp is active // - returning feedback to the user incase of a bad command var ( internalErrChan = make(chan error) timeoutChan = make(chan bool) promptActiveC = make(chan bool) livePrefixState struct { livePrefix string isEnabled bool } ) const ( activeSessionPrefix = ">>> " darkModeSessionPrefix = "ddd> " ) func changeLivePrefix() (string, bool) { return livePrefixState.livePrefix, livePrefixState.isEnabled } func sessionPromptCompleter(in prompt.Document) []prompt.Suggest { s := []prompt.Suggest{ {Text: "help", Description: "Display help"}, {Text: "exit", Description: "Return to Remote CLI prompt"}, } // this means the session is active and we can show prompt suggestions if livePrefixState.livePrefix == activeSessionPrefix || livePrefixState.livePrefix == darkModeSessionPrefix { return prompt.FilterHasPrefix(s, in.CurrentLineBeforeCursor(), true) } // by "default" don't show anything return []prompt.Suggest{} } // Used to split the command string from emulator command options // unit separator ascii 1f is unlikely to be used when typing terminal commands, although it is not guaranteed const sendOptionsSplitter = string('\u001f') + "||||" + string('\u001f') // Contains options used on the emulator side when dealing with commands and responses type CommandOpts struct { // Specifies which file the output message should be sent to FileRedirection string } // launches the display and input routines to display data asynchronously. func (em *Emulator) startSession() { fmt.Println(colors.Text("Session prompt.'Ctrl-D', 'end', 'exit' or 'q' to exit. ", colors.BgGreen)) //passing common concolors.Text to both functions ctx := em.cls.GetSessionContext() go func() { em.unPauseSessionPrompt() em.sessionPrompt().Run() // end the session and context err := em.cls.End() if err != nil { em.log.Error(err, "ending session") } }() go em.display(ctx) em.handleExitEvents(ctx) } // creates a session prompt func (em *Emulator) sessionPrompt() *prompt.Prompt { opts := []prompt.Option{ // utility prompt.OptionHistory(em.sessionHistory.history), prompt.OptionSetExitCheckerOnInput(em.sessionPromptExitHandler), prompt.OptionLivePrefix(changeLivePrefix), // keybinding prompt.OptionAddKeyBind(append(sessionKeyBindings, commonKeyBindings...)...), // colors prompt.OptionPreviewSuggestionTextColor(prompt.Blue), prompt.OptionSelectedSuggestionBGColor(prompt.LightGray), prompt.OptionSuggestionBGColor(prompt.DarkGray), } darkmode := getDarkmode(em.cls) if darkmode { opts = append(opts, prompt.OptionPrefixTextColor(prompt.Fuchsia), prompt.OptionPrefix(darkModeSessionPrefix), ) } else { opts = append(opts, prompt.OptionPrefixTextColor(prompt.Yellow), prompt.OptionPrefix(activeSessionPrefix), ) } return prompt.New(em.sessionPromptExecutor, sessionPromptCompleter, opts..., ) } // similar to connect prompt executor. func (em *Emulator) sessionPromptExecutor(in string) { if em.sessionPromptPaused { // this means the session prompt is inactive em.unPauseSessionPrompt() promptActiveC <- true // pauses the display channel return } if in == "help" { fmt.Println(sessionHelp) return } if em.handleBreakLineExit(in, true) { return } in = strings.TrimRight(in, "\r") err := em.sessionHistory.updateHistory(in, historyFileLimit) if err != nil { em.log.Error(err, "Error updating session history file") } command, opts, err := processSessionInput(in) if err != nil { fmt.Println(colors.Text("Error processing command: %s", colors.BgRed, in)) em.displaySessionError(err) return } commandID, err := em.cls.Send(command) if err != nil { fmt.Println(colors.Text("Error with last submitted command: %q", colors.BgRed, command)) em.displaySessionError(err) } // Possibly shouldn't save if there is an error? em.commandOptions[commandID] = opts // deactivate the session prompt by default em.pauseSessionPrompt() promptActiveC <- false // tells the display channel it can display again } // Type implements UserError and error, printing the same string for both type userErr string func (err userErr) Error() string { return string(err) } func (err userErr) UserError() []string { return []string{string(err)} } func processSessionInput(input string) (string, CommandOpts, error) { vals := strings.Split(input, sendOptionsSplitter) if len(vals) < 2 { // len vals must be at least 1 if sendOptionsSplitter is not empty, // so safe to access first element return vals[0], CommandOpts{}, nil } if len(vals) > 2 { return "", CommandOpts{}, userErr("Unexpected number of command options") } opts, err := processCommandOptions(vals[1]) return vals[0], opts, err } func processCommandOptions(options string) (CommandOpts, error) { vals, err := shlex.Split(options) if err != nil { return CommandOpts{}, err } if len(vals) == 0 { return CommandOpts{}, userErr("No command options specified") } switch vals[0] { case ">": if len(vals) != 2 { return CommandOpts{}, userErr("Unexpected number of arguments for File Redirection argument: " + options) } return CommandOpts{FileRedirection: vals[1]}, nil default: return CommandOpts{}, userErr(fmt.Sprintf("Unknown option specifier: %s", vals[0])) } } func (em *Emulator) sessionPromptExitHandler(in string, breakline bool) bool { // exit commands select { default: return em.handleBreakLineExit(in, breakline) // exit the session prompt without breaking the connect prompt. case <-internalErrChan: em.pauseSessionPrompt() return true case <-timeoutChan: em.pauseSessionPrompt() return true } } func (em Emulator) handleBreakLineExit(in string, breakline bool) bool { if breakline { for _, item := range exitCommands { if in == item { em.pauseSessionPrompt() return true } } } return false } // Blocking function that waits for the session context done channel to close or // for a session timeout func (em Emulator) handleExitEvents(ctx context.Context) { ticker := time.NewTicker(1 * time.Second) log := fog.FromContext(ctx) for { select { case <-ctx.Done(): log.Info("handle exit events exiting normally") return case <-ticker.C: // periodically check the idle time if em.cls.IdleTime() > em.config.sessionTimeout { // display the timeout notice from main routine displayTimeoutNotice() // wait for session prompt to return timeoutChan <- true return } } } } func displayExitError() { fmt.Println(colors.Text("\rSomething went wrong with the connection. See the remotecli.log log file in the current directory for further details", colors.BgRed)) fmt.Println(colors.Text("Press any key to return to Remote CLI prompt to attempt reconnection.", colors.FgRed)) } func displayTimeoutNotice() { fmt.Println(colors.Text("\rConnection timed out.", colors.BgRed)) fmt.Println(colors.Text("Press any key to return to Remote CLI prompt.", colors.FgRed)) } // Blocking function that loops over the display channel and checks for returned responses // Exits when the context is done or the display channel is closed func (em Emulator) display(ctx context.Context) { for { select { case response, ok := <-em.cls.GetDisplayChannel(): if !ok { // Likely (but not guaranteed) this path will only be encountered // when there is a subscription error, as the context done should // be closed before the display channel displayExitError() // wait for session prompt exit handler to read this internalErrChan <- nil // return once the session prompt has exited return } em.handleResponse(response) case pause := <-promptActiveC: if pause { <-promptActiveC } case <-ctx.Done(): return } } } // Function handles all response messages and displays them on the console func (em *Emulator) handleResponse(response msgdata.CommandResponse) { if response.Data().Type != "OUTPUT" { return } display := true if opts, ok := em.commandOptions[response.Attributes().ReqMsgID]; ok { display = em.handleResponseOptions(response, opts) delete(em.commandOptions, response.Attributes().ReqMsgID) } if display { fmt.Println(response.Data().Output) } if response.Data().ExitCode == 0 { fmt.Println(colors.BufferedText("Exit code: %d", colors.BgGreen, response.Data().ExitCode)) } else { fmt.Println(colors.BufferedText("Exit code: %d", colors.BgRed, response.Data().ExitCode)) } } // Function executes all options for a particular response, returns boolean // indicating if the full response body should be displayed or only a summary func (em *Emulator) handleResponseOptions(response msgdata.CommandResponse, opts CommandOpts) bool { display := true if opts.FileRedirection != "" { em.handleResponseFileRedirection(response, opts.FileRedirection) display = false } return display } func (em *Emulator) handleResponseFileRedirection(response msgdata.CommandResponse, file string) { err := os.WriteFile(file, []byte(response.Data().Output), 0644) if err != nil { em.log.Error(err, "failed writing response to file", "commandID", response.Attributes().ReqMsgID) fmt.Println(colors.Text("Error occurred while saving response to file. See logs for details", colors.BgRed)) } } func (em *Emulator) pauseSessionPrompt() { livePrefixState.isEnabled = true livePrefixState.livePrefix = "" em.sessionPromptPaused = true } func (em *Emulator) unPauseSessionPrompt() { livePrefixState.isEnabled = true livePrefixState.livePrefix = activeSessionPrefix if getDarkmode(em.cls) { livePrefixState.livePrefix = darkModeSessionPrefix } em.sessionPromptPaused = false }