No description
  • C++ 71.3%
  • Shell 20.3%
  • Python 7.3%
  • Makefile 1.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Emmett1 9db252c30c sshc: add --create, for a missing config or key
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>
2026-09-20 23:55:42 +08:00
man sshc: add --create, for a missing config or key 2026-09-20 23:55:42 +08:00
src sshc: add --create, for a missing config or key 2026-09-20 23:55:42 +08:00
tests sshc: add --create, for a missing config or key 2026-09-20 23:55:42 +08:00
.gitignore sshc: ssh manager driven by ~/.ssh/config 2026-08-28 00:17:14 +08:00
Makefile Install under $HOME by default 2026-09-04 10:28:10 +08:00
README.md sshc: add --create, for a missing config or key 2026-09-20 23:55:42 +08:00

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 any Match), 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:

  1. a trailing comment on the Host line: Host web1 # production frontend
  2. a # note: ... line anywhere in the block
  3. a plain comment on the line directly below the Host line

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

  • Host blocks 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 off
  • Keyword value and Keyword=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 Host block, 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.