I built this to make onboarding and offboarding engineers less tedious across a mix of environments: AWS boxes, third-party hosts, old servers nobody wants to touch, all of that.
It solves a very specific operational problem I kept running into. If that is also your problem, great. If not, you can still steal pieces of it.
Caution: Plan your group memberships carefully. Keep your management key out of any groups to avoid accidentally removing it from a host, which could lock you out.
$ go install github.com/shoobyban/sshman@latestRequirements:
- Go 1.25 or newer
If you are building from a checkout instead of installing the released CLI, build the embedded frontend first:
make frontend
go build ./...Run sshman from a machine that can already reach the rest of your fleet over
SSH using a management key that is not shared with other people.
Configuration lives in ~/.ssh/.sshman. If you move the tool to another
machine, copy that file and the binary and you are most of the way there. The
file stores hosts, users, and groups. It does not store private keys.
The two main things in sshman are users and hosts. Users are keyed by their
public SSH key and labeled with an email-like identifier. That identifier does
not have to be a real email address. sam-key-1 and sam-key-2 work just as
well if that fits how you manage keys.
The real center of the tool is the group. Groups are just tags shared between users and hosts. If a user and a host share a group name, that user should be on that host.
In practice that usually means groups like production, staging,
client1, or live-hosts.
When you add a host, sshman connects to it, reads its authorized_keys, and
pulls that state back into the local config. That gives you a starting point
instead of forcing you to rebuild access state by hand.
~/.ssh/.sshman is a JSON snapshot of your known hosts, users, and groups. It
is closer to a local state file than a classic config file.
Here is the shape of the CLI.
It is organized by resource: user, host, group, role, plus a few global
commands.
sshman
├── user
│ ├── add <email> <sshkey.pub> [flags]
│ ├── remove <email>
│ ├── list
│ ├── rename <old_email> <new_email>
│ └── groups <email> [groups...]
├── host
│ ├── add <alias> <host:port> <user> <keyfile> [flags]
│ ├── remove <alias>
│ ├── list
│ ├── rename <old_alias> <new_alias>
│ └── groups <alias> [groups...]
├── group
│ └── list
├── role
│ ├── assign --user <email> --role <role>
│ └── list
├── sync
├── tree - this command
├── web
└── version
--config <file>: Path to the configuration file.--verbose: Enable verbose output.
Add a user:
sshman user add <email> <sshkey.pub> --group <group1> --group <group2><email>: A unique identifier for the user (e.g.,email@test.com).<sshkey.pub>: Path to the user's public SSH key.--group: (Optional, repeatable) The group(s) to which the user belongs.
Example:
sshman user add email@test.com ~/.ssh/user1.pub --group production-team --group staging-hostsRemove a user and clean them off managed hosts:
sshman user remove <email>Example:
sshman user remove email@test.comList users and their groups:
sshman user listExample Output:
email@test.com [production-team staging-hosts]
junior1@test.com [dev-team]
Rename a user identifier:
sshman user rename <old_email> <new_email>Example:
sshman user rename email@test.com new-email@test.comSet or replace a user's groups:
sshman user groups <email> [groups...]- If groups are provided, the user's groups will be replaced with the new list.
- If no groups are provided, the user will be removed from all groups.
Example:
sshman user groups email@test.com production-team dev-teamAdd a host:
sshman host add <alias> <host:port> <user> <keyfile> --group <group1><alias>: A short, unique name for the host (e.g.,google).<host:port>: The host's address and SSH port.<user>: The user to connect with.<keyfile>: Path to the private SSH key for connecting to the host.--group: (Optional, repeatable) The group(s) to which the host belongs.
Example:
sshman host add google my.google.com:22 myuser ~/.ssh/google --group deploy --group hostingRemove a host:
sshman host remove <alias>Example:
sshman host remove googleList hosts, connection targets, and groups:
sshman host listExample Output:
google my.google.com:22 [deploy hosting]
client1.live www.client1.com:22 [production-team]
Rename a host alias:
sshman host rename <old_alias> <new_alias>Example:
sshman host rename google google-prodSet or replace a host's groups:
sshman host groups <alias> [groups...]Example:
sshman host groups google deploy productionList groups and the users and hosts attached to them:
sshman group listExample Output:
production-team hosts: [client1.live]
production-team users: [email@test.com]
dev-team hosts: [client1.staging]
dev-team users: [junior1@test.com]
Assign a role to a user:
sshman role assign --user <email> --role <role_name>Note: Roles can only be assigned to users, not hosts.
Example:
sshman role assign --user email@test.com --role adminList roles:
sshman role listRefresh local state from the remote hosts:
sshman syncThis is useful when somebody edits authorized_keys directly and you need to
pull reality back into the local state file.
Start the web UI:
sshman web --port 8080--port: (Optional) The port to run the web UI on.--bind: (Optional) The IP address to bind to. Defaults to127.0.0.1.--allow-remote: Required when binding to a non-loopback address such as0.0.0.0.--enable-keys-api: Exposes the/api/keysendpoint. Disabled by default.
The web UI is an admin surface. It only listens on loopback by default so you do not accidentally expose host and user management to the network.
To bind the web UI to all interfaces intentionally:
sshman web --bind 0.0.0.0 --allow-remote --port 8080The repo includes a Docker sandbox for checking the embedded web UI and the SSH propagation flow without pointing the tool at real infrastructure.
docker compose -f docker-compose.sandbox.yml up --build -dThat starts:
- the embedded app on http://localhost:18080
- disposable SSH target containers seeded with sample hosts, users, and groups
Print the version:
sshman version-
All other core functionality should have unit tests
-
Edge case: deleting user should delete the user from all hosts (unless canceled from changeset)
-
Misfeature: Changing keyfile on host does not upload new key with old and delete old
-
Misfeature: Adding host does not check if host config is working
-
Misfeature: Adding host with groups does not upload initial users from group
-
Misfeature: Modifying user groups does not upload / delete hosts
- Web authentication
- Delete host with editing ssh keys
- Auto-group host specific users (when user is on several hosts, create a group for them, auto-merge groups when possible)
- CLI to use API (not sure)
- Web Interface Authentication (where to store creds?)
- Updated At timestamps
- Audit log
- audit log logging all changes from changeset (sync op) on apply
- Implement user "role" group of groups for RBAC level of abstraction (developers role = uat-servers+staging-servers group)
- Testing connection after creating authorized_keys entry
- Changeset based operation (see Future plans details)
- Web Aria tags (at least tagging buttons better and connecting labels)
- More backend (currently
.ssh/.sshmanJSON configuration file) - Adding host key to server using password auth
- Text UI based on Web frontend
- State handling (see Future plans details)
- Edit multiple items (see Future plans details)
Most of the credit goes to the pain of being a CTO for 17+ years in small and mid-sized companies, where SSH key management is not solved.
The project would have been much harder without the work of Steve Francia and all the cobra and viper contributors, the web UI relies on Chi and React.
Web UI embedding wouldn't be working without Gregor Best, who nerd-sniped me into helping with a tricky bug on Gophers Slack.
I love the Go community.

