From d13d0f747dac6d013e345617f4cc53c34a5ad5bd Mon Sep 17 00:00:00 2001 From: Mirivlad Date: Sun, 6 Sep 2026 16:20:13 +0800 Subject: [PATCH] feat: add optional tmux-backed persistent sessions Add tmux-backed persistent SSH sessions when tmux is available, hide the feature entirely otherwise, and keep tmux as a recommended rather than required package. --- README.md | 22 +++- cmd/root.go | 1 + cmd/session.go | 57 ++++++++++ cmd/tui.go | 23 ++++ docs/guide.md | 33 ++++-- internal/session/tmux.go | 194 +++++++++++++++++++++++++++++++++ internal/session/tmux_test.go | 68 ++++++++++++ internal/tui/app.go | 197 +++++++++++++++++++++++++--------- internal/tui/app_test.go | 32 ++++++ internal/tui/help_screen.go | 20 +++- internal/tui/layout_test.go | 4 + internal/tui/session.go | 134 +++++++++++++++++++++++ internal/tui/session_test.go | 24 +++++ packaging/nfpm.yaml | 4 + 14 files changed, 748 insertions(+), 65 deletions(-) create mode 100644 cmd/session.go create mode 100644 internal/session/tmux.go create mode 100644 internal/session/tmux_test.go create mode 100644 internal/tui/session.go create mode 100644 internal/tui/session_test.go diff --git a/README.md b/README.md index 0adb9ef..43e7134 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ port forwarding management. - **Routes / ProxyJump** — ordered bastion chains with stable references to sshkeeper profiles; profile renames do not break routes. - **Port forwarding** — named local/remote/SOCKS forwards with type selector, validation, and OpenSSH preview. - **Tunnel management** — start/stop/list background tunnels, PID tracking, runtime state. +- **Persistent sessions** — optional tmux-backed SSH tabs that stay alive while you switch between servers. - **Tunnel vs Forward** — clear separation: forward = saved rule, tunnel = running SSH process. - First-class groups, multi-select tags, command templates, search by metadata/routes/forward ports, and OpenSSH config generation. - Import from `~/.ssh/config` and simple tab-separated export. @@ -46,15 +47,15 @@ Or use the build scripts: ./release.sh # Build release archives to dist/ ``` -Requirements: Go 1.25+ and system OpenSSH. +Requirements: Go 1.25+ and system OpenSSH. `tmux` is optional and recommended for persistent multi-session tabs; without it, all Sessions UI is hidden. Platform status: | Platform | Status | Notes | |----------|--------|-------| -| Linux | Primary release target | `amd64`/`arm64` tarballs plus native `.deb` and `.rpm` packages. | -| macOS | Primary release target | `darwin/amd64` and `darwin/arm64` release tarballs are available. Requires system `ssh` client. Homebrew formula planned. | -| Windows | Experimental | Requires OpenSSH Client available as `ssh.exe` in `PATH`. Password/key-passphrase PTY flows are not validated on Windows. | +| Linux | Primary release target | `amd64`/`arm64` tarballs plus native `.deb` and `.rpm` packages. Native packages recommend (but do not require) `tmux` for persistent Sessions. | +| macOS | Primary release target | `darwin/amd64` and `darwin/arm64` release tarballs are available. Requires system `ssh`; install optional `tmux` with `brew install tmux` to enable Sessions. Homebrew formula planned. | +| Windows | Experimental | Requires OpenSSH Client as `ssh.exe` in `PATH`. Native Windows builds do not expose tmux Sessions; running the Linux build inside WSL can use them when `tmux` is installed there. | On Windows, install OpenSSH Client via Windows Optional Features or PowerShell: @@ -190,6 +191,19 @@ In add/edit forms: | Enter | Move to action / activate | | Esc | Back | +## Persistent Sessions (optional tmux) + +When `tmux` is available in `PATH`, sshkeeper exposes a persistent Sessions workflow. +If `tmux` is missing, the feature is completely hidden: there is no disabled Sessions menu or broken action, and ordinary `Connect` behaves exactly as before. + +- **Server Actions → Open in session** creates a tmux window named after the server alias and attaches to it. +- **Manage → Sessions** lists the SSH windows created by sshkeeper; `Enter` attaches, `Ctrl+D` closes with confirmation, and `Ctrl+R` refreshes. +- If sshkeeper itself is already running inside tmux, new SSH windows are created in the current tmux session. Otherwise sshkeeper uses a dedicated `sshkeeper` tmux workspace. +- Leaving a tmux client with the normal tmux detach key (`Ctrl+B`, then `D`) returns to sshkeeper while the SSH windows keep running. Standard tmux window switching (`Ctrl+B`, then `N`/`P` or a window number) provides the tab workflow. +- Key and SSH-agent sessions start without unlocking the vault. Password and key-passphrase sessions ask for the vault master password inside their own tmux window, so secrets are never copied through command-line arguments or environment variables. + +`tmux` is intentionally optional. Debian/RPM packages mark it as a recommendation rather than a hard dependency. On macOS install it with `brew install tmux`. Native Windows builds do not expose Sessions; use the Linux build inside WSL if this workflow is needed on Windows. + ## Routes, Tunnels, and Port Forwards Routes are stored as ordered hops. If a hop matches an existing sshkeeper profile, diff --git a/cmd/root.go b/cmd/root.go index adbb295..3e78518 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -63,6 +63,7 @@ func init() { rootCmd.AddCommand(routeCmd) rootCmd.AddCommand(forwardCmd) rootCmd.AddCommand(tunnelCmd) + rootCmd.AddCommand(sessionConnectCmd) } func initApp() { diff --git a/cmd/session.go b/cmd/session.go new file mode 100644 index 0000000..2caf1e0 --- /dev/null +++ b/cmd/session.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "fmt" + "syscall" + + "github.com/mirivlad/sshkeeper/internal/model" + "github.com/mirivlad/sshkeeper/internal/ssh" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var sessionConnectCmd = &cobra.Command{ + Use: "__session-connect ", + Hidden: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + alias := args[0] + server, err := appDB.GetServer(alias) + if err != nil { + return fmt.Errorf("server not found: %s", alias) + } + if server.AuthMethod == model.AuthPassword || server.AuthMethod == model.AuthKeyPassphrase { + if err := unlockVaultForSession(); err != nil { + return err + } + } + if err := ssh.ConnectResolved(cfg, server, dbProfileResolver, serverVaultFunc(server)); err != nil { + return err + } + _ = appDB.UpdateLastConnected(alias) + return nil + }, +} + +func unlockVaultForSession() error { + v := getOrCreateVault() + if v.IsUnlocked() { + return nil + } + for attempts := 0; attempts < 3; attempts++ { + fmt.Print("Master password: ") + password, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return fmt.Errorf("read vault password: %w", err) + } + if err := v.Unlock(string(password)); err == nil { + return nil + } + remaining := 2 - attempts + if remaining > 0 { + fmt.Printf("Invalid password. %d attempts remaining.\n", remaining) + } + } + return fmt.Errorf("too many failed vault unlock attempts") +} diff --git a/cmd/tui.go b/cmd/tui.go index 76bbf58..d026018 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -6,6 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/mirivlad/sshkeeper/internal/model" + sessionpkg "github.com/mirivlad/sshkeeper/internal/session" "github.com/mirivlad/sshkeeper/internal/ssh" "github.com/mirivlad/sshkeeper/internal/tui" tunnelpkg "github.com/mirivlad/sshkeeper/internal/tunnel" @@ -193,6 +194,28 @@ func runTUI() error { // Check if TUI requested a connect action result := m.Result() + if result != nil && result.Action == "session_open" && result.Server != nil { + fresh, err := appDB.GetServer(result.Server.Alias) + if err != nil { + fmt.Fprintf(os.Stderr, "Server not found: %s\n", result.Server.Alias) + } else { + windowID, _, openErr := sessionpkg.Open(fresh.Alias) + if openErr != nil { + fmt.Fprintf(os.Stderr, "Open session: %v\n", openErr) + } else if attachErr := sessionpkg.Attach(windowID); attachErr != nil { + fmt.Fprintf(os.Stderr, "Attach session: %v\n", attachErr) + } + } + servers, _ = appDB.ListServers() + continue + } + if result != nil && result.Action == "session_attach" && result.SessionID != "" { + if err := sessionpkg.Attach(result.SessionID); err != nil { + fmt.Fprintf(os.Stderr, "Attach session: %v\n", err) + } + servers, _ = appDB.ListServers() + continue + } if result != nil && result.Action == "connect" && result.Server != nil { // TUI has exited, terminal is restored by tea.WithAltScreen. // Now connect. diff --git a/docs/guide.md b/docs/guide.md index 4bce378..dd1d729 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -9,10 +9,11 @@ 5. [Управление серверами](#управление-серверами) 6. [Маршруты и бастионы](#маршруты-и-бастионы) 7. [Port Forwards и Tunnels](#port-forwards-и-tunnels) -8. [CLI команды](#cli-команды) -9. [Vault — хранилище секретов](#vault--хранилище-секретов) -10. [Сценарии использования](#сценарии-использования) -11. [Справка по клавишам](#справка-по-клавишам) +8. [Sessions — постоянные SSH-вкладки](#sessions--постоянные-ssh-вкладки) +9. [CLI команды](#cli-команды) +10. [Vault — хранилище секретов](#vault--хранилище-секретов) +11. [Сценарии использования](#сценарии-использования) +12. [Справка по клавишам](#справка-по-клавишам) --- @@ -63,15 +64,15 @@ go build -o ~/.local/bin/sshkeeper . ./release.sh # сборка релизных архивов в dist/ ``` -**Требования:** Go 1.25+ и системный OpenSSH. +**Требования:** Go 1.25+ и системный OpenSSH. `tmux` необязателен, но рекомендуется для постоянных SSH-сессий; если его нет, весь интерфейс Sessions скрыт. Статус платформ: | Платформа | Статус | Примечание | |-----------|--------|------------| -| Linux | Основная релизная платформа | Архивы `amd64`/`arm64`, а также `.deb` и `.rpm`. | -| macOS | Основная релизная платформа | Архивы `darwin/amd64` и `darwin/arm64`, нужен системный `ssh`. Homebrew formula запланирована. | -| Windows | Experimental | Нужен OpenSSH Client как `ssh.exe` в `PATH`; password/key-passphrase PTY-сценарии на Windows пока не подтверждены. | +| Linux | Основная релизная платформа | Архивы `amd64`/`arm64`, а также `.deb` и `.rpm`. Пакеты рекомендуют, но не требуют `tmux` для Sessions. | +| macOS | Основная релизная платформа | Архивы `darwin/amd64` и `darwin/arm64`, нужен системный `ssh`; `brew install tmux` включает Sessions. Homebrew formula sshkeeper запланирована. | +| Windows | Experimental | Нужен OpenSSH Client как `ssh.exe` в `PATH`. В native Windows сборке Sessions скрыты; Linux-сборка внутри WSL может использовать `tmux`. | На Windows OpenSSH Client можно установить через Windows Optional Features или PowerShell: @@ -566,6 +567,22 @@ sshkeeper tunnel stop-all --- +## Sessions — постоянные SSH-вкладки + +Sessions — необязательный слой поверх системного `tmux`: он позволяет держать несколько SSH-подключений в одном терминальном workspace. + +Если `tmux` найден в `PATH`, у сервера появляется **Open in session**, а в меню `m` появляется **Sessions**. Если `tmux` отсутствует, оба пункта полностью скрыты и обычный `Connect` работает как раньше. + +Экран Sessions показывает только tmux-окна, созданные sshkeeper. `Enter` подключается к выбранной вкладке, `Ctrl+D` закрывает её после подтверждения, `Ctrl+R` обновляет список. + +Вне tmux sshkeeper использует отдельную tmux-сессию `sshkeeper`. Если sshkeeper уже запущен внутри tmux, новые SSH-вкладки создаются в текущей tmux-сессии без вложенного клиента. Обычное отсоединение tmux возвращает к sshkeeper, а SSH-процессы продолжают работать. + +Для `key` и `agent` отдельного разблокирования vault не требуется. При `password` или `key_passphrase` master password запрашивается внутри новой вкладки; секреты не передаются через argv, environment или временные shell-скрипты. + +На Linux установите пакет `tmux` через пакетный менеджер дистрибутива. На macOS — `brew install tmux`. В `.deb`/`.rpm` `tmux` указан как рекомендация, а не обязательная зависимость. Native Windows-сборка Sessions не показывает; этот режим доступен при запуске Linux-сборки sshkeeper внутри WSL с установленным там `tmux`. + +--- + ## CLI команды ### Серверы diff --git a/internal/session/tmux.go b/internal/session/tmux.go new file mode 100644 index 0000000..bd818fd --- /dev/null +++ b/internal/session/tmux.go @@ -0,0 +1,194 @@ +package session + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "time" +) + +var dedicatedWorkspace = "sshkeeper" + +type Window struct { + ID string + Index int + Name string + ServerAlias string + Active bool + StartedAt time.Time +} + +func Available() bool { + if runtime.GOOS == "windows" { + return false + } + _, err := exec.LookPath("tmux") + return err == nil +} +func workspaceTarget() (string, bool, error) { + if !Available() { + return "", false, fmt.Errorf("tmux is unavailable") + } + if os.Getenv("TMUX") == "" { + return dedicatedWorkspace, false, nil + } + out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output() + if err != nil { + return "", true, fmt.Errorf("resolve current tmux session: %w", err) + } + name := strings.TrimSpace(string(out)) + if name == "" { + return "", true, fmt.Errorf("current tmux session has no name") + } + return name, true, nil +} + +func sessionExists(target string) bool { + cmd := exec.Command("tmux", "has-session", "-t", target) + return cmd.Run() == nil +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" +} +func Open(serverAlias string) (string, bool, error) { + executable, err := os.Executable() + if err != nil { + return "", false, fmt.Errorf("resolve sshkeeper executable: %w", err) + } + command := shellQuote(executable) + " __session-connect " + shellQuote(serverAlias) + return openWindow(serverAlias, command) +} + +func openWindow(serverAlias, command string) (string, bool, error) { + target, insideTmux, err := workspaceTarget() + if err != nil { + return "", insideTmux, err + } + name := sanitizeWindowName(serverAlias) + var args []string + if !insideTmux && !sessionExists(target) { + args = []string{"new-session", "-d", "-P", "-F", "#{window_id}", "-s", target, "-n", name, command} + } else { + args = []string{"new-window", "-d", "-P", "-F", "#{window_id}", "-t", target, "-n", name, command} + } + out, err := exec.Command("tmux", args...).CombinedOutput() + if err != nil { + return "", insideTmux, fmt.Errorf("create tmux session window: %s: %w", strings.TrimSpace(string(out)), err) + } + windowID := strings.TrimSpace(string(out)) + if windowID == "" { + return "", insideTmux, fmt.Errorf("tmux did not return a window id") + } + if err := setWindowMetadata(windowID, serverAlias, time.Now()); err != nil { + return "", insideTmux, err + } + return windowID, insideTmux, nil +} + +func sanitizeWindowName(alias string) string { + name := strings.TrimSpace(alias) + if name == "" { + return "ssh" + } + name = strings.ReplaceAll(name, ":", "-") + name = strings.ReplaceAll(name, " ", "-") + if len(name) > 40 { + name = name[:40] + } + return name +} + +func setWindowMetadata(windowID, alias string, started time.Time) error { + pairs := [][2]string{ + {"@sshkeeper_server", alias}, + {"@sshkeeper_started", strconv.FormatInt(started.Unix(), 10)}, + } + for _, pair := range pairs { + out, err := exec.Command("tmux", "set-window-option", "-t", windowID, pair[0], pair[1]).CombinedOutput() + if err != nil { + return fmt.Errorf("set tmux metadata %s: %s: %w", pair[0], strings.TrimSpace(string(out)), err) + } + } + return nil +} + +func List() ([]Window, error) { + if !Available() { + return nil, nil + } + target, _, err := workspaceTarget() + if err != nil { + return nil, err + } + if !sessionExists(target) { + return nil, nil + } + format := "#{window_id}\t#{window_index}\t#{window_name}\t#{window_active}\t#{@sshkeeper_server}\t#{@sshkeeper_started}" + out, err := exec.Command("tmux", "list-windows", "-t", target, "-F", format).CombinedOutput() + if err != nil { + return nil, fmt.Errorf("list tmux windows: %s: %w", strings.TrimSpace(string(out)), err) + } + var result []Window + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + parts := strings.Split(line, "\t") + if len(parts) < 6 || strings.TrimSpace(parts[4]) == "" { + continue + } + index, _ := strconv.Atoi(parts[1]) + startedUnix, _ := strconv.ParseInt(parts[5], 10, 64) + window := Window{ID: parts[0], Index: index, Name: parts[2], Active: parts[3] == "1", ServerAlias: parts[4]} + if startedUnix > 0 { + window.StartedAt = time.Unix(startedUnix, 0) + } + result = append(result, window) + } + return result, nil +} +func Attach(windowID string) error { + if !Available() { + return fmt.Errorf("tmux is unavailable") + } + if strings.TrimSpace(windowID) == "" { + return fmt.Errorf("tmux window id is required") + } + if os.Getenv("TMUX") != "" { + out, err := exec.Command("tmux", "select-window", "-t", windowID).CombinedOutput() + if err != nil { + return fmt.Errorf("select tmux window: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil + } + target, _, err := workspaceTarget() + if err != nil { + return err + } + if out, err := exec.Command("tmux", "select-window", "-t", windowID).CombinedOutput(); err != nil { + return fmt.Errorf("select tmux window: %s: %w", strings.TrimSpace(string(out)), err) + } + cmd := exec.Command("tmux", "attach-session", "-t", target) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("attach tmux session: %w", err) + } + return nil +} + +func Close(windowID string) error { + if !Available() { + return fmt.Errorf("tmux is unavailable") + } + out, err := exec.Command("tmux", "kill-window", "-t", windowID).CombinedOutput() + if err != nil { + return fmt.Errorf("close tmux window: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} diff --git a/internal/session/tmux_test.go b/internal/session/tmux_test.go new file mode 100644 index 0000000..93cc866 --- /dev/null +++ b/internal/session/tmux_test.go @@ -0,0 +1,68 @@ +package session + +import ( + "fmt" + "os/exec" + "testing" + "time" +) + +func TestSanitizeWindowName(t *testing.T) { + if got := sanitizeWindowName(" prod:db "); got != "prod-db" { + t.Fatalf("sanitizeWindowName = %q, want prod-db", got) + } + if got := sanitizeWindowName(" "); got != "ssh" { + t.Fatalf("empty name = %q, want ssh", got) + } +} + +func TestShellQuote(t *testing.T) { + got := shellQuote("prod'one") + want := `'prod'"'"'one'` + if got != want { + t.Fatalf("shellQuote = %q, want %q", got, want) + } +} + +func TestTmuxWindowLifecycle(t *testing.T) { + if !Available() { + t.Skip("tmux is not available") + } + t.Setenv("TMUX", "") + oldWorkspace := dedicatedWorkspace + dedicatedWorkspace = fmt.Sprintf("sshkeeper-test-%d", time.Now().UnixNano()) + t.Cleanup(func() { + _, _ = exec.Command("tmux", "kill-session", "-t", dedicatedWorkspace).CombinedOutput() + dedicatedWorkspace = oldWorkspace + }) + + windowID, inside, err := openWindow("smoke-server", "sleep 30") + if err != nil { + t.Fatalf("openWindow: %v", err) + } + if inside { + t.Fatal("expected dedicated workspace outside tmux") + } + windows, err := List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(windows) != 1 || windows[0].ID != windowID || windows[0].ServerAlias != "smoke-server" { + t.Fatalf("unexpected windows: %#v", windows) + } + if err := Close(windowID); err != nil { + t.Fatalf("Close: %v", err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + windows, err = List() + if err != nil { + t.Fatalf("List after close: %v", err) + } + if len(windows) == 0 { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("window %s still listed after close: %#v", windowID, windows) +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 4a19a20..8b8afd1 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/mirivlad/sshkeeper/internal/model" + sessionpkg "github.com/mirivlad/sshkeeper/internal/session" ) // --- Styles --- @@ -242,6 +243,7 @@ const ( screenManageMenu screenForwardList screenForwardForm + screenSessionManager screenTunnelManager screenConfirm screenFullHelp @@ -275,47 +277,50 @@ type TUIResult struct { Action string // "connect" or "run_template_foreground" Command string TemplateName string + SessionID string } // --- Main TUI model --- type tuiModel struct { - screen screen - list list.Model - servers []*model.Server - searchInput textinput.Model - form *formModel - templateForm *templateFormModel - templates []*model.CommandTemplate - templateList list.Model - pendingTemplate *model.CommandTemplate - tagList list.Model - tags []string - tagInput textinput.Model - tagMode string - tagOldName string - groups []*model.Group - groupList list.Model - groupInput textinput.Model - groupMode string - groupOldName string - selected map[string]bool - tunnelScreen *tunnelScreenModel - bgResults []templateRunResult - err error - success string - width int - height int - result *TUIResult - helpScreen *helpScreenModel - actionMenu *actionMenuModel - manageMenu *actionMenuModel - forwardScreen *forwardScreenModel - forwardForm *forwardFormModel - confirm *confirmState - fullHelp *fullHelpModel - helpParent screen - vaultUnlocked bool + screen screen + list list.Model + servers []*model.Server + searchInput textinput.Model + form *formModel + templateForm *templateFormModel + templates []*model.CommandTemplate + templateList list.Model + pendingTemplate *model.CommandTemplate + tagList list.Model + tags []string + tagInput textinput.Model + tagMode string + tagOldName string + groups []*model.Group + groupList list.Model + groupInput textinput.Model + groupMode string + groupOldName string + selected map[string]bool + sessionsAvailable bool + sessionScreen *sessionScreenModel + tunnelScreen *tunnelScreenModel + bgResults []templateRunResult + err error + success string + width int + height int + result *TUIResult + helpScreen *helpScreenModel + actionMenu *actionMenuModel + manageMenu *actionMenuModel + forwardScreen *forwardScreenModel + forwardForm *forwardFormModel + confirm *confirmState + fullHelp *fullHelpModel + helpParent screen + vaultUnlocked bool } func New(servers []*model.Server) *tuiModel { @@ -357,17 +362,18 @@ func New(servers []*model.Server) *tuiModel { } return &tuiModel{ - screen: screenList, - list: l, - servers: servers, - searchInput: search, - selected: map[string]bool{}, - tagInput: tagInput, - groupInput: groupInput, - templateList: templateList, - tagList: tagList, - groupList: groupList, - vaultUnlocked: vaultIsUnlocked, + screen: screenList, + list: l, + servers: servers, + searchInput: search, + selected: map[string]bool{}, + sessionsAvailable: sessionpkg.Available(), + tagInput: tagInput, + groupInput: groupInput, + templateList: templateList, + tagList: tagList, + groupList: groupList, + vaultUnlocked: vaultIsUnlocked, } } @@ -402,6 +408,11 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.forwardForm.width = msg.Width m.forwardForm.height = msg.Height } + if m.sessionScreen != nil { + m.sessionScreen.width = msg.Width + m.sessionScreen.height = msg.Height + m.sessionScreen.list.SetSize(msg.Width, managerListHeight(msg.Height)) + } if m.tunnelScreen != nil { m.tunnelScreen.width = msg.Width m.tunnelScreen.height = msg.Height @@ -635,6 +646,18 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case sessionsLoadedMsg: + if msg.closed && m.confirm != nil && m.confirm.pending && m.confirm.parent == screenSessionManager { + m.finishConfirm() + } + if m.sessionScreen != nil { + m.sessionScreen.err = msg.err + if msg.err == nil { + m.sessionScreen.setSessions(msg.sessions) + } + } + return m, nil + case tunnelsLoadedMsg: if m.tunnelScreen != nil { m.tunnelScreen.tunnels = nil @@ -788,6 +811,8 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateForwardList(msg) case screenForwardForm: return m.updateForwardForm(msg) + case screenSessionManager: + return m.updateSessionManager(msg) case screenTunnelManager: return m.updateTunnelManager(msg) case screenConfirm: @@ -877,7 +902,7 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyRunes: if msg.String() == "m" || msg.String() == "M" { - m.manageMenu = newManageMenuModel(m.width, m.height) + m.manageMenu = newManageMenuModel(m.width, m.height, m.sessionsAvailable) m.screen = screenManageMenu return m, nil } @@ -897,7 +922,7 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case tea.KeyCtrlX: - m.actionMenu = newActionMenuModel(m.width, m.height) + m.actionMenu = newActionMenuModel(m.width, m.height, m.sessionsAvailable) m.screen = screenActionMenu return m, nil @@ -1438,6 +1463,11 @@ func (m *tuiModel) View() string { b.WriteString(m.forwardForm.View()) } + case screenSessionManager: + if m.sessionScreen != nil { + b.WriteString(m.sessionScreen.View()) + } + case screenTunnelManager: if m.tunnelScreen != nil { b.WriteString(m.tunnelScreen.View()) @@ -1484,6 +1514,12 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return connectRequestMsg{server: item.server} } } + case "session_open": + if item, ok := m.list.SelectedItem().(serverItem); ok { + m.actionMenu = nil + m.result = &TUIResult{Server: item.server, Action: "session_open"} + return m, tea.Quit + } case "tunnel": if item, ok := m.list.SelectedItem().(serverItem); ok { m.actionMenu = nil @@ -1582,6 +1618,10 @@ func (m *tuiModel) updateManageMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "templates": m.screen = screenTemplates return m, m.loadTemplatesCmd() + case "sessions": + m.sessionScreen = newSessionScreenModel(m.width, m.height) + m.screen = screenSessionManager + return m, m.sessionScreen.loadSessions() case "tunnels": m.tunnelScreen = newTunnelScreenModel(m.width, m.height) m.screen = screenTunnelManager @@ -1686,6 +1726,45 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m *tuiModel) updateSessionManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.screen = screenList + m.sessionScreen = nil + return m, nil + case tea.KeyEnter: + if m.sessionScreen != nil { + if selected := m.sessionScreen.selected(); selected != nil { + m.result = &TUIResult{Action: "session_attach", SessionID: selected.ID} + return m, tea.Quit + } + } + case tea.KeyCtrlD: + m.confirmSessionClose() + return m, nil + case tea.KeyCtrlR: + if m.sessionScreen != nil { + return m, m.sessionScreen.loadSessions() + } + case tea.KeyRunes: + switch msg.String() { + case "d", "D": + m.confirmSessionClose() + return m, nil + case "r", "R": + if m.sessionScreen != nil { + return m, m.sessionScreen.loadSessions() + } + } + } + if m.sessionScreen != nil { + var cmd tea.Cmd + m.sessionScreen.list, cmd = m.sessionScreen.list.Update(msg) + return m, cmd + } + return m, nil +} + func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: @@ -1914,6 +1993,26 @@ func (m *tuiModel) confirmForwardDelete(fwd *model.Forward) { }) } +func (m *tuiModel) confirmSessionClose() { + if m.sessionScreen == nil { + return + } + selected := m.sessionScreen.selected() + if selected == nil { + return + } + m.beginConfirm(confirmState{ + title: "Close SSH session?", + target: fmt.Sprintf("%q · tmux %s", selected.ServerAlias, selected.ID), + consequence: "The interactive SSH process in this tmux window will be terminated.", + verb: "Close", + parent: screenSessionManager, + action: func() tea.Cmd { + return m.sessionScreen.closeSelected() + }, + }) +} + func (m *tuiModel) confirmTunnelStop() { if m.tunnelScreen == nil { return diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index cc84653..69c14da 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -1038,3 +1038,35 @@ func TestStartupTemplatePickerCopiesCommand(t *testing.T) { t.Fatalf("startup command = %q", got) } } + +func menuHasAction(menu *actionMenuModel, action string) bool { + for _, item := range menu.list.Items() { + entry, ok := item.(actionMenuItem) + if ok && entry.action == action { + return true + } + } + return false +} + +func TestSessionActionsAreHiddenWhenTmuxUnavailable(t *testing.T) { + actions := newActionMenuModel(80, 24, false) + manage := newManageMenuModel(80, 24, false) + if menuHasAction(actions, "session_open") { + t.Fatal("server actions exposed tmux session action while unavailable") + } + if menuHasAction(manage, "sessions") { + t.Fatal("manage menu exposed Sessions while tmux unavailable") + } +} + +func TestSessionActionsAreVisibleWhenTmuxAvailable(t *testing.T) { + actions := newActionMenuModel(80, 24, true) + manage := newManageMenuModel(80, 24, true) + if !menuHasAction(actions, "session_open") { + t.Fatal("server actions did not expose tmux session action") + } + if !menuHasAction(manage, "sessions") { + t.Fatal("manage menu did not expose Sessions") + } +} diff --git a/internal/tui/help_screen.go b/internal/tui/help_screen.go index b754918..b35b73e 100644 --- a/internal/tui/help_screen.go +++ b/internal/tui/help_screen.go @@ -310,9 +310,15 @@ type actionMenuModel struct { height int } -func newActionMenuModel(w, h int) *actionMenuModel { +func newActionMenuModel(w, h int, availability ...bool) *actionMenuModel { + sessionsAvailable := len(availability) > 0 && availability[0] items := []list.Item{ actionMenuItem{label: "Connect", action: "connect", description: "Open an interactive SSH session."}, + } + if sessionsAvailable { + items = append(items, actionMenuItem{label: "Open in session", action: "session_open", description: "Open this server in a persistent tmux-backed SSH tab."}) + } + items = append(items, actionMenuItem{label: "Connect with tunnels", action: "tunnel", description: "Open SSH and activate enabled port forwards."}, actionMenuItem{label: "Start tunnels only", action: "tunnel_n", description: "Activate enabled forwards without a shell."}, actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg", description: "Run enabled forwards as a background process."}, @@ -321,21 +327,27 @@ func newActionMenuModel(w, h int) *actionMenuModel { actionMenuItem{label: "Test connection", action: "test", description: "Check SSH reachability for this profile."}, actionMenuItem{label: "Edit", action: "edit", description: "Change this server profile."}, actionMenuItem{label: "Delete", action: "delete", description: "Permanently remove this server profile."}, - } + ) return newMenuModel("Server Actions", items, w, h) } -func newManageMenuModel(w, h int) *actionMenuModel { +func newManageMenuModel(w, h int, availability ...bool) *actionMenuModel { + sessionsAvailable := len(availability) > 0 && availability[0] items := []list.Item{ actionMenuItem{label: "Groups", action: "groups", description: "Create, rename, and remove server groups."}, actionMenuItem{label: "Tags", action: "tags", description: "Manage tags and apply them to selected servers."}, actionMenuItem{label: "Command templates", action: "templates", description: "Manage reusable commands."}, + } + if sessionsAvailable { + items = append(items, actionMenuItem{label: "Sessions", action: "sessions", description: "Attach to or close tmux-backed SSH sessions."}) + } + items = append(items, actionMenuItem{label: "Running tunnels", action: "tunnels", description: "Inspect and stop tracked background tunnels."}, actionMenuItem{label: "Import SSH config", action: "import", description: "Import profiles from ~/.ssh/config."}, actionMenuItem{label: "Export", action: "export", description: "Export server profiles."}, actionMenuItem{label: "Vault: lock", action: "vault_lock", description: "Lock secrets for the current session."}, actionMenuItem{label: "Vault: change password", action: "vault_change_pw", description: "Change the password protecting stored secrets."}, - } + ) return newMenuModel("Manage", items, w, h) } diff --git a/internal/tui/layout_test.go b/internal/tui/layout_test.go index 54f62c8..585d60d 100644 --- a/internal/tui/layout_test.go +++ b/internal/tui/layout_test.go @@ -253,6 +253,9 @@ func TestManagerScreensUseUnifiedShell(t *testing.T) { }) } + sessionScreen := newSessionScreenModel(size.width, size.height) + assertUnifiedScreen(t, sessionScreen.View(), size.width, size.height) + tunnelScreen := newTunnelScreenModel(size.width, size.height) assertUnifiedScreen(t, tunnelScreen.View(), size.width, size.height) } @@ -277,6 +280,7 @@ func TestLayoutMatrixInventoriesEveryScreen(t *testing.T) { screenManageMenu: "manage matrix", screenForwardList: "forward matrix", screenForwardForm: "forward form matrix", + screenSessionManager: "manager matrix", screenTunnelManager: "manager matrix", screenConfirm: "confirmation matrix", screenFullHelp: "help matrix", diff --git a/internal/tui/session.go b/internal/tui/session.go new file mode 100644 index 0000000..69a88d3 --- /dev/null +++ b/internal/tui/session.go @@ -0,0 +1,134 @@ +package tui + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + sessionpkg "github.com/mirivlad/sshkeeper/internal/session" +) + +type sessionScreenModel struct { + list list.Model + sessions []sessionpkg.Window + width int + height int + err error +} + +type sessionItem struct { + window sessionpkg.Window +} + +func (i sessionItem) Title() string { + active := "" + if i.window.Active { + active = " active" + } + return fmt.Sprintf("%-28s #%d%s", truncate(i.window.ServerAlias, 28), i.window.Index, active) +} +func (i sessionItem) Description() string { + if i.window.StartedAt.IsZero() { + return "tmux window " + i.window.ID + } + return fmt.Sprintf("running %s · tmux %s", time.Since(i.window.StartedAt).Round(time.Second), i.window.ID) +} + +func (i sessionItem) FilterValue() string { + return i.window.ServerAlias + " " + i.window.Name +} + +func newSessionScreenModel(w, h int) *sessionScreenModel { + l := list.New([]list.Item{}, list.NewDefaultDelegate(), w, managerListHeight(h)) + l.Title = "Sessions" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.SetShowHelp(false) + l.Styles.Title = titleStyle + return &sessionScreenModel{list: l, width: w, height: h} +} + +func (m *sessionScreenModel) loadSessions() tea.Cmd { + return func() tea.Msg { + windows, err := sessionpkg.List() + return sessionsLoadedMsg{sessions: windows, err: err, closed: true} + } +} +func (m *sessionScreenModel) setSessions(windows []sessionpkg.Window) { + m.sessions = windows + items := make([]list.Item, len(windows)) + for index, window := range windows { + items[index] = sessionItem{window: window} + } + m.list.SetItems(items) +} + +func (m *sessionScreenModel) selected() *sessionpkg.Window { + item, ok := m.list.SelectedItem().(sessionItem) + if !ok { + return nil + } + window := item.window + return &window +} + +func (m *sessionScreenModel) closeSelected() tea.Cmd { + selected := m.selected() + if selected == nil { + return nil + } + id := selected.ID + return func() tea.Msg { + err := sessionpkg.Close(id) + windows, listErr := sessionpkg.List() + if err == nil { + err = listErr + } + return sessionsLoadedMsg{sessions: windows, err: err, closed: true} + } +} +func (m *sessionScreenModel) View() string { + notification := "" + if m.err != nil { + notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err)) + } + body := func(width, height int) string { + if len(m.sessions) == 0 { + return renderPaddedPanel(width, height, []string{dashboardHelp("No active SSH sessions.")}) + } + capacity := max(1, height-2) + start, end := visibleServerRange(len(m.sessions), m.list.Index(), max(1, capacity/2)) + lines := make([]string, 0, capacity) + for index := start; index < end; index++ { + item := sessionItem{window: m.sessions[index]} + marker := " " + if index == m.list.Index() { + marker = "> " + } + lines = append(lines, marker+item.Title(), " "+item.Description()) + } + return renderPaddedPanel(width, height, lines) + } + return renderScreenShell(screenShell{ + breadcrumb: "Sessions", + status: fmt.Sprintf("%d active · tmux", len(m.sessions)), + notification: notification, + width: m.width, + height: m.height, + body: body, + footer: []helpItem{ + {Key: "Enter", Action: "attach"}, + {Key: "Ctrl+D (d)", Action: "close"}, + {Key: "Ctrl+R (r)", Action: "refresh"}, + {Key: "Ctrl+H", Action: "help"}, + {Key: "Esc", Action: "back"}, + }, + }) +} + +type sessionsLoadedMsg struct { + sessions []sessionpkg.Window + err error + closed bool +} diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go new file mode 100644 index 0000000..ee936f5 --- /dev/null +++ b/internal/tui/session_test.go @@ -0,0 +1,24 @@ +package tui + +import ( + "testing" + + sessionpkg "github.com/mirivlad/sshkeeper/internal/session" +) + +func TestSessionCloseCommandMarksOperationComplete(t *testing.T) { + model := newSessionScreenModel(80, 24) + model.setSessions([]sessionpkg.Window{{ID: "@sshkeeper-test-missing", ServerAlias: "test"}}) + + cmd := model.closeSelected() + if cmd == nil { + t.Fatal("closeSelected returned nil command") + } + msg, ok := cmd().(sessionsLoadedMsg) + if !ok { + t.Fatalf("closeSelected returned %T, want sessionsLoadedMsg", cmd()) + } + if !msg.closed { + t.Fatal("closeSelected did not mark the close operation complete") + } +} diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index aec937c..e6d5e1c 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -13,6 +13,10 @@ vendor: sshkeeper homepage: https://github.com/mirivlad/sshkeeper license: MIT +# Optional: enables tmux-backed persistent SSH sessions. +recommends: + - tmux + contents: - src: ${NFPM_BINARY} dst: /usr/bin/sshkeeper