Cross-platform SSH session manager with a background daemon
Project description
sshm
An SSH session manager with a background daemon. Remote shells stay alive when you
close the terminal, reconnect on their own when the link drops, and reattach
instantly. State is kept in plain ~/.ssh/config, so ssh, scp, and rsync
keep working alongside it.
sshm add prod root@192.0.2.10 # keygen + copy key + write ~/.ssh/config
sshm prod # attach to a live shell (or start one)
# close the terminal — the shell keeps running; `sshm prod` reattaches it
ssh prod # plain ssh/scp/rsync work too — same alias & key
Install
The package is published as sshmd and installs the sshm (and sshmd)
commands. With uv or pipx:
uv tool install sshmd
# or
pipx install sshmd
Latest from git instead of PyPI:
uv tool install git+https://github.com/revsearch/sshm
From a checkout:
git clone https://github.com/revsearch/sshm
cd sshm
uv tool install . # or: pip install .
Needs Python 3.12+. The sshm and sshmd commands are installed to your tool bin
directory (~/.local/bin, or the Python Scripts dir on Windows) — make sure it's
on your PATH. Update with uv tool upgrade sshm.
Quick start
# Add a server: generates an ed25519 key, copies it to the remote, writes config
sshm add myserver root@192.168.1.100
# Connect (interactive shell via the daemon)
sshm myserver
# Or explicitly
sshm c myserver
Works with ssh, scp, rsync
sshm add writes a normal Host entry to ~/.ssh/config, so the alias works with
any tool that reads it — not just sshm. The generated key and port are picked up
automatically, so no -i/-p flags are needed:
ssh myserver # plain SSH, same alias
ssh myserver htop # run a one-off command
scp ./backup.tar.gz myserver:/srv/ # copy a file up
scp myserver:/var/log/app.log . # ...and back down
rsync -avz ./site/ myserver:/var/www/ # sync a directory
sftp myserver # sftp, git over ssh, etc. all work too
git clone myserver:/srv/repo.git # alias works as the ssh host anywhere
The difference: sshm myserver attaches to a persistent, auto-reconnecting shell
via the daemon, while ssh myserver is a plain one-off connection — both to the
same host, using the same key and config.
Commands
Most commands have a short alias (shown first). sshm <alias> with no command is
shorthand for connect.
Sessions
sshm <alias> # Connect (shorthand)
sshm c, connect <alias> [name] # Attach to a session or create a new one
sshm l, list # List all hosts
sshm l, list <alias> # List active sessions for a host
sshm a, add <alias> user@host # Add a server (keygen + copy key)
sshm r, remove <alias> # Remove a host and disconnect all sessions
sshm mv, rename <alias> <new> # Rename an alias (and its managed key)
add takes user@host, user@host:port, or bracketed IPv6
(user@[2001:db8::1]:22).
Port forwarding and SOCKS
Forwards are written as native LocalForward / RemoteForward / DynamicForward
directives in ~/.ssh/config, so any SSH client sees them. Direction is -L /
-R / -D, same as ssh:
sshm p a, port add <alias> -L <local>:<host>:<remote> # Local forward
sshm p a, port add <alias> -R <remote>:<host>:<local> # Reverse forward
sshm p a, port add <alias> -D <port> # SOCKS proxy (ssh -D)
sshm p r, port remove <alias> -L <local>:<host>:<remote> # Remove a forward
sshm p r, port remove <alias> -D <port> # Remove a SOCKS proxy
-D <port> is a dynamic forward — a SOCKS5 proxy on 127.0.0.1:<port> tunnelled
through the host. Point a browser or any SOCKS-aware app at it.
Auto-connect
When enabled, the daemon keeps at least one shell alive and reconnects it on failure, so attaching is instant.
sshm e, enable <alias> # Keep a session alive, auto-reconnect
sshm d, disable <alias> # Stop auto-connect
Import / export
Move hosts, including their keys, between machines as JSON.
sshm export servers.json # Export all hosts
sshm export prod.json web db api # Export specific hosts
sshm l servers.json # Preview a JSON file
sshm import servers.json # Import (skip existing)
sshm import servers.json -o # Import (override existing)
sshm import servers.json web db # Import only specific hosts
sshm import servers.json web=prod # Import 'web' under the alias 'prod'
Daemon
The daemon (sshmd) starts on first use.
sshm status # Daemon status
sshm stop # Stop the daemon
sshm install # Autostart on login (systemd / launchd / Task Scheduler)
sshm uninstall # Remove autostart
Shell completions
fish
sshm ships fish completions: subcommands, port -L/-R/-D flags, and host aliases
pulled live from ~/.ssh/config (including the bare sshm <alias> shorthand).
uv tool install does not wire these up automatically, so install them once
(exec fish reloads the shell so they're active immediately):
sshm completions fish > ~/.config/fish/completions/sshm.fish && exec fish
fish autoloads from that directory — new sessions pick it up with no source. From
a checkout you can instead symlink the source file so edits are picked up live:
ln -s (path resolve src/sshm/completions/sshm.fish) ~/.config/fish/completions/sshm.fish
Session states
| Icon | State | Meaning |
|---|---|---|
● |
ready | Shell running, waiting for attach |
◆ |
attached | A client is connected |
○ |
dead | Process exited |
How it works
sshm (CLI) ── TCP localhost:19222 ──> sshmd (daemon)
├── SSH processes (one shell per session)
├── Reader threads (scrollback buffer)
├── Watchdog (health, reconnect, keep-warm)
└── IPC server (JSON protocol + I/O bridge)
- The daemon spawns
ssh -tt <alias>under a real PTY (POSIX; pipes on Windows) and holds the shell process. connectbridges your terminal I/O to that process over TCP and forwards your terminal size (and resizes /SIGWINCH) so the remote shell matches your window.- Detaching (closing the terminal, Ctrl-C) leaves the shell running; reattach later.
- Typing
exitin the shell removes the session cleanly — no reconnect. - A lost connection (SSH exit 255) triggers reconnect with exponential backoff.
- Config stays in
~/.ssh/config. Every rewrite goes to a temp file and is replaced atomically (aconfig.bakis kept), so a crash mid-write won't corrupt it. - Runtime state lives in
~/.sshm/: pid file, IPC token,sshmd.log. - The IPC server binds
127.0.0.1only and checks a random per-daemon token (stored0600in~/.sshm/token) on every request. - The IPC port defaults to
19222; setSSHM_PORTto change it (e.g. to run sshm in both Windows and WSL when mirrored networking shares localhost).sshm installpersists the port to~/.sshm/portso the autostarted daemon — which doesn't see your shell env — uses the same one.
Platforms
- Linux — systemd user service for autostart.
- macOS — launchd LaunchAgent.
- Windows — Task Scheduler for autostart, Win32 console VT mode for terminal I/O.
On POSIX, ssh runs under a real PTY, so the remote shell tracks your window size
and resizes. Windows has no pty module, so there a session keeps the size it
attached with — full-screen apps (vim, htop) won't follow later resizes.
Development
See CONTRIBUTING.md for the full branch / commit / PR workflow.
uv sync # install dependencies (including the dev group)
uv run pytest # run the tests
Module layout (src/sshm/):
| Module | Responsibility |
|---|---|
cli.py |
CLI commands (click), entry point sshm |
daemon.py |
sshmd: request dispatch, watchdog, entry sshmd |
process.py |
SSH sessions: PTY spawn, scrollback, reconnect, health |
ipc.py |
TCP IPC client/server on 127.0.0.1:19222 (token auth) |
protocol.py |
JSON message schema for IPC |
config.py |
~/.ssh/config parser/writer, port-forward rules |
terminal.py |
Raw terminal bridge (termios on Unix, Win32 console API) |
procutil.py |
Cross-platform process helpers (pid checks, Popen flags) |
state.py |
~/.sshm runtime files: pid, token, port, log |
autostart.py |
Task Scheduler / systemd / launchd integration |
Troubleshooting
Windows: Bad owner or permissions on ~/.ssh/config
OpenSSH on Windows refuses to read ~/.ssh/config if the ACL contains extra
principals like OWNER RIGHTS (S-1-3-4). Symptom:
Bad permissions. Try removing permissions for user: \\OWNER RIGHTS (S-1-3-4) on file C:/Users/<you>/.ssh/config.
Bad owner or permissions on C:\\Users\\<you>/.ssh/config
Remove the offending principal:
icacls "$env:USERPROFILE\.ssh\config" /remove "OWNER RIGHTS"
If other files in .ssh have the same issue (private keys, etc.):
icacls "$env:USERPROFILE\.ssh\*" /remove "OWNER RIGHTS"
If that's not enough (inheritance can pull in other groups), reset the ACL so only you have access:
$f = "$env:USERPROFILE\.ssh\config"
icacls $f /inheritance:r
icacls $f /grant:r "$($env:USERNAME):(F)"
If icacls returns Access is denied, you don't own the file. Take ownership
first, then fix the ACL:
takeown /F "$env:USERPROFILE\.ssh\config"
icacls "$env:USERPROFILE\.ssh\config" /grant "$($env:USERNAME):F"
icacls "$env:USERPROFILE\.ssh\config" /inheritance:r
icacls "$env:USERPROFILE\.ssh\config" /grant:r "$($env:USERNAME):(F)"
If takeown fails, run PowerShell as Administrator and repeat. Verify with
icacls "$env:USERPROFILE\.ssh\config" — only your user with (F) should remain.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sshmd-0.1.0.tar.gz.
File metadata
- Download URL: sshmd-0.1.0.tar.gz
- Upload date:
- Size: 33.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
87804423144d3622523dd3718cdc07eb2105187df20ab036900bc2fb913a47ef
|
|
| MD5 |
e83841f5149075de58ab302249ffca06
|
|
| BLAKE2b-256 |
972b99e1ad89f188c898974560f0a7b5b1627de6622861096f732916dc9142ea
|
Provenance
The following attestation bundles were made for sshmd-0.1.0.tar.gz:
Publisher:
release.yml on revsearch/sshm
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sshmd-0.1.0.tar.gz -
Subject digest:
87804423144d3622523dd3718cdc07eb2105187df20ab036900bc2fb913a47ef - Sigstore transparency entry: 1808606398
- Sigstore integration time:
-
Permalink:
revsearch/sshm@2bc04c32a6164f1fbd9b061e4e0a88ad2389cf23 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/revsearch
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@2bc04c32a6164f1fbd9b061e4e0a88ad2389cf23 -
Trigger Event:
push
-
Statement type:
File details
Details for the file sshmd-0.1.0-py3-none-any.whl.
File metadata
- Download URL: sshmd-0.1.0-py3-none-any.whl
- Upload date:
- Size: 39.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4d977d8b52ab64ee6d2366a1d60db2f5ae89d7be87a38804a375afd5135129ec
|
|
| MD5 |
4b7b1ed35298daecda8bbd491db23dee
|
|
| BLAKE2b-256 |
f1ee15433fb9806f8ff3d27109ed4addabc78190d7c7e8293b3d45ce6f8d5f67
|
Provenance
The following attestation bundles were made for sshmd-0.1.0-py3-none-any.whl:
Publisher:
release.yml on revsearch/sshm
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sshmd-0.1.0-py3-none-any.whl -
Subject digest:
4d977d8b52ab64ee6d2366a1d60db2f5ae89d7be87a38804a375afd5135129ec - Sigstore transparency entry: 1808606401
- Sigstore integration time:
-
Permalink:
revsearch/sshm@2bc04c32a6164f1fbd9b061e4e0a88ad2389cf23 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/revsearch
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@2bc04c32a6164f1fbd9b061e4e0a88ad2389cf23 -
Trigger Event:
push
-
Statement type: