A fast, modern SSH + container manager for your terminal.
SSHM is a TUI & CLI tool written in Rust to manage and connect to SSH hosts, Docker containers, Incus instances, and Kubernetes pods — all from one keyboard-driven interface.
Built for developers, sysadmins, pentesters, and homelab folks who live in a terminal.
- Host management — add, edit, delete, tag, organize into nested folders
- Clone host —
yduplicates the selected host (tunnels included) and drops you straight into the editor - Fuzzy search + prefix filters —
tag:prod host:10.* user:ubuntu, fzf-style scoring - Tunnels — saved per-host port forwards (local
-L, remote-R, dynamic SOCKS-D); start them in the background and watch / stop them from thetdashboard - Multi-hop ProxyJump —
bastion1,bastion2, each entry resolves against your saved hosts automatically - Identity management — push SSH public keys, generate new keys (
ed25519,ed25519-skFIDO2,ecdsa,rsa), load intossh-agent - ForwardAgent (
-A) per host — opt-in with a visible warning, badged in the list - Mosh per host — opt-in toggle; connects via
moshinstead ofssh, forwarding port / identity / ProxyJump automatically - Run-on-connect — a per-host command run at login (
RemoteCommand+-t), then you land in a normal shell; start it withexecto take over the session yourself - Copy connection string —
Ycopiesuser@hostto the clipboard (pbcopy/wl-copy/xclip/xsel) - Per-host notes — free-text reminder shown in the detail panel
- Hardware key detection —
[HW]badge for*-skkeys - Frecency sort + Recently Used —
scyclesname → MRU → most-used → favorites → frecency - Group by tag —
gtoggles between folder view and tag view - Bulk actions —
Spaceselects,Tadds tags to selection,Ddeletes,Cclears - Fan-out —
Xruns one command on every selected host over SSH, with per-host output and an ok/failed summary - Quick connect —
1-9connects to the Nth visible host - Health probes — periodic TCP + SSH banner check, latency in ms, banner version (
OpenSSH_9.6) shown inline
A dedicated tab between Hosts and Identities to manage containers and pods:
- Docker (local) — auto-detected if
dockeris on PATH and the daemon is up - Docker (remote) — pick any saved SSH host, sshm sets
DOCKER_HOST=ssh://...and tunnels everything natively. No port to open, no TLS, no socket setup - Apple
container(macOS) — Apple's native container runtime (macOS 26+, Apple silicon) auto-detected when thecontainerCLI and its system service are up. Lists / shells / logs / start-stop, same as Docker - Incus (local) — auto-detected, lists containers and VMs
- Incus (remote) — auto-imported from
incus remote list - Kubernetes / K3s — auto-imported from every context in
~/.kube/configand$KUBECONFIG - One Enter to shell into any container / pod / instance —
/bin/shdirectly, no bash dance - Rich detail view —
iopens a scrollable inspect panel: overview (image, status, CPU/mem, platform), networking (IPs, gateway, MAC), published ports, volumes, entrypoint/command, and a live log tail. Backed bydocker/container inspect - One
lto follow logs —Ctrl+Creturns to the TUI cleanly (no app exit) - Lifecycle control —
sstarts/stops andRrestarts Docker/Apple containers and Incus instances right from the list - Pod cleanup —
don aSucceeded/Failedpod runskubectl delete pod - Section folding — clusters collapsed by default,
Enteron a header toggles - Live filter —
/fuzzy-filters containers, pods and instances across every section (force-expands while filtering) - Live discovery — background worker polls every
kluster_refresh_secs(configurable in Settings)
- i18n — UI strings translatable; English + French bundled. Pick via
SSHM_LANG=fr - Themes — fully customizable colors via
theme.toml, with an optional transparent background that uses the terminal's own - Toast notifications — non-intrusive feedback for actions
- Desktop notifications — native OS alerts (
notify-send/osascript) when a background tunnel drops or a host changes reachability - Open in a new terminal —
olaunches the SSH session in a separate terminal window (auto-detected, or setexternal_terminal) - Host-key trust — vet fingerprints with
F; on connect, a never-seen host offers trust-on-first-use with its fingerprint shown, and a changed host key is detected and offers to wipe the staleknown_hostsentry and reconnect - Auto-export — optionally writes a clean
~/.ssh/configon every save - Config sync over git — keep your hosts, clusters and theme in a private git repo, authenticated with an SSH key. Manual, scheduled or cron-driven; several running instances share one schedule and only one of them ever syncs
- CLI mode — scriptable commands for automation
brew tap Sn0wAlice/sshm https://github.com/Sn0wAlice/sshm
brew install sshmGrab the latest binary from the Releases page.
Linux (amd64)
curl -sL https://github.com/Sn0wAlice/sshm/releases/latest/download/sshm-linux-amd64.tar.gz | tar xz
sudo mv sshm /usr/local/bin/Linux (arm64)
curl -sL https://github.com/Sn0wAlice/sshm/releases/latest/download/sshm-linux-arm64.tar.gz | tar xz
sudo mv sshm /usr/local/bin/macOS (Apple Silicon)
curl -sL https://github.com/Sn0wAlice/sshm/releases/latest/download/sshm-darwin-arm64.tar.gz | tar xz
sudo mv sshm /usr/local/bin/git clone https://github.com/Sn0wAlice/sshm.git
cd sshm
cargo build --release
sudo cp target/release/sshm /usr/local/bin/Requirements:
- Rust stable toolchain (build only)
sshclient (always)dockerCLI on PATH (for the Docker section of the Kluster tab — local and remote)kubectlon PATH (for k8s/k3s clusters)incusCLI on PATH (for Incus instances)- A terminal with UTF-8 & ANSI support
The Kluster tab degrades gracefully — sections show (unavailable) when the corresponding CLI / daemon isn't reachable.
The repo is a Cargo workspace:
crates/sshm-core # frontend-agnostic engine (models, config IO, ssh/kluster, filter, i18n)
crates/sshm # the TUI + CLI (binary `sshm`) — unchanged
crates/sshm-gui # the desktop app (binary `sshm-desktop`), Svelte + Tauri 2
The GUI shares the exact same on-disk database (~/.config/sshm/*), so a host added
in the terminal shows up in the app live, and vice-versa. It's a launcher: ssh
sessions open in your external terminal (never embedded), and it never reads or
writes private-key contents — only paths, exactly like the TUI.
# One-time: install the frontend toolchain
cd crates/sshm-gui
npm install
# Dev (hot-reload UI + Rust)
npm run tauri dev # alias: gui:dev
# Production bundle (.app / .dmg / .deb / AppImage)
npm run tauri build # alias: gui:buildRequirements: Node 18+ and the Tauri prerequisites for your OS
(macOS: Xcode CLT; Linux: libwebkit2gtk-4.1-dev, libgtk-3-dev, librsvg2-dev).
Typed IPC (tauri-specta) derives the TypeScript types from the Rust structs, so the
frontend and backend never drift — the generated crates/sshm-gui/src/lib/bindings.ts
is refreshed on every tauri dev, or headlessly with
cargo test -p sshm-desktop export_bindings.
cargo build --release at the workspace root builds only the TUI (target/release/sshm);
the desktop app is built explicitly (-p sshm-desktop or via the Tauri CLI).
sshmThe TUI has 6 tabs (← / → to switch):
| Tab | Purpose |
|---|---|
| Hosts | SSH host list with folders, tags, tunnels, identity management |
| Kluster | Docker / Incus / k8s containers and pods |
| Identities | Local SSH keys (~/.ssh), generate, push, load into agent |
| Settings | Defaults, health-check intervals, kluster refresh, etc. |
| Theme | Pick / customize TUI colors |
| Help | In-app help |
- Add an SSH host in the Hosts tab pointing at a machine where Docker runs.
- Make sure your SSH user is in the
dockergroup on that host (ssh user@host docker psshould work). - In the Kluster tab, navigate to the
Docker (local)header and pressn. - Pick the host from the list. Done — sshm tunnels every
dockercall over SSH.
No ports opened, no TLS to set up, no dockerd socket exposed.
sshm list [--filter "expr"] # list hosts (filter: tag:foo host:1.* user:bar name:*xyz*)
sshm connect <name> [ssh-options...] # connect to a host (alias: c)
sshm create # interactively create a host
sshm edit # edit an existing host
sshm delete # delete a host
sshm tag add <name> <tag1,tag2> # add tags
sshm tag del <name> <tag1,tag2> # remove tags
sshm load_local_conf # import hosts from ~/.ssh/config
sshm export [path] # export DB as ~/.ssh/config format
sshm add-identity <name?> [--pub key] # push pubkey to authorized_keys
sshm sync # sync the config with your git repo
sshm sync setup|status|pull|push|cron # configure / inspect / one-way / crontab line
sshm help # full CLI reference| Key | Action |
|---|---|
← / → |
Switch tabs |
q |
Quit |
| Key | Action |
|---|---|
↑ / ↓ |
Navigate |
Enter |
Connect to host / expand-collapse folder |
/ or any letter |
Activate fuzzy filter |
1-9 |
Quick-connect to Nth visible host |
s |
Cycle sort mode (name / MRU / most used / favorites / frecency) |
g |
Toggle group-by-folder ⇆ group-by-tag |
f |
Toggle favorite on selected host |
c |
One-shot health check on selected host |
| Key | Action |
|---|---|
a |
Add a host (or folder when on a folder row) |
e |
Edit selected host |
y |
Clone selected host (full copy, opens the editor) |
Y (Shift+y) |
Copy the connection string (user@host) to the clipboard |
d |
Delete selected host / folder |
p |
Open port-forward menu — start a tunnel in the background (f runs it foreground) |
t |
Background-tunnels dashboard — d/x stop a tunnel, o open a local tunnel's URL |
o |
Open the SSH session in a new terminal window |
F (Shift+f) |
Host key — show the pinned vs. live fingerprint, then pin (trust), forget, or replace it |
i |
Push identity to selected host |
r |
Rename folder |
Space |
Toggle host in bulk selection |
T (Shift+t) |
Bulk-add tags to selected hosts |
D (Shift+d) |
Bulk-delete selected hosts (with confirm) |
C (Shift+c) |
Clear bulk selection |
X (Shift+x) |
Fan-out: run a command on every selected host |
The available actions depend on what's under the cursor.
| Key | When | Action |
|---|---|---|
↑/↓ j/k |
always | Navigate |
/ |
always | Fuzzy-filter containers / pods / instances (Esc clears) |
Enter |
on a header | Expand / collapse the section |
Enter |
on a container / pod / instance | Open /bin/sh (Ctrl+D to exit) |
i |
on a container / pod / instance | Open the rich detail (inspect) panel — scroll with ↑/↓, Esc closes |
l |
on a container / pod / instance | Stream logs -f (Ctrl+C returns to TUI) |
s |
on a Docker/Apple container / Incus instance | Start it if stopped, stop it if running |
R (Shift+r) |
on a Docker/Apple container / Incus instance | Restart it |
r |
always | Force a refresh now |
n |
on a Docker header | Pick a saved host → register a Docker remote |
n |
elsewhere | Add a new k8s/k3s cluster (TUI form) |
e |
on a Cluster header | Edit cluster (kubeconfig / context / namespace) |
d |
on a Cluster header | Unlink cluster from sshm (cluster itself untouched) |
d |
on a Docker remote header | Unlink Docker remote (host still in Hosts tab) |
d |
on a Succeeded / Failed pod | kubectl delete pod (with confirm) |
| Key | Action |
|---|---|
↑ / ↓ |
Navigate keys in ~/.ssh |
/ |
Fuzzy-filter keys by file name / type / comment (Esc clears) |
g |
Generate a new key (interactive: ed25519 / ed25519-sk / ecdsa / rsa) |
p |
Push selected pubkey to a host |
a |
Add selected key to ssh-agent |
x |
Remove selected key from ssh-agent |
K (Shift+k) |
Clean a hostname from ~/.ssh/known_hosts |
r |
Rescan ~/.ssh |
| Path | Purpose |
|---|---|
~/.config/sshm/host.json |
Hosts, folders, tunnels, ProxyJump |
~/.config/sshm/kluster.json |
Saved clusters + Incus remotes + Docker remotes |
~/.config/sshm/settings.toml |
Defaults, health & kluster intervals |
~/.config/sshm/theme.toml |
TUI color theme (optional) |
~/.config/sshm/tunnels/<pid>.json |
Live background tunnels per running instance — used to clean up after a crash |
~/.config/sshm/sync-repo/ |
Working clone used by config sync — scratch space, safe to delete |
~/.config/sshm/sync-state.json |
Last sync time/result, shared by every running instance |
~/.config/sshm/sync.lock |
Held while a sync runs, so only one instance syncs at a time |
The Settings tab (Tab → Settings) exposes:
- Default Port / Default Username / Default Identity File — used when creating new hosts
- Export Path — where to write the auto-exported
~/.ssh/config(empty = disabled) - Auto Health Check — toggle the background SSH probe
- Health Refresh / Cache TTL — seconds between probe rounds
- Probe Connect Timeout — TCP connect timeout in ms (banner read uses ~1/3)
- Kluster Refresh Interval — seconds between Docker / kubectl / Incus refreshes
- Kluster Log Tail — default
--tail Nforl(logs) - Desktop notifications — toggle native OS alerts (tunnel dropped, host up/down)
- Config sync — repository URL, SSH key, branch, auto-sync interval, and the on-start / on-exit triggers (see Config sync)
The Settings tab groups these into labelled sections (Defaults, Export, Health checks, Kluster, Notifications, Config sync).
All values are live: hit Save and the background workers pick up the new TTL on the next tick.
external_terminal — a settings.toml-only key (not shown in the Settings tab). It's the command prefix used by the o hotkey to open a session in a new terminal window; the SSH command is appended to it. Leave it empty to auto-detect (wezterm, kitty, alacritty, gnome-terminal, konsole, xterm, or Terminal.app on macOS). Examples:
external_terminal = "kitty -e"
external_terminal = "wezterm start --"
external_terminal = "gnome-terminal --"notification_icon — another settings.toml-only key: a path (~ allowed) to a custom icon for desktop notifications.
notification_icon = "~/.config/sshm/icon.png"On Linux it's passed straight to notify-send -i. On macOS the default osascript notification cannot override its icon (it's always osascript's) — install terminal-notifier (brew install terminal-notifier) and SSHM will use it automatically to honour the custom icon.
Keep the same hosts on your laptop, your desktop and that one server you keep forgetting about. sshm pushes its config to a git repository you own, over SSH, with your key. Nothing is sent anywhere you didn't configure.
sshm sync setup # repo URL, key, what travels, how often
sshm sync # sync now
sshm sync status # what's configured, when it last ran, who holds the lockCreate an empty private repo first (GitHub, GitLab, Gitea, a bare repo on your
own box — anything reachable over SSH), then paste its SSH URL:
git@github.com:you/sshm-config.git. HTTPS URLs are rejected: they can't
authenticate with a key.
What travels. host.json and kluster.json by default, plus theme.toml;
settings.toml is opt-in. The [sync] block itself never leaves the
machine — it holds your key path, and syncing it would point every other
machine at the same one (or switch sync off everywhere at once).
How conflicts resolve. Hosts and clusters are merged entry by entry
against the last state you synced, so a host added on the laptop and another
added on the desktop both survive, and a host deleted on one machine stays
deleted instead of coming back. Only the same entry edited on both sides in the
same window is a real conflict, resolved by your policy (default: this machine
wins). settings.toml and theme.toml are whole-file, so the policy decides
directly.
When it syncs — any combination of:
| Trigger | Set with |
|---|---|
| Manually | sshm sync |
| Every N minutes | Settings tab, or mode = "interval" in settings.toml |
| On start / on exit | Settings tab toggles |
| From cron | sshm sync cron prints the line, --if-due respects the interval |
Several instances at once are fine. Two TUIs, the desktop app and a cron entry all share one lock and one schedule through the config directory: exactly one of them syncs each round, the others skip that tick instead of piling up behind it. A crashed instance never wedges the lock — it's reclaimed once its process is gone.
# settings.toml — written by `sshm sync setup`, editable by hand
[sync]
enabled = true
repo_url = "git@github.com:you/sshm-config.git"
ssh_key = "~/.ssh/id_ed25519"
branch = "main"
mode = "interval" # or "manual"
interval_secs = 900 # floored at 60
on_start = true
on_exit = true
items = ["hosts", "kluster", "theme"] # add "settings" to sync those too
conflict = "prefer_local" # or "prefer_remote"
strict_host_key_checking = falseSync shells out to your own git, so your ~/.ssh/config, agent and proxy
settings all apply. A passphrase-protected key needs to be in your ssh-agent —
sync never prompts (it would hang a background worker), it fails with a clear
error instead.
bg = "#1e1e2e"
fg = "#cdd6f4"
accent = "#89b4fa"
muted = "#6c7086"
error = "#f38ba8"
success = "#a6e3a1"
transparent_bg = falseSet transparent_bg = true (or tick Transparent background in the Theme
tab) to drop the bg colour entirely and let your terminal's own background —
including any transparency / blur — show through. The bg hex is kept on disk
so unticking the box restores it.
SSHM_LANG=fr sshm # French
SSHM_LANG=en sshm # English (default)Falls back to the value of LC_ALL / LANG if SSHM_LANG is unset. Unknown locales fall back to English silently.
src/
├── main.rs # CLI dispatch
├── lib.rs # crate root
├── models.rs # Host, Tunnel, Database
├── history.rs # frecency, sort modes
├── i18n.rs # localization
├── locales/ # en.toml, fr.toml
├── filter/ # fuzzy + prefix-token matcher
├── config/ # io, path, settings, export
├── ssh/ # client, keys, agent, known_hosts, proxy
├── import/ # ~/.ssh/config parser
├── kluster/ # Docker / Incus / kubectl wrappers
│ ├── docker.rs # docker ps / exec / logs (local + DOCKER_HOST=ssh://)
│ ├── incus.rs # incus list / exec / logs (local + remotes)
│ ├── kube.rs # kubectl get/exec/logs/delete pod
│ ├── shell.rs # /bin/sh constant
│ └── db.rs # kluster.json + bootstrap from kubeconfig + incus remotes
├── tui/
│ ├── app/ # main loop + worker submodules
│ │ ├── health_worker.rs
│ │ ├── kluster_worker.rs
│ │ ├── kluster_actions.rs
│ │ ├── cluster_form.rs
│ │ ├── host_form.rs
│ │ └── key_flows.rs
│ ├── tabs/ # one file per tab
│ ├── ssh/ # host detail box, modals, toast, port forward
│ └── theme.rs
└── commands/ # CLI subcommands
PRs welcome — especially for:
- Terminal UX polish
- New runtime backends (LXD, Podman, ...)
- Platform support (Windows is currently best-effort)
- More translations (just drop a
src/locales/<code>.toml)
Run cargo test before sending a PR — the suite covers parsers (filter, kubeconfig, ssh_config, JSON migrations) and a handful of pure logic units (frecency, ssh banner, ProxyJump resolver, etc.).
Made by Sn0wAlice
