diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 800013e..5676792 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Makefile b/Makefile index 6906c4d..9b4f106 100644 --- a/Makefile +++ b/Makefile @@ -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 . diff --git a/README.md b/README.md index 611b9ce..0adb9ef 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/build.sh b/build.sh index b014837..a9c7a02 100755 --- a/build.sh +++ b/build.sh @@ -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-N-g. 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} . diff --git a/cmd/root.go b/cmd/root.go index e4b9d58..adbb295 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 diff --git a/cmd/root_test.go b/cmd/root_test.go index 2db750b..96bfc35 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -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) + } + } +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..687fc3f --- /dev/null +++ b/cmd/version.go @@ -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() diff --git a/cmd/version_test.go b/cmd/version_test.go new file mode 100644 index 0000000..0d04b26 --- /dev/null +++ b/cmd/version_test.go @@ -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) + } +} diff --git a/docs/release.md b/docs/release.md index 51af280..6ca220c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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: diff --git a/docs/releases/v0.4.1.md b/docs/releases/v0.4.1.md new file mode 100644 index 0000000..8a24cb5 --- /dev/null +++ b/docs/releases/v0.4.1.md @@ -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. diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index f46e2a1..aec937c 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -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 diff --git a/packaging/scripts/postinstall.sh b/packaging/scripts/postinstall.sh new file mode 100755 index 0000000..a842984 --- /dev/null +++ b/packaging/scripts/postinstall.sh @@ -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 diff --git a/packaging/scripts/postremove.sh b/packaging/scripts/postremove.sh new file mode 100755 index 0000000..05d3e6c --- /dev/null +++ b/packaging/scripts/postremove.sh @@ -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 diff --git a/packaging/scripts/test-legacy-migration.sh b/packaging/scripts/test-legacy-migration.sh new file mode 100755 index 0000000..4b26396 --- /dev/null +++ b/packaging/scripts/test-legacy-migration.sh @@ -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" diff --git a/release.sh b/release.sh index 5fe8333..c2e3174 100755 --- a/release.sh +++ b/release.sh @@ -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