Compare commits

..

No commits in common. "main" and "v0.4.1" have entirely different histories.
main ... v0.4.1

18 changed files with 71 additions and 851 deletions

View File

@ -25,7 +25,6 @@ port forwarding management.
- **Routes / ProxyJump** — ordered bastion chains with stable references to sshkeeper profiles; profile renames do not break routes. - **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. - **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. - **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. - **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. - 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. - Import from `~/.ssh/config` and simple tab-separated export.
@ -47,15 +46,15 @@ Or use the build scripts:
./release.sh # Build release archives to dist/ ./release.sh # Build release archives to dist/
``` ```
Requirements: Go 1.25+ and system OpenSSH. `tmux` is optional and recommended for persistent multi-session tabs; without it, all Sessions UI is hidden. Requirements: Go 1.25+ and system OpenSSH.
Platform status: Platform status:
| Platform | Status | Notes | | Platform | Status | Notes |
|----------|--------|-------| |----------|--------|-------|
| Linux | Primary release target | `amd64`/`arm64` tarballs plus native `.deb` and `.rpm` packages. Native packages recommend (but do not require) `tmux` for persistent Sessions. | | 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`; install optional `tmux` with `brew install tmux` to enable Sessions. Homebrew formula planned. | | 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 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. | | Windows | Experimental | Requires OpenSSH Client available as `ssh.exe` in `PATH`. Password/key-passphrase PTY flows are not validated on Windows. |
On Windows, install OpenSSH Client via Windows Optional Features or PowerShell: On Windows, install OpenSSH Client via Windows Optional Features or PowerShell:
@ -72,13 +71,13 @@ Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
Debian/Ubuntu (amd64): Debian/Ubuntu (amd64):
```bash ```bash
sudo apt install ./sshkeeper_0.5.1-1_amd64.deb sudo apt install ./sshkeeper_0.4.1-1_amd64.deb
``` ```
Fedora/RHEL-family (x86_64): Fedora/RHEL-family (x86_64):
```bash ```bash
sudo dnf install ./sshkeeper-0.5.1-1.x86_64.rpm sudo dnf install ./sshkeeper-0.4.1-1.x86_64.rpm
``` ```
`arm64`/`aarch64` packages are published alongside the x86_64 builds. Native `arm64`/`aarch64` packages are published alongside the x86_64 builds. Native
@ -98,8 +97,8 @@ sshkeeper --version
The traditional tar.gz archive remains available too: The traditional tar.gz archive remains available too:
```bash ```bash
tar -xzf sshkeeper_v0.5.1_linux_amd64.tar.gz tar -xzf sshkeeper_v0.4.1_linux_amd64.tar.gz
sudo install -m 0755 sshkeeper_v0.5.1_linux_amd64/sshkeeper /usr/local/bin/sshkeeper sudo install -m 0755 sshkeeper_v0.4.1_linux_amd64/sshkeeper /usr/local/bin/sshkeeper
sshkeeper sshkeeper
``` ```
@ -191,19 +190,6 @@ In add/edit forms:
| Enter | Move to action / activate | | Enter | Move to action / activate |
| Esc | Back | | 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, Tunnels, and Port Forwards
Routes are stored as ordered hops. If a hop matches an existing sshkeeper profile, Routes are stored as ordered hops. If a hop matches an existing sshkeeper profile,

View File

@ -63,7 +63,6 @@ func init() {
rootCmd.AddCommand(routeCmd) rootCmd.AddCommand(routeCmd)
rootCmd.AddCommand(forwardCmd) rootCmd.AddCommand(forwardCmd)
rootCmd.AddCommand(tunnelCmd) rootCmd.AddCommand(tunnelCmd)
rootCmd.AddCommand(sessionConnectCmd)
} }
func initApp() { func initApp() {

View File

@ -1,57 +0,0 @@
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 <alias>",
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")
}

View File

@ -6,7 +6,6 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/mirivlad/sshkeeper/internal/model" "github.com/mirivlad/sshkeeper/internal/model"
sessionpkg "github.com/mirivlad/sshkeeper/internal/session"
"github.com/mirivlad/sshkeeper/internal/ssh" "github.com/mirivlad/sshkeeper/internal/ssh"
"github.com/mirivlad/sshkeeper/internal/tui" "github.com/mirivlad/sshkeeper/internal/tui"
tunnelpkg "github.com/mirivlad/sshkeeper/internal/tunnel" tunnelpkg "github.com/mirivlad/sshkeeper/internal/tunnel"
@ -194,28 +193,6 @@ func runTUI() error {
// Check if TUI requested a connect action // Check if TUI requested a connect action
result := m.Result() 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 { if result != nil && result.Action == "connect" && result.Server != nil {
// TUI has exited, terminal is restored by tea.WithAltScreen. // TUI has exited, terminal is restored by tea.WithAltScreen.
// Now connect. // Now connect.

View File

@ -9,11 +9,10 @@
5. [Управление серверами](#управление-серверами) 5. [Управление серверами](#управление-серверами)
6. [Маршруты и бастионы](#маршруты-и-бастионы) 6. [Маршруты и бастионы](#маршруты-и-бастионы)
7. [Port Forwards и Tunnels](#port-forwards-и-tunnels) 7. [Port Forwards и Tunnels](#port-forwards-и-tunnels)
8. [Sessions — постоянные SSH-вкладки](#sessions--постоянные-ssh-вкладки) 8. [CLI команды](#cli-команды)
9. [CLI команды](#cli-команды) 9. [Vault — хранилище секретов](#vault--хранилище-секретов)
10. [Vault — хранилище секретов](#vault--хранилище-секретов) 10. [Сценарии использования](#сценарии-использования)
11. [Сценарии использования](#сценарии-использования) 11. [Справка по клавишам](#справка-по-клавишам)
12. [Справка по клавишам](#справка-по-клавишам)
--- ---
@ -64,15 +63,15 @@ go build -o ~/.local/bin/sshkeeper .
./release.sh # сборка релизных архивов в dist/ ./release.sh # сборка релизных архивов в dist/
``` ```
**Требования:** Go 1.25+ и системный OpenSSH. `tmux` необязателен, но рекомендуется для постоянных SSH-сессий; если его нет, весь интерфейс Sessions скрыт. **Требования:** Go 1.25+ и системный OpenSSH.
Статус платформ: Статус платформ:
| Платформа | Статус | Примечание | | Платформа | Статус | Примечание |
|-----------|--------|------------| |-----------|--------|------------|
| Linux | Основная релизная платформа | Архивы `amd64`/`arm64`, а также `.deb` и `.rpm`. Пакеты рекомендуют, но не требуют `tmux` для Sessions. | | Linux | Основная релизная платформа | Архивы `amd64`/`arm64`, а также `.deb` и `.rpm`. |
| macOS | Основная релизная платформа | Архивы `darwin/amd64` и `darwin/arm64`, нужен системный `ssh`; `brew install tmux` включает Sessions. Homebrew formula sshkeeper запланирована. | | macOS | Основная релизная платформа | Архивы `darwin/amd64` и `darwin/arm64`, нужен системный `ssh`. Homebrew formula запланирована. |
| Windows | Experimental | Нужен OpenSSH Client как `ssh.exe` в `PATH`. В native Windows сборке Sessions скрыты; Linux-сборка внутри WSL может использовать `tmux`. | | Windows | Experimental | Нужен OpenSSH Client как `ssh.exe` в `PATH`; password/key-passphrase PTY-сценарии на Windows пока не подтверждены. |
На Windows OpenSSH Client можно установить через Windows Optional Features или PowerShell: На Windows OpenSSH Client можно установить через Windows Optional Features или PowerShell:
@ -567,22 +566,6 @@ 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 команды ## CLI команды
### Серверы ### Серверы

View File

@ -1,72 +0,0 @@
# sshkeeper v0.5.0 — Persistent SSH Sessions
v0.5.0 adds an optional multi-session workflow backed by `tmux`. It lets
sshkeeper keep several interactive SSH connections alive in one terminal
workspace without turning sshkeeper itself into a terminal emulator.
`tmux` is intentionally optional. If it is not available, Sessions are not
shown anywhere in the TUI and ordinary Connect/Tunnel workflows behave exactly
as they did in v0.4.1.
## Persistent sessions
When `tmux` is available in `PATH`:
- **Server Actions → Open in session** opens the selected server in a persistent
tmux window;
- **Manage → Sessions** lists SSH windows created by sshkeeper;
- `Enter` attaches to the selected session;
- `Ctrl+D` closes it after confirmation;
- `Ctrl+R` refreshes the list.
If sshkeeper runs outside tmux, it uses a dedicated tmux workspace named
`sshkeeper`. If it is already running inside tmux, new SSH windows are created
inside the current tmux session rather than starting a nested client.
## Vault and authentication
Sessions continue to use the existing sshkeeper/OpenSSH connection planner,
including routes, bastions, identity files and startup commands.
Key and SSH-agent sessions do not need a vault unlock. Password and
key-passphrase sessions ask for the vault master password inside their own tmux
window. Secrets are not copied through command-line arguments, environment
variables or temporary shell scripts.
## Platform behavior
Linux and macOS support Sessions when `tmux` is installed. On macOS it can be
installed with Homebrew using `brew install tmux`.
Native Windows builds keep Sessions hidden because upstream tmux is not a native
Windows backend for this workflow. Windows users can use Sessions by running the
Linux build inside WSL with tmux installed there.
Linux native packages do **not** require tmux. Package metadata keeps OpenSSH as
the hard dependency and marks tmux only as `Recommends`, so the application
remains fully usable without the Sessions feature.
## Validation
The release is covered by normal unit tests plus a real tmux lifecycle test that
creates a temporary workspace/window, verifies sshkeeper can discover its
metadata, closes it, and confirms it disappears.
The release gate also runs `go vet`, Linux package migration tests and release
cross-builds for Linux amd64/arm64, macOS amd64/arm64 and Windows amd64. GitHub
CI additionally runs the test suite natively on both Ubuntu and macOS.
## Install
Debian/Ubuntu (amd64):
```bash
sudo apt install ./sshkeeper_0.5.0-1_amd64.deb
```
Fedora/RHEL-family (x86_64):
```bash
sudo dnf install ./sshkeeper-0.5.0-1.x86_64.rpm
```
ARM64 packages and tar/zip archives are published alongside them. Verify
downloads against `checksums.txt`.

View File

@ -1,12 +0,0 @@
# sshkeeper v0.5.1 — Package Migration Fix
v0.5.1 is a patch release on top of v0.5.0 Persistent SSH Sessions.
## Fixed
- Fixed DEB/RPM post-install discovery of legacy per-user binaries at `~/.local/bin/sshkeeper`.
- Package installation now correctly backs up a legacy binary and replaces its old path with a symlink to `/usr/bin/sshkeeper`.
- Added a regression test that discovers the user path through passwd data, matching the real package-install path.
- `sshkeeper --version` and `sshkeeper version` continue to report the embedded release version.
No session functionality from v0.5.0 is removed or rolled back.

View File

@ -1,194 +0,0 @@
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
}

View File

@ -1,68 +0,0 @@
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)
}

View File

@ -10,7 +10,6 @@ import (
"github.com/charmbracelet/bubbletea" "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/mirivlad/sshkeeper/internal/model" "github.com/mirivlad/sshkeeper/internal/model"
sessionpkg "github.com/mirivlad/sshkeeper/internal/session"
) )
// --- Styles --- // --- Styles ---
@ -243,7 +242,6 @@ const (
screenManageMenu screenManageMenu
screenForwardList screenForwardList
screenForwardForm screenForwardForm
screenSessionManager
screenTunnelManager screenTunnelManager
screenConfirm screenConfirm
screenFullHelp screenFullHelp
@ -277,7 +275,6 @@ type TUIResult struct {
Action string // "connect" or "run_template_foreground" Action string // "connect" or "run_template_foreground"
Command string Command string
TemplateName string TemplateName string
SessionID string
} }
// --- Main TUI model --- // --- Main TUI model ---
@ -303,8 +300,6 @@ type tuiModel struct {
groupMode string groupMode string
groupOldName string groupOldName string
selected map[string]bool selected map[string]bool
sessionsAvailable bool
sessionScreen *sessionScreenModel
tunnelScreen *tunnelScreenModel tunnelScreen *tunnelScreenModel
bgResults []templateRunResult bgResults []templateRunResult
err error err error
@ -367,7 +362,6 @@ func New(servers []*model.Server) *tuiModel {
servers: servers, servers: servers,
searchInput: search, searchInput: search,
selected: map[string]bool{}, selected: map[string]bool{},
sessionsAvailable: sessionpkg.Available(),
tagInput: tagInput, tagInput: tagInput,
groupInput: groupInput, groupInput: groupInput,
templateList: templateList, templateList: templateList,
@ -408,11 +402,6 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.forwardForm.width = msg.Width m.forwardForm.width = msg.Width
m.forwardForm.height = msg.Height 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 { if m.tunnelScreen != nil {
m.tunnelScreen.width = msg.Width m.tunnelScreen.width = msg.Width
m.tunnelScreen.height = msg.Height m.tunnelScreen.height = msg.Height
@ -646,18 +635,6 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil 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: case tunnelsLoadedMsg:
if m.tunnelScreen != nil { if m.tunnelScreen != nil {
m.tunnelScreen.tunnels = nil m.tunnelScreen.tunnels = nil
@ -811,8 +788,6 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateForwardList(msg) return m.updateForwardList(msg)
case screenForwardForm: case screenForwardForm:
return m.updateForwardForm(msg) return m.updateForwardForm(msg)
case screenSessionManager:
return m.updateSessionManager(msg)
case screenTunnelManager: case screenTunnelManager:
return m.updateTunnelManager(msg) return m.updateTunnelManager(msg)
case screenConfirm: case screenConfirm:
@ -902,7 +877,7 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case tea.KeyRunes: case tea.KeyRunes:
if msg.String() == "m" || msg.String() == "M" { if msg.String() == "m" || msg.String() == "M" {
m.manageMenu = newManageMenuModel(m.width, m.height, m.sessionsAvailable) m.manageMenu = newManageMenuModel(m.width, m.height)
m.screen = screenManageMenu m.screen = screenManageMenu
return m, nil return m, nil
} }
@ -922,7 +897,7 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case tea.KeyCtrlX: case tea.KeyCtrlX:
m.actionMenu = newActionMenuModel(m.width, m.height, m.sessionsAvailable) m.actionMenu = newActionMenuModel(m.width, m.height)
m.screen = screenActionMenu m.screen = screenActionMenu
return m, nil return m, nil
@ -1463,11 +1438,6 @@ func (m *tuiModel) View() string {
b.WriteString(m.forwardForm.View()) b.WriteString(m.forwardForm.View())
} }
case screenSessionManager:
if m.sessionScreen != nil {
b.WriteString(m.sessionScreen.View())
}
case screenTunnelManager: case screenTunnelManager:
if m.tunnelScreen != nil { if m.tunnelScreen != nil {
b.WriteString(m.tunnelScreen.View()) b.WriteString(m.tunnelScreen.View())
@ -1514,12 +1484,6 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return connectRequestMsg{server: item.server} 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": case "tunnel":
if item, ok := m.list.SelectedItem().(serverItem); ok { if item, ok := m.list.SelectedItem().(serverItem); ok {
m.actionMenu = nil m.actionMenu = nil
@ -1618,10 +1582,6 @@ func (m *tuiModel) updateManageMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "templates": case "templates":
m.screen = screenTemplates m.screen = screenTemplates
return m, m.loadTemplatesCmd() return m, m.loadTemplatesCmd()
case "sessions":
m.sessionScreen = newSessionScreenModel(m.width, m.height)
m.screen = screenSessionManager
return m, m.sessionScreen.loadSessions()
case "tunnels": case "tunnels":
m.tunnelScreen = newTunnelScreenModel(m.width, m.height) m.tunnelScreen = newTunnelScreenModel(m.width, m.height)
m.screen = screenTunnelManager m.screen = screenTunnelManager
@ -1726,45 +1686,6 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil 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) { func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type { switch msg.Type {
case tea.KeyEsc: case tea.KeyEsc:
@ -1993,26 +1914,6 @@ 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() { func (m *tuiModel) confirmTunnelStop() {
if m.tunnelScreen == nil { if m.tunnelScreen == nil {
return return

View File

@ -1038,35 +1038,3 @@ func TestStartupTemplatePickerCopiesCommand(t *testing.T) {
t.Fatalf("startup command = %q", got) 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")
}
}

View File

@ -310,15 +310,9 @@ type actionMenuModel struct {
height int height int
} }
func newActionMenuModel(w, h int, availability ...bool) *actionMenuModel { func newActionMenuModel(w, h int) *actionMenuModel {
sessionsAvailable := len(availability) > 0 && availability[0]
items := []list.Item{ items := []list.Item{
actionMenuItem{label: "Connect", action: "connect", description: "Open an interactive SSH session."}, 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: "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 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."}, actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg", description: "Run enabled forwards as a background process."},
@ -327,27 +321,21 @@ func newActionMenuModel(w, h int, availability ...bool) *actionMenuModel {
actionMenuItem{label: "Test connection", action: "test", description: "Check SSH reachability for this profile."}, 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: "Edit", action: "edit", description: "Change this server profile."},
actionMenuItem{label: "Delete", action: "delete", description: "Permanently remove this server profile."}, actionMenuItem{label: "Delete", action: "delete", description: "Permanently remove this server profile."},
) }
return newMenuModel("Server Actions", items, w, h) return newMenuModel("Server Actions", items, w, h)
} }
func newManageMenuModel(w, h int, availability ...bool) *actionMenuModel { func newManageMenuModel(w, h int) *actionMenuModel {
sessionsAvailable := len(availability) > 0 && availability[0]
items := []list.Item{ items := []list.Item{
actionMenuItem{label: "Groups", action: "groups", description: "Create, rename, and remove server groups."}, 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: "Tags", action: "tags", description: "Manage tags and apply them to selected servers."},
actionMenuItem{label: "Command templates", action: "templates", description: "Manage reusable commands."}, 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: "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: "Import SSH config", action: "import", description: "Import profiles from ~/.ssh/config."},
actionMenuItem{label: "Export", action: "export", description: "Export server profiles."}, 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: 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."}, actionMenuItem{label: "Vault: change password", action: "vault_change_pw", description: "Change the password protecting stored secrets."},
) }
return newMenuModel("Manage", items, w, h) return newMenuModel("Manage", items, w, h)
} }

View File

@ -253,9 +253,6 @@ 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) tunnelScreen := newTunnelScreenModel(size.width, size.height)
assertUnifiedScreen(t, tunnelScreen.View(), size.width, size.height) assertUnifiedScreen(t, tunnelScreen.View(), size.width, size.height)
} }
@ -280,7 +277,6 @@ func TestLayoutMatrixInventoriesEveryScreen(t *testing.T) {
screenManageMenu: "manage matrix", screenManageMenu: "manage matrix",
screenForwardList: "forward matrix", screenForwardList: "forward matrix",
screenForwardForm: "forward form matrix", screenForwardForm: "forward form matrix",
screenSessionManager: "manager matrix",
screenTunnelManager: "manager matrix", screenTunnelManager: "manager matrix",
screenConfirm: "confirmation matrix", screenConfirm: "confirmation matrix",
screenFullHelp: "help matrix", screenFullHelp: "help matrix",

View File

@ -1,134 +0,0 @@
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
}

View File

@ -1,24 +0,0 @@
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")
}
}

View File

@ -13,10 +13,6 @@ vendor: sshkeeper
homepage: https://github.com/mirivlad/sshkeeper homepage: https://github.com/mirivlad/sshkeeper
license: MIT license: MIT
# Optional: enables tmux-backed persistent SSH sessions.
recommends:
- tmux
contents: contents:
- src: ${NFPM_BINARY} - src: ${NFPM_BINARY}
dst: /usr/bin/sshkeeper dst: /usr/bin/sshkeeper

View File

@ -4,7 +4,6 @@ umask 077
SYSTEM_BINARY=${SSHKEEPER_SYSTEM_BINARY:-/usr/bin/sshkeeper} SYSTEM_BINARY=${SSHKEEPER_SYSTEM_BINARY:-/usr/bin/sshkeeper}
STATE_FILE=${SSHKEEPER_STATE_FILE:-/var/lib/sshkeeper/package-legacy-paths} STATE_FILE=${SSHKEEPER_STATE_FILE:-/var/lib/sshkeeper/package-legacy-paths}
PASSWD_FILE=${SSHKEEPER_PASSWD_FILE:-/etc/passwd}
candidate_paths() { candidate_paths() {
if [ -n "${SSHKEEPER_LEGACY_PATHS:-}" ]; then if [ -n "${SSHKEEPER_LEGACY_PATHS:-}" ]; then
@ -15,8 +14,8 @@ candidate_paths() {
fi fi
printf 'root\t%s\n' /usr/local/bin/sshkeeper printf 'root\t%s\n' /usr/local/bin/sshkeeper
if [ -r "$PASSWD_FILE" ]; then if [ -r /etc/passwd ]; then
awk -F: '$3 == 0 || $3 >= 1000 { if ($6 != "" && $6 != "/") printf "%s\t%s/.local/bin/sshkeeper\n", $1, $6 }' "$PASSWD_FILE" awk -F: '$3 == 0 || $3 >= 1000 { if ($6 != "" && $6 != "/") printf "%s\\t%s/.local/bin/sshkeeper\\n", $1, $6 }' /etc/passwd
fi fi
} }

View File

@ -15,18 +15,6 @@ printf 'old-user\n' > "$legacy_user"
printf 'old-local\n' > "$legacy_local" printf 'old-local\n' > "$legacy_local"
chmod +x "$system" "$legacy_user" "$legacy_local" chmod +x "$system" "$legacy_user" "$legacy_local"
# Discover ~/.local/bin/sshkeeper through passwd exactly as a real package install does.
passwd_file="$tmp/passwd"
printf 'test:x:1000:1000:test:%s:/bin/bash\n' "$tmp/home/test" > "$passwd_file"
env SSHKEEPER_MIGRATION_RUN_AS_CURRENT=1 SSHKEEPER_SYSTEM_BINARY="$system" SSHKEEPER_STATE_FILE="$state" SSHKEEPER_PASSWD_FILE="$passwd_file" \
packaging/scripts/postinstall.sh configure
test -L "$legacy_user"
test "$(readlink "$legacy_user")" = "$system"
test -f "${legacy_user}.legacy-backup"
env SSHKEEPER_MIGRATION_RUN_AS_CURRENT=1 SSHKEEPER_SYSTEM_BINARY="$system" SSHKEEPER_STATE_FILE="$state" \
packaging/scripts/postremove.sh remove
test "$(cat "$legacy_user")" = old-user
paths=$(printf '%s\n%s' "$legacy_user" "$legacy_local") paths=$(printf '%s\n%s' "$legacy_user" "$legacy_local")
env SSHKEEPER_MIGRATION_RUN_AS_CURRENT=1 SSHKEEPER_SYSTEM_BINARY="$system" SSHKEEPER_STATE_FILE="$state" SSHKEEPER_LEGACY_PATHS="$paths" \ env SSHKEEPER_MIGRATION_RUN_AS_CURRENT=1 SSHKEEPER_SYSTEM_BINARY="$system" SSHKEEPER_STATE_FILE="$state" SSHKEEPER_LEGACY_PATHS="$paths" \
packaging/scripts/postinstall.sh configure packaging/scripts/postinstall.sh configure