Compare commits

...

6 Commits
v0.4.0 ... main

Author SHA1 Message Date
mirivlad c3492f9a18 docs: prepare v0.5.1 release 2026-09-06 21:06:50 +08:00
mirivlad a3d97fb1c0 fix: repair passwd legacy migration 2026-09-06 20:51:06 +08:00
mirivlad a67e7143fa docs: add v0.5.0 release notes 2026-09-06 16:25:50 +08:00
Mirivlad d13d0f747d
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.
2026-09-06 16:20:13 +08:00
mirivlad dd7ea6012d fix: make package install authoritative 2026-09-06 11:55:50 +08:00
mirivlad b481bb9f3e build: add deb and rpm release packages 2026-09-05 22:46:45 +08:00
32 changed files with 1475 additions and 88 deletions

View File

@ -46,6 +46,10 @@ jobs:
- name: go test
run: go test ./... -count=1
- name: package migration test
if: matrix.os == 'ubuntu-latest'
run: ./packaging/scripts/test-legacy-migration.sh
cross-build:
name: cross-build
runs-on: ubuntu-latest

View File

@ -28,6 +28,14 @@ jobs:
go-version-file: go.mod
cache: true
- name: install nfpm
env:
NFPM_VERSION: v2.47.0
run: |
mkdir -p "$RUNNER_TEMP/bin"
GOBIN="$RUNNER_TEMP/bin" go install github.com/goreleaser/nfpm/v2/cmd/nfpm@"$NFPM_VERSION"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
# Cheaper than the full release-check, but still refuses to publish a
# broken build.
- name: test
@ -81,6 +89,7 @@ jobs:
Verify downloads against \`checksums.txt\`.
EOF
PKG_VERSION="${VERSION#v}"
gh release delete nightly --yes || echo "no previous nightly release"
gh release create nightly \
--prerelease \
@ -91,4 +100,8 @@ jobs:
"dist/sshkeeper_${VERSION}_darwin_amd64.tar.gz" \
"dist/sshkeeper_${VERSION}_darwin_arm64.tar.gz" \
"dist/sshkeeper_${VERSION}_windows_amd64.zip" \
"dist/sshkeeper_${PKG_VERSION}-1_amd64.deb" \
"dist/sshkeeper_${PKG_VERSION}-1_arm64.deb" \
"dist/sshkeeper-${PKG_VERSION}-1.x86_64.rpm" \
"dist/sshkeeper-${PKG_VERSION}-1.aarch64.rpm" \
dist/checksums.txt

View File

@ -23,6 +23,14 @@ jobs:
go-version-file: go.mod
cache: true
- name: install nfpm
env:
NFPM_VERSION: v2.47.0
run: |
mkdir -p "$RUNNER_TEMP/bin"
GOBIN="$RUNNER_TEMP/bin" go install github.com/goreleaser/nfpm/v2/cmd/nfpm@"$NFPM_VERSION"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
# Gate the release on the same checks used locally. A red suite must not
# be able to publish.
- name: release checks
@ -53,6 +61,7 @@ jobs:
set -- --generate-notes
fi
PKG_VERSION="${VERSION#v}"
gh release create "$VERSION" \
--title "sshkeeper $VERSION" \
--verify-tag \
@ -62,4 +71,8 @@ jobs:
"dist/sshkeeper_${VERSION}_darwin_amd64.tar.gz" \
"dist/sshkeeper_${VERSION}_darwin_arm64.tar.gz" \
"dist/sshkeeper_${VERSION}_windows_amd64.zip" \
"dist/sshkeeper_${PKG_VERSION}-1_amd64.deb" \
"dist/sshkeeper_${PKG_VERSION}-1_arm64.deb" \
"dist/sshkeeper-${PKG_VERSION}-1.x86_64.rpm" \
"dist/sshkeeper-${PKG_VERSION}-1.aarch64.rpm" \
dist/checksums.txt

View File

@ -1,10 +1,12 @@
APP=sshkeeper
VERSION ?= $(shell git describe --tags --match 'v*' --always --dirty 2>/dev/null || echo dev)
LDFLAGS = -s -w -X github.com/mirivlad/sshkeeper/cmd.Version=$(VERSION)
RELEASE_CHECK_DIR ?= /tmp/sshkeeper-release-check
.PHONY: build run test vet fmt clean install release-check
.PHONY: build run test vet fmt clean install packaging-test release-check
build:
go build -o bin/$(APP) .
go build -ldflags "$(LDFLAGS)" -o bin/$(APP) .
run:
go run .
@ -22,13 +24,17 @@ clean:
rm -rf bin
install:
go build -o $(HOME)/.local/bin/$(APP) .
go build -ldflags "$(LDFLAGS)" -o $(HOME)/.local/bin/$(APP) .
packaging-test:
./packaging/scripts/test-legacy-migration.sh
release-check:
rm -rf $(RELEASE_CHECK_DIR)
mkdir -p $(RELEASE_CHECK_DIR)
go test ./...
go vet ./...
./packaging/scripts/test-legacy-migration.sh
CGO_ENABLED=0 go build -o $(RELEASE_CHECK_DIR)/$(APP) .
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o $(RELEASE_CHECK_DIR)/$(APP)-linux-amd64 .
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o $(RELEASE_CHECK_DIR)/$(APP)-linux-arm64 .

View File

@ -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 | `linux/amd64` and `linux/arm64` release tarballs are available. |
| 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:
@ -68,9 +69,37 @@ Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
### Install from release
Debian/Ubuntu (amd64):
```bash
tar -xzf sshkeeper_v0.4.0_linux_amd64.tar.gz
sudo install -m 0755 sshkeeper_v0.4.0_linux_amd64/sshkeeper /usr/local/bin/sshkeeper
sudo apt install ./sshkeeper_0.5.1-1_amd64.deb
```
Fedora/RHEL-family (x86_64):
```bash
sudo dnf install ./sshkeeper-0.5.1-1.x86_64.rpm
```
`arm64`/`aarch64` packages are published alongside the x86_64 builds. Native
packages own command resolution: when installing or upgrading they detect old
`/usr/local/bin/sshkeeper` and local-account `~/.local/bin/sshkeeper` copies, preserve
each as `*.legacy-backup`, and redirect the old path to `/usr/bin/sshkeeper`.
Removing the package restores preserved legacy binaries.
Check the exact running binary and embedded version with:
```bash
command -v sshkeeper
sshkeeper --version
# or: sshkeeper version
```
The traditional tar.gz archive remains available too:
```bash
tar -xzf sshkeeper_v0.5.1_linux_amd64.tar.gz
sudo install -m 0755 sshkeeper_v0.5.1_linux_amd64/sshkeeper /usr/local/bin/sshkeeper
sshkeeper
```
@ -162,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,

View File

@ -8,7 +8,7 @@ APP=sshkeeper
# plain `git describe --tags` picks whichever tag is nearest, so a nightly build
# would otherwise stamp binaries "nightly" instead of v<last release>-N-g<sha>.
VERSION=$(git describe --tags --match 'v*' --always --dirty 2>/dev/null || echo "dev")
LDFLAGS="-s -w -X main.version=${VERSION}"
LDFLAGS="-s -w -X github.com/mirivlad/sshkeeper/cmd.Version=${VERSION}"
echo "==> Building ${APP} ${VERSION}..."
go build -ldflags "${LDFLAGS}" -o bin/${APP} .

View File

@ -20,6 +20,7 @@ var (
var rootCmd = &cobra.Command{
Use: "sshkeeper",
Version: Version,
Short: "sshkeeper — SSH connection manager",
Long: `sshkeeper is a console SSH connection manager.
Linux and macOS are primary release targets; Windows is experimental.
@ -38,7 +39,9 @@ func Execute() {
}
func init() {
rootCmd.SetVersionTemplate("sshkeeper {{.Version}}\n")
cobra.OnInitialize(initApp)
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(initCmd)
rootCmd.AddCommand(addCmd)
rootCmd.AddCommand(listCmd)
@ -60,9 +63,14 @@ func init() {
rootCmd.AddCommand(routeCmd)
rootCmd.AddCommand(forwardCmd)
rootCmd.AddCommand(tunnelCmd)
rootCmd.AddCommand(sessionConnectCmd)
}
func initApp() {
if commandSkipsAppInitialization(os.Args[1:]) {
return
}
var err error
cfg, err = config.Load()
@ -171,6 +179,18 @@ func initApp() {
}
}
func commandSkipsAppInitialization(args []string) bool {
if len(args) == 0 {
return false
}
for _, arg := range args {
if arg == "-h" || arg == "--help" || arg == "--version" {
return true
}
}
return args[0] == "version"
}
func commandRequiresStartupVaultUnlock(args []string) bool {
if len(args) == 0 {
return true

View File

@ -24,6 +24,8 @@ func TestCommandRequiresStartupVaultUnlock(t *testing.T) {
{name: "background tunnel does not need startup vault", args: []string{"tunnel", "prod", "--background"}, want: false},
{name: "config path only reads config", args: []string{"config", "path"}, want: false},
{name: "help", args: []string{"--help"}, want: false},
{name: "version command", args: []string{"version"}, want: false},
{name: "version flag", args: []string{"--version"}, want: false},
}
for _, tt := range tests {
@ -34,3 +36,23 @@ func TestCommandRequiresStartupVaultUnlock(t *testing.T) {
})
}
}
func TestCommandSkipsAppInitialization(t *testing.T) {
tests := []struct {
args []string
want bool
}{
{args: nil, want: false},
{args: []string{"list"}, want: false},
{args: []string{"version"}, want: true},
{args: []string{"--version"}, want: true},
{args: []string{"--help"}, want: true},
{args: []string{"list", "--help"}, want: true},
}
for _, tt := range tests {
if got := commandSkipsAppInitialization(tt.args); got != tt.want {
t.Fatalf("commandSkipsAppInitialization(%v) = %v; want %v", tt.args, got, tt.want)
}
}
}

57
cmd/session.go Normal file
View File

@ -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 <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,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.

24
cmd/version.go Normal file
View File

@ -0,0 +1,24 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// Version is replaced at link time by build.sh/release.sh.
var Version = "dev"
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print sshkeeper version",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
_, err := fmt.Fprintf(cmd.OutOrStdout(), "sshkeeper %s\n", Version)
return err
},
}
}
var versionCmd = newVersionCmd()

