A command-line tool for managing SSH connections with an interactive interface, password management, and optional encryption.
- Overview
- Features
- Installation
- Usage
- Filtering the Server List
- Adding Servers Non-Interactively
- Server Notes, Tags, and Keep-Alive
- Port Forwarding
- File Transfer (put / get)
- Interactive SFTP Session
- Pre- and Post-Connect Hooks
- Environment Variables
- X11 Forwarding
- Parallel Command Broadcast
- Exporting Back to SSH Config
- Viewing a Single Server
- Jump Hosts (ProxyJump)
- Configuration
- Password Encryption
- Platform Support
- Contributing
- License
better-ssh simplifies SSH connection management by providing an interactive terminal interface for selecting and connecting to servers. It supports password storage with optional SSH key-based encryption, automatic password clipboard integration, and works across Windows, macOS, and Linux.
- Interactive server selection menu with search capabilities
- Pinned favorites that stay above recent history
- Interactive menus prioritize recently used servers automatically
- Import existing hosts from
~/.ssh/config - Password storage with optional SSH key-based encryption
- Automatic password clipboard integration
- Support for SSH private key and certificate authentication
- ProxyJump support — connect through a bastion host (or a chain of hosts)
- Optional SSH keep-alive per server (ServerAliveInterval)
- Per-server port forwarding presets (local
-L, remote-R, dynamic SOCKS-D) - Optional X11 forwarding per server (
ssh -X) - Per-server environment variables pushed to the remote session (
ssh -o SetEnv) - Pre- and post-connect shell hooks per server (VPN setup, cleanup, notifications)
- File transfer (
bssh put/bssh get) that reuses a server's stored profile - Round-trip with OpenSSH config:
bssh iscimports Host blocks;bssh escexports them back - Parallel command broadcast (
bssh exec) across matched servers with per-host colored output - Free-form notes and tags attached to each server
- Detailed per-server card view (
bssh view <name>) - Time-sorted "recents" list (
bssh recent) with a relativeLast usedcolumn - Server management (add, edit, remove, list)
bssh ls <query>filter by name, host, user, tag, id prefix, or jump host- Server availability checking (ping individual or health check all)
- Configuration backup and restore (export/import)
- Cross-platform compatibility (Windows, macOS, Linux)
- Short command aliases for faster workflow
- Flexible server identification (by name, partial name, or ID)
- Non-interactive CLI flags for scripted provisioning
- Auto-detection of SSH keys in standard locations
- Python 3.12 or higher
- uv package manager
- SSH client (OpenSSH)
- Clone the repository:
git clone https://github.com/aa-blinov/better-ssh.git
cd better-ssh- Install as a global tool:
uv tool install .- Verify installation:
bssh --helpBoth bssh and better-ssh are registered as entry points and work identically.
For development instead of a global install:
uv sync
uv run bssh --helpThe tool requires a system SSH client.
Windows:
# Via Windows Settings
Settings -> Apps -> Optional Features -> OpenSSH Client
# Via winget
winget install --id Microsoft.OpenSSH.Client -emacOS:
SSH client is pre-installed. If needed:
brew install opensshLinux:
# Ubuntu/Debian
sudo apt install openssh-client
# Fedora/RHEL
sudo dnf install openssh-clients
# Arch Linux
sudo pacman -S opensshbssh --install-completion # install for current shell
bssh --show-completion # preview without installingbssh
bssh <query>
bssh import-ssh-configbsshopens the interactive connect menu immediatelybssh <query>connects directly when the match is uniquebssh import-ssh-configbootstraps your saved hosts from~/.ssh/config
Usage: bssh [OPTIONS] COMMAND [ARGS]...
Better SSH: quick server selection, connection and password management.
Commands:
add Add a new server. Alias: a
connect Connect to a server. Alias: c
copy-pass Copy password to clipboard. Alias: cp
decrypt Disable password encryption. Alias: dec
edit Edit a server. Alias: e
encrypt Enable password encryption. Alias: enc
encryption-status Show encryption status. Alias: es
exec Run a command on one or more servers in parallel.
export Export servers to JSON file. Alias: ex
export-ssh-config Export servers to SSH config. Alias: esc
get Download a remote file/dir (scp).
health Check all servers availability. Alias: h
import Import servers from JSON file. Alias: im
import-ssh-config Import hosts from SSH config. Alias: isc
list Show list of servers. Alias: ls
pin Pin a server to the top of lists.
ping Check server availability. Alias: p
put Upload a local file/dir (scp).
recent Show recently used servers. Alias: r
remove Remove a server. Alias: rm
sftp Open an interactive SFTP session.
show-pass Show password. Alias: sp
unpin Remove a server from pinned favorites.
view Show a detailed card for a server. Alias: v
Run bssh without a subcommand to open the interactive connect menu immediately.
Run bssh <query> to connect directly when the match is unique. If the query is ambiguous or missing, the tool falls back to an interactive menu.
Use bssh pin <query> to keep critical hosts above the normal recent/frequent ordering, and bssh unpin <query> to remove them from favorites.
bssh recent (alias r) lists servers by most-recently-connected, newest first — independent of pin status. Cap the list with --limit N / -n N (default 10). Servers that were never connected via bssh don't appear. The rendered table includes a Last used column with a relative timestamp (just now, 5m ago, 2h ago, 3d ago, or an ISO date for anything older than 30 days). The same column is shown in bssh ls once at least one server has a recorded connection.
Most commands work without arguments and will present an interactive menu.
bssh add walks through the optional fields after Name/Host/Port/Username. Binary/complex fields (SSH key, password, jump host, keep-alive, forwards, env vars, pre/post hooks) use a y/N confirm before asking for the value. Free-text fields (note, tags) use a direct prompt with "(Enter to skip)" — a one-keystroke skip if you don't want one, or just type the value. All of these also have non-interactive flags for scripted use (see Adding Servers Non-Interactively).
For password-based hosts, bssh copies the saved password to your clipboard before starting OpenSSH. You still paste it into the SSH password prompt manually; the password is not injected into the ssh command automatically.
For detailed help on any command, use --help:
bssh connect --help
bssh add --helpbssh ls accepts an optional query that filters the table by substring against name, host, username, tags, jump host, and id prefix (all case-insensitive except the id prefix):
bssh ls prod # matches name/host/user/tag/jump containing "prod"
bssh ls bastion # surfaces both the bastion itself and everything routed through it
bssh ls a1b2 # id-prefix matchWhen nothing matches, the command prints a single friendly line instead of an empty table. Omitting the query lists everything as before.
Every field bssh add prompts for has an equivalent flag, so the full flow can be scripted. Pair with -s / --skip to suppress the "Add SSH key?", "Add password?", "Use a jump host?" etc. confirms — any field not passed via flag stays unset.
# Minimal non-interactive add:
bssh add --name prod-db --host 10.0.0.5 --username deploy -s
# With the full profile populated via flags:
bssh add \
--name prod-db \
--host 10.0.0.5 \
--port 22 \
--username deploy \
--key ~/.ssh/prod_ed25519 \
--certificate ~/.ssh/prod_ed25519-cert.pub \
--jump bastion \
--keep-alive 60 \
--notes "main postgres" \
-t prod -t db \
-sWithout -s, bssh add drops into the interactive confirm sequence for any field you didn't pre-populate, which is friendly for humans but inconvenient for Ansible/Terraform-style runs.
Flag reference:
| Flag | Short | Purpose |
|---|---|---|
--key <path> |
— | Path to SSH private key |
--certificate <path> |
— | Path to SSH certificate |
--password <value> |
— | Password (see security note below) |
--jump <name> |
-J |
Saved server name to use as ProxyJump (case-insensitive) |
--keep-alive <seconds> |
-K |
ServerAliveInterval in seconds; 0 leaves it disabled |
--notes <text> |
— | Free-form note attached to the server |
--tag <value> |
-t |
Tag (repeatable: -t prod -t db) |
--local-forward <spec> |
-L |
Local forward, repeatable: [bind:]port:host:port |
--remote-forward <spec> |
-R |
Remote forward, repeatable: [bind:]port:host:port |
--dynamic-forward <spec> |
-D |
Dynamic SOCKS forward, repeatable: [bind:]port |
--x11 |
— | Enable X11 forwarding (ssh -X) |
--env <K=V> |
-e |
Environment variable pushed via ssh -o SetEnv (repeatable) |
--pre <cmd> |
— | Shell command run locally BEFORE each connect (empty string clears) |
--post <cmd> |
— | Shell command run locally AFTER each connect (empty string clears) |
--skip |
-s |
Skip all interactive "Add X?" confirms (provisioning / scripts) |
Passing an empty string (--key "", --notes "") stores None — useful when a script wants to be explicit about clearing a field.
--passwordsecurity note: values passed on the command line end up in shell history (e.g.~/.bash_history),/proc/<pid>/cmdline, and process-listing tools. Prefer the interactive prompt when possible. The flag exists for provisioning scripts that source the password from a secure secret store at invocation time.
The same flag set is also available on bssh edit, plus --name, --host, --port, and --username for in-place field updates. By default, flags you pass are applied and everything else still goes through the interactive prompts, so mixing is fine:
# Apply the listed fields, interactively review the rest
bssh edit prod --keep-alive 60 --notes "updated" -t prod -t eu
# Update one field, then interactively review the rest
bssh edit prod --host 10.0.0.9Pair any flag with -s / --skip to bypass the interactive review entirely — only the flags you pass are applied, everything else is preserved verbatim:
# Clear just the pre-connect hook, no prompts
bssh edit prod --no-pre -s
# Update only the host, no prompts
bssh edit prod --host 10.0.0.9 -sEmpty-string clearing works here too: --jump "" drops the ProxyJump, --notes "" wipes the note, etc. The dedicated --no-pre / --no-post / --no-env / --no-forwards flags do the same thing without having to quote an empty string (useful on PowerShell, where --pre "" gets eaten before reaching the CLI).
Boolean toggles use Typer's --x11/--no-x11 syntax on bssh edit: pass --x11 to enable, --no-x11 to disable, or omit both to keep the current value.
Four optional per-server fields surface as columns in the ls / recent tables when at least one server has them set:
- Notes — free-form text attached to a server (
Notescolumn, truncated at 40 chars). Edit withbssh editor set at creation time with--notes. - Tags — small labels (e.g.
prod,db,eu-west) used to organize and filter the list (Tagscolumn). Tags are comma-separated in the interactive prompt or passed repeatedly as-t prod -t db.bssh ls <query>matches on tags, sobssh ls prodpulls every server labeled that way. - Keep-Alive — a
ServerAliveIntervalvalue in seconds. When set,bssh connectpasses-o ServerAliveInterval=<N> -o ServerAliveCountMax=3to OpenSSH, preventing NAT/idle disconnects (Alivecolumn, e.g.60s). Enter0at the prompt (or--keep-alive 0) to leave it disabled. - Last used — a relative timestamp (
5m ago,2h ago,3d ago, or an ISO date for older) automatically recorded on every successfulbssh connect. Surfaces in bothlsandrecentso you can see freshness at a glance.
All four columns are auto-hidden when no server has a value set.
bssh stores per-server port-forward presets and passes them to OpenSSH automatically on every bssh connect. Three forwarding types are supported, matching OpenSSH flags:
| Type | Flag | Example | Meaning |
|---|---|---|---|
| Local | -L |
-L 5432:localhost:5432 |
Forward local port 5432 to localhost:5432 on the remote side (typical: tunnel a remote DB to your laptop). |
| Remote | -R |
-R 9000:internal:9000 |
Forward remote port 9000 back to internal:9000 on your network (typical: expose a local service to the remote host). |
| Dynamic | -D |
-D 1080 |
Open a SOCKS proxy on local port 1080 that routes through the server. |
All three accept an optional bind address (e.g. 127.0.0.1:5432:localhost:5432).
Non-interactive provisioning:
bssh add --name prod-db --host 10.0.0.5 --username deploy \
-L 5432:localhost:5432 \
-L 6379:redis-internal:6379 \
-D 1080Interactive flow: bssh add and bssh edit ask "Configure port forwards?" — picking yes opens a small loop where you choose local/remote/dynamic and enter a spec, repeating until you pick (done).
Editing an existing server's forwards:
- Passing any
-L/-R/-Dflag tobssh editreplaces the entire forwards list (consistent with--tag). bssh edit <name> --no-forwardsclears all forwards.- Passing no forward-related flags keeps the existing list and opens the interactive prompt when no other flags trigger non-interactive mode.
bssh ls shows a Fwd column with the count (hidden when nobody has any). bssh view <name> lists each forward on its own line.
bssh put uploads and bssh get downloads, using scp under the hood and composing with the server's saved profile (port, key, certificate, jump host, keep-alive).
# Upload
bssh put prod-db ./backup.sql /var/backups/backup.sql
bssh put prod-db ./releases -r -C # directory with compression
bssh put prod-db ./archive.tar.gz /var/backups/ -p # keep mtime/atime/mode
# Download
bssh get prod-db /var/log/app.log ./app.log
bssh get prod-db /etc/nginx -r ./nginx-snapshot
bssh get prod-db /var/snapshots/data.bin ./data.bin -p # preserve timestampsFlag reference:
| Flag | Short | Purpose |
|---|---|---|
--recursive |
-r |
Recurse into directories (scp -r) |
--compress |
-C |
Enable scp compression |
--preserve |
-p |
Preserve modification times, access times, and modes (scp -p) |
Notes:
- Port forwards (
-L/-R/-D) and X11 on the server profile are not applied to transfers — they're connection-only settings. - Password authentication prompts come from OpenSSH itself;
bssh's clipboard copy is only wired intobssh connectandbssh sftp, notput/get. - ProxyJump uses the server's stored
jump_hostchain, same asconnect. - Requires
scpon PATH (shipped with OpenSSH client tools). Missing binary -> exit code 127.
bssh put / bssh get work great when you already know the remote path. When you don't — you want to browse, list, grab a few files — drop into a full SFTP session via bssh sftp:
bssh sftp # pick from the interactive menu
bssh sftp prod-db # go straight to this server
bssh sftp prod-db --no-copy # skip the password-to-clipboard copyThen you're at the sftp> prompt:
sftp> cd /var/log
sftp> ls -la
sftp> get app.log
sftp> put ./local-patch.sh /tmp/
sftp> bye
The session reuses the server's full profile — port, key, certificate, jump-host chain, keep-alive — and honors the same pre / post-connect hooks bssh connect does. So a VPN-up hook, an aws sso login refresh, or an sshfs mount that you configured for a server runs here too. Forwards / X11 / SetEnv are connection-oriented features and don't apply to sftp.
Requires the sftp binary on PATH (shipped with the OpenSSH client package). Missing binary exits with rc=127.
Two optional per-server shell commands that run locally around every bssh connect. Typical use cases: bringing up a corporate VPN, refreshing an AWS SSO token, mounting sshfs, posting to Slack, or running audit logging.
# Bring a VPN up before each connect, tear it down after:
bssh edit prod-db --pre "openvpn-connect corp" --post "openvpn-disconnect corp"
# Refresh SSO credentials before each connect:
bssh edit prod --pre "aws sso login --profile prod"
# Mount a remote filesystem alongside the interactive session:
bssh edit dev --pre "sshfs dev:/srv ~/remote-dev" --post "fusermount -u ~/remote-dev"
# Clear both:
bssh edit prod --no-pre --no-post # explicit clear
bssh edit prod --pre "" --post "" # empty-string form works tooSemantics:
- Pre runs before ssh. It is a prerequisite: if the hook exits non-zero,
bssh connectaborts with the hook's exit code and does not attempt ssh. The post hook is not run in this case. - Post always runs once pre has succeeded (or no pre was set) — after a successful ssh session, after Ctrl+C (rc 130), after an auth failure, and after an upfront config error like a broken jump-host reference where ssh was never launched. This is the place for cleanup and the "setup ran ⇒ teardown runs" contract holds even when ssh itself never starts. A non-zero post exit is reported as a yellow warning but does not override the final exit code.
- Commands execute through the platform shell (
sh -con POSIX,cmd.exe /con Windows). Pipes, redirects, and environment-variable expansion all work. - Hooks only apply to
bssh connect(and the interactivebssh <query>shortcut).bssh exec,bssh put,bssh getintentionally do not run hooks — they're non-interactive flows where running hooks per-host would be surprising.
Security note: the commands run on your local machine with your shell and your privileges. A servers.json imported from an untrusted source could ship a malicious pre/post hook; audit any imported config before letting it touch your machine.
Per-server environment variables pushed to every bssh connect via OpenSSH's SetEnv directive. Useful for fixing LANG / LC_ALL mismatches, flagging debug mode (DEBUG=1), injecting project / cluster context (DEPLOY_ENV=prod), etc.
bssh add --name prod --host h --username u \
-e LANG=en_US.UTF-8 \
-e DEPLOY_ENV=prod \
-s
bssh edit prod --env LANG=en_US.UTF-8 # replaces the whole env dict
bssh edit prod --no-env # clears all env vars
bssh view prod # shows "Environment: LANG=..., DEPLOY_ENV=..."Notes:
- Each pair becomes a separate
-o SetEnv=KEY=VALUEon the ssh command line. SetEnv(OpenSSH 7.8+) pushes a literal value and does not requireAcceptEnvon the remote sshd. Older servers silently drop unknown env vars.- Passing any
--envtobssh editreplaces the stored dict (symmetric with--tag). Use--no-envto clear. - Empty values are allowed (
--env FOO=), useful for unsetting an inherited variable pattern. Values may contain=(partitioned on the first=only).
Set x11_forwarding on a server to have bssh connect pass -X to OpenSSH. GUI apps launched on the remote side will try to render through a local X11 display.
bssh add --name workstation --host ws.example --username dev --x11
bssh edit workstation --x11 # turn on
bssh edit workstation --no-x11 # turn off
bssh view workstation # shows "X11: enabled (ssh -X)" row when onPrerequisites on the client side:
- Linux — an X11 display is usually already running; nothing extra to install.
- macOS — install XQuartz and log out/in once so
$DISPLAYis set. - Windows — install an X server: VcXsrv, Xming, or use WSLg (Windows 11). Without one, OpenSSH will log
X11 forwarding request failedand GUI apps will fail withCan't open display.
-X vs -Y: bssh emits -X (untrusted mode) which is the safer default. Some X11 apps — older xterm, certain Matplotlib backends — fail under the X11 SECURITY extension and need trusted mode (-Y). If you hit BadAccess / SECURITY errors, enable ForwardX11Trusted yes for that host in ~/.ssh/config — bssh's -X composes with your SSH config, so the directive takes effect.
bssh view <name> (alias v) renders a full-fidelity card for one server in a Rich panel. Unlike ls, nothing gets truncated — useful when you want to see the complete notes, the resolved jump chain (user@bastion:port → target-name), and the list of other servers that reference this one as a jump host.
bssh view prod
bssh v prod-dbThe view also flags a broken jump-host reference in red instead of failing silently, so you can spot stale ProxyJump targets before a connection attempt.
Import hosts from your OpenSSH config:
bssh iscOr import from a custom path:
bssh isc ~/.ssh/work-configThe importer resolves each host through ssh -G, so Host *, Include, explicit IdentityFile, and explicit CertificateFile are reflected in imported entries.
Default OpenSSH keys remain implicit. If a host works with plain ssh host because of default keys or ssh-agent, bssh will keep using that behavior without pinning a key path unless your SSH config explicitly does so.
When servers already exist locally, both bssh import (JSON) and bssh isc (SSH config) ask how to reconcile — keep existing + add new (merge) or wipe and replace (replace). Flags let you pick in scripts:
# Interactive: mode picker + explicit safety confirm naming the counts
bssh import backup.json
bssh isc
# Non-interactive: pre-pick the mode
bssh import backup.json --merge
bssh isc --replace
# CI / scripted (skip the safety prompt entirely):
bssh import backup.json --replace --yes # WIPES everything and imports
bssh isc --merge -yThe safety confirmation spells out the destructive action with counts: DELETE all 12 existing server(s) and import 3 from 'backup.json'? — so a reflex y doesn't silently wipe the store. Passing --merge and --replace together is rejected (rc=2).
bssh exec runs a single shell command on every server matched by a query, in parallel, with a per-host colored prefix on each output line and an aggregated summary at the end.
bssh exec "uptime" prod # every server matched by the 'prod' query
bssh exec "df -h /" --all # every saved server
bssh exec "systemctl is-active nginx" web --timeout 15The query matches the same fields as bssh ls / shorthand connect: name, host, username, tag, id prefix, or jump host. Use --all to skip the filter.
Flag reference:
| Flag | Purpose | Default |
|---|---|---|
--all |
Run on every saved server instead of filtering | off |
--timeout <seconds> |
Per-host overall timeout (remote command is killed after) | 30 |
--connect-timeout <seconds> |
TCP connect timeout (ssh -o ConnectTimeout) |
10 |
Each host's stdout lines are tagged with [<name>] in its own color, stderr lines are red, and a (<duration>, ok) or (<duration>, exit N) status line closes each host's output. The summary reports how many hosts succeeded, e.g. Summary: 8/10 ok in 2.3s. Exit code is 0 only when every host returned 0 without a connection-level error.
Known caveats:
- Runs use
ssh -o BatchMode=yesto avoid interleaved password prompts from parallel runs. Password-only servers will fail fast with a clear auth error — use key or certificate auth (or ssh-agent) forexec. - Port forwards and X11 forwarding on the server profile are ignored here (they're interactive-connection features).
- No concurrency cap in v1; every matched server gets its own
sshprocess. Fine up to low hundreds of hosts.
The reverse operation — turn every saved server into Host blocks that native OpenSSH tooling (ssh/scp/rsync/git/IDEs) can read directly:
bssh export-ssh-config ~/.ssh/config.bssh
# or the short alias:
bssh esc ~/.ssh/config.d/bssh.confEach server becomes a Host <name> block with every field that has an ssh_config equivalent:
HostName,User, non-defaultPortIdentityFile,CertificateFileProxyJumpServerAliveInterval+ServerAliveCountMax 3(when keep-alive is set)ForwardX11 yes(when X11 is enabled)LocalForward/RemoteForward/DynamicForward(one line per port forward, bind host preserved)
Fields that have no native ssh_config syntax (password, tags, notes) are written as leading comments so the context survives for a human reader.
After exporting, activate the file by adding
Include ~/.ssh/config.bssh
to your ~/.ssh/config, or pass -F ~/.ssh/config.bssh directly to ssh. Overwriting an existing file prompts for confirmation — pass -f / --force (or use bssh export -f for the JSON variant) to skip the prompt for scripts/CI. Exit code is 0 on success, 1 when the user declines the overwrite prompt, so pipelines can tell whether the file was actually written.
Every command that takes a <query> argument (connect, edit, view, remove, pin, unpin, ping, put, get, copy-pass, show-pass, ls, exec, health) resolves it the same way. Match order for a single-target command:
- Exact server ID
- Unique server-ID prefix
- Exact name (case-insensitive)
- Unique broad match — case-insensitive substring in name, host, username, any tag, or jump host
If step 4 finds more than one server, the command drops into an interactive picker over the matching candidates (same set bssh ls <query> would print). If nothing matches at all, the command prints a "not found" error.
bssh ls <query> and bssh exec <query> print / act on every matching server directly, so they skip the picker step; the match fields are the same.
Names are unique (case-insensitive): adding or renaming to a name already in use is rejected with an error pointing at the existing entry.
bssh supports connecting through one or more bastion hosts using OpenSSH's ProxyJump (-J). A jump host is just another saved server referenced by name.
During bssh add, after the basic fields, the tool asks:
Use a jump host (ProxyJump)? [y/N]
Confirming opens a picker over your other saved servers. The picker marks the current selection and shows how many other servers already use each candidate as a jump host.
For non-interactive use (scripts, automation), pass the name directly:
bssh add --name prod --host prod.example --username deploy --jump bastion
# short form
bssh add --name prod --host prod.example --username deploy -J bastionReference matching is case-insensitive; the canonical casing from the saved server is stored.
bssh editalways shows "Change jump host?" when one is set, opening the picker with a(none — direct connection)option first.- Renaming a server used as a jump host by others automatically updates all referencing servers in one save.
bssh rmwarns when the target is used as a jump host by others and offers to clearjump_hoston those dependents (default yes). Declining aborts the removal entirely.- Cycles (
A → B → A) and unknown references are rejected at save time, not silently accepted.
bssh isc reads the ProxyJump directive and sets jump_host when the referenced target matches another imported alias (case-insensitive).
- Bastion auth is not forwarded into
ssh -J. We pass onlyuser@host:portfor each hop; OpenSSH resolves credentials for the bastion through its own mechanisms (~/.ssh/config,ssh-agent, default keys). Akey_pathorpasswordsaved on the bastion entry inbsshis not used during a jump connection — if the bastion needs a specific key, declare it viaIdentityFilein~/.ssh/configor add it tossh-agent. This is a limitation ofssh -Jitself, notbssh. - Password clipboard covers only the target, not the bastion. You'll be prompted for the bastion's password separately during connection.
- Multi-hop
ProxyJump h1,h2and inlineuser@host:portspecs in~/.ssh/configare skipped during import. Only single-hop alias references to other imported hosts are preserved. Set multi-hop chains manually by adding each bastion as its own server and chainingjump_hostreferences.
Configuration files are stored in platform-specific directories:
- Windows:
%LOCALAPPDATA%\better-ssh\ - macOS:
~/Library/Application Support/better-ssh/ - Linux:
~/.config/better-ssh/
servers.json— server configurations and encrypted passwordssettings.json— application settings (encryption status, key source, salt)
By default, passwords are stored in plaintext. The application offers optional encryption using your SSH private key as the encryption key source.
bssh es # check encryption status
bssh enc # enable encryption (interactive)
bssh dec # disable encryption (interactive)
# For scripts / CI — skip the "are you sure?" prompt:
bssh enc -y
bssh dec -yWhen exporting servers, you can choose to export passwords in plaintext or encrypted format through an interactive prompt.
The encryption system uses your SSH private key (~/.ssh/id_ed25519 or id_rsa) to derive an encryption key via PBKDF2-HMAC-SHA256 with 100,000 iterations and a random per-installation salt stored in settings.json. Passwords are encrypted using Fernet (symmetric encryption) and stored in base64 format.
- Key Dependency: If you delete or modify your SSH key, encrypted passwords become inaccessible
- Machine Specific: Decryption requires the same SSH key and salt on the same machine
- Backup Recommended: Back up your SSH key and
settings.jsonbefore enabling encryption - Automatic Operation: Passwords are automatically encrypted on save and decrypted on load
- Passwords remain protected if the
servers.jsonfile is compromised - No master password required for daily use
- SSH key protected by operating system file permissions
- Random per-installation salt prevents precomputed key attacks
- Windows 10/11
- macOS 10.15+
- Linux (any distribution with Python 3.12+)
All Python dependencies are cross-platform. The only external requirement is a system SSH client, which is typically pre-installed on macOS and Linux.
Contributions are welcome. Please follow these guidelines:
- Check existing issues before creating a new one
- Include your operating system and Python version
- Provide steps to reproduce the problem
- Include relevant error messages or logs
- Fork the repository
- Clone your fork:
git clone https://github.com/aa-blinov/better-ssh.git - Create a virtual environment:
uv sync - Create a feature branch:
git checkout -b feature-name - Make your changes
- Follow PEP 8 style guidelines
- Use type hints for function signatures
- Write docstrings for public functions and classes
- Run linting:
uv run ruff check app tests - Format code:
uv run ruff format app tests - Ensure all checks pass before submitting
uv run pytest # run all tests
uv run pytest -v # verbose output
uv run pytest tests/test_models.py # specific file
uv run pytest --cov=app --cov-report=html # with coverage reportTest Structure:
tests/test_models.py— Server model teststests/test_domain.py— Pure domain helpers (tag parsing, name uniqueness, jump-chain validation, sort/filter)tests/test_encryption.py— Encryption/decryption teststests/test_storage.py— Configuration persistence teststests/test_ssh.py— SSH command and availability teststests/test_ssh_config.py— SSH config importer teststests/test_transfer.py— put / get scp wrapper teststests/test_exec_cmd.py— parallel broadcast command teststests/test_cli.py— CLI commands and interface tests
Source Layout:
app/
models.py Pydantic Server model
domain.py Pure logic (sort/filter, validation, tag parsing, jump-chain walking)
storage.py JSON file I/O with transparent encryption layering
encryption.py Fernet + PBKDF2 key derivation from an SSH key
ssh.py OpenSSH command construction, jump resolution, availability check
ssh_config.py ~/.ssh/config importer via `ssh -G`
cli/
__init__.py Package entry: builds the Typer app, re-exports for tests
_shared.py Typer app instance, Console, pickers, common helpers
connection.py connect, copy-pass, show-pass + root callback
manage.py add, edit, remove, view
organize.py list, pin, unpin
crypto.py encrypt, decrypt, encryption-status
backup.py export, import, import-ssh-config, export-ssh-config
health.py ping, health
transfer.py put, get (scp wrappers)
exec_cmd.py exec (parallel broadcast)
- Ensure all tests pass:
uv run pytest - Run linting:
uv run ruff check app tests - Format code:
uv run ruff format app tests - Commit your changes with clear, descriptive messages
- Push to your fork
- Submit a pull request with a description of your changes
- Ensure all CI checks pass
This project is licensed under the MIT License - see the LICENSE file for details.