Compare commits
6 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
c3492f9a18 | |
|
|
a3d97fb1c0 | |
|
|
a67e7143fa | |
|
|
d13d0f747d | |
|
|
dd7ea6012d | |
|
|
b481bb9f3e |
|
|
@ -46,6 +46,10 @@ jobs:
|
||||||
- name: go test
|
- name: go test
|
||||||
run: go test ./... -count=1
|
run: go test ./... -count=1
|
||||||
|
|
||||||
|
- name: package migration test
|
||||||
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
run: ./packaging/scripts/test-legacy-migration.sh
|
||||||
|
|
||||||
cross-build:
|
cross-build:
|
||||||
name: cross-build
|
name: cross-build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,14 @@ jobs:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
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
|
# Cheaper than the full release-check, but still refuses to publish a
|
||||||
# broken build.
|
# broken build.
|
||||||
- name: test
|
- name: test
|
||||||
|
|
@ -81,6 +89,7 @@ jobs:
|
||||||
Verify downloads against \`checksums.txt\`.
|
Verify downloads against \`checksums.txt\`.
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
PKG_VERSION="${VERSION#v}"
|
||||||
gh release delete nightly --yes || echo "no previous nightly release"
|
gh release delete nightly --yes || echo "no previous nightly release"
|
||||||
gh release create nightly \
|
gh release create nightly \
|
||||||
--prerelease \
|
--prerelease \
|
||||||
|
|
@ -91,4 +100,8 @@ jobs:
|
||||||
"dist/sshkeeper_${VERSION}_darwin_amd64.tar.gz" \
|
"dist/sshkeeper_${VERSION}_darwin_amd64.tar.gz" \
|
||||||
"dist/sshkeeper_${VERSION}_darwin_arm64.tar.gz" \
|
"dist/sshkeeper_${VERSION}_darwin_arm64.tar.gz" \
|
||||||
"dist/sshkeeper_${VERSION}_windows_amd64.zip" \
|
"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
|
dist/checksums.txt
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,14 @@ jobs:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
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
|
# Gate the release on the same checks used locally. A red suite must not
|
||||||
# be able to publish.
|
# be able to publish.
|
||||||
- name: release checks
|
- name: release checks
|
||||||
|
|
@ -53,6 +61,7 @@ jobs:
|
||||||
set -- --generate-notes
|
set -- --generate-notes
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
PKG_VERSION="${VERSION#v}"
|
||||||
gh release create "$VERSION" \
|
gh release create "$VERSION" \
|
||||||
--title "sshkeeper $VERSION" \
|
--title "sshkeeper $VERSION" \
|
||||||
--verify-tag \
|
--verify-tag \
|
||||||
|
|
@ -62,4 +71,8 @@ jobs:
|
||||||
"dist/sshkeeper_${VERSION}_darwin_amd64.tar.gz" \
|
"dist/sshkeeper_${VERSION}_darwin_amd64.tar.gz" \
|
||||||
"dist/sshkeeper_${VERSION}_darwin_arm64.tar.gz" \
|
"dist/sshkeeper_${VERSION}_darwin_arm64.tar.gz" \
|
||||||
"dist/sshkeeper_${VERSION}_windows_amd64.zip" \
|
"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
|
dist/checksums.txt
|
||||||
|
|
|
||||||
12
Makefile
12
Makefile
|
|
@ -1,10 +1,12 @@
|
||||||
APP=sshkeeper
|
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
|
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:
|
build:
|
||||||
go build -o bin/$(APP) .
|
go build -ldflags "$(LDFLAGS)" -o bin/$(APP) .
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go run .
|
go run .
|
||||||
|
|
@ -22,13 +24,17 @@ clean:
|
||||||
rm -rf bin
|
rm -rf bin
|
||||||
|
|
||||||
install:
|
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:
|
release-check:
|
||||||
rm -rf $(RELEASE_CHECK_DIR)
|
rm -rf $(RELEASE_CHECK_DIR)
|
||||||
mkdir -p $(RELEASE_CHECK_DIR)
|
mkdir -p $(RELEASE_CHECK_DIR)
|
||||||
go test ./...
|
go test ./...
|
||||||
go vet ./...
|
go vet ./...
|
||||||
|
./packaging/scripts/test-legacy-migration.sh
|
||||||
CGO_ENABLED=0 go build -o $(RELEASE_CHECK_DIR)/$(APP) .
|
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=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 .
|
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o $(RELEASE_CHECK_DIR)/$(APP)-linux-arm64 .
|
||||||
|
|
|
||||||
54
README.md
54
README.md
|
|
@ -25,6 +25,7 @@ port forwarding management.
|
||||||
- **Routes / ProxyJump** — ordered bastion chains with stable references to sshkeeper profiles; profile renames do not break routes.
|
- **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.
|
||||||
|
|
@ -46,15 +47,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.
|
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:
|
||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Linux | Primary release target | `linux/amd64` and `linux/arm64` release tarballs are available. |
|
| 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` client. Homebrew formula planned. |
|
| 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 available as `ssh.exe` in `PATH`. Password/key-passphrase PTY flows are not validated on Windows. |
|
| 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:
|
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
|
### Install from release
|
||||||
|
|
||||||
|
Debian/Ubuntu (amd64):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -xzf sshkeeper_v0.4.0_linux_amd64.tar.gz
|
sudo apt install ./sshkeeper_0.5.1-1_amd64.deb
|
||||||
sudo install -m 0755 sshkeeper_v0.4.0_linux_amd64/sshkeeper /usr/local/bin/sshkeeper
|
```
|
||||||
|
|
||||||
|
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
|
sshkeeper
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -162,6 +191,19 @@ 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,
|
||||||
|
|
|
||||||
2
build.sh
2
build.sh
|
|
@ -8,7 +8,7 @@ APP=sshkeeper
|
||||||
# plain `git describe --tags` picks whichever tag is nearest, so a nightly build
|
# 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>.
|
# 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")
|
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}..."
|
echo "==> Building ${APP} ${VERSION}..."
|
||||||
go build -ldflags "${LDFLAGS}" -o bin/${APP} .
|
go build -ldflags "${LDFLAGS}" -o bin/${APP} .
|
||||||
|
|
|
||||||
20
cmd/root.go
20
cmd/root.go
|
|
@ -20,6 +20,7 @@ var (
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "sshkeeper",
|
Use: "sshkeeper",
|
||||||
|
Version: Version,
|
||||||
Short: "sshkeeper — SSH connection manager",
|
Short: "sshkeeper — SSH connection manager",
|
||||||
Long: `sshkeeper is a console SSH connection manager.
|
Long: `sshkeeper is a console SSH connection manager.
|
||||||
Linux and macOS are primary release targets; Windows is experimental.
|
Linux and macOS are primary release targets; Windows is experimental.
|
||||||
|
|
@ -38,7 +39,9 @@ func Execute() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
rootCmd.SetVersionTemplate("sshkeeper {{.Version}}\n")
|
||||||
cobra.OnInitialize(initApp)
|
cobra.OnInitialize(initApp)
|
||||||
|
rootCmd.AddCommand(versionCmd)
|
||||||
rootCmd.AddCommand(initCmd)
|
rootCmd.AddCommand(initCmd)
|
||||||
rootCmd.AddCommand(addCmd)
|
rootCmd.AddCommand(addCmd)
|
||||||
rootCmd.AddCommand(listCmd)
|
rootCmd.AddCommand(listCmd)
|
||||||
|
|
@ -60,9 +63,14 @@ 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() {
|
||||||
|
if commandSkipsAppInitialization(os.Args[1:]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
cfg, err = config.Load()
|
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 {
|
func commandRequiresStartupVaultUnlock(args []string) bool {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -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: "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: "config path only reads config", args: []string{"config", "path"}, want: false},
|
||||||
{name: "help", args: []string{"--help"}, 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 {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
23
cmd/tui.go
23
cmd/tui.go
|
|
@ -6,6 +6,7 @@ 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"
|
||||||
|
|
@ -193,6 +194,28 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,10 +9,11 @@
|
||||||
5. [Управление серверами](#управление-серверами)
|
5. [Управление серверами](#управление-серверами)
|
||||||
6. [Маршруты и бастионы](#маршруты-и-бастионы)
|
6. [Маршруты и бастионы](#маршруты-и-бастионы)
|
||||||
7. [Port Forwards и Tunnels](#port-forwards-и-tunnels)
|
7. [Port Forwards и Tunnels](#port-forwards-и-tunnels)
|
||||||
8. [CLI команды](#cli-команды)
|
8. [Sessions — постоянные SSH-вкладки](#sessions--постоянные-ssh-вкладки)
|
||||||
9. [Vault — хранилище секретов](#vault--хранилище-секретов)
|
9. [CLI команды](#cli-команды)
|
||||||
10. [Сценарии использования](#сценарии-использования)
|
10. [Vault — хранилище секретов](#vault--хранилище-секретов)
|
||||||
11. [Справка по клавишам](#справка-по-клавишам)
|
11. [Сценарии использования](#сценарии-использования)
|
||||||
|
12. [Справка по клавишам](#справка-по-клавишам)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -63,15 +64,15 @@ go build -o ~/.local/bin/sshkeeper .
|
||||||
./release.sh # сборка релизных архивов в dist/
|
./release.sh # сборка релизных архивов в dist/
|
||||||
```
|
```
|
||||||
|
|
||||||
**Требования:** Go 1.25+ и системный OpenSSH.
|
**Требования:** Go 1.25+ и системный OpenSSH. `tmux` необязателен, но рекомендуется для постоянных SSH-сессий; если его нет, весь интерфейс Sessions скрыт.
|
||||||
|
|
||||||
Статус платформ:
|
Статус платформ:
|
||||||
|
|
||||||
| Платформа | Статус | Примечание |
|
| Платформа | Статус | Примечание |
|
||||||
|-----------|--------|------------|
|
|-----------|--------|------------|
|
||||||
| Linux | Основная релизная платформа | Архивы `linux/amd64` и `linux/arm64`. |
|
| Linux | Основная релизная платформа | Архивы `amd64`/`arm64`, а также `.deb` и `.rpm`. Пакеты рекомендуют, но не требуют `tmux` для Sessions. |
|
||||||
| macOS | Основная релизная платформа | Архивы `darwin/amd64` и `darwin/arm64`, нужен системный `ssh`. Homebrew formula запланирована. |
|
| macOS | Основная релизная платформа | Архивы `darwin/amd64` и `darwin/arm64`, нужен системный `ssh`; `brew install tmux` включает Sessions. Homebrew formula sshkeeper запланирована. |
|
||||||
| Windows | Experimental | Нужен OpenSSH Client как `ssh.exe` в `PATH`; password/key-passphrase PTY-сценарии на Windows пока не подтверждены. |
|
| Windows | Experimental | Нужен OpenSSH Client как `ssh.exe` в `PATH`. В native Windows сборке Sessions скрыты; Linux-сборка внутри WSL может использовать `tmux`. |
|
||||||
|
|
||||||
На Windows OpenSSH Client можно установить через Windows Optional Features или PowerShell:
|
На 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
|
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Из релиза (после публикации v0.2.0)
|
### Из релиза
|
||||||
|
|
||||||
|
Для Debian/Ubuntu (amd64):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -xzf sshkeeper_v0.2.0_linux_amd64.tar.gz
|
sudo apt install ./sshkeeper_0.4.0-1_amd64.deb
|
||||||
sudo install -m 0755 sshkeeper_v0.2.0_linux_amd64/sshkeeper /usr/local/bin/sshkeeper
|
```
|
||||||
|
|
||||||
|
Для 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 команды
|
## CLI команды
|
||||||
|
|
||||||
### Серверы
|
### Серверы
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| `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,
|
`release.yml` builds through `release.sh` rather than reimplementing packaging,
|
||||||
so CI and a local run stay in step. See [Reproducibility](#reproducibility) for
|
so CI and a local run stay in step. Linux `.deb` and `.rpm` packages are built
|
||||||
what that guarantees.
|
with nFPM v2.47.0 from the exact Linux tarball binaries. See
|
||||||
|
[Reproducibility](#reproducibility) for what that guarantees.
|
||||||
|
|
||||||
### Release notes
|
### Release notes
|
||||||
|
|
||||||
|
|
@ -90,7 +91,14 @@ This runs:
|
||||||
|
|
||||||
## Build Artifacts
|
## 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
|
```bash
|
||||||
./release.sh v0.2.0
|
./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_amd64.tar.gz
|
||||||
sshkeeper_v0.2.0_darwin_arm64.tar.gz
|
sshkeeper_v0.2.0_darwin_arm64.tar.gz
|
||||||
sshkeeper_v0.2.0_windows_amd64.zip
|
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
|
checksums.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -114,6 +126,19 @@ Each archive contains:
|
||||||
- `LICENSE`
|
- `LICENSE`
|
||||||
- `docs/guide.md`
|
- `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
|
## Verify Checksums
|
||||||
|
|
||||||
From the `dist/` directory:
|
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.
|
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`
|
- 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
|
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,
|
`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
|
## 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
|
- Arch PKGBUILD / AUR
|
||||||
- rpm later
|
|
||||||
- Homebrew tap
|
- Homebrew tap
|
||||||
- Scoop manifest
|
- Scoop manifest
|
||||||
- Winget later
|
- Winget later
|
||||||
|
|
|
||||||
|
|
@ -185,11 +185,23 @@ the data matters to you.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
Debian/Ubuntu (amd64):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -xzf sshkeeper_v0.4.0_linux_amd64.tar.gz
|
sudo apt install ./sshkeeper_0.4.0-1_amd64.deb
|
||||||
sudo install -m 0755 sshkeeper_v0.4.0_linux_amd64/sshkeeper /usr/local/bin/sshkeeper
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
Verify downloads against `checksums.txt`. Linux and macOS are the primary
|
||||||
release targets. Windows remains experimental and requires OpenSSH Client
|
release targets. Windows remains experimental and requires OpenSSH Client
|
||||||
(`ssh.exe`) in `PATH`.
|
(`ssh.exe`) in `PATH`.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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`.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ 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 ---
|
||||||
|
|
@ -242,6 +243,7 @@ const (
|
||||||
screenManageMenu
|
screenManageMenu
|
||||||
screenForwardList
|
screenForwardList
|
||||||
screenForwardForm
|
screenForwardForm
|
||||||
|
screenSessionManager
|
||||||
screenTunnelManager
|
screenTunnelManager
|
||||||
screenConfirm
|
screenConfirm
|
||||||
screenFullHelp
|
screenFullHelp
|
||||||
|
|
@ -275,6 +277,7 @@ 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 ---
|
||||||
|
|
@ -300,6 +303,8 @@ 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
|
||||||
|
|
@ -362,6 +367,7 @@ 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,
|
||||||
|
|
@ -402,6 +408,11 @@ 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
|
||||||
|
|
@ -635,6 +646,18 @@ 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
|
||||||
|
|
@ -788,6 +811,8 @@ 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:
|
||||||
|
|
@ -877,7 +902,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.manageMenu = newManageMenuModel(m.width, m.height, m.sessionsAvailable)
|
||||||
m.screen = screenManageMenu
|
m.screen = screenManageMenu
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
@ -897,7 +922,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.actionMenu = newActionMenuModel(m.width, m.height, m.sessionsAvailable)
|
||||||
m.screen = screenActionMenu
|
m.screen = screenActionMenu
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
|
@ -1438,6 +1463,11 @@ 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())
|
||||||
|
|
@ -1484,6 +1514,12 @@ 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
|
||||||
|
|
@ -1582,6 +1618,10 @@ 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
|
||||||
|
|
@ -1686,6 +1726,45 @@ 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:
|
||||||
|
|
@ -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() {
|
func (m *tuiModel) confirmTunnelStop() {
|
||||||
if m.tunnelScreen == nil {
|
if m.tunnelScreen == nil {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1038,3 +1038,35 @@ 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -310,9 +310,15 @@ type actionMenuModel struct {
|
||||||
height int
|
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{
|
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."},
|
||||||
|
|
@ -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: "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) *actionMenuModel {
|
func newManageMenuModel(w, h int, availability ...bool) *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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
tunnelScreen := newTunnelScreenModel(size.width, size.height)
|
||||||
assertUnifiedScreen(t, tunnelScreen.View(), size.width, size.height)
|
assertUnifiedScreen(t, tunnelScreen.View(), size.width, size.height)
|
||||||
}
|
}
|
||||||
|
|
@ -277,6 +280,7 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -6,9 +6,10 @@ cd "$(dirname "$0")"
|
||||||
APP=sshkeeper
|
APP=sshkeeper
|
||||||
# --match 'v*' ignores the rolling `nightly` tag; see build.sh for the details.
|
# --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")}}
|
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"
|
DIST_DIR="dist"
|
||||||
SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2>/dev/null || date +%s)}
|
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 "==> Building release ${APP} ${VERSION}..."
|
||||||
echo "==> SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}"
|
echo "==> SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}"
|
||||||
|
|
@ -88,7 +89,9 @@ build_tarball darwin amd64
|
||||||
build_tarball darwin arm64
|
build_tarball darwin arm64
|
||||||
build_zip windows amd64
|
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."
|
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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue