Compare commits
No commits in common. "main" and "codex/tui-ux-redesign" have entirely different histories.
main
...
codex/tui-
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
12
Makefile
|
|
@ -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 .
|
||||
|
|
|
|||
99
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
7
build.sh
|
|
@ -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} .
|
||||
|
|
|
|||
79
cmd/add.go
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
}
|
||||
|
|
|
|||
152
cmd/edit.go
|
|
@ -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 ©Server
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
70
cmd/extra.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
24
cmd/root.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
100
cmd/route.go
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
180
cmd/secrets.go
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
117
cmd/tui.go
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
11
cmd/vault.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
136
docs/guide.md
|
|
@ -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` | Назад / отмена |
|
||||
|
||||
|
|
|
|||
113
docs/release.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (70–99) stacks them, narrow (60–69) 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.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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.
|
||||
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 25 KiB |
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ",")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
35
release.sh
|
|
@ -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"
|
||||
|
|
|
|||