package sshsrv import ( "bufio" "context" "fmt" "io" "sort" "strings" "time" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/gin-gonic/gin" "github.com/gliderlabs/ssh" "github.com/samber/lo" "github.com/spf13/cast" "go.uber.org/zap" "golang.org/x/sync/errgroup" "github.com/veops/oneterm/internal/acl" "github.com/veops/oneterm/internal/api/controller" myConnector "github.com/veops/oneterm/internal/connector" "github.com/veops/oneterm/internal/model" "github.com/veops/oneterm/internal/repository" "github.com/veops/oneterm/internal/service" "github.com/veops/oneterm/internal/session" "github.com/veops/oneterm/internal/sshsrv/assetlist" "github.com/veops/oneterm/internal/sshsrv/colors" "github.com/veops/oneterm/internal/sshsrv/icons" "github.com/veops/oneterm/internal/sshsrv/textinput" "github.com/veops/oneterm/pkg/cache" "github.com/veops/oneterm/pkg/errors" "github.com/veops/oneterm/pkg/logger" ) const ( prompt = "> " hisCmdsFmt = "hiscmds-%d" ) var ( errStyle = colors.ErrorStyle hintStyle = colors.HintStyle warningStyle = colors.WarningStyle hiddenBorder = lipgloss.HiddenBorder() p2p = map[string]int{ "ssh": 22, "redis": 6379, "mysql": 3306, "mongodb": 27017, "postgresql": 5432, "telnet": 23, } ) func init() { hiddenBorder.Left = " " } type errMsg error type keymap struct{} func (k keymap) ShortHelp() []key.Binding { return []key.Binding{ key.NewBinding(key.WithKeys("up/down"), key.WithHelp("↑/↓", "navigate suggestions")), key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "auto-complete")), key.NewBinding(key.WithKeys("f5"), key.WithHelp("F5", "refresh")), key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "connect")), key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "quit")), } } func (k keymap) FullHelp() [][]key.Binding { return [][]key.Binding{k.ShortHelp()} } type viewMode int const ( modeCLI viewMode = iota modeTable ) type view struct { Ctx *gin.Context Sess ssh.Session currentUser *acl.Session textinput textinput.Model assetTable assetlist.Model cmds []string cmdsIdx int combines map[string][3]int connecting bool help help.Model keys keymap r io.ReadCloser w io.WriteCloser gctx context.Context mode viewMode suggestionIdx int // Track current suggestion selection selectedSugg string // Store the selected suggestion text } func initialView(ctx *gin.Context, sess ssh.Session, r io.ReadCloser, w io.WriteCloser, gctx context.Context) *view { currentUser, _ := acl.GetSessionFromCtx(ctx) ti := textinput.New() ti.Placeholder = "Type 'help' or start with 'ssh user@host'..." ti.Focus() ti.Prompt = prompt ti.ShowSuggestions = true ti.PromptStyle = colors.PrimaryStyle ti.Cursor.Style = colors.AccentStyle // Disable Tab for AcceptSuggestion to handle it ourselves ti.KeyMap.AcceptSuggestion = key.NewBinding(key.WithKeys("ctrl+x")) // Use a key that won't be pressed v := view{ Ctx: ctx, Sess: sess, currentUser: currentUser, textinput: ti, cmds: []string{}, help: help.New(), r: r, w: w, gctx: gctx, mode: modeCLI, suggestionIdx: 0, } v.refresh() return &v } func (m *view) Init() tea.Cmd { welcomeStyle := colors.AccentStyle exampleStyle := colors.HintStyle return tea.Batch( tea.Println(banner()), tea.Printf("\n %s\n\n", welcomeStyle.Render("→ Welcome to OneTerm! Start typing or use 'table' to browse assets")), tea.Printf(" %s\n", exampleStyle.Render("Examples: ssh admin@server1, mysql db@prod, redis cache@redis")), ) } func (m *view) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var ( hisCmd tea.Cmd tiCmd tea.Cmd tableCmd tea.Cmd ) // Handle table mode if m.mode == modeTable { switch msg := msg.(type) { case tea.KeyMsg: if msg.Type == tea.KeyEsc || msg.String() == "q" { // Exit table mode m.mode = modeCLI return m, nil } case assetlist.ConnectMsg: // Handle connection from table m.mode = modeCLI cmd := msg.Asset.Command return m, m.handleConnectionCommand(cmd) case tea.WindowSizeMsg: // Handle window resize in table mode m.assetTable, tableCmd = m.assetTable.Update(msg) return m, tableCmd } m.assetTable, tableCmd = m.assetTable.Update(msg) return m, tableCmd } // Handle CLI mode switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { case tea.KeyCtrlC: // Clear current input like in terminal, don't quit m.textinput.Reset() m.suggestionIdx = 0 m.selectedSugg = "" return m, tea.Printf("\n%s", prompt) case tea.KeyEsc: return m, tea.Quit case tea.KeyEnter: // Use selected suggestion if one is selected, otherwise use typed value cmd := m.textinput.Value() if m.selectedSugg != "" { cmd = m.selectedSugg } m.textinput.Reset() m.selectedSugg = "" m.suggestionIdx = 0 if cmd == "" { return m, tea.Batch(tea.Printf(prompt)) } hisCmd = tea.Printf("šŸš€ %s", cmd) m.cmds = append(m.cmds, cmd) ln := len(m.cmds) if ln > 100 { m.cmds = m.cmds[ln-100 : ln] } m.cmdsIdx = len(m.cmds) switch { case cmd == "exit" || cmd == "quit" || cmd == `\q`: return m, tea.Sequence(tea.Printf("šŸ‘‹ Goodbye!"), tea.Quit) case cmd == "help" || cmd == `\h` || cmd == `\?`: return m, tea.Sequence(hisCmd, tea.Printf(m.helpText()), tea.Printf("%s", prompt)) case cmd == "clear" || cmd == `\c`: return m, tea.ClearScreen case cmd == "list" || cmd == "ls" || cmd == "table": pty, _, _ := m.Sess.Pty() m.assetTable = assetlist.New(m.combines, pty.Window.Width, pty.Window.Height) m.mode = modeTable return m, tea.ClearScreen } // Try to handle as connection command if connectionCmd := m.handleConnectionCommand(cmd); connectionCmd != nil { return m, tea.Sequence( hisCmd, connectionCmd, ) } else { var suggestion string if strings.Contains(cmd, "@") { suggestion = "\nšŸ’Ŗ Try: ssh " + cmd + " (if connecting via SSH)" } else { suggestion = "\nšŸ’Ŗ Available commands: ssh, mysql, redis, mongodb, postgresql, telnet, help, list, exit" } return m, tea.Sequence( hisCmd, tea.Printf(" %s %s%s\n\n", errStyle.Render("āš ļø Unknown command:"), cmd, hintStyle.Render(suggestion), ), tea.Printf("%s", prompt), ) } case tea.KeyUp: // If we have suggestions and input is not empty, navigate suggestions input := m.textinput.Value() if len(input) > 0 { suggestions := m.getFilteredSuggestions(input) if len(suggestions) > 0 { if m.suggestionIdx > 0 { m.suggestionIdx-- if m.suggestionIdx < len(suggestions) { m.selectedSugg = suggestions[m.suggestionIdx] } } return m, nil } } // Otherwise navigate command history ln := len(m.cmds) if ln <= 0 { return m, nil } m.cmdsIdx = max(0, m.cmdsIdx-1) m.textinput.SetValue(m.cmds[m.cmdsIdx]) m.suggestionIdx = 0 m.selectedSugg = "" case tea.KeyDown: // If we have suggestions and input is not empty, navigate suggestions input := m.textinput.Value() if len(input) > 0 { suggestions := m.getFilteredSuggestions(input) if len(suggestions) > 0 { limit := min(8, len(suggestions)) if m.suggestionIdx < limit-1 { m.suggestionIdx++ if m.suggestionIdx < len(suggestions) { m.selectedSugg = suggestions[m.suggestionIdx] } } return m, nil } } // Otherwise navigate command history ln := len(m.cmds) m.cmdsIdx++ if m.cmdsIdx >= ln { m.cmdsIdx = ln - 1 m.textinput.SetValue("") } else { m.textinput.SetValue(m.cmds[m.cmdsIdx]) } m.suggestionIdx = 0 m.selectedSugg = "" case tea.KeyF5: m.refresh() case tea.KeyTab: // Auto-complete with common prefix or selected suggestion input := m.textinput.Value() if input == "" { return m, nil } suggestions := m.getFilteredSuggestions(input) if len(suggestions) == 0 { return m, nil } if len(suggestions) == 1 { // Single match - complete fully m.textinput.SetValue(suggestions[0]) m.textinput.CursorEnd() // Move cursor to end m.selectedSugg = "" m.suggestionIdx = 0 } else { // Multiple matches - complete to common prefix commonPrefix := m.findCommonPrefix(suggestions) if len(commonPrefix) > len(input) { m.textinput.SetValue(commonPrefix) m.textinput.CursorEnd() // Move cursor to end m.selectedSugg = "" m.suggestionIdx = 0 } } } case errMsg: if msg != nil { str := msg.Error() if ae, ok := msg.(*errors.ApiError); ok { str = errors.Err2Msg[ae.Code].One } return m, tea.Printf(" [ERROR] %s\n\n", errStyle.Render(str)) } } // Reset suggestion index and selected when typing if msg, ok := msg.(tea.KeyMsg); ok && msg.Type == tea.KeyRunes { m.suggestionIdx = 0 m.selectedSugg = "" } m.textinput, tiCmd = m.textinput.Update(msg) return m, tea.Batch(hisCmd, tiCmd) } func (m *view) View() string { if m.connecting { return "\n šŸ”„ Connecting...\n\n" } if m.mode == modeTable { return m.assetTable.View() } suggestionView := m.smartSuggestionView() return fmt.Sprintf( "%s\n %s\n%s%s", m.textinput.View(), m.help.View(m.keys), suggestionView, m.assetOverview(), ) + "\n\n" } func (m *view) smartSuggestionView() string { // Get all suggestions and filter them ourselves for better matching input := strings.ToLower(m.textinput.Value()) if input == "" { return "" } // Use our consistent filtered suggestions function matches := m.getFilteredSuggestions(input) ln := len(matches) if ln <= 0 { return "" } if ln > 20 { countStyle := lipgloss.NewStyle(). Foreground(colors.TextSecondary). Italic(true) return "\n " + countStyle.Render(fmt.Sprintf("%d matches found. Keep typing to filter...", ln)) + "\n" } // Clean and validate matches before displaying cleanMatches := make([]string, 0, len(matches)) for _, match := range matches { match = strings.TrimSpace(match) // Only filter out truly empty matches if match != "" { cleanMatches = append(cleanMatches, match) } } if len(cleanMatches) == 0 { return "" } limit := min(8, len(cleanMatches)) displaySuggestions := cleanMatches[:limit] // Ensure suggestion index is within bounds if m.suggestionIdx >= limit { m.suggestionIdx = limit - 1 } var result strings.Builder suggestTitle := colors.SubtitleStyle result.WriteString("\n " + suggestTitle.Render("Suggestions:") + "\n") // Render each suggestion for i, suggestion := range displaySuggestions { // Get protocol for icon parts := strings.Fields(suggestion) protocol := "unknown" if len(parts) > 0 { protocol = parts[0] } icon := icons.GetStyledProtocolIcon(protocol) // Render with appropriate style if i == m.suggestionIdx { selectedStyle := colors.HighlightStyle result.WriteString(fmt.Sprintf(" → %s %s\n", icon, selectedStyle.Render(suggestion))) } else { // Use a lighter color for non-selected suggestions on dark background normalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) result.WriteString(fmt.Sprintf(" %s %s\n", icon, normalStyle.Render(suggestion))) } } // Show count if there are more suggestions if len(cleanMatches) > limit { moreStyle := lipgloss.NewStyle(). Foreground(colors.TextSecondary). Italic(true) result.WriteString(" " + moreStyle.Render(fmt.Sprintf("... +%d more", len(cleanMatches)-limit)) + "\n") } return result.String() } func (m *view) helpText() string { return fmt.Sprintf(`%s %s • ssh user@host - Connect via SSH • mysql user@host - Connect to MySQL database • redis user@host - Connect to Redis server • mongodb user@host - Connect to MongoDB database • postgresql user@host - Connect to PostgreSQL database • telnet user@host - Connect via Telnet • list/ls/table - Show assets in interactive table • help or \h or \? - Show this help message • clear or \c - Clear screen • exit/quit or \q - Exit OneTerm %s • Use ↑/↓ arrows to browse command history • Press Tab to autocomplete connection names • Press Ctrl+C to clear current input • Press F5 to refresh asset list `, colors.TitleStyle.Render("🌟 OneTerm Help"), hintStyle.Render("šŸ“ Available Commands:"), hintStyle.Render("āŒØļø Keyboard Shortcuts:"), ) } func (m *view) handleConnectionCommand(cmd string) tea.Cmd { // Check if this is a valid connection command if _, exists := m.combines[cmd]; !exists { return nil } // Extract protocol from command p, ok := lo.Find(lo.Keys(p2p), func(item string) bool { return strings.HasPrefix(cmd, item) }) if !ok { return nil } // Setup connection parameters pty, _, _ := m.Sess.Pty() m.Ctx.Request.URL.RawQuery = fmt.Sprintf("w=%d&h=%d", pty.Window.Width, pty.Window.Height) m.Ctx.Params = nil m.Ctx.Params = append(m.Ctx.Params, gin.Param{Key: "account_id", Value: cast.ToString(m.combines[cmd][0])}) m.Ctx.Params = append(m.Ctx.Params, gin.Param{Key: "asset_id", Value: cast.ToString(m.combines[cmd][1])}) m.Ctx.Params = append(m.Ctx.Params, gin.Param{Key: "protocol", Value: fmt.Sprintf("%s:%d", p, m.combines[cmd][2])}) m.Ctx = m.Ctx.Copy() m.connecting = true return tea.Sequence( tea.Printf("šŸ”Œ Establishing connection to %s...\n", cmd), tea.Exec(&connector{Ctx: m.Ctx, Sess: m.Sess, Vw: m, gctx: m.gctx}, func(err error) tea.Msg { m.connecting = false if err != nil { return errMsg(fmt.Errorf("āŒ Connection failed: %v", err)) } return nil }), tea.Printf("%s", prompt), func() tea.Msg { m.textinput.ClearMatched() return nil }, m.magicn, ) } func (m *view) assetOverview() string { if len(m.textinput.Value()) > 0 { return "" // Hide overview when user is typing } if len(m.combines) == 0 { return warningStyle.Render("\n ⚠ No accessible assets found. Check your permissions.") } // Group assets by protocol for better organization protocolGroups := make(map[string][]string) for cmd := range m.combines { parts := strings.Split(cmd, " ") if len(parts) > 0 { protocol := parts[0] protocolGroups[protocol] = append(protocolGroups[protocol], cmd) } } // Provide a better tip with modern styling tipStyle := lipgloss.NewStyle(). Foreground(colors.PrimaryColor2). PaddingTop(1) return tipStyle.Render("→ Type 'table' for interactive mode or start typing to connect") } func (m *view) refresh() { eg := &errgroup.Group{} eg.Go(func() (err error) { assets, err := repository.GetAllFromCacheDb(m.gctx, model.DefaultAsset) if err != nil { return } accounts, err := repository.GetAllFromCacheDb(m.gctx, model.DefaultAccount) if err != nil { return } if !acl.IsAdmin(m.currentUser) { var assetIds, accountIds []int // Use V2 authorization system for asset filtering authV2Service := service.NewAuthorizationV2Service() if _, assetIds, _, err = authV2Service.GetAuthorizationScopeByACL(m.Ctx); err != nil { return } assets = lo.Filter(assets, func(a *model.Asset, _ int) bool { return lo.Contains(assetIds, a.Id) }) if accountIds, err = controller.GetAccountIdsByAuthorization(m.Ctx); err != nil { return } accounts = lo.Filter(accounts, func(a *model.Account, _ int) bool { return lo.Contains(accountIds, a.Id) }) } accountMap := lo.SliceToMap(accounts, func(a *model.Account) (int, *model.Account) { return a.Id, a }) m.combines = make(map[string][3]int) for _, asset := range assets { for accountId, authData := range asset.Authorization { account, ok := accountMap[accountId] if !ok { continue } // Check if this account has connect permission if authData.Permissions == nil || !authData.Permissions.Connect { continue } for _, p := range asset.Protocols { ss := strings.Split(p, ":") if len(ss) != 2 { continue } protocol := ss[0] defaultPort, ok := p2p[protocol] if !ok { continue } k := fmt.Sprintf("%s %s@%s", protocol, account.Name, asset.Name) port := cast.ToInt(ss[1]) // Ensure we're not creating empty or malformed keys if k != "" && len(k) > 3 { m.combines[lo.Ternary(port == defaultPort, k, fmt.Sprintf("%s:%s", k, ss[1]))] = [3]int{account.Id, asset.Id, port} } } } } m.textinput.SetSuggestions(lo.Keys(m.combines)) return }) eg.Go(func() error { var err error if len(m.cmds) != 0 { return err } m.cmds, err = cache.RC.LRange(m.Ctx, fmt.Sprintf(hisCmdsFmt, m.currentUser.GetUid()), -100, -1).Result() m.cmdsIdx = len(m.cmds) return err }) if err := eg.Wait(); err != nil { logger.L().Error("refresh failed", zap.Error(err)) return } } func (m *view) magicn() tea.Msg { m.w.Write([]byte("\n")) return nil } func (m *view) RecordHisCmd() { k := fmt.Sprintf(hisCmdsFmt, m.currentUser.GetUid()) cache.RC.RPush(m.Ctx, k, m.cmds) cache.RC.LTrim(m.Ctx, k, -100, -1) cache.RC.Expire(m.Ctx, k, time.Hour*24*30) } // getFilteredSuggestions returns suggestions that match the input func (m *view) getFilteredSuggestions(input string) []string { if input == "" { return nil } inputLower := strings.ToLower(input) var matches []string for cmd := range m.combines { // Clean any potential issues with the command string cmd = strings.TrimSpace(cmd) if cmd == "" { continue } if strings.HasPrefix(strings.ToLower(cmd), inputLower) { // Ensure we're not adding empty or malformed entries if len(cmd) > len(inputLower) { matches = append(matches, cmd) } } } // Sort matches for consistent ordering sort.Strings(matches) // Remove any duplicates (shouldn't happen but just in case) if len(matches) > 1 { unique := make([]string, 0, len(matches)) prev := "" for _, m := range matches { if m != prev { unique = append(unique, m) prev = m } } matches = unique } return matches } // findCommonPrefix finds the longest common prefix among suggestions func (m *view) findCommonPrefix(suggestions []string) string { if len(suggestions) == 0 { return "" } if len(suggestions) == 1 { return suggestions[0] } // Start with the first suggestion prefix := suggestions[0] // Compare with each other suggestion for _, s := range suggestions[1:] { // Find common prefix between current prefix and this suggestion i := 0 minLen := min(len(prefix), len(s)) for i < minLen && strings.EqualFold(string(prefix[i:i+1]), string(s[i:i+1])) { i++ } prefix = prefix[:i] if len(prefix) == 0 { return "" } } return prefix } type connector struct { Ctx *gin.Context Sess ssh.Session Vw *view stdin io.Reader stdout io.Writer stderr io.Writer gctx context.Context } func (conn *connector) SetStdin(r io.Reader) { conn.stdin = r } func (conn *connector) SetStdout(w io.Writer) { conn.stdout = w } func (conn *connector) SetStderr(w io.Writer) { conn.stderr = w } func (conn *connector) Run() error { gsess, err := myConnector.DoConnect(conn.Ctx, nil) if err != nil { return err } conn.Vw.magicn() r, w := io.Pipe() go func() { _, err := io.Copy(w, conn.stdin) gsess.Chans.ErrChan <- err }() gsess.CliRw = &session.CliRW{ Reader: bufio.NewReader(r), Writer: conn.stdout, } _, ch, ok := conn.Sess.Pty() if !ok { ch = make(<-chan ssh.Window) } gsess.G.Go(func() (err error) { defer r.Close() defer w.Close() for { select { case <-gsess.Chans.AwayChan: return case <-conn.gctx.Done(): gsess.Once.Do(func() { close(gsess.Chans.AwayChan) }) return case <-gsess.Gctx.Done(): return case w := <-ch: gsess.Chans.WindowChan <- w } } }) myConnector.HandleTerm(gsess, nil) if err = gsess.G.Wait(); err != nil { logger.L().Error("sshsrv run stopped", zap.String("sessionId", gsess.SessionId), zap.Error(err)) } conn.stdout.Write([]byte("\n\n")) return nil }