Compare commits

..

No commits in common. "main" and "codex/tui-ux-redesign" have entirely different histories.

73 changed files with 1471 additions and 6788 deletions

View File

@ -1,75 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Formatting is platform independent, so check it once rather than twice.
- name: gofmt
if: matrix.os == 'ubuntu-latest'
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:" >&2
echo "$unformatted" >&2
exit 1
fi
- name: go vet
run: go vet ./...
- 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
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Mirrors the release targets, so a platform-specific break surfaces on
# the pull request rather than at tag time.
- name: build all release targets
run: |
set -euo pipefail
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do
goos="${target%/*}"
goarch="${target#*/}"
echo "==> ${goos}/${goarch}"
GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 \
go build -trimpath -o /tmp/sshkeeper-ci-build .
done

View File

@ -1,107 +0,0 @@
name: Nightly
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
# Two pushes in quick succession must not race for the rolling tag. Let the
# newer commit win rather than publishing a nightly built from older code.
concurrency:
group: nightly
cancel-in-progress: true
jobs:
nightly:
name: publish nightly
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
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
run: |
go vet ./...
go test ./... -count=1
# Version discovery is pinned to v* tags (see build.sh), so the rolling
# nightly tag below cannot hijack this value.
- name: resolve version
id: version
run: echo "value=$(git describe --tags --match 'v*' --always)" >> "$GITHUB_OUTPUT"
- name: build artifacts
env:
VERSION: ${{ steps.version.outputs.value }}
run: ./release.sh "$VERSION"
# Move the rolling tag before touching the release: a GitHub release must
# point at a tag, and this one always tracks the tip of main.
- name: move nightly tag
run: |
set -euo pipefail
git tag -f nightly
git push -f origin nightly
# Replace rather than update: assets are immutable once uploaded, so the
# old release has to go before the new archives can take its name.
- name: replace nightly release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.version.outputs.value }}
run: |
set -euo pipefail
# Heredoc, not an inline string: the notes are markdown and must not
# inherit this file's YAML indentation.
cat > /tmp/nightly-notes.md <<EOF
Automated build from the tip of \`main\`, rebuilt on every push.
**This is not a stable release.** It is untagged, unannounced and may be
broken. The \`Latest\` badge stays on the newest \`v*\` release, which is
what you want for normal use.
| | |
|---|---|
| Version | \`${VERSION}\` |
| Commit | ${GITHUB_SHA} |
| Built | $(date -u '+%Y-%m-%d %H:%M UTC') |
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 \
--title "sshkeeper nightly (${VERSION})" \
--notes-file /tmp/nightly-notes.md \
"dist/sshkeeper_${VERSION}_linux_amd64.tar.gz" \
"dist/sshkeeper_${VERSION}_linux_arm64.tar.gz" \
"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,78 +0,0 @@
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
name: publish ${{ github.ref_name }}
runs-on: ubuntu-latest
steps:
# Full history and tags: release.sh derives SOURCE_DATE_EPOCH from the
# tagged commit, and version discovery needs the v* tags to be present.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
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
run: make release-check
# Build through release.sh rather than reimplementing packaging here, so
# CI and a local ./release.sh produce byte-identical archives.
- name: build artifacts
env:
VERSION: ${{ github.ref_name }}
run: ./release.sh "$VERSION"
- name: publish
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ github.ref_name }}
run: |
set -euo pipefail
# A hand-written docs/releases/<tag>.md wins; otherwise fall back to
# GitHub's generated changelog.
notes="docs/releases/${VERSION}.md"
if [ -f "$notes" ]; then
echo "Using hand-written notes from $notes"
set -- --notes-file "$notes"
else
echo "No $notes, generating notes from commit history"
set -- --generate-notes
fi
PKG_VERSION="${VERSION#v}"
gh release create "$VERSION" \
--title "sshkeeper $VERSION" \
--verify-tag \
"$@" \
"dist/sshkeeper_${VERSION}_linux_amd64.tar.gz" \
"dist/sshkeeper_${VERSION}_linux_arm64.tar.gz" \
"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,12 +1,10 @@
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 packaging-test release-check
.PHONY: build run test vet fmt clean install release-check
build:
go build -ldflags "$(LDFLAGS)" -o bin/$(APP) .
go build -o bin/$(APP) .
run:
go run .
@ -24,17 +22,13 @@ clean:
rm -rf bin
install:
go build -ldflags "$(LDFLAGS)" -o $(HOME)/.local/bin/$(APP) .
packaging-test:
./packaging/scripts/test-legacy-migration.sh
go build -o $(HOME)/.local/bin/$(APP) .
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

@ -22,12 +22,11 @@ port forwarding management.
- Password and key-passphrase auth through a PTY prompt handler, without putting
secrets in command-line arguments.
- Key, SSH-agent, password, and key+passphrase auth modes.
- **Routes / ProxyJump**ordered bastion chains with stable references to sshkeeper profiles; profile renames do not break routes.
- **Routes / ProxyJump**manage bastion hosts and jump chains with human-readable display.
- **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.
- Groups, tags, command templates, search by metadata/routes/forward ports, and OpenSSH config generation.
- Import from `~/.ssh/config` and simple tab-separated export.
## Install
@ -47,15 +46,15 @@ Or use the build scripts:
./release.sh # Build release archives to dist/
```
Requirements: Go 1.25+ and system OpenSSH. `tmux` is optional and recommended for persistent multi-session tabs; without it, all Sessions UI is hidden.
Requirements: Go 1.25+ and system OpenSSH.
Platform status:
| Platform | Status | Notes |
|----------|--------|-------|
| Linux | Primary release target | `amd64`/`arm64` tarballs plus native `.deb` and `.rpm` packages. Native packages recommend (but do not require) `tmux` for persistent Sessions. |
| 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. |
| 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. |
On Windows, install OpenSSH Client via Windows Optional Features or PowerShell:
@ -67,39 +66,11 @@ Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
- Primary public repository: [github.com/mirivlad/sshkeeper](https://github.com/mirivlad/sshkeeper)
- Self-hosted mirror: `git@git.mirv.top:mirivlad/sshkeeper`
### Install from release
Debian/Ubuntu (amd64):
### Install from release (after v0.2.0 publication)
```bash
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
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
sshkeeper
```
@ -140,16 +111,11 @@ sshkeeper / Servers Vault unlocked · 1 profile
Press `?` outside text editors for a compact hotkey reference. Inside forms and
search, `?` remains normal text input.
### Full Help (Ctrl+H)
### Full Help (F1)
Press `Ctrl+H` on any screen for full documentation including routes, port
Press `F1` on any screen for full documentation including routes, port
forwarding, tunnels, and vault.
`Ctrl+H` is the BS control character (0x08). xterm and most modern emulators
send DEL (0x7F) for Backspace, so help and text editing never collide. A
terminal configured to send BS for Backspace cannot distinguish the two; switch
it to DEL (in xterm, `backarrowKey: false`).
### Screenshots
| Wide dashboard | Dashboard 80x24 | Server form 60x16 |
@ -169,11 +135,10 @@ it to DEL (in xterm, `backarrowKey: false`).
| Ctrl+E | Edit server |
| Ctrl+F | Search |
| Ctrl+W | Manage port forwards for selected server |
| Ctrl+X | Server actions (connect, tunnels, forwards, route, test, edit, delete) |
| m | Manage groups, tags, command templates, running tunnels, import/export, and vault |
| Ctrl+X | Action menu (connect, tunnels, forwards, route, test, edit, delete, import/export, vault actions) |
| Ins | Select / deselect a server |
| ? | Quick help (hotkeys) |
| Ctrl+H | Full documentation |
| F1 | Full documentation |
| Ctrl+Q / Ctrl+C | Quit |
Templates are global entities and can run on any server. Foreground template
@ -187,38 +152,12 @@ In add/edit forms:
|-----|--------|
| Tab / Down | Next field |
| Shift+Tab / Up | Previous field |
| `/` on Auth, Identity File, Route, Group, Startup Command, or Tags | Open the relevant picker/editor |
| `/` on Auth Method or Group | Pick from list |
| 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,
sshkeeper stores a stable reference to that profile ID, not the mutable alias. The
connection planner resolves the profile's real host/user/port/key and writes a
temporary OpenSSH config for the session, so a sshkeeper bastion does **not** need
a matching `Host` entry in `~/.ssh/config`.
Use `profile:<alias>` to require a profile reference and `raw:<target>` to require
a literal OpenSSH jump target. An unprefixed exact known alias is treated as a
profile; any other value remains a raw target.
In the TUI, `/` on Route opens the ordered route editor: `Enter` adds a profile,
`x`/Delete removes a hop, and `[`/`]` moves it.
### Jump host (single bastion)
```bash
@ -304,11 +243,11 @@ key-passphrase authentication so the PTY prompt handler can provide the secret.
| Action | Command | TUI | Description |
|--------|---------|-----|-------------|
| Connect | `sshkeeper connect <alias>` | `Enter` | Standard SSH session, no port forwards |
| Connect with tunnels | `sshkeeper tunnel <alias>` | Server Actions → Connect with tunnels | SSH session with all enabled forwards active |
| Start tunnels only | `sshkeeper tunnel <alias> --forward-only` | Server Actions → Start tunnels only | Foreground tunnel, no shell |
| Start tunnels in background | `sshkeeper tunnel <alias> --background` | Server Actions → Start tunnels in background | Detached tunnel process with PID tracking |
| Port forwards | `sshkeeper forward` | Server Actions → Port forwards (or `Ctrl+W`) | Add/edit/enable/delete forward rules |
| Running tunnels | `sshkeeper tunnel list/stop/stop-all` | `m` → Running tunnels | View tracked/running tunnels and stop them |
| Connect with tunnels | `sshkeeper tunnel <alias>` | Action menu → Connect with tunnels | SSH session with all enabled forwards active |
| Start tunnels only | `sshkeeper tunnel <alias> --forward-only` | Action menu → Start tunnels only | Foreground tunnel, no shell |
| Start tunnels in background | `sshkeeper tunnel <alias> --background` | Action menu → Start tunnels in background | Detached tunnel process with PID tracking |
| Manage port forwards | `sshkeeper forward` | Action menu → Manage port forwards | Add/edit/delete forward rules |
| Manage tunnels | `sshkeeper tunnel list/stop/stop-all` | Action menu → Manage tunnels | View running tunnels and stop them |
## Vault

View File

@ -4,11 +4,8 @@ set -euo pipefail
cd "$(dirname "$0")"
APP=sshkeeper
# --match 'v*' keeps the rolling `nightly` tag from hijacking the version: a
# 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 github.com/mirivlad/sshkeeper/cmd.Version=${VERSION}"
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS="-s -w -X main.version=${VERSION}"
echo "==> Building ${APP} ${VERSION}..."
go build -ldflags "${LDFLAGS}" -o bin/${APP} .

View File

@ -21,7 +21,6 @@ var addFlags struct {
authMethod string
identityFile string
proxyJump string
route string
groupName string
displayName string
notes string
@ -51,14 +50,6 @@ func addInteractive() error {
}
func addNonInteractive(alias string) error {
routeSpec := strings.TrimSpace(addFlags.route)
if routeSpec == "" {
routeSpec = strings.TrimSpace(addFlags.proxyJump)
}
route, err := parseRouteSpec(routeSpec)
if err != nil {
return fmt.Errorf("route: %w", err)
}
server := &model.Server{
Alias: alias,
DisplayName: addFlags.displayName,
@ -67,8 +58,7 @@ func addNonInteractive(alias string) error {
User: addFlags.user,
AuthMethod: model.AuthMethod(addFlags.authMethod),
IdentityFile: addFlags.identityFile,
Route: route,
ProxyJump: route.ProxyJumpString(),
ProxyJump: addFlags.proxyJump,
GroupName: addFlags.groupName,
Notes: addFlags.notes,
StartupCommand: addFlags.startup,
@ -88,30 +78,13 @@ func addNonInteractive(alias string) error {
}
func saveServerWithOptionalSecret(server *model.Server) error {
if len(server.Route.Hops) == 0 && strings.TrimSpace(server.ProxyJump) != "" {
route, err := parseRouteSpec(server.ProxyJump)
if err != nil {
return fmt.Errorf("route: %w", err)
}
server.Route = route
}
server.ProxyJump = server.Route.ProxyJumpString()
if err := model.ValidateServerBasics(server); err != nil {
return err
}
if addFlags.tags != "" {
server.Tags = strings.Split(addFlags.tags, ",")
}
var secret []byte
var v = getOrCreateVault()
needsSecret := server.AuthMethod == model.AuthPassword || server.AuthMethod == model.AuthKeyPassphrase
if needsSecret {
// Handle password/passphrase auth — request interactively, never via argv
if server.AuthMethod == model.AuthPassword || server.AuthMethod == model.AuthKeyPassphrase {
secretType := "password"
if server.AuthMethod == model.AuthKeyPassphrase {
secretType = "passphrase"
}
fmt.Printf("Enter %s (will be stored in vault, input hidden): ", secretType)
password, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
@ -121,41 +94,40 @@ func saveServerWithOptionalSecret(server *model.Server) error {
if len(password) == 0 {
return fmt.Errorf("%s cannot be empty", secretType)
}
secret = password
defer func() {
for i := range secret {
secret[i] = 0
}
}()
v := getOrCreateVault()
if err := unlockVaultForCommand(v); err != nil {
return err
}
vaultKey := fmt.Sprintf("server:%s:ssh_password", server.Alias)
vaultType := "ssh_password"
if server.AuthMethod == model.AuthKeyPassphrase {
vaultKey = fmt.Sprintf("server:%s:key_passphrase", server.Alias)
vaultType = "key_passphrase"
}
if err := v.Put(vaultKey, vaultType, password); err != nil {
return fmt.Errorf("store %s in vault: %w", secretType, err)
}
if err := v.Save(); err != nil {
return fmt.Errorf("save vault: %w", err)
}
}
if err := appDB.CreateServer(server); err != nil {
return fmt.Errorf("create server: %w", err)
}
rollbackDB := func() { _ = appDB.DeleteServer(server.Alias) }
if addFlags.tags != "" {
server.Tags = strings.Split(addFlags.tags, ",")
}
if len(server.Tags) > 0 {
if err := appDB.SetServerTags(server.ID, server.Tags); err != nil {
rollbackDB()
return fmt.Errorf("set tags: %w", err)
}
}
if needsSecret {
if err := syncServerSecrets(v, "", server, string(secret)); err != nil {
rollbackDB()
return fmt.Errorf("store secret in vault: %w", err)
}
if err := v.Save(); err != nil {
cleanupServerSecretsForServer(v, server)
rollbackDB()
return fmt.Errorf("save vault: %w", err)
}
}
fmt.Println("Saved.")
return nil
}
@ -199,7 +171,7 @@ func promptServerForAdd(in io.Reader, out io.Writer) (*model.Server, error) {
if err != nil {
return nil, err
}
proxyJump, err := promptOptional(reader, out, "Route / ProxyJump (profile:<alias> or raw:<target>)", "")
proxyJump, err := promptOptional(reader, out, "ProxyJump", "")
if err != nil {
return nil, err
}
@ -281,8 +253,7 @@ func init() {
addCmd.Flags().StringVar(&addFlags.user, "user", "", "SSH username")
addCmd.Flags().StringVar(&addFlags.authMethod, "auth", "key", "Auth method: password, key, key_passphrase, agent")
addCmd.Flags().StringVar(&addFlags.identityFile, "identity-file", "", "Path to SSH private key")
addCmd.Flags().StringVar(&addFlags.route, "route", "", "Route hops: profile:<alias>, raw:<target>, comma-separated")
addCmd.Flags().StringVar(&addFlags.proxyJump, "proxy-jump", "", "Compatibility alias for --route")
addCmd.Flags().StringVar(&addFlags.proxyJump, "proxy-jump", "", "ProxyJump host")
addCmd.Flags().StringVar(&addFlags.groupName, "group", "", "Server group")
addCmd.Flags().StringVar(&addFlags.displayName, "display-name", "", "Display name")
addCmd.Flags().StringVar(&addFlags.notes, "notes", "", "Notes")

View File

@ -19,9 +19,33 @@ var connectCmd = &cobra.Command{
if err != nil {
return fmt.Errorf("server not found: %s", alias)
}
if err := ssh.ConnectResolved(cfg, server, dbProfileResolver, serverVaultFunc(server)); err != nil {
v := getOrCreateVault()
vaultFunc := func(serverAlias string, secretType string) (string, error) {
if !v.IsUnlocked() {
return "", fmt.Errorf("%s", vaultLockedProcessMessage())
}
key := fmt.Sprintf("server:%s:%s", serverAlias, secretType)
data, err := v.Get(key)
if err != nil {
return "", err
}
return string(data), nil
}
if err := ssh.Connect(cfg, &model.Server{
Alias: server.Alias,
Host: server.Host,
Port: server.Port,
User: server.User,
AuthMethod: server.AuthMethod,
IdentityFile: server.IdentityFile,
ProxyJump: server.ProxyJump,
Route: server.Route,
}, vaultFunc); err != nil {
return err
}
appDB.UpdateLastConnected(alias)
return nil
},
@ -37,7 +61,31 @@ var testCmd = &cobra.Command{
if err != nil {
return fmt.Errorf("server not found: %s", alias)
}
ok, testErr := ssh.TestResolved(cfg, server, dbProfileResolver, serverVaultFunc(server))
v := getOrCreateVault()
vaultFunc := func(serverAlias string, secretType string) (string, error) {
if !v.IsUnlocked() {
return "", fmt.Errorf("%s", vaultLockedProcessMessage())
}
key := fmt.Sprintf("server:%s:%s", serverAlias, secretType)
data, err := v.Get(key)
if err != nil {
return "", err
}
return string(data), nil
}
ok, testErr := ssh.Test(cfg, &model.Server{
Alias: server.Alias,
Host: server.Host,
Port: server.Port,
User: server.User,
AuthMethod: server.AuthMethod,
IdentityFile: server.IdentityFile,
ProxyJump: server.ProxyJump,
Route: server.Route,
}, vaultFunc)
if ok {
fmt.Println("Connection OK.")
appDB.UpdateTestResult(alias, model.TestOK, "")
@ -45,6 +93,7 @@ var testCmd = &cobra.Command{
fmt.Printf("Connection failed:\n%s\n", testErr)
appDB.UpdateTestResult(alias, model.TestFailed, testErr)
}
return nil
},
}

View File

@ -20,138 +20,99 @@ var editCmd = &cobra.Command{
if err != nil {
return fmt.Errorf("server not found: %s", alias)
}
original := cloneServer(server)
if cmd.Flags().Changed("host") {
oldAuthMethod := server.AuthMethod
if parsedHost != "" {
server.Host = parsedHost
}
if cmd.Flags().Changed("port") {
if parsedPort < 1 || parsedPort > 65535 {
return fmt.Errorf("port must be between 1 and 65535")
}
if parsedPort != 0 {
server.Port = parsedPort
}
if cmd.Flags().Changed("user") {
if parsedUser != "" {
server.User = parsedUser
}
authChanged := cmd.Flags().Changed("auth")
if authChanged {
if parsedAuth != "" {
server.AuthMethod = model.AuthMethod(parsedAuth)
}
if cmd.Flags().Changed("identity-file") {
if parsedIdentity != "" {
server.IdentityFile = parsedIdentity
}
if cmd.Flags().Changed("group") {
if parsedProxyJump != "" {
server.ProxyJump = parsedProxyJump
}
if parsedGroup != "" {
server.GroupName = parsedGroup
}
if cmd.Flags().Changed("display-name") {
if parsedDisplayName != "" {
server.DisplayName = parsedDisplayName
}
if cmd.Flags().Changed("notes") {
if parsedNotes != "" {
server.Notes = parsedNotes
}
if cmd.Flags().Changed("startup-command") {
if parsedStartup != "" {
server.StartupCommand = parsedStartup
}
routeChanged := cmd.Flags().Changed("route") || cmd.Flags().Changed("proxy-jump")
if routeChanged {
if cmd.Flags().Changed("route") && cmd.Flags().Changed("proxy-jump") {
return fmt.Errorf("use either --route or --proxy-jump, not both")
}
spec := parsedRoute
if cmd.Flags().Changed("proxy-jump") {
spec = parsedProxyJump
}
route, err := parseRouteSpec(spec)
if err != nil {
return fmt.Errorf("route: %w", err)
}
server.Route = route
server.ProxyJump = route.ProxyJumpString()
}
tagsChanged := cmd.Flags().Changed("tags")
if tagsChanged {
if strings.TrimSpace(parsedTags) == "" {
server.Tags = nil
} else {
server.Tags = strings.Split(parsedTags, ",")
}
}
if err := model.ValidateServerBasics(server); err != nil {
return err
server.Tags = strings.Split(parsedTags, ",")
}
var secret []byte
v := getOrCreateVault()
if authChanged {
if err := unlockVaultForCommand(v); err != nil {
return err
}
switch server.AuthMethod {
case model.AuthPassword, model.AuthKeyPassphrase:
label := "password"
if server.AuthMethod == model.AuthKeyPassphrase {
label = "key passphrase"
}
fmt.Printf("Enter new %s (stored in vault, input hidden): ", label)
secret, err = term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return fmt.Errorf("read %s: %w", label, err)
}
if len(secret) == 0 {
return fmt.Errorf("%s cannot be empty", label)
}
defer func() {
for i := range secret {
secret[i] = 0
if parsedAuth != "" && oldAuthMethod != server.AuthMethod {
v := getOrCreateVault()
if v.IsUnlocked() {
var secret string
if server.AuthMethod == model.AuthPassword {
fmt.Print("Enter new password (stored in vault, input hidden): ")
pw, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return fmt.Errorf("read password: %w", err)
}
}()
if len(pw) > 0 {
secret = string(pw)
}
} else if server.AuthMethod == model.AuthKeyPassphrase {
fmt.Print("Enter key passphrase (stored in vault, input hidden): ")
pw, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return fmt.Errorf("read passphrase: %w", err)
}
if len(pw) > 0 {
secret = string(pw)
}
}
if err := syncServerSecrets(v, alias, server, secret); err != nil {
return fmt.Errorf("sync vault secrets: %w", err)
}
if err := v.Save(); err != nil {
return fmt.Errorf("save vault: %w", err)
}
}
}
if err := appDB.UpdateServerByAlias(alias, server); err != nil {
if err := appDB.UpdateServer(server); err != nil {
return fmt.Errorf("update server: %w", err)
}
rollback := func() {
_ = appDB.UpdateServerByAlias(server.Alias, original)
_ = appDB.SetServerTags(original.ID, original.Tags)
}
if tagsChanged {
if err := appDB.SetServerTags(server.ID, server.Tags); err != nil {
rollback()
return fmt.Errorf("set tags: %w", err)
}
}
if authChanged {
if err := syncServerSecrets(v, alias, server, string(secret)); err != nil {
rollback()
return fmt.Errorf("sync vault secrets: %w", err)
}
if err := v.Save(); err != nil {
rollback()
return fmt.Errorf("save vault: %w", err)
}
}
fmt.Println("Saved.")
return nil
},
}
func cloneServer(server *model.Server) *model.Server {
copyServer := *server
copyServer.Route.Hops = append([]model.RouteHop(nil), server.Route.Hops...)
copyServer.Tags = append([]string(nil), server.Tags...)
return &copyServer
}
var (
parsedHost string
parsedPort int
parsedUser string
parsedAuth string
parsedIdentity string
parsedRoute string
parsedProxyJump string
parsedGroup string
parsedDisplayName string
@ -163,14 +124,13 @@ var (
func init() {
editCmd.Flags().StringVar(&parsedHost, "host", "", "Server hostname or IP")
editCmd.Flags().IntVar(&parsedPort, "port", 0, "SSH port")
editCmd.Flags().StringVar(&parsedUser, "user", "", "SSH username; empty lets OpenSSH choose")
editCmd.Flags().StringVar(&parsedUser, "user", "", "SSH username")
editCmd.Flags().StringVar(&parsedAuth, "auth", "", "Auth method")
editCmd.Flags().StringVar(&parsedIdentity, "identity-file", "", "Path to SSH private key; empty clears it")
editCmd.Flags().StringVar(&parsedRoute, "route", "", "Route hops: profile:<alias>, raw:<target>, comma-separated; empty means direct")
editCmd.Flags().StringVar(&parsedProxyJump, "proxy-jump", "", "Compatibility alias for --route")
editCmd.Flags().StringVar(&parsedGroup, "group", "", "Server group; empty clears it")
editCmd.Flags().StringVar(&parsedDisplayName, "display-name", "", "Display name; empty clears it")
editCmd.Flags().StringVar(&parsedNotes, "notes", "", "Notes; empty clears them")
editCmd.Flags().StringVar(&parsedStartup, "startup-command", "", "Startup command; empty clears it")
editCmd.Flags().StringVar(&parsedTags, "tags", "", "Comma-separated tags; empty clears all")
editCmd.Flags().StringVar(&parsedIdentity, "identity-file", "", "Path to SSH private key")
editCmd.Flags().StringVar(&parsedProxyJump, "proxy-jump", "", "ProxyJump host")
editCmd.Flags().StringVar(&parsedGroup, "group", "", "Server group")
editCmd.Flags().StringVar(&parsedDisplayName, "display-name", "", "Display name")
editCmd.Flags().StringVar(&parsedNotes, "notes", "", "Notes")
editCmd.Flags().StringVar(&parsedStartup, "startup-command", "", "Command to run after connecting")
editCmd.Flags().StringVar(&parsedTags, "tags", "", "Comma-separated tags")
}

View File

@ -43,6 +43,7 @@ func importServersFromSSHConfig(report func(format string, args ...interface{}))
if err != nil {
return 0, fmt.Errorf("import: %w", err)
}
if len(servers) == 0 {
if report != nil {
report("No servers found in ~/.ssh/config")
@ -50,63 +51,27 @@ func importServersFromSSHConfig(report func(format string, args ...interface{}))
return 0, nil
}
// First pass creates every profile without routes. That makes ProxyJump
// aliases resolvable to stable sshkeeper IDs in the second pass, regardless
// of declaration order in ~/.ssh/config.
type pendingRoute struct {
server *model.Server
spec string
}
pending := make([]pendingRoute, 0, len(servers))
imported := 0
for _, server := range servers {
if existing, _ := appDB.GetServer(server.Alias); existing != nil {
for _, s := range servers {
existing, _ := appDB.GetServer(s.Alias)
if existing != nil {
if report != nil {
report(" skip (exists): %s", server.Alias)
report(" skip (exists): %s", s.Alias)
}
continue
}
spec := strings.TrimSpace(server.ProxyJump)
server.ProxyJump = ""
server.Route = model.Route{}
if err := appDB.CreateServer(server); err != nil {
if err := appDB.CreateServer(s); err != nil {
if report != nil {
report(" error: %s: %v", server.Alias, err)
report(" error: %s: %v", s.Alias, err)
}
continue
}
pending = append(pending, pendingRoute{server: server, spec: spec})
if report != nil {
report(" imported: %s (%s@%s:%d)", s.Alias, s.User, s.Host, s.Port)
}
imported++
}
for _, item := range pending {
if item.spec != "" {
route, err := parseRouteSpec(item.spec)
if err != nil {
if report != nil {
report(" warning: %s imported direct; route %q could not be parsed: %v", item.server.Alias, item.spec, err)
}
continue
}
item.server.Route = route
item.server.ProxyJump = route.ProxyJumpString()
if err := appDB.UpdateServer(item.server); err != nil {
item.server.Route = model.Route{}
item.server.ProxyJump = ""
if report != nil {
report(" warning: %s imported direct; route could not be saved: %v", item.server.Alias, err)
}
continue
}
}
if report != nil {
routeSuffix := ""
if len(item.server.Route.Hops) > 0 {
routeSuffix = " via " + model.FormatRouteSpec(item.server.Route)
}
report(" imported: %s (%s@%s:%d)%s", item.server.Alias, item.server.User, item.server.Host, item.server.Port, routeSuffix)
}
}
return imported, nil
}
@ -136,5 +101,18 @@ var runCmd = &cobra.Command{
}
func runCommandOnServer(server *model.Server, command string) error {
return ssh.RunCommandResolved(cfg, server, dbProfileResolver, serverVaultFunc(server), command)
return ssh.RunCommand(cfg, server, commandVaultFunc, command)
}
func commandVaultFunc(serverAlias string, secretType string) (string, error) {
v := getOrCreateVault()
if !v.IsUnlocked() {
return "", fmt.Errorf("%s", vaultLockedProcessMessage())
}
vaultKey := fmt.Sprintf("server:%s:%s", serverAlias, secretType)
data, err := v.Get(vaultKey)
if err != nil {
return "", err
}
return string(data), nil
}

View File

@ -199,7 +199,6 @@ func init() {
forwardAddCmd.Flags().String("name", "", "Forward name")
forwardAddCmd.Flags().String("description", "", "Forward description")
forwardAddCmd.Flags().String("local-addr", "127.0.0.1", "Listen address")
forwardAddCmd.Flags().Int("local-port", 0, "Listen port")
forwardAddCmd.MarkFlagRequired("local-port")
forwardAddCmd.Flags().String("remote-addr", "", "Target address")
forwardAddCmd.Flags().Int("remote-port", 0, "Target port")

View File

@ -7,22 +7,8 @@ import (
"github.com/mirivlad/sshkeeper/internal/db"
"github.com/mirivlad/sshkeeper/internal/model"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// restoreFlags returns every flag touched during the test back to its default.
// The cobra commands are package-level singletons, so parsing argv into one
// leaks state into whatever test runs next.
func restoreFlags(t *testing.T, cmd *cobra.Command) {
t.Helper()
t.Cleanup(func() {
cmd.Flags().Visit(func(f *pflag.Flag) {
_ = f.Value.Set(f.DefValue)
f.Changed = false
})
})
}
func TestForwardEditUpdatesEnabledFlag(t *testing.T) {
testDB, err := db.Open(t.TempDir())
if err != nil {
@ -124,70 +110,3 @@ func TestForwardAddStoresNameAndDescription(t *testing.T) {
t.Fatalf("unexpected forward metadata: %#v", forwards[0])
}
}
// TestForwardAddParsesItsOwnFlags drives the real forwardAddCmd flag set the
// way the CLI does, instead of handing RunE a command built by the test.
//
// Regression: RunE read --local-port and init() marked it required, but the
// flag was never registered on forwardAddCmd. Cobra silently ignores
// MarkFlagRequired for an unknown flag and GetInt returns 0 for one, so every
// real invocation died on "invalid local port 0" while the sibling tests --
// which registered the flag on a throwaway command themselves -- kept passing.
func TestForwardAddParsesItsOwnFlags(t *testing.T) {
testDB, err := db.Open(t.TempDir())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer testDB.Close()
previousDB := appDB
appDB = testDB
t.Cleanup(func() { appDB = previousDB })
server := &model.Server{Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey}
if err := appDB.CreateServer(server); err != nil {
t.Fatalf("create server: %v", err)
}
restoreFlags(t, forwardAddCmd)
if err := forwardAddCmd.Flags().Parse([]string{
"--name", "Local PostgreSQL",
"--type", "local",
"--local-port", "15432",
"--remote-addr", "db01.internal.example.com",
"--remote-port", "5432",
}); err != nil {
t.Fatalf("parse forward add flags: %v", err)
}
if err := forwardAddCmd.RunE(forwardAddCmd, []string{"web"}); err != nil {
t.Fatalf("add forward: %v", err)
}
forwards, err := appDB.GetForwards(server.ID)
if err != nil {
t.Fatalf("get forwards: %v", err)
}
if len(forwards) != 1 {
t.Fatalf("expected one forward, got %d", len(forwards))
}
got := forwards[0]
if got.LocalPort != 15432 {
t.Fatalf("local port not carried through: got %d, want 15432", got.LocalPort)
}
if got.Type != model.ForwardLocal || got.RemoteAddr != "db01.internal.example.com" || got.RemotePort != 5432 {
t.Fatalf("unexpected forward: %#v", got)
}
}
// TestForwardAddRequiresLocalPort pins the flag's registration and its required
// annotation, which is what MarkFlagRequired silently failed to attach.
func TestForwardAddRequiresLocalPort(t *testing.T) {
flag := forwardAddCmd.Flags().Lookup("local-port")
if flag == nil {
t.Fatal("forward add does not register --local-port")
}
if _, ok := flag.Annotations[cobra.BashCompOneRequiredFlag]; !ok {
t.Fatal("--local-port is registered but not marked required")
}
}

View File

@ -1,39 +0,0 @@
package cmd
import (
"os"
"path/filepath"
"sort"
"strings"
)
func listIdentityFiles() ([]string, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
sshDir := filepath.Join(home, ".ssh")
entries, err := os.ReadDir(sshDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var paths []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
lower := strings.ToLower(name)
if strings.HasSuffix(lower, ".pub") || lower == "config" || strings.HasPrefix(lower, "known_hosts") || lower == "authorized_keys" {
continue
}
if strings.HasPrefix(name, "id_") || strings.HasSuffix(lower, ".pem") || strings.HasSuffix(lower, ".key") {
paths = append(paths, filepath.Join(sshDir, name))
}
}
sort.Strings(paths)
return paths, nil
}

View File

@ -19,9 +19,8 @@ var (
)
var rootCmd = &cobra.Command{
Use: "sshkeeper",
Version: Version,
Short: "sshkeeper — SSH connection manager",
Use: "sshkeeper",
Short: "sshkeeper — SSH connection manager",
Long: `sshkeeper is a console SSH connection manager.
Linux and macOS are primary release targets; Windows is experimental.
It manages server profiles, secrets, and provides a convenient way
@ -39,9 +38,7 @@ func Execute() {
}
func init() {
rootCmd.SetVersionTemplate("sshkeeper {{.Version}}\n")
cobra.OnInitialize(initApp)
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(initCmd)
rootCmd.AddCommand(addCmd)
rootCmd.AddCommand(listCmd)
@ -63,14 +60,9 @@ 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()
@ -179,18 +171,6 @@ 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,8 +24,6 @@ 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 {
@ -36,23 +34,3 @@ 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)
}
}
}

View File

@ -8,9 +8,11 @@ import (
"github.com/spf13/cobra"
)
// --- Route commands ---
var routeCmd = &cobra.Command{
Use: "route",
Short: "Manage server routes (bastions / ProxyJump)",
Short: "Manage server routes (ProxyJump)",
}
var routeShowCmd = &cobra.Command{
@ -18,29 +20,30 @@ var routeShowCmd = &cobra.Command{
Short: "Show route for a server",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
server, err := appDB.GetServer(args[0])
alias := args[0]
server, err := appDB.GetServer(alias)
if err != nil {
return fmt.Errorf("server not found: %s", args[0])
return fmt.Errorf("server not found: %s", alias)
}
target := server.Host
if server.User != "" {
target = server.User + "@" + server.Host
}
target = fmt.Sprintf("%s:%d", target, server.Port)
if len(server.Route.Hops) == 0 {
fmt.Println("Direct connection (no route)")
return nil
}
fmt.Printf("Route: %s\n", server.Route.DisplaySummary(target))
fmt.Printf("Mode: %s\n", server.Route.RouteMode())
fmt.Printf("Spec: %s\n", model.FormatRouteSpec(server.Route))
fmt.Println("Hops:")
for index, hop := range server.Route.Hops {
if hop.Profile() {
fmt.Printf(" %d. %s (sshkeeper profile #%d)\n", index+1, hop.Alias, hop.ServerID)
} else {
fmt.Printf(" %d. %s (raw OpenSSH target)\n", index+1, hop.Raw)
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
if len(server.Route.Hops) > 0 {
fmt.Printf("Route: %s\n", server.Route.DisplaySummary(target))
fmt.Printf("Mode: %s\n", server.Route.RouteMode())
fmt.Printf("ProxyJump: %s\n", server.Route.ProxyJumpString())
if server.Route.HasProfileLinks() {
fmt.Println("Hops:")
for _, h := range server.Route.Hops {
if h.IsProfile {
fmt.Printf(" - %s (profile)\n", h.Alias)
} else {
fmt.Printf(" - %s (raw)\n", h.Raw)
}
}
}
} else if server.ProxyJump != "" {
fmt.Printf("ProxyJump: %s\n", server.ProxyJump)
} else {
fmt.Println("Direct connection (no route)")
}
return nil
},
@ -49,38 +52,47 @@ var routeShowCmd = &cobra.Command{
var routeSetCmd = &cobra.Command{
Use: "set <alias>",
Short: "Set route for a server",
Long: `Set an ordered route. Known aliases are resolved to stable sshkeeper profile IDs.
Use profile:<alias> to require a profile reference and raw:<target> to force a literal OpenSSH target.`,
Args: cobra.ExactArgs(1),
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
server, err := appDB.GetServer(args[0])
alias := args[0]
server, err := appDB.GetServer(alias)
if err != nil {
return fmt.Errorf("server not found: %s", args[0])
return fmt.Errorf("server not found: %s", alias)
}
mode, _ := cmd.Flags().GetString("mode")
jumps, _ := cmd.Flags().GetString("jumps")
mode = strings.ToLower(strings.TrimSpace(mode))
if mode == "clear" || mode == "direct" {
if mode == "clear" || jumps == "" {
server.Route = model.Route{}
server.ProxyJump = ""
} else {
if strings.TrimSpace(jumps) == "" {
return fmt.Errorf("--jumps is required unless --mode=direct/clear")
parts := strings.Split(jumps, ",")
hops := make([]model.RouteHop, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if strings.Contains(p, "@") || strings.Contains(p, ":") {
hops = append(hops, model.RouteHop{Raw: p, IsProfile: false})
} else {
hops = append(hops, model.RouteHop{Alias: p, IsProfile: true})
}
}
route, err := parseRouteSpec(jumps)
if err != nil {
return err
}
server.Route = route
server.ProxyJump = route.ProxyJumpString()
server.Route = model.Route{Hops: hops}
server.ProxyJump = server.Route.ProxyJumpString()
}
if err := appDB.UpdateServer(server); err != nil {
return fmt.Errorf("update route: %w", err)
}
if len(server.Route.Hops) == 0 {
fmt.Println("✓ Route cleared (direct connection)")
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
if len(server.Route.Hops) > 0 {
fmt.Printf("✓ Route set: %s\n", server.Route.DisplaySummary(target))
} else {
fmt.Printf("✓ Route set: %s\n", model.FormatRouteSpec(server.Route))
fmt.Println("✓ Route cleared (direct connection)")
}
return nil
},
@ -91,9 +103,10 @@ var routeClearCmd = &cobra.Command{
Short: "Clear route for a server (set direct)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
server, err := appDB.GetServer(args[0])
alias := args[0]
server, err := appDB.GetServer(alias)
if err != nil {
return fmt.Errorf("server not found: %s", args[0])
return fmt.Errorf("server not found: %s", alias)
}
server.Route = model.Route{}
server.ProxyJump = ""
@ -106,8 +119,9 @@ var routeClearCmd = &cobra.Command{
}
func init() {
routeSetCmd.Flags().String("mode", "via", "Route mode: via, chain, direct, or clear")
routeSetCmd.Flags().String("jumps", "", "Comma-separated hops; use profile:<alias> or raw:<target> for explicit type")
routeSetCmd.Flags().String("mode", "via", "Route mode: direct, via, chain, or clear")
routeSetCmd.Flags().String("jumps", "", "Comma-separated jump hosts (aliases or raw addresses)")
routeCmd.AddCommand(routeShowCmd)
routeCmd.AddCommand(routeSetCmd)
routeCmd.AddCommand(routeClearCmd)

View File

@ -1,27 +0,0 @@
package cmd
import (
"github.com/mirivlad/sshkeeper/internal/model"
"github.com/mirivlad/sshkeeper/internal/ssh"
)
func dbProfileResolver(serverID int64) (*model.Server, error) {
return appDB.GetServerByID(serverID)
}
func parseRouteSpec(spec string) (model.Route, error) {
return model.ParseRouteSpec(spec, appDB.ResolveAlias)
}
func serverVaultFunc(server *model.Server) ssh.VaultFunc {
return vaultFuncForServer(getOrCreateVault(), server)
}
func rollbackSavedServer(server, original *model.Server) {
if original != nil {
_ = appDB.UpdateServerByAlias(server.Alias, original)
_ = appDB.SetServerTags(original.ID, original.Tags)
return
}
_ = appDB.DeleteServer(server.Alias)
}

View File

@ -20,187 +20,58 @@ var serverSecretTypes = []string{
secretSudoPassword,
}
// serverSecretID is the legacy alias-based key kept for migration/tests.
func serverSecretID(alias, secretType string) string {
return fmt.Sprintf("server:%s:%s", alias, secretType)
}
func stableServerSecretID(serverID int64, secretType string) string {
return fmt.Sprintf("server-id:%d:%s", serverID, secretType)
}
func getServerSecret(v *vault.Vault, server *model.Server, secretType string) ([]byte, error) {
if server == nil {
return nil, fmt.Errorf("server is required")
}
if server.ID > 0 {
stableID := stableServerSecretID(server.ID, secretType)
if data, err := v.Get(stableID); err == nil {
return data, nil
}
}
legacyID := serverSecretID(server.Alias, secretType)
data, err := v.Get(legacyID)
if err != nil {
return nil, err
}
if server.ID > 0 {
if err := v.Put(stableServerSecretID(server.ID, secretType), secretType, data); err != nil {
return nil, err
}
v.Delete(legacyID)
if err := v.Save(); err != nil {
return nil, fmt.Errorf("save migrated vault secret: %w", err)
}
}
return data, nil
}
func hasServerSecret(v *vault.Vault, server *model.Server, secretType string) bool {
if server == nil {
return false
}
if server.ID > 0 && v.HasSecret(stableServerSecretID(server.ID, secretType)) {
return true
}
return v.HasSecret(serverSecretID(server.Alias, secretType))
}
func cleanupServerSecretsForServer(v *vault.Vault, server *model.Server, legacyAliases ...string) {
if server == nil {
return
}
aliases := append([]string{server.Alias}, legacyAliases...)
for _, secretType := range serverSecretTypes {
if server.ID > 0 {
v.Delete(stableServerSecretID(server.ID, secretType))
}
for _, alias := range aliases {
if alias != "" {
v.Delete(serverSecretID(alias, secretType))
}
}
}
}
// syncServerSecrets writes credentials only under stable identity after the DB
// save has succeeded. Existing alias keys are migrated without depending on a
// rename operation, so a failed DB rename cannot orphan credentials.
// cleanupServerSecrets keeps the legacy helper surface for CLI/tests and also
// removes stable-ID records when the server still exists.
func cleanupServerSecrets(v *vault.Vault, alias string) {
if appDB != nil {
server, _ := appDB.GetServer(alias)
if server != nil {
cleanupServerSecretsForServer(v, server)
return
}
}
for _, secretType := range serverSecretTypes {
v.Delete(serverSecretID(alias, secretType))
}
}
func syncServerSecrets(v *vault.Vault, oldAlias string, server *model.Server, secret string) error {
if server == nil {
return fmt.Errorf("server is required")
if oldAlias == "" {
oldAlias = server.Alias
}
if server.ID <= 0 {
// Compatibility path for pre-persistence callers/tests. Real saves assign
// Server.ID before this function is called. Keep old alias-based vaults
// working and complete alias renames atomically in memory.
if oldAlias != "" && oldAlias != server.Alias {
for _, secretType := range serverSecretTypes {
oldID := serverSecretID(oldAlias, secretType)
if data, err := v.Get(oldID); err == nil {
if err := v.Put(serverSecretID(server.Alias, secretType), secretType, data); err != nil {
return err
}
v.Delete(oldID)
if oldAlias != server.Alias {
for _, secretType := range serverSecretTypes {
oldID := serverSecretID(oldAlias, secretType)
data, err := v.Get(oldID)
if err == nil {
if err := v.Put(serverSecretID(server.Alias, secretType), secretType, data); err != nil {
return err
}
}
}
key := func(secretType string) string { return serverSecretID(server.Alias, secretType) }
switch server.AuthMethod {
case model.AuthPassword:
v.Delete(key(secretKeyPassphrase))
if secret != "" {
return v.Put(key(secretSSHPassword), secretSSHPassword, []byte(secret))
}
case model.AuthKeyPassphrase:
v.Delete(key(secretSSHPassword))
if secret != "" {
return v.Put(key(secretKeyPassphrase), secretKeyPassphrase, []byte(secret))
}
default:
v.Delete(key(secretSSHPassword))
v.Delete(key(secretKeyPassphrase))
}
return nil
}
aliases := []string{server.Alias}
if oldAlias != "" && oldAlias != server.Alias {
aliases = append(aliases, oldAlias)
}
for _, secretType := range serverSecretTypes {
stableID := stableServerSecretID(server.ID, secretType)
if !v.HasSecret(stableID) {
for _, alias := range aliases {
legacyID := serverSecretID(alias, secretType)
if data, err := v.Get(legacyID); err == nil {
if err := v.Put(stableID, secretType, data); err != nil {
return err
}
break
}
}
}
for _, alias := range aliases {
v.Delete(serverSecretID(alias, secretType))
v.Delete(oldID)
}
}
switch server.AuthMethod {
case model.AuthPassword:
v.Delete(stableServerSecretID(server.ID, secretKeyPassphrase))
v.Delete(serverSecretID(server.Alias, secretKeyPassphrase))
if secret != "" {
return v.Put(stableServerSecretID(server.ID, secretSSHPassword), secretSSHPassword, []byte(secret))
return v.Put(serverSecretID(server.Alias, secretSSHPassword), secretSSHPassword, []byte(secret))
}
case model.AuthKeyPassphrase:
v.Delete(stableServerSecretID(server.ID, secretSSHPassword))
v.Delete(serverSecretID(server.Alias, secretSSHPassword))
if secret != "" {
return v.Put(stableServerSecretID(server.ID, secretKeyPassphrase), secretKeyPassphrase, []byte(secret))
return v.Put(serverSecretID(server.Alias, secretKeyPassphrase), secretKeyPassphrase, []byte(secret))
}
default:
v.Delete(stableServerSecretID(server.ID, secretSSHPassword))
v.Delete(stableServerSecretID(server.ID, secretKeyPassphrase))
v.Delete(serverSecretID(server.Alias, secretSSHPassword))
v.Delete(serverSecretID(server.Alias, secretKeyPassphrase))
}
return nil
}
func deleteVaultSecrets(v *vault.Vault, alias string, secretType string) error {
var server *model.Server
if appDB != nil {
server, _ = appDB.GetServer(alias)
}
if server == nil {
if secretType != "" {
v.Delete(serverSecretID(alias, secretType))
} else {
for _, t := range serverSecretTypes {
v.Delete(serverSecretID(alias, t))
}
}
return nil
}
if secretType != "" {
v.Delete(stableServerSecretID(server.ID, secretType))
v.Delete(serverSecretID(server.Alias, secretType))
v.Delete(serverSecretID(alias, secretType))
return nil
}
cleanupServerSecretsForServer(v, server)
cleanupServerSecrets(v, alias)
return nil
}
@ -212,16 +83,3 @@ func formTestVaultFunc(getVault ssh.VaultFunc, server *model.Server, formSecret
return getVault(serverAlias, secretType)
}
}
func vaultFuncForServer(v *vault.Vault, server *model.Server) ssh.VaultFunc {
return func(_ string, secretType string) (string, error) {
if !v.IsUnlocked() {
return "", fmt.Errorf("%s", vaultLockedProcessMessage())
}
data, err := getServerSecret(v, server, secretType)
if err != nil {
return "", err
}
return string(data), nil
}
}

View File

@ -1,57 +0,0 @@
package cmd
import (
"fmt"
"syscall"
"github.com/mirivlad/sshkeeper/internal/model"
"github.com/mirivlad/sshkeeper/internal/ssh"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var sessionConnectCmd = &cobra.Command{
Use: "__session-connect <alias>",
Hidden: true,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
alias := args[0]
server, err := appDB.GetServer(alias)
if err != nil {
return fmt.Errorf("server not found: %s", alias)
}
if server.AuthMethod == model.AuthPassword || server.AuthMethod == model.AuthKeyPassphrase {
if err := unlockVaultForSession(); err != nil {
return err
}
}
if err := ssh.ConnectResolved(cfg, server, dbProfileResolver, serverVaultFunc(server)); err != nil {
return err
}
_ = appDB.UpdateLastConnected(alias)
return nil
},
}
func unlockVaultForSession() error {
v := getOrCreateVault()
if v.IsUnlocked() {
return nil
}
for attempts := 0; attempts < 3; attempts++ {
fmt.Print("Master password: ")
password, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return fmt.Errorf("read vault password: %w", err)
}
if err := v.Unlock(string(password)); err == nil {
return nil
}
remaining := 2 - attempts
if remaining > 0 {
fmt.Printf("Invalid password. %d attempts remaining.\n", remaining)
}
}
return fmt.Errorf("too many failed vault unlock attempts")
}

View File

@ -6,7 +6,6 @@ import (
tea "github.com/charmbracelet/bubbletea"
"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"
@ -18,6 +17,19 @@ func runTUI() error {
return fmt.Errorf("load servers: %w", err)
}
vaultFunc := func(sa string, st string) (string, error) {
v := getOrCreateVault()
if !v.IsUnlocked() {
return "", fmt.Errorf("vault is locked")
}
key := fmt.Sprintf("server:%s:%s", sa, st)
data, err := v.Get(key)
if err != nil {
return "", err
}
return string(data), nil
}
tui.ListServers = func() ([]*model.Server, error) {
return appDB.ListServers()
}
@ -25,88 +37,56 @@ func runTUI() error {
return appDB.SearchServers(query)
}
tui.DeleteServer = func(alias string) error {
server, err := appDB.GetServer(alias)
if err != nil {
return err
}
if err := appDB.DeleteServer(alias); err != nil {
return err
}
v := getOrCreateVault()
if v.IsUnlocked() {
cleanupServerSecretsForServer(v, server)
cleanupServerSecrets(v, alias)
if err := v.Save(); err != nil {
// The vault file is unchanged on save failure; restore the DB profile.
_ = appDB.CreateServer(server)
_ = appDB.SetServerTags(server.ID, server.Tags)
return fmt.Errorf("save vault after cleanup: %w", err)
}
}
return nil
}
tui.TestConnection = func(server *model.Server) (bool, string) {
return ssh.TestResolved(cfg, server, dbProfileResolver, serverVaultFunc(server))
return ssh.Test(cfg, server, vaultFunc)
}
tui.TestConnectionWithPassword = func(server *model.Server, password string) (bool, string) {
base := serverVaultFunc(server)
return ssh.TestResolved(cfg, server, dbProfileResolver, formTestVaultFunc(base, server, password))
return ssh.Test(cfg, server, formTestVaultFunc(vaultFunc, server, password))
}
tui.SaveServer = func(server *model.Server, password string, oldAlias string) error {
v := getOrCreateVault()
if v.IsUnlocked() {
if err := syncServerSecrets(v, oldAlias, server, password); err != nil {
return fmt.Errorf("sync vault secrets: %w", err)
}
if err := v.Save(); err != nil {
return fmt.Errorf("save vault: %w", err)
}
}
lookupAlias := server.Alias
if oldAlias != "" {
lookupAlias = oldAlias
}
existing, _ := appDB.GetServer(lookupAlias)
var original *model.Server
if existing != nil {
original = cloneServer(existing)
server.ID = existing.ID
if err := appDB.UpdateServerByAlias(existing.Alias, server); err != nil {
return err
}
} else {
if err := appDB.CreateServer(server); err != nil {
return err
}
return appDB.SetServerTags(existing.ID, server.Tags)
}
if err := appDB.SetServerTags(server.ID, server.Tags); err != nil {
if original != nil {
_ = appDB.UpdateServerByAlias(server.Alias, original)
_ = appDB.SetServerTags(original.ID, original.Tags)
} else {
_ = appDB.DeleteServer(server.Alias)
}
if err := appDB.CreateServer(server); err != nil {
return err
}
v := getOrCreateVault()
if !v.IsUnlocked() {
return nil
}
if err := syncServerSecrets(v, oldAlias, server, password); err != nil {
rollbackSavedServer(server, original)
return fmt.Errorf("sync vault secrets: %w", err)
}
if err := v.Save(); err != nil {
rollbackSavedServer(server, original)
return fmt.Errorf("save vault: %w", err)
}
return nil
return appDB.SetServerTags(server.ID, server.Tags)
}
tui.ListIdentityFiles = listIdentityFiles
tui.GetGroups = func() ([]string, error) {
return appDB.GetGroups()
}
tui.ListGroups = func() ([]*model.Group, error) {
return appDB.ListGroups()
}
tui.CreateGroup = func(name string) error {
return appDB.CreateGroup(name)
}
tui.ResolveRouteAlias = func(alias string) (int64, bool) {
return appDB.ResolveAlias(alias)
}
tui.RenameGroup = func(oldName, newName string) error {
return appDB.RenameGroup(oldName, newName)
}
@ -143,7 +123,7 @@ func runTUI() error {
if err != nil {
return "", err
}
return ssh.RunCommandOutputResolved(cfg, fresh, dbProfileResolver, serverVaultFunc(fresh), command)
return ssh.RunCommandOutput(cfg, fresh, vaultFunc, command)
}
tui.ListForwards = func(serverID int64) ([]*model.Forward, error) {
return appDB.GetForwards(serverID)
@ -177,11 +157,7 @@ func runTUI() error {
if !v.IsUnlocked() {
return false
}
server, err := appDB.GetServer(alias)
if err != nil {
return false
}
return hasServerSecret(v, server, secretType)
return v.HasSecret(serverSecretID(alias, secretType))
}
// Run TUI in a loop — if user requests connect, handle it and restart TUI
@ -194,28 +170,6 @@ 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.
@ -231,7 +185,7 @@ func runTUI() error {
fmt.Printf("Connecting to %s@%s:%d...\n", fresh.User, fresh.Host, fresh.Port)
if err := ssh.ConnectResolved(cfg, fresh, dbProfileResolver, serverVaultFunc(fresh)); err != nil {
if err := ssh.Connect(cfg, fresh, vaultFunc); err != nil {
fmt.Fprintf(os.Stderr, "Connection error: %v\n", err)
} else {
fmt.Println("Connection closed.")
@ -256,7 +210,7 @@ func runTUI() error {
continue
}
fmt.Printf("Running template %q on %s...\n", result.TemplateName, fresh.Alias)
if err := ssh.RunCommandResolved(cfg, fresh, dbProfileResolver, serverVaultFunc(fresh), result.Command); err != nil {
if err := ssh.RunCommand(cfg, fresh, vaultFunc, result.Command); err != nil {
fmt.Fprintf(os.Stderr, "Command error on %s: %v\n", fresh.Alias, err)
}
appDB.UpdateLastConnected(fresh.Alias)
@ -325,7 +279,7 @@ func runTUI() error {
servers, _ = appDB.ListServers()
continue
}
state, err := tunnelpkg.StartResolved(cfg, fresh, forwards, forwardOnly, dbProfileResolver)
state, err := tunnelpkg.Start(cfg, fresh, forwards, forwardOnly)
if err != nil {
fmt.Fprintf(os.Stderr, "Start tunnel: %v\n", err)
} else {
@ -341,7 +295,8 @@ func runTUI() error {
fmt.Printf("Starting session to %s...\n", fresh.Alias)
}
if err := ssh.ConnectWithForwardsResolved(cfg, fresh, forwards, forwardOnly, dbProfileResolver, serverVaultFunc(fresh)); err != nil {
sshArgs := ssh.BuildSSHArgs(fresh, forwards, forwardOnly)
if err := ssh.ConnectWithArgs(cfg, sshArgs, vaultFunc, fresh); err != nil {
fmt.Fprintf(os.Stderr, "Tunnel error: %v\n", err)
} else {
fmt.Println("Tunnel closed.")

View File

@ -34,7 +34,7 @@ var tunnelCmd = &cobra.Command{
if err := validateBackgroundTunnel(server, forwards); err != nil {
return err
}
state, err := tunnelpkg.StartResolved(cfg, server, forwards, true, dbProfileResolver)
state, err := tunnelpkg.Start(cfg, server, forwards, true)
if err != nil {
return err
}
@ -46,17 +46,31 @@ var tunnelCmd = &cobra.Command{
return fmt.Errorf("no forwards configured for %s", alias)
}
v := getOrCreateVault()
vaultFunc := func(serverAlias string, secretType string) (string, error) {
if !v.IsUnlocked() {
return "", fmt.Errorf("%s", vaultLockedProcessMessage())
}
key := fmt.Sprintf("server:%s:%s", serverAlias, secretType)
data, err := v.Get(key)
if err != nil {
return "", err
}
return string(data), nil
}
if len(forwards) > 0 {
fmt.Printf("Starting tunnel to %s with %d forward(s)...\n", alias, len(forwards))
} else {
fmt.Printf("Starting session to %s...\n", alias)
}
sshArgs := ssh.BuildSSHArgs(server, forwards, forwardsOnly)
if forwardsOnly {
fmt.Printf("Tunnel mode (ssh -N). Press Ctrl+C to exit.\n")
}
return ssh.ConnectWithForwardsResolved(cfg, server, forwards, forwardsOnly, dbProfileResolver, serverVaultFunc(server))
return ssh.ConnectWithArgs(cfg, sshArgs, vaultFunc, server)
},
}

View File

@ -250,16 +250,7 @@ func formatVaultSecretsList(v *vault.Vault) (string, error) {
var b strings.Builder
fmt.Fprintf(&b, "%-24s %-18s\n", "ALIAS", "TYPE")
for _, meta := range metas {
alias := meta.Alias
if alias == "" && meta.ServerID > 0 {
alias = fmt.Sprintf("#%d", meta.ServerID)
if appDB != nil {
if server, err := appDB.GetServerByID(meta.ServerID); err == nil && server != nil {
alias = server.Alias
}
}
}
fmt.Fprintf(&b, "%-24s %-18s\n", alias, meta.Type)
fmt.Fprintf(&b, "%-24s %-18s\n", meta.Alias, meta.Type)
}
return b.String(), nil
}

View File

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

View File

@ -1,32 +0,0 @@
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,11 +9,10 @@
5. [Управление серверами](#управление-серверами)
6. [Маршруты и бастионы](#маршруты-и-бастионы)
7. [Port Forwards и Tunnels](#port-forwards-и-tunnels)
8. [Sessions — постоянные SSH-вкладки](#sessions--постоянные-ssh-вкладки)
9. [CLI команды](#cli-команды)
10. [Vault — хранилище секретов](#vault--хранилище-секретов)
11. [Сценарии использования](#сценарии-использования)
12. [Справка по клавишам](#справка-по-клавишам)
8. [CLI команды](#cli-команды)
9. [Vault — хранилище секретов](#vault--хранилище-секретов)
10. [Сценарии использования](#сценарии-использования)
11. [Справка по клавишам](#справка-по-клавишам)
---
@ -64,15 +63,15 @@ go build -o ~/.local/bin/sshkeeper .
./release.sh # сборка релизных архивов в dist/
```
**Требования:** Go 1.25+ и системный OpenSSH. `tmux` необязателен, но рекомендуется для постоянных SSH-сессий; если его нет, весь интерфейс Sessions скрыт.
**Требования:** Go 1.25+ и системный OpenSSH.
Статус платформ:
| Платформа | Статус | Примечание |
|-----------|--------|------------|
| 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`. |
| 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 пока не подтверждены. |
На Windows OpenSSH Client можно установить через Windows Optional Features или PowerShell:
@ -80,26 +79,11 @@ go build -o ~/.local/bin/sshkeeper .
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
```
### Из релиза
Для Debian/Ubuntu (amd64):
### Из релиза (после публикации v0.2.0)
```bash
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
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
```
---
@ -182,12 +166,11 @@ Auth: agent Group: - Status: ?
| `Enter` | Подключиться к серверу |
| `Ctrl+A` | Добавить сервер |
| `Ctrl+E` | Редактировать сервер |
| `Ctrl+X` | Действия выбранного сервера |
| `m` | Глобальное меню Manage |
| `Ctrl+X` | Меню действий |
| `Ctrl+F` | Поиск |
| `Ins` | Выбрать/снять выбор |
| `?` | Краткая справка по клавишам |
| `Ctrl+H` | Полная справка по приложению |
| `F1` | Полная справка по приложению |
| `Ctrl+Q` | Выход |
`Ctrl+Q` работает глобально. Если активная форма содержит несохранённые
@ -196,7 +179,7 @@ Auth: agent Group: - Status: ?
### Быстрая справка по клавишам
Нажмите `?` на экране списка или менеджера. В текстовом поле символ `?`
остаётся обычным вводом. `Ctrl+H` открывает полную справку также из форм.
остаётся обычным вводом. `F1` открывает полную справку также из форм.
```
sshkeeper — Quick Help
@ -218,8 +201,7 @@ sshkeeper — Quick Help
Ctrl+A Add server
Ctrl+E Edit server
Ctrl+F Search
Ctrl+X Server actions
m Manage
Ctrl+X Action menu
Ins Select / deselect
Port forwards
@ -229,20 +211,14 @@ sshkeeper — Quick Help
Other
? This quick help
Ctrl+H Full documentation
F1 Full documentation
Esc / Enter / ? / q — close
```
### Полная справка по приложению
Нажмите `Ctrl+H` на любом экране. Это полная документация по sshkeeper:
> **Про терминалы.** `Ctrl+H` — это управляющий символ BS (0x08), а Backspace в
> xterm и большинстве современных эмуляторов шлёт DEL (0x7F), поэтому справка и
> редактирование текста не конфликтуют. Если ваш терминал настроен отправлять BS
> по Backspace, различить их невозможно: Backspace начнёт открывать справку.
> В этом случае переключите терминал на DEL (в xterm — `backarrowKey: false`).
Нажмите `F1` на любом экране. Это полная документация по sshkeeper:
```
sshkeeper — Full Help
@ -262,7 +238,7 @@ sshkeeper — Full Help
Enter Select / Confirm / Open
Esc Back / Cancel / Close
? Quick help (hotkeys)
Ctrl+H Full documentation
F1 Full documentation
Ctrl+Q Quit
Server list
@ -270,8 +246,7 @@ sshkeeper — Full Help
Ctrl+A Add server
Ctrl+E Edit server
Ctrl+F Search
Ctrl+X Server actions
m Manage
Ctrl+X Action menu
Ins Select / deselect
...
@ -306,7 +281,7 @@ Add Server
User: root
Auth Method: key
Identity File: ~/.ssh/id_ed25519
Route: profile:bastion
Route hops: bastion
Group: KP
Notes: Main mail server
Startup Command: tmux attach -t ops
@ -330,12 +305,8 @@ Add Server
|---------|----------|
| `Tab` или `↓` | Следующее поле |
| `Shift+Tab` или `↑` | Предыдущее поле |
| `/` на Auth Method | Выбрать password/key/key_passphrase/agent |
| `/` на Identity File | Выбрать приватный ключ из `~/.ssh` |
| `/` на Route | Открыть редактор цепочки бастионов |
| `/` на Group | Выбрать существующую группу |
| `/` на Startup Command | Скопировать команду из глобального шаблона |
| `/` на Tags | Multi-select существующих тегов; новые можно ввести вручную |
| `/` на Auth Method | Выбрать из списка (password/key/key_passphrase/agent) |
| `/` на Group | Выбрать из существующих групп |
| `Enter` на Test | Проверить подключение |
| `Enter` на Save | Сохранить |
| `Esc` | Назад; при изменённых данных сначала запросить подтверждение сброса |
@ -401,16 +372,9 @@ ROUTE: ⇒ bastion → dmz-gw → … → root@secure.internal:22
### Настройка маршрута
**Через TUI:**
1. Добавьте/редактируйте сервер или выберите `Ctrl+X`**Route**.
2. Нажмите `/` на поле Route — откроется список существующих профилей.
3. `Enter` добавляет выбранный профиль в ordered chain, `x`/Delete удаляет hop, `[`/`]` меняет порядок.
4. Для произвольной OpenSSH-цели оставьте escape hatch в поле: `raw:user@bastion.example.com:2222`.
Ссылка на профиль хранится по стабильному ID. Переименование alias бастиона не
ломает зависимые маршруты, а удалить используемый бастион sshkeeper не даст,
пока он присутствует в route других серверов. Во время подключения profile-hop
разрешается из БД sshkeeper; отдельная запись с тем же alias в `~/.ssh/config`
не требуется.
1. Добавьте/редактируйте сервер или выберите `Ctrl+X` → "Manage route"
2. В поле "Route hops" введите бастионы через запятую: `bastion,dmz-gw`
3. Или введите адрес напрямую: `user@bastion.example.com:2222`
**Через CLI:**
@ -443,7 +407,7 @@ sshkeeper route clear web
### Управление forwards через TUI
1. Выберите сервер на главном экране
2. Нажмите `Ctrl+X`**Port forwards**
2. Нажмите `Ctrl+X`"Manage port forwards"
3. Откроется список forwards:
```
@ -456,7 +420,7 @@ Selected
Port 127.0.0.1:15432 on this machine will be forwarded through web to 127.0.0.1:5432.
ssh -L 127.0.0.1:15432:127.0.0.1:5432
Ctrl+A: add | Ctrl+E/Enter: edit | Space: enable/disable | Ctrl+D: delete | Esc: back
Ctrl+A: add | Ctrl+E/Enter: edit | Ctrl+D: delete | Esc: back
```
Строки и пояснение выбранного forward сокращаются по экранным ячейкам, а не
@ -468,7 +432,6 @@ Selected
|---------|----------|
| `Ctrl+A` | Добавить forward |
| `Enter` или `Ctrl+E` | Редактировать выбранный |
| `Space` | Включить/выключить правило |
| `Ctrl+D` | Удалить (с подтверждением) |
| `Esc` | Назад |
@ -545,7 +508,7 @@ forward и сейчас поддерживает только `key` или `agen
### Управление туннелями
**Через TUI:**
1. Нажмите `m` → **Running tunnels**
1. Нажмите `Ctrl+X` → "Manage tunnels"
2. Список запущенных туннелей:
```
@ -567,22 +530,6 @@ sshkeeper tunnel stop-all
---
## Sessions — постоянные SSH-вкладки
Sessions — необязательный слой поверх системного `tmux`: он позволяет держать несколько SSH-подключений в одном терминальном workspace.
Если `tmux` найден в `PATH`, у сервера появляется **Open in session**, а в меню `m` появляется **Sessions**. Если `tmux` отсутствует, оба пункта полностью скрыты и обычный `Connect` работает как раньше.
Экран Sessions показывает только tmux-окна, созданные sshkeeper. `Enter` подключается к выбранной вкладке, `Ctrl+D` закрывает её после подтверждения, `Ctrl+R` обновляет список.
Вне tmux sshkeeper использует отдельную tmux-сессию `sshkeeper`. Если sshkeeper уже запущен внутри tmux, новые SSH-вкладки создаются в текущей tmux-сессии без вложенного клиента. Обычное отсоединение tmux возвращает к sshkeeper, а SSH-процессы продолжают работать.
Для `key` и `agent` отдельного разблокирования vault не требуется. При `password` или `key_passphrase` master password запрашивается внутри новой вкладки; секреты не передаются через argv, environment или временные shell-скрипты.
На Linux установите пакет `tmux` через пакетный менеджер дистрибутива. На macOS — `brew install tmux`. В `.deb`/`.rpm` `tmux` указан как рекомендация, а не обязательная зависимость. Native Windows-сборка Sessions не показывает; этот режим доступен при запуске Linux-сборки sshkeeper внутри WSL с установленным там `tmux`.
---
## CLI команды
### Серверы
@ -777,7 +724,7 @@ sshkeeper connect secure
```bash
# В TUI: выбрать несколько серверов (Ins), затем:
# Ctrl+R → выбрать шаблон
# Ctrl+X → Run template → выбрать шаблон
# Команда выполнится на всех выбранных серверах
```
@ -793,14 +740,13 @@ sshkeeper connect secure
| `Ctrl+A` | Добавить сервер |
| `Ctrl+E` | Редактировать сервер |
| `Ctrl+F` | Поиск |
| `Ctrl+X` | Действия выбранного сервера |
| `m` | Глобальное меню Manage |
| `Ctrl+X` | Меню действий |
| `Ins` | Выбрать/снять выбор |
| `?` | Краткая справка по клавишам |
| `Ctrl+H` | Полная справка по приложению |
| `F1` | Полная справка по приложению |
| `Ctrl+Q` | Выход |
### Действия сервера (Ctrl+X)
### Меню действий (Ctrl+X)
| Действие | Описание |
|----------|----------|
@ -808,21 +754,13 @@ sshkeeper connect secure
| Connect with tunnels | SSH + все активные forwards |
| Start tunnels only | Туннель без shell |
| Start tunnels in background | Фоновый туннель |
| Port forwards | Управление forwards выбранного сервера |
| Route | Ordered chain бастионов выбранного сервера |
| Manage port forwards | Управление forwards |
| Manage tunnels | Список туннелей |
| Manage route | Настройка маршрута |
| Test connection | Проверка подключения |
| Edit | Редактирование сервера |
| Delete | Удаление (с подтверждением) |
### Manage (`m`)
| Действие | Описание |
|----------|----------|
| Groups | Создание, переименование и удаление групп с количеством серверов |
| Tags | Управление тегами |
| Command templates | Глобальные шаблоны команд |
| Running tunnels | Список и остановка фоновых туннелей |
| Import SSH config | Импорт из `~/.ssh/config` |
| Import | Импорт из `~/.ssh/config` и обновление списка |
| Export | Выход в терминал и печать экспорта |
| Vault: lock | Заблокировать vault в текущем процессе |
| Vault: change password | Выход в терминал и смена master password |
@ -833,7 +771,7 @@ sshkeeper connect secure
|---------|----------|
| `Tab` / `↓` | Следующее поле |
| `Shift+Tab` / `↑` | Предыдущее поле |
| `/` | Открыть picker/editor для Auth, key, Route, Group, Startup Command или Tags |
| `/` | Выбрать из списка (Auth Method, Group) |
| `Enter` | Действие / переход |
| `Esc` | Назад / отмена |

View File

@ -1,42 +1,10 @@
# Release Packaging
Releases are published by GitHub Actions. Pushing a `v*` tag is the whole
release procedure; the rest of this document describes what that automation
runs, and how to reproduce it by hand when needed.
## Workflows
| Workflow | Trigger | Result |
|----------|---------|--------|
| `ci.yml` | push to `main`, every pull request | `gofmt`, `go vet`, `go test` on Linux and macOS, plus a cross-build of all five release targets |
| `release.yml` | push of a `v*` tag | runs `make release-check`, then `release.sh`, then publishes the GitHub release |
| `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. 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
`release.yml` looks for `docs/releases/<tag>.md`. If that file exists it becomes
the release body; otherwise GitHub generates notes from commit history. Write
the file before pushing the tag when a release deserves a real description.
### The nightly prerelease
`nightly.yml` force-moves a rolling `nightly` tag to the tip of `main` and
republishes a prerelease from it. It is marked prerelease deliberately, so
GitHub's `Latest` badge stays on the newest `v*` release.
Because that tag moves, version discovery in `build.sh` and `release.sh` is
pinned with `--match 'v*'`. Without the filter `git describe` would select
`nightly` and stamp binaries with it instead of `v<last release>-<n>-g<sha>`.
Keep the filter if you touch those scripts.
This document describes the manual release flow for sshkeeper.
## Create a Tag
Use a semantic version tag. Pushing it is what triggers `release.yml`:
Use a semantic version tag:
```bash
git status --short
@ -44,14 +12,8 @@ git tag -a v0.2.0 -m "sshkeeper v0.2.0"
git push origin v0.2.0
```
The remaining sections describe the manual equivalent, which is still the way
to test packaging locally or to recover if Actions is unavailable.
The release script uses `git describe --tags --match 'v*' --always --dirty` by
default. The `--match 'v*'` filter matters: nightly builds move a `nightly` tag
across `main`, and without the filter `git describe` would pick that tag and
stamp binaries `nightly` instead of `v<last release>-<n>-g<sha>`. You can also
pass the version explicitly:
The release script uses `git describe --tags --always --dirty` by default. You
can also pass the version explicitly:
```bash
./release.sh v0.2.0
@ -91,14 +53,7 @@ This runs:
## Build Artifacts
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:
Run:
```bash
./release.sh v0.2.0
@ -112,10 +67,6 @@ 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
```
@ -126,19 +77,6 @@ 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:
@ -149,43 +87,9 @@ sha256sum -c checksums.txt
Expected result: every archive reports `OK`.
## Reproducibility
Rebuilding the same commit with the same Go version reproduces the **binaries**
byte for byte. `release.sh` pins everything that would otherwise vary:
- `-trimpath` and `CGO_ENABLED=0` keep build paths and the host toolchain out
of the binary;
- `SOURCE_DATE_EPOCH` (the commit timestamp) sets every archive mtime;
- `tar --sort=name --owner=0 --group=0 --numeric-owner` fixes entry order and
ownership, and `gzip -n` drops the compression timestamp;
- `normalize_package` forces 755 on directories and the program and 644 on
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;
- 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,
`ru_RU.UTF-8`, UTC+08) produce identical checksums for all five archives.
The strongest check is still the binary, since it does not depend on the host's
`tar` and `gzip` at all:
```bash
tar -xzf sshkeeper_<version>_linux_amd64.tar.gz
sha256sum sshkeeper_<version>_linux_amd64/sshkeeper
```
Comparing whole-archive hashes works too, as long as both builds used the same
Go version.
## Publish in GitHub Release
`release.yml` does this automatically on tag push. To publish by hand, upload:
Upload these files to the release:
- all five platform archives
- `checksums.txt`
@ -200,10 +104,11 @@ Release notes should mention platform status:
## Packaging TODO
Native `.deb` and `.rpm` packages are part of the release pipeline. Remaining
package channels:
Prepare these package channels after the first archive-based release:
- deb package
- Arch PKGBUILD / AUR
- rpm later
- Homebrew tap
- Scoop manifest
- Winget later

View File

@ -1,140 +0,0 @@
Release automation and reproducible packaging. sshkeeper itself behaves exactly
as in v0.3.1 — no functional changes to the TUI or the CLI.
This is also the first release published by GitHub Actions rather than by hand.
## In this release
**Archives are now reproducible.** Rebuilding a tag on a different machine used
to produce different checksums even when every packaged file was byte-identical,
because three host properties leaked into the archives:
| Leak | Effect |
|------|--------|
| File modes followed the builder's umask | umask 002 packaged `664`/`775`, umask 022 packaged `644`/`755` |
| `sort` orders entries by locale | a `ru_RU.UTF-8` host emitted `docs/` before `LICENSE`, a C locale the reverse |
| zip stores DOS local time with no zone | the same commit embedded `19:06` at UTC+08 and `11:06` at UTC |
All three are pinned now. A build on `ubuntu-latest` and one on a workstation
with a different umask, locale and timezone produce identical checksums for all
five archives. The binaries were always reproducible; only the packaging varied.
**CI.** The repository previously had no automation. It now runs `gofmt`,
`go vet` and `go test` on Linux *and* macOS for every push and pull request,
plus a cross-build of all five release targets. macOS is a stated release
target that until now was only ever cross-compiled, never tested.
**Releases are automated.** Pushing a `v*` tag runs the release checks, builds
through the same `release.sh` used locally, and publishes. Nightly builds from
`main` are published as a separate `nightly` prerelease, so the `Latest` badge
always points at a real release.
---
# Everything since v0.2.0
## Breaking change: full help moved off F1
**`Ctrl+H` opens full help. `F1` no longer has any binding.** `?` still opens
contextual quick help outside text editors. This landed in v0.3.0.
`Ctrl+H` is the BS control character (0x08). xterm and most modern emulators
send DEL (0x7F) for Backspace, so help and text editing do not collide. A
terminal configured to send BS for Backspace cannot tell them apart; switch it
to DEL (in xterm, `backarrowKey: false`).
Nothing else requires action when upgrading. Vaults, server profiles and stored
port forwards are unchanged, and no migration runs.
## The TUI was rebuilt around one shell (v0.3.0)
In v0.2.0 only the server dashboard had a real layout. Other screens rendered
free-form strings or the default Bubbles list frame, so they had no shared
width budget, no borders, and footers that floated wherever the content ended.
Every full-screen state now shares one contract: a header with breadcrumb and
truthful vault status, a separator, framed content panels, and a contextual
footer anchored to the last terminal row.
- Actions, search, tag input, confirmations and both help screens render inside
the shell.
- The port forward manager and editor use framed, width-budgeted layouts. Column
widths derive from the panel's inner width, so no row consumes the terminal's
last column.
- Tag, command template, template picker/mode/results and tunnel managers use
framed lists with a `>` selection marker, so selection never depends on colour
alone.
- Server and template editors use a framed form panel with the title moved into
the breadcrumb. Required markers, validation and dirty-state confirmation are
unchanged.
**Responsive layouts.** The supported floor is `60x16`. Wide (100+ columns)
shows two panels, medium (7099) stacks them, narrow (6069) keeps a single
compact panel. Below the floor only the minimum-size message renders. Long
ASCII, Cyrillic, CJK, combining and emoji content truncates by display cells
rather than byte count.
**Safety.** Destructive actions confirm with Cancel selected first and name the
exact target and its consequence. Status and help context stay truthful to
actual vault and connection state. Form validation prevents silent loss of
edits.
## `forward add` was completely broken (fixed in v0.3.1)
`sshkeeper forward add` could never succeed in v0.2.0 or v0.3.0. The command
read `--local-port` and marked it required, but the flag was never registered,
so cobra rejected it as unknown and the value fell back to 0:
```
$ sshkeeper forward add web --type local --local-port 15432 \
--remote-addr 127.0.0.1 --remote-port 5432
unknown flag: --local-port
```
No combination of arguments worked. Both documented forms work now:
```
$ sshkeeper forward add web --name "Local PostgreSQL" --type local \
--local-port 15432 --remote-addr db01.internal.example.com --remote-port 5432
✓ Forward added [1]
$ sshkeeper forward add web --name "SOCKS proxy" --type dynamic --local-port 1080
✓ Forward added [2]
```
Omitting the flag now reports `required flag(s) "local-port" not set` instead of
a misleading port-range error. The TUI (`Ctrl+W`) was never affected.
The command's tests had constructed their own throwaway cobra command and
registered the flags by hand, so the real command's registration was never
exercised and the suite passed against a broken command. Coverage now parses
argv into the actual command. An audit of every other command found no further
flag that is read but never registered.
## Also fixed since v0.2.0
- Port forward fields accept digits correctly (`10bcc07`).
- Platform and repository status are stated accurately in the docs: Linux and
macOS are primary release targets, Windows is experimental.
## Release-by-release
| Version | Contents |
|---------|----------|
| v0.3.0 | Unified TUI shell, responsive layouts, `F1``Ctrl+H` |
| v0.3.1 | `forward add` fix |
| v0.3.2 | Reproducible packaging, CI, automated releases |
## Install
```bash
tar -xzf sshkeeper_v0.3.2_linux_amd64.tar.gz
sudo install -m 0755 sshkeeper_v0.3.2_linux_amd64/sshkeeper /usr/local/bin/sshkeeper
```
Verify downloads against `checksums.txt`. Linux and macOS are the primary
release targets; the Windows build is experimental and needs OpenSSH Client
available as `ssh.exe` on `PATH`.
To verify a build yourself, check out the tag and run `./release.sh v0.3.2`
the checksums should match this release exactly, given the same Go version.

View File

@ -1,207 +0,0 @@
# sshkeeper v0.4.0 — Model & Workflow Cleanup
v0.4.0 is a structural release. The main goal is to make sshkeeper's data model
match what the user sees: server profiles are real reusable objects, routes
reference those objects instead of mutable text, and the TUI offers pickers for
things that already exist instead of asking you to retype them.
Existing databases and vaults are migrated automatically. No manual conversion
is required.
## Routes are now real relationships
The old implementation stored both `ProxyJump` text and a second route
representation. Different commands wrote different fields, and runtime SSH used
one in preference to the other. That made it possible for `edit --proxy-jump` to
say "Saved" while the old structured route was still used.
v0.4.0 makes `Route` canonical and stores profile hops in a normalized
`server_route_hops` table with foreign keys to stable server IDs.
- Renaming a bastion no longer breaks dependent routes.
- Deleting a profile that is still used as a route hop is rejected and names
the dependent profiles.
- Self references, duplicate hops and route cycles are rejected.
- `proxy_jump` and legacy `route_hops` remain compatibility projections for old
databases/tools, not competing sources of truth.
- Existing `group_name` strings are likewise migrated to first-class `groups`
with stable IDs.
### Profile hops no longer depend on `~/.ssh/config`
A route hop that references a sshkeeper profile now resolves that profile's real
host, user, port and identity file from SQLite. sshkeeper generates a temporary
OpenSSH config for the connection and passes it with `ssh -F`.
This fixes the old accidental requirement that a sshkeeper alias such as
`bastion-prod` also had to exist as a matching `Host` in the user's OpenSSH
config.
CLI route syntax is explicit when needed:
```bash
# exact known aliases are profile references
sshkeeper route set prod --jumps bastion,dmz-gw
# force the interpretation
sshkeeper route set prod --jumps profile:bastion,raw:ops@external-gw:2222
```
An unprefixed value matching an existing sshkeeper alias becomes a profile hop;
an unknown value remains a raw OpenSSH target.
Password or key-passphrase authentication on an *intermediate* profile hop is
rejected with a clear error for now. The current PTY secret flow can safely feed
the target profile, but it cannot reliably route different vault secrets to
multiple OpenSSH prompts in a jump chain. Key/agent bastions are supported.
## Route editor in the TUI
`Ctrl+X`**Route**, or `/` while the Route field is focused, opens an ordered
route editor.
- `Enter` adds an existing server profile as a hop.
- `x` / Delete removes a hop.
- `[` / `]` moves a hop up/down in the chain.
- `/` filters the available profiles.
- `raw:<target>` remains available in the editable field for arbitrary OpenSSH
targets.
The server form no longer claims that a picker exists while still requiring a
comma-separated string.
## Server Actions vs Manage
`Ctrl+X` is now strictly about the selected server:
- Connect
- Connect with tunnels
- Start tunnels only / in background
- Port forwards
- Route
- Test connection
- Edit
- Delete
Press **`m`** for global management:
- Groups
- Tags
- Command templates
- Running tunnels
- Import SSH config
- Export
- Vault lock / password change
Groups now have their own manager with server counts, create/rename/delete
operations, and safe delete consequences.
## Context-aware server form
The form now exposes only authentication fields that matter:
| Auth | Fields |
|------|--------|
| password | Password |
| key | Identity File |
| key_passphrase | Identity File + Key passphrase |
| agent | no credential fields |
`/` opens context-specific pickers:
- Auth method → auth list
- Identity File → detected private keys in `~/.ssh`
- Route → profile/chain editor
- Group → existing groups
- Startup Command → global command templates (the command is copied, not linked)
- Tags → multi-select existing tags; new tags can still be typed manually
## CLI consistency fixes
All writers now go through the same route semantics.
- `add`, `edit`, `route set`, TUI save and SSH config import all create the same
canonical route model.
- SSH config import is two-pass: profiles are created first, then `ProxyJump`
aliases are resolved against the complete imported/existing profile set.
- `edit` uses Cobra's `Changed()` state, so `--group ''`, `--notes ''`,
`--startup-command ''`, `--identity-file ''`, `--proxy-jump ''` and
`--tags ''` can actually clear values.
- An empty server `User` no longer produces the invalid target `@host`; OpenSSH
is allowed to choose its configured/current user.
- Shared server/auth/route validation replaced duplicated partial checks.
## Vault identity follows the server, not its alias
New and migrated server secrets are stored under stable server IDs. Renaming a
profile no longer requires a risky copy/delete of secrets keyed by alias.
Legacy `server:<alias>:<type>` records remain readable and are lazily migrated
when used. `vault list` understands both legacy and stable-ID records and
resolves stable IDs back to current aliases when the database is available.
TUI server save now commits the database change before rewriting vault state;
if the vault step fails, the profile/tags are rolled back instead of leaving the
database and vault disagreeing.
## Port forward fixes
Two concrete TUI bugs are fixed:
- Editing a disabled forward no longer silently re-enables it.
- Remote-forward preview now uses the same semantic builder as validation/save,
so listen/target endpoints cannot be shown reversed.
The editor has an explicit Enabled toggle and the forward list supports `Space`
to enable/disable a rule quickly.
## Tunnel fixes
Profile routes work consistently for foreground and background tunnels.
Background tunnels keep their generated temporary SSH config for the lifetime of
the process and remove it on stop/stop-all.
Tunnel Manager now reports `running` separately from `tracked`; stale tracked
state is no longer counted as a running process.
## Cleanup
- Removed the stale per-server `CommandTemplate.ServerID` field; command
templates are global.
- Removed an unused legacy `model.Secret` type; the encrypted vault has its own
actual storage model.
- Added regression coverage for stable route references, deletion protection,
cycle detection, stable-ID vault metadata, contextual auth fields, Manage,
route/identity/tag/startup pickers, and forward semantics.
## Upgrade notes
On first start, v0.4.0 automatically creates `groups` and
`server_route_hops`, links existing group names, and converts existing route
text/JSON. The old columns are retained for compatibility.
As always, keep a copy of `~/.local/share/sshkeeper/` before a major upgrade if
the data matters to you.
## Install
Debian/Ubuntu (amd64):
```bash
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`.

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,141 +0,0 @@
# Unified sshkeeper TUI Shell Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Apply the approved dashboard-style full-screen shell to every sshkeeper TUI screen and replace F1 help with Ctrl+H.
**Architecture:** Add one pure screen-shell renderer that owns header, notification, bordered content height, inner width, and bottom footer. Refactor each child screen to supply bounded body panels and contextual help rather than free-form terminal strings; retain existing Bubble Tea state and callback boundaries.
**Tech Stack:** Go 1.25, Bubble Tea v1.3.10, Bubbles v1.0.0, Lip Gloss v1.1.0, charmbracelet/x/ansi v0.11.6, tmux/xterm/Xvfb runtime capture.
## Global Constraints
- The existing server dashboard is the visual reference.
- Supported terminal floor is exactly `60x16`.
- Breakpoints are narrow 60-69, medium 70-99, and wide 100+ columns.
- Every screen has a header, framed bounded content, and footer anchored to the bottom.
- No rendered row directly consumes the last terminal column.
- `Ctrl+H` is global full help; remove `F1` from runtime and documentation.
- Printable input ownership, destructive safety, validation, dirty state, and callback boundaries remain intact.
- No release publication or release-metadata changes.
- Commit and push after every task.
---
### Task 1: Shared shell and help binding
**Files:**
- Create: `internal/tui/shell.go`
- Create: `internal/tui/shell_test.go`
- Modify: `internal/tui/app.go`
- Modify: `internal/tui/help.go`
- Modify: `internal/tui/help_screen.go`
- Modify: `internal/tui/status_help_test.go`
**Interfaces:**
- Produces: `screenShell`, `renderScreenShell(screenShell) string`, `renderBodyPanel(width, height int, lines []string) string`, and `footerAtBottom(body, footer string, height int) string`.
- Consumes: existing `renderPanel`, display-cell helpers, root vault/notification state, and child body strings.
- [ ] Add failing tests that render representative action/help/confirm states at 120x40, 80x24, and 60x16 and assert header, border, exact height, last-row footer, one-cell right safety margin, and no `F1` text.
- [ ] Add failing root-event tests proving `tea.KeyCtrlH` opens full help from list, manager, and form; `tea.KeyBackspace` still reaches a focused text input; and closing help restores its parent.
- [ ] Run `go test ./internal/tui -run 'Test(ScreenShell|CtrlH|Backspace|NoF1)' -count=1` and confirm failures identify missing shell/binding behavior.
- [ ] Implement the shell with a spec carrying `breadcrumb`, `status`, `notification`, `body`, `footer`, `width`, and `height`; calculate body height after wrapped footer rows and render content inside a bounded panel.
- [ ] Replace the root F1 branch with `tea.KeyCtrlH`, remove F1 entries from quick/full help content, and preserve active confirmation ownership.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Commit as `feat: add unified tui screen shell` and push.
### Task 2: Actions, help, search, and confirmations
**Files:**
- Modify: `internal/tui/app.go`
- Modify: `internal/tui/help_screen.go`
- Modify: `internal/tui/layout_test.go`
- Modify: `internal/tui/status_help_test.go`
**Interfaces:**
- Consumes: `renderScreenShell` and `renderBodyPanel` from Task 1.
- Produces: shell-backed action menu, quick/full help, search, tag input, and confirmation views.
- [ ] Add failing render tests for action selection at every breakpoint, long help rows, search input, tag input, and long Unicode confirmation content; assert selection/focus markers and bottom footer.
- [ ] Run `go test ./internal/tui -run 'Test(ActionShell|HelpShell|InputShell|ConfirmationShell)' -count=1` and confirm current free-form views fail.
- [ ] Render actions as a bounded list with a wide description panel and stacked medium description; render help as a scrolling framed body; render search/tag input inside a form panel; render confirmation as a bounded dialog within the shell.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Capture action, help, and confirmation in real xterm at 120x40, 80x24, and 60x16; inspect header, border, focus, footer, and right margin.
- [ ] Commit as `feat: unify tui actions and help screens` and push.
### Task 3: Port forward manager and form
**Files:**
- Modify: `internal/tui/forward.go`
- Modify: `internal/tui/forward_test.go`
- Modify: `internal/tui/layout_test.go`
**Interfaces:**
- Consumes: shared shell and panel primitives.
- Produces: responsive framed forward table/details and framed forward editor.
- [ ] Add failing tests with long ASCII/Cyrillic/CJK/emoji names and endpoints at all three sizes. Assert every ANSI-stripped line is at most `width-1`, both table and footer stay within height, and footer occupies the final rows.
- [ ] Add table-driven tests for wide two-panel, medium stacked, and narrow compact column sets.
- [ ] Run `go test ./internal/tui -run 'Test(ForwardManagerShell|ForwardFormShell|ForwardColumns)' -count=1` and confirm overflow/frame/footer failures.
- [ ] Derive every table width from panel inner width with a one-cell safety margin; add framed table/details layouts per breakpoint and move form content into the shared framed shell.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Capture forward list and form at all three sizes in xterm, inspect for wrapping/overflow, and add a failing regression test before correcting any observed defect.
- [ ] Commit as `feat: redesign tui port forward screens` and push.
### Task 4: Tags, templates, results, and tunnels
**Files:**
- Modify: `internal/tui/app.go`
- Modify: `internal/tui/tunnel.go`
- Modify: `internal/tui/template_form.go`
- Modify: `internal/tui/layout_test.go`
**Interfaces:**
- Consumes: shared shell and bounded list/panel primitives.
- Produces: shell-backed tag manager, template manager/form/picker/mode/results, and tunnel manager.
- [ ] Add a screen-inventory render test covering normal, empty, error, and selected states for every manager at 120x40, 80x24, and 60x16.
- [ ] Run `go test ./internal/tui -run 'Test(ManagerScreenInventory|TunnelShell|TemplateShell|TagShell)' -count=1` and confirm missing frames/footer anchoring.
- [ ] Replace default Bubbles list rendering and free-form strings with bounded viewport rows inside framed panels. Keep exact selection, scroll index, and contextual actions.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Capture every manager family at 80x24 and its densest/longest state at 60x16; inspect borders, focus, footer, empty/error copy, and truncation.
- [ ] Commit as `feat: unify tui manager screens` and push.
### Task 5: Server form and complete screen matrix
**Files:**
- Modify: `internal/tui/form.go`
- Modify: `internal/tui/template_form.go`
- Modify: `internal/tui/layout_test.go`
- Modify: `internal/tui/form_validation_test.go`
**Interfaces:**
- Consumes: shared shell and existing focus-centered form viewport.
- Produces: framed server/template forms plus a complete enum-state layout matrix.
- [ ] Add failing tests proving server/template form breadcrumbs, panel borders, focused field visibility, validation visibility, action row, and bottom footer at all sizes.
- [ ] Add one exhaustive table listing every `screen` enum value with a representative model builder; assert all non-below-floor screens satisfy shell invariants.
- [ ] Run `go test ./internal/tui -run 'Test(FormShell|AllScreensUseShell)' -count=1` and confirm remaining non-shell states fail.
- [ ] Move form viewport content into the shared shell without changing navigation or save behavior; close all inventory gaps.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Commit as `feat: finish unified tui screen coverage` and push.
### Task 6: Runtime verification, documentation, and binary
**Files:**
- Modify: `README.md`
- Modify: `docs/guide.md`
- Replace after runtime verification: `docs/screenshots/screen_1.png` through `docs/screenshots/screen_5.png`
**Interfaces:**
- Consumes: completed TUI and isolated XDG audit profile.
- Produces: verified screenshots, synchronized documentation, and `bin/sshkeeper`.
- [ ] Build `/tmp/sshkeeper-unified-audit` and run it in tmux/xterm with the isolated audit profile.
- [ ] Capture dashboard, actions, forwards, forward form, tunnels, server form, tags/templates, confirmation, quick help, and full help at 120x40, 80x24, and 60x16.
- [ ] Inspect every capture for border continuity, right-column overflow, focus, notification truth, bottom footer, and correct Ctrl+H copy. For any defect, add a failing automated test before editing code.
- [ ] Verify real xterm sends Backspace as `KeyBackspace` and Ctrl+H as `KeyCtrlH`; document the terminal mapping constraint without restoring F1.
- [ ] Update README and guide only after runtime behavior is verified; refresh repository screenshots from the verified binary.
- [ ] Run `gofmt`, `git diff --check`, `go vet ./...`, `go test ./... -count=1`, and `go build ./...`.
- [ ] Commit as `docs: refresh unified tui screenshots` and push.
- [ ] Run `./build.sh`, report the absolute binary path, version, SHA-256, feature-branch HEAD, remote synchronization, and clean worktree. Do not publish a release.

View File

@ -1,138 +0,0 @@
# Unified sshkeeper TUI Shell
## Goal
Bring every sshkeeper TUI screen to the same visual and behavioral standard as
the approved server dashboard. Every screen must have a stable header, bounded
framed content, and a contextual footer anchored to the bottom of the terminal.
## User-approved direction
The existing server dashboard is the visual reference. Do not introduce a new
visual language or browser mockups. Apply its hierarchy, border treatment,
selection markers, spacing, colors, and responsive degradation to all child
screens.
`Ctrl+H` replaces `F1` as the global full-help binding. `?` remains contextual
quick help outside text editors. Remove `F1` from runtime help, footers, README,
and the user guide.
## Root cause
The dashboard owns a height-aware renderer with a header, panels, and footer.
Most child screens still render independent free-form strings or a default
Bubbles list. They therefore do not share inner-width budgeting, borders,
viewport height, or bottom-footer placement. The correction is a shared shell,
not per-screen blank-line padding.
## Shared screen shell
Every full-screen state uses this vertical contract:
1. Header: `sshkeeper / <breadcrumb>` on the left and truthful vault/context
status on the right.
2. Separator: one display-cell-bounded horizontal line.
3. Content: one or two bordered panels filling all available rows.
4. Footer: only the current screen's primary shortcuts, wrapped by display
width and anchored to the last terminal row.
The shell computes content height as terminal height minus header, separator,
notification, and wrapped-footer rows. A panel owns a one-cell border and at
least one-cell inner horizontal padding. No content row may consume the
terminal's last column directly.
Errors, success messages, pending state, and partial success appear in a
dedicated notification row below the separator. Rendering remains pure.
## Screen families
### Actions
Actions use a framed selectable list. At 100 columns and wider, a second panel
describes the selected action and its target. At 70-99 columns, the description
appears below the list when height permits. At 60-69 columns, only the framed
list remains. The footer is always at the bottom.
### Port forwards
At 100 columns and wider, forwards use a framed table and a framed selected-rule
panel. At 70-99 columns, the selected-rule panel is stacked below the table. At
60-69 columns, the table contains Name, Type, and On; details stay available
through the selected-rule panel only when vertical space permits.
All column widths are derived from the panel's inner width. The table must
leave an inner right margin, so an 80-column terminal never renders a row at 80
display cells. Long names, endpoints, explanations, and SSH arguments truncate
by display cells with an ellipsis.
The forward form uses the shared shell, a framed form panel, radio markers for
type, and an action row. Focused fields and validation remain visible.
### Managers and pickers
Tags, command templates, template picker/mode/results, and tunnel manager use a
framed list or result panel. Selection uses `>` as well as color. Empty and
error states remain inside the panel. Lists viewport around the selected item
and never rely on the default Bubbles frame or footer.
### Forms and text entry
Server, template, tag, search, and forward editors use a framed form panel.
Their title moves into the common breadcrumb. Required markers, validation,
dirty confirmation, and input ownership do not change. The focused control,
status/error, and action row remain in the panel's visible window.
### Confirmations
Confirmations use the same application header and a centered or width-bounded
framed dialog panel. Exact target, consequence, Cancel-first action row, and
footer remain visible at 60x16. Long Unicode text wraps by display cells.
### Help
`Ctrl+H` opens full help from every state except a confirmation overlay. `?`
opens contextual quick help only when a text editor does not own printable
input. `F1` has no documented or runtime binding.
Bubble Tea v1 distinguishes `KeyCtrlH` (ASCII BS) from `KeyBackspace` (DEL).
Automated and PTY checks must prove that xterm Backspace edits text while
`Ctrl+H` opens help. Terminals configured to emit BS for Backspace cannot
distinguish the two; the supported runtime check uses xterm's default DEL
Backspace mapping.
Both help screens use the shared shell and a framed, scrolling body. Their
footer stays at the bottom and states how to close/scroll.
## Responsive contract
- Supported floor: `60x16`.
- Wide: `width >= 100`, two panels where useful.
- Medium: `70 <= width < 100`, stacked panels.
- Narrow: `60 <= width < 70`, compact single panel.
- Below the floor: render only the minimum-size message.
Every screen family is tested at 120x40, 80x24, and 60x16 with long ASCII,
Cyrillic, CJK, combining, and emoji content. Assertions measure ANSI-aware
display cells and exact maximum height.
## Verification
Automated coverage must inventory every `screen` enum value and prove that its
normal representative state:
- fits terminal width and height;
- contains a top header and at least one border;
- keeps the contextual footer on the final rendered row(s);
- retains a non-color selection/focus marker where applicable;
- does not expose `F1` and does expose `Ctrl+H` help where applicable.
Runtime verification uses the freshly built binary in an isolated XDG profile.
Capture and inspect dashboard, actions, forwards, forward form, tunnel manager,
server form, template/tag managers, confirmation, and help at all three sizes.
## Delivery boundary
Commit and push each implementation stage. Finish with a fresh binary under
`bin/sshkeeper` and report its absolute path and checksum. Do not publish a
release or update release metadata until the user explicitly approves the
binary.

View File

@ -28,9 +28,6 @@ func Open(dataDir string) (*DB, error) {
if err := conn.Ping(); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
if _, err := conn.Exec("PRAGMA foreign_keys = ON"); err != nil {
return nil, fmt.Errorf("enable foreign keys: %w", err)
}
db := &DB{conn: conn}
@ -72,10 +69,6 @@ func (db *DB) ensureSchema() error {
}
}
if err := db.ensureV040Schema(); err != nil {
return err
}
// Add forwards name/description/enabled columns
for _, col := range []struct {
name string

View File

@ -3,7 +3,6 @@ package db
import (
"database/sql"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
@ -42,25 +41,60 @@ func unmarshalRoute(s string) model.Route {
// --- Server CRUD ---
type rowScanner interface {
Scan(dest ...any) error
func (db *DB) CreateServer(s *model.Server) error {
result, err := db.conn.Exec(`
INSERT INTO servers (alias, display_name, host, port, user, auth_method, identity_file, proxy_jump, route_hops, group_name, notes, startup_command)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.Alias, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod, s.IdentityFile, s.ProxyJump, marshalRoute(s.Route), s.GroupName, s.Notes, s.StartupCommand)
if err != nil {
return err
}
s.ID, _ = result.LastInsertId()
return nil
}
const serverSelectColumns = `
id, alias, display_name, host, port, user, auth_method,
identity_file, proxy_jump, route_hops, COALESCE(group_id, 0), group_name,
notes, startup_command, created_at, updated_at, last_connected_at,
last_test_at, last_test_status, last_test_error`
func (db *DB) UpdateServer(s *model.Server) error {
_, err := db.conn.Exec(`
UPDATE servers SET
display_name=?, host=?, port=?, user=?, auth_method=?,
identity_file=?, proxy_jump=?, route_hops=?, group_name=?, notes=?, startup_command=?, updated_at=CURRENT_TIMESTAMP
WHERE alias=?`,
s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod,
s.IdentityFile, s.ProxyJump, marshalRoute(s.Route), s.GroupName, s.Notes, s.StartupCommand, s.Alias)
return err
}
func scanServerBase(row rowScanner) (*model.Server, error) {
func (db *DB) UpdateServerByAlias(oldAlias string, s *model.Server) error {
_, err := db.conn.Exec(`
UPDATE servers SET
alias=?, display_name=?, host=?, port=?, user=?, auth_method=?,
identity_file=?, proxy_jump=?, route_hops=?, group_name=?, notes=?, startup_command=?, updated_at=CURRENT_TIMESTAMP
WHERE alias=?`,
s.Alias, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod,
s.IdentityFile, s.ProxyJump, marshalRoute(s.Route), s.GroupName, s.Notes, s.StartupCommand, oldAlias)
return err
}
func (db *DB) DeleteServer(alias string) error {
_, err := db.conn.Exec("DELETE FROM servers WHERE alias=?", alias)
return err
}
func (db *DB) GetServer(alias string) (*model.Server, error) {
var s model.Server
var lastConnected, lastTest sql.NullTime
var legacyRoute sql.NullString
if err := row.Scan(
var routeHops sql.NullString
err := db.conn.QueryRow(`
SELECT id, alias, display_name, host, port, user, auth_method,
identity_file, proxy_jump, route_hops, group_name, notes, startup_command,
created_at, updated_at, last_connected_at,
last_test_at, last_test_status, last_test_error
FROM servers WHERE alias=?`, alias).Scan(
&s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod,
&s.IdentityFile, &s.ProxyJump, &legacyRoute, &s.GroupID, &s.GroupName,
&s.Notes, &s.StartupCommand, &s.CreatedAt, &s.UpdatedAt, &lastConnected,
&lastTest, &s.LastTestStatus, &s.LastTestError); err != nil {
&s.IdentityFile, &s.ProxyJump, &routeHops, &s.GroupName, &s.Notes, &s.StartupCommand,
&s.CreatedAt, &s.UpdatedAt, &lastConnected,
&lastTest, &s.LastTestStatus, &s.LastTestError)
if err != nil {
return nil, err
}
if lastConnected.Valid {
@ -69,402 +103,134 @@ func scanServerBase(row rowScanner) (*model.Server, error) {
if lastTest.Valid {
s.LastTestAt = &lastTest.Time
}
if routeHops.Valid && routeHops.String != "" {
s.Route = unmarshalRoute(routeHops.String)
}
if len(s.Route.Hops) == 0 && s.ProxyJump != "" {
s.Route = unmarshalRoute(s.ProxyJump)
}
tags, err := db.GetServerTags(s.ID)
if err != nil {
return nil, err
}
s.Tags = tags
return &s, nil
}
func (db *DB) ResolveAlias(alias string) (int64, bool) {
var id int64
if err := db.conn.QueryRow(`SELECT id FROM servers WHERE alias=?`, strings.TrimSpace(alias)).Scan(&id); err != nil {
return 0, false
}
return id, true
}
func (db *DB) normalizeRoute(route model.Route) (model.Route, error) {
resolved := model.Route{Hops: make([]model.RouteHop, 0, len(route.Hops))}
for _, hop := range route.Hops {
if hop.Profile() {
var id int64
alias := strings.TrimSpace(hop.Alias)
if hop.ServerID > 0 {
id = hop.ServerID
if err := db.conn.QueryRow(`SELECT alias FROM servers WHERE id=?`, id).Scan(&alias); err != nil {
if err == sql.ErrNoRows {
return model.Route{}, fmt.Errorf("route profile #%d not found", id)
}
return model.Route{}, err
}
} else {
if alias == "" {
return model.Route{}, fmt.Errorf("route profile alias is empty")
}
if err := db.conn.QueryRow(`SELECT id FROM servers WHERE alias=?`, alias).Scan(&id); err != nil {
if err == sql.ErrNoRows {
return model.Route{}, fmt.Errorf("route profile not found: %s", alias)
}
return model.Route{}, err
}
}
resolved.Hops = append(resolved.Hops, model.RouteHop{ServerID: id, Alias: alias, IsProfile: true})
continue
}
raw := strings.TrimSpace(hop.Raw)
if raw == "" {
return model.Route{}, fmt.Errorf("raw route hop is empty")
}
resolved.Hops = append(resolved.Hops, model.RouteHop{Raw: raw})
}
return resolved, nil
}
func (db *DB) ValidateRoute(targetID int64, route model.Route) error {
resolved, err := db.normalizeRoute(route)
if err != nil {
return err
}
if err := model.ValidateRouteShape(targetID, resolved); err != nil {
return err
}
for _, hop := range resolved.Hops {
if !hop.Profile() {
continue
}
reaches, err := db.routeReaches(hop.ServerID, targetID, map[int64]bool{})
if err != nil {
return err
}
if reaches {
return fmt.Errorf("route cycle detected through %s", hop.Alias)
}
}
return nil
}
func (db *DB) routeReaches(startID, targetID int64, visiting map[int64]bool) (bool, error) {
if targetID > 0 && startID == targetID {
return true, nil
}
if visiting[startID] {
return false, fmt.Errorf("existing route cycle detected at server #%d", startID)
}
visiting[startID] = true
defer delete(visiting, startID)
route, err := db.loadRoute(startID)
if err != nil {
return false, err
}
for _, hop := range route.Hops {
if !hop.Profile() {
continue
}
reaches, err := db.routeReaches(hop.ServerID, targetID, visiting)
if err != nil || reaches {
return reaches, err
}
}
return false, nil
}
func insertRouteTx(tx *sql.Tx, targetID int64, route model.Route) error {
if _, err := tx.Exec(`DELETE FROM server_route_hops WHERE target_server_id=?`, targetID); err != nil {
return err
}
for pos, hop := range route.Hops {
if hop.Profile() {
if _, err := tx.Exec(`INSERT INTO server_route_hops(target_server_id, position, hop_server_id) VALUES(?,?,?)`, targetID, pos, hop.ServerID); err != nil {
return err
}
} else {
if _, err := tx.Exec(`INSERT INTO server_route_hops(target_server_id, position, raw_target) VALUES(?,?,?)`, targetID, pos, hop.Raw); err != nil {
return err
}
}
}
return nil
}
func (db *DB) loadRoute(targetID int64) (model.Route, error) {
func (db *DB) ListServers() ([]*model.Server, error) {
rows, err := db.conn.Query(`
SELECT h.hop_server_id, h.raw_target, COALESCE(s.alias, '')
FROM server_route_hops h
LEFT JOIN servers s ON s.id=h.hop_server_id
WHERE h.target_server_id=? ORDER BY h.position`, targetID)
if err != nil {
return model.Route{}, err
}
defer rows.Close()
route := model.Route{}
for rows.Next() {
var hopID sql.NullInt64
var raw sql.NullString
var alias string
if err := rows.Scan(&hopID, &raw, &alias); err != nil {
return model.Route{}, err
}
if hopID.Valid {
route.Hops = append(route.Hops, model.RouteHop{ServerID: hopID.Int64, Alias: alias, IsProfile: true})
} else if raw.Valid {
route.Hops = append(route.Hops, model.RouteHop{Raw: raw.String})
}
}
return route, rows.Err()
}
func (db *DB) routeDependents(serverID int64) ([]string, error) {
rows, err := db.conn.Query(`
SELECT DISTINCT s.alias FROM server_route_hops h
JOIN servers s ON s.id=h.target_server_id
WHERE h.hop_server_id=? ORDER BY s.alias`, serverID)
SELECT id, alias, display_name, host, port, user, auth_method,
identity_file, proxy_jump, route_hops, group_name, notes, startup_command,
created_at, updated_at, last_connected_at,
last_test_at, last_test_status, last_test_error
FROM servers ORDER BY alias`)
if err != nil {
return nil, err
}
defer rows.Close()
var aliases []string
for rows.Next() {
var alias string
if err := rows.Scan(&alias); err != nil {
return nil, err
}
aliases = append(aliases, alias)
}
return aliases, rows.Err()
}
func (db *DB) refreshRouteCompatibility() error {
rows, err := db.conn.Query(`SELECT id FROM servers ORDER BY id`)
if err != nil {
return err
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
ids = append(ids, id)
}
rows.Close()
for _, id := range ids {
route, err := db.loadRoute(id)
if err != nil {
return err
}
proxy, legacy := routeCompatibilityProjection(route)
if _, err := db.conn.Exec(`UPDATE servers SET proxy_jump=?, route_hops=? WHERE id=?`, proxy, legacy, id); err != nil {
return err
}
}
return nil
}
func (db *DB) CreateServer(s *model.Server) error {
resolved, err := db.normalizeRoute(s.Route)
if err != nil {
return err
}
s.Route = resolved
s.ProxyJump = s.Route.ProxyJumpString()
if err := model.ValidateServerBasics(s); err != nil {
return err
}
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
groupID, err := ensureGroupTx(tx, s.GroupName)
if err != nil {
return err
}
proxy, legacy := routeCompatibilityProjection(s.Route)
result, err := tx.Exec(`
INSERT INTO servers (alias, display_name, host, port, user, auth_method, identity_file,
proxy_jump, route_hops, group_id, group_name, notes, startup_command)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.Alias, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod, s.IdentityFile,
proxy, legacy, nullGroupID(groupID), strings.TrimSpace(s.GroupName), s.Notes, s.StartupCommand)
if err != nil {
return err
}
s.ID, err = result.LastInsertId()
if err != nil {
return err
}
s.GroupID = groupID
if err := model.ValidateRouteShape(s.ID, s.Route); err != nil {
return err
}
if err := insertRouteTx(tx, s.ID, s.Route); err != nil {
return err
}
return tx.Commit()
}
func nullGroupID(id int64) any {
if id == 0 {
return nil
}
return id
}
func (db *DB) UpdateServer(s *model.Server) error {
return db.UpdateServerByAlias(s.Alias, s)
}
func (db *DB) UpdateServerByAlias(oldAlias string, s *model.Server) error {
var id int64
if err := db.conn.QueryRow(`SELECT id FROM servers WHERE alias=?`, oldAlias).Scan(&id); err != nil {
return err
}
resolved, err := db.normalizeRoute(s.Route)
if err != nil {
return err
}
s.ID = id
s.Route = resolved
s.ProxyJump = s.Route.ProxyJumpString()
if err := model.ValidateServerBasics(s); err != nil {
return err
}
if err := db.ValidateRoute(id, s.Route); err != nil {
return err
}
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
groupID, err := ensureGroupTx(tx, s.GroupName)
if err != nil {
return err
}
proxy, legacy := routeCompatibilityProjection(s.Route)
result, err := tx.Exec(`
UPDATE servers SET alias=?, display_name=?, host=?, port=?, user=?, auth_method=?,
identity_file=?, proxy_jump=?, route_hops=?, group_id=?, group_name=?, notes=?, startup_command=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?`,
s.Alias, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod,
s.IdentityFile, proxy, legacy, nullGroupID(groupID), strings.TrimSpace(s.GroupName), s.Notes, s.StartupCommand, id)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return fmt.Errorf("server not found: %s", oldAlias)
}
if err := insertRouteTx(tx, id, s.Route); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
s.GroupID = groupID
return db.refreshRouteCompatibility()
}
func (db *DB) DeleteServer(alias string) error {
var id int64
if err := db.conn.QueryRow(`SELECT id FROM servers WHERE alias=?`, alias).Scan(&id); err != nil {
return err
}
dependents, err := db.routeDependents(id)
if err != nil {
return err
}
if len(dependents) > 0 {
return fmt.Errorf("server %q is used as a route hop by: %s", alias, strings.Join(dependents, ", "))
}
_, err = db.conn.Exec(`DELETE FROM servers WHERE id=?`, id)
return err
}
func (db *DB) loadServerByQuery(query string, arg any) (*model.Server, error) {
s, err := scanServerBase(db.conn.QueryRow(`SELECT `+serverSelectColumns+` FROM servers WHERE `+query, arg))
if err != nil {
return nil, err
}
s.Route, err = db.loadRoute(s.ID)
if err != nil {
return nil, err
}
s.ProxyJump = s.Route.ProxyJumpString()
s.Tags, err = db.GetServerTags(s.ID)
if err != nil {
return nil, err
}
return s, nil
}
func (db *DB) GetServer(alias string) (*model.Server, error) {
return db.loadServerByQuery(`alias=?`, alias)
}
func (db *DB) GetServerByID(id int64) (*model.Server, error) {
return db.loadServerByQuery(`id=?`, id)
}
func (db *DB) listServersQuery(query string, args ...any) ([]*model.Server, error) {
rows, err := db.conn.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var servers []*model.Server
for rows.Next() {
s, err := scanServerBase(rows)
var s model.Server
var lastConnected, lastTest sql.NullTime
var routeHops sql.NullString
err := rows.Scan(
&s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod,
&s.IdentityFile, &s.ProxyJump, &routeHops, &s.GroupName, &s.Notes, &s.StartupCommand,
&s.CreatedAt, &s.UpdatedAt, &lastConnected,
&lastTest, &s.LastTestStatus, &s.LastTestError)
if err != nil {
return nil, err
}
servers = append(servers, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
for _, s := range servers {
s.Route, err = db.loadRoute(s.ID)
if err != nil {
return nil, err
}
s.ProxyJump = s.Route.ProxyJumpString()
s.Tags, err = db.GetServerTags(s.ID)
if lastConnected.Valid {
s.LastConnectedAt = &lastConnected.Time
}
if lastTest.Valid {
s.LastTestAt = &lastTest.Time
}
if routeHops.Valid && routeHops.String != "" {
s.Route = unmarshalRoute(routeHops.String)
}
if len(s.Route.Hops) == 0 && s.ProxyJump != "" {
s.Route = unmarshalRoute(s.ProxyJump)
}
tags, err := db.GetServerTags(s.ID)
if err != nil {
return nil, err
}
s.Tags = tags
servers = append(servers, &s)
}
return servers, nil
}
func (db *DB) ListServers() ([]*model.Server, error) {
return db.listServersQuery(`SELECT ` + serverSelectColumns + ` FROM servers ORDER BY alias`)
return servers, rows.Err()
}
func (db *DB) SearchServers(query string) ([]*model.Server, error) {
pattern := "%" + query + "%"
return db.listServersQuery(`
SELECT `+serverSelectColumns+` FROM servers
rows, err := db.conn.Query(`
SELECT id, alias, display_name, host, port, user, auth_method,
identity_file, proxy_jump, route_hops, group_name, notes, startup_command,
created_at, updated_at, last_connected_at,
last_test_at, last_test_status, last_test_error
FROM servers
WHERE alias LIKE ? OR display_name LIKE ? OR host LIKE ? OR user LIKE ?
OR group_name LIKE ? OR notes LIKE ? OR proxy_jump LIKE ? OR route_hops LIKE ?
OR EXISTS (
SELECT 1 FROM server_route_hops rh
LEFT JOIN servers hs ON hs.id=rh.hop_server_id
WHERE rh.target_server_id=servers.id
AND (hs.alias LIKE ? OR rh.raw_target LIKE ?)
)
OR EXISTS (
SELECT 1 FROM server_tags st JOIN tags t ON t.id=st.tag_id
WHERE st.server_id=servers.id AND t.name LIKE ?
)
OR EXISTS (
SELECT 1 FROM forwards f WHERE f.server_id=servers.id
AND (f.name LIKE ? OR f.description LIKE ? OR f.local_addr LIKE ? OR f.remote_addr LIKE ?
OR CAST(f.local_port AS TEXT) LIKE ? OR CAST(f.remote_port AS TEXT) LIKE ?)
)
OR group_name LIKE ? OR notes LIKE ? OR proxy_jump LIKE ? OR route_hops LIKE ?
OR EXISTS (
SELECT 1 FROM server_tags st
JOIN tags t ON t.id = st.tag_id
WHERE st.server_id = servers.id AND t.name LIKE ?
)
OR EXISTS (
SELECT 1 FROM forwards f
WHERE f.server_id = servers.id
AND (
f.name LIKE ? OR f.description LIKE ?
OR f.local_addr LIKE ? OR f.remote_addr LIKE ?
OR CAST(f.local_port AS TEXT) LIKE ?
OR CAST(f.remote_port AS TEXT) LIKE ?
)
)
ORDER BY alias`,
pattern, pattern, pattern, pattern, pattern, pattern, pattern, pattern,
pattern, pattern, pattern,
pattern,
pattern, pattern, pattern, pattern, pattern, pattern)
if err != nil {
return nil, err
}
defer rows.Close()
var servers []*model.Server
for rows.Next() {
var s model.Server
var lastConnected, lastTest sql.NullTime
var routeHops sql.NullString
err := rows.Scan(
&s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod,
&s.IdentityFile, &s.ProxyJump, &routeHops, &s.GroupName, &s.Notes, &s.StartupCommand,
&s.CreatedAt, &s.UpdatedAt, &lastConnected,
&lastTest, &s.LastTestStatus, &s.LastTestError)
if err != nil {
return nil, err
}
if lastConnected.Valid {
s.LastConnectedAt = &lastConnected.Time
}
if lastTest.Valid {
s.LastTestAt = &lastTest.Time
}
if routeHops.Valid && routeHops.String != "" {
s.Route = unmarshalRoute(routeHops.String)
}
if len(s.Route.Hops) == 0 && s.ProxyJump != "" {
s.Route = unmarshalRoute(s.ProxyJump)
}
tags, err := db.GetServerTags(s.ID)
if err != nil {
return nil, err
}
s.Tags = tags
servers = append(servers, &s)
}
return servers, rows.Err()
}
func (db *DB) UpdateTestResult(alias string, status model.TestStatus, testErr string) error {
@ -708,93 +474,38 @@ func uniqueCleanStrings(values []string) []string {
// --- Group methods ---
func (db *DB) CreateGroup(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("group name is required")
}
_, err := db.conn.Exec(`INSERT INTO groups(name) VALUES(?)`, name)
return err
}
func (db *DB) ListGroups() ([]*model.Group, error) {
func (db *DB) GetGroups() ([]string, error) {
rows, err := db.conn.Query(`
SELECT g.id, g.name, count(s.id)
FROM groups g LEFT JOIN servers s ON s.group_id=g.id
GROUP BY g.id, g.name ORDER BY g.name`)
SELECT group_name FROM servers
WHERE group_name != ''
GROUP BY group_name
ORDER BY group_name`)
if err != nil {
return nil, err
}
defer rows.Close()
var groups []*model.Group
var groups []string
for rows.Next() {
var group model.Group
if err := rows.Scan(&group.ID, &group.Name, &group.ServerCount); err != nil {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
groups = append(groups, &group)
groups = append(groups, name)
}
return groups, rows.Err()
}
func (db *DB) GetGroups() ([]string, error) {
groups, err := db.ListGroups()
if err != nil {
return nil, err
}
names := make([]string, len(groups))
for i, group := range groups {
names[i] = group.Name
}
return names, nil
}
func (db *DB) RenameGroup(oldName, newName string) error {
oldName = strings.TrimSpace(oldName)
newName = strings.TrimSpace(newName)
if oldName == "" || newName == "" {
return fmt.Errorf("group name is required")
}
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var id int64
if err := tx.QueryRow(`SELECT id FROM groups WHERE name=?`, oldName).Scan(&id); err != nil {
return err
}
if _, err := tx.Exec(`UPDATE groups SET name=? WHERE id=?`, newName, id); err != nil {
return err
}
if _, err := tx.Exec(`UPDATE servers SET group_name=?, updated_at=CURRENT_TIMESTAMP WHERE group_id=?`, newName, id); err != nil {
return err
}
return tx.Commit()
_, err := db.conn.Exec(
"UPDATE servers SET group_name = ?, updated_at = CURRENT_TIMESTAMP WHERE group_name = ?",
newName, oldName)
return err
}
func (db *DB) DeleteGroup(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return nil
}
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var id int64
if err := tx.QueryRow(`SELECT id FROM groups WHERE name=?`, name).Scan(&id); err != nil {
if err == sql.ErrNoRows {
return nil
}
return err
}
if _, err := tx.Exec(`UPDATE servers SET group_id=NULL, group_name='', updated_at=CURRENT_TIMESTAMP WHERE group_id=?`, id); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM groups WHERE id=?`, id); err != nil {
return err
}
return tx.Commit()
_, err := db.conn.Exec(
"UPDATE servers SET group_name = '', updated_at = CURRENT_TIMESTAMP WHERE group_name = ?",
name)
return err
}

View File

@ -1,7 +1,6 @@
package db
import (
"strings"
"testing"
"github.com/mirivlad/sshkeeper/internal/model"
@ -241,10 +240,6 @@ func TestSearchServersMatchesTagsRoutesAndForwardPorts(t *testing.T) {
}
defer db.Close()
bastion := &model.Server{Alias: "bastion", Host: "bastion.internal", Port: 22, User: "root", AuthMethod: model.AuthKey}
if err := db.CreateServer(bastion); err != nil {
t.Fatalf("create bastion: %v", err)
}
server := &model.Server{
Alias: "db",
Host: "db.internal",
@ -252,8 +247,8 @@ func TestSearchServersMatchesTagsRoutesAndForwardPorts(t *testing.T) {
User: "postgres",
AuthMethod: model.AuthKey,
Route: model.Route{Hops: []model.RouteHop{
{ServerID: bastion.ID, Alias: "bastion", IsProfile: true},
{Raw: "dmz.example.org"},
{Alias: "bastion", IsProfile: true},
{Raw: "dmz.example.org", IsProfile: false},
}},
}
if err := db.CreateServer(server); err != nil {
@ -287,73 +282,3 @@ func TestSearchServersMatchesTagsRoutesAndForwardPorts(t *testing.T) {
})
}
}
func TestRouteProfileReferenceSurvivesAliasRename(t *testing.T) {
db, err := Open(t.TempDir())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
bastion := &model.Server{Alias: "bastion", Host: "gw.example", Port: 22, User: "root", AuthMethod: model.AuthKey}
if err := db.CreateServer(bastion); err != nil {
t.Fatalf("create bastion: %v", err)
}
target := &model.Server{Alias: "prod", Host: "10.0.0.10", Port: 22, User: "ops", AuthMethod: model.AuthKey, Route: model.Route{Hops: []model.RouteHop{{ServerID: bastion.ID, Alias: bastion.Alias, IsProfile: true}}}}
if err := db.CreateServer(target); err != nil {
t.Fatalf("create target: %v", err)
}
bastion.Alias = "edge-gw"
if err := db.UpdateServerByAlias("bastion", bastion); err != nil {
t.Fatalf("rename bastion: %v", err)
}
got, err := db.GetServer("prod")
if err != nil {
t.Fatalf("load target: %v", err)
}
if len(got.Route.Hops) != 1 || got.Route.Hops[0].ServerID != bastion.ID || got.Route.Hops[0].Alias != "edge-gw" {
t.Fatalf("route did not follow renamed profile: %#v", got.Route.Hops)
}
}
func TestDeleteServerRejectsReferencedRouteProfile(t *testing.T) {
db, err := Open(t.TempDir())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
bastion := &model.Server{Alias: "bastion", Host: "gw.example", Port: 22, AuthMethod: model.AuthKey}
if err := db.CreateServer(bastion); err != nil {
t.Fatalf("create bastion: %v", err)
}
target := &model.Server{Alias: "prod", Host: "10.0.0.10", Port: 22, AuthMethod: model.AuthKey, Route: model.Route{Hops: []model.RouteHop{{ServerID: bastion.ID, Alias: bastion.Alias, IsProfile: true}}}}
if err := db.CreateServer(target); err != nil {
t.Fatalf("create target: %v", err)
}
if err := db.DeleteServer("bastion"); err == nil || !strings.Contains(err.Error(), "prod") {
t.Fatalf("expected dependent-route delete error, got %v", err)
}
}
func TestRouteCycleIsRejected(t *testing.T) {
db, err := Open(t.TempDir())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
a := &model.Server{Alias: "a", Host: "a.example", Port: 22, AuthMethod: model.AuthKey}
b := &model.Server{Alias: "b", Host: "b.example", Port: 22, AuthMethod: model.AuthKey}
if err := db.CreateServer(a); err != nil {
t.Fatalf("create a: %v", err)
}
if err := db.CreateServer(b); err != nil {
t.Fatalf("create b: %v", err)
}
a.Route = model.Route{Hops: []model.RouteHop{{ServerID: b.ID, Alias: b.Alias, IsProfile: true}}}
if err := db.UpdateServer(a); err != nil {
t.Fatalf("set a route: %v", err)
}
b.Route = model.Route{Hops: []model.RouteHop{{ServerID: a.ID, Alias: a.Alias, IsProfile: true}}}
if err := db.UpdateServer(b); err == nil || !strings.Contains(strings.ToLower(err.Error()), "cycle") {
t.Fatalf("expected cycle error, got %v", err)
}
}

View File

@ -1,159 +0,0 @@
package db
import (
"database/sql"
"fmt"
"strings"
"github.com/mirivlad/sshkeeper/internal/model"
)
func (db *DB) ensureV040Schema() error {
if _, err := db.conn.Exec(`
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
)`); err != nil {
return fmt.Errorf("create groups: %w", err)
}
hasGroupID, err := db.hasColumn("servers", "group_id")
if err != nil {
return err
}
if !hasGroupID {
if _, err := db.conn.Exec("ALTER TABLE servers ADD COLUMN group_id INTEGER"); err != nil {
return fmt.Errorf("add servers.group_id: %w", err)
}
}
if _, err := db.conn.Exec(`
CREATE TABLE IF NOT EXISTS server_route_hops (
target_server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
hop_server_id INTEGER REFERENCES servers(id) ON DELETE RESTRICT,
raw_target TEXT,
PRIMARY KEY (target_server_id, position),
CHECK ((hop_server_id IS NOT NULL AND raw_target IS NULL) OR
(hop_server_id IS NULL AND raw_target IS NOT NULL AND length(trim(raw_target)) > 0))
)`); err != nil {
return fmt.Errorf("create server_route_hops: %w", err)
}
if _, err := db.conn.Exec(`CREATE INDEX IF NOT EXISTS idx_route_hop_profile ON server_route_hops(hop_server_id)`); err != nil {
return fmt.Errorf("index server_route_hops: %w", err)
}
if err := db.migrateLegacyGroups(); err != nil {
return err
}
if err := db.migrateLegacyRoutes(); err != nil {
return err
}
return nil
}
func (db *DB) migrateLegacyGroups() error {
if _, err := db.conn.Exec(`
INSERT OR IGNORE INTO groups(name)
SELECT DISTINCT trim(group_name) FROM servers WHERE trim(group_name) != ''`); err != nil {
return fmt.Errorf("migrate groups: %w", err)
}
if _, err := db.conn.Exec(`
UPDATE servers
SET group_id = (SELECT id FROM groups WHERE groups.name = servers.group_name)
WHERE group_id IS NULL AND trim(group_name) != ''`); err != nil {
return fmt.Errorf("link migrated groups: %w", err)
}
return nil
}
func (db *DB) migrateLegacyRoutes() error {
rows, err := db.conn.Query(`SELECT id, proxy_jump, route_hops FROM servers ORDER BY id`)
if err != nil {
return fmt.Errorf("read legacy routes: %w", err)
}
defer rows.Close()
type legacyServer struct {
id int64
proxyJump string
routeHops string
}
var legacy []legacyServer
for rows.Next() {
var item legacyServer
if err := rows.Scan(&item.id, &item.proxyJump, &item.routeHops); err != nil {
return err
}
legacy = append(legacy, item)
}
if err := rows.Err(); err != nil {
return err
}
for _, item := range legacy {
var count int
if err := db.conn.QueryRow(`SELECT count(*) FROM server_route_hops WHERE target_server_id=?`, item.id).Scan(&count); err != nil {
return err
}
if count > 0 {
continue
}
source := strings.TrimSpace(item.routeHops)
if source == "" {
source = strings.TrimSpace(item.proxyJump)
}
if source == "" {
continue
}
route := unmarshalRoute(source)
for pos, hop := range route.Hops {
hopID := hop.ServerID
candidate := strings.TrimSpace(hop.Alias)
if candidate == "" {
candidate = strings.TrimSpace(hop.Raw)
}
if hopID == 0 && candidate != "" {
_ = db.conn.QueryRow(`SELECT id FROM servers WHERE alias=?`, candidate).Scan(&hopID)
}
if hopID > 0 && hopID != item.id {
if _, err := db.conn.Exec(`INSERT INTO server_route_hops(target_server_id, position, hop_server_id) VALUES(?,?,?)`, item.id, pos, hopID); err != nil {
return fmt.Errorf("migrate route hop: %w", err)
}
continue
}
raw := candidate
if raw == "" {
raw = strings.TrimSpace(hop.Raw)
}
if raw == "" {
continue
}
if _, err := db.conn.Exec(`INSERT INTO server_route_hops(target_server_id, position, raw_target) VALUES(?,?,?)`, item.id, pos, raw); err != nil {
return fmt.Errorf("migrate raw route hop: %w", err)
}
}
}
return nil
}
func ensureGroupTx(tx *sql.Tx, name string) (int64, error) {
name = strings.TrimSpace(name)
if name == "" {
return 0, nil
}
if _, err := tx.Exec(`INSERT OR IGNORE INTO groups(name) VALUES(?)`, name); err != nil {
return 0, err
}
var id int64
if err := tx.QueryRow(`SELECT id FROM groups WHERE name=?`, name).Scan(&id); err != nil {
return 0, err
}
return id, nil
}
func routeCompatibilityProjection(route model.Route) (proxyJump, routeJSON string) {
proxyJump = route.ProxyJumpString()
routeJSON = marshalRoute(route)
return proxyJump, routeJSON
}

View File

@ -24,18 +24,16 @@ const (
)
type Server struct {
ID int64 `json:"id"`
Alias string `json:"alias"`
DisplayName string `json:"display_name"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
AuthMethod AuthMethod `json:"auth_method"`
IdentityFile string `json:"identity_file"`
// ProxyJump is a deprecated compatibility projection of Route.
ID int64 `json:"id"`
Alias string `json:"alias"`
DisplayName string `json:"display_name"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
AuthMethod AuthMethod `json:"auth_method"`
IdentityFile string `json:"identity_file"`
ProxyJump string `json:"proxy_jump"`
Route Route `json:"route"`
GroupID int64 `json:"group_id"`
GroupName string `json:"group_name"`
Notes string `json:"notes"`
StartupCommand string `json:"startup_command"`
@ -48,6 +46,22 @@ type Server struct {
LastTestError string `json:"last_test_error"`
}
type SecretType string
const (
SecretSSHPassword SecretType = "ssh_password"
SecretKeyPassphrase SecretType = "key_passphrase"
SecretSudoPassword SecretType = "sudo_password"
SecretCustom SecretType = "custom_secret"
)
type Secret struct {
ID string `json:"id"`
Type SecretType `json:"type"`
Nonce []byte `json:"nonce"`
Data []byte `json:"data"`
}
type ForwardType string
const (
@ -127,41 +141,20 @@ func (f *Forward) ForwardTarget() string {
}
type Tag struct {
ID int64 `json:"id"`
Name string `json:"name"`
ServerCount int `json:"server_count,omitempty"`
}
type Group struct {
ID int64 `json:"id"`
Name string `json:"name"`
ServerCount int `json:"server_count,omitempty"`
ID int64 `json:"id"`
Name string `json:"name"`
}
// --- Route ---
// RouteHop is either a stable reference to another sshkeeper profile or a raw
// OpenSSH jump target. Alias is a display/backward-compatibility cache only;
// ServerID is the identity for profile hops.
// RouteHop represents a single jump host in a route.
// IsProfile: true = use Alias (references a sshkeeper profile), false = use Raw (literal address).
type RouteHop struct {
ServerID int64 `json:"server_id,omitempty"`
Alias string `json:"alias,omitempty"`
Raw string `json:"raw,omitempty"`
Alias string `json:"alias"`
Raw string `json:"raw"`
IsProfile bool `json:"is_profile"`
}
func (h RouteHop) Profile() bool { return h.IsProfile || h.ServerID != 0 }
func (h RouteHop) DisplayName() string {
if h.Profile() {
if h.Alias != "" {
return h.Alias
}
return fmt.Sprintf("profile#%d", h.ServerID)
}
return h.Raw
}
// Route represents the SSH jump route for a server.
// Mode is computed from Hops length: 0=direct, 1=via, 2+=chain
type Route struct {
@ -184,7 +177,11 @@ func (r Route) RouteMode() string {
func (r Route) ProxyJumpString() string {
parts := make([]string, len(r.Hops))
for i, h := range r.Hops {
parts[i] = h.DisplayName()
if h.IsProfile {
parts[i] = h.Alias
} else {
parts[i] = h.Raw
}
}
return strings.Join(parts, ",")
}
@ -197,7 +194,11 @@ func (r Route) DisplaySummary(target string) string {
}
names := make([]string, len(r.Hops))
for i, h := range r.Hops {
names[i] = h.DisplayName()
if h.IsProfile {
names[i] = h.Alias
} else {
names[i] = h.Raw
}
}
return strings.Join(names, " → ") + " → " + target
}
@ -205,7 +206,7 @@ func (r Route) DisplaySummary(target string) string {
// HasProfileLinks returns true if any hop references a known profile.
func (r Route) HasProfileLinks() bool {
for _, h := range r.Hops {
if h.Profile() {
if h.IsProfile {
return true
}
}
@ -214,6 +215,7 @@ func (r Route) HasProfileLinks() bool {
type CommandTemplate struct {
ID int64 `json:"id"`
ServerID int64 `json:"server_id"`
Name string `json:"name"`
Command string `json:"command"`
Description string `json:"description"`
@ -229,7 +231,6 @@ type TunnelState struct {
Name string `json:"name"`
PID int `json:"pid"`
ForwardIDs []int64 `json:"forward_ids"`
ConfigPath string `json:"config_path,omitempty"`
StartedAt time.Time `json:"started_at"`
LastError string `json:"last_error"`
}

View File

@ -1,147 +0,0 @@
package model
import (
"fmt"
"strconv"
"strings"
)
// IsSupportedAuthMethod reports whether method is a supported sshkeeper auth mode.
func IsSupportedAuthMethod(method AuthMethod) bool {
switch method {
case AuthPassword, AuthKey, AuthKeyPassphrase, AuthAgent:
return true
default:
return false
}
}
// ValidateServerBasics validates fields that do not require database access.
func ValidateServerBasics(s *Server) error {
if s == nil {
return fmt.Errorf("server is required")
}
if strings.TrimSpace(s.Alias) == "" {
return fmt.Errorf("alias is required")
}
if strings.TrimSpace(s.Host) == "" {
return fmt.Errorf("host is required")
}
if s.Port < 1 || s.Port > 65535 {
return fmt.Errorf("port must be between 1 and 65535")
}
if s.AuthMethod == "" {
s.AuthMethod = AuthKey
}
if !IsSupportedAuthMethod(s.AuthMethod) {
return fmt.Errorf("unsupported auth method: %s", s.AuthMethod)
}
if (s.AuthMethod == AuthKey || s.AuthMethod == AuthKeyPassphrase) && strings.TrimSpace(s.IdentityFile) == "" {
// OpenSSH may still find a default key, so this is intentionally allowed.
}
return ValidateRouteShape(s.ID, s.Route)
}
// ValidateRouteShape validates a route without resolving external references.
func ValidateRouteShape(targetID int64, route Route) error {
seenProfiles := map[int64]bool{}
seenRaw := map[string]bool{}
for i, hop := range route.Hops {
if hop.Profile() {
if hop.ServerID <= 0 && strings.TrimSpace(hop.Alias) == "" {
return fmt.Errorf("route hop %d has no profile reference", i+1)
}
if targetID > 0 && hop.ServerID == targetID {
return fmt.Errorf("route cannot use the target server itself as a hop")
}
if hop.ServerID > 0 {
if seenProfiles[hop.ServerID] {
return fmt.Errorf("route contains duplicate profile hop %s", hop.DisplayName())
}
seenProfiles[hop.ServerID] = true
}
continue
}
raw := strings.TrimSpace(hop.Raw)
if raw == "" {
return fmt.Errorf("route hop %d is empty", i+1)
}
if seenRaw[raw] {
return fmt.Errorf("route contains duplicate raw hop %q", raw)
}
seenRaw[raw] = true
}
return nil
}
// AliasResolver resolves an sshkeeper alias to a stable server ID.
type AliasResolver func(alias string) (int64, bool)
// ParseRouteSpec parses CLI/legacy route syntax into an explicit Route.
// profile:<alias> requires an existing sshkeeper profile; raw:<target> is always
// a literal OpenSSH target. Unprefixed entries are backward-compatible: an
// exact known alias becomes a profile reference, otherwise the entry is raw.
func ParseRouteSpec(input string, resolve AliasResolver) (Route, error) {
input = strings.TrimSpace(input)
if input == "" {
return Route{}, nil
}
parts := strings.Split(input, ",")
route := Route{Hops: make([]RouteHop, 0, len(parts))}
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
switch {
case strings.HasPrefix(part, "profile:"):
alias := strings.TrimSpace(strings.TrimPrefix(part, "profile:"))
if alias == "" {
return Route{}, fmt.Errorf("empty profile route hop")
}
if resolve == nil {
return Route{}, fmt.Errorf("cannot resolve profile route hop %q", alias)
}
id, ok := resolve(alias)
if !ok || id <= 0 {
return Route{}, fmt.Errorf("route profile not found: %s", alias)
}
route.Hops = append(route.Hops, RouteHop{ServerID: id, Alias: alias, IsProfile: true})
case strings.HasPrefix(part, "raw:"):
raw := strings.TrimSpace(strings.TrimPrefix(part, "raw:"))
if raw == "" {
return Route{}, fmt.Errorf("empty raw route hop")
}
route.Hops = append(route.Hops, RouteHop{Raw: raw})
default:
if resolve != nil {
if id, ok := resolve(part); ok && id > 0 {
route.Hops = append(route.Hops, RouteHop{ServerID: id, Alias: part, IsProfile: true})
continue
}
}
route.Hops = append(route.Hops, RouteHop{Raw: part})
}
}
if err := ValidateRouteShape(0, route); err != nil {
return Route{}, err
}
return route, nil
}
// FormatRouteSpec returns an unambiguous CLI representation.
func FormatRouteSpec(route Route) string {
parts := make([]string, 0, len(route.Hops))
for _, hop := range route.Hops {
if hop.Profile() {
name := hop.Alias
if name == "" {
name = strconv.FormatInt(hop.ServerID, 10)
}
parts = append(parts, "profile:"+name)
} else {
parts = append(parts, "raw:"+hop.Raw)
}
}
return strings.Join(parts, ",")
}

View File

@ -1,194 +0,0 @@
package session
import (
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
)
var dedicatedWorkspace = "sshkeeper"
type Window struct {
ID string
Index int
Name string
ServerAlias string
Active bool
StartedAt time.Time
}
func Available() bool {
if runtime.GOOS == "windows" {
return false
}
_, err := exec.LookPath("tmux")
return err == nil
}
func workspaceTarget() (string, bool, error) {
if !Available() {
return "", false, fmt.Errorf("tmux is unavailable")
}
if os.Getenv("TMUX") == "" {
return dedicatedWorkspace, false, nil
}
out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output()
if err != nil {
return "", true, fmt.Errorf("resolve current tmux session: %w", err)
}
name := strings.TrimSpace(string(out))
if name == "" {
return "", true, fmt.Errorf("current tmux session has no name")
}
return name, true, nil
}
func sessionExists(target string) bool {
cmd := exec.Command("tmux", "has-session", "-t", target)
return cmd.Run() == nil
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}
func Open(serverAlias string) (string, bool, error) {
executable, err := os.Executable()
if err != nil {
return "", false, fmt.Errorf("resolve sshkeeper executable: %w", err)
}
command := shellQuote(executable) + " __session-connect " + shellQuote(serverAlias)
return openWindow(serverAlias, command)
}
func openWindow(serverAlias, command string) (string, bool, error) {
target, insideTmux, err := workspaceTarget()
if err != nil {
return "", insideTmux, err
}
name := sanitizeWindowName(serverAlias)
var args []string
if !insideTmux && !sessionExists(target) {
args = []string{"new-session", "-d", "-P", "-F", "#{window_id}", "-s", target, "-n", name, command}
} else {
args = []string{"new-window", "-d", "-P", "-F", "#{window_id}", "-t", target, "-n", name, command}
}
out, err := exec.Command("tmux", args...).CombinedOutput()
if err != nil {
return "", insideTmux, fmt.Errorf("create tmux session window: %s: %w", strings.TrimSpace(string(out)), err)
}
windowID := strings.TrimSpace(string(out))
if windowID == "" {
return "", insideTmux, fmt.Errorf("tmux did not return a window id")
}
if err := setWindowMetadata(windowID, serverAlias, time.Now()); err != nil {
return "", insideTmux, err
}
return windowID, insideTmux, nil
}
func sanitizeWindowName(alias string) string {
name := strings.TrimSpace(alias)
if name == "" {
return "ssh"
}
name = strings.ReplaceAll(name, ":", "-")
name = strings.ReplaceAll(name, " ", "-")
if len(name) > 40 {
name = name[:40]
}
return name
}
func setWindowMetadata(windowID, alias string, started time.Time) error {
pairs := [][2]string{
{"@sshkeeper_server", alias},
{"@sshkeeper_started", strconv.FormatInt(started.Unix(), 10)},
}
for _, pair := range pairs {
out, err := exec.Command("tmux", "set-window-option", "-t", windowID, pair[0], pair[1]).CombinedOutput()
if err != nil {
return fmt.Errorf("set tmux metadata %s: %s: %w", pair[0], strings.TrimSpace(string(out)), err)
}
}
return nil
}
func List() ([]Window, error) {
if !Available() {
return nil, nil
}
target, _, err := workspaceTarget()
if err != nil {
return nil, err
}
if !sessionExists(target) {
return nil, nil
}
format := "#{window_id}\t#{window_index}\t#{window_name}\t#{window_active}\t#{@sshkeeper_server}\t#{@sshkeeper_started}"
out, err := exec.Command("tmux", "list-windows", "-t", target, "-F", format).CombinedOutput()
if err != nil {
return nil, fmt.Errorf("list tmux windows: %s: %w", strings.TrimSpace(string(out)), err)
}
var result []Window
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
parts := strings.Split(line, "\t")
if len(parts) < 6 || strings.TrimSpace(parts[4]) == "" {
continue
}
index, _ := strconv.Atoi(parts[1])
startedUnix, _ := strconv.ParseInt(parts[5], 10, 64)
window := Window{ID: parts[0], Index: index, Name: parts[2], Active: parts[3] == "1", ServerAlias: parts[4]}
if startedUnix > 0 {
window.StartedAt = time.Unix(startedUnix, 0)
}
result = append(result, window)
}
return result, nil
}
func Attach(windowID string) error {
if !Available() {
return fmt.Errorf("tmux is unavailable")
}
if strings.TrimSpace(windowID) == "" {
return fmt.Errorf("tmux window id is required")
}
if os.Getenv("TMUX") != "" {
out, err := exec.Command("tmux", "select-window", "-t", windowID).CombinedOutput()
if err != nil {
return fmt.Errorf("select tmux window: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
target, _, err := workspaceTarget()
if err != nil {
return err
}
if out, err := exec.Command("tmux", "select-window", "-t", windowID).CombinedOutput(); err != nil {
return fmt.Errorf("select tmux window: %s: %w", strings.TrimSpace(string(out)), err)
}
cmd := exec.Command("tmux", "attach-session", "-t", target)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("attach tmux session: %w", err)
}
return nil
}
func Close(windowID string) error {
if !Available() {
return fmt.Errorf("tmux is unavailable")
}
out, err := exec.Command("tmux", "kill-window", "-t", windowID).CombinedOutput()
if err != nil {
return fmt.Errorf("close tmux window: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}

View File

@ -1,68 +0,0 @@
package session
import (
"fmt"
"os/exec"
"testing"
"time"
)
func TestSanitizeWindowName(t *testing.T) {
if got := sanitizeWindowName(" prod:db "); got != "prod-db" {
t.Fatalf("sanitizeWindowName = %q, want prod-db", got)
}
if got := sanitizeWindowName(" "); got != "ssh" {
t.Fatalf("empty name = %q, want ssh", got)
}
}
func TestShellQuote(t *testing.T) {
got := shellQuote("prod'one")
want := `'prod'"'"'one'`
if got != want {
t.Fatalf("shellQuote = %q, want %q", got, want)
}
}
func TestTmuxWindowLifecycle(t *testing.T) {
if !Available() {
t.Skip("tmux is not available")
}
t.Setenv("TMUX", "")
oldWorkspace := dedicatedWorkspace
dedicatedWorkspace = fmt.Sprintf("sshkeeper-test-%d", time.Now().UnixNano())
t.Cleanup(func() {
_, _ = exec.Command("tmux", "kill-session", "-t", dedicatedWorkspace).CombinedOutput()
dedicatedWorkspace = oldWorkspace
})
windowID, inside, err := openWindow("smoke-server", "sleep 30")
if err != nil {
t.Fatalf("openWindow: %v", err)
}
if inside {
t.Fatal("expected dedicated workspace outside tmux")
}
windows, err := List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(windows) != 1 || windows[0].ID != windowID || windows[0].ServerAlias != "smoke-server" {
t.Fatalf("unexpected windows: %#v", windows)
}
if err := Close(windowID); err != nil {
t.Fatalf("Close: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
windows, err = List()
if err != nil {
t.Fatalf("List after close: %v", err)
}
if len(windows) == 0 {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("window %s still listed after close: %#v", windowID, windows)
}

View File

@ -36,7 +36,54 @@ func validateSSHBinaryForOS(goos string, binary string, lookPath func(string) (s
return nil
}
func runPrepared(cfg *config.Config, args []string, server *model.Server, getVault VaultFunc) error {
func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error {
if err := EnsureSSHBinary(cfg.SSH.Binary); err != nil {
return err
}
args := BuildSSHArgsSimple(server)
if strings.TrimSpace(server.StartupCommand) != "" {
args = append(args, server.StartupCommand)
}
switch server.AuthMethod {
case model.AuthPassword:
password, err := getVault(server.Alias, "ssh_password")
if err != nil {
return fmt.Errorf("get password from vault: %w", err)
}
return ConnectWithPassword(cfg.SSH.Binary, args, password)
case model.AuthKeyPassphrase:
passphrase, err := getVault(server.Alias, "key_passphrase")
if err != nil {
return fmt.Errorf("get key passphrase from vault: %w", err)
}
return ConnectWithPassword(cfg.SSH.Binary, args, passphrase)
default:
// key and agent auth use direct OpenSSH execution.
cmd := exec.Command(cfg.SSH.Binary, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("start ssh: %w", err)
}
return cmd.Wait()
}
}
func RunCommand(cfg *config.Config, server *model.Server, getVault VaultFunc, command string) error {
if err := EnsureSSHBinary(cfg.SSH.Binary); err != nil {
return err
}
args := BuildSSHArgsSimple(server)
args = append(args, command)
switch server.AuthMethod {
case model.AuthPassword:
password, err := getVault(server.Alias, "ssh_password")
@ -62,69 +109,17 @@ func runPrepared(cfg *config.Config, args []string, server *model.Server, getVau
}
}
func insertBeforeTarget(args []string, values ...string) []string {
if len(args) == 0 {
return append([]string(nil), values...)
}
result := make([]string, 0, len(args)+len(values))
result = append(result, args[:len(args)-1]...)
result = append(result, values...)
result = append(result, args[len(args)-1])
return result
}
func ConnectResolved(cfg *config.Config, server *model.Server, resolve ProfileResolver, getVault VaultFunc) error {
if err := EnsureSSHBinary(cfg.SSH.Binary); err != nil {
return err
}
invocation, err := PrepareSSHInvocation(server, nil, false, resolve)
if err != nil {
return err
}
defer invocation.Cleanup()
args := append([]string(nil), invocation.Args...)
if strings.TrimSpace(server.StartupCommand) != "" {
args = append(args, server.StartupCommand)
}
return runPrepared(cfg, args, server, getVault)
}
func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error {
return ConnectResolved(cfg, server, nil, getVault)
}
func RunCommandResolved(cfg *config.Config, server *model.Server, resolve ProfileResolver, getVault VaultFunc, command string) error {
if err := EnsureSSHBinary(cfg.SSH.Binary); err != nil {
return err
}
invocation, err := PrepareSSHInvocation(server, nil, false, resolve)
if err != nil {
return err
}
defer invocation.Cleanup()
args := append(append([]string(nil), invocation.Args...), command)
return runPrepared(cfg, args, server, getVault)
}
func RunCommand(cfg *config.Config, server *model.Server, getVault VaultFunc, command string) error {
return RunCommandResolved(cfg, server, nil, getVault, command)
}
func RunCommandOutputResolved(cfg *config.Config, server *model.Server, resolve ProfileResolver, getVault VaultFunc, command string) (string, error) {
func RunCommandOutput(cfg *config.Config, server *model.Server, getVault VaultFunc, command string) (string, error) {
if err := EnsureSSHBinary(cfg.SSH.Binary); err != nil {
return "", err
}
invocation, err := PrepareSSHInvocation(server, nil, false, resolve)
if err != nil {
return "", err
}
defer invocation.Cleanup()
args := insertBeforeTarget(invocation.Args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec))
args := BuildSSHArgsSimple(server)
args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec))
switch server.AuthMethod {
case model.AuthPassword:
args = insertBeforeTarget(args, "-o", "NumberOfPasswordPrompts=1")
args = append(args, command)
args = append(args, "-o", "NumberOfPasswordPrompts=1", command)
password, err := getVault(server.Alias, "ssh_password")
if err != nil {
return "", fmt.Errorf("get password from vault: %w", err)
@ -135,8 +130,7 @@ func RunCommandOutputResolved(cfg *config.Config, server *model.Server, resolve
}
return output, nil
case model.AuthKeyPassphrase:
args = insertBeforeTarget(args, "-o", "NumberOfPasswordPrompts=1")
args = append(args, command)
args = append(args, "-o", "NumberOfPasswordPrompts=1", command)
passphrase, err := getVault(server.Alias, "key_passphrase")
if err != nil {
return "", fmt.Errorf("get key passphrase from vault: %w", err)
@ -147,8 +141,7 @@ func RunCommandOutputResolved(cfg *config.Config, server *model.Server, resolve
}
return output, nil
default:
args = insertBeforeTarget(args, "-o", "BatchMode=yes")
args = append(args, command)
args = append(args, "-o", "BatchMode=yes", command)
cmd := exec.Command(cfg.SSH.Binary, args...)
output, err := cmd.CombinedOutput()
if err != nil {
@ -158,88 +151,104 @@ func RunCommandOutputResolved(cfg *config.Config, server *model.Server, resolve
}
}
func RunCommandOutput(cfg *config.Config, server *model.Server, getVault VaultFunc, command string) (string, error) {
return RunCommandOutputResolved(cfg, server, nil, getVault, command)
}
func TestResolved(cfg *config.Config, server *model.Server, resolve ProfileResolver, getVault VaultFunc) (bool, string) {
func Test(cfg *config.Config, server *model.Server, getVault VaultFunc) (bool, string) {
if err := EnsureSSHBinary(cfg.SSH.Binary); err != nil {
return false, err.Error()
}
invocation, err := PrepareSSHInvocation(server, nil, false, resolve)
if err != nil {
return false, err.Error()
}
defer invocation.Cleanup()
args := insertBeforeTarget(invocation.Args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec))
args := BuildSSHArgsSimple(server)
args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec))
switch server.AuthMethod {
case model.AuthPassword:
args = insertBeforeTarget(args, "-o", "NumberOfPasswordPrompts=1")
args = append(args, "-o", "NumberOfPasswordPrompts=1")
password, err := getVault(server.Alias, "ssh_password")
if err != nil {
return false, fmt.Sprintf("vault error: %v", err)
}
return testWithPassword(cfg, append(args, cfg.SSH.TestCommand), password)
return testWithPassword(cfg, args, password)
case model.AuthKeyPassphrase:
args = insertBeforeTarget(args, "-o", "NumberOfPasswordPrompts=1")
args = append(args, "-o", "NumberOfPasswordPrompts=1")
passphrase, err := getVault(server.Alias, "key_passphrase")
if err != nil {
return false, fmt.Sprintf("vault error: %v", err)
}
return testWithPassword(cfg, append(args, cfg.SSH.TestCommand), passphrase)
return testWithPassword(cfg, args, passphrase)
default:
args = insertBeforeTarget(args, "-o", "BatchMode=yes")
// key and agent auth should not prompt during tests.
args = append(args, "-o", "BatchMode=yes")
args = append(args, cfg.SSH.TestCommand)
cmd := exec.Command(cfg.SSH.Binary, args...)
cmd.Stdin = nil
output, err := cmd.CombinedOutput()
if err != nil {
return false, strings.TrimSpace(string(output))
}
result := strings.TrimSpace(string(output))
if result == "SSHKEEPER_OK" || strings.Contains(result, "SSHKEEPER_OK") {
if result == "SSHKEEPER_OK" {
return true, ""
}
return false, result
}
}
func Test(cfg *config.Config, server *model.Server, getVault VaultFunc) (bool, string) {
return TestResolved(cfg, server, nil, getVault)
}
// testWithPassword tests SSH connection with password auth via PTY-wrapper.
// It connects, sends the password, runs the test command, and checks the output.
func testWithPassword(cfg *config.Config, args []string, password string) (bool, string) {
args = append(args, cfg.SSH.TestCommand)
ok, output := connectWithPasswordAndRead(cfg.SSH.Binary, args, password, cfg.SSH.ConnectTimeoutSec)
if !ok {
return false, output
}
result := strings.TrimSpace(output)
if result == "SSHKEEPER_OK" || strings.Contains(result, "SSHKEEPER_OK") {
if result == "SSHKEEPER_OK" {
return true, ""
}
// The output might have the test command echo before the result
if strings.Contains(result, "SSHKEEPER_OK") {
return true, ""
}
return false, result
}
func ConnectWithForwardsResolved(cfg *config.Config, server *model.Server, forwards []*model.Forward, forwardOnly bool, resolve ProfileResolver, getVault VaultFunc) error {
if err := EnsureSSHBinary(cfg.SSH.Binary); err != nil {
return err
}
invocation, err := PrepareSSHInvocation(server, forwards, forwardOnly, resolve)
if err != nil {
return err
}
defer invocation.Cleanup()
return runPrepared(cfg, invocation.Args, server, getVault)
}
func ConnectWithArgs(cfg *config.Config, args []string, vaultFunc VaultFunc, server *model.Server) error {
if err := EnsureSSHBinary(cfg.SSH.Binary); err != nil {
return err
}
return runPrepared(cfg, args, server, vaultFunc)
}
switch server.AuthMethod {
case model.AuthPassword:
password, err := vaultFunc(server.Alias, "ssh_password")
if err != nil {
return fmt.Errorf("get password from vault: %w", err)
}
return ConnectWithPassword(cfg.SSH.Binary, args, password)
case model.AuthKeyPassphrase:
passphrase, err := vaultFunc(server.Alias, "key_passphrase")
if err != nil {
return fmt.Errorf("get key passphrase from vault: %w", err)
}
return ConnectWithPassword(cfg.SSH.Binary, args, passphrase)
default:
cmd := exec.Command(cfg.SSH.Binary, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("start ssh: %w", err)
}
return cmd.Wait()
}
}
func BuildForwardArgs(forwards []*model.Forward, exitOnForwardFailure bool) []string {
var args []string
for _, f := range forwards {
@ -266,11 +275,7 @@ func BuildForwardArgs(forwards []*model.Forward, exitOnForwardFailure bool) []st
func BuildSSHArgs(server *model.Server, forwards []*model.Forward, forwardOnly bool) []string {
var args []string
port := server.Port
if port == 0 {
port = 22
}
args = append(args, "-p", fmt.Sprintf("%d", port))
args = append(args, "-p", fmt.Sprintf("%d", server.Port))
if server.IdentityFile != "" {
args = append(args, "-i", server.IdentityFile)
@ -295,10 +300,7 @@ func BuildSSHArgs(server *model.Server, forwards []*model.Forward, forwardOnly b
args = append(args, "-N")
}
target := server.Host
if strings.TrimSpace(server.User) != "" {
target = fmt.Sprintf("%s@%s", server.User, server.Host)
}
target := fmt.Sprintf("%s@%s", server.User, server.Host)
args = append(args, target)
return args

View File

@ -1,225 +0,0 @@
package ssh
import (
"fmt"
"os"
"sort"
"strings"
"github.com/mirivlad/sshkeeper/internal/model"
)
// ProfileResolver resolves a stable sshkeeper server ID for route planning.
type ProfileResolver func(serverID int64) (*model.Server, error)
type PlannedHop struct {
Server *model.Server
Raw string
}
type ConnectionPlan struct {
Target *model.Server
Hops []PlannedHop
UsesProfiles bool
}
func PlanConnection(target *model.Server, resolve ProfileResolver) (*ConnectionPlan, error) {
if target == nil {
return nil, fmt.Errorf("target server is required")
}
plan := &ConnectionPlan{Target: target}
stack := map[int64]bool{}
if target.ID > 0 {
stack[target.ID] = true
}
seenProfiles := map[int64]bool{}
seenRaw := map[string]bool{}
hops, err := flattenRoute(target, resolve, stack, seenProfiles, seenRaw)
if err != nil {
return nil, err
}
plan.Hops = hops
for _, hop := range hops {
if hop.Server != nil {
plan.UsesProfiles = true
break
}
}
return plan, nil
}
func flattenRoute(owner *model.Server, resolve ProfileResolver, stack, seenProfiles map[int64]bool, seenRaw map[string]bool) ([]PlannedHop, error) {
var result []PlannedHop
for _, hop := range owner.Route.Hops {
if hop.Profile() {
if hop.ServerID <= 0 {
return nil, fmt.Errorf("route profile %q has no stable ID; edit and re-save the route", hop.Alias)
}
if resolve == nil {
return nil, fmt.Errorf("route profile %s requires sshkeeper profile resolution", hop.DisplayName())
}
if stack[hop.ServerID] {
return nil, fmt.Errorf("route cycle detected at %s", hop.DisplayName())
}
server, err := resolve(hop.ServerID)
if err != nil {
return nil, fmt.Errorf("resolve route profile %s: %w", hop.DisplayName(), err)
}
if server.AuthMethod == model.AuthPassword || server.AuthMethod == model.AuthKeyPassphrase {
return nil, fmt.Errorf("jump profile %s uses %s authentication; password/passphrase jump profiles are not supported by the current OpenSSH vault flow", server.Alias, server.AuthMethod)
}
stack[server.ID] = true
nested, err := flattenRoute(server, resolve, stack, seenProfiles, seenRaw)
delete(stack, server.ID)
if err != nil {
return nil, err
}
result = append(result, nested...)
if seenProfiles[server.ID] {
return nil, fmt.Errorf("route resolves to duplicate profile hop %s", server.Alias)
}
seenProfiles[server.ID] = true
result = append(result, PlannedHop{Server: server})
continue
}
raw := strings.TrimSpace(hop.Raw)
if raw == "" {
return nil, fmt.Errorf("route contains an empty raw hop")
}
if seenRaw[raw] {
return nil, fmt.Errorf("route resolves to duplicate raw hop %q", raw)
}
seenRaw[raw] = true
result = append(result, PlannedHop{Raw: raw})
}
return result, nil
}
func syntheticProfileHost(id int64) string {
return fmt.Sprintf("sshkeeper-profile-%d", id)
}
func syntheticTargetHost(target *model.Server) string {
if target.ID > 0 {
return fmt.Sprintf("sshkeeper-target-%d", target.ID)
}
return "sshkeeper-target-unsaved"
}
func appendHostBlock(b *strings.Builder, hostAlias string, server *model.Server) {
fmt.Fprintf(b, "Host %s\n", hostAlias)
fmt.Fprintf(b, " HostName %s\n", server.Host)
port := server.Port
if port == 0 {
port = 22
}
fmt.Fprintf(b, " Port %d\n", port)
if server.User != "" {
fmt.Fprintf(b, " User %s\n", server.User)
}
if server.IdentityFile != "" && server.AuthMethod != model.AuthPassword && server.AuthMethod != model.AuthAgent {
fmt.Fprintf(b, " IdentityFile %s\n", server.IdentityFile)
}
fmt.Fprintln(b, " StrictHostKeyChecking accept-new")
fmt.Fprintln(b)
}
// OpenSSHConfig renders the deterministic temporary config used when a route
// references sshkeeper profiles. The user's normal config is included first so
// raw OpenSSH jump targets keep their existing configuration.
func (p *ConnectionPlan) OpenSSHConfig() string {
var b strings.Builder
b.WriteString("# Temporary config generated by sshkeeper\n")
b.WriteString("Include ~/.ssh/config\n\n")
profiles := map[int64]*model.Server{}
for _, hop := range p.Hops {
if hop.Server != nil {
profiles[hop.Server.ID] = hop.Server
}
}
ids := make([]int64, 0, len(profiles))
for id := range profiles {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, id := range ids {
appendHostBlock(&b, syntheticProfileHost(id), profiles[id])
}
appendHostBlock(&b, syntheticTargetHost(p.Target), p.Target)
if len(p.Hops) > 0 {
parts := make([]string, 0, len(p.Hops))
for _, hop := range p.Hops {
if hop.Server != nil {
parts = append(parts, syntheticProfileHost(hop.Server.ID))
} else {
parts = append(parts, hop.Raw)
}
}
// OpenSSH uses the first value it obtains. Append a target-specific
// stanza after the generic one with ProxyJump before any competing rule.
fmt.Fprintf(&b, "Host %s\n", syntheticTargetHost(p.Target))
fmt.Fprintf(&b, " ProxyJump %s\n\n", strings.Join(parts, ","))
}
return b.String()
}
type PreparedInvocation struct {
Args []string
ConfigPath string
}
func (p *PreparedInvocation) Cleanup() {
if p != nil && p.ConfigPath != "" {
_ = os.Remove(p.ConfigPath)
p.ConfigPath = ""
}
}
func enabledForwards(forwards []*model.Forward) []*model.Forward {
result := make([]*model.Forward, 0, len(forwards))
for _, forward := range forwards {
if forward != nil && forward.Enabled {
result = append(result, forward)
}
}
return result
}
func PrepareSSHInvocation(server *model.Server, forwards []*model.Forward, forwardOnly bool, resolve ProfileResolver) (*PreparedInvocation, error) {
plan, err := PlanConnection(server, resolve)
if err != nil {
return nil, err
}
active := enabledForwards(forwards)
if !plan.UsesProfiles {
return &PreparedInvocation{Args: BuildSSHArgs(server, active, forwardOnly)}, nil
}
file, err := os.CreateTemp("", "sshkeeper-*.conf")
if err != nil {
return nil, fmt.Errorf("create temporary ssh config: %w", err)
}
path := file.Name()
if err := file.Chmod(0600); err != nil {
file.Close()
os.Remove(path)
return nil, err
}
if _, err := file.WriteString(plan.OpenSSHConfig()); err != nil {
file.Close()
os.Remove(path)
return nil, err
}
if err := file.Close(); err != nil {
os.Remove(path)
return nil, err
}
args := []string{"-F", path}
if len(active) > 0 {
args = append(args, BuildForwardArgs(active, true)...)
}
if forwardOnly {
args = append(args, "-N")
}
args = append(args, syntheticTargetHost(server))
return &PreparedInvocation{Args: args, ConfigPath: path}, nil
}

File diff suppressed because it is too large Load Diff

View File

@ -148,7 +148,7 @@ func TestServerListHelpWrapsSelectionAndResultHints(t *testing.T) {
plainLines = append(plainLines, plainHelpLine(line))
}
joined := strings.Join(plainLines, "\n")
for _, want := range []string{"Ins: select (2 selected)", "Esc: clear result", "Ctrl+X: server actions", "m: manage", "Ctrl+Q: quit"} {
for _, want := range []string{"Ins: select (2 selected)", "Esc: clear result", "Ctrl+X: actions", "Ctrl+Q: quit"} {
if !strings.Contains(joined, want) {
t.Fatalf("expected wrapped help to contain %q\nlines:%#v", want, lines)
}
@ -342,8 +342,8 @@ func TestAuthMethodListViewShowsAllOptions(t *testing.T) {
if between := view[authPos:listPos]; strings.Contains(between, "Identity File") {
t.Fatalf("expected auth method list to render directly under auth field\nview:\n%s", view)
}
if !strings.Contains(view, "│") {
t.Fatalf("expected auth method dropdown inside the unified frame\nview:\n%s", view)
if strings.Contains(view, "│") {
t.Fatalf("expected compact auth method dropdown without default list border\nview:\n%s", view)
}
for _, method := range []model.AuthMethod{
model.AuthPassword,
@ -383,8 +383,8 @@ func TestGroupListViewRendersDirectlyUnderGroupField(t *testing.T) {
if between := view[groupPos:listPos]; strings.Contains(between, "Password") {
t.Fatalf("expected group dropdown to render before password field\nview:\n%s", view)
}
if !strings.Contains(view, "│") {
t.Fatalf("expected group dropdown inside the unified frame\nview:\n%s", view)
if strings.Contains(view, "│") {
t.Fatalf("expected compact group dropdown without default list border\nview:\n%s", view)
}
}
@ -444,7 +444,7 @@ func TestFormViewUsesSectionsAndStableLabels(t *testing.T) {
"Alias",
"Display Name",
"Auth Method",
"Identity File",
"Password / Passphrase",
} {
if !strings.Contains(view, want) {
t.Fatalf("expected form view to contain %q\nview:\n%s", want, view)
@ -452,28 +452,6 @@ func TestFormViewUsesSectionsAndStableLabels(t *testing.T) {
}
}
func TestFormAuthFieldsAreContextual(t *testing.T) {
fm := newFormModel(100, 30)
fm.inputs[5].SetValue(string(model.AuthPassword))
view := fm.View()
if !strings.Contains(view, "Password") || strings.Contains(view, "Identity File") {
t.Fatalf("password auth fields are not contextual:\n%s", view)
}
fm.inputs[5].SetValue(string(model.AuthKeyPassphrase))
view = fm.View()
if !strings.Contains(view, "Key passphrase") || !strings.Contains(view, "Identity File") {
t.Fatalf("key passphrase auth fields are not contextual:\n%s", view)
}
fm.inputs[5].SetValue(string(model.AuthAgent))
view = fm.View()
if strings.Contains(view, "Identity File") || strings.Contains(view, "Password") || strings.Contains(view, "passphrase") {
t.Fatalf("agent auth still shows credential fields:\n%s", view)
}
}
func TestFormTestResultDoesNotUpdateSelectedListServer(t *testing.T) {
oldUpdateTestResult := UpdateTestResult
oldListServers := ListServers
@ -813,12 +791,7 @@ func TestActionMenuClosesOnAllActions(t *testing.T) {
// Test delete closes menu
m.actionMenu = newActionMenuModel(m.width, m.height)
m.screen = screenActionMenu
for i := 0; i < len(m.actionMenu.list.Items()); i++ {
m.actionMenu.list.Select(i)
if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "delete" {
break
}
}
m.actionMenu.list.Select(3) // Delete
DeleteServer = func(alias string) error { return nil }
ListServers = func() ([]*model.Server, error) { return []*model.Server{server}, nil }
updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
@ -875,7 +848,7 @@ func TestActionMenuManageRouteOpensRouteField(t *testing.T) {
}
}
func TestManageMenuImportUsesCallbackAndRefreshesList(t *testing.T) {
func TestActionMenuImportUsesCallbackAndRefreshesList(t *testing.T) {
server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey}
imported := false
ImportServers = func() (int, error) {
@ -893,16 +866,16 @@ func TestManageMenuImportUsesCallbackAndRefreshesList(t *testing.T) {
m := New([]*model.Server{})
m.width = 100
m.height = 30
m.manageMenu = newManageMenuModel(m.width, m.height)
m.screen = screenManageMenu
for i := 0; i < len(m.manageMenu.list.Items()); i++ {
m.manageMenu.list.Select(i)
if item, ok := m.manageMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "import" {
m.actionMenu = newActionMenuModel(m.width, m.height)
m.screen = screenActionMenu
for i := 0; i < len(m.actionMenu.list.Items()); i++ {
m.actionMenu.list.Select(i)
if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "import" {
break
}
}
updated, cmd := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter})
updated, cmd := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if cmd == nil {
t.Fatal("expected import command")
@ -922,23 +895,23 @@ func TestManageMenuImportUsesCallbackAndRefreshesList(t *testing.T) {
}
}
func TestManageMenuExportAndVaultChangePasswordExitTUI(t *testing.T) {
func TestActionMenuExportAndVaultChangePasswordExitTUI(t *testing.T) {
server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey}
for _, action := range []string{"export", "vault_change_pw"} {
t.Run(action, func(t *testing.T) {
m := New([]*model.Server{server})
m.width = 100
m.height = 30
m.manageMenu = newManageMenuModel(m.width, m.height)
m.screen = screenManageMenu
for i := 0; i < len(m.manageMenu.list.Items()); i++ {
m.manageMenu.list.Select(i)
if item, ok := m.manageMenu.list.SelectedItem().(actionMenuItem); ok && item.action == action {
m.actionMenu = newActionMenuModel(m.width, m.height)
m.screen = screenActionMenu
for i := 0; i < len(m.actionMenu.list.Items()); i++ {
m.actionMenu.list.Select(i)
if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == action {
break
}
}
updated, cmd := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter})
updated, cmd := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if cmd == nil {
t.Fatalf("expected %s to quit TUI", action)
@ -950,7 +923,7 @@ func TestManageMenuExportAndVaultChangePasswordExitTUI(t *testing.T) {
}
}
func TestManageMenuVaultLockUsesCallback(t *testing.T) {
func TestActionMenuVaultLockUsesCallback(t *testing.T) {
server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey}
locked := false
LockVault = func() error {
@ -962,16 +935,16 @@ func TestManageMenuVaultLockUsesCallback(t *testing.T) {
m := New([]*model.Server{server})
m.width = 100
m.height = 30
m.manageMenu = newManageMenuModel(m.width, m.height)
m.screen = screenManageMenu
for i := 0; i < len(m.manageMenu.list.Items()); i++ {
m.manageMenu.list.Select(i)
if item, ok := m.manageMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "vault_lock" {
m.actionMenu = newActionMenuModel(m.width, m.height)
m.screen = screenActionMenu
for i := 0; i < len(m.actionMenu.list.Items()); i++ {
m.actionMenu.list.Select(i)
if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "vault_lock" {
break
}
}
updated, _ := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter})
updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if !locked {
@ -981,92 +954,3 @@ func TestManageMenuVaultLockUsesCallback(t *testing.T) {
t.Fatalf("expected vault lock success, got %q", m.success)
}
}
func TestServerActionMenuContainsOnlyServerScopedActions(t *testing.T) {
menu := newActionMenuModel(100, 30)
for _, raw := range menu.list.Items() {
item := raw.(actionMenuItem)
switch item.action {
case "import", "export", "vault_lock", "vault_change_pw", "groups", "tags", "templates", "tunnels":
t.Fatalf("global action %q leaked into server actions", item.action)
}
}
}
func TestManageMenuOpensGroups(t *testing.T) {
oldList := ListGroups
t.Cleanup(func() { ListGroups = oldList })
ListGroups = func() ([]*model.Group, error) { return []*model.Group{{ID: 1, Name: "Prod", ServerCount: 3}}, nil }
m := New(nil)
m.width, m.height = 100, 30
m.manageMenu = newManageMenuModel(m.width, m.height)
m.screen = screenManageMenu
for i := range m.manageMenu.list.Items() {
m.manageMenu.list.Select(i)
if item := m.manageMenu.list.SelectedItem().(actionMenuItem); item.action == "groups" {
break
}
}
updated, cmd := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if m.screen != screenGroups || cmd == nil {
t.Fatalf("manage groups did not open: screen=%v cmd=%v", m.screen, cmd)
}
updated, _ = m.Update(cmd())
m = updated.(*tuiModel)
if len(m.groups) != 1 || m.groups[0].ServerCount != 3 {
t.Fatalf("groups were not loaded: %#v", m.groups)
}
}
func TestStartupTemplatePickerCopiesCommand(t *testing.T) {
oldList := ListCommandTemplates
t.Cleanup(func() { ListCommandTemplates = oldList })
ListCommandTemplates = func() ([]*model.CommandTemplate, error) {
return []*model.CommandTemplate{{ID: 1, Name: "Ops", Command: "tmux attach -t ops"}}, nil
}
fm := newFormModel(100, 30)
fm.focusIdx = 10
updated, _ := fm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
fm = updated.(*formModel)
if !fm.showStartupList {
t.Fatal("startup template picker did not open")
}
updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyEnter})
fm = updated.(*formModel)
if got := fm.inputs[10].Value(); got != "tmux attach -t ops" {
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

@ -16,8 +16,6 @@ func (m *tuiModel) renderServerDashboard() string {
if height <= 0 {
height = 40
}
sizeClass := classifyTerminal(width, height)
width = max(1, width-1)
header := m.renderDashboardHeader(width)
notification := m.renderDashboardNotification(width)
@ -31,7 +29,7 @@ func (m *tuiModel) renderServerDashboard() string {
}
var body string
switch sizeClass {
switch classifyTerminal(width, height) {
case sizeWide:
leftWidth := width * 62 / 100
rightWidth := width - leftWidth - 1

File diff suppressed because it is too large Load Diff

View File

@ -81,8 +81,8 @@ func TestForwardValidationMovesFocusToInvalidPort(t *testing.T) {
fm.inputs[3].SetValue("5432")
updated, _ := fm.Update(fm.runSave()())
fm = updated.(*forwardFormModel)
if fm.focusIdx != 7 {
t.Fatalf("invalid listen port focus = %d, want 7", fm.focusIdx)
if fm.focusIdx != 6 {
t.Fatalf("invalid listen port focus = %d, want 6", fm.focusIdx)
}
view := fm.View()
if !strings.Contains(view, "Listen Port") || !strings.Contains(view, "must be a number") {

View File

@ -79,91 +79,52 @@ func (m *forwardScreenModel) editSelected() tea.Cmd {
}
func (m *forwardScreenModel) View() string {
notification := ""
footer := renderHelp([]helpItem{
{Key: "Ctrl+A (a)", Action: "add"},
{Key: "Ctrl+E/Enter", Action: "edit"},
{Key: "Ctrl+D (d)", Action: "delete"},
{Key: "Esc", Action: "back"},
}, m.width)
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine("Port Forwards — "+m.serverAlias, m.width))}
if m.err != nil {
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)), m.width))
}
body := func(width, height int) string {
switch classifyShellContent(width) {
case sizeWide:
leftWidth := width * 70 / 100
rightWidth := width - leftWidth - 1
return joinPanelColumns(
renderPaddedPanel(leftWidth, height, m.forwardListLines(leftWidth-4, height-2, false)), leftWidth,
renderPaddedPanel(rightWidth, height, m.forwardDetailLines(rightWidth-4, false)), rightWidth,
footerRows := displayLineCount(footer)
detailRows := 0
if len(m.list) > 0 && m.height-footerRows >= 7 {
detailRows = 3
}
rowCapacity := max(1, m.height-len(lines)-footerRows-detailRows-1)
if len(m.list) == 0 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("No port forwards configured. Ctrl+A adds one.", m.width)))
} else {
lines = append(lines, m.renderForwardRow(nil, false))
rowCapacity--
start, end := visibleServerRange(len(m.list), m.selected, rowCapacity)
for index := start; index < end; index++ {
lines = append(lines, m.renderForwardRow(m.list[index], index == m.selected))
}
if end < len(m.list) || start > 0 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list))))
}
if detailRows > 0 && m.selected >= 0 && m.selected < len(m.list) {
forward := m.list[m.selected]
lines = append(lines,
sectionStyle.Copy().MarginTop(0).Render("Selected"),
fitLine(forward.ForwardHumanExplanation(m.serverAlias), m.width),
fitLine("ssh "+strings.Join(forward.ForwardSSHArgs(), " "), m.width),
)
case sizeMedium:
detailHeight := min(7, max(4, height/3))
listHeight := max(3, height-detailHeight-1)
listPanel := renderPaddedPanel(width, listHeight, m.forwardListLines(width-4, listHeight-2, false))
detailPanel := renderPaddedPanel(width, detailHeight, m.forwardDetailLines(width-4, true))
return listPanel + "\n" + detailPanel
default:
return renderPaddedPanel(width, height, m.forwardListLines(width-4, height-2, true))
}
}
return renderScreenShell(screenShell{
breadcrumb: "Port Forwards / " + m.serverAlias,
status: fmt.Sprintf("%d rules", len(m.list)),
notification: notification,
width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "Ctrl+A (a)", Action: "add"},
{Key: "Ctrl+E/Enter", Action: "edit"},
{Key: "Space", Action: "enable/disable"},
{Key: "Ctrl+D (d)", Action: "delete"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
lines = append(lines, strings.Split(footer, "\n")...)
if len(lines) > m.height && m.height > 0 {
lines = lines[:m.height]
}
return strings.Join(lines, "\n")
}
func (m *forwardScreenModel) forwardListLines(width, capacity int, compact bool) []string {
if len(m.list) == 0 {
return []string{helpStyle.Copy().MarginLeft(0).Render("No port forwards configured. Ctrl+A adds one.")}
}
lines := []string{m.renderForwardRow(nil, false, width, compact)}
rowCapacity := max(1, capacity-1)
showRange := len(m.list) > rowCapacity
if showRange {
rowCapacity = max(1, rowCapacity-1)
}
start, end := visibleServerRange(len(m.list), m.selected, rowCapacity)
for index := start; index < end; index++ {
lines = append(lines, m.renderForwardRow(m.list[index], index == m.selected, width, compact))
}
if showRange {
lines = append(lines, dashboardHelp(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list))))
}
return lines
}
func (m *forwardScreenModel) forwardDetailLines(width int, compact bool) []string {
lines := []string{dashboardSection("Selected rule")}
if m.selected < 0 || m.selected >= len(m.list) {
return append(lines, "", dashboardHelp("No rule selected."))
}
forward := m.list[m.selected]
name := forward.Name
if name == "" {
name = forward.ForwardListen()
}
if compact {
lines = append(lines, name+" · "+string(forward.Type))
} else {
lines = append(lines, "", name, string(forward.Type))
}
lines = append(lines, wrapCells(forward.ForwardHumanExplanation(m.serverAlias), max(1, width))...)
if !compact {
lines = append(lines, "")
}
lines = append(lines, dashboardHelp("ssh "+strings.Join(forward.ForwardSSHArgs(), " ")))
return lines
}
func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool, width int, compact bool) string {
func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool) string {
marker, name, kind, listen, target, enabled := " ", "NAME", "TYPE", "LISTEN", "TARGET", "ON"
if forward != nil {
if selected {
@ -181,30 +142,30 @@ func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected b
enabled = "no"
}
}
wide := m.width >= 70
typeWidth, enabledWidth := 8, 3
if !compact && width >= 58 {
flexible := max(3, width-typeWidth-enabledWidth-7)
nameWidth := max(1, flexible*30/100)
listenWidth := max(1, flexible*32/100)
targetWidth := max(1, flexible-nameWidth-listenWidth)
line := padCells(marker, 2) + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(listen, listenWidth) + " " + padCells(target, targetWidth) + " " + padCells(enabled, enabledWidth)
if wide {
nameWidth := max(12, (m.width-typeWidth-enabledWidth-6)*30/100)
listenWidth := max(14, (m.width-typeWidth-enabledWidth-nameWidth-6)/2)
targetWidth := m.width - nameWidth - typeWidth - listenWidth - enabledWidth - 5
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(listen, listenWidth) + " " + padCells(target, targetWidth) + " " + padCells(enabled, enabledWidth)
if forward == nil {
return listHeaderStyle.Render(fitLine(line, width))
return listHeaderStyle.Render(fitLine(line, m.width))
}
if selected {
return selectedRowStyle.Render(fitLine(line, width))
return selectedRowStyle.Render(fitLine(line, m.width))
}
return fitLine(line, width)
return fitLine(line, m.width)
}
nameWidth := max(1, width-typeWidth-enabledWidth-5)
line := padCells(marker, 2) + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth)
nameWidth := max(12, m.width-typeWidth-enabledWidth-4)
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth)
if forward == nil {
return listHeaderStyle.Render(fitLine(line, width))
return listHeaderStyle.Render(fitLine(line, m.width))
}
if selected {
return selectedRowStyle.Render(fitLine(line, width))
return selectedRowStyle.Render(fitLine(line, m.width))
}
return fitLine(line, width)
return fitLine(line, m.width)
}
// --- Forward form screen model ---
@ -222,7 +183,6 @@ type forwardFormModel struct {
nameInput textinput.Model
descInput textinput.Model
typeIdx int // 0=local, 1=remote, 2=socks
enabled bool
width int
height int
initial forwardFormSnapshot
@ -233,7 +193,6 @@ type forwardFormSnapshot struct {
description string
values []string
forwardType model.ForwardType
enabled bool
}
var forwardTypes = []forwardTypeItem{
@ -265,7 +224,6 @@ func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
focusIdx: 0,
currentType: model.ForwardLocal,
typeIdx: 0,
enabled: true,
nameInput: nameInput,
descInput: descInput,
width: w,
@ -284,7 +242,6 @@ func newForwardEditModel(serverID int64, fwd *model.Forward, w, h int) *forwardF
fm.descInput.SetValue(fwd.Description)
fm.currentType = fwd.Type
fm.typeIdx = typeIndex(fwd.Type)
fm.enabled = fwd.Enabled
if fwd.Type == model.ForwardRemote {
fm.inputs[0].SetValue(fwd.RemoteAddr)
fm.inputs[1].SetValue(strconv.Itoa(fwd.RemotePort))
@ -311,13 +268,12 @@ func (fm *forwardFormModel) snapshot() forwardFormSnapshot {
description: fm.descInput.Value(),
values: values,
forwardType: fm.currentType,
enabled: fm.enabled,
}
}
func (fm *forwardFormModel) Dirty() bool {
current := fm.snapshot()
if current.name != fm.initial.name || current.description != fm.initial.description || current.forwardType != fm.initial.forwardType || current.enabled != fm.initial.enabled || len(current.values) != len(fm.initial.values) {
if current.name != fm.initial.name || current.description != fm.initial.description || current.forwardType != fm.initial.forwardType || len(current.values) != len(fm.initial.values) {
return true
}
for i := range current.values {
@ -388,7 +344,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyTab:
fm.focusIdx++
total := 2 + 3 + 1 + len(fm.visibleFields()) + 1 // name + desc + type(3) + fields + save
total := 2 + 3 + len(fm.visibleFields()) + 1 // name + desc + type(3) + fields + save
if fm.focusIdx >= total {
fm.focusIdx = 0
}
@ -397,7 +353,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyShiftTab:
fm.focusIdx--
if fm.focusIdx < 0 {
total := 2 + 3 + 1 + len(fm.visibleFields()) + 1
total := 2 + 3 + len(fm.visibleFields()) + 1
fm.focusIdx = total - 1
}
fm.updateFocus()
@ -411,11 +367,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
fm.updateFocus()
return fm, nil
}
if fm.focusIdx == 2+3 {
fm.enabled = !fm.enabled
return fm, nil
}
if fm.focusIdx == 2+3+1+len(fm.visibleFields()) {
if fm.focusIdx == 2+3+len(fm.visibleFields()) {
return fm, fm.runSave()
}
fm.focusIdx++
@ -425,7 +377,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return fm, nil
case tea.KeyDown:
fm.focusIdx++
total := 2 + 3 + 1 + len(fm.visibleFields()) + 1
total := 2 + 3 + len(fm.visibleFields()) + 1
if fm.focusIdx >= total {
fm.focusIdx = 0
}
@ -434,16 +386,12 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyUp:
fm.focusIdx--
if fm.focusIdx < 0 {
total := 2 + 3 + 1 + len(fm.visibleFields()) + 1
total := 2 + 3 + len(fm.visibleFields()) + 1
fm.focusIdx = total - 1
}
fm.updateFocus()
return fm, nil
case tea.KeyRunes:
if fm.focusIdx == 2+3 && msg.String() == " " {
fm.enabled = !fm.enabled
return fm, nil
}
// Direct number keys select a type only while the type selector has focus.
if fm.focusIdx >= 2 && fm.focusIdx < 2+len(forwardTypes) && len(msg.Runes) == 1 {
switch msg.Runes[0] {
@ -479,8 +427,8 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return fm, cmd
}
visible := fm.visibleFields()
if fm.focusIdx >= 2+3+1 && fm.focusIdx < 2+3+1+len(visible) {
fieldIdx := visible[fm.focusIdx-(2+3+1)]
if fm.focusIdx >= 2+3 && fm.focusIdx < 2+3+len(visible) {
fieldIdx := visible[fm.focusIdx-(2+3)]
var cmd tea.Cmd
fm.inputs[fieldIdx], cmd = fm.inputs[fieldIdx].Update(msg)
return fm, cmd
@ -499,7 +447,7 @@ func (fm *forwardFormModel) updateFocus() {
fm.inputs[i].Prompt = blurredStyle.Render(fm.labelForField(i) + ": ")
}
total := 2 + 3 + 1 + len(fm.visibleFields()) + 1
total := 2 + 3 + len(fm.visibleFields()) + 1
switch {
case fm.focusIdx == 0:
fm.nameInput.Focus()
@ -509,96 +457,98 @@ func (fm *forwardFormModel) updateFocus() {
fm.descInput.Prompt = focusedStyle.Render("Description> ")
case fm.focusIdx >= 2 && fm.focusIdx < 2+3:
// Type selector focused — no input to focus
case fm.focusIdx == 2+3:
// Enabled toggle focused.
case fm.focusIdx >= 2+3+1 && fm.focusIdx < total-1:
case fm.focusIdx >= 2+3 && fm.focusIdx < total-1:
visible := fm.visibleFields()
fieldIdx := visible[fm.focusIdx-(2+3+1)]
fieldIdx := visible[fm.focusIdx-(2+3)]
fm.inputs[fieldIdx].Focus()
fm.inputs[fieldIdx].Prompt = focusedStyle.Render(fm.labelForField(fieldIdx) + "> ")
}
}
func (fm *forwardFormModel) buildForwardFromForm() (*model.Forward, error) {
name := strings.TrimSpace(fm.nameInput.Value())
if name == "" {
return nil, fmt.Errorf("name is required")
}
forward := &model.Forward{
ID: fm.editID,
ServerID: fm.serverID,
Name: name,
Description: strings.TrimSpace(fm.descInput.Value()),
Type: fm.currentType,
Enabled: fm.enabled,
}
var err error
switch fm.currentType {
case model.ForwardLocal:
forward.LocalAddr = strings.TrimSpace(fm.inputs[0].Value())
if forward.LocalAddr == "" {
forward.LocalAddr = "127.0.0.1"
}
forward.LocalPort, err = parseNamedPort("Listen port", fm.inputs[1].Value())
if err != nil {
return nil, err
}
forward.RemoteAddr = strings.TrimSpace(fm.inputs[2].Value())
if forward.RemoteAddr == "" {
return nil, fmt.Errorf("target host is required for local forward")
}
forward.RemotePort, err = parseNamedPort("Target port", fm.inputs[3].Value())
if err != nil {
return nil, err
}
case model.ForwardRemote:
forward.RemoteAddr = strings.TrimSpace(fm.inputs[0].Value())
if forward.RemoteAddr == "" {
return nil, fmt.Errorf("remote listen address is required")
}
forward.RemotePort, err = parseNamedPort("Remote listen port", fm.inputs[1].Value())
if err != nil {
return nil, err
}
forward.LocalAddr = strings.TrimSpace(fm.inputs[2].Value())
if forward.LocalAddr == "" {
forward.LocalAddr = "127.0.0.1"
}
forward.LocalPort, err = parseNamedPort("Local target port", fm.inputs[3].Value())
if err != nil {
return nil, err
}
case model.ForwardDynamic:
forward.LocalAddr = strings.TrimSpace(fm.inputs[0].Value())
if forward.LocalAddr == "" {
forward.LocalAddr = "127.0.0.1"
}
forward.LocalPort, err = parseNamedPort("Listen port", fm.inputs[1].Value())
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported forward type: %s", fm.currentType)
}
return forward, nil
}
func (fm *forwardFormModel) runSave() tea.Cmd {
return func() tea.Msg {
forward, err := fm.buildForwardFromForm()
if err != nil {
return saveDoneMsg{err: err}
name := strings.TrimSpace(fm.nameInput.Value())
desc := strings.TrimSpace(fm.descInput.Value())
localAddr, remoteAddr := "", ""
localPort, remotePort := 0, 0
var err error
if name == "" {
return saveDoneMsg{err: fmt.Errorf("name is required")}
}
switch fm.currentType {
case model.ForwardLocal:
localAddr = strings.TrimSpace(fm.inputs[0].Value())
if localAddr == "" {
localAddr = "127.0.0.1"
}
localPort, err = parseNamedPort("Listen port", fm.inputs[1].Value())
if err != nil {
return saveDoneMsg{err: err}
}
remoteAddr = strings.TrimSpace(fm.inputs[2].Value())
if remoteAddr == "" {
return saveDoneMsg{err: fmt.Errorf("target host is required for local forward")}
}
remotePort, err = parseNamedPort("Target port", fm.inputs[3].Value())
if err != nil {
return saveDoneMsg{err: err}
}
case model.ForwardRemote:
remoteAddr = strings.TrimSpace(fm.inputs[0].Value())
if remoteAddr == "" {
return saveDoneMsg{err: fmt.Errorf("remote listen address is required")}
}
remotePort, err = parseNamedPort("Remote listen port", fm.inputs[1].Value())
if err != nil {
return saveDoneMsg{err: err}
}
localAddr = strings.TrimSpace(fm.inputs[2].Value())
if localAddr == "" {
localAddr = "127.0.0.1"
}
localPort, err = parseNamedPort("Local target port", fm.inputs[3].Value())
if err != nil {
return saveDoneMsg{err: err}
}
case model.ForwardDynamic:
localAddr = strings.TrimSpace(fm.inputs[0].Value())
if localAddr == "" {
localAddr = "127.0.0.1"
}
localPort, err = parseNamedPort("Listen port", fm.inputs[1].Value())
if err != nil {
return saveDoneMsg{err: err}
}
remoteAddr = ""
remotePort = 0
}
fwd := &model.Forward{
ServerID: fm.serverID,
Name: name,
Description: desc,
Type: fm.currentType,
LocalAddr: localAddr,
LocalPort: localPort,
RemoteAddr: remoteAddr,
RemotePort: remotePort,
Enabled: true,
}
if fm.editMode {
fwd.ID = fm.editID
if UpdateForward == nil {
return saveDoneMsg{err: fmt.Errorf("update not available")}
}
return saveDoneMsg{err: UpdateForward(forward)}
return saveDoneMsg{err: UpdateForward(fwd)}
}
if SaveForward == nil {
return saveDoneMsg{err: fmt.Errorf("forward storage is unavailable")}
}
return saveDoneMsg{err: SaveForward(forward)}
err = SaveForward(fwd)
return saveDoneMsg{err: err}
}
}
@ -624,7 +574,7 @@ func (fm *forwardFormModel) applySaveError(err error) {
fieldIndex = 3
}
if fieldIndex >= 0 {
fm.focusIdx = 2 + len(forwardTypes) + 1 + fieldIndex
fm.focusIdx = 2 + len(forwardTypes) + fieldIndex
fm.updateFocus()
}
}
@ -645,77 +595,76 @@ func (fm *forwardFormModel) View() string {
if fm.editMode {
title = "Edit Port Forward"
}
notification := ""
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))}
lines = append(lines,
fitLine(fm.nameInput.View(), fm.width),
fitLine(fm.descInput.View(), fm.width),
)
typeParts := make([]string, len(forwardTypes))
for i, forwardType := range forwardTypes {
selected := "○"
if i == fm.typeIdx {
selected = "●"
}
focus := " "
if fm.focusIdx == 2+i {
focus = ">"
}
typeParts[i] = fmt.Sprintf("%s%s %d %s", focus, selected, i+1, forwardType.label)
}
lines = append(lines, fitLine("Type "+strings.Join(typeParts, " "), fm.width))
if fm.width >= 100 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine(forwardTypes[fm.typeIdx].description, fm.width)))
}
visible := fm.visibleFields()
for _, idx := range visible {
lines = append(lines, fitLine(fm.inputs[idx].View(), fm.width))
}
if localAddr := strings.TrimSpace(fm.inputs[0].Value()); localAddr == "0.0.0.0" {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("⚠ This port will be accessible from the network.", fm.width)))
}
if fm.width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
fwd := &model.Forward{
Type: fm.currentType,
LocalAddr: fm.inputs[0].Value(),
LocalPort: 0,
RemoteAddr: fm.inputs[2].Value(),
RemotePort: 0,
}
fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort)
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
preview := strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
lines = append(lines, fitLine("Preview ssh "+preview, fm.width))
}
total := 2 + 3 + len(visible) + 1
button := " [ Save ]"
if fm.focusIdx == total-1 {
button = selectedStyle.Render("> [ Save ]")
}
if fm.err != nil {
notification = errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err))
} else if fm.saved {
notification = successStyle.Render("✓ Saved.")
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)), fm.width))
}
body := func(width, height int) string {
contentWidth := max(1, width-4)
lines := []string{fm.nameInput.View(), fm.descInput.View()}
typeParts := make([]string, len(forwardTypes))
for i, forwardType := range forwardTypes {
selected := "○"
if i == fm.typeIdx {
selected = "●"
}
focus := " "
if fm.focusIdx == 2+i {
focus = ">"
}
typeParts[i] = fmt.Sprintf("%s%s %d %s", focus, selected, i+1, forwardType.label)
}
lines = append(lines, "Type "+strings.Join(typeParts, " "))
if width >= 100 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(forwardTypes[fm.typeIdx].description))
}
enabledMark := "[ ]"
if fm.enabled {
enabledMark = "[x]"
}
enabledLine := " Enabled " + enabledMark
if fm.focusIdx == 2+3 {
enabledLine = selectedStyle.Render("> Enabled " + enabledMark + " Enter/Space toggles")
}
lines = append(lines, enabledLine)
visible := fm.visibleFields()
for _, idx := range visible {
lines = append(lines, fm.inputs[idx].View())
}
if strings.TrimSpace(fm.inputs[0].Value()) == "0.0.0.0" {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render("⚠ This port will be accessible from the network."))
}
if width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
if fwd, err := fm.buildForwardFromForm(); err == nil {
preview := "Preview ssh " + strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
lines = append(lines, wrapCells(preview, contentWidth)...)
}
}
total := 2 + 3 + 1 + len(visible) + 1
button := " [ Save ]"
if fm.focusIdx == total-1 {
button = selectedStyle.Render("> [ Save ]")
}
lines = append(lines, "", button)
return renderPaddedPanel(width, height, lines)
if fm.saved {
lines = append(lines, successStyle.Render("✓ Saved."))
}
return renderScreenShell(screenShell{
breadcrumb: "Port Forwards / " + title,
status: string(fm.currentType),
notification: notification,
width: fm.width,
height: fm.height,
body: body,
footer: []helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "1/2/3", Action: "select type"},
{Key: "Enter/Space", Action: "toggle/save"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
lines = append(lines, button)
footer := renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "1/2/3", Action: "select type"},
{Key: "Enter", Action: "save"},
{Key: "Esc", Action: "back"},
}, fm.width)
lines = append(lines, strings.Split(footer, "\n")...)
if len(lines) > fm.height && fm.height > 0 {
lines = lines[:fm.height]
}
return strings.Join(lines, "\n")
}
// forwardEditSignal is sent when user wants to edit a forward

View File

@ -11,7 +11,7 @@ import (
func TestForwardFormDigitsReachFocusedInput(t *testing.T) {
fm := newForwardFormModel(1, 100, 30)
fm.focusIdx = 2 + len(forwardTypes) + 2
fm.focusIdx = 2 + len(forwardTypes) + 1
fm.updateFocus()
for _, digit := range []rune{'1', '2', '3'} {
@ -72,40 +72,6 @@ func TestRemoteForwardEditPopulatesSemanticFields(t *testing.T) {
}
}
func TestForwardEditPreservesDisabledState(t *testing.T) {
forward := &model.Forward{ID: 11, ServerID: 7, Name: "db", Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 15432, RemoteAddr: "127.0.0.1", RemotePort: 5432, Enabled: false}
fm := newForwardEditModel(7, forward, 80, 24)
if fm.enabled {
t.Fatal("disabled forward became enabled in edit form")
}
built, err := fm.buildForwardFromForm()
if err != nil {
t.Fatalf("build forward: %v", err)
}
if built.Enabled {
t.Fatal("disabled forward would be saved as enabled")
}
}
func TestRemoteForwardPreviewUsesSavedEndpointMapping(t *testing.T) {
fm := newForwardFormModel(7, 100, 30)
fm.currentType = model.ForwardRemote
fm.typeIdx = typeIndex(model.ForwardRemote)
fm.nameInput.SetValue("remote web")
fm.inputs[0].SetValue("0.0.0.0")
fm.inputs[1].SetValue("18080")
fm.inputs[2].SetValue("127.0.0.1")
fm.inputs[3].SetValue("8080")
built, err := fm.buildForwardFromForm()
if err != nil {
t.Fatalf("build preview forward: %v", err)
}
want := []string{"-R", "0.0.0.0:18080:127.0.0.1:8080"}
if got := built.ForwardSSHArgs(); !reflect.DeepEqual(got, want) {
t.Fatalf("preview args = %#v, want %#v", got, want)
}
}
func TestForwardFormDigitShortcutsWorkOnTypeSelector(t *testing.T) {
tests := []struct {
digit rune

View File

@ -3,6 +3,7 @@ package tui
import (
"fmt"
"io"
"strings"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbletea"
@ -11,9 +12,8 @@ import (
// --- Help screen (?) ---
type helpScreenModel struct {
list list.Model
width int
height int
list list.Model
width int
}
func newHelpScreenModel(w, h int) *helpScreenModel {
@ -26,12 +26,11 @@ func newHelpScreenModel(w, h int) *helpScreenModel {
helpScreenItem{key: "Ctrl+A", action: "Add server", section: "Server list"},
helpScreenItem{key: "Ctrl+E", action: "Edit server", section: "Server list"},
helpScreenItem{key: "Ctrl+F", action: "Search", section: "Server list"},
helpScreenItem{key: "Ctrl+X", action: "Server actions", section: "Server list"},
helpScreenItem{key: "m", action: "Manage groups / tags / templates / tunnels / vault", section: "Server list"},
helpScreenItem{key: "Ctrl+X", action: "Action menu", section: "Server list"},
helpScreenItem{key: "Ins", action: "Select / deselect", section: "Server list"},
helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Forwards"},
helpScreenItem{key: "?", action: "This quick help", section: "Other"},
helpScreenItem{key: "Ctrl+H", action: "Full documentation", section: "Other"},
helpScreenItem{key: "F1", action: "Full documentation", section: "Other"},
helpScreenItem{key: "Ctrl+Q", action: "Quit", section: "Other"},
}
@ -41,7 +40,7 @@ func newHelpScreenModel(w, h int) *helpScreenModel {
l.SetFilteringEnabled(false)
l.Styles.Title = titleStyle
return &helpScreenModel{list: l, width: w, height: h}
return &helpScreenModel{list: l, width: w}
}
type helpScreenItem struct {
@ -87,7 +86,6 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.list.SetSize(msg.Width, msg.Height-4)
return m, nil
}
@ -97,39 +95,10 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m *helpScreenModel) View() string {
items := m.list.Items()
body := func(width, height int) string {
innerRows := max(1, height-2)
start, end := visibleServerRange(len(items), m.list.Index(), innerRows)
lines := make([]string, 0, innerRows)
for index := start; index < end; index++ {
item, ok := items[index].(helpScreenItem)
if !ok {
continue
}
marker := " "
if index == m.list.Index() {
marker = "> "
}
lines = append(lines, marker+padCells(item.key, 12)+" "+item.action)
}
return renderPaddedPanel(width, height, lines)
}
return renderScreenShell(screenShell{
breadcrumb: "Quick Help",
status: "Keyboard reference",
width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "↑/↓", Action: "move"},
{Key: "Ctrl+H", Action: "full help"},
{Key: "Esc", Action: "back"},
},
})
return m.list.View()
}
// --- Full help (Ctrl+H) ---
// --- Full help (F1) ---
type fullHelpModel struct {
width int
@ -180,6 +149,11 @@ func (m *fullHelpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m *fullHelpModel) View() string {
var b strings.Builder
b.WriteString(titleStyle.Render("sshkeeper — Full Help"))
b.WriteString("\n\n")
sections := []struct {
title string
rows [][2]string
@ -200,7 +174,7 @@ func (m *fullHelpModel) View() string {
{"Enter", "Select / Confirm / Open"},
{"Esc", "Back / Cancel / Close"},
{"?", "Quick help (hotkeys)"},
{"Ctrl+H", "Full documentation"},
{"F1", "Full documentation"},
{"Ctrl+Q", "Quit"},
}},
{"Server list", [][2]string{
@ -208,29 +182,21 @@ func (m *fullHelpModel) View() string {
{"Ctrl+A", "Add server"},
{"Ctrl+E", "Edit server"},
{"Ctrl+F", "Search"},
{"Ctrl+X", "Server actions"},
{"m", "Manage global entities"},
{"Ctrl+X", "Action menu"},
{"Ins", "Select / deselect"},
}},
{"Server actions (Ctrl+X)", [][2]string{
{"Action menu (Ctrl+X)", [][2]string{
{"Connect", "Standard SSH session"},
{"Connect with tunnels", "SSH + all enabled forwards"},
{"Start tunnels only", "Forwards without shell"},
{"Start tunnels in bg", "Background tunnel process"},
{"Port forwards", "Add / edit / enable / delete forwards"},
{"Route", "Configure ordered bastions"},
{"Manage port forwards", "Add / edit / delete forwards"},
{"Manage tunnels", "View and stop running tunnels"},
{"Manage route", "Configure ProxyJump / bastions"},
{"Test connection", "Check if server is reachable"},
{"Edit", "Edit server profile"},
{"Delete", "Remove server profile"},
}},
{"Manage (m)", [][2]string{
{"Groups", "Create / rename / remove groups"},
{"Tags", "Manage and apply tags"},
{"Command templates", "Manage reusable commands"},
{"Running tunnels", "View and stop tracked tunnels"},
{"Import / Export", "Move server profile data"},
{"Vault", "Lock or change master password"},
}},
{"Routes / ProxyJump", [][2]string{
{"", "Routes define how to reach a server through jump hosts."},
{"● direct", "No jump host"},
@ -258,45 +224,47 @@ func (m *fullHelpModel) View() string {
}},
}
var lines []string
for _, sec := range sections {
lines = append(lines, sectionStyle.Copy().MarginTop(0).Render(sec.title))
b.WriteString(sectionStyle.Render(sec.title))
b.WriteString("\n")
for _, row := range sec.rows {
if row[0] == "" {
lines = append(lines, " "+row[1])
b.WriteString(fmt.Sprintf(" %s\n", row[1]))
} else {
lines = append(lines, fmt.Sprintf(" %-16s %s", row[0], row[1]))
b.WriteString(fmt.Sprintf(" %-16s %s\n", row[0], row[1]))
}
}
lines = append(lines, "")
b.WriteString("\n")
}
body := func(width, height int) string {
capacity := max(1, height-2)
start := min(m.offset, max(0, len(lines)-capacity))
end := min(len(lines), start+capacity)
return renderPaddedPanel(width, height, lines[start:end])
b.WriteString(helpStyle.Render(" ↑/↓ scroll — q/Esc/Enter close"))
// Simple scroll
lines := strings.Split(b.String(), "\n")
maxLines := m.height - 1
if maxLines < 5 {
maxLines = 5
}
return renderScreenShell(screenShell{
breadcrumb: "Full Help",
status: fmt.Sprintf("line %d/%d", min(m.offset+1, len(lines)), len(lines)),
width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "↑/↓", Action: "scroll"},
{Key: "Ctrl+H", Action: "full help"},
{Key: "Esc/Enter", Action: "close"},
},
})
start := m.offset
if start > len(lines)-maxLines {
start = len(lines) - maxLines
}
if start < 0 {
start = 0
}
end := start + maxLines
if end > len(lines) {
end = len(lines)
}
return strings.Join(lines[start:end], "\n")
}
// --- Action menu ---
type actionMenuItem struct {
label string
action string
description string
label string
action string
}
func (i actionMenuItem) Title() string { return i.label }
@ -305,60 +273,36 @@ func (i actionMenuItem) FilterValue() string { return i.label }
type actionMenuModel struct {
list list.Model
title string
width int
height int
}
func newActionMenuModel(w, h int, availability ...bool) *actionMenuModel {
sessionsAvailable := len(availability) > 0 && availability[0]
func newActionMenuModel(w, h int) *actionMenuModel {
items := []list.Item{
actionMenuItem{label: "Connect", action: "connect", description: "Open an interactive SSH session."},
actionMenuItem{label: "Connect", action: "connect"},
actionMenuItem{label: "Connect with tunnels", action: "tunnel"},
actionMenuItem{label: "Start tunnels only", action: "tunnel_n"},
actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg"},
actionMenuItem{label: "Manage port forwards", action: "forwards"},
actionMenuItem{label: "Manage tunnels", action: "tunnels"},
actionMenuItem{label: "Manage route", action: "route"},
actionMenuItem{label: "Test connection", action: "test"},
actionMenuItem{label: "Edit", action: "edit"},
actionMenuItem{label: "Delete", action: "delete"},
actionMenuItem{label: "Import", action: "import"},
actionMenuItem{label: "Export", action: "export"},
actionMenuItem{label: "Vault: lock", action: "vault_lock"},
actionMenuItem{label: "Vault: change password", action: "vault_change_pw"},
}
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."},
actionMenuItem{label: "Port forwards", action: "forwards", description: "Add, edit, enable, or remove forwarding rules for this server."},
actionMenuItem{label: "Route", action: "route", description: "Configure direct or bastion routing for this server."},
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, 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)
}
func newMenuModel(title string, items []list.Item, w, h int) *actionMenuModel {
l := list.New(items, list.NewDefaultDelegate(), 34, len(items)+2)
l.Title = title
l := list.New(items, list.NewDefaultDelegate(), 30, len(items)+2)
l.Title = "Actions"
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.SetShowHelp(false)
l.Styles.Title = titleStyle
return &actionMenuModel{list: l, title: title, width: w, height: h}
return &actionMenuModel{list: l, width: w, height: h}
}
func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
@ -380,44 +324,10 @@ func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
}
func (m *actionMenuModel) View() string {
body := func(width, height int) string {
listLines := m.actionLines(max(1, height-2))
if classifyShellContent(width) == sizeWide {
leftWidth := width * 48 / 100
rightWidth := width - leftWidth - 1
selected, _ := m.list.SelectedItem().(actionMenuItem)
detail := []string{dashboardSection("Selected action"), "", selected.label, ""}
detail = append(detail, wrapCells(selected.description, max(1, rightWidth-4))...)
return joinPanelColumns(
renderPaddedPanel(leftWidth, height, listLines), leftWidth,
renderPaddedPanel(rightWidth, height, detail), rightWidth,
)
}
if classifyShellContent(width) == sizeMedium {
if selected, ok := m.list.SelectedItem().(actionMenuItem); ok && len(listLines) < height-4 {
listLines = append(listLines, "", dashboardSection("Selected"), selected.description)
}
}
return renderPaddedPanel(width, height, listLines)
}
return renderScreenShell(screenShell{
breadcrumb: m.title,
status: fmt.Sprintf("%d actions", len(m.list.Items())),
width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "↑/↓", Action: "move"},
{Key: "Enter", Action: "select"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
}
func (m *actionMenuModel) actionLines(capacity int) []string {
footer := renderHelp([]helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Esc", Action: "back"}}, m.width)
lines := []string{titleStyle.Copy().MarginLeft(0).Render("Actions")}
capacity := max(1, m.height-displayLineCount(footer)-1)
start, end := visibleServerRange(len(m.list.Items()), m.list.Index(), capacity)
lines := make([]string, 0, capacity)
for index := start; index < end; index++ {
item, ok := m.list.Items()[index].(actionMenuItem)
if !ok {
@ -427,7 +337,11 @@ func (m *actionMenuModel) actionLines(capacity int) []string {
if index == m.list.Index() {
marker = "> "
}
lines = append(lines, marker+item.label)
lines = append(lines, fitLine(marker+item.label, m.width))
}
return lines
lines = append(lines, strings.Split(footer, "\n")...)
if len(lines) > m.height && m.height > 0 {
lines = lines[:m.height]
}
return strings.Join(lines, "\n")
}

View File

@ -1,7 +1,6 @@
package tui
import (
"fmt"
"strings"
"testing"
@ -47,7 +46,6 @@ func TestDashboardFitsSupportedTerminalSizes(t *testing.T) {
m := New(servers)
m.width, m.height = size.width, size.height
assertViewFits(t, m.View(), size.width, size.height)
assertRightMargin(t, m.View(), size.width)
for _, want := range []string{"sshkeeper", "Servers", "Vault", "Enter", "Ctrl+Q"} {
if !strings.Contains(m.View(), want) {
t.Fatalf("dashboard at %dx%d missing %q:\n%s", size.width, size.height, want, m.View())
@ -92,7 +90,6 @@ func TestServerFormFitsSupportedTerminalSizes(t *testing.T) {
fm.updateFocus()
view := fm.View()
assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Server", "Port *", "not-a-port", "Port must be", "Save", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
@ -110,9 +107,7 @@ func TestForwardFormFitsSupportedTerminalSizes(t *testing.T) {
fm.inputs[1].SetValue("15432")
fm.inputs[2].SetValue("database.internal.example")
fm.inputs[3].SetValue("5432")
view := fm.View()
assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
assertViewFits(t, fm.View(), size.width, size.height)
}
}
@ -125,7 +120,6 @@ func TestForwardListFitsSupportedTerminalSizes(t *testing.T) {
}
view := fm.View()
assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Port Forwards", "Local PostgreSQL", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("forward list at %dx%d missing %q:\n%s", size.width, size.height, want, view)
@ -139,8 +133,7 @@ func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) {
menu := newActionMenuModel(size.width, size.height)
view := menu.View()
assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Server Actions", "Connect", "Port forwards", "Esc"} {
for _, want := range []string{"Actions", "Connect", "Manage port forwards", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("action menu at %dx%d missing %q:\n%s", size.width, size.height, want, view)
}
@ -148,20 +141,6 @@ func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) {
}
}
func TestManageMenuFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
menu := newManageMenuModel(size.width, size.height)
view := menu.View()
assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Manage", "Groups", "Command templates", "Vault", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("manage menu at %dx%d missing %q:\n%s", size.width, size.height, want, view)
}
}
}
}
func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
m := New(nil)
@ -175,7 +154,6 @@ func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
})
view := m.View()
assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Local PostgreSQL", "not stopped.", "> [ Cancel ]", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("confirmation at %dx%d missing %q:\n%s", size.width, size.height, want, view)
@ -184,153 +162,6 @@ func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
}
}
func TestConfirmationKeepsActionsVisibleWithLongContent(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
m := New(nil)
m.width, m.height = size.width, size.height
m.beginConfirm(confirmState{
title: "Delete port forward?",
target: strings.Repeat("非常に長い-очень-длинный-🔐 ", 20),
consequence: strings.Repeat("Active connections can be interrupted. ", 20),
verb: "Delete",
parent: screenForwardList,
})
view := m.View()
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"[ Cancel ]", "[ Delete ]"} {
if !strings.Contains(view, want) {
t.Fatalf("confirmation at %dx%d clipped %q:\n%s", size.width, size.height, want, view)
}
}
}
}
func TestHelpScreensUseUnifiedShell(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
for name, view := range map[string]string{
"quick": newHelpScreenModel(size.width, size.height).View(),
"full": newFullHelpModel(size.width, size.height).View(),
} {
t.Run(name+itoa(size.width), func(t *testing.T) {
assertUnifiedScreen(t, view, size.width, size.height)
if strings.Contains(view, "F1") || !strings.Contains(view, "Ctrl+H") {
t.Fatalf("help exposes the wrong global binding:\n%s", view)
}
})
}
}
}
func TestManagerScreensUseUnifiedShell(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
m := New([]*model.Server{{Alias: "prod", Host: "prod.example", Port: 22, User: "ops"}})
m.width, m.height = size.width, size.height
template := &model.CommandTemplate{Name: "Disk usage", Command: "df -h", Description: "Show mounted filesystems"}
m.setTemplates([]*model.CommandTemplate{template})
m.setTags([]string{"production"})
m.setGroups([]*model.Group{{ID: 1, Name: "Production", ServerCount: 1}})
m.pendingTemplate = template
m.bgResults = []templateRunResult{{Alias: "prod", Output: "ok\n数据库 ready"}}
screens := []struct {
name string
screen screen
}{
{"search", screenSearch},
{"tags", screenTags},
{"tag-input", screenTagInput},
{"groups", screenGroups},
{"group-input", screenGroupInput},
{"templates", screenTemplates},
{"template-picker", screenTemplatePicker},
{"template-mode", screenTemplateMode},
{"background-results", screenBackgroundResults},
}
for _, tt := range screens {
m.screen = tt.screen
t.Run(tt.name+itoa(size.width), func(t *testing.T) {
assertUnifiedScreen(t, m.View(), size.width, size.height)
})
}
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)
}
}
func TestLayoutMatrixInventoriesEveryScreen(t *testing.T) {
covered := map[screen]string{
screenList: "dashboard",
screenForm: "server form",
screenSearch: "manager matrix",
screenTags: "manager matrix",
screenTagInput: "manager matrix",
screenGroups: "manager matrix",
screenGroupInput: "manager matrix",
screenTemplates: "manager matrix",
screenTemplateForm: "template form",
screenTemplatePicker: "manager matrix",
screenTemplateMode: "manager matrix",
screenBackgroundResults: "manager matrix",
screenHelp: "help matrix",
screenActionMenu: "action matrix",
screenManageMenu: "manage matrix",
screenForwardList: "forward matrix",
screenForwardForm: "forward form matrix",
screenSessionManager: "manager matrix",
screenTunnelManager: "manager matrix",
screenConfirm: "confirmation matrix",
screenFullHelp: "help matrix",
}
for value := screenList; value <= screenFullHelp; value++ {
if _, ok := covered[value]; !ok {
t.Fatalf("screen %d is missing from the layout matrix", value)
}
}
}
func TestShellBreakpointsUseTerminalWidth(t *testing.T) {
for _, tt := range []struct {
contentWidth int
want terminalSizeClass
}{{68, sizeNarrow}, {69, sizeMedium}, {98, sizeMedium}, {99, sizeWide}} {
if got := classifyShellContent(tt.contentWidth); got != tt.want {
t.Fatalf("content width %d classified as %v, want %v", tt.contentWidth, got, tt.want)
}
}
}
func TestTunnelErrorUsesUnifiedShellRows(t *testing.T) {
tunnelModel := newTunnelScreenModel(60, 16)
tunnelModel.tunnels = []*model.TunnelState{{Name: "prod tunnel", ServerAlias: "prod", LastError: "connection lost\nretry failed"}}
tunnelModel.rebuildList()
view := tunnelModel.View()
assertUnifiedScreen(t, view, 60, 16)
if !strings.Contains(view, "connection lost") || !strings.Contains(view, "retry failed") {
t.Fatalf("tunnel error was lost:\n%s", view)
}
}
func TestTemplateViewportKeepsSelectedDescribedItemVisible(t *testing.T) {
m := New(nil)
m.width, m.height = 60, 16
templates := make([]*model.CommandTemplate, 20)
for index := range templates {
templates[index] = &model.CommandTemplate{Name: fmt.Sprintf("template-%02d", index), Command: "echo ok", Description: "description"}
}
m.setTemplates(templates)
m.templateList.Select(len(templates) - 1)
m.screen = screenTemplates
view := m.View()
assertUnifiedScreen(t, view, 60, 16)
if !strings.Contains(view, "> template-19") {
t.Fatalf("selected template is outside viewport:\n%s", view)
}
}
func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
form := newTemplateFormModel(nil, size.width, size.height)
@ -338,7 +169,6 @@ func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
form.inputs[1].SetValue("printf 'a very long command that remains editable'")
view := form.View()
assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Template", "Name *", "Save", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("template form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
@ -347,21 +177,6 @@ func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
}
}
func TestServerFormDropdownUsesUnifiedShell(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
form := newFormModel(size.width, size.height)
form.focusIdx = 5
form.showAuthList = true
view := form.View()
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Select auth method", "password", "agent", "Enter", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("dropdown at %dx%d missing %q:\n%s", size.width, size.height, want, view)
}
}
}
}
func assertViewFits(t *testing.T, view string, width, height int) {
t.Helper()
lines := strings.Split(strings.TrimRight(view, "\n"), "\n")
@ -375,37 +190,6 @@ func assertViewFits(t *testing.T, view string, width, height int) {
}
}
func assertUnifiedScreen(t *testing.T, view string, width, height int) {
t.Helper()
lines := strings.Split(view, "\n")
if len(lines) != height {
t.Fatalf("unified screen has %d lines, want %d:\n%s", len(lines), height, view)
}
if !strings.HasPrefix(ansi.Strip(lines[0]), "sshkeeper / ") {
t.Fatalf("unified screen has no breadcrumb header: %q", ansi.Strip(lines[0]))
}
if !strings.Contains(ansi.Strip(view), "┌") || !strings.Contains(ansi.Strip(view), "┘") {
t.Fatalf("unified screen has no framed content:\n%s", view)
}
if strings.TrimSpace(ansi.Strip(lines[height-1])) == "" {
t.Fatalf("unified screen footer is not on last row:\n%s", view)
}
for index, line := range lines {
if got := ansi.StringWidth(line); got > width-1 {
t.Fatalf("line %d uses unsafe last terminal column: width=%d terminal=%d", index+1, got, width)
}
}
}
func assertRightMargin(t *testing.T, view string, width int) {
t.Helper()
for index, line := range strings.Split(view, "\n") {
if got := ansi.StringWidth(line); got > width-1 {
t.Fatalf("line %d uses unsafe last terminal column: width=%d terminal=%d", index+1, got, width)
}
}
}
type errText string
func (e errText) Error() string { return string(e) }

View File

@ -1,134 +0,0 @@
package tui
import (
"fmt"
"time"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
sessionpkg "github.com/mirivlad/sshkeeper/internal/session"
)
type sessionScreenModel struct {
list list.Model
sessions []sessionpkg.Window
width int
height int
err error
}
type sessionItem struct {
window sessionpkg.Window
}
func (i sessionItem) Title() string {
active := ""
if i.window.Active {
active = " active"
}
return fmt.Sprintf("%-28s #%d%s", truncate(i.window.ServerAlias, 28), i.window.Index, active)
}
func (i sessionItem) Description() string {
if i.window.StartedAt.IsZero() {
return "tmux window " + i.window.ID
}
return fmt.Sprintf("running %s · tmux %s", time.Since(i.window.StartedAt).Round(time.Second), i.window.ID)
}
func (i sessionItem) FilterValue() string {
return i.window.ServerAlias + " " + i.window.Name
}
func newSessionScreenModel(w, h int) *sessionScreenModel {
l := list.New([]list.Item{}, list.NewDefaultDelegate(), w, managerListHeight(h))
l.Title = "Sessions"
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.SetShowHelp(false)
l.Styles.Title = titleStyle
return &sessionScreenModel{list: l, width: w, height: h}
}
func (m *sessionScreenModel) loadSessions() tea.Cmd {
return func() tea.Msg {
windows, err := sessionpkg.List()
return sessionsLoadedMsg{sessions: windows, err: err, closed: true}
}
}
func (m *sessionScreenModel) setSessions(windows []sessionpkg.Window) {
m.sessions = windows
items := make([]list.Item, len(windows))
for index, window := range windows {
items[index] = sessionItem{window: window}
}
m.list.SetItems(items)
}
func (m *sessionScreenModel) selected() *sessionpkg.Window {
item, ok := m.list.SelectedItem().(sessionItem)
if !ok {
return nil
}
window := item.window
return &window
}
func (m *sessionScreenModel) closeSelected() tea.Cmd {
selected := m.selected()
if selected == nil {
return nil
}
id := selected.ID
return func() tea.Msg {
err := sessionpkg.Close(id)
windows, listErr := sessionpkg.List()
if err == nil {
err = listErr
}
return sessionsLoadedMsg{sessions: windows, err: err, closed: true}
}
}
func (m *sessionScreenModel) View() string {
notification := ""
if m.err != nil {
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
}
body := func(width, height int) string {
if len(m.sessions) == 0 {
return renderPaddedPanel(width, height, []string{dashboardHelp("No active SSH sessions.")})
}
capacity := max(1, height-2)
start, end := visibleServerRange(len(m.sessions), m.list.Index(), max(1, capacity/2))
lines := make([]string, 0, capacity)
for index := start; index < end; index++ {
item := sessionItem{window: m.sessions[index]}
marker := " "
if index == m.list.Index() {
marker = "> "
}
lines = append(lines, marker+item.Title(), " "+item.Description())
}
return renderPaddedPanel(width, height, lines)
}
return renderScreenShell(screenShell{
breadcrumb: "Sessions",
status: fmt.Sprintf("%d active · tmux", len(m.sessions)),
notification: notification,
width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "Enter", Action: "attach"},
{Key: "Ctrl+D (d)", Action: "close"},
{Key: "Ctrl+R (r)", Action: "refresh"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
}
type sessionsLoadedMsg struct {
sessions []sessionpkg.Window
err error
closed bool
}

View File

@ -1,24 +0,0 @@
package tui
import (
"testing"
sessionpkg "github.com/mirivlad/sshkeeper/internal/session"
)
func TestSessionCloseCommandMarksOperationComplete(t *testing.T) {
model := newSessionScreenModel(80, 24)
model.setSessions([]sessionpkg.Window{{ID: "@sshkeeper-test-missing", ServerAlias: "test"}})
cmd := model.closeSelected()
if cmd == nil {
t.Fatal("closeSelected returned nil command")
}
msg, ok := cmd().(sessionsLoadedMsg)
if !ok {
t.Fatalf("closeSelected returned %T, want sessionsLoadedMsg", cmd())
}
if !msg.closed {
t.Fatal("closeSelected did not mark the close operation complete")
}
}

View File

@ -1,133 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
type screenShell struct {
breadcrumb string
status string
notification string
width int
height int
body func(width, height int) string
footer []helpItem
}
func renderScreenShell(shell screenShell) string {
width, height := shell.width, shell.height
if width <= 0 {
width = 80
}
if height <= 0 {
height = 24
}
canvasWidth := max(1, width-1)
headerLeft := "sshkeeper"
if shell.breadcrumb != "" {
headerLeft += " / " + shell.breadcrumb
}
header := headerLeft
if shell.status != "" {
if gap := canvasWidth - lipgloss.Width(headerLeft) - lipgloss.Width(shell.status); gap > 0 {
header = headerLeft + strings.Repeat(" ", gap) + shell.status
} else {
header = headerLeft + " " + shell.status
}
}
footer := renderHelp(shell.footer, canvasWidth)
footerLines := splitBlock(footer)
if len(footerLines) == 0 {
footerLines = []string{""}
}
fixedRows := 2 + len(footerLines)
notificationLines := []string(nil)
if shell.notification != "" {
notificationLines = []string{fitLine(shell.notification, canvasWidth)}
fixedRows++
}
bodyHeight := max(1, height-fixedRows)
body := ""
if shell.body != nil {
body = shell.body(canvasWidth, bodyHeight)
}
bodyLines := fitBlock(body, canvasWidth, bodyHeight)
lines := make([]string, 0, height)
lines = append(lines,
titleStyle.Copy().MarginLeft(0).Render(fitLine(header, canvasWidth)),
helpStyle.Copy().MarginLeft(0).Render(strings.Repeat("─", canvasWidth)),
)
lines = append(lines, notificationLines...)
lines = append(lines, bodyLines...)
for _, line := range footerLines {
lines = append(lines, fitLine(line, canvasWidth))
}
if len(lines) > height {
lines = lines[:height]
}
for len(lines) < height {
lines = append(lines, "")
}
return strings.Join(lines, "\n")
}
func renderPaddedPanel(width, height int, lines []string) string {
if width < 4 || height < 2 {
return ""
}
contentWidth := width - 4
padded := make([]string, 0, len(lines))
for _, line := range lines {
padded = append(padded, " "+padCells(line, contentWidth)+" ")
}
return renderPanel(width, height, padded)
}
func fitBlock(block string, width, height int) []string {
lines := splitBlock(block)
if len(lines) > height {
lines = lines[:height]
}
for index := range lines {
lines[index] = fitLine(lines[index], width)
}
for len(lines) < height {
lines = append(lines, strings.Repeat(" ", width))
}
return lines
}
func splitBlock(block string) []string {
if block == "" {
return nil
}
return strings.Split(strings.TrimRight(block, "\n"), "\n")
}
func classifyShellContent(contentWidth int) terminalSizeClass {
terminalWidth := contentWidth + 1
if terminalWidth >= 100 {
return sizeWide
}
if terminalWidth >= 70 {
return sizeMedium
}
return sizeNarrow
}
func shellStatus(vaultUnlocked bool, detail string) string {
vault := "Vault locked"
if vaultUnlocked {
vault = "Vault unlocked"
}
if detail == "" {
return vault
}
return fmt.Sprintf("%s · %s", vault, detail)
}

View File

@ -1,62 +0,0 @@
package tui
import (
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
)
func TestScreenShellFitsAndAnchorsFooter(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
t.Run(itoa(size.width)+"x"+itoa(size.height), func(t *testing.T) {
view := renderScreenShell(screenShell{
breadcrumb: "Actions / production-数据库",
status: "Vault unlocked",
width: size.width,
height: size.height,
body: func(width, height int) string {
return renderPaddedPanel(width, height, []string{"Actions", "> Connect", " Manage port forwards"})
},
footer: []helpItem{{Key: "Enter", Action: "select"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
})
lines := strings.Split(view, "\n")
if len(lines) != size.height {
t.Fatalf("shell has %d lines, want %d:\n%s", len(lines), size.height, view)
}
if !strings.Contains(ansi.Strip(lines[0]), "sshkeeper / Actions") {
t.Fatalf("missing breadcrumb header: %q", ansi.Strip(lines[0]))
}
if !strings.HasPrefix(ansi.Strip(lines[2]), "┌") || !strings.HasSuffix(ansi.Strip(lines[size.height-2]), "┘") {
t.Fatalf("body panel does not fill shell:\n%s", view)
}
if !strings.Contains(ansi.Strip(lines[size.height-1]), "Ctrl+H") {
t.Fatalf("footer is not anchored to last row: %q", ansi.Strip(lines[size.height-1]))
}
for index, line := range lines {
if got := ansi.StringWidth(line); got > size.width-1 {
t.Fatalf("line %d uses unsafe last terminal column: width=%d, terminal=%d", index+1, got, size.width)
}
}
})
}
}
func TestScreenShellShowsNotificationWithoutMovingFooter(t *testing.T) {
view := renderScreenShell(screenShell{
breadcrumb: "Port Forwards / prod",
status: "2 rules",
notification: "Forward saved",
width: 60,
height: 16,
body: func(width, height int) string {
return renderPaddedPanel(width, height, []string{"Local PostgreSQL"})
},
footer: []helpItem{{Key: "Esc", Action: "back"}},
})
lines := strings.Split(view, "\n")
if len(lines) != 16 || !strings.Contains(view, "Forward saved") || !strings.Contains(ansi.Strip(lines[15]), "Esc") {
t.Fatalf("notification broke shell geometry:\n%s", view)
}
}

View File

@ -18,16 +18,16 @@ func TestVaultStatusTracksSuccessfulLock(t *testing.T) {
if !strings.Contains(m.View(), "Vault unlocked") {
t.Fatalf("initial status is not unlocked:\n%s", m.View())
}
m.manageMenu = newManageMenuModel(80, 24)
m.screen = screenManageMenu
for i := range m.manageMenu.list.Items() {
m.manageMenu.list.Select(i)
item, ok := m.manageMenu.list.SelectedItem().(actionMenuItem)
m.actionMenu = newActionMenuModel(80, 24)
m.screen = screenActionMenu
for i := range m.actionMenu.list.Items() {
m.actionMenu.list.Select(i)
item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem)
if ok && item.action == "vault_lock" {
break
}
}
updated, _ := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter})
updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
view := m.View()
if !strings.Contains(view, "Vault locked") || strings.Contains(view, "Vault unlocked") {
@ -45,15 +45,15 @@ func TestNotificationSurvivesRepeatedView(t *testing.T) {
}
}
func TestCtrlHFullHelpReturnsToOriginatingScreen(t *testing.T) {
func TestFullHelpReturnsToOriginatingScreen(t *testing.T) {
m := New(nil)
m.screen = screenForwardList
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlH})
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyF1})
m = updated.(*tuiModel)
if m.screen != screenFullHelp || m.fullHelp == nil {
t.Fatalf("Ctrl+H did not open full help from forward list: screen=%v", m.screen)
t.Fatalf("F1 did not open full help from forward list: screen=%v", m.screen)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(*tuiModel)
@ -62,27 +62,6 @@ func TestCtrlHFullHelpReturnsToOriginatingScreen(t *testing.T) {
}
}
func TestF1NoLongerOpensFullHelp(t *testing.T) {
m := New(nil)
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyF1})
m = updated.(*tuiModel)
if m.screen == screenFullHelp || m.fullHelp != nil {
t.Fatalf("F1 still opens full help: screen=%v", m.screen)
}
}
func TestBackspaceStillEditsSearchAfterCtrlHBinding(t *testing.T) {
m := New(nil)
m.screen = screenSearch
m.searchInput.SetValue("prod")
m.searchInput.Focus()
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
m = updated.(*tuiModel)
if m.screen != screenSearch || m.searchInput.Value() != "pro" {
t.Fatalf("Backspace did not edit search: screen=%v value=%q", m.screen, m.searchInput.Value())
}
}
func TestContextHelpReturnsToOriginatingManager(t *testing.T) {
m := New(nil)
m.screen = screenForwardList

View File

@ -151,40 +151,31 @@ func (tf *templateFormModel) save() tea.Cmd {
}
func (tf *templateFormModel) View() string {
var b strings.Builder
title := "Add Template"
if tf.edit {
title = "Edit Template"
}
notification := ""
if tf.err != nil {
notification = errorStyle.Render(tf.err.Error())
} else if tf.saved {
notification = successStyle.Render("✓ Saved.")
b.WriteString(titleStyle.Copy().MarginLeft(0).Render(fitLine(title, tf.width)))
b.WriteString("\n\n")
for i := range tf.inputs {
b.WriteString(fitLine(tf.inputs[i].View(), tf.width))
b.WriteString("\n")
}
return renderScreenShell(screenShell{
breadcrumb: "Command Templates / " + title,
status: "Template editor",
notification: notification,
width: tf.width,
height: tf.height,
body: func(width, height int) string {
lines := make([]string, 0, len(tf.inputs)+3)
for i := range tf.inputs {
lines = append(lines, tf.inputs[i].View())
}
button := " [ Save ]"
if tf.focusIdx == len(tf.inputs) {
button = selectedStyle.Render("> [ Save ]")
}
lines = append(lines, "", button)
return renderPaddedPanel(width, height, lines)
},
footer: []helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "Enter", Action: "select"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
button := " [ Save ]"
if tf.focusIdx == len(tf.inputs) {
button = selectedStyle.Render("> [ Save ]")
}
b.WriteString("\n" + button + "\n\n")
if tf.err != nil {
b.WriteString(errorStyle.Render(tf.err.Error()))
b.WriteString("\n")
}
b.WriteString(renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "Enter", Action: "select"},
{Key: "Esc", Action: "back"},
}, tf.width))
return b.String()
}

View File

@ -93,55 +93,20 @@ func (m *tunnelScreenModel) stopSelected() tea.Cmd {
return nil
}
func (m *tunnelScreenModel) runningCount() int {
count := 0
for _, state := range m.tunnels {
if state != nil && tunnel.IsRunning(state.ID) {
count++
}
}
return count
}
func (m *tunnelScreenModel) View() string {
notification := ""
var b strings.Builder
b.WriteString(m.list.View())
b.WriteString("\n\n")
if m.err != nil {
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
b.WriteString("\n\n")
}
body := func(width, height int) string {
if len(m.tunnels) == 0 {
return renderPaddedPanel(width, height, []string{dashboardHelp("No tracked tunnels.")})
}
capacity := max(1, height-2)
start, end := visibleServerRange(len(m.tunnels), m.list.Index(), max(1, capacity/3))
lines := make([]string, 0, capacity)
for index := start; index < end; index++ {
item := tunnelItem{state: m.tunnels[index]}
marker := " "
if index == m.list.Index() {
marker = "> "
}
lines = append(lines, marker+item.Title())
for _, description := range strings.Split(item.Description(), "\n") {
lines = append(lines, " "+strings.TrimSpace(description))
}
}
return renderPaddedPanel(width, height, lines)
}
return renderScreenShell(screenShell{
breadcrumb: "Tunnel Manager",
status: fmt.Sprintf("%d running · %d tracked", m.runningCount(), len(m.tunnels)),
notification: notification,
width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "Ctrl+D (s)", Action: "stop tunnel"},
{Key: "Ctrl+R (r)", Action: "refresh"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
b.WriteString(renderHelp([]helpItem{
{Key: "Ctrl+D (s)", Action: "stop tunnel"},
{Key: "Ctrl+R (r)", Action: "refresh"},
{Key: "Esc", Action: "back"},
}, m.width))
return b.String()
}
type tunnelsLoadedMsg struct {

View File

@ -88,14 +88,8 @@ func Get(id int64) *model.TunnelState {
return states[id]
}
// Start starts a tunnel without profile resolution (legacy/direct routes).
// Start starts a tunnel for the given server with its forwards.
func Start(cfg *config.Config, server *model.Server, forwards []*model.Forward, forwardOnly bool) (*model.TunnelState, error) {
return StartResolved(cfg, server, forwards, forwardOnly, nil)
}
// StartResolved starts a background tunnel and retains any generated OpenSSH
// config until the tunnel is stopped.
func StartResolved(cfg *config.Config, server *model.Server, forwards []*model.Forward, forwardOnly bool, resolve ssh.ProfileResolver) (*model.TunnelState, error) {
mu.Lock()
defer mu.Unlock()
@ -111,11 +105,9 @@ func StartResolved(cfg *config.Config, server *model.Server, forwards []*model.F
}
}
invocation, err := ssh.PrepareSSHInvocation(server, active, forwardOnly, resolve)
if err != nil {
return nil, err
}
args := append([]string(nil), invocation.Args...)
sshArgs := ssh.BuildSSHArgs(server, active, forwardOnly)
args := make([]string, len(sshArgs))
copy(args, sshArgs)
cmd := exec.Command(cfg.SSH.Binary, args...)
cmd.Env = os.Environ()
@ -124,7 +116,6 @@ func StartResolved(cfg *config.Config, server *model.Server, forwards []*model.F
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
invocation.Cleanup()
return nil, fmt.Errorf("start tunnel: %w", err)
}
@ -141,7 +132,6 @@ func StartResolved(cfg *config.Config, server *model.Server, forwards []*model.F
Name: fmt.Sprintf("Tunnel to %s", server.Alias),
PID: cmd.Process.Pid,
ForwardIDs: forwardIDs,
ConfigPath: invocation.ConfigPath,
StartedAt: time.Now(),
}
@ -149,12 +139,10 @@ func StartResolved(cfg *config.Config, server *model.Server, forwards []*model.F
if err := saveStates(); err != nil {
delete(states, id)
_ = cmd.Process.Kill()
invocation.Cleanup()
return nil, fmt.Errorf("save tunnel state: %w", err)
}
if err := cmd.Process.Release(); err != nil {
delete(states, id)
invocation.Cleanup()
return nil, fmt.Errorf("release tunnel process: %w", err)
}
@ -178,9 +166,6 @@ func Stop(id int64) error {
}
}
if state.ConfigPath != "" {
_ = os.Remove(state.ConfigPath)
}
delete(states, id)
return saveStates()
}
@ -197,9 +182,6 @@ func StopAll() error {
proc.Kill()
}
}
if state.ConfigPath != "" {
_ = os.Remove(state.ConfigPath)
}
delete(states, id)
}
return saveStates()

View File

@ -9,7 +9,6 @@ import (
"io"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
@ -68,10 +67,9 @@ type derivedKey struct {
}
type SecretMeta struct {
ID string
Alias string
ServerID int64
Type string
ID string
Alias string
Type string
}
func New(path string) *Vault {
@ -273,32 +271,24 @@ func (v *Vault) ListSecrets() ([]SecretMeta, error) {
metas := make([]SecretMeta, 0, len(v.records))
for id, record := range v.records {
alias, secretType, legacy := parseServerSecretID(id)
serverID := int64(0)
if !legacy {
var ok bool
serverID, secretType, ok = parseStableServerSecretID(id)
if !ok {
continue
}
alias, secretType, ok := parseServerSecretID(id)
if !ok {
continue
}
if record.secretType != "" {
secretType = record.secretType
}
metas = append(metas, SecretMeta{ID: id, Alias: alias, ServerID: serverID, Type: secretType})
metas = append(metas, SecretMeta{
ID: id,
Alias: alias,
Type: secretType,
})
}
sort.Slice(metas, func(i, j int) bool {
left, right := metas[i].Alias, metas[j].Alias
if left == "" {
left = fmt.Sprintf("#%d", metas[i].ServerID)
}
if right == "" {
right = fmt.Sprintf("#%d", metas[j].ServerID)
}
if left == right {
if metas[i].Alias == metas[j].Alias {
return metas[i].Type < metas[j].Type
}
return left < right
return metas[i].Alias < metas[j].Alias
})
return metas, nil
}
@ -505,14 +495,10 @@ func inferSecretType(id string, recordType string) string {
return recordType
}
_, secretType, ok := parseServerSecretID(id)
if ok {
return secretType
if !ok {
return ""
}
_, secretType, ok = parseStableServerSecretID(id)
if ok {
return secretType
}
return ""
return secretType
}
func parseServerSecretID(id string) (string, string, bool) {
@ -523,18 +509,6 @@ func parseServerSecretID(id string) (string, string, bool) {
return parts[1], parts[2], true
}
func parseStableServerSecretID(id string) (int64, string, bool) {
parts := strings.Split(id, ":")
if len(parts) != 3 || parts[0] != "server-id" || parts[1] == "" || parts[2] == "" {
return 0, "", false
}
serverID, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || serverID <= 0 {
return 0, "", false
}
return serverID, parts[2], true
}
func decryptRecord(key []byte, rec Record) ([]byte, error) {
aead, err := chacha20poly1305.NewX(key)
if err != nil {

View File

@ -266,24 +266,3 @@ func TestHasSecretReportsPresenceWithoutReturningValue(t *testing.T) {
t.Fatal("expected missing passphrase to be reported absent")
}
}
func TestListSecretsIncludesStableServerIDs(t *testing.T) {
path := filepath.Join(t.TempDir(), "vault.bin")
if err := Create(path, "master"); err != nil {
t.Fatalf("create: %v", err)
}
v := New(path)
if err := v.Unlock("master"); err != nil {
t.Fatalf("unlock: %v", err)
}
if err := v.Put("server-id:42:ssh_password", "ssh_password", []byte("secret")); err != nil {
t.Fatalf("put: %v", err)
}
metas, err := v.ListSecrets()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(metas) != 1 || metas[0].ServerID != 42 || metas[0].Type != "ssh_password" || metas[0].Alias != "" {
t.Fatalf("unexpected stable metadata: %#v", metas)
}
}

View File

@ -1,73 +0,0 @@
#!/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

View File

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

@ -1,97 +0,0 @@
#!/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

View File

@ -1,63 +0,0 @@
#!/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

@ -1,71 +0,0 @@
#!/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

@ -4,12 +4,10 @@ set -euo pipefail
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 github.com/mirivlad/sshkeeper/cmd.Version=${VERSION}"
VERSION=${VERSION:-${1:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}}
LDFLAGS="-s -w -X main.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}"
@ -29,16 +27,6 @@ package_docs() {
normalize_package() {
local package_dir="$1"
local binary="$2"
# Permissions must not depend on the builder's umask. Without this, a host
# with umask 002 packages 664/775 while one with umask 022 packages
# 644/755, and the archives differ even though every file inside is
# byte-identical.
find "${package_dir}" -type d -exec chmod 755 {} +
find "${package_dir}" -type f -exec chmod 644 {} +
chmod 755 "${package_dir}/${binary}"
find "${package_dir}" -exec touch -h -d "@${SOURCE_DATE_EPOCH}" {} +
}
@ -54,7 +42,7 @@ build_tarball() {
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o "${package_dir}/${APP}" .
package_docs "${package_dir}"
normalize_package "${package_dir}" "${APP}"
normalize_package "${package_dir}"
tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@${SOURCE_DATE_EPOCH}" -cf - -C "${DIST_DIR}" "$(basename "${package_dir}")" | gzip -n > "${archive}"
rm -rf "${package_dir}"
}
@ -71,15 +59,8 @@ build_zip() {
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o "${package_dir}/${APP}.exe" .
package_docs "${package_dir}"
normalize_package "${package_dir}" "${APP}.exe"
# LC_ALL=C: `sort` is locale-sensitive, and it decides the entry order here.
# A ru_RU.UTF-8 host orders docs/ before LICENSE where a C locale does the
# reverse, producing a different archive from identical files.
#
# TZ=UTC: zip records DOS local time with no zone, so the same build in
# +08:00 and in UTC would embed different timestamps. tar needs neither —
# it sorts internally and stores Unix epochs.
(cd "${DIST_DIR}" && export LC_ALL=C TZ=UTC && find "$(basename "${package_dir}")" -print | sort | zip -X -q "$(basename "${archive}")" -@)
normalize_package "${package_dir}"
(cd "${DIST_DIR}" && find "$(basename "${package_dir}")" -print | sort | zip -X -q "$(basename "${archive}")" -@)
rm -rf "${package_dir}"
}
@ -89,9 +70,7 @@ build_tarball darwin amd64
build_tarball darwin arm64
build_zip windows amd64
./packaging/build-linux-packages.sh "${VERSION}"
(cd "${DIST_DIR}" && sha256sum *.tar.gz *.zip *.deb *.rpm > checksums.txt)
(cd "${DIST_DIR}" && sha256sum *.tar.gz *.zip > checksums.txt)
echo "==> Done."
ls -lh "${DIST_DIR}/"*.tar.gz "${DIST_DIR}/"*.zip "${DIST_DIR}/"*.deb "${DIST_DIR}/"*.rpm "${DIST_DIR}/checksums.txt"
ls -lh "${DIST_DIR}/"*.tar.gz "${DIST_DIR}/"*.zip "${DIST_DIR}/checksums.txt"