An SSH connection manager with an interactive host browser. It reads the hosts
you already have in ~/.ssh/config โ nothing new to configure, nothing to keep
in step โ and the hosts you add from the browser go to ~/.ohmyssh/hosts, which
is read alongside it.
go install github.com/maogou/ohmyssh/cmd/ohmyssh@latestGo 1.27.1 or newer, which is what go.mod names. An older toolchain fetches
the one the module asks for rather than failing โ GOTOOLCHAIN=auto is the
default โ at the cost of that download.
ohmyssh browse hosts and connect with enter
ohmyssh web1 open a shell on web1
ohmyssh web1 -- uptime run one command under a PTY
ohmyssh exec web1 -- df -h run one command without a PTY
ohmyssh scp ./a.sh web1:/tmp copy a file, naming the host the way scp does
ohmyssh list browse hosts
ohmyssh forget web1 delete the password saved for web1
ohmyssh with no arguments opens a host list. Type to filter by name, hostname,
user or tag, move with โ/โ, and press enter to connect. The session takes
over the terminal; when it ends you land back on the list with the outcome in
the status line, ready for the next host.
The list of keys along the bottom of the screen is a budget: on a narrow
terminal it gives up its rightmost hints, and the ones that go first are the ones
the screen itself already says. ? is what to press for the whole list, for
whichever view is on screen โ the host list's keys from the host list, the file
view's from the file view โ and it is where the bindings the bar has no room for
are written down, along with where a host added with A is written.
U and D open a two-pane file view on the host under the cursor: the local
files on the left, the host's home directory on the right. U starts with the
cursor in the left pane, D in the right, but both open the same view โ which
way a file moves is decided by the pane the cursor is in, not by the key it was
opened with.
tab switch panes enter directory: walk in โ file: send it
โ โ k j move c send the whole tree, directory or not
โ h up a level / filter the pane you are in
pgup pgdown a page at a time r re-read the pane you are in
g G first, last esc stop a transfer, then leave
? the whole list
The entry under the cursor goes to whatever the other pane is showing, under
its own name, so a transfer is a walk through two directories rather than two
paths typed from memory: a file lands in the other pane's directory, and so does
a directory, as a directory of its own rather than spilling its contents into
one. c is the half of the pair enter cannot do: it sends a directory whole
rather than walking into it. The progress bars draw where the listing was, esc
stops a transfer, and where it got to is reported in the status line like
anything else.
A terminal narrower than 80 columns draws one pane at a time rather than two too
narrow to read a name in; tab brings the other forward. Names starting with a
dot are left out of both panes โ the two columns are read side by side, and two
of them disagreeing about what a file is would read as a bug. Take .env and
.ssh along with put and get, which have no such qualms.
A adds a host, which saves leaving the browser to write one into a config file
by hand: five fields, tab between them, enter to save and esc to cancel.
Only the alias is required โ an empty field is left out of the block and takes
ssh's own default โ and the block goes to ~/.ohmyssh/hosts, which the browser
reads along with your ssh config. Adding hosts has
the details.
X deletes the host under the cursor, after asking. The question is drawn on the
status line with the list still on screen โ the one thing a confirmation has to
show is which host it is about โ and y goes ahead while any other key
(n, esc, enter, anything) leaves the host alone:
? delete web2 from ~/.ohmyssh/hosts? y/n
Only hosts in ~/.ohmyssh/hosts can be deleted: the ones you added with A. A
host from your ssh config is refused and the message names that file, since
ohmyssh reads it and never writes it. Deleting a host keeps the password saved
for it โ a password belongs to a login (user@host:port) that another alias may
well share โ so ohmyssh forget <alias> is what clears it, if that is what you
meant. Adding and removing hosts has the details.
A, U, D and X are capital letters for a reason, and they follow q's
rule: they act on an empty filter only. A plain a, u, d or x would
otherwise be swallowed out of the first host name you searched for that starts
with one. ? needs no such rule, and is a binding whatever the filter says: no
host has a question mark in a name, an address, a user or a tag, so the character
is no use to a search. The file view never prompts for a password โ it owns the
terminal โ so a host with no saved password is told to connect to it once from a
shell instead.
Unlisted targets work too, in the shape ssh accepts:
ohmyssh deploy@10.0.0.5
ohmyssh deploy@10.0.0.5:2222| Command | Aliases | Purpose |
|---|---|---|
list |
ls, browse |
Browse hosts interactively (--filter seeds the search) |
connect |
ssh |
Open a shell, or run a command under a PTY |
exec |
Run a command without a PTY, streaming stdout/stderr | |
put |
Upload a file or a directory tree: put <host> <local> <remote> |
|
get |
Download one: get <host> <remote> <local> |
|
scp |
Copy either way, naming the host as host:path |
|
forget |
Delete saved passwords (--all, or list them with no argument) |
connect and exec differ in whether a pseudo-terminal is requested. Use
connect for interactive programs and exec when the output is piped
somewhere: exec leaves stdout and stderr untouched, so it composes.
Put -- before a remote command so its flags are not parsed by ohmyssh.
put, get and scp move files over SFTP on the connection ohmyssh already
knows how to make: a host behind a ProxyJump, with a saved password or with a
host key you have already trusted transfers without any of that being set up
again.
ohmyssh put web1 ./deploy.sh /tmp/deploy.sh # host, local, remote
ohmyssh get web1 /var/log/app.log ./app.log # host, remote, local
ohmyssh scp ./deploy.sh web1:/tmp/deploy.sh # host:path, either way roundscp reads the direction off which side names a host, so it is put or get
with nothing to decide. A path with no colon is local, a bare web1: is that
host's home directory, and both ends naming a host is an error rather than a
guess โ there is no one credential to send a file between two machines with.
Directories go recursively, and what lands where follows cp and rsync:
| Command | Result |
|---|---|
put web1 ./dist/ /opt/app/ |
the contents of dist into /opt/app |
put web1 ./dist /opt/app |
the same: a directory's contents go into the destination, not a directory named after it |
put web1 ./a.txt /opt/app/ |
/opt/app/a.txt |
put web1 ./a.txt /opt/app |
the same: /opt/app is a directory, and a file cannot be written over one |
put web1 ./a.txt /opt/app/renamed.txt |
/opt/app/renamed.txt, overwritten |
A file takes its own name whenever the destination is a directory โ saying so
with a trailing slash or by the directory already being there, which is how cp
and scp read it too. A destination that is not there is the name of the file
being made: put web1 ./a.txt /opt/new.txt writes a file called new.txt.
Existing files are overwritten, as scp does. A transfer that is stopped part way
โ esc in the browser, ctrl+c on the command line โ leaves what had already been
written at the far end, since bytes already sent cannot be recalled. The file is
there but incomplete; scp behaves the same way, so check for a truncated file
before resuming one.
Progress is drawn on stderr, so stdout stays clean and the result can be piped somewhere. On a terminal the bars are rewritten in place; anywhere else โ a pipe, a CI log โ they give way to one line per file, because a cursor moving up and rewriting lines is unreadable once it has been captured.
deploy.sh โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ 83% 12 MB/s ETA 1s
3 files โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ 61% 2/3 4.0 MB / 6.6 MB
โ put ./dist โ /opt/app 3 files 6.6 MB
Symlinks are never recreated at the far end. Uploading, a link that points at a regular file is copied as the bytes behind it, and one that points at a directory is left out โ following it would let a link pointing back up the tree recurse without end. Downloading, every link is left out: the listing the transfer is measured from carries each entry's type, and resolving one would cost a round trip per link. Anything that is not a regular file โ a socket, a device, a named pipe โ is left out either way, having no bytes to send.
Both ends of a transfer are measured before any of it is copied, which is what lets the overall bar have a denominator and what makes a mistyped path fail without moving anything. It also means a very large directory spends a moment being walked before the first byte goes; a download walks the remote tree over the connection to do it.
The remote exit status becomes ohmyssh's exit status, so both of these work as expected:
ohmyssh exec web1 -- systemctl is-active nginx
ohmyssh exec web1 -- failing-command && echo ok| Flag | Default | Purpose |
|---|---|---|
--config, -F |
~/.ssh/config |
Config file to read (OHMYSSH_CONFIG) |
--known-hosts |
~/.ssh/known_hosts |
Known-hosts file (OHMYSSH_KNOWN_HOSTS) |
--password, -p |
Password for password/keyboard-interactive auth (OHMYSSH_PASSWORD) |
|
--password-stdin |
Read the password from standard input, one line | |
--no-prompt |
Never prompt for a password; fail instead | |
--no-save-password |
Do not remember passwords that work | |
--no-agent |
Do not authenticate with ssh-agent | |
--insecure |
Skip host key verification (unsafe) | |
--timeout |
15s |
Connection and handshake timeout |
--log-level |
info |
trace, debug, info, warn, error, disabled |
--log-format |
console |
console or json |
--lang, --language |
from the environment | Language of the interface and --help: en, zh, or auto (OHMYSSH_LANG) |
--debug, -d |
Shortcut for --log-level debug |
Flags are global, so they can appear before or after the subcommand. Logs go to
stderr, which keeps exec output safe to pipe. The browser is the exception:
while it is up its logs go to ~/.ohmyssh/ohmyssh.log, because the terminal then
belongs to the frame and a log line written into it lands in the middle of one.
Text that is not a log line goes the same way โ a server's login banner, a
ProxyCommand's own stderr โ which would otherwise be the same problem with a
different name on it.
English and Simplified Chinese, chosen from your locale: LC_ALL, then
LC_MESSAGES, then LANG, first one set that names a language wins. zh in any
of its spellings โ zh_CN.UTF-8, zh-Hans, zh โ is Chinese; anything else,
including a language ohmyssh does not have, is English, and never an error. You
are not asked to configure anything.
--lang (or OHMYSSH_LANG) overrides that, and --lang auto hands the decision
back to the locale. Everything follows it: the browser, its help, --help and the
subcommand descriptions. A language named on the command line that ohmyssh does
not have is a usage error rather than a silent fallback, so a typo is not
mistaken for a working setting.
LC_ALL=zh_CN.UTF-8 ohmyssh # the browser in Chinese
ohmyssh --lang zh --help # the help in Chinese
LANG=fr_FR.UTF-8 ohmyssh # no French; English, and no complaint
ohmyssh --lang fr # exit 2: ohmyssh has en and zhCommand names, flag names, host names and log lines are not translated; the log is written to be pasted into an issue. Errors from the operating system and from the SSH library are passed through in their own words.
The parser follows OpenSSH's own rules, including the ones that are easy to get wrong:
- First value wins per parameter, so a specific
Hostblock beats a catch-allHost *that appears later in the file. - File-scope directives (before any
HostorMatch) act as global defaults. Includeglobs,Matchblocks,~expansion,=as a separator, and quoting all behave as they do in ssh.*and?wildcards,!negation in aHostlist, and multi-alias lines likeHost web1 web2are all resolved per target.
Wildcard-only blocks are not offered as browsable hosts, since they name a pattern rather than a machine.
A # Tags: comment attaches tags to the block that follows, and tags are
searchable in the browser:
# Tags: prod, web
Host web1
HostName 10.0.0.1
User deployA in the browser โ an empty filter, like U, D and X โ opens a form for a
host worth keeping:
alias required; the name you type
host address; the alias when empty
user login name; yours when empty
port 22
tags prod, web
tab or โ/โ moves between the fields, enter saves, esc cancels. What is
written is an ordinary Host block with a # Tags: line above it, in the same
syntax as your ssh config, so the file can be read, edited and version controlled
like any other config you keep. A refused add leaves the form up with everything
in it: what is wrong with a host is usually one field, and retyping the other four
to fix it is the worst thing a form can ask for.
Where it goes. ~/.ohmyssh/hosts (%USERPROFILE%\.ohmyssh\hosts on
Windows), written 0600 in a directory 0700 โ the same place and the same
permissions as the saved passwords. Your ssh config is never written to, and what
is already in the file is left exactly as it is: the block is appended at the end,
by way of a temporary file, so an interrupted write cannot leave half a block
behind for everything after it in the file to be parsed against.
How it is read. The two files are parsed as one, as if your ssh config ended
with Include ~/.ohmyssh/hosts. A file-scope User, IdentityFile or
ProxyJump in your ssh config therefore applies to a host added here exactly as
it does to one you wrote by hand โ which is what keeps such a host connectable
without anything else being set up. The order matters in one direction only:
first value wins across both files and your ssh config is read first, so nothing
in ~/.ohmyssh/hosts can shadow a host you already had. An alias that is already
taken is refused, and the message names the file it is already in. --config
replaces the ssh config being read; the hosts added here are read either way,
being your own state rather than part of any one config file.
What it is not. These hosts are ohmyssh's, and ssh web2 does not know about
them โ ssh reads ~/.ssh/config and nothing else. A host you want in both places
belongs in ~/.ssh/config, where you would have written it anyway; ohmyssh reads
that file first and browses it as it always has.
Removing. X takes one back out. It is the same file and the same block the
add wrote: the Host line that names the alias, its directives, and the
# Tags: line above it, which belongs to the block under it and would otherwise
end up tagging the host below. Everything else in the file is left exactly as it
is โ the delete works line by line rather than by rewriting what it parsed, so
your own comments, indentation and blank lines survive untouched, and the write
goes through the same temporary file and rename as the add, so an interrupted
delete cannot truncate the file.
A host from your ssh config is refused rather than deleted, and the message names
that file: ohmyssh reads it and never writes it, so the only thing it could do
with a y there is fail. The password saved for the host is kept โ it is saved
against user@host:port, which another alias may share, so deleting a host is
one decision and forgetting its password is another. ohmyssh forget <alias>
makes the second one, and the status line says so when a host goes.
Methods are tried in the order ssh uses:
- ssh-agent
- A key named by
IdentityFile - The default keys:
id_ed25519,id_ecdsa,id_rsa,id_dsa - Password and keyboard-interactive: a saved password first, otherwise a prompt
An encrypted key is unlocked with the password given by --password, or the one
prompted for after authentication fails, so -p doubles as the key passphrase.
A key that cannot be unlocked is skipped rather than aborting the attempt, so
the remaining methods still get their turn.
ProxyJump chains and ProxyCommand are both supported, including a jump host
that is itself another alias in the config.
A password is typed once. When one works, it is saved and used from then on, so the second connection to a host asks for nothing:
$ ohmyssh web1 # asks for the password, then saves it
$ ohmyssh web1 # connects straight away
Passwords are keyed by login identity (deploy@10.0.0.1:22), not by alias, so
two aliases for the same machine share one entry, and changing User or Port
asks for the new password rather than silently reusing the old one. A saved
password that has stopped working is replaced by a prompt โ a stale entry can
never lock you out.
To manage what has been saved:
ohmyssh forget # list the hosts with a saved password
ohmyssh forget web1 # forget one
ohmyssh forget --all # forget all of them--no-save-password turns off saving for a run while still using what is
already saved. --password still works and takes precedence, which is what you
want in a script.
In a script, pipe the password rather than passing it. An argument is
public: anything that can list the processes on the machine can read a command
line, and the shell keeps it in its history. What arrives on standard input is
neither. --password-stdin reads one line and leaves the rest of the input
alone:
echo "$PW" | ohmyssh --password-stdin web1 -- uptime
ohmyssh --password-stdin web1 < ~/.secrets/web1The two flags cannot be combined in one run, and an empty line is refused rather than attempted.
Where they are kept. Passwords are stored in ~/.ohmyssh/credentials.json
(%USERPROFILE%\.ohmyssh\credentials.json on Windows), written mode 0600 in a
directory mode 0700 โ which Windows applies as the read-only attribute and
nothing else, so there the encryption is the whole of the protection. A store
left at the old ~/.config/ohmyssh/ location is moved into place the first time
one is read, passwords intact.
What the encryption is worth. The file is sealed with AES-256-GCM under a
key derived from your user, hostname and OS. That keeps the passwords out of
sight of anything that merely reads the file โ a backup, a sync client, a stray
cat โ but the same machine and account can derive the key, so it is not a
defence against a compromised account. It is obfuscation with a clean interface,
not a secret store. A copy of the file on another machine will not decrypt;
those passwords read as absent and you are prompted again.
Unknown hosts are trusted on first use and appended to known_hosts (mode
0600). A host whose key has changed is rejected โ that is the case where
trust-on-first-use stops protecting you, so it stops rather than asking.
--insecure disables verification entirely and says so in the log.
make build # build ./ohmyssh
make test # go test ./...
make lint # golangci-lint run ./...
make coverage # write coverage.html and open itWithout make:
go build ./...
go vet ./...
go test ./...The test suite stands up a real in-process SSH server, so dialling, host key
verification, authentication, PTY handling, exit status propagation and SFTP
transfers are all exercised against the actual protocol rather than mocks. The
server serves a temporary directory as its SFTP root, which is what lets an
upload be asserted on by reading the files back off the filesystem. The password
policy โ which password is tried, when you are asked, what gets saved โ is
covered separately in internal/service, against a transport that answers dials
instead of opening sockets, so each rule is asserted on its own.
make lint needs golangci-lint v2:
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latestcmd/ohmyssh/ entry point
internal/command/ the command tree, and flags โ options
internal/service/ orchestration and the password policy
internal/repository/ interfaces over dialling, host lookup and the store
internal/sshclient/ SSH itself: sessions, PTY, auth, known_hosts, SFTP
command only turns a command line into options. service never sees a
*cli.Command. repository puts sshclient, config and credential behind
interfaces, which is what lets the password policy be tested without a
connection.
MIT. Use it, change it, ship it โ the licence asks one thing, that
the copyright notice travels with it, and withholds one, any warranty. The
files under assets/ are the project's own and carry the same licence.