Skip to main content

Cross-platform SSH session manager with a background daemon

Project description

sshm

PyPI Tests

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.
  • connect bridges 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 exit in 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 (a config.bak is 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.1 only and checks a random per-daemon token (stored 0600 in ~/.sshm/token) on every request.
  • The IPC port defaults to 19222; set SSHM_PORT to change it (e.g. to run sshm in both Windows and WSL when mirrored networking shares localhost). sshm install persists the port to ~/.sshm/port so 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sshmd-0.1.0.tar.gz (33.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sshmd-0.1.0-py3-none-any.whl (39.9 kB view details)

Uploaded Python 3

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

Hashes for sshmd-0.1.0.tar.gz
Algorithm Hash digest
SHA256 87804423144d3622523dd3718cdc07eb2105187df20ab036900bc2fb913a47ef
MD5 e83841f5149075de58ab302249ffca06
BLAKE2b-256 972b99e1ad89f188c898974560f0a7b5b1627de6622861096f732916dc9142ea

See more details on using hashes here.

Provenance

The following attestation bundles were made for sshmd-0.1.0.tar.gz:

Publisher: release.yml on revsearch/sshm

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for sshmd-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4d977d8b52ab64ee6d2366a1d60db2f5ae89d7be87a38804a375afd5135129ec
MD5 4b7b1ed35298daecda8bbd491db23dee
BLAKE2b-256 f1ee15433fb9806f8ff3d27109ed4addabc78190d7c7e8293b3d45ce6f8d5f67

See more details on using hashes here.

Provenance

The following attestation bundles were made for sshmd-0.1.0-py3-none-any.whl:

Publisher: release.yml on revsearch/sshm

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page