32
cmd/version_test.go Normal file
View File

@ -0,0 +1,32 @@
package cmd
import (
"bytes"
"strings"
"testing"
)
func TestVersionCommand(t *testing.T) {
original := Version
Version = "v9.8.7-test"
t.Cleanup(func() { Version = original })
var out bytes.Buffer
cmd := newVersionCmd()
cmd.SetOut(&out)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("version command: %v", err)
}
if got, want := out.String(), "sshkeeper v9.8.7-test\n"; got != want {
t.Fatalf("output = %q; want %q", got, want)
}
}
func TestRootVersionFlagIsEnabled(t *testing.T) {
if rootCmd.Version == "" {
t.Fatal("root command Version must be set so Cobra exposes --version")
}
if !strings.Contains(rootCmd.Version, "v") && rootCmd.Version != "dev" {
t.Fatalf("unexpected root version %q", rootCmd.Version)
}
}

View File

@ -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 | Основная релизная платформа | Архивы `linux/amd64` и `linux/arm64`. |
| 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:
@ -79,11 +80,26 @@ go build -o ~/.local/bin/sshkeeper .
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
```
### Из релиза (после публикации v0.2.0)
### Из релиза
Для Debian/Ubuntu (amd64):
```bash
tar -xzf sshkeeper_v0.2.0_linux_amd64.tar.gz
sudo install -m 0755 sshkeeper_v0.2.0_linux_amd64/sshkeeper /usr/local/bin/sshkeeper
sudo apt install ./sshkeeper_0.4.0-1_amd64.deb
```
Для Fedora/RHEL-подобных систем (x86_64):
```bash
sudo dnf install ./sshkeeper-0.4.0-1.x86_64.rpm
```
Для ARM64 публикуются `sshkeeper_0.4.0-1_arm64.deb` и
`sshkeeper-0.4.0-1.aarch64.rpm`. Архивный вариант остаётся доступен:
```bash
tar -xzf sshkeeper_v0.4.0_linux_amd64.tar.gz
sudo install -m 0755 sshkeeper_v0.4.0_linux_amd64/sshkeeper /usr/local/bin/sshkeeper
```
---
@ -551,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 команды
### Серверы

View File

@ -13,8 +13,9 @@ runs, and how to reproduce it by hand when needed.
| `nightly.yml` | push to `main` | rebuilds the tip of `main` and replaces the `nightly` prerelease |
`release.yml` builds through `release.sh` rather than reimplementing packaging,
so CI and a local run stay in step. See [Reproducibility](#reproducibility) for
what that guarantees.
so CI and a local run stay in step. Linux `.deb` and `.rpm` packages are built
with nFPM v2.47.0 from the exact Linux tarball binaries. See
[Reproducibility](#reproducibility) for what that guarantees.
### Release notes
@ -90,7 +91,14 @@ This runs:
## Build Artifacts
Run:
Linux package generation requires nFPM v2.47.0. GitHub Actions installs this
exact version; for a local release build install the same tool first:
```bash
go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.47.0
```
Then run:
```bash
./release.sh v0.2.0
@ -104,6 +112,10 @@ sshkeeper_v0.2.0_linux_arm64.tar.gz
sshkeeper_v0.2.0_darwin_amd64.tar.gz
sshkeeper_v0.2.0_darwin_arm64.tar.gz
sshkeeper_v0.2.0_windows_amd64.zip
sshkeeper_0.2.0-1_amd64.deb
sshkeeper_0.2.0-1_arm64.deb
sshkeeper-0.2.0-1.x86_64.rpm
sshkeeper-0.2.0-1.aarch64.rpm
checksums.txt
```
@ -114,6 +126,19 @@ Each archive contains:
- `LICENSE`
- `docs/guide.md`
Linux packages install the same release binary as `/usr/bin/sshkeeper` and add
README, LICENSE, and the user guide under `/usr/share/doc/sshkeeper/`. Debian
packages depend on `openssh-client`; RPM packages depend on `openssh-clients`.
The package revision starts at `1` and is reset when the upstream version changes.
Package maintainer scripts also migrate known legacy command paths. Existing
`/usr/local/bin/sshkeeper` and per-user `~/.local/bin/sshkeeper` entries are moved
to a non-destructive `*.legacy-backup` and replaced by symlinks to the packaged
`/usr/bin/sshkeeper`. This makes package installation authoritative even for a
shell that already cached the old command path. Home-directory changes are executed as the account owner rather than as root.
Package removal restores backups; package upgrades keep the redirect active. Migration behavior is covered by
`packaging/scripts/test-legacy-migration.sh` and is part of `make release-check`.
## Verify Checksums
From the `dist/` directory:
@ -138,7 +163,10 @@ byte for byte. `release.sh` pins everything that would otherwise vary:
everything else, so the builder's umask cannot leak into the archive.
- the Windows zip is packaged under `LC_ALL=C` and `TZ=UTC`, because `sort`
orders entries by locale and zip stores DOS local time with no zone.
orders entries by locale and zip stores DOS local time with no zone;
- nFPM receives the same `SOURCE_DATE_EPOCH` and packages files extracted from
the already-built Linux tarballs, so `.deb`/`.rpm` contain the identical Linux
binary rather than triggering a second compile.
With those in place the archives themselves reproduce across hosts: a build on
`ubuntu-latest` (umask 022, C locale, UTC) and one on a workstation (umask 002,
@ -172,11 +200,10 @@ Release notes should mention platform status:
## Packaging TODO
Prepare these package channels after the first archive-based release:
Native `.deb` and `.rpm` packages are part of the release pipeline. Remaining
package channels:
- deb package
- Arch PKGBUILD / AUR
- rpm later
- Homebrew tap
- Scoop manifest
- Winget later

View File

@ -185,11 +185,23 @@ the data matters to you.
## Install
Debian/Ubuntu (amd64):
```bash
tar -xzf sshkeeper_v0.4.0_linux_amd64.tar.gz
sudo install -m 0755 sshkeeper_v0.4.0_linux_amd64/sshkeeper /usr/local/bin/sshkeeper
sudo apt install ./sshkeeper_0.4.0-1_amd64.deb
```
Fedora/RHEL-family (x86_64):
```bash
sudo dnf install ./sshkeeper-0.4.0-1.x86_64.rpm
```
ARM64 packages (`arm64.deb` / `aarch64.rpm`) and the original tar.gz archives
are published alongside them. Package dependencies pull in the distro OpenSSH
client; user config, database and vault files are not owned or modified by the
package.
Verify downloads against `checksums.txt`. Linux and macOS are the primary
release targets. Windows remains experimental and requires OpenSSH Client
(`ssh.exe`) in `PATH`.

45
docs/releases/v0.4.1.md Normal file
View File

@ -0,0 +1,45 @@
# sshkeeper v0.4.1 — Package Install & Version Fixes
This patch release makes native Linux packages authoritative after installation
and exposes the version embedded in the executable.
## Package installation now wins over legacy binaries
DEB and RPM installs detect older sshkeeper copies at known legacy paths:
- `/usr/local/bin/sshkeeper`;
- per-user `~/.local/bin/sshkeeper` paths discovered from the system account database.
A detected legacy entry is preserved as `*.legacy-backup`, then its old path is
replaced by a symlink to `/usr/bin/sshkeeper`. This deliberately handles both
`PATH` precedence and shells that have already cached the previous executable
path. No database, vault, configuration, SSH key, or other user data is touched.
The migration is idempotent across package upgrades. Removing the package restores
the preserved legacy binary when the redirect is still package-managed; if the
user changed that path while the package was installed, the package leaves the
user's replacement alone and keeps the backup rather than overwriting it.
## Version reporting
Both forms are now supported without initializing the database or vault:
```bash
sshkeeper --version
sshkeeper version
```
A v0.4.1 release binary prints:
```text
sshkeeper v0.4.1
```
The build and release scripts now link the discovered version into the real
`cmd.Version` symbol instead of the stale `main.version` target.
## Packaging verification
The release gate now tests legacy-path migration and restoration in addition to
the existing Go tests, vet, and cross-platform builds. DEB/RPM packages continue
to contain the exact Linux binary produced for the matching tarball.

72
docs/releases/v0.5.0.md Normal file
View File

@ -0,0 +1,72 @@
# 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`.

12
docs/releases/v0.5.1.md Normal file
View File

@ -0,0 +1,12 @@
# 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.

194
internal/session/tmux.go Normal file
View File

@ -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
}

View File

@ -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)
}

View File

@ -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,6 +277,7 @@ type TUIResult struct {
Action string // "connect" or "run_template_foreground"
Command string
TemplateName string
SessionID string
}
// --- Main TUI model ---
@ -300,6 +303,8 @@ type tuiModel struct {
groupMode string
groupOldName string
selected map[string]bool
sessionsAvailable bool
sessionScreen *sessionScreenModel
tunnelScreen *tunnelScreenModel
bgResults []templateRunResult
err error
@ -362,6 +367,7 @@ func New(servers []*model.Server) *tuiModel {
servers: servers,
searchInput: search,
selected: map[string]bool{},
sessionsAvailable: sessionpkg.Available(),
tagInput: tagInput,
groupInput: groupInput,
templateList: templateList,
@ -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

View File

@ -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")
}
}

View File

@ -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)
}

View File

@ -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",

134
internal/tui/session.go Normal file
View File

@ -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
}

View File

@ -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")
}
}

View File

@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
APP=sshkeeper
VERSION=${1:-${VERSION:-}}
NFPM_BIN=${NFPM_BIN:-nfpm}
NFPM_RELEASE=${NFPM_RELEASE:-1}
if [[ -z "$VERSION" ]]; then
echo "usage: $0 <version>" >&2
exit 2
fi
if ! command -v "$NFPM_BIN" >/dev/null 2>&1; then
echo "nfpm is required to build .deb/.rpm packages" >&2
exit 1
fi
PKG_VERSION=${VERSION#v}
if [[ -z "${SOURCE_DATE_EPOCH:-}" ]]; then
if git rev-parse --verify -q "${VERSION}^{commit}" >/dev/null; then
SOURCE_DATE_EPOCH=$(git log -1 --format=%ct "$VERSION")
else
SOURCE_DATE_EPOCH=$(git log -1 --format=%ct 2>/dev/null || date +%s)
fi
fi
export SOURCE_DATE_EPOCH
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
build_one() {
local goarch="$1"
local rpmarch
local tarball="dist/${APP}_${VERSION}_linux_${goarch}.tar.gz"
local package_root="${TMP_DIR}/${APP}_${VERSION}_linux_${goarch}"
local extracted="${package_root}/${APP}"
case "$goarch" in
amd64) rpmarch=x86_64 ;;
arm64) rpmarch=aarch64 ;;
*) echo "unsupported package arch: $goarch" >&2; return 1 ;;
esac
if [[ ! -f "$tarball" ]]; then
echo "missing Linux release archive: $tarball" >&2
return 1
fi
tar -xzf "$tarball" -C "$TMP_DIR"
if [[ ! -x "$extracted" ]]; then
echo "missing binary in $tarball" >&2
return 1
fi
export NFPM_ARCH="$goarch"
export NFPM_VERSION="$PKG_VERSION"
export NFPM_RELEASE
export NFPM_BINARY="$extracted"
export NFPM_README="${package_root}/README.md"
export NFPM_LICENSE="${package_root}/LICENSE"
export NFPM_GUIDE="${package_root}/docs/guide.md"
"$NFPM_BIN" package --config packaging/nfpm.yaml --packager deb \
--target "dist/${APP}_${PKG_VERSION}-${NFPM_RELEASE}_${goarch}.deb"
"$NFPM_BIN" package --config packaging/nfpm.yaml --packager rpm \
--target "dist/${APP}-${PKG_VERSION}-${NFPM_RELEASE}.${rpmarch}.rpm"
}
build_one amd64
build_one arm64
echo "==> Linux packages:"
ls -lh dist/*.deb dist/*.rpm

57
packaging/nfpm.yaml Normal file
View File

@ -0,0 +1,57 @@
name: sshkeeper
arch: ${NFPM_ARCH}
platform: linux
version: ${NFPM_VERSION}
release: ${NFPM_RELEASE}
section: utils
priority: optional
maintainer: mirivlad <mirvtop@yandex.ru>
description: |
Console manager for SSH profiles, bastion routes, port forwards,
background tunnels, and encrypted SSH secrets.
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
expand: true
file_info:
mode: 0755
- src: ${NFPM_README}
dst: /usr/share/doc/sshkeeper/README.md
expand: true
file_info:
mode: 0644
- src: ${NFPM_GUIDE}
dst: /usr/share/doc/sshkeeper/guide.md
expand: true
file_info:
mode: 0644
- src: ${NFPM_LICENSE}
dst: /usr/share/doc/sshkeeper/LICENSE
expand: true
file_info:
mode: 0644
overrides:
deb:
depends:
- openssh-client
scripts:
postinstall: ./packaging/scripts/postinstall.sh
postremove: ./packaging/scripts/postremove.sh
rpm:
depends:
- openssh-clients
scripts:
postinstall: ./packaging/scripts/postinstall.sh
postremove: ./packaging/scripts/postremove.sh
rpm:
compression: gzip

View File

@ -0,0 +1,97 @@
#!/bin/sh
set -u
umask 077
SYSTEM_BINARY=${SSHKEEPER_SYSTEM_BINARY:-/usr/bin/sshkeeper}
STATE_FILE=${SSHKEEPER_STATE_FILE:-/var/lib/sshkeeper/package-legacy-paths}
PASSWD_FILE=${SSHKEEPER_PASSWD_FILE:-/etc/passwd}
candidate_paths() {
if [ -n "${SSHKEEPER_LEGACY_PATHS:-}" ]; then
printf '%s\n' "$SSHKEEPER_LEGACY_PATHS" | while IFS= read -r path; do
printf 'test\t%s\n' "$path"
done
return
fi
printf 'root\t%s\n' /usr/local/bin/sshkeeper
if [ -r "$PASSWD_FILE" ]; then
awk -F: '$3 == 0 || $3 >= 1000 { if ($6 != "" && $6 != "/") printf "%s\t%s/.local/bin/sshkeeper\n", $1, $6 }' "$PASSWD_FILE"
fi
}
run_as() {
owner=$1
shift
if [ "${SSHKEEPER_MIGRATION_RUN_AS_CURRENT:-}" = 1 ]; then
"$@"
elif [ "$owner" = root ]; then
"$@"
elif command -v runuser >/dev/null 2>&1; then
runuser -u "$owner" -- "$@"
else
return 127
fi
}
next_backup() {
path=$1
base="${path}.legacy-backup"
if [ ! -e "$base" ] && [ ! -L "$base" ]; then
printf '%s\n' "$base"
return
fi
n=1
while [ -e "${base}.${n}" ] || [ -L "${base}.${n}" ]; do
n=$((n + 1))
done
printf '%s\n' "${base}.${n}"
}
record_migration() {
owner=$1
path=$2
backup=$3
state_dir=$(dirname "$STATE_FILE")
if mkdir -p "$state_dir" 2>/dev/null; then
chmod 700 "$state_dir" 2>/dev/null || true
printf '%s\t%s\t%s\n' "$owner" "$path" "$backup" >> "$STATE_FILE"
chmod 600 "$STATE_FILE" 2>/dev/null || true
else
printf 'sshkeeper: warning: cannot create migration state directory %s\n' "$state_dir" >&2
fi
}
migrate_one() {
owner=$1
path=$2
[ "$path" = "$SYSTEM_BINARY" ] && return 0
[ -e "$path" ] || [ -L "$path" ] || return 0
if [ -L "$path" ] && [ "$(readlink "$path" 2>/dev/null || true)" = "$SYSTEM_BINARY" ]; then
return 0
fi
backup=$(next_backup "$path")
if ! run_as "$owner" mv -- "$path" "$backup" 2>/dev/null; then
printf 'sshkeeper: warning: cannot disable legacy binary %s as user %s\n' "$path" "$owner" >&2
return 0
fi
if ! run_as "$owner" ln -s "$SYSTEM_BINARY" "$path" 2>/dev/null; then
run_as "$owner" mv -- "$backup" "$path" 2>/dev/null || true
printf 'sshkeeper: warning: cannot redirect legacy path %s to %s\n' "$path" "$SYSTEM_BINARY" >&2
return 0
fi
record_migration "$owner" "$path" "$backup"
printf 'sshkeeper: migrated legacy binary: %s -> %s (backup: %s)\n' "$path" "$SYSTEM_BINARY" "$backup"
}
tab=$(printf '\t')
candidate_paths | while IFS="$tab" read -r owner path; do
[ -n "$owner" ] && [ -n "$path" ] && migrate_one "$owner" "$path"
done
exit 0

63
packaging/scripts/postremove.sh Executable file
View File

@ -0,0 +1,63 @@
#!/bin/sh
set -u
umask 077
SYSTEM_BINARY=${SSHKEEPER_SYSTEM_BINARY:-/usr/bin/sshkeeper}
STATE_FILE=${SSHKEEPER_STATE_FILE:-/var/lib/sshkeeper/package-legacy-paths}
action=${1:-remove}
case "$action" in
1|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
exit 0
;;
0|remove|purge)
;;
*)
exit 0
;;
esac
[ -f "$STATE_FILE" ] || exit 0
run_as() {
owner=$1
shift
if [ "${SSHKEEPER_MIGRATION_RUN_AS_CURRENT:-}" = 1 ]; then
"$@"
elif [ "$owner" = root ]; then
"$@"
elif command -v runuser >/dev/null 2>&1; then
runuser -u "$owner" -- "$@"
else
return 127
fi
}
tab=$(printf '\t')
tac "$STATE_FILE" 2>/dev/null | while IFS="$tab" read -r owner path backup; do
[ -n "$owner" ] || continue
[ -n "$path" ] || continue
[ -n "$backup" ] || continue
managed=false
if [ -L "$path" ] && [ "$(readlink "$path" 2>/dev/null || true)" = "$SYSTEM_BINARY" ]; then
managed=true
run_as "$owner" rm -f -- "$path" 2>/dev/null || managed=false
elif [ ! -e "$path" ] && [ ! -L "$path" ]; then
managed=true
fi
if [ "$managed" = true ] && { [ -e "$backup" ] || [ -L "$backup" ]; }; then
if run_as "$owner" mv -- "$backup" "$path" 2>/dev/null; then
printf 'sshkeeper: restored legacy binary: %s\n' "$path"
else
printf 'sshkeeper: warning: could not restore %s from %s\n' "$path" "$backup" >&2
fi
elif [ -e "$backup" ] || [ -L "$backup" ]; then
printf 'sshkeeper: warning: %s changed while package was installed; legacy backup kept at %s\n' "$path" "$backup" >&2
fi
done
rm -f -- "$STATE_FILE" 2>/dev/null || true
rmdir -- "$(dirname "$STATE_FILE")" 2>/dev/null || true
exit 0

View File

@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/../.."
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
system="$tmp/usr/bin/sshkeeper"
legacy_user="$tmp/home/test/.local/bin/sshkeeper"
legacy_local="$tmp/usr/local/bin/sshkeeper"
state="$tmp/var/lib/sshkeeper/package-legacy-paths"
mkdir -p "$(dirname "$system")" "$(dirname "$legacy_user")" "$(dirname "$legacy_local")"
printf 'packaged\n' > "$system"
printf 'old-user\n' > "$legacy_user"
printf 'old-local\n' > "$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")
env SSHKEEPER_MIGRATION_RUN_AS_CURRENT=1 SSHKEEPER_SYSTEM_BINARY="$system" SSHKEEPER_STATE_FILE="$state" SSHKEEPER_LEGACY_PATHS="$paths" \
packaging/scripts/postinstall.sh configure
for path in "$legacy_user" "$legacy_local"; do
test -L "$path"
test "$(readlink "$path")" = "$system"
test -f "${path}.legacy-backup"
done
test "$(wc -l < "$state")" -eq 2
test "$(stat -c %a "$state")" = 600
# Re-running postinstall on upgrade is idempotent.
env SSHKEEPER_MIGRATION_RUN_AS_CURRENT=1 SSHKEEPER_SYSTEM_BINARY="$system" SSHKEEPER_STATE_FILE="$state" SSHKEEPER_LEGACY_PATHS="$paths" \
packaging/scripts/postinstall.sh configure 0.4.0
test "$(wc -l < "$state")" -eq 2
env SSHKEEPER_MIGRATION_RUN_AS_CURRENT=1 SSHKEEPER_SYSTEM_BINARY="$system" SSHKEEPER_STATE_FILE="$state" \
packaging/scripts/postremove.sh upgrade
for path in "$legacy_user" "$legacy_local"; do test -L "$path"; done
env SSHKEEPER_MIGRATION_RUN_AS_CURRENT=1 SSHKEEPER_SYSTEM_BINARY="$system" SSHKEEPER_STATE_FILE="$state" \
packaging/scripts/postremove.sh remove
test ! -e "$state"
test ! -L "$legacy_user"
test ! -L "$legacy_local"
test "$(cat "$legacy_user")" = old-user
test "$(cat "$legacy_local")" = old-local
# If a user replaces the package-managed redirect, uninstall must not overwrite it.
printf 'old-again\n' > "$legacy_user"
env SSHKEEPER_MIGRATION_RUN_AS_CURRENT=1 SSHKEEPER_SYSTEM_BINARY="$system" SSHKEEPER_STATE_FILE="$state" SSHKEEPER_LEGACY_PATHS="$legacy_user" \
packaging/scripts/postinstall.sh configure
rm -f "$legacy_user"
printf 'user-replacement\n' > "$legacy_user"
env SSHKEEPER_MIGRATION_RUN_AS_CURRENT=1 SSHKEEPER_SYSTEM_BINARY="$system" SSHKEEPER_STATE_FILE="$state" \
packaging/scripts/postremove.sh remove
test "$(cat "$legacy_user")" = user-replacement
test "$(cat "${legacy_user}.legacy-backup")" = old-again
echo "legacy migration tests: OK"

View File

@ -6,9 +6,10 @@ cd "$(dirname "$0")"
APP=sshkeeper
# --match 'v*' ignores the rolling `nightly` tag; see build.sh for the details.
VERSION=${VERSION:-${1:-$(git describe --tags --match 'v*' --always --dirty 2>/dev/null || echo "dev")}}
LDFLAGS="-s -w -X main.version=${VERSION}"
LDFLAGS="-s -w -X github.com/mirivlad/sshkeeper/cmd.Version=${VERSION}"
DIST_DIR="dist"
SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2>/dev/null || date +%s)}
export SOURCE_DATE_EPOCH
echo "==> Building release ${APP} ${VERSION}..."
echo "==> SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}"
@ -88,7 +89,9 @@ build_tarball darwin amd64
build_tarball darwin arm64
build_zip windows amd64
(cd "${DIST_DIR}" && sha256sum *.tar.gz *.zip > checksums.txt)
./packaging/build-linux-packages.sh "${VERSION}"
(cd "${DIST_DIR}" && sha256sum *.tar.gz *.zip *.deb *.rpm > checksums.txt)
echo "==> Done."
ls -lh "${DIST_DIR}/"*.tar.gz "${DIST_DIR}/"*.zip "${DIST_DIR}/checksums.txt"
ls -lh "${DIST_DIR}/"*.tar.gz "${DIST_DIR}/"*.zip "${DIST_DIR}/"*.deb "${DIST_DIR}/"*.rpm "${DIST_DIR}/checksums.txt"