fix: make package install authoritative

This commit is contained in:
mirivlad 2026-09-06 11:55:50 +08:00
parent b481bb9f3e
commit dd7ea6012d
15 changed files with 410 additions and 13 deletions

View File

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

View File

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

View File

@ -71,21 +71,34 @@ Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
Debian/Ubuntu (amd64):
```bash
sudo apt install ./sshkeeper_0.4.0-1_amd64.deb
sudo apt install ./sshkeeper_0.4.1-1_amd64.deb
```
Fedora/RHEL-family (x86_64):
```bash
sudo dnf install ./sshkeeper-0.4.0-1.x86_64.rpm
sudo dnf install ./sshkeeper-0.4.1-1.x86_64.rpm
```
`arm64`/`aarch64` packages are published alongside the x86_64 builds. The
traditional tar.gz archive remains available too:
`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
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
command -v sshkeeper
sshkeeper --version
# or: sshkeeper version
```
The traditional tar.gz archive remains available too:
```bash
tar -xzf sshkeeper_v0.4.1_linux_amd64.tar.gz
sudo install -m 0755 sshkeeper_v0.4.1_linux_amd64/sshkeeper /usr/local/bin/sshkeeper
sshkeeper
```

View File

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

View File

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

View File

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

24
cmd/version.go Normal file
View File

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

32
cmd/version_test.go Normal file
View File

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

View File

@ -131,6 +131,14 @@ 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:

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

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

View File

@ -39,9 +39,15 @@ overrides:
deb:
depends:
- openssh-client
scripts:
postinstall: ./packaging/scripts/postinstall.sh
postremove: ./packaging/scripts/postremove.sh
rpm:
depends:
- openssh-clients
scripts:
postinstall: ./packaging/scripts/postinstall.sh
postremove: ./packaging/scripts/postremove.sh
rpm:
compression: gzip

View File

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

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

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

View File

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

View File

@ -6,7 +6,7 @@ cd "$(dirname "$0")"
APP=sshkeeper
# --match 'v*' ignores the rolling `nightly` tag; see build.sh for the details.
VERSION=${VERSION:-${1:-$(git describe --tags --match 'v*' --always --dirty 2>/dev/null || echo "dev")}}
LDFLAGS="-s -w -X main.version=${VERSION}"
LDFLAGS="-s -w -X github.com/mirivlad/sshkeeper/cmd.Version=${VERSION}"
DIST_DIR="dist"
SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2>/dev/null || date +%s)}
export SOURCE_DATE_EPOCH