From 8e59c3052e49f87d900056c9df2600f1fc59d146 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 6 Jun 2026 10:49:56 +0800 Subject: [PATCH] feat: add cross-platform release packaging --- Makefile | 15 ++++- README.md | 32 +++++++--- cmd/root.go | 3 +- docs/guide.md | 27 +++++++-- docs/release.md | 114 +++++++++++++++++++++++++++++++++++ internal/config/config.go | 10 ++- internal/ssh/command.go | 44 ++++++++++++++ internal/ssh/command_test.go | 34 +++++++++++ internal/ssh/pty.go | 2 + internal/ssh/pty_test.go | 2 + internal/ssh/pty_windows.go | 15 +++++ internal/tui/help_screen.go | 3 +- internal/tunnel/manager.go | 4 ++ release.sh | 71 ++++++++++++++++++---- 14 files changed, 348 insertions(+), 28 deletions(-) create mode 100644 docs/release.md create mode 100644 internal/ssh/pty_windows.go diff --git a/Makefile b/Makefile index 9a6ba15..6906c4d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ APP=sshkeeper +RELEASE_CHECK_DIR ?= /tmp/sshkeeper-release-check -.PHONY: build run test vet fmt clean install +.PHONY: build run test vet fmt clean install release-check build: go build -o bin/$(APP) . @@ -22,3 +23,15 @@ clean: install: go build -o $(HOME)/.local/bin/$(APP) . + +release-check: + rm -rf $(RELEASE_CHECK_DIR) + mkdir -p $(RELEASE_CHECK_DIR) + go test ./... + go vet ./... + 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 . + GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -o $(RELEASE_CHECK_DIR)/$(APP)-darwin-amd64 . + GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o $(RELEASE_CHECK_DIR)/$(APP)-darwin-arm64 . + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o $(RELEASE_CHECK_DIR)/$(APP)-windows-amd64.exe . diff --git a/README.md b/README.md index ef0e48e..920241d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # sshkeeper -`sshkeeper` is a Linux console manager for SSH profiles, secrets, and quick -OpenSSH launches. It does not replace OpenSSH; it keeps connection metadata in a +`sshkeeper` is a console manager for SSH profiles, secrets, and quick OpenSSH +launches. Primary target: Linux. macOS builds are available, and Windows builds +are experimental. It does not replace OpenSSH; it keeps connection metadata in a local SQLite database, keeps passwords/passphrases in an encrypted vault, and starts the system `ssh` client with the right options. @@ -42,10 +43,24 @@ Or use the build scripts: ```bash ./build.sh # Build binary to bin/ -./release.sh # Build release tarballs to dist/ +./release.sh # Build release archives to dist/ ``` -Requirements: Go 1.25+, Linux x86_64, system OpenSSH. +Requirements: Go 1.25+ and system OpenSSH. + +Platform status: + +| Platform | Status | Notes | +|----------|--------|-------| +| Linux | Primary target | `linux/amd64` and `linux/arm64` release tarballs are available. | +| macOS | Supported, needs verification on target machines | `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: + +```powershell +Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0 +``` **Source repositories:** - Main public: [github.com/mirivlad/sshkeeper](https://github.com/mirivlad/sshkeeper) @@ -55,8 +70,7 @@ Requirements: Go 1.25+, Linux x86_64, system OpenSSH. ```bash tar -xzf sshkeeper_v0.2.0_linux_amd64.tar.gz -chmod +x sshkeeper-linux-amd64 -sudo install -m 0755 sshkeeper-linux-amd64 /usr/local/bin/sshkeeper +sudo install -m 0755 sshkeeper_v0.2.0_linux_amd64/sshkeeper /usr/local/bin/sshkeeper sshkeeper ``` @@ -285,10 +299,13 @@ If `XDG_CONFIG_HOME` or `XDG_DATA_HOME` are set, sshkeeper stores data under ```bash go test ./... go build -o bin/sshkeeper . +make release-check ``` `bin/` is ignored by git. +For release packaging details, see [docs/release.md](docs/release.md). + ## Project Layout ```text @@ -302,8 +319,9 @@ sshkeeper/ ├── internal/vault/ # Encrypted vault ├── internal/tunnel/ # Tunnel state management ├── docs/guide.md # User guide +├── docs/release.md # Release packaging guide ├── build.sh # Build binary to bin/ -├── release.sh # Build release tarballs to dist/ +├── release.sh # Build release archives to dist/ └── main.go ``` diff --git a/cmd/root.go b/cmd/root.go index 65d8956..9a73075 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,7 +21,8 @@ var ( var rootCmd = &cobra.Command{ Use: "sshkeeper", Short: "sshkeeper — SSH connection manager", - Long: `sshkeeper is a console SSH connection manager for Linux. + Long: `sshkeeper is a console SSH connection manager. +Linux is the primary target; macOS builds are available and Windows is experimental. It manages server profiles, secrets, and provides a convenient way to launch SSH sessions using the system OpenSSH client.`, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/docs/guide.md b/docs/guide.md index 4ce5e11..abc892f 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -18,7 +18,7 @@ ## Что такое sshkeeper -sshkeeper — это консольный менеджер SSH-подключений для Linux. Он хранит профили серверов, секреты (пароли, фразы от ключей) и запускает системный `ssh` с нужными опциями. +sshkeeper — это консольный менеджер SSH-подключений. Основная целевая платформа — Linux; сборки для macOS доступны, Windows-сборка пока экспериментальная. Он хранит профили серверов, секреты (пароли, фразы от ключей) и запускает системный `ssh` с нужными опциями. **Чем sshkeeper НЕ является:** - Это не Ansible — он не настраивает серверы и не пушит файлы @@ -63,14 +63,27 @@ go build -o ~/.local/bin/sshkeeper . ./release.sh # сборка релизных архивов в dist/ ``` -**Требования:** Go 1.25+, Linux x86_64, системный OpenSSH. +**Требования:** Go 1.25+ и системный OpenSSH. + +Статус платформ: + +| Платформа | Статус | Примечание | +|-----------|--------|------------| +| 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: + +```powershell +Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0 +``` ### Из релиза (после публикации v0.2.0) ```bash tar -xzf sshkeeper_v0.2.0_linux_amd64.tar.gz -chmod +x sshkeeper-linux-amd64 -sudo install -m 0755 sshkeeper-linux-amd64 /usr/local/bin/sshkeeper +sudo install -m 0755 sshkeeper_v0.2.0_linux_amd64/sshkeeper /usr/local/bin/sshkeeper ``` --- @@ -192,7 +205,8 @@ sshkeeper — Quick Help sshkeeper — Full Help What is sshkeeper - sshkeeper is a Linux console SSH connection manager. + sshkeeper is a console SSH connection manager. + Linux is the primary target; macOS builds are available and Windows is experimental. It stores server profiles, secrets, and launches the system ssh client. Navigation @@ -782,6 +796,9 @@ go test ./... # Сборка go build -o bin/sshkeeper . +# Проверка релизной кросс-сборки +make release-check + # Или через скрипты ./build.sh # сборка в bin/ ./release.sh # релизные архивы в dist/ diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..9096cf5 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,114 @@ +# Release Packaging + +This document describes the manual release flow for sshkeeper. + +## Create a Tag + +Use a semantic version tag: + +```bash +git status --short +git tag -a v0.2.0 -m "sshkeeper v0.2.0" +git push origin v0.2.0 +``` + +The release script uses `git describe --tags --always --dirty` by default. You +can also pass the version explicitly: + +```bash +./release.sh v0.2.0 +``` + +or: + +```bash +VERSION=v0.2.0 ./release.sh +``` + +For reproducible archives, the script uses `SOURCE_DATE_EPOCH`. By default it +uses the timestamp of the latest git commit. To force a specific timestamp: + +```bash +SOURCE_DATE_EPOCH=1760000000 ./release.sh v0.2.0 +``` + +## Run Release Checks + +Before packaging, run: + +```bash +make release-check +``` + +This runs: + +- `go test ./...` +- `go vet ./...` +- native `go build` +- `linux/amd64` cross-build with `CGO_ENABLED=0` +- `linux/arm64` cross-build with `CGO_ENABLED=0` +- `darwin/amd64` cross-build with `CGO_ENABLED=0` +- `darwin/arm64` cross-build with `CGO_ENABLED=0` +- `windows/amd64` cross-build with `CGO_ENABLED=0` + +## Build Artifacts + +Run: + +```bash +./release.sh v0.2.0 +``` + +Expected files in `dist/`: + +```text +sshkeeper_v0.2.0_linux_amd64.tar.gz +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 +checksums.txt +``` + +Each archive contains: + +- `sshkeeper` or `sshkeeper.exe` +- `README.md` +- `LICENSE` +- `docs/guide.md` + +## Verify Checksums + +From the `dist/` directory: + +```bash +sha256sum -c checksums.txt +``` + +Expected result: every archive reports `OK`. + +## Publish in GitHub Release + +Upload these files to the release: + +- all five platform archives +- `checksums.txt` + +Release notes should mention platform status: + +- Linux is the primary target. +- macOS builds are available as tar.gz for amd64 and arm64 and require the + system `ssh` client. +- Windows build is experimental and requires OpenSSH Client available as + `ssh.exe` in `PATH`. + +## Packaging TODO + +Prepare these package channels after the first archive-based release: + +- deb package +- Arch PKGBUILD / AUR +- rpm later +- Homebrew tap +- Scoop manifest +- Winget later diff --git a/internal/config/config.go b/internal/config/config.go index c0fea49..316ae0c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "runtime" "github.com/BurntSushi/toml" ) @@ -34,7 +35,7 @@ type UIConfig struct { func defaultConfig() *Config { return &Config{ SSH: SSHConfig{ - Binary: "/usr/bin/ssh", + Binary: defaultSSHBinary(), ConnectTimeoutSec: 10, TestCommand: "echo SSHKEEPER_OK", }, @@ -47,6 +48,13 @@ func defaultConfig() *Config { } } +func defaultSSHBinary() string { + if runtime.GOOS == "windows" { + return "ssh.exe" + } + return "/usr/bin/ssh" +} + func Load() (*Config, error) { cfg := defaultConfig() diff --git a/internal/ssh/command.go b/internal/ssh/command.go index 896f6bb..f7743f0 100644 --- a/internal/ssh/command.go +++ b/internal/ssh/command.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/exec" + "runtime" "strings" "github.com/mirivlad/sshkeeper/internal/config" @@ -12,7 +13,34 @@ import ( type VaultFunc func(serverAlias string, secretType string) (string, error) +const windowsOpenSSHInstallHint = "Install OpenSSH Client via Windows Optional Features or PowerShell:\nAdd-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0" + +func EnsureSSHBinary(binary string) error { + return validateSSHBinaryForOS(runtime.GOOS, binary, exec.LookPath) +} + +func validateSSHBinaryForOS(goos string, binary string, lookPath func(string) (string, error)) error { + if goos == "windows" { + if _, err := lookPath("ssh.exe"); err != nil { + return fmt.Errorf("ssh.exe not found in PATH. Windows build is experimental and requires OpenSSH Client.\n%s", windowsOpenSSHInstallHint) + } + return nil + } + + if strings.TrimSpace(binary) == "" { + binary = "ssh" + } + if _, err := lookPath(binary); err != nil { + return fmt.Errorf("ssh binary not found (%s): %w", binary, err) + } + return nil +} + 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) @@ -49,6 +77,10 @@ func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error } 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) @@ -78,6 +110,10 @@ func RunCommand(cfg *config.Config, server *model.Server, getVault VaultFunc, co } func RunCommandOutput(cfg *config.Config, server *model.Server, getVault VaultFunc, command string) (string, error) { + if err := EnsureSSHBinary(cfg.SSH.Binary); err != nil { + return "", err + } + args := BuildSSHArgsSimple(server) args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec)) @@ -116,6 +152,10 @@ func RunCommandOutput(cfg *config.Config, server *model.Server, getVault VaultFu } func Test(cfg *config.Config, server *model.Server, getVault VaultFunc) (bool, string) { + if err := EnsureSSHBinary(cfg.SSH.Binary); err != nil { + return false, err.Error() + } + args := BuildSSHArgsSimple(server) args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec)) @@ -179,6 +219,10 @@ func testWithPassword(cfg *config.Config, args []string, password string) (bool, } func ConnectWithArgs(cfg *config.Config, args []string, vaultFunc VaultFunc, server *model.Server) error { + if err := EnsureSSHBinary(cfg.SSH.Binary); err != nil { + return err + } + switch server.AuthMethod { case model.AuthPassword: password, err := vaultFunc(server.Alias, "ssh_password") diff --git a/internal/ssh/command_test.go b/internal/ssh/command_test.go index 90d56e9..b26ebbd 100644 --- a/internal/ssh/command_test.go +++ b/internal/ssh/command_test.go @@ -104,6 +104,40 @@ func TestConnectRunsStartupCommand(t *testing.T) { } } +func TestValidateSSHBinaryForWindowsRequiresOpenSSHClient(t *testing.T) { + err := validateSSHBinaryForOS("windows", "ssh.exe", func(name string) (string, error) { + if name != "ssh.exe" { + t.Fatalf("expected lookup for ssh.exe, got %q", name) + } + return "", os.ErrNotExist + }) + + if err == nil { + t.Fatal("expected missing ssh.exe error") + } + if !strings.Contains(err.Error(), "OpenSSH Client") { + t.Fatalf("expected OpenSSH Client guidance, got %q", err) + } + if !strings.Contains(err.Error(), "Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0") { + t.Fatalf("expected PowerShell install command, got %q", err) + } +} + +func TestValidateSSHBinaryForNonWindowsChecksConfiguredBinary(t *testing.T) { + var lookedUp string + err := validateSSHBinaryForOS("linux", "/usr/bin/ssh", func(name string) (string, error) { + lookedUp = name + return "/usr/bin/ssh", nil + }) + + if err != nil { + t.Fatalf("validate ssh binary: %v", err) + } + if lookedUp != "/usr/bin/ssh" { + t.Fatalf("expected lookup for configured binary, got %q", lookedUp) + } +} + func TestBuildSSHArgs_Simple(t *testing.T) { server := &model.Server{Host: "example.org", Port: 22, User: "root"} args := BuildSSHArgsSimple(server) diff --git a/internal/ssh/pty.go b/internal/ssh/pty.go index 4961405..7571c5c 100644 --- a/internal/ssh/pty.go +++ b/internal/ssh/pty.go @@ -1,3 +1,5 @@ +//go:build !windows + package ssh import ( diff --git a/internal/ssh/pty_test.go b/internal/ssh/pty_test.go index 9d0271f..d6855fd 100644 --- a/internal/ssh/pty_test.go +++ b/internal/ssh/pty_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package ssh import ( diff --git a/internal/ssh/pty_windows.go b/internal/ssh/pty_windows.go new file mode 100644 index 0000000..c2cb202 --- /dev/null +++ b/internal/ssh/pty_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package ssh + +import "fmt" + +const windowsPTYUnsupportedMessage = "password and key-passphrase auth are not supported on Windows experimental builds; use key or agent auth with OpenSSH Client" + +func ConnectWithPassword(sshBinary string, args []string, password string) error { + return fmt.Errorf("%s", windowsPTYUnsupportedMessage) +} + +func connectWithPasswordAndRead(sshBinary string, args []string, password string, timeoutSec int) (bool, string) { + return false, windowsPTYUnsupportedMessage +} diff --git a/internal/tui/help_screen.go b/internal/tui/help_screen.go index 1d0e061..834c176 100644 --- a/internal/tui/help_screen.go +++ b/internal/tui/help_screen.go @@ -159,7 +159,8 @@ func (m *fullHelpModel) View() string { rows [][2]string }{ {"What is sshkeeper", [][2]string{ - {"", "sshkeeper is a Linux console SSH connection manager."}, + {"", "sshkeeper is a console SSH connection manager."}, + {"", "Linux is the primary target; macOS builds are available and Windows is experimental."}, {"", "It stores server profiles, secrets, and launches the system ssh client."}, {"", ""}, }}, diff --git a/internal/tunnel/manager.go b/internal/tunnel/manager.go index 227f624..3f7f96e 100644 --- a/internal/tunnel/manager.go +++ b/internal/tunnel/manager.go @@ -93,6 +93,10 @@ func Start(cfg *config.Config, server *model.Server, forwards []*model.Forward, mu.Lock() defer mu.Unlock() + if err := ssh.EnsureSSHBinary(cfg.SSH.Binary); err != nil { + return nil, err + } + // Filter enabled forwards var active []*model.Forward for _, f := range forwards { diff --git a/release.sh b/release.sh index 583130e..8327381 100755 --- a/release.sh +++ b/release.sh @@ -4,26 +4,73 @@ set -euo pipefail cd "$(dirname "$0")" APP=sshkeeper -VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev") +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)} echo "==> Building release ${APP} ${VERSION}..." +echo "==> SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}" rm -rf "${DIST_DIR}" mkdir -p "${DIST_DIR}" -# Linux amd64 -echo "==> linux/amd64..." -GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "${LDFLAGS}" -o "${DIST_DIR}/${APP}" . -tar -czf "${DIST_DIR}/${APP}_${VERSION}_linux_amd64.tar.gz" -C "${DIST_DIR}" "${APP}" -rm -f "${DIST_DIR}/${APP}" +package_docs() { + local package_dir="$1" -# Linux arm64 -echo "==> linux/arm64..." -GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "${LDFLAGS}" -o "${DIST_DIR}/${APP}" . -tar -czf "${DIST_DIR}/${APP}_${VERSION}_linux_arm64.tar.gz" -C "${DIST_DIR}" "${APP}" -rm -f "${DIST_DIR}/${APP}" + cp README.md LICENSE "${package_dir}/" + if [[ -f docs/guide.md ]]; then + mkdir -p "${package_dir}/docs" + cp docs/guide.md "${package_dir}/docs/" + fi +} + +normalize_package() { + local package_dir="$1" + find "${package_dir}" -exec touch -h -d "@${SOURCE_DATE_EPOCH}" {} + +} + +build_tarball() { + local goos="$1" + local goarch="$2" + local package_dir="${DIST_DIR}/${APP}_${VERSION}_${goos}_${goarch}" + local archive="${DIST_DIR}/${APP}_${VERSION}_${goos}_${goarch}.tar.gz" + + echo "==> ${goos}/${goarch}..." + rm -rf "${package_dir}" + mkdir -p "${package_dir}" + + GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o "${package_dir}/${APP}" . + package_docs "${package_dir}" + 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}" +} + +build_zip() { + local goos="$1" + local goarch="$2" + local package_dir="${DIST_DIR}/${APP}_${VERSION}_${goos}_${goarch}" + local archive="${DIST_DIR}/${APP}_${VERSION}_${goos}_${goarch}.zip" + + echo "==> ${goos}/${goarch}..." + rm -rf "${package_dir}" + mkdir -p "${package_dir}" + + 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}" + (cd "${DIST_DIR}" && find "$(basename "${package_dir}")" -print | sort | zip -X -q "$(basename "${archive}")" -@) + rm -rf "${package_dir}" +} + +build_tarball linux amd64 +build_tarball linux arm64 +build_tarball darwin amd64 +build_tarball darwin arm64 +build_zip windows amd64 + +(cd "${DIST_DIR}" && sha256sum *.tar.gz *.zip > checksums.txt) echo "==> Done." -ls -lh "${DIST_DIR}/"*.tar.gz +ls -lh "${DIST_DIR}/"*.tar.gz "${DIST_DIR}/"*.zip "${DIST_DIR}/checksums.txt"