- C++ 71.3%
- Shell 20.3%
- Python 7.3%
- Makefile 1.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Neither is invented behind the user's back: without the option a missing ~/.ssh/config, or a missing key to push, is still an error. With -c, or by answering the prompt the terminal gets, sshc makes what is missing. The config file, and ~/.ssh with it, are created mode 600 and 700, seeded with a comment. The commands that would write to the config anyway - add, edit and the picker - offer to create it; the read-only ones just report it, since an empty config would not help them. For "key", the pair created is the one the command was going to look for: -i, else the host's IdentityFile, else ~/.ssh/id_ed25519. A private key whose .pub went missing has it recovered with "ssh-keygen -y" rather than being replaced, and a .pub that exists but cannot be parsed is reported, never overwritten. ssh-keygen keeps the terminal and asks for the passphrase itself; off a terminal there is nobody to ask, so the key is generated without one and sshc says so. A dry run creates nothing and prints the ssh-keygen command it would have run. Two supporting changes: make_private_dir() is shared by both paths, and write_host() no longer hoists a new block above trailing comments when it is appending at the end - otherwise the first "add" to a freshly created config landed above its header. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
| man | ||
| src | ||
| tests | ||
| .gitignore | ||
| Makefile | ||
| README.md | ||
sshc
A small ssh manager for the terminal. It reads ~/.ssh/config, shows you what's
in there, and hands the connection off to ssh itself - no credentials, no
sessions, no config rewriting of its own.
Build
make # produces ./sshc
make install # ~/.local/bin/sshc and ~/.local/share/man/man1/sshc.1
man sshc # or: make man, to read it without installing
C++17, no dependencies beyond libc. Builds on macOS and Linux.
make install goes under your home directory by default, so it needs no sudo.
It warns if ~/.local/bin is not on your PATH or ~/.local/share/man is not
on your manpath. To install system-wide instead, override PREFIX:
sudo make install PREFIX=/usr/local
DESTDIR is honoured for staged installs, and make uninstall removes both
files from wherever PREFIX points.
Use
sshc # interactive picker over every configured host
sshc vici # picker pre-filtered to "vici"
sshc pve01 # a known alias connects straight away, then exits
sshc pve01 -L 8006:localhost:8006 # extra args are passed to ssh
sshc add web1 deploy@web.example.com:2222 # add a new Host entry
sshc add web1 # prompts for the fields it still needs
sshc set pve01 -u root -p 2222 # change an existing entry
sshc set pve01 --note "proxmox node 1" # label it
sshc rm pve01 # remove the entry (asks first)
sshc key pve01 # install your public key on the host
sshc ping # check which hosts are answering
sshc list # aliases, one per line (pipe-friendly)
sshc list -l # alias, user, hostname, port, identity, note
sshc show vps # effective options, with the file:line each came from
sshc files # every config file that was read, includes and all
sshc edit # open the config in $VISUAL/$EDITOR
sshc -n pve01 # print the ssh command instead of running it
sshc -F ./other_config … # use a different config file
sshc --create # create ~/.ssh/config if it is not there yet
sshc -c key pve01 # create a key too, if there is none to push
The picker and list -l show one column each for alias, user, hostname, port
and note, sorted by alias. The filter matches across all of them, so 9.114,
bkdadmin and staging all find something.
In the picker: type to filter, ↑/↓ or ^P/^N to move, PgUp/PgDn to
page, Home/End to jump, ↵ to connect, ^A to add a host, ^E to edit the
highlighted one, ^X to remove it, ^K to push your key to it, ^U to clear
the query, and esc (or ^C/^G) to quit. The filter is a
fuzzy match over the alias first, then over user@hostname:port, so 9.114
finds a host by its address just as well as by its name.
The picker is a loop. Log out of a host and you land back in the list rather
than back in your shell, and the same goes for adding, editing, removing and
pushing a key, so one sshc covers a whole run of machines. Only esc, ^C
or ^G leaves it, which is why an interactive session exits 130 even when you
connected to something.
If ssh exits non-zero the status is printed and sshc waits for a keypress
before redrawing, so a "Connection refused" is readable instead of being wiped
by the picker. Interrupting with ^C skips that, since you already know why it
stopped.
Naming a host on the command line is the exception: sshc pve01 replaces
itself with ssh and exits with ssh's status, so it stays usable in scripts
and aliases.
Adding hosts
sshc add web1 deploy@web.example.com:2222
sshc add web1 -H web.example.com -u deploy -p 2222 -i ~/.ssh/id_web
sshc add web1 -H web.example.com -o "ForwardAgent yes" -o "Compression yes"
sshc add web1 # asks for whatever is missing
sshc add # asks for everything
^A in the picker runs the same flow and drops you back in the list with the
new host in it, and ^E edits the highlighted one. Explicit flags win over the user@host:port shorthand, and
Port 22 is left out since it is the default.
Two details worth knowing:
- The block is inserted before the first catch-all section (
Host *or anyMatch), not appended at the end. Because ssh keeps the first value it finds, a block written after a catch-all could be silently overridden by it. - The file is replaced atomically via a temp file in the same directory and keeps its original permissions, so an interrupted write cannot truncate your config. Indentation is copied from what the file already uses.
Duplicate aliases, wildcard aliases, ports outside 1-65535, and values
containing whitespace are refused. If an existing wildcard rule already matches
the new name, add says so and still writes the entry.
Starting from nothing
A missing config file is an error rather than something sshc quietly invents:
$ sshc list
sshc: /home/you/.ssh/config does not exist; create it with 'sshc --create'
--create (-c) makes it, and ~/.ssh along with it, with the permissions
ssh insists on — mode 600 for the file, 700 for the directory — and nothing in
it but a comment. The commands that would write to the config anyway (add,
edit, and the picker) offer to do the same when they are run on a terminal:
$ sshc add web1 -H web.example.com
/home/you/.ssh/config does not exist.
create it? [Y/n]:
created /home/you/.ssh/config
added to /home/you/.ssh/config:3
Host web1
HostName web.example.com
connect with: sshc web1
Everything else — list, show, ping, rm — just reports the missing file,
since creating an empty one would not help it.
Checking what is up
sshc ping # every host
sshc ping pve01 vps # just these
sshc ping -t 1 # wait one second per host instead of three
sshc ping -j 4 # four at a time instead of sixteen
ALIAS USER HOSTNAME PORT STATUS
grafana bkdadmin 10.10.90.114 22 up 70ms
freepbx root 10.10.90.6 22 down timed out
vps emmett emmett1.my 8643 up 230ms
3 hosts: 2 up, 1 down
Each check opens a TCP connection to the host's HostName and Port, so up
means the ssh port accepted a connection - not that your key would be accepted.
Nothing is sent and no session starts. This is deliberately not ICMP: plenty of
hosts drop pings while answering ssh perfectly well, and a TCP connect needs no
privileges.
Hosts are checked in parallel (16 at a time by default) and printed in alias
order as results arrive, so the slowest host does not hold up the rest. A host
behind ProxyJump or ProxyCommand is reported as proxied rather than
checked, since a direct connection to it would prove nothing either way.
The exit status is 1 if any host is down, so sshc ping works in a monitoring
script.
Notes
Each host can carry a free-text note, shown as the last column:
sshc add web1 deploy@web.example.com --note "production frontend"
sshc set web1 --note "production frontend, EU"
sshc set web1 --note - # remove it
sshc writes notes as a # note: comment inside the block, which is invisible
to ssh itself. Reading is more forgiving - a note is taken from, in order:
- a trailing comment on the
Hostline:Host web1 # production frontend - a
# note: ...line anywhere in the block - a plain comment on the line directly below the
Hostline
The third rule stops at the line below the Host line on purpose. A comment
further down a block is usually introducing whatever comes next rather than
labelling this entry - in a real config, # Added by sshc; do not remove.
sitting above a Match all block was being read as the previous host's note.
Editing a note rewrites it wherever it already lives, including in place on the
Host line, so nothing ends up with two.
Editing an entry
sshc set pve01 -u root -p 2222 # change fields
sshc set pve01 -i - # '-' removes a directive
sshc set pve01 --rename proxmox-01 # rename the alias
sshc set pve01 # prompts, current values as defaults
sshc edit pve01 # the same command
sshc edit # no alias: opens $EDITOR on the config
^E in the picker opens the same prompts for the highlighted host and returns
you to the list afterwards.
Removing an entry
sshc rm pve01 # shows the block, then asks
sshc rm pve01 -y # no questions asked
sshc remove pve01 # 'remove', 'delete' and 'del' also work
^X in the picker removes the highlighted host, confirming first, and returns
you to the list afterwards.
Removal takes the whole Host block - its note and any directives sshc does not
manage included - from whichever file declares it, and closes the blank line it
leaves behind so that adding and removing entries in turn cannot make the file
drift. Comments above the Host line are left alone: they are as likely to
introduce the file or a group of entries as that one block. A Host line that
declares several aliases (Host web1 web2) is refused rather than taking the
others with it; split it by hand with sshc edit first.
Editing an entry, in detail
Editing is surgical: only the directives you name are touched. Anything else in
the block - ForwardAgent, LocalForward, comments - is left exactly as it
was, as is the rest of the file. New directives are placed after the block's
last directive, above any trailing comment and above an Include (text after
an Include is spliced in behind the included file, so putting it there would
change which value wins). Entries declared in an included file are edited in
that file. Setting a field to the value it already holds is a no-op and does
not rewrite anything.
Pushing your key
sshc key pve01 # ssh-copy-id, but config-aware
sshc key pve01 -i ~/.ssh/id_work # choose the key
sshc key pve01 --no-verify # skip the login check afterwards
sshc key pve01 -c # make a key first if there is none
sshc -n key pve01 # show what would happen
^K in the picker does the same for the highlighted host, holds the result on
screen until you press a key, then returns to the list.
Which key gets sent, in order: -i if given, else the IdentityFile the
config sets for that host, else the first of ~/.ssh/id_ed25519.pub,
id_ecdsa_sk.pub, id_ed25519_sk.pub, id_ecdsa.pub, id_rsa.pub. Passing
the private half (-i ~/.ssh/id_ed25519) is fine - it sends the matching
.pub. If a file has no public half and looks like a private key, it is
refused outright rather than sent.
When you have no key yet
Same bargain as the config file: nothing is generated behind your back, but
--create (-c) will, and on a terminal you are asked:
$ sshc key pve01
no public key found in ~/.ssh. Create one with --create, or with: ssh-keygen -t ed25519
create an ed25519 key in /home/you/.ssh/id_ed25519? [Y/n]:
Generating public/private ed25519 key pair.
Enter passphrase (empty for no passphrase):
The key it makes is the one it was going to look for: -i if you gave one,
else the host's IdentityFile, else ~/.ssh/id_ed25519. ssh-keygen runs on
your terminal and asks for the passphrase itself — sshc never handles it. Off
a terminal (a script, a pipe) there is nobody to ask, so --create generates
the key without a passphrase and says so on stderr.
If the private key is there and only the .pub has gone missing, that pair is
kept: the public half is recovered with ssh-keygen -y rather than a new,
unrelated key being generated. A .pub that exists but cannot be parsed is
reported and left alone — sshc never overwrites key material. -n creates
nothing either; it prints the ssh-keygen command it would have run.
The key is piped over stdin into a small POSIX-sh snippet on the server, which
creates ~/.ssh at mode 700 and authorized_keys at 600 only if they are
missing, appends the key only when that exact line is not already present, and
runs restorecon where SELinux is in play. It never rewrites an existing
authorized_keys, and it reports which of the two things it did. Passwords
still prompt normally: ssh reads them from /dev/tty, not from the stdin the
key arrives on.
Afterwards sshc tries ssh -o BatchMode=yes <alias> true to confirm the key
works. A failed check is reported as unverified rather than as an error - that
is the expected result when your key has a passphrase and no agent is loaded.
What it understands
Hostblocks with several patterns per line, wildcards (*,?) and negation (!pattern)Include, with globs and relative paths resolved against the including file; include cycles and runaway depth are cut offKeyword valueandKeyword=value, quoted values, and#comments- OpenSSH precedence: the first value obtained for a keyword wins, which is why catch-all defaults belong at the end of the file
- Directives before the first
Hostblock, which apply to every host
Match blocks are listed by show but not applied: their criteria (exec,
canonicalised hostnames, the final user) are only knowable inside ssh at
connect time, so guessing at them would be worse than saying nothing.
Only literal (wildcard-free) Host aliases are listed as connectable hosts -
Host *.internal is a rule, not a machine.
Layout
| path | what's in it |
|---|---|
src/ssh_config.{h,cpp} |
config parser and option resolution |
src/picker.{h,cpp} |
raw-mode terminal picker and fuzzy matcher |
src/editor.{h,cpp} |
validating, writing and updating Host blocks |
src/keys.{h,cpp} |
choosing a public key and installing it remotely |
src/probe.{h,cpp} |
parallel reachability checks |
src/main.cpp |
command line, and the execvp into ssh |
tests/run_tests.sh |
parser tests against a synthetic config tree |
tests/keypush_tests.sh |
sshc key, against a stub ssh that runs the remote command locally |
tests/ping_tests.sh |
sshc ping, against a real local socket and a closed port |
tests/picker_keys.py |
key bindings, driven through a pty |
man/sshc.1 |
the manual page (mdoc) |
Tests
make test
The suite also lints man/sshc.1 and cross-checks that the options in
--help and in the man page match, so the documentation cannot quietly drift
from the binary. tests/run_tests.sh covers the parser, add and set, tests/keypush_tests.sh
covers sshc key; tests/picker_keys.py drives the picker
through a pty and checks every key binding. One note for anyone touching the
input loop: it uses select(), not poll(). On macOS poll() does not
reliably time out on a tty - it reports the fd as readable with nothing
pending, and the read() that follows blocks forever, which froze the picker on
a bare esc